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,141 +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:
|
||||
|
||||
d:\\vibe\\MeshTerm\\.venv\\Scripts\\python.exe .claude\\skills\\add-narrow-emoji\\classify.py 👋
|
||||
|
||||
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))
|
||||
@@ -96,6 +96,24 @@ version is written**. `[project] version` in `pyproject.toml` is `dynamic` and h
|
||||
reads the attribute, so the number in a built wheel cannot drift from the number in the
|
||||
source. Do not add a second one.
|
||||
|
||||
### Then re-point the README's download commands
|
||||
|
||||
`installers.yml` names each asset `meshterm-${version}-<label><ext>`, so **the file a reader
|
||||
downloads is named after the release** — and the README tells them what to type at it. Those
|
||||
lines are prose, not code, so nothing rewrites them for you:
|
||||
|
||||
```
|
||||
grep -n '<the previous version>' README.md
|
||||
```
|
||||
|
||||
Every hit is a download or run command (`.\meshterm-X.Y.Z-windows-x64.exe`,
|
||||
`chmod +x meshterm-X.Y.Z-*`, the `xattr` quarantine note, the `MESHTERM_HOME` trial runs).
|
||||
Replace the old number with the one just bumped to, and re-read each line: they must name
|
||||
the file the workflow is about to publish, or the first thing a new user does fails.
|
||||
|
||||
Also check `docs/cli.md` for a sample document carrying a `"version"` field, which is the
|
||||
live `{version}` placeholder and must show the new number too.
|
||||
|
||||
## 5. Commit, tag, push
|
||||
|
||||
```
|
||||
@@ -121,7 +139,11 @@ gh run watch --exit-status
|
||||
gh release view vX.Y.Z
|
||||
```
|
||||
|
||||
Five platform builds plus the release job, so it takes a few minutes. **Check the notes
|
||||
Five platform builds, the release job, and `website` — which rebuilds meshterm.net from the
|
||||
tag so the site shows the new version — so it takes a few minutes. If only `website` fails,
|
||||
the release itself is fine (it runs after publishing); the usual cause is the
|
||||
`SITE_DEPLOY_KEY` secret, and once that is fixed `gh run rerun <run-id> --failed` finishes
|
||||
it. **Check the notes
|
||||
actually came out of the changelog** — a green run is not proof, because the fallback is a
|
||||
successful step:
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
## Before you mark it ready
|
||||
|
||||
- [ ] I've read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) and agree its licensing terms apply to this contribution
|
||||
- [ ] `python -m pytest -q` passes, including the dual-platform gallery gate
|
||||
- [ ] `ruff check .` and `ruff format --check .` are clean
|
||||
- [ ] New screens, rows and hints go through the helpers in `ui/menus.py`, and follow the
|
||||
|
||||
@@ -18,7 +18,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
# A second push while the first is still running makes the first answer obsolete.
|
||||
# Cancel it — on a private repo those are metered minutes spent on a stale commit.
|
||||
# Cancel it — a run on a stale commit is a queue slot and a wait spent on nothing.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -54,13 +54,11 @@ jobs:
|
||||
# and one red leg cancelling its siblings hides the rest until the next push.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Shaped for a *private* repo, where minutes are metered and billed by a
|
||||
# multiplier: Linux 1x, Windows 2x, macOS 10x. So Linux carries the full
|
||||
# Python range and the costly runners cover the ends of it, which is where a
|
||||
# version-dependent break actually shows up.
|
||||
#
|
||||
# Once the repo is public, standard runners are unmetered: widen this to every
|
||||
# OS times every version and stop thinking about it.
|
||||
# Linux carries the full Python range and the slower-to-schedule runners
|
||||
# cover the ends of it, which is where a version-dependent break actually
|
||||
# shows up. It was shaped this way when the repository was private and
|
||||
# minutes were metered; on a public one they are not, so this can widen to
|
||||
# every OS times every version whenever the wait for a full run is worth it.
|
||||
include:
|
||||
- { os: ubuntu-latest, python: "3.10" }
|
||||
- { os: ubuntu-latest, python: "3.11" }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Puts the downloadable builds on the repository's Releases page.
|
||||
#
|
||||
# Nothing here leaves GitHub. While the repository is private the release is private too,
|
||||
# visible and downloadable only by people who can see the repo. This is *not* the PyPI
|
||||
# publish — that is `release.yml`, it is a separate act, and it does not run from here.
|
||||
# Nothing here leaves GitHub: a release is exactly as visible as the repository it sits
|
||||
# on. This is *not* the PyPI publish — that is `release.yml`, it is a separate act, and it
|
||||
# does not run from here.
|
||||
#
|
||||
# Triggered by pushing a version tag, which is the moment a release exists in git anyway:
|
||||
#
|
||||
@@ -11,6 +11,11 @@
|
||||
#
|
||||
# The binaries are built by `installers.yml`, called rather than copied, so the files
|
||||
# people download are made exactly the way the ones CI checks on every push are.
|
||||
#
|
||||
# Once the release exists, `website` puts it on meshterm.net: it rebuilds that site from
|
||||
# the tagged tree — the landing and About pages carry the version — and pushes the result
|
||||
# to jpmartineau/meshterm.net, which GitHub Pages serves. It writes with a deploy key
|
||||
# scoped to that one repository (secret SITE_DEPLOY_KEY); that repo's README makes one.
|
||||
name: GitHub release
|
||||
|
||||
on:
|
||||
@@ -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
|
||||
@@ -14,12 +14,12 @@ on:
|
||||
# Callable, so the release workflow builds these binaries rather than keeping a second
|
||||
# copy of the matrix that would drift from this one.
|
||||
workflow_call:
|
||||
# Only when the packaging itself changes. Three operating systems is not something to
|
||||
# spend on every commit while the repository is private and minutes are metered.
|
||||
# Only when the packaging itself changes. A five-platform build takes the better part
|
||||
# of half an hour, and a commit that touched nothing it freezes has nothing to learn
|
||||
# from one; the release build is what proves the binaries.
|
||||
push:
|
||||
# `main` only. Without this, every Dependabot branch that touches this file — which
|
||||
# is every action bump — starts a five-platform build, and three of those legs are
|
||||
# billed at 2x and 10x.
|
||||
# is every action bump — starts a five-platform build of its own.
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packaging/**"
|
||||
@@ -50,9 +50,9 @@ jobs:
|
||||
# fail, it queues forever and holds the whole run open behind it.)
|
||||
- { os: macos-15-intel, label: macos-x64, ext: "" }
|
||||
- { os: ubuntu-22.04, label: linux-x64, ext: "" }
|
||||
# For the uConsole and other 64-bit ARM handhelds. ARM runners are free on
|
||||
# public repositories; on a private one this leg may sit unclaimed until the
|
||||
# repo is public. Drop the line if it blocks you.
|
||||
# For the uConsole and other 64-bit ARM handhelds. The ARM runners are the
|
||||
# public-repository ones; a private fork of this file may find the leg sits
|
||||
# unclaimed, and can drop the line.
|
||||
- { os: ubuntu-22.04-arm, label: linux-arm64, ext: "" }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -69,6 +69,37 @@ jobs:
|
||||
working-directory: packaging
|
||||
run: pyinstaller --clean --noconfirm meshterm.spec
|
||||
|
||||
# `packaging/meshterm.spec` regenerates `THIRD-PARTY-NOTICES.txt` fresh on every
|
||||
# leg's own build (see `packaging/notices.py`) and bundles it, along with LICENSE
|
||||
# and NOTICE, inside that leg's own installer -- so each downloaded binary always
|
||||
# carries an accurate copy of what it actually contains. This step is a separate
|
||||
# convenience: the same three files land on the release page itself, next to the
|
||||
# downloads, so nobody has to extract an installer just to read a license. One
|
||||
# copy serves every platform there (LICENSE/NOTICE never vary, and the runtime
|
||||
# dependency closures differ only in a handful of platform-specific packages), so
|
||||
# it rides on this leg alone rather than being uploaded -- and attached to the
|
||||
# release -- five identical-in-substance times over. linux-x64 was picked
|
||||
# arbitrarily among the five; nothing here is Linux-specific.
|
||||
- name: Upload the licences for the release page
|
||||
if: matrix.label == 'linux-x64'
|
||||
shell: bash
|
||||
run: |
|
||||
# `build/<specname>/` is PyInstaller's own workpath layout (see the spec's
|
||||
# comment on `workpath`), not something worth this step knowing by heart
|
||||
# twice -- copy it flat, next to LICENSE and NOTICE, so the artifact (and,
|
||||
# via `find`, the release assets built from it) is three plain files.
|
||||
cp packaging/build/meshterm/THIRD-PARTY-NOTICES.txt .
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: matrix.label == 'linux-x64'
|
||||
with:
|
||||
name: meshterm-licenses
|
||||
path: |
|
||||
LICENSE
|
||||
NOTICE
|
||||
THIRD-PARTY-NOTICES.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Check it actually runs
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -87,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: |
|
||||
|
||||
+4
-3
@@ -12,6 +12,7 @@ dist/
|
||||
# planning that names hardware and unshipped ideas.
|
||||
!.claude/
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/plans/
|
||||
.claude/housekeeping.md
|
||||
# ...and except the GitHub configuration, which only works where GitHub looks for
|
||||
@@ -30,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/
|
||||
+337
-1
@@ -17,6 +17,337 @@ 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
|
||||
|
||||
- **The macOS installers build again.** 0.3.3's release never published: both macOS legs
|
||||
stopped at the new third-party notices step, which refuses to bundle a dependency whose
|
||||
wheel carries no licence text — and on macOS bleak's Bluetooth backend pulls in two such
|
||||
wheels, `pyobjc-core` and `pyobjc-framework-libdispatch`. Their MIT text is vendored
|
||||
beside pyserial's and pywinrt's now, verified against the wheels themselves, and a test
|
||||
checks every vendored path on every platform so a gap that only one runner can hit is
|
||||
caught here first. The 0.3.3 tag stands with no release behind it; this is the same
|
||||
code with its installers.
|
||||
|
||||
## [0.3.3] — 2026-09-13
|
||||
|
||||
### Added
|
||||
|
||||
- **`meshterm --version` answers**, and under `--mock` the `devices` command lists the
|
||||
simulator as the one device and scans nothing — it used to walk the serial ports and
|
||||
switch the Bluetooth radio on, on the first command a stranger without a radio would try.
|
||||
|
||||
- **[`docs/hardware.md`](docs/hardware.md)** is the one manual for putting MeshTerm on a
|
||||
PicoCalc or a uConsole: the Calculinux bring-up, the console font, the XIAO UART radio from
|
||||
parts to a verified link, the SPI bridge, and a closing list of what has only ever been
|
||||
proven on one bench. The two script READMEs are short indexes pointing at it.
|
||||
|
||||
- **The standalone installers carry their own licences now.** Each one-file build
|
||||
bundles `LICENSE`, `NOTICE` and a generated `THIRD-PARTY-NOTICES.txt` — every
|
||||
MIT/BSD/PSF dependency's own licence text, gathered at build time from what that
|
||||
build actually installed — at the root of the archive, and the same three files ride
|
||||
beside the downloads on the GitHub release page.
|
||||
|
||||
- **The map credits OpenStreetMap in its own bottom-right corner.** The full-screen map is
|
||||
arrived at with `© OpenMapTiles · Data from OpenStreetMap` there — the corner a paper map
|
||||
puts it in — and the first pan, zoom, reframe or find keystroke collapses it to
|
||||
`© OpenStreetMap`, which is a complete credit in its own right, so nothing is ever taken
|
||||
back. It costs no chrome at all: no title atom, no footer character, no body row. On a
|
||||
desktop terminal it is set into the panel's bottom border rule, right-justified, the way
|
||||
a title sits in the top one, so the drawing keeps every cell it had; on the PicoCalc,
|
||||
whose frame has no bottom rule to set anything into, it rides the right end of the map's
|
||||
own last row, where a pan can move the ground out from under it. The node page's location
|
||||
preview carries the short form throughout, being a picture rather than a place. The
|
||||
licence and its URL live where they can be read — the About page, which also now names
|
||||
**OpenMapTiles**, as OpenFreeMap asks and neither the page nor the README did.
|
||||
|
||||
- **Every Python file names its licence with an SPDX identifier** on its first line (second, after a shebang), and `tests/test_spdx.py` keeps a new file from landing without one. Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all.
|
||||
|
||||
- **The XIAO firmware patch carries MeshCore's MIT notice beside it** as `scripts/xiao-radio/LICENSE.MeshCore`, and the console-font scripts state that the shared generator block is offered under GPL-2.0-only as well as Apache-2.0, which is what lets the GPL-2.0-only 6×8 script run it.
|
||||
|
||||
- **The SPI radio bridge runs on `openhop_core`.** The library the uConsole's software
|
||||
node runs on was renamed from `pymc_core` in 2026, and the current `meshcore-uconsole`
|
||||
package depends on the new name. `meshterm-spi-bridge` now drives either runtime,
|
||||
preferring the newer, and two of its three compatibility shims stand down under it —
|
||||
openhop_core accepts long ACK payloads and pushes completed trace replies itself; only
|
||||
the raw RX-log push is still the bridge's. On a bookworm uConsole, where the trixie
|
||||
package cannot run, the bridge takes a venv of its own with `openhop-core[hardware]`
|
||||
and still uses the GUI's identity, so it stays the same node. Verified on the device:
|
||||
radio up, 159 contacts restored, `info` and `contacts` answered over TCP.
|
||||
|
||||
- **The PicoCalc picks its console font, and MeshTerm hands the console back.** A *Console
|
||||
font* row under Display on the Preferences page offers `6x12 (53x26)` — the font the
|
||||
device boots in — and `6x8 (53x40)`, fourteen more rows of the same glyph inventory in a
|
||||
shorter cell. Picking one is the preview: the console loads it there and then, and the
|
||||
page repaints at the new height (the kernel raises SIGWINCH on a font change), so the
|
||||
choice is looked at before it is kept. Apply keeps it; leaving and discarding puts the
|
||||
saved font back. The 6×8 base has no Cyrillic, so a node named in Cyrillic draws as
|
||||
boxes on it.
|
||||
|
||||
A virtual terminal has one font for the whole console, so switching MeshTerm's switches
|
||||
the shell's. It saves whatever font was loaded before the first paint and reloads it on
|
||||
the way out — from the session's teardown and from an `atexit` hook, so quitting, an
|
||||
unwind and a crash all leave the console as they found it. Off the handheld, off a real
|
||||
VT, or with no `setfont`, none of it happens and the log says which. The row is drawn on
|
||||
the PicoCalc only, while the value round-trips through `preferences.toml` everywhere.
|
||||
|
||||
The 6×8 font's build moved into `scripts/calculinux-console-font-6x8.sh`, on its own
|
||||
because its base bitmap is the Linux kernel's `font_6x8` and therefore GPL-2.0 — marked
|
||||
as such, with the licence beside it, and reading the donor, alias and keeper tables out
|
||||
of the 6×12 script rather than keeping a second copy of them. Running the 6×12 script
|
||||
still builds both.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Preferences are kept in `preferences.toml`.** The same file as before — only what you
|
||||
have changed, grouped and commented the way the Preferences page reads — in the format
|
||||
`config.toml` and the device-config backups already use, so every file you edit by hand
|
||||
speaks one syntax and MeshTerm no longer needs PyYAML. A `preferences.yaml` from an
|
||||
earlier build is not read: set those preferences again, or copy the lines across as
|
||||
`key = value` with text in quotes. A line TOML refuses costs only that line, and a word
|
||||
left unquoted (`log_level = DEBUG`) is still understood.
|
||||
|
||||
- **A trophy case record is a page, not a card floating over the list.** It fills the frame
|
||||
like the browser under it and takes the node page's shape: a header line that reads as a
|
||||
sentence — the discipline's mark, its standing and its score — over three tabs. *Info* is
|
||||
the record's stats, identity first, with the page's actions at its foot; *Route* is the walk
|
||||
two ways, the route graph and the route line, where Enter traces the path on show; *Area*
|
||||
is the ground the walk covered, drawn across the whole stage with every pin labelled by its
|
||||
hash byte so a pin and the graph node under it match by eye. Tab and Shift+Tab switch, F3 on
|
||||
the PicoCalc. The trophy case itself fills the frame whichever way it is opened, from the
|
||||
menu or from a trace.
|
||||
|
||||
- **A QR code is white on black, and when it is the answer it is the whole screen.** A camera
|
||||
reads contrast, and light-on-dark is what a scanner expects of a screen, so every code
|
||||
MeshTerm draws is pure white on pure black on both themes. A channel's share link and a
|
||||
contact card are no longer a titled popup with an instruction over the code: they are a
|
||||
bare frame with nothing on it but the code and the URL it encodes, and Esc leaves.
|
||||
|
||||
- **A question is a popup, and it is gone before the page it asked for opens.** The trace
|
||||
target, the repeater admin's node and the TX optimize link used to stay pushed as full
|
||||
screens under the page they led to, so Esc out of a trace or an admin page landed back on
|
||||
the list it was picked from — two places where there is one. Each now floats over the main
|
||||
menu, is popped before its page opens, and Esc from the page lands on the menu. The TX
|
||||
optimize link is one box that turns its page (`… · step 1 of 2`): Esc on the second step
|
||||
turns back with the picked node still highlighted. A refused admin login re-asks with the
|
||||
same node highlighted.
|
||||
|
||||
- The three delay settings — TX delay, direct TX delay and RX delay — show, stage and send at
|
||||
one decimal on both the repeater admin and Device config pages; Device config's RX delay
|
||||
had no rounding at all.
|
||||
|
||||
- The source distribution ships the package, its tests and the manual, not
|
||||
the repository: the Claude skills, the GitHub config, the PyInstaller spec and the device
|
||||
scripts are out of it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A share code's bottom-left finder was one cell skewed**, so a camera could not lock on.
|
||||
Rich's centre justify strips a row's trailing spaces before padding it, so a code row whose
|
||||
right edge was light modules landed a column off its neighbours. A code is now moved across
|
||||
a line one way only, the same indent in front of every row.
|
||||
|
||||
## [0.3.2] — 2026-09-11
|
||||
|
||||
### Added
|
||||
@@ -579,7 +910,12 @@ deliberately not reconstructed here.
|
||||
- Two `TYPE_CHECKING` imports the test suite referenced but never imported, on paths that
|
||||
happened never to run.
|
||||
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.2...HEAD
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.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
|
||||
[0.3.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.1
|
||||
[0.3.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.0
|
||||
|
||||
@@ -16,6 +16,26 @@ Commit directly on `main` — do not create topic branches unless explicitly ask
|
||||
is a solo repo; branches just create divergence to merge back later). Commit freely, but
|
||||
never `git push` unless explicitly asked in that moment.
|
||||
|
||||
## Licensing — what every file and every build carries
|
||||
|
||||
- **Every Python file opens with `# SPDX-License-Identifier: Apache-2.0`** (line 2 after a
|
||||
shebang). `tests/test_spdx.py` is the gate. Anything under another licence lives beside
|
||||
its own text and says so in its header — the GPL-2.0-only console-font script, the
|
||||
MeshCore MIT notice beside the XIAO patch — and never enters the distributed package.
|
||||
- **The map credits OpenStreetMap in the map's own bottom-right corner**
|
||||
(`ui/attribution.py`): the full OpenFreeMap line on arrival, `© OpenStreetMap` after the
|
||||
first keystroke, `muted`, on both platforms and in the same words. *Which* corner is the
|
||||
frame's, not the drawing's, wherever the frame has one — **regular** sets the credit into
|
||||
the panel's **bottom border rule**, right-justified, one rule cell before the corner, the
|
||||
way a title sits in the top rule (`Screen.bottom_caption` → `frame._panel_box`), so the
|
||||
map's cells are untouched; **picocalc**, whose frame has no bottom rule at all, stamps it
|
||||
over the right end of the drawing's last row instead, overprinting what it covers. The
|
||||
minimap always stamps. The split is bound once via `platforms.on_platform`, never asked
|
||||
per frame. Never a title atom, never a footer character, never a key. The licence and URL
|
||||
live on the About page.
|
||||
- The name and the wordmark are trademarks reserved in `NOTICE`, which Apache §4(d) makes
|
||||
every fork carry; `NOTICE` holds only what must travel with a redistribution.
|
||||
|
||||
## UX standards
|
||||
|
||||
These are binding. Every new screen, dialog, row, or hint follows them; when you touch an
|
||||
@@ -28,7 +48,7 @@ them instead of hand-rolling:
|
||||
the language below).
|
||||
- `ui/widgets.py` — `highlighted_hash` (THE key widget — shows a key, lights its hash),
|
||||
`format_ago` (prose ages),
|
||||
`_format_age` (column ages), `channel_glyph`, `_NODE_GLYPHS`, heard-age heat colouring.
|
||||
`format_age` (column ages), `channel_glyph`, `NODE_GLYPHS`, heard-age heat colouring.
|
||||
- `ui/theme.py` — `name_style`/`node_style` (per-node hues, hash-derived), `snr_style`,
|
||||
the `you` white.
|
||||
|
||||
@@ -58,14 +78,14 @@ sortable list you pick from is `contactlist.py` (`ContactListScreen`/`ContactRow
|
||||
Setting vs preference — the other boundary, three-way and never blurred: a **setting** is
|
||||
the *radio's* (`core/device_config.py`, read live from the companion, edited on Device
|
||||
config); a **preference** is *MeshTerm's own behaviour* (`core/preferences.py`, defaults in
|
||||
code, overrides in `preferences.yaml`, edited on the Preferences page — `ctx.preferences`,
|
||||
code, overrides in `preferences.toml`, edited on the Preferences page — `ctx.preferences`,
|
||||
or `preferences.current()` where there is no context to reach through); and **config** is
|
||||
machine setup — paths and device profiles (`core/config.py`, `config.toml`, a text editor
|
||||
only). A behaviour value belongs in the preference registry, not as a module constant and
|
||||
not in `config.toml`: a code constant a reader can't reach is a preference nobody has.
|
||||
|
||||
Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now ago"),
|
||||
`_format_age` for aligned columns ("now", "5m").
|
||||
`format_age` for aligned columns ("now", "5m").
|
||||
|
||||
### Navigation — the stack
|
||||
|
||||
@@ -85,11 +105,26 @@ deliberately, one at a time, and say why in the code.
|
||||
refreshes in place with `SelectScreen.replace_items`, which follows the highlighted row
|
||||
by value and keeps the filter. Rebuilding the screen is for when the rows it was holding
|
||||
a place in are genuinely gone (a purge, a delete) — and then say so.
|
||||
- **A popup is not a place.** The stack is for *places* — full-frame screens the reader
|
||||
is *in* (a menu, a map, a list that is the tool's own page, an editor). A popup
|
||||
*informs, confirms, or asks*, and then it is gone: it is never kept pushed as a hub
|
||||
under the screen it was gathering for, so Esc from that screen lands where the tool
|
||||
was launched from, not on a picker. The test is whether the reader is in it or being
|
||||
asked something — a `SelectScreen` can be either (Channels is a place; "node to
|
||||
manage" is a question). A question **draws as a box** even when it is the only frame
|
||||
on the stack (the main menu is popped while a tool runs, and a lone floating screen
|
||||
would otherwise be painted full-frame): `session.select(floating=True)`,
|
||||
`session.text(floating=True)`, `session.stay(screen, dialog=True)` — all through
|
||||
`TuiSession._floated`, which puts a blank base under it.
|
||||
- **An entry chain is a stack too.** A flow that asks two or more things in a row runs
|
||||
through `menus.run_steps`: each step gets the answers so far (to label itself, and to
|
||||
offer its own previous answer as its default) and returns `None` to step back, so Esc
|
||||
undoes one step instead of the whole flow. A trailing Cancel/verb confirm is still a
|
||||
decision, not a step — its Cancel abandons.
|
||||
decision, not a step — its Cancel abandons. Where the steps are *lists to pick from*,
|
||||
the chain is **one popup that turns its page** — `menus.run_wizard` over `WizardPage`s
|
||||
(`SelectScreen.turn_page`; title `Feature — subject · step 1 of 2`), never two popups
|
||||
stacked. Esc on a later page turns back one, with that page's previous answer
|
||||
highlighted; the box is popped before whatever the answers were for opens.
|
||||
- **Esc peels before it leaves.** A screen carrying a find-as-you-type filter treats the
|
||||
typed query as the most recent thing the reader entered: the first Esc clears it and the
|
||||
screen stays, the second leaves. One rule on all four (`SelectScreen` and everything
|
||||
@@ -157,6 +192,21 @@ deliberately, one at a time, and say why in the code.
|
||||
the query in its footer hint draws the body line only where the footer isn't drawn
|
||||
(`Platform.footer_fkeys`), never both: the query must be visible on every platform, and
|
||||
twice on none.
|
||||
- **A QR code is white on black, and when it is the answer it is the whole screen.**
|
||||
Modules are pure white ink on a pure black field whatever theme the terminal runs
|
||||
(`qr._STYLE`, both ends named) — a camera reads contrast, light-on-dark is what a
|
||||
scanner expects of a screen — and never black on white. A share screen
|
||||
(`qr.share_screen` → `qr.QrScreen`: the channel share, the contact card, the CLI's
|
||||
share commands in the menu) is a **bare frame**: `Screen.bare` / `frame.compose_bare`
|
||||
draw the body alone on blank rows — no header, no footer, no title bar, no box, no
|
||||
hint, no F-key lane, no instruction — with nothing on it but the code and the URL it
|
||||
encodes, sat a little above centre; Esc leaves, as everywhere. The code **fits itself
|
||||
to the frame every paint**, width and rows both: a lighter error level, then a
|
||||
narrower quiet zone, before it would ever be cut (a contact card is 57 cells and 29
|
||||
rows at the standard fit; the PicoCalc is 53 across and a regular terminal 24 tall),
|
||||
and when code and URL can't share the frame the code is whole at the top and the URL
|
||||
a page down. The one exception is a ` ```qr ` fence in a written page, where the code
|
||||
is an illustration drawn in the prose where the fence is.
|
||||
|
||||
### Written pages
|
||||
|
||||
@@ -261,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
|
||||
@@ -277,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) ·
|
||||
@@ -399,7 +456,7 @@ splitting safe has gone, because splitting is the machine face's job now.
|
||||
quoting stays and always will (`script._escaped`): a node broadcasts its own name and a
|
||||
stranger fills in a message body, and neither may end the record it sits in.
|
||||
- **A time is an age** — `now`, `5m`, `3h`, `never` (`script.age`, delegating to
|
||||
`widgets._format_age` so the two faces cannot drift). An absolute instant survives where
|
||||
`widgets.format_age` so the two faces cannot drift). An absolute instant survives where
|
||||
the instant *is* the fact (`script.stamp`, `fields.instant`): the device clock, an
|
||||
appointment set with `--at`, a live capture's own `TIME`, every column under `--absolute`.
|
||||
`--absolute` is an override of the `cli_time_format` **preference**, not a switch beside
|
||||
@@ -495,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
|
||||
@@ -506,7 +563,7 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
|
||||
base style into every span, so a silent one changes colour inside a selected row. Same
|
||||
trap outside the theme: `MapCanvas` drops emphasis entirely where bold is brightness,
|
||||
since its colours arrive quantized and it can't know which bank they landed in, and the
|
||||
fold's quantizer (`theme._nearest_slot_sgr`) states intent for it — a dim slot leaves as
|
||||
fold's quantizer (`theme._nearest_slot_params`) states intent for it — a dim slot leaves as
|
||||
`22;3N`, never a bare `3N`, because `9N` is *how* the console spells bright and adjacent
|
||||
art spans (the wordmark's bevels, a raster's neighbouring cells) reset nothing between
|
||||
them, so a bare one inherits the intensity bit and lands a bank too high mid-row.
|
||||
|
||||
@@ -25,6 +25,14 @@ project and is not authorized to do so.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
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/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.
|
||||
|
||||
Bundled font: meshterm/assets/fonts/CascadiaMonoPL.ttf is Cascadia Mono PL,
|
||||
Copyright (c) 2019 - Present, Microsoft Corporation, with Reserved Font Name
|
||||
Cascadia Code. It is redistributed here unmodified under the SIL Open Font
|
||||
|
||||
@@ -26,11 +26,18 @@ your mesh's history is worth.
|
||||
> MeshTerm is a side project, not a product. Bug reports are very welcome — but please
|
||||
> open an issue before writing a pull request. [More on how it's run](#how-this-project-is-run).
|
||||
|
||||
<!--
|
||||
Screenshots welcome here. Drop PNGs into docs/ and reference them, e.g.:
|
||||

|
||||

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