Compare commits
No files matched your search
@@ -1,107 +0,0 @@
|
||||
---
|
||||
name: add-narrow-emoji
|
||||
description: Fix a MeshTerm row whose layout an emoji breaks — a right border pulled a column short, or a glyph overrunning its lane — because the terminal draws it in a different number of cells than Rich/prompt_toolkit measure. Works in both directions: a glyph drawn narrower than measured joins the narrow allowlist, one drawn wider joins the wide set. Classifies the emoji (lone codepoint, country flag, VS16 sequence, or ZWJ/other cluster), says whether it is already listed, and adds it the correct way. Use whenever an emoji "fucks up the layout", notches a panel border, or smears a row.
|
||||
---
|
||||
|
||||
# Adding an emoji to MeshTerm's terminal-aligned width sets
|
||||
|
||||
MeshTerm aligns Rich's and prompt_toolkit's glyph widths with how the terminal
|
||||
*actually draws* an emoji, so a row frames flush instead of notching its right border a
|
||||
column short or overrunning its lane. The machinery lives in
|
||||
[emoji_width.py](../../..//meshterm/ui/tui/emoji_width.py); the design is in that module's
|
||||
docstring and in the `glyph-width-alignment` memory. Background: the terminal's
|
||||
narrow/wide split is **per-glyph and font-driven** — there is no rule and no way to probe
|
||||
it (a cursor probe reads the PTY, not the renderer) — so the exceptions are two
|
||||
hand-curated sets, extended one confirmed glyph at a time. This skill does that one
|
||||
addition correctly.
|
||||
|
||||
## When to run
|
||||
|
||||
An emoji in a row breaks its layout, in either of two ways:
|
||||
|
||||
- **Border pulled in** (a blank sits between the border and the frame edge), or a smear to
|
||||
the right of the glyph — the terminal draws it in *fewer* cells than the authorities
|
||||
reserve. → the narrow allowlist (`_DEFAULT_NARROW_LONE`).
|
||||
- **The row overruns**, pushing what follows a column right — the terminal draws it in
|
||||
*more* cells than the authorities reserve. → the wide set (`_DEFAULT_WIDE_BASE`). In
|
||||
practice this only happens to a **VS16 sequence** the narrow verdict got wrong; a *bare*
|
||||
codepoint both authorities call one cell is measured correctly and must be left alone.
|
||||
|
||||
Step 1 tells you which; it also tells you when the glyph is **already listed**, which is
|
||||
the commonest answer to "please add this one".
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Classify the emoji.** Run, from the repo root:
|
||||
|
||||
The interpreter below is `$DEV_VENV_PYTHON` from `.dev.env` at the repo root (gitignored;
|
||||
`.dev.env.example` shows the shape) — read it first.
|
||||
|
||||
```
|
||||
$DEV_VENV_PYTHON .claude/skills/add-narrow-emoji/classify.py <emoji>
|
||||
```
|
||||
|
||||
It prints the codepoints, how each authority measures it now (`Rich now`, `pt now`)
|
||||
and after the fix (`Rich fixed`, `pt fixed`), the class, and the action. `pt now` is
|
||||
the number that matters — prompt_toolkit places the panel border.
|
||||
|
||||
2. **Act on the class.** The machinery runs in *both* directions — a glyph drawn narrower
|
||||
than measured joins `_DEFAULT_NARROW_LONE`, one drawn wider joins `_DEFAULT_WIDE_BASE`
|
||||
— and the classifier names which, including "already listed", the commonest answer:
|
||||
- **already listed** (either set): nothing to add. If a row still misaligns, some
|
||||
*other* glyph on it is the culprit — classify that one.
|
||||
- **lone wide codepoint** (e.g. `👋`, `👍`): the narrow-allowlist case. Only add it **if
|
||||
the terminal draws it in one cell** — confirm visually first. Append the character to
|
||||
`_DEFAULT_NARROW_LONE` in [emoji_width.py](../../..//meshterm/ui/tui/emoji_width.py):
|
||||
`_DEFAULT_NARROW_LONE = "👋👍"`. (For a throwaway session test instead of a code
|
||||
change, set `MESHTERM_NARROW_EMOJI="👋👍"` in the environment.)
|
||||
- **lone narrow codepoint, bare** (e.g. `🛣` U+1F6E3, `🕸` U+1F578): **no change**, and
|
||||
resist the pull to "fix" it. Both authorities say one because the codepoint is outside
|
||||
Emoji_Presentation, and with no VS16 asking for emoji presentation the font draws a
|
||||
one-cell *text* glyph — they are right. Forcing it into `_DEFAULT_WIDE_BASE` reserves a
|
||||
cell the terminal never draws and pulls the row's border a column **in**; that is
|
||||
exactly what listing `🛣` did to the Trophy case, while `🕸` — same class, one board
|
||||
down, never listed — framed flush throughout. Both authorities already agreeing is not
|
||||
a bug to correct.
|
||||
- **VS16 emoji-presentation sequence** (e.g. `☀️`): **handled by default** (the variation
|
||||
selector is skipped, so the base is measured alone). But the renderer is not uniform —
|
||||
if *this* one paints two cells wide, add its **base** codepoint to `_DEFAULT_WIDE_BASE`,
|
||||
as `🛩️` (U+1F6E9) needed. Keying on the base makes the bare form ride along.
|
||||
- **flag (Regional Indicator pair)** (e.g. `🇨🇦`): **already handled** as a whole
|
||||
category — no entry. Confirm `pt fixed` is 2 and stop. Never list an individual flag:
|
||||
indicators are shared, so it would half-fix that flag's neighbours and skip the rest.
|
||||
- **ZWJ sequence / keycap / skin-tone / other cluster** (e.g. `👨👩👧`, `1️⃣`, `👋🏽`):
|
||||
**not covered** by the lone/flag mechanism — resizing its parts individually would
|
||||
misalign it. Do not force it into either set; note it for bespoke handling.
|
||||
|
||||
3. **Extend the tests.** In [tests/test_emoji_width.py](../../..//tests/test_emoji_width.py),
|
||||
add the new glyph to `test_rich_cell_len_narrows_only_allowlisted_lone_emoji` and
|
||||
`test_pt_cache_narrows_only_allowlisted_lone_emoji` (both authorities → one cell for a
|
||||
narrow addition, two for a wide one), and to the width-1 calibrate test. Keep a
|
||||
known-wide emoji (`📡`) asserted at two, so a future over-broad change is caught.
|
||||
|
||||
4. **Run the tests:**
|
||||
|
||||
```
|
||||
$DEV_VENV_PYTHON -m pytest tests/test_emoji_width.py -q
|
||||
```
|
||||
|
||||
5. **Confirm visually.** The fix can't be reproduced headlessly — a non-interactive run
|
||||
keeps Rich's defaults, and CI can't see the renderer. Ask the person on the affected
|
||||
terminal to send a chat message with the glyph and confirm the border is now flush and
|
||||
nothing shifted. Only then is the addition trustworthy.
|
||||
|
||||
6. **Commit** with a message naming the glyph and why (see the repo's commit style).
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Never narrow a glyph the terminal draws two wide** (a menu icon like `📡`, `💬`), and
|
||||
never widen one it draws in one cell. Each set only helps glyphs mismeasured in its own
|
||||
direction; a wrong entry *introduces* the misalignment it was meant to remove. When
|
||||
unsure, leave it out.
|
||||
- **One glyph, confirmed, at a time.** An unlisted emoji is no worse than before (the
|
||||
`erase_down` full-repaint still prevents a narrow one's smear); a wrongly-listed one
|
||||
breaks a border. Bias to omission.
|
||||
- **Do not touch Rich/prompt_toolkit width globally.** A blanket emoji remap has been tried
|
||||
and reverted twice — it wrecks the menu icons the terminal genuinely draws two wide. The
|
||||
curated allowlist and the flag category are the only sanctioned levers.
|
||||
@@ -1,144 +0,0 @@
|
||||
r"""Diagnose one emoji for the MeshTerm terminal-aligned width machinery.
|
||||
|
||||
Given an emoji on the command line, report its codepoints and how the three width
|
||||
authorities measure it (wcwidth, Rich, prompt_toolkit), then classify it and print the
|
||||
exact action the `add-narrow-emoji` skill should take. Run from the repo root:
|
||||
|
||||
$DEV_VENV_PYTHON .claude/skills/add-narrow-emoji/classify.py 👋
|
||||
|
||||
(``$DEV_VENV_PYTHON`` is the interpreter named in ``.dev.env``; ``.dev.env.example`` shows
|
||||
the shape.)
|
||||
|
||||
The point is to remove the guesswork the skill's judgement call depends on: a lone wide
|
||||
codepoint gets an allowlist entry, a flag is already handled by category, and a ZWJ /
|
||||
keycap / modifier cluster is *not* covered and must not be shoehorned in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
# The chat border fix only ever engages on a terminal that draws emoji narrow; measure
|
||||
# there or trust the maintainer's eyes. This script just classifies — it never patches.
|
||||
sys.stdout.reconfigure(encoding="utf-8") # print emoji safely on a cp1252 console
|
||||
# Run from anywhere: put the repo root (four levels up) on the path so `meshterm` imports.
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3]))
|
||||
|
||||
import rich.cells as rc # noqa: E402
|
||||
from prompt_toolkit.utils import get_cwidth # noqa: E402
|
||||
from wcwidth import wcwidth # noqa: E402
|
||||
|
||||
from meshterm.ui.tui.emoji_width import ( # noqa: E402
|
||||
_DEFAULT_NARROW_LONE,
|
||||
_DEFAULT_WIDE_BASE,
|
||||
_is_regional_indicator,
|
||||
_make_cell_len,
|
||||
_make_pt_cache,
|
||||
)
|
||||
|
||||
_ZWJ = ""
|
||||
_VS16 = "️"
|
||||
|
||||
_NARROW = frozenset(_DEFAULT_NARROW_LONE)
|
||||
_WIDE = frozenset(_DEFAULT_WIDE_BASE) - frozenset((_ZWJ, _VS16))
|
||||
|
||||
|
||||
def _base(emoji: str) -> str:
|
||||
"""The emoji's base codepoint — what the wide set is keyed on (selectors dropped)."""
|
||||
others = [c for c in emoji if c not in (_VS16, _ZWJ)]
|
||||
return others[0] if len(others) == 1 else ""
|
||||
|
||||
|
||||
def _classify(emoji: str) -> tuple[str, str]:
|
||||
"""Return ``(label, action)`` for ``emoji`` — what it is and what the skill should do."""
|
||||
if not emoji:
|
||||
return "empty", "Nothing to classify — pass an emoji as the argument."
|
||||
|
||||
# Already curated? Say so first — it is the commonest answer to "please add this one",
|
||||
# and every branch below would otherwise re-derive advice for a decision already made.
|
||||
base = _base(emoji)
|
||||
if base and base in _WIDE:
|
||||
return (
|
||||
"lone codepoint drawn WIDE — already listed",
|
||||
"ALREADY IN _DEFAULT_WIDE_BASE (both authorities measure it 2, bare and with its "
|
||||
"VS16 selector). Nothing to add. If a row still misaligns, the culprit is another "
|
||||
"glyph on it — classify that one instead.",
|
||||
)
|
||||
if len(emoji) == 1 and emoji in _NARROW:
|
||||
return (
|
||||
"lone codepoint drawn NARROW — already listed",
|
||||
"ALREADY IN _DEFAULT_NARROW_LONE (both authorities measure it 1). Nothing to add.",
|
||||
)
|
||||
|
||||
if len(emoji) == 2 and all(_is_regional_indicator(c) for c in emoji):
|
||||
return (
|
||||
"flag (Regional Indicator pair)",
|
||||
"ALREADY HANDLED as a category — no allowlist entry. Verify pt width is 2 below; "
|
||||
"if so there is nothing to add. Never list a single flag (it half-fixes its "
|
||||
"neighbours that share an indicator).",
|
||||
)
|
||||
if _ZWJ in emoji:
|
||||
return (
|
||||
"ZWJ sequence (multi-glyph cluster)",
|
||||
"NOT covered by the lone/flag mechanism (a family, a profession, a flag-with-tag). "
|
||||
"Do not add it to either set — the parts would be resized individually and "
|
||||
"misalign. Flag this to a maintainer for bespoke handling.",
|
||||
)
|
||||
if len(emoji) == 1:
|
||||
if wcwidth(emoji) == 2:
|
||||
return (
|
||||
"lone wide codepoint",
|
||||
"IF your terminal draws it in ONE cell, add it to _DEFAULT_NARROW_LONE in "
|
||||
"meshterm/ui/tui/emoji_width.py (or MESHTERM_NARROW_EMOJI for a session test). "
|
||||
"If your terminal draws it TWO wide, leave it — narrowing would break its border.",
|
||||
)
|
||||
return (
|
||||
"lone narrow codepoint (bare, text presentation)",
|
||||
"NO CHANGE — and resist the pull to 'fix' it. Both authorities measure it one "
|
||||
"because it sits outside Emoji_Presentation, and with no VS16 asking for emoji "
|
||||
"presentation the font draws a one-cell text glyph: they are right. Forcing it "
|
||||
"to two reserves a cell the terminal never draws and pulls the row's border a "
|
||||
"column IN — the mistake U+1F6E3 🛣 cost the Trophy case. If this row really is "
|
||||
"misaligned, another glyph on it is the culprit; classify that one.",
|
||||
)
|
||||
if base and _VS16 in emoji:
|
||||
return (
|
||||
"VS16 emoji-presentation sequence",
|
||||
"HANDLED BY DEFAULT (the variation selector is skipped, so the base is measured "
|
||||
"alone and the narrow-VS16 verdict applies). If your terminal draws THIS one two "
|
||||
"cells wide anyway — the renderer is not uniform — add its BASE codepoint to "
|
||||
"_DEFAULT_WIDE_BASE (or MESHTERM_WIDE_EMOJI), as U+1F6E9 🛩️ needed.",
|
||||
)
|
||||
return (
|
||||
"multi-codepoint cluster (keycap / skin-tone / other)",
|
||||
"NOT a plain lone codepoint — the lone/flag mechanism does not cover it. Flag this to a "
|
||||
"maintainer rather than adding parts to either set.",
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2:
|
||||
print("usage: classify.py <emoji>")
|
||||
return 2
|
||||
emoji = argv[1]
|
||||
label, action = _classify(emoji)
|
||||
|
||||
print(f"emoji : {emoji}")
|
||||
print("codepoints : " + " ".join(f"U+{ord(c):04X}" for c in emoji))
|
||||
print(f"grapheme : {len(emoji)} codepoint(s)")
|
||||
print(f"wcwidth : {sum(max(0, wcwidth(c)) for c in emoji)} (per char: "
|
||||
+ ", ".join(str(wcwidth(c)) for c in emoji) + ")")
|
||||
print(f"Rich now : {rc.cell_len(emoji)}")
|
||||
print(f"pt now : {get_cwidth(emoji)} <- the authority that places the panel border")
|
||||
# "fixed" = what the width-1 calibration path already produces today, both sets applied.
|
||||
print(f"Rich fixed : {_make_cell_len(_NARROW, _WIDE)(emoji)}")
|
||||
print(f"pt fixed : {_make_pt_cache(_NARROW, _WIDE)[emoji]}")
|
||||
print()
|
||||
print(f"class : {label}")
|
||||
print(f"action : {action}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
@@ -139,7 +139,11 @@ gh run watch --exit-status
|
||||
gh release view vX.Y.Z
|
||||
```
|
||||
|
||||
Five platform builds plus the release job, so it takes a few minutes. **Check the notes
|
||||
Five platform builds, the release job, and `website` — which rebuilds meshterm.net from the
|
||||
tag so the site shows the new version — so it takes a few minutes. If only `website` fails,
|
||||
the release itself is fine (it runs after publishing); the usual cause is the
|
||||
`SITE_DEPLOY_KEY` secret, and once that is fixed `gh run rerun <run-id> --failed` finishes
|
||||
it. **Check the notes
|
||||
actually came out of the changelog** — a green run is not proof, because the fallback is a
|
||||
successful step:
|
||||
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
buy_me_a_coffee: johnputer
|
||||
custom:
|
||||
- "https://www.patreon.com/c/MeshTerm"
|
||||
- "https://tinyurl.com/SupportMeshTerm"
|
||||
- "https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6"
|
||||
@@ -11,6 +11,11 @@
|
||||
#
|
||||
# The binaries are built by `installers.yml`, called rather than copied, so the files
|
||||
# people download are made exactly the way the ones CI checks on every push are.
|
||||
#
|
||||
# Once the release exists, `website` puts it on meshterm.net: it rebuilds that site from
|
||||
# the tagged tree — the landing and About pages carry the version — and pushes the result
|
||||
# to jpmartineau/meshterm.net, which GitHub Pages serves. It writes with a deploy key
|
||||
# scoped to that one repository (secret SITE_DEPLOY_KEY); that repo's README makes one.
|
||||
name: GitHub release
|
||||
|
||||
on:
|
||||
@@ -50,6 +55,10 @@ jobs:
|
||||
# Every platform's artifact, flattened into one directory of files to attach.
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
# The build uploads one artifact nobody should ever download: the unsigned
|
||||
# Windows binary it hands to SignPath. Everything meant for the release page is
|
||||
# named `meshterm-…`, so the pattern is the filter.
|
||||
pattern: meshterm-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
@@ -106,3 +115,55 @@ jobs:
|
||||
--verify-tag \
|
||||
$PRERELEASE
|
||||
echo "https://github.com/${{ github.repository }}/releases/tag/$TAG"
|
||||
|
||||
website:
|
||||
name: put the version on meshterm.net
|
||||
needs: publish
|
||||
# A pre-release stays out of "Latest", so it stays off the landing page too.
|
||||
if: ${{ !inputs.prerelease }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check for the deploy key
|
||||
env:
|
||||
SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }}
|
||||
run: |
|
||||
if [ -z "$SITE_DEPLOY_KEY" ]; then
|
||||
echo "::error::SITE_DEPLOY_KEY is not set; the meshterm.net README says how to make it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
path: meshterm
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: jpmartineau/meshterm.net
|
||||
ssh-key: ${{ secrets.SITE_DEPLOY_KEY }}
|
||||
path: site
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Rebuild the site from this release
|
||||
run: |
|
||||
python -m pip install --quiet -r site/requirements.txt
|
||||
python site/build.py --meshterm meshterm
|
||||
|
||||
- name: Publish it
|
||||
working-directory: site
|
||||
env:
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "meshterm.net already shows ${TAG#v}."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898341+github-actions[bot]@users.noreply.github.com"
|
||||
git commit -m "MeshTerm ${TAG#v}"
|
||||
git push
|
||||
@@ -118,6 +118,50 @@ jobs:
|
||||
"$GITHUB_WORKSPACE/$bin" contacts --help > /dev/null
|
||||
echo "runs, finds its assets, and its tools registered"
|
||||
|
||||
# The Windows binary is code-signed, so Windows has a publisher to recognise
|
||||
# instead of an unknown file it has never seen before — SmartScreen's warning eases
|
||||
# as that publisher's downloads accumulate, and antivirus engines, which flag
|
||||
# unsigned PyInstaller one-file builds on sight, are markedly less trigger-happy.
|
||||
# The certificate belongs to SignPath Foundation, who issue it free to open-source
|
||||
# projects and sign on their own infrastructure; nothing secret exists on this
|
||||
# runner, and there is no key here to leak. Its condition is that the artifact
|
||||
# provably came from this repository's CI, which is why the binary is handed over
|
||||
# as a GitHub artifact rather than uploaded: SignPath reads the workflow run behind
|
||||
# that artifact id and refuses anything it cannot trace to an approved branch.
|
||||
#
|
||||
# Signed between the build's own smoke test and the naming step, so what gets
|
||||
# verified is the build and what gets checksummed is the signed file — a checksum
|
||||
# taken before signing would describe a binary nobody ever downloads.
|
||||
#
|
||||
# Both legs are skipped wholesale until the account exists (the variable is unset
|
||||
# until then), so this file builds unsigned Windows binaries in the meantime rather
|
||||
# than failing, and a fork with no SignPath project of its own is unaffected.
|
||||
- name: Hand the Windows binary to SignPath
|
||||
id: unsigned
|
||||
if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != ''
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
# Deliberately not `meshterm-*`: the release workflow attaches every artifact
|
||||
# matching that prefix, and this one is the unsigned draft.
|
||||
name: unsigned-${{ matrix.label }}
|
||||
path: packaging/dist/meshterm.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Sign it
|
||||
if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != ''
|
||||
uses: signpath/github-action-submit-signing-request@v1
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: meshterm
|
||||
signing-policy-slug: release-signing
|
||||
artifact-configuration-slug: exe
|
||||
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
|
||||
# The signed file comes back where the unsigned one was built, under the same
|
||||
# name, so every step after this one is untouched by any of it.
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: packaging/dist
|
||||
|
||||
- name: Name it after the version and the platform
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
+3
-3
@@ -31,6 +31,6 @@ output/
|
||||
# addresses, logins, the local venv path). .dev.env.example is tracked and shows the shape.
|
||||
.dev.env
|
||||
|
||||
# Built radio firmware (scripts/xiao-radio/build-firmware.sh writes it here)
|
||||
scripts/xiao-radio/*.uf2
|
||||
scripts/xiao-radio/_meshcore-build/
|
||||
# Built radio firmware (scripts/picocalc/xiao-radio/build-firmware.sh writes it here)
|
||||
scripts/picocalc/xiao-radio/*.uf2
|
||||
scripts/picocalc/xiao-radio/_meshcore-build/
|
||||
+204
-1
@@ -17,6 +17,206 @@ allowed to change behaviour, not just add to it.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.7] — 2026-09-19
|
||||
|
||||
### Added
|
||||
|
||||
- **A setting you use on a node's command line becomes a row on that node's page.**
|
||||
Third-party builds carry knobs the catalog has never heard of, and MeshTerm cannot know
|
||||
which firmware a repeater runs without asking — and every question is a paced round trip,
|
||||
so probing for a fork's keys would spend a sweep on every node to benefit a few. Nothing
|
||||
is probed speculatively and the catalog does not grow. Instead a `get` or `set` you ran,
|
||||
which that node answered, earns a row under **Extra** on that node's page: the round trip
|
||||
was one you were spending anyway, and no other node's page changes. Only the reader
|
||||
removes a discovered row — a later read that comes back empty takes it to `n/a` rather
|
||||
than deleting the evidence that it exists.
|
||||
|
||||
- **The Windows binary is signed** by SignPath Foundation, who issue certificates free to
|
||||
open-source projects, so it no longer arrives as an unknown publisher.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MeshTerm finds your companion on a Mac.** Every Mac permanently presents two virtual
|
||||
serial ports — `/dev/cu.Bluetooth-Incoming-Port` and `/dev/cu.debug-console` — that carry
|
||||
no USB vendor id, and they were counted as candidates. A Mac with exactly one real board
|
||||
attached therefore saw three devices and refused to choose, so automatic detection could
|
||||
never fire there and every Mac user met *"Multiple companion devices detected"* on a first
|
||||
run, naming two ports that are not companions as the ones to pick between. Which device is
|
||||
plausible was already known, and already printed in that listing; it now decides with it. A
|
||||
lone unrecognised adapter still connects, and several implausible ports no longer claim to
|
||||
be companions.
|
||||
|
||||
- **A PIN-protected Bluetooth companion connects on the first attempt on macOS**, instead of
|
||||
reporting an error and working when you selected it again. CoreBluetooth has no pairing
|
||||
API: a peripheral pairs *implicitly* when something touches a characteristic that requires
|
||||
encryption, which is exactly what the companion's firmware demands — so the refusal
|
||||
MeshTerm was treating as a verdict is in fact macOS *starting* to pair, and its Passkey
|
||||
dialog goes up as the attempt unwinds. The connection now waits for that dialog to be
|
||||
answered, bounded so a dismissed one still fails. Elsewhere a rejection keeps meaning what
|
||||
it always did.
|
||||
|
||||
- **The map draws its ground again on the macOS and Linux downloads.** A standalone build can
|
||||
have no certificate store to consult — the default one names a path baked into the
|
||||
interpreter's own build, which need not exist on a machine that never installed that
|
||||
Python — so every tile fetch failed TLS verification. Because a refused fetch is
|
||||
indistinguishable from empty terrain, the map drew nodes over blank ground and said nothing
|
||||
about why; Windows hid it for so long because it reads the operating system's certificate
|
||||
store. The fetch now falls back to the CA bundle the OS ships. Verification is never
|
||||
weakened: with no bundle to be found it still verifies, and still fails.
|
||||
|
||||
- **The splash wordmark draws in its own colours on macOS.** The art spells brightness the
|
||||
way DOS did, with bold lifting a colour into the bright bank — true on a DOS console and on
|
||||
the PicoCalc, false on macOS Terminal, where bold asks for a heavier typeface and leaves
|
||||
the colour alone. The mark came out muted and its dithered gradients faded toward the wrong
|
||||
end. Brightness is now emitted as the colour it means, which cannot be mistaken for a font
|
||||
weight. Nothing about the art changed, and no cell moved.
|
||||
|
||||
- **The device list shows the device.** The startup splash sized its PORT / ADDRESS column to
|
||||
the longest target and gave the name whatever was left, which held while a Bluetooth
|
||||
address was a 17-character MAC. macOS reports no MAC at all but a 36-character per-machine
|
||||
UUID, and that squeezed the DEVICE column down to the width of its own heading. The name is
|
||||
now sized first and shown in full; the address takes what is left and ellipsizes from the
|
||||
front, keeping the end that tells two targets apart.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The macOS download note leads with `curl`.** The flag that makes Gatekeeper refuse an
|
||||
unsigned binary is set by the *browser* that downloaded it, not by macOS, so fetching it
|
||||
with `curl` sidesteps the dialog entirely — and for a program whose users are already at a
|
||||
terminal, that is the normal way to install it. The old advice was also going stale:
|
||||
Sequoia removed the right-click → Open bypass, and since Catalina a quarantined binary
|
||||
started from Terminal gets the same block rather than escaping it.
|
||||
|
||||
## [0.3.6] — 2026-09-17
|
||||
|
||||
### Added
|
||||
|
||||
- **A contact can be locked.** Lock contact on a contact's page keeps it from ever being
|
||||
archived: the archive sweep counts it protected, and the page hides Archive until you
|
||||
unlock it. Locked contacts show a padlock ahead of their node type icon in the Contacts
|
||||
list — 🔒, or the console font's own padlock on the PicoCalc. The lock is kept by
|
||||
MeshTerm, not the radio, and reading the device never clears it.
|
||||
|
||||
- **Set clock on connect.** A new preference in the Device group sets the radio's clock from
|
||||
this computer's every time MeshTerm connects to it — at launch, when you connect later,
|
||||
and after a reconnect, since a dropped link is exactly when a board with no real-time
|
||||
clock may have rebooted and lost the time. It is off by default, runs in the background,
|
||||
and records the drift it corrected only in the log. Scripted commands never write the
|
||||
clock.
|
||||
|
||||
- **Two installation manuals, one per handheld.** [`docs/picocalc.md`](docs/picocalc.md)
|
||||
takes a stock ClockworkPi PicoCalc from a shopping list to a Linux handheld running
|
||||
MeshTerm with a LoRa radio soldered inside — the Lyra swap, writing Calculinux, the setup
|
||||
script, building and flashing the XIAO firmware (the patch it still needs, explained
|
||||
hunk by hunk), the wiring, and the Lyra setup. [`docs/uconsole.md`](docs/uconsole.md)
|
||||
installs the SPI bridge on a uConsole with the AIO LoRa board and connects MeshTerm to
|
||||
it. Both are step-by-step in plain language and self-contained, each ending on its own
|
||||
list of what has only been proven on one bench; [`docs/hardware.md`](docs/hardware.md)
|
||||
shrinks to the page that says which manual is yours, and the README's new
|
||||
**Documentation** table points at all of it.
|
||||
|
||||
- **A basemap doctor for a map that stays blank.** Every failure on the map's one network
|
||||
path is logged below the default log level, so tiles that never arrive leave a blank
|
||||
ground and no explanation. [`scripts/basemap-doctor.py`](scripts/README.md), run on the
|
||||
machine with the problem, reports the Python MeshTerm runs under, its certificate store,
|
||||
the tile source, and the terminal's colour depth, then fetches a real tile and names the
|
||||
cause with its fix: a Python with an empty certificate store (a python.org install on
|
||||
macOS, until `Install Certificates.command` is run), the network in between, or a
|
||||
terminal at 256 colours, where the basemap's greens and blues collide.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Purge contacts is Archive contacts**, because archiving is what it does: every contact
|
||||
it takes off the device stays in MeshTerm and can be restored. The row, its screens, its
|
||||
confirm button, and its progress bar all say Archive, and the row wears the 💾 that
|
||||
archiving one contact already wore, not the red 🗑 a deletion gets.
|
||||
|
||||
- **The archive preview shows the evidence, one kind to a column.** Its rows led on a
|
||||
percentile and followed it with the reasons a contact ranked low, three columns under one
|
||||
header, so a message count, an age, and a hop count traded places from row to row. Each
|
||||
column now holds one measurement all the way down: `HEARD` and `PKTS` first, drawn as the
|
||||
Contacts list draws them, then the `MSGS` and `HOPS` the sweep adds. The percentile is
|
||||
gone, since the list's order already is the ranking. Every contact leads with its node
|
||||
type icon, as in the Contacts list, and ←→ scrolls the columns under a name that stays
|
||||
put. Archiving a single contact from its page names it the same way in the confirm.
|
||||
|
||||
- **`scripts/` is sorted by handheld.** The PicoCalc's Calculinux bring-up, its two
|
||||
console-font builds, the GPL-2.0 text that belongs beside the 6×8 one, and the
|
||||
`xiao-radio/` firmware kit all live under `scripts/picocalc/`; the SPI bridge is
|
||||
`scripts/uconsole/meshterm-spi-bridge`. The index of what every file does and which
|
||||
machine runs it is a section of [`docs/hardware.md`](docs/hardware.md) now, beside the
|
||||
table that says which manual is yours, and the folder's own README keeps only a pointer
|
||||
to each handheld's folder and the basemap doctor. Every path in the manuals, the notices,
|
||||
the tests, and the scripts' own headers follows.
|
||||
|
||||
- **The 6×12 console-font script carries its size in its name**,
|
||||
`calculinux-console-font-6x12.sh`, like its 6×8 sibling always did. Same script, every
|
||||
reference updated.
|
||||
|
||||
- A tool that fails says so in an amber ⚠ popup, which wraps a long explanation instead of
|
||||
opening the full-screen result window for it.
|
||||
|
||||
- The About page opens on a new description of what MeshTerm is and what it plugs into.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Reading a big contact table no longer times out.** The library under MeshTerm gave a
|
||||
whole contacts read one five-second deadline, so a companion holding a few hundred
|
||||
contacts failed every read with "no event received", however healthy the radio. The wait
|
||||
is now for the gap between records — each record restarts it, and the read takes as long
|
||||
as the table does — so a timeout means the companion really stopped answering. An error
|
||||
reply meant for some other command no longer ends the read either.
|
||||
|
||||
- **An emoji no longer knocks a row out of line.** Terminals disagree about how wide they
|
||||
draw an emoji, and a contact's name arrives off the air carrying whatever its owner typed,
|
||||
so one emoji in a name could shift every column after it, and the screen's right border,
|
||||
by a cell. Every glyph that may be drawn as an emoji now gets two cells, and each is drawn
|
||||
at the column MeshTerm measured for it — in lists, in dialogs, and on the screens behind
|
||||
them — so one drawn narrow leaves a blank beside it rather than moving anything else, and
|
||||
a repaint no longer leaves a stale letter standing next to it. A name cut to fit its
|
||||
column is cut between glyphs, never through one, and a newline or an escape in a name no
|
||||
longer ends its row early. `MESHTERM_COLUMN_SNAP=0` turns the placement off.
|
||||
|
||||
- **Hops in near-identical colours stay separate chips.** Where a path is drawn as coloured
|
||||
chips, two neighbours of the same colour cut a wedge of bare background out of the route,
|
||||
and two of nearly the same colour were joined by a chevron nobody could see. Both are now
|
||||
divided by a thin chevron in a shade of the first chip's own colour, so the route runs on
|
||||
unbroken and the join still shows. "Nearly the same" is measured the way the eye sees
|
||||
colour, in OKLab, rather than as a hue gap, which judged the greens and the cyans very
|
||||
differently.
|
||||
|
||||
- Walking back up a scrolled list no longer lets the highlight ride off the top of its box.
|
||||
|
||||
## [0.3.5] — 2026-09-14
|
||||
|
||||
### Added
|
||||
|
||||
- A release now rebuilds [meshterm.net](https://meshterm.net) from the tag, so the site
|
||||
carries the version it links to.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The purge preview counts in columns.** The target ladder used to describe each rung in a
|
||||
phrase — "keeps 45 · archives 12" — so comparing two rungs meant finding the numbers inside
|
||||
two sentences. Keeps and archives are right-aligned columns now under a pinned
|
||||
`TARGET / KEEPS / ARCHIVES` header, one lane measured across both sections so the numbers
|
||||
run straight down the ladder; a rung that archives nothing shows a muted 0. The reasons a
|
||||
contact ranks low sit in three lanes of their own under one header, instead of running on
|
||||
from wherever the previous reason ended.
|
||||
|
||||
- The PayPal link on the Support page, and in the repository's funding file, is PayPal's own
|
||||
donate address rather than a shortener redirect, so a reader can see where it goes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A name with a skin tone or a cut-off joiner no longer pulls its row out of line.** Two
|
||||
real contact names showed it: one carries a skin-tone modifier, which one width table
|
||||
counted as two cells and the terminal draws as none, so the row reserved four cells for a
|
||||
two-cell glyph; the other is a flag sequence cut at the name's byte limit just past its
|
||||
joiner, which swallowed the padding space after it and left the name a cell short. A
|
||||
modifier now measures zero, and a joiner joins only a pictograph. The purge preview, the
|
||||
Contacts list and the chat picker all pad names through that one measurement.
|
||||
|
||||
## [0.3.4] — 2026-09-13
|
||||
|
||||
### Fixed
|
||||
@@ -710,7 +910,10 @@ deliberately not reconstructed here.
|
||||
- Two `TYPE_CHECKING` imports the test suite referenced but never imported, on paths that
|
||||
happened never to run.
|
||||
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.4...HEAD
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.7...HEAD
|
||||
[0.3.7]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.7
|
||||
[0.3.6]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.6
|
||||
[0.3.5]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.5
|
||||
[0.3.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.4
|
||||
[0.3.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.3
|
||||
[0.3.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.2
|
||||
|
||||
@@ -311,8 +311,14 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
that differ from row to row. As a chip it is the map's yellow star on neutral dark
|
||||
grey, padded like every other chip.
|
||||
- Chip seams are **one** interlocked chevron (previous fill on next). Two chips of the
|
||||
same fill are the exception the interlock can't draw: the point drops its background so
|
||||
the page cuts the wedge. An elision breaks the ribbon rather than joining it — bare `⋯`
|
||||
same fill are the exception the interlock can't draw — and so are two fills the eye
|
||||
can't tell apart: under `pathline.SEAM_BLUR` apart in OKLab (`ui/oklab.py`, THE
|
||||
perceptual colour distance — never a hue gap, never sRGB, both of which mis-size the
|
||||
greens against the cyans). There the seam is the **thin** chevron (`POWERLINE_THIN`)
|
||||
in the previous chip's own fill shaded `SEAM_SHADE` darker (lighter for a dark fill),
|
||||
drawn on the next, so the ribbon runs on unbroken and the join is a line the chip draws
|
||||
on itself, never a wedge of page cut out of the route and never a third colour. An
|
||||
elision breaks the ribbon rather than joining it — bare `⋯`
|
||||
on the page between a closing point and the next chip's notch, no fill, no padding.
|
||||
- The ribbon's **outer ends**: the chevron means *the route continues*, so an end that is
|
||||
the route's own never wears one. It ends **square** — nothing appended, the last chip's
|
||||
@@ -327,7 +333,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
to a whole route). Arrow mode spells the same claim with the separator alone, a leading
|
||||
or trailing `→` with no hop on the far side of it.
|
||||
- Concept icons: 📡 advert · 🕒 clock/sync · 🔄 reboot · 💾 backup · 📂 restore ·
|
||||
🔑 channel/credential key · 🔐 identity/auth secret · 🗑 clear/delete · ✎ compose/edit ·
|
||||
🔑 channel/credential key · 🔐 identity/auth secret ·
|
||||
🔒 lock (a locked contact, a private channel) · 🔓 unlock · 🗑 clear/delete · ✎ compose/edit ·
|
||||
⚙ parameter · `#` count · ▶ run · ⚡ explore/probe · ★ best/winner · ⭐ watch ·
|
||||
📤 send now · 📨 courier/queue · 💬 chat · 🔔 notify · 🔕 mute (notifications off) ·
|
||||
📱 QR · 🔗 link · ↻ re-read · ↕ reorder · ⇄ reverse (flip a path's direction) ·
|
||||
@@ -545,7 +552,7 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
|
||||
`MapCanvas.to_ansi_lines`) — but the fold is the safety net, not the design.
|
||||
- The glyph contract is `ui/fontset.py` — the installed console font's exact codepoint
|
||||
inventory, device-verified. A character outside it is a test failure, not a tofu box
|
||||
found on-device. The font itself is built by `scripts/calculinux-console-font.sh`;
|
||||
found on-device. The font itself is built by `scripts/picocalc/calculinux-console-font-6x12.sh`;
|
||||
the two files move in the same commit.
|
||||
- The 16-slot palette is `theme._VT_SLOTS` (programmed via `/etc/vtrgb`; same script).
|
||||
`MESH_THEME_16` speaks `color(0..15)` only; backgrounds stop at slot 7. Both themes
|
||||
|
||||
@@ -25,10 +25,10 @@ project and is not authorized to do so.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Device script: scripts/calculinux-console-font-6x8.sh embeds the Linux kernel's
|
||||
Device script: scripts/picocalc/calculinux-console-font-6x8.sh embeds the Linux kernel's
|
||||
font_6x8 bitmap (lib/fonts/font_6x8.c) and is therefore licensed GPL-2.0-only,
|
||||
as is the console font it builds; its terms are included beside it as
|
||||
scripts/LICENSE.GPL-2.0 and are its only license -- the Apache License above
|
||||
scripts/picocalc/LICENSE.GPL-2.0 and are its only license -- the Apache License above
|
||||
does not apply to it. Nothing GPL-2.0 reaches the MeshTerm package: that script
|
||||
is excluded from the distribution, runs on the device, and produces a PSF data
|
||||
file the Linux console driver loads, which MeshTerm neither links nor bundles.
|
||||
|
||||
@@ -26,22 +26,18 @@ your mesh's history is worth.
|
||||
> MeshTerm is a side project, not a product. Bug reports are very welcome — but please
|
||||
> open an issue before writing a pull request. [More on how it's run](#how-this-project-is-run).
|
||||
|
||||
<!--
|
||||
Screenshots to take — PNG, a 72-column window, the dark theme — saved under
|
||||
docs/screenshots/ with these names, and the four links below come alive:
|
||||
dashboard.png the dashboard on a live mesh: heard-age heat, the braille timelines
|
||||
map.png the map zoomed on a handful of nodes, basemap under them
|
||||
trace.png a trace result with its route drawn as chips
|
||||
picocalc.png a photo of the app on the PicoCalc's 53x26 console
|
||||
-->
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/dashboard.png" alt="The dashboard" width="49%">
|
||||
<img src="docs/screenshots/map.png" alt="The mesh map" width="49%">
|
||||
<img src="docs/screenshots/dashboard.png" alt="The dashboard: a packets-per-minute timeline over the last two hours, and every packet class counted" width="49%">
|
||||
<img src="docs/screenshots/map.png" alt="The map: repeaters named over an OpenStreetMap basemap of the Lachine Canal" width="49%">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/trace.png" alt="A trace and the route it walked" width="49%">
|
||||
<img src="docs/screenshots/picocalc.png" alt="MeshTerm on the PicoCalc" width="49%">
|
||||
<img src="docs/screenshots/trace.png" alt="A trace: the route it walked drawn as chips, then the SNR of every hop" width="49%">
|
||||
<img src="docs/screenshots/path.png" alt="Message paths: every route a channel message was heard over, drawn on the ground" width="49%">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/picocalc.png" alt="MeshTerm on the PicoCalc, a 53-column console with a radio wired inside" width="38%">
|
||||
</p>
|
||||
<p align="center"><sub>The dashboard, the map, a trace, the paths a message took — and the whole thing on a PicoCalc.</sub></p>
|
||||
|
||||
---
|
||||
|
||||
@@ -79,16 +75,19 @@ if anything goes wrong at startup the window closes before you can read why.
|
||||
**Windows** — open Windows Terminal or PowerShell, `cd` to your downloads, then:
|
||||
|
||||
```powershell
|
||||
.\meshterm-0.3.4-windows-x64.exe
|
||||
.\meshterm-0.3.7-windows-x64.exe
|
||||
```
|
||||
|
||||
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
|
||||
|
||||
```bash
|
||||
chmod +x meshterm-0.3.4-*
|
||||
./meshterm-0.3.4-*
|
||||
chmod +x meshterm-0.3.7-*
|
||||
./meshterm-0.3.7-*
|
||||
```
|
||||
|
||||
On macOS, downloading it with `curl` instead of a browser saves you a fight with Gatekeeper
|
||||
— the note below has the line.
|
||||
|
||||
### A word about terminals, on Windows
|
||||
|
||||
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether you
|
||||
@@ -123,8 +122,19 @@ becomes just `meshterm` from anywhere.
|
||||
> code-signed — signing costs real money on both platforms and this is a free side
|
||||
> project.
|
||||
>
|
||||
> - **macOS**: *"cannot be opened because the developer cannot be verified"*. Clear it with
|
||||
> `xattr -d com.apple.quarantine meshterm-0.3.4-*` and run it again.
|
||||
> - **macOS**: *"cannot be opened because the developer cannot be verified"* — or, on Sequoia
|
||||
> and later, *"Apple could not verify…"*, and right-click → Open no longer gets past it.
|
||||
> The flag that causes it is set by your **browser**, not by macOS, so the easiest answer
|
||||
> is to not use one:
|
||||
>
|
||||
> ```bash
|
||||
> curl -L -O https://github.com/jpmartineau/MeshTerm/releases/download/v0.3.7/meshterm-0.3.7-macos-arm64
|
||||
> chmod +x meshterm-0.3.7-macos-arm64 && ./meshterm-0.3.7-macos-arm64
|
||||
> ```
|
||||
>
|
||||
> `curl` marks nothing, so it simply runs — on an Intel Mac, swap `arm64` for `x64`. If it's
|
||||
> already downloaded, clear the flag instead: `xattr -d com.apple.quarantine meshterm-0.3.7-*`.
|
||||
> Installing with pipx (below) avoids all of this too, since nothing is downloaded as a binary.
|
||||
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
|
||||
>
|
||||
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
|
||||
@@ -134,10 +144,10 @@ becomes just `meshterm` from anywhere.
|
||||
> MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation:
|
||||
>
|
||||
> ```bash
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.4-* # macOS, Linux
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.7-* # macOS, Linux
|
||||
> ```
|
||||
> ```powershell
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.4-windows-x64.exe
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.7-windows-x64.exe
|
||||
> ```
|
||||
>
|
||||
> Same variable if you run two radios and want them kept apart.
|
||||
@@ -171,6 +181,17 @@ question is still being sorted out ([why](https://github.com/pypi/support/issues
|
||||
|
||||
Setting up for development instead? That's in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
| Read | For |
|
||||
| --- | --- |
|
||||
| **[The command line](docs/cli.md)** | Every subcommand and option, what each prints on the plain and JSON faces, the exit statuses, recipes. |
|
||||
| **[MeshTerm on the PicoCalc](docs/picocalc.md)** | Building the handheld from a stock PicoCalc: the shopping list, swapping in the Lyra, installing Calculinux and MeshTerm, the soldered LoRa radio. |
|
||||
| **[MeshTerm on the uConsole](docs/uconsole.md)** | A uConsole with the AIO LoRa board: installing the bridge that fronts its SPI radio as a companion, and connecting to it. |
|
||||
| [MeshTerm on hardware](docs/hardware.md) | Which of the two manuals is yours, and the two platform flavours MeshTerm draws in. |
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | The development setup and the house rules. |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | What changed, newest first. |
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
@@ -193,9 +214,12 @@ meshterm --mock --platform picocalc
|
||||
meshterm specimen
|
||||
```
|
||||
|
||||
Putting MeshTerm on that handheld — the console font, the boot services, a soldered radio for
|
||||
it, and the bridge that fronts a uConsole's SPI LoRa chip as a companion — is
|
||||
[`scripts/`](scripts/), and **[`docs/hardware.md`](docs/hardware.md) is its manual**.
|
||||
Putting MeshTerm on a handheld has its own manuals. **[MeshTerm on the
|
||||
PicoCalc](docs/picocalc.md)** takes a stock PicoCalc from the shopping list to a Linux
|
||||
handheld with a LoRa radio soldered inside; **[MeshTerm on the uConsole](docs/uconsole.md)**
|
||||
connects a uConsole's SPI LoRa board through a small bridge. The device-side scripts both
|
||||
manuals run are in [`scripts/`](scripts/); not sure which manual is yours?
|
||||
[`docs/hardware.md`](docs/hardware.md) says.
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
+1
-1
@@ -1460,7 +1460,7 @@ each link; the scripted face prints the link alone.
|
||||
**`--json`:**
|
||||
|
||||
```json
|
||||
{"page":"discord","title":"Join Discord","version":"0.3.4","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
|
||||
{"page":"discord","title":"Join Discord","version":"0.3.7","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
|
||||
```
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|
||||
+51
-660
@@ -1,679 +1,70 @@
|
||||
# MeshTerm on hardware
|
||||
|
||||
MeshTerm talks to a **node** — a MeshCore radio that speaks the companion protocol over
|
||||
serial, Bluetooth LE or TCP. Plug a companion board into USB and none of this document
|
||||
applies: MeshTerm finds it, and the [command line manual](cli.md) is all you need.
|
||||
serial, Bluetooth LE or TCP. Plug a companion board into USB, or pair one over Bluetooth,
|
||||
and none of this page applies: MeshTerm finds it, and the [README](../README.md) and the
|
||||
[command line manual](cli.md) are all you need.
|
||||
|
||||
This is the manual for the hardware that *doesn't* present a node that way, and for the
|
||||
handheld whose console can't draw the interface until you build it a font. Everything under
|
||||
[`scripts/`](../scripts/) is here: what each script does, what it assumes about the machine
|
||||
it runs on, why it does it the way it does, and what has only ever been proven on one bench.
|
||||
This page is for the hardware that *doesn't* present a node that way. Each such device has
|
||||
its own step-by-step manual, and this page only tells you which one is yours.
|
||||
|
||||
The scripts are small, POSIX `sh` or stdlib Python, and meant to be read before they are
|
||||
run. They are also *device-side* — they change the machine they run on, most of them as
|
||||
root — so read the section for your target first and run the script second.
|
||||
|
||||
- [Three targets, and which one is yours](#three-targets-and-which-one-is-yours)
|
||||
- [The regular and picocalc platforms](#the-regular-and-picocalc-platforms)
|
||||
- [Calculinux bring-up](#calculinux-bring-up)
|
||||
- [Before you run it](#before-you-run-it)
|
||||
- [What the nine phases do](#what-the-nine-phases-do)
|
||||
- [The console font on its own](#the-console-font-on-its-own)
|
||||
- [Joining a network by hand](#joining-a-network-by-hand)
|
||||
- [Re-running, and undoing](#re-running-and-undoing)
|
||||
- [A real radio for the PicoCalc: XIAO nRF52840 over UART](#a-real-radio-for-the-picocalc-xiao-nrf52840-over-uart)
|
||||
- [Parts and wiring](#parts-and-wiring)
|
||||
- [Build the firmware](#build-the-firmware)
|
||||
- [Flash the XIAO](#flash-the-xiao)
|
||||
- [Set up the Lyra](#set-up-the-lyra)
|
||||
- [Run it, and check the link](#run-it-and-check-the-link)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [The SPI radio bridge](#the-spi-radio-bridge)
|
||||
- [Before you start: the runtime rename](#before-you-start-the-runtime-rename)
|
||||
- [Setting it up](#setting-it-up)
|
||||
- [Identity, and one program at a time](#identity-and-one-program-at-a-time)
|
||||
- [Hardware knobs](#hardware-knobs)
|
||||
- [What works through the bridge](#what-works-through-the-bridge)
|
||||
- [The unfinished UART-to-TCP pump](#the-unfinished-uart-to-tcp-pump)
|
||||
- [What is verified and what is not](#what-is-verified-and-what-is-not)
|
||||
|
||||
---
|
||||
|
||||
## Three targets, and which one is yours
|
||||
|
||||
There are three independent jobs in `scripts/`, and they belong to different machines.
|
||||
## Which guide is yours
|
||||
|
||||
| I have… | Read |
|
||||
| --- | --- |
|
||||
| a ClockworkPi **PicoCalc** with a Luckfox Lyra running Calculinux, and I want MeshTerm on its own screen | [Calculinux bring-up](#calculinux-bring-up) |
|
||||
| …and I want a **radio** in it | [XIAO nRF52840 over UART](#a-real-radio-for-the-picocalc-xiao-nrf52840-over-uart) |
|
||||
| a **uConsole** with the hackergadgets AIO, or a Pi with a Waveshare LoRa HAT — an SX1262 on the host's own SPI bus | [The SPI radio bridge](#the-spi-radio-bridge) |
|
||||
| a MeshCore companion on USB, BLE or WiFi | nothing here — the [main README](../README.md) |
|
||||
| a ClockworkPi **PicoCalc**, stock or already running Calculinux on a Luckfox Lyra, and I want MeshTerm on its own screen | **[MeshTerm on the PicoCalc](picocalc.md)** — from the shopping list to a working machine |
|
||||
| …and I want a **radio** inside it | the same manual, [Phase 3](picocalc.md#phase-3--add-the-radio) — a XIAO nRF52840 + Wio-SX1262 soldered to the Lyra's UART |
|
||||
| a ClockworkPi **uConsole** with the hackergadgets AIO board — an SX1262 on the host's own SPI bus | **[MeshTerm on the uConsole](uconsole.md)** — the bridge that fronts that chip as a companion |
|
||||
| a MeshCore companion on USB, BLE or Wi-Fi | nothing here — the [README](../README.md) |
|
||||
|
||||
The PicoCalc target and the SPI target have nothing in common but MeshTerm itself. The
|
||||
console font applies only to the PicoCalc's framebuffer console; the SPI bridge runs a mesh
|
||||
node in software on a Debian host and never touches a console font. Do not run one target's
|
||||
scripts on the other's machine.
|
||||
The two targets have nothing in common but MeshTerm itself. The device-side scripts each
|
||||
manual runs live in [`scripts/`](../scripts/), one folder per handheld; the
|
||||
[index below](#the-scripts) says what every file does and which machine it runs on. Read a
|
||||
script before you run it; most run as root and change the machine they run on.
|
||||
|
||||
Within the PicoCalc target the two radio routes are mutually exclusive, and the reason is
|
||||
the kernel's, not MeshTerm's: **only one program may hold `/dev/ttyS1`.** Either MeshTerm
|
||||
opens the UART directly, which is what [`lyra-setup.sh`](../scripts/xiao-radio/lyra-setup.sh)
|
||||
configures and what you want on the device itself, or a pump holds the UART and fronts it on
|
||||
TCP for other machines — never both at once.
|
||||
## The scripts
|
||||
|
||||
---
|
||||
[`scripts/`](../scripts/) holds the device-side helpers, one folder per manual:
|
||||
[`picocalc/`](../scripts/picocalc/) for the PicoCalc — bringing up Calculinux on the Lyra,
|
||||
building its console font, and (under [`xiao-radio/`](../scripts/picocalc/xiao-radio/))
|
||||
giving it a MeshCore radio over UART — and [`uconsole/`](../scripts/uconsole/) for the
|
||||
bridge that fronts the uConsole's SPI-attached LoRa chip as a companion. The manual is the
|
||||
walkthrough; each script's own header says what it assumes about the machine and why each
|
||||
step is the way it is. On the Lyra, only one program may hold `/dev/ttyS1` — MeshTerm
|
||||
directly, or a pump in front of it, never both.
|
||||
|
||||
| File | Runs on | What it does |
|
||||
| --- | --- | --- |
|
||||
| [`picocalc/calculinux-setup.sh`](../scripts/picocalc/calculinux-setup.sh) | the Lyra (root) | One-time bring-up: python packages, the Wi-Fi boot-scan kick, a boot clock sync (there is no RTC), the deploy user, the checkout, the venv and `pip install -e .`, the login PATH, and the console font. Idempotent. |
|
||||
| [`picocalc/calculinux-console-font-6x12.sh`](../scripts/picocalc/calculinux-console-font-6x12.sh) | the Lyra (root) | Just the console font: builds, installs and persists the 512-glyph **meshterm** PSF (braille, node marks, rounded frame corners, the list cursor) and restores the stock 16-slot palette. Calls the 6×8 build below when it sits alongside. Run it alone after a MeshTerm update. |
|
||||
| [`picocalc/calculinux-console-font-6x8.sh`](../scripts/picocalc/calculinux-console-font-6x8.sh) | the Lyra (root) | The shorter-cell companion font, **meshterm8** (6×8 → 53×40), for anyone who wants more rows than the 6×12 default gives. Its base bitmap is the kernel's `font_6x8`, so this one file and the PSF it builds are **GPL-2.0-only** ([`LICENSE.GPL-2.0`](../scripts/picocalc/LICENSE.GPL-2.0) sits beside it); it reads the donor/alias/keeper tables out of the script above rather than keeping a second copy. Pick between the two fonts from MeshTerm's Preferences page. |
|
||||
| [`picocalc/calculinux-wifi-set.sh`](../scripts/picocalc/calculinux-wifi-set.sh) | the Lyra (sudo) | Interactive Wi-Fi join: prompts for an SSID and passphrase, writes iwd's credentials, connects now. This is how a network *becomes* known to the boot kick. |
|
||||
| [`picocalc/calculinux-radio-bridge.sh`](../scripts/picocalc/calculinux-radio-bridge.sh) | the Lyra (root) | **Unfinished, unsupported.** An early UART-to-TCP pump, superseded by `xiao-radio/lyra-setup.sh`; its header describes routing it does not contain. See the manual before reading it. |
|
||||
| [`picocalc/xiao-radio/`](../scripts/picocalc/xiao-radio/) | see below | Everything for the XIAO nRF52840 + Wio-SX1262 radio wired to the Lyra's UART1; its [bench card](../scripts/picocalc/xiao-radio/README.md) has the wiring table. The firmware is MeshCore, MIT-licensed — [`LICENSE.MeshCore`](../scripts/picocalc/xiao-radio/LICENSE.MeshCore) sits beside the patch. |
|
||||
| [`xiao-radio/build-firmware.sh`](../scripts/picocalc/xiao-radio/build-firmware.sh) | dev machine | Clones and patches MeshCore, builds the `Xiao_nrf52_companion_radio_serial` environment, emits the `.uf2`. |
|
||||
| [`xiao-radio/flash.py`](../scripts/picocalc/xiao-radio/flash.py) | dev machine | Flashes the `.uf2` to the XIAO over its UF2 bootloader, refusing the wrong board or SoftDevice; prints the serial-DFU command when the bootloader offers no drive. |
|
||||
| [`xiao-radio/meshcore-uart1.patch`](../scripts/picocalc/xiao-radio/meshcore-uart1.patch) | — | The two firmware hunks: the companion on nRF52 `Serial1`, and I²C moved off the UART pads. Not upstream yet. |
|
||||
| [`xiao-radio/lyra-setup.sh`](../scripts/picocalc/xiao-radio/lyra-setup.sh) | the Lyra (root) | Boot-persistent UART1 → GP4/GP5 mux service, `dialout` membership, and a default MeshTerm serial profile for `/dev/ttyS1`. |
|
||||
| [`xiao-radio/uart1-mux.py`](../scripts/picocalc/xiao-radio/uart1-mux.py) | the Lyra (root) | The matrix-IO register poke that routes UART1 to the header pads, run once per boot by the service above. |
|
||||
| [`uconsole/meshterm-spi-bridge`](../scripts/uconsole/meshterm-spi-bridge) | the radio host (your user) | Runs a mesh node in software over an SPI-attached SX1262 (uConsole AIO, Waveshare HATs) and serves the companion protocol on `127.0.0.1:5000`. Setup menu, preflight, optional `systemd --user` service. Runs on `openhop_core` (preferred) or its predecessor `pymc_core`; see the manual for the venv it wants on a bookworm uConsole. |
|
||||
|
||||
## The regular and picocalc platforms
|
||||
|
||||
MeshTerm resolves one frozen platform spec at boot and draws everything through it. The
|
||||
MeshTerm resolves one frozen **platform** at boot and draws everything through it. The
|
||||
**regular** platform is a desktop or ssh terminal: 72 readable columns, truecolour, emoji,
|
||||
a footer hint line. The **picocalc** platform is the PicoCalc's framebuffer console, and
|
||||
every number in it comes from the panel: 53 columns, 26 rows on the 6×12 console font (40
|
||||
on the 6×8 candidate), no panel border, no emoji anywhere, an F-key lane instead of a hint
|
||||
line, and sixteen colour slots rather than a colour space — the RGB565 panel is not the
|
||||
limit, the Linux VT layer is. Nothing loses its colour for being on the console; the node
|
||||
hue and the recency heat gradient **quantise** to those slots rather than switching rules.
|
||||
The platform is detected when `/proc/device-tree/model` reads exactly `Luckfox Lyra`, and
|
||||
`--platform picocalc` or `MESHTERM_PLATFORM=picocalc` forces it anywhere — which is how you
|
||||
preview the handheld layout on a desktop, alongside `meshterm specimen` for the whole
|
||||
visual language on one card.
|
||||
|
||||
The console font is the part that needs building, and it is built **on the device** by
|
||||
[`calculinux-console-font.sh`](../scripts/calculinux-console-font.sh) rather than shipped as
|
||||
a binary. Its only inputs are the stock Terminus console font the Calculinux image already
|
||||
carries and `python3`; every added glyph is generated geometry in the script itself. So
|
||||
nothing binary lives in the repo, and the base font is whatever your image installed rather
|
||||
than whatever a maintainer's was. What it adds is what the bare console cannot draw and
|
||||
MeshTerm cannot do without: the 256 braille cells every chart and map is made of, eighteen
|
||||
marks (`●` `◉` `★` `✓` `✗` `▶` `❯` `▸` `⚠` `⌫` `⚙` `↻` among them), and rounded frame
|
||||
corners aliased onto the square ones.
|
||||
|
||||
The budget is the interesting constraint. The framebuffer console caps a font at **512
|
||||
glyphs**, and braille alone is 256 of them on top of the base font's 256 — so the budget is
|
||||
full before a single mark is drawn. Each mark is therefore drawn *into a donor slot*: a
|
||||
pictograph MeshTerm never emits, whose Unicode entry is repointed at the new bitmap. PSF
|
||||
Unicode tables alias several codepoints onto one glyph, so a careless donation takes a
|
||||
bystander with it — donating `π` once erased a Cyrillic letter that shared its slot. The
|
||||
script aborts if a donor is missing, and after the build it verifies a list of keepers with
|
||||
`π` and `пе` as canaries. A repo test parses the script's own mark, alias and donor tables
|
||||
and pins them to `meshterm/ui/fontset.py`, the device-verified inventory of the installed
|
||||
font: the script and the inventory move in the same commit or the test fails.
|
||||
|
||||
> **Licensing.** The installed 6×12 font is a **derivative of Terminus**, which is under the
|
||||
> SIL Open Font Licence 1.1 with "Terminus" as a Reserved Font Name — so the output is named
|
||||
> `meshterm`, which is exactly what the OFL requires of a renamed derivative, and it must not
|
||||
> be called Terminus. Nothing Terminus itself is in this repo. The 6×8 companion is
|
||||
> different: its base bitmap is the Linux kernel's `font_6x8`, which is **GPL-2.0**, embedded
|
||||
> base64 in the script. So that material lives in one file of its own,
|
||||
> [`calculinux-console-font-6x8.sh`](../scripts/calculinux-console-font-6x8.sh), marked
|
||||
> `SPDX-License-Identifier: GPL-2.0-only` with the licence beside it as
|
||||
> [`scripts/LICENSE.GPL-2.0`](../scripts/LICENSE.GPL-2.0) — and that is the end of the
|
||||
> awkwardness. Nothing GPL-2.0 enters the Apache-2.0 program: the script is excluded from
|
||||
> the Python distribution, it runs **on the device**, and what it produces is a PSF **data
|
||||
> file the kernel's console driver loads** with `setfont`. MeshTerm never links it, never
|
||||
> bundles it and never redistributes it; it names a path on the console's own filesystem.
|
||||
> The two scripts share one copy of the donor, alias and keeper tables — the 6×8 one reads
|
||||
> them out of the 6×12 one, between its `shared generator` marker lines — so a glyph added
|
||||
> in one place lands in both fonts. To permit this sharing, the shared generator block in
|
||||
> the 6×12 script (Apache-2.0) is additionally offered under GPL-2.0-only by its source
|
||||
> file. The XIAO firmware patch and builder reference MeshCore, which is under the MIT
|
||||
> License — see [`scripts/xiao-radio/LICENSE.MeshCore`](../scripts/xiao-radio/LICENSE.MeshCore).
|
||||
|
||||
---
|
||||
|
||||
## Calculinux bring-up
|
||||
|
||||
[`calculinux-setup.sh`](../scripts/calculinux-setup.sh) turns a freshly flashed Calculinux
|
||||
SD card into a machine that runs MeshTerm as a dedicated user, with Wi-Fi that survives a
|
||||
cold boot, a clock that is correct, and a console font. It runs **on the Lyra, as root**,
|
||||
and the serial console is a fine place to run it from. It is idempotent: every phase checks
|
||||
before it acts.
|
||||
a footer hint line. The **picocalc** platform is a framebuffer console: 53 columns, no
|
||||
emoji, an F-key lane instead of a hint line, and sixteen colour slots rather than a colour
|
||||
space — nothing loses its colour there, the node hue and the recency heat gradient
|
||||
quantise to those slots instead. It is detected from the device tree on the Lyra, and
|
||||
`--platform picocalc` or `MESHTERM_PLATFORM=picocalc` forces it anywhere, which is how you
|
||||
preview the handheld layout on a desktop:
|
||||
|
||||
```bash
|
||||
# on the Lyra, as root, with scripts/ copied over
|
||||
sh calculinux-setup.sh
|
||||
passwd meshterm # the script deliberately sets no password
|
||||
meshterm --mock --platform picocalc # the whole app, simulated radio, handheld layout
|
||||
meshterm specimen # the visual language on one card
|
||||
meshterm platform # which platform this terminal resolved to
|
||||
```
|
||||
|
||||
Then log in as that user and look at it:
|
||||
|
||||
```bash
|
||||
meshterm --mock # the interface, with a simulated radio
|
||||
meshterm specimen # the visual language on one card
|
||||
```
|
||||
|
||||
### Before you run it
|
||||
|
||||
The script assumes Calculinux as shipped — Yocto, systemd, a read-only root with writable
|
||||
overlays, `opkg` reachable over the network — and that `sh`, `su`, `useradd` or busybox
|
||||
`adduser`, `systemctl`, `timedatectl`, `sed`, `grep`, `ssh-keyscan`, `git` and `kbd` are
|
||||
present. It installs `git` and `kbd` anyway rather than trusting the base image.
|
||||
|
||||
One thing it cannot embed, and one choice it makes by looking:
|
||||
|
||||
- **Wi-Fi credentials.** The boot-time kick only nudges a network `iwd` already knows.
|
||||
Either export `WIFI_SSID` and `WIFI_PSK` before running, and the script writes iwd's PSK
|
||||
file (mode 600, secrets staying in your environment and never in the repo), or join once
|
||||
by hand with [`calculinux-wifi-set.sh`](#joining-a-network-by-hand).
|
||||
- **How the clone gets in.** MeshTerm is a public repository, so this needs no credential at
|
||||
all: with no key file at `$KEY_PATH` the phase clones `$REPO_URL` over public HTTPS and
|
||||
says so. Put a read-only GitHub deploy key there (mode 600) and it clones `$REPO_SSH` over
|
||||
SSH instead — pinning github.com's host key with `ssh-keyscan` first so the clone never
|
||||
blocks on a prompt, and baking the key into the checkout's `core.sshCommand` so later
|
||||
pulls need no environment. Either way an existing checkout at
|
||||
`/home/$DEPLOY_USER/MeshTerm` is pulled `--ff-only` rather than replaced.
|
||||
|
||||
| Knob | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `DEPLOY_USER` | `meshterm` | the login MeshTerm runs under; its home holds the checkout |
|
||||
| `TIMEZONE` | `America/Toronto` | any IANA zone; see phase 4 for what happens without tzdata |
|
||||
| `REPO_URL` | the project's HTTPS URL | cloned when there is no deploy key |
|
||||
| `REPO_SSH` | the project's SSH URL | cloned when there is one |
|
||||
| `KEY_PATH` | `/home/$DEPLOY_USER/.ssh/id_ed25519` | the read-only deploy key, if you want the SSH route |
|
||||
| `WIFI_SSID`, `WIFI_PSK` | empty | set **both** to have iwd's credentials written for you |
|
||||
|
||||
### What the nine phases do
|
||||
|
||||
Each phase exists because of something that went wrong on the real device, and the *why* is
|
||||
the part worth reading.
|
||||
|
||||
| Phase | What it does, and why |
|
||||
| --- | --- |
|
||||
| **1. opkg packages** | Installs `python3-modules`, `python3-pip`, `git`, `kbd`. Calculinux ships a stripped Python 3.13 — no `pip`, no `venv`, no `ensurepip`, and missing `sqlite3`, `ctypes`, `curses` and more — so MeshTerm cannot even import until `python3-modules` restores the interpreter. `/usr` is a writable overlay, so the install persists. |
|
||||
| **2. Wi-Fi kick** | Writes `/etc/wifi-kick.sh` and a oneshot `wifi-kick.service`. On cold boot the USB RTL8188EU's firmware finishes loading *after* iwd's first scans, so iwd's own scans come back empty and `wlan0` sits UP/NO-CARRIER forever. The kick waits for the interface, then runs **raw `iw dev wlan0 scan`** in rounds until a lease appears. Raw `iw` and not `iwctl scan`: a completed raw scan fills the mac80211 scan results and iwd then associates by itself, whereas `iwctl scan` makes iwd hold the device and the raw scan fails busy. |
|
||||
| **3. Clock sync** | Writes `/etc/time-sync.sh` and a oneshot ordered after the kick. This board has no battery-backed RTC, so every cold boot starts at the kernel's build epoch — fatal for "heard" ordering and message timestamps. It waits for a real route, then tries `chronyd`, busybox `ntpd`, `sntp`, and finally an HTTPS `Date:` header, and writes the hardware clock best-effort. It always exits 0. |
|
||||
| **4. Timezone** | Sets `$TIMEZONE` — `America/Toronto` unless you say otherwise — through `timedatectl` once tzdata is installed, and falls back to a POSIX `TZ` rule in `/etc/environment` and the deploy user's `~/.profile` when it is not, because glibc honours a POSIX rule with no zoneinfo database at all and the opkg feed index has been seen to 404. The fallback rule is only known for the default zone; another zone on a device with no tzdata is set through `timedatectl` alone, with a warning. |
|
||||
| **5. Deploy user** | Creates `$DEPLOY_USER` and puts it in `wheel` for sudo and in `input`, `dialout` and `video`. `input` is not optional: the picocalc platform watches modifier keys directly, and `dialout` is what lets MeshTerm open the radio. Group changes need a fresh login. |
|
||||
| **6. Clone** | The checkout at `/home/$DEPLOY_USER/MeshTerm`, pulled `--ff-only` if it is already there. See [Before you run it](#before-you-run-it). |
|
||||
| **7. venv and install** | `python3 -m venv ~/MeshTerm/.venv`, then `pip install -e .` with **`TMPDIR=$HOME/tmp`** — `/tmp` is a small RAM tmpfs and pip's C builds hit ENOSPC in it. |
|
||||
| **8. Login PATH** | Appends the venv's `bin` to the deploy user's `~/.profile` once, so `meshterm` is just a word. |
|
||||
| **9. Console font** | Hands off to `calculinux-console-font.sh` if it sits alongside, and otherwise tells you to run it yourself. |
|
||||
|
||||
> **A Lyra with no Wi-Fi dongle attached takes about six minutes to boot past the kick**,
|
||||
> which waits a minute for the interface and then burns thirty rounds before exiting 0 — and
|
||||
> the clock sync is ordered after it. Nothing is broken; it is a oneshot giving a slow radio
|
||||
> every chance. Disable `wifi-kick.service` if the device is permanently wired or offline.
|
||||
|
||||
### The console font on its own
|
||||
|
||||
The font script stands alone, which is what you want after a MeshTerm update that adds a
|
||||
glyph:
|
||||
|
||||
```bash
|
||||
sh calculinux-console-font.sh
|
||||
```
|
||||
|
||||
It builds both fonts — the 6×8 one by handing off to
|
||||
[`calculinux-console-font-6x8.sh`](../scripts/calculinux-console-font-6x8.sh) beside it, or
|
||||
telling you it is missing; `SKIP_6X8=1` builds only the default — applies the 6×12 one live
|
||||
with `setfont` on `/dev/tty1`, and persists it as `FONT=meshterm` in `/etc/vconsole.conf` so
|
||||
systemd loads it at boot. If `setfont` fails it aborts *before* persisting, so you are never
|
||||
left with a font that only exists in `vconsole.conf`. A 6×8 build that fails is a warning
|
||||
and nothing more: the default is installed either way.
|
||||
|
||||
**Choosing between them is a preference, not a command.** *Display → Console font* on the
|
||||
Preferences page offers `6x12 (53x26)` and `6x8 (53x40)`, and it is drawn on the PicoCalc
|
||||
only — it is a row about hardware no desktop has, though the value round-trips through
|
||||
`preferences.toml` on any machine. Picking one is the preview: the console loads it
|
||||
immediately and the page repaints at the new row count (the kernel raises SIGWINCH on a
|
||||
font change), so you look at the choice before you keep it. *Apply* keeps it; leaving the
|
||||
page and discarding puts the saved font back.
|
||||
|
||||
MeshTerm puts the font back when it exits. Before the first paint it saves the console's
|
||||
current font with `setfont -O ~/.meshterm/console-font-before.psf` — whatever the *shell*
|
||||
was in, not whatever we assume it was — and reloads that file on the way out, from the
|
||||
session's own teardown and from an `atexit` hook, so ^Q and a crash both leave the console
|
||||
as they found it. Then the file is deleted. No `sudo` anywhere: the VT is the process's
|
||||
controlling terminal and belongs to the logged-in user. Every part of this is optional and
|
||||
silent — off the PicoCalc, off a real VT (an ssh session is a pty, not a console), or with
|
||||
no `setfont` on `PATH`, it does nothing and says so once in the log. A permanent choice
|
||||
still goes in `vconsole.conf` by hand: `FONT=meshterm8` for 53×40, `FONT=meshterm` for 53×26.
|
||||
|
||||
> **The 6×8 font has no Cyrillic.** Its base is CP437, where the 6×12 Terminus base is not,
|
||||
> so a node named in Cyrillic draws as tofu boxes on it. That is a known and accepted cost
|
||||
> of the fourteen extra rows, not a bug to report.
|
||||
|
||||
The same script owns the sixteen colour slots. A **default run restores the stock kernel VT
|
||||
palette** and removes any previous remap: it disables and deletes the `meshterm-vtrgb`
|
||||
boot unit, `/etc/vtrgb` and the applier, programmes the stock table with `setvtrgb`, and
|
||||
then writes the sixteen `OSC P` sequences to the tty as well. Both, and not just the
|
||||
escape-sequence reset, because a previous `setvtrgb` overwrote the kernel's *default*
|
||||
colormap — so a reset alone would faithfully restore the custom colours you were trying to
|
||||
get rid of. `MESHTERM_CUSTOM_PALETTE=1` installs the archived custom remap instead, as
|
||||
`/etc/vtrgb` plus a oneshot that re-applies it after `systemd-vconsole-setup`; a repo test
|
||||
pins that remap to the palette table in `meshterm/ui/theme.py`. There is no uninstall of
|
||||
the fonts themselves — delete the two `.psf.gz` files and the `FONT=` line.
|
||||
|
||||
### Joining a network by hand
|
||||
|
||||
[`calculinux-wifi-set.sh`](../scripts/calculinux-wifi-set.sh) is how a network *becomes*
|
||||
known, which is the half the boot kick cannot do. It lists known networks and a fresh scan,
|
||||
prompts for an SSID and a passphrase with the echo off, writes iwd's PSK file for it, and
|
||||
connects now. It re-execs itself under `sudo` if you are not root.
|
||||
|
||||
```bash
|
||||
sh calculinux-wifi-set.sh
|
||||
```
|
||||
|
||||
It uses `iwctl` where the boot kick deliberately uses raw `iw`, and that is not a
|
||||
contradiction: the kick's problem is a cold-boot firmware race with iwd's scanner, and this
|
||||
is an interactive join on a machine that is already up.
|
||||
|
||||
### Re-running, and undoing
|
||||
|
||||
Re-running is safe and is the normal way to update: the packages, the user, the checkout,
|
||||
the venv and the PATH line are all checked first, the two helper scripts and their units are
|
||||
rewritten with identical content, `pip install -e .` runs again cheaply, and the fonts are
|
||||
rebuilt. Two things to know. Re-running under a **different `DEPLOY_USER` creates a second
|
||||
user and a second checkout** rather than moving anything, so a device provisioned under one
|
||||
name must keep being given that name. And there is **no uninstall** of any of it: to undo,
|
||||
disable `wifi-kick.service` and `time-sync.service`, delete the two helper scripts, drop the
|
||||
`FONT=` line from `/etc/vconsole.conf`, and remove the user.
|
||||
|
||||
---
|
||||
|
||||
## A real radio for the PicoCalc: XIAO nRF52840 over UART
|
||||
|
||||
The PicoCalc has no radio and no USB host to hang a companion off, so the radio is soldered
|
||||
to the header: a Seeed XIAO nRF52840 with a Wio-SX1262, running MeshCore's companion
|
||||
firmware on its own second serial port, wired to the Lyra's UART1. MeshTerm then opens
|
||||
`/dev/ttyS1` like any other serial companion — no bridge process, no BLE, no SD slot
|
||||
sacrificed.
|
||||
|
||||
```
|
||||
MeshTerm ──serial──> /dev/ttyS1 ──UART1 (GP4/GP5)──> XIAO Serial1 (D6/D7) ──> SX1262 ──))) mesh
|
||||
(on the Lyra) 115200 8N1 matrix-IO mux companion firmware
|
||||
```
|
||||
|
||||
Three steps on two machines: **build** the firmware and **flash** the XIAO from your dev
|
||||
box, then **set up the Lyra** as root.
|
||||
|
||||
### Parts and wiring
|
||||
|
||||
- A **Seeed XIAO nRF52840**, plain or Sense, stacked with the **Wio-SX1262** LoRa module.
|
||||
The SX1262 uses XIAO pins D1–D5 and D8–D10 and is already wired inside the kit.
|
||||
- A **Luckfox Lyra** in Pico form factor (RK3506, SD boot) running Calculinux in a
|
||||
ClockworkPi PicoCalc.
|
||||
- Four wires, TX and RX crossed.
|
||||
|
||||
| XIAO pad | → | Lyra header | Physical pin | Carries |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **D7** (Serial1 RX) | → | **GP4** | pin **6** | Lyra UART1 **TX** → XIAO RX |
|
||||
| **D6** (Serial1 TX) | → | **GP5** | pin **7** | XIAO **TX** → Lyra UART1 RX |
|
||||
| **GND** | → | **GND** | pin **8** | common ground |
|
||||
| **3V3** | → | **3V3 OUT** | pin **36** | power, so it runs without USB |
|
||||
|
||||
> **The Lyra does not use Raspberry Pi Pico GPIO numbering.** The 40-pin header is
|
||||
> physically Pico-compatible and each pad's function is Luckfox's own RK3506 mapping: GP4 is
|
||||
> `gpio0-0` and GP5 is `gpio0-1`. Don't read a Pico pinout for anything else here. Every
|
||||
> signal is 3.3 V, so no level shifter, and there is **no 5 V rail** on this board — power
|
||||
> the XIAO from 3V3 only.
|
||||
|
||||
### Build the firmware
|
||||
|
||||
On your dev machine, with `git` and PlatformIO (`pip install platformio`):
|
||||
|
||||
```bash
|
||||
cd scripts/xiao-radio
|
||||
sh build-firmware.sh
|
||||
```
|
||||
|
||||
It clones MeshCore into `./_meshcore-build` (override with `MESHCORE_DIR`), checks out the
|
||||
pinned commit **`e9edfc8e`** on the `dev` branch (override with `MESHCORE_COMMIT`), applies
|
||||
[`meshcore-uart1.patch`](../scripts/xiao-radio/meshcore-uart1.patch), builds the
|
||||
`Xiao_nrf52_companion_radio_serial` environment, and converts the result into
|
||||
`meshcore-xiao-radio.uf2` beside the script. The `.uf2` is a build product and is not
|
||||
committed.
|
||||
|
||||
The patch is two hunks, and both are required:
|
||||
|
||||
- **Teach the existing companion-over-UART path to build on nRF52.** MeshCore has had the
|
||||
`SERIAL_RX`/`SERIAL_TX` companion interface and per-board `*_companion_radio_serial`
|
||||
environments for a while — the ESP32-S3 twin of this board uses the *same* D6/D7 pads —
|
||||
but the shared code declares `HardwareSerial companion_serial(1)`, and on the Adafruit
|
||||
nRF52 core `HardwareSerial` is abstract with no numbered constructor. `Serial1` is the
|
||||
concrete `Uart` that core always defines and `Uart::setPins(rx, tx)` takes the same
|
||||
arguments in the same order, so one reference fixes the build and the call site is
|
||||
untouched.
|
||||
- **Move I²C off D6/D7.** The base `Xiao_nrf52` environment maps the I²C bus onto exactly
|
||||
those two pads, and both `XiaoNrf52Board::begin()` and `sensors.begin()` start `Wire` on
|
||||
them — so the I²C peripheral seizes the UART pins and the link stays dead with perfect
|
||||
wiring and a correct build. The new environment moves I²C to the internal IMU pins 16 and
|
||||
17. This was the single reason the link stayed silent through every other fix.
|
||||
|
||||
Both changes are upstream-shaped and have been submitted to MeshCore. **They are not merged
|
||||
yet**: as of 2026-09-12, upstream `dev` still carries the bare `HardwareSerial` declaration
|
||||
with no nRF52 branch, and `variants/xiao_nrf52/platformio.ini` still has no
|
||||
`companion_radio_serial` environment. So the patch step stays for now, and when it lands the
|
||||
patch and the `git apply` line can both be deleted. To track upstream instead of the pin,
|
||||
run with `MESHCORE_COMMIT=dev`; if the patch no longer applies, its two hunks are small
|
||||
enough to re-apply by hand. Only the pinned commit is known to build.
|
||||
|
||||
### Flash the XIAO
|
||||
|
||||
Plug the **XIAO's own USB-C** into your computer — it can stay wired to the Lyra — and:
|
||||
|
||||
```bash
|
||||
python flash.py
|
||||
```
|
||||
|
||||
It pulses the application port at 1200 baud, the Arduino-style touch that drops the board
|
||||
into its bootloader, then looks for a UF2 drive and copies the firmware onto it. It only
|
||||
ever touches Seeed's USB vendor id, never Adafruit's, so another nRF52840 board on the same
|
||||
bench is left alone. If the touch doesn't take, **double-tap the XIAO's reset button** and
|
||||
run it again.
|
||||
|
||||
Two things it refuses to guess. A drive letter is not an identity — when one board leaves
|
||||
the USB bus another can inherit its letter — so it reads `INFO_UF2.TXT` and declines a
|
||||
volume whose model is not a XIAO, and declines **SoftDevice S140 6.1.1**, which wants the
|
||||
application at `0x26000` while this firmware links for S140 v7 at `0x27000` (it would flash
|
||||
and then not boot). `--force` overrides both, if you know better than it does.
|
||||
|
||||
**If no UF2 drive appears at all, that is not necessarily a fault.** On the bench where this
|
||||
was developed the bootloader came up as a serial port with no mass-storage interface
|
||||
whatsoever, so there was nothing to copy a file onto. `flash.py` recognises the bootloader's
|
||||
serial port — on the plain XIAO and on the Sense — and prints the serial-DFU command to run
|
||||
from your MeshCore checkout instead:
|
||||
|
||||
```bash
|
||||
pio run -e Xiao_nrf52_companion_radio_serial -t upload --upload-port <bootloader port>
|
||||
```
|
||||
|
||||
That is the route that worked: it ends in `Device programmed.` and the XIAO reboots itself
|
||||
into the radio firmware. Afterwards the XIAO's USB serial port goes **silent to companion
|
||||
frames** — the companion now lives on D6/D7, not on USB. That silence is correct.
|
||||
|
||||
### Set up the Lyra
|
||||
|
||||
Copy `scripts/` to the Lyra and, as root:
|
||||
|
||||
```bash
|
||||
sh scripts/xiao-radio/lyra-setup.sh # MT_USER=meshterm by default
|
||||
```
|
||||
|
||||
It does three things. It installs [`uart1-mux.py`](../scripts/xiao-radio/uart1-mux.py) as
|
||||
`/usr/local/bin/uart1-radio-mux.py` behind a oneshot `uart1-radio-mux.service` that runs on
|
||||
every boot; it adds `$MT_USER` to `dialout` (a fresh login is needed before that counts);
|
||||
and, **only if the file is absent**, it writes a MeshTerm profile:
|
||||
|
||||
```toml
|
||||
default_profile = "picocalc"
|
||||
[profiles.picocalc]
|
||||
port = "/dev/ttyS1"
|
||||
baudrate = 115200
|
||||
transport = "serial"
|
||||
default_tx_power = 22
|
||||
description = "XIAO nRF52840 + SX1262 via Lyra UART1 on GP4/GP5"
|
||||
```
|
||||
|
||||
If a `config.toml` is already there it prints those lines for you to paste rather than
|
||||
touching your configuration. Because `default_profile` is set, a bare `meshterm` connects
|
||||
straight to the radio.
|
||||
|
||||
The mux service is the interesting half. **Calculinux enables the UART1 controller but wires
|
||||
it to no pad**, which is why `/dev/ttyS1` exists and yet nothing ever comes out of it. The
|
||||
RK3506's matrix IO can carry UART1 on almost any pad, so `uart1-mux.py` makes three 32-bit
|
||||
writes through a one-page `/dev/mem` mapping: two RMIO signal-select words that put UART1-TX
|
||||
on `gpio0-0` and UART1-RX on `gpio0-1`, and one PMU IOC word that switches those two pins
|
||||
into matrix mode. The writes are idempotent, which is why a oneshot at boot is enough, and
|
||||
they need root and a kernel that permits `/dev/mem` writes to that range. Choosing those two
|
||||
pads is what saves the front SD-card slot: UART1's *default* pads belong to SPI1, which
|
||||
drives the slot, so the mux avoids them entirely.
|
||||
|
||||
> The register addresses, the signal ids and the claim that `gpio0-0`/`gpio0-1` are header
|
||||
> GP4/GP5 come from the maintainer's live probing of one board. They are **not verified
|
||||
> against an RK3506 reference manual**, and neither is the assumption that this kernel allows
|
||||
> those `/dev/mem` writes. See [What is verified and what is
|
||||
> not](#what-is-verified-and-what-is-not).
|
||||
|
||||
### Run it, and check the link
|
||||
|
||||
```bash
|
||||
meshterm
|
||||
```
|
||||
|
||||
Your node should come up with whatever name the firmware adverts — the device page and the
|
||||
dashboard header both show it. If you want to prove the wire itself before blaming anything
|
||||
else, route the mux, open `/dev/ttyS1` at 115200 as root and send an `APP_START` frame
|
||||
(`3c 0e 00 01`, seven zero bytes, then a name); a live radio answers with a framed `05`,
|
||||
`SELF_INFO`. The frame's command byte and reply code are the companion protocol's; the
|
||||
`<`/`>` plus little-endian length framing around them is from the firmware and is worth
|
||||
confirming against it rather than taking from here.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| `meshterm` can't open the port | Is `MT_USER` in `dialout`? (`groups`) — it needs a fresh login after setup. |
|
||||
| Port opens but nothing is there | Re-flash with the **`_serial`** environment, not `_ble` or `_usb`; only that one defines `SERIAL_RX`/`SERIAL_TX`. The XIAO's USB serial should be *silent* to companion frames. |
|
||||
| Still silent, wiring confirmed | Confirm the firmware carries the **I²C remap** (`PIN_WIRE_SCL=16`, `PIN_WIRE_SDA=17`). This is the failure that looks like bad wiring and isn't. |
|
||||
| Flashed fine, board never comes back | Wrong board, or the wrong SoftDevice — `INFO_UF2.TXT` must say a XIAO and **S140 v7**, not 6.1.1. `flash.py` refuses both without `--force`. |
|
||||
| `flash.py` finds no UF2 drive | Expected on a CDC-only bootloader. Use the `pio … -t upload --upload-port` line it prints. |
|
||||
| Nothing on `/dev/ttyS1` after a reboot | `systemctl status uart1-radio-mux` first, then `journalctl -b -u uart1-radio-mux` — the poke must run every boot, and a oneshot that failed says why. |
|
||||
| MeshTerm says the radio is unplugged | Use a MeshTerm build with the soldered-UART liveness fix: pyserial's port enumeration doesn't list platform-bus ports like `/dev/ttyS1`, so liveness stats the device node instead. It is in this repo. |
|
||||
|
||||
---
|
||||
|
||||
## The SPI radio bridge
|
||||
|
||||
On some boards the LoRa chip hangs straight off the host's SPI bus — the ClockworkPi
|
||||
uConsole with the hackergadgets AIO, Waveshare LoRa HATs, and similar. There is **no
|
||||
companion microcontroller and no firmware to flash**, so there is no node for MeshTerm to
|
||||
talk to: the mesh node itself has to run in software on the host, through the `openhop_core`
|
||||
library — `pymc_core` until its 2026 rename, and on the uConsole it arrives with the
|
||||
`meshcore-uconsole` package, alongside its own `meshcore-console` GUI.
|
||||
|
||||
[`scripts/meshterm-spi-bridge`](../scripts/meshterm-spi-bridge) fills that gap. It runs the
|
||||
software node and puts the **standard companion protocol in front of it on a local TCP
|
||||
port**, so
|
||||
|
||||
```bash
|
||||
meshterm --tcp 127.0.0.1:5000
|
||||
```
|
||||
|
||||
connects exactly as if a companion were plugged in. One file is both halves: a setup menu
|
||||
that runs under any Python 3, and the bridge process itself, which the menu re-execs under
|
||||
whichever interpreter has the radio library. That split is deliberate — the library lives in
|
||||
a packaged virtual environment, and you should never need to know that.
|
||||
|
||||
It runs on the radio host as **your normal user, never root**: the node identity and the
|
||||
service are per-user.
|
||||
|
||||
### Before you start: which runtime you have
|
||||
|
||||
The library was renamed in 2026: the `pyMC_core` project became `openhop_core`, PyPI's
|
||||
`pymc-core` stopped at 1.0.12, and `openhop-core` carries on from 1.1.x. The bridge drives
|
||||
**either**, and prefers the newer one when both are installed. Its preflight names the
|
||||
runtime it found beside the interpreter it will run under:
|
||||
|
||||
```
|
||||
[ ok ] node runtime /home/you/.local/share/meshterm-spi-bridge/venv/bin/python (openhop_core)
|
||||
```
|
||||
|
||||
Under `openhop_core` two of the bridge's three compatibility shims stand down, because the
|
||||
library now does their work itself — it accepts the longer ACK payloads modern firmware
|
||||
sends, and it pushes a completed trace reply to the client. The bridge logs each as
|
||||
`not needed` at startup. The third, the raw RX-log push that feeds MeshTerm's live feed, is
|
||||
still the bridge's, and still logs `compat:` when it is wired.
|
||||
|
||||
**Which runtime you get depends on your Debian release.** The `meshcore-uconsole` package
|
||||
from 1.12.0 is built for *trixie* against Python 3.13; on a *bookworm* uConsole (Python
|
||||
3.11) that package will not run — its virtual environment points at an interpreter the
|
||||
system does not have — and apt will offer it anyway. Do not take the upgrade. Give the
|
||||
bridge a runtime of its own instead, in the directory the preflight looks in first:
|
||||
|
||||
```bash
|
||||
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
|
||||
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
|
||||
```
|
||||
|
||||
The GUI keeps its own stack, and the bridge still loads the GUI's identity key from
|
||||
`~/.local/share/meshcore-uconsole/identity.key`, so it is the same node either way. Without
|
||||
the `meshcore_console` helpers in that venv the bridge configures the radio itself, with
|
||||
the uConsole AIO preset (SPI bus 1, reset 25, busy 24, IRQ 26, no TX-enable pin, the DIO2
|
||||
RF switch and the DIO3 TCXO on, 910.525 MHz) and every `MESHCORE_*` override the GUI
|
||||
honours — see [Hardware knobs](#hardware-knobs). Verified on a bookworm uConsole with
|
||||
`openhop-core` 1.1.3: the radio initialises, the contacts come back, and MeshTerm reads
|
||||
`info` and `contacts` over the TCP port. On a pre-rename install — `pymc-core` 1.0.x —
|
||||
everything below applies as written, all three shims included.
|
||||
|
||||
### Setting it up
|
||||
|
||||
```bash
|
||||
./meshterm-spi-bridge # the interactive setup menu
|
||||
./meshterm-spi-bridge --check # run the prerequisite checks and exit
|
||||
./meshterm-spi-bridge --run # run the bridge now, in this terminal
|
||||
```
|
||||
|
||||
The preflight is worth running first. It finds the interpreter that has the radio library,
|
||||
checks that a `/dev/spidev*` node and `/dev/gpiochip0` are readable and writable by you —
|
||||
the uConsole needs `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt` for the first, and
|
||||
membership of the `spi` and `gpio` groups for both — and tells you if the GUI is holding the
|
||||
radio or if something is already listening on the port.
|
||||
|
||||
The menu then offers to **run the bridge in the foreground** (Ctrl+C returns to the menu),
|
||||
to **install it as a service**, to **uninstall** it, or to show its status. Installing copies
|
||||
the script to `~/.local/bin/` and writes a `systemd --user` unit that restarts on failure and
|
||||
starts with your session; it also asks systemd to enable lingering for your user, which is
|
||||
what makes the service come up at boot rather than at your first login, and prints the `sudo`
|
||||
command if it cannot. Either way it offers to add a MeshTerm profile for you:
|
||||
|
||||
```toml
|
||||
[profiles.bridge]
|
||||
host = "127.0.0.1"
|
||||
tcp_port = 5000
|
||||
description = "Local SPI radio via meshterm-spi-bridge"
|
||||
```
|
||||
|
||||
It sets no `default_profile`, so a bare `meshterm` offers the bridge in the picker rather
|
||||
than connecting to it behind your back. Uninstalling removes the service and the copied
|
||||
script and nothing else: it never touches the `meshcore-console` or radio-library packages,
|
||||
and it deliberately leaves your identity and contact files, and the lingering setting, where
|
||||
they are.
|
||||
|
||||
### Identity, and one program at a time
|
||||
|
||||
**Only one program may use the radio at a time.** Run *either* the `meshcore-console` GUI or
|
||||
this bridge, never both — the preflight warns you when the GUI is up, and refuses to start a
|
||||
second bridge over a running service.
|
||||
|
||||
Which node you are depends on what is installed. Where the `meshcore-console` stack is
|
||||
present the bridge loads that stack's identity key, so **MeshTerm is the same node as the
|
||||
GUI** and your contacts and your public key follow you between them. On a library-only
|
||||
install it mints its own key the first time and persists it under
|
||||
`~/.local/share/meshterm-spi-bridge/`, so the node is stable across restarts. Contacts are
|
||||
snapshotted to a file in that same directory on every change, because the library's contact
|
||||
store is memory-only — without that, direct messages to a contact you added before a restart
|
||||
came back "not found".
|
||||
|
||||
### Hardware knobs
|
||||
|
||||
The defaults match the hackergadgets uConsole AIO: SPI bus 1, chip-select 0, reset on 25,
|
||||
busy on 24, IRQ on 26, 910.525 MHz, 22 dBm, SF7, 62.5 kHz, coding rate 4/5. For another SPI
|
||||
board, export what differs before launching.
|
||||
|
||||
| Variable | What it sets |
|
||||
| --- | --- |
|
||||
| `MESHCORE_BUS_ID`, `MESHCORE_CS_ID`, `MESHCORE_CS_PIN` | which SPI bus, device and chip-select pin the radio is on |
|
||||
| `MESHCORE_RESET_PIN`, `MESHCORE_BUSY_PIN`, `MESHCORE_IRQ_PIN` | the three control lines |
|
||||
| `MESHCORE_FREQUENCY`, `MESHCORE_TX_POWER` | frequency in Hz, power in dBm |
|
||||
| `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining |
|
||||
| `MESHCORE_TXEN_PIN`, `MESHCORE_RXEN_PIN`, `MESHCORE_EN_PINS` | the RF-switch and power-enable lines a board may need (`-1` for none; `EN_PINS` is a comma list — the AIO v2 wants `27`) |
|
||||
| `MESHCORE_USE_DIO2_RF`, `MESHCORE_USE_DIO3_TCXO`, `MESHCORE_IS_WAVESHARE` | whether DIO2 drives the RF switch and DIO3 the TCXO (both on for the uConsole), and the Waveshare HAT's own wiring |
|
||||
| `MESHCORE_GPIO_CHIP`, `MESHCORE_USE_GPIOD_BACKEND`, `MESHCORE_PREAMBLE_LENGTH` | which gpiochip, whether to drive it through `gpiod`, and the LoRa preamble |
|
||||
| `MESHTERM_PYMC_PYTHON` | force a specific interpreter instead of letting the bridge discover one |
|
||||
|
||||
The direct path passes a knob only when the runtime's radio constructor accepts it, so the
|
||||
same environment works under `pymc_core` 1.0.x, which lacks the newer ones. Under the
|
||||
`meshcore_console` helpers the GUI's own configuration reads the same names.
|
||||
|
||||
Where the `meshcore-console` stack is installed, the bridge builds the radio through *its*
|
||||
hardware configuration, which reads a longer list of variables — transmit/receive enable
|
||||
pins, the GPIO chip, the Waveshare and DIO options, and the enable pins an AIO v2 needs to
|
||||
power its radio at all. Those come from that package's documentation. On a library-only
|
||||
install the table above is the whole of it, so an AIO v2 without that package present may
|
||||
not power its radio.
|
||||
|
||||
### What works through the bridge
|
||||
|
||||
The bridge exposes the companion protocol as the radio library implements it, plus two
|
||||
pushes of its own. In MeshTerm's terms: messaging, contacts, channels, adverts, device
|
||||
configuration, traces and the live packet feed all work. Three things do not, because the
|
||||
library has no handler for them — **reboot and factory reset have no effect, and a device
|
||||
PIN cannot be set**. MeshTerm reports the device model as `pyMC-spi-bridge`, which is a
|
||||
useful way to tell a bridged node from a real companion in the logs.
|
||||
|
||||
Traces and the live feed reach MeshTerm through compatibility shims the bridge installs on
|
||||
the library: it subscribes to raw packets to feed the live feed, reassembles a completed
|
||||
trace reply and pushes it, and relaxes an acknowledgement length check that newer firmware
|
||||
trips by appending bytes after the CRC. None of that is MeshTerm's protocol being bent —
|
||||
`pymc_core` simply never registered those two pushes. `openhop_core` has since taken the
|
||||
ACK relaxation and the trace push upstream, and under it the bridge installs only the raw
|
||||
RX-log shim, logging the other two as `not needed`. Their **on-air behaviour was validated
|
||||
on one bench**, against the radios that were in range of it.
|
||||
|
||||
---
|
||||
|
||||
## The unfinished UART-to-TCP pump
|
||||
|
||||
[`calculinux-radio-bridge.sh`](../scripts/calculinux-radio-bridge.sh) is a work in progress
|
||||
and is not a supported route. It was written to put the UART-attached XIAO radio on a TCP
|
||||
port so other machines could reach it, before the direct-serial path above existed, and it
|
||||
is superseded by `lyra-setup.sh` for on-device use. As it stands its own header describes a
|
||||
pin-routing phase that the body does not contain, on a theory of which pads carry UART1 that
|
||||
`uart1-mux.py` later replaced; it binds to every interface by default, unauthenticated; and
|
||||
it writes a competing default profile into the same `config.toml` that `lyra-setup.sh`
|
||||
writes. Read it if you want the shape of a UART-to-TCP pump — a single-client pump is the
|
||||
right shape, since companion frames from two clients would interleave — but don't run it
|
||||
expecting a finished tool.
|
||||
|
||||
---
|
||||
|
||||
## What is verified and what is not
|
||||
|
||||
Most of what this manual explains is checked in code: the platform spec, the font and
|
||||
palette contracts (a test parses the font script itself and pins it to MeshTerm's glyph
|
||||
inventory), the profile keys the scripts write, the soldered-UART liveness fix, and every
|
||||
interface the SPI bridge calls in `pymc_core` 1.0.12 and in `openhop_core` 1.1.3 (the
|
||||
latter on a live uConsole: radio up, contacts restored, `info` and `contacts` answered over
|
||||
TCP). The upstream firmware state is checked
|
||||
too: the pinned MeshCore commit exists, the patch's two hunks are still needed on `dev`
|
||||
today, and the nRF52840 UF2 family id and both XIAO bootloader ids match Adafruit's board
|
||||
files.
|
||||
|
||||
The rest is one maintainer's bench, reported honestly rather than dressed up. None of the
|
||||
following is confirmable from code in this repository, and a second device may disagree:
|
||||
|
||||
- **The RK3506 register map** in `uart1-mux.py` — the two register blocks, the UART1 signal
|
||||
ids, matrix mode, and that `gpio0-0`/`gpio0-1` are header GP4 and GP5. Probed live on one
|
||||
board, not read from a reference manual.
|
||||
- **That the Calculinux kernel permits those `/dev/mem` writes**, and that the mux oneshot
|
||||
really does run at every boot.
|
||||
- **The PicoCalc header pin numbers** 6, 7, 8 and 36 for GP4, GP5, GND and 3V3 on the Lyra
|
||||
carrier. The Pico-side numbering is right; the carrier's wiring is a bench observation.
|
||||
- **The Calculinux specifics the scripts assert**: the stripped Python and the package names
|
||||
that fix it, which overlays are writable, that `/tmp` is a small RAM tmpfs, the rtl8xxxu
|
||||
cold-boot race with iwd, the opkg feed's occasional 404, the absolute `ip` and `iw` paths,
|
||||
that the stock Terminus console font is installed, and that `kbd` brings `setvtrgb`.
|
||||
- **That this board has no RTC.** Only the script says so, and everything about the clock
|
||||
phase follows from it.
|
||||
- **The XIAO bootloader that exposed no mass storage**, and the S140 v7 / `0x27000`
|
||||
requirement — public references agree, but neither is a primary source.
|
||||
- **Whether the firmware patch still applies at MeshCore `dev` HEAD.** It is unmerged; only
|
||||
the pinned commit is known to build.
|
||||
- **MeshCore's serial framing** around the raw `APP_START` check above. The command byte and
|
||||
the `SELF_INFO` reply code are verified against the protocol documentation; the framing
|
||||
bytes are not.
|
||||
- **Everything on-air about the SPI bridge** — the per-hop trace semantics it reassembles and
|
||||
the acknowledgement bytes "newer firmware" appends. The code is internally consistent with
|
||||
the library's API; the wire behaviour was seen on one mesh.
|
||||
- **The bridge under `openhop_core` beyond `info` and `contacts`.** Startup, identity,
|
||||
contact restore and those two reads were exercised on a uConsole; a trace, a message and
|
||||
the live feed under the new runtime were not, though the library's own trace push is
|
||||
what the bridge now relies on for the first.
|
||||
- **The framebuffer console's 512-glyph cap.** Well known, taken as given, not re-measured
|
||||
here.
|
||||
|
||||
If you have the hardware and any of it is wrong on your bench, that is worth an issue — the
|
||||
scripts encode a set of discoveries, and a second device is the only way to learn which of
|
||||
them were about the hardware and which were about one unit.
|
||||
What that platform needs from the console — a font with the glyphs the interface draws,
|
||||
and the stock sixteen-slot palette — is installed by the PicoCalc manual's setup script;
|
||||
the details are in that manual and in the scripts' own headers.
|
||||
@@ -0,0 +1,914 @@
|
||||
# MeshTerm on the PicoCalc
|
||||
|
||||
You start with a stock ClockworkPi PicoCalc — the kit that ships with a Raspberry Pi Pico —
|
||||
and a short shopping list. You end with a Linux handheld: MeshTerm running full-screen on
|
||||
the PicoCalc's own display and keyboard, a LoRa radio soldered inside, and no laptop needed
|
||||
to use it day to day. There are three phases: build the machine (swap the Pico for a Linux
|
||||
board), install the OS and MeshTerm, and add the radio. Budget an afternoon for the first
|
||||
two phases, most of it spent waiting for a toolchain to download; the radio phase needs a
|
||||
soldering iron and can be done later — MeshTerm's `--mock` mode runs perfectly well without
|
||||
a radio in the meantime.
|
||||
|
||||
> ⚠ **Read this first.** This build takes the PicoCalc apart, swaps its core board, and
|
||||
> solders to it. It assumes you can already solder cleanly, use a multimeter, and work with
|
||||
> small electronics without forcing anything. There is a real risk of damaging the
|
||||
> hardware if a step goes wrong — a cracked screen, a snapped shell post, a dead board, or
|
||||
> an overcharged lithium cell — and some of that damage is not repairable. You do this at
|
||||
> your own risk.
|
||||
>
|
||||
> **Read the whole manual through once before you buy anything or open anything.** Several
|
||||
> steps only make sense in light of a later one — where the SD card goes, what must be out
|
||||
> of the case before a board is pressed in, which USB port is never to be powered — and the
|
||||
> shopping list is easier to get right when you know what each part is for.
|
||||
>
|
||||
> This is a guide, not a prescription. Despite the author's best efforts it may contain
|
||||
> errors, and parts of it will go out of date as boards, images and firmware change. It is
|
||||
> up to you to check every fact in it against your own hardware and the current sources
|
||||
> before you act on it, and every decision along the way is yours. The author accepts no
|
||||
> responsibility for any damage, loss or injury that results from following this guide,
|
||||
> and nothing in it is a warranty that your parts, your tools or your hands will behave the
|
||||
> way the author's did. If you are not comfortable with any step, stop and get help from
|
||||
> someone who is.
|
||||
|
||||
- [Shopping list](#shopping-list)
|
||||
- [Before you start](#before-you-start)
|
||||
- [Three ways to break it](#three-ways-to-break-it)
|
||||
- [Phase 1 — Build the machine](#phase-1--build-the-machine)
|
||||
- [Step 1 — Assemble the PicoCalc](#step-1--assemble-the-picocalc)
|
||||
- [Step 2 — Swap the Pico for the Lyra](#step-2--swap-the-pico-for-the-lyra)
|
||||
- [Step 3 — Attach the Wi-Fi dongle](#step-3--attach-the-wi-fi-dongle)
|
||||
- [Phase 2 — Install Calculinux and MeshTerm](#phase-2--install-calculinux-and-meshterm)
|
||||
- [Step 4 — Write the image to the microSD](#step-4--write-the-image-to-the-microsd)
|
||||
- [Step 5 — First boot](#step-5--first-boot)
|
||||
- [Step 6 — Open the serial console](#step-6--open-the-serial-console)
|
||||
- [Step 7 — Join Wi-Fi](#step-7--join-wi-fi)
|
||||
- [Step 8 — Copy the setup scripts to the device](#step-8--copy-the-setup-scripts-to-the-device)
|
||||
- [Step 9 — Run calculinux-setup.sh](#step-9--run-calculinux-setupsh)
|
||||
- [Step 10 — Log in as meshterm](#step-10--log-in-as-meshterm)
|
||||
- [Phase 3 — Add the radio](#phase-3--add-the-radio)
|
||||
- [Step 11 — Install PlatformIO](#step-11--install-platformio)
|
||||
- [Step 12 — Build the firmware](#step-12--build-the-firmware)
|
||||
- [Step 13 — Flash the XIAO](#step-13--flash-the-xiao)
|
||||
- [Step 14 — Wire the radio](#step-14--wire-the-radio)
|
||||
- [Step 15 — Set up the Lyra](#step-15--set-up-the-lyra)
|
||||
- [Step 16 — Run it](#step-16--run-it)
|
||||
- [Day-to-day](#day-to-day)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [What has been tested](#what-has-been-tested)
|
||||
|
||||
---
|
||||
|
||||
## Shopping list
|
||||
|
||||
### The machine
|
||||
|
||||
| Item | Product / what to search for | Approx. price | Link | Note |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| PicoCalc kit | ClockworkPi PicoCalc | $89 | [clockworkpi.com/product-page/picocalc](https://www.clockworkpi.com/product-page/picocalc) | Comes with a Raspberry Pi Pico 1H, screen, keyboard, shell, hex key and a 32 GB SD card. **18650 batteries are not included.** |
|
||||
| Luckfox Lyra | "Luckfox Lyra 128MB, pre-soldered header, no NAND" | $20–25 | [luckfox.com/Luckfox-Lyra](https://www.luckfox.com/Luckfox-Lyra) | Buy the **plain 128 MB RAM, Pico-form-factor** variant, **with the header pre-soldered**. See the note below — this is the one part of the list where the wrong SKU won't work. |
|
||||
| microSD card | 16 GB+, Class 10 or better | $6–10 | — | The PicoCalc's stock 32 GB card works fine — it's more than the 8 GB minimum. You can reuse it instead of buying a new one. |
|
||||
| Two 18650 batteries | e.g. Samsung 30Q, Molicel P26A, Sony VTC6 (examples, not endorsements) | $10–20 | — | **Unprotected**, flat-top or button-top, Ø18 × 65–69 mm. See [the battery note](#a-note-on-batteries) below. |
|
||||
| USB Wi-Fi dongle | TP-Link TL-WN725N (an RTL8188EU nano dongle), or search the **chipset name**: RTL8192CU, R8712U, RTL8188EU | $5–10 | [amazon.ca/dp/B008IFXQFU](https://www.amazon.ca/dp/B008IFXQFU) | The TL-WN725N is the dongle this guide was built with. Must run at **3.3 V** — see [Step 3](#step-3--attach-the-wi-fi-dongle). Only needed for setup and updates; MeshTerm itself runs offline. |
|
||||
| MX1.25 4-pin pigtail | "MX1.25 4P cable with leads" (a pack of pre-crimped pigtails, 10–15 cm) | $2–4 | — | One end of the DIY USB lead in [Step 3](#step-3--attach-the-wi-fi-dongle). Pre-crimped, because MX1.25 contacts need a crimper you don't otherwise own. |
|
||||
| USB-A female socket | "USB 2.0 type A female socket, solder / through-hole" | $2–3 | — | The other end of the DIY lead. A ready-made [MX1.25 4P to USB-A cable](https://spotpear.com/shop/Luckfox-Lyra-MX1.25-4P-To-USB-A-Cable.html) (about $1, 30 cm) works too, but it is a lot of cable to fold into the shell. |
|
||||
|
||||
### The radio
|
||||
|
||||
| Item | Product / what to search for | Approx. price | Link | Note |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| XIAO nRF52840 + Wio-SX1262 kit | "XIAO nRF52840 & Wio-SX1262 Kit for Meshtastic", SKU 102010710 | $13.49 | [seeedstudio.com](https://www.seeedstudio.com/XIAO-nRF52840-Wio-SX1262-Kit-for-Meshtastic-p-6400.html) | Includes the XIAO nRF52840, the Wio-SX1262 LoRa board (stacked and wired together) and an antenna. Covers both 868 and 915 MHz. Plain or Sense XIAO both work. **Do not** buy the "-N" variant — it has no antenna connector. |
|
||||
|
||||
### Tools and consumables
|
||||
|
||||
| Item | Note |
|
||||
| --- | --- |
|
||||
| Soldering iron, fine tip, and solder | For the four radio wires. |
|
||||
| Hookup wire, 26–30 AWG, four short lengths | TX and RX get crossed — see [Step 14](#step-14--wire-the-radio). |
|
||||
| 2.5 mm hex key | Comes with the PicoCalc kit. |
|
||||
| USB-C data cable | For the serial console, and later for flashing the XIAO. A charge-only cable won't do; it has to carry data. |
|
||||
| microSD card reader | For writing the Calculinux image from your PC. |
|
||||
| Small side cutters | For trimming wire ends, and a section of the case grille if the Wi-Fi lead needs the room. |
|
||||
| Small needle file | To open the back panel's USB-C cutout a little if the Lyra's connector doesn't clear it. |
|
||||
| Kapton (polyimide) tape | Wraps the XIAO stack and the DIY USB lead so nothing bare touches the board or the shell. Heat-proof, thin, and it peels off cleanly. |
|
||||
| A PC (Windows/macOS/Linux) with Python 3 and git | Runs the image-writing, firmware-build and flashing steps. |
|
||||
|
||||
### Optional
|
||||
|
||||
| Item | Why |
|
||||
| --- | --- |
|
||||
| A second 18650 battery | The holder takes two; one is enough to run, two roughly double your runtime. |
|
||||
| Powered USB hub | If you want the Wi-Fi dongle and something else on the Lyra's single USB host socket at once, or a 5 V dongle. |
|
||||
| Foam tape | To secure the XIAO board inside the shell once it's wired in. |
|
||||
|
||||
Approximate total: **$130–150** for the machine and radio together, before batteries — most
|
||||
of that is the two boards ($89 + ~$22) and the radio kit ($13.49).
|
||||
|
||||
**What you do NOT need:**
|
||||
|
||||
- A level shifter. Every signal in this build — the Lyra's header, the XIAO, the SX1262 — is
|
||||
3.3 V.
|
||||
- A separate USB-to-serial adapter. The PicoCalc mainboard already has one built in.
|
||||
- Bluetooth anything. Neither the Lyra nor this radio path uses it.
|
||||
- A new SD card, if your stock 32 GB card is handy — it's well over the 8 GB minimum
|
||||
Calculinux asks for.
|
||||
|
||||
### A note on batteries
|
||||
|
||||
ClockworkPi has not published an official battery spec for the PicoCalc — a
|
||||
[still-open GitHub issue](https://github.com/clockworkpi/PicoCalc/issues/6) asking for one
|
||||
has gone unanswered. What's known from the kit itself: the holder takes two 18650 cells, one
|
||||
cell is enough to power the board, and the physical envelope is about Ø18 mm × 65–69 mm,
|
||||
which rules out most *protected* cells — the protection circuit adds length that doesn't
|
||||
fit. Unprotected flat-top or button-top cells both work; the PicoCalc mainboard has its own
|
||||
charge circuit built in. Buy from a reputable brand rather than a cheap unbranded listing —
|
||||
Samsung 30Q, Molicel P26A and Sony VTC6 are commonly recommended examples, not an
|
||||
endorsement of any one of them.
|
||||
|
||||
---
|
||||
|
||||
## Before you start
|
||||
|
||||
Work happens on two machines, and it helps to keep straight which is which:
|
||||
|
||||
- **Your PC** writes the Calculinux image to the microSD card, builds the radio firmware,
|
||||
and flashes the XIAO. Nothing in these three jobs touches the PicoCalc itself.
|
||||
- **The PicoCalc** (really, the Luckfox Lyra inside it) does everything else — setup,
|
||||
Wi-Fi, running MeshTerm — either over the serial console from your PC or on its own
|
||||
screen and keyboard. Both work identically; the serial console is just easier for pasting
|
||||
long commands.
|
||||
|
||||
The device has two logins you'll use for different things: **`root`**, which you use once
|
||||
to run the setup script and fix Wi-Fi, and **`meshterm`**, the unprivileged account
|
||||
MeshTerm actually runs under day to day.
|
||||
|
||||
Calculinux is a console-only Linux — no desktop, no window manager. That's deliberate: the
|
||||
PicoCalc's screen and keyboard are a terminal, and MeshTerm is a full-screen terminal
|
||||
program built for exactly that.
|
||||
|
||||
---
|
||||
|
||||
## Three ways to break it
|
||||
|
||||
The PicoCalc is a kit, and its forum has a well-worn list of the ways people have damaged
|
||||
theirs. Three of them apply directly to this build, and the first two are not theoretical:
|
||||
the author of this guide **cracked a screen and snapped screw posts** building the very
|
||||
machine it describes. Read this section before you open the shell.
|
||||
|
||||
> ⚠ **The screen is extremely fragile.** It is a bare glass panel with no frame of its own,
|
||||
> and when the case is closed it sits directly against the mainboard. Anything that flexes
|
||||
> the mainboard flexes the glass: pushing a board into the Pico socket, prying one out,
|
||||
> closing the shell with the screen not sitting square in its opening, or pressing a key
|
||||
> while the halves are being screwed together. Forum members have cracked screens doing
|
||||
> each of those, and the swap in [Step 2](#step-2--swap-the-pico-for-the-lyra) is the
|
||||
> classic one — pressing a new board into the socket with the mainboard still in the case
|
||||
> is enough on its own. That is exactly how the screen in this guide's own PicoCalc broke.
|
||||
> ([Before replacing the Pico](https://forum.clockworkpi.com/t/before-replacing-the-pico-read-this-to-avoid-cracked-screen/16666),
|
||||
> [Avoid breaking your screen](https://forum.clockworkpi.com/t/avoid-breaking-your-screen/18805))
|
||||
>
|
||||
> What to do instead:
|
||||
>
|
||||
> - **Never push a board into the socket, or pull one out, with the mainboard in the case.**
|
||||
> Unplug the display's ribbon cable (the FPC) from the mainboard, lift the mainboard out,
|
||||
> and do the swap on a flat, padded surface — a folded microfibre cloth is ideal. Ease a
|
||||
> board out a little at each end in turn, never by levering one end.
|
||||
> - **Secure the screen with Kapton tape** along its edges so it cannot shift in the
|
||||
> opening while you work and while the case is closed. Kapton is the right tape: thin,
|
||||
> it stays put, and it peels off cleanly. Don't use anything that sets hard.
|
||||
> - **Before the back goes on, check the screen sits flush and square** in the front half's
|
||||
> opening with no pressure on any corner. If the mainboard doesn't sit perfectly flush on
|
||||
> the front half, stop and find out why before you add the back.
|
||||
> - Handle the panel as little as possible, and keep your fingers off the keyboard while
|
||||
> closing the shell.
|
||||
|
||||
> ⚠ **Don't overtighten the screws.** The shell's screw posts are brass inserts moulded
|
||||
> into plastic, and forcing a screw snaps the post out of the shell — a repair that needs
|
||||
> epoxy and never quite comes back
|
||||
> ([Broken screw thread](https://forum.clockworkpi.com/t/broken-screw-thread/20726)).
|
||||
> This guide's author snapped several on the first assembly; it takes less force than you
|
||||
> would think.
|
||||
> Turn each screw until it just seats, then stop. If a screw won't go in easily, the halves
|
||||
> aren't aligned, or something inside is in the way; more force is never the answer.
|
||||
|
||||
> ⚠ **Never power the Lyra through its own USB-C while the batteries are in.** The
|
||||
> PicoCalc charges its cells only through the **mainboard's** USB-C port, under the control
|
||||
> of its power-management chip (an AXP2101). Power fed in through the core board's USB port
|
||||
> — the Pico's originally, the Lyra's now — arrives on the header's system-power pin and
|
||||
> bypasses that charge control. Forum members who powered the Pico's port directly measured
|
||||
> their cells at **4.46 V and 4.74 V** afterwards, well over the 4.2 V a lithium cell is
|
||||
> allowed to reach
|
||||
> ([New PicoCalc — battery charging](https://forum.clockworkpi.com/t/new-picocalc-battery-charging/22330)).
|
||||
> An overcharged cell is a fire risk, not just a worn one.
|
||||
>
|
||||
> Nothing in this guide needs the Lyra's USB-C: the serial console, charging and power all
|
||||
> use the mainboard's port on the side of the case, and the Wi-Fi dongle uses the 4-pin
|
||||
> socket on top of the Lyra. If you ever must plug the Lyra's USB-C into a PC — to
|
||||
> reflash it with Luckfox's tools, say — **take both batteries out first.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Build the machine
|
||||
|
||||
### Step 1 — Assemble the PicoCalc
|
||||
|
||||
Follow ClockworkPi's own
|
||||
[assembly guidelines](https://github.com/clockworkpi/PicoCalc/blob/master/Clockwork_PicoCalc_Assembly_Guidelines.pdf)
|
||||
(a PDF in the [PicoCalc repository](https://github.com/clockworkpi/PicoCalc), which also
|
||||
has a wiki) to put the kit together: mainboard, screen, keyboard, speakers and shell. Insert your two
|
||||
18650 batteries, watching the `+`/`-` marks in the battery compartment.
|
||||
|
||||
⚠ Two of the warnings in [Three ways to break it](#three-ways-to-break-it) apply here: tape
|
||||
the screen's edges with Kapton before you close the shell and check it sits square, and
|
||||
turn the screws only until they seat. You will open the shell again in the next step, so
|
||||
there is no reason to make the screws tight now.
|
||||
|
||||
Power it on once with the **stock Pico still in place**. You should see the stock BASIC
|
||||
firmware boot on the screen, and the keyboard should respond. This confirms the screen,
|
||||
keyboard and battery are all good before you start swapping boards.
|
||||
|
||||
Power it back off.
|
||||
|
||||
### Step 2 — Swap the Pico for the Lyra
|
||||
|
||||
Remove the back of the shell with the hex key.
|
||||
|
||||
⚠ **Do the swap with the mainboard out of the case.** This is the step that cracks screens
|
||||
(see [Three ways to break it](#three-ways-to-break-it)). Unplug the display's ribbon cable
|
||||
from the mainboard, lift the mainboard out, and lay it on a padded surface. Only then ease
|
||||
the Raspberry Pi Pico out of its socket, a little at each end in turn — **keep it**, you're
|
||||
just setting it aside.
|
||||
|
||||
If your Luckfox Lyra has SPI NAND on board (a "Lyra B"), erase it first — the boot ROM
|
||||
otherwise ignores the SD card entirely and boots the on-board flash instead. See
|
||||
[Calculinux's hardware requirements page](https://calculinux.org/getting-started/hardware-requirements/)
|
||||
for how.
|
||||
|
||||
Seat the Lyra into the same socket the Pico came out of, in the same orientation — its
|
||||
USB-C port at the end where the Pico's USB port was, so it lines up with the cutout in the
|
||||
back of the shell. Press it down fully with the mainboard still on the padded surface; a
|
||||
Lyra that isn't fully seated won't boot. Then reconnect the ribbon cable and set the
|
||||
mainboard back into the front half, checking the screen sits square before anything is
|
||||
screwed down.
|
||||
|
||||
You should see the Lyra sitting flat on the socket with no gap at either end, and, once
|
||||
the mainboard is back in, its USB-C port centred in the back-panel cutout.
|
||||
|
||||
The cutout was made for the Pico's micro-USB, and the Lyra's USB-C connector is a
|
||||
different shape. Depending on the connector Luckfox fitted to your board, the back panel
|
||||
may not close over it. If it catches, open the hole a little with a small needle file,
|
||||
testing the fit as you go, until the panel sits down without pressing on the board.
|
||||
|
||||
Leave the back off for now. The microSD card goes into the **Lyra's own slot** in
|
||||
[Step 4](#step-4--write-the-image-to-the-microsd), and that slot is easier to reach with the
|
||||
board exposed.
|
||||
|
||||
### Step 3 — Attach the Wi-Fi dongle
|
||||
|
||||
The Lyra has two USB connectors. The USB-C on its edge is for power and flashing and is
|
||||
not used here. The **tiny 4-pin socket on top of the board** (an MX1.25 connector, next to
|
||||
the two buttons by the USB-C end) is the USB host. The Wi-Fi dongle hangs off it through a
|
||||
short lead you make yourself: a ready-made cable is 30 cm long and a struggle to fold into
|
||||
the shell, and you have the soldering iron out for the radio anyway.
|
||||
|
||||
**Make the lead.** Solder the four wires of an MX1.25 pigtail to a USB-A female socket,
|
||||
keeping the run short — 5 to 8 cm is enough to reach a spot where the dongle sits flat.
|
||||
The pigtail's pins are in the standard USB order, which the pre-made cable's wire colours
|
||||
follow:
|
||||
|
||||
| MX1.25 pin | USB-A socket pin | Signal | Pigtail wire colour (usually) |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 1 | VBUS (**3.3 V** on this board) | red |
|
||||
| 2 | 2 | D− | white |
|
||||
| 3 | 3 | D+ | green |
|
||||
| 4 | 4 | GND | black |
|
||||
|
||||
USB-A socket pins are numbered 1 to 4 across the connector's opening: VBUS on one edge,
|
||||
GND on the other, the data pair between them. Colours on a pigtail are a convention, not a
|
||||
promise, so check pin 1 before you plug anything in: with the Lyra powered, the socket's
|
||||
pin 1 reads **3.3 V** against a GND header pin and the pin at the other end reads 0 V.
|
||||
Swapping VBUS and GND is the one mistake that damages the dongle.
|
||||
|
||||
Once soldered, wrap the socket's solder side and the exposed wire in Kapton tape. Plug the
|
||||
MX1.25 end into the socket on the Lyra — it is keyed and only goes in one way — and the
|
||||
Wi-Fi dongle into the USB-A end. Lead and dongle stay inside the shell; tuck them where
|
||||
they don't press on the board.
|
||||
|
||||
The MX1.25 socket sits on top of the Lyra, and the lead leaves it upward, which is where
|
||||
the back panel's grille is. The wire may interfere with the grille when the back goes on.
|
||||
If it does, snip a small section out of the grille with the side cutters where the wire
|
||||
passes and shape the edge until nothing presses on the plug or the wire. Better to remove a
|
||||
sliver of plastic than to close the case against a connector.
|
||||
|
||||
> **The Lyra's USB port is 3.3 V, not 5 V.** A standard 5 V USB Wi-Fi dongle will not work
|
||||
> and may damage the board. Stick to the tested chipsets — RTL8192CU, R8712U, or RTL8188EU
|
||||
> — which is why the shopping list names a known dongle (the TP-Link TL-WN725N) and
|
||||
> otherwise tells you to search by chipset rather than by brand.
|
||||
|
||||
Don't power the device on yet — Wi-Fi is set up later, once Calculinux is installed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Install Calculinux and MeshTerm
|
||||
|
||||
### Step 4 — Write the image to the microSD
|
||||
|
||||
On your PC, download the latest `calculinux-image-luckfox-lyra.rootfs.wic.gz` from the
|
||||
[Calculinux releases page](https://github.com/Calculinux/meta-calculinux/releases).
|
||||
|
||||
Decompress it:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
gunzip calculinux-image-luckfox-lyra.rootfs.wic.gz
|
||||
```
|
||||
|
||||
(The Calculinux installation page mentions `unxz`, but the file is a `.gz`, so use
|
||||
`gunzip`. 7-Zip works on any platform if you'd rather not use the command line.)
|
||||
|
||||
Write the resulting `.wic` file to your microSD card. Three options:
|
||||
|
||||
- **Balena Etcher** (recommended, all platforms) — open it, select the `.wic` file, select
|
||||
your SD card, click Flash.
|
||||
- **`dd`**, on Linux/macOS. Check the device name first — writing to the wrong device
|
||||
destroys its contents:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
lsblk # find your SD card's device, e.g. /dev/sdb
|
||||
sudo dd if=calculinux-image-luckfox-lyra.rootfs.wic of=/dev/sdX bs=4M status=progress conv=fsync
|
||||
```
|
||||
|
||||
- **Rufus**, on Windows — select the device and the image, choose the **MBR** partition
|
||||
scheme, and start.
|
||||
|
||||
This erases everything already on the card. Once it finishes, eject it and put it in the
|
||||
**Luckfox Lyra's own microSD slot** — not the PicoCalc's front-facing slot. The Lyra boots
|
||||
from its own slot; the front slot is only reachable once MeshTerm (or anything else) is
|
||||
already running on Linux.
|
||||
|
||||
### Step 5 — First boot
|
||||
|
||||
With the card in the Lyra, power the PicoCalc on. Give it 30–60 seconds — the first boot is
|
||||
slower than later ones. You should see boot messages scroll on the PicoCalc's own screen,
|
||||
ending in a `Calculinux GNU/Linux` login prompt.
|
||||
|
||||
Log in:
|
||||
|
||||
```
|
||||
login: root
|
||||
Password: root
|
||||
```
|
||||
|
||||
Change the password immediately:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
passwd
|
||||
```
|
||||
|
||||
### Step 6 — Open the serial console
|
||||
|
||||
The PicoCalc's own screen and keyboard work fine, so you can skip this step. The serial
|
||||
console makes pasting the longer commands in this guide much easier, though, so it is
|
||||
recommended.
|
||||
|
||||
The PicoCalc mainboard's USB-C port carries a USB-to-UART bridge (a CH340 chip). Plug a
|
||||
USB-C cable from that port into your PC. It shows up as:
|
||||
|
||||
- `/dev/ttyUSB0` on Linux (you may need to be in the `dialout` group),
|
||||
- a `COM` port on Windows,
|
||||
- `/dev/cu.usbserial-*` on macOS.
|
||||
|
||||
Settings: **1500000 8N1**, no flow control. Using `pyserial`'s `miniterm`:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
pip install pyserial
|
||||
python3 -m serial.tools.miniterm /dev/ttyUSB0 1500000
|
||||
```
|
||||
|
||||
minicom and PuTTY work too, at the same settings. To leave `miniterm`, press **Ctrl+]**.
|
||||
|
||||
You should see the same login prompt you saw on the PicoCalc's screen, now in your PC's
|
||||
terminal.
|
||||
|
||||
### Step 7 — Join Wi-Fi
|
||||
|
||||
Calculinux manages Wi-Fi with `iwd`. The easiest way is its text-mode picker: run `uwific`,
|
||||
highlight your network, press Enter and type the passphrase (`Q` quits). Or do the same
|
||||
from the command line:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
iwctl station wlan0 scan
|
||||
iwctl station wlan0 get-networks
|
||||
iwctl station wlan0 connect "<your SSID>"
|
||||
```
|
||||
|
||||
It'll prompt for the passphrase. Check you're online:
|
||||
|
||||
```bash
|
||||
ip addr show wlan0
|
||||
ping -c 3 1.1.1.1
|
||||
```
|
||||
|
||||
You should see an `inet` address on `wlan0`, and replies from the ping. `iwd` remembers
|
||||
the network, so this is a one-time step; to change networks later, run `uwific` again.
|
||||
|
||||
### Step 8 — Copy the setup scripts to the device
|
||||
|
||||
You need this repository's `scripts/` directory on the Lyra — its `picocalc/` folder is the
|
||||
part the Lyra runs. Three ways to get it there, in order of ease:
|
||||
|
||||
- **`scp` from your PC**, once Wi-Fi is up. Find the Lyra's IP address with `ip addr show
|
||||
wlan0` on the device, then from your PC:
|
||||
|
||||
```bash
|
||||
# on the PC, from your MeshTerm checkout
|
||||
scp -r scripts root@<lyra-ip>:/root/
|
||||
```
|
||||
|
||||
If root is refused over SSH, copy as the `pico` user (password `calc`) instead, then
|
||||
move the directory as root on the device:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
scp -r scripts pico@<lyra-ip>:/home/pico/
|
||||
# on the PicoCalc, as root
|
||||
mv /home/pico/scripts /root/
|
||||
```
|
||||
|
||||
- **The microSD card.** Pull the card, copy `scripts/` onto it from your PC's card reader,
|
||||
put it back.
|
||||
- **`git clone` on the device.** Only once `git` is installed, and the setup script is what
|
||||
installs it — so this is a route for later updates, not the first copy.
|
||||
|
||||
`scp` is the recommended route.
|
||||
|
||||
### Step 9 — Run calculinux-setup.sh
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root, with scripts/ copied over
|
||||
cd scripts/picocalc
|
||||
sh calculinux-setup.sh
|
||||
```
|
||||
|
||||
It's idempotent — safe to re-run — and it checks before it acts. Two things it can't figure
|
||||
out on its own, both covered by environment variables read at the top of the script:
|
||||
|
||||
| Knob | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `DEPLOY_USER` | `meshterm` | the login MeshTerm runs under |
|
||||
| `TIMEZONE` | `America/Toronto` | any IANA zone |
|
||||
| `REPO_URL` | the project's HTTPS URL | cloned when there's no deploy key |
|
||||
| `REPO_SSH` | the project's SSH URL | cloned when there is one |
|
||||
| `KEY_PATH` | `/home/$DEPLOY_USER/.ssh/id_ed25519` | a read-only GitHub deploy key, if you want SSH instead of HTTPS |
|
||||
|
||||
You don't need to set any of these for a normal install — the defaults clone MeshTerm's
|
||||
public repository over plain HTTPS, which needs no credential at all.
|
||||
|
||||
What it does, one line per phase:
|
||||
|
||||
- **opkg packages** — installs `python3-modules`, `python3-pip`, `git`, `kbd`, because
|
||||
Calculinux ships a stripped Python missing `pip`, `venv` and several stdlib modules.
|
||||
- **Wi-Fi kick** — installs a boot-time service that re-scans until the dongle joins the
|
||||
network `iwd` already knows, because on a cold boot the dongle's firmware finishes
|
||||
loading after `iwd`'s first scan and the link would otherwise never come up.
|
||||
- **Time sync** — installs a boot-time service that sets the clock over the network, since
|
||||
this board has no battery-backed real-time clock.
|
||||
- **Timezone** — sets `$TIMEZONE` (Eastern by default).
|
||||
- **Deploy user** — creates the `meshterm` login and puts it in the groups it needs.
|
||||
- **Clone** — checks out MeshTerm to `/home/meshterm/MeshTerm`.
|
||||
- **venv and install** — builds a Python virtual environment and `pip install -e .`s
|
||||
MeshTerm into it.
|
||||
- **Login PATH** — puts that venv's `bin` on the `meshterm` user's PATH.
|
||||
- **Console font** — builds and installs the console font MeshTerm's UI needs (braille
|
||||
charts, node glyphs, rounded corners).
|
||||
|
||||
This takes a few minutes on a good connection — most of it is `pip install` compiling
|
||||
things. It ends with `done` and tells you to log in as `meshterm`. That user has no
|
||||
password yet, so set one first:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
passwd meshterm
|
||||
```
|
||||
|
||||
> **With no Wi-Fi dongle attached, the next boot takes about six minutes** to get past the
|
||||
> Wi-Fi kick, which keeps trying before it gives up. Nothing is broken. If the device is
|
||||
> going to live offline, see [Day-to-day](#day-to-day) for how to switch the kick off.
|
||||
|
||||
### Step 10 — Log in as meshterm
|
||||
|
||||
Log out of `root`, and log in as `meshterm` with the password you just set. Try the app:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as meshterm
|
||||
meshterm --mock
|
||||
```
|
||||
|
||||
You should see MeshTerm's full-screen interface come up at **53 columns**, with a simulated
|
||||
radio behind it, drawing braille charts and node glyphs cleanly — no empty tofu boxes. Along
|
||||
the bottom you should see the **F-key lane**: five labelled chips instead of the footer hint
|
||||
line a desktop terminal shows.
|
||||
|
||||
Have a look around. **Esc** backs out of any screen, and **Quit** on the main menu leaves
|
||||
the app.
|
||||
|
||||
Then look at the whole visual language on one card:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as meshterm
|
||||
meshterm specimen
|
||||
```
|
||||
|
||||
There are two console fonts available — a 6×12 one (53×26 rows) and a 6×8 one (53×40 rows,
|
||||
more rows but no Cyrillic glyphs). Try both from *Preferences → Display → Console font* —
|
||||
picking one previews it immediately, and the screen repaints at the new row count so you can
|
||||
see the choice before you keep it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Add the radio
|
||||
|
||||
This phase can wait. Everything above already gives you a working MeshTerm in `--mock`
|
||||
mode. Come back to this when you have the soldering iron out.
|
||||
|
||||
### Step 11 — Install PlatformIO
|
||||
|
||||
On your PC:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
pip install platformio
|
||||
```
|
||||
|
||||
(or use the
|
||||
[official installer script](https://docs.platformio.org/en/latest/core/installation/methods/installer-script.html)
|
||||
if you'd rather not touch your system Python). Confirm `pio` is on your PATH:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
pio --version
|
||||
```
|
||||
|
||||
The first firmware build downloads the nRF52 toolchain — several hundred megabytes — so do
|
||||
this somewhere with a decent connection.
|
||||
|
||||
### Step 12 — Build the firmware
|
||||
|
||||
```bash
|
||||
# on the PC (Git Bash or WSL on Windows)
|
||||
cd scripts/picocalc/xiao-radio
|
||||
sh build-firmware.sh
|
||||
```
|
||||
|
||||
You should see it clone MeshCore, apply a patch, build, and finish with:
|
||||
|
||||
```
|
||||
DONE. Firmware: /path/to/scripts/picocalc/xiao-radio/meshcore-xiao-radio.uf2
|
||||
Next: put the XIAO in bootloader (double-tap reset) and run: python flash.py
|
||||
```
|
||||
|
||||
Here's what it did, so you could do it by hand if you needed to:
|
||||
|
||||
1. Cloned [MeshCore](https://github.com/meshcore-dev/MeshCore) into
|
||||
`scripts/picocalc/xiao-radio/_meshcore-build`.
|
||||
2. Checked out the pinned commit `e9edfc8e` on the `dev` branch — the commit this patch is
|
||||
known to apply cleanly to and build against.
|
||||
3. Applied `meshcore-uart1.patch`.
|
||||
4. Built the `Xiao_nrf52_companion_radio_serial` PlatformIO environment:
|
||||
`pio run -e Xiao_nrf52_companion_radio_serial`.
|
||||
5. Converted the resulting `.hex` to a `.uf2` with MeshCore's own converter, using the
|
||||
nRF52840 UF2 family id:
|
||||
`python bin/uf2conv/uf2conv.py firmware.hex -c -f 0xADA52840 -o meshcore-xiao-radio.uf2`.
|
||||
|
||||
#### What the patch changes, and why it is still needed
|
||||
|
||||
`meshcore-uart1.patch` touches two files, and both hunks are required:
|
||||
|
||||
- **`examples/companion_radio/main.cpp`.** MeshCore's companion-over-serial code declares
|
||||
`HardwareSerial companion_serial(1)` — a numbered constructor. On the Adafruit nRF52 core,
|
||||
`HardwareSerial` is an abstract base class with no numbered constructor at all, so this
|
||||
line simply doesn't compile for the XIAO nRF52840. The patch adds an `#if
|
||||
defined(NRF52_PLATFORM)` branch that binds `companion_serial` to `Serial1` instead — the
|
||||
concrete UART object the nRF52 core always provides. `Uart::setPins(rx, tx)`, called later
|
||||
in the same file, takes the same arguments either way, so nothing else needs to change.
|
||||
- **`variants/xiao_nrf52/platformio.ini`.** This adds a new
|
||||
`Xiao_nrf52_companion_radio_serial` build environment that puts the companion serial link
|
||||
on pins **D6 (TX)** and **D7 (RX)**. It also **moves I²C off those same two pads**, onto
|
||||
two internal pins, 16 and 17, that nothing in this build uses. This second change is the one that matters: the
|
||||
stock `Xiao_nrf52` environment maps I²C onto D6/D7 by default, and both the board's
|
||||
`begin()` and the sensor code start the I²C bus on them automatically. Without the move,
|
||||
I²C seizes the UART pins at boot — the wiring is correct, the firmware builds and runs,
|
||||
and the serial link is still completely silent, because something else already owns the
|
||||
pins.
|
||||
|
||||
Both changes have been submitted upstream to MeshCore, but as of this writing they are
|
||||
**not merged**, and there's no sign that will happen soon — upstream `dev` still declares
|
||||
the bare `HardwareSerial(1)` with no nRF52 branch, and the XIAO's `platformio.ini` still has
|
||||
no `_serial` companion environment. Building with the patch is the normal route here, not a
|
||||
stopgap; when the patch does land, the `git apply` step (and the patch file itself) can be
|
||||
deleted.
|
||||
|
||||
#### Building by hand
|
||||
|
||||
If you want to track newer MeshCore instead of the pinned commit:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
git clone https://github.com/meshcore-dev/MeshCore.git
|
||||
cd MeshCore
|
||||
git checkout dev # or MESHCORE_COMMIT=dev sh build-firmware.sh
|
||||
git apply /path/to/scripts/picocalc/xiao-radio/meshcore-uart1.patch
|
||||
pio run -e Xiao_nrf52_companion_radio_serial
|
||||
python bin/uf2conv/uf2conv.py .pio/build/Xiao_nrf52_companion_radio_serial/firmware.hex \
|
||||
-c -f 0xADA52840 -o meshcore-xiao-radio.uf2
|
||||
```
|
||||
|
||||
If `git apply` fails — upstream has moved and the patch context no longer lines up — the two
|
||||
hunks are small enough to make by hand:
|
||||
|
||||
1. In `examples/companion_radio/main.cpp`, find the line
|
||||
`HardwareSerial companion_serial(1);` and replace it with:
|
||||
|
||||
```cpp
|
||||
#if defined(NRF52_PLATFORM)
|
||||
Uart& companion_serial = Serial1;
|
||||
#else
|
||||
HardwareSerial companion_serial(1);
|
||||
#endif
|
||||
```
|
||||
|
||||
2. At the end of `variants/xiao_nrf52/platformio.ini`, add a new environment:
|
||||
|
||||
```ini
|
||||
[env:Xiao_nrf52_companion_radio_serial]
|
||||
extends = Xiao_nrf52
|
||||
board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld
|
||||
board_upload.maximum_size = 708608
|
||||
build_flags =
|
||||
${Xiao_nrf52.build_flags}
|
||||
-I examples/companion_radio/ui-orig
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D QSPIFLASH=1
|
||||
-D SERIAL_TX=D6
|
||||
-D SERIAL_RX=D7
|
||||
-D PIN_WIRE_SCL=16
|
||||
-D PIN_WIRE_SDA=17
|
||||
build_unflags =
|
||||
-D PIN_WIRE_SCL=D6
|
||||
-D PIN_WIRE_SDA=D7
|
||||
build_src_filter = ${Xiao_nrf52.build_src_filter}
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-orig/*.cpp>
|
||||
lib_deps =
|
||||
${Xiao_nrf52.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
```
|
||||
|
||||
Only the pinned commit (`e9edfc8e`) is known to build cleanly with this patch — a newer
|
||||
`dev` may need small further adjustments.
|
||||
|
||||
### Step 13 — Flash the XIAO
|
||||
|
||||
Plug the **XIAO's own USB-C port** into your PC — it doesn't matter yet whether it's also
|
||||
wired to the Lyra. Then:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
python flash.py
|
||||
```
|
||||
|
||||
It first tries a 1200-baud "touch" over USB serial to reset the XIAO into its bootloader —
|
||||
this only ever targets Seeed's own USB vendor id, so it won't disturb another nRF52840 board
|
||||
on the same bench. If that doesn't take, **double-tap the XIAO's reset button** and run it
|
||||
again.
|
||||
|
||||
Once it finds the bootloader, it checks two things before writing anything:
|
||||
|
||||
- **`INFO_UF2.TXT`'s `Model` field** must say a XIAO — a drive letter isn't an identity, and
|
||||
another board could have inherited the letter your XIAO just gave up.
|
||||
- **`SoftDevice` must not be `6.1.1`.** This firmware links for SoftDevice S140 v7 (app at
|
||||
`0x27000`); a S140 6.1.1 bootloader wants the app at `0x26000` and would flash successfully
|
||||
and then simply never boot.
|
||||
|
||||
`--force` overrides both checks, if you're certain. A clean flash ends with:
|
||||
|
||||
```
|
||||
DONE. XIAO flashed and rebooting into the radio firmware.
|
||||
```
|
||||
|
||||
**If no UF2 drive ever appears**, that's not necessarily wrong — some XIAO bootloaders
|
||||
expose only a serial port, no mass-storage drive at all. `flash.py` recognises this case and
|
||||
prints the fallback command instead of failing silently:
|
||||
|
||||
```bash
|
||||
# on the PC, from your MeshCore checkout
|
||||
pio run -e Xiao_nrf52_companion_radio_serial -t upload --upload-port <bootloader port>
|
||||
```
|
||||
|
||||
That command ends in `Device programmed.` and the XIAO reboots itself into the radio
|
||||
firmware.
|
||||
|
||||
Afterwards, the XIAO's USB serial port goes **silent** to companion frames — the companion
|
||||
protocol now lives on D6/D7, not on USB. That silence is correct; it doesn't mean the flash
|
||||
failed.
|
||||
|
||||
### Step 14 — Wire the radio
|
||||
|
||||
Four wires, TX and RX crossed:
|
||||
|
||||
| XIAO pad | → | Lyra header | Physical pin | Carries |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **D7** (Serial1 RX) | → | **GP4** | pin **6** | Lyra UART1 **TX** → XIAO RX |
|
||||
| **D6** (Serial1 TX) | → | **GP5** | pin **7** | XIAO **TX** → Lyra UART1 RX |
|
||||
| **GND** | → | **GND** | pin **8** | common ground |
|
||||
| **3V3** | → | **3V3 OUT** | pin **36** | power, so it runs without USB |
|
||||
|
||||
> **The Lyra does not use Raspberry Pi Pico GPIO numbering.** The 40-pin header is
|
||||
> physically Pico-shaped, but each pad's function is Luckfox's own RK3506 mapping: GP4 is
|
||||
> `gpio0-0` and GP5 is `gpio0-1`. Don't reach for a Pico pinout for anything else on this
|
||||
> header. Every signal here is 3.3 V, and there is **no 5 V rail** on this board — power the
|
||||
> XIAO from 3V3 only.
|
||||
|
||||
With the PicoCalc powered off and the back of the shell removed, solder the four wires
|
||||
between the XIAO's pads and the Lyra's header pins as above. The Lyra can stay seated in
|
||||
its socket: the tops of its header pins are reachable from inside the case, so solder to
|
||||
those. Keep the iron on each pin only as long as it takes, and don't press down on the
|
||||
board. Attach the LoRa antenna to the Wio-SX1262's u.FL connector before you
|
||||
power anything back on — running the radio without an antenna attached can damage it.
|
||||
Wrap the XIAO + Wio-SX1262 stack in Kapton tape so no pad can touch the Lyra, the
|
||||
mainboard or a battery, then tuck it into the shell wherever it fits without straining the
|
||||
wires; a strip of foam tape holds it in place against the inside of the case.
|
||||
|
||||
### Step 15 — Set up the Lyra
|
||||
|
||||
With the wiring done and the shell closed back up, copy `scripts/` to the device if you
|
||||
haven't already (Step 8), then:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
sh scripts/picocalc/xiao-radio/lyra-setup.sh # MT_USER=meshterm by default
|
||||
```
|
||||
|
||||
It does three idempotent things:
|
||||
|
||||
1. Installs `uart1-mux.py` as `/usr/local/bin/uart1-radio-mux.py`, run by a oneshot
|
||||
`uart1-radio-mux.service` on every boot. This is the part that actually makes
|
||||
`/dev/ttyS1` reach the header pins — Calculinux enables the UART1 controller but never
|
||||
wires it to a physical pad on its own, and the RK3506's flexible pin matrix needs a
|
||||
direct register poke to route it onto GP4/GP5 instead.
|
||||
2. Adds `$MT_USER` to the `dialout` group, so it can open the serial port. **This needs a
|
||||
fresh login before it takes effect.**
|
||||
3. Writes a MeshTerm profile, *only if none exists yet*, at
|
||||
`/home/$MT_USER/.meshterm/config.toml`:
|
||||
|
||||
```toml
|
||||
default_profile = "picocalc"
|
||||
|
||||
[profiles.picocalc]
|
||||
port = "/dev/ttyS1"
|
||||
baudrate = 115200
|
||||
transport = "serial"
|
||||
default_tx_power = 22
|
||||
description = "XIAO nRF52840 + SX1262 via Lyra UART1 on GP4/GP5"
|
||||
```
|
||||
|
||||
If a `config.toml` already exists, it prints those lines instead of touching your file —
|
||||
paste them in yourself.
|
||||
|
||||
### Step 16 — Run it
|
||||
|
||||
Log out and back in as `meshterm` (so the `dialout` group membership takes effect), then:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as meshterm
|
||||
meshterm
|
||||
```
|
||||
|
||||
Because `default_profile` is set, a bare `meshterm` connects straight to the radio. You
|
||||
should see your node come up under whatever name the firmware advertises — both the device
|
||||
page and the dashboard header show it.
|
||||
|
||||
If you want to prove the wiring itself before suspecting anything else, there's a deeper
|
||||
check: open `/dev/ttyS1` at 115200 as root and send a raw `APP_START` frame (`3c 0e 00 01`,
|
||||
seven zero bytes, then a name) — a live radio answers with a framed `05`, `SELF_INFO`. The
|
||||
command byte and the reply code are the companion protocol's; the `<`/`>` and
|
||||
little-endian-length framing around them come from the firmware and are worth confirming
|
||||
against it. It's a lower-level check than running MeshTerm and mostly useful for isolating
|
||||
a wiring problem.
|
||||
|
||||
---
|
||||
|
||||
## Day-to-day
|
||||
|
||||
**Charging.** Only through the mainboard's USB-C port on the side of the case. ⚠ Never
|
||||
through the Lyra's own USB-C at the back — see
|
||||
[Three ways to break it](#three-ways-to-break-it).
|
||||
|
||||
**Powering off.** Shut down cleanly rather than pulling the batteries:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc
|
||||
sudo poweroff
|
||||
```
|
||||
|
||||
**Updating MeshTerm.** Either re-run the setup script from root (it pulls the repository,
|
||||
reinstalls, and rebuilds the console font):
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
sh calculinux-setup.sh
|
||||
```
|
||||
|
||||
or, as the `meshterm` user, do just the update:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as meshterm
|
||||
cd ~/MeshTerm && git pull && .venv/bin/pip install -e .
|
||||
```
|
||||
|
||||
If an update adds new glyphs to the console font, rebuild it on its own afterwards:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
sh calculinux-console-font-6x12.sh
|
||||
```
|
||||
|
||||
**Changing Wi-Fi.** Run `uwific` as root, as in [Step 7](#step-7--join-wi-fi). The boot-time
|
||||
kick picks up the new network on its own; it only needs `iwd` to know it.
|
||||
|
||||
**The six-minute boot without a dongle.** If the device is going to run permanently offline,
|
||||
the Wi-Fi kick is wasted time on every boot. Disable it:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
systemctl disable --now wifi-kick.service
|
||||
```
|
||||
|
||||
**The console font restores itself.** MeshTerm saves whatever font the console was using
|
||||
before it starts, and puts it back when it exits — on a normal quit and on a crash alike.
|
||||
You don't need to do anything to keep the shell's own font intact.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| Nothing on the PicoCalc's screen at all | Is the SD card in the **Lyra's own slot**, not the PicoCalc's front slot? Is a "Lyra B" (SPI NAND) erased? Is it the **128 MB RAM, Pico-form-factor** variant — other RAM sizes have incompatible pinouts? |
|
||||
| Wi-Fi never comes up | Is the dongle one of the tested chipsets (RTL8192CU / R8712U / RTL8188EU)? Remember the Lyra's USB port is **3.3 V** — a 5 V dongle won't work. Give it the full six minutes on a cold boot before assuming it's stuck. |
|
||||
| `pip install` fails with `ENOSPC` | `/tmp` is a small RAM disk. `calculinux-setup.sh` already sets `TMPDIR=$HOME/tmp` for its own install; if you're running `pip` by hand, do the same. |
|
||||
| `meshterm: command not found` after setup | Log out and back in — the PATH line is added to `~/.profile`, which only takes effect on a fresh login shell. |
|
||||
| Tofu boxes instead of braille charts or node glyphs | The console font script hasn't run, or didn't persist. Re-run `sh calculinux-console-font-6x12.sh`, and check `/etc/vconsole.conf` has a `FONT=` line. |
|
||||
| `meshterm` can't open `/dev/ttyS1` | Is the `meshterm` user in `dialout` (`groups`)? Group changes need a fresh login to take effect. |
|
||||
| Port opens but the radio never answers | Re-flash with the **`_serial`** environment, not `_ble` or `_usb` — only it defines `SERIAL_RX`/`SERIAL_TX`. The XIAO's USB serial should be silent once it's running the radio firmware; that's expected, not a fault. |
|
||||
| Still silent with wiring confirmed | Confirm the firmware actually carries the I²C remap (`PIN_WIRE_SCL=16`, `PIN_WIRE_SDA=17`) — a build without the patch's second hunk looks identical until you check this. |
|
||||
| Flashed fine, XIAO never comes back | Wrong board, or the wrong SoftDevice — `INFO_UF2.TXT` must report a XIAO and **S140 v7**, not `6.1.1`. `flash.py` refuses to flash either mismatch without `--force`. |
|
||||
| `flash.py` finds no UF2 drive | Expected on a CDC-only bootloader — use the `pio … -t upload --upload-port` command it prints instead. |
|
||||
| Nothing on `/dev/ttyS1` after a reboot | `systemctl status uart1-radio-mux`, then `journalctl -b -u uart1-radio-mux` — the pin-mux poke has to run every boot, and a failed oneshot says why. |
|
||||
|
||||
---
|
||||
|
||||
## What has been tested
|
||||
|
||||
This whole path — from a stock PicoCalc kit through a running radio — has been exercised on
|
||||
one maintainer's bench: one Luckfox Lyra, one PicoCalc, one XIAO + Wio-SX1262 pair. It
|
||||
works. Some of it is also checked in code: a test parses the font script and pins it to
|
||||
MeshTerm's glyph inventory, the profile keys the scripts write are tested, the pinned
|
||||
MeshCore commit exists, the patch's two hunks are still needed on `dev`, and the nRF52840
|
||||
UF2 family id and both XIAO bootloader ids match Adafruit's board files.
|
||||
|
||||
The rest is one bench's findings, and a second device may disagree:
|
||||
|
||||
- **The RK3506 register map** in `uart1-mux.py` — the two register blocks, the UART1 signal
|
||||
ids, matrix mode, and that `gpio0-0`/`gpio0-1` are header GP4 and GP5 — was probed live
|
||||
on one board, not read from a reference manual.
|
||||
- **That the Calculinux kernel permits those `/dev/mem` writes**, and that the mux oneshot
|
||||
really runs at every boot.
|
||||
- **The header pin numbers** 6, 7, 8 and 36 for GP4, GP5, GND and 3V3. The Pico-side
|
||||
numbering is right; the carrier's wiring is a bench observation.
|
||||
- **The MX1.25 socket's pin order.** Taken from the wire colours of Luckfox's own cable,
|
||||
which is why [Step 3](#step-3--attach-the-wi-fi-dongle) has you check pin 1 with a meter.
|
||||
- **The 5 V path through the Lyra's USB-C.** The mechanism is read off the mainboard
|
||||
schematic and two forum measurements on the Pico; nobody has measured it with a Lyra.
|
||||
Treat the warning as the safe assumption it is.
|
||||
- **The Calculinux specifics the scripts assert**: the stripped Python and the package
|
||||
names that fix it, which overlays are writable, that `/tmp` is a small RAM tmpfs, the
|
||||
dongle's cold-boot race with `iwd`, the opkg feed's occasional 404, that the stock
|
||||
Terminus console font is installed, and that `kbd` brings `setvtrgb`.
|
||||
- **That this board has no RTC.** Only the script says so; the clock-sync phase follows.
|
||||
- **The XIAO bootloader that exposed no mass storage**, and the S140 v7 / `0x27000`
|
||||
requirement — public references agree, but neither is a primary source.
|
||||
- **Whether the firmware patch still applies at MeshCore `dev` HEAD.** Only the pinned
|
||||
commit is known to build.
|
||||
- **MeshCore's serial framing** around the raw `APP_START` check in
|
||||
[Step 16](#step-16--run-it): the command byte and reply code are from the protocol
|
||||
documentation; the framing bytes are not.
|
||||
- **The framebuffer console's 512-glyph cap.** Well known, taken as given, not re-measured.
|
||||
|
||||
If you build this and something disagrees with what's written here, that's worth
|
||||
reporting — a second board is the only way to learn which parts of this were about the
|
||||
hardware and which were about the one unit it was built on.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,395 @@
|
||||
# MeshTerm on the uConsole
|
||||
|
||||
A ClockworkPi uConsole with the hackergadgets AIO LoRa board has no companion
|
||||
microcontroller and no firmware to flash — the LoRa chip sits straight on the host's SPI
|
||||
bus. So a small bridge runs the mesh node in software on the uConsole itself and puts the
|
||||
standard MeshCore companion protocol in front of it on a local TCP port. MeshTerm then
|
||||
connects to that port like it would any network companion. By the end of this guide you'll
|
||||
have the bridge running as a background service and MeshTerm talking to it.
|
||||
|
||||
> ⚠ **Read this first.** This guide edits your uConsole's boot configuration, adds your
|
||||
> user to hardware groups, installs a service under your account, and then keys a LoRa
|
||||
> transmitter. It assumes you are comfortable at a Linux shell and know which frequency
|
||||
> plan and power limits apply where you live — what the radio does on air is your
|
||||
> responsibility, not the bridge's. There is a real risk of leaving the system, the radio
|
||||
> or the mesh around you in a worse state if a step goes wrong. You do this at your own
|
||||
> risk.
|
||||
>
|
||||
> **Read the whole manual through once before you start.** Which route you take in
|
||||
> Step 2 depends on your Debian release, and whether the GUI is running changes what the
|
||||
> preflight and the service will do — both are easier to get right knowing the later steps.
|
||||
>
|
||||
> This is a guide, not a prescription. Despite the author's best efforts it may contain
|
||||
> errors, and parts of it will go out of date as packages, runtimes and boards change. It
|
||||
> is up to you to check every fact in it against your own hardware and the current sources
|
||||
> before you act on it, and every decision along the way is yours. The author accepts no
|
||||
> responsibility for any damage, loss or injury that results from following this guide.
|
||||
|
||||
- [What you need](#what-you-need)
|
||||
- [Step 1 — Prepare the system](#step-1--prepare-the-system)
|
||||
- [Step 2 — Install the radio runtime](#step-2--install-the-radio-runtime)
|
||||
- [Step 3 — Get the bridge](#step-3--get-the-bridge)
|
||||
- [Step 4 — Run the preflight](#step-4--run-the-preflight)
|
||||
- [Step 5 — Run it once in the foreground](#step-5--run-it-once-in-the-foreground)
|
||||
- [Step 6 — Install it as a service](#step-6--install-it-as-a-service)
|
||||
- [Step 7 — Install MeshTerm and connect](#step-7--install-meshterm-and-connect)
|
||||
- [Updating and uninstalling](#updating-and-uninstalling)
|
||||
- [Hardware knobs](#hardware-knobs)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [What this has been tested on](#what-this-has-been-tested-on)
|
||||
|
||||
---
|
||||
|
||||
## What you need
|
||||
|
||||
**Hardware:**
|
||||
|
||||
| Part | Notes |
|
||||
| --- | --- |
|
||||
| ClockworkPi **uConsole** (CM4 module) | Any uConsole with a Compute Module 4. |
|
||||
| hackergadgets **AIO** expansion board | The board this guide is written for and the one we recommend: an SX1262 on SPI bus 1, plus GPS, an RTL-SDR and a USB hub you don't need here. The bridge's defaults are its wiring. |
|
||||
| An antenna for your region's LoRa band | Required. |
|
||||
|
||||
Installing the AIO board into the uConsole is out of scope here — follow
|
||||
[hackergadgets' own setup guide](https://hackergadgets.com/pages/hackergadgets-uconsole-rtl-sdr-lora-gps-rtc-usb-hub-all-in-one-extension-board-setup-guide)
|
||||
for that part.
|
||||
|
||||
The bridge assumes the AIO's wiring by default:
|
||||
|
||||
| Setting | Default |
|
||||
| --- | --- |
|
||||
| SPI bus | 1 |
|
||||
| Reset pin | 25 |
|
||||
| Busy pin | 24 |
|
||||
| IRQ pin | 26 |
|
||||
| TX-enable pin | none |
|
||||
| DIO2 (RF switch) | on |
|
||||
| DIO3 (TCXO) | on |
|
||||
| Frequency | 910.525 MHz |
|
||||
| TX power | 22 dBm |
|
||||
| Spreading factor | 7 |
|
||||
| Bandwidth | 62.5 kHz |
|
||||
| Coding rate | 4/5 |
|
||||
|
||||
Every one of these is overridable with a `MESHCORE_*` environment variable if your board or
|
||||
region differs — see [Hardware knobs](#hardware-knobs).
|
||||
|
||||
**Software:**
|
||||
|
||||
- Raspberry Pi OS / Debian **bookworm** or **trixie** on the uConsole, 64-bit.
|
||||
- Python 3 (the system one is fine — the bridge finds whichever interpreter has the radio
|
||||
library, and re-execs itself under it).
|
||||
- A `/dev/spidev*` node. This needs `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt`.
|
||||
- `/dev/gpiochip0`, and membership of the `spi` and `gpio` groups so you can read and write
|
||||
both without root.
|
||||
- Optionally, the [`meshcore-uconsole`](https://github.com/cwill747/meshcore-uconsole)
|
||||
package (it brings the `meshcore-console` GUI and a radio runtime). Whether you can use it depends on your Debian release — see
|
||||
[Step 2](#step-2--install-the-radio-runtime).
|
||||
|
||||
The radio library that actually drives the chip was renamed in 2026: `pymc_core` became
|
||||
`openhop_core`. PyPI's `pymc-core` stopped at 1.0.12; `openhop-core` carries on from 1.1.x.
|
||||
The bridge supports both and prefers the newer one when it finds both installed.
|
||||
|
||||
**MeshTerm itself** — either:
|
||||
|
||||
- the Linux ARM64 one-file release from
|
||||
[the latest release page](https://github.com/jpmartineau/MeshTerm/releases/latest), or
|
||||
- an install from the repository with `pipx` (Python 3.10 or newer). Plain `pip install`
|
||||
into the system Python is refused on bookworm and trixie; `pipx` (`sudo apt install pipx`)
|
||||
gives MeshTerm a virtual environment of its own.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Prepare the system
|
||||
|
||||
Enable the SPI overlay. Edit `/boot/firmware/config.txt` and add:
|
||||
|
||||
```
|
||||
dtoverlay=spi1-1cs
|
||||
```
|
||||
|
||||
Add yourself to the groups that own the SPI and GPIO device nodes:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG spi,gpio $USER
|
||||
```
|
||||
|
||||
Reboot so both the overlay and the group membership take effect:
|
||||
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
After it comes back, check the device nodes exist and that you can use them:
|
||||
|
||||
```bash
|
||||
ls -l /dev/spidev* /dev/gpiochip0
|
||||
groups
|
||||
```
|
||||
|
||||
You should see `/dev/spidev1.0` (or another `spidev*` node) and `/dev/gpiochip0` listed,
|
||||
and `spi` and `gpio` in your `groups` output.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Install the radio runtime
|
||||
|
||||
Which route you take depends on your Debian release:
|
||||
|
||||
- **trixie**, with `meshcore-uconsole` installed from the vendor's package: you already
|
||||
have a working runtime (Python 3.13-based). Skip to [Step 3](#step-3--get-the-bridge).
|
||||
- **bookworm**: the `meshcore-uconsole` 1.12.0+ package is built for Python 3.13 and will
|
||||
**not run** — its virtualenv points at an interpreter bookworm doesn't have. Don't take
|
||||
the upgrade if apt offers it. Instead give the bridge a runtime of its own:
|
||||
|
||||
```bash
|
||||
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
|
||||
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
|
||||
```
|
||||
|
||||
This is also the route to take if you'd rather not install the `meshcore-uconsole` package
|
||||
at all.
|
||||
|
||||
You'll confirm which runtime the bridge actually found in [Step 4](#step-4--run-the-preflight) —
|
||||
its first check line names the interpreter and the runtime.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Get the bridge
|
||||
|
||||
`scripts/uconsole/meshterm-spi-bridge` is a single, self-contained Python file — no install step of
|
||||
its own. Either clone the MeshTerm repo:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jpmartineau/MeshTerm
|
||||
cd MeshTerm/scripts/uconsole
|
||||
```
|
||||
|
||||
or download just the one file:
|
||||
|
||||
```bash
|
||||
curl -L -o meshterm-spi-bridge \
|
||||
https://raw.githubusercontent.com/jpmartineau/MeshTerm/main/scripts/uconsole/meshterm-spi-bridge
|
||||
```
|
||||
|
||||
Make it executable:
|
||||
|
||||
```bash
|
||||
chmod +x meshterm-spi-bridge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Run the preflight
|
||||
|
||||
```bash
|
||||
./meshterm-spi-bridge --check
|
||||
```
|
||||
|
||||
This prints a `Prerequisite check:` block with one line per check. What each line means and
|
||||
what to do if it fails:
|
||||
|
||||
| Check | Meaning if it fails | Fix |
|
||||
| --- | --- | --- |
|
||||
| `node runtime` | Neither `openhop_core` nor `pymc_core` is importable by any interpreter the bridge tried. | Build the venv from [Step 2](#step-2--install-the-radio-runtime), or set `MESHTERM_PYMC_PYTHON` to a python that has one. |
|
||||
| `SPI device` | No `/dev/spidev*` node exists, or it exists but you can't read/write it. | No node at all: add `dtoverlay=spi1-1cs` to `/boot/firmware/config.txt` and reboot. Node present but no permission: `sudo usermod -aG spi $USER`, then log out and back in. |
|
||||
| `GPIO chip` | `/dev/gpiochip0` is missing or not read/write for you. | `sudo usermod -aG gpio $USER`, then log out and back in. |
|
||||
| `bridge service` / `radio in use` / `port 5000 busy` / `radio is free` | Tells you whether something already holds the radio: the bridge's own boot service, a running `meshcore-console`, or something else already listening on the TCP port. | Only one program may use the radio at a time — see [Step 7](#step-7--install-meshterm-and-connect). If `meshcore-console` is running, close it before running the bridge. |
|
||||
|
||||
If the first three checks all pass, you're ready to run it.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Run it once in the foreground
|
||||
|
||||
```bash
|
||||
./meshterm-spi-bridge --run
|
||||
```
|
||||
|
||||
This runs the preflight again, prints it, then starts the bridge in this terminal. A
|
||||
healthy startup logs something like:
|
||||
|
||||
```
|
||||
node runtime openhop_core 1.1.3 (/home/you/.local/share/meshterm-spi-bridge/venv/bin/python)
|
||||
radio config bus_id=1 reset_pin=25 busy_pin=24 irq_pin=26 frequency=910525000 tx_power=22
|
||||
radio.begin() attempt 1/4
|
||||
radio ready
|
||||
node identity ab12cd34ef567890… (hash 0xab, name 'uConsole')
|
||||
READY — connect with: meshterm --tcp 127.0.0.1:5000
|
||||
```
|
||||
|
||||
The `radio config` line lists every setting as `key=value`, so the one above is
|
||||
shortened; yours is longer.
|
||||
|
||||
Leave it running, and in another terminal on the same machine try:
|
||||
|
||||
```bash
|
||||
meshterm --tcp 127.0.0.1:5000
|
||||
```
|
||||
|
||||
Back in the bridge's terminal, press **Ctrl+C** to stop it.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Install it as a service
|
||||
|
||||
Run the bridge with no flags to get the interactive menu:
|
||||
|
||||
```bash
|
||||
./meshterm-spi-bridge
|
||||
```
|
||||
|
||||
Choose **2) Install as a service**. This:
|
||||
|
||||
- copies the script to `~/.local/bin/meshterm-spi-bridge`,
|
||||
- writes a `systemd --user` unit at
|
||||
`~/.config/systemd/user/meshterm-spi-bridge.service` that restarts on failure and starts
|
||||
with your session,
|
||||
- enables and starts it now,
|
||||
- tries to enable lingering for your user, so the service comes up at boot even before you
|
||||
log in. If it can't, it prints the command to run yourself:
|
||||
|
||||
```bash
|
||||
sudo loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
It also offers to add a `[profiles.bridge]` block to your MeshTerm `config.toml` — say yes,
|
||||
it's the profile you'll use in [Step 7](#step-7--install-meshterm-and-connect).
|
||||
|
||||
Check on it any time:
|
||||
|
||||
```bash
|
||||
systemctl --user status meshterm-spi-bridge.service
|
||||
journalctl --user -u meshterm-spi-bridge.service -f
|
||||
```
|
||||
|
||||
Menu option **4) Show status** prints the same information from inside the script.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Install MeshTerm and connect
|
||||
|
||||
Install MeshTerm on the uConsole. The simplest way is the Linux ARM64 one-file release:
|
||||
|
||||
```bash
|
||||
chmod +x meshterm-*-linux-arm64
|
||||
sudo mv meshterm-*-linux-arm64 /usr/local/bin/meshterm
|
||||
```
|
||||
|
||||
Or install it from the repository with `pipx`:
|
||||
|
||||
```bash
|
||||
sudo apt install pipx
|
||||
pipx install git+https://github.com/jpmartineau/MeshTerm
|
||||
pipx ensurepath
|
||||
```
|
||||
|
||||
If you accepted the profile in Step 6, your `config.toml` now has:
|
||||
|
||||
```toml
|
||||
[profiles.bridge]
|
||||
host = "127.0.0.1"
|
||||
tcp_port = 5000
|
||||
description = "Local SPI radio via meshterm-spi-bridge"
|
||||
```
|
||||
|
||||
Connect with:
|
||||
|
||||
```bash
|
||||
meshterm -p bridge
|
||||
```
|
||||
|
||||
or, without a profile:
|
||||
|
||||
```bash
|
||||
meshterm --tcp 127.0.0.1:5000
|
||||
```
|
||||
|
||||
You're connected when the device page or dashboard shows a node name (`uConsole` by
|
||||
default) instead of a connection error. MeshTerm reports the device model as
|
||||
`pyMC-spi-bridge` — that's how you tell a bridged node from a real companion.
|
||||
|
||||
**Identity.** If the `meshcore-console` GUI's package is present, the bridge loads *that*
|
||||
package's identity key, so MeshTerm and the GUI are the same node — same contacts, same
|
||||
public key. On a library-only install, the bridge mints and keeps its own identity key
|
||||
under `~/.local/share/meshterm-spi-bridge/identity.key`.
|
||||
|
||||
**Only one program may use the radio at a time.** Never run the bridge and the
|
||||
`meshcore-console` GUI at the same time — whichever started second will fail to open the
|
||||
radio.
|
||||
|
||||
---
|
||||
|
||||
## Updating and uninstalling
|
||||
|
||||
To update, pull or re-download `meshterm-spi-bridge` and reinstall the service (menu option
|
||||
2 again — it overwrites the copy in `~/.local/bin/`).
|
||||
|
||||
To remove the bridge, run the menu and choose **3) Uninstall the service**. This stops and
|
||||
removes the `systemd --user` unit and the installed script copy. It does **not** touch
|
||||
`meshcore-console`, the radio library, your node identity, or your saved contacts — those
|
||||
stay put. If you'd added `[profiles.bridge]` to your MeshTerm config, it tells you to remove
|
||||
that block by hand if you no longer want it.
|
||||
|
||||
---
|
||||
|
||||
## Hardware knobs
|
||||
|
||||
The defaults match the hackergadgets uConsole AIO, so a stock AIO needs none of these. They
|
||||
exist for a revision or a board that differs: export what differs before launching the
|
||||
bridge, or put the lines in the service's environment.
|
||||
|
||||
| Variable | What it sets |
|
||||
| --- | --- |
|
||||
| `MESHCORE_BUS_ID`, `MESHCORE_CS_ID`, `MESHCORE_CS_PIN` | which SPI bus, device and chip-select pin the radio is on |
|
||||
| `MESHCORE_RESET_PIN`, `MESHCORE_BUSY_PIN`, `MESHCORE_IRQ_PIN` | the three control lines |
|
||||
| `MESHCORE_FREQUENCY`, `MESHCORE_TX_POWER` | frequency in Hz, power in dBm |
|
||||
| `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining |
|
||||
| `MESHCORE_TXEN_PIN`, `MESHCORE_RXEN_PIN`, `MESHCORE_EN_PINS` | the RF-switch and power-enable lines a board may need (`-1` for none; `EN_PINS` is a comma list — the AIO v2 wants `27`) |
|
||||
| `MESHCORE_USE_DIO2_RF`, `MESHCORE_USE_DIO3_TCXO`, `MESHCORE_IS_WAVESHARE` | whether DIO2 drives the RF switch and DIO3 the TCXO (both on for the AIO), and a flag for a different vendor's wiring the runtime knows about |
|
||||
| `MESHCORE_GPIO_CHIP`, `MESHCORE_USE_GPIOD_BACKEND`, `MESHCORE_PREAMBLE_LENGTH` | which gpiochip, whether to drive it through `gpiod`, and the LoRa preamble |
|
||||
| `MESHTERM_PYMC_PYTHON` | force a specific interpreter instead of letting the bridge discover one |
|
||||
|
||||
The bridge passes a knob only when the runtime's radio constructor accepts it, so the same
|
||||
environment works under `pymc_core` 1.0.x, which lacks the newer ones. Where the
|
||||
`meshcore-console` package is installed, the bridge builds the radio through *its* hardware
|
||||
configuration, which reads the same names plus a few more of its own — see that package's
|
||||
documentation. On a library-only install the table above is the whole of it, so an AIO v2
|
||||
without that package present may not power its radio.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| `node runtime` fails in the preflight | Did you build the venv in [Step 2](#step-2--install-the-radio-runtime)? Is `MESHTERM_PYMC_PYTHON` pointing at the right interpreter, if set? |
|
||||
| `SPI device` shows "no /dev/spidev* found" | Is `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt`? Did you reboot after adding it? |
|
||||
| `SPI device` or `GPIO chip` shows a permission error | Are you in the `spi` and `gpio` groups (`groups`)? Did you log out and back in after `usermod`? |
|
||||
| Preflight warns "radio in use — meshcore-console is running" | Close the GUI before running or installing the bridge. |
|
||||
| Preflight warns "port 5000 busy" | Something else is already listening there — check `systemctl --user status meshterm-spi-bridge.service` for an already-running instance. |
|
||||
| Bridge starts but `radio.begin()` fails after 4 attempts | GPIO lines may still be held by a session that just closed — wait a few seconds and try again. |
|
||||
| MeshTerm connects but direct messages to an old contact say "not found" | Restart the bridge — contacts are restored from its snapshot file at startup; if the snapshot is missing, the contact needs to be heard again. |
|
||||
| Reboot, factory reset, or setting a device PIN silently does nothing | Expected — the radio library has no handler for these three, whatever runtime you're on. |
|
||||
| Traces or the live feed don't show up | These rely on compatibility shims the bridge installs (or, under `openhop_core`, on the library's own equivalents). Check the bridge's log for lines starting `compat:` to confirm they're wired. |
|
||||
|
||||
---
|
||||
|
||||
## What this has been tested on
|
||||
|
||||
This has been verified on a bookworm uConsole with `openhop-core` 1.1.3: the radio comes up,
|
||||
contacts are restored across a restart, and `info` and `contacts` answer correctly over the
|
||||
TCP connection. The older `pymc-core` 1.0.x path, with all three of the bridge's
|
||||
compatibility shims active, has been exercised in full, including traces and the live feed.
|
||||
Every interface the bridge calls in both runtimes is checked by MeshTerm's test suite.
|
||||
|
||||
What has not been confirmed:
|
||||
|
||||
- **The bridge under `openhop_core` beyond `info` and `contacts`.** Startup, identity,
|
||||
contact restore and those two reads were exercised; a trace, a message and the live feed
|
||||
under the new runtime were not, though the library's own trace push is what the bridge
|
||||
now relies on for the first.
|
||||
- **Everything on-air** — the per-hop trace semantics the bridge reassembles and the
|
||||
acknowledgement bytes newer firmware appends after the CRC. The code is internally
|
||||
consistent with the library's API; the wire behaviour was seen on one mesh, against the
|
||||
radios in range of one bench.
|
||||
@@ -6,7 +6,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
__version__ = "0.3.4"
|
||||
__version__ = "0.3.7"
|
||||
__author__ = "Jean-Pierre Martineau"
|
||||
|
||||
#: The year MeshTerm was first published; the copyright span starts here.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## What it is
|
||||
|
||||
**MeshTerm** is a full-featured **MeshCore** client for the terminal. It talks to companion devices running the stock firmware over serial, Bluetooth, or TCP.
|
||||
**MeshTerm** is a full-featured **MeshCore** client that lives in your terminal. Plug in a companion device over USB, pair one over Bluetooth, or reach one over TCP. Windows, macOS, and Linux. Free and open-source.
|
||||
|
||||
Nearly everything MeshTerm can do thru its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The live pictures stay in the TUI, where they belong — the map, the dashboard, the live feed, the watchtower, the mesh walk — and everything else has a command. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks! Every command prints for a person by default, and speaks JSON with `--json` when a program is reading!
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ https://buymeacoffee.com/johnputer
|
||||
|
||||
### Donate via Paypal
|
||||
|
||||
- *https://tinyurl.com/SupportMeshTerm*
|
||||
- *https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6*
|
||||
|
||||
```qr
|
||||
https://tinyurl.com/SupportMeshTerm
|
||||
https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6
|
||||
```
|
||||
|
||||
### Become a patron
|
||||
|
||||
+35
-6
@@ -38,6 +38,7 @@ if TYPE_CHECKING:
|
||||
from .services.basemap import BasemapSource
|
||||
from .services.battery_service import BatteryService
|
||||
from .services.chat_service import ChatService
|
||||
from .services.clock_sync import ClockSync
|
||||
from .services.courier import CourierService
|
||||
from .services.device_state import DeviceState
|
||||
from .services.event_hub import EventHub
|
||||
@@ -155,6 +156,7 @@ class AppContext:
|
||||
_chat: ChatService | None = field(default=None, init=False, repr=False)
|
||||
_adverts: AdvertScheduler | None = field(default=None, init=False, repr=False)
|
||||
_watchtower: WatchtowerService | None = field(default=None, init=False, repr=False)
|
||||
_clock_sync: ClockSync | None = field(default=None, init=False, repr=False)
|
||||
_courier: CourierService | None = field(default=None, init=False, repr=False)
|
||||
_battery: BatteryService | None = field(default=None, init=False, repr=False)
|
||||
_devstate: DeviceState | None = field(default=None, init=False, repr=False)
|
||||
@@ -358,6 +360,21 @@ class AppContext:
|
||||
self._watchtower = WatchtowerService(self)
|
||||
return self._watchtower
|
||||
|
||||
@property
|
||||
def clock_sync(self) -> ClockSync:
|
||||
"""Return the session's on-connect clock setter, creating it on first use.
|
||||
|
||||
Created idle here; the interactive session starts it alongside the other
|
||||
always-on services, and :meth:`device` tells it about each connection as it
|
||||
settles. Scripted CLI runs never start it, so a one-shot command that only reads
|
||||
the radio never writes its clock.
|
||||
"""
|
||||
if self._clock_sync is None:
|
||||
from .services.clock_sync import ClockSync
|
||||
|
||||
self._clock_sync = ClockSync(self)
|
||||
return self._clock_sync
|
||||
|
||||
@property
|
||||
def courier(self) -> CourierService:
|
||||
"""Return the session's store-and-forward courier, creating it on first use.
|
||||
@@ -515,8 +532,7 @@ class AppContext:
|
||||
self._active_port = None
|
||||
self._active_address = None
|
||||
await self._open(f"TCP companion {tcp_host}:{tcp_port}")
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
await self._settle_connection()
|
||||
return self._device
|
||||
|
||||
# A Bluetooth endpoint (an explicit ``--ble``, a BLE profile, a device picked at
|
||||
@@ -549,8 +565,7 @@ class AppContext:
|
||||
self._active_port = None
|
||||
self._active_endpoint = None
|
||||
await self._open(f"BLE companion {ble_address}")
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
await self._settle_connection()
|
||||
return self._device
|
||||
|
||||
resolution = resolve_device(
|
||||
@@ -568,8 +583,7 @@ class AppContext:
|
||||
self._active_address = None
|
||||
self._active_endpoint = None
|
||||
await self._open(f"serial port {resolution.port}")
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
await self._settle_connection()
|
||||
return self._device
|
||||
|
||||
def _resolve_tcp_endpoint(self) -> tuple[str | None, int | None]:
|
||||
@@ -656,6 +670,19 @@ class AppContext:
|
||||
hardware_model=await self._hardware_model(),
|
||||
)
|
||||
|
||||
async def _settle_connection(self) -> None:
|
||||
"""The steps every fresh connection gets once the link is open, in order.
|
||||
|
||||
Remember the device as the default, replay its remembered channels, and hand it to
|
||||
the on-connect clock setter — which returns at once and does its work in the
|
||||
background, so the connection is usable the moment this returns. Each step is
|
||||
best-effort on its own; none can fail the connection.
|
||||
"""
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
if self._device is not None:
|
||||
self.clock_sync.on_connected(self._device)
|
||||
|
||||
async def _reconcile_channels(self) -> None:
|
||||
"""Replay channels remembered for this device that it isn't reporting (best-effort).
|
||||
|
||||
@@ -792,6 +819,8 @@ class AppContext:
|
||||
await self._battery.aclose()
|
||||
if self._watchtower is not None:
|
||||
await self._watchtower.aclose()
|
||||
if self._clock_sync is not None:
|
||||
await self._clock_sync.aclose()
|
||||
if self._adverts is not None:
|
||||
await self._adverts.aclose()
|
||||
if self._monitor is not None:
|
||||
|
||||
+181
-12
@@ -145,6 +145,16 @@ _BLE_CONNECT_ATTEMPTS = 2
|
||||
#: Pause between BLE link-open attempts (seconds), giving the OS radio a beat to settle.
|
||||
_BLE_CONNECT_RETRY_DELAY_S = 1.0
|
||||
|
||||
#: Attempts, and the wait between them, while macOS finishes a pairing it has just begun.
|
||||
#: An unbonded subscribe to the companion's authenticated characteristic is what *triggers*
|
||||
#: Passkey Entry there, so the very failure we catch is the signal that the OS has raised
|
||||
#: its dialog — and the person now has to read a 6-digit code off the device and type it.
|
||||
#: Giving up at once meant a correct PIN produced an error and only the *second* attempt
|
||||
#: worked, the first having quietly done the bonding. Sized to cover a human typing a code
|
||||
#: rather than a radio settling, and bounded so a cancelled dialog still fails in the end.
|
||||
_BLE_MACOS_PAIRING_ATTEMPTS = 5
|
||||
_BLE_MACOS_PAIRING_DELAY_S = 6.0
|
||||
|
||||
TX_POWER_MIN = 1
|
||||
TX_POWER_MAX = 22
|
||||
|
||||
@@ -1224,8 +1234,55 @@ class MeshCoreDevice(Device):
|
||||
# Clear it, pair with the PIN, and retry the connect exactly once before giving up.
|
||||
if allow_repair and await self._pair_ble_windows(force=True):
|
||||
return await self._open_ble(mesh_core, allow_repair=False)
|
||||
# On macOS the same rejection means the opposite thing: it is not the end of a
|
||||
# pairing attempt but the *start* of one, because touching the authenticated
|
||||
# characteristic is the only way to make CoreBluetooth pair at all. The OS is
|
||||
# putting its Passkey dialog up as we unwind. Wait for the person to answer it.
|
||||
if allow_repair and sys.platform == "darwin":
|
||||
return await self._open_ble_after_macos_pairing(mesh_core, exc)
|
||||
raise DeviceAuthenticationError(self._ble_auth_message()) from exc
|
||||
|
||||
async def _open_ble_after_macos_pairing(self, mesh_core, cause: BaseException): # type: ignore[no-untyped-def]
|
||||
"""Re-open the link while macOS is running the Passkey dialog it just raised.
|
||||
|
||||
CoreBluetooth exposes no pairing API — Apple's model is that a peripheral pairs
|
||||
*implicitly* when something touches a characteristic that requires encryption. The
|
||||
companion firmware puts its UART characteristic at ENC+MITM precisely so that
|
||||
happens (``SECMODE_ENC_WITH_MITM`` on nRF52, ``ESP_GATT_PERM_*_ENC_MITM`` on
|
||||
ESP32), so the subscribe fails with "Insufficient Authentication" *and* that
|
||||
failure is what makes macOS ask for the code. The GATT operation is already dead by
|
||||
then; the bond it started arrives seconds later, once a human has typed six digits.
|
||||
|
||||
Failing there reported an error for a pairing that was in fact succeeding, and the
|
||||
device connected on the next attempt — the first having silently done the work. So
|
||||
retry rather than give up, for long enough to cover the typing.
|
||||
|
||||
Args:
|
||||
mesh_core: The imported ``meshcore.MeshCore`` class.
|
||||
cause: The authentication failure that opened the dialog, chained onto the
|
||||
final error if the pairing never completes.
|
||||
|
||||
Returns:
|
||||
The connected ``MeshCore`` client, or ``None`` if a later attempt opened the
|
||||
transport but the peripheral never answered the identity handshake.
|
||||
|
||||
Raises:
|
||||
DeviceAuthenticationError: If every attempt was still refused — the dialog was
|
||||
dismissed, or the code entered was wrong.
|
||||
"""
|
||||
for attempt in range(_BLE_MACOS_PAIRING_ATTEMPTS):
|
||||
await asyncio.sleep(_BLE_MACOS_PAIRING_DELAY_S)
|
||||
try:
|
||||
return await self._open_ble(mesh_core, allow_repair=False)
|
||||
except DeviceAuthenticationError:
|
||||
_log.debug(
|
||||
"BLE bond with %s not established yet (attempt %d/%d)",
|
||||
self._address,
|
||||
attempt + 1,
|
||||
_BLE_MACOS_PAIRING_ATTEMPTS,
|
||||
)
|
||||
raise DeviceAuthenticationError(self._ble_auth_message()) from cause
|
||||
|
||||
async def _create_ble_with_retry(self, mesh_core): # type: ignore[no-untyped-def]
|
||||
"""Open the owned BLE client, retrying the transport-level failures that are transient.
|
||||
|
||||
@@ -1305,7 +1362,20 @@ class MeshCoreDevice(Device):
|
||||
"""
|
||||
from meshcore import BLEConnection
|
||||
|
||||
connection = BLEConnection(address=self._address, device=self._ble_device, pin=self._pin)
|
||||
# Withhold the PIN on macOS. Handed one, ``BLEConnection.connect`` calls bleak's
|
||||
# ``client.pair()``, and CoreBluetooth has no pairing API at all — the macOS
|
||||
# backend raises ``NotImplementedError`` outright — whereupon the library
|
||||
# disconnects and re-raises, so supplying a *correct* PIN is what breaks the
|
||||
# connection. Apple's model is that pairing is the OS's to run, not ours: the
|
||||
# companion firmware puts its UART characteristic at ENC+MITM
|
||||
# (``SECMODE_ENC_WITH_MITM`` on nRF52, ``ESP_GATT_PERM_*_ENC_MITM`` on ESP32), so
|
||||
# the unbonded subscribe below is answered with "Insufficient Authentication",
|
||||
# and macOS reacts by running Passkey Entry and prompting for the code itself.
|
||||
# Saying nothing here is therefore what *lets* a PIN-protected companion bond;
|
||||
# the OS keeps the bond, and later connections need no PIN. Windows and Linux do
|
||||
# expose explicit pairing, and keep it (see :meth:`_pair_ble_windows`).
|
||||
pin = None if sys.platform == "darwin" else self._pin
|
||||
connection = BLEConnection(address=self._address, device=self._ble_device, pin=pin)
|
||||
mc = mesh_core(
|
||||
connection,
|
||||
default_timeout=self._connect_timeout,
|
||||
@@ -1632,6 +1702,16 @@ class MeshCoreDevice(Device):
|
||||
before an authenticated characteristic can be subscribed.
|
||||
"""
|
||||
where = self._address or "the selected Bluetooth device"
|
||||
if sys.platform == "darwin":
|
||||
# macOS collects the code itself, in its own dialog, so --ble-pin is not the
|
||||
# remedy here and naming it would send the reader somewhere that cannot help.
|
||||
return (
|
||||
f"{where} was not paired. macOS asks for the pairing code in its own dialog "
|
||||
"rather than through MeshTerm — enter the 6-digit code shown on the device "
|
||||
"(or in the MeshCore app) when it appears, and the bond is remembered for "
|
||||
"next time. If no dialog appeared, check that Bluetooth is allowed for this "
|
||||
"terminal in System Settings > Privacy & Security > Bluetooth."
|
||||
)
|
||||
if self._pin:
|
||||
return (
|
||||
f"{where} rejected the Bluetooth PIN — it needs pairing and the PIN provided "
|
||||
@@ -1786,18 +1866,29 @@ class MeshCoreDevice(Device):
|
||||
return {}
|
||||
return dict(getattr(result, "payload", {}) or {})
|
||||
|
||||
async def _contacts_payload(self, mc, *, retries: int = 3, delay: float = 0.5) -> dict: # noqa: ANN001
|
||||
"""Fetch the raw contacts map, retrying the transient "no event" blip.
|
||||
#: How long the contacts stream may go quiet before a read is called failed (seconds).
|
||||
#: The library's own ``get_contacts`` arms one future for the *whole* dump and never
|
||||
#: re-arms it, so a table that takes longer than its five seconds to stream fails with
|
||||
#: "no event received" however healthy the radio is — which is what a node holding four
|
||||
#: hundred contacts does over Bluetooth. What actually means "the companion stopped
|
||||
#: answering" is a gap *between* records, so that is what this times: the dump may take
|
||||
#: as long as it takes, for as long as it keeps arriving.
|
||||
_CONTACTS_IDLE_S = 6.0
|
||||
|
||||
The companion intermittently fails to emit the contacts event in time and
|
||||
returns an error event instead of data (``no event received during contacts
|
||||
retrieval``). That's a recoverable timing hiccup, so retry a few times with a
|
||||
short backoff before surfacing a clean, actionable error.
|
||||
async def _contacts_payload(
|
||||
self, mc, *, retries: int = 3, delay: float = 0.5, idle: float | None = None
|
||||
) -> dict: # noqa: ANN001
|
||||
"""Fetch the raw contacts map, retrying a read that stalls or is refused.
|
||||
|
||||
The companion intermittently refuses or drops a contacts request — a recoverable
|
||||
timing hiccup — so the read is attempted a few times with a short backoff before a
|
||||
clean, actionable error is raised. One attempt is :meth:`_stream_contacts`.
|
||||
|
||||
Args:
|
||||
mc: The connected ``MeshCore`` client.
|
||||
retries: Number of extra attempts after the first.
|
||||
delay: Seconds to wait between attempts.
|
||||
idle: Seconds of silence that end an attempt (default :data:`_CONTACTS_IDLE_S`).
|
||||
|
||||
Returns:
|
||||
The contacts payload mapping (possibly empty).
|
||||
@@ -1805,13 +1896,12 @@ class MeshCoreDevice(Device):
|
||||
Raises:
|
||||
DeviceCommandError: If every attempt fails to retrieve contacts.
|
||||
"""
|
||||
gap = self._CONTACTS_IDLE_S if idle is None else idle
|
||||
reason = ""
|
||||
for attempt in range(retries + 1):
|
||||
event = await mc.commands.get_contacts()
|
||||
if event is None or not getattr(event, "is_error", lambda: False)():
|
||||
return dict(getattr(event, "payload", {}) or {})
|
||||
payload = getattr(event, "payload", {}) or {}
|
||||
reason = str(payload.get("reason", payload))
|
||||
payload, reason = await self._stream_contacts(mc, gap)
|
||||
if payload is not None:
|
||||
return payload
|
||||
if attempt < retries:
|
||||
await asyncio.sleep(delay)
|
||||
raise DeviceCommandError(
|
||||
@@ -1820,6 +1910,85 @@ class MeshCoreDevice(Device):
|
||||
"retry, or power-cycle/reconnect the radio if it persists."
|
||||
)
|
||||
|
||||
async def _stream_contacts(self, mc, idle: float) -> tuple[dict | None, str]: # noqa: ANN001
|
||||
"""Run one contacts read, ending it on *silence* rather than on a deadline.
|
||||
|
||||
The contacts table arrives as one ``NEXT_CONTACT`` frame per record and a closing
|
||||
``CONTACTS`` frame holding the whole map. A dump is therefore not one answer that is
|
||||
either late or on time, it is a stream, and the only thing that distinguishes a slow
|
||||
big table from a radio that has stopped talking is how long it has been since the
|
||||
last record. So each record restarts the clock and only ``idle`` seconds of quiet
|
||||
end the attempt — which is the whole of the fix for a four-hundred-contact node,
|
||||
whose dump simply takes longer than any fixed deadline the library would allow it.
|
||||
|
||||
Args:
|
||||
mc: The connected ``MeshCore`` client.
|
||||
idle: Seconds of silence that end the attempt.
|
||||
|
||||
Returns:
|
||||
``(payload, "")`` on success, or ``(None, reason)`` describing how it ended.
|
||||
"""
|
||||
from meshcore import EventType
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
finished: asyncio.Future = loop.create_future()
|
||||
arrived = asyncio.Event()
|
||||
seen = False
|
||||
refusal = ""
|
||||
|
||||
def on_record(event) -> None: # noqa: ANN001 - meshcore Event
|
||||
nonlocal seen
|
||||
seen = True
|
||||
arrived.set()
|
||||
|
||||
def on_end(event) -> None: # noqa: ANN001 - meshcore Event
|
||||
if not finished.done():
|
||||
finished.set_result(event)
|
||||
|
||||
def on_error(event) -> None: # noqa: ANN001 - meshcore Event
|
||||
# An ERROR frame carries no request id, so it is only ours while nothing else
|
||||
# can have earned it: before the first record it is this request being refused
|
||||
# (a companion too busy to serve a dump answers ERR_CODE_BAD_STATE), and after
|
||||
# one it belongs to whatever else is on the link — a battery poll, a courier
|
||||
# send — where treating it as ours is how a read that was working stopped.
|
||||
nonlocal refusal
|
||||
payload = getattr(event, "payload", {}) or {}
|
||||
refusal = str(payload.get("reason", payload))
|
||||
if not seen and not finished.done():
|
||||
finished.set_result(None)
|
||||
|
||||
subscriptions = [
|
||||
mc.subscribe(EventType.NEXT_CONTACT, on_record),
|
||||
mc.subscribe(EventType.CONTACTS, on_end),
|
||||
mc.subscribe(EventType.ERROR, on_error),
|
||||
]
|
||||
try:
|
||||
await mc.commands.get_contacts_async()
|
||||
while not finished.done():
|
||||
arrived.clear()
|
||||
ticking = asyncio.ensure_future(arrived.wait())
|
||||
try:
|
||||
await asyncio.wait(
|
||||
{finished, ticking},
|
||||
timeout=idle,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
finally:
|
||||
ticking.cancel()
|
||||
if not finished.done() and not arrived.is_set():
|
||||
break
|
||||
finally:
|
||||
for subscription in subscriptions:
|
||||
subscription.unsubscribe()
|
||||
event = finished.result() if finished.done() else None
|
||||
if event is not None:
|
||||
return dict(getattr(event, "payload", {}) or {}), ""
|
||||
if refusal and not seen:
|
||||
return None, refusal
|
||||
if seen:
|
||||
return None, "no event received during contacts retrieval — it stopped partway"
|
||||
return None, "no event received during contacts retrieval"
|
||||
|
||||
async def get_contacts(self) -> list[Contact]: # noqa: D102 - inherited docstring
|
||||
mc = self._require()
|
||||
payload = await self._contacts_payload(mc)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
A companion's contact table is finite, and a mesh fills it with whatever adverts happen to
|
||||
arrive — so a radio left running long enough ends up holding mostly nodes heard once, in
|
||||
passing, from four hops away, while there is no room left to *discover* anyone new. The
|
||||
Contacts screen's bulk sweep (see :func:`~meshterm.ui.purge_screen.purge_contacts`) is how
|
||||
Contacts screen's bulk sweep (see :func:`~meshterm.ui.sweep_screen.archive_contacts`) is how
|
||||
that table gets its headroom back, and this module is the judgement it runs on: one score
|
||||
per contact, so the sweep can take the weakest and leave the ones you would actually miss.
|
||||
|
||||
@@ -18,7 +18,7 @@ must not have.
|
||||
contacts on a real mesh advertise no location, plenty are never overheard as a relayed
|
||||
packet so have no hop count, and a channel poster whose name matches no contact cannot be
|
||||
attributed at all. If "we don't know" resolved to ``0.0``, every one of those would sink to
|
||||
the bottom *together*, and the sweep would purge by how much metadata a node happens to
|
||||
the bottom *together*, and the sweep would archive by how much metadata a node happens to
|
||||
broadcast rather than by how much it is worth. So a term that cannot be computed returns
|
||||
``None`` and is filled with the **population median** of the contacts that could compute it
|
||||
(:func:`_fill_unknowns`) — the contact lands exactly where an average peer would on that
|
||||
@@ -35,17 +35,19 @@ before this history began) has no arrival time to decay from and is protected ou
|
||||
rather than being punished for MeshTerm's own ignorance — see :data:`PROTECT_UNOBSERVED`.
|
||||
|
||||
**Some contacts are never candidates.** A score is a heuristic and a heuristic must not
|
||||
overrule an explicit choice, so :func:`protection_for` short-circuits four cases before any
|
||||
arithmetic: a watched node, anyone you have sent a direct message to, a repeater whose
|
||||
admin credentials are stored, and the unobserved contact above. They are still scored and
|
||||
still ranked — they are real contacts and the percentile scale is the whole population —
|
||||
but they are never swept.
|
||||
overrule an explicit choice, so :func:`protection_for` short-circuits five cases before any
|
||||
arithmetic: a contact you locked, a watched node, anyone you have sent a direct message to,
|
||||
a repeater whose admin credentials are stored, and the unobserved contact above. They are
|
||||
still scored and still ranked — they are real contacts and the percentile scale is the whole
|
||||
population — but they are never swept.
|
||||
|
||||
**What is displayed is the percentile, never the score.** A raw ``47.3`` means nothing
|
||||
without the distribution it came from; a contact's *rank against your other contacts* is
|
||||
self-calibrating, needs no legend, and stays comparable when the weights change. So
|
||||
:class:`ScoredContact` carries :attr:`~ScoredContact.percentile` and the UI shows only that
|
||||
(see :func:`percentile_rank`).
|
||||
**The score is never displayed.** A raw ``47.3`` means nothing without the distribution it
|
||||
came from. What this module hands a screen instead is a *rank* — the list comes back ordered,
|
||||
and :attr:`~ScoredContact.percentile` places one contact in the field without a legend and
|
||||
without going stale when the weights change (see :func:`percentile_rank`). What a screen draws
|
||||
beside a contact is neither: the archive preview shows the :class:`ContactSignals` themselves, a
|
||||
lane per measured kind, because the thing a reader can check against what they know is the
|
||||
evidence, not this module's reading of it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -58,6 +60,10 @@ from statistics import median
|
||||
from .geo import haversine_km
|
||||
from .models import Contact
|
||||
|
||||
#: Protection reason: the reader locked this contact from its page. The most explicit claim
|
||||
#: there is — a lock says nothing but "never archive this one" — so it is checked first.
|
||||
PROTECT_LOCKED = "locked"
|
||||
|
||||
#: Protection reason: the node is starred in the Watchtower. An explicit pin outranks every
|
||||
#: heuristic in this module — the user already said this one matters.
|
||||
PROTECT_WATCHED = "watched"
|
||||
@@ -67,22 +73,29 @@ PROTECT_WATCHED = "watched"
|
||||
#: undone by a score. (Inbound-only traffic does *not* protect — anyone can message you.)
|
||||
PROTECT_MESSAGED = "messaged"
|
||||
|
||||
#: Protection reason: a repeater or room server whose admin password is stored. Purging it
|
||||
#: Protection reason: a repeater or room server whose admin password is stored. Archiving it
|
||||
#: costs the login, which is a far larger loss than a contact slot is a gain.
|
||||
PROTECT_ADMIN = "admin"
|
||||
|
||||
#: Protection reason: MeshTerm has never heard this contact, so every evidence term is
|
||||
#: empty for a reason that says nothing about the node. It came from the device's own table
|
||||
#: (or was added by hand) before this history began; sweeping it would be purging by how
|
||||
#: (or was added by hand) before this history began; sweeping it would be archiving by how
|
||||
#: long MeshTerm has been running rather than by anything the contact did.
|
||||
PROTECT_UNOBSERVED = "unobserved"
|
||||
|
||||
#: The order protections are reported in when a contact qualifies for several — most
|
||||
#: deliberate first, so the preview explains a row by the strongest claim on it.
|
||||
PROTECTION_ORDER = (PROTECT_WATCHED, PROTECT_MESSAGED, PROTECT_ADMIN, PROTECT_UNOBSERVED)
|
||||
PROTECTION_ORDER = (
|
||||
PROTECT_LOCKED,
|
||||
PROTECT_WATCHED,
|
||||
PROTECT_MESSAGED,
|
||||
PROTECT_ADMIN,
|
||||
PROTECT_UNOBSERVED,
|
||||
)
|
||||
|
||||
#: How a protection reads in the UI, keyed by the constants above.
|
||||
PROTECTION_LABELS = {
|
||||
PROTECT_LOCKED: "locked",
|
||||
PROTECT_WATCHED: "watched",
|
||||
PROTECT_MESSAGED: "you messaged",
|
||||
PROTECT_ADMIN: "admin login",
|
||||
@@ -179,6 +192,8 @@ class ContactSignals:
|
||||
which is what :data:`PROTECT_UNOBSERVED` keys on.
|
||||
watched: Whether the node is starred in the Watchtower.
|
||||
has_admin: Whether admin credentials are stored for it.
|
||||
locked: Whether the reader locked the contact against archiving (see
|
||||
:meth:`~meshterm.core.contact_store.ContactStore.set_locked`).
|
||||
"""
|
||||
|
||||
node: str
|
||||
@@ -194,26 +209,29 @@ class ContactSignals:
|
||||
known_days: float | None = None
|
||||
watched: bool = False
|
||||
has_admin: bool = False
|
||||
locked: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoredContact:
|
||||
"""One contact's standing among the others: its score, its percentile, and why.
|
||||
"""One contact's standing among the others: its score, its rank, and the evidence for both.
|
||||
|
||||
Attributes:
|
||||
contact: The contact this describes.
|
||||
signals: The evidence the score was computed from.
|
||||
score: The weighted sum plus any newcomer grace. Never displayed — see
|
||||
:attr:`percentile`, and the module docstring for why.
|
||||
percentile: Percentile rank within the scored population, ``0``–``100``. **This is
|
||||
the only number the UI shows**: it is self-calibrating (a mesh of 30 contacts
|
||||
and one of 300 both read the same way), needs no legend, and stays meaningful
|
||||
if the weights are ever retuned.
|
||||
percentile: Percentile rank within the scored population, ``0``–``100``. The
|
||||
score's one publishable reading: self-calibrating (a mesh of 30 contacts and
|
||||
one of 300 both read the same way), needing no legend, and still meaningful if
|
||||
the weights are ever retuned.
|
||||
protection: Why this contact can never be swept, or ``None`` if it can. One of the
|
||||
``PROTECT_*`` constants.
|
||||
reasons: Short lowercase phrases naming what this contact's standing rests on —
|
||||
its weakest lanes when it is a purge candidate, so the preview row explains
|
||||
itself without the reader having to know the formula.
|
||||
|
||||
The **signals** are what a screen shows, not the score or its terms: the archive preview
|
||||
draws one lane per measured kind straight off :attr:`signals` (see
|
||||
:mod:`~meshterm.ui.sweep_screen`), so a reader auditing a sweep reads the evidence in
|
||||
the same units it was gathered in rather than a phrase this module chose for them.
|
||||
"""
|
||||
|
||||
contact: Contact
|
||||
@@ -221,7 +239,6 @@ class ScoredContact:
|
||||
score: float
|
||||
percentile: int
|
||||
protection: str | None = None
|
||||
reasons: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def protected(self) -> bool:
|
||||
@@ -375,6 +392,7 @@ def protection_for(signals: ContactSignals) -> str | None:
|
||||
One of the ``PROTECT_*`` constants, or ``None``.
|
||||
"""
|
||||
claims = {
|
||||
PROTECT_LOCKED: signals.locked,
|
||||
PROTECT_WATCHED: signals.watched,
|
||||
PROTECT_MESSAGED: signals.dm_outbound > 0,
|
||||
PROTECT_ADMIN: signals.has_admin,
|
||||
@@ -395,7 +413,7 @@ def protection_for(signals: ContactSignals) -> str | None:
|
||||
#: unknown.
|
||||
_MIN_LOCATED = 4
|
||||
|
||||
#: The term names, in the order a :class:`ScoredContact` reports its reasons.
|
||||
#: The term names, in the order the weighted sum walks them.
|
||||
_TERM_NAMES = ("dm", "recency", "volume", "channel", "hops", "distance")
|
||||
|
||||
|
||||
@@ -449,54 +467,6 @@ def percentile_rank(score: float, population: Sequence[float]) -> int:
|
||||
return int(round(100.0 * (below + 0.5 * equal) / len(population)))
|
||||
|
||||
|
||||
def _reasons(
|
||||
signals: ContactSignals, terms: dict[str, float], weights: ScoreWeights
|
||||
) -> tuple[str, ...]:
|
||||
"""Name what this contact's standing rests on, weakest lane first.
|
||||
|
||||
The preview has one line per contact and the reader should not have to know the formula
|
||||
to audit a sweep — so each row says, in plain words, the two things that put it where it
|
||||
is. Phrases are drawn from the *measured* signals rather than from the term values, so
|
||||
"3 packets" is the actual tally and not a normalised fraction.
|
||||
"""
|
||||
phrases: list[tuple[float, str]] = []
|
||||
contribution = {
|
||||
"dm": terms["dm"] * weights.dm,
|
||||
"recency": terms["recency"] * weights.recency,
|
||||
"volume": terms["volume"] * weights.volume,
|
||||
"hops": terms["hops"] * weights.hops,
|
||||
}
|
||||
if signals.dm_total:
|
||||
phrases.append((contribution["dm"], f"{signals.dm_total} messages"))
|
||||
else:
|
||||
phrases.append((0.0, "never messaged"))
|
||||
if signals.heard_age_days is None:
|
||||
phrases.append((0.0, "never heard"))
|
||||
else:
|
||||
phrases.append((contribution["recency"], _age_phrase(signals.heard_age_days)))
|
||||
packets = signals.packets
|
||||
phrases.append((contribution["volume"], f"{packets} pkt{'' if packets == 1 else 's'}"))
|
||||
if signals.hops is not None:
|
||||
hops = signals.hops
|
||||
label = "direct" if hops < 0.5 else f"{hops:g} hops"
|
||||
phrases.append((contribution["hops"], label))
|
||||
phrases.sort(key=lambda pair: pair[0])
|
||||
return tuple(phrase for _, phrase in phrases[:3])
|
||||
|
||||
|
||||
def _age_phrase(days: float) -> str:
|
||||
"""A last-heard age in the fewest words: ``today`` / ``4d`` / ``6w`` / ``9mo`` / ``2y``."""
|
||||
if days < 1:
|
||||
return "today"
|
||||
if days < 14:
|
||||
return f"{int(days)}d"
|
||||
if days < 60:
|
||||
return f"{int(days / 7)}w"
|
||||
if days < 365:
|
||||
return f"{int(days / 30)}mo"
|
||||
return f"{days / 365:.0f}y"
|
||||
|
||||
|
||||
def rank_contacts(
|
||||
contacts: Sequence[Contact],
|
||||
signals: dict[str, ContactSignals],
|
||||
@@ -550,7 +520,6 @@ def rank_contacts(
|
||||
filled = {name: _fill_unknowns(column) for name, column in columns.items()}
|
||||
|
||||
scores: list[float] = []
|
||||
per_contact_terms: list[dict[str, float]] = []
|
||||
for index, sig in enumerate(gathered):
|
||||
terms = {name: filled[name][index] for name in _TERM_NAMES}
|
||||
total = (
|
||||
@@ -562,7 +531,6 @@ def rank_contacts(
|
||||
+ terms["distance"] * weights.distance
|
||||
+ term_grace(sig, weights)
|
||||
)
|
||||
per_contact_terms.append(terms)
|
||||
scores.append(total)
|
||||
|
||||
ranked = [
|
||||
@@ -572,11 +540,8 @@ def rank_contacts(
|
||||
score=score,
|
||||
percentile=percentile_rank(score, scores),
|
||||
protection=protection_for(sig),
|
||||
reasons=_reasons(sig, terms, weights),
|
||||
)
|
||||
for contact, sig, score, terms in zip(
|
||||
contacts, gathered, scores, per_contact_terms, strict=True
|
||||
)
|
||||
for contact, sig, score in zip(contacts, gathered, scores, strict=True)
|
||||
]
|
||||
ranked.sort(key=lambda scored: (-scored.score, scored.contact.name.casefold()))
|
||||
return ranked
|
||||
@@ -591,7 +556,7 @@ def sweep_candidates(ranked: Sequence[ScoredContact], keep: int) -> list[ScoredC
|
||||
protections against the target instead would mean starring a node quietly deepened the
|
||||
next sweep, which is the opposite of what a star is for. The picker's rungs therefore
|
||||
report their own real removal counts rather than arithmetic on the table size (see
|
||||
:func:`~meshterm.ui.purge_screen.purge_contacts`).
|
||||
:func:`~meshterm.ui.sweep_screen.archive_contacts`).
|
||||
|
||||
Args:
|
||||
ranked: The full ranking from :func:`rank_contacts`, strongest first.
|
||||
|
||||
@@ -34,6 +34,13 @@ it back. So a restore is one write, and the chat screen already performs it on d
|
||||
send is rejected for an unknown recipient (see
|
||||
:class:`~meshterm.core.connection.ContactNotOnDeviceError`).
|
||||
|
||||
A live contact can also be **locked**, which is the reader's own word that it stays: a
|
||||
locked contact is never a sweep candidate (see
|
||||
:data:`~meshterm.core.contact_score.PROTECT_LOCKED`) and its page offers no Archive. The flag
|
||||
lives here rather than on the device because the firmware has no such field — it is
|
||||
MeshTerm's claim about the contact, kept beside the other one it makes (the archive stamp),
|
||||
and a device read never clears it.
|
||||
|
||||
The one place to be careful is a firmware-less bridge, whose contact table is RAM-only and
|
||||
for which this store *is* the memory: archiving there removes the contact from the lists for
|
||||
real, and only a restore brings it back.
|
||||
@@ -47,7 +54,7 @@ contact set actually changes, and flushes atomically.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
@@ -97,6 +104,9 @@ class RememberedContact:
|
||||
while it is a live contact. An archived contact is remembered in full but kept
|
||||
*out* of :func:`merge_contacts`, so it stops occupying a device slot without
|
||||
being forgotten — and the stamp is what lets a list say how long ago it went.
|
||||
locked: Whether the reader locked this contact against archiving. MeshTerm's own
|
||||
state, not the device's, so :meth:`ContactStore.remember_all` carries it across
|
||||
every fresh read of the contact rather than resetting it.
|
||||
"""
|
||||
|
||||
public_key: str
|
||||
@@ -106,6 +116,7 @@ class RememberedContact:
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
archived_at: int | None = None
|
||||
locked: bool = False
|
||||
|
||||
@property
|
||||
def archived(self) -> bool:
|
||||
@@ -219,7 +230,11 @@ class ContactStore:
|
||||
if not contact.public_key:
|
||||
continue # unaddressable — nothing to remember it by
|
||||
remembered = RememberedContact.from_contact(contact)
|
||||
if current.get(remembered.public_key) != remembered:
|
||||
known = current.get(remembered.public_key)
|
||||
if known is not None and known.locked:
|
||||
# The lock is ours, not the device's: a fresh read knows nothing about it.
|
||||
remembered = replace(remembered, locked=True)
|
||||
if known != remembered:
|
||||
current[remembered.public_key] = remembered
|
||||
changed = True
|
||||
if changed:
|
||||
@@ -256,16 +271,7 @@ class ContactStore:
|
||||
dev = _norm(device_pubkey)
|
||||
if not dev or not contact.public_key:
|
||||
return # unaddressable — there would be nothing to restore it by
|
||||
remembered = RememberedContact.from_contact(contact)
|
||||
entry = RememberedContact(
|
||||
public_key=remembered.public_key,
|
||||
name=remembered.name,
|
||||
node_type=remembered.node_type,
|
||||
last_advert=remembered.last_advert,
|
||||
lat=remembered.lat,
|
||||
lon=remembered.lon,
|
||||
archived_at=int(when),
|
||||
)
|
||||
entry = replace(RememberedContact.from_contact(contact), archived_at=int(when))
|
||||
current = dict(self._state.get(dev, {}))
|
||||
if current.get(entry.public_key) == entry:
|
||||
return
|
||||
@@ -295,19 +301,45 @@ class ContactStore:
|
||||
if entry is None or not entry.archived:
|
||||
return None
|
||||
current = dict(contacts)
|
||||
current[key] = RememberedContact(
|
||||
public_key=entry.public_key,
|
||||
name=entry.name,
|
||||
node_type=entry.node_type,
|
||||
last_advert=entry.last_advert,
|
||||
lat=entry.lat,
|
||||
lon=entry.lon,
|
||||
archived_at=None,
|
||||
)
|
||||
current[key] = replace(entry, archived_at=None)
|
||||
self._state[dev] = current
|
||||
self._save()
|
||||
return entry
|
||||
|
||||
def locked_keys(self, device_pubkey: str) -> frozenset[str]:
|
||||
"""The full keys (lowercase hex) of every contact locked on this device."""
|
||||
remembered = self._state.get(_norm(device_pubkey), {}).values()
|
||||
return frozenset(c.public_key for c in remembered if c.locked)
|
||||
|
||||
def is_locked(self, device_pubkey: str, contact_pubkey: str) -> bool:
|
||||
"""Whether one contact is locked against archiving on this device."""
|
||||
entry = self._state.get(_norm(device_pubkey), {}).get(_norm(contact_pubkey))
|
||||
return entry is not None and entry.locked
|
||||
|
||||
def set_locked(self, device_pubkey: str, contact: Contact, locked: bool) -> None:
|
||||
"""Lock or unlock one contact, upserting it so a contact the store never saw can be.
|
||||
|
||||
Only a live contact is locked — the lock exists to keep a contact *off* the archive
|
||||
path, so locking an archived one would claim something about a contact that has
|
||||
already gone. Persists only a real change.
|
||||
|
||||
Args:
|
||||
device_pubkey: The device's own public key.
|
||||
contact: The contact to lock or unlock, addressed by its full key.
|
||||
locked: The state to leave it in.
|
||||
"""
|
||||
dev = _norm(device_pubkey)
|
||||
if not dev or not contact.public_key:
|
||||
return # unaddressable — nothing to hang the flag on
|
||||
current = dict(self._state.get(dev, {}))
|
||||
key = _norm(contact.public_key)
|
||||
known = current.get(key) or RememberedContact.from_contact(contact)
|
||||
if known.archived or known.locked == locked:
|
||||
return
|
||||
current[key] = replace(known, locked=locked)
|
||||
self._state[dev] = current
|
||||
self._save()
|
||||
|
||||
def forget(self, device_pubkey: str, contact_pubkey: str) -> None:
|
||||
"""Drop one remembered contact; persist only a real change."""
|
||||
dev = _norm(device_pubkey)
|
||||
@@ -350,6 +382,8 @@ def _contact_to_json(contact: RememberedContact) -> dict:
|
||||
entry["lon"] = contact.lon
|
||||
if contact.archived_at is not None:
|
||||
entry["archived_at"] = contact.archived_at
|
||||
if contact.locked:
|
||||
entry["locked"] = True
|
||||
return entry
|
||||
|
||||
|
||||
@@ -369,6 +403,7 @@ def _contact_from_json(entry: object) -> RememberedContact | None:
|
||||
lat=_opt_float(entry.get("lat")),
|
||||
lon=_opt_float(entry.get("lon")),
|
||||
archived_at=_opt_int(entry.get("archived_at")),
|
||||
locked=entry.get("locked") is True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -57,11 +57,12 @@ else: # pragma: no cover - exercised only on 3.10
|
||||
import tomli as tomllib
|
||||
|
||||
#: Display groups, in the order the page and the file present them. The order is the
|
||||
#: order a session happens in — what MeshTerm puts on the air, how loud, what it watches
|
||||
#: for, how it draws the world, what it keeps, and how it paints. Nothing sorts
|
||||
#: alphabetically: a reader looking for "how long before I give up on a message" should
|
||||
#: not have to know it starts with a D.
|
||||
#: order a session happens in — what MeshTerm does to the radio as the link opens, what
|
||||
#: it puts on the air, how loud, what it watches for, how it draws the world, what it
|
||||
#: keeps, and how it paints. Nothing sorts alphabetically: a reader looking for "how long
|
||||
#: before I give up on a message" should not have to know it starts with a D.
|
||||
GROUPS: tuple[str, ...] = (
|
||||
"Device",
|
||||
"Sending",
|
||||
"TX optimize",
|
||||
"Watchtower",
|
||||
@@ -178,9 +179,9 @@ _CONSOLE_SETUP_CHOICES: dict[str, str] = {
|
||||
"off": "Leave it alone",
|
||||
}
|
||||
|
||||
#: The two console fonts ``scripts/calculinux-console-font.sh`` (and its 6x8 companion)
|
||||
#: build for the PicoCalc's framebuffer panel. Each is named by its cell and by the screen
|
||||
#: that cell buys, because choosing a font here is choosing how much fits — not a bitmap.
|
||||
#: The two console fonts ``scripts/picocalc/calculinux-console-font-6x12.sh`` (and its 6x8
|
||||
#: companion) build for the PicoCalc's framebuffer panel. Each is named by its cell and by the
|
||||
#: screen that cell buys, because choosing a font here is choosing how much fits — not a bitmap.
|
||||
#: Plain ASCII ``x`` and not ``×``: the multiplication sign is not one of the 512 glyphs
|
||||
#: the console font carries, so it would draw as a tofu box on the one platform this
|
||||
#: preference is offered on. The value maps to a file in
|
||||
@@ -205,6 +206,18 @@ _LOG_LEVEL_CHOICES: dict[str, str] = {
|
||||
#: code-level behaviour is named, never re-typed: one source, so changing it cannot leave
|
||||
#: the constant and the registry disagreeing about what MeshTerm does.
|
||||
PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
# --- Device ------------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="set_clock_on_connect",
|
||||
label="Set clock on connect",
|
||||
help="Set the device clock from this computer when it connects",
|
||||
group="Device",
|
||||
value_type="bool",
|
||||
# Off: writing to a radio nobody asked to be written to is the owner's call. The
|
||||
# set is silent (the log records it) and happens once per connection — see
|
||||
# :mod:`meshterm.services.clock_sync`.
|
||||
default=False,
|
||||
),
|
||||
# --- Sending -----------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="trace_cooldown_s",
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -75,10 +75,11 @@ class Platform:
|
||||
this is ``False`` there. ``MESHTERM_FULL_WIDTH`` remains an explicit override
|
||||
on top of this default.
|
||||
emoji: Whether emoji icons render at all. ``False`` means every icon funnel routes
|
||||
through a compact single-BMP-glyph table instead (P3), and the emoji-width
|
||||
calibration probe (:func:`~meshterm.ui.tui.emoji_width.calibrate`) is skipped
|
||||
outright — there is nothing to calibrate once no emoji are ever drawn, and the
|
||||
probe writes escape sequences the console font can't shape anyway.
|
||||
through a compact single-BMP-glyph table instead (P3), and neither half of the
|
||||
emoji alignment runs — the reserve-two measurement
|
||||
(:func:`~meshterm.ui.tui.emoji_width.install`) or the column pinning
|
||||
(:mod:`~meshterm.ui.tui.colsnap`). Once no emoji are ever drawn, every glyph on
|
||||
screen is one the console font has verified, and the stock widths are exact.
|
||||
ascii_fold: Whether names and message bodies are NFKD-folded (accents stripped) at
|
||||
the render boundary before display. Storage is never touched. Wired in P3.
|
||||
truecolor: Whether the theme may use arbitrary 24-bit SGR colour. ``False`` selects
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Set the companion's clock from this computer — by hand from Device config, or on connect.
|
||||
|
||||
Most MeshCore boards carry no real-time clock: a power cycle puts them back at whatever
|
||||
epoch the firmware boots with, and every message they then send is stamped in a year the
|
||||
mesh does not agree on. :func:`set_clock` is the one device-side step that corrects it —
|
||||
read what the radio thinks the time is, write ours, and hand back the drift that was
|
||||
corrected, the fact worth keeping and the one the set destroys by succeeding. The Device
|
||||
config screen's *Sync clock* action (``config sync-clock`` on the command line) calls it
|
||||
and acknowledges on screen; :class:`ClockSync` calls the same function on its own, once
|
||||
per connection, when the ``set_clock_on_connect`` preference asks for it (off by default —
|
||||
writing to a radio that nobody asked to be written to is a choice the owner makes).
|
||||
|
||||
The automatic set is a background step, not a startup step: the connect path hands the
|
||||
device over and carries on, and the write happens in a task of its own so a slow or silent
|
||||
firmware never holds the menu. It reports in the **log**, never on screen.
|
||||
|
||||
Once per *connection*, keyed by the device object's identity like the advert scheduler's
|
||||
key probe: a reconnect after a link drop is a fresh :class:`~meshterm.core.connection.Device`
|
||||
and gets its own set, because a drop is exactly when the board may have rebooted and lost
|
||||
its clock again. The interactive session starts the service; scripted CLI runs never do,
|
||||
so a one-shot command cannot change a radio it was only asked to read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
from ..core.connection import Device
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ClockSet:
|
||||
"""What one clock set did.
|
||||
|
||||
Attributes:
|
||||
epoch: The UNIX time written to the device.
|
||||
drift_s: How far the device's clock was from ours *before* the set (device minus
|
||||
host, seconds), or ``None`` where the firmware would not report its clock.
|
||||
"""
|
||||
|
||||
epoch: int
|
||||
drift_s: int | None
|
||||
|
||||
@property
|
||||
def set_at(self) -> datetime:
|
||||
"""The written instant, in this computer's local time."""
|
||||
return datetime.fromtimestamp(self.epoch).astimezone()
|
||||
|
||||
@property
|
||||
def stamp(self) -> str:
|
||||
"""The written instant as the acknowledgement and the log print it."""
|
||||
return self.set_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
async def set_clock(device: Device) -> ClockSet:
|
||||
"""Set ``device``'s clock to this computer's, returning what was corrected.
|
||||
|
||||
The read of the device's own clock before the write is optional — the drift is a
|
||||
nicety, and a firmware that will not answer still gets its clock set.
|
||||
|
||||
Args:
|
||||
device: A connected device.
|
||||
|
||||
Returns:
|
||||
The instant written and the drift it corrected.
|
||||
|
||||
Raises:
|
||||
Whatever the device raises when the write itself fails.
|
||||
"""
|
||||
try:
|
||||
before = await device.get_time()
|
||||
except Exception: # noqa: BLE001 - optional read; the drift is a nicety
|
||||
before = None
|
||||
epoch = int(time.time())
|
||||
await device.set_time(epoch)
|
||||
return ClockSet(epoch=epoch, drift_s=None if before is None else before - epoch)
|
||||
|
||||
|
||||
class ClockSync:
|
||||
"""Owns the on-connect clock set for an interactive session.
|
||||
|
||||
Interact through :meth:`start`, :meth:`stop`/:meth:`aclose`, and
|
||||
:meth:`on_connected`, which the context calls as each connection settles.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: AppContext) -> None:
|
||||
"""Initialize an idle service bound to an application context.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context, read for the preference at each
|
||||
connection and for the logger. Nothing is touched until :meth:`start`.
|
||||
"""
|
||||
self._ctx = ctx
|
||||
self._armed = False
|
||||
self._task: asyncio.Task | None = None
|
||||
# Identity of the device object last set, so one connection is set once even if
|
||||
# the connect path reports it more than once.
|
||||
self._synced: int | None = None
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""Whether the service is armed to act on connections."""
|
||||
return self._armed
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Arm the service, setting the clock at once if a device is already connected.
|
||||
|
||||
Idempotent. The startup picker may have adopted a live connection before any
|
||||
service started, so the connection that already exists counts as the first one.
|
||||
"""
|
||||
if self._armed:
|
||||
return
|
||||
self._armed = True
|
||||
device = self._ctx._device
|
||||
if device is not None:
|
||||
self.on_connected(device)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Disarm, cancelling a set still in flight. Idempotent."""
|
||||
self._armed = False
|
||||
task, self._task = self._task, None
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Stop at session end (an alias for :meth:`stop`)."""
|
||||
await self.stop()
|
||||
|
||||
def on_connected(self, device: Device) -> None:
|
||||
"""Note a settled connection and, if the preference asks, set its clock in the background.
|
||||
|
||||
Returns at once; the set runs in its own task. A no-op while the service is not
|
||||
started (scripted runs), while the preference is off, and for a device this
|
||||
session has already set.
|
||||
|
||||
Args:
|
||||
device: The device the context just finished connecting.
|
||||
"""
|
||||
if not self._armed or not self._ctx.preferences.set_clock_on_connect:
|
||||
return
|
||||
if self._synced == id(device):
|
||||
return
|
||||
self._synced = id(device)
|
||||
self._task = asyncio.ensure_future(self._sync(device))
|
||||
|
||||
async def _sync(self, device: Device) -> None:
|
||||
"""Run :func:`set_clock` and put the outcome in the log."""
|
||||
log = self._ctx.log
|
||||
try:
|
||||
done = await set_clock(device)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - best-effort; the link is not ours to break
|
||||
log.warning("clock sync: could not set the device clock: %s", exc)
|
||||
return
|
||||
if done.drift_s is None:
|
||||
log.info("clock sync: device clock set to %s (drift unknown)", done.stamp)
|
||||
else:
|
||||
log.info("clock sync: device clock set to %s (was %+d s off)", done.stamp, done.drift_s)
|
||||
@@ -39,7 +39,8 @@ from ..core.config import default_config_dir
|
||||
from ..persistence.logging import get_logger
|
||||
from ..platforms import get_platform
|
||||
|
||||
#: Where ``scripts/calculinux-console-font.sh`` and its 6x8 companion install the fonts.
|
||||
#: Where ``scripts/picocalc/calculinux-console-font-6x12.sh`` and its 6x8 companion install
|
||||
#: the fonts.
|
||||
FONT_DIR = Path("/usr/share/consolefonts")
|
||||
|
||||
#: One preference value -> the file the build script writes for it. This map lives beside
|
||||
|
||||
@@ -207,7 +207,7 @@ class DeviceState:
|
||||
|
||||
So the three are merged by taking the latest, here — once, at the one point every
|
||||
screen fetches contacts through, so heard lanes, recency sorts, silence alerts and
|
||||
the purge ladder all see the same honest value. Taking the *latest* rather than
|
||||
the archive ladder all see the same honest value. Taking the *latest* rather than
|
||||
preferring either side means a device time still stands whenever the firmware caught
|
||||
an advert we did not record (monitoring off, app not running); we only override it
|
||||
with proof of a later reception. A contact we have truly never heard stays ``None``
|
||||
|
||||
@@ -135,7 +135,7 @@ class ChatTool(Tool):
|
||||
# scroll nor the erase atom is written into the base: the select list splices
|
||||
# each in exactly when its key would act — ←→ only while the highlighted row
|
||||
# overflows, Del only on a thread that has history to lose — and the three of
|
||||
# them together are what has to stay inside 72 cells (the purge preview's list
|
||||
# them together are what has to stay inside 72 cells (the archive preview's list
|
||||
# is the same arithmetic). `Del erase` is that budget's doing: the row already
|
||||
# names the conversation, so the atom spends its cells on the verb, and `erase`
|
||||
# is the word the app uses elsewhere for taking data away.
|
||||
|
||||
@@ -29,6 +29,7 @@ from ..core.device_config import (
|
||||
parse_value,
|
||||
settings_by_category,
|
||||
)
|
||||
from ..services.clock_sync import set_clock
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
@@ -306,23 +307,19 @@ async def apply_ops(
|
||||
elif kind == "share":
|
||||
report.append(await _share_contact(ctx, snapshot))
|
||||
elif kind == "sync_clock":
|
||||
import time as _time
|
||||
from datetime import datetime
|
||||
|
||||
epoch = int(_time.time())
|
||||
drift = await _try_clock(device)
|
||||
await device.set_time(epoch)
|
||||
stamp = datetime.fromtimestamp(epoch).astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
||||
ctx.ui.ack(f"[ok]✓[/ok] device clock set to [brand]{stamp}[/brand]")
|
||||
# The same step the on-connect service runs in the background
|
||||
# (:mod:`meshterm.services.clock_sync`); here it is acknowledged and reported.
|
||||
done = await set_clock(device)
|
||||
ctx.ui.ack(f"[ok]✓[/ok] device clock set to [brand]{done.stamp}[/brand]")
|
||||
changes += 1
|
||||
report.append(
|
||||
_acted(
|
||||
"clock",
|
||||
changes=1,
|
||||
set_at=datetime.fromtimestamp(epoch).astimezone(),
|
||||
set_at=done.set_at,
|
||||
# What the clock was off by *before* the set: the fact worth logging,
|
||||
# and the one this command destroys by succeeding.
|
||||
drift_s=None if drift is None else drift - epoch,
|
||||
drift_s=done.drift_s,
|
||||
)
|
||||
)
|
||||
elif kind == "reboot":
|
||||
@@ -802,11 +799,3 @@ def _acted(key: str, **facts: Any) -> Facts:
|
||||
values=dict(facts),
|
||||
shape=SILENT,
|
||||
)
|
||||
|
||||
|
||||
async def _try_clock(device: Device) -> int | None:
|
||||
"""The device's current clock, or ``None`` where the firmware will not answer."""
|
||||
try:
|
||||
return await device.get_time()
|
||||
except Exception: # noqa: BLE001 - optional read; the drift is a nicety
|
||||
return None
|
||||
@@ -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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The Archived contacts list: what the sweep took off the device, and when.
|
||||
|
||||
A purge (see :mod:`~meshterm.ui.purge_screen`) removes a contact from the *device* — the
|
||||
scarce resource — and keeps it here in full. This screen is where those contacts live: the
|
||||
same sortable, filterable lane layout as the Contacts list, in its own lane set
|
||||
An archive sweep (see :mod:`~meshterm.ui.sweep_screen`) removes a contact from the *device*
|
||||
— the scarce resource — and keeps it here in full. This screen is where those contacts live:
|
||||
the same sortable, filterable lane layout as the Contacts list, in its own lane set
|
||||
(``NAME · ARCHIVED · KEY``, :data:`~meshterm.ui.contactlist.ARCHIVED_LANES`), with its own
|
||||
sort ring over exactly those three columns.
|
||||
|
||||
|
||||
+63
-14
@@ -32,7 +32,7 @@ from rich.text import Text
|
||||
|
||||
from .marks import SELF_MARK
|
||||
from .menus import fit_cells
|
||||
from .theme import name_style
|
||||
from .theme import glyph, name_style
|
||||
from .tui.select import Choice, SelectScreen, Separator
|
||||
from .widgets import (
|
||||
DEFAULT_GLYPH,
|
||||
@@ -122,6 +122,35 @@ TRACE_LANES: tuple[ContactLane, ...] = (TRACED_LANE, HEARD_LANE, PKTS_LANE)
|
||||
#: about; when it left is.
|
||||
ARCHIVED_LANES: tuple[ContactLane, ...] = (ARCHIVED_LANE,)
|
||||
|
||||
#: The padlock a locked contact's row leads with (see :attr:`ContactRow.locked`) — the
|
||||
#: private-channel ``🔒`` of the icon lexicon, which is the same claim (this one is closed to
|
||||
#: something), and on the PicoCalc the font's own hand-drawn padlock ``⚿`` through
|
||||
#: :func:`~meshterm.ui.theme.glyph`. An unlocked contact draws nothing: the mark is an
|
||||
#: exception worth noticing, and a lane of open padlocks would bury the few that aren't.
|
||||
_LOCK_ICON = "🔒"
|
||||
|
||||
|
||||
def _lock_lane_width(rows: list[ContactRow]) -> int:
|
||||
"""The lock column's width in cells: this platform's padlock and its gap, or ``0``.
|
||||
|
||||
The column sits left of the type glyph and exists only while some row is locked, so a
|
||||
list with no locks spends no cells on it — on the PicoCalc's 53 those are a name's cells.
|
||||
"""
|
||||
if not any(row.locked for row in rows):
|
||||
return 0
|
||||
return cell_len(glyph(_LOCK_ICON)) + 1
|
||||
|
||||
|
||||
def _lock_cell(row: ContactRow, lock_w: int) -> str:
|
||||
"""A row's lock column: the padlock and its gap when locked, blank otherwise."""
|
||||
if not lock_w:
|
||||
return ""
|
||||
if not row.locked:
|
||||
return " " * lock_w
|
||||
mark = glyph(_LOCK_ICON)
|
||||
return mark + " " * (lock_w - cell_len(mark))
|
||||
|
||||
|
||||
#: The muted ``(you)`` tag on the own-node lane (see :func:`_lane`), its width folded into
|
||||
#: the name lane's content sizing so the tag never truncates.
|
||||
_YOU_TAG = " (you)"
|
||||
@@ -204,6 +233,8 @@ class ContactRow:
|
||||
Ignored by lists that don't declare the lane.
|
||||
archived_at: When this contact was archived off the device (aware UTC) — fills the
|
||||
``ARCHIVED`` lane (:data:`ARCHIVED_LANES`). Ignored by lists without it.
|
||||
locked: Whether the contact is locked against archiving. Leads the row with a
|
||||
padlock, left of the type glyph; ``False`` draws nothing there.
|
||||
you: Whether this is our own node: the ``★`` marker, the pure-white ``you`` name
|
||||
style with a muted ``(you)`` tag, faint ``—`` heard/packet lanes (we never
|
||||
overhear ourselves), pinned above the sorted block whatever the sort.
|
||||
@@ -217,11 +248,15 @@ class ContactRow:
|
||||
count: int | None = None
|
||||
last_traced: datetime | None = None
|
||||
archived_at: datetime | None = None
|
||||
locked: bool = False
|
||||
you: bool = False
|
||||
|
||||
|
||||
def _header(
|
||||
name_w: int, sort: ContactsSort, lanes: tuple[ContactLane, ...] = DEFAULT_LANES
|
||||
name_w: int,
|
||||
sort: ContactsSort,
|
||||
lanes: tuple[ContactLane, ...] = DEFAULT_LANES,
|
||||
lock_w: int = 0,
|
||||
) -> Text:
|
||||
"""Column labels over the contact lanes (see :func:`_lane`).
|
||||
|
||||
@@ -258,7 +293,8 @@ def _header(
|
||||
if not last:
|
||||
header.append(" " * (_GAP - 2), style="muted")
|
||||
|
||||
header = Text(" ", style="muted") # pointer (2) + the row's type glyph and gap (2)
|
||||
# The pointer (2), the lock column when the list has one, the type glyph and its gap (2).
|
||||
header = Text(" " * (4 + lock_w), style="muted")
|
||||
column(header, "NAME", "name", name_w)
|
||||
for lane in lanes:
|
||||
# Right-aligned label over a right-aligned value, so a narrow field's header sits
|
||||
@@ -291,6 +327,7 @@ def _you_lane(
|
||||
prefix_bytes: int,
|
||||
hash_w: int,
|
||||
lanes: tuple[ContactLane, ...] = DEFAULT_LANES,
|
||||
lock_w: int = 0,
|
||||
) -> Text:
|
||||
"""Our own node's lane — laid out exactly like :func:`_lane`'s regular rows.
|
||||
|
||||
@@ -301,7 +338,7 @@ def _you_lane(
|
||||
reads a faint ``—``**, whatever the list declares: we never overhear, trace, or archive
|
||||
ourselves, so none of those has a value to show and each says so identically.
|
||||
"""
|
||||
text = Text(no_wrap=True, overflow="ellipsis")
|
||||
text = Text(" " * lock_w, no_wrap=True, overflow="ellipsis") # we are never locked
|
||||
text.append(SELF_MARK[0], style=SELF_MARK[1])
|
||||
text.append(" ")
|
||||
# The name lane, exactly name_w cells: the name (white) with a snug muted "(you)" tag —
|
||||
@@ -332,11 +369,14 @@ def _lane(
|
||||
prefix_bytes: int,
|
||||
hash_w: int,
|
||||
lanes: tuple[ContactLane, ...] = DEFAULT_LANES,
|
||||
lock_w: int = 0,
|
||||
) -> Text:
|
||||
"""One contact as fixed, colour-coded lanes under :func:`_header`'s columns.
|
||||
|
||||
The type glyph leads (the app's shared marker palette), so the mark reads ``▲`` for a
|
||||
repeater, ``■`` for a room, ``◉`` for a sensor, ``●`` for a plain node. The name takes
|
||||
A locked contact's padlock leads, in a column of its own (``lock_w`` cells, zero when
|
||||
no row in the list is locked). The type glyph follows (the app's shared marker palette),
|
||||
so the mark reads ``▲`` for a repeater, ``■`` for a room, ``◉`` for a sensor, ``●`` for a
|
||||
plain node. The name takes
|
||||
the contact's hash-derived palette hue (a nameless contact's ``unknown`` placeholder
|
||||
stays muted — colour marks a name, and the hash lane already carries the identity) and
|
||||
the flexing name lane (``name_w`` cells). Each declared lane follows in order, drawn by
|
||||
@@ -351,12 +391,13 @@ def _lane(
|
||||
hash_w: The flexing key lane's width in cells (a short key pads out to it; see
|
||||
:func:`~meshterm.ui.widgets.highlighted_hash`).
|
||||
lanes: The middle lanes to draw between the name and the key.
|
||||
lock_w: The lock column's width in cells (see :func:`_lock_lane_width`).
|
||||
"""
|
||||
if row.you:
|
||||
return _you_lane(row, name_w, prefix_bytes, hash_w, lanes)
|
||||
glyph, glyph_style = NODE_GLYPHS.get(row.node_type, DEFAULT_GLYPH)
|
||||
text = Text(no_wrap=True, overflow="ellipsis")
|
||||
text.append(glyph, style=glyph_style)
|
||||
return _you_lane(row, name_w, prefix_bytes, hash_w, lanes, lock_w)
|
||||
mark, mark_style = NODE_GLYPHS.get(row.node_type, DEFAULT_GLYPH)
|
||||
text = Text(_lock_cell(row, lock_w), no_wrap=True, overflow="ellipsis")
|
||||
text.append(mark, style=mark_style)
|
||||
text.append(" ")
|
||||
text.append(
|
||||
fit_cells(row.name or "unknown", name_w),
|
||||
@@ -465,7 +506,7 @@ class ContactListScreen(SelectScreen):
|
||||
|
||||
Built on ``super()``'s lane rather than the bare default, so a contact list that
|
||||
carries section headings (the Time Machine's whole-mesh lead, the Contacts screen's
|
||||
purge tail) keeps the section jumps the select list promotes onto F1/F2.
|
||||
archive tail) keeps the section jumps the select list promotes onto F1/F2.
|
||||
"""
|
||||
from .tui.fkeys import FPair
|
||||
|
||||
@@ -536,13 +577,16 @@ class ContactListScreen(SelectScreen):
|
||||
"""The lead rows, the sort-aware column header, the contact lanes, then any tail rows.
|
||||
|
||||
Own-node rows lead the lanes and stay first whatever the sort: only the block
|
||||
below them reorders (see :func:`_ordered`). Tail rows (a purge action, the exit
|
||||
below them reorders (see :func:`_ordered`). Tail rows (an archive action, the exit
|
||||
group) close the list, past every contact whatever the sort.
|
||||
"""
|
||||
items: list = list(self._lead)
|
||||
# The lane names lead the contacts as their landmark, so they pin overhead while the
|
||||
# list scrolls — a row deep in the sort can still be read off its columns.
|
||||
items.append(Separator(_header(self._name_w, self._sort, self._lanes), heading=True))
|
||||
lock_w = _lock_lane_width(self._contact_rows)
|
||||
items.append(
|
||||
Separator(_header(self._name_w, self._sort, self._lanes, lock_w), heading=True)
|
||||
)
|
||||
pinned = [row for row in self._contact_rows if row.you]
|
||||
rest = [row for row in self._contact_rows if not row.you]
|
||||
for row in (*pinned, *_ordered(rest, self._sort, self._lanes)):
|
||||
@@ -554,6 +598,7 @@ class ContactListScreen(SelectScreen):
|
||||
self._prefix_bytes,
|
||||
self._hash_w,
|
||||
self._lanes,
|
||||
lock_w,
|
||||
),
|
||||
row.value,
|
||||
)
|
||||
@@ -626,7 +671,11 @@ class ContactListScreen(SelectScreen):
|
||||
adds its gapped width to the fixed lead, so a list that drops two lanes (the
|
||||
Archived one) hands both back to the key.
|
||||
"""
|
||||
lead = _LEAD + sum(_GAP + lane.width for lane in self._lanes)
|
||||
lead = (
|
||||
_LEAD
|
||||
+ _lock_lane_width(self._contact_rows)
|
||||
+ sum(_GAP + lane.width for lane in self._lanes)
|
||||
)
|
||||
name_cap = max(_NAME_MIN, width - lead - _HASH_MIN)
|
||||
name_w = max(_NAME_MIN, min(self._widest_name(), name_cap))
|
||||
hash_w = max(_HASH_MIN, width - lead - name_w)
|
||||
|
||||
@@ -11,15 +11,15 @@ and backing out of that page returns to the list right where it stood. The one-s
|
||||
(``meshterm contacts --sort …``) still renders the static
|
||||
:func:`~meshterm.ui.widgets.contacts_table`; only the menu gets the live list.
|
||||
|
||||
Below the contacts sits the list's own maintenance action — **Purge contacts** — the bulk
|
||||
Below the contacts sits the list's own maintenance action — **Archive contacts** — the bulk
|
||||
counterpart, which exists because a companion's contact table is finite and a busy mesh
|
||||
fills it with nodes heard once in passing until there is no room left to discover anyone
|
||||
new. It ranks the whole table (see :mod:`~meshterm.core.contact_score`) and archives the
|
||||
weakest off the device while MeshTerm keeps them; the flow, its ladder and its preview live
|
||||
in :mod:`~meshterm.ui.purge_screen`. Our own node, pinned above the list, is never a
|
||||
in :mod:`~meshterm.ui.sweep_screen`. Our own node, pinned above the list, is never a
|
||||
candidate — it isn't in the device's contact table.
|
||||
|
||||
What a sweep took is never hidden: **View archived contacts** sits directly under the purge
|
||||
What a sweep took is never hidden: **View archived contacts** sits directly under the archive action
|
||||
action and opens the archived list (see :mod:`~meshterm.ui.archived_screen`) — its own
|
||||
screen, in the same lane layout, sorted on its own ``NAME · ARCHIVED · KEY`` columns. It
|
||||
earns a screen rather than a section at the foot of this one because it is a different list
|
||||
@@ -42,7 +42,7 @@ from typing import TYPE_CHECKING
|
||||
from .archived_screen import open_archived
|
||||
from .contactlist import ContactListScreen, ContactRow
|
||||
from .menus import icon_lane, marked_label
|
||||
from .purge_screen import purge_contacts
|
||||
from .sweep_screen import archive_contacts
|
||||
from .tui import Choice, Separator
|
||||
from .tui.screen import CANCEL
|
||||
from .widgets import ContactsSort, contact_packets
|
||||
@@ -57,13 +57,13 @@ YOU = ("you",)
|
||||
|
||||
#: The contact-list tail sentinels (distinct from any :class:`~meshterm.core.models.Contact`
|
||||
#: and from :data:`YOU`): the sweep, and the way in to what it has already taken.
|
||||
_PURGE = ("purge",)
|
||||
_ARCHIVE = ("archive",)
|
||||
_ARCHIVED = ("archived",)
|
||||
|
||||
#: Every mark the tail's maintenance rows lead with — the set the icon column is measured
|
||||
#: over (see :func:`~meshterm.ui.menus.icon_lane`). Declared rather than inferred so the
|
||||
#: column is one width for the whole tail whichever of the two rows a given visit draws.
|
||||
_TAIL_ICONS = ("🗑", "📂")
|
||||
_TAIL_ICONS = ("💾", "📂")
|
||||
|
||||
|
||||
class ContactsScreen(ContactListScreen):
|
||||
@@ -83,6 +83,7 @@ class ContactsScreen(ContactListScreen):
|
||||
counts: dict[str, int],
|
||||
sort: ContactsSort,
|
||||
archived: int = 0,
|
||||
locked: frozenset[str] = frozenset(),
|
||||
) -> None:
|
||||
"""Create the contacts screen over already-fetched contact data.
|
||||
|
||||
@@ -100,36 +101,28 @@ class ContactsScreen(ContactListScreen):
|
||||
``View archived contacts`` row and shows the count on it, so the tally is
|
||||
visible without opening the list; zero draws no row, since a screen should
|
||||
not offer a way into an empty list.
|
||||
locked: The full keys (lowercase hex) of the contacts locked against archiving;
|
||||
each of those rows closes its name lane on a padlock.
|
||||
"""
|
||||
rows = [ContactRow(value=YOU, name=self_name, key=self_key, you=True)]
|
||||
for c in contacts:
|
||||
rows.append(
|
||||
ContactRow(
|
||||
# The contact itself is the row's identity, so Enter hands the whole
|
||||
# record straight to the detail page — no re-lookup by name/key.
|
||||
value=c,
|
||||
name=c.name,
|
||||
key=c.public_key,
|
||||
node_type=c.node_type,
|
||||
last_seen=c.last_seen,
|
||||
count=contact_packets(c, counts),
|
||||
)
|
||||
)
|
||||
self._self_name, self._self_key = self_name, self_key
|
||||
self._contacts, self._counts = contacts, counts
|
||||
rows = self._contact_rows_for(locked)
|
||||
# The maintenance actions close the list, past every contact whatever the sort: a
|
||||
# blank spacer, then the err-tinted sweep (a `…` — it opens further prompts), then
|
||||
# the way in to what the sweep has already taken. The two sit together because they
|
||||
# are the two halves of one idea, and the archived row is *under* the purge for the
|
||||
# same reason: it is where the purge's output went.
|
||||
# One icon column for both rows: 🗑 draws a single cell where 📂 draws two, so an
|
||||
# unpadded mark would start the purge row's label a column left of the other's.
|
||||
# blank spacer, then the sweep (a `…` — it opens further prompts), then the way in
|
||||
# to what the sweep has already taken. The two sit together because they are the
|
||||
# two halves of one idea, and the archived row is *under* the sweep for the same
|
||||
# reason: it is where the sweep's output went. The sweep wears the single-contact
|
||||
# Archive action's 💾 and no red: it archives, and red is kept for data loss.
|
||||
# One icon column for both rows, measured rather than assumed, so a mark narrower
|
||||
# than its neighbour can't start its row's label a column left of the other's.
|
||||
lane = icon_lane(_TAIL_ICONS)
|
||||
tail: list = []
|
||||
if contacts:
|
||||
tail = [
|
||||
Separator(" "),
|
||||
Choice(
|
||||
title=marked_label("🗑", "Purge contacts…", "err", lane=lane),
|
||||
value=_PURGE,
|
||||
title=marked_label("💾", "Archive contacts…", "", lane=lane),
|
||||
value=_ARCHIVE,
|
||||
),
|
||||
]
|
||||
if archived:
|
||||
@@ -147,6 +140,34 @@ class ContactsScreen(ContactListScreen):
|
||||
tail=tail,
|
||||
)
|
||||
|
||||
def _contact_rows_for(self, locked: frozenset[str]) -> list[ContactRow]:
|
||||
"""Our own node, then every contact, each marked locked or not by ``locked``."""
|
||||
rows = [ContactRow(value=YOU, name=self._self_name, key=self._self_key, you=True)]
|
||||
for c in self._contacts:
|
||||
rows.append(
|
||||
ContactRow(
|
||||
# The contact itself is the row's identity, so Enter hands the whole
|
||||
# record straight to the detail page — no re-lookup by name/key.
|
||||
value=c,
|
||||
name=c.name,
|
||||
key=c.public_key,
|
||||
node_type=c.node_type,
|
||||
last_seen=c.last_seen,
|
||||
count=contact_packets(c, self._counts),
|
||||
locked=(c.public_key or "").lower().removeprefix("0x") in locked,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
def refresh_locks(self, locked: frozenset[str]) -> None:
|
||||
"""Redraw the padlocks in place after a detail page may have locked or unlocked one.
|
||||
|
||||
A lock changes a lane, not which contacts exist, so this is the
|
||||
:meth:`~meshterm.ui.contactlist.ContactListScreen.update_rows` case rather than a
|
||||
rebuild: the cursor, the sort, the scroll and a typed filter all stay where they were.
|
||||
"""
|
||||
self.update_rows(self._contact_rows_for(locked))
|
||||
|
||||
|
||||
async def open_contacts(
|
||||
ctx: AppContext,
|
||||
@@ -160,7 +181,7 @@ async def open_contacts(
|
||||
"""Open the interactive contacts list; Enter opens Node detail, Esc leaves.
|
||||
|
||||
The list stays pushed for the whole visit, so whichever node's detail page Enter commits
|
||||
(or the purge flow, or the archived list, that the tail actions open) nests *above* it
|
||||
(or the archive flow, or the archived list, that the tail actions open) nests *above* it
|
||||
and Esc from there is one pop back onto the row it was opened from — same sort, same
|
||||
filter, same scroll. Anything that changes which contacts the device holds — a sweep
|
||||
here, a single archive, restore or delete on a detail page, or a restore made from
|
||||
@@ -198,6 +219,7 @@ async def open_contacts(
|
||||
counts,
|
||||
sort,
|
||||
archived=_archived_count(ctx, self_key),
|
||||
locked=_locked_keys(ctx, self_key),
|
||||
)
|
||||
|
||||
screen = build()
|
||||
@@ -208,13 +230,13 @@ async def open_contacts(
|
||||
chosen = await visit.result()
|
||||
if chosen is CANCEL or chosen is None: # Esc
|
||||
return
|
||||
if chosen == _PURGE:
|
||||
# The purge ladder, its preview and its confirm all float over the list,
|
||||
if chosen == _ARCHIVE:
|
||||
# The archive ladder, its preview and its confirm all float over the list,
|
||||
# which is already the backdrop. A sweep that archived anything
|
||||
# invalidates the contacts cache, so the list is re-read and rebuilt —
|
||||
# the one deliberate reset in this screen's life, because the rows it was
|
||||
# keeping a place in are genuinely gone from the device.
|
||||
if await purge_contacts(ctx, self_key):
|
||||
if await archive_contacts(ctx, self_key):
|
||||
rebuild = True
|
||||
break
|
||||
continue
|
||||
@@ -234,6 +256,9 @@ async def open_contacts(
|
||||
if await open_node_detail(ctx, None if chosen == YOU else chosen):
|
||||
rebuild = True
|
||||
break
|
||||
# The page may have locked or unlocked the contact without ending the visit;
|
||||
# the store answers from memory, so the padlocks are simply redrawn.
|
||||
screen.refresh_locks(_locked_keys(ctx, self_key))
|
||||
if not rebuild:
|
||||
return
|
||||
contacts = await ctx.devstate.contacts()
|
||||
@@ -247,3 +272,12 @@ def _archived_count(ctx: AppContext, self_key: str) -> int:
|
||||
if store is None or not dev_pub:
|
||||
return 0
|
||||
return len(store.archived(dev_pub))
|
||||
|
||||
|
||||
def _locked_keys(ctx: AppContext, self_key: str) -> frozenset[str]:
|
||||
"""The keys of the contacts locked on this device — which rows draw a padlock."""
|
||||
store = getattr(ctx, "contact_store", None)
|
||||
dev_pub = (self_key or "").lower().removeprefix("0x")
|
||||
if store is None or not dev_pub:
|
||||
return frozenset()
|
||||
return store.locked_keys(dev_pub)
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""The PicoCalc console font's codepoint inventory — the glyph contract.
|
||||
|
||||
The PicoCalc panel draws through the kernel console with one 512-glyph PSF font
|
||||
(``meshterm.psf.gz``, built by ``scripts/calculinux-console-font.sh``): the stock
|
||||
(``meshterm.psf.gz``, built by ``scripts/picocalc/calculinux-console-font-6x12.sh``): the stock
|
||||
Terminus 6×12 base (a Cyrillic-coverage table — note there are **no accented Latin
|
||||
letters**), the full braille block, MeshTerm's own marks drawn into donor slots, and a
|
||||
few aliases. A character outside this set renders as a blank on the panel, so *nothing
|
||||
|
||||
+43
-15
@@ -158,13 +158,25 @@ def _rewrap(line: str, width: int) -> list[str]:
|
||||
|
||||
|
||||
def _state_intensity(row: str) -> str:
|
||||
"""Rewrite the row's colour changes so every one says whether it is bright.
|
||||
"""Rewrite the row's colour changes so brightness is stated as a colour, not as bold.
|
||||
|
||||
An art editor spells brightness the DOS way — ``1m`` lifts the bank and each span after
|
||||
it inherits that — which reads correctly only while the escapes stay one unbroken
|
||||
stream. They don't here: a row is handed to Rich on its own, and the console draws bold
|
||||
*as* the bright bank, so a span that never mentions intensity is read against whatever
|
||||
happened to be set. Saying it outright changes no colour; it just stops one drifting.
|
||||
An art editor spells brightness the DOS way — ``1m`` lifts the foreground into the
|
||||
bright bank, and each span after it inherits that. Two things then have to be settled
|
||||
before the row can be drawn anywhere else.
|
||||
|
||||
The first is that the inheritance does not survive: a row is handed to Rich on its own,
|
||||
so a span that never mentions intensity is read against whatever the *previous row* left
|
||||
set. That is resolved by working the state out here and saying it outright.
|
||||
|
||||
The second is that **bold is not brightness everywhere**. It is on the PicoCalc console
|
||||
and on a DOS one; it is not on macOS Terminal, where bold asks for a heavier face and
|
||||
leaves the colour alone. There ``1;30`` is not dark grey but plain black — and this art
|
||||
leans on it, seventy-nine spans of the wide mark being bold-black, much of that on a
|
||||
black ground. The mark came out muted, and the ``░▒▓`` dithers that fade one colour into
|
||||
another faded toward the wrong end. So brightness is emitted as the colour it means:
|
||||
``9N`` (the aixterm bright bank), which needs no bold and is also how the PicoCalc's own
|
||||
console spells bright. Nothing about *which* colour is being asked for changes; only the
|
||||
spelling, from one a terminal may read as a font weight to one that can only be a colour.
|
||||
"""
|
||||
out: list[str] = []
|
||||
bright = False
|
||||
@@ -177,16 +189,32 @@ def _state_intensity(row: str) -> str:
|
||||
continue
|
||||
params = found.group()[2:-1]
|
||||
parts = params.split(";") if params else [""]
|
||||
for part in parts: # what this sequence leaves the intensity set to
|
||||
if part in ("", "0", "22"):
|
||||
bright = False
|
||||
elif part == "1":
|
||||
bright = True
|
||||
dim = any(_DIM_FG.fullmatch(p) for p in parts)
|
||||
if dim and not any(p in _SAYS_INTENSITY for p in parts):
|
||||
out.append("\x1b[" + ";".join(["1" if bright else "22"] + parts) + "m")
|
||||
# What this sequence leaves the intensity set to, for the spans that follow it.
|
||||
says = [p for p in parts if p in ("", "0", "1", "22")]
|
||||
for part in says:
|
||||
bright = part == "1"
|
||||
# Whether *this* sequence's own foreground is bright: what it says, else what stands.
|
||||
here = bright
|
||||
fg = next((p for p in parts if _DIM_FG.fullmatch(p)), None)
|
||||
if fg is None:
|
||||
# No foreground to restate. A bare "1" has nothing left to do once brightness
|
||||
# travels as a colour, and dropping it keeps bold off glyphs a terminal may
|
||||
# redraw at another weight; a reset still has to go through.
|
||||
kept = [p for p in parts if p != "1"]
|
||||
if kept:
|
||||
out.append("\x1b[" + ";".join(kept) + "m")
|
||||
else:
|
||||
out.append(found.group())
|
||||
rewritten = []
|
||||
for part in parts:
|
||||
if part == "1":
|
||||
continue # brightness is in the colour now
|
||||
if part == fg:
|
||||
rewritten.append(str(int(fg) + 60) if here else fg)
|
||||
else:
|
||||
rewritten.append(part)
|
||||
if not here and "22" not in rewritten:
|
||||
rewritten.insert(0, "22") # say dim outright; never inherit a bank
|
||||
out.append("\x1b[" + ";".join(rewritten) + "m")
|
||||
at = found.end()
|
||||
return "".join(out)
|
||||
|
||||
|
||||
+18
-8
@@ -48,7 +48,7 @@ from .tui import (
|
||||
Separator,
|
||||
TuiSession,
|
||||
)
|
||||
from .tui.emoji_width import calibrate as calibrate_emoji_width
|
||||
from .tui.emoji_width import install as install_emoji_widths
|
||||
from .tui.spinner import spinner_interval
|
||||
from .widgets import battery_cell
|
||||
|
||||
@@ -379,13 +379,13 @@ async def run_menu(ctx: AppContext) -> None:
|
||||
Args:
|
||||
ctx: The shared application context.
|
||||
"""
|
||||
# Measure how this terminal renders emoji and align Rich to it, before
|
||||
# prompt_toolkit takes over the screen. This keeps every panel border — and
|
||||
# every chat bubble — aligned regardless of the terminal's emoji widths. Skipped
|
||||
# outright on a platform that never draws emoji (PicoCalc): there is nothing to
|
||||
# calibrate, and the probe writes escape sequences the console font can't shape.
|
||||
# Reserve two cells for every glyph that may be drawn as an emoji, in every width
|
||||
# authority, before prompt_toolkit lays out its first frame: the session pins each such
|
||||
# glyph into its reservation, so rows line up whatever the terminal's font does with it.
|
||||
# Skipped outright on a platform that never draws emoji (PicoCalc), where every glyph is
|
||||
# one its own font has verified and the stock widths are already exact.
|
||||
if get_platform().emoji:
|
||||
calibrate_emoji_width()
|
||||
install_emoji_widths()
|
||||
|
||||
header_cache: dict = {}
|
||||
session = TuiSession(header=lambda cols: _header(ctx, header_cache, cols))
|
||||
@@ -399,6 +399,7 @@ async def run_menu(ctx: AppContext) -> None:
|
||||
# Stop history + chat recording (closing their run records), the background
|
||||
# advert scheduler, and the always-on event hub, even on an unexpected exit.
|
||||
await ctx.adverts.aclose()
|
||||
await ctx.clock_sync.aclose()
|
||||
await ctx.monitor.aclose()
|
||||
await ctx.chat.aclose()
|
||||
await ctx.events.aclose()
|
||||
@@ -821,6 +822,10 @@ async def _resume_monitor(ctx: AppContext) -> None:
|
||||
await ctx.adverts.start()
|
||||
# The Watchtower only listens (rules over hub events), so it too is safe from launch.
|
||||
await ctx.watchtower.start()
|
||||
# The clock setter acts only when a connection settles (and at once on one the startup
|
||||
# picker already adopted), only when its preference is on, and only in the background;
|
||||
# it never opens the radio and never touches the screen — it reports in the log.
|
||||
await ctx.clock_sync.start()
|
||||
# The courier drains the outbox on its own paced schedule; each pass checks for a
|
||||
# connected device and skips quietly without one, exactly like the advert scheduler.
|
||||
await ctx.courier.start()
|
||||
@@ -943,7 +948,12 @@ async def _run_selection(ctx: AppContext, name: str) -> None:
|
||||
if is_connection_lost(exc):
|
||||
ctx.ui.discard() # drop the half-built output; the watcher will prompt to reconnect
|
||||
return
|
||||
ctx.ui.note(f"[err]✗ {title} failed:[/err] {exc}")
|
||||
# A tool that failed is a *caution*, not a loss: the run is over, nothing it was
|
||||
# going to do has been half-done, and the reader's next move is to try again. So it
|
||||
# takes the amber tone rather than the reserved red — the same tier a danger dialog
|
||||
# uses — and the message dialog reads that tone off the note to frame itself
|
||||
# (see :func:`~meshterm.ui.tui.session._message_border`).
|
||||
ctx.ui.note(f"[warn]⚠ {title} failed:[/warn] {exc}")
|
||||
await ctx.ui.present(title=title)
|
||||
return
|
||||
|
||||
|
||||
+15
-6
@@ -51,6 +51,7 @@ from rich.text import Text
|
||||
from ..platforms import get_platform
|
||||
from .theme import glyph
|
||||
from .tui import Choice, Separator
|
||||
from .tui.emoji_width import cut_cells, drawable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
@@ -619,6 +620,17 @@ def fit_cells(text: str, width: int, *, align: str = "left") -> str:
|
||||
truncation are measured in display cells (wide glyphs count 2), so a name carrying
|
||||
an emoji or CJK character can't skew the lanes the way ``str.ljust`` would.
|
||||
|
||||
A lane's text is very often a **node's name**, which is written by whoever owns the
|
||||
radio and arrives over the air, so two more things are settled here rather than by
|
||||
every caller: whatever a terminal cannot draw is folded to a space
|
||||
(:func:`~meshterm.ui.tui.emoji_width.drawable` — a newline or an escape in a name
|
||||
would end the row mid-lane, and no width table can measure that away), and the cut
|
||||
falls **between** glyphs (:func:`~meshterm.ui.tui.emoji_width.cut_cells`), never
|
||||
inside one. A cut a codepoint at a time slices an emoji sequence in half, and the
|
||||
stranded joiner it leaves swallows the ellipsis appended after it — the lane then
|
||||
measures one cell short of what the terminal draws, and every column to its right
|
||||
starts late.
|
||||
|
||||
Args:
|
||||
text: The text to fit.
|
||||
width: The exact cell width to return.
|
||||
@@ -627,13 +639,10 @@ def fit_cells(text: str, width: int, *, align: str = "left") -> str:
|
||||
Returns:
|
||||
A string measuring exactly ``width`` cells.
|
||||
"""
|
||||
if not text.isprintable():
|
||||
text = drawable(text)
|
||||
if cell_len(text) > width:
|
||||
kept = ""
|
||||
for ch in text:
|
||||
if cell_len(kept + ch) > width - 1:
|
||||
break
|
||||
kept += ch
|
||||
text = kept + "…"
|
||||
text = cut_cells(text, width - 1) + "…"
|
||||
pad = " " * max(0, width - cell_len(text))
|
||||
return pad + text if align == "right" else text + pad
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ the node itself, in full:
|
||||
the rest), ``Time machine``, ``Share contact`` — a popup contact card (QR code +
|
||||
``meshcore://`` link, see :func:`~meshterm.ui.config_editor.show_contact_card`),
|
||||
offered whenever the node's full key is known — and, last, ``Remove contact``: the
|
||||
single-contact counterpart to the Contacts list's bulk purge (see
|
||||
single-contact counterpart to the Contacts list's bulk archive (see
|
||||
:mod:`~meshterm.ui.contacts_screen`), dropping *this* node from the device's contact
|
||||
table behind a red confirm. It is the one thing on the page that changes anything, so
|
||||
it sits at the foot of the actions, and committing it closes the page — the contact it
|
||||
@@ -446,10 +446,7 @@ class NodeDetailScreen(Screen):
|
||||
#: The action rows' icon column, in cells — measured once over every mark this page
|
||||
#: can draw, so a one-cell ``🗑`` pads out to its two-cell siblings and every label
|
||||
#: starts in the same column. Zero on a platform that draws no icon lane at all.
|
||||
self._icon_lane = menus.icon_lane(
|
||||
action.glyph
|
||||
for action in (*self._info_actions, *([trace_action] if trace_action else []))
|
||||
)
|
||||
self._icon_lane = self._measure_icon_lane()
|
||||
self._tab_index = 0
|
||||
self._row_index = 0
|
||||
#: The highlighted route on the Routes tab (drives the graph); tracks the cursor as
|
||||
@@ -574,6 +571,31 @@ class NodeDetailScreen(Screen):
|
||||
return 0
|
||||
return 2 if self._tabs[self._tab_index].kind == "info" else 0
|
||||
|
||||
def _measure_icon_lane(self) -> int:
|
||||
"""The action rows' icon column over every mark currently on the page."""
|
||||
trace = [self._trace_action] if self._trace_action else []
|
||||
return menus.icon_lane(action.glyph for action in (*self._info_actions, *trace))
|
||||
|
||||
def replace_info_actions(self, actions: list[_Action]) -> None:
|
||||
"""Swap the Info tab's action rows in place, keeping the cursor on the row it was on.
|
||||
|
||||
For an action that changes what the page offers without ending the visit — locking a
|
||||
contact turns its row into Unlock and withdraws Archive. The highlight follows the
|
||||
committed row by its :attr:`_Action.key` family (``lock`` and ``unlock`` are one
|
||||
row), so the reader's next press lands where their last one did.
|
||||
"""
|
||||
focus = self._focusables()
|
||||
current = focus[self._row_index % len(focus)][1] if focus else None
|
||||
was = getattr(current, "key", None)
|
||||
self._info_actions = list(actions)
|
||||
self._icon_lane = self._measure_icon_lane()
|
||||
family = {"lock": "unlock", "unlock": "lock"}
|
||||
for index, (_kind, payload) in enumerate(self._focusables()):
|
||||
key = getattr(payload, "key", None)
|
||||
if key is not None and key in (was, family.get(was or "")):
|
||||
self._row_index = index
|
||||
break
|
||||
|
||||
def handle(self, action: str, data: str = "") -> None:
|
||||
"""Answer one key press on the page.
|
||||
|
||||
@@ -1089,8 +1111,15 @@ async def open_node_detail(
|
||||
on the Info tab, and the page offers exactly the ones that make sense — a live contact
|
||||
can be archived or deleted, an archived one restored or deleted.
|
||||
|
||||
**Locking is management that stays.** A live contact can be locked against archiving
|
||||
(see :meth:`~meshterm.core.contact_store.ContactStore.set_locked`): the sweep then never
|
||||
counts it a candidate, and this page withdraws its Archive row for as long as the lock
|
||||
holds. The toggle rewrites the page's action rows in place rather than ending the visit
|
||||
— nothing about the contact the page describes has gone — and the list underneath redraws
|
||||
its padlock when the reader comes back to it.
|
||||
|
||||
``manage`` is how a caller says the page is being opened to *look*, not to act. The
|
||||
purge preview passes ``False``: a screen whose whole job is choosing what to archive
|
||||
archive preview passes ``False``: a screen whose whole job is choosing what to archive
|
||||
should not also hand out a second, singular way to archive — or a delete — from inside
|
||||
its own candidate list. The rule is that management verbs belong to the list a contact
|
||||
actually lives in, never to a page opened out of a list that is about to act on it
|
||||
@@ -1100,7 +1129,8 @@ async def open_node_detail(
|
||||
ctx: The shared application context (must be running the interactive TUI).
|
||||
contact: The contact to detail, or ``None`` for our own node (an identity-and-ledger
|
||||
page — we never overhear ourselves, so there is no reception history to show).
|
||||
manage: Whether to offer the contact-management actions (archive, restore, delete).
|
||||
manage: Whether to offer the contact-management actions (lock, archive, restore,
|
||||
delete).
|
||||
``False`` draws none of them, and the page can then only ever return ``False``.
|
||||
|
||||
Returns:
|
||||
@@ -1328,18 +1358,41 @@ async def open_node_detail(
|
||||
and contact is not None
|
||||
and bool(contact.public_key or contact.key_prefix)
|
||||
)
|
||||
if manageable:
|
||||
# The constructive verb first, then the destructive one, so the row a mistaken press
|
||||
# is likeliest to land on is the recoverable one. Archive and restore are the two
|
||||
# halves of one reversible move, and take the ``💾``/``📂`` pair the lexicon already
|
||||
# gives put-it-away and bring-it-back; only a contact with a full key can be written
|
||||
# back to the device, so a prefix-only contact is never offered the archive that
|
||||
# would strand it.
|
||||
store = getattr(ctx, "contact_store", None)
|
||||
dev_pub = self_key.lower().removeprefix("0x")
|
||||
whole_key = contact is not None and len(contact.public_key.lower().removeprefix("0x")) == 64
|
||||
# A lock is MeshTerm's own mark, hung on the full key in the contact store, and only a
|
||||
# live contact carries one — it exists to keep a contact off the archive path.
|
||||
lockable = manageable and not archived and whole_key and store is not None and bool(dev_pub)
|
||||
base_actions = list(info_actions)
|
||||
|
||||
def management(locked: bool) -> list[_Action]:
|
||||
"""The page's action rows, closing on the management group for this lock state."""
|
||||
rows = list(base_actions)
|
||||
if not manageable:
|
||||
return rows
|
||||
# The lock first: it changes nothing on the device and ends nothing, so it is the
|
||||
# safest row in the group to land on. Then the constructive verb, then the
|
||||
# destructive one, so the row a mistaken press is likeliest to land on is the
|
||||
# recoverable one. Archive and restore are the two halves of one reversible move,
|
||||
# and take the ``💾``/``📂`` pair the lexicon already gives put-it-away and
|
||||
# bring-it-back; only a contact with a full key can be written back to the device,
|
||||
# so a prefix-only contact is never offered the archive that would strand it — and
|
||||
# a locked one is never offered it at all, which is what the lock is for.
|
||||
if lockable:
|
||||
if locked:
|
||||
rows.append(_Action("unlock", "🔓", "", "Unlock contact"))
|
||||
else:
|
||||
rows.append(_Action("lock", "🔒", "", "Lock contact"))
|
||||
if archived:
|
||||
info_actions.append(_Action("restore", "📂", "ok", "Restore to device"))
|
||||
elif len((contact.public_key or "").lower().removeprefix("0x")) == 64:
|
||||
info_actions.append(_Action("archive", "💾", "", "Archive contact"))
|
||||
info_actions.append(_Action("remove", "🗑", "err", "Delete contact…"))
|
||||
rows.append(_Action("restore", "📂", "ok", "Restore to device"))
|
||||
elif whole_key and not locked:
|
||||
rows.append(_Action("archive", "💾", "", "Archive contact"))
|
||||
rows.append(_Action("remove", "🗑", "err", "Delete contact…"))
|
||||
return rows
|
||||
|
||||
locked = bool(lockable and store.is_locked(dev_pub, contact.public_key))
|
||||
info_actions = management(locked)
|
||||
try:
|
||||
adv_type = int(node_type) if node_type is not None else 1
|
||||
except (TypeError, ValueError):
|
||||
@@ -1410,6 +1463,13 @@ async def open_node_detail(
|
||||
if await _restore_archived(ctx, contact, self_key, label):
|
||||
contact_removed = True
|
||||
break
|
||||
elif action in ("lock", "unlock"):
|
||||
# No confirm and no notice: the lock is reversible from the very row that set
|
||||
# it, and the row turning into its opposite is the acknowledgement.
|
||||
assert contact is not None and store is not None # only offered when lockable
|
||||
locked = action == "lock"
|
||||
store.set_locked(dev_pub, contact, locked)
|
||||
screen.replace_info_actions(management(locked))
|
||||
elif action == "archive":
|
||||
assert contact is not None
|
||||
if await _archive_contact(ctx, contact, self_key, label):
|
||||
@@ -1451,7 +1511,7 @@ async def _archive_contact(ctx: AppContext, contact: Contact, self_key: str, lab
|
||||
"""Confirm and archive one contact off the device; ``True`` once it is gone from the radio.
|
||||
|
||||
The single-contact counterpart to the bulk sweep (see
|
||||
:func:`~meshterm.ui.purge_screen.purge_contacts`), doing exactly what the sweep does to
|
||||
:func:`~meshterm.ui.sweep_screen.archive_contacts`), doing exactly what the sweep does to
|
||||
each of its victims: remove from the device, then record it in the cross-session store
|
||||
with an archive stamp so nothing is actually lost.
|
||||
|
||||
@@ -1478,10 +1538,30 @@ async def _archive_contact(ctx: AppContext, contact: Contact, self_key: str, lab
|
||||
assert isinstance(ctx.ui, TuiUi) # guaranteed by open_node_detail
|
||||
session = ctx.ui.session
|
||||
|
||||
# The contact is named as the Contacts list names it: the type mark in its own colour,
|
||||
# then the name in its key-derived hue (a bare id standing in for a nameless one is
|
||||
# ``node.unknown``). The prose carries the amber span by span rather than as the
|
||||
# prompt's base style, whose bold Rich would merge into the mark.
|
||||
glyph, glyph_style = NODE_GLYPHS.get(contact.node_type, DEFAULT_GLYPH)
|
||||
prompt = Text()
|
||||
prompt.append("Archive ", style="warn")
|
||||
prompt.append(f"{glyph} ", style=glyph_style)
|
||||
prompt.append(
|
||||
label,
|
||||
style=(
|
||||
name_style(label, contact.public_key or contact.key_prefix)
|
||||
if contact.name
|
||||
else "node.unknown"
|
||||
),
|
||||
)
|
||||
prompt.append(
|
||||
"? It comes off this device's contact list, freeing a slot for a new one. MeshTerm "
|
||||
"keeps it — with its key, reception history and messages — and you can restore it "
|
||||
"at any time.",
|
||||
style="warn",
|
||||
)
|
||||
if not await ctx.ui.dialog(
|
||||
f"Archive {label}? It comes off this device's contact list, freeing a slot for a "
|
||||
"new one. MeshTerm keeps it — with its key, reception history and messages — and "
|
||||
"you can restore it at any time.",
|
||||
prompt,
|
||||
[("Cancel", False), ("Archive", True)],
|
||||
title="Archive contact",
|
||||
default=1,
|
||||
@@ -1515,7 +1595,7 @@ async def _restore_archived(ctx: AppContext, contact: Contact, self_key: str, la
|
||||
"""Write an archived contact back onto the device; ``True`` once it is live again.
|
||||
|
||||
The inverse of the Contacts sweep, one contact at a time (see
|
||||
:mod:`~meshterm.ui.purge_screen`). The device write comes **first** and the store's
|
||||
:mod:`~meshterm.ui.sweep_screen`). The device write comes **first** and the store's
|
||||
archive mark is cleared only once it succeeded — a failed write must never leave a
|
||||
contact listed as live on a radio that doesn't hold it, which would be a row you cannot
|
||||
message and cannot restore.
|
||||
@@ -1560,7 +1640,7 @@ async def _remove_contact(ctx: AppContext, contact: Contact, self_key: str, labe
|
||||
"""Confirm and drop one contact from the device; ``True`` once it is gone.
|
||||
|
||||
The single-contact counterpart to the Contacts list's bulk sweep (see
|
||||
:func:`~meshterm.ui.purge_screen.purge_contacts`) — but a *deletion* where that one
|
||||
:func:`~meshterm.ui.sweep_screen.archive_contacts`) — but a *deletion* where that one
|
||||
archives: this is the way to make MeshTerm forget a node entirely, so it removes the
|
||||
contact in both places, because the list a screen sees is the *union* of the two: the device's
|
||||
own contact table, and the contacts MeshTerm remembers for that device (see
|
||||
@@ -1641,7 +1721,7 @@ async def _remove_contact(ctx: AppContext, contact: Contact, self_key: str, labe
|
||||
ctx.contact_store.forget(dev_pub, contact.public_key)
|
||||
# The device's table just changed under the session cache; the list we return to re-reads.
|
||||
ctx.devstate.invalidate_contacts()
|
||||
# No success notice: unlike the purge — whose outcome is a count nobody could predict —
|
||||
# No success notice: unlike the archive sweep — whose outcome is a count nobody could predict —
|
||||
# this one is self-evident. The page closes on the contact it detailed and the list
|
||||
# behind it comes back without the row, which says it better than a dialog to dismiss.
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""OKLab — the perceptual colour space the UI measures and shades ``#rrggbb`` values in.
|
||||
|
||||
Where the app has to ask a question about two colours *as the eye sees them* — are these
|
||||
two chip fills the same to a reader, what is a slightly darker version of this one — it
|
||||
asks here, not in RGB and not by hue. sRGB is a device encoding: a fixed step in it is a
|
||||
different size to the eye in every region of the wheel (a step across the greens is a
|
||||
whisper, the same step across the cyans a shout), and a hue-only comparison is worse,
|
||||
blind to lightness and saturation altogether. OKLab (Björn Ottosson, 2020) is built so
|
||||
that Euclidean distance approximates perceived difference and so that changing ``L``
|
||||
alone changes lightness and nothing else, which is exactly the two operations wanted.
|
||||
|
||||
Everything is the reference transform, no dependencies: linear sRGB → LMS → cube root →
|
||||
Lab. ``L`` runs 0 (black) to 1 (white); ``a``/``b`` are the two chroma axes, roughly
|
||||
±0.4 at the sRGB gamut's edge. A distance of about 0.02 is a just-noticeable difference
|
||||
between two large patches.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
#: An OKLab triple ``(L, a, b)``.
|
||||
Lab = tuple[float, float, float]
|
||||
|
||||
|
||||
def _linear(channel: float) -> float:
|
||||
"""The sRGB transfer function, 0–1 encoded → 0–1 linear."""
|
||||
return channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4
|
||||
|
||||
|
||||
def _encoded(channel: float) -> float:
|
||||
"""The inverse: 0–1 linear → 0–1 sRGB encoded."""
|
||||
return 12.92 * channel if channel <= 0.0031308 else 1.055 * channel ** (1 / 2.4) - 0.055
|
||||
|
||||
|
||||
def from_hex(colour: str) -> Lab:
|
||||
"""``#rrggbb`` → OKLab.
|
||||
|
||||
Args:
|
||||
colour: A six-digit hex colour, ``#`` optional, either case.
|
||||
|
||||
Raises:
|
||||
ValueError: If the string is not six hex digits.
|
||||
"""
|
||||
raw = colour.removeprefix("#")
|
||||
if len(raw) != 6:
|
||||
raise ValueError(f"not a #rrggbb colour: {colour!r}")
|
||||
r, g, b = (_linear(int(raw[i : i + 2], 16) / 255) for i in (0, 2, 4))
|
||||
l_ = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1 / 3)
|
||||
m_ = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1 / 3)
|
||||
s_ = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1 / 3)
|
||||
return (
|
||||
0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
|
||||
1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
|
||||
0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
|
||||
)
|
||||
|
||||
|
||||
def to_hex(lab: Lab) -> str:
|
||||
"""OKLab → ``#rrggbb``, each channel clipped to the sRGB gamut.
|
||||
|
||||
Clipping is the honest answer for the one caller that can leave the gamut — a
|
||||
saturated fill darkened at constant chroma pushes a channel below zero — because the
|
||||
clipped colour is the nearest the terminal can show, and it stays the right hue to
|
||||
the eye at the shifts the app asks for.
|
||||
"""
|
||||
lightness, a, b = lab
|
||||
l_ = lightness + 0.3963377774 * a + 0.2158037573 * b
|
||||
m_ = lightness - 0.1055613458 * a - 0.0638541728 * b
|
||||
s_ = lightness - 0.0894841775 * a - 1.2914855480 * b
|
||||
lms = (l_**3, m_**3, s_**3)
|
||||
linear = (
|
||||
4.0767416621 * lms[0] - 3.3077115913 * lms[1] + 0.2309699292 * lms[2],
|
||||
-1.2684380046 * lms[0] + 2.6097574011 * lms[1] - 0.3413193965 * lms[2],
|
||||
-0.0041960863 * lms[0] - 0.7034186147 * lms[1] + 1.7076147010 * lms[2],
|
||||
)
|
||||
return "#" + "".join(f"{round(_encoded(min(1.0, max(0.0, c))) * 255):02x}" for c in linear)
|
||||
|
||||
|
||||
def distance(first: str, second: str) -> float:
|
||||
"""Perceived difference between two ``#rrggbb`` colours: Euclidean distance in OKLab.
|
||||
|
||||
``0`` for the same colour; about ``0.02`` at the threshold of noticing between two
|
||||
large patches; ``1`` is black to white.
|
||||
"""
|
||||
return math.dist(from_hex(first), from_hex(second))
|
||||
|
||||
|
||||
def shaded(colour: str, by: float, *, floor: float | None = None) -> str:
|
||||
"""``colour`` moved ``by`` in lightness, away from whichever end it is nearer.
|
||||
|
||||
A light colour comes back darker and a dark one lighter, at the same chroma, so the
|
||||
result is recognisably *the same colour, shaded* rather than a different one — the
|
||||
version of a fill that draws a line on itself.
|
||||
|
||||
Args:
|
||||
colour: The ``#rrggbb`` to shade.
|
||||
by: The lightness shift, in OKLab ``L`` (``0.1`` is a clear step, ``0.25`` bold).
|
||||
floor: If given, the shift starts from this lightness rather than the colour's
|
||||
own where that is further along — a caller guaranteeing the result clears a
|
||||
*second* colour's lightness by ``by`` as well as this one's passes that
|
||||
colour's ``L``. Read as "at least this far from both".
|
||||
"""
|
||||
lightness, a, b = from_hex(colour)
|
||||
if lightness >= 0.5:
|
||||
start = lightness if floor is None else min(lightness, floor)
|
||||
return to_hex((start - by, a, b))
|
||||
start = lightness if floor is None else max(lightness, floor)
|
||||
return to_hex((start + by, a, b))
|
||||
+82
-18
@@ -15,13 +15,17 @@ drawn*, so every surface the survey found can eventually route through it:
|
||||
chevron: the oh-my-posh look, at one cell a seam. The chip fill is the node's
|
||||
hash-derived hue (:func:`~meshterm.ui.theme.node_style`), our own node the map's yellow
|
||||
``★`` on a neutral dark grey, a faded hop dark slate, a keyless hop grey. Two chips that
|
||||
land on the *same* fill — a mirrored return leg, a stretch of keyless greys — take the
|
||||
one exception the interlock can't draw, dropping the background so the page cuts the
|
||||
wedge (:meth:`PathLine._seam`). Hops elided out of the middle break the ribbon instead
|
||||
of joining it: the mark sits bare on the page between a closing point and the next
|
||||
chip's notch (:func:`elision_hop`), because a filled ``⋯`` chip would read as a node by
|
||||
that name. A chip caught by a *cut* — a lane that ran out, a line scrolled past its
|
||||
edge — breaks off on a half block in its own fill
|
||||
land on the *same* fill — a mirrored return leg, a stretch of keyless greys — or on two
|
||||
fills too close to tell apart (under :data:`SEAM_BLUR` apart in OKLab, the perceptual
|
||||
distance :mod:`~meshterm.ui.oklab` measures) take the one exception the interlock can't
|
||||
draw: the seam is the *thin* chevron instead, the previous fill shaded a step darker
|
||||
(lighter, for a dark fill) and drawn on the next, so the ribbon runs on unbroken and the
|
||||
join is a line the chip draws on itself (:meth:`PathLine._seam`). Hops elided out of
|
||||
the middle break the
|
||||
ribbon instead of joining it: the mark sits bare on the page between a closing point and
|
||||
the next chip's notch (:func:`elision_hop`), because a filled ``⋯`` chip would read as a
|
||||
node by that name. A chip caught by a *cut* — a lane that ran out, a line scrolled past
|
||||
its edge — breaks off on a half block in its own fill
|
||||
(:func:`cut_mark`), and that crack is what says the segment continues; only chips
|
||||
crack, an arrow line still ellipsizes. A line's two *outer* ends read the same way:
|
||||
a path that begins at its origin and ends at its destination opens and closes flat (a
|
||||
@@ -85,17 +89,24 @@ from rich.measure import Measurement
|
||||
from rich.text import Text
|
||||
|
||||
from ..core.models import LOCAL_DEVICE_LABEL
|
||||
from . import oklab
|
||||
from .marks import SELF_MARK
|
||||
from .termfont import powerline_enabled, powerline_full
|
||||
from .theme import active_theme, node_style
|
||||
|
||||
#: The powerline solid right-pointing triangle (U+E0B0) — the only glyph the widget
|
||||
#: draws, seam and closing edge alike, deliberately from the *core* set so every
|
||||
#: recommended font qualifies (the rounded caps at U+E0B4+ exist only in full Nerd
|
||||
#: Font patches). A path only ever flows one way, so the point only ever faces right;
|
||||
#: what changes is whether it lands on a field (a seam) or on the page (an edge).
|
||||
#: The powerline solid right-pointing triangle (U+E0B0) — the glyph the widget draws
|
||||
#: at every seam and closing edge, deliberately from the *core* set so every recommended
|
||||
#: font qualifies (the rounded caps at U+E0B4+ exist only in full Nerd Font patches). A
|
||||
#: path only ever flows one way, so the point only ever faces right; what changes is
|
||||
#: whether it lands on a field (a seam) or on the page (an edge).
|
||||
POWERLINE_SEP = "\ue0b0"
|
||||
|
||||
#: The thin right-pointing chevron (U+E0B1), the solid point's outline sibling in the
|
||||
#: same core set — powerline's own mark for a join *inside* one colour. It draws the seam
|
||||
#: between two chips whose fills the eye can't tell apart (:meth:`PathLine._seam`): the
|
||||
#: previous fill, shaded, drawn on the next, where a solid point would have vanished.
|
||||
POWERLINE_THIN = "\ue0b1"
|
||||
|
||||
#: The rounded caps (U+E0B6 opening, U+E0B4 closing) that finish a path's two outer
|
||||
#: ends as a lozenge. These live in the *extended* block, which only a full Nerd Font
|
||||
#: patch carries (:func:`~meshterm.ui.termfont.powerline_full`) — a terminal with just
|
||||
@@ -249,6 +260,48 @@ def _style_hex(style: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
#: How far apart two chip fills must sit, as the eye measures it, for the seam between
|
||||
#: them to interlock: closer than this and the solid point would be laid on a field it
|
||||
#: can't be told from, so the seam is the thin chevron instead (:meth:`PathLine._seam`).
|
||||
#: The unit is OKLab distance (:func:`~meshterm.ui.oklab.distance`), where ``0.02`` is a
|
||||
#: just-noticeable difference between two large patches and ``1`` is black to white.
|
||||
#:
|
||||
#: The rule it replaced was first key bytes under ``0x10`` apart on the hue wheel (JP,
|
||||
#: 2026-09-15), which measures ``0.087`` at the median hue but ``0.026`` across the greens
|
||||
#: and ``0.165`` across the cyans: the wheel is not uniform to the eye, so a hue gap
|
||||
#: blurred too little there and too much here, and an sRGB distance would only have
|
||||
#: restated the hue gap (a fixed byte step is a near-constant ``55``–``60`` of 8-bit RGB
|
||||
#: all the way round). The value was then set by eye on a ladder of the spectrum's own
|
||||
#: pairs drawn both ways (JP, 2026-09-16), and it sits where the solid point is not just
|
||||
#: detectable at one cell but *found* without looking for it: ``0.06``, three noticeable
|
||||
#: differences. Tighter (``0.035``) left wedges a reader had to hunt for; looser
|
||||
#: (``0.10``) blurred pairs whose point still read plainly; ``0.05`` was a shade too
|
||||
#: tight. ``0x10`` of green is ``0.026`` and blurs; ``0x10`` of red is ``0.124`` and
|
||||
#: doesn't; the two greys, ``0.18`` apart, keep their interlock on the metric alone.
|
||||
SEAM_BLUR = 0.06
|
||||
|
||||
#: How far the thin chevron's colour steps from the fill it is drawn in, in OKLab ``L``
|
||||
#: — a lightness shift alone, so it reads as *that chip's colour, shaded* rather than as
|
||||
#: a third colour on the line. The step is taken from the previous fill but measured from
|
||||
#: whichever of the two fills is further along (:func:`~meshterm.ui.oklab.shaded`'s
|
||||
#: ``floor``), so the mark clears the fill it actually sits on by this much even when the
|
||||
#: two differ by everything :data:`SEAM_BLUR` allows. A lightness step is what a thin line
|
||||
#: is seen by (the eye resolves detail in luminance, not in chroma), so it needs nothing
|
||||
#: like the blur distance to read: ``0.10`` is five noticeable differences, a clear line
|
||||
#: that is still plainly the chip's own colour, chosen on the same ladder (JP, 2026-09-16).
|
||||
SEAM_SHADE = 0.10
|
||||
|
||||
|
||||
def _fills_blur(before: str, after: str) -> bool:
|
||||
"""Whether two fills are the same to the eye: identical, or under :data:`SEAM_BLUR`."""
|
||||
return before == after or oklab.distance(before, after) < SEAM_BLUR
|
||||
|
||||
|
||||
def _seam_ink(before: str, after: str) -> str:
|
||||
"""The thin chevron's colour: ``before`` shaded :data:`SEAM_SHADE` past both fills."""
|
||||
return oklab.shaded(before, SEAM_SHADE, floor=oklab.from_hex(after)[0])
|
||||
|
||||
|
||||
#: A chip's fill, as it appears in a rendered span's style — the ``on #rrggbb`` half.
|
||||
#: Only :meth:`PathLine._chip` sets a background, which is what makes this the test for
|
||||
#: "is there a chip here": a seam's point, a notch, and every arrow-mode span carry a
|
||||
@@ -960,12 +1013,23 @@ class PathLine:
|
||||
to spend two cells leaving a sliver of page between every pair, insurance against
|
||||
a collision that the palette makes rare).
|
||||
|
||||
Two fills that *do* land the same are the exception the interlock cannot draw —
|
||||
Two fills the eye can't tell apart are the exception the interlock cannot draw —
|
||||
a chevron in its own background is no chevron — and they are not always bad luck:
|
||||
a mirrored return leg is a run of identically faded hops by construction, and so
|
||||
is a stretch of keyless greys. There the point drops its background and the page
|
||||
shows through the wedge instead, which is the one thing that still separates two
|
||||
blocks of one colour. One cell either way.
|
||||
is a stretch of keyless greys. Nor is "can't tell apart" the same as "identical":
|
||||
two hues a few steps apart on the wheel draw a point the eye can't find either,
|
||||
one shade of green on the next, so the test is a perceptual distance under
|
||||
:data:`SEAM_BLUR` (:func:`_fills_blur`), not equality and not a hue gap.
|
||||
|
||||
There the seam is the **thin** chevron, :data:`POWERLINE_THIN`, in the previous
|
||||
chip's own fill shaded a step darker — lighter, for a dark fill — and drawn on
|
||||
the fill ahead (:func:`_seam_ink`, JP, 2026-09-16): powerline's own mark for a
|
||||
join inside one colour, and the chip it belongs to drawing a line on itself, so
|
||||
the ribbon runs on unbroken and nothing on it is a colour that isn't a chip's.
|
||||
The solid point drawn bare used to cut a wedge of page out of the route here (a
|
||||
run of near hues read as dashes, and on a light terminal the wedge was a hole),
|
||||
and a chevron in the dark chip ink read as a mark laid on the route rather than a
|
||||
seam in it. One cell either way.
|
||||
|
||||
Args:
|
||||
before: The fill the point is drawn in (the previous chip's).
|
||||
@@ -974,8 +1038,8 @@ class PathLine:
|
||||
Returns:
|
||||
The seam's single styled cell.
|
||||
"""
|
||||
if before == after:
|
||||
return Text(POWERLINE_SEP, style=before) # fg only — the page cuts the wedge
|
||||
if _fills_blur(before, after):
|
||||
return Text(POWERLINE_THIN, style=f"{_seam_ink(before, after)} on {after}")
|
||||
return Text(POWERLINE_SEP, style=f"{before} on {after}")
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -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:
|
||||
|
||||
+58
-19
@@ -32,25 +32,53 @@ Validator = Callable[[str], "bool | str"]
|
||||
|
||||
#: Buffered output no taller than this many lines qualifies for the message dialog —
|
||||
#: the OK popup :meth:`TuiUi.present` shows instead of a full result window. Kept small:
|
||||
#: a dialog is an acknowledgement, not a reading surface.
|
||||
#: a dialog is an acknowledgement, not a reading surface. It counts the lines the tool
|
||||
#: *emitted*, before any wrapping: one sentence is one outcome however long it runs.
|
||||
_DIALOG_MAX_LINES = 3
|
||||
|
||||
#: ... and no line may be wider than this many cells, so the popup stays a tidy box on a
|
||||
#: typical terminal and never has to wrap. Anything wider (a long path, a verbose error)
|
||||
#: falls back to the scrollable result window, which wraps properly.
|
||||
_DIALOG_MAX_CELLS = 76
|
||||
#: ... and no taller than this once wrapped to the box's width, which is what the box
|
||||
#: actually has to draw. The two counts differ only for a long line, and that is the case
|
||||
#: worth bounding: a three-line outcome stays a popup, while a note long enough to be a
|
||||
#: page of reading opens the scrollable window it belongs in.
|
||||
_DIALOG_MAX_WRAPPED = 8
|
||||
|
||||
#: What :class:`~meshterm.ui.tui.prompt.ButtonDialog` spends on chrome around its widest
|
||||
#: line — its own ``dialog_width`` margin (panel padding, border, breathing room). Taken
|
||||
#: off the platform's readable width, it leaves the cells a message may actually use.
|
||||
_DIALOG_CHROME_CELLS = 12
|
||||
|
||||
|
||||
def _dialog_max_cells() -> int:
|
||||
"""The popup-qualifying width for the active platform.
|
||||
def _dialog_wrap_cells() -> int:
|
||||
"""How wide a line may be inside the message popup, on the active platform.
|
||||
|
||||
The regular platform keeps the long-standing 76. On the PicoCalc the dialog frame
|
||||
itself is capped at ``cols - 6 = 47`` outer (43 inner), so a line qualifying at 76
|
||||
would wrap inside the box it was promoted into — the gate shrinks to match.
|
||||
A dialog sizes to its widest line and the compositor caps that to the terminal, so a
|
||||
line long enough to need the cap is a line clipped on a readable-width screen. Wrapping
|
||||
to what fits *there* is what lets a long outcome — the sentence explaining why a read
|
||||
failed — stay a popup at all rather than commandeering the whole frame.
|
||||
"""
|
||||
from ..platforms import get_platform
|
||||
|
||||
return _DIALOG_MAX_CELLS if get_platform().frame_border else 43
|
||||
platform = get_platform()
|
||||
return max(24, platform.readable_cols - platform.dialog_margin - _DIALOG_CHROME_CELLS)
|
||||
|
||||
|
||||
def _wrapped(lines: list[Text], width: int) -> list[Text]:
|
||||
"""Re-flow ``lines`` to ``width`` cells, keeping every span they carry.
|
||||
|
||||
A blank line wraps to nothing, and a blank line is content in an outcome block, so it
|
||||
is kept as itself. The wrap leaves the break's trailing space on the line it ended,
|
||||
which the dialog would then centre the line around — so each one is trimmed.
|
||||
"""
|
||||
from .tui.render import _console
|
||||
|
||||
console = _console(width)
|
||||
out: list[Text] = []
|
||||
for line in lines:
|
||||
parts = list(line.wrap(console, width)) or [Text("")]
|
||||
for part in parts:
|
||||
part.rstrip() # in place; Rich's rstrip returns nothing
|
||||
out.extend(parts)
|
||||
return out
|
||||
|
||||
|
||||
class _NullBusy:
|
||||
@@ -62,13 +90,19 @@ class _NullBusy:
|
||||
|
||||
|
||||
def _collapse_to_message(buffered: list[RenderableType]) -> Text | None:
|
||||
"""Collapse small, text-only buffered output into one dialog message.
|
||||
"""Collapse small, text-only buffered output into one wrapped dialog message.
|
||||
|
||||
This is the gate for :meth:`TuiUi.present`'s popup upgrade: output qualifies only
|
||||
when everything buffered is plain note text (:class:`Text` — a table or panel from
|
||||
``show()`` disqualifies the lot) totalling at most :data:`_DIALOG_MAX_LINES` lines,
|
||||
none wider than :data:`_DIALOG_MAX_CELLS` cells. Styling (the ``[ok]``/``[err]``
|
||||
markup on outcome notes) is preserved in the joined message.
|
||||
``show()`` disqualifies the lot) totalling at most :data:`_DIALOG_MAX_LINES` emitted
|
||||
lines, and at most :data:`_DIALOG_MAX_WRAPPED` once re-flowed to the box's width.
|
||||
Styling (the ``[ok]``/``[warn]`` markup on outcome notes) is preserved in the joined
|
||||
message, which is what lets the dialog frame take its tone from it.
|
||||
|
||||
The width is a **wrap**, not a rejection. A one-sentence outcome is an outcome to
|
||||
acknowledge however long it runs, and sending it to the scrollable window for being
|
||||
140 cells wide made the least interesting results the ones that took over the screen —
|
||||
a tool that failed before it produced anything most of all.
|
||||
|
||||
Args:
|
||||
buffered: The renderables collected since the last present.
|
||||
@@ -84,7 +118,8 @@ def _collapse_to_message(buffered: list[RenderableType]) -> Text | None:
|
||||
lines.extend(item.split("\n") or [item])
|
||||
if len(lines) > _DIALOG_MAX_LINES:
|
||||
return None
|
||||
if any(line.cell_len > _dialog_max_cells() for line in lines):
|
||||
lines = _wrapped(lines, _dialog_wrap_cells())
|
||||
if len(lines) > _DIALOG_MAX_WRAPPED:
|
||||
return None
|
||||
return Text("\n").join(lines)
|
||||
|
||||
@@ -295,7 +330,7 @@ class Ui:
|
||||
|
||||
async def dialog(
|
||||
self,
|
||||
prompt: str,
|
||||
prompt: str | Text,
|
||||
buttons: list[tuple[str, Any]],
|
||||
*,
|
||||
title: str = "",
|
||||
@@ -313,6 +348,10 @@ class Ui:
|
||||
sibling. Buttons follow the platform-dialog convention: the safe way out sits on
|
||||
the left and the committing action on the right, which is also the sensible
|
||||
``default`` so Enter commits it while Esc always backs out.
|
||||
|
||||
A pre-styled :class:`~rich.text.Text` prompt is drawn as it is, so it can name a
|
||||
node in its own colours; the tier then themes only the border, and the prompt's
|
||||
prose carries the tier's colour itself.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -599,7 +638,7 @@ class PlainUi(Ui):
|
||||
|
||||
async def dialog(
|
||||
self,
|
||||
prompt: str,
|
||||
prompt: str | Text,
|
||||
buttons: list[tuple[str, Any]],
|
||||
*,
|
||||
title: str = "",
|
||||
@@ -870,7 +909,7 @@ class TuiUi(Ui):
|
||||
|
||||
async def dialog(
|
||||
self,
|
||||
prompt: str,
|
||||
prompt: str | Text,
|
||||
buttons: list[tuple[str, Any]],
|
||||
*,
|
||||
title: str = "",
|
||||
|
||||
@@ -13,9 +13,10 @@ the pushed Contacts list:
|
||||
computed from the actual ranking rather than from arithmetic on the table size, because
|
||||
protected contacts are never victims.
|
||||
2. **Read the list, and edit it.** Every contact the sweep would take, weakest first, with
|
||||
its percentile and the two or three facts that put it there — for the age rungs too,
|
||||
since knowing how the contacts an age threshold caught actually *rank* is exactly the
|
||||
check that threshold cannot do for itself. ``Delete`` drops a row from the list, so a
|
||||
the evidence beside it in lanes — how lately it was heard, what it has sent, whether you
|
||||
ever messaged it, how far out it sits. For the age rungs too, since seeing what the
|
||||
contacts an age threshold caught have actually *done* is exactly the check that threshold
|
||||
cannot do for itself. ``Delete`` drops a row from the list, so a
|
||||
contact the reader wants to keep is spared without abandoning the whole sweep and picking
|
||||
a shallower rung; ``←→`` scroll a row too wide for the terminal. Enter on a contact opens
|
||||
its detail page to *look* at, with the page's own contact-management verbs withheld (see
|
||||
@@ -24,15 +25,20 @@ the pushed Contacts list:
|
||||
candidate list. It ends in the Apply/Back pair that
|
||||
:func:`~meshterm.ui.menus.exit_rows` draws for staged changes, because that is what this
|
||||
is: a choice with a cost on both sides, not an exit.
|
||||
3. **Confirm.** An amber Cancel/Purge dialog, not the red typed gate a deletion gets: this
|
||||
3. **Confirm.** An amber Cancel/Archive dialog, not the red typed gate a deletion gets: this
|
||||
is reversible. Every contact keeps its key, its reception history and its transcripts,
|
||||
the Archived list is one row away on the Contacts screen, and a restore is a single
|
||||
write. Saving the red for what cannot be undone is what keeps the red meaning anything.
|
||||
|
||||
**Only the percentile is ever shown.** A raw score means nothing without the distribution
|
||||
it came from, and would need a legend the moment the weights were ever retuned; a contact's
|
||||
rank against your other contacts explains itself. See :mod:`~meshterm.core.contact_score`
|
||||
for the scoring itself, which is pure and lives in core.
|
||||
**The preview shows the evidence, never the verdict.** Not the score — a weighted sum means
|
||||
nothing without the distribution it came from, and would need a legend the moment the weights
|
||||
were retuned. Not the percentile either, which led the row for one round: a rank is the
|
||||
score's own reading of itself, so a lane of them restated the order the list was already in
|
||||
and spent five columns saying "trust me". What an audit needs is the measurements, each in a
|
||||
lane of its own kind — headed like the Contacts list heads the two they share — so a column
|
||||
means one thing all the way down and the reader can check the sweep against what they know
|
||||
rather than against its arithmetic. See :mod:`~meshterm.core.contact_score` for the scoring
|
||||
itself, which is pure, lives in core, and still ranks the list.
|
||||
|
||||
**Archived, not deleted.** The sweep removes each contact from the *device* — freeing the
|
||||
slot, which is the whole point — and records it in the cross-session store with an archive
|
||||
@@ -50,9 +56,11 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.text import Text
|
||||
|
||||
from ..core.connection import ContactNotOnDeviceError
|
||||
@@ -62,19 +70,20 @@ from ..core.contact_score import (
|
||||
sweep_candidates,
|
||||
)
|
||||
from ..core.models import Contact
|
||||
from .menus import Lane, column_header, fit_cells, menu_rows, section_heading
|
||||
from .menus import Lane, column_header, fit_cells, section_heading
|
||||
from .theme import name_style
|
||||
from .tui import Choice, Separator
|
||||
from .tui.screen import CANCEL
|
||||
from .widgets import DEFAULT_GLYPH, NODE_GLYPHS, _recency_style, format_age
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
|
||||
#: The keep-the-strongest rungs the target picker offers, as a share of the *sweepable*
|
||||
#: contacts. Percentages rather than absolute counts because the score itself is only ever
|
||||
#: read as a percentile: a rung and the lane it filters against then speak one language, and
|
||||
#: the ladder means the same thing on a table of thirty and a table of three hundred without
|
||||
#: a single value needing to be retuned. Coarse at the aggressive end — the difference
|
||||
#: contacts. Percentages rather than absolute counts because a share is the one form the
|
||||
#: ladder can state once and have mean the same thing on a table of thirty and a table of
|
||||
#: three hundred — the ranking underneath is a rank, so a count would have to be retuned
|
||||
#: for every mesh and a share never does. Coarse at the aggressive end — the difference
|
||||
#: between keeping 90% and 75% is a tidy-up, between 50% and 25% a decision.
|
||||
KEEP_RUNGS: tuple[int, ...] = (90, 75, 60, 50, 25)
|
||||
|
||||
@@ -88,7 +97,7 @@ _DAY = 86400
|
||||
#: The last-heard rungs, ``(label, min_age_seconds)``. A contact qualifies when its age is
|
||||
#: *at least* that long; a never-heard contact is excluded here and swept only by
|
||||
#: :data:`_NEVER`, so an age rung can't quietly take a contact that simply hasn't adverted
|
||||
#: yet. The same ladder the age-only purge offered before this flow absorbed it.
|
||||
#: yet. The same ladder the age-only sweep offered before this flow absorbed it.
|
||||
AGE_RUNGS: tuple[tuple[str, int], ...] = (
|
||||
("Not heard in 1 week", 7 * _DAY),
|
||||
("Not heard in 1 month", 30 * _DAY),
|
||||
@@ -100,95 +109,218 @@ AGE_RUNGS: tuple[tuple[str, int], ...] = (
|
||||
#: The age-rung value meaning the never-heard bucket (contacts with no advert time at all).
|
||||
_NEVER = -1
|
||||
|
||||
#: Percentile lane width, plus its gap — three digits and two cells of air.
|
||||
_PCTL_W = 5
|
||||
#: The gap between two lanes, in cells — the air that keeps a right-aligned value clear of
|
||||
#: the one before it, and what a lane's declared width carries on top of its content.
|
||||
_GAP = 2
|
||||
|
||||
#: Name lane width in the preview, plus its gap. Wide enough for the 20-odd bytes a
|
||||
#: MeshCore advert name runs to, and narrow enough to leave the reasons lane readable at
|
||||
#: MeshCore advert name runs to, and narrow enough to leave the evidence lanes readable at
|
||||
#: the PicoCalc's 53 columns.
|
||||
_NAME_W = 22
|
||||
|
||||
#: The type-mark lane ahead of the name: the node's glyph and its space, which is how the
|
||||
#: Contacts list leads every row (see :func:`~meshterm.ui.contactlist._lane`).
|
||||
_MARK_W = 2
|
||||
|
||||
def _pctl_style(percentile: int) -> str:
|
||||
"""The style a percentile is drawn in — the same quality reading ``snr_style`` gives SNR.
|
||||
|
||||
Three bands rather than a gradient: the heat ramp is spoken for (it colours *ages*, and
|
||||
a second gradient in a neighbouring lane would read as the same scale), and a percentile
|
||||
in a purge preview only ever answers one question — is this contact near the bottom?
|
||||
def _count_cell(tally: int | None) -> tuple[str, str]:
|
||||
"""A tally lane's value: the number muted, or a faint ``—`` where there is nothing.
|
||||
|
||||
The Contacts list's ``count`` reading exactly (see
|
||||
:func:`~meshterm.ui.contactlist._lane_cell`), clamp included — a runaway tally widens no
|
||||
column — because the reader arrived here from that list and must not have to learn a
|
||||
second grammar for the same fact.
|
||||
"""
|
||||
if percentile >= 66:
|
||||
return "ok"
|
||||
if percentile >= 33:
|
||||
return "warn"
|
||||
return "err"
|
||||
if not tally:
|
||||
return "—", "faint"
|
||||
return f"{min(tally, 99999)}", "muted"
|
||||
|
||||
|
||||
def _pctl_cell(percentile: int) -> Text:
|
||||
"""One right-aligned percentile lane: the number alone, coloured by band."""
|
||||
return Text(f"{percentile:>3} ", style=_pctl_style(percentile))
|
||||
def _heard_cell(scored: ScoredContact) -> tuple[str, str]:
|
||||
"""The last-heard lane: the column age under recency heat, or a cold ``never``.
|
||||
|
||||
:func:`~meshterm.ui.widgets.format_age` and the heat the Contacts list colours its own
|
||||
``HEARD`` lane with — one grammar for one fact, whichever screen is asking it.
|
||||
"""
|
||||
secs = age_seconds(scored)
|
||||
return format_age(secs), _recency_style(secs)
|
||||
|
||||
|
||||
def _preview_header(width: int) -> str:
|
||||
"""The preview's pinned column header, fitted to the terminal."""
|
||||
def _hops_cell(scored: ScoredContact) -> tuple[str, str]:
|
||||
"""The distance lane: ``direct`` for a neighbour, else the mean hop count it arrives by."""
|
||||
hops = scored.signals.hops
|
||||
if hops is None:
|
||||
return "—", "faint"
|
||||
return ("direct" if hops < 0.5 else f"{hops:g}"), "muted"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Evidence:
|
||||
"""One evidence lane: the word over it, and how to read a contact's value for it.
|
||||
|
||||
A declaration rather than a branch, like :class:`~meshterm.ui.contactlist.ContactLane`
|
||||
next door: the header builder, the row builder and the width arithmetic all walk the
|
||||
same tuple, so a lane is added — or reordered — in one place.
|
||||
|
||||
Attributes:
|
||||
label: The header word. Right-aligned over its lane, where its digits will land.
|
||||
read: The contact's value for this lane, as ``(text, style)``.
|
||||
"""
|
||||
|
||||
label: str
|
||||
read: Callable[[ScoredContact], tuple[str, str]]
|
||||
|
||||
|
||||
#: The preview's evidence lanes, left to right. ``HEARD`` and ``PKTS`` lead, in the Contacts
|
||||
#: list's own order and drawn in its own grammar: the reader has just come from that list,
|
||||
#: and these are the two lanes they were already reading. ``MSGS`` and ``HOPS`` follow — the
|
||||
#: two the sweep adds, and the two nearly always empty in a list of candidates (a contact you
|
||||
#: have messaged is rarely weak enough to be swept), so a terminal too narrow for the whole
|
||||
#: row cuts the least informative end first and ``←→`` brings it back.
|
||||
_EVIDENCE: tuple[_Evidence, ...] = (
|
||||
_Evidence("HEARD", _heard_cell),
|
||||
_Evidence("PKTS", lambda scored: _count_cell(scored.signals.packets)),
|
||||
_Evidence("MSGS", lambda scored: _count_cell(scored.signals.dm_total)),
|
||||
_Evidence("HOPS", _hops_cell),
|
||||
)
|
||||
|
||||
|
||||
def _lane_widths(victims: list[ScoredContact]) -> tuple[int, ...]:
|
||||
"""Each evidence lane's width: its header word, or the widest value under it.
|
||||
|
||||
Measured against the list rather than fixed, so a lane of single digits is one cell of
|
||||
numbers instead of five of air — and remeasured whenever a spared row leaves, which can
|
||||
only tighten it.
|
||||
"""
|
||||
return tuple(
|
||||
max([cell_len(lane.label), *(cell_len(lane.read(v)[0]) for v in victims)])
|
||||
for lane in _EVIDENCE
|
||||
)
|
||||
|
||||
|
||||
def _preview_header(widths: tuple[int, ...], width: int) -> str:
|
||||
"""The preview's pinned column header: the name lane, then one word per evidence lane.
|
||||
|
||||
Each word is right-aligned inside its own lane, over values right-aligned too, so a
|
||||
header sits where its digits will land rather than off to the left of them — how the
|
||||
Contacts list heads the same two lanes.
|
||||
|
||||
One word per lane is the whole point of the layout. A single ``WHY IT RANKS LOW`` used to
|
||||
head three lanes at once, and could not name any of them: a lane held a contact's *n*-th
|
||||
weakest reason, so a message count, an age and a hop count moved between columns from row
|
||||
to row and nothing in a column meant the same thing twice. The lanes are fixed by *kind*
|
||||
now, which is what makes them comparable down the list — and what lets a reader see that
|
||||
the contact they were unsure about is the one that has never sent a packet.
|
||||
"""
|
||||
lanes = [Lane("NAME", _NAME_W)]
|
||||
last = len(_EVIDENCE) - 1
|
||||
for index, (lane, lane_w) in enumerate(zip(_EVIDENCE, widths, strict=True)):
|
||||
lanes.append(Lane(f"{lane.label:>{lane_w}}", 0 if index == last else lane_w + _GAP))
|
||||
# Past the pointer and the type mark, so ``NAME`` sits over the name, not the glyph.
|
||||
return column_header(lanes, width, indent=2 + _MARK_W)
|
||||
|
||||
|
||||
def _victim_row(scored: ScoredContact, widths: tuple[int, ...]) -> Text:
|
||||
"""One preview line: the contact's type mark and name, then its evidence, lane by lane.
|
||||
|
||||
The row leads the way the Contacts list's does — the shared type mark in its own colour
|
||||
(``▲`` repeater, ``■`` room, ``◉`` sensor, ``●`` plain node), then the name — so the
|
||||
contact reads as the same row here as on the list the reader came from, and a repeater
|
||||
about to be archived says so before its name is read.
|
||||
|
||||
The name keeps its key-derived colour like everywhere else in the app — this is a list of
|
||||
nodes, and a reader picking one out of thirty rows should not have to read it letter by
|
||||
letter because the screen it is on happens to be about archiving things. It goes through
|
||||
:func:`~meshterm.ui.menus.fit_cells`, so an over-long name ends on an ellipsis that says
|
||||
it was shortened, the lane is measured in display cells, and a name carrying an emoji is
|
||||
cut between glyphs rather than through one — a name is whatever a stranger's radio
|
||||
advertised, and it is the one value on this screen that can be anything at all.
|
||||
|
||||
Every other lane is right-aligned into its measured width and carries its own style: the
|
||||
heard age its recency heat, a tally muted, an absent one a faint ``—``. The row is built
|
||||
on an unstyled :class:`~rich.text.Text` rather than under the name's own style, because
|
||||
Rich merges a base style into every span — a faint ``—`` under a name-hue base would come
|
||||
out faint *in that node's colour*.
|
||||
"""
|
||||
contact = scored.contact
|
||||
name = contact.name or contact.key_prefix or "?"
|
||||
mark, mark_style = NODE_GLYPHS.get(contact.node_type, DEFAULT_GLYPH)
|
||||
row = Text()
|
||||
row.append(mark, style=mark_style)
|
||||
row.append(" " * (_MARK_W - cell_len(mark)))
|
||||
row.append(
|
||||
fit_cells(name, _NAME_W - _GAP) + " " * _GAP,
|
||||
style=name_style(name, contact.public_key or contact.key_prefix),
|
||||
)
|
||||
last = len(_EVIDENCE) - 1
|
||||
for index, (lane, lane_w) in enumerate(zip(_EVIDENCE, widths, strict=True)):
|
||||
value, style = lane.read(scored)
|
||||
row.append(fit_cells(value, lane_w, align="right"), style=style)
|
||||
if index != last:
|
||||
row.append(" " * _GAP)
|
||||
return row
|
||||
|
||||
|
||||
#: The ladder's two count lanes, headed by these words. Each lane is exactly as wide as its
|
||||
#: word, and the count right-aligns in it, so a number ends under its header's last letter.
|
||||
_KEEPS = "KEEPS"
|
||||
_ARCHIVES = "ARCHIVES"
|
||||
|
||||
|
||||
def _ladder_header(label_w: int, width: int) -> str:
|
||||
"""The target ladder's pinned column header, over the rung lane and its two counts."""
|
||||
return column_header(
|
||||
[
|
||||
Lane("PCTL", _PCTL_W),
|
||||
Lane("NAME", _NAME_W),
|
||||
Lane(("WHY IT RANKS LOW", "WHY")),
|
||||
Lane("TARGET", label_w + 2),
|
||||
Lane(_KEEPS, len(_KEEPS) + 2),
|
||||
Lane(_ARCHIVES),
|
||||
],
|
||||
width,
|
||||
)
|
||||
|
||||
|
||||
def _victim_row(scored: ScoredContact) -> Text:
|
||||
"""One preview line: percentile, the contact's name in its own hue, then why it ranks low.
|
||||
def _rung_row(label: str, kept: int, archived: int, label_w: int) -> Text:
|
||||
"""One rung: its label, then how many contacts it keeps and how many it archives.
|
||||
|
||||
The name keeps its key-derived colour like everywhere else in the app — this is a list
|
||||
of nodes, and a reader picking one out of thirty rows should not have to read it letter
|
||||
by letter because the screen it is on happens to be about deleting things.
|
||||
**Keeps leads**, not archives. The device's contact table is the scarce thing and the
|
||||
whole reason to sweep at all, so the useful number is the one that has to fit in it —
|
||||
how many are archived is the arithmetic left over, and it follows. A percentile cutoff
|
||||
led here for one round and was the wrong unit twice over: it answered a question nobody
|
||||
asked at this step, and at 53 columns it did not fit anyway.
|
||||
|
||||
Both are columns rather than a phrase (``keeps 45 · archives 12``), so the ladder reads
|
||||
down as a table: every count sits right-aligned under its own header, and comparing two
|
||||
rungs is a glance down a lane rather than finding the number inside each sentence. A
|
||||
rung that archives nothing shows its ``0`` muted, so the rungs that do something stand
|
||||
out from the ones that don't.
|
||||
|
||||
Args:
|
||||
label: The rung's own label.
|
||||
kept: Contacts left on the device, protected ones included.
|
||||
archived: Contacts the rung would archive.
|
||||
label_w: The rung lane's width in cells — the widest label across both sections.
|
||||
"""
|
||||
contact = scored.contact
|
||||
row = _pctl_cell(scored.percentile)
|
||||
name = contact.name or contact.key_prefix or "?"
|
||||
# Through ``fit_cells``, so an over-long name ends on an ellipsis that says it was
|
||||
# shortened — and so the lane is measured in display cells, which is the only measure
|
||||
# that keeps the column straight once a name carries a wide glyph.
|
||||
row.append(
|
||||
fit_cells(name, _NAME_W - 2) + " ",
|
||||
style=name_style(name, contact.public_key or contact.key_prefix),
|
||||
)
|
||||
row.append(" · ".join(scored.reasons), style="muted")
|
||||
row = Text(label)
|
||||
row.append(" " * (label_w - cell_len(label) + 2))
|
||||
row.append(f"{kept:>{len(_KEEPS)}} ")
|
||||
row.append(f"{archived:>{len(_ARCHIVES)}}", style=None if archived else "muted")
|
||||
return row
|
||||
|
||||
|
||||
def _rung_desc(kept: int, archived: int) -> str:
|
||||
"""A rung's description lane, in the number the reader is actually choosing.
|
||||
|
||||
**How many contacts you keep**, not how many go. The device's contact table is the
|
||||
scarce thing and the whole reason to sweep at all, so the useful number is the one that
|
||||
has to fit in it — how many are archived is the arithmetic left over, and it follows in
|
||||
the muted half rather than leading. A percentile cutoff led this lane for one round and
|
||||
was the wrong unit twice over: it answered a question nobody asked at this step, and at
|
||||
53 columns it did not fit anyway.
|
||||
"""
|
||||
if not archived:
|
||||
return f"keeps all {kept}"
|
||||
return f"keeps {kept} · archives {archived}"
|
||||
|
||||
|
||||
async def _rank(ctx: AppContext, contacts: list[Contact]) -> list[ScoredContact]:
|
||||
async def _rank(ctx: AppContext, contacts: list[Contact], self_key: str) -> list[ScoredContact]:
|
||||
"""Gather every signal these contacts have and rank them, strongest first.
|
||||
|
||||
Five grouped scans of the history (:meth:`
|
||||
~meshterm.persistence.repository.Repository.contact_signals`), one pass over the channel
|
||||
transcript for name attribution, and the two stores that carry the explicit protections
|
||||
— the Watchtower's stars and the remembered admin logins. Our own position comes from
|
||||
the device's self-info, and is simply absent on a device that advertises none, which the
|
||||
distance term reads as unknown for everybody.
|
||||
transcript for name attribution, and the three stores that carry the explicit protections
|
||||
— the contacts you locked, the Watchtower's stars and the remembered admin logins. Our
|
||||
own position comes from the device's self-info, and is simply absent on a device that
|
||||
advertises none, which the distance term reads as unknown for everybody.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
contacts: The device's contacts (our own node is not among them).
|
||||
self_key: The device's own public key (hex), which scopes the locks.
|
||||
|
||||
Returns:
|
||||
The full ranking, strongest first.
|
||||
@@ -210,6 +342,8 @@ async def _rank(ctx: AppContext, contacts: list[Contact]) -> list[ScoredContact]
|
||||
if folded:
|
||||
seen[folded] = seen.get(folded, 0) + 1
|
||||
|
||||
store = getattr(ctx, "contact_store", None)
|
||||
locked = store.locked_keys(self_key) if store is not None and self_key else frozenset()
|
||||
watch = getattr(ctx, "watch_store", None)
|
||||
admin = getattr(ctx, "admin_store", None)
|
||||
for contact, node in zip(contacts, nodes, strict=True):
|
||||
@@ -224,6 +358,7 @@ async def _rank(ctx: AppContext, contacts: list[Contact]) -> list[ScoredContact]
|
||||
channel_attributed=unique,
|
||||
watched=bool(watch is not None and watch.is_watched(node)),
|
||||
has_admin=bool(admin is not None and admin.get(contact)),
|
||||
locked=(contact.public_key or "").lower().removeprefix("0x") in locked,
|
||||
)
|
||||
|
||||
self_lat = self_lon = None
|
||||
@@ -235,12 +370,12 @@ async def _rank(ctx: AppContext, contacts: list[Contact]) -> list[ScoredContact]
|
||||
if lat and lon:
|
||||
self_lat, self_lon = float(lat), float(lon)
|
||||
except Exception as exc: # noqa: BLE001 - an unplaced device just loses one weak term
|
||||
ctx.log.debug("purge: no self position (%s); distance term stays unknown", exc)
|
||||
ctx.log.debug("archive: no self position (%s); distance term stays unknown", exc)
|
||||
|
||||
return rank_contacts(contacts, signals, self_lat=self_lat, self_lon=self_lon)
|
||||
|
||||
|
||||
async def purge_contacts(ctx: AppContext, self_key: str) -> int:
|
||||
async def archive_contacts(ctx: AppContext, self_key: str) -> int:
|
||||
"""Run the whole sweep — rank, pick a target, preview, confirm, archive. Returns the count.
|
||||
|
||||
Runs over the pushed Contacts list as its backdrop, so every step floats and Esc walks
|
||||
@@ -267,7 +402,7 @@ async def purge_contacts(ctx: AppContext, self_key: str) -> int:
|
||||
|
||||
async with ctx.ui.busy_overlay("Rating contacts"):
|
||||
contacts = await ctx.devstate.contacts()
|
||||
ranked = await _rank(ctx, contacts)
|
||||
ranked = await _rank(ctx, contacts, self_key)
|
||||
|
||||
sweepable = [scored for scored in ranked if not scored.protected]
|
||||
if not sweepable:
|
||||
@@ -340,26 +475,37 @@ def _target_screen(ranked: list[ScoredContact], sweepable: list[ScoredContact]):
|
||||
from .tui import SelectScreen
|
||||
|
||||
protected = len(ranked) - len(sweepable)
|
||||
sections: list[tuple[str, list[tuple[str, tuple]]]] = [
|
||||
(
|
||||
"By standing",
|
||||
[(f"Keep the strongest {share}%", ("keep", share)) for share in KEEP_RUNGS],
|
||||
),
|
||||
(
|
||||
"Long silent",
|
||||
[(label, ("age", secs)) for label, secs in AGE_RUNGS]
|
||||
+ [("Never heard at all", ("age", _NEVER))],
|
||||
),
|
||||
]
|
||||
# One rung lane across both sections, so the counts stand in two columns down the whole
|
||||
# ladder rather than restarting under each heading.
|
||||
label_w = max(cell_len(label) for _, rungs in sections for label, _ in rungs)
|
||||
|
||||
def rung(label: str, value: tuple) -> tuple:
|
||||
victims = victims_for(ranked, sweepable, value)
|
||||
# What remains on the device: every protected contact, plus the sweepable ones this
|
||||
# rung doesn't take. Counted from the ranking rather than from the rung's own
|
||||
# percentage, because a protection is never a victim and the two would disagree.
|
||||
return (label, _rung_desc(len(ranked) - len(victims), len(victims)), value)
|
||||
|
||||
items: list = [section_heading("By standing")]
|
||||
items += menu_rows(
|
||||
[rung(f"Keep the strongest {share}%", ("keep", share)) for share in KEEP_RUNGS]
|
||||
)
|
||||
items.append(section_heading("Long silent"))
|
||||
items += menu_rows(
|
||||
[rung(label, ("age", secs)) for label, secs in AGE_RUNGS]
|
||||
+ [rung("Never heard at all", ("age", _NEVER))]
|
||||
)
|
||||
# Pinned like the editors' header (menus.lane_header), so a scrolled ladder keeps its
|
||||
# column words overhead along with the section it is in.
|
||||
items: list = [Separator(lambda w: _ladder_header(label_w, w), pinned=True)]
|
||||
for heading, rungs in sections:
|
||||
items.append(section_heading(heading))
|
||||
for label, value in rungs:
|
||||
victims = victims_for(ranked, sweepable, value)
|
||||
# What remains on the device: every protected contact, plus the sweepable ones
|
||||
# this rung doesn't take. Counted from the ranking rather than from the rung's
|
||||
# own percentage, because a protection is never a victim and the two would
|
||||
# disagree.
|
||||
kept = len(ranked) - len(victims)
|
||||
items.append(Choice(title=_rung_row(label, kept, len(victims), label_w), value=value))
|
||||
held = f" · {protected} protected" if protected else ""
|
||||
return SelectScreen(
|
||||
f"Purge contacts — {len(ranked)} known{held}",
|
||||
f"Archive contacts — {len(ranked)} known{held}",
|
||||
items,
|
||||
prompt=(
|
||||
"Archives the weakest off the device, keeping them here. Ranked on messages, "
|
||||
@@ -374,14 +520,24 @@ def _preview_items(victims: list[ScoredContact]) -> list:
|
||||
"""The preview's rows: a pinned column header, one line per victim, then Apply/Back.
|
||||
|
||||
Each contact row is ``deletable``, so ``Delete`` lifts it out of the sweep, and pins its
|
||||
percentile lane out of the ``←→`` scroll (``hscroll_from``): the number is the reader's
|
||||
place in a list ordered by it, and sliding it away to read a long reasons lane would
|
||||
cost the row its bearing to gain nothing — the percentile is the part that already fits.
|
||||
type mark and **name** out of the ``←→`` scroll (``hscroll_from``): the evidence lanes
|
||||
are what a narrow terminal cuts, and sliding the name away to read them would cost the
|
||||
row the one thing that says which contact is being read.
|
||||
|
||||
The lanes are measured here, across every victim, which is also why a spared row rebuilds
|
||||
the rows through this function rather than dropping one: the widths it leaves behind may
|
||||
have been set by the value that just left.
|
||||
"""
|
||||
widths = _lane_widths(victims)
|
||||
return [
|
||||
Separator(_preview_header, pinned=True),
|
||||
Separator(lambda w: _preview_header(widths, w), pinned=True),
|
||||
*(
|
||||
Choice(title=_victim_row(v), value=v, deletable=True, hscroll_from=_PCTL_W)
|
||||
Choice(
|
||||
title=_victim_row(v, widths),
|
||||
value=v,
|
||||
deletable=True,
|
||||
hscroll_from=_MARK_W + _NAME_W,
|
||||
)
|
||||
for v in victims
|
||||
),
|
||||
# The same shape ``exit_rows`` draws, in this flow's own words: Apply has no key of
|
||||
@@ -402,7 +558,7 @@ def _preview_screen(victims: list[ScoredContact]):
|
||||
from .tui import SelectScreen
|
||||
|
||||
return SelectScreen(
|
||||
f"Purge contacts — {_count_desc(len(victims))} to archive",
|
||||
f"Archive contacts — {_count_desc(len(victims))} to archive",
|
||||
_preview_items(victims),
|
||||
prompt="Archived contacts leave the device but stay here, with their history.",
|
||||
# Neither the scroll nor the keep atom is written into the base hint: the select
|
||||
@@ -468,7 +624,7 @@ async def _preview(ctx: AppContext, victims: list[ScoredContact]) -> bool:
|
||||
# The title counts the victims, so it is content too and moves with them.
|
||||
screen.replace_items(
|
||||
_preview_items(victims),
|
||||
title=f"Purge contacts — {_count_desc(len(victims))} to archive",
|
||||
title=f"Archive contacts — {_count_desc(len(victims))} to archive",
|
||||
)
|
||||
continue
|
||||
# Any other row is a contact: Enter opens its node detail page, so a reader who
|
||||
@@ -506,8 +662,8 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
|
||||
f"Archive {_count_desc(len(victims))}? They come off this device's contact list, "
|
||||
"freeing space for new ones. MeshTerm keeps them — with their keys, reception "
|
||||
"history and messages — under Archived contacts, and any of them can be restored.",
|
||||
[("Cancel", False), ("Purge", True)],
|
||||
title="Purge contacts",
|
||||
[("Cancel", False), ("Archive", True)],
|
||||
title="Archive contacts",
|
||||
default=1,
|
||||
danger=True,
|
||||
):
|
||||
@@ -518,7 +674,7 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
|
||||
store = ctx.contact_store
|
||||
stamp = int(time.time())
|
||||
archived = failed = 0
|
||||
with ctx.ui.progress("Purge contacts") as progress:
|
||||
with ctx.ui.progress("Archive contacts") as progress:
|
||||
task = progress.add_task("Archiving", total=len(victims))
|
||||
for scored in victims:
|
||||
contact = scored.contact
|
||||
@@ -527,10 +683,10 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
|
||||
except ContactNotOnDeviceError:
|
||||
# Already absent from the radio: the sweep's device-side work is done, and
|
||||
# archiving it here is what makes the row go away. Not a failure.
|
||||
ctx.log.debug("purge: %s was not on the device; archiving ours", contact.name)
|
||||
ctx.log.debug("archive: %s was not on the device; archiving ours", contact.name)
|
||||
except Exception as exc: # noqa: BLE001 - one bad removal mustn't abort the sweep
|
||||
failed += 1
|
||||
ctx.log.debug("purge: could not remove %s: %s", contact.name, exc)
|
||||
ctx.log.debug("archive: could not remove %s: %s", contact.name, exc)
|
||||
continue
|
||||
finally:
|
||||
progress.advance(task)
|
||||
@@ -547,5 +703,5 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
|
||||
)
|
||||
else:
|
||||
outcome = Text("no contacts could be archived", style="err")
|
||||
await ctx.ui.session.message_dialog(outcome, title="Purge contacts")
|
||||
await ctx.ui.session.message_dialog(outcome, title="Archive contacts")
|
||||
return archived
|
||||
@@ -240,7 +240,7 @@ _VT_SLOTS: tuple[tuple[int, str, str], ...] = (
|
||||
#: The custom 16-slot remap P3 originally shipped (tailwind-family RGBs programmed via
|
||||
#: ``setvtrgb``) — **archived, not installed**: JP chose the standard palette but asked
|
||||
#: to keep this in case he changes his mind. Reinstall with
|
||||
#: ``MESHTERM_CUSTOM_PALETTE=1 sh scripts/calculinux-console-font.sh`` (whose opt-in
|
||||
#: ``MESHTERM_CUSTOM_PALETTE=1 sh scripts/picocalc/calculinux-console-font-6x12.sh`` (whose opt-in
|
||||
#: block a test keeps byte-identical to :func:`vtrgb_lines`); MESH_THEME_16 would then
|
||||
#: want re-tuning against it (see git history at c6485c6 for the matching theme).
|
||||
_VT_SLOTS_CUSTOM: tuple[tuple[int, str, str], ...] = (
|
||||
@@ -660,7 +660,8 @@ _GLYPH_MAP: dict[str, str] = {
|
||||
# Channel openness (widgets.channel_glyph)
|
||||
"#": "#", # name-derived channel
|
||||
"🌐": "@", # well-known public channel
|
||||
"🔒": "⚿", # private channel (the padlock mark)
|
||||
"🔒": "⚿", # private channel, locked contact (the padlock mark)
|
||||
"🔓": "⚿", # unlock — the same padlock; the row's word says which way it turns
|
||||
# Concept icons (menu/list rows)
|
||||
"📡": "☼", # advert tool — same concept as the advert class
|
||||
"🕒": "◷", # clock/sync (the clock-face mark)
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Pin a written row's columns to the terminal's own grid, so no glyph can shift it.
|
||||
|
||||
:mod:`~meshterm.ui.tui.emoji_width` answers *how many cells does this glyph take*. This
|
||||
module answers the question that actually decides whether a row lines up: **which column does
|
||||
the next glyph land in?**
|
||||
|
||||
A row reaches the terminal as one run of characters, and the terminal advances its own cursor
|
||||
by its own idea of each glyph's width. Where that idea differs from ours by a cell,
|
||||
*everything after that glyph* is drawn a column out — the lanes to its right shift, the values
|
||||
under a heading stop being under it, and the panel's right border notches in one short of the
|
||||
frame. One emoji in one node's name, and the row it sits on is the only row in the list that
|
||||
does not line up.
|
||||
|
||||
**That disagreement cannot be measured away**, which is the part worth being plain about.
|
||||
Whether a font draws a given emoji in one cell or two is the font's business rather than the
|
||||
codepoint's; there is no table that is right on every terminal; and no escape sequence reports
|
||||
a renderer's width back to the program, because on a split terminal the component that tracks
|
||||
the cursor and the component that paints the glyph are different programs that disagree with
|
||||
each other (a cursor probe reads the PTY, not the painter). Curating exceptions one confirmed
|
||||
glyph at a time — which is what the app used to do — lines up the rows somebody has already
|
||||
looked at, and leaves every name that arrives off the air, written by a stranger and carrying
|
||||
whatever they felt like typing, exactly as broken as it was. A contact list is the worst case
|
||||
in the app for precisely that reason: its content is the one thing on screen that nobody can
|
||||
put on a list in advance.
|
||||
|
||||
So this module stops asking the terminal to agree. After any glyph whose drawn width is not
|
||||
certain, the row carries an **absolute column address** — ``CSI n G``, naming the one-based
|
||||
column the app measured that glyph into — so the terminal's answer to *where am I* is replaced
|
||||
with ours before the next glyph is drawn. The cursor is re-pinned to the app's own grid at
|
||||
every point where a disagreement could have started, and a mismeasured glyph can no longer
|
||||
move anything but itself:
|
||||
|
||||
* Drawn **narrower** than we reserved, it leaves a blank cell after it. The cells a glyph is
|
||||
reserved are erased in its own colours before it is drawn (``CSI n X``), so that cell is
|
||||
blank in the right background: never a character left over from the last frame, and never a
|
||||
hole in the fill of a chip.
|
||||
* Drawn **wider**, its overhang is written over by whatever the next column holds.
|
||||
|
||||
Both are one cell of cosmetic damage inside the glyph's own lane, where a shift was a whole row
|
||||
of it, and the second never happens in practice: :mod:`~meshterm.ui.tui.emoji_width` reserves
|
||||
every glyph that may be drawn as an emoji two cells, the most a single glyph draws. Pinning holds
|
||||
the lanes after a glyph wherever the terminal puts it; the reservation is what makes the text
|
||||
beside it line up from row to row as well, since a pin can only land where the glyph was measured.
|
||||
|
||||
**What is certain.** Pinning after a glyph that did not need it is harmless (it addresses the
|
||||
column the cursor is already in) but it is not free: five bytes on the wire and a cluster walk
|
||||
in Python, and MeshTerm draws some non-ASCII glyphs by the thousand — a map is a canvas of
|
||||
braille, every frame is box drawing, a path ribbon is half blocks and chevrons. So
|
||||
:data:`_TEXT_GLYPHS` lists the ranges this app draws in bulk whose one-cell *text* width both
|
||||
authorities already agree on, and :func:`snap_row` pins after everything else. A row holding
|
||||
nothing outside that list is handed back untouched, which is nearly every row of nearly every
|
||||
screen; the walk is paid only where an emoji actually is.
|
||||
|
||||
Getting an entry in that list wrong costs exactly what today costs anyway — one glyph's row —
|
||||
so the list is a performance claim and never the thing correctness rests on. That is the whole
|
||||
point of the inversion: the exception sets this replaced had to be *complete* to keep the app
|
||||
aligned, and this one only has to be *cheap*.
|
||||
|
||||
**Two writers, two spellings of the same pin.** A frame reaches the terminal one of two ways,
|
||||
and each knows something different about where its cursor is:
|
||||
|
||||
* :mod:`~meshterm.ui.tui.fastrender` writes a plain full-screen frame itself, each changed row
|
||||
as one run from column 0. It knows every column absolutely, so :func:`snap_row` spells the
|
||||
pin as an absolute address, ``CSI n G``.
|
||||
* Anything prompt_toolkit lays out — a floating dialog, and the backdrop it repaints — goes out
|
||||
through its differential renderer, which never knows a column absolutely: it steps a cursor
|
||||
of its own by relative moves and adds each character's measured width as it writes. So
|
||||
:class:`PinnedOutput` spells the pin *relative to the glyph itself* — save the cursor, draw
|
||||
the glyph, restore, step forward exactly the width the renderer is about to add. The
|
||||
terminal's cursor then lands where the renderer's arithmetic says it is, after every glyph,
|
||||
and a relative move computed from that arithmetic is a correct move. Nothing is tracked, so
|
||||
nothing can drift out of step with the renderer's own bookkeeping.
|
||||
|
||||
A platform that never draws a glyph outside its own font has nothing to pin, and pins nothing:
|
||||
the PicoCalc folds every emoji away before it reaches the console, and every glyph it does draw
|
||||
is one :mod:`~meshterm.ui.fontset` has verified on the device.
|
||||
|
||||
The escape hatch is ``MESHTERM_COLUMN_SNAP=0``: this is terminal geometry, read once at session
|
||||
build, not a choice anybody makes about how the app behaves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
from rich.cells import cell_len
|
||||
|
||||
from ...platforms import get_platform
|
||||
from .emoji_width import clusters
|
||||
|
||||
#: The glyph ranges MeshTerm draws in bulk whose single-cell width both width authorities
|
||||
#: already agree on, so a row made only of them needs no pinning at all. Each entry is an
|
||||
#: inclusive ``(first, last)`` pair; a single glyph names itself twice.
|
||||
#:
|
||||
#: These are *text* glyphs — outside Emoji_Presentation, carrying no variation selector — which
|
||||
#: is the class the emoji-width module's hardest-won lesson is about: ``U+1F6E3`` and
|
||||
#: ``U+1F578`` measure one cell by both authorities because the font draws them as one-cell
|
||||
#: text, and forcing either to two pulled a row's border in. Everything else non-ASCII — every
|
||||
#: pictograph, every flag, every joined sequence, every fullwidth character, and anything nobody
|
||||
#: has ever looked at — falls outside this list and gets pinned.
|
||||
_TEXT_GLYPHS: tuple[tuple[str, str], ...] = (
|
||||
("\x00", "\x7f"), # ASCII: every letter, digit and escape byte in a composed row
|
||||
(" ", "ſ"), # Latin-1 + Latin Extended-A: an accented name, °, ·, ±
|
||||
("‐", "‧"), # General Punctuation: – — ‘ ’ “ ” • …
|
||||
("←", "↓"), # ← ↑ → ↓, the arrow atoms every footer hint leads with
|
||||
("─", "╿"), # Box Drawing: every panel border and every rule
|
||||
("▀", "▟"), # Block Elements: the path line's half blocks, the splash art
|
||||
("■", "■"), # ■ room server
|
||||
("▲", "▲"), # ▲ repeater
|
||||
("▸", "▸"), # ▸ a row's own marker
|
||||
("◉", "◉"), # ◉ sensor
|
||||
("○", "●"), # ○ unknown through ● node, and the rings between them
|
||||
("★", "★"), # ★ you
|
||||
("✓", "✓"), # ✓ ok
|
||||
("✗", "✗"), # ✗ err
|
||||
("❯", "❯"), # ❯ the cursor
|
||||
("⠀", "⣿"), # Braille: every chart, and the whole map canvas
|
||||
("", ""), # the powerline chevrons a path ribbon's seams are drawn from
|
||||
)
|
||||
|
||||
#: Matches the first glyph in a row that is *not* one of :data:`_TEXT_GLYPHS` — the probe that
|
||||
#: decides whether a row needs walking at all. A negated class of the ranges above, so the test
|
||||
#: is one C-speed scan of the row instead of a Python loop over its cells.
|
||||
_UNPINNED = re.compile("[^" + "".join(f"{low}-{high}" for low, high in _TEXT_GLYPHS) + "]")
|
||||
|
||||
#: One escape sequence: a CSI (which is everything a composed row actually carries — the
|
||||
#: theme's SGR colour runs), an OSC string, or a bare two-character escape. Matched so it can
|
||||
#: be copied through *without* advancing the column: a style change occupies no cell, and
|
||||
#: counting one would pin every glyph after it to the wrong column.
|
||||
_ESCAPE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])")
|
||||
|
||||
#: Pinned rows, keyed by the row as composed. Scrolling a list rewrites the same few rows over
|
||||
#: and over — the highlighted one, and the one the highlight just left — so the walk is paid
|
||||
#: once per distinct row rather than once per paint.
|
||||
_CACHE: OrderedDict[str, str] = OrderedDict()
|
||||
|
||||
#: Rows the cache holds before evicting the least recently used.
|
||||
_CACHE_MAX = 1024
|
||||
|
||||
#: DECSC and DECRC: save the cursor, and return to it. What :class:`PinnedOutput` brackets a
|
||||
#: glyph with, so the step after it is measured from where the glyph *started* rather than from
|
||||
#: wherever the terminal's own width table left the cursor. Both also save and restore the
|
||||
#: current colour, which is harmless here: nothing changes it between the two.
|
||||
_SAVE = "\x1b7"
|
||||
_RESTORE = "\x1b8"
|
||||
|
||||
|
||||
def _erase(width: int) -> str:
|
||||
"""ECH: blank ``width`` cells from the cursor in the current colours, without moving it.
|
||||
|
||||
Written in front of a glyph reserved more than one cell, so every cell of its reservation is
|
||||
painted by this frame whatever the font does with the glyph: the background a chip or a
|
||||
highlighted row filled it with, rather than whatever the terminal last held there.
|
||||
"""
|
||||
return f"\x1b[{width}X"
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
"""Whether written glyphs are pinned to the columns the app measured them into.
|
||||
|
||||
On wherever the platform draws emoji, with ``MESHTERM_COLUMN_SNAP=0`` to turn it off for one
|
||||
run — the escape hatch a terminal that mishandles the pins would need, and the switch that
|
||||
shows what the alignment looks like without them. Off on a platform that draws no emoji at
|
||||
all, where every glyph is one the platform's own font has already verified. Read at session
|
||||
build, like its neighbours.
|
||||
"""
|
||||
return os.environ.get("MESHTERM_COLUMN_SNAP") != "0" and get_platform().emoji
|
||||
|
||||
|
||||
def _pinned(cluster: str) -> bool:
|
||||
"""Whether ``cluster`` needs an absolute column address written after it.
|
||||
|
||||
True for anything the app cannot be sure the terminal draws at the width it measured: every
|
||||
multi-codepoint glyph (a flag, a joined sequence, an emoji carrying a variation selector or
|
||||
a skin tone — the forms :func:`~meshterm.ui.tui.emoji_width.clusters` gathers), and every
|
||||
lone codepoint outside :data:`_TEXT_GLYPHS`.
|
||||
"""
|
||||
return len(cluster) > 1 or bool(_UNPINNED.match(cluster))
|
||||
|
||||
|
||||
def snap_row(row: str) -> str:
|
||||
"""``row`` with an absolute column address after every glyph that could be mismeasured.
|
||||
|
||||
The row is taken to start in **column 0**, which is what
|
||||
:mod:`~meshterm.ui.tui.fastrender` guarantees: it addresses the row, then writes it whole.
|
||||
|
||||
A row holding only :data:`_TEXT_GLYPHS` comes back unchanged and untouched — one scan, no
|
||||
walk, no allocation — because that is nearly every row the app draws.
|
||||
|
||||
Args:
|
||||
row: One composed terminal row: text with the theme's escape sequences in it, already
|
||||
sliced to the terminal's width.
|
||||
|
||||
Returns:
|
||||
The row, with ``CSI n G`` inserted wherever the cursor has to be re-pinned.
|
||||
"""
|
||||
if _UNPINNED.search(row) is None:
|
||||
return row
|
||||
hit = _CACHE.get(row)
|
||||
if hit is not None:
|
||||
_CACHE.move_to_end(row)
|
||||
return hit
|
||||
out = _snapped(row)
|
||||
_CACHE[row] = out
|
||||
if len(_CACHE) > _CACHE_MAX:
|
||||
_CACHE.popitem(last=False)
|
||||
return out
|
||||
|
||||
|
||||
def _snapped(row: str) -> str:
|
||||
"""Walk ``row`` glyph by glyph, tracking the column, and pin where :func:`_pinned` says.
|
||||
|
||||
The address is written **lazily** — held over until something drawable actually follows it —
|
||||
so a row that ends on an emoji (a chat line, very often) pays nothing for an address nothing
|
||||
would have used, and a style change between two glyphs stays where the theme put it.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
column = 0
|
||||
owed = -1 # the column a held-over address names, or -1 when none is owed
|
||||
position = 0
|
||||
for escape in _ESCAPE.finditer(row):
|
||||
column, owed = _pin_text(row[position : escape.start()], parts, column, owed)
|
||||
parts.append(escape.group())
|
||||
position = escape.end()
|
||||
column, owed = _pin_text(row[position:], parts, column, owed)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _pin_text(text: str, parts: list[str], column: int, owed: int) -> tuple[int, int]:
|
||||
"""Append one escape-free stretch of ``text`` to ``parts``, pinned glyph by glyph.
|
||||
|
||||
Args:
|
||||
text: The stretch to append, with no escape sequence in it.
|
||||
parts: The row being built, appended to in place.
|
||||
column: The column this stretch starts in.
|
||||
owed: The column a held-over address names, or ``-1`` for none.
|
||||
|
||||
Returns:
|
||||
The column the stretch ends in, and the address it ends owing.
|
||||
"""
|
||||
for cluster in clusters(text):
|
||||
if owed >= 0:
|
||||
parts.append(f"\x1b[{owed + 1}G")
|
||||
owed = -1
|
||||
width = cell_len(cluster)
|
||||
pinned = _pinned(cluster)
|
||||
if pinned and width > 1:
|
||||
parts.append(_erase(width))
|
||||
parts.append(cluster)
|
||||
column += width
|
||||
if pinned:
|
||||
owed = column
|
||||
return column, owed
|
||||
|
||||
|
||||
def _lone_pinned_glyph(data: str) -> bool:
|
||||
"""Whether ``data`` is exactly one glyph, and one :func:`_pinned` says needs pinning.
|
||||
|
||||
What prompt_toolkit's renderer writes is one screen cell's content at a time — a character,
|
||||
or a whole sequence it was handed as one (see
|
||||
:class:`~meshterm.ui.tui.emoji_width.ClusterTextControl`) — so that is the only shape pinned.
|
||||
Anything longer is not a cell, and passes through as it came.
|
||||
"""
|
||||
if len(data) == 1:
|
||||
return bool(_UNPINNED.match(data))
|
||||
for cluster in clusters(data):
|
||||
return cluster == data
|
||||
return False
|
||||
|
||||
|
||||
class PinnedOutput:
|
||||
"""A prompt_toolkit ``Output`` proxy that pins every uncertain glyph it is asked to write.
|
||||
|
||||
prompt_toolkit's renderer writes a screen cell with ``write`` and then adds that cell's
|
||||
measured width to a cursor it keeps for itself; every later move on the row is computed
|
||||
from that cursor, relatively. Where the terminal draws the glyph at another width, the two
|
||||
cursors part company and every move after it lands a column out. This proxy closes the gap
|
||||
at the one moment it opens: a glyph :func:`_pinned` cannot vouch for is written between a
|
||||
cursor save and a restore, and followed by a forward step of exactly the width the renderer
|
||||
is about to add — so the terminal's cursor is back in step before the renderer's next move.
|
||||
|
||||
Everything else — ``write_raw``, the cursor moves, the size, the attributes — forwards to the
|
||||
wrapped output untouched, and so does ``write`` for anything that is not a lone uncertain
|
||||
glyph, which is almost every call it gets.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: Any) -> None:
|
||||
"""Wrap ``inner``, the concrete prompt_toolkit output for the real terminal."""
|
||||
self._inner = inner
|
||||
|
||||
def write(self, data: str) -> None:
|
||||
"""Write ``data``, pinning the cursor after it when it is a glyph worth pinning."""
|
||||
inner = self._inner
|
||||
if data.isascii() or not _lone_pinned_glyph(data):
|
||||
inner.write(data)
|
||||
return
|
||||
width = get_cwidth(data)
|
||||
# The renderer skips the cells after a wide glyph as the glyph's own and never writes
|
||||
# them, so erasing them first is what keeps a glyph drawn narrower from leaving the
|
||||
# last frame's character in the cell it did not cover.
|
||||
inner.write_raw(_SAVE + _erase(width) if width > 1 else _SAVE)
|
||||
inner.write(data)
|
||||
# A forward step of zero is read as a step of one, so a glyph that measures nothing
|
||||
# (a stranded joiner) just returns to where it started.
|
||||
inner.write_raw(f"{_RESTORE}\x1b[{width}C" if width else _RESTORE)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""Forward every other attribute and method straight to the wrapped output."""
|
||||
return getattr(self._inner, name)
|
||||
|
||||
|
||||
__all__ = ["PinnedOutput", "enabled", "snap_row"]
|
||||
+400
-513
File diff suppressed because it is too large.
Load diff
@@ -23,14 +23,20 @@ would mean re-implementing that placement. Anything else falls straight through
|
||||
stock renderer, which stays the authority: :meth:`FastRenderer.render` is an accelerator
|
||||
for the common case, never a second implementation of the whole layout engine.
|
||||
|
||||
Two details make the row diff safe rather than merely fast:
|
||||
Three details make the row diff safe rather than merely fast:
|
||||
|
||||
* **A row is rewritten whole, from column 0.** The terminal's own cursor advance re-lays
|
||||
the line, so a glyph it draws narrower than we measured cannot leave the rest of the row
|
||||
shifted — the same guarantee :meth:`~meshterm.ui.tui.session.TuiSession._emit` relies on,
|
||||
reached here for free rather than by a scrub pass.
|
||||
* **Each row is prefixed with a reset and followed by an erase-to-end-of-line**, so no
|
||||
style leaks in from the row above and a row that got shorter leaves no tail behind.
|
||||
* **A row is rewritten whole, from column 0.** Stepping down to a row returns the terminal
|
||||
to a true column 0 whatever happened on the row above, so a glyph drawn at a width we did
|
||||
not measure can never throw off the rows below it — the same guarantee
|
||||
:meth:`~meshterm.ui.tui.session.TuiSession._emit` relies on, reached here for free rather
|
||||
than by a scrub pass.
|
||||
* **Every column of that row is pinned to the grid the app measured**
|
||||
(:func:`~meshterm.ui.tui.colsnap.snap_row`), which is what keeps the drift from mattering
|
||||
*within* the row either. This is the only place in the app where a row's bytes are still
|
||||
ours on their way to the terminal, so it is the only place that correction can be made.
|
||||
* **Each row is cleared before it is drawn, never after**, so no style leaks in from the row
|
||||
above, a row that got shorter leaves no tail behind, and a row that fills the terminal
|
||||
exactly keeps its last cell.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -40,6 +46,13 @@ from collections.abc import Callable
|
||||
|
||||
from prompt_toolkit.renderer import Renderer
|
||||
|
||||
from . import colsnap
|
||||
|
||||
|
||||
def _as_is(row: str) -> str:
|
||||
"""The unpinned write path: a row goes out exactly as it was composed."""
|
||||
return row
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
"""Whether the direct row writer is active.
|
||||
@@ -77,6 +90,10 @@ class FastRenderer(Renderer):
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self._frame_source = frame_source
|
||||
#: How a row is pinned to the app's column grid on its way out — resolved once here,
|
||||
#: because the escape hatch is a property of the terminal this session is talking to
|
||||
#: and not of any one paint. Identity when it is off, so the write path never branches.
|
||||
self._pin: Callable[[str], str] = colsnap.snap_row if colsnap.enabled() else _as_is
|
||||
self._prev_rows: list[str] | None = None
|
||||
self._prev_size: tuple[int, int] | None = None
|
||||
#: Paints answered here vs handed to the stock renderer — read by the bench.
|
||||
@@ -157,7 +174,9 @@ class FastRenderer(Renderer):
|
||||
# missing on the PicoCalc's 53-column console). Clearing first also drops the
|
||||
# tail of a row that got shorter, which is what the erase was for.
|
||||
write("\x1b[0m\x1b[K")
|
||||
write(row)
|
||||
# Pinned, not merely written: an emoji in a node's name is drawn at whatever width
|
||||
# this font gives it, and the lanes after it belong to the app's grid either way.
|
||||
write(self._pin(row))
|
||||
last = i
|
||||
# Park the cursor out of the text. The app hides it, but a terminal that ignores
|
||||
# that should not leave it blinking in the middle of a row.
|
||||
|
||||
@@ -39,17 +39,26 @@ _PIN_SETTLE_PASSES = 3
|
||||
_BANNER_CROP_ROWS = 9
|
||||
|
||||
|
||||
def _window_start(scroll: int, total: int, viewport: int, pinned: int) -> int:
|
||||
def _window_start(
|
||||
scroll: int, total: int, viewport: int, pinned: int, cursor: int | None = None
|
||||
) -> int:
|
||||
"""The body line the visible window starts at, given ``pinned`` reserved top rows.
|
||||
|
||||
Normally the scroll offset itself. Scrolled fully to the bottom, the reserved rows must
|
||||
not cost the *last* body lines (a chat's input row would vanish exactly when the
|
||||
transcript fills the screen), so the window slides down by as many rows as are pinned —
|
||||
the lines it drops are at the top, right under the pins, where they are stale.
|
||||
|
||||
Except the ``cursor``'s, which is never stale: the slide stops at the highlighted line.
|
||||
A reader walking ↑ back up a list that is still scrolled to its bottom reaches the
|
||||
window's top line with the scroll yet to move, and a slide past that line hid the very
|
||||
row they had just stepped onto. Where the two cannot share the window the highlight
|
||||
keeps its row and the last line waits below the ``↓`` marker.
|
||||
"""
|
||||
cap = max(1, viewport - pinned)
|
||||
if scroll >= total - viewport and total > cap:
|
||||
return min(scroll + pinned, total - cap)
|
||||
start = min(scroll + pinned, total - cap)
|
||||
return start if cursor is None else max(scroll, min(start, cursor))
|
||||
return scroll
|
||||
|
||||
|
||||
@@ -102,14 +111,14 @@ def _visible_slice(screen: Screen, lines: list[str], viewport: int) -> tuple[lis
|
||||
screen.scroll = scroll
|
||||
|
||||
if pinned:
|
||||
start = _window_start(scroll, total, viewport, len(pinned))
|
||||
start = _window_start(scroll, total, viewport, len(pinned), cursor)
|
||||
if start != scroll:
|
||||
# The window slid down off the scroll offset, so the pins must describe the row
|
||||
# it now *starts* at — otherwise a section heading among the dropped lines would
|
||||
# simply vanish instead of being pinned. Re-ask, then re-settle the start against
|
||||
# however many rows that reserves (never unpinning: the slide depends on it).
|
||||
pinned = screen.sticky_rows(start) or pinned
|
||||
start = _window_start(scroll, total, viewport, len(pinned))
|
||||
start = _window_start(scroll, total, viewport, len(pinned), cursor)
|
||||
cap = max(1, viewport - len(pinned))
|
||||
visible = pinned + lines[start : start + cap]
|
||||
more_below = start + cap < total
|
||||
|
||||
@@ -711,14 +711,12 @@ class SelectScreen(Screen):
|
||||
# One entry per body line: a finished string, or a callable that draws it when the
|
||||
# frame asks. Positions are exact either way, which is all the layout below reads.
|
||||
lines: list[str | Callable[[], str]] = []
|
||||
# A prompt (when set) sits above the list, offsetting every row below it; the cursor
|
||||
# line and sticky-header indices below are shifted by exactly this many lines.
|
||||
prefix = 0
|
||||
# A prompt (when set) sits above the list, offsetting every row below it. Every index
|
||||
# recorded below — the cursor line, the sticky headers — is read off ``len(lines)``,
|
||||
# which already counts the prompt's lines, so none of them adds the offset again.
|
||||
if self._prompt:
|
||||
plines = render_lines(Text(self._prompt), width)
|
||||
lines.extend(plines)
|
||||
lines.extend(render_lines(Text(self._prompt), width))
|
||||
lines.append("")
|
||||
prefix = len(plines) + 1
|
||||
# Record each section heading (a Separator that says it is one) as a sticky block, so
|
||||
# one that scrolls off is re-pinned to the top rows by the base Screen.sticky_block —
|
||||
# and a pinned separator (a column header) as the whole-list header pinned above it.
|
||||
@@ -780,9 +778,8 @@ class SelectScreen(Screen):
|
||||
lines.append(self._detail_drawer(detail, width))
|
||||
if not choices:
|
||||
lines.append(render_to_ansi(Text("no matches", style="muted"), width))
|
||||
# Remember where the highlighted row landed so the session can keep it in view,
|
||||
# shifted past any prompt lines drawn above the list.
|
||||
self._cursor = None if cursor_at is None else cursor_at + prefix
|
||||
# Remember where the highlighted row landed so the session can keep it in view.
|
||||
self._cursor = cursor_at
|
||||
return LazyLines(lines)
|
||||
|
||||
def _row_drawer(self, item: Choice, is_sel: bool, width: int) -> Callable[[], str]:
|
||||
|
||||
@@ -34,7 +34,7 @@ from rich.text import Text
|
||||
from ...core import win32dll
|
||||
from ...platforms import get_platform
|
||||
from ...services import modifier_watch
|
||||
from . import fastrender, fkeys, frame
|
||||
from . import colsnap, fastrender, fkeys, frame
|
||||
from .emoji_width import ClusterTextControl
|
||||
from .overlay import BusyOverlay
|
||||
from .progress import TuiProgress
|
||||
@@ -1665,18 +1665,33 @@ class TuiSession:
|
||||
return app
|
||||
|
||||
def _resolve_output(self) -> Any:
|
||||
"""The output the app renders to — optionally widened to reclaim the last column.
|
||||
"""The output the app renders to: the real terminal, pinned and optionally widened.
|
||||
|
||||
Only the *real* terminal (``self._output is None``, so prompt_toolkit would build its
|
||||
own output) is wrapped, and only when :func:`_reclaim_last_column` says on: a test
|
||||
that supplies its own output keeps the exact size it set, so headless rendering stays
|
||||
deterministic. See :class:`_WidthExtendedOutput` for what the wrap does.
|
||||
own output) is wrapped: a test that supplies its own output keeps the exact size and
|
||||
the exact bytes it set, so headless rendering stays deterministic. Two wraps, each
|
||||
where its own gate says so, and either may be the only one:
|
||||
|
||||
* :class:`~meshterm.ui.tui.colsnap.PinnedOutput`, where
|
||||
:func:`~meshterm.ui.tui.colsnap.enabled` — every glyph prompt_toolkit's renderer
|
||||
writes lands in the column that renderer measured it into.
|
||||
* :class:`_WidthExtendedOutput`, where :func:`_reclaim_last_column` — outermost, since
|
||||
the size it reports is what both renderers and the compositor lay out against.
|
||||
|
||||
With neither, this is ``None`` and prompt_toolkit builds its own output as before.
|
||||
"""
|
||||
if self._output is not None or not _reclaim_last_column():
|
||||
pin = colsnap.enabled()
|
||||
reclaim = _reclaim_last_column()
|
||||
if self._output is not None or not (pin or reclaim):
|
||||
return self._output
|
||||
from prompt_toolkit.output.defaults import create_output
|
||||
|
||||
return _WidthExtendedOutput(create_output())
|
||||
output: Any = create_output()
|
||||
if pin:
|
||||
output = colsnap.PinnedOutput(output)
|
||||
if reclaim:
|
||||
output = _WidthExtendedOutput(output)
|
||||
return output
|
||||
|
||||
# --- rendering -----------------------------------------------------------
|
||||
|
||||
|
||||
+37
-26
@@ -1,30 +1,41 @@
|
||||
# scripts
|
||||
# scripts/
|
||||
|
||||
Device-side helpers that put MeshTerm on real hardware: bringing up a ClockworkPi PicoCalc
|
||||
(Luckfox Lyra, Calculinux) and building its console font, giving that handheld a real
|
||||
MeshCore radio over UART, and fronting an SPI-attached LoRa chip as if it were a companion
|
||||
device. They run **on the device**, most of them as root, and they are meant to be read
|
||||
before they are run.
|
||||
Repo-side tools. None of these ship in the wheel.
|
||||
|
||||
**[`docs/hardware.md`](../docs/hardware.md) is the manual** — what each target is, what the
|
||||
scripts assume about the machine, why each step is the way it is, and what has only ever
|
||||
been proven on one bench. Start there and pick your target; the table below is the index.
|
||||
| Script | What it is for |
|
||||
|---|---|
|
||||
| `basemap-doctor.py` | Why a user's map has no basemap — certificates, DNS, proxy, or colour depth |
|
||||
| `picocalc/` | Calculinux device setup: console font, palette, Wi-Fi, radio bridge |
|
||||
| `uconsole/` | The uConsole SPI bridge service |
|
||||
|
||||
| File | Runs on | What it does |
|
||||
| --- | --- | --- |
|
||||
| [`calculinux-setup.sh`](calculinux-setup.sh) | the Lyra (root) | One-time bring-up: python packages, the Wi-Fi boot-scan kick, a boot clock sync (there is no RTC), the deploy user, the checkout, the venv and `pip install -e .`, the login PATH, and the console font. Idempotent. |
|
||||
| [`calculinux-console-font.sh`](calculinux-console-font.sh) | the Lyra (root) | Just the console font: builds, installs and persists the 512-glyph **meshterm** PSF (braille, node marks, rounded frame corners, the list cursor) and restores the stock 16-slot palette. Calls the 6×8 build below when it sits alongside. Run it alone after a MeshTerm update. |
|
||||
| [`calculinux-console-font-6x8.sh`](calculinux-console-font-6x8.sh) | the Lyra (root) | The shorter-cell companion font, **meshterm8** (6×8 → 53×40), for anyone who wants more rows than the 6×12 default gives. Its base bitmap is the kernel's `font_6x8`, so this one file and the PSF it builds are **GPL-2.0-only** ([`LICENSE.GPL-2.0`](LICENSE.GPL-2.0)); it reads the donor/alias/keeper tables out of the script above rather than keeping a second copy. Pick between the two fonts from MeshTerm's Preferences page. |
|
||||
| [`calculinux-wifi-set.sh`](calculinux-wifi-set.sh) | the Lyra (sudo) | Interactive Wi-Fi join: prompts for an SSID and passphrase, writes iwd's credentials, connects now. This is how a network *becomes* known to the boot kick. |
|
||||
| [`calculinux-radio-bridge.sh`](calculinux-radio-bridge.sh) | the Lyra (root) | **Unfinished, unsupported.** An early UART-to-TCP pump, superseded by `xiao-radio/lyra-setup.sh`; its header describes routing it does not contain. See the manual before reading it. |
|
||||
| [`meshterm-spi-bridge`](meshterm-spi-bridge) | the radio host (your user) | Runs a mesh node in software over an SPI-attached SX1262 (uConsole AIO, Waveshare HATs) and serves the companion protocol on `127.0.0.1:5000`. Setup menu, preflight, optional `systemd --user` service. Runs on `openhop_core` (preferred) or its predecessor `pymc_core`; see the manual for the venv it wants on a bookworm uConsole. |
|
||||
| [`xiao-radio/`](xiao-radio/) | see below | Everything for the XIAO nRF52840 + Wio-SX1262 radio wired to the Lyra's UART1. |
|
||||
| [`xiao-radio/build-firmware.sh`](xiao-radio/build-firmware.sh) | dev machine | Clones and patches MeshCore, builds the `Xiao_nrf52_companion_radio_serial` environment, emits the `.uf2`. |
|
||||
| [`xiao-radio/flash.py`](xiao-radio/flash.py) | dev machine | Flashes the `.uf2` to the XIAO over its UF2 bootloader, refusing the wrong board or SoftDevice; prints the serial-DFU command when the bootloader offers no drive. |
|
||||
| [`xiao-radio/meshcore-uart1.patch`](xiao-radio/meshcore-uart1.patch) | — | The two firmware hunks: the companion on nRF52 `Serial1`, and I²C moved off the UART pads. Not upstream yet. |
|
||||
| [`xiao-radio/lyra-setup.sh`](xiao-radio/lyra-setup.sh) | the Lyra (root) | Boot-persistent UART1 → GP4/GP5 mux service, `dialout` membership, and a default MeshTerm serial profile for `/dev/ttyS1`. |
|
||||
| [`xiao-radio/uart1-mux.py`](xiao-radio/uart1-mux.py) | the Lyra (root) | The matrix-IO register poke that routes UART1 to the header pads, run once per boot by the service above. |
|
||||
## basemap-doctor.py
|
||||
|
||||
The PicoCalc scripts and the SPI bridge belong to different machines and share nothing but
|
||||
MeshTerm. On the Lyra, only one program may hold `/dev/ttyS1` — MeshTerm directly, or a pump
|
||||
in front of it, never both.
|
||||
The map is the only networked part of MeshTerm, and every fetch failure on that path is
|
||||
logged at DEBUG while `log_level` defaults to WARNING — so a reader whose tiles never
|
||||
arrive sees a blank ground and no explanation. This prints the explanation and the fix.
|
||||
|
||||
Send a user this, to run **on the machine with the problem**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/jpmartineau/MeshTerm/main/scripts/basemap-doctor.py -o /tmp/basemap-doctor.py
|
||||
python3 /tmp/basemap-doctor.py
|
||||
```
|
||||
|
||||
Any `python3` will do: if that one has no MeshTerm, the script reads the interpreter out
|
||||
of the installed `meshterm` command's shebang and hands itself over, so the answer always
|
||||
comes from **the app's own Python** — a Mac routinely has three and only that one has the
|
||||
trust store that matters. Save it to a file as above rather than piping into `python3 -`:
|
||||
with no file on disk there is nothing to hand over.
|
||||
|
||||
Have them paste the whole output back. It names the interpreter, the certificate store,
|
||||
the tile source, the cached-tile count, `COLORTERM`, and then one verdict:
|
||||
|
||||
- **curl reached it, Python did not** — the Python's certificate store is empty. A
|
||||
python.org install on macOS ships that way until `Install Certificates.command` is run.
|
||||
- **neither reached it** — the network path: a firewall, a proxy, a DNS filter, or an
|
||||
outage. Nothing to fix in MeshTerm.
|
||||
- **both reached it** — tiles are fine and the complaint is colour: an unset `COLORTERM`
|
||||
means 256 colours, where the basemap's greens and blues collide. Terminal.app cannot do
|
||||
24-bit colour at all.
|
||||
|
||||
Exit status is 0 when tiles arrive and 1 when they do not, so it can be scripted.
|
||||
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Diagnose why MeshTerm's map has no basemap, on the machine that has the problem.
|
||||
|
||||
The map is the only networked part of MeshTerm: it fetches OpenStreetMap vector tiles over
|
||||
HTTPS from the source named by the ``basemap_tilejson_url`` preference. Every failure on
|
||||
that path — a missing certificate store, DNS, a timeout, a middlebox — is swallowed and
|
||||
logged at DEBUG (:mod:`meshterm.services.basemap`), and ``log_level`` defaults to WARNING,
|
||||
so a reader whose tiles never arrive sees a blank ground and no explanation anywhere.
|
||||
|
||||
This script is that explanation. It is standalone and stdlib-only so it can be pasted onto
|
||||
any machine running any version of MeshTerm, and it prints one verdict with the fix.
|
||||
|
||||
It runs under MeshTerm's *own* interpreter, which is the one thing that matters here: a Mac
|
||||
routinely has three Pythons, only one of them is the app's, and only that one's certificate
|
||||
store is the one the map actually uses. Started under any other, it finds the installed
|
||||
``meshterm`` command, reads the interpreter out of its shebang and hands itself over — so
|
||||
the user needs no idea which Python they installed into. Save it to a file rather than
|
||||
piping it into ``python3 -``: with no file on disk there is nothing to hand over.
|
||||
|
||||
The decisive test is an A/B. ``curl`` verifies against the operating system's trust store
|
||||
and Python verifies against its own, so curl succeeding where Python fails means the
|
||||
certificates are at fault and nothing about the network is — the one comparison that
|
||||
separates the two causes that look identical from inside the app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
#: Where tiles come from unless the preference says otherwise. Kept in step with
|
||||
#: ``basemap.DEFAULT_TILEJSON_URL`` by hand: this script may run against an install whose
|
||||
#: ``meshterm`` it cannot import, so it cannot read the constant it is mirroring.
|
||||
FALLBACK_TILEJSON_URL = "https://tiles.openfreemap.org/planet"
|
||||
|
||||
#: Per-request timeout, matching ``BasemapSource``'s own so that a link which is merely
|
||||
#: slow fails here for the same reason it fails in the app rather than for a stricter one.
|
||||
TIMEOUT = 12.0
|
||||
|
||||
#: What the app sends, so a source that filters by agent treats this run the same way.
|
||||
USER_AGENT = "MeshTerm-basemap-doctor (+https://github.com/jpmartineau/MeshTerm)"
|
||||
|
||||
#: A tile that exists: zoom 9 over central Poland. The TileJSON resolving is only half the
|
||||
#: path — the tiles themselves are often a different host — so the check fetches real
|
||||
#: geometry before it reports success.
|
||||
SAMPLE_TILE = (9, 285, 167)
|
||||
|
||||
|
||||
def heading(text: str) -> None:
|
||||
"""Print a section heading."""
|
||||
print(f"\n=== {text} ===")
|
||||
|
||||
|
||||
def line(label: str, value: object) -> None:
|
||||
"""Print one aligned ``label: value`` fact."""
|
||||
print(f" {label:<20} {value}")
|
||||
|
||||
|
||||
def find_meshterm() -> tuple[str | None, str | None, str | None]:
|
||||
"""Report MeshTerm's version, tile URL and config dir, if this interpreter has them.
|
||||
|
||||
Returns:
|
||||
``(version, tilejson_url, config_dir)``, each ``None`` where it could not be read.
|
||||
"""
|
||||
try:
|
||||
import meshterm
|
||||
from meshterm.core.config import default_config_dir
|
||||
from meshterm.core.preferences import Preferences
|
||||
except Exception: # noqa: BLE001 - any import failure means "not this interpreter"
|
||||
return None, None, None
|
||||
config_dir = default_config_dir()
|
||||
url = None
|
||||
try:
|
||||
url = Preferences.load(config_dir / "preferences.toml").basemap_tilejson_url
|
||||
except Exception: # noqa: BLE001 - an unreadable preferences file is itself a finding
|
||||
pass
|
||||
return getattr(meshterm, "__version__", "?"), url, str(config_dir)
|
||||
|
||||
|
||||
#: Set on the re-executed child, so a second interpreter that still cannot import MeshTerm
|
||||
#: gets on with the diagnosis instead of handing itself on forever.
|
||||
REEXEC_ENV = "MESHTERM_DOCTOR_REEXEC"
|
||||
|
||||
|
||||
def meshterm_interpreter() -> str | None:
|
||||
"""The interpreter the installed ``meshterm`` command runs under, if it can be read.
|
||||
|
||||
A console script on Unix is a text file whose shebang names the exact interpreter the
|
||||
app was installed into — and that interpreter's certificate store is the one the map
|
||||
actually uses, which no other Python on the machine can speak for. On Windows the
|
||||
console script is a binary launcher with no shebang, so this finds nothing and the
|
||||
diagnosis proceeds with a banner saying which interpreter it did use.
|
||||
"""
|
||||
script = shutil.which("meshterm")
|
||||
if script is None:
|
||||
return None
|
||||
try:
|
||||
with open(script, "rb") as fh:
|
||||
first = fh.readline(512)
|
||||
except OSError:
|
||||
return None
|
||||
if not first.startswith(b"#!"):
|
||||
return None
|
||||
# A shebang may carry arguments (``#!/usr/bin/env python3``); the interpreter is the
|
||||
# first word, and an ``env`` line names no absolute path worth re-execing.
|
||||
interpreter = first[2:].decode("utf-8", "replace").strip().split(" ")[0]
|
||||
return interpreter if interpreter and Path(interpreter).is_file() else None
|
||||
|
||||
|
||||
def reexec_under_meshterm() -> None:
|
||||
"""Hand this script to MeshTerm's own interpreter when it is not the one running it.
|
||||
|
||||
The user should not have to know which of their three Pythons the app was installed
|
||||
into — that detail is exactly what the failure is made of.
|
||||
"""
|
||||
if os.environ.get(REEXEC_ENV):
|
||||
return
|
||||
try:
|
||||
if importlib.util.find_spec("meshterm") is not None:
|
||||
return
|
||||
except (ImportError, ValueError): # a broken or shadowed package: keep looking
|
||||
pass
|
||||
target = meshterm_interpreter()
|
||||
here = Path(globals().get("__file__", "")).resolve() if globals().get("__file__") else None
|
||||
if target is None or here is None or not here.is_file():
|
||||
return
|
||||
if Path(target).resolve() == Path(sys.executable).resolve():
|
||||
return
|
||||
print(f" (handing over to MeshTerm's own interpreter: {target})")
|
||||
# The child inherits this stdout, so the parent's buffer has to go out first or the
|
||||
# banner lands after the report it introduces. Block buffering makes that certain the
|
||||
# moment the output is piped into a file or a paste, which is how it is meant to
|
||||
# travel. (``os.execve`` would avoid the extra process, but on Windows it is not a
|
||||
# real exec: it spawns and exits, and segfaults outright under a redirect.)
|
||||
sys.stdout.flush()
|
||||
child = subprocess.run([target, str(here), *sys.argv[1:]], env={**os.environ, REEXEC_ENV: "1"})
|
||||
sys.exit(child.returncode)
|
||||
|
||||
|
||||
def exists_marker(path: str) -> str:
|
||||
"""Return a marker saying whether ``path`` is actually on disk."""
|
||||
return "" if Path(path).exists() else " <- MISSING"
|
||||
|
||||
|
||||
def describe_env() -> tuple[str | None, str | None]:
|
||||
"""Print interpreter, platform and MeshTerm facts; return the tile URL and config dir."""
|
||||
heading("This machine")
|
||||
line("Python", sys.version.split()[0])
|
||||
line("Interpreter", sys.executable)
|
||||
line("OS", f"{platform.system()} {platform.release()} ({platform.machine()})")
|
||||
line("TERM", os.environ.get("TERM", "(unset)"))
|
||||
# Not a tile question, but it is the other thing that makes a map look wrong and it
|
||||
# costs nothing to answer in the same round trip: no COLORTERM means 256 colours, and
|
||||
# the basemap's greens and blues are what collide when they are quantized.
|
||||
# Windows is exempt: prompt_toolkit's Windows output reports 24-bit outright, so an
|
||||
# unset COLORTERM there says nothing. Everywhere else it is the whole of the evidence.
|
||||
unset = "(unset)" if os.name == "nt" else "(unset) <- only 256 colours"
|
||||
line("COLORTERM", os.environ.get("COLORTERM") or unset)
|
||||
|
||||
version, url, config_dir = find_meshterm()
|
||||
if version is None:
|
||||
line("MeshTerm", "NOT importable here <- probably the wrong interpreter")
|
||||
else:
|
||||
line("MeshTerm", version)
|
||||
line("Config dir", config_dir)
|
||||
return url, config_dir
|
||||
|
||||
|
||||
def describe_trust() -> None:
|
||||
"""Print the certificate store this Python would verify a tile server against."""
|
||||
heading("Certificate store")
|
||||
line("OpenSSL", ssl.OPENSSL_VERSION)
|
||||
paths = ssl.get_default_verify_paths()
|
||||
for label, value in (("CA file", paths.cafile), ("CA path", paths.capath)):
|
||||
line(label, f"{value}{exists_marker(value)}" if value else "(none)")
|
||||
try:
|
||||
import certifi
|
||||
|
||||
line("certifi", certifi.where())
|
||||
except ImportError:
|
||||
line("certifi", "not installed")
|
||||
for var in (
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"ALL_PROXY",
|
||||
):
|
||||
if os.environ.get(var):
|
||||
line(var, os.environ[var])
|
||||
loaded = len(ssl.create_default_context().get_ca_certs())
|
||||
line("Certs loaded", loaded if loaded else "0 <- nothing to verify against")
|
||||
|
||||
|
||||
#: ``curl`` exit codes that mean TLS, as opposed to never getting that far. The whole
|
||||
#: value of asking curl is that it verifies against the *operating system's* trust store,
|
||||
#: so "curl got a certificate it disliked" and "curl could not reach the host" are
|
||||
#: opposite answers and must not be collapsed into "curl failed".
|
||||
CURL_TLS_CODES = frozenset({35, 51, 58, 59, 60, 66, 77, 83, 90, 91})
|
||||
|
||||
|
||||
class Curl(NamedTuple):
|
||||
"""What the system ``curl`` made of the same URL.
|
||||
|
||||
Attributes:
|
||||
verdict: ``ok`` (the server answered, whatever its status), ``tls`` (a certificate
|
||||
or handshake failure), ``unreachable`` (never got that far), or ``absent``
|
||||
(no ``curl`` on this machine to ask).
|
||||
detail: The status or error, for the transcript.
|
||||
"""
|
||||
|
||||
verdict: str
|
||||
detail: str
|
||||
|
||||
|
||||
def curl_says(url: str) -> Curl:
|
||||
"""Fetch ``url`` with the system ``curl``, which verifies against the OS trust store."""
|
||||
curl = shutil.which("curl")
|
||||
if curl is None:
|
||||
return Curl("absent", "no curl on this machine to compare against")
|
||||
argv = [
|
||||
curl,
|
||||
"-sS",
|
||||
"-o",
|
||||
os.devnull,
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"--max-time",
|
||||
str(int(TIMEOUT)),
|
||||
url,
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(argv, capture_output=True, text=True, timeout=TIMEOUT + 5)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return Curl("unreachable", "timed out")
|
||||
if proc.returncode == 0:
|
||||
# Any HTTP status at all means the transport worked, which is the only thing this
|
||||
# comparison is asking about - a 301 or a 404 still proves TLS and the route.
|
||||
return Curl("ok", f"HTTP {proc.stdout.strip()}")
|
||||
kind = "tls" if proc.returncode in CURL_TLS_CODES else "unreachable"
|
||||
return Curl(kind, f"exit {proc.returncode}: {proc.stderr.strip() or 'no detail'}")
|
||||
|
||||
|
||||
def fetch(url: str) -> tuple[bool, object]:
|
||||
"""GET ``url`` exactly the way the app does. Returns ``(ok, body-or-exception)``."""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
||||
return True, resp.read()
|
||||
except Exception as exc: # noqa: BLE001 - classifying the failure is the whole job
|
||||
return False, exc
|
||||
|
||||
|
||||
#: What to do about a certificate store this Python cannot verify with. The macOS case is
|
||||
#: first because it is the one that reaches a user who did nothing wrong: a python.org
|
||||
#: install ships with an empty store until its own installer command is run.
|
||||
CERT_FIX = (
|
||||
"Give this Python a certificate store. On macOS a python.org install ships\n"
|
||||
" without one until you run its own command:\n"
|
||||
" open /Applications/Python*/Install\\ Certificates.command\n"
|
||||
" Or point it at certifi's, in the environment MeshTerm runs in:\n"
|
||||
" pip install certifi\n"
|
||||
' export SSL_CERT_FILE="$(python3 -m certifi)" # then relaunch MeshTerm'
|
||||
)
|
||||
|
||||
#: What to do when TLS is being terminated in the middle rather than at the tile server.
|
||||
INTERCEPT_FIX = (
|
||||
"Trust the interceptor's root certificate in this Python (SSL_CERT_FILE can\n"
|
||||
" point at a bundle that includes it), or exempt the tile host from HTTPS\n"
|
||||
" inspection. MeshTerm has no way to accept a certificate it cannot verify."
|
||||
)
|
||||
|
||||
|
||||
def classify(exc: BaseException) -> tuple[str, str, str]:
|
||||
"""Turn a fetch exception into ``(kind, what happened, what to do about it)``.
|
||||
|
||||
The *kind* is what the verdict reasons with: a certificate failure and a timeout point
|
||||
at opposite halves of the world, and the ``curl`` comparison means something different
|
||||
for each.
|
||||
"""
|
||||
reason = getattr(exc, "reason", exc)
|
||||
if isinstance(exc, urllib.error.HTTPError):
|
||||
return (
|
||||
"http",
|
||||
f"the server answered HTTP {exc.code}",
|
||||
"The network is fine. A 4xx means the tile URL is wrong (check the "
|
||||
"'Map tiles' preference); a 5xx is the tile server itself.",
|
||||
)
|
||||
if isinstance(reason, ssl.SSLCertVerificationError):
|
||||
return ("cert", "this Python could not verify the tile server's certificate", CERT_FIX)
|
||||
if isinstance(reason, ssl.SSLError):
|
||||
return ("tls", "the TLS handshake failed", INTERCEPT_FIX)
|
||||
if isinstance(reason, socket.gaierror):
|
||||
return (
|
||||
"dns",
|
||||
"the tile host does not resolve",
|
||||
"DNS. Check the machine is online, and that no DNS filter or ad-blocker "
|
||||
"is swallowing the tile host.",
|
||||
)
|
||||
if isinstance(reason, TimeoutError):
|
||||
return (
|
||||
"timeout",
|
||||
"the connection timed out",
|
||||
"The host is unreachable, or something is dropping the traffic silently: "
|
||||
"a firewall, or a link too slow for a 12-second timeout.",
|
||||
)
|
||||
if isinstance(reason, ConnectionResetError):
|
||||
return (
|
||||
"reset",
|
||||
"the connection was reset",
|
||||
"Something in the path is cutting the connection - a firewall or a "
|
||||
"middlebox rather than the tile server.",
|
||||
)
|
||||
return ("other", f"of {type(reason).__name__}: {reason}", "")
|
||||
|
||||
|
||||
def verdict(kind: str, cause: str, fix: str, curl: Curl) -> None:
|
||||
"""Print one coherent story from what Python saw and what ``curl`` saw.
|
||||
|
||||
The two readings have to be reconciled rather than printed side by side: a certificate
|
||||
Python rejects *and curl accepts* is MeshTerm's own trust store, while one they both
|
||||
reject is the network re-signing traffic or a genuinely bad server - the same Python
|
||||
exception, opposite causes, opposite fixes.
|
||||
"""
|
||||
print(f" The map cannot fetch tiles because {cause}.")
|
||||
if kind in ("cert", "tls"):
|
||||
if curl.verdict == "ok":
|
||||
print(" The system curl reached that very URL, verifying against the OS trust")
|
||||
print(" store, so the network, the ISP and the tile server are all innocent:")
|
||||
print(" the fault is this Python's own certificate store.")
|
||||
print(f"\n Fix: {CERT_FIX}")
|
||||
return
|
||||
if curl.verdict == "tls":
|
||||
print(" The system curl refuses the same certificate, so this is not just")
|
||||
print(" MeshTerm's trust store: either something on this network is")
|
||||
print(" intercepting TLS and re-signing it, or the certificate really is bad.")
|
||||
print(f"\n Fix: {INTERCEPT_FIX}")
|
||||
return
|
||||
if curl.verdict == "unreachable":
|
||||
print(" The system curl could not reach the host at all, so start with the")
|
||||
print(" network - a firewall, a proxy or a DNS filter - before the certificates.")
|
||||
return
|
||||
elif curl.verdict == "ok":
|
||||
print(" The system curl reached that very URL, so the route exists and this")
|
||||
print(" Python is going somewhere else - check the proxy variables above.")
|
||||
elif curl.verdict in ("tls", "unreachable"):
|
||||
print(" The system curl could not reach it either, so this is the network path")
|
||||
print(" rather than MeshTerm - a firewall, a proxy, a DNS filter or an outage.")
|
||||
if fix:
|
||||
print(f"\n Fix: {fix}")
|
||||
|
||||
|
||||
def count_cached(config_dir: str | None) -> None:
|
||||
"""Print how many tiles are already on disk, which the map can draw with no network."""
|
||||
if not config_dir:
|
||||
return
|
||||
tiles = Path(config_dir) / "tilecache" / "tiles"
|
||||
if not tiles.exists():
|
||||
line("Tile cache", "empty - no tile has ever arrived")
|
||||
return
|
||||
line("Tile cache", f"{sum(1 for _ in tiles.rglob('*.pbf'))} tiles at {tiles.parent}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run every check and print one verdict. Returns a process exit status."""
|
||||
print("MeshTerm basemap doctor")
|
||||
reexec_under_meshterm()
|
||||
url, config_dir = describe_env()
|
||||
tilejson_url = url or FALLBACK_TILEJSON_URL
|
||||
describe_trust()
|
||||
|
||||
heading("Reaching the tile source")
|
||||
line("Tile source", tilejson_url + ("" if url else " (default)"))
|
||||
count_cached(config_dir)
|
||||
|
||||
curl = curl_says(tilejson_url)
|
||||
line("System curl", curl.detail)
|
||||
|
||||
ok, result = fetch(tilejson_url)
|
||||
line("This Python", "OK" if ok else f"FAILED - {type(result).__name__}")
|
||||
|
||||
tile_ok = False
|
||||
if ok:
|
||||
try:
|
||||
template = json.loads(result)["tiles"][0]
|
||||
z, x, y = SAMPLE_TILE
|
||||
tile_url = template.replace("{z}", str(z)).replace("{x}", str(x)).replace("{y}", str(y))
|
||||
tile_ok, tile_result = fetch(tile_url)
|
||||
line("One real tile", f"OK, {len(tile_result)} bytes" if tile_ok else "FAILED")
|
||||
if not tile_ok:
|
||||
result = tile_result
|
||||
except (ValueError, KeyError, IndexError) as exc:
|
||||
line("TileJSON", f"unreadable: {exc}")
|
||||
|
||||
heading("Verdict")
|
||||
if ok and tile_ok:
|
||||
print(" Tiles reach this machine, so the basemap is NOT a network problem.")
|
||||
print(" If the map still looks wrong it is the terminal: COLORTERM unset means")
|
||||
print(" 256 colours, and the basemap's greens and blues collide once they are")
|
||||
print(" quantized. Apple's Terminal.app cannot do 24-bit colour at all - try")
|
||||
print(" iTerm2, Ghostty or kitty.")
|
||||
return 0
|
||||
|
||||
failure = result if isinstance(result, BaseException) else RuntimeError("no answer")
|
||||
verdict(*classify(failure), curl)
|
||||
prefs = Path(config_dir) / "preferences.toml" if config_dir else Path("~/.meshterm")
|
||||
print("\n Meanwhile the map still works - it plots nodes without a basemap.")
|
||||
print(" Per-request detail goes to the log if you set log_level = DEBUG in")
|
||||
print(f" {prefs} and reopen the map.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File renamed without changes.
+5
-5
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# calculinux-console-font.sh -- build, install, and persist the "meshterm" console fonts
|
||||
# calculinux-console-font-6x12.sh -- build, install, and persist the "meshterm" console fonts
|
||||
# and the 16-slot palette.
|
||||
#
|
||||
# MeshTerm runs on a Luckfox Lyra inside a ClockworkPi PicoCalc under Calculinux, drawing
|
||||
@@ -42,7 +42,7 @@
|
||||
# Everything else is pure geometry, generated on-device; only the stock Terminus font and
|
||||
# python3 are required. Run as root on the Lyra (serial console is fine):
|
||||
#
|
||||
# sh calculinux-console-font.sh
|
||||
# sh calculinux-console-font-6x12.sh
|
||||
#
|
||||
# Idempotent -- safe to re-run after a MeshTerm update or a font tweak. Also manages the
|
||||
# 16-slot palette: a default run restores the stock VT palette (theme._VT_SLOTS) and
|
||||
@@ -258,7 +258,7 @@ PYEOF
|
||||
|
||||
# --- the 6x8 companion, out of its own (GPL-2.0) file -----------------------------------
|
||||
# Kept separate because its base bitmap is the kernel's font_6x8 and the produced PSF is
|
||||
# therefore GPL-2.0 -- see scripts/calculinux-console-font-6x8.sh and scripts/LICENSE.GPL-2.0.
|
||||
# therefore GPL-2.0 -- see scripts/picocalc/calculinux-console-font-6x8.sh and scripts/picocalc/LICENSE.GPL-2.0.
|
||||
# It re-uses the shared generator block above rather than carrying a second copy of the
|
||||
# donor/alias/keeper tables, so a run here still produces both fonts, as it always did.
|
||||
# A failure there is not a failure here: the 6x12 default is the font the device boots in.
|
||||
@@ -267,7 +267,7 @@ if [ "${SKIP_6X8:-0}" = 1 ]; then
|
||||
elif [ -f "$BUILD8" ]; then
|
||||
sh "$BUILD8" || echo "warning: the 6x8 build failed; $OUT is installed either way" >&2
|
||||
else
|
||||
echo "note: $BUILD8 not found -- only $OUT was built (copy the whole scripts/ dir)" >&2
|
||||
echo "note: $BUILD8 not found -- only $OUT was built (copy the whole scripts/picocalc/ dir)" >&2
|
||||
fi
|
||||
|
||||
# --- apply live: setfont re-renders the whole console immediately -----------------------
|
||||
@@ -294,7 +294,7 @@ fi
|
||||
# (theme._VT_SLOTS). The custom tailwind-family remap originally shipped is ARCHIVED,
|
||||
# one environment variable away for anyone who wants it back:
|
||||
#
|
||||
# MESHTERM_CUSTOM_PALETTE=1 sh calculinux-console-font.sh
|
||||
# MESHTERM_CUSTOM_PALETTE=1 sh calculinux-console-font-6x12.sh
|
||||
#
|
||||
# The opt-in block's values match theme.vtrgb_lines() / theme._VT_SLOTS_CUSTOM exactly (a
|
||||
# test keeps them in sync). A DEFAULT run removes any previously-installed remap and
|
||||
+5
-5
@@ -6,7 +6,7 @@
|
||||
# LICENCE -- READ THIS FIRST. The base bitmap embedded below is the Linux kernel's own
|
||||
# font_6x8 (lib/fonts/font_6x8.c), which is GPL-2.0. This script is therefore GPL-2.0-only
|
||||
# and so is the PSF it produces; the full licence text sits beside it as
|
||||
# scripts/LICENSE.GPL-2.0. The rest of MeshTerm is Apache-2.0 and stays that way: the
|
||||
# scripts/picocalc/LICENSE.GPL-2.0. The rest of MeshTerm is Apache-2.0 and stays that way: the
|
||||
# produced meshterm8.psf.gz is a DATA FILE the Linux kernel console reads with setfont, on
|
||||
# the device, at run time. MeshTerm never links it, never bundles it, and never
|
||||
# redistributes it -- it is built on the machine it runs on, from this script, and the
|
||||
@@ -15,7 +15,7 @@
|
||||
#
|
||||
# WHERE THE SHARED PARTS LIVE. The donor, alias and keeper tables, the braille generator
|
||||
# and the PSF2 writer are NOT duplicated here. They are read out of
|
||||
# calculinux-console-font.sh (Apache-2.0), between its "shared generator (BEGIN)" and
|
||||
# calculinux-console-font-6x12.sh (Apache-2.0), between its "shared generator (BEGIN)" and
|
||||
# "(END)" marker lines, and exec'd -- so the two fonts can never drift, and a repo test
|
||||
# that parses those tables has exactly one file to parse. That file must sit beside this
|
||||
# one; point SHARED at it if it does not. The shared generator is additionally offered
|
||||
@@ -28,7 +28,7 @@
|
||||
# named in Cyrillic draws as tofu in this font. The keeper list drops the Cyrillic canary
|
||||
# accordingly, and only pi is checked.
|
||||
#
|
||||
# Run as root on the Lyra, either on its own or through calculinux-console-font.sh, which
|
||||
# Run as root on the Lyra, either on its own or through calculinux-console-font-6x12.sh, which
|
||||
# calls it:
|
||||
#
|
||||
# sh calculinux-console-font-6x8.sh
|
||||
@@ -43,7 +43,7 @@ FONT_NAME=meshterm
|
||||
CONSOLEFONTS=/usr/share/consolefonts
|
||||
OUT8="$CONSOLEFONTS/${FONT_NAME}8.psf.gz"
|
||||
HERE=$(dirname "$0")
|
||||
SHARED=${SHARED:-"$HERE/calculinux-console-font.sh"} # Apache-2.0; holds the shared tables
|
||||
SHARED=${SHARED:-"$HERE/calculinux-console-font-6x12.sh"} # Apache-2.0; holds the shared tables
|
||||
|
||||
# --- preflight: fail early with a plain reason, never half-apply -----------------------
|
||||
[ "$(id -u)" = 0 ] || { echo "error: run as root (writes $CONSOLEFONTS)" >&2; exit 1; }
|
||||
@@ -57,7 +57,7 @@ import os
|
||||
import re
|
||||
|
||||
# --- the shared generator, borrowed rather than copied ---------------------------------
|
||||
# Everything between the two marker lines in calculinux-console-font.sh: the braille
|
||||
# Everything between the two marker lines in calculinux-console-font-6x12.sh: the braille
|
||||
# generator, art(), DONORS, ALIASES, KEEP_COMMON, BANDS8 and build(). One copy of those
|
||||
# tables, in the Apache-2.0 file, both fonts built from it.
|
||||
SHARED = os.environ["SHARED"]
|
||||
File renamed without changes.
@@ -34,7 +34,7 @@
|
||||
# 7. venv + install A venv + `pip install -e .`. pip's C builds hit ENOSPC because /tmp
|
||||
# is a tiny RAM tmpfs, so TMPDIR is redirected to $HOME/tmp on /data.
|
||||
# 8. PATH Put the venv's `meshterm` on that user's login PATH via ~/.profile.
|
||||
# 9. console font Hand off to calculinux-console-font.sh (braille + node glyphs +
|
||||
# 9. console font Hand off to calculinux-console-font-6x12.sh (braille + node glyphs +
|
||||
# rounded frame corners + the list cursor the bare console can't draw).
|
||||
#
|
||||
# Two prerequisites this script cannot safely embed and will check for / guide you through:
|
||||
@@ -348,10 +348,10 @@ fi
|
||||
|
||||
# --- 8. console font -------------------------------------------------------------------
|
||||
log "9/9 console font"
|
||||
if [ -f "$SCRIPT_DIR/calculinux-console-font.sh" ]; then
|
||||
sh "$SCRIPT_DIR/calculinux-console-font.sh"
|
||||
if [ -f "$SCRIPT_DIR/calculinux-console-font-6x12.sh" ]; then
|
||||
sh "$SCRIPT_DIR/calculinux-console-font-6x12.sh"
|
||||
else
|
||||
info "calculinux-console-font.sh not alongside this script -- skipping (run it separately)"
|
||||
info "calculinux-console-font-6x12.sh not alongside this script -- skipping (run it separately)"
|
||||
fi
|
||||
|
||||
# --- done ------------------------------------------------------------------------------
|
||||
File renamed without changes.
File renamed without changes.
@@ -4,10 +4,10 @@ A Seeed XIAO nRF52840 + Wio-SX1262 wired to the Luckfox Lyra's UART1, running Me
|
||||
companion firmware on `Serial1`, so MeshTerm on the PicoCalc opens `/dev/ttyS1` like any
|
||||
other serial companion. No BLE, no USB host, no SD-slot sacrifice.
|
||||
|
||||
**The manual is [`docs/hardware.md`](../../docs/hardware.md#a-real-radio-for-the-picocalc-xiao-nrf52840-over-uart)**
|
||||
— parts, the firmware patch and why it is still needed, flashing a bootloader that offers no
|
||||
drive, what the register poke does, troubleshooting, and what has only been proven on one
|
||||
bench. This page is the bench card.
|
||||
**The step-by-step guide is [`docs/picocalc.md`](../../../docs/picocalc.md), Phase 3** — parts,
|
||||
the firmware build and the patch explained, flashing, wiring, the Lyra setup, and what has
|
||||
only been proven on one bench. What the register poke does is in `uart1-mux.py`'s own
|
||||
header. This page is the bench card.
|
||||
|
||||
**Licensing.** The firmware is MeshCore, under the MIT License. The `meshcore-uart1.patch`
|
||||
modifies MeshCore source and `build-firmware.sh` produces a MeshCore binary; the licence is
|
||||
@@ -17,13 +17,13 @@ Three steps on two machines:
|
||||
|
||||
```bash
|
||||
# 1. dev machine: build (needs git + PlatformIO)
|
||||
cd scripts/xiao-radio && sh build-firmware.sh
|
||||
cd scripts/picocalc/xiao-radio && sh build-firmware.sh
|
||||
|
||||
# 2. dev machine: flash, with the XIAO's own USB-C plugged in
|
||||
python flash.py # double-tap reset if the touch doesn't take
|
||||
|
||||
# 3. the Lyra, as root
|
||||
sh scripts/xiao-radio/lyra-setup.sh # MT_USER=meshterm by default
|
||||
sh scripts/picocalc/xiao-radio/lyra-setup.sh # MT_USER=meshterm by default
|
||||
|
||||
# then, as that user
|
||||
meshterm
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Tests for the clock set: the shared device step, and the on-connect service around it.
|
||||
|
||||
One function sets the device clock for both the Device config action and the automatic
|
||||
service; the service is off by default, acts once per connection in the background, and
|
||||
says what it did in the log rather than on screen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from meshterm.context import AppContext
|
||||
from meshterm.core.admin_store import AdminStore
|
||||
from meshterm.core.config import Settings
|
||||
from meshterm.core.device_store import DeviceStore
|
||||
from meshterm.core.preferences import get_spec
|
||||
from meshterm.persistence.repository import Repository
|
||||
from meshterm.services.clock_sync import ClockSync, set_clock
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ctx(tmp_path: Path) -> AppContext:
|
||||
"""A mock-backed application context, preferences at their defaults."""
|
||||
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "clock.db")
|
||||
context = AppContext(
|
||||
console=Console(file=io.StringIO()),
|
||||
settings=settings,
|
||||
repo=Repository(settings.db_path),
|
||||
device_store=DeviceStore(tmp_path / "devices.json"),
|
||||
admin_store=AdminStore(tmp_path / "admin.json"),
|
||||
mock=True,
|
||||
)
|
||||
yield context
|
||||
context.repo.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def log_lines() -> list[str]:
|
||||
"""Every record the app logger emits during the test (it does not propagate to root)."""
|
||||
lines: list[str] = []
|
||||
|
||||
class _Collect(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
lines.append(record.getMessage())
|
||||
|
||||
handler = _Collect(level=logging.DEBUG)
|
||||
logger = logging.getLogger("meshterm")
|
||||
previous = logger.level
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.addHandler(handler)
|
||||
yield lines
|
||||
logger.removeHandler(handler)
|
||||
logger.setLevel(previous)
|
||||
|
||||
|
||||
async def _settle() -> None:
|
||||
"""Let a task spawned by the service run to completion."""
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
def test_the_preference_exists_and_is_off_by_default() -> None:
|
||||
"""Writing to a radio nobody asked to be written to is the owner's call."""
|
||||
spec = get_spec("set_clock_on_connect")
|
||||
assert spec.value_type == "bool"
|
||||
assert spec.default is False
|
||||
assert spec.group == "Device"
|
||||
|
||||
|
||||
async def test_set_clock_writes_the_host_time_and_reports_the_drift(ctx: AppContext) -> None:
|
||||
"""The shared step corrects a clock an hour slow and says by how much."""
|
||||
device = await ctx.device()
|
||||
await device.set_time(int(time.time()) - 3600)
|
||||
|
||||
done = await set_clock(device)
|
||||
|
||||
assert abs(done.epoch - int(time.time())) <= 2
|
||||
assert done.drift_s is not None and -3602 <= done.drift_s <= -3598
|
||||
assert abs(await device.get_time() - int(time.time())) <= 2
|
||||
|
||||
|
||||
async def test_the_service_does_nothing_while_the_preference_is_off(
|
||||
ctx: AppContext, log_lines: list[str]
|
||||
) -> None:
|
||||
"""Off by default: a connection is noted and the clock is left alone."""
|
||||
device = await ctx.device()
|
||||
await device.set_time(int(time.time()) - 3600)
|
||||
service = ClockSync(ctx)
|
||||
await service.start()
|
||||
|
||||
service.on_connected(device)
|
||||
await _settle()
|
||||
|
||||
assert abs(await device.get_time() - (int(time.time()) - 3600)) <= 2
|
||||
assert not any("clock sync" in line for line in log_lines)
|
||||
|
||||
|
||||
async def test_the_service_sets_the_clock_once_per_connection_and_logs_it(
|
||||
ctx: AppContext, log_lines: list[str]
|
||||
) -> None:
|
||||
"""On: the first settled connection is set in the background; the same one is not set twice."""
|
||||
ctx.preferences.set("set_clock_on_connect", True)
|
||||
device = await ctx.device()
|
||||
await device.set_time(int(time.time()) - 3600)
|
||||
service = ClockSync(ctx)
|
||||
await service.start() # a device is already connected: that counts as the first one
|
||||
|
||||
await _settle()
|
||||
assert abs(await device.get_time() - int(time.time())) <= 2
|
||||
said = [line for line in log_lines if line.startswith("clock sync: device clock set to")]
|
||||
assert len(said) == 1
|
||||
assert "-36" in said[0] # "(was -3600 s off)", give or take the seconds the test took
|
||||
|
||||
await device.set_time(int(time.time()) - 3600)
|
||||
service.on_connected(device)
|
||||
await _settle()
|
||||
assert abs(await device.get_time() - (int(time.time()) - 3600)) <= 2 # untouched
|
||||
assert len([line for line in log_lines if "device clock set to" in line]) == 1
|
||||
await service.aclose()
|
||||
|
||||
|
||||
async def test_a_service_never_started_ignores_connections(
|
||||
ctx: AppContext, log_lines: list[str]
|
||||
) -> None:
|
||||
"""A scripted run never starts the service, so its connections never write the clock."""
|
||||
ctx.preferences.set("set_clock_on_connect", True)
|
||||
device = await ctx.device()
|
||||
await device.set_time(int(time.time()) - 3600)
|
||||
|
||||
ctx.clock_sync.on_connected(device) # what the connect path does
|
||||
await _settle()
|
||||
|
||||
assert abs(await device.get_time() - (int(time.time()) - 3600)) <= 2
|
||||
assert not any("clock sync" in line for line in log_lines)
|
||||
|
||||
|
||||
async def test_a_write_that_fails_is_logged_not_raised(
|
||||
ctx: AppContext, log_lines: list[str]
|
||||
) -> None:
|
||||
"""A firmware that refuses the write costs a warning line, never the session."""
|
||||
ctx.preferences.set("set_clock_on_connect", True)
|
||||
device = await ctx.device()
|
||||
|
||||
async def _refuse(epoch: int) -> None:
|
||||
raise RuntimeError("no clock here")
|
||||
|
||||
device.set_time = _refuse # type: ignore[method-assign]
|
||||
service = ClockSync(ctx)
|
||||
await service.start()
|
||||
await _settle()
|
||||
|
||||
assert any("could not set the device clock: no clock here" in line for line in log_lines)
|
||||
@@ -0,0 +1,555 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Column pinning: a row lands on the app's grid however wide the terminal draws its glyphs.
|
||||
|
||||
The reasoning lives in :mod:`meshterm.ui.tui.colsnap`. What these tests pin is the claim that
|
||||
makes it worth having — that alignment no longer depends on the two width tables being *right*
|
||||
about a glyph, only on the row saying where its columns are.
|
||||
|
||||
So the fixture here is a terminal that **disagrees on purpose** (:class:`_Terminal`, handed the
|
||||
glyphs it draws at a width of its own). That is not a contrived case: it is the situation the
|
||||
emoji-width module documents as unprobeable and unfixable-in-general, and the one a node name
|
||||
off the air walks into every time somebody puts an emoji in it. Every assertion below is about
|
||||
where a glyph *landed*, never about what was measured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from rich.cells import cell_len
|
||||
|
||||
from meshterm.ui.tui import colsnap
|
||||
from meshterm.ui.tui.emoji_width import clusters
|
||||
|
||||
_WAVE = "👋" # two cells by both stock tables; one cell on the reference terminal's font
|
||||
_ROAD = "\U0001f6e3" # 🛣 one cell by both tables — the glyph a font may still draw wide
|
||||
_CA = "🇨🇦" # a flag: one glyph out of two Regional Indicators
|
||||
_FAMILY = "\U0001f468\U0001f469\U0001f467" # 👨👩👧 one glyph out of three joined people
|
||||
_ROW = "│ ❯ Lakeside ● ▲ ★ ─── 5m │" # chrome only: nothing here needs an address
|
||||
|
||||
|
||||
class _Terminal:
|
||||
"""A terminal that lays out what it is written using a width table of its own.
|
||||
|
||||
Honours exactly one control sequence — ``CSI n G``, the absolute column address — and
|
||||
ignores every other one, which is how a real terminal treats the theme's colour runs for
|
||||
the purposes of column arithmetic.
|
||||
|
||||
Args:
|
||||
width: The terminal's width in cells.
|
||||
draws: Glyphs this terminal draws at a width the app did not measure, as
|
||||
``{glyph: cells}``. Everything absent is drawn at the measured width, so a test
|
||||
names its disagreement and nothing else.
|
||||
"""
|
||||
|
||||
_CSI = re.compile(r"\x1b\[([0-9;]*)([A-Za-z])")
|
||||
|
||||
def __init__(self, width: int, *, draws: dict[str, int] | None = None) -> None:
|
||||
"""Start with a blank row and the cursor in column 0."""
|
||||
self.width = width
|
||||
self.draws = draws or {}
|
||||
self.cells: list[str] = [" "] * width
|
||||
self.column = 0
|
||||
|
||||
def write(self, data: str) -> None:
|
||||
"""Draw ``data``, obeying any column address in it."""
|
||||
position = 0
|
||||
for match in self._CSI.finditer(data):
|
||||
self._draw(data[position : match.start()])
|
||||
if match.group(2) == "G":
|
||||
self.column = int(match.group(1) or "1") - 1
|
||||
position = match.end()
|
||||
self._draw(data[position:])
|
||||
|
||||
def _draw(self, text: str) -> None:
|
||||
"""Place each glyph of one escape-free stretch, advancing by this terminal's widths."""
|
||||
for cluster in clusters(text):
|
||||
size = self.draws.get(cluster, cell_len(cluster))
|
||||
if 0 <= self.column < self.width:
|
||||
self.cells[self.column] = cluster
|
||||
for overhang in range(1, size):
|
||||
if self.column + overhang < self.width:
|
||||
self.cells[self.column + overhang] = ""
|
||||
self.column += size
|
||||
|
||||
def at(self, column: int) -> str:
|
||||
"""The glyph drawn in ``column``."""
|
||||
return self.cells[column]
|
||||
|
||||
|
||||
def test_a_row_of_chrome_is_handed_back_untouched() -> None:
|
||||
"""The common row costs one scan and no allocation — the same object comes back.
|
||||
|
||||
Borders, block elements, braille and the node marks are what the app draws by the
|
||||
thousand, and pinning after each of them would be five bytes and a cluster walk to
|
||||
address the column the cursor is already in.
|
||||
"""
|
||||
assert colsnap.snap_row(_ROW) is _ROW
|
||||
assert colsnap.snap_row("plain ascii row") is "plain ascii row" # noqa: F632 - identity
|
||||
assert colsnap.snap_row("⠁⠂⠃⡿ braille chart ▁▃▅█ ╭─╮") is not None
|
||||
|
||||
|
||||
def test_an_emoji_is_followed_by_the_column_it_was_measured_into() -> None:
|
||||
"""The whole mechanism: the glyph, then the one-based column the next glyph belongs in."""
|
||||
assert cell_len(_WAVE) == 2, "the stock table measures the wave at two cells"
|
||||
# "a" fills column 0, the wave is measured into columns 1-2, so "b" belongs in column 3.
|
||||
assert colsnap.snap_row(f"a{_WAVE}b") == f"a\x1b[2X{_WAVE}\x1b[4Gb"
|
||||
|
||||
|
||||
def test_nothing_is_pinned_after_the_last_glyph_on_the_row() -> None:
|
||||
"""An address is held over until something drawable follows it, and dropped if none does.
|
||||
|
||||
A chat line ending on an emoji is the common case, and there is nothing after it whose
|
||||
column could be wrong.
|
||||
"""
|
||||
assert colsnap.snap_row(f"hi {_WAVE}") == f"hi \x1b[2X{_WAVE}"
|
||||
|
||||
|
||||
def test_a_style_change_occupies_no_column_and_stays_where_the_theme_put_it() -> None:
|
||||
"""A colour run is copied through without advancing the column it would have shifted."""
|
||||
pinned = colsnap.snap_row(f"\x1b[31m{_WAVE}\x1b[0m|")
|
||||
assert pinned == f"\x1b[31m\x1b[2X{_WAVE}\x1b[0m\x1b[3G|"
|
||||
|
||||
|
||||
def test_a_flag_and_a_joined_sequence_are_pinned_once_as_whole_glyphs() -> None:
|
||||
"""One address per glyph, not per codepoint — the cut that halves a flag is never made."""
|
||||
for glyph in (_CA, _FAMILY):
|
||||
width = cell_len(glyph)
|
||||
pinned = colsnap.snap_row(f"{glyph}|")
|
||||
assert pinned == f"\x1b[{width}X{glyph}\x1b[{width + 1}G|"
|
||||
assert pinned.count("G") == 1
|
||||
|
||||
|
||||
def test_a_bare_text_emoji_is_pinned_even_though_both_tables_agree_about_it() -> None:
|
||||
"""Agreement is not certainty: what a font draws is still the font's business.
|
||||
|
||||
``🛣`` is the glyph whose measurement must not be *forced* to two (that pulled the Trophy
|
||||
case's border in). Pinning is the other answer to the same doubt: leave the measurement
|
||||
alone and say where the next column is, so the row holds either way.
|
||||
"""
|
||||
assert cell_len(_ROAD) == 1
|
||||
assert colsnap.snap_row(f"{_ROAD}|") == f"{_ROAD}\x1b[2G|"
|
||||
|
||||
|
||||
def test_the_row_frames_flush_on_a_terminal_that_draws_the_glyph_narrow() -> None:
|
||||
"""The notch, and its absence: a border lands in its measured column once pinned."""
|
||||
row = f"│ {_WAVE} Lakeside │"
|
||||
edge = cell_len(row) - 1
|
||||
|
||||
loose = _Terminal(cell_len(row), draws={_WAVE: 1})
|
||||
loose.write(row)
|
||||
assert loose.at(edge) != "│", "unpinned, the narrow glyph pulls the border a column in"
|
||||
assert loose.column == cell_len(row) - 1
|
||||
|
||||
pinned = _Terminal(cell_len(row), draws={_WAVE: 1})
|
||||
pinned.write(colsnap.snap_row(row))
|
||||
assert pinned.at(edge) == "│"
|
||||
assert pinned.column == cell_len(row)
|
||||
|
||||
|
||||
def test_the_row_frames_flush_on_a_terminal_that_draws_the_glyph_wide() -> None:
|
||||
"""The mirror case: the overhang is written over, and the row still ends where it should.
|
||||
|
||||
One cell of the glyph is lost to its neighbour, which is the trade — a cell of cosmetic
|
||||
damage inside the glyph's own lane, in place of every lane after it moving.
|
||||
"""
|
||||
row = f"│ {_ROAD} Lakeside │"
|
||||
edge = cell_len(row) - 1
|
||||
|
||||
loose = _Terminal(cell_len(row), draws={_ROAD: 2})
|
||||
loose.write(row)
|
||||
assert loose.at(edge) != "│"
|
||||
|
||||
pinned = _Terminal(cell_len(row), draws={_ROAD: 2})
|
||||
pinned.write(colsnap.snap_row(row))
|
||||
assert pinned.at(edge) == "│"
|
||||
|
||||
|
||||
def test_every_lane_of_a_archive_preview_row_lands_where_the_screen_measured_it() -> None:
|
||||
"""The screen this started on: a swept contact whose name carries an emoji.
|
||||
|
||||
The name lane is already fitted in display cells (:func:`~meshterm.ui.menus.fit_cells`),
|
||||
which is the padding that cannot help — it is computed with the same measurement the
|
||||
terminal disagrees with. What holds the evidence lanes under their headers is the pinning.
|
||||
"""
|
||||
from meshterm.core.contact_score import ContactSignals, ScoredContact
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.sweep_screen import _NAME_W, _lane_widths, _victim_row
|
||||
from meshterm.ui.tui.render import render_to_ansi
|
||||
|
||||
key = "ab" * 32
|
||||
victim = ScoredContact(
|
||||
contact=Contact(name=f"{_WAVE} Lakeside", public_key=key, key_prefix=key[:12]),
|
||||
signals=ContactSignals(node=key[:12], packets=12, hops=2.0),
|
||||
score=1.0,
|
||||
percentile=3,
|
||||
)
|
||||
row = _victim_row(victim, _lane_widths([victim]))
|
||||
measured = cell_len(row.plain)
|
||||
ansi = render_to_ansi(row, 72, no_wrap=True)
|
||||
|
||||
loose = _Terminal(80, draws={_WAVE: 1})
|
||||
loose.write(ansi)
|
||||
assert loose.column != measured, "unpinned, one emoji shifts every lane after it"
|
||||
|
||||
pinned = _Terminal(80, draws={_WAVE: 1})
|
||||
pinned.write(colsnap.snap_row(ansi))
|
||||
assert pinned.column == measured
|
||||
# Not just the right edge: *every* glyph of the row is in the column the screen's own
|
||||
# cell arithmetic put it in, which is what keeps a lane under the header naming it.
|
||||
column = 0
|
||||
for cluster in clusters(row.plain):
|
||||
if cluster != " ":
|
||||
assert pinned.at(column) == cluster, f"column {column} holds the wrong glyph"
|
||||
column += cell_len(cluster)
|
||||
assert column == measured
|
||||
assert _NAME_W == 22 # the name lane the evidence starts after, gap included
|
||||
|
||||
|
||||
def test_pinning_is_on_with_an_escape_hatch(monkeypatch) -> None: # noqa: ANN001
|
||||
"""On by default; only an explicit 0 writes rows the way they were composed."""
|
||||
monkeypatch.delenv("MESHTERM_COLUMN_SNAP", raising=False)
|
||||
assert colsnap.enabled()
|
||||
monkeypatch.setenv("MESHTERM_COLUMN_SNAP", "0")
|
||||
assert not colsnap.enabled()
|
||||
|
||||
|
||||
def test_the_row_writer_pins_what_it_writes(monkeypatch) -> None: # noqa: ANN001
|
||||
"""The wiring: a composed frame reaches the terminal carrying its column addresses."""
|
||||
from prompt_toolkit.data_structures import Size
|
||||
from prompt_toolkit.styles import Style
|
||||
|
||||
from meshterm.ui.tui.fastrender import FastRenderer
|
||||
|
||||
class _Capturing:
|
||||
"""Just enough output for one paint: what was written, and how big the screen is."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.written: list[str] = []
|
||||
|
||||
def write_raw(self, data: str) -> None:
|
||||
self.written.append(data)
|
||||
|
||||
def get_size(self) -> Size:
|
||||
return Size(rows=26, columns=72)
|
||||
|
||||
def __getattr__(self, name: str): # noqa: ANN001, ANN204 - the rest is inert here
|
||||
return lambda *args, **kwargs: None
|
||||
|
||||
frame = f"contact {_WAVE} 5m\nsecond row\n"
|
||||
out = _Capturing()
|
||||
renderer = FastRenderer(Style([]), out, full_screen=True, frame_source=lambda: frame)
|
||||
renderer.render(None, None)
|
||||
assert "\x1b[11G" in "".join(out.written) # the wave measured into columns 8-9
|
||||
|
||||
monkeypatch.setenv("MESHTERM_COLUMN_SNAP", "0")
|
||||
off = _Capturing()
|
||||
plain = FastRenderer(Style([]), off, full_screen=True, frame_source=lambda: frame)
|
||||
plain.render(None, None)
|
||||
assert "\x1b[11G" not in "".join(off.written)
|
||||
|
||||
|
||||
def test_a_platform_that_draws_no_emoji_pins_nothing() -> None:
|
||||
"""The PicoCalc draws only glyphs its own font has verified, so there is nothing to pin."""
|
||||
from meshterm.platforms import PICOCALC, set_platform
|
||||
|
||||
assert colsnap.enabled()
|
||||
set_platform(PICOCALC)
|
||||
assert not colsnap.enabled()
|
||||
|
||||
|
||||
# --- prompt_toolkit's renderer: the dialog path -------------------------------------------------
|
||||
|
||||
|
||||
class _Tty:
|
||||
"""A two-dimensional terminal, for the renderer that moves its cursor relatively.
|
||||
|
||||
Honours what prompt_toolkit's differential renderer and :class:`colsnap.PinnedOutput` send:
|
||||
carriage return, newline, backspace, cursor save and restore, and the CSI cursor moves.
|
||||
Colour and mode sequences are ignored, which is all a terminal does with them as far as
|
||||
column arithmetic goes. Glyph widths come from prompt_toolkit's own measurement unless
|
||||
``draws`` names a glyph this terminal disagrees about.
|
||||
"""
|
||||
|
||||
_SEQUENCE = re.compile(r"\x1b(?:\[([0-9;?]*)([A-Za-z@`])|([78]))")
|
||||
|
||||
def __init__(self, cols: int, rows: int, *, draws: dict[str, int] | None = None) -> None:
|
||||
"""Start blank, with the cursor home."""
|
||||
self.cols, self.rows = cols, rows
|
||||
self.draws = draws or {}
|
||||
self.grid = [[" "] * cols for _ in range(rows)]
|
||||
self.x = self.y = 0
|
||||
self._saved = (0, 0)
|
||||
|
||||
def feed(self, data: str) -> None:
|
||||
"""Interpret ``data`` as the terminal would."""
|
||||
position = 0
|
||||
for match in self._SEQUENCE.finditer(data):
|
||||
self._text(data[position : match.start()])
|
||||
position = match.end()
|
||||
if match.group(3) == "7":
|
||||
self._saved = (self.x, self.y)
|
||||
elif match.group(3) == "8":
|
||||
self.x, self.y = self._saved
|
||||
else:
|
||||
self._csi(match.group(1), match.group(2))
|
||||
self._text(data[position:])
|
||||
|
||||
def _csi(self, params: str, final: str) -> None:
|
||||
"""One control sequence: the cursor moves, the erases, and nothing else."""
|
||||
if params.startswith("?"):
|
||||
return
|
||||
numbers = [int(part) if part else 0 for part in params.split(";")] if params else []
|
||||
count = numbers[0] if numbers and numbers[0] else 1
|
||||
if final == "A":
|
||||
self.y = max(0, self.y - count)
|
||||
elif final == "B":
|
||||
self.y = min(self.rows - 1, self.y + count)
|
||||
elif final == "C":
|
||||
self.x = min(self.cols - 1, self.x + count)
|
||||
elif final == "D":
|
||||
self.x = max(0, self.x - count)
|
||||
elif final == "G":
|
||||
self.x = count - 1
|
||||
elif final in "Hf":
|
||||
row = numbers[0] if numbers and numbers[0] else 1
|
||||
column = numbers[1] if len(numbers) > 1 and numbers[1] else 1
|
||||
self.y, self.x = row - 1, column - 1
|
||||
elif final == "X":
|
||||
for column in range(self.x, min(self.cols, self.x + count)):
|
||||
self.grid[self.y][column] = " "
|
||||
elif final == "K":
|
||||
self.grid[self.y][self.x :] = [" "] * (self.cols - self.x)
|
||||
elif final == "J":
|
||||
self.grid[self.y][self.x :] = [" "] * (self.cols - self.x)
|
||||
for row in range(self.y + 1, self.rows):
|
||||
self.grid[row] = [" "] * self.cols
|
||||
|
||||
def _text(self, text: str) -> None:
|
||||
"""Place each glyph at the cursor, advancing by this terminal's idea of its width."""
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
for cluster in clusters(text):
|
||||
if cluster == "\r":
|
||||
self.x = 0
|
||||
elif cluster == "\n":
|
||||
self.y = min(self.rows - 1, self.y + 1)
|
||||
elif cluster == "\b":
|
||||
self.x = max(0, self.x - 1)
|
||||
else:
|
||||
size = self.draws.get(cluster, get_cwidth(cluster))
|
||||
if self.x < self.cols:
|
||||
self.grid[self.y][self.x] = cluster
|
||||
for overhang in range(1, size):
|
||||
if self.x + overhang < self.cols:
|
||||
self.grid[self.y][self.x + overhang] = ""
|
||||
self.x += size
|
||||
|
||||
def glyph_at(self, row: int, column: int) -> str:
|
||||
"""The glyph drawn at ``(row, column)``."""
|
||||
return self.grid[row][column]
|
||||
|
||||
|
||||
def _screen(rows: list[str], cols: int): # noqa: ANN202 - a prompt_toolkit Screen
|
||||
"""Lay ``rows`` out exactly as prompt_toolkit lays out a window's text."""
|
||||
from prompt_toolkit.layout import Window
|
||||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||
from prompt_toolkit.layout.mouse_handlers import MouseHandlers
|
||||
from prompt_toolkit.layout.screen import Screen, WritePosition
|
||||
|
||||
screen = Screen()
|
||||
window = Window(FormattedTextControl("\n".join(rows)), wrap_lines=False)
|
||||
window.write_to_screen(
|
||||
screen, MouseHandlers(), WritePosition(0, 0, cols, len(rows)), "", False, None
|
||||
)
|
||||
return screen
|
||||
|
||||
|
||||
def _paint(output, screen, previous, position, cols: int, rows: int): # noqa: ANN001, ANN202
|
||||
"""One pass of prompt_toolkit's own differential writer over ``screen``."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from prompt_toolkit.data_structures import Size
|
||||
from prompt_toolkit.output.color_depth import ColorDepth
|
||||
from prompt_toolkit.renderer import (
|
||||
_output_screen_diff,
|
||||
_StyleStringHasStyleCache,
|
||||
_StyleStringToAttrsCache,
|
||||
)
|
||||
from prompt_toolkit.styles import DummyStyleTransformation, Style
|
||||
|
||||
attrs = _StyleStringToAttrsCache(Style([]).get_attrs_for_style_str, DummyStyleTransformation())
|
||||
position, _style = _output_screen_diff(
|
||||
SimpleNamespace(layout=SimpleNamespace(current_window=None)),
|
||||
output,
|
||||
screen,
|
||||
position,
|
||||
ColorDepth.DEPTH_24_BIT,
|
||||
previous,
|
||||
None,
|
||||
False,
|
||||
True,
|
||||
attrs,
|
||||
_StyleStringHasStyleCache(attrs),
|
||||
Size(rows=rows, columns=cols),
|
||||
cols if previous is not None else 0,
|
||||
)
|
||||
return position
|
||||
|
||||
|
||||
def _vt100(cols: int, rows: int): # noqa: ANN202 - a Vt100_Output writing into memory
|
||||
"""A real VT100 output, so every cursor move is spelled the way the terminal receives it."""
|
||||
import io
|
||||
|
||||
from prompt_toolkit.data_structures import Size
|
||||
from prompt_toolkit.output.vt100 import Vt100_Output
|
||||
|
||||
return Vt100_Output(io.StringIO(), lambda: Size(rows=rows, columns=cols), term="xterm")
|
||||
|
||||
|
||||
def _drained(output) -> str: # noqa: ANN001
|
||||
"""Everything written to ``output`` since the last drain."""
|
||||
vt100 = getattr(output, "_inner", output)
|
||||
data = "".join(vt100._buffer)
|
||||
vt100._buffer.clear()
|
||||
return data
|
||||
|
||||
|
||||
def _assert_landed(tty: _Tty, row: int, text: str) -> None:
|
||||
"""Every visible glyph of ``text`` is in the column prompt_toolkit measured it into."""
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
column = 0
|
||||
for cluster in clusters(text):
|
||||
if cluster != " ":
|
||||
assert tty.glyph_at(row, column) == cluster, f"column {column} of row {row}"
|
||||
column += get_cwidth(cluster)
|
||||
|
||||
|
||||
def test_a_dialog_row_frames_flush_on_a_terminal_that_draws_the_glyph_narrow() -> None:
|
||||
"""prompt_toolkit's renderer, unpinned and pinned, on a terminal that disagrees about 👋.
|
||||
|
||||
The renderer writes the whole row in one run after its first move, adding two cells for the
|
||||
wave to a cursor of its own while the terminal adds one. Unpinned, the rest of the row is
|
||||
drawn a column early. Pinned, every glyph is where the renderer measured it.
|
||||
"""
|
||||
cols, rows = 24, 2
|
||||
text = [f"│ {_WAVE} Lakeside 5m │", "│ Plain row 5m │"]
|
||||
screen = _screen(text, cols)
|
||||
|
||||
loose, loose_tty = _vt100(cols, rows), _Tty(cols, rows, draws={_WAVE: 1})
|
||||
_paint(loose, screen, None, _point(), cols, rows)
|
||||
loose_tty.feed(_drained(loose))
|
||||
assert loose_tty.glyph_at(0, cell_len(text[0]) - 1) != "│", "unpinned, the border moves"
|
||||
|
||||
pinned, pinned_tty = (
|
||||
colsnap.PinnedOutput(_vt100(cols, rows)),
|
||||
_Tty(cols, rows, draws={_WAVE: 1}),
|
||||
)
|
||||
_paint(pinned, screen, None, _point(), cols, rows)
|
||||
pinned_tty.feed(_drained(pinned))
|
||||
for row, line in enumerate(text):
|
||||
_assert_landed(pinned_tty, row, line)
|
||||
|
||||
|
||||
def test_a_repaint_after_the_glyph_lands_where_the_renderer_measured_it() -> None:
|
||||
"""The move that follows a pinned glyph is computed relatively, and still lands.
|
||||
|
||||
A second paint changes the glyph *and* a value later on the same row, with unchanged cells
|
||||
between them, so the renderer writes the new glyph and then jumps forward by the distance its
|
||||
own arithmetic says. That jump is only right if the terminal's cursor was put back in step
|
||||
after the glyph — which is the whole claim of the relative pin.
|
||||
"""
|
||||
cols, rows = 24, 1
|
||||
first = [f"│ {_WAVE} Lakeside 5m │"]
|
||||
second = [f"│ {_ROCKET} Lakeside 7m │"]
|
||||
before, after = _screen(first, cols), _screen(second, cols)
|
||||
|
||||
for pin, lands in ((False, False), (True, True)):
|
||||
output = _vt100(cols, rows)
|
||||
if pin:
|
||||
output = colsnap.PinnedOutput(output)
|
||||
tty = _Tty(cols, rows, draws={_WAVE: 1, _ROCKET: 1})
|
||||
position = _paint(output, before, None, _point(), cols, rows)
|
||||
tty.feed(_drained(output))
|
||||
_paint(output, after, before, position, cols, rows)
|
||||
tty.feed(_drained(output))
|
||||
seven = cell_len(second[0].split("7m")[0])
|
||||
assert (tty.glyph_at(0, seven) == "7") is lands
|
||||
|
||||
|
||||
def test_a_glyph_drawn_narrow_leaves_no_character_from_the_last_frame_beside_it() -> None:
|
||||
"""The cell a narrow-drawn glyph does not cover is blank, not what the last frame put there.
|
||||
|
||||
prompt_toolkit treats the cell after a wide glyph as part of it and never writes it, so a
|
||||
repaint that puts a two-cell glyph where two letters were leaves the second letter standing
|
||||
on a terminal that draws the glyph in one cell. The pin erases its reservation first.
|
||||
"""
|
||||
cols, rows = 16, 1
|
||||
before, after = _screen(["│ ab Lakeside │"], cols), _screen([f"│ {_WAVE} Lakeside │"], cols)
|
||||
|
||||
for pin, stale in ((False, True), (True, False)):
|
||||
output = _vt100(cols, rows)
|
||||
if pin:
|
||||
output = colsnap.PinnedOutput(output)
|
||||
tty = _Tty(cols, rows, draws={_WAVE: 1})
|
||||
position = _paint(output, before, None, _point(), cols, rows)
|
||||
tty.feed(_drained(output))
|
||||
_paint(output, after, before, position, cols, rows)
|
||||
tty.feed(_drained(output))
|
||||
assert tty.glyph_at(0, 2) == _WAVE
|
||||
assert (tty.glyph_at(0, 3) == "b") is stale
|
||||
|
||||
|
||||
def test_the_pinned_output_brackets_only_a_lone_uncertain_glyph() -> None:
|
||||
"""What reaches the terminal for each kind of write prompt_toolkit's renderer makes."""
|
||||
|
||||
class _Recorder:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
def write(self, data: str) -> None:
|
||||
self.calls.append(("write", data))
|
||||
|
||||
def write_raw(self, data: str) -> None:
|
||||
self.calls.append(("raw", data))
|
||||
|
||||
recorder = _Recorder()
|
||||
output = colsnap.PinnedOutput(recorder)
|
||||
for data in ("a", "\r\n", "│", "⠿", "é", "ab"):
|
||||
output.write(data)
|
||||
assert recorder.calls == [
|
||||
("write", "a"),
|
||||
("write", "\r\n"),
|
||||
("write", "│"),
|
||||
("write", "⠿"),
|
||||
("write", "é"),
|
||||
("write", "ab"),
|
||||
], "a cell of text passes straight through"
|
||||
|
||||
for glyph in (_WAVE, _FAMILY, _CA):
|
||||
recorder.calls.clear()
|
||||
output.write(glyph)
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
step = get_cwidth(glyph)
|
||||
assert recorder.calls == [
|
||||
("raw", f"\x1b7\x1b[{step}X"),
|
||||
("write", glyph),
|
||||
("raw", f"\x1b8\x1b[{step}C"),
|
||||
]
|
||||
|
||||
recorder.calls.clear()
|
||||
output.write(f"{_WAVE}{_WAVE}") # two glyphs is not a cell, and is not pinned
|
||||
assert recorder.calls == [("write", f"{_WAVE}{_WAVE}")]
|
||||
assert output.calls is recorder.calls # everything else forwards
|
||||
|
||||
|
||||
_ROCKET = "\U0001f680" # 🚀 a second emoji the disagreeing terminal draws in one cell
|
||||
|
||||
|
||||
def _point(): # noqa: ANN202 - a prompt_toolkit Point at the origin
|
||||
"""The cursor at home, where a first paint starts."""
|
||||
from prompt_toolkit.data_structures import Point
|
||||
|
||||
return Point(x=0, y=0)
|
||||
+10
-12
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
from meshterm.core.contact_score import (
|
||||
PROTECT_ADMIN,
|
||||
PROTECT_LOCKED,
|
||||
PROTECT_MESSAGED,
|
||||
PROTECT_UNOBSERVED,
|
||||
PROTECT_WATCHED,
|
||||
@@ -58,7 +59,7 @@ def test_an_unknown_signal_scores_neutral_not_zero() -> None:
|
||||
location at all, plenty of nodes are never overheard as a relayed packet so carry no hop
|
||||
count, and a channel poster whose name matches no contact can't be attributed. If any of
|
||||
those resolved to zero, every unmeasurable contact would sink together and the sweep
|
||||
would be purging by how much metadata a node happens to broadcast.
|
||||
would be archiving by how much metadata a node happens to broadcast.
|
||||
"""
|
||||
# Two contacts identical in every measured way; one is simply unplaced and unrouted.
|
||||
ranked = _by_name(
|
||||
@@ -145,11 +146,12 @@ def test_a_newcomer_is_given_a_fortnight_to_earn_its_keep() -> None:
|
||||
|
||||
|
||||
def test_protections_outrank_the_arithmetic() -> None:
|
||||
"""A star, a sent message, an admin login or an unheard contact is never a candidate.
|
||||
"""A lock, a star, a sent message, an admin login or an unheard contact is never a candidate.
|
||||
|
||||
A heuristic must not overrule an explicit choice, and it must not punish a contact for
|
||||
MeshTerm's own ignorance.
|
||||
"""
|
||||
assert protection_for(ContactSignals(node="a" * 12, **_BASE, locked=True)) == PROTECT_LOCKED
|
||||
assert protection_for(ContactSignals(node="a" * 12, **_BASE, watched=True)) == PROTECT_WATCHED
|
||||
assert protection_for(ContactSignals(node="a" * 12, **_BASE, dm_outbound=1)) == PROTECT_MESSAGED
|
||||
assert protection_for(ContactSignals(node="a" * 12, **_BASE, has_admin=True)) == PROTECT_ADMIN
|
||||
@@ -306,16 +308,6 @@ def test_a_contact_with_no_signals_at_all_is_protected() -> None:
|
||||
assert ranked[0].protection == PROTECT_UNOBSERVED
|
||||
|
||||
|
||||
def test_reasons_name_the_weakest_lanes_in_plain_words() -> None:
|
||||
"""Each preview row explains itself without the reader having to know the formula."""
|
||||
ranked = _rank(("Quiet", {"packets": 3, "heard_age_days": 240.0, "dm_total": 0}))
|
||||
reasons = ranked[0].reasons
|
||||
assert "never messaged" in reasons
|
||||
assert "3 pkts" in reasons
|
||||
# The age reads in the fewest words that are still true — months, here.
|
||||
assert any(r.endswith("mo") for r in reasons)
|
||||
|
||||
|
||||
def test_a_stronger_contact_never_scores_below_a_weaker_one_on_every_axis() -> None:
|
||||
"""Sanity: dominance is preserved — better everywhere means ranked above."""
|
||||
ranked = _by_name(
|
||||
@@ -335,3 +327,9 @@ def test_a_stronger_contact_never_scores_below_a_weaker_one_on_every_axis() -> N
|
||||
)
|
||||
assert ranked["Better"].score > ranked["Worse"].score
|
||||
assert ranked["Better"].percentile > ranked["Worse"].percentile
|
||||
|
||||
|
||||
def test_a_lock_explains_a_contact_before_any_other_claim_on_it() -> None:
|
||||
"""A lock says nothing but "keep this one", so it is the reason the preview gives."""
|
||||
both = ContactSignals(node="a" * 12, **_BASE, locked=True, watched=True, dm_outbound=3)
|
||||
assert protection_for(both) == PROTECT_LOCKED
|
||||
@@ -238,7 +238,7 @@ async def test_devstate_merge_keeps_a_repeater_a_repeater(ctx) -> None:
|
||||
def test_an_archived_contact_is_remembered_but_kept_out_of_the_merge(tmp_path) -> None: # noqa: ANN001
|
||||
"""Archiving frees a device slot without losing the contact — and without undoing itself.
|
||||
|
||||
The sweep's whole shape (see :mod:`meshterm.ui.purge_screen`): the contact is deleted
|
||||
The sweep's whole shape (see :mod:`meshterm.ui.sweep_screen`): the contact is deleted
|
||||
from the *device*, which is the scarce resource, and kept here in full. The subtlety is
|
||||
the merge — this store's normal job is to union remembered contacts back into any list
|
||||
the device isn't reporting, which would put an archived contact straight back and make
|
||||
@@ -312,3 +312,44 @@ def test_a_keyless_contact_cannot_be_archived(tmp_path) -> None: # noqa: ANN001
|
||||
store = ContactStore(tmp_path / "contacts.json")
|
||||
store.archive(PUB_A, Contact(name="Ghost"), when=1_700_000_000)
|
||||
assert store.archived(PUB_A) == []
|
||||
|
||||
|
||||
def test_a_lock_is_ours_and_survives_every_fresh_read_of_the_contact(tmp_path) -> None: # noqa: ANN001
|
||||
"""The device knows nothing of a lock, so reading the device must never clear one."""
|
||||
from meshterm.core.models import Contact
|
||||
|
||||
store = ContactStore(tmp_path / "contacts.json")
|
||||
alice = Contact(name="Alice", public_key="aa" * 32, key_prefix="aa" * 6)
|
||||
bob = Contact(name="Bob", public_key="bb" * 32, key_prefix="bb" * 6)
|
||||
# Locking a contact the store never saw upserts it, as archiving does.
|
||||
store.set_locked(PUB_A, alice, True)
|
||||
assert store.is_locked(PUB_A, "aa" * 32)
|
||||
assert store.locked_keys(PUB_A) == frozenset({"aa" * 32})
|
||||
|
||||
# A fresh read — a rename included — keeps the lock and changes the rest.
|
||||
store.remember_all(PUB_A, [Contact(name="Alice II", public_key="aa" * 32), bob])
|
||||
assert store.is_locked(PUB_A, "aa" * 32)
|
||||
assert not store.is_locked(PUB_A, "bb" * 32)
|
||||
assert [c.name for c in store.contacts(PUB_A)] == ["Alice II", "Bob"]
|
||||
|
||||
# Persisted, and scoped to the device it was set on.
|
||||
reloaded = ContactStore(tmp_path / "contacts.json")
|
||||
assert reloaded.locked_keys(PUB_A) == frozenset({"aa" * 32})
|
||||
assert reloaded.locked_keys("ff" * 32) == frozenset()
|
||||
|
||||
reloaded.set_locked(PUB_A, alice, False)
|
||||
assert ContactStore(tmp_path / "contacts.json").locked_keys(PUB_A) == frozenset()
|
||||
|
||||
|
||||
def test_an_archived_or_keyless_contact_takes_no_lock(tmp_path) -> None: # noqa: ANN001
|
||||
"""The lock keeps a live contact off the archive path; there is nothing else to lock."""
|
||||
from meshterm.core.models import Contact
|
||||
|
||||
store = ContactStore(tmp_path / "contacts.json")
|
||||
alice = Contact(name="Alice", public_key="aa" * 32, key_prefix="aa" * 6)
|
||||
store.archive(PUB_A, alice, when=1_700_000_000)
|
||||
store.set_locked(PUB_A, alice, True)
|
||||
assert store.locked_keys(PUB_A) == frozenset()
|
||||
|
||||
store.set_locked(PUB_A, Contact(name="Ghost"), True)
|
||||
assert store.locked_keys(PUB_A) == frozenset()
|
||||
+244
-39
@@ -311,7 +311,7 @@ def test_contacts_screen_lists_contacts_in_the_shared_lanes() -> None:
|
||||
import re
|
||||
|
||||
from meshterm.core.models import Contact, utcnow
|
||||
from meshterm.ui.contacts_screen import _PURGE, YOU, ContactsScreen
|
||||
from meshterm.ui.contacts_screen import _ARCHIVE, YOU, ContactsScreen
|
||||
from meshterm.ui.tui.screen import CANCEL
|
||||
|
||||
contacts = [
|
||||
@@ -333,8 +333,8 @@ def test_contacts_screen_lists_contacts_in_the_shared_lanes() -> None:
|
||||
assert f"{7:>5}" in body # Alice's overheard packets, right-aligned in its lane
|
||||
assert "never" in body # Bob has no last_seen
|
||||
# Each contact row carries the Contact itself, so Enter hands the whole record on; the
|
||||
# tail closes the list with the purge action (there is no exit row — Esc leaves).
|
||||
assert choices[-1].value == _PURGE
|
||||
# tail closes the list with the archive action (there is no exit row — Esc leaves).
|
||||
assert choices[-1].value == _ARCHIVE
|
||||
assert all(isinstance(c.value, Contact) for c in choices[1:-1])
|
||||
|
||||
# Plain arrows move the highlight without touching the sort.
|
||||
@@ -354,7 +354,7 @@ def test_contacts_screen_lists_contacts_in_the_shared_lanes() -> None:
|
||||
assert resolved == [highlighted, CANCEL]
|
||||
|
||||
|
||||
def test_purge_age_rungs_split_stale_from_never_heard() -> None:
|
||||
def test_archive_age_rungs_split_stale_from_never_heard() -> None:
|
||||
"""An age rung takes only contacts once heard and since gone quiet; never-heard has its own.
|
||||
|
||||
The ladder's second section is the plain predictable operation a ranking cannot express,
|
||||
@@ -364,7 +364,7 @@ def test_purge_age_rungs_split_stale_from_never_heard() -> None:
|
||||
"""
|
||||
from meshterm.core.contact_score import ContactSignals, ScoredContact
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.purge_screen import _DAY, _NEVER, victims_for
|
||||
from meshterm.ui.sweep_screen import _DAY, _NEVER, victims_for
|
||||
|
||||
def scored(name: str, days, score: float) -> ScoredContact: # noqa: ANN001
|
||||
contact = Contact(name=name, public_key=f"{ord(name[0]):02x}" * 32)
|
||||
@@ -396,7 +396,7 @@ def test_purge_age_rungs_split_stale_from_never_heard() -> None:
|
||||
assert names(("age", 7 * _DAY)) == ["Ancient", "Old"]
|
||||
|
||||
|
||||
async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> None:
|
||||
async def test_archive_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> None:
|
||||
"""The whole sweep, end to end: ladder → preview → amber confirm → bar → outcome dialog.
|
||||
|
||||
Two things the busy overlay could not do (JP, 2026-08-10). It only ever said "working",
|
||||
@@ -414,8 +414,8 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
|
||||
|
||||
from meshterm.core.contact_score import ContactSignals
|
||||
from meshterm.core.models import Contact, utcnow
|
||||
from meshterm.ui.purge_screen import purge_contacts
|
||||
from meshterm.ui.surface import TuiUi
|
||||
from meshterm.ui.sweep_screen import archive_contacts
|
||||
from meshterm.ui.tui.progress import ProgressScreen
|
||||
from meshterm.ui.tui.session import TuiSession
|
||||
|
||||
@@ -459,10 +459,12 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
|
||||
ui.dialog = _dialog # type: ignore[method-assign]
|
||||
ctx = SimpleNamespace(
|
||||
ui=ui,
|
||||
log=logging.getLogger("test.purge"),
|
||||
log=logging.getLogger("test.archive"),
|
||||
repo=_Repo(),
|
||||
contact_store=SimpleNamespace(
|
||||
archive=lambda dev, contact, when: archived.append(contact.name)
|
||||
locked_keys=lambda *a: frozenset(),
|
||||
is_locked=lambda *a: False,
|
||||
archive=lambda dev, contact, when: archived.append(contact.name),
|
||||
),
|
||||
watch_store=None,
|
||||
admin_store=None,
|
||||
@@ -474,15 +476,16 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
|
||||
device=lambda: _device(_Device()),
|
||||
)
|
||||
|
||||
task = asyncio.ensure_future(purge_contacts(ctx, "cc" * 32))
|
||||
task = asyncio.ensure_future(archive_contacts(ctx, "cc" * 32))
|
||||
|
||||
# Walk past the five standing rungs into the second section and take "not heard in 1
|
||||
# year", which catches all three — the standing rungs keep a share, so none of them
|
||||
# ever takes a whole table.
|
||||
ladder = await _step_until_screen(session, lambda s: s.title.startswith("Purge contacts"))
|
||||
ladder = await _step_until_screen(session, lambda s: s.title.startswith("Archive contacts"))
|
||||
body = _screen_text(ladder)
|
||||
# A rung is described by what it *keeps* — the number that has to fit the device.
|
||||
assert "keeps" in body
|
||||
# A rung is counted in columns, and what it *keeps* — the number that has to fit the
|
||||
# device — leads what it archives.
|
||||
assert body.index("KEEPS") < body.index("ARCHIVES")
|
||||
for _ in range(9):
|
||||
ladder.handle("down")
|
||||
ladder.handle("enter")
|
||||
@@ -490,7 +493,10 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
|
||||
# The preview lists exactly who would go, and commits on its Apply row (the default).
|
||||
preview = await _step_until_screen(session, lambda s: "to archive" in getattr(s, "title", ""))
|
||||
body = _screen_text(preview)
|
||||
assert "PCTL" in body # the standing lane is a percentile, never a raw score
|
||||
# The evidence, one lane per kind — never a score, and no longer a percentile either:
|
||||
# the row shows what was measured, and the list's order is the ranking.
|
||||
lanes = ["NAME", "HEARD", "PKTS", "MSGS", "HOPS"]
|
||||
assert [body.index(word) for word in lanes] == sorted(body.index(word) for word in lanes)
|
||||
assert all(v.name in body for v in victims)
|
||||
preview.handle("enter")
|
||||
|
||||
@@ -499,12 +505,12 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
|
||||
bar = await _step_until_screen(session, lambda s: isinstance(s, ProgressScreen))
|
||||
assert asked and asked[0]["danger"] is True
|
||||
assert "destructive" not in asked[0]
|
||||
assert [label for label, _v in asked[0]["buttons"]] == ["Cancel", "Purge"]
|
||||
assert [label for label, _v in asked[0]["buttons"]] == ["Cancel", "Archive"]
|
||||
assert "restored" in asked[0]["prompt"]
|
||||
|
||||
# The sweep runs under the progress dialog, which is the frontmost screen for its whole
|
||||
# lifetime — so the list underneath cannot be walked while it works.
|
||||
assert bar.title == "Purge contacts"
|
||||
assert bar.title == "Archive contacts"
|
||||
assert bar.render_body(60) # a real bar, with a real total to count down
|
||||
bar.handle("down") # every key is swallowed; nothing underneath moves
|
||||
|
||||
@@ -520,6 +526,55 @@ async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> No
|
||||
assert sorted(archived) == ["Old0", "Old1", "Old2"]
|
||||
|
||||
|
||||
def test_a_preview_row_leads_with_the_contacts_type_mark() -> None:
|
||||
"""Each contact the sweep would take leads with its type mark, as the Contacts list's do.
|
||||
|
||||
The mark is a lane of its own ahead of the name, in the type's colour rather than the
|
||||
name's. The header moves over with it — ``NAME`` over the name, every evidence word still
|
||||
over its values — and ``←→`` keeps the mark pinned beside the name it belongs to.
|
||||
"""
|
||||
from meshterm.core.contact_score import ContactSignals, ScoredContact
|
||||
from meshterm.core.models import NODE_TYPE_REPEATER, Contact
|
||||
from meshterm.ui.sweep_screen import (
|
||||
_MARK_W,
|
||||
_NAME_W,
|
||||
_lane_widths,
|
||||
_preview_header,
|
||||
_preview_items,
|
||||
_victim_row,
|
||||
)
|
||||
from meshterm.ui.theme import name_style
|
||||
|
||||
def scored(name: str, key: str, node_type: int | None) -> ScoredContact:
|
||||
return ScoredContact(
|
||||
contact=Contact(name=name, public_key=key, key_prefix=key[:12], node_type=node_type),
|
||||
signals=ContactSignals(node=key[:12], packets=3),
|
||||
score=1.0,
|
||||
percentile=1,
|
||||
)
|
||||
|
||||
victims = [scored("Hilltop", "3d" * 32, NODE_TYPE_REPEATER), scored("Walker", "5e" * 32, None)]
|
||||
widths = _lane_widths(victims)
|
||||
hilltop, walker = (_victim_row(v, widths) for v in victims)
|
||||
assert hilltop.plain.startswith("▲ Hilltop")
|
||||
assert walker.plain.startswith("● Walker") # never advertised a type: the plain node
|
||||
|
||||
def styles_at(row, index: int) -> list[str]: # noqa: ANN001
|
||||
return [str(span.style) for span in row.spans if span.start <= index < span.end]
|
||||
|
||||
assert styles_at(hilltop, 0) == ["type.repeater"]
|
||||
assert styles_at(hilltop, hilltop.plain.index("Hilltop")) == [name_style("Hilltop", "3d" * 32)]
|
||||
|
||||
# The header is drawn from the pointer column's edge and a row from just past it.
|
||||
header = _preview_header(widths, 72)
|
||||
assert header.index("NAME") == 2 + hilltop.plain.index("Hilltop")
|
||||
assert header.index("PKTS") + len("PKTS") - 1 == 2 + hilltop.plain.index("3")
|
||||
|
||||
items = _preview_items(victims)
|
||||
rows = [c for c in items if isinstance(getattr(c, "value", None), ScoredContact)]
|
||||
assert len(rows) == 2 and all(c.hscroll_from == _MARK_W + _NAME_W for c in rows)
|
||||
|
||||
|
||||
async def test_sparing_a_row_shrinks_the_sweep_without_leaving_the_preview() -> None:
|
||||
"""Delete on a preview row lifts that contact out of the sweep, in place.
|
||||
|
||||
@@ -534,8 +589,8 @@ async def test_sparing_a_row_shrinks_the_sweep_without_leaving_the_preview() ->
|
||||
|
||||
from meshterm.core.contact_score import ContactSignals, ScoredContact
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.purge_screen import _preview
|
||||
from meshterm.ui.surface import TuiUi
|
||||
from meshterm.ui.sweep_screen import _preview
|
||||
from meshterm.ui.tui.session import TuiSession
|
||||
|
||||
def scored(name: str, pct: int) -> ScoredContact:
|
||||
@@ -597,8 +652,8 @@ async def test_sparing_every_row_leaves_the_sweep_with_nothing_to_do() -> None:
|
||||
|
||||
from meshterm.core.contact_score import ContactSignals, ScoredContact
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.purge_screen import _preview
|
||||
from meshterm.ui.surface import TuiUi
|
||||
from meshterm.ui.sweep_screen import _preview
|
||||
from meshterm.ui.tui.session import TuiSession
|
||||
|
||||
key = "aa" * 32
|
||||
@@ -618,6 +673,50 @@ async def test_sparing_every_row_leaves_the_sweep_with_nothing_to_do() -> None:
|
||||
assert victims == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform_name", ["regular", "picocalc"])
|
||||
def test_the_archive_preview_keeps_its_cursor_in_its_box_walking_both_ways(
|
||||
platform_name: str,
|
||||
) -> None:
|
||||
"""The preview's highlight stays inside its box however far the reader walks, and back.
|
||||
|
||||
A long sweep scrolls, and it is a list with a prompt over a pinned column header. Both
|
||||
once let the cursor leave the window on the way back up: the prompt was counted twice in
|
||||
the line the frame keeps in view, so the real row rode above the box's top edge, and at
|
||||
the bottom the header's slide dropped the row the reader had just stepped onto. Walked
|
||||
through the real dialog compositor on each platform, at its own readable width.
|
||||
"""
|
||||
from rich.text import Text
|
||||
|
||||
from meshterm.core.contact_score import ContactSignals, ScoredContact
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||||
from meshterm.ui.sweep_screen import _preview_screen
|
||||
from meshterm.ui.tui.frame import compose_dialog
|
||||
|
||||
platform = {"regular": REGULAR, "picocalc": PICOCALC}[platform_name]
|
||||
set_platform(platform)
|
||||
|
||||
def scored(index: int) -> ScoredContact:
|
||||
key = f"{index:02x}" * 32
|
||||
return ScoredContact(
|
||||
contact=Contact(name=f"Faraway {index}", public_key=key, key_prefix=key[:12]),
|
||||
signals=ContactSignals(node=key[:12], heard_age_days=40.0 + index, packets=index),
|
||||
score=float(index),
|
||||
percentile=index,
|
||||
)
|
||||
|
||||
screen = _preview_screen([scored(i) for i in range(40)])
|
||||
cols, rows = platform.readable_cols, 24
|
||||
# The cursor opens on Apply, at the bottom. Walk to the first contact, down to Back,
|
||||
# and up again: every step is a paint the reader sees.
|
||||
walk = [None] + ["up"] * 45 + ["down"] * 45 + ["up"] * 45
|
||||
for step, key in enumerate(walk):
|
||||
if key is not None:
|
||||
screen.handle(key)
|
||||
box = Text.from_ansi(compose_dialog(screen, cols, rows)).plain
|
||||
assert "❯" in box, f"step {step} ({key}): the highlight left the box\n{box}"
|
||||
|
||||
|
||||
async def test_deleting_one_contact_drops_it_from_the_device_and_the_store() -> None:
|
||||
"""The single-contact delete: a red confirm, then both halves of the union forgotten.
|
||||
|
||||
@@ -654,7 +753,11 @@ async def test_deleting_one_contact_drops_it_from_the_device_and_the_store() ->
|
||||
ctx = SimpleNamespace(
|
||||
ui=ui,
|
||||
log=logging.getLogger("test.remove"),
|
||||
contact_store=SimpleNamespace(forget=lambda dev, key: forgotten.append((dev, key))),
|
||||
contact_store=SimpleNamespace(
|
||||
locked_keys=lambda *a: frozenset(),
|
||||
is_locked=lambda *a: False,
|
||||
forget=lambda dev, key: forgotten.append((dev, key)),
|
||||
),
|
||||
devstate=SimpleNamespace(invalidate_contacts=lambda: invalidated.append(True)),
|
||||
device=lambda: _device(_Device()),
|
||||
)
|
||||
@@ -700,7 +803,11 @@ async def test_cancelling_the_remove_confirm_touches_nothing() -> None:
|
||||
ctx = SimpleNamespace(
|
||||
ui=ui,
|
||||
log=logging.getLogger("test.remove"),
|
||||
contact_store=SimpleNamespace(forget=lambda *a: touched.append("forgot")),
|
||||
contact_store=SimpleNamespace(
|
||||
locked_keys=lambda *a: frozenset(),
|
||||
is_locked=lambda *a: False,
|
||||
forget=lambda *a: touched.append("forgot"),
|
||||
),
|
||||
devstate=SimpleNamespace(invalidate_contacts=lambda: touched.append("invalidated")),
|
||||
device=lambda: _device(_Device()),
|
||||
)
|
||||
@@ -745,7 +852,11 @@ async def test_a_refused_removal_is_shown_and_the_contact_stays() -> None:
|
||||
ctx = SimpleNamespace(
|
||||
ui=ui,
|
||||
log=logging.getLogger("test.remove"),
|
||||
contact_store=SimpleNamespace(forget=lambda *a: forgotten.append(a)),
|
||||
contact_store=SimpleNamespace(
|
||||
locked_keys=lambda *a: frozenset(),
|
||||
is_locked=lambda *a: False,
|
||||
forget=lambda *a: forgotten.append(a),
|
||||
),
|
||||
devstate=SimpleNamespace(invalidate_contacts=lambda: forgotten.append("cache")),
|
||||
device=lambda: _device(_Device()),
|
||||
)
|
||||
@@ -798,7 +909,11 @@ async def test_a_contact_the_device_never_held_is_still_removed_here() -> None:
|
||||
ctx = SimpleNamespace(
|
||||
ui=ui,
|
||||
log=logging.getLogger("test.remove"),
|
||||
contact_store=SimpleNamespace(forget=lambda dev, key: forgotten.append((dev, key))),
|
||||
contact_store=SimpleNamespace(
|
||||
locked_keys=lambda *a: frozenset(),
|
||||
is_locked=lambda *a: False,
|
||||
forget=lambda dev, key: forgotten.append((dev, key)),
|
||||
),
|
||||
devstate=SimpleNamespace(invalidate_contacts=lambda: invalidated.append(True)),
|
||||
device=lambda: _device(_Device()),
|
||||
)
|
||||
@@ -915,19 +1030,19 @@ async def test_the_list_rebuilds_when_the_detail_page_deletes_the_contact(
|
||||
await task
|
||||
|
||||
|
||||
def test_contacts_screen_tail_offers_purge_only_when_populated() -> None:
|
||||
"""A populated list closes with the purge action; an empty one has no tail at all."""
|
||||
def test_contacts_screen_tail_offers_archive_only_when_populated() -> None:
|
||||
"""A populated list closes with the archive action; an empty one has no tail at all."""
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.contacts_screen import _PURGE, ContactsScreen
|
||||
from meshterm.ui.contacts_screen import _ARCHIVE, ContactsScreen
|
||||
|
||||
populated = ContactsScreen(
|
||||
"Us", "cc" * 32, [Contact(name="Alice", public_key="aa" * 32)], 1, {}, _contacts_sort()
|
||||
)
|
||||
assert populated._choices()[-1].value == _PURGE
|
||||
assert populated._choices()[-1].value == _ARCHIVE
|
||||
|
||||
empty = ContactsScreen("Us", "cc" * 32, [], 1, {}, _contacts_sort())
|
||||
values = [c.value for c in empty._choices()]
|
||||
assert _PURGE not in values # nothing to purge — no action row
|
||||
assert _ARCHIVE not in values # nothing to archive — no action row
|
||||
|
||||
|
||||
def test_the_archived_row_appears_only_when_something_is_archived() -> None:
|
||||
@@ -938,7 +1053,7 @@ def test_the_archived_row_appears_only_when_something_is_archived() -> None:
|
||||
own screen is what keeps archiving from being something you lose track of.
|
||||
"""
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.contacts_screen import _ARCHIVED, _PURGE, ContactsScreen
|
||||
from meshterm.ui.contacts_screen import _ARCHIVE, _ARCHIVED, ContactsScreen
|
||||
|
||||
contacts = [Contact(name="Alice", public_key="aa" * 32)]
|
||||
|
||||
@@ -947,16 +1062,16 @@ def test_the_archived_row_appears_only_when_something_is_archived() -> None:
|
||||
|
||||
some = ContactsScreen("Us", "cc" * 32, contacts, 1, {}, _contacts_sort(), archived=12)
|
||||
values = [c.value for c in some._choices()]
|
||||
# Under the purge, because it is where the purge's output went.
|
||||
assert values[-2:] == [_PURGE, _ARCHIVED]
|
||||
# Under the archive action, because it is where the sweep's output went.
|
||||
assert values[-2:] == [_ARCHIVE, _ARCHIVED]
|
||||
row = next(c for c in some._choices() if c.value == _ARCHIVED)
|
||||
assert "View archived contacts" in row.label and "12" in row.label
|
||||
|
||||
# It stands on its own for a device whose whole table has been swept: there is nothing
|
||||
# left to purge, but there is very much something to go and look at.
|
||||
# left to archive, but there is very much something to go and look at.
|
||||
swept = ContactsScreen("Us", "cc" * 32, [], 1, {}, _contacts_sort(), archived=3)
|
||||
values = [c.value for c in swept._choices()]
|
||||
assert _PURGE not in values and values[-1] == _ARCHIVED
|
||||
assert _ARCHIVE not in values and values[-1] == _ARCHIVED
|
||||
|
||||
|
||||
async def test_archiving_one_contact_takes_it_off_the_device_and_keeps_it() -> None:
|
||||
@@ -969,12 +1084,15 @@ async def test_archiving_one_contact_takes_it_off_the_device_and_keeps_it() -> N
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.core.models import NODE_TYPE_REPEATER, Contact
|
||||
from meshterm.ui.node_detail_screen import _archive_contact
|
||||
from meshterm.ui.surface import TuiUi
|
||||
from meshterm.ui.theme import name_style
|
||||
from meshterm.ui.tui.session import TuiSession
|
||||
|
||||
hub = Contact(name="Hub", public_key="3d" * 32, key_prefix="3d" * 6)
|
||||
hub = Contact(
|
||||
name="Hub", public_key="3d" * 32, key_prefix="3d" * 6, node_type=NODE_TYPE_REPEATER
|
||||
)
|
||||
removed: list[str] = []
|
||||
archived: list[tuple[str, str]] = []
|
||||
invalidated: list[bool] = []
|
||||
@@ -995,7 +1113,9 @@ async def test_archiving_one_contact_takes_it_off_the_device_and_keeps_it() -> N
|
||||
ui=ui,
|
||||
log=logging.getLogger("test.archive"),
|
||||
contact_store=SimpleNamespace(
|
||||
archive=lambda dev, contact, when: archived.append((dev, contact.public_key))
|
||||
locked_keys=lambda *a: frozenset(),
|
||||
is_locked=lambda *a: False,
|
||||
archive=lambda dev, contact, when: archived.append((dev, contact.public_key)),
|
||||
),
|
||||
devstate=SimpleNamespace(invalidate_contacts=lambda: invalidated.append(True)),
|
||||
device=lambda: _device(_Device()),
|
||||
@@ -1005,7 +1125,19 @@ async def test_archiving_one_contact_takes_it_off_the_device_and_keeps_it() -> N
|
||||
assert asked[0]["danger"] is True and "destructive" not in asked[0]
|
||||
assert [label for label, _v in asked[0]["buttons"]] == ["Cancel", "Archive"]
|
||||
assert asked[0]["default"] == 1
|
||||
assert "restore it at any time" in asked[0]["prompt"]
|
||||
prompt = asked[0]["prompt"]
|
||||
assert "restore it at any time" in prompt
|
||||
# The contact is named as the Contacts list names it — its type mark to the left of the
|
||||
# name, each in its own colour — while the sentence around them keeps the amber.
|
||||
assert prompt.plain.startswith("Archive ▲ Hub? ")
|
||||
|
||||
def styles_at(index: int) -> list[str]:
|
||||
return [str(span.style) for span in prompt.spans if span.start <= index < span.end]
|
||||
|
||||
assert styles_at(prompt.plain.index("▲")) == ["type.repeater"]
|
||||
assert styles_at(prompt.plain.index("Hub")) == [name_style("Hub", "3d" * 32)]
|
||||
assert styles_at(0) == styles_at(prompt.plain.index("restore")) == ["warn"]
|
||||
assert not prompt.style, "a base style would merge the amber's bold into the mark"
|
||||
# Off the radio, kept here, and the cached contact list dropped so the list re-reads.
|
||||
assert removed == ["Hub"]
|
||||
assert archived == [("cc" * 32, "3d" * 32)]
|
||||
@@ -1034,7 +1166,11 @@ async def test_restoring_writes_the_contact_back_before_clearing_the_mark() -> N
|
||||
ctx = SimpleNamespace(
|
||||
ui=TuiUi(session),
|
||||
log=logging.getLogger("test.restore"),
|
||||
contact_store=SimpleNamespace(restore=lambda dev, key: restored.append(key)),
|
||||
contact_store=SimpleNamespace(
|
||||
locked_keys=lambda *a: frozenset(),
|
||||
is_locked=lambda *a: False,
|
||||
restore=lambda dev, key: restored.append(key),
|
||||
),
|
||||
devstate=SimpleNamespace(invalidate_contacts=lambda: None),
|
||||
device=lambda: _device(_Refuses()),
|
||||
)
|
||||
@@ -1375,7 +1511,7 @@ async def _channels(device: MockDevice) -> list[dict]:
|
||||
async def test_the_preview_opens_node_pages_with_no_management_verbs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Enter on a purge candidate opens its page to *look* at, not to act on.
|
||||
"""Enter on a archive candidate opens its page to *look* at, not to act on.
|
||||
|
||||
The rule (JP, 2026-09-01): management verbs belong to the list a contact actually lives
|
||||
in, never to a page opened out of a list that is about to act on it wholesale. A preview
|
||||
@@ -1388,8 +1524,8 @@ async def test_the_preview_opens_node_pages_with_no_management_verbs(
|
||||
import meshterm.ui.node_detail_screen as nd
|
||||
from meshterm.core.contact_score import ContactSignals, ScoredContact
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.purge_screen import _preview
|
||||
from meshterm.ui.surface import TuiUi
|
||||
from meshterm.ui.sweep_screen import _preview
|
||||
from meshterm.ui.tui.session import TuiSession
|
||||
|
||||
opened: list[dict] = []
|
||||
@@ -1421,3 +1557,72 @@ async def test_the_preview_opens_node_pages_with_no_management_verbs(
|
||||
assert session.top is screen
|
||||
screen.handle("escape")
|
||||
assert await task is False
|
||||
|
||||
|
||||
def test_a_locked_contact_leads_its_row_with_a_padlock_left_of_its_type_glyph() -> None:
|
||||
"""Locked rows open on the padlock; the rest keep the column blank, so glyphs line up."""
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.contacts_screen import ContactsScreen
|
||||
|
||||
contacts = [
|
||||
Contact(name="Al", public_key="aa" * 32),
|
||||
Contact(name="Bartholomew", public_key="bb" * 32),
|
||||
]
|
||||
screen = ContactsScreen(
|
||||
"Us", "dd" * 32, contacts, 1, {}, _contacts_sort(), locked=frozenset({"aa" * 32})
|
||||
)
|
||||
screen.note_viewport(30)
|
||||
screen.render_body(72)
|
||||
lines = {
|
||||
name: choice.title.plain
|
||||
for choice in screen._choices()
|
||||
for name in ("Al", "Bartholomew")
|
||||
if getattr(choice.value, "name", None) == name
|
||||
}
|
||||
assert lines["Al"].startswith("\U0001f512 ● Al ")
|
||||
assert lines["Bartholomew"].startswith(" ● Bartholomew")
|
||||
|
||||
# Unlocking redraws in place, and a list with no locks spends no cells on the column.
|
||||
screen.refresh_locks(frozenset())
|
||||
screen.render_body(72)
|
||||
al = next(c for c in screen._choices() if getattr(c.value, "name", None) == "Al")
|
||||
assert al.title.plain.startswith("● Al ")
|
||||
|
||||
|
||||
async def test_a_locked_contact_is_never_offered_to_the_sweep() -> None:
|
||||
"""The ranking reads the store's locks, so a locked contact is protected, not a victim."""
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
from meshterm.core.contact_score import PROTECT_LOCKED, ContactSignals
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.sweep_screen import _rank
|
||||
|
||||
contacts = [
|
||||
Contact(name="Kept", public_key="aa" * 32, key_prefix="aa" * 6),
|
||||
Contact(name="Loose", public_key="bb" * 32, key_prefix="bb" * 6),
|
||||
]
|
||||
|
||||
class _Repo:
|
||||
def contact_signals(self, nodes): # noqa: ANN001, ANN202
|
||||
return {
|
||||
n: ContactSignals(node=n, heard_age_days=400.0, known_days=500.0) for n in nodes
|
||||
}
|
||||
|
||||
def channel_post_counts(self): # noqa: ANN202
|
||||
return {}
|
||||
|
||||
async def _self_info(): # noqa: ANN202
|
||||
return {}
|
||||
|
||||
ctx = SimpleNamespace(
|
||||
repo=_Repo(),
|
||||
log=logging.getLogger("test.lock"),
|
||||
contact_store=SimpleNamespace(locked_keys=lambda dev: frozenset({"aa" * 32})),
|
||||
watch_store=None,
|
||||
admin_store=None,
|
||||
devstate=SimpleNamespace(self_info=_self_info),
|
||||
)
|
||||
ranked = {s.contact.name: s for s in await _rank(ctx, contacts, "cc" * 32)}
|
||||
assert ranked["Kept"].protection == PROTECT_LOCKED
|
||||
assert not ranked["Loose"].protected
|
||||
@@ -187,7 +187,7 @@ def test_a_direct_message_counts_as_hearing_its_sender() -> None:
|
||||
|
||||
A direct message never touches the firmware's ``last_advert`` and is stored as a message
|
||||
rather than an observation, so a node we actively chat with could read days stale (and be
|
||||
swept by the purge ladder's quiet rungs) while talking to us. The peer prefix the wire
|
||||
swept by the archive ladder's quiet rungs) while talking to us. The peer prefix the wire
|
||||
addressed need not match the contact table's width, so either may be the shorter.
|
||||
"""
|
||||
dev = FakeDevice()
|
||||
|
||||
@@ -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"):
|
||||
|
||||
+337
-267
@@ -1,240 +1,322 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Terminal-aligned emoji width: VS16 sequences and curated lone-codepoint emoji.
|
||||
"""Reserve-two emoji widths: one rule in every width authority, and every glyph kept whole.
|
||||
|
||||
The border-alignment story lives in :mod:`meshterm.ui.tui.emoji_width`. These tests pin the
|
||||
two things that make a chat row with ``👋`` frame flush: Rich must *measure* the glyph as one
|
||||
cell (so it pads the row to the right width) and prompt_toolkit must *count* it as one (so it
|
||||
places the panel's right border where the terminal actually draws it). An emoji the terminal
|
||||
draws two wide — a menu icon like ``📡``, never on the allowlist — must stay two in both.
|
||||
The measuring story lives in :mod:`meshterm.ui.tui.emoji_width`, and the placement half in
|
||||
:mod:`meshterm.ui.tui.colsnap`. What these tests pin:
|
||||
|
||||
The factory helpers (``_make_cell_len``, ``_make_pt_cache``) are exercised in isolation so no
|
||||
process-global width table is touched; the two :func:`calibrate` tests that do patch the real
|
||||
tables snapshot and restore them in a ``finally`` so nothing leaks into a later test.
|
||||
* every glyph that may be drawn as an emoji is reserved two cells, in Rich and in prompt_toolkit;
|
||||
* the app's own marks, Rich's single-cell ranges and ordinary text keep their stock widths;
|
||||
* Rich's three measurements and prompt_toolkit's agree about every string, which is what keeps
|
||||
Rich's segment splitter from walking past a cut it can never meet;
|
||||
* the app's source draws no text-default emoji its vocabulary does not name;
|
||||
* a glyph built from several codepoints is split, cut and laid out as the one glyph it is.
|
||||
|
||||
:func:`~meshterm.ui.tui.emoji_width.install` patches process-global tables, so every test that
|
||||
needs it goes through the ``installed`` fixture, which puts every table and cache back afterwards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
|
||||
import prompt_toolkit.utils as ptu
|
||||
import pytest
|
||||
import rich.cells as cells
|
||||
import rich.segment as segment
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
from meshterm.ui.tui import emoji_width as ew
|
||||
|
||||
# One confirmed narrow glyph, plus one the same terminal draws two wide, used throughout.
|
||||
_WAVE = "👋" # the confirmed lone-codepoint emoji this terminal draws in one cell
|
||||
_DISH = "📡" # a menu icon the terminal draws two wide — must never be narrowed
|
||||
_CA = "🇨🇦" # a flag: two Regional Indicators; prompt_toolkit miscounts it as four cells
|
||||
_CN = "🇨🇳" # a second flag sharing the "C" indicator — the whole category must be handled
|
||||
_PLANE = "🛩️" # a VS16 sequence this terminal draws two wide despite the narrow-VS16 verdict
|
||||
_PLANE_BASE = "\U0001f6e9" # its base codepoint (no selector) — what the wide set is keyed on
|
||||
_ROAD = "\U0001f6e3" # 🛣 a bare text-default emoji: one cell, and both authorities agree
|
||||
_WEB = "\U0001f578" # 🕸 its untouched sibling one Trophy board down — the control
|
||||
_SHRUG = "\U0001f937♂️" # 🤷♂️ a ZWJ sequence: base, joiner, male sign, selector — one glyph
|
||||
_FAMILY = "\U0001f468\U0001f469\U0001f467" # 👨👩👧 three joined people, still one glyph
|
||||
_ZWJ = "" # the joiner itself: the tell that its neighbours are a single glyph
|
||||
_ZWJ = "\u200d" # the joiner: the tell that its neighbours are a single glyph
|
||||
_VS16 = "\ufe0f" # the request for emoji presentation
|
||||
_WAVE = "\U0001f44b" # 👋 emoji presentation by default: two cells by the stock tables already
|
||||
_DISH = "\U0001f4e1" # 📡 a menu icon, the same
|
||||
_CA = "\U0001f1e8\U0001f1e6" # 🇨🇦 a flag: two Regional Indicators, one glyph
|
||||
_CN = "\U0001f1e8\U0001f1f3" # 🇨🇳 a second flag sharing the "C" indicator
|
||||
_SUN = "☀" # ☀ a text-default emoji, alone
|
||||
_PLANE = "\U0001f6e9" # 🛩 another, which a font draws either way
|
||||
_HEART = "❤" # ❤ another
|
||||
_ROAD = "\U0001f6e3" # 🛣 text-default too, but one of the app's own marks
|
||||
_WEB = "\U0001f578" # 🕸 the same
|
||||
_BIN = "\U0001f5d1" # 🗑 the same
|
||||
_WARN = "⚠" # ⚠ the same
|
||||
_SHRUG = f"\U0001f937{_ZWJ}♂{_VS16}" # the shrug: base, joiner, sign, selector
|
||||
_FAMILY = f"\U0001f468{_ZWJ}\U0001f469{_ZWJ}\U0001f467" # the family: three joined people
|
||||
_THUMB = "\U0001f44d\U0001f3fd" # a thumb and its skin tone, no joiner: still one glyph
|
||||
_TECHIE = f"\U0001f468\U0001f3fb{_ZWJ}\U0001f4bb" # the technologist: toned, then joined
|
||||
_KEYCAP = f"1{_VS16}\u20e3" # a keycap: a digit, a selector and the enclosing mark
|
||||
_FLAG = "\U0001f3f4" # a black flag, the base of a pirate flag
|
||||
_STRANDED = f"{_FLAG}{_ZWJ}" # a pirate flag cut after its joiner by a name's byte limit
|
||||
|
||||
#: Strings every authority has to agree about: emoji of every shape, the app's own marks, and the
|
||||
#: scripts and marks that must come through the rule untouched.
|
||||
_SAMPLES = (
|
||||
"plain ascii",
|
||||
f"Bob {_FAMILY} x",
|
||||
f"{_CA}{_CN}",
|
||||
f"{_SUN}{_VS16}{_SHRUG}",
|
||||
f"{_SUN} bare, {_PLANE} bare, {_HEART} bare",
|
||||
f"a{_ZWJ}b",
|
||||
f"{_STRANDED} x",
|
||||
f"{_KEYCAP}!",
|
||||
f"│ {_WARN}{_VS16} warn │ {_WARN} mark │",
|
||||
f"Tech {_TECHIE} x {_THUMB}{_THUMB}",
|
||||
"漢字 names",
|
||||
"ष\u094d\u200dक", # a Devanagari conjunct: a joiner between letters, not pictographs
|
||||
f"{_CA[0]} a lone indicator",
|
||||
"e\u0301 a combining accent",
|
||||
f"{_ROAD}{_WEB}{_BIN} ↔ ↕",
|
||||
)
|
||||
|
||||
|
||||
def test_narrow_lone_set_defaults_extends_and_disables(monkeypatch) -> None:
|
||||
"""The allowlist seeds to the confirmed glyph; the env var overrides it outright."""
|
||||
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
|
||||
assert _WAVE in ew._narrow_lone_set()
|
||||
@pytest.fixture
|
||||
def installed() -> Iterator[None]:
|
||||
"""Install the reserve-two rule for one test, and put every table and cache back after."""
|
||||
from meshterm.ui.tui import colsnap
|
||||
from meshterm.ui.tui.render import _ANSI_CACHE
|
||||
|
||||
monkeypatch.setenv("MESHTERM_NARROW_EMOJI", "👋🤙")
|
||||
assert {"👋", "🤙"} <= ew._narrow_lone_set()
|
||||
|
||||
# Empty string is the off switch — a way back to Rich/pt defaults with no code change.
|
||||
monkeypatch.setenv("MESHTERM_NARROW_EMOJI", "")
|
||||
assert ew._narrow_lone_set() == frozenset()
|
||||
|
||||
|
||||
def test_wide_base_set_defaults_extends_and_disables(monkeypatch) -> None:
|
||||
"""The wide set seeds to the confirmed bases; the env var overrides it outright."""
|
||||
monkeypatch.delenv("MESHTERM_WIDE_EMOJI", raising=False)
|
||||
# One source only: a VS16 sequence the narrow verdict gets wrong. A *bare* codepoint both
|
||||
# authorities already measure at one is not a candidate — see test_bare_text_default_emoji.
|
||||
assert _PLANE_BASE in ew._wide_base_set()
|
||||
assert not {_ROAD, _WEB} & ew._wide_base_set()
|
||||
|
||||
# ✈ (U+2708) is a second VS16 airplane base; both list cleanly.
|
||||
monkeypatch.setenv("MESHTERM_WIDE_EMOJI", "\U0001f6e9✈")
|
||||
assert {"\U0001f6e9", "✈"} <= ew._wide_base_set()
|
||||
|
||||
# Pasting the whole rendered glyph keeps the base but strips the zero-width selector, so
|
||||
# the selector is never itself counted as a wide cell.
|
||||
monkeypatch.setenv("MESHTERM_WIDE_EMOJI", _PLANE)
|
||||
wide = ew._wide_base_set()
|
||||
assert _PLANE_BASE in wide and "️" not in wide
|
||||
|
||||
# Empty string trusts the narrow-VS16 verdict for every sequence.
|
||||
monkeypatch.setenv("MESHTERM_WIDE_EMOJI", "")
|
||||
assert ew._wide_base_set() == frozenset()
|
||||
|
||||
|
||||
def test_rich_cell_len_narrows_only_allowlisted_lone_emoji() -> None:
|
||||
"""Rich's replacement measures a listed lone emoji as one, an unlisted one still as two."""
|
||||
cell_len = ew._make_cell_len(frozenset(_WAVE), frozenset(_PLANE_BASE))
|
||||
|
||||
assert cell_len(_WAVE) == 1
|
||||
assert cell_len(_DISH) == 2 # unlisted: the terminal draws it wide, so leave it wide
|
||||
assert cell_len("Bob 👋 hi") == 8 # the narrowed glyph flows through a whole line
|
||||
# The pre-existing VS16 handling still applies: a narrow base is measured alone, its
|
||||
# variation selector skipped, so "☀️" stays one cell rather than being promoted to two.
|
||||
assert cell_len("☀️") == 1
|
||||
# A wide-VS16 exception is forced back to two, selector still skipped, so the airplane
|
||||
# frames flush instead of collapsing to one and smearing the row.
|
||||
assert cell_len(_PLANE) == 2
|
||||
assert cell_len("hi 🛩️") == len("hi ") + 2
|
||||
# A ZWJ sequence is one glyph, measured as its base: the codepoints the joiner folds in
|
||||
# cost nothing, however many of them there are. Rich's stock loop already does this, and
|
||||
# the selector-skipping replacement must not lose it — counting the male sign as a cell
|
||||
# of its own is what pulls the row's right border a column in.
|
||||
assert cell_len(_SHRUG) == 2
|
||||
assert cell_len(_FAMILY) == 2
|
||||
assert cell_len(f"Bob {_FAMILY} hi") == len("Bob hi") + 2
|
||||
|
||||
|
||||
def test_pt_cache_narrows_only_allowlisted_lone_emoji() -> None:
|
||||
"""prompt_toolkit's cache — the authority that places the border — matches Rich."""
|
||||
cache = ew._make_pt_cache(frozenset(_WAVE), frozenset(_PLANE_BASE))
|
||||
|
||||
assert cache[_WAVE] == 1
|
||||
assert cache[_DISH] == 2
|
||||
# The base cache sums a multi-char string per character through the cache, so the
|
||||
# one-cell wave is inherited by any line that contains it.
|
||||
assert cache["Bob 👋"] == 5
|
||||
# The wide-VS16 airplane is the reverse: its base is forced to two, the selector stays
|
||||
# zero, so the whole glyph (and any line holding it) keeps the terminal's two cells.
|
||||
assert cache[_PLANE] == 2
|
||||
assert cache["🛩️ hi"] == 2 + len(" hi")
|
||||
# The sequences prompt_toolkit added up part by part: three cells for the shrug and six
|
||||
# for the family, each of them one two-cell glyph, each pulling a border in by the excess.
|
||||
assert cache[_SHRUG] == 2
|
||||
assert cache[_FAMILY] == 2
|
||||
assert cache[f"Bob {_FAMILY} hi"] == len("Bob hi") + 2
|
||||
# A lone joiner is still zero, and a codepoint a sequence joins keeps its own width when
|
||||
# it stands alone — ``↕`` is the reorder icon, not part of anything.
|
||||
assert cache[_ZWJ] == 0
|
||||
assert cache["↕"] == 1
|
||||
|
||||
|
||||
def test_bare_text_default_emoji_are_left_exactly_as_measured() -> None:
|
||||
"""An emoji outside Emoji_Presentation, drawn *bare*, is one cell — and stays one.
|
||||
|
||||
``🛣`` and ``🕸`` have East-Asian width Neutral, so Rich and wcwidth both measure them at
|
||||
one; with no variation selector asking for emoji presentation, the font draws a one-cell
|
||||
text glyph, which is exactly what they said. Both authorities already agreeing is not a
|
||||
bug to correct: listing ``🛣`` in the wide set made the Trophy case's Longest-distance
|
||||
heading reserve a cell the terminal never drew and pulled that row's border a column in,
|
||||
while ``🕸`` — same class, one board down, never listed — framed flush throughout.
|
||||
"""
|
||||
cell_len = ew._make_cell_len(frozenset(_WAVE), ew._wide_base_set())
|
||||
cache = ew._make_pt_cache(frozenset(_WAVE), ew._wide_base_set())
|
||||
for bare in (_ROAD, _WEB):
|
||||
assert cell_len(bare) == 1 and cache[bare] == 1
|
||||
# A heading built around one costs its own cells and no more, so the row frames flush.
|
||||
caption = cell_len(f"── {_ROAD} Longest distance ──")
|
||||
assert caption == len("── ") + 1 + len(" Longest distance ──")
|
||||
# The Trophy case's *visible* gap after the road is not this module's business and must
|
||||
# not become it: the heading pads the mark out to the widest of the seven disciplines
|
||||
# (records_screen.discipline_label), which costs a real cell the terminal advances over
|
||||
# — where widening the glyph here would claim one it does not.
|
||||
|
||||
|
||||
def test_flags_measure_two_cells_in_both_authorities() -> None:
|
||||
"""A country flag measures two cells in both width authorities.
|
||||
|
||||
A flag is a Regional Indicator pair: prompt_toolkit's wcwidth calls it four cells,
|
||||
while Rich and the terminal draw it as one two-cell glyph. Narrowing the
|
||||
indicators as a category makes every flag sum to two in both — no per-country
|
||||
allowlist entry, and flags sharing an indicator (🇨🇦 / 🇨🇳) are fixed at once.
|
||||
"""
|
||||
# Only the lone-emoji allowlist is passed; flags are handled by category, not by listing.
|
||||
cell_len = ew._make_cell_len(frozenset(_WAVE), frozenset())
|
||||
cache = ew._make_pt_cache(frozenset(_WAVE), frozenset())
|
||||
|
||||
for flag in (_CA, _CN):
|
||||
assert cell_len(flag) == 2
|
||||
assert cache[flag] == 2 # was 4 unpatched: two indicators at wcwidth 2 each
|
||||
|
||||
# A flag rides a chat line without dragging the border: "eh? 🇨🇦" measures its plain
|
||||
# cells plus the flag's two.
|
||||
assert cell_len("eh 🇨🇦") == len("eh ") + 2
|
||||
|
||||
|
||||
def _snapshot() -> tuple:
|
||||
"""Capture the mutable width state :func:`calibrate` patches, to restore afterwards."""
|
||||
return (cells._cell_len, ptu._CHAR_SIZES_CACHE, ew._CALIBRATED, ew._CLUSTERS)
|
||||
|
||||
|
||||
def _restore(snap: tuple) -> None:
|
||||
"""Put the width authorities (and the once-only flag) back, clearing Rich's memo cache."""
|
||||
cells._cell_len, ptu._CHAR_SIZES_CACHE, ew._CALIBRATED, ew._CLUSTERS = snap
|
||||
cells.cached_cell_len.cache_clear()
|
||||
|
||||
|
||||
def test_calibrate_width1_narrows_the_wave_in_both_authorities(monkeypatch) -> None:
|
||||
"""A renderer that draws emoji narrow gets both Rich and pt aligned to the terminal."""
|
||||
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
|
||||
monkeypatch.delenv("MESHTERM_WIDE_EMOJI", raising=False)
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
snap = _snapshot()
|
||||
saved = (
|
||||
cells._cell_len,
|
||||
cells.get_character_cell_size,
|
||||
cells.split_graphemes,
|
||||
segment.get_character_cell_size,
|
||||
ptu._CHAR_SIZES_CACHE,
|
||||
ew._INSTALLED,
|
||||
ew._CLUSTERS,
|
||||
)
|
||||
ew._INSTALLED = False
|
||||
ew.install()
|
||||
try:
|
||||
ew._CALIBRATED = False
|
||||
ew.calibrate(force_width=1)
|
||||
assert cells.cell_len(_WAVE) == 1
|
||||
assert cells.cell_len(_DISH) == 2 # unlisted icon stays wide
|
||||
assert cells.cell_len(_CA) == 2 # flag handled by category, no allowlist entry
|
||||
assert cells.cell_len(_PLANE) == 2 # wide-VS16 exception carved back out of the narrowing
|
||||
assert cells.cell_len(_ROAD) == 1 # bare text-default emoji: left exactly as measured
|
||||
assert cells.cell_len(_WEB) == 1
|
||||
assert get_cwidth(_WAVE) == 1 # pt now places the border a cell earlier
|
||||
assert get_cwidth(_DISH) == 2
|
||||
assert get_cwidth(_CA) == 2 # was 4 unpatched
|
||||
assert get_cwidth(_PLANE) == 2 # was 1 unpatched: the airplane smeared a cell short
|
||||
assert get_cwidth(_ROAD) == 1 and get_cwidth(_WEB) == 1 # untouched, in both authorities
|
||||
assert cells.cell_len(_SHRUG) == 2 and get_cwidth(_SHRUG) == 2 # pt counted three
|
||||
assert cells.cell_len(_FAMILY) == 2 and get_cwidth(_FAMILY) == 2 # pt counted six
|
||||
yield
|
||||
finally:
|
||||
_restore(snap)
|
||||
(
|
||||
cells._cell_len,
|
||||
cells.get_character_cell_size,
|
||||
cells.split_graphemes,
|
||||
segment.get_character_cell_size,
|
||||
ptu._CHAR_SIZES_CACHE,
|
||||
ew._INSTALLED,
|
||||
ew._CLUSTERS,
|
||||
) = saved
|
||||
cells.cached_cell_len.cache_clear()
|
||||
_ANSI_CACHE.clear()
|
||||
colsnap._CACHE.clear()
|
||||
|
||||
|
||||
def test_calibrate_width2_narrows_nothing_but_still_joins_clusters(monkeypatch) -> None:
|
||||
"""A width-2 terminal narrows nothing, but still gets the cluster join.
|
||||
def test_whatever_may_be_drawn_as_an_emoji_is_reserved_two_cells(installed) -> None: # noqa: ANN001
|
||||
"""Every shape of emoji measures two in both authorities, whatever the stock tables said.
|
||||
|
||||
Narrowing would itself break the border there — but a joined sequence is
|
||||
over-measured at *every* width, so that one correction still lands.
|
||||
|
||||
prompt_toolkit sums a ZWJ sequence's codepoints wherever it runs, which no terminal draws:
|
||||
the family is one two-cell glyph on the widest terminal as surely as on the narrowest. So
|
||||
the cluster rule is installed on its own here, and nothing else is — the curated sets and
|
||||
the flag category stay unconfirmed on this renderer, and Rich is not touched at all.
|
||||
The stock answers were all over the place — prompt_toolkit gave a flag four, a sun asking
|
||||
for emoji presentation one, a toned thumb four and a family six — and no answer below two
|
||||
is safe for a glyph a font may draw in two: the pin after it would overwrite its right half.
|
||||
"""
|
||||
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
snap = _snapshot()
|
||||
try:
|
||||
ew._CALIBRATED = False
|
||||
ew.calibrate(force_width=2)
|
||||
assert cells.cell_len(_WAVE) == 2
|
||||
assert get_cwidth(_WAVE) == 2
|
||||
# Rich untouched: it still promotes a VS16 sequence to the two cells this terminal draws.
|
||||
assert cells.cell_len(_PLANE) == 2 and cells.cell_len("☀️") == 2
|
||||
# Neither curated set nor the flag category applies here — pt keeps its stock answers.
|
||||
assert get_cwidth("☀️") == 1
|
||||
assert get_cwidth(_CA) == 4
|
||||
# The one correction that holds at either width.
|
||||
assert get_cwidth(_SHRUG) == 2 # was 3
|
||||
assert get_cwidth(_FAMILY) == 2 # was 6
|
||||
finally:
|
||||
_restore(snap)
|
||||
for glyph in (
|
||||
_WAVE,
|
||||
_DISH,
|
||||
_SUN,
|
||||
f"{_SUN}{_VS16}",
|
||||
_PLANE,
|
||||
f"{_PLANE}{_VS16}",
|
||||
_HEART,
|
||||
_CA,
|
||||
_CA[0],
|
||||
_SHRUG,
|
||||
_FAMILY,
|
||||
_THUMB,
|
||||
_TECHIE,
|
||||
_KEYCAP,
|
||||
f"{_WARN}{_VS16}", # a mark asking for emoji presentation is an emoji like any other
|
||||
):
|
||||
assert cells.cell_len(glyph) == 2, f"Rich: {glyph!r}"
|
||||
assert get_cwidth(glyph) == 2, f"prompt_toolkit: {glyph!r}"
|
||||
|
||||
|
||||
def test_join_zwj_clusters_merges_only_joined_runs() -> None:
|
||||
"""The fragment merge gathers a whole sequence and leaves everything else alone.
|
||||
def test_text_the_app_draws_keeps_its_stock_width(installed) -> None: # noqa: ANN001
|
||||
"""The rule reserves nothing it has no reason to: text, chrome and the app's own marks.
|
||||
|
||||
prompt_toolkit's ANSI text arrives one codepoint per fragment, so a sequence is a run:
|
||||
a base, an optional selector, then *joiner + component + optional selector* groups.
|
||||
The marks are text-default emoji too, but they are the app's own vocabulary, drawn in one
|
||||
cell on the terminals it is used on, so they keep the one cell every screen was laid out
|
||||
with. Rich's single-cell ranges are measured by a fast path no patch reaches, so everything
|
||||
has to agree with it there.
|
||||
"""
|
||||
for text, width in (
|
||||
("a", 1),
|
||||
("#", 1),
|
||||
("1", 1),
|
||||
("é", 1),
|
||||
("漢", 2),
|
||||
("─", 1),
|
||||
("⠿", 1),
|
||||
("©", 1),
|
||||
("▶", 1),
|
||||
(_WARN, 1),
|
||||
(_ROAD, 1),
|
||||
(_WEB, 1),
|
||||
(_BIN, 1),
|
||||
("↕", 1),
|
||||
("↔", 1),
|
||||
("↩", 1),
|
||||
("⌨", 1),
|
||||
("⚙", 1),
|
||||
("a\U0001f3fd", 1), # a stray skin tone on a letter leaves it a letter
|
||||
):
|
||||
assert cells.cell_len(text) == width, f"Rich: {text!r}"
|
||||
assert get_cwidth(text) == width, f"prompt_toolkit: {text!r}"
|
||||
|
||||
|
||||
def test_every_authority_measures_every_string_the_same(installed) -> None: # noqa: ANN001
|
||||
"""Rich's whole-string width, its grapheme spans and prompt_toolkit's cache all agree.
|
||||
|
||||
And every lone character measures the same in all of them. These are the four places a width
|
||||
is read, and a row is only laid out once if they give it one answer.
|
||||
"""
|
||||
for text in _SAMPLES:
|
||||
spans, total = cells.split_graphemes(text)
|
||||
assert cells.cell_len(text) == total == get_cwidth(text), text
|
||||
assert total == sum(width for _start, _end, width in spans), text
|
||||
assert spans[0][0] == 0 and spans[-1][1] == len(text), f"spans cover {text!r}"
|
||||
for char in text:
|
||||
size = cells.get_character_cell_size(char)
|
||||
assert size == segment.get_character_cell_size(char) == get_cwidth(char), repr(char)
|
||||
|
||||
|
||||
def test_no_prefix_outgrows_the_character_that_ends_it(installed) -> None: # noqa: ANN001
|
||||
"""The invariant Rich's segment splitter terminates on, then the splitter itself.
|
||||
|
||||
``Segment.split_cells`` steps a string a codepoint at a time until a whole-string width meets
|
||||
the cut, stepping over a two-cell character only where that character measures two on its
|
||||
own. A prefix that grew by two at a character measuring one would be a cut the walk steps past
|
||||
in both directions forever — which is why the character width is patched alongside the string
|
||||
width, and why this checks the invariant before trusting the splitter with it.
|
||||
"""
|
||||
for text in _SAMPLES:
|
||||
for index, char in enumerate(text):
|
||||
grew = cells.cell_len(text[: index + 1]) - cells.cell_len(text[:index])
|
||||
assert 0 <= grew <= 2, f"{text!r} at {index}"
|
||||
if grew == 2:
|
||||
assert cells.get_character_cell_size(char) == 2, f"{text!r} at {index}"
|
||||
|
||||
def split_everywhere() -> None:
|
||||
for text in _SAMPLES:
|
||||
for cut in range(cells.cell_len(text) + 1):
|
||||
segment.Segment(text).split_cells(cut)
|
||||
|
||||
worker = threading.Thread(target=split_everywhere, daemon=True)
|
||||
worker.start()
|
||||
worker.join(timeout=20)
|
||||
assert not worker.is_alive(), "a segment split never met its cut"
|
||||
|
||||
|
||||
def test_a_stranded_joiner_leaves_the_next_character_its_cell(installed) -> None: # noqa: ANN001
|
||||
"""A joiner with no pictograph after it joins nothing, in both authorities.
|
||||
|
||||
A MeshCore name is cut at a byte limit, so a name ending on a pirate flag arrives as the
|
||||
black flag and a bare joiner, followed by the lane's padding. Rich's stock loop folded the
|
||||
padding into the flag, so the name lane measured a cell short of what the terminal drew.
|
||||
"""
|
||||
padded = f"{_STRANDED} x" # the flag's two cells, two of padding, then the next lane
|
||||
assert cells.cell_len(padded) == 5 and get_cwidth(padded) == 5
|
||||
# A real sequence still joins: the shrug's male sign is a pictograph, not a padding space.
|
||||
assert cells.cell_len(f"{_SHRUG} x") == 4 and get_cwidth(f"{_SHRUG} x") == 4
|
||||
|
||||
|
||||
def test_install_happens_once(installed) -> None: # noqa: ANN001
|
||||
"""A second call is a no-op: the tables patched by the first stay exactly as they are."""
|
||||
patched = (cells._cell_len, ptu._CHAR_SIZES_CACHE)
|
||||
ew.install()
|
||||
assert (cells._cell_len, ptu._CHAR_SIZES_CACHE) == patched
|
||||
|
||||
|
||||
def test_the_app_draws_no_text_default_emoji_its_vocabulary_does_not_name() -> None:
|
||||
"""Every text-default emoji the package draws is one of its own marks, and every mark is used.
|
||||
|
||||
This is what keeps :data:`~meshterm.ui.tui.emoji_width._APP_TEXT_MARKS` a closed vocabulary
|
||||
rather than a list kept by care: a new one-cell mark added to a screen without being named
|
||||
there fails here, instead of quietly gaining a blank cell beside it. Prose is skipped
|
||||
(docstrings and bare strings are read, not drawn), and so is the module that defines the list.
|
||||
"""
|
||||
from rich._unicode_data import load
|
||||
|
||||
text_default = {
|
||||
char
|
||||
for char in load("auto").narrow_to_wide
|
||||
if not char.isascii() and char not in cells._SINGLE_CELLS
|
||||
}
|
||||
# Inputs to the PicoCalc's substitution table, which maps them to glyphs its font has. They
|
||||
# are never drawn on a terminal that shows emoji, so they are not marks.
|
||||
fold_inputs = {"↪", "✔", "✖"}
|
||||
|
||||
package = pathlib.Path(ew.__file__).parents[2]
|
||||
drawn: set[str] = set()
|
||||
for path in package.rglob("*.py"):
|
||||
if path.name == "emoji_width.py":
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
prose = {
|
||||
id(node.value)
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)
|
||||
}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
if id(node) not in prose:
|
||||
drawn |= set(node.value) & text_default
|
||||
|
||||
assert drawn - fold_inputs <= ew._APP_TEXT_MARKS, (
|
||||
f"name these in _APP_TEXT_MARKS: {sorted(drawn - fold_inputs - ew._APP_TEXT_MARKS)}"
|
||||
)
|
||||
assert ew._APP_TEXT_MARKS <= drawn, (
|
||||
f"no longer drawn anywhere: {sorted(ew._APP_TEXT_MARKS - drawn)}"
|
||||
)
|
||||
|
||||
|
||||
def test_clusters_split_between_glyphs_and_never_inside_one() -> None:
|
||||
"""The unit a lane may cut on and a width is measured by: one entry per glyph drawn.
|
||||
|
||||
A joined run, a flag's indicator *pair*, a toned base and a keycap each come back whole,
|
||||
because each is one glyph — while a joiner that joins nothing stands on its own (see
|
||||
:func:`~meshterm.ui.tui.emoji_width._joins`), and so do letters around a joiner.
|
||||
"""
|
||||
text = f"a{_FAMILY}{_CA}{_THUMB}{_KEYCAP}b"
|
||||
assert list(ew.clusters(text)) == ["a", _FAMILY, _CA, _THUMB, _KEYCAP, "b"]
|
||||
# Two flags in a row pair up one at a time rather than running together into four.
|
||||
assert list(ew.clusters(_CA + _CN)) == [_CA, _CN]
|
||||
# A name cut at its byte limit just past a joiner: the joiner is not part of the flag.
|
||||
assert list(ew.clusters(_STRANDED)) == [_FLAG, _ZWJ]
|
||||
# A joiner between letters joins nothing a font draws as one emoji.
|
||||
assert list(ew.clusters(f"a{_ZWJ}b")) == ["a", _ZWJ, "b"]
|
||||
|
||||
|
||||
def test_cut_cells_keeps_every_glyph_whole_and_strands_no_joiner() -> None:
|
||||
"""A lane's truncation lands between glyphs, so nothing is measured that is not drawn."""
|
||||
assert ew.cut_cells(f"Bob {_FAMILY}", 6) == f"Bob {_FAMILY}" # it fits, so it is kept
|
||||
assert ew.cut_cells(f"Bob {_FAMILY}", 5) == "Bob " # it doesn't: dropped whole
|
||||
assert ew.cut_cells(_CA, 1) == "" # half a flag is a letter, not half a glyph
|
||||
assert ew.cut_cells(_STRANDED, 4) == _FLAG # the joiner does not trail the cut
|
||||
assert ew.cut_cells("plain name", 5) == "plain"
|
||||
|
||||
|
||||
def test_drawable_folds_only_what_no_terminal_can_draw() -> None:
|
||||
"""A newline, an escape or a bidi override in an advert name is folded to a space.
|
||||
|
||||
None of them is a width question — they are text that must not reach the terminal at all.
|
||||
The joiner is format-class too and is the one kept, because it holds an emoji together.
|
||||
"""
|
||||
assert ew.drawable("line\nbreak") == "line break"
|
||||
assert ew.drawable("esc\x1b[31mape") == "esc [31mape"
|
||||
assert ew.drawable("flip\u202eme") == "flip me"
|
||||
assert ew.drawable(f"Bob {_FAMILY}{_THUMB}") == f"Bob {_FAMILY}{_THUMB}"
|
||||
|
||||
|
||||
def test_join_clusters_merges_every_multi_codepoint_glyph() -> None:
|
||||
"""The fragment merge gathers each glyph built from several codepoints into one fragment.
|
||||
|
||||
prompt_toolkit's ANSI text arrives one codepoint per fragment, so a glyph is a run to be
|
||||
gathered — and every such run is merged now, not only the joined ones, because a mark
|
||||
measured one cell and followed by a selector must not be folded into a one-cell slot.
|
||||
"""
|
||||
|
||||
def line(text: str) -> list:
|
||||
@@ -243,41 +325,41 @@ def test_join_zwj_clusters_merges_only_joined_runs() -> None:
|
||||
def texts(fragments: list) -> list:
|
||||
return [text for _style, text in fragments]
|
||||
|
||||
# A line with no joiner is handed straight back — the same object, not a copy.
|
||||
plain = line("hi 📡")
|
||||
assert ew._join_zwj_clusters(plain) is plain
|
||||
# A line with nothing to merge is handed straight back — the same object, not a copy.
|
||||
plain = line(f"hi {_DISH} {_WARN}")
|
||||
assert ew._join_clusters(plain) is plain
|
||||
|
||||
# The shrug's four fragments become one; the bars around it are untouched.
|
||||
assert texts(ew._join_zwj_clusters(line(f"|{_SHRUG}|"))) == ["|", _SHRUG, "|"]
|
||||
# Three joined people, five fragments, still one glyph.
|
||||
assert texts(ew._join_zwj_clusters(line(_FAMILY))) == [_FAMILY]
|
||||
# A selector on the *base*, before the joiner, belongs to the run: ❤️🔥.
|
||||
burning = "❤️\U0001f525"
|
||||
assert texts(ew._join_zwj_clusters(line(burning))) == [burning]
|
||||
# A bare VS16 pair is not a sequence — prompt_toolkit already folds it into the cell
|
||||
# before it, so those fragments are left exactly as they came.
|
||||
assert texts(ew._join_zwj_clusters(line(f"☀️{_SHRUG}"))) == ["☀", "️", _SHRUG]
|
||||
# A trailing joiner with nothing to join is not a sequence either.
|
||||
assert texts(ew._join_zwj_clusters(line(f"a{_ZWJ}"))) == ["a", _ZWJ]
|
||||
assert texts(ew._join_clusters(line(f"|{_SHRUG}|"))) == ["|", _SHRUG, "|"]
|
||||
assert texts(ew._join_clusters(line(_FAMILY))) == [_FAMILY]
|
||||
burning = f"❤{_VS16}{_ZWJ}\U0001f525" # ❤\ufe0f\u200d🔥 a selector on the base, then a join
|
||||
assert texts(ew._join_clusters(line(burning))) == [burning]
|
||||
# A selector pair, a toned emoji and a keycap are one glyph each, merged like the rest.
|
||||
assert texts(ew._join_clusters(line(f"{_SUN}{_VS16}{_SHRUG}"))) == [f"{_SUN}{_VS16}", _SHRUG]
|
||||
assert texts(ew._join_clusters(line(f"{_WARN}{_VS16}|"))) == [f"{_WARN}{_VS16}", "|"]
|
||||
assert texts(ew._join_clusters(line(f"{_THUMB}{_KEYCAP}"))) == [_THUMB, _KEYCAP]
|
||||
# A flag's two indicators are one glyph, written as one: a cursor pin between the halves
|
||||
# would leave a terminal drawing two letters instead of a flag.
|
||||
assert texts(ew._join_clusters(line(f"|{_CA}|"))) == ["|", _CA, "|"]
|
||||
assert texts(ew._join_clusters(line(f"{_CA}{_CN}"))) == [_CA, _CN]
|
||||
# A lone indicator is no pair, and a trailing or stranded joiner joins nothing.
|
||||
assert texts(ew._join_clusters(line(f"{_CA[0]} x"))) == [_CA[0], " ", "x"]
|
||||
assert texts(ew._join_clusters(line(f"a{_ZWJ}"))) == ["a", _ZWJ]
|
||||
assert texts(ew._join_clusters(line(f"{_STRANDED} x"))) == [_FLAG, _ZWJ, " ", "x"]
|
||||
|
||||
# A merged fragment iterates as the whole sequence, which is what makes prompt_toolkit
|
||||
# build one Char of it instead of one per codepoint.
|
||||
(merged,) = texts(ew._join_zwj_clusters(line(_SHRUG)))
|
||||
# A merged fragment iterates as the whole glyph, which is what makes prompt_toolkit build
|
||||
# one Char of it instead of one per codepoint.
|
||||
(merged,) = texts(ew._join_clusters(line(_SHRUG)))
|
||||
assert list(merged) == [_SHRUG] and merged == _SHRUG
|
||||
|
||||
|
||||
def test_cluster_control_lays_a_sequence_into_a_single_screen_cell(monkeypatch) -> None:
|
||||
"""The delivery half: one ``Char`` per glyph, with every codepoint still written out.
|
||||
def test_cluster_control_lays_each_glyph_into_a_single_screen_cell(installed) -> None: # noqa: ANN001
|
||||
"""The delivery half: one two-cell ``Char`` per glyph, every codepoint still written out.
|
||||
|
||||
Measuring the sequence right is not enough on its own — prompt_toolkit lays out one
|
||||
codepoint at a time, so an uncorrected screen puts the male sign in a cell of its own and
|
||||
everything after it a column late. The control merges the run, so the window makes a
|
||||
single two-cell ``Char`` of it and the text after lands where the terminal draws it. The
|
||||
codepoints themselves are untouched: all four still reach the screen, in order, so the
|
||||
terminal composes the same glyph.
|
||||
Measuring a glyph right is not enough on its own — prompt_toolkit lays out one codepoint at a
|
||||
time, so an unmerged shrug puts its male sign in a cell of its own, and a warning mark with a
|
||||
selector becomes a two-cell glyph in a one-cell slot. Merged, each is one ``Char`` two cells
|
||||
wide, and the text after lands where the pins will put it.
|
||||
"""
|
||||
monkeypatch.delenv("MESHTERM_NARROW_EMOJI", raising=False)
|
||||
monkeypatch.delenv("MESHTERM_WIDE_EMOJI", raising=False)
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.application.current import set_app
|
||||
from prompt_toolkit.formatted_text import ANSI
|
||||
@@ -287,30 +369,18 @@ def test_cluster_control_lays_a_sequence_into_a_single_screen_cell(monkeypatch)
|
||||
from prompt_toolkit.layout.screen import Screen, WritePosition
|
||||
from prompt_toolkit.output import DummyOutput
|
||||
|
||||
text = f"|{_SHRUG}|{_FAMILY}|end"
|
||||
warn = f"{_WARN}{_VS16}"
|
||||
text = f"|{_SHRUG}|{_FAMILY}|{warn}|end"
|
||||
window = Window(ew.ClusterTextControl(lambda: ANSI(text)), always_hide_cursor=True)
|
||||
app = Application(layout=Layout(window), input=DummyInput(), output=DummyOutput())
|
||||
with set_app(app):
|
||||
screen = Screen(default_char=None, initial_width=40, initial_height=1)
|
||||
window.write_to_screen(screen, MouseHandlers(), WritePosition(0, 0, 40, 1), "", False, None)
|
||||
row = screen.data_buffer[0]
|
||||
|
||||
def paint() -> list:
|
||||
window = Window(ew.ClusterTextControl(lambda: ANSI(text)), always_hide_cursor=True)
|
||||
app = Application(layout=Layout(window), input=DummyInput(), output=DummyOutput())
|
||||
with set_app(app):
|
||||
screen = Screen(default_char=None, initial_width=40, initial_height=1)
|
||||
window.write_to_screen(
|
||||
screen, MouseHandlers(), WritePosition(0, 0, 40, 1), "", False, None
|
||||
)
|
||||
return screen.data_buffer[0]
|
||||
|
||||
snap = _snapshot()
|
||||
try:
|
||||
ew._CALIBRATED = False
|
||||
ew.calibrate(force_width=1)
|
||||
row = paint()
|
||||
# Each sequence is one cell-occupying Char, two cells wide, exactly as drawn.
|
||||
assert (row[1].char, row[1].width) == (_SHRUG, 2)
|
||||
assert (row[4].char, row[4].width) == (_FAMILY, 2)
|
||||
# So the text after them starts at column 6 rather than 13 — seven cells of border
|
||||
# drift removed (one for the shrug, four for the family, two from the second bar).
|
||||
assert "".join(row[x].char for x in range(6, 10)) == "|end"
|
||||
# Nothing was dropped on the way: the row still spells the source exactly.
|
||||
assert "".join(row[x].char for x in range(40)).rstrip() == text
|
||||
finally:
|
||||
_restore(snap)
|
||||
assert (row[1].char, row[1].width) == (_SHRUG, 2)
|
||||
assert (row[4].char, row[4].width) == (_FAMILY, 2)
|
||||
assert (row[7].char, row[7].width) == (warn, 2)
|
||||
assert "".join(row[x].char for x in range(9, 13)) == "|end"
|
||||
# Nothing was dropped on the way: the row still spells the source exactly.
|
||||
assert "".join(row[x].char for x in range(40)).rstrip() == text
|
||||
+41
-16
@@ -40,7 +40,9 @@ from meshterm.core.advert_store import AdvertPolicy
|
||||
from meshterm.core.channels import DEFAULT_PUBLIC_SECRET, derive_secret
|
||||
from meshterm.core.courier_store import CourierStore
|
||||
from meshterm.core.models import (
|
||||
NODE_TYPE_CHAT,
|
||||
NODE_TYPE_REPEATER,
|
||||
NODE_TYPE_SENSOR,
|
||||
ChatMessage,
|
||||
Contact,
|
||||
Conversation,
|
||||
@@ -186,11 +188,21 @@ def _contacts(cols: int, rows: int) -> Screen:
|
||||
Contact(name="A Rather Long Repeater Name For Width", public_key=_HUB_KEY),
|
||||
]
|
||||
# A non-zero archived tally, so the tail draws both maintenance rows — its populated
|
||||
# state, and the widest the tail ever gets.
|
||||
return ContactsScreen("Homestead", "cc" * 32, contacts, 1, {"aa" * 6: 7}, sort, archived=12)
|
||||
# state, and the widest the tail ever gets — and a locked contact, so its padlock is held
|
||||
# to both platforms' glyph contracts.
|
||||
return ContactsScreen(
|
||||
"Homestead",
|
||||
"cc" * 32,
|
||||
contacts,
|
||||
1,
|
||||
{"aa" * 6: 7},
|
||||
sort,
|
||||
archived=12,
|
||||
locked=frozenset({"aa" * 32}),
|
||||
)
|
||||
|
||||
|
||||
def _purge_ranked(): # noqa: ANN201
|
||||
def _archive_ranked(): # noqa: ANN201
|
||||
"""A ranked contact table for the sweep's screens — scored by the real function.
|
||||
|
||||
Deliberately *not* hand-stamped percentiles. The whole point of the gallery is that a
|
||||
@@ -223,30 +235,41 @@ def _purge_ranked(): # noqa: ANN201
|
||||
("Lakeside", "3d" * 32, dict(heard_age_days=30.0, packets=22, known_days=250.0, hops=1.0)),
|
||||
("sensor-2", "7c" * 32, dict(heard_age_days=210.0, packets=2, known_days=260.0)),
|
||||
]
|
||||
contacts = [Contact(name=n, public_key=k, key_prefix=k[:12]) for n, k, _ in specs]
|
||||
# Typed as their names say, so the preview draws a repeater's ``▲`` and a sensor's ``◉``
|
||||
# beside the plain ``●`` of ``hop-9``, which never advertised a type.
|
||||
types = {
|
||||
"Alice": NODE_TYPE_CHAT,
|
||||
"A Rather Long Repeater Name For Width": NODE_TYPE_REPEATER,
|
||||
"Lakeside": NODE_TYPE_REPEATER,
|
||||
"sensor-2": NODE_TYPE_SENSOR,
|
||||
}
|
||||
contacts = [
|
||||
Contact(name=n, public_key=k, key_prefix=k[:12], node_type=types.get(n))
|
||||
for n, k, _ in specs
|
||||
]
|
||||
signals = {k[:12]: ContactSignals(node=k[:12], **sig) for _, k, sig in specs}
|
||||
return rank_contacts(contacts, signals)
|
||||
|
||||
|
||||
def _purge_victims(): # noqa: ANN201
|
||||
def _archive_victims(): # noqa: ANN201
|
||||
"""The sweep's victim list: the weakest half of the ranking, weakest first."""
|
||||
from meshterm.core.contact_score import sweep_candidates
|
||||
|
||||
ranked = _purge_ranked()
|
||||
ranked = _archive_ranked()
|
||||
return sweep_candidates(ranked, keep=2)
|
||||
|
||||
|
||||
def _purge_ladder(cols: int, rows: int) -> Screen:
|
||||
from meshterm.ui.purge_screen import _target_screen
|
||||
def _archive_ladder(cols: int, rows: int) -> Screen:
|
||||
from meshterm.ui.sweep_screen import _target_screen
|
||||
|
||||
ranked = _purge_ranked()
|
||||
ranked = _archive_ranked()
|
||||
return _target_screen(ranked, [r for r in ranked if not r.protected])
|
||||
|
||||
|
||||
def _purge_preview(cols: int, rows: int) -> Screen:
|
||||
from meshterm.ui.purge_screen import _preview_screen
|
||||
def _archive_preview(cols: int, rows: int) -> Screen:
|
||||
from meshterm.ui.sweep_screen import _preview_screen
|
||||
|
||||
return _preview_screen(_purge_victims())
|
||||
return _preview_screen(_archive_victims())
|
||||
|
||||
|
||||
def _archived(cols: int, rows: int) -> Screen:
|
||||
@@ -972,8 +995,8 @@ def _share_qr(cols: int, rows: int) -> Screen:
|
||||
_ENTRIES: list[_Entry] = [
|
||||
_Entry("dashboard", _dashboard),
|
||||
_Entry("contacts", _contacts),
|
||||
_Entry("purge_ladder", _purge_ladder),
|
||||
_Entry("purge_preview", _purge_preview),
|
||||
_Entry("archive_ladder", _archive_ladder),
|
||||
_Entry("archive_preview", _archive_preview),
|
||||
_Entry("archived", _archived),
|
||||
_Entry("node_detail", _node_detail),
|
||||
_Entry("node_detail_map", _node_detail_map),
|
||||
@@ -1180,8 +1203,10 @@ def test_gallery_map_shows_the_basemap_credit_where_its_frame_puts_it(
|
||||
|
||||
if platform.frame_border:
|
||||
assert "OpenStreetMap" not in body, "the credit reached the drawing on a bordered frame"
|
||||
rule = next(ln for ln in reversed(lines) if "└" in ln)
|
||||
assert rule.endswith(f" {expected} ─┘"), rule
|
||||
# Rounded corners, or square where Rich judges the console legacy Windows (a
|
||||
# captured pytest run there) — either is the frame's own bottom rule.
|
||||
rule = next(ln for ln in reversed(lines) if "╰" in ln or "└" in ln)
|
||||
assert rule[-1] in "╯┘" and rule[:-1].endswith(f" {expected} ─"), rule
|
||||
else:
|
||||
assert body.splitlines()[-1].endswith(expected)
|
||||
assert any(expected in ln for ln in lines)
|
||||
+92
-5
@@ -767,7 +767,7 @@ def test_basemap_sends_its_user_agent_on_every_fetch(tmp_path: Path) -> None:
|
||||
src = _offline_source(tmp_path / "cache")
|
||||
seen: list[urllib.request.Request] = []
|
||||
|
||||
def _urlopen(req: urllib.request.Request, timeout: float | None = None):
|
||||
def _urlopen(req: urllib.request.Request, timeout: float | None = None, context=None):
|
||||
seen.append(req)
|
||||
raise urllib.error.URLError("no network in tests")
|
||||
|
||||
@@ -782,6 +782,84 @@ def test_basemap_sends_its_user_agent_on_every_fetch(tmp_path: Path) -> None:
|
||||
assert seen[0].get_header("User-agent") == basemap_mod._USER_AGENT
|
||||
|
||||
|
||||
def test_basemap_keeps_the_default_tls_context_when_it_works() -> None:
|
||||
"""A working platform trust store is left alone — urllib's default is passed through."""
|
||||
from meshterm.services import basemap as basemap_mod
|
||||
|
||||
basemap_mod._tls_context.cache_clear()
|
||||
try:
|
||||
assert basemap_mod._tls_context() is None
|
||||
finally:
|
||||
basemap_mod._tls_context.cache_clear()
|
||||
|
||||
|
||||
def test_basemap_finds_a_ca_bundle_when_the_default_store_is_empty(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""An empty default store falls back to the OS CA bundle instead of failing every fetch.
|
||||
|
||||
THE macOS bug: a frozen build asks OpenSSL for a trust store at a filesystem path baked
|
||||
into the interpreter's own build, which need not exist on a machine that never
|
||||
installed that Python — which is every machine a PyInstaller bundle lands on. Fetches
|
||||
then failed with ``CERTIFICATE_VERIFY_FAILED``, and because a refused fetch is
|
||||
indistinguishable from empty terrain the map drew no ground under the nodes and said
|
||||
nothing. Windows hid it by reading the OS certificate store in its default context.
|
||||
"""
|
||||
import ssl
|
||||
|
||||
from meshterm.services import basemap as basemap_mod
|
||||
|
||||
# The file only has to exist: loading is recorded below rather than really parsed,
|
||||
# so the test pins *which path is chosen* without depending on a PEM being around.
|
||||
bundle = tmp_path / "cert.pem"
|
||||
bundle.write_text("# stand-in for the OS bundle\n", encoding="utf-8")
|
||||
|
||||
loaded: list[str] = []
|
||||
|
||||
class _EmptyStore(ssl.SSLContext):
|
||||
def get_ca_certs(self, binary_form: bool = False): # type: ignore[override]
|
||||
return []
|
||||
|
||||
def load_verify_locations(self, cafile=None, capath=None, cadata=None): # type: ignore[override]
|
||||
loaded.append(cafile or "")
|
||||
|
||||
monkeypatch.setattr(
|
||||
ssl, "create_default_context", lambda *a, **k: _EmptyStore(ssl.PROTOCOL_TLS_CLIENT)
|
||||
)
|
||||
monkeypatch.setattr(basemap_mod, "_CA_BUNDLES", (str(tmp_path / "nope.pem"), str(bundle)))
|
||||
|
||||
basemap_mod._tls_context.cache_clear()
|
||||
try:
|
||||
ctx = basemap_mod._tls_context()
|
||||
assert ctx is not None, "no context built despite a readable bundle"
|
||||
assert loaded == [str(bundle)] # skipped the missing path, used the present one
|
||||
assert ctx.verify_mode == ssl.CERT_REQUIRED # never verified away to make it work
|
||||
finally:
|
||||
basemap_mod._tls_context.cache_clear()
|
||||
|
||||
|
||||
def test_basemap_never_disables_verification(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""With no bundle anywhere, the fetch still verifies — it fails rather than trusts."""
|
||||
import ssl
|
||||
|
||||
from meshterm.services import basemap as basemap_mod
|
||||
|
||||
class _EmptyStore(ssl.SSLContext):
|
||||
def get_ca_certs(self, binary_form: bool = False): # type: ignore[override]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
ssl, "create_default_context", lambda *a, **k: _EmptyStore(ssl.PROTOCOL_TLS_CLIENT)
|
||||
)
|
||||
monkeypatch.setattr(basemap_mod, "_CA_BUNDLES", ("/nonexistent/ca.pem",))
|
||||
|
||||
basemap_mod._tls_context.cache_clear()
|
||||
try:
|
||||
assert basemap_mod._tls_context() is None # urllib's default, still verifying
|
||||
finally:
|
||||
basemap_mod._tls_context.cache_clear()
|
||||
|
||||
|
||||
def test_basemap_user_agent_needs_no_installed_distribution() -> None:
|
||||
"""Importing the module must not depend on package metadata being on disk.
|
||||
|
||||
@@ -1127,7 +1205,7 @@ def test_basemap_http_get_separates_an_answer_from_silence(tmp_path: Path) -> No
|
||||
return False
|
||||
|
||||
def _fake(result):
|
||||
def _urlopen(_req, timeout=None): # noqa: ANN001
|
||||
def _urlopen(_req, timeout=None, context=None): # noqa: ANN001
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
@@ -2589,6 +2667,12 @@ def _last_row(screen: MapScreen, cols: int = 80) -> str:
|
||||
return _plain(screen.render_body(cols)).splitlines()[-1]
|
||||
|
||||
|
||||
#: A panel's bottom corners, in both shapes Rich draws them: rounded wherever the console
|
||||
#: can take them, square where it judges the console a legacy Windows one — which a
|
||||
#: captured pytest run on Windows is. Asserting one shape passes on one OS only.
|
||||
_BOTTOM_CORNERS = ("╰╯", "└┘")
|
||||
|
||||
|
||||
def _bottom_rule(screen: MapScreen, cols: int = 80, rows: int = 24) -> str:
|
||||
"""The composed frame's bottom border rule — the line the caption is set into."""
|
||||
from rich.text import Text
|
||||
@@ -2598,7 +2682,9 @@ def _bottom_rule(screen: MapScreen, cols: int = 80, rows: int = 24) -> str:
|
||||
screen.note_viewport(max(1, rows - 4))
|
||||
composed = frame.compose_base(Text(""), screen, screen.footer_hint, cols, rows)
|
||||
return next(
|
||||
ln for ln in reversed([Text.from_ansi(x).plain for x in composed.split("\n")]) if "└" in ln
|
||||
ln
|
||||
for ln in reversed([Text.from_ansi(x).plain for x in composed.split("\n")])
|
||||
if any(pair[0] in ln for pair in _BOTTOM_CORNERS)
|
||||
)
|
||||
|
||||
|
||||
@@ -2651,9 +2737,10 @@ def test_a_bordered_frame_sets_the_credit_in_its_bottom_rule_and_spares_the_draw
|
||||
set_platform(REGULAR)
|
||||
screen = _credited_map(72)
|
||||
# Right-justified with exactly one rule cell before the corner, as a title sits in the
|
||||
# top rule: "└──… © OpenMapTiles · Data from OpenStreetMap ─┘".
|
||||
# top rule: "╰──… © OpenMapTiles · Data from OpenStreetMap ─╯".
|
||||
rule = _bottom_rule(screen, 72)
|
||||
assert rule.startswith("└") and rule.endswith(f" {attribution.CREDIT_FULL} ─┘")
|
||||
assert rule[0] + rule[-1] in _BOTTOM_CORNERS, rule
|
||||
assert rule[:-1].endswith(f" {attribution.CREDIT_FULL} ─"), rule
|
||||
assert cell_len(rule) == 72
|
||||
# And nothing of it reached the drawing: the map's own rows are pure picture.
|
||||
assert "OpenStreetMap" not in _plain(screen.render_body(72))
|
||||
|
||||
+37
-6
@@ -143,6 +143,37 @@ def test_fit_cells_measures_display_cells_not_characters() -> None:
|
||||
assert fit_cells("ab", 5, align="right") == " ab"
|
||||
|
||||
|
||||
def test_fit_cells_keeps_a_broken_name_inside_its_lane() -> None:
|
||||
"""A name is written by a stranger's radio, and no part of one may skew the lane.
|
||||
|
||||
Two failures, one lane. A cut through the middle of an emoji sequence leaves a stranded
|
||||
joiner, which folds the ellipsis appended after it into the glyph before it — the lane
|
||||
then measures a cell short of what the terminal draws, and every column right of the
|
||||
name starts late. And a name carrying a newline or an escape ends the row mid-lane
|
||||
whatever it measures. Both are settled inside ``fit_cells`` (see
|
||||
:mod:`meshterm.ui.tui.emoji_width`), so every column in the app is covered by the one
|
||||
helper they all fit their labels through.
|
||||
"""
|
||||
from rich.cells import cell_len
|
||||
|
||||
family = "\U0001f468\U0001f469\U0001f467" # one glyph, three joined people
|
||||
flag = "\U0001f1e8\U0001f1e6" # one glyph, two Regional Indicators
|
||||
|
||||
for name in (f"Bob {family} Family", f"{flag} Canada Hub", "line\nbreak\x1b[31m"):
|
||||
for width in range(4, 24):
|
||||
assert cell_len(fit_cells(name, width)) == width, (name, width)
|
||||
|
||||
# The cut falls between glyphs: half a family is not a glyph, and half a flag is a letter.
|
||||
assert family in fit_cells(f"Bob {family}", 8) # it fits: the glyph is kept whole
|
||||
assert "" not in fit_cells(f"Bob {family}", 5) # it doesn't: no joiner left behind
|
||||
assert fit_cells(f"{flag} Hub", 4).startswith(flag) # the pair survives, or neither does
|
||||
|
||||
# What the terminal cannot draw never reaches it; an ordinary name is left alone.
|
||||
assert "\n" not in fit_cells("line\nbreak", 12)
|
||||
assert "\x1b" not in fit_cells("esc\x1b[31mape", 12)
|
||||
assert fit_cells("plain", 12).strip() == "plain"
|
||||
|
||||
|
||||
def test_main_menu_sections_answer_the_menus_own_question() -> None:
|
||||
"""Each section is a *doing*, in workflow order, and holds only what shares it.
|
||||
|
||||
@@ -220,12 +251,12 @@ def test_marked_label_lines_up_a_mixed_list_when_told_its_lane() -> None:
|
||||
|
||||
lane = icon_lane((_NARROW, _WIDE))
|
||||
rows = [
|
||||
marked_label(_NARROW, "Purge contacts…", "err", lane=lane),
|
||||
marked_label(_NARROW, "Archive contacts…", "err", lane=lane),
|
||||
marked_label(_WIDE, "View archived contacts", "", lane=lane),
|
||||
]
|
||||
starts = {
|
||||
cell_len(row.plain[: row.plain.index(word)])
|
||||
for row, word in zip(rows, ("Purge", "View"), strict=True)
|
||||
for row, word in zip(rows, ("Archive", "View"), strict=True)
|
||||
}
|
||||
assert starts == {lane + 1}
|
||||
|
||||
@@ -256,10 +287,10 @@ def test_align_icons_starts_every_word_in_the_same_cell() -> None:
|
||||
"""
|
||||
plain_row = "Reorder"
|
||||
labels = align_icons(
|
||||
[f"{_NARROW} Purge contacts…", Text.assemble((_WIDE, "ok"), " View archived"), plain_row]
|
||||
[f"{_NARROW} Archive contacts…", Text.assemble((_WIDE, "ok"), " View archived"), plain_row]
|
||||
)
|
||||
assert [_plain(label) for label in labels] == [
|
||||
f"{_NARROW} Purge contacts…",
|
||||
f"{_NARROW} Archive contacts…",
|
||||
f"{_WIDE} View archived",
|
||||
"Reorder",
|
||||
]
|
||||
@@ -306,8 +337,8 @@ def test_align_icons_pads_nothing_where_the_platform_draws_no_icons() -> None:
|
||||
|
||||
set_platform(PICOCALC)
|
||||
try:
|
||||
labels = align_icons([f"{_NARROW} Purge contacts…", f"{_WIDE} View archived"])
|
||||
assert [_plain(label) for label in labels] == ["Purge contacts…", "View archived"]
|
||||
labels = align_icons([f"{_NARROW} Archive contacts…", f"{_WIDE} View archived"])
|
||||
assert [_plain(label) for label in labels] == ["Archive contacts…", "View archived"]
|
||||
finally:
|
||||
set_platform(REGULAR)
|
||||
|
||||
|
||||
@@ -1236,3 +1236,30 @@ def test_node_detail_single_tab_hides_the_switch_hint() -> None:
|
||||
assert "↑↓ move" in screen.footer_hint and screen.footer_hint.endswith("Esc back")
|
||||
# Nothing to switch to, so the lane leaves the slot empty rather than dimming it.
|
||||
assert screen.fkey_lane[2] is None
|
||||
|
||||
|
||||
def test_toggling_the_lock_rewrites_the_rows_and_keeps_the_cursor_on_it() -> None:
|
||||
"""Lock becomes Unlock on the same row, and Archive is withdrawn while the lock holds."""
|
||||
tm = _Action("timemachine", "⏳", "", "Time machine — 42 receptions")
|
||||
delete = _Action("remove", "🗑", "err", "Delete contact…")
|
||||
screen = _screen(
|
||||
info_actions=[
|
||||
tm,
|
||||
_Action("lock", "🔒", "", "Lock contact"),
|
||||
_Action("archive", "💾", "", "Archive contact"),
|
||||
delete,
|
||||
]
|
||||
)
|
||||
resolved: list = []
|
||||
screen.resolve = lambda value: resolved.append(value) # type: ignore[method-assign]
|
||||
screen.note_viewport(30)
|
||||
screen.render_body(72)
|
||||
screen.handle("down")
|
||||
screen.handle("enter")
|
||||
assert resolved == ["lock"]
|
||||
|
||||
screen.replace_info_actions([tm, _Action("unlock", "🔓", "", "Unlock contact"), delete])
|
||||
screen.render_body(72)
|
||||
screen.handle("enter")
|
||||
assert resolved == ["lock", "unlock"]
|
||||
assert [a.key for a in screen._info_actions] == ["timemachine", "unlock", "remove"]
|
||||
@@ -0,0 +1,68 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""``ui/oklab.py`` — the perceptual colour space the seam and its shade are measured in."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from meshterm.ui import oklab
|
||||
from meshterm.ui.theme import _node_style_spectrum
|
||||
|
||||
|
||||
def _fill(key: str) -> str:
|
||||
return _node_style_spectrum(key).split()[-1]
|
||||
|
||||
|
||||
def test_every_node_fill_round_trips_through_oklab_exactly() -> None:
|
||||
"""Hex → Lab → hex is lossless on all 256 spectrum fills and the fixed greys."""
|
||||
for byte in range(256):
|
||||
colour = _fill(f"{byte:02x}")
|
||||
assert oklab.to_hex(oklab.from_hex(colour)) == colour
|
||||
for grey in ("#000000", "#ffffff", "#64748b", "#334155", "#3f3f46"):
|
||||
assert oklab.to_hex(oklab.from_hex(grey)) == grey
|
||||
|
||||
|
||||
def test_lightness_runs_black_to_white_and_distance_is_a_metric() -> None:
|
||||
"""``L`` spans 0–1, black to white is distance 1, and distance is symmetric."""
|
||||
black, white = oklab.from_hex("#000000"), oklab.from_hex("#ffffff")
|
||||
assert black[0] == pytest.approx(0.0, abs=1e-6) and white[0] == pytest.approx(1.0, abs=1e-3)
|
||||
assert oklab.distance("#000000", "#ffffff") == pytest.approx(1.0, abs=1e-3)
|
||||
assert oklab.distance("#f25555", "#f25555") == 0.0
|
||||
assert oklab.distance("#f25555", "#5557f2") == oklab.distance("#5557f2", "#f25555")
|
||||
|
||||
|
||||
def test_the_wheel_is_not_uniform_to_the_eye() -> None:
|
||||
"""The reason this module exists: one hue step is a whisper in green, a shout in cyan."""
|
||||
green = oklab.distance(_fill("4c"), _fill("5c"))
|
||||
cyan = oklab.distance(_fill("80"), _fill("90"))
|
||||
assert green < 0.03 < 0.15 < cyan
|
||||
|
||||
|
||||
def test_shaded_moves_lightness_alone_away_from_the_nearer_end() -> None:
|
||||
"""A light colour comes back darker, a dark one lighter, chroma untouched."""
|
||||
light, dark = "#55f255", "#334155"
|
||||
for colour, sign in ((light, -1), (dark, +1)):
|
||||
before, after = oklab.from_hex(colour), oklab.from_hex(oklab.shaded(colour, 0.1))
|
||||
assert (after[0] - before[0]) * sign == pytest.approx(0.1, abs=0.01)
|
||||
assert after[1] == pytest.approx(before[1], abs=0.02) # in-gamut: chroma kept
|
||||
assert after[2] == pytest.approx(before[2], abs=0.02)
|
||||
|
||||
|
||||
def test_shaded_floor_clears_a_second_colour_too() -> None:
|
||||
"""With a floor, the shift starts from whichever lightness is further along."""
|
||||
a, b = _fill("4c"), _fill("5c") # b is the darker of two near greens
|
||||
plain = oklab.shaded(a, 0.15)
|
||||
floored = oklab.shaded(a, 0.15, floor=oklab.from_hex(b)[0])
|
||||
assert oklab.from_hex(floored)[0] <= oklab.from_hex(plain)[0]
|
||||
assert oklab.from_hex(b)[0] - oklab.from_hex(floored)[0] == pytest.approx(0.15, abs=0.01)
|
||||
|
||||
slate, lighter_slate = "#334155", "#3f3f46" # dark: the floor pushes *up* instead
|
||||
floored = oklab.shaded(slate, 0.15, floor=oklab.from_hex(lighter_slate)[0])
|
||||
assert oklab.from_hex(floored)[0] >= oklab.from_hex(oklab.shaded(slate, 0.15))[0]
|
||||
|
||||
|
||||
def test_from_hex_rejects_anything_but_six_digits() -> None:
|
||||
"""Six hex digits, ``#`` and case optional; anything else is a ``ValueError``."""
|
||||
with pytest.raises(ValueError):
|
||||
oklab.from_hex("#fff")
|
||||
assert oklab.from_hex("F25555") == oklab.from_hex("#f25555")
|
||||
+65
-18
@@ -14,7 +14,9 @@ import random
|
||||
import pytest
|
||||
|
||||
import meshterm.ui.pathline as pathline
|
||||
from meshterm.ui import oklab
|
||||
from meshterm.ui.pathline import (
|
||||
_DIM_BG,
|
||||
_SELF_INK,
|
||||
_YOU_BG,
|
||||
CRACK_HEAD,
|
||||
@@ -26,10 +28,14 @@ from meshterm.ui.pathline import (
|
||||
POWERLINE_ROUND_CLOSE,
|
||||
POWERLINE_ROUND_OPEN,
|
||||
POWERLINE_SEP,
|
||||
POWERLINE_THIN,
|
||||
SEAM_BLUR,
|
||||
SEAM_SHADE,
|
||||
SELF_GLYPH,
|
||||
WRAP_OFFSET,
|
||||
PathHop,
|
||||
PathLine,
|
||||
_seam_ink,
|
||||
_style_hex,
|
||||
cut_mark,
|
||||
cut_to,
|
||||
@@ -147,7 +153,7 @@ def test_auto_mode_follows_the_terminal_verdict(monkeypatch: pytest.MonkeyPatch)
|
||||
"""``auto`` renders chips exactly when the terminal can draw them."""
|
||||
hops = [PathHop("a"), PathHop("b")]
|
||||
monkeypatch.setattr(pathline, "powerline_enabled", lambda: True)
|
||||
assert POWERLINE_SEP in PathLine(hops).text().plain
|
||||
assert POWERLINE_THIN in PathLine(hops).text().plain # two keyless greys: the thin seam
|
||||
monkeypatch.setattr(pathline, "powerline_enabled", lambda: False)
|
||||
assert PathLine(hops).text().plain == "a → b"
|
||||
|
||||
@@ -639,31 +645,72 @@ def test_a_wrapped_route_middle_pays_for_both_its_chevrons() -> None:
|
||||
assert not folded[-1].plain.strip().startswith("→") # a continuation opens on its hop
|
||||
|
||||
|
||||
def test_a_seam_between_two_of_one_colour_falls_back_to_the_page() -> None:
|
||||
"""Where two chips land on the same fill, the seam falls back to the bare page.
|
||||
def _seams(*hops: PathHop) -> list[tuple[str, str]]:
|
||||
"""Every seam cell of a chip line as ``(glyph, style)``, in order."""
|
||||
text = PathLine(list(hops), mode="powerline").text()
|
||||
return [
|
||||
(text.plain[s.start], str(s.style))
|
||||
for s in text.spans
|
||||
if text.plain[s.start] in (POWERLINE_SEP, POWERLINE_THIN)
|
||||
]
|
||||
|
||||
The interlock can't draw a chevron in its own background, so the point drops its
|
||||
background and the page cuts the wedge instead.
|
||||
|
||||
def test_a_seam_between_two_of_one_colour_is_the_thin_chevron_shaded() -> None:
|
||||
"""Where two chips land on the same fill, the seam is the thin chevron in that fill, shaded.
|
||||
|
||||
The interlock can't draw a solid point in its own background, so the join is drawn
|
||||
*on* the shared fill instead — powerline's own mark for a join inside one colour —
|
||||
and the ribbon runs on unbroken. Its colour is the previous chip's own, a lightness
|
||||
step darker, so nothing on the line is a colour that isn't a chip's; a dark fill (the
|
||||
faded slate) steps lighter instead.
|
||||
|
||||
Not a rare accident, either: a mirrored return leg is a run of identically faded hops
|
||||
by construction, and a stretch of keyless hops shares one grey. Either way it stays a
|
||||
single cell — the fallback trades the blend for the separation, not for width.
|
||||
single cell — the fallback trades the interlock for the outline, not for width.
|
||||
"""
|
||||
hue = _style_hex(node_style("aa"))
|
||||
same = PathLine([PathHop("A", key="aa"), PathHop("B", key="aa")], mode="powerline")
|
||||
text = same.text()
|
||||
seam = next(s for s in text.spans if text.plain[s.start] == POWERLINE_SEP)
|
||||
assert str(seam.style) == hue # foreground only: the page shows through the wedge
|
||||
glyph, style = _seams(PathHop("A", key="aa"), PathHop("B", key="aa"))[0]
|
||||
assert glyph == POWERLINE_THIN and style == f"{_seam_ink(hue, hue)} on {hue}"
|
||||
shade = _seam_ink(hue, hue)
|
||||
assert oklab.from_hex(shade)[0] < oklab.from_hex(hue)[0] # darker…
|
||||
assert oklab.distance(shade, hue) == pytest.approx(SEAM_SHADE, abs=0.01) # …by the step
|
||||
assert oklab.distance(shade, hue) < oklab.distance(hue, _style_hex(node_style("77")))
|
||||
|
||||
apart = PathLine([PathHop("A", key="aa"), PathHop("B", key="77")], mode="powerline")
|
||||
text = apart.text()
|
||||
seams = [str(s.style) for s in text.spans if text.plain[s.start] == POWERLINE_SEP]
|
||||
assert seams[0] == f"{hue} on {_style_hex(node_style('77'))}" # blended, one cell
|
||||
assert _seams(PathHop("A", key="aa"), PathHop("B", key="77"))[0] == (
|
||||
POWERLINE_SEP,
|
||||
f"{hue} on {_style_hex(node_style('77'))}",
|
||||
) # blended, one cell
|
||||
|
||||
dimmed = PathLine([PathHop("A", dim=True), PathHop("B", dim=True)], mode="powerline")
|
||||
text = dimmed.text()
|
||||
gaps = [str(s.style) for s in text.spans if text.plain[s.start] == POWERLINE_SEP]
|
||||
assert " on " not in gaps[0] # a dimmed return leg reads as hops, not one bar
|
||||
glyph, style = _seams(PathHop("A", dim=True), PathHop("B", dim=True))[0]
|
||||
assert glyph == POWERLINE_THIN and style.endswith(f" on {_DIM_BG}")
|
||||
lighter = style.split()[0]
|
||||
assert oklab.from_hex(lighter)[0] > oklab.from_hex(_DIM_BG)[0] # a dark fill steps up
|
||||
|
||||
|
||||
def test_a_seam_blurs_by_perceived_distance_not_by_hue_gap() -> None:
|
||||
"""Two fills under ``SEAM_BLUR`` apart to the eye blur like an exact match.
|
||||
|
||||
The eye, not the wheel: sixteen first-byte steps across the greens are one colour to
|
||||
a reader and sixteen across the reds are two, so the same hue gap blurs on one side
|
||||
of the wheel and interlocks on the other. The greys follow the same metric with no
|
||||
exemption: the keyless grey and the faded slate sit well apart and keep their seam.
|
||||
"""
|
||||
green = _seams(PathHop("A", key="4c"), PathHop("B", key="5c"))[0]
|
||||
red = _seams(PathHop("A", key="00"), PathHop("B", key="10"))[0]
|
||||
assert green[0] == POWERLINE_THIN and red[0] == POWERLINE_SEP
|
||||
assert oklab.distance(_style_hex(node_style("4c")), _style_hex(node_style("5c"))) < SEAM_BLUR
|
||||
assert oklab.distance(_style_hex(node_style("00")), _style_hex(node_style("10"))) > SEAM_BLUR
|
||||
|
||||
# The shade is taken from the chip behind but clears the chip ahead as well.
|
||||
before, after = _style_hex(node_style("4c")), _style_hex(node_style("5c"))
|
||||
assert green[1] == f"{_seam_ink(before, after)} on {after}"
|
||||
assert oklab.distance(_seam_ink(before, after), after) >= SEAM_SHADE - 1e-6
|
||||
|
||||
wrapped = _seams(PathHop("A", key="fc"), PathHop("B", key="02"))[0]
|
||||
assert wrapped[0] == POWERLINE_THIN # neighbours across the wheel's seam, too
|
||||
|
||||
glyph, style = _seams(PathHop("A"), PathHop("B", dim=True))[0]
|
||||
assert glyph == POWERLINE_SEP and " on " in style # two greys, still interlocked
|
||||
|
||||
|
||||
def test_bare_self_stands_us_on_a_star_and_fades_both_ends() -> None:
|
||||
|
||||
@@ -377,14 +377,14 @@ def test_host_battery_reads_the_sysfs_supply(tmp_path, monkeypatch) -> None:
|
||||
assert service.reading() is None # unreadable supply = absent, header draws nothing
|
||||
|
||||
|
||||
def test_dialog_gate_shrinks_on_picocalc() -> None:
|
||||
"""A dialog is allowed fewer cells on the PicoCalc, the screen being narrower."""
|
||||
from meshterm.ui.surface import _dialog_max_cells
|
||||
def test_dialog_wrap_shrinks_on_picocalc() -> None:
|
||||
"""A dialog message wraps to fewer cells on the PicoCalc, the screen being narrower."""
|
||||
from meshterm.ui.surface import _dialog_wrap_cells
|
||||
|
||||
set_platform(PICOCALC)
|
||||
assert _dialog_max_cells() == 43
|
||||
assert _dialog_wrap_cells() == 37 # 53 readable - 4 margin - 12 chrome
|
||||
set_platform(REGULAR)
|
||||
assert _dialog_max_cells() == 76
|
||||
assert _dialog_wrap_cells() == 54 # 72 readable - 6 margin - 12 chrome
|
||||
|
||||
|
||||
# --- the dialog border's hint, against the lane one row below it ------------------------
|
||||
|
||||
@@ -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"
|
||||
@@ -52,6 +52,24 @@ def test_session_wraps_the_real_terminal_on_regular(monkeypatch) -> None:
|
||||
assert out.get_size() == Size(rows=30, columns=101)
|
||||
|
||||
|
||||
def test_session_pins_the_real_terminal_inside_the_width_extension(monkeypatch) -> None:
|
||||
"""Both wraps on REGULAR, reclaim outermost: its size is what everything lays out against."""
|
||||
from meshterm.ui.tui.colsnap import PinnedOutput
|
||||
|
||||
fake = SimpleNamespace(get_size=lambda: Size(rows=30, columns=100))
|
||||
monkeypatch.setattr("prompt_toolkit.output.defaults.create_output", lambda: fake)
|
||||
monkeypatch.delenv("MESHTERM_FULL_WIDTH", raising=False)
|
||||
monkeypatch.delenv("MESHTERM_COLUMN_SNAP", raising=False)
|
||||
out = TuiSession()._resolve_output()
|
||||
assert isinstance(out, _WidthExtendedOutput)
|
||||
assert isinstance(out._inner, PinnedOutput) and out._inner._inner is fake
|
||||
|
||||
# Pinning alone, where the column is not reclaimed.
|
||||
monkeypatch.setenv("MESHTERM_FULL_WIDTH", "0")
|
||||
pinned = TuiSession()._resolve_output()
|
||||
assert isinstance(pinned, PinnedOutput) and pinned._inner is fake
|
||||
|
||||
|
||||
def test_session_leaves_the_bare_terminal_on_picocalc(monkeypatch) -> None:
|
||||
"""PICOCALC's exact-width console → no reclaim: a phantom column would tear the frame."""
|
||||
monkeypatch.delenv("MESHTERM_FULL_WIDTH", raising=False)
|
||||
@@ -69,7 +87,19 @@ def test_env_override_forces_reclaim_on_despite_picocalc(monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_env_override_forces_reclaim_off_despite_regular(monkeypatch) -> None:
|
||||
"""``MESHTERM_FULL_WIDTH=0`` wins over REGULAR's on-by-default."""
|
||||
"""``MESHTERM_FULL_WIDTH=0`` wins over REGULAR's on-by-default.
|
||||
|
||||
The terminal is still wrapped for pinning, which has a gate of its own; with that turned
|
||||
off too there is nothing left to wrap, and prompt_toolkit builds its own output.
|
||||
"""
|
||||
fake = SimpleNamespace(get_size=lambda: Size(rows=30, columns=100))
|
||||
monkeypatch.setattr("prompt_toolkit.output.defaults.create_output", lambda: fake)
|
||||
monkeypatch.setenv("MESHTERM_FULL_WIDTH", "0")
|
||||
monkeypatch.delenv("MESHTERM_COLUMN_SNAP", raising=False)
|
||||
set_platform(REGULAR)
|
||||
out = TuiSession()._resolve_output()
|
||||
assert not isinstance(out, _WidthExtendedOutput)
|
||||
assert out.get_size() == Size(rows=30, columns=100) # no column reclaimed
|
||||
|
||||
monkeypatch.setenv("MESHTERM_COLUMN_SNAP", "0")
|
||||
assert TuiSession()._resolve_output() is None
|
||||
+128
-48
@@ -6,6 +6,7 @@ These run without hardware against the :class:`MockDevice` simulator.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -699,19 +700,23 @@ class _Event:
|
||||
|
||||
|
||||
def _mc_with(contacts: dict, path_hash_mode: int = 2):
|
||||
"""Build a minimal mock ``MeshCore`` client exposing the trace commands used."""
|
||||
"""Build a minimal mock ``MeshCore`` client exposing the trace commands used.
|
||||
|
||||
class _Commands:
|
||||
async def get_contacts(self) -> _Event:
|
||||
return _Event(contacts)
|
||||
The contacts arrive the way the radio sends them — one record frame each, then the
|
||||
closing frame holding the whole table (see :class:`_FakeMeshCore`).
|
||||
"""
|
||||
from meshcore import EventType
|
||||
|
||||
async def get_path_hash_mode(self) -> int:
|
||||
return path_hash_mode
|
||||
mc = _FakeMeshCore(
|
||||
[(EventType.NEXT_CONTACT, info) for info in contacts.values()]
|
||||
+ [(EventType.CONTACTS, contacts)]
|
||||
)
|
||||
|
||||
class _MC:
|
||||
commands = _Commands()
|
||||
async def get_path_hash_mode() -> int:
|
||||
return path_hash_mode
|
||||
|
||||
return _MC()
|
||||
mc.commands.get_path_hash_mode = get_path_hash_mode
|
||||
return mc
|
||||
|
||||
|
||||
async def test_contact_with_future_advert_stamp_reads_as_never_heard() -> None:
|
||||
@@ -831,59 +836,134 @@ async def test_unknown_contact_resolves_to_none() -> None:
|
||||
assert resolved is None
|
||||
|
||||
|
||||
async def test_contacts_payload_retries_transient_error() -> None:
|
||||
"""A transient 'no event received' on contacts retrieval is retried, not fatal."""
|
||||
class _FakeSubscription:
|
||||
"""One :meth:`_FakeMeshCore.subscribe` registration, undone by ``unsubscribe``."""
|
||||
|
||||
def __init__(self, bus: dict, event_type, callback) -> None: # noqa: ANN001
|
||||
"""Register ``callback`` for ``event_type`` on ``bus``."""
|
||||
self._bus = bus
|
||||
self._key = event_type
|
||||
self._callback = callback
|
||||
bus.setdefault(event_type, []).append(callback)
|
||||
|
||||
def unsubscribe(self) -> None:
|
||||
"""Drop the registration, as the library's subscription does."""
|
||||
self._bus.get(self._key, []).remove(self._callback)
|
||||
|
||||
|
||||
class _FakeMeshCore:
|
||||
"""A companion that answers a contacts request with a scripted run of frames.
|
||||
|
||||
Each script entry is one ``(EventType, payload)`` frame dispatched to whoever
|
||||
subscribed to it, so a test can stream records, close the dump, refuse it outright,
|
||||
or simply say nothing at all.
|
||||
"""
|
||||
|
||||
def __init__(self, *scripts) -> None:
|
||||
"""Take one frame script per contacts request, answered in order."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
self._bus: dict = {}
|
||||
self._scripts = list(scripts)
|
||||
self.requests = 0
|
||||
self.commands = SimpleNamespace(get_contacts_async=self._request)
|
||||
|
||||
def subscribe(self, event_type, callback, attribute_filters=None): # noqa: ANN001, ANN201
|
||||
"""Register a listener, returning something that can unsubscribe it."""
|
||||
return _FakeSubscription(self._bus, event_type, callback)
|
||||
|
||||
async def _request(self, lastmod: int = 0) -> None:
|
||||
"""Answer the next scripted run of frames."""
|
||||
from meshcore.events import Event
|
||||
|
||||
self.requests += 1
|
||||
script = self._scripts.pop(0) if self._scripts else []
|
||||
for event_type, payload in script:
|
||||
for callback in list(self._bus.get(event_type, [])):
|
||||
callback(Event(event_type, payload))
|
||||
|
||||
|
||||
async def test_contacts_payload_retries_a_refused_read() -> None:
|
||||
"""A companion that refuses the dump outright is retried, not surfaced."""
|
||||
from meshcore import EventType
|
||||
|
||||
from meshterm.core.connection import MeshCoreDevice
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
class _Err:
|
||||
payload = {"reason": "no event received during contacts retrieval"}
|
||||
|
||||
def is_error(self) -> bool:
|
||||
return True
|
||||
|
||||
class _Ok:
|
||||
payload = {"Repeater": {"adv_name": "Repeater", "public_key": "aabbcc" + "00" * 29}}
|
||||
|
||||
def is_error(self) -> bool:
|
||||
return False
|
||||
|
||||
class _Commands:
|
||||
async def get_contacts(self): # noqa: ANN202
|
||||
calls["n"] += 1
|
||||
return _Err() if calls["n"] < 3 else _Ok()
|
||||
|
||||
class _MC:
|
||||
commands = _Commands()
|
||||
record = {"adv_name": "Repeater", "public_key": "aabbcc" + "00" * 29}
|
||||
mc = _FakeMeshCore(
|
||||
[(EventType.ERROR, {"reason": "ERR_CODE_BAD_STATE"})],
|
||||
[(EventType.ERROR, {"reason": "ERR_CODE_BAD_STATE"})],
|
||||
[(EventType.NEXT_CONTACT, record), (EventType.CONTACTS, {"Repeater": record})],
|
||||
)
|
||||
|
||||
device = MeshCoreDevice(port="COM-test")
|
||||
payload = await device._contacts_payload(_MC(), retries=3, delay=0)
|
||||
payload = await device._contacts_payload(mc, retries=3, delay=0, idle=0.05)
|
||||
assert "Repeater" in payload
|
||||
assert calls["n"] == 3 # failed twice, succeeded on the third attempt
|
||||
assert mc.requests == 3 # refused twice, served on the third attempt
|
||||
|
||||
|
||||
async def test_contacts_payload_waits_out_a_slow_dump() -> None:
|
||||
"""A table that streams for longer than any fixed deadline still completes.
|
||||
|
||||
The idle clock restarts on every record, so the only thing that ends a read is the
|
||||
radio going quiet — not how long the whole dump took.
|
||||
"""
|
||||
from meshcore import EventType
|
||||
from meshcore.events import Event
|
||||
|
||||
from meshterm.core.connection import MeshCoreDevice
|
||||
|
||||
class _SlowMeshCore(_FakeMeshCore):
|
||||
async def _request(self, lastmod: int = 0) -> None:
|
||||
self.requests += 1
|
||||
table = {}
|
||||
for index in range(8):
|
||||
await asyncio.sleep(0.04) # each gap is inside the idle window
|
||||
record = {"adv_name": f"Node{index}", "public_key": f"{index:02x}" + "00" * 31}
|
||||
table[record["adv_name"]] = record
|
||||
for callback in list(self._bus.get(EventType.NEXT_CONTACT, [])):
|
||||
callback(Event(EventType.NEXT_CONTACT, record))
|
||||
for callback in list(self._bus.get(EventType.CONTACTS, [])):
|
||||
callback(Event(EventType.CONTACTS, table))
|
||||
|
||||
mc = _SlowMeshCore()
|
||||
device = MeshCoreDevice(port="COM-test")
|
||||
payload = await device._contacts_payload(mc, retries=0, delay=0, idle=0.1)
|
||||
assert len(payload) == 8 # 0.32 s of streaming under a 0.1 s idle window
|
||||
|
||||
|
||||
async def test_contacts_payload_ignores_an_error_meant_for_another_command() -> None:
|
||||
"""An uncorrelated ERROR mid-dump belongs to something else and never ends the read."""
|
||||
from meshcore import EventType
|
||||
|
||||
from meshterm.core.connection import MeshCoreDevice
|
||||
|
||||
record = {"adv_name": "Repeater", "public_key": "aabbcc" + "00" * 29}
|
||||
mc = _FakeMeshCore(
|
||||
[
|
||||
(EventType.NEXT_CONTACT, record),
|
||||
(EventType.ERROR, {"reason": "ERR_CODE_BAD_STATE"}), # a battery poll's, not ours
|
||||
(EventType.CONTACTS, {"Repeater": record}),
|
||||
]
|
||||
)
|
||||
|
||||
device = MeshCoreDevice(port="COM-test")
|
||||
payload = await device._contacts_payload(mc, retries=0, delay=0, idle=0.05)
|
||||
assert "Repeater" in payload
|
||||
assert mc.requests == 1
|
||||
|
||||
|
||||
async def test_contacts_payload_raises_clean_error_after_retries() -> None:
|
||||
"""Persistent contacts-retrieval failure raises a clean, actionable error."""
|
||||
"""A companion that says nothing at all raises a clean, actionable error."""
|
||||
from meshterm.core.connection import DeviceCommandError, MeshCoreDevice
|
||||
|
||||
class _Err:
|
||||
payload = {"reason": "no event received during contacts retrieval"}
|
||||
|
||||
def is_error(self) -> bool:
|
||||
return True
|
||||
|
||||
class _Commands:
|
||||
async def get_contacts(self): # noqa: ANN202
|
||||
return _Err()
|
||||
|
||||
class _MC:
|
||||
commands = _Commands()
|
||||
mc = _FakeMeshCore() # every request answered with silence
|
||||
|
||||
device = MeshCoreDevice(port="COM-test")
|
||||
with pytest.raises(DeviceCommandError) as exc:
|
||||
await device._contacts_payload(_MC(), retries=2, delay=0)
|
||||
await device._contacts_payload(mc, retries=2, delay=0, idle=0.05)
|
||||
assert "no event received" in str(exc.value)
|
||||
assert mc.requests == 3
|
||||
|
||||
|
||||
async def test_latest_trace_returns_previous_run(tmp_path: Path) -> None:
|
||||
|
||||
+3
-3
@@ -17,7 +17,7 @@ def test_spdx_headers():
|
||||
python_files = []
|
||||
|
||||
# Collect all Python files from the required directories
|
||||
for directory in ["meshterm", "tests", "packaging", "scripts/xiao-radio"]:
|
||||
for directory in ["meshterm", "tests", "packaging", "scripts/picocalc/xiao-radio"]:
|
||||
dir_path = repo_root / directory
|
||||
if dir_path.exists():
|
||||
if directory == "packaging":
|
||||
@@ -27,8 +27,8 @@ def test_spdx_headers():
|
||||
else:
|
||||
python_files.extend(dir_path.rglob("*.py"))
|
||||
|
||||
# Also check the extensionless scripts/meshterm-spi-bridge script
|
||||
spi_bridge = repo_root / "scripts" / "meshterm-spi-bridge"
|
||||
# Also check the extensionless scripts/uconsole/meshterm-spi-bridge script
|
||||
spi_bridge = repo_root / "scripts" / "uconsole" / "meshterm-spi-bridge"
|
||||
if spi_bridge.exists():
|
||||
python_files.append(spi_bridge)
|
||||
|
||||
|
||||
+20
-4
@@ -15,7 +15,7 @@ from typing import Any
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from meshterm.ui.surface import _DIALOG_MAX_CELLS, TuiUi, _collapse_to_message
|
||||
from meshterm.ui.surface import _DIALOG_MAX_WRAPPED, TuiUi, _collapse_to_message
|
||||
from meshterm.ui.tui.session import TuiSession, _message_border
|
||||
|
||||
# -- the collapse gate ------------------------------------------------------------
|
||||
@@ -43,9 +43,25 @@ def test_collapse_rejects_tall_output() -> None:
|
||||
assert _collapse_to_message([Text(f"line {i}") for i in range(4)]) is None
|
||||
|
||||
|
||||
def test_collapse_rejects_wide_lines() -> None:
|
||||
"""A line too wide for the popup (a long path, say) falls back to the window."""
|
||||
assert _collapse_to_message([Text("x" * (_DIALOG_MAX_CELLS + 1))]) is None
|
||||
def test_collapse_wraps_a_wide_line_instead_of_rejecting_it() -> None:
|
||||
"""A long one-line outcome stays a popup; the box wraps it rather than passing it on.
|
||||
|
||||
This is what keeps a failure — "✗ Chat failed: could not read contacts …" — an
|
||||
acknowledgement to dismiss instead of a full-frame reading surface.
|
||||
"""
|
||||
sentence = "could not read contacts from the radio. " * 3
|
||||
message = _collapse_to_message([Text(sentence)])
|
||||
assert message is not None
|
||||
lines = message.plain.splitlines()
|
||||
assert len(lines) > 1 # it was re-flowed
|
||||
assert max(len(line) for line in lines) <= 54 # the regular platform's wrap width
|
||||
assert " ".join(line.strip() for line in lines) == sentence.strip()
|
||||
|
||||
|
||||
def test_collapse_rejects_a_line_long_enough_to_be_a_page() -> None:
|
||||
"""Wrapping has a ceiling: past it the output is reading, and the window takes it."""
|
||||
assert _collapse_to_message([Text("word " * 120)]) is None
|
||||
assert _DIALOG_MAX_WRAPPED == 8
|
||||
|
||||
|
||||
def test_collapse_rejects_non_text_renderables() -> None:
|
||||
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user