Compare commits
No files matched your search
+7
-24
@@ -1,31 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python -)",
|
||||
"Bash(python -m pytest *)",
|
||||
"Bash(.venv/Scripts/python.exe -m pytest *)",
|
||||
"Bash(./.venv/Scripts/python.exe -m pytest *)",
|
||||
"Bash(python -m ruff check *)",
|
||||
"Bash(python -m ruff format *)",
|
||||
"Bash(ruff check *)",
|
||||
"Bash(ruff format *)",
|
||||
"PowerShell(python -m pytest *)",
|
||||
"PowerShell(.venv\\Scripts\\python.exe -m pytest *)",
|
||||
"PowerShell(.\\.venv\\Scripts\\python.exe -m pytest *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"PowerShell(git add *)",
|
||||
"PowerShell(git commit *)",
|
||||
"Bash(PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -c ' *)",
|
||||
"Bash(python -m pytest tests/test_tui.py -q -k \"progress\")",
|
||||
"Bash(python -m pytest tests/test_tui.py -q -k \"overlay or progress\")",
|
||||
"PowerShell([console]::beep\\(880,150\\); Start-Sleep -Milliseconds 60; [console]::beep\\(1175,200\\))",
|
||||
"Bash(PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -m pytest tests/test_tui.py -q -k \"busy_overlay or scanner or dim_color or overlay_fade\")",
|
||||
"Bash(PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -m pytest -q)",
|
||||
"Bash(python -c ' *)",
|
||||
"Bash(grep -v \"^$\")",
|
||||
"Bash(PYTHONIOENCODING=utf-8 PYTHONUTF8=1 python -c ' *)",
|
||||
"Bash(python -m pytest tests/test_channels.py -q)",
|
||||
"Bash(git commit -m ' *)",
|
||||
"Bash(.venv/Scripts/python.exe -m meshterm --mock trace --target Alice --profile yagi)",
|
||||
"Bash(echo \"=== EXIT: $? ===\")"
|
||||
],
|
||||
"defaultMode": "dontAsk"
|
||||
"PowerShell(ruff check *)",
|
||||
"PowerShell(ruff format *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -96,23 +96,22 @@ version is written**. `[project] version` in `pyproject.toml` is `dynamic` and h
|
||||
reads the attribute, so the number in a built wheel cannot drift from the number in the
|
||||
source. Do not add a second one.
|
||||
|
||||
### Then re-point the README's download commands
|
||||
### The README needs nothing — deliberately
|
||||
|
||||
`installers.yml` names each asset `meshterm-${version}-<label><ext>`, so **the file a reader
|
||||
downloads is named after the release** — and the README tells them what to type at it. Those
|
||||
lines are prose, not code, so nothing rewrites them for you:
|
||||
It used to. `installers.yml` names each asset `meshterm-${version}-<label><ext>`, so every
|
||||
download command in the README named a version, and every release meant grepping the old
|
||||
number out of five of them. That step failed quietly when it was missed: a README naming
|
||||
last release's file still reads perfectly well, and only breaks for the one person who
|
||||
types it.
|
||||
|
||||
```
|
||||
grep -n '<the previous version>' README.md
|
||||
```
|
||||
`github-release.yml` now attaches a **version-less copy of every binary** beside the
|
||||
stamped one, so `releases/latest/download/meshterm-macos-arm64` is a URL that never moves —
|
||||
which is what the README and `docs/uconsole.md` use. Nothing in either names a version.
|
||||
Leave them alone, and if you find yourself adding a version to an install command, add an
|
||||
alias to the gather step instead.
|
||||
|
||||
Every hit is a download or run command (`.\meshterm-X.Y.Z-windows-x64.exe`,
|
||||
`chmod +x meshterm-X.Y.Z-*`, the `xattr` quarantine note, the `MESHTERM_HOME` trial runs).
|
||||
Replace the old number with the one just bumped to, and re-read each line: they must name
|
||||
the file the workflow is about to publish, or the first thing a new user does fails.
|
||||
|
||||
Also check `docs/cli.md` for a sample document carrying a `"version"` field, which is the
|
||||
live `{version}` placeholder and must show the new number too.
|
||||
What *does* still carry the number: `docs/cli.md` has a sample document with a `"version"`
|
||||
field, which is the live `{version}` placeholder and must show the new one.
|
||||
|
||||
## 5. Commit, tag, push
|
||||
|
||||
@@ -143,16 +142,26 @@ Five platform builds, the release job, and `website` — which rebuilds meshterm
|
||||
tag so the site shows the new version — so it takes a few minutes. If only `website` fails,
|
||||
the release itself is fine (it runs after publishing); the usual cause is the
|
||||
`SITE_DEPLOY_KEY` secret, and once that is fixed `gh run rerun <run-id> --failed` finishes
|
||||
it. **Check the notes
|
||||
actually came out of the changelog** — a green run is not proof, because the fallback is a
|
||||
successful step:
|
||||
it.
|
||||
|
||||
The notes open with an install block the workflow writes from the tag — the same commands
|
||||
as the README, but pinned to *this* release's own assets — and the changelog follows under
|
||||
`## What changed`. **Check that second half actually came out of the changelog**, because a
|
||||
green run is not proof: the fallback is a successful step.
|
||||
|
||||
```
|
||||
gh release view vX.Y.Z --json body --jq .body | head -3
|
||||
gh release view vX.Y.Z --json body --jq .body | sed -n '/## What changed/,$p' | head -5
|
||||
```
|
||||
|
||||
If that reads "No changelog entry for X.Y.Z.", the extraction failed rather than the
|
||||
changelog being empty. Fix the workflow, then repair the published notes in place with
|
||||
changelog being empty.
|
||||
|
||||
Fourteen assets is the right count — five version-stamped binaries, five version-less
|
||||
copies of them, `SHA256SUMS`, and the three licence files:
|
||||
|
||||
```
|
||||
gh release view vX.Y.Z --json assets --jq '.assets | length'
|
||||
``` Fix the workflow, then repair the published notes in place with
|
||||
`gh release edit vX.Y.Z --notes-file` — the binaries are fine and the tag does not move.
|
||||
|
||||
If a *build* fails, the tag is already public: fix forward with a new patch version rather
|
||||
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
# way to chip in and this file doesn't, the two have drifted and one of them is lying.
|
||||
buy_me_a_coffee: johnputer
|
||||
custom:
|
||||
- "https://www.patreon.com/c/MeshTerm"
|
||||
- "https://www.patreon.com/cw/MeshTerm"
|
||||
- "https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6"
|
||||
+66
-106
@@ -7,12 +7,22 @@ body:
|
||||
value: |
|
||||
Hopefully you never need this form.
|
||||
|
||||
If you do: how much of this you fill in really does decide what happens next. A
|
||||
report I can act on gets worked on. A vague one usually doesn't, because I can't
|
||||
do anything with it. If it isn't worth describing properly, it's probably not
|
||||
worth fixing.
|
||||
If you do: start by running this, and attach the file it writes.
|
||||
|
||||
Most of these take ten seconds each.
|
||||
```
|
||||
meshterm diagnostics --out bug.md
|
||||
```
|
||||
|
||||
It answers which MeshTerm, on which machine, in which terminal, talking to which
|
||||
radio over which transport and firmware — most of what I would otherwise ask one
|
||||
question at a time, and the details people most often end up guessing at. Nothing
|
||||
in it is private: no keys, no pairing PINs, no positions, no contacts. That is a
|
||||
tested property, not a promise.
|
||||
|
||||
The **Diagnostics** page in the menu saves the same file (with `s`, or F3 on the
|
||||
PicoCalc), next to the log.
|
||||
|
||||
The rest of this form asks only for what that file cannot know.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
@@ -49,118 +59,62 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: workaround
|
||||
attributes:
|
||||
label: Is there a way around it
|
||||
description: Anything that avoids it, or gets you unstuck. Say so even if it's ugly.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: MeshTerm version
|
||||
description: It's on the splash screen when you start, and on the About page.
|
||||
placeholder: "0.2.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: hardware
|
||||
attributes:
|
||||
label: What are you running MeshTerm on
|
||||
description: >
|
||||
Desktop, laptop, Mac, or a handheld — and if it's a handheld, which one.
|
||||
placeholder: "ClockworkPi uConsole (CM4), or: desktop PC"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Which OS, exact version
|
||||
placeholder: "Windows 11 24H2 / macOS 15.3 / Debian 12"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: terminal
|
||||
attributes:
|
||||
label: Which terminal, and how big
|
||||
description: >
|
||||
PowerShell, Windows Terminal, iTerm2, the PicoCalc console, ssh from something.
|
||||
Size in columns x rows if you know it — a lot of layout bugs are really terminal
|
||||
bugs.
|
||||
placeholder: "Windows Terminal, 120x40"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: companion
|
||||
attributes:
|
||||
label: Which companion device
|
||||
description: >
|
||||
Be precise. There are a lot of these and they don't behave the same. "A Xiao"
|
||||
doesn't narrow it down — "Seeed XIAO nRF52840 + Wio-SX1262" does. Leave blank if
|
||||
no radio was involved.
|
||||
placeholder: "Heltec V3 / RAK4631 / XIAO nRF52840 + Wio-SX1262"
|
||||
|
||||
- type: input
|
||||
id: firmware
|
||||
attributes:
|
||||
label: Which firmware version is on it
|
||||
placeholder: "MeshCore 1.15.0"
|
||||
|
||||
- type: dropdown
|
||||
id: transport
|
||||
attributes:
|
||||
label: How is it connected
|
||||
options:
|
||||
- USB / serial
|
||||
- Bluetooth
|
||||
- TCP
|
||||
- SPI-to-TCP bridge
|
||||
- Something else (say so below)
|
||||
- No radio — simulator / --mock
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: flavour
|
||||
attributes:
|
||||
label: Regular or PicoCalc
|
||||
description: >
|
||||
MeshTerm draws itself differently on a PicoCalc — narrower, fewer colours, a
|
||||
different font. A bug in one often doesn't exist in the other, so this saves a
|
||||
round of guessing.
|
||||
options:
|
||||
- Regular (desktop, laptop, ssh)
|
||||
- PicoCalc handheld
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: mock
|
||||
attributes:
|
||||
label: Does it still happen with `meshterm --mock`
|
||||
description: >
|
||||
`--mock` runs the whole app against a fake radio. If the bug survives that, it's
|
||||
in MeshTerm and I can chase it without hardware. If it doesn't, it's in the link
|
||||
or the radio, which is a different hunt. Skip it if the bug is about connecting
|
||||
in the first place.
|
||||
`--mock` runs the whole app against a fake radio. If the bug survives that, it is
|
||||
in MeshTerm and I can chase it without owning your hardware. If it doesn't, it is
|
||||
in the link or the radio, which is a different hunt. This one question decides
|
||||
which of those two jobs this is, so it is worth the minute it takes.
|
||||
options:
|
||||
- "Yes, still happens with --mock"
|
||||
- "No, --mock is fine"
|
||||
- "Haven't tried"
|
||||
- "Doesn't apply"
|
||||
- "Doesn't apply — the problem is connecting"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: diagnostics
|
||||
attributes:
|
||||
label: The diagnostics block
|
||||
description: >
|
||||
Attach `bug.md` by dragging it in, or paste its contents here. If you genuinely
|
||||
cannot produce one, because MeshTerm will not start at all, write down why instead,
|
||||
along with your version, and I will work with that.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: handheld
|
||||
attributes:
|
||||
label: Which handheld, if it is a handheld
|
||||
description: >
|
||||
Only if you are running on something the diagnostics file cannot name. It knows a
|
||||
PicoCalc from a regular terminal, but a uConsole and a laptop look much the same to
|
||||
it. Leave blank on a desktop, laptop or Mac.
|
||||
placeholder: "ClockworkPi uConsole (CM4)"
|
||||
|
||||
- type: input
|
||||
id: radio
|
||||
attributes:
|
||||
label: Which radio and firmware, if MeshTerm could not reach it
|
||||
description: >
|
||||
The diagnostics file reads the model and firmware off the radio itself, so when the
|
||||
problem *is* connecting, it has nothing to read. That is the one case where I need
|
||||
you to tell me. Be precise — "a Xiao" covers several boards that do not behave
|
||||
alike.
|
||||
placeholder: "Seeed XIAO nRF52840 + Wio-SX1262, MeshCore 1.15.0"
|
||||
|
||||
- type: textarea
|
||||
id: error
|
||||
attributes:
|
||||
label: The exact error, if there was one
|
||||
description: >
|
||||
Copy it as text rather than photographing the screen — text can be searched, and
|
||||
a photo of a terminal is hard to read. Paste the whole traceback if it crashed.
|
||||
Copy it as text rather than photographing the screen — text can be searched, and a
|
||||
photo of a terminal is hard to read. Paste the whole traceback if it crashed.
|
||||
render: text
|
||||
|
||||
- type: textarea
|
||||
@@ -169,10 +123,16 @@ body:
|
||||
label: The log file
|
||||
description: >
|
||||
MeshTerm keeps one at `~/.meshterm/meshterm.log` (or wherever `MESHTERM_HOME`
|
||||
points). Attach it, or paste the last twenty lines. If the problem is hard to
|
||||
catch, set **Log detail** to `DEBUG` on the Preferences page, do it once more,
|
||||
and send that instead — it records a great deal more.
|
||||
render: text
|
||||
points). Attach the file, or paste the last twenty lines inside a ``` fence. If the
|
||||
problem is hard to catch, set **Log detail** to Debug on the Preferences page,
|
||||
restart MeshTerm, do it once more, and send that instead. It records a great deal
|
||||
more.
|
||||
|
||||
- type: textarea
|
||||
id: workaround
|
||||
attributes:
|
||||
label: Is there a way around it
|
||||
description: Anything that avoids it, or gets you unstuck. Say so even if it's ugly.
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
|
||||
@@ -4,10 +4,12 @@ contact_links:
|
||||
url: https://discord.gg/AZwe5Uvb3S
|
||||
about: >
|
||||
Setup trouble, "is it supposed to do this", or anything you want a second opinion
|
||||
on. The support forum is the place. Someone running your radio has probably hit it.
|
||||
on. The #support forum is the place. Someone running your radio has probably hit it.
|
||||
|
||||
- name: No GitHub account? Report it on Discord instead
|
||||
- name: "No GitHub account? Report it in the #bugs forum instead"
|
||||
url: https://discord.gg/AZwe5Uvb3S
|
||||
about: >
|
||||
Drop reproducible bugs in #bugs and they'll get moved here. Being told beats
|
||||
One post per bug, tagged, with the diagnostics file attached. Real ones get opened
|
||||
here and your post is renamed with the issue number, so you can follow it without an
|
||||
account — and something comes back to tell you when it ships. Being told beats
|
||||
filing it tidily.
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
## Related issue
|
||||
|
||||
<!-- CONTRIBUTING asks for an issue first on anything non-trivial, so a large PR with no
|
||||
issue behind it will get a conversation before it gets a review. Link it here. -->
|
||||
<!-- Small fixes can come straight to a PR. For anything bigger, CONTRIBUTING asks for an
|
||||
issue first and a yes on it, so a large PR with no issue behind it will get a
|
||||
conversation before it gets a review. Link it here. -->
|
||||
|
||||
## Before you mark it ready
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.14"
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
|
||||
@@ -64,9 +64,10 @@ jobs:
|
||||
- { os: ubuntu-latest, python: "3.11" }
|
||||
- { os: ubuntu-latest, python: "3.12" }
|
||||
- { os: ubuntu-latest, python: "3.13" }
|
||||
- { os: ubuntu-latest, python: "3.14" }
|
||||
- { os: windows-latest, python: "3.10" }
|
||||
- { os: windows-latest, python: "3.13" }
|
||||
- { os: macos-latest, python: "3.13" }
|
||||
- { os: windows-latest, python: "3.14" }
|
||||
- { os: macos-latest, python: "3.14" }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -97,7 +98,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.14"
|
||||
|
||||
- run: pip install build twine
|
||||
|
||||
|
||||
@@ -66,14 +66,45 @@ jobs:
|
||||
id: gather
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ inputs.tag || github.ref_name }}"
|
||||
version="${tag#v}"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
mkdir -p release
|
||||
find artifacts -type f -exec mv {} release/ \;
|
||||
ls -lh release/
|
||||
# One combined checksum file is easier to verify against than five loose ones.
|
||||
(cd release && cat *.sha256 > SHA256SUMS && rm -f *.sha256)
|
||||
tag="${{ inputs.tag || github.ref_name }}"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
|
||||
cd release
|
||||
|
||||
# A second, version-less copy of every binary. GitHub's evergreen download URL,
|
||||
# `releases/latest/download/<name>`, resolves against an exact asset name and
|
||||
# nothing else — so a link that always fetches the newest build needs a name
|
||||
# that never moves. That is what lets the README, the website and anyone's
|
||||
# install script say `meshterm-macos-arm64` once instead of naming a version
|
||||
# that goes stale the moment the next release lands. The version-stamped file
|
||||
# stays beside it: it is the one you want on disk when you keep several builds
|
||||
# around, and it is what the release-notes install block points at, since that
|
||||
# page is about this release rather than the latest one.
|
||||
made=0
|
||||
for versioned in "meshterm-$version"-*; do
|
||||
case "$versioned" in *.sha256) continue ;; esac
|
||||
[ -e "$versioned" ] || continue
|
||||
stable="meshterm-${versioned#meshterm-$version-}"
|
||||
cp "$versioned" "$stable"
|
||||
sha256sum "$stable" > "$stable.sha256"
|
||||
made=$((made + 1))
|
||||
done
|
||||
# Nothing matched means the tag and `meshterm.__version__` disagree, which is a
|
||||
# broken release either way: the notes below are about to name files by version
|
||||
# that the build did not produce. Fail here, where it is one line to read.
|
||||
if [ "$made" -eq 0 ]; then
|
||||
echo "::error::No binary named meshterm-$version-*; $tag and meshterm.__version__ disagree."
|
||||
exit 1
|
||||
fi
|
||||
echo "$made version-less aliases made"
|
||||
|
||||
# One combined checksum file is easier to verify against than ten loose ones.
|
||||
cat *.sha256 > SHA256SUMS && rm -f *.sha256
|
||||
|
||||
- name: Take the release notes from the changelog
|
||||
id: notes
|
||||
@@ -96,6 +127,7 @@ jobs:
|
||||
awk -v v="$VERSION" '
|
||||
$0 ~ "^## \\[" v "\\]" {found=1; next}
|
||||
found && /^## \[/ {exit}
|
||||
found && /^\[[^]]+\]: / {next}
|
||||
found {print}
|
||||
' CHANGELOG.md > notes.md
|
||||
if ! grep -q '[^[:space:]]' notes.md; then
|
||||
@@ -103,6 +135,72 @@ jobs:
|
||||
fi
|
||||
echo "--- notes ---"; cat notes.md
|
||||
|
||||
# Most people who open a release page came to install the thing, so the commands go
|
||||
# above the changelog rather than in a link to a README that names some other
|
||||
# version. Written here rather than kept as prose anywhere means they can only ever
|
||||
# name *this* release: the tag interpolates, so the URLs are the assets attached to
|
||||
# the very page they are printed on, and there is no example version to go stale.
|
||||
#
|
||||
# The heredoc is quoted, so the markdown's own backticks stay backticks instead of
|
||||
# becoming command substitution, and the two placeholders are filled by `sed`
|
||||
# afterwards. Neither a tag nor a version can contain a slash, so the substitution
|
||||
# needs no escaping.
|
||||
- name: Open the notes with this release's install commands
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ steps.gather.outputs.tag }}
|
||||
VERSION: ${{ steps.gather.outputs.version }}
|
||||
run: |
|
||||
cat > install.md.in <<'INSTALL_MD'
|
||||
## Install
|
||||
|
||||
One file, no Python needed.
|
||||
|
||||
**macOS** — paste these into Terminal. Use `curl`, **not your browser**: a browser
|
||||
flags the download and macOS then refuses to open it at all, and on Sequoia and
|
||||
later right-click → Open no longer gets you past it. `curl` flags nothing, so it
|
||||
simply runs.
|
||||
|
||||
```bash
|
||||
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/download/@TAG@/meshterm-@VERSION@-macos-arm64
|
||||
chmod +x meshterm
|
||||
./meshterm
|
||||
```
|
||||
|
||||
That is the build for Apple silicon Macs (M1 and later). On an Intel Mac, swap
|
||||
`arm64` for `x64`.
|
||||
|
||||
**Linux** — the same three lines with `linux-x64`, or `linux-arm64` on a uConsole
|
||||
or another 64-bit ARM handheld.
|
||||
|
||||
```bash
|
||||
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/download/@TAG@/meshterm-@VERSION@-linux-x64
|
||||
chmod +x meshterm
|
||||
./meshterm
|
||||
```
|
||||
|
||||
**Windows** — download [meshterm-@VERSION@-windows-x64.exe](https://github.com/jpmartineau/MeshTerm/releases/download/@TAG@/meshterm-@VERSION@-windows-x64.exe), then run it from Windows
|
||||
Terminal or PowerShell rather than by double-clicking, so you can read anything it
|
||||
says on the way up.
|
||||
|
||||
```powershell
|
||||
.\meshterm-@VERSION@-windows-x64.exe
|
||||
```
|
||||
|
||||
Every binary below is listed in `SHA256SUMS`. The version-less copies —
|
||||
`meshterm-macos-arm64` and its four siblings — are the same binaries under a name
|
||||
that never changes, for anything that wants whichever release is newest without
|
||||
being told its number. [Installing with pip or pipx, and the rest of the
|
||||
detail](https://github.com/jpmartineau/MeshTerm#install).
|
||||
|
||||
---
|
||||
|
||||
## What changed
|
||||
INSTALL_MD
|
||||
sed -e "s/@TAG@/$TAG/g" -e "s/@VERSION@/$VERSION/g" install.md.in > install.md
|
||||
cat install.md notes.md > body.md
|
||||
echo "--- body ---"; cat body.md
|
||||
|
||||
- name: Create it
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -111,7 +209,7 @@ jobs:
|
||||
run: |
|
||||
gh release create "$TAG" release/* \
|
||||
--title "MeshTerm ${TAG#v}" \
|
||||
--notes-file notes.md \
|
||||
--notes-file body.md \
|
||||
--verify-tag \
|
||||
$PRERELEASE
|
||||
echo "https://github.com/${{ github.repository }}/releases/tag/$TAG"
|
||||
@@ -146,7 +244,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Rebuild the site from this release
|
||||
run: |
|
||||
|
||||
@@ -59,9 +59,10 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
# Oldest supported version on purpose: a binary built against an older glibc
|
||||
# and an older Python runs on more machines than one built against the newest.
|
||||
python-version: "3.10"
|
||||
# The Python the project builds with, bundled into every binary. How widely a
|
||||
# Linux binary runs is set by the runner's glibc, which is why the Linux legs
|
||||
# stay on ubuntu-22.04 rather than ubuntu-latest.
|
||||
python-version: "3.14"
|
||||
|
||||
- run: pip install . pyinstaller
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Issue trailers
|
||||
|
||||
# A commit that names an issue is the only thing connecting a fix to the person
|
||||
# who reported it. GitHub resolves that link silently: `Fixes: #42` against an
|
||||
# issue that does not exist is not an error, it is nothing at all -- the issue
|
||||
# stays open, the reporter is never told, and the commit looks like it did the
|
||||
# job. A typo costs a whole loop and announces itself nowhere.
|
||||
#
|
||||
# So this checks the trailers that ARE there rather than nagging about the ones
|
||||
# that are not. Most commits here are internal work that legitimately closes
|
||||
# nothing, and a workflow that complains about all of them gets muted in a week.
|
||||
#
|
||||
# Cross-repo trailers (`Fixes: owner/repo#12`) are reported but not verified:
|
||||
# GITHUB_TOKEN only reaches this repository.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write # to leave a comment on the offending commit
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
steps:
|
||||
- name: Verify every issue named in a trailer exists
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
COMMITS: ${{ toJSON(github.event.commits) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "$COMMITS" > /tmp/commits.json
|
||||
count=$(jq 'length' /tmp/commits.json)
|
||||
echo "checking $count commit(s)"
|
||||
|
||||
problems=0
|
||||
|
||||
for i in $(seq 0 $((count - 1))); do
|
||||
sha=$(jq -r ".[$i].id" /tmp/commits.json)
|
||||
msg=$(jq -r ".[$i].message" /tmp/commits.json)
|
||||
short="${sha:0:8}"
|
||||
|
||||
# Trailers only: a line that starts with the keyword. A bare "#12"
|
||||
# in prose is a reference someone wrote on purpose, not a claim
|
||||
# that this commit closes anything.
|
||||
refs=$(printf '%s\n' "$msg" \
|
||||
| grep -oiE '^(fixes|closes|resolves|refs|see):[[:space:]]*([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)?#[0-9]+' \
|
||||
|| true)
|
||||
|
||||
[ -z "$refs" ] && continue
|
||||
|
||||
while IFS= read -r ref; do
|
||||
[ -z "$ref" ] && continue
|
||||
target=$(printf '%s' "$ref" | sed -E 's/^[A-Za-z]+:[[:space:]]*//')
|
||||
num="${target##*#}"
|
||||
|
||||
if [ "$target" != "#${num}" ]; then
|
||||
repo="${target%%#*}"
|
||||
echo " $short -> $target (other repository, not verified here)"
|
||||
continue
|
||||
fi
|
||||
|
||||
if state=$(gh api "repos/$REPO/issues/$num" --jq .state 2>/dev/null); then
|
||||
echo " $short -> #$num ($state)"
|
||||
else
|
||||
echo "::error::commit $short names #$num, which does not exist in $REPO"
|
||||
problems=$((problems + 1))
|
||||
gh api "repos/$REPO/commits/$sha/comments" \
|
||||
-f body="This commit's trailer names **#$num**, which does not exist in \`$REPO\`.
|
||||
|
||||
GitHub resolves a closing keyword silently, so nothing failed and nothing was linked — the issue this was meant to close is still open, and whoever reported it has not been told. Worth a follow-up commit with the right number, or a note on the issue itself." \
|
||||
>/dev/null
|
||||
fi
|
||||
done <<< "$refs"
|
||||
done
|
||||
|
||||
if [ "$problems" -gt 0 ]; then
|
||||
echo "::warning::$problems trailer(s) pointed at issues that do not exist"
|
||||
fi
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.14"
|
||||
|
||||
- run: pip install build twine
|
||||
|
||||
|
||||
+76
-923
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
# MeshTerm — working notes for Claude
|
||||
|
||||
MeshTerm is a full-screen terminal companion for MeshCore LoRa mesh devices. The UI is a
|
||||
MeshTerm is a full-featured TUI MeshCore client for your terminal. The UI is a
|
||||
prompt_toolkit session rendering Rich content (`meshterm/ui/`), tools register into a
|
||||
menu (`meshterm/tools/`), services run in the background (`meshterm/services/`), and
|
||||
everything heard is recorded to SQLite (`meshterm/persistence/`).
|
||||
@@ -56,7 +56,7 @@ them instead of hand-rolling:
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| node | any device on the mesh — a radio broadcasting packets; roles: client, repeater, room server. The umbrella term |
|
||||
| node | any device on the mesh — a radio broadcasting packets; roles: companion, repeater, room server, sensor. The umbrella term |
|
||||
| contact | a node your device knows: **discovered** (heard broadcasting, not yet added) or **added** (in the contact list, messageable). Every contact is a node; not every node is a contact |
|
||||
| heard | received from ("last heard", "first heard") — never "seen" in UX text |
|
||||
| key | the full fixed-length value — a node's public key, a channel secret |
|
||||
@@ -68,7 +68,7 @@ them instead of hand-rolling:
|
||||
| Quit | leave the app (main menu, device splash) — nowhere else |
|
||||
|
||||
Node vs contact — the boundary: **node** is the hardware/participant sense — the map,
|
||||
mesh walk, heard-nodes, relay hops, graph vertices, and node *types* (client/repeater/room/
|
||||
mesh walk, heard-nodes, relay hops, graph vertices, and node *types* (companion/repeater/room server/
|
||||
sensor) all speak "node". **Contact** is the saved-identity sense — the Contacts screen,
|
||||
the courier recipient, anything you *address*. The reception/persistence layer
|
||||
(`observations.node`, `HeardNode`, `heard_nodes()`, `trace_hops.node`) stays "node"; the
|
||||
@@ -296,8 +296,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
|
||||
- Status marks (single-width, themed): `✓` ok · `✗` err · `⚠`/`?` warn · `●` unread/
|
||||
unacked (err) · `○` acked/empty (muted). Never `✔`, `✖`, `✅`, `❌` as status.
|
||||
- Node types (shared with the map): `★` you (yellow) · `●` node · `▲` repeater ·
|
||||
`■` room · `◉` sensor · `○` unknown.
|
||||
- Node types (shared with the map): `★` you (yellow) · `●` companion · `▲` repeater ·
|
||||
`■` room server · `◉` sensor · `○` unknown.
|
||||
- Path cuts: a path line drawn as **chips** that gets cut — a lane that ran out, a row
|
||||
scrolled past its edge — breaks the chip off on a half block in that chip's own fill
|
||||
(`▐` ends a line, `▌` opens one; `pathline.cut_mark`/`cut_to`), so half the cell is
|
||||
|
||||
+9
-4
@@ -25,8 +25,8 @@ this repository.
|
||||
seen, etc.), UX standards for screens/dialogs/menus, and where the reusable building
|
||||
blocks live in code (`ui/menus.py`, `ui/markdown.py`, `ui/widgets.py`, `ui/theme.py`).
|
||||
New code should read like it always belonged here.
|
||||
- For anything bigger than a small fix, open an issue first to talk it through before
|
||||
writing a lot of code — saves both of us a rewrite.
|
||||
- Small fixes can go straight to a pull request. For anything bigger, open an issue
|
||||
first and wait for a yes before writing a lot of code — saves both of us a rewrite.
|
||||
|
||||
## Making a change
|
||||
|
||||
@@ -61,7 +61,12 @@ this repository.
|
||||
Open a GitHub issue. For bugs, include what you expected, what happened, and enough to
|
||||
reproduce it — device/platform, MeshCore firmware version if relevant, and steps.
|
||||
|
||||
The [bug form](https://github.com/jpmartineau/MeshTerm/issues/new?template=bug.yml) asks
|
||||
for all of this. Attach the file that `meshterm diagnostics --out bug.md` writes. No
|
||||
GitHub account? Post it in the #bugs forum on [Discord](https://discord.gg/AZwe5Uvb3S)
|
||||
instead.
|
||||
|
||||
---
|
||||
|
||||
Questions about any of this, licensing included, are welcome as a GitHub issue or
|
||||
discussion before you put work in.
|
||||
Questions about any of this, licensing included, are welcome as a GitHub issue before you
|
||||
put work in.
|
||||
@@ -1,14 +1,14 @@
|
||||
# MeshTerm
|
||||
|
||||
**A full-screen terminal companion for [MeshCore](https://meshcore.io/) LoRa mesh
|
||||
devices** — tune your radio, chat across the mesh, watch the network live, and keep a
|
||||
longitudinal record of everything you overhear, all from a connected serial, Bluetooth, or
|
||||
network (TCP) companion.
|
||||
**A full-featured TUI [MeshCore](https://meshcore.io/) client for your terminal** — tune
|
||||
your radio, chat across the mesh, watch the network live, and keep a longitudinal record
|
||||
of everything you overhear, all from a connected serial, Bluetooth, or network (TCP)
|
||||
companion.
|
||||
|
||||
MeshTerm is interactive by default: a modern, keyboard-driven TUI built on
|
||||
[Rich](https://github.com/Textualize/rich) and
|
||||
[prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit). It is also
|
||||
fully scriptable — **every screen has a mirror `meshterm` subcommand** — so the same
|
||||
fully scriptable — **most screens have a mirror `meshterm` subcommand** — so the same
|
||||
capabilities drive both an evening of exploring the mesh and a cron job. Every packet the
|
||||
radio overhears is recorded to a local SQLite database, so the longer you run it, the more
|
||||
your mesh's history is worth.
|
||||
@@ -23,8 +23,14 @@ your mesh's history is worth.
|
||||
<a href="https://discord.gg/AZwe5Uvb3S"><img alt="Discord" src="https://img.shields.io/badge/chat-Discord-5865F2"></a>
|
||||
</p>
|
||||
|
||||
> MeshTerm is a side project, not a product. Bug reports are very welcome — but please
|
||||
> open an issue before writing a pull request. [More on how it's run](#how-this-project-is-run).
|
||||
> MeshTerm is a side project, run by one person. Bug reports are very welcome. Small fixes
|
||||
> can go straight to a pull request; for anything bigger, please open an issue first.
|
||||
> [More on how it's run](#how-this-project-is-run).
|
||||
|
||||
<p align="center">
|
||||
<a href="https://meshterm.net/#demo"><img src="https://meshterm.net/assets/demo-play.png" alt="Watch the demo: MeshTerm showing every route a channel message took to arrive" width="840"></a>
|
||||
</p>
|
||||
<p align="center"><sub>Under two minutes on a real mesh — <a href="https://meshterm.net/#demo">watch it on meshterm.net</a>.</sub></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/dashboard.png" alt="The dashboard: a packets-per-minute timeline over the last two hours, and every packet class counted" width="49%">
|
||||
@@ -64,90 +70,92 @@ your mesh's history is worth.
|
||||
|
||||
### Download a build
|
||||
|
||||
**[⬇ Latest release](https://github.com/jpmartineau/MeshTerm/releases/latest)** — one file,
|
||||
no Python needed. Windows, macOS (Intel and Apple silicon), and Linux (x64 and ARM64, so
|
||||
the uConsole is covered).
|
||||
One file, no Python needed — Windows, macOS (Intel and Apple silicon), and Linux (x64 and
|
||||
ARM64, so the uConsole is covered). Every command below fetches the
|
||||
**[⬇ latest release](https://github.com/jpmartineau/MeshTerm/releases/latest)**, whichever
|
||||
one that is, so none of them goes stale.
|
||||
|
||||
MeshTerm is a terminal program, so **open a terminal and run it from there.** You can
|
||||
double-click it and it will work, but you'll get whatever console your system picks, and
|
||||
if anything goes wrong at startup the window closes before you can read why.
|
||||
|
||||
**Windows** — open Windows Terminal or PowerShell, `cd` to your downloads, then:
|
||||
#### macOS — download it with `curl`
|
||||
|
||||
```powershell
|
||||
.\meshterm-0.3.7-windows-x64.exe
|
||||
```
|
||||
|
||||
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
|
||||
**Don't use your browser.** A browser flags the file as downloaded, and macOS then refuses
|
||||
to open it at all — on Sequoia and later, right-click → Open no longer gets you past that
|
||||
either. `curl` flags nothing, so what it hands you simply runs. Open Terminal and paste:
|
||||
|
||||
```bash
|
||||
chmod +x meshterm-0.3.7-*
|
||||
./meshterm-0.3.7-*
|
||||
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-macos-arm64
|
||||
chmod +x meshterm
|
||||
./meshterm
|
||||
```
|
||||
|
||||
On macOS, downloading it with `curl` instead of a browser saves you a fight with Gatekeeper
|
||||
— the note below has the line.
|
||||
That's the build for Apple silicon Macs (M1 and later). On an **Intel** Mac, swap `arm64`
|
||||
for `x64`. And if you already downloaded it with a browser, you don't have to start over:
|
||||
`xattr -d com.apple.quarantine meshterm` clears the flag.
|
||||
|
||||
### A word about terminals, on Windows
|
||||
#### Linux — the same three lines
|
||||
|
||||
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether you
|
||||
see them is up to your *terminal*, not really your font: a modern terminal, asked for a
|
||||
character its font doesn't have, quietly borrows it from another font on the machine.
|
||||
That's why the app looks right in Windows Terminal, in VS Code's terminal, and on macOS
|
||||
and Linux — usually with a font that contains almost none of it.
|
||||
`linux-x64` on a PC, `linux-arm64` on a uConsole or another 64-bit ARM handheld.
|
||||
|
||||
```bash
|
||||
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-linux-x64
|
||||
chmod +x meshterm
|
||||
./meshterm
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
Download
|
||||
**[meshterm-windows-x64.exe](https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-windows-x64.exe)**,
|
||||
then open Windows Terminal or PowerShell, `cd` to your downloads, and run it:
|
||||
|
||||
```powershell
|
||||
.\meshterm-windows-x64.exe
|
||||
```
|
||||
|
||||
Windows says *"Windows protected your PC"* the first time. Click **More info → Run anyway**.
|
||||
|
||||
Whichever you took, you now have a single file: `meshterm` on macOS and Linux,
|
||||
`meshterm-windows-x64.exe` on Windows. Put it somewhere on your `PATH` (renamed to
|
||||
`meshterm.exe` on Windows) and it's just `meshterm` from anywhere.
|
||||
|
||||
> **These builds aren't code-signed**, which is why macOS and Windows both push back the
|
||||
> first time; signing costs real money on both platforms and this is a free side project.
|
||||
> Every release ships a `SHA256SUMS` file if you'd rather check what you got, and
|
||||
> [installing with pip or pipx](#or-install-with-pip) sidesteps the whole business, since
|
||||
> nothing arrives as a downloaded binary.
|
||||
>
|
||||
> Each release page also carries these commands written out for **that exact version**,
|
||||
> next to its own downloads.
|
||||
|
||||
### If your terminal draws empty boxes
|
||||
|
||||
MeshTerm is drawn with emoji icons, braille charts and powerline path chips, and whether
|
||||
you see them is up to your *terminal* rather than your font. Windows Terminal, VS Code's
|
||||
terminal, and anything modern on macOS and Linux all draw them correctly.
|
||||
|
||||
The classic Windows console — the black `cmd.exe` window you get from a double-click —
|
||||
doesn't borrow. It draws what its one font holds and empty boxes for everything else, and
|
||||
no font fixes the icons there: the only two fonts on a Windows machine with emoji in them
|
||||
are proportional, and a console won't take a proportional font.
|
||||
|
||||
So when MeshTerm lands in that console it just **moves to Windows Terminal** — it says
|
||||
so, and opens there. Nothing is installed and nothing is changed; it's one window instead
|
||||
of another, and it's the whole app exactly as the screenshots show it. Windows Terminal is
|
||||
already on every Windows 11 machine and is a free install on Windows 10.
|
||||
|
||||
Where there's no Windows Terminal to move to, MeshTerm offers the next best thing instead
|
||||
— the charts and marks, without the icons. It ships
|
||||
[Cascadia Mono PL](https://github.com/microsoft/cascadia-code), Microsoft's own console
|
||||
font and one of the very few monospace faces that carries braille at all, and will install
|
||||
it just for you: no administrator rights, nothing downloaded. If you already have Cascadia
|
||||
it simply switches to it.
|
||||
does not. So when MeshTerm lands there it **moves to Windows Terminal**, says so, and
|
||||
opens there; nothing is installed and nothing is changed. Where there's no Windows
|
||||
Terminal to move to, it offers the charts and marks without the icons instead, and will
|
||||
install Microsoft's [Cascadia Mono PL](https://github.com/microsoft/cascadia-code) just
|
||||
for you — no administrator rights, nothing downloaded.
|
||||
|
||||
Preferences → Display → Console setup turns all of this off if you'd rather stay put.
|
||||
|
||||
Rename it to something you don't mind typing and put it somewhere on your `PATH`, and it
|
||||
becomes just `meshterm` from anywhere.
|
||||
|
||||
> **Your system will complain the first time, and it's right to.** These builds aren't
|
||||
> code-signed — signing costs real money on both platforms and this is a free side
|
||||
> project.
|
||||
>
|
||||
> - **macOS**: *"cannot be opened because the developer cannot be verified"* — 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.
|
||||
[Terminals, icons, and the Windows console](docs/terminals.md) explains why any of it is
|
||||
necessary.
|
||||
|
||||
> **Trying a build without touching your real data.** MeshTerm keeps everything in
|
||||
> `~/.meshterm` — your history database, contacts, channel keys — and *every* copy of
|
||||
> MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation:
|
||||
>
|
||||
> ```bash
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.7-* # macOS, Linux
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm # macOS, Linux
|
||||
> ```
|
||||
> ```powershell
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.7-windows-x64.exe
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-windows-x64.exe
|
||||
> ```
|
||||
>
|
||||
> Same variable if you run two radios and want them kept apart.
|
||||
@@ -183,12 +191,17 @@ Setting up for development instead? That's in [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
## Documentation
|
||||
|
||||
**[The documentation index](docs/README.md)** lists everything, including the two code
|
||||
surveys kept as a historical record. The short version:
|
||||
|
||||
| Read | For |
|
||||
| --- | --- |
|
||||
| **[The command line](docs/cli.md)** | Every subcommand and option, what each prints on the plain and JSON faces, the exit statuses, recipes. |
|
||||
| **[MeshTerm on the PicoCalc](docs/picocalc.md)** | Building the handheld from a stock PicoCalc: the shopping list, swapping in the Lyra, installing Calculinux and MeshTerm, the soldered LoRa radio. |
|
||||
| **[MeshTerm on the uConsole](docs/uconsole.md)** | A uConsole with the AIO LoRa board: installing the bridge that fronts its SPI radio as a companion, and connecting to it. |
|
||||
| [MeshTerm on hardware](docs/hardware.md) | Which of the two manuals is yours, and the two platform flavours MeshTerm draws in. |
|
||||
| **[What MeshTerm does](docs/features.md)** | Every screen the menu offers, and the command that does the same job without it. |
|
||||
| [The CLI cookbook](docs/cookbook.md) | Common one-liners, by the thing you're trying to do. |
|
||||
| [Configuring MeshTerm](docs/configuration.md) | Preferences, device profiles, and everything kept under `~/.meshterm`. |
|
||||
| [MeshTerm on hardware](docs/hardware.md) | Which handheld manual is yours — the [PicoCalc](docs/picocalc.md) build, or the [uConsole](docs/uconsole.md) bridge. |
|
||||
| [How the code is laid out](docs/architecture.md) | The layering, and where a new feature goes. |
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | The development setup and the house rules. |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | What changed, newest first. |
|
||||
|
||||
@@ -221,233 +234,47 @@ connects a uConsole's SPI LoRa board through a small bridge. The device-side scr
|
||||
manuals run are in [`scripts/`](scripts/); not sure which manual is yours?
|
||||
[`docs/hardware.md`](docs/hardware.md) says.
|
||||
|
||||
## Features
|
||||
## What it does
|
||||
|
||||
MeshTerm's menu answers one question — *what would you like to do?* — so its five
|
||||
sections are named for the doing, not the subject. Every interactive screen is listed
|
||||
here with its scripted equivalent, where one exists.
|
||||
The menu asks one question — *what would you like to do?* — and answers it in two halves.
|
||||
Three sections name a doing: **Message** is chat, channels, the courier outbox and
|
||||
contacts; **Watch** is the dashboard, the live feed, the watchtower and the time machine;
|
||||
**Explore** is the map, the mesh walk, tracing and the trophy case. Three name a scope:
|
||||
**This node** is the radio in your hand, **Other nodes** is someone else's over the mesh,
|
||||
and **This app** is MeshTerm itself — preferences, diagnostics, and the written pages.
|
||||
|
||||
### 💬 Message — the people on the other end
|
||||
Nearly every one of them has a mirror `meshterm` subcommand — the same registry builds
|
||||
the menu and the CLI, so they can't drift apart. The live screens, such as the map and
|
||||
the dashboard, are only in the menu.
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **💬 Chat** | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. Every message is logged; unread counts show in the menu header. | `meshterm chat send / history / list` |
|
||||
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels — with QR codes and `meshcore://` share links. | `meshterm channels list / add / join / import / share / clear` |
|
||||
| **📨 Courier** | Store-and-forward outbox for contacts that aren't reachable yet. Queue a message; it goes out (with ack tracking and polite exponential backoff) the moment the contact is next heard, or at a scheduled time. | `meshterm courier queue / list / send / cancel / clear` |
|
||||
| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. The scripted listing is the contacts alone (this node is `meshterm info`'s answer, in far more detail). | `meshterm contacts` |
|
||||
**[What MeshTerm does](docs/features.md)** is the full catalogue: every screen, what it
|
||||
does, and its scripted equivalent where one exists.
|
||||
|
||||
### 📊 Watch — what the mesh is doing, and what it did
|
||||
## Scripting it
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **📊 Dashboard** | The live mesh overview: a two-hour all-packet activity chart with a pulse line, session traffic tallies by packet class, and window RF health beside the radio's own live numbers. Repaints every second. | — |
|
||||
| **📰 Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* — the node for an advert, the channel for a channel text, sender → recipient for a direct message or a request, the tag for a trace — with reception quality beside it. Enter opens any row in the packet viewer, route graph and all. | — |
|
||||
| **🚨 Watchtower** | A passive sentinel over the nodes you star: silence alarms when a watched node goes quiet, SNR-sag warnings when reception degrades, recovery notes when it returns, and a heads-up when a never-before-seen node appears. Runs in the background all session; unacked alerts show as a header badge. | — |
|
||||
| **⏳ Time machine** | Everything the recorder ever heard, as braille charts: reception volume, the median-SNR band, hour-of-day rhythm, packets and nodes per day, and first-ever arrivals — over a switchable 24h / 7d / 30d / all-time window, per node or mesh-wide. | — |
|
||||
| **🎧 Monitor** | Passive recording is always on in the app. On the CLI, capture a bounded foreground window, tailing each overheard packet and summarising when it ends. It transmits nothing — it only listens. | `meshterm monitor --seconds 60` |
|
||||
|
||||
### 🧭 Explore — where the nodes are and how they reach each other
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | — (a map is a picture; `meshterm contacts` lists the same nodes) |
|
||||
| **🌐 Mesh walk** | The mesh's *observed shape*, walked one node at a time: an evidence graph built from trace walks, firmware routes, overheard relay chains, and repeater neighbour tables. SNR-coloured braille edges, quality bars, Enter to walk, ⌫ to backtrack, type to find any node. The map answers *where*; the walk answers *how it hangs together*. | — |
|
||||
| **🎯 Trace target** | A live trace screen: pick a target and watch each trace stream in hop by hop, with running per-hop medians and reliability. Compose or force a route through specific repeaters. A trace transmits **exactly once** (repeaters can blacklist nodes that burst) — sample more by running it again. | `meshterm trace --target …` |
|
||||
| **👣 Trace path** | The other half of tracing: compose the whole circuit by hand — out and back whichever way you choose — and walk it. The path composer suggests each next hop from the links actually observed, strongest first, and can fetch a repeater's neighbour table over the mesh when you hold its admin password. | `meshterm trace-path --path …` |
|
||||
| **🏆 Trophy case** | Every trace that comes home is scored, on seven boards: longest distance, farthest node, longest single leg, most nodes (with and without revisits), weakest surviving link, and biggest enclosed loop. Records are kept per hash width, and a record walk must be a *trail* — no link crossed twice the same way. | `meshterm records` |
|
||||
|
||||
### 🔧 This node — the radio in your hand
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **📋 Device info** | The connected companion's identity and full radio configuration at a glance. | `meshterm info` |
|
||||
| **🔧 Device config** | Every setting the companion firmware exposes — name, radio, client repeat, auto-add, telemetry, custom variables — *staged* for review and applied in place, plus the operations on the box itself: clock sync, TOML backup/restore, the identity key, reboot, and factory reset, each acting the moment it's confirmed, with destructive ops gated. Laid out like Repeater admin, so the radio in your hand and one over the mesh are configured the same way. | `meshterm config` / `config set <key> <value>` / `config backup` / `reboot` / … |
|
||||
| **📡 Send advert** | Announce this node to the mesh — a zero-hop or flood advertisement, or share this node's contact card as a QR code. | `meshterm config advert` / `config share` |
|
||||
| **🔌 Device discovery** | Enumerate serial *and* Bluetooth LE companions, pick one interactively (or auto-select the only one present), add a network (TCP) companion by host:port, and remember the last good default. A dropped link is detected live and offers to reconnect. | `meshterm devices` |
|
||||
|
||||
### 🗼 Other nodes — someone else's radio, over the mesh
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **🗼 Repeater admin** | Set up remote repeaters and room servers over the mesh: log in (remembered or prompted password), then a config-style editor speaking the node's text CLI — including repeater-only knobs (TX delay, airtime factor, advert intervals) — plus one-shot actions and a readline remote command line. | `meshterm repeater-admin <node> <command…>` |
|
||||
| **📶 TX optimize** | Sweep a remote node's transmit power live — coarse, then refine, then verify — watch each level land, and decide whether to apply the winner. | `meshterm tx-optimize --path … [--apply]` |
|
||||
|
||||
## CLI cookbook
|
||||
|
||||
Nearly every menu option is also a subcommand — ideal for scripting, cron, and bots. The
|
||||
live pictures stay in the menu, where their meaning is (the map, dashboard, live feed,
|
||||
watchtower and mesh walk); everything else has a command, and every command speaks
|
||||
`--json`. **[`docs/cli.md`](docs/cli.md) is the full manual**: every command and option,
|
||||
what each one prints on both faces, and the exit statuses. A taste:
|
||||
Nearly every command speaks `--json`, and the global options — `--profile/-p`, `--port`,
|
||||
`--ble`, `--ble-pin`, `--tcp`, `--mock`, `--db`, `--json`, `--absolute`, `--quiet/-q`,
|
||||
`--platform` — may be typed before or after the subcommand.
|
||||
|
||||
```bash
|
||||
# Trace — a trace transmits exactly once; run it again to sample more.
|
||||
# Every repeater's public key, for a script
|
||||
meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
|
||||
|
||||
# A trace transmits exactly once; run it again to sample more
|
||||
meshterm trace --target Alice --profile yagi
|
||||
|
||||
# Force a route through specific repeaters (MeshCore-app style): comma-separated
|
||||
# contact names and/or hex key prefixes, mixed freely. Blank lets the device route.
|
||||
meshterm trace --target Alice --path "3d,f2,3d"
|
||||
meshterm trace --target Alice --path "3d,Bravo-Repeater,f2"
|
||||
|
||||
# Walk a composed circuit with no target at all — out and back your own way
|
||||
meshterm trace-path --path "3d,f2,3d"
|
||||
|
||||
# Sweep and apply a remote node's TX power
|
||||
meshterm tx-optimize --path "Bravo-Repeater,Alice" --samples 6 --step 3 --apply
|
||||
|
||||
# Messaging: the live transcript is a menu screen; the CLI takes a subcommand
|
||||
meshterm chat send --to Alice "on my way" # direct message
|
||||
meshterm chat send --channel 0 "net in 5" # channel broadcast
|
||||
meshterm chat history --to Alice
|
||||
meshterm chat list
|
||||
|
||||
# Store-and-forward: queue for a contact that's offline right now
|
||||
# Messaging, and store-and-forward for a contact who isn't there yet
|
||||
meshterm chat send --channel 0 "net in 5"
|
||||
meshterm courier queue Alice "ping me when you're back" --at 18:30
|
||||
meshterm courier list # the outbox — waiting and finished
|
||||
meshterm courier send 3 # force one delivery attempt now
|
||||
|
||||
# Remote repeater admin (one transmission per invocation)
|
||||
meshterm repeater-admin Bravo-Repeater "get name"
|
||||
|
||||
# Device configuration: view, set, back up, restore
|
||||
meshterm config # show all current settings, one `key value` per line
|
||||
meshterm config get name # just the value, ready for $(...)
|
||||
meshterm config set radio_sf 9 # change one setting
|
||||
meshterm config backup node.toml # archive every setting to TOML
|
||||
meshterm config restore node.toml --dry-run
|
||||
meshterm config advert-cadence 2 # auto-advert to neighbours every 2 h (0 = off)
|
||||
meshterm config advert-cadence 24 --flood # flood the wider mesh daily
|
||||
|
||||
# Passive capture window (records to history; transmits nothing)
|
||||
# The radio's own settings, and a passive capture window
|
||||
meshterm config set radio_sf 9
|
||||
meshterm monitor --seconds 60
|
||||
```
|
||||
|
||||
Global options — `--profile/-p`, `--port`, `--ble`, `--ble-pin`, `--tcp`, `--mock`,
|
||||
`--db`, `--json`, `--absolute`, `--quiet/-q` — may be typed **before or after** the
|
||||
subcommand: `meshterm contacts --json` and `meshterm --json contacts` are the same run.
|
||||
|
||||
### Two output faces
|
||||
|
||||
*(The short version — [`docs/cli.md`](docs/cli.md) has the whole of it.)*
|
||||
|
||||
The **plain face** is for a person at a prompt. It prints like a standard Unix utility —
|
||||
no colour, no borders, one record per line, nothing wrapped — and it is allowed to be
|
||||
comfortable about it: listings are `ps`-style aligned records with **bare names**
|
||||
(alignment is the delimiter), times are **relative ages** (`now`, `5m`, `never`), and a
|
||||
route is drawn with arrows — `MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4)`.
|
||||
`--absolute` swaps every age back for an ISO-8601 instant. A *path*, the spec `--path`
|
||||
takes back, stays comma-separated hex. `-` is the one token for absent. Errors,
|
||||
acknowledgements and progress all go to stderr, so a redirect catches only the answer.
|
||||
|
||||
The **JSON face** is the machine contract, and `--json` works on **every** command:
|
||||
|
||||
```console
|
||||
$ meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
|
||||
b2c3d4e500000000000000000000000000000000000000000000000000000000
|
||||
a1b2c3d400000000000000000000000000000000000000000000000000000000
|
||||
```
|
||||
|
||||
No envelope — an array for a listing, an object for a set of facts. One compact line;
|
||||
`monitor` and `chat listen` stream one document per record. Values are typed, absent is
|
||||
`null` and never an omitted key, and timestamps are always UTC to the second regardless of
|
||||
`--absolute`. `--json` changes the rendering, never the report: same records, same exit
|
||||
status.
|
||||
|
||||
Anything structural should go through `--json` and `jq`. The plain face is for looking at.
|
||||
|
||||
### Exit status
|
||||
|
||||
`0` success · `1` failure · `2` usage error · `3` no device found (nothing was
|
||||
transmitted) · `4` the device was reached but the operation failed · `5` nothing to
|
||||
report. The table with its full wording is printed under `meshterm --help` and explained
|
||||
in [`docs/cli.md`](docs/cli.md#exit-status).
|
||||
|
||||
`5` is the one worth knowing about: it lets a script tell "found nothing" from "worked"
|
||||
without counting output lines.
|
||||
|
||||
```bash
|
||||
if meshterm contacts > contacts.txt; then
|
||||
echo "$(wc -l < contacts.txt) contacts"
|
||||
elif [ $? -eq 5 ]; then
|
||||
echo "no contacts yet"
|
||||
fi
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Two files, two jobs — and you need neither to run.
|
||||
|
||||
**Preferences** are how MeshTerm behaves: the pause it leaves between transmissions, how
|
||||
hard it retries a message, how far back it keeps history, how a map frames itself. Every one has a built-in default, so change them only where you disagree — from
|
||||
the **Preferences** page in the menu (grouped, staged, saved by one action at the bottom),
|
||||
or from a shell:
|
||||
|
||||
```console
|
||||
$ meshterm preferences show # every preference, its value, its default
|
||||
$ meshterm preferences set history_days 90
|
||||
$ meshterm preferences reset --yes # back to the built-in defaults
|
||||
```
|
||||
|
||||
They are kept in `~/.meshterm/preferences.toml`, which lists only what you have changed;
|
||||
delete a line and the default takes over again.
|
||||
|
||||
**Config** is where things live and which device to talk to. MeshTerm runs with none of it
|
||||
— it discovers attached serial devices and nearby Bluetooth companions, lets you pick one,
|
||||
and remembers the last good default. A network (TCP) companion isn't discoverable, so reach
|
||||
it with `--tcp host:port`, a TCP profile, or the picker's "add a network device" prompt.
|
||||
Write a config file only to give your hardware stable aliases.
|
||||
|
||||
Copy [`config.example.toml`](config.example.toml) to `~/.meshterm/config.toml`:
|
||||
|
||||
```toml
|
||||
default_profile = "s3"
|
||||
connect_on_start = true # false opens the radio link lazily instead of at launch
|
||||
|
||||
[profiles.s3]
|
||||
port = "COM5"
|
||||
baudrate = 115200
|
||||
default_tx_power = 20
|
||||
description = "XIAO ESP32-S3 + Wio SX1262 serial companion"
|
||||
|
||||
# A Bluetooth LE companion: give it an `address` instead of a `port`.
|
||||
[profiles.handheld]
|
||||
address = "AA:BB:CC:DD:EE:FF"
|
||||
# ble_pin = "123456" # only if your device requires a pairing PIN
|
||||
description = "Pocket handheld over Bluetooth"
|
||||
|
||||
# A network (TCP) companion: give it a `host` (and optional `tcp_port`, default 5000).
|
||||
[profiles.wifi]
|
||||
host = "192.168.1.50"
|
||||
description = "Basestation over WiFi"
|
||||
```
|
||||
|
||||
Both live in `~/.meshterm`, along with everything else MeshTerm remembers: the SQLite
|
||||
history, the outbox, the contact and channel caches, stored admin passwords, and the log.
|
||||
**`$MESHTERM_HOME` moves the whole directory**, which is the way to run a second radio —
|
||||
or a `--mock` session — without touching the one you use every day. `--db` moves the
|
||||
database alone; the rest stays where it was. [`docs/cli.md`](docs/cli.md#where-meshterm-keeps-its-state)
|
||||
lists every file.
|
||||
|
||||
Timestamps are stored as UTC and rendered in your local time. History older than the
|
||||
`history_days` preference is pruned once at session start.
|
||||
|
||||
## Architecture
|
||||
|
||||
MeshTerm is layered so a new feature is one file in `meshterm/tools/`:
|
||||
|
||||
```
|
||||
cli.py Typer app; no subcommand -> interactive menu
|
||||
context.py AppContext (console, config, repository, device) dependency container
|
||||
core/ Domain: models, preferences + device profiles, connection abstraction (+ mock)
|
||||
tools/ Pluggable "menu options"; each self-registers and gets logging for free
|
||||
services/ Background algorithms (monitor, trace, tx search, courier, watchtower) — no UI
|
||||
persistence/ SQLite schema, repository, structured logging
|
||||
ui/ Rich theme/widgets + the full-screen TUI (screens, dialogs, map, chat)
|
||||
```
|
||||
|
||||
Adding a feature means subclassing `Tool`, decorating it with `@register`, and
|
||||
implementing `run()`. The same registry builds both the CLI and the menu, and the base
|
||||
class wraps every execution in a logged `runs` row automatically.
|
||||
**[The command line](docs/cli.md)** is the manual — every command and option, what each
|
||||
prints on both faces, and the exit statuses.
|
||||
**[The CLI cookbook](docs/cookbook.md)** has more one-liners like these.
|
||||
|
||||
## How this project is run
|
||||
|
||||
@@ -455,18 +282,19 @@ MeshTerm is one person working evenings and weekends. I'd rather tell you that u
|
||||
than have you guess from how long things take.
|
||||
|
||||
**Issues are welcome — all of them.** Bugs, questions, "is this supposed to do that". The
|
||||
[bug form](.github/ISSUE_TEMPLATE/bug.yml) asks for a fair bit, and that's on purpose: how
|
||||
well a problem is described really does decide whether I can do anything with it. If I can
|
||||
reproduce it, I'll usually chase it. If I can't, I'm mostly guessing.
|
||||
[bug form](https://github.com/jpmartineau/MeshTerm/issues/new?template=bug.yml) asks for a
|
||||
fair bit, and that's on purpose: how well a problem is described really does decide whether
|
||||
I can do anything with it. If I can reproduce it, I'll usually chase it. If I can't, I'm
|
||||
mostly guessing.
|
||||
|
||||
**Ask before you write a pull request.** Open an issue first and wait for a yes. I'm not
|
||||
being precious — MeshTerm has firm house rules about how screens get built (they're in
|
||||
**Ask before you write a big pull request.** Small fixes can go straight to a pull
|
||||
request. For anything bigger, open an issue first and wait for a yes. I'm not being
|
||||
precious — MeshTerm has firm house rules about how screens get built (they're in
|
||||
[CLAUDE.md](CLAUDE.md)), and I'd hate for you to spend a weekend on something I then ask
|
||||
you to rewrite. A quick conversation first saves us both.
|
||||
|
||||
**I can't promise timelines.** Some things get fixed the same night. Some sit for a month
|
||||
because life happened. If your issue goes quiet, a nudge is completely fine — it's not
|
||||
rude, it's helpful.
|
||||
because life happened. If your issue goes quiet, please give it a nudge. That helps me.
|
||||
|
||||
**Where to report things.** Either works:
|
||||
|
||||
@@ -484,19 +312,6 @@ Also here: [CONTRIBUTING.md](CONTRIBUTING.md) ·
|
||||
[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
|
||||
[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
python -m pytest -q # tests
|
||||
ruff check . # lint
|
||||
ruff format . # format
|
||||
```
|
||||
|
||||
Screens are designed to stay readable at **72 columns**.
|
||||
|
||||
Want to send a pull request? See [CONTRIBUTING.md](CONTRIBUTING.md) first.
|
||||
|
||||
## Acknowledgements & attribution
|
||||
|
||||
MeshTerm stands on the [MeshCore](https://meshcore.io/) project — its firmware and the
|
||||
@@ -523,7 +338,7 @@ short form throughout. The About page inside the app spells out the licence URL.
|
||||
MeshTerm ships **Cascadia Mono PL**, © 2019–present Microsoft Corporation, redistributed
|
||||
unmodified under the [SIL Open Font License 1.1](meshterm/assets/fonts/CascadiaMono-OFL.txt).
|
||||
It's offered to Windows users whose console can't draw the charts — see
|
||||
[A word about terminals, on Windows](#a-word-about-terminals-on-windows). Microsoft doesn't endorse MeshTerm; the font is
|
||||
[Terminals, icons, and the Windows console](docs/terminals.md). Microsoft doesn't endorse MeshTerm; the font is
|
||||
simply the right tool, being one of the very few monospace faces that carries the braille
|
||||
block the timelines are drawn from.
|
||||
|
||||
@@ -534,7 +349,7 @@ respective project for the authoritative terms):
|
||||
|
||||
| Library | Role | License |
|
||||
| --- | --- | --- |
|
||||
| [meshcore](https://pypi.org/project/meshcore/) | Companion-device protocol (serial / BLE) | MIT |
|
||||
| [meshcore](https://pypi.org/project/meshcore/) | Companion-device protocol (serial / BLE / TCP) | MIT |
|
||||
| [pycryptodome](https://www.pycryptodome.org/) | AES/HMAC for decrypting overheard channel packets | BSD-2-Clause / Public Domain |
|
||||
| [pyserial](https://github.com/pyserial/pyserial) | Serial-port enumeration and I/O | BSD-3-Clause |
|
||||
| [bleak](https://github.com/hbldh/bleak) | Bluetooth LE scanning + connection | MIT |
|
||||
@@ -563,7 +378,8 @@ is Microsoft's Cascadia Mono PL, redistributed unmodified under the SIL Open Fon
|
||||
[CascadiaMono-OFL.txt](meshterm/assets/fonts/CascadiaMono-OFL.txt) and stays its only
|
||||
license.
|
||||
|
||||
Each standalone build carries `LICENSE`, `NOTICE` and a generated `THIRD-PARTY-NOTICES.txt`
|
||||
(every dependency's own license text) inside the archive, and the same three files sit
|
||||
beside the downloads on the [releases page](https://github.com/jpmartineau/MeshTerm/releases)
|
||||
so you don't have to extract one just to read them.
|
||||
Each standalone build is a single file with `LICENSE`, `NOTICE` and a generated
|
||||
`THIRD-PARTY-NOTICES.txt` (every dependency's own license text) bundled inside it. The same
|
||||
three files are also attached to each release on the
|
||||
[releases page](https://github.com/jpmartineau/MeshTerm/releases), so you can read them
|
||||
without running anything.
|
||||
+7
-6
@@ -2,10 +2,9 @@
|
||||
# Copy to ~/.meshterm/config.toml (or pass --db / --port on the CLI).
|
||||
#
|
||||
# How MeshTerm itself behaves — transmit cooldown, message retries, how much history it
|
||||
# keeps, whether it connects at launch — is *not* here. Those are preferences: they live
|
||||
# in ~/.meshterm/preferences.toml, and are edited on the Preferences page (or with
|
||||
# `meshterm preferences set <key> <value>`). Every one has a built-in default, so that
|
||||
# file only ever lists what you have changed.
|
||||
# keeps — is *not* here. Those are preferences: they live in ~/.meshterm/preferences.toml,
|
||||
# and are edited on the Preferences page (or with `meshterm preferences set <key> <value>`).
|
||||
# Every one has a built-in default, so that file only ever lists what you have changed.
|
||||
#
|
||||
# Profiles are optional. With no profile, no --port/--ble/--tcp, MeshTerm discovers attached
|
||||
# serial devices and nearby Bluetooth companions, lets you pick one (interactively) or uses the
|
||||
@@ -14,7 +13,10 @@
|
||||
# the picker's "add a network device" prompt. Define profiles below only to give your hardware
|
||||
# stable aliases (e.g. --profile yagi, --profile handheld for Bluetooth, --profile wifi for TCP).
|
||||
|
||||
default_profile = "s3"
|
||||
# Uncomment and name one of your own profiles below to make it the default. Left
|
||||
# commented, MeshTerm finds the device itself, as described above.
|
||||
# default_profile = "s3"
|
||||
|
||||
# Open the radio link as soon as the menu appears. False opens it lazily instead — only
|
||||
# once monitoring is turned on or a tool first needs the radio.
|
||||
connect_on_start = true
|
||||
@@ -24,7 +26,6 @@ connect_on_start = true
|
||||
[profiles.s3]
|
||||
port = "COM5"
|
||||
baudrate = 115200
|
||||
default_tx_power = 20
|
||||
description = "XIAO ESP32-S3 + Wio SX1262 serial companion"
|
||||
|
||||
[profiles.yagi]
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# MeshTerm documentation
|
||||
|
||||
Everything that didn't belong on the front page. The [README](../README.md) is the front
|
||||
door — what MeshTerm is, how to install it, and enough to get a first session running.
|
||||
This folder is the rest.
|
||||
|
||||
## Using MeshTerm
|
||||
|
||||
| Read | For |
|
||||
| --- | --- |
|
||||
| **[What MeshTerm does](features.md)** | Every screen the menu offers, grouped the way the menu groups them, with the command that does the same job without it. |
|
||||
| **[The command line](cli.md)** | The manual. Every subcommand and option, what each prints on the plain and JSON faces, the exit statuses, and recipes built on `jq`. |
|
||||
| [The CLI cookbook](cookbook.md) | Common one-liners by task, for when you want the shape of a command rather than its specification. |
|
||||
| [Configuring MeshTerm](configuration.md) | Preferences versus config, device profiles, and everything kept under `~/.meshterm`. |
|
||||
| [Terminals, icons, and the Windows console](terminals.md) | Why the icons and charts draw on some terminals and not others, and what MeshTerm does about it on Windows. |
|
||||
|
||||
## Putting it on hardware
|
||||
|
||||
Only if you want MeshTerm running on a handheld's own screen. A MeshCore companion on
|
||||
USB, Bluetooth or Wi-Fi needs none of this.
|
||||
|
||||
| Read | For |
|
||||
| --- | --- |
|
||||
| **[MeshTerm on hardware](hardware.md)** | Start here — which of the two manuals below is yours, and what the device-side scripts do. |
|
||||
| [MeshTerm on the PicoCalc](picocalc.md) | A stock ClockworkPi PicoCalc to a Linux handheld with a LoRa radio soldered inside: the shopping list, the Lyra swap, Calculinux, and the radio. |
|
||||
| [MeshTerm on the uConsole](uconsole.md) | A uConsole with the hackergadgets AIO LoRa board, whose SX1262 sits on the host's own SPI bus, fronted as a companion by a small bridge. |
|
||||
|
||||
## Reading the code
|
||||
|
||||
| Read | For |
|
||||
| --- | --- |
|
||||
| [How the code is laid out](architecture.md) | The layering, and where a new feature goes. |
|
||||
| [CLAUDE.md](../CLAUDE.md) | The actual style guide — terminology, UX standards, and the reusable building blocks. Written for AI assistants, and the house rules for everyone. |
|
||||
| [CONTRIBUTING.md](../CONTRIBUTING.md) | The dev install, the three gates a change has to pass, and what happens to your contribution's licensing. |
|
||||
|
||||
## Historical record
|
||||
|
||||
A survey of the code, kept because a project that writes down its own problems is easier
|
||||
to trust than one that doesn't — but it is a **snapshot of a date, not a to-do list**.
|
||||
Some of what it describes has been fixed since, and it does not mark which.
|
||||
|
||||
| Read | For |
|
||||
| --- | --- |
|
||||
| [UI consistency survey](survey-ui-consistency.md) | Everything else in the CLAUDE.md UX standard — six sweeps found nothing, which is the useful part. |
|
||||
|
||||
## Also here
|
||||
|
||||
`screenshots/` holds the captures the README shows and the ones
|
||||
[meshterm.net](https://meshterm.net) builds its landing page from, so a file in there is
|
||||
referenced from more than one place.
|
||||
@@ -0,0 +1,23 @@
|
||||
# How the code is laid out
|
||||
|
||||
A map of the package for anyone reading it for the first time. The house rules that
|
||||
govern what goes where are in [CLAUDE.md](../CLAUDE.md), which is the real style guide;
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md) has the dev install and the gates a change has to
|
||||
pass.
|
||||
|
||||
MeshTerm is layered so a new feature is one file in `meshterm/tools/`:
|
||||
|
||||
```
|
||||
cli.py Typer app; no subcommand -> interactive menu
|
||||
context.py AppContext (console, config, repository, device) dependency container
|
||||
core/ Domain: models, preferences + device profiles, connection abstraction (+ mock)
|
||||
tools/ Pluggable "menu options"; each self-registers and gets logging for free
|
||||
services/ Background algorithms (monitor, trace, tx search, courier, watchtower) — no UI
|
||||
persistence/ SQLite schema, repository, structured logging
|
||||
ui/ Rich theme/widgets + the full-screen TUI (screens, dialogs, map, chat)
|
||||
```
|
||||
|
||||
Adding a feature means subclassing `Tool`, decorating it with `@register`, and
|
||||
implementing `run()`. The same registry builds both the CLI and the menu, and the base
|
||||
class wraps every execution in a logged `runs` row automatically.
|
||||
|
||||
+219
-70
@@ -13,13 +13,11 @@ It prints two ways, and the difference is who is reading.
|
||||
`2026-09-08T04:26:00-04:00`, and a route is drawn with arrows.
|
||||
- The **JSON face** (`--json`) is for a program, often on another machine and often later.
|
||||
It is the contract: typed values, absent spelled `null`, timestamps in UTC, keys that do
|
||||
not move. It works on **every** command — it used to reach two out of twenty.
|
||||
not move. It works on **every** command.
|
||||
|
||||
The CLI used to make one promise to both of them — that it was written to be read by
|
||||
`awk` — and every rule followed from that. That promise now belongs to `--json`, which
|
||||
keeps it properly, and the plain face is released from it. If you are splitting output on
|
||||
whitespace to feed a script, stop: `--json` exists so you do not have to, and the plain
|
||||
face is no longer holding still for you.
|
||||
The promise to be read by a program belongs to `--json`. The plain face makes no such
|
||||
promise. If you are splitting output on whitespace to feed a script, stop: `--json` exists
|
||||
so you do not have to, and the plain face may change between releases.
|
||||
|
||||
- [Running it](#running-it)
|
||||
- [Where MeshTerm keeps its state](#where-meshterm-keeps-its-state)
|
||||
@@ -57,8 +55,8 @@ With no command, MeshTerm launches the interactive menu instead.
|
||||
| `--ble ADDRESS` | Bluetooth address of a companion; selects the BLE transport. |
|
||||
| `--ble-pin PIN` | Pairing PIN, if the Bluetooth companion asks for one. |
|
||||
| `--tcp HOST[:PORT]` | Network address of a TCP companion; selects the TCP transport. Default port 5000. |
|
||||
| `--mock` | Use the built-in simulator instead of real hardware. Nothing transmits. |
|
||||
| `--db PATH` | Use this SQLite database instead of `~/.meshterm/meshterm.db`. **It moves the database and nothing else** — see [Where MeshTerm keeps its state](#where-meshterm-keeps-its-state). |
|
||||
| `--mock` | Use the built-in simulator instead of real hardware. Nothing transmits, and the run records to `meshterm-mock.db` rather than your real history. |
|
||||
| `--db PATH` | Use this SQLite database instead of `~/.meshterm/meshterm.db` (or `meshterm-mock.db`, under `--mock`). **It moves the database and nothing else** — see [Where MeshTerm keeps its state](#where-meshterm-keeps-its-state). |
|
||||
| `--json` | Print the answer as JSON instead of aligned text. |
|
||||
| `--absolute` | Print times as ISO-8601 instants rather than relative ages, for this run. |
|
||||
| `-q`, `--quiet` | Suppress console logging entirely (the log file still records). |
|
||||
@@ -97,6 +95,7 @@ of two directories from the same shell.
|
||||
| File | What it holds |
|
||||
| --- | --- |
|
||||
| `meshterm.db` | The history: every packet overheard, every trace walked, every message. **The one file `--db` moves.** |
|
||||
| `meshterm-mock.db` | The same, for the simulator: where a `--mock` run records when it doesn't name a database itself. Absent until you run one. |
|
||||
| `config.toml` | Machine setup — device profiles, where the database lives. Yours to write; MeshTerm only reads it. |
|
||||
| `preferences.toml` | Your overrides of MeshTerm's own behaviour. Lists only what you changed. |
|
||||
| `courier.json` | The outbox — queued messages, waiting and finished. |
|
||||
@@ -114,14 +113,16 @@ weight but none of the identity: the contact and channel caches, the outbox, the
|
||||
admin passwords and the remembered device all live beside it in the config directory and
|
||||
would still be the ones you use every day. To run against a scratch state — a test, a
|
||||
demo, a second radio — set `MESHTERM_HOME`, and set `--db` inside it if you want the
|
||||
database somewhere else again.
|
||||
database somewhere else again. The same holds for the database `--mock` picks for itself:
|
||||
it keeps the simulator's invented mesh out of your history, not out of the caches.
|
||||
|
||||
---
|
||||
|
||||
## About the examples
|
||||
|
||||
**Every sample below was captured from a real run against the built-in simulator**
|
||||
(`--mock`), in a throwaway `MESHTERM_HOME`. None of it is typed out by hand. The simulator
|
||||
(`--mock`), in a throwaway `MESHTERM_HOME`, except the `diagnostics` sample, which says so
|
||||
where it appears. None of it is typed out by hand. The simulator
|
||||
answers as a companion would — it has four contacts (`Alice`, `Yagi-Repeater`,
|
||||
`Local-Repeater`, `Observer-Bot`), it adverts, it replies to traces — so the shapes are
|
||||
real even where the numbers are invented.
|
||||
@@ -140,11 +141,13 @@ Three things the simulator does that a radio does not, all visible in the sample
|
||||
*forced* path (`--path "a1,d4"`) resolves properly. The forced form is what the samples
|
||||
use, and what to build a golden file from.
|
||||
|
||||
> **`--mock` records into your real history unless you tell it not to.** The simulator is
|
||||
> a fake radio, not a fake MeshTerm: what it adverts is written to the database and the
|
||||
> caches like anything else, and invented nodes then turn up in the mesh walk, the
|
||||
> dashboard and the map. Give it its own home — `MESHTERM_HOME=... meshterm --mock …` —
|
||||
> or its own database with `--db`, and it leaves your mesh alone.
|
||||
> **`--mock` records into its own history, not yours — but it still writes the caches.**
|
||||
> The simulator is a fake radio, not a fake MeshTerm: what it adverts is written down like
|
||||
> anything else. The database it writes is `meshterm-mock.db`, so its invented nodes stay
|
||||
> out of your mesh walk, dashboard and map; naming a database with `--db` overrides that
|
||||
> choice. The contact and channel caches, the outbox and the remembered devices are still
|
||||
> the ones you use every day, so a demo that should touch nothing at all gets a home of
|
||||
> its own: `MESHTERM_HOME=... meshterm --mock …`.
|
||||
|
||||
---
|
||||
|
||||
@@ -172,11 +175,10 @@ Yagi-Repeater repeater 7m - a1 45.50190,-73.56740 a1b2c3d40000000
|
||||
Alice node 7m - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000
|
||||
```
|
||||
|
||||
Names used to be quoted, so that `awk` could split on the quote and get one field out of a
|
||||
name holding a space. That bought one reader a delimiter and cost every other reader a
|
||||
line full of punctuation. The **escaping under the quoting stays and always will**: a node
|
||||
broadcasts its own name and a stranger fills in a message body, so neither may hold a raw
|
||||
control character or end the record it sits in.
|
||||
Names are not quoted. Quoting would give a script a delimiter and give every person a
|
||||
line full of punctuation. Names **are escaped**: a node broadcasts its own name and a
|
||||
stranger fills in a message body, so neither may hold a raw control character or end the
|
||||
record it sits in.
|
||||
|
||||
**A time is an age**, because "recently?" is the question a listing is opened to answer:
|
||||
`now`, `5m`, `3h`, `never`. An absolute instant survives where the instant *is* the fact —
|
||||
@@ -186,11 +188,11 @@ the device clock, an appointment set with `--at`, a live capture's own `TIME` co
|
||||
|
||||
```console
|
||||
$ meshterm --absolute contacts
|
||||
NAME TYPE HEARD PKTS HASH LOCATION KEY
|
||||
Observer-Bot node 2026-09-08T04:30:50-04:00 - c3 45.48800,-73.58100 c3d4e5f600000000000000000000000000000000000000000000000000000000
|
||||
Local-Repeater repeater 2026-09-08T04:30:49-04:00 - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000
|
||||
Yagi-Repeater repeater 2026-09-08T04:30:49-04:00 - a1 45.50190,-73.56740 a1b2c3d400000000000000000000000000000000000000000000000000000000
|
||||
Alice node 2026-09-08T04:30:48-04:00 - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000
|
||||
NAME TYPE HEARD PKTS HASH LOCATION KEY
|
||||
Observer-Bot companion 2026-09-08T04:30:50-04:00 - c3 45.48800,-73.58100 c3d4e5f600000000000000000000000000000000000000000000000000000000
|
||||
Local-Repeater repeater 2026-09-08T04:30:49-04:00 - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000
|
||||
Yagi-Repeater repeater 2026-09-08T04:30:49-04:00 - a1 45.50190,-73.56740 a1b2c3d400000000000000000000000000000000000000000000000000000000
|
||||
Alice companion 2026-09-08T04:30:48-04:00 - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000
|
||||
```
|
||||
|
||||
The flag is an **override of a preference**, not a switch beside one: `cli_time_format` is
|
||||
@@ -256,12 +258,12 @@ last so it can push nothing.
|
||||
**No wrapping, with one exception.** A record is a line; wrapping would put half a
|
||||
record's fields under the wrong headings, so nothing folds and a long line runs off the
|
||||
right. The exception is the four written pages (`about`, `about-author`, `discord`,
|
||||
`support`), which now wrap at 72 cells. The no-wrap rule exists to protect a record's
|
||||
`support`), which wrap at 72 cells. The no-wrap rule exists to protect a record's
|
||||
fields; a paragraph has no fields, and unwrapped it is a 600-cell line no terminal can
|
||||
read.
|
||||
|
||||
**stdout is the answer; everything else is stderr** — errors (`meshterm: what went
|
||||
wrong`), progress bars, log records, and now the two things that used to be thrown away:
|
||||
wrong`), progress bars, log records, acknowledgements and closing messages:
|
||||
|
||||
```console
|
||||
$ meshterm config set radio_sf 9
|
||||
@@ -271,10 +273,9 @@ $ meshterm config set radio_sf 9 2>&1
|
||||
```
|
||||
|
||||
An **acknowledgement** ("✓ device clock set") and a command's closing **message** ("0
|
||||
records stored") are reassurance a person needs and a script does not. They used to be
|
||||
dropped on the CLI entirely. Sending them to stderr keeps the promise the dropping was
|
||||
made to keep — `meshterm contacts > contacts.txt` still puts contacts in the file and
|
||||
nothing else — while giving the person at the prompt back their tick and their count.
|
||||
records stored") are reassurance a person needs and a script does not. Sending them to
|
||||
stderr means `meshterm contacts > contacts.txt` puts contacts in the file and nothing
|
||||
else, while the person at the prompt still sees their tick and their count.
|
||||
|
||||
---
|
||||
|
||||
@@ -373,7 +374,7 @@ present.
|
||||
| `name` | string \| null | The name the node advertises. Never quoted, never truncated. |
|
||||
| `key` | string \| null | The full public key, lowercase hex, 64 characters. `null` where only a hash was ever heard. |
|
||||
| `hash` | string \| null | The short derived id, at the width the surrounding surface addressed the node by. Never `key` cut short. |
|
||||
| `type` | string \| null | `"node"` \| `"repeater"` \| `"room"` \| `"sensor"` \| `null`. |
|
||||
| `type` | string \| null | `"companion"` \| `"repeater"` \| `"room server"` \| `"sensor"` \| `null`. |
|
||||
| `self` | boolean | `true` for our own node. What the menu's star says and the plain face deliberately does not. |
|
||||
|
||||
Reception facts — when heard, how strong, where — belong to the **row**, not to the node:
|
||||
@@ -424,7 +425,7 @@ The return value is half the report, on both faces.
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `0` | Success. |
|
||||
| `1` | Failure with no more specific code — an unreadable file, an unhandled fault. |
|
||||
| `1` | Failure with no more specific code — an unreadable file, an unknown setting or preference key (`config set`, `preferences set`), an unhandled fault. |
|
||||
| `2` | Usage error: an unknown flag, a missing argument, a value the parser rejected, a confirmation not given. |
|
||||
| `3` | No companion device could be selected: none attached, the named one could not be opened, or the choice was ambiguous. **Nothing was transmitted.** |
|
||||
| `4` | A device was reached but the operation failed — a command error, a timeout, a link lost mid-run. Retrying is reasonable. |
|
||||
@@ -432,9 +433,8 @@ The return value is half the report, on both faces.
|
||||
|
||||
The table is printed under `meshterm --help` as well.
|
||||
|
||||
Two boundaries are worth stating because both used to sit in the wrong place. **Nothing
|
||||
transmitted is not a device failure**: a value the tool refuses before it opens the radio
|
||||
is a usage error, so a missing `--yes`, a bad `--sort` or `--category`, an unknown
|
||||
Two boundaries need stating. **Nothing transmitted is not a device failure**: a value
|
||||
the tool refuses before it opens the radio is a usage error, so a missing `--yes`, a bad `--sort` or `--category`, an unknown
|
||||
`--profile`, an outbox id that does not exist, and `tx-optimize` with no password
|
||||
available are all `2`. And **a named connection that will not open is `3`, not `4`**: a
|
||||
`--port` that is not there means no device was selected, and nothing went out.
|
||||
@@ -617,11 +617,11 @@ table has no room for.
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `target` | string | What `--port` / `--ble` take, verbatim. |
|
||||
| `transport` | string | `"serial"` \| `"ble"` \| `"tcp"`. |
|
||||
| `port` | string \| null | The serial port, for a serial device. |
|
||||
| `transport` | string | `"serial"` \| `"ble"` \| `"tcp"`, or `"mock"` under `--mock`. |
|
||||
| `port` | string \| null | The serial port, for a serial device. `""` on the `--mock` row. |
|
||||
| `address` | string \| null | The Bluetooth address, for a BLE device. |
|
||||
| `label` | string | The OS's name for the device, or the confirmed node name where we have one. |
|
||||
| `hardware` | string \| null | The confirmed hardware model, else the USB vendor label. |
|
||||
| `hardware` | string \| null | The confirmed hardware model, else the USB vendor label. `""` on the `--mock` row. |
|
||||
| `meshcore` | string | `"yes"` \| `"maybe"` \| `"no"`. Not a boolean — rounding a hint up to `true` would be a lie. |
|
||||
| `confidence` | string | `"board"` \| `"bridge"` \| `"unknown"` — what a `"maybe"` was derived from. |
|
||||
| `serial_number` | string \| null | |
|
||||
@@ -630,6 +630,9 @@ table has no room for.
|
||||
| `remembered` | boolean | Whether it is the remembered default. |
|
||||
| `active` | boolean | Whether this invocation is (or would be) using it. |
|
||||
|
||||
Under `--mock` there is no scan. The list is one row for the simulator: `target`
|
||||
`"--mock"`, `transport` `"mock"`, `stable_id` `"mock:simulator"`.
|
||||
|
||||
Returns `5` when nothing is found.
|
||||
|
||||
---
|
||||
@@ -682,10 +685,11 @@ always present:
|
||||
{"name":"MockCompanion","public_key":"0000000000000000000000000000000000000000000000000000000000000000","role":"companion","model":"MeshCore Simulator","firmware":"mock mock","battery_v":4.1,"storage_used_kb":128,"storage_total_kb":1024,"clock_at":"2026-09-08T08:24:42Z","clock_drift_s":-125,"uptime_s":93784,"noise_floor_dbm":-110,"last_rssi_dbm":-62,"last_snr_db":9.5,"tx_air_s":42,"rx_air_s":360,"packets_sent":210,"packets_received":1234,"receive_errors":3}
|
||||
```
|
||||
|
||||
`role` is `"companion"` \| `"repeater"` \| `"room server"` \| `"sensor"` \| `null`. This is
|
||||
the one place the two faces disagree about absence on purpose: plain omits a key the
|
||||
firmware never answered for, JSON writes `null`, because a variable key set costs every
|
||||
consumer a lookup guard on every field.
|
||||
`role` is `"companion"` \| `"repeater"` \| `"room server"` \| `"sensor"` \| `null`, or
|
||||
`"type N"` for an advert type MeshTerm does not know. This is the one place the two faces
|
||||
disagree about absence on purpose: plain omits a key the firmware never answered for, JSON
|
||||
writes `null`, because a variable key set costs every consumer a lookup guard on every
|
||||
field.
|
||||
|
||||
`info` does not embed a `node` object — it is our own node in far more detail than the
|
||||
shape carries, and `name` / `public_key` here are its whole identity. What the radio is
|
||||
@@ -724,10 +728,12 @@ radio_bw 62.5
|
||||
radio_sf 8
|
||||
radio_cr 5
|
||||
tx_power 20
|
||||
client_repeat false
|
||||
airtime_factor 0.0
|
||||
rx_delay 0.0
|
||||
manual_add_contacts false
|
||||
autoadd_config 0
|
||||
autoadd_max_hops 0
|
||||
flood_scope ""
|
||||
adv_loc_policy 0
|
||||
multi_acks 0
|
||||
@@ -788,7 +794,7 @@ $ meshterm config advert-cadence 6 --json
|
||||
$ meshterm config sync-clock --json
|
||||
{"changes":1,"set_at":"2026-09-08T08:29:27Z","drift_s":-125}
|
||||
$ meshterm config backup node.toml --json
|
||||
{"path":"D:\\vibe\\MeshTerm\\node.toml","settings":20,"channels":0,"custom":0}
|
||||
{"path":"node.toml","settings":22,"channels":0,"custom":0}
|
||||
$ meshterm config restore node.toml --dry-run --json
|
||||
{"dry_run":true,"changes":0}
|
||||
$ meshterm config export-key --json
|
||||
@@ -801,8 +807,8 @@ $ meshterm config share --json
|
||||
|
||||
`previous` is `null` where the snapshot held no prior value; `changes` is `0` and
|
||||
`applied` is `[]` where the value already was what was asked for, and the exit stays `0`
|
||||
because the command did what it was asked. `backup`'s `path` is the file actually written
|
||||
(the plain face prints it bare, so a caller can keep it). `export-key` carries a private
|
||||
because the command did what it was asked. `backup`'s `path` is the file written, as you
|
||||
named it (the plain face prints it bare, so a caller can keep it). `export-key` carries a private
|
||||
key, exactly as the plain face does — that is what the command was asked for, and no extra
|
||||
gate is introduced here. `factory-reset --yes` answers `{"factory_reset":true}`.
|
||||
|
||||
@@ -823,20 +829,20 @@ The contacts your device knows.
|
||||
|
||||
```console
|
||||
$ meshterm contacts --sort name
|
||||
NAME TYPE HEARD PKTS HASH LOCATION KEY
|
||||
Alice node 7m - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000
|
||||
Local-Repeater repeater 7m - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000
|
||||
Observer-Bot node 7m - c3 45.48800,-73.58100 c3d4e5f600000000000000000000000000000000000000000000000000000000
|
||||
Yagi-Repeater repeater 7m - a1 45.50190,-73.56740 a1b2c3d400000000000000000000000000000000000000000000000000000000
|
||||
NAME TYPE HEARD PKTS HASH LOCATION KEY
|
||||
Alice companion 7m - d4 - d4e5f6a700000000000000000000000000000000000000000000000000000000
|
||||
Local-Repeater repeater 7m - b2 45.47680,-73.59900 b2c3d4e500000000000000000000000000000000000000000000000000000000
|
||||
Observer-Bot companion 7m - c3 45.48800,-73.58100 c3d4e5f600000000000000000000000000000000000000000000000000000000
|
||||
Yagi-Repeater repeater 7m - a1 45.50190,-73.56740 a1b2c3d400000000000000000000000000000000000000000000000000000000
|
||||
```
|
||||
|
||||
`TYPE` is the node's advertised role in words — `node`, `repeater`, `room`, `sensor`, or
|
||||
`unknown`. `HEARD` is a relative age, because "recently?" is what this listing is opened to
|
||||
answer; `--absolute` turns it into an instant. `PKTS` is how many packets passive
|
||||
`TYPE` is the node's advertised role in words — `companion`, `repeater`, `room server`,
|
||||
`sensor`, or `unknown`. `HEARD` is a relative age, because "recently?" is what this listing
|
||||
is opened to answer; `--absolute` turns it into an instant. `PKTS` is how many packets passive
|
||||
monitoring has overheard.
|
||||
|
||||
**`HASH` is the token `--path` and `--to` take.** It used to have to be sliced out of `KEY`
|
||||
by hand, and getting the width wrong meant addressing a different node. It is exactly the
|
||||
**`HASH` is the token `--path` and `--to` take**, so it never has to be sliced out of `KEY`
|
||||
by hand, where getting the width wrong would address a different node. It is exactly the
|
||||
device's path-hash width. **`LOCATION`** is a fact the model always carried and no column
|
||||
had room for. `KEY` stays full and last, so it runs off the right harmlessly and is never
|
||||
elided: a truncated key is not something a caller can hand back.
|
||||
@@ -847,7 +853,7 @@ far more detail than a row could hold.
|
||||
**`--json`** is an array of rows, each embedding the `node` shape.
|
||||
|
||||
```json
|
||||
{"node":{"name":"Observer-Bot","key":"c3d4e5f600000000000000000000000000000000000000000000000000000000","hash":"c3","type":"node","self":false},"heard_at":"2026-09-08T08:30:50Z","packets":null,"position":{"lat":45.488,"lon":-73.581}}
|
||||
{"node":{"name":"Observer-Bot","key":"c3d4e5f600000000000000000000000000000000000000000000000000000000","hash":"c3","type":"companion","self":false},"heard_at":"2026-09-08T08:30:50Z","packets":null,"position":{"lat":45.488,"lon":-73.581}}
|
||||
```
|
||||
|
||||
| Field | Type | Meaning |
|
||||
@@ -876,7 +882,6 @@ TIME NODE SNR_DB RSSI_DBM LOCATION NAME
|
||||
2026-09-08T04:26:00-04:00 b2c3d4e5 +9.5 -98 45.47680,-73.59900 Local-Repeater
|
||||
2026-09-08T04:26:00-04:00 c3d4e5f6 +4.9 -93 - Observer-Bot
|
||||
2026-09-08T04:26:00-04:00 d4e5f6a7 +7.4 -86 - Alice
|
||||
|
||||
NODE NAME PKTS MEDIAN_SNR_DB BEST_SNR_DB RSSI_DBM HEARD LOCATION
|
||||
d4e5f6a7 Alice 149 +6.9 +14.1 -93 now -
|
||||
c3d4e5f6 Observer-Bot 148 +5.7 +13.0 -108 now -
|
||||
@@ -884,9 +889,10 @@ b2c3d4e5 Local-Repeater 132 +6.7 +14.9 -102 now 45.
|
||||
a1b2c3d4 Yagi-Repeater 132 +6.0 +13.6 -90 now 45.50190,-73.56740
|
||||
```
|
||||
|
||||
Two blocks, separated by a blank line. The **stream** is a header and then a record per
|
||||
packet as it arrives; its lanes are pinned in advance, because a capture cannot measure a
|
||||
column it has not seen yet, and `TIME` is an absolute instant rather than an age — on a
|
||||
Two blocks, one straight after the other: the summary starts at its own `NODE` header
|
||||
line, with no blank line before it. The sample shows only the first four stream records.
|
||||
The **stream** is a header and then a record per packet as it arrives; its lanes are
|
||||
pinned in advance, because a capture cannot measure a column it has not seen yet, and `TIME` is an absolute instant rather than an age — on a
|
||||
live tail every row would otherwise read `now`. The **summary** is the aggregate the
|
||||
stream cannot give: a count, a median, a best per node, most recently heard first.
|
||||
|
||||
@@ -968,7 +974,7 @@ prints, honestly.
|
||||
| `lower_bound` | boolean | **JSON only.** `true` where the score is a floor — the plain face's `>=` prefix, extracted, so a consumer comparing scores need not string-match a qualifier off the front of its own data. |
|
||||
| `recorded_at` | timestamp | |
|
||||
| `version` | string | The MeshTerm version that discovered it. |
|
||||
| `path` | string | **JSON only.** The transmitted spec, comma-separated hex — what a caller re-walks with. It has no column because a route line already runs off the right. |
|
||||
| `path` | string \| null | **JSON only.** The transmitted spec, comma-separated hex — what a caller re-walks with. It has no column because a route line already runs off the right. `null` where the record stored no spec. |
|
||||
| `route` | array | The `route` shape, hop-aligned with `path`. |
|
||||
|
||||
Returns `5` and `[]` when no records match.
|
||||
@@ -1044,7 +1050,7 @@ for working out whether messages are being pulled from the companion at all.
|
||||
|
||||
```console
|
||||
$ meshterm chat send "see you there" --to Alice --json
|
||||
{"kind":"direct","node":{"name":"Alice","key":"d4e5f6a700000000000000000000000000000000000000000000000000000000","hash":"d4e5f6a7","type":"node","self":false},"channel":null,"sent":true,"acked":true}
|
||||
{"kind":"direct","node":{"name":"Alice","key":"d4e5f6a700000000000000000000000000000000000000000000000000000000","hash":"d4e5f6a7","type":"companion","self":false},"channel":null,"sent":true,"acked":true}
|
||||
|
||||
$ meshterm chat send "net in 5" --channel 0 --json
|
||||
{"kind":"channel","node":null,"channel":{"slot":0,"name":"#0","public":true,"hash":null},"sent":true,"acked":null}
|
||||
@@ -1329,7 +1335,7 @@ single transmission.
|
||||
objects, which matters most here: the two nodes are told apart by nothing else on the line.
|
||||
|
||||
```json
|
||||
{"tuning_node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"target":{"name":"Alice","key":"d4e5f6a700000000000000000000000000000000000000000000000000000000","hash":"d4e5f6a7","type":"node","self":false},"path":"a1b2c3d4,d4e5f6a7","optimal_tx_dbm":19,"target_snr_db":8.7,"reliability":1.0,"previous_tx_dbm":20,"applied":true,"levels":[{"tx_dbm":18,"target_snr_db":6.6,"successes":2,"samples":2},{"tx_dbm":19,"target_snr_db":8.7,"successes":4,"samples":4},{"tx_dbm":20,"target_snr_db":9.1,"successes":2,"samples":2},{"tx_dbm":21,"target_snr_db":7.6,"successes":2,"samples":2},{"tx_dbm":24,"target_snr_db":4.0,"successes":2,"samples":2},{"tx_dbm":27,"target_snr_db":-9.9,"successes":2,"samples":2},{"tx_dbm":28,"target_snr_db":-15.1,"successes":1,"samples":2}]}
|
||||
{"tuning_node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"target":{"name":"Alice","key":"d4e5f6a700000000000000000000000000000000000000000000000000000000","hash":"d4e5f6a7","type":"companion","self":false},"path":"a1b2c3d4,d4e5f6a7","optimal_tx_dbm":19,"target_snr_db":8.7,"reliability":1.0,"previous_tx_dbm":20,"applied":true,"levels":[{"tx_dbm":18,"target_snr_db":6.6,"successes":2,"samples":2},{"tx_dbm":19,"target_snr_db":8.7,"successes":4,"samples":4},{"tx_dbm":20,"target_snr_db":9.1,"successes":2,"samples":2},{"tx_dbm":21,"target_snr_db":7.6,"successes":2,"samples":2},{"tx_dbm":24,"target_snr_db":4.0,"successes":2,"samples":2},{"tx_dbm":27,"target_snr_db":-9.9,"successes":2,"samples":2},{"tx_dbm":28,"target_snr_db":-15.1,"successes":1,"samples":2}]}
|
||||
```
|
||||
|
||||
`reliability` is a fraction in `[0, 1]`, not a formatted `"1.00"`. `levels` is ordered by
|
||||
@@ -1405,20 +1411,33 @@ runs `show`.
|
||||
```console
|
||||
$ meshterm preferences show
|
||||
PREFERENCE VALUE DEFAULT DESCRIPTION
|
||||
set_clock_on_connect off off Set the device clock from this computer when it connects
|
||||
trace_cooldown_s 5 5 Wait this long before transmitting again
|
||||
flood_advert_cooldown_s 60 60 Extra wait before another mesh-wide advert
|
||||
direct_message_soft_retries 0 0 Times to resend a message that gets no reply
|
||||
tx_opt_min 18 18 Weakest power it will try
|
||||
tx_opt_max 28 28 Strongest power it will try
|
||||
tx_snr_tolerance_db 1 1 Signals this close are a tie, so less power wins
|
||||
watch_silence_hours 12 12 Warn when a watched node goes quiet this long
|
||||
watch_alerts_kept 200 200 How many past alerts to keep
|
||||
map_view_fraction 0.5 0.5 Share of nodes to fit on screen; 1 shows them all
|
||||
basemap_tilejson_url https://tiles.openfreemap.org/planet https://tiles.openfreemap.org/planet Where map images come from (next launch)
|
||||
history_days 365 365 Older ones are deleted; 0 keeps everything
|
||||
chat_history_limit 200 200 Old messages to load when you open a chat
|
||||
courier_history_kept 100 100 Finished outbox messages to keep
|
||||
cli_time_format relative relative Whether the command line prints ages or timestamps
|
||||
fast_render on on Faster screen updates; turn off if it looks wrong (next launch)
|
||||
full_width auto auto Use the extra column some terminals hide
|
||||
color_depth auto auto How many colours to send this terminal (next launch)
|
||||
console_setup auto auto Move to a better terminal when this console can't draw MeshTerm
|
||||
console_font 6x12 6x12 Bigger text, or more rows on the handheld's panel
|
||||
log_level WARNING WARNING How much MeshTerm writes to its log file (next launch)
|
||||
```
|
||||
|
||||
**`DESCRIPTION` is back**, because a key's name is not its meaning and the alternative was
|
||||
opening the menu to find out what one does. Values print in the form `preferences set`
|
||||
accepts back — no unit suffix on a number, `on`/`off` for a boolean. Where `VALUE` differs
|
||||
from `DEFAULT`, this install has an override.
|
||||
**`DESCRIPTION`** says what each preference does, because a key's name is not its
|
||||
meaning. Values print in the form `preferences set` accepts back — no unit suffix on a
|
||||
number, `on`/`off` for a boolean. Where `VALUE` differs from `DEFAULT`, this install has an
|
||||
override.
|
||||
|
||||
Overrides live in `preferences.toml` in [MeshTerm's state
|
||||
directory](#where-meshterm-keeps-its-state), which lists only what you have changed; delete
|
||||
@@ -1460,7 +1479,7 @@ each link; the scripted face prints the link alone.
|
||||
**`--json`:**
|
||||
|
||||
```json
|
||||
{"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"]}
|
||||
{"page":"discord","title":"Join Discord","version":"0.9.0","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
|
||||
```
|
||||
|
||||
| Field | Type | Meaning |
|
||||
@@ -1476,9 +1495,139 @@ bothers to draw a QR of. `text` is the page's plain rendering, not a fold of the
|
||||
into nested JSON — a structured markdown tree would be a second rendering rather than data,
|
||||
and nothing would consume it.
|
||||
|
||||
#### `meshterm diagnostics`
|
||||
|
||||
Everything a bug report opens with, in one block: which MeshTerm, on which host, in which
|
||||
terminal, talking to which radio, over how much stored history. It is the answer to the
|
||||
three or four questions that otherwise get asked one at a time in an issue thread, and the
|
||||
person who hit the bug is usually the one least able to answer them — half of it is
|
||||
resolved at boot and shown on no screen.
|
||||
|
||||
This sample is the one exception to [About the examples](#about-the-examples): it is an
|
||||
illustrative report from a real radio over Bluetooth, because the simulator's report says
|
||||
little. The field names and layout are what the command prints; the values are examples.
|
||||
|
||||
```console
|
||||
$ meshterm diagnostics
|
||||
meshterm 0.9.0
|
||||
install frozen
|
||||
|
||||
os Windows 11
|
||||
os_build 10.0.26100
|
||||
arch AMD64
|
||||
python 3.13.1
|
||||
|
||||
terminal Windows Terminal
|
||||
term -
|
||||
colorterm truecolor
|
||||
terminal_size 120x30
|
||||
over_ssh no
|
||||
platform regular
|
||||
icons yes
|
||||
icons_source windows-terminal
|
||||
font Cascadia Mono PL (windows-terminal)
|
||||
powerline full (font:windows-terminal)
|
||||
|
||||
connected yes
|
||||
transport ble
|
||||
port -
|
||||
device_role companion
|
||||
device_model Heltec V3
|
||||
firmware v1.7.1 1c3f9a2
|
||||
radio_freq_mhz 906.8750
|
||||
radio_bw_khz 250.00
|
||||
radio_sf 10
|
||||
radio_cr 5
|
||||
tx_power_dbm 20
|
||||
error -
|
||||
|
||||
config_dir C:\Users\you\.meshterm
|
||||
db_size_kb 41280
|
||||
log_level DEBUG
|
||||
log_size_kb 96
|
||||
runs_failed 3
|
||||
first_heard 2026-01-14T19:42:10-05:00
|
||||
last_heard 2026-09-21T11:03:00-04:00
|
||||
|
||||
TABLE ROWS
|
||||
app_state 4
|
||||
discovered_paths 1287
|
||||
messages 3316
|
||||
neighbour_reports 214
|
||||
observations 418203
|
||||
path_candidates 96
|
||||
runs 912
|
||||
schema_meta 1
|
||||
trace_hops 233
|
||||
traces 47
|
||||
tx_samples 68
|
||||
|
||||
PREFERENCE VALUE
|
||||
log_level DEBUG
|
||||
history_days 730
|
||||
```
|
||||
|
||||
The seven groups are the page's seven sections — build, host, terminal, radio, storage,
|
||||
stored rows, changed preferences. The command line shows them as blank-line-separated
|
||||
records, because that face is a stream to grep; the page and the saved file show them as
|
||||
markdown headings, because that one is a document to read.
|
||||
|
||||
**The radio is asked but never required.** A report about a companion that will not connect
|
||||
is exactly the report most worth filing, so a failure to reach it becomes `connected no`
|
||||
with the radio's own words in `error`, and every other fact still arrives.
|
||||
|
||||
**Only overridden preferences are listed.** The defaults are in the source and identical for
|
||||
everyone; the two the reporter changed are the two that can explain anything.
|
||||
|
||||
**The mesh is described in aggregate and only in aggregate** — a row count per table, and
|
||||
the span of time the observations cover. The counts come off the schema rather than a list
|
||||
written down somewhere, so a table added later starts being reported the day it lands.
|
||||
|
||||
**Nothing in the block is private.** No pairing PIN, no admin password, no channel secret,
|
||||
no private key, no position, and no contact's name or key. That is a property of the
|
||||
feature and not a habit: the block is designed to be pasted in public by somebody who has
|
||||
not read it, and `tests/test_diagnostics.py` plants real secrets in every store that holds
|
||||
one and fails if any of them — or a field merely *named* like one — reaches either face.
|
||||
|
||||
Under `--json`, the five fact blocks merge into one flat object and the two listings keep
|
||||
a key of their own:
|
||||
|
||||
```json
|
||||
{"meshterm":"0.9.0","install":"frozen","os":"Windows 11","…":"…","tables":[{"table":"app_state","rows":4},"…"],"preferences":[{"preference":"log_level","value":"DEBUG"},{"preference":"history_days","value":"730"}]}
|
||||
```
|
||||
|
||||
**`--out PATH` writes it to a file instead of printing it**, and the answer becomes the
|
||||
path — the same trade `config export-key --out` makes, since a caller who asked for a file
|
||||
wants to be told where it is rather than handed the contents they just redirected into it.
|
||||
|
||||
```console
|
||||
$ meshterm diagnostics --out meshterm-diagnostics.md
|
||||
✓ diagnostics written — attach it to the report.
|
||||
meshterm-diagnostics.md
|
||||
```
|
||||
|
||||
**The file is markdown**, whichever face the run was printing, because an issue tracker
|
||||
renders markdown as written — no fence, no apology. Every value is a code span, so a
|
||||
Windows path's backslashes and a firmware error's asterisks arrive as themselves rather
|
||||
than as markdown. Redirection still works and is not replaced; `--out` is what gives you
|
||||
the document rather than the record stream.
|
||||
|
||||
In the menu this is the **Diagnostics** page under *This app*, and it is one of the app's
|
||||
**written pages** — the same markdown renderer behind the About pages, drawing the same
|
||||
document the file holds. That buys what a long block most needs: its `##` headings are
|
||||
landmarks, so each pins to the top row while its own section scrolls under it and
|
||||
`^PgUp`/`^PgDn` step section by section.
|
||||
|
||||
`s` saves, advertised in the footer hint on a desktop and on the lane's free **F3** slot on
|
||||
the PicoCalc — never in the page's body, which belongs to the report. It writes
|
||||
`meshterm-diagnostics.md` into the config directory beside the log, and answers in a popup
|
||||
naming the path. A write that fails says so in the same popup, in red, and leaves the page
|
||||
readable.
|
||||
|
||||
#### `meshterm platform`
|
||||
|
||||
A diagnostic for which UI flavour a given invocation resolves to, and why.
|
||||
The narrow sibling of `diagnostics`: which UI flavour a given invocation resolves to, and
|
||||
why, without asking the radio anything.
|
||||
|
||||
```console
|
||||
$ meshterm platform
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Configuring MeshTerm
|
||||
|
||||
Preferences, device profiles, and where everything is kept. You need none of it to run
|
||||
MeshTerm: it discovers a companion, lets you pick one, and remembers the choice.
|
||||
|
||||
Two files, two jobs — and you need neither to run.
|
||||
|
||||
**Preferences** are how MeshTerm behaves: the pause it leaves between transmissions, how
|
||||
hard it retries a message, how far back it keeps history, how a map frames itself. Every one has a built-in default, so change them only where you disagree — from
|
||||
the **Preferences** page in the menu (grouped, staged, saved by one action at the bottom),
|
||||
or from a shell:
|
||||
|
||||
```console
|
||||
$ meshterm preferences show # every preference, its value, its default
|
||||
$ meshterm preferences set history_days 90
|
||||
$ meshterm preferences reset --yes # back to the built-in defaults
|
||||
```
|
||||
|
||||
They are kept in `~/.meshterm/preferences.toml`, which lists only what you have changed;
|
||||
delete a line and the default takes over again.
|
||||
|
||||
**Config** is where things live and which device to talk to. MeshTerm runs with none of it
|
||||
— it discovers attached serial devices and nearby Bluetooth companions, lets you pick one,
|
||||
and remembers the last good default. A network (TCP) companion isn't discoverable, so reach
|
||||
it with `--tcp host:port`, a TCP profile, or the picker's "add a network device" prompt.
|
||||
Write a config file only to give your hardware stable aliases.
|
||||
|
||||
Copy [`config.example.toml`](../config.example.toml) to `~/.meshterm/config.toml`:
|
||||
|
||||
```toml
|
||||
# default_profile = "s3" # uncomment and name your own profile to make it the default
|
||||
connect_on_start = true # false opens the radio link lazily instead of at launch
|
||||
|
||||
[profiles.s3]
|
||||
port = "COM5"
|
||||
baudrate = 115200
|
||||
description = "XIAO ESP32-S3 + Wio SX1262 serial companion"
|
||||
|
||||
# A Bluetooth LE companion: give it an `address` instead of a `port`.
|
||||
[profiles.handheld]
|
||||
address = "AA:BB:CC:DD:EE:FF"
|
||||
# ble_pin = "123456" # only if your device requires a pairing PIN
|
||||
description = "Pocket handheld over Bluetooth"
|
||||
|
||||
# A network (TCP) companion: give it a `host` (and optional `tcp_port`, default 5000).
|
||||
[profiles.wifi]
|
||||
host = "192.168.1.50"
|
||||
description = "Basestation over WiFi"
|
||||
```
|
||||
|
||||
Both live in `~/.meshterm`, along with everything else MeshTerm remembers: the SQLite
|
||||
history, the outbox, the contact and channel caches, stored admin passwords, and the log.
|
||||
**`$MESHTERM_HOME` moves the whole directory**, which is the way to run a second radio —
|
||||
or a `--mock` session — without touching the one you use every day. `--db` moves the
|
||||
database alone; the rest stays where it was. A `--mock` run that names neither writes to
|
||||
`meshterm-mock.db` beside the real history, so the simulator's invented nodes never enter
|
||||
it. [`docs/cli.md`](cli.md#where-meshterm-keeps-its-state)
|
||||
lists every file.
|
||||
|
||||
Timestamps are stored as UTC and rendered in your local time. Heard packets older than the
|
||||
`history_days` preference are deleted when monitoring starts: once in each menu session,
|
||||
and at the start of each `meshterm monitor` run. Nothing else is pruned.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# The CLI cookbook
|
||||
|
||||
Common one-liners, by the thing you are trying to do. [The command line](cli.md) is the
|
||||
full manual — every command, every option, both output faces, the exit statuses — and its
|
||||
own [Recipes](cli.md#recipes) section goes further than this one, into `jq` pipelines and
|
||||
shell loops.
|
||||
|
||||
Nearly every menu option is also a subcommand — ideal for scripting, cron, and bots. The
|
||||
live pictures stay in the menu, where their meaning is (the map, dashboard, live feed,
|
||||
watchtower and mesh walk); everything else has a command, and every command speaks
|
||||
`--json`. **[`docs/cli.md`](cli.md) is the full manual**: every command and option,
|
||||
what each one prints on both faces, and the exit statuses. A taste:
|
||||
|
||||
```bash
|
||||
# Trace — a trace transmits exactly once; run it again to sample more.
|
||||
meshterm trace --target Alice --profile yagi
|
||||
|
||||
# Force a route through specific repeaters (MeshCore-app style): comma-separated
|
||||
# contact names and/or hex key prefixes, mixed freely. Blank lets the device route.
|
||||
meshterm trace --target Alice --path "3d,f2,3d"
|
||||
meshterm trace --target Alice --path "3d,Bravo-Repeater,f2"
|
||||
|
||||
# Walk a composed circuit with no target at all — out and back your own way
|
||||
meshterm trace-path --path "3d,f2,3d"
|
||||
|
||||
# Sweep and apply a remote node's TX power
|
||||
meshterm tx-optimize --path "Bravo-Repeater,Alice" --samples 6 --step 3 --apply
|
||||
|
||||
# Messaging: the live transcript is a menu screen; the CLI takes a subcommand
|
||||
meshterm chat send --to Alice "on my way" # direct message
|
||||
meshterm chat send --channel 0 "net in 5" # channel broadcast
|
||||
meshterm chat history --to Alice
|
||||
meshterm chat list
|
||||
|
||||
# Store-and-forward: queue for a contact that's offline right now
|
||||
meshterm courier queue Alice "ping me when you're back" --at 18:30
|
||||
meshterm courier list # the outbox — waiting and finished
|
||||
meshterm courier send 3 # force one delivery attempt now
|
||||
|
||||
# Remote repeater admin (one transmission per invocation). --password is needed
|
||||
# until an interactive login has stored one.
|
||||
meshterm repeater-admin Bravo-Repeater "get name" --password YOUR-ADMIN-PASSWORD
|
||||
|
||||
# Device configuration: view, set, back up, restore
|
||||
meshterm config # show all current settings, one `key value` per line
|
||||
meshterm config get name # just the value, ready for $(...)
|
||||
meshterm config set radio_sf 9 # change one setting
|
||||
meshterm config backup node.toml # archive every setting to TOML
|
||||
meshterm config restore node.toml --dry-run
|
||||
meshterm config advert-cadence 2 # auto-advert to neighbours every 2 h (0 = off)
|
||||
meshterm config advert-cadence 24 --flood # flood the wider mesh daily
|
||||
|
||||
# Passive capture window (records to history; transmits nothing)
|
||||
meshterm monitor --seconds 60
|
||||
```
|
||||
|
||||
Global options — `--profile/-p`, `--port`, `--ble`, `--ble-pin`, `--tcp`, `--mock`,
|
||||
`--db`, `--json`, `--absolute`, `--quiet/-q`, `--platform` — may be typed **before or
|
||||
after** the subcommand: `meshterm contacts --json` and `meshterm --json contacts` are the
|
||||
same run.
|
||||
|
||||
## Two output faces
|
||||
|
||||
*(The short version — [`docs/cli.md`](cli.md) has the whole of it.)*
|
||||
|
||||
The **plain face** is for a person at a prompt. It prints like a standard Unix utility —
|
||||
no colour, no borders, one record per line, nothing wrapped except the four written
|
||||
pages (`about`, `about-author`, `discord`, `support`), which wrap at 72 cells — and it is
|
||||
allowed to be comfortable about it: listings are `ps`-style aligned records with **bare names**
|
||||
(alignment is the delimiter), times are **relative ages** (`now`, `5m`, `never`), and a
|
||||
route is drawn with arrows — `MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4)`.
|
||||
`--absolute` swaps every age back for an ISO-8601 instant. A *path*, the spec `--path`
|
||||
takes back, stays comma-separated hex. `-` is the one token for absent. Errors,
|
||||
acknowledgements and progress all go to stderr, so a redirect catches only the answer.
|
||||
|
||||
The **JSON face** is the machine contract, and `--json` works on **every** command:
|
||||
|
||||
```console
|
||||
$ meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
|
||||
b2c3d4e500000000000000000000000000000000000000000000000000000000
|
||||
a1b2c3d400000000000000000000000000000000000000000000000000000000
|
||||
```
|
||||
|
||||
No envelope — an array for a listing, an object for a set of facts. One compact line;
|
||||
`monitor` and `chat listen` stream one document per record. Values are typed, absent is
|
||||
`null` and never an omitted key, and timestamps are always UTC to the second regardless of
|
||||
`--absolute`. `--json` changes the rendering, never the report: same records, same exit
|
||||
status.
|
||||
|
||||
Anything structural should go through `--json` and `jq`. The plain face is for looking at.
|
||||
|
||||
## Exit status
|
||||
|
||||
`0` success · `1` failure · `2` usage error · `3` no device found (nothing was
|
||||
transmitted) · `4` the device was reached but the operation failed · `5` nothing to
|
||||
report. The table with its full wording is printed under `meshterm --help` and explained
|
||||
in [`docs/cli.md`](cli.md#exit-status).
|
||||
|
||||
`5` is the one worth knowing about: it lets a script tell "found nothing" from "worked"
|
||||
without counting output lines.
|
||||
|
||||
```bash
|
||||
if meshterm contacts > contacts.txt; then
|
||||
echo "$(($(wc -l < contacts.txt) - 1)) contacts" # minus the header
|
||||
elif [ $? -eq 5 ]; then
|
||||
echo "no contacts yet"
|
||||
fi
|
||||
```
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# What MeshTerm does
|
||||
|
||||
Every screen the menu offers, and the command that does the same job without it. This is
|
||||
the catalogue; [the command line](cli.md) is the manual for the scripted half of it.
|
||||
|
||||
MeshTerm's menu asks one question — *what would you like to do?* — and its six sections
|
||||
answer it in two halves. The first three name a **doing**: Message, Watch, Explore. The
|
||||
last three name the **scope** a setting belongs to: This node, the radio in your hand;
|
||||
Other nodes, someone else's over the mesh; and This app, the program in front of you.
|
||||
|
||||
Every interactive screen is listed here with its scripted equivalent, where one exists.
|
||||
|
||||
## 💬 Message — the people on the other end
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **💬 Chat** | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. Every message is logged; unread counts show in the menu header. | `meshterm chat send / history / list` |
|
||||
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels — with QR codes and `meshcore://` share links. | `meshterm channels list / add / join / import / share / clear` |
|
||||
| **📨 Courier** | Store-and-forward outbox for contacts that aren't reachable yet. Queue a message; it goes out (with ack tracking and polite exponential backoff) the moment the contact is next heard, or at a scheduled time. | `meshterm courier queue / list / send / cancel / clear` |
|
||||
| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. The scripted listing is the contacts alone (this node is `meshterm info`'s answer, in far more detail). | `meshterm contacts` |
|
||||
|
||||
## 📊 Watch — what the mesh is doing, and what it did
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **📊 Dashboard** | The live mesh overview: a two-hour all-packet activity chart with a pulse line, session traffic tallies by packet class, and window RF health beside the radio's own live numbers. Repaints every second. | — |
|
||||
| **📰 Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* — the node for an advert, the channel for a channel text, sender → recipient for a direct message or a request, the tag for a trace — with reception quality beside it. Enter opens any row in the packet viewer, route graph and all. | — |
|
||||
| **🚨 Watchtower** | A passive sentinel over the nodes you star: silence alarms when a watched node goes quiet, SNR-sag warnings when reception degrades, recovery notes when it returns, and a heads-up when a never-before-seen node appears. Runs in the background all session; unacked alerts show as a header badge. | — |
|
||||
| **⏳ Time machine** | Everything the recorder ever heard, as braille charts: reception volume, the median-SNR band, hour-of-day rhythm, packets and nodes per day, and first-ever arrivals — over a switchable 24h / 7d / 30d / all-time window (on the PicoCalc the windows stop at 30d), per node or mesh-wide. | — |
|
||||
| **🎧 Monitor** | Passive recording is always on in the app. On the CLI, capture a bounded foreground window, tailing each overheard packet and summarising when it ends. It transmits nothing — it only listens. | `meshterm monitor --seconds 60` |
|
||||
|
||||
## 🧭 Explore — where the nodes are and how they reach each other
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | — (a map is a picture; `meshterm contacts` lists the same nodes) |
|
||||
| **🌐 Mesh walk** | The mesh's *observed shape*, walked one node at a time: an evidence graph built from trace walks, firmware routes, overheard relay chains, and repeater neighbour tables. SNR-coloured braille edges, quality bars, Enter to walk, ⌫ to backtrack, type to find any node. The map answers *where*; the walk answers *how it hangs together*. | — |
|
||||
| **🎯 Trace target** | A live trace screen: pick a target and watch each trace stream in hop by hop, with running per-hop medians and reliability. Compose or force a route through specific repeaters. A trace transmits **exactly once** (repeaters can blacklist nodes that burst) — sample more by running it again. | `meshterm trace --target …` |
|
||||
| **👣 Trace path** | The other half of tracing: compose the whole circuit by hand — out and back whichever way you choose — and walk it. The path composer suggests each next hop from the links actually observed, strongest first, and can fetch a repeater's neighbour table over the mesh when you hold its admin password. | `meshterm trace-path --path …` |
|
||||
| **🏆 Trophy case** | Every trace that comes home is scored, on seven boards: longest distance, farthest node, longest single leg, most nodes (with and without revisits), weakest surviving link, and biggest enclosed loop. Records are kept per hash width, and a record walk must be a *trail* — no link crossed twice the same way. | `meshterm records` |
|
||||
|
||||
## 🔧 This node — the radio in your hand
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **📋 Device info** | The connected companion's identity and full radio configuration at a glance. | `meshterm info` |
|
||||
| **🔧 Device config** | Every setting the companion firmware exposes — name, radio, client repeat, auto-add, telemetry, custom variables — *staged* for review and applied in place, plus the operations on the box itself: clock sync, TOML backup/restore, the identity key, reboot, and factory reset, each acting the moment it's confirmed, with destructive ops gated. Laid out like Repeater admin, so the radio in your hand and one over the mesh are configured the same way. | `meshterm config` / `config set <key> <value>` / `config backup` / `config reboot` / … |
|
||||
| **📡 Send advert** | Announce this node to the mesh — a zero-hop or flood advertisement, or share this node's contact card as a QR code. | `meshterm config advert` / `config share` |
|
||||
| **🔌 Devices** | Not a menu screen. The companion is picked on the startup splash, which enumerates serial *and* Bluetooth LE companions (or auto-selects the only one present), adds a network (TCP) companion by host:port, and remembers the last good default. A dropped link is detected live and offers to reconnect. `meshterm devices` lists what discovery finds. | `meshterm devices` |
|
||||
|
||||
## 🗼 Other nodes — someone else's radio, over the mesh
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **🗼 Repeater admin** | Set up remote repeaters and room servers over the mesh: log in (remembered or prompted password), then a config-style editor speaking the node's text CLI — including repeater-only knobs (TX delay, airtime factor, advert intervals) — plus one-shot actions and a readline remote command line. | `meshterm repeater-admin <node> <command…>` |
|
||||
| **📶 TX optimize** | Sweep a remote node's transmit power live — coarse, then refine, then verify — watch each level land, and decide whether to apply the winner. The scripted form applies the winner unless told not to. | `meshterm tx-optimize --path … [--no-apply]` |
|
||||
|
||||
## ⚙ This app — the program in front of you
|
||||
|
||||
The last scope, and the one nothing on the mesh can answer. Preferences leads it because
|
||||
it is the only row here that *changes* MeshTerm rather than describing it; the written
|
||||
pages close it.
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **⚙ Preferences** | How MeshTerm behaves, in eight groups: Device, Sending, TX optimize, Watchtower, Map, History, Display and Diagnostics. Grouped and staged, saved by one action at the bottom, and written to `preferences.toml` as only the values you changed. [Configuring MeshTerm](configuration.md) has the detail. | `meshterm preferences show / set / reset` |
|
||||
| **🩺 Diagnostics** | Everything a bug report opens with, in one block: which build this is and how it was installed, the OS, the terminal and its size, the boot-time verdicts that show on no screen (icons, powerline, platform flavour and why), the radio and its link, and how much history is stored. Nothing private — no keys, passwords, positions or contact names; the mesh is described as per-table counts and the span they cover. `s` saves it beside the log for attaching. | `meshterm diagnostics [--out PATH]` |
|
||||
| **📖 About MeshTerm** | What MeshTerm is, where it came from, and the terms it ships under — including the map and font credits and the licence URL. | `meshterm about` |
|
||||
| **👤 About the author** | The person behind MeshTerm, on the mesh and off it. | `meshterm about-author` |
|
||||
| **🔗 Join Discord** | The community server: one invite link, and a QR of it for a phone to read. | `meshterm discord` |
|
||||
| **💰 Support MeshTerm** | What keeps MeshTerm going, and the ways — paid and unpaid — to help it along. | `meshterm support` |
|
||||
+3
-3
@@ -36,10 +36,10 @@ directly, or a pump in front of it, never both.
|
||||
| File | Runs on | What it does |
|
||||
| --- | --- | --- |
|
||||
| [`picocalc/calculinux-setup.sh`](../scripts/picocalc/calculinux-setup.sh) | the Lyra (root) | One-time bring-up: python packages, the Wi-Fi boot-scan kick, a boot clock sync (there is no RTC), the deploy user, the checkout, the venv and `pip install -e .`, the login PATH, and the console font. Idempotent. |
|
||||
| [`picocalc/calculinux-console-font-6x12.sh`](../scripts/picocalc/calculinux-console-font-6x12.sh) | the Lyra (root) | Just the console font: builds, installs and persists the 512-glyph **meshterm** PSF (braille, node marks, rounded frame corners, the list cursor) and restores the stock 16-slot palette. Calls the 6×8 build below when it sits alongside. Run it alone after a MeshTerm update. |
|
||||
| [`picocalc/calculinux-console-font-6x12.sh`](../scripts/picocalc/calculinux-console-font-6x12.sh) | the Lyra (root) | Just the console font: builds, installs and persists the 512-glyph **meshterm** PSF (braille, node marks, rounded frame corners, the list cursor) and restores the stock 16-slot palette. Calls the 6×8 build below when it sits alongside. Run it alone after a MeshTerm update, from the pulled checkout (`/home/meshterm/MeshTerm/scripts/picocalc/`) rather than the copy made at install time. |
|
||||
| [`picocalc/calculinux-console-font-6x8.sh`](../scripts/picocalc/calculinux-console-font-6x8.sh) | the Lyra (root) | The shorter-cell companion font, **meshterm8** (6×8 → 53×40), for anyone who wants more rows than the 6×12 default gives. Its base bitmap is the kernel's `font_6x8`, so this one file and the PSF it builds are **GPL-2.0-only** ([`LICENSE.GPL-2.0`](../scripts/picocalc/LICENSE.GPL-2.0) sits beside it); it reads the donor/alias/keeper tables out of the script above rather than keeping a second copy. Pick between the two fonts from MeshTerm's Preferences page. |
|
||||
| [`picocalc/calculinux-wifi-set.sh`](../scripts/picocalc/calculinux-wifi-set.sh) | the Lyra (sudo) | Interactive Wi-Fi join: prompts for an SSID and passphrase, writes iwd's credentials, connects now. This is how a network *becomes* known to the boot kick. |
|
||||
| [`picocalc/calculinux-radio-bridge.sh`](../scripts/picocalc/calculinux-radio-bridge.sh) | the Lyra (root) | **Unfinished, unsupported.** An early UART-to-TCP pump, superseded by `xiao-radio/lyra-setup.sh`; its header describes routing it does not contain. See the manual before reading it. |
|
||||
| [`picocalc/calculinux-radio-bridge.sh`](../scripts/picocalc/calculinux-radio-bridge.sh) | the Lyra (root) | **Unfinished, unsupported.** An early UART-to-TCP pump: it serves `/dev/ttyS1` on TCP port 5000 and writes a `radio` TCP profile. It does not route UART1 to the header pads. Superseded by `xiao-radio/lyra-setup.sh`, which does the routing and points MeshTerm at the serial port directly. |
|
||||
| [`picocalc/xiao-radio/`](../scripts/picocalc/xiao-radio/) | see below | Everything for the XIAO nRF52840 + Wio-SX1262 radio wired to the Lyra's UART1; its [bench card](../scripts/picocalc/xiao-radio/README.md) has the wiring table. The firmware is MeshCore, MIT-licensed — [`LICENSE.MeshCore`](../scripts/picocalc/xiao-radio/LICENSE.MeshCore) sits beside the patch. |
|
||||
| [`xiao-radio/build-firmware.sh`](../scripts/picocalc/xiao-radio/build-firmware.sh) | dev machine | Clones and patches MeshCore, builds the `Xiao_nrf52_companion_radio_serial` environment, emits the `.uf2`. |
|
||||
| [`xiao-radio/flash.py`](../scripts/picocalc/xiao-radio/flash.py) | dev machine | Flashes the `.uf2` to the XIAO over its UF2 bootloader, refusing the wrong board or SoftDevice; prints the serial-DFU command when the bootloader offers no drive. |
|
||||
@@ -54,7 +54,7 @@ MeshTerm resolves one frozen **platform** at boot and draws everything through i
|
||||
**regular** platform is a desktop or ssh terminal: 72 readable columns, truecolour, emoji,
|
||||
a footer hint line. The **picocalc** platform is a framebuffer console: 53 columns, no
|
||||
emoji, an F-key lane instead of a hint line, and sixteen colour slots rather than a colour
|
||||
space — nothing loses its colour there, the node hue and the recency heat gradient
|
||||
space. Nothing loses its colour there: the node hue and the recency heat gradient
|
||||
quantise to those slots instead. It is detected from the device tree on the Lyra, and
|
||||
`--platform picocalc` or `MESHTERM_PLATFORM=picocalc` forces it anywhere, which is how you
|
||||
preview the handheld layout on a desktop:
|
||||
|
||||
+83
-57
@@ -65,9 +65,9 @@ a radio in the meantime.
|
||||
| Item | Product / what to search for | Approx. price | Link | Note |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| PicoCalc kit | ClockworkPi PicoCalc | $89 | [clockworkpi.com/product-page/picocalc](https://www.clockworkpi.com/product-page/picocalc) | Comes with a Raspberry Pi Pico 1H, screen, keyboard, shell, hex key and a 32 GB SD card. **18650 batteries are not included.** |
|
||||
| Luckfox Lyra | "Luckfox Lyra 128MB, pre-soldered header, no NAND" | $20–25 | [luckfox.com/Luckfox-Lyra](https://www.luckfox.com/Luckfox-Lyra) | Buy the **plain 128 MB RAM, Pico-form-factor** variant, **with the header pre-soldered**. See the note below — this is the one part of the list where the wrong SKU won't work. |
|
||||
| Luckfox Lyra | "Luckfox Lyra 128MB, pre-soldered header, no NAND" | $20–25 | [luckfox.com/Luckfox-Lyra](https://www.luckfox.com/Luckfox-Lyra) | Buy the **plain 128 MB RAM, Pico-form-factor** variant, **with the header pre-soldered**. Other RAM sizes have incompatible pinouts, so this is the one part of the list where the wrong SKU won't work. |
|
||||
| microSD card | 16 GB+, Class 10 or better | $6–10 | — | The PicoCalc's stock 32 GB card works fine — it's more than the 8 GB minimum. You can reuse it instead of buying a new one. |
|
||||
| Two 18650 batteries | e.g. Samsung 30Q, Molicel P26A, Sony VTC6 (examples, not endorsements) | $10–20 | — | **Unprotected**, flat-top or button-top, Ø18 × 65–69 mm. See [the battery note](#a-note-on-batteries) below. |
|
||||
| An 18650 battery | e.g. Samsung 30Q, Molicel P26A, Sony VTC6 (examples, not endorsements) | $5–10 | — | **Unprotected**, flat-top or button-top, Ø18 × 65–69 mm. One is enough to run; a second is optional (below). See [the battery note](#a-note-on-batteries) below. |
|
||||
| USB Wi-Fi dongle | TP-Link TL-WN725N (an RTL8188EU nano dongle), or search the **chipset name**: RTL8192CU, R8712U, RTL8188EU | $5–10 | [amazon.ca/dp/B008IFXQFU](https://www.amazon.ca/dp/B008IFXQFU) | The TL-WN725N is the dongle this guide was built with. Must run at **3.3 V** — see [Step 3](#step-3--attach-the-wi-fi-dongle). Only needed for setup and updates; MeshTerm itself runs offline. |
|
||||
| MX1.25 4-pin pigtail | "MX1.25 4P cable with leads" (a pack of pre-crimped pigtails, 10–15 cm) | $2–4 | — | One end of the DIY USB lead in [Step 3](#step-3--attach-the-wi-fi-dongle). Pre-crimped, because MX1.25 contacts need a crimper you don't otherwise own. |
|
||||
| USB-A female socket | "USB 2.0 type A female socket, solder / through-hole" | $2–3 | — | The other end of the DIY lead. A ready-made [MX1.25 4P to USB-A cable](https://spotpear.com/shop/Luckfox-Lyra-MX1.25-4P-To-USB-A-Cable.html) (about $1, 30 cm) works too, but it is a lot of cable to fold into the shell. |
|
||||
@@ -101,7 +101,7 @@ a radio in the meantime.
|
||||
| Foam tape | To secure the XIAO board inside the shell once it's wired in. |
|
||||
|
||||
Approximate total: **$130–150** for the machine and radio together, before batteries — most
|
||||
of that is the two boards ($89 + ~$22) and the radio kit ($13.49).
|
||||
of that is the PicoCalc kit ($89), the Lyra (~$22) and the radio kit ($13.49).
|
||||
|
||||
**What you do NOT need:**
|
||||
|
||||
@@ -213,8 +213,8 @@ machine it describes. Read this section before you open the shell.
|
||||
Follow ClockworkPi's own
|
||||
[assembly guidelines](https://github.com/clockworkpi/PicoCalc/blob/master/Clockwork_PicoCalc_Assembly_Guidelines.pdf)
|
||||
(a PDF in the [PicoCalc repository](https://github.com/clockworkpi/PicoCalc), which also
|
||||
has a wiki) to put the kit together: mainboard, screen, keyboard, speakers and shell. Insert your two
|
||||
18650 batteries, watching the `+`/`-` marks in the battery compartment.
|
||||
has a wiki) to put the kit together: mainboard, screen, keyboard, speakers and shell. Insert your 18650
|
||||
battery (or two), watching the `+`/`-` marks in the battery compartment.
|
||||
|
||||
⚠ Two of the warnings in [Three ways to break it](#three-ways-to-break-it) apply here: tape
|
||||
the screen's edges with Kapton before you close the shell and check it sits square, and
|
||||
@@ -311,14 +311,17 @@ Don't power the device on yet — Wi-Fi is set up later, once Calculinux is inst
|
||||
|
||||
### Step 4 — Write the image to the microSD
|
||||
|
||||
On your PC, download the latest `calculinux-image-luckfox-lyra.rootfs.wic.gz` from the
|
||||
[Calculinux releases page](https://github.com/Calculinux/meta-calculinux/releases).
|
||||
On your PC, download `calculinux-image-luckfox-lyra.rootfs-<timestamp>.wic.gz` from the
|
||||
newest release on the
|
||||
[Calculinux releases page](https://github.com/Calculinux/meta-calculinux/releases). Every
|
||||
Calculinux release is marked **Pre-release**, so there is no "Latest" badge to follow; take
|
||||
the newest pre-release.
|
||||
|
||||
Decompress it:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
gunzip calculinux-image-luckfox-lyra.rootfs.wic.gz
|
||||
gunzip calculinux-image-luckfox-lyra.rootfs-*.wic.gz
|
||||
```
|
||||
|
||||
(The Calculinux installation page mentions `unxz`, but the file is a `.gz`, so use
|
||||
@@ -331,11 +334,11 @@ Write the resulting `.wic` file to your microSD card. Three options:
|
||||
- **`dd`**, on Linux/macOS. Check the device name first — writing to the wrong device
|
||||
destroys its contents:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
lsblk # find your SD card's device, e.g. /dev/sdb
|
||||
sudo dd if=calculinux-image-luckfox-lyra.rootfs.wic of=/dev/sdX bs=4M status=progress conv=fsync
|
||||
```
|
||||
```bash
|
||||
# on the PC
|
||||
lsblk # find your SD card's device, e.g. /dev/sdb
|
||||
sudo dd if=calculinux-image-luckfox-lyra.rootfs-*.wic of=/dev/sdX bs=4M status=progress conv=fsync
|
||||
```
|
||||
|
||||
- **Rufus**, on Windows — select the device and the image, choose the **MBR** partition
|
||||
scheme, and start.
|
||||
@@ -345,6 +348,9 @@ This erases everything already on the card. Once it finishes, eject it and put i
|
||||
from its own slot; the front slot is only reachable once MeshTerm (or anything else) is
|
||||
already running on Linux.
|
||||
|
||||
Leave the front slot **empty** for the first boot, as Calculinux's own instructions warn: a
|
||||
second card there with duplicate partition labels leaves the system read-only.
|
||||
|
||||
### Step 5 — First boot
|
||||
|
||||
With the card in the Lyra, power the PicoCalc on. Give it 30–60 seconds — the first boot is
|
||||
@@ -417,30 +423,29 @@ the network, so this is a one-time step; to change networks later, run `uwific`
|
||||
### Step 8 — Copy the setup scripts to the device
|
||||
|
||||
You need this repository's `scripts/` directory on the Lyra — its `picocalc/` folder is the
|
||||
part the Lyra runs. Three ways to get it there, in order of ease:
|
||||
part the Lyra runs. Two ways to get it there, once Wi-Fi is up:
|
||||
|
||||
- **`scp` from your PC**, once Wi-Fi is up. Find the Lyra's IP address with `ip addr show
|
||||
wlan0` on the device, then from your PC:
|
||||
- **`scp` from your PC**. Find the Lyra's IP address with `ip addr show wlan0` on the
|
||||
device, then from your PC:
|
||||
|
||||
```bash
|
||||
# on the PC, from your MeshTerm checkout
|
||||
scp -r scripts root@<lyra-ip>:/root/
|
||||
```
|
||||
```bash
|
||||
# on the PC, from your MeshTerm checkout
|
||||
scp -r scripts root@<lyra-ip>:/root/
|
||||
```
|
||||
|
||||
If root is refused over SSH, copy as the `pico` user (password `calc`) instead, then
|
||||
move the directory as root on the device:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
scp -r scripts pico@<lyra-ip>:/home/pico/
|
||||
# on the PicoCalc, as root
|
||||
mv /home/pico/scripts /root/
|
||||
```
|
||||
```bash
|
||||
# on the PC
|
||||
scp -r scripts pico@<lyra-ip>:/home/pico/
|
||||
# on the PicoCalc, as root
|
||||
mv /home/pico/scripts /root/
|
||||
```
|
||||
|
||||
- **The microSD card.** Pull the card, copy `scripts/` onto it from your PC's card reader,
|
||||
put it back.
|
||||
- **`git clone` on the device.** Only once `git` is installed, and the setup script is what
|
||||
installs it — so this is a route for later updates, not the first copy.
|
||||
- **`git clone` on the device.** `git` ships in the Calculinux base image, so as root you
|
||||
can run `git clone https://github.com/jpmartineau/MeshTerm` and use the `scripts/`
|
||||
folder inside the clone.
|
||||
|
||||
`scp` is the recommended route.
|
||||
|
||||
@@ -452,30 +457,40 @@ cd scripts/picocalc
|
||||
sh calculinux-setup.sh
|
||||
```
|
||||
|
||||
It's idempotent — safe to re-run — and it checks before it acts. Two things it can't figure
|
||||
out on its own, both covered by environment variables read at the top of the script:
|
||||
It's idempotent — safe to re-run — and it checks before it acts. It reads these
|
||||
environment variables at the top of the script:
|
||||
|
||||
| Knob | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `DEPLOY_USER` | `meshterm` | the login MeshTerm runs under |
|
||||
| `TIMEZONE` | `America/Toronto` | any IANA zone |
|
||||
| `TIMEZONE` | `America/Toronto` | any IANA zone. Set it if you are not on Eastern time. |
|
||||
| `REPO_URL` | the project's HTTPS URL | cloned when there's no deploy key |
|
||||
| `REPO_SSH` | the project's SSH URL | cloned when there is one |
|
||||
| `KEY_PATH` | `/home/$DEPLOY_USER/.ssh/id_ed25519` | a read-only GitHub deploy key, if you want SSH instead of HTTPS |
|
||||
| `WIFI_SSID`, `WIFI_PSK` | unset | set both and the script writes `iwd`'s credentials for that network, as an alternative to Step 7 |
|
||||
|
||||
You don't need to set any of these for a normal install — the defaults clone MeshTerm's
|
||||
public repository over plain HTTPS, which needs no credential at all.
|
||||
The defaults clone MeshTerm's public repository over plain HTTPS, which needs no credential
|
||||
at all. The one you are likely to need is `TIMEZONE`, if you live outside Eastern time:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
TIMEZONE=America/Vancouver sh calculinux-setup.sh
|
||||
```
|
||||
|
||||
The script installs only the Americas time-zone data, so a zone elsewhere may not take. If
|
||||
it cannot set the zone, it warns and the clock stays on UTC.
|
||||
|
||||
What it does, one line per phase:
|
||||
|
||||
- **opkg packages** — installs `python3-modules`, `python3-pip`, `git`, `kbd`, because
|
||||
Calculinux ships a stripped Python missing `pip`, `venv` and several stdlib modules.
|
||||
- **opkg packages** — installs `python3-modules` and `python3-pip` (and `git` and `kbd`,
|
||||
if the image lacks them), because Calculinux ships a stripped Python missing `pip`,
|
||||
`venv` and several stdlib modules.
|
||||
- **Wi-Fi kick** — installs a boot-time service that re-scans until the dongle joins the
|
||||
network `iwd` already knows, because on a cold boot the dongle's firmware finishes
|
||||
loading after `iwd`'s first scan and the link would otherwise never come up.
|
||||
- **Time sync** — installs a boot-time service that sets the clock over the network, since
|
||||
this board has no battery-backed real-time clock.
|
||||
- **Timezone** — sets `$TIMEZONE` (Eastern by default).
|
||||
- **Timezone** — sets `$TIMEZONE` (`America/Toronto` by default).
|
||||
- **Deploy user** — creates the `meshterm` login and puts it in the groups it needs.
|
||||
- **Clone** — checks out MeshTerm to `/home/meshterm/MeshTerm`.
|
||||
- **venv and install** — builds a Python virtual environment and `pip install -e .`s
|
||||
@@ -493,9 +508,10 @@ password yet, so set one first:
|
||||
passwd meshterm
|
||||
```
|
||||
|
||||
> **With no Wi-Fi dongle attached, the next boot takes about six minutes** to get past the
|
||||
> Wi-Fi kick, which keeps trying before it gives up. Nothing is broken. If the device is
|
||||
> going to live offline, see [Day-to-day](#day-to-day) for how to switch the kick off.
|
||||
> **With no Wi-Fi dongle attached, the next boot can take up to about eleven minutes.**
|
||||
> The Wi-Fi kick keeps trying for about six before it gives up, and the time sync then
|
||||
> waits up to five more for a network route. Nothing is broken. If the device is going to
|
||||
> live offline, see [Day-to-day](#day-to-day) for how to switch both off.
|
||||
|
||||
### Step 10 — Log in as meshterm
|
||||
|
||||
@@ -521,7 +537,7 @@ Then look at the whole visual language on one card:
|
||||
meshterm specimen
|
||||
```
|
||||
|
||||
There are two console fonts available — a 6×12 one (53×26 rows) and a 6×8 one (53×40 rows,
|
||||
There are two console fonts available — a 6×12 one (53 × 26) and a 6×8 one (53 × 40,
|
||||
more rows but no Cyrillic glyphs). Try both from *Preferences → Display → Console font* —
|
||||
picking one previews it immediately, and the screen repaints at the new row count so you can
|
||||
see the choice before you keep it.
|
||||
@@ -776,8 +792,9 @@ default_tx_power = 22
|
||||
description = "XIAO nRF52840 + SX1262 via Lyra UART1 on GP4/GP5"
|
||||
```
|
||||
|
||||
If a `config.toml` already exists, it prints those lines instead of touching your file —
|
||||
paste them in yourself.
|
||||
If a `config.toml` already exists, it leaves your file untouched and prints only the
|
||||
`default_profile` line, the `[profiles.picocalc]` heading and the `port`. Copy the whole
|
||||
block above into your file yourself.
|
||||
|
||||
### Step 16 — Run it
|
||||
|
||||
@@ -788,7 +805,8 @@ Log out and back in as `meshterm` (so the `dialout` group membership takes effec
|
||||
meshterm
|
||||
```
|
||||
|
||||
Because `default_profile` is set, a bare `meshterm` connects straight to the radio. You
|
||||
A bare `meshterm` opens on the **Select a companion device** splash, with the `picocalc`
|
||||
profile listed as a row; pick it. To skip the splash, run `meshterm -p picocalc`. You
|
||||
should see your node come up under whatever name the firmware advertises — both the device
|
||||
page and the dashboard header show it.
|
||||
|
||||
@@ -815,39 +833,47 @@ through the Lyra's own USB-C at the back — see
|
||||
sudo poweroff
|
||||
```
|
||||
|
||||
**Updating MeshTerm.** Either re-run the setup script from root (it pulls the repository,
|
||||
reinstalls, and rebuilds the console font):
|
||||
**Updating MeshTerm.** The copy of `scripts/` you made in Step 8 goes stale as soon as
|
||||
the checkout moves on, so run the scripts inside the checkout instead. Either pull and
|
||||
re-run the setup script from root (it reinstalls and rebuilds the console font):
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
sh calculinux-setup.sh
|
||||
su - meshterm -c 'git -C ~/MeshTerm pull'
|
||||
sh /home/meshterm/MeshTerm/scripts/picocalc/calculinux-setup.sh
|
||||
```
|
||||
|
||||
or, as the `meshterm` user, do just the update:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as meshterm
|
||||
cd ~/MeshTerm && git pull && .venv/bin/pip install -e .
|
||||
cd ~/MeshTerm && git pull && TMPDIR=$HOME/tmp .venv/bin/pip install -e .
|
||||
```
|
||||
|
||||
If an update adds new glyphs to the console font, rebuild it on its own afterwards:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
sh calculinux-console-font-6x12.sh
|
||||
sh /home/meshterm/MeshTerm/scripts/picocalc/calculinux-console-font-6x12.sh
|
||||
```
|
||||
|
||||
**Changing Wi-Fi.** Run `uwific` as root, as in [Step 7](#step-7--join-wi-fi). The boot-time
|
||||
kick picks up the new network on its own; it only needs `iwd` to know it.
|
||||
|
||||
**The six-minute boot without a dongle.** If the device is going to run permanently offline,
|
||||
the Wi-Fi kick is wasted time on every boot. Disable it:
|
||||
**The slow boot without a dongle.** If the device is going to run permanently offline,
|
||||
the Wi-Fi kick and the time sync are wasted time on every boot. Disabling the kick is not
|
||||
enough, because the time sync pulls it back in, so mask it. Then disable the time sync,
|
||||
which would otherwise still wait up to five minutes for a network:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
systemctl disable --now wifi-kick.service
|
||||
systemctl mask --now wifi-kick.service
|
||||
systemctl disable --now time-sync.service
|
||||
```
|
||||
|
||||
To undo it later, `systemctl unmask wifi-kick.service` and
|
||||
`systemctl enable time-sync.service`.
|
||||
|
||||
**The console font restores itself.** MeshTerm saves whatever font the console was using
|
||||
before it starts, and puts it back when it exits — on a normal quit and on a crash alike.
|
||||
You don't need to do anything to keep the shell's own font intact.
|
||||
@@ -876,10 +902,10 @@ You don't need to do anything to keep the shell's own font intact.
|
||||
|
||||
This whole path — from a stock PicoCalc kit through a running radio — has been exercised on
|
||||
one maintainer's bench: one Luckfox Lyra, one PicoCalc, one XIAO + Wio-SX1262 pair. It
|
||||
works. Some of it is also checked in code: a test parses the font script and pins it to
|
||||
MeshTerm's glyph inventory, the profile keys the scripts write are tested, the pinned
|
||||
MeshCore commit exists, the patch's two hunks are still needed on `dev`, and the nRF52840
|
||||
UF2 family id and both XIAO bootloader ids match Adafruit's board files.
|
||||
works. One part is also checked in code: a test (`tests/test_theme16.py`) parses the font
|
||||
script and pins it to MeshTerm's glyph inventory. The pinned MeshCore commit, the need for
|
||||
the patch's two hunks on `dev`, and the nRF52840 UF2 family id and XIAO bootloader ids
|
||||
against Adafruit's board files were checked by hand when this was written.
|
||||
|
||||
The rest is one bench's findings, and a second device may disagree:
|
||||
|
||||
|
||||
@@ -1,485 +0,0 @@
|
||||
# Navigation survey — every exception to "Esc pops the stack"
|
||||
|
||||
> **A historical record.** This was a snapshot of the code on the date below, kept
|
||||
> because a project that writes down its own problems is easier to trust than one
|
||||
> that doesn't. Some of what's here has since been fixed and some hasn't; nothing in
|
||||
> it is a to-do list. Read it as "what this looked like then", not "what is wrong now".
|
||||
|
||||
Written 2026-08-22 against `main` @ `4b29248`. A catalogue, not a change: nothing here had
|
||||
been fixed at the time of writing. Each finding says where it is, what it does, and what
|
||||
makes it a question.
|
||||
|
||||
**Resolved since:** findings **E** and **§10** — 2026-08-29. Exit rows were retired
|
||||
app-wide: `back_rows()` is gone, `exit_rows()` draws nothing while clean, and every
|
||||
caller's three-way Back test collapsed to `is CANCEL` / `is None`. What is kept is stated
|
||||
in CLAUDE.md.
|
||||
|
||||
**Resolved 2026-08-30 — A, B, C, and the shortcut that made B possible.** Navigation is
|
||||
a strict stack now; the rules are in CLAUDE.md under *Navigation — the stack*.
|
||||
|
||||
- **A1/A2/A3.** A2 is the rule, and it grew a second half. A hub stays pushed for the
|
||||
whole visit (`session.stay` / `Visit.result`), so the *object* — not a `default=`
|
||||
restore — carries the cursor, the sort, the scroll and the filter. Where the rows are
|
||||
genuinely data (the editors, the outbox, the watchtower) they are swapped in place with
|
||||
`SelectScreen.replace_items`, which follows the highlighted row by value and clamps to
|
||||
its position when that row is gone. A1's vanishing-row problem is answered by that
|
||||
clamp, everywhere, rather than by watchtower's one hand-written case. A screen is
|
||||
rebuilt only when the rows it was holding a place in no longer exist — a purge, a
|
||||
removal, a record count that moved — and each such site says so.
|
||||
- **B1/B2/B3.** They no longer flatten. Both hand-built escapes existed because a deep
|
||||
stack had no way out; **^W** is the way out, so trace ↔ trophy case and node detail's
|
||||
peers all nest, and Esc from any of them is one pop back onto the screen that opened
|
||||
them. The trace screen takes an `open_trophy_case` callback instead of resolving a
|
||||
sentinel its opener had to interpret, which is also what lets the two open each other.
|
||||
- **C.** All five backdrop re-pushes are gone, and so are the six hand-rolled
|
||||
push/await/pop loops the survey paired them with — the same need, spelled one way.
|
||||
Repeater admin's was the worst of them: a second, identical `SelectScreen` drawn as a
|
||||
static stand-in for the real one. `admin_node_visit` keeps the real list up instead.
|
||||
- **F.** Still the one place the stack is cleared rather than unwound, and it now also
|
||||
disarms a pending ^W: a disconnect has already abandoned the flow that unwind was for.
|
||||
|
||||
**Resolved 2026-08-30 (second pass) — D, plus H and I, found by re-running the survey.**
|
||||
D is closed by peeling *everywhere*: the select list and the path composer now clear a
|
||||
standing filter on the first Esc and leave on the second, as the map and the walk always
|
||||
did, and the footer swaps its trailing verb to `Esc clear` while that is true. Two
|
||||
exceptions the first survey missed are closed with it — see **H** and **I** below.
|
||||
|
||||
**Still open: G** (`popup` declared by hand). It is not navigation-visible: one tool
|
||||
floats over the menu, the rest replace it, and the result presenter separately infers the
|
||||
same thing from content.
|
||||
|
||||
The point of the survey is that MeshTerm's navigation is *almost* uniform. The exceptions
|
||||
are few enough to list exhaustively, which means they can be decided one at a time rather
|
||||
than discovered one at a time.
|
||||
|
||||
---
|
||||
|
||||
## 0. Exceptions H and I — found 2026-08-30
|
||||
|
||||
Two more shapes, neither in the original catalogue, both closed the day they were found.
|
||||
|
||||
**H. A picker gathered in `prompt_params` pops before the screen it feeds.** A tool's
|
||||
`prompt_params` is a *one-shot*: whatever it opens resolves and pops before `run` is
|
||||
called. Three tools picked something there and then opened a screen, so the screen sat
|
||||
directly on the main menu and Esc from it skipped the list it had just been chosen from.
|
||||
|
||||
| Site | What Esc did | Now |
|
||||
|---|---|---|
|
||||
| Trace target | sweep → **main menu**, skipping the target list | picker stays; Esc lands on the row it was launched from |
|
||||
| TX optimize | sweep → **main menu**; and the target list → **main menu**, not the node list | both pickers stay, nested; Esc walks the flow back one list at a time |
|
||||
| Chat | landed on a picker, but a rebuilt one (filter lost, cursor by `default=`) | one picker for the visit, rows swapped in place |
|
||||
|
||||
The fix is A2's rule applied one level up: the loop that owns the picker moves into `run`,
|
||||
and `prompt_params` returns only the marker for the interactive path. Trace's picker also
|
||||
re-reads its `TRACED` lane after each walk (`ContactListScreen.update_rows`), because that
|
||||
is the column the list is sorted by and a returning trace changes it.
|
||||
|
||||
*Revised 2026-09-12 — TX optimize.* Keeping both pickers pushed under the sweep was the
|
||||
wrong reading of the rule for a **popup**: a popup asks, confirms or informs, and then it is
|
||||
gone — it is not a place to walk back to, and two of them stacked read as two places. The
|
||||
two picks are now one floating list that turns its page (`menus.run_wizard`, `… · step 1
|
||||
of 2`): Esc on the second step turns back to the first with the picked node still
|
||||
highlighted, Esc on the first leaves, and the box is popped before the sweep opens — so Esc
|
||||
from the sweep lands on the main menu, the sweep being the whole visit. It also *draws* as
|
||||
a popup: the menu is popped while a tool runs, and a floating screen that is the only frame
|
||||
on the stack used to be painted as the full-frame background (`_base_index`), so the first
|
||||
of the two pickers never looked like a question at all. `stay(dialog=True)` now puts the
|
||||
blank base under a popup's visit that `_run_dialog_screen` puts under a one-shot. Trace's
|
||||
picker is a full-screen contact list (a place), and stays as it is.
|
||||
|
||||
*Same day — Repeater admin.* The same shape, one popup instead of two: the node picker was
|
||||
a hub under the password prompt and the admin page. It is now a floating one-shot
|
||||
(`session.select(floating=True)`, `admin_picker.pick_admin_node`), gone once answered; the
|
||||
admin page opens over the main menu and Esc from it lands there. A refused login re-asks
|
||||
the question with the same node highlighted. `admin_node_visit` is deleted — nothing keeps
|
||||
a popup pushed any more.
|
||||
|
||||
*Same day — the base under a popup.* The blank `ScrollScreen` that `_run_dialog_screen`
|
||||
pushed on an empty stack showed nothing behind the box. A dialog is smaller than the frame,
|
||||
so what shows around it is the page it was reached from: `TuiSession._floated` now pushes
|
||||
the **root** (the main menu, popped while a tool runs but still declared for ^W), and the
|
||||
reconnect dialog draws over it too. A blank base survives only for a session with no root.
|
||||
|
||||
*Same day — the trophy case is a place.* A bare `SelectScreen` floats by default, and the
|
||||
browser was full-frame from the menu only by the compositor's fallback; opened from a
|
||||
trace it came up as a box. It declares `floating = False` now, like the contact list.
|
||||
|
||||
*Same day — Trace target.* The third of the three pickers finding H kept pushed, and the
|
||||
one the previous note said would stay: JP's call is that it is a question too. The contact
|
||||
list floats over the menu as a one-shot (`session.run_dialog`, public now), the trace screen
|
||||
opens over the menu, Esc from it lands there; tracing another node is opening the tool
|
||||
again. The typed fallback floats the same way. `ContactListScreen` stays a full-screen place
|
||||
everywhere else.
|
||||
|
||||
*Same day — a record is a page.* `RecordDialog` floated a 62-column card over the trophy
|
||||
case; the reader is *in* a record (reading, scrolling, arming a trace), so it is
|
||||
`RecordScreen`, full-frame, Esc back onto the browser.
|
||||
|
||||
**I. An entry chain of prompts was not a stack.** Five flows asked two or more things in a
|
||||
row, and Esc on any of them abandoned the whole flow rather than stepping back one — a
|
||||
mistyped 32-hex channel key cost the name typed before it. `menus.run_steps` now runs a
|
||||
chain as a stack; a trailing Cancel/verb confirm is deliberately left as a decision rather
|
||||
than a step. Sites: `channels._join_with_key`, `channels._edit`,
|
||||
`config_editor._stage_custom_var`, `courier._queue_flow`, `courier._pick_schedule`.
|
||||
|
||||
Also closed in the same pass: the channel detail was the last screen rebuilt per round
|
||||
(A1's `default=cursor`, now `stay` + `replace_items`), which left `_menu_round` with no
|
||||
session path at all and shrank it to the sessionless select-and-dispatch it always was
|
||||
underneath — the last of §5's hand-rolled loops.
|
||||
|
||||
---
|
||||
|
||||
## 1. The machinery, in one screenful
|
||||
|
||||
Three primitives, all in [`ui/tui/`](../meshterm/ui/tui/):
|
||||
|
||||
| Call | What it does |
|
||||
|---|---|
|
||||
| `session.push(screen)` | screen becomes the top of the stack and is drawn |
|
||||
| `session.pop(screen)` | that screen leaves the stack |
|
||||
| `screen.resolve(value)` | the screen's future completes; the awaiting caller decides what happens next |
|
||||
|
||||
`session.run_screen(screen)` is push + await + pop as one call — the normal way to show a
|
||||
screen. `Screen.handle("escape")` resolves with the `CANCEL` sentinel
|
||||
([`screen.py:243`](../meshterm/ui/tui/screen.py#L243)); every screen inherits that unless it
|
||||
overrides `handle`.
|
||||
|
||||
So the app has no "navigator". A screen never decides where the user goes — it resolves a
|
||||
value and its *caller* decides. Every exception below is a caller deciding something other
|
||||
than "pop and carry on".
|
||||
|
||||
---
|
||||
|
||||
## 2. What "normal" looks like
|
||||
|
||||
The reference flow is the main menu ([`menu.py:466-559`](../meshterm/ui/menu.py#L466)):
|
||||
|
||||
1. build the row list
|
||||
2. `push` the menu
|
||||
3. await a choice
|
||||
4. `pop` the menu in a `finally`
|
||||
5. run whatever was chosen
|
||||
6. loop — rebuilding the menu, **re-highlighting the row just used** (`default=last_selection`)
|
||||
|
||||
Point 6 is the part worth naming: *returning from a thing lands you on the thing you
|
||||
returned from.* It is the behaviour every list should have, and most do.
|
||||
|
||||
---
|
||||
|
||||
## 3. Exception A — cursor position on re-entry
|
||||
|
||||
**The single largest inconsistency in the app.** Three groups, three behaviours:
|
||||
|
||||
### A1. Restores the cursor — the intended behaviour (7 sites)
|
||||
|
||||
| Screen | Where | How |
|
||||
|---|---|---|
|
||||
| Main menu | [`menu.py:502`](../meshterm/ui/menu.py#L502) | `default=last_selection` |
|
||||
| Channels (manager) | [`channels.py:214`](../meshterm/ui/channels.py#L214) | `default=highlight` |
|
||||
| Channels (one channel) | [`channels.py:728`](../meshterm/ui/channels.py#L728) | `default=cursor` |
|
||||
| Device config editor | [`config_editor.py:161`](../meshterm/ui/config_editor.py#L161) | `default=cursor` |
|
||||
| Device actions | [`config_editor.py:688`](../meshterm/ui/config_editor.py#L688) | `default=cursor` |
|
||||
| Repeater admin | [`repeater_admin.py:172`](../meshterm/ui/repeater_admin.py#L172) | `default=cursor` |
|
||||
| Courier outbox | [`courier_screen.py:120`](../meshterm/ui/courier_screen.py#L120) | `default=cursor` |
|
||||
| Watchtower | [`watchtower_screen.py:137`](../meshterm/ui/watchtower_screen.py#L137) | `cursor = choice` |
|
||||
|
||||
Watchtower has the most careful version of it — it *drops* the restore when the chosen row
|
||||
is about to vanish ([`watchtower_screen.py:145-146`](../meshterm/ui/watchtower_screen.py#L145)):
|
||||
|
||||
```python
|
||||
elif choice == _CLEAR:
|
||||
store.clear_acked()
|
||||
cursor = None # the row itself disappears
|
||||
```
|
||||
|
||||
That refinement exists in exactly one place. Anywhere else a row can disappear, the restore
|
||||
either silently fails to match (harmless — falls back to the top) or lands on whatever row
|
||||
slid into the vacated position (not harmless).
|
||||
|
||||
### A2. Reuses the screen object, so it keeps everything (4 sites)
|
||||
|
||||
Contacts states the principle outright
|
||||
([`contacts_screen.py:200-201`](../meshterm/ui/contacts_screen.py#L200)):
|
||||
|
||||
> One screen for the whole visit: re-running it keeps the highlight (and any sort or
|
||||
> filter) on the row the user just opened a detail for, rather than snapping to the top.
|
||||
|
||||
Also chat, live feed, dashboard. Strictly better than A1 — it preserves sort and filter too,
|
||||
not just the cursor — and it costs less code.
|
||||
|
||||
Contacts also shows the honest exception: after a purge changes the data, it deliberately
|
||||
*does* rebuild ([`contacts_screen.py:217-219`](../meshterm/ui/contacts_screen.py#L217)), and
|
||||
says why.
|
||||
|
||||
### A3. Rebuilds and loses your place (3 sites) — **the actual finding**
|
||||
|
||||
| Screen | Where | What is lost |
|
||||
|---|---|---|
|
||||
| Trophy case | [`records_screen.py:851`](../meshterm/ui/records_screen.py#L851) | cursor — a fresh `SelectScreen` per round, no `default=` |
|
||||
| Time Machine picker | [`timemachine_screen.py:1323`](../meshterm/ui/timemachine_screen.py#L1323) | cursor (sort *is* carried) |
|
||||
| Node detail | [`node_detail_screen.py:1360`](../meshterm/ui/node_detail_screen.py#L1360) | cursor **and the open tab** |
|
||||
|
||||
Node detail is the sharpest case. `NodeDetailScreen.__init__` sets `self._tab_index = 0`
|
||||
([`node_detail_screen.py:446`](../meshterm/ui/node_detail_screen.py#L446)) and the opener
|
||||
constructs a new one every iteration. So: open a contact → Tab to **Routes** → pick a route
|
||||
→ Enter to trace it → come back → **you are on Info, at the top**. The route you were
|
||||
working through is two keypresses away again, every time.
|
||||
|
||||
All three are a one-line fix in the A2 direction (hoist the construction out of the loop).
|
||||
|
||||
> **Question for JP:** is A2 the rule? If so these three are bugs. If a *deliberate* reset
|
||||
> is wanted anywhere, Contacts' purge branch is the model for how to say so.
|
||||
|
||||
---
|
||||
|
||||
## 4. Exception B — hand-offs that flatten the stack instead of nesting
|
||||
|
||||
Two places let one screen hand off to a *peer* screen, and both deliberately unwind first so
|
||||
the user does not end up deep in a stack they have to climb.
|
||||
|
||||
**B1. Trace → Trophy case.** A trace that sets a record raises a dialog offering the trophy
|
||||
case. Choosing it resolves the sentinel `OPEN_TROPHY_CASE`
|
||||
([`trace_screen.py:128`](../meshterm/ui/trace_screen.py#L128)), the trace screen comes down,
|
||||
and only then does the caller open records
|
||||
([`trace_screen.py:2193-2199`](../meshterm/ui/trace_screen.py#L2193)):
|
||||
|
||||
> the trace screen (and every prompt over it) is already down, so the trophy case opens
|
||||
> over a clean stack and Esc from it unwinds straight to the main menu — never back into
|
||||
> this session.
|
||||
|
||||
**B2. Trophy case → Trace.** The mirror image, and the comment is even more explicit
|
||||
([`records_screen.py:905-909`](../meshterm/ui/records_screen.py#L905)):
|
||||
|
||||
> the browser does not reopen behind it: when the trace screen closes, this whole flow
|
||||
> returns, landing the user on the main menu instead of a trophy-case → trace →
|
||||
> trophy-case stack that takes many Escs to climb out of.
|
||||
|
||||
These two are the same rule discovered twice: **a hand-off between peers flattens; only a
|
||||
sub-view nests.** It is not written down anywhere.
|
||||
|
||||
**B3. Node detail → Trace / Map / Time Machine / Share** ([`node_detail_screen.py:1372-1397`](../meshterm/ui/node_detail_screen.py#L1372))
|
||||
follows the same rule *without saying so*, and that is where §3's A3 comes from. Because
|
||||
`run_screen` pops on resolve, node detail is already down when the peer opens — so the peer
|
||||
gets a clean stack, exactly as B1 and B2 arrange by hand. But unlike B1/B2, node detail then
|
||||
**comes back**, by constructing a fresh screen at the top of the loop.
|
||||
|
||||
So the three sites agree on flattening and differ on what happens afterwards:
|
||||
|
||||
| | flattens before the peer | returns afterwards | keeps its state |
|
||||
|---|---|---|---|
|
||||
| B1 Trace → Trophy case | yes, deliberately | no — lands on the menu | n/a |
|
||||
| B2 Trophy case → Trace | yes, deliberately | no — lands on the menu | n/a |
|
||||
| B3 Node detail → peers | yes, as a side effect of `run_screen` | **yes** | **no** |
|
||||
|
||||
B3 is the only one that re-enters, and re-entering through a constructor is what costs the
|
||||
tab and the cursor. That makes A3-on-node-detail a *consequence* of this shape, not an
|
||||
independent bug: fixing it means either hoisting the screen out of the loop (keep the object,
|
||||
keep the state) or nesting properly (keep it pushed while the peer runs).
|
||||
|
||||
> **Question for JP:** "flatten before a peer opens" looks like the settled rule — three for
|
||||
> three. What is unsettled is whether a hub screen should *nest* instead, so it never has to
|
||||
> be rebuilt. Node detail is the only hub, so this is really one decision about one screen.
|
||||
|
||||
---
|
||||
|
||||
## 5. Exception C — the backdrop re-push
|
||||
|
||||
`run_screen` pops the screen when it resolves. But a dialog raised straight afterwards needs
|
||||
something behind it, or it draws over a blank frame. So five sites pop, then immediately
|
||||
re-push the same screen as a static backdrop:
|
||||
|
||||
| Site | Screen re-pushed as backdrop |
|
||||
|---|---|
|
||||
| [`records_screen.py:867`](../meshterm/ui/records_screen.py#L867) | trophy case, behind the discipline picker / delete confirms |
|
||||
| [`contacts_screen.py:210`](../meshterm/ui/contacts_screen.py#L210) | contacts, behind the purge picker |
|
||||
| [`repeater_admin.py:108`](../meshterm/ui/repeater_admin.py#L108) | node picker, behind the login prompt |
|
||||
| [`menu.py:910-911`](../meshterm/ui/menu.py#L910) | base screen, behind the disconnect dialog |
|
||||
| [`tx_screen.py:885`](../meshterm/ui/tx_screen.py#L885) | flight screen |
|
||||
|
||||
Records has the fullest explanation ([`records_screen.py:862-866`](../meshterm/ui/records_screen.py#L862)).
|
||||
|
||||
This is a **workaround for an API shape**, repeated five times. The screens that avoid it
|
||||
(channels, config editor, repeater admin's own menu, courier, watchtower, main menu) all
|
||||
drive `push`/`await future`/`pop` by hand instead of calling `run_screen`, precisely so the
|
||||
screen stays up while sub-prompts float.
|
||||
|
||||
> **Question for JP:** would a `session.run_screen(screen, keep_pushed=True)` — or a
|
||||
> `session.backdrop(screen)` context manager — retire both the five re-pushes and the six
|
||||
> hand-rolled push/await/pop loops? They are the same need spelled two ways.
|
||||
|
||||
---
|
||||
|
||||
## 6. Exception D — what Esc peels before it leaves
|
||||
|
||||
Four screens carry a live find-as-you-type filter and draw it through the one
|
||||
`render.query_line`. **Esc means two different things across them:**
|
||||
|
||||
| Screen | Esc with a filter active | ⌫ with a filter active |
|
||||
|---|---|---|
|
||||
| Map | clears the filter, stays ([`map_screen.py:920-925`](../meshterm/ui/map_screen.py#L920)) | — |
|
||||
| Mesh walk | clears the filter, stays ([`walk_screen.py:430-436`](../meshterm/ui/walk_screen.py#L430)) | deletes a char, then pops the trail ([`walk_screen.py:449-456`](../meshterm/ui/walk_screen.py#L449)) |
|
||||
| **Select list** | **leaves the screen, filter and all** ([`select.py:773-774`](../meshterm/ui/tui/select.py#L773)) | deletes a char |
|
||||
| **Path composer** | **leaves the screen, filter and all** ([`path_composer.py:717-718`](../meshterm/ui/path_composer.py#L717)) | deletes a char, then pops a hop |
|
||||
|
||||
So on the map, typing `hub` then Esc shows you the map again. On a contact list, typing
|
||||
`hub` then Esc drops you back to the menu. Same affordance, same glyphs, opposite outcome —
|
||||
and the select list is by far the most-used of the four.
|
||||
|
||||
Chat is a fifth member of the family with a different peelable thing
|
||||
([`chat.py:624-629`](../meshterm/ui/chat.py#L624)):
|
||||
|
||||
```python
|
||||
if self._selected is not None:
|
||||
self._clear_selection() # first Esc unpicks; next leaves the chat
|
||||
```
|
||||
|
||||
so chat peels, and its comment reads as though peeling were the house rule.
|
||||
|
||||
**Score: 3 screens peel, 2 do not.** Worth noting the peel is *not* free — on a screen where
|
||||
Esc peels, leaving a filtered list takes two Escs, which is the cost JP is weighing on exit
|
||||
rows elsewhere.
|
||||
|
||||
> **Question for JP:** peel everywhere, or nowhere? The current split is not defensible as
|
||||
> a distinction between spatial and list screens, because the path composer is a list and
|
||||
> does not peel, while chat is not a filter and does.
|
||||
|
||||
---
|
||||
|
||||
## 7. Exception E — six spellings of "the user pressed Back"
|
||||
|
||||
`back_rows(value)` takes the value its Back row resolves with. Call sites, verbatim:
|
||||
|
||||
| Value passed | Sites |
|
||||
|---|---|
|
||||
| *(nothing — `None`)* | [`admin_picker.py:49`](../meshterm/ui/admin_picker.py#L49), [`:77`](../meshterm/ui/admin_picker.py#L77), [`config_editor.py:617`](../meshterm/ui/config_editor.py#L617), [`:790`](../meshterm/ui/config_editor.py#L790), [`:1025`](../meshterm/ui/config_editor.py#L1025), [`courier_screen.py:242`](../meshterm/ui/courier_screen.py#L242), [`watchtower_screen.py:225`](../meshterm/ui/watchtower_screen.py#L225), [`:362`](../meshterm/ui/watchtower_screen.py#L362) |
|
||||
| explicit `None` | [`tx_optimize.py:439`](../meshterm/tools/tx_optimize.py#L439), [`contacts_screen.py:263`](../meshterm/ui/contacts_screen.py#L263) |
|
||||
| `"__back__"` | [`tools/chat.py:187`](../meshterm/tools/chat.py#L187), [`records_screen.py:790`](../meshterm/ui/records_screen.py#L790) |
|
||||
| `_BACK` | [`channels.py:619`](../meshterm/ui/channels.py#L619), [`:683`](../meshterm/ui/channels.py#L683), [`contacts_screen.py:152`](../meshterm/ui/contacts_screen.py#L152) |
|
||||
| `_CANCEL` | [`config_editor.py:736`](../meshterm/ui/config_editor.py#L736) |
|
||||
| a tuple shaped like the screen's other values | [`records_screen.py:850`](../meshterm/ui/records_screen.py#L850) `("back", None, 0, None)`, [`trace_screen.py:2012`](../meshterm/ui/trace_screen.py#L2012) `("back", None)` |
|
||||
|
||||
The cost lands on the callers, which have to test every way at once:
|
||||
|
||||
```python
|
||||
# contacts_screen.py:204
|
||||
if chosen is CANCEL or chosen is None or chosen == _BACK:
|
||||
# records_screen.py:858
|
||||
if picked is CANCEL or picked is None or picked[0] == "back":
|
||||
```
|
||||
|
||||
Note `config_editor` names its Back sentinel `_CANCEL` — the one word CLAUDE.md reserves for
|
||||
dialogs, used here for a row that says "Back".
|
||||
|
||||
> **Suggestion:** `back_rows()` could default its value to `CANCEL` itself, so a Back row and
|
||||
> Esc resolve *identically* and every caller's three-way test collapses to `is CANCEL`. The
|
||||
> tuple-shaped ones exist only because their screens type their values as tuples; those could
|
||||
> take `CANCEL` too.
|
||||
|
||||
**Resolved 2026-08-29** — by removal rather than by defaulting: with no Back row there is
|
||||
no Back value, and every caller now tests `is CANCEL` / `is None` alone. All six spellings
|
||||
are gone, `channels._BACK` and `contacts_screen._BACK` with them.
|
||||
|
||||
This tied directly into the exit-row question — see §10.
|
||||
|
||||
---
|
||||
|
||||
## 8. Exception F — the stack reset
|
||||
|
||||
One site clears the whole stack: [`menu.py:824`](../meshterm/ui/menu.py#L824), when the device
|
||||
link drops mid-session. The watcher cancels the menu worker, `session.reset()` drops whatever
|
||||
screens it left behind, and the reconnect dialog opens over a clean frame.
|
||||
|
||||
This is the only place the stack is cleared rather than unwound. It relies on every screen
|
||||
being pop-safe in a `finally`, which is documented at
|
||||
[`session.py:388-395`](../meshterm/ui/tui/session.py#L388). Correct as written — noted here
|
||||
because it is the one code path where a screen can vanish without its caller resolving it,
|
||||
and anything holding state across a `run_screen` boundary would silently lose it.
|
||||
|
||||
---
|
||||
|
||||
## 9. Exception G — one tool floats, twenty-two replace
|
||||
|
||||
`Tool.popup` ([`tools/base.py:67`](../meshterm/tools/base.py#L67)) decides whether the menu
|
||||
stays pushed while the tool runs. **Exactly one tool sets it:** `advert`
|
||||
([`tools/advert.py:33`](../meshterm/tools/advert.py#L33)).
|
||||
|
||||
Every other tool pops the menu, runs full-screen, and the menu is rebuilt afterwards. Which
|
||||
is right for a screen — but several tools are *also* dialog-sized in practice, and the result
|
||||
presenter already makes exactly this call at the other end
|
||||
([`surface.py:603-610`](../meshterm/ui/surface.py#L603)): a short text-only result floats as
|
||||
a dialog, anything bigger opens a window.
|
||||
|
||||
So the app decides "float or replace" twice, by two different mechanisms, one hand-declared
|
||||
per tool and one measured from the content.
|
||||
|
||||
> **Question for JP:** should `popup` be inferred the way the result presentation is, or is
|
||||
> one declared flag on one tool the whole of the need?
|
||||
|
||||
---
|
||||
|
||||
## 10. Where the exit-row question actually lands
|
||||
|
||||
> **Resolved 2026-08-29.** Acted on in full, along the middle position this section
|
||||
> proposed — wider, in the end, than the 19 `back_rows()` sites: the four hand-drawn Back
|
||||
> rows went too (`ReorderScreen`'s clean-state row, the live feed's pinned foot row, node
|
||||
> detail's tail action, and the `trace`/`tx` screens' action-list rows). Kept: the
|
||||
> `exit_rows` discard pair, `ReorderScreen`'s dirty-state pair, the two **Quit** rows, and
|
||||
> the new-record dialog's `Close` button — each for the reason named below. The gallery
|
||||
> now asserts the *absence* of an exit row on every screen, on both platforms. What
|
||||
> follows is the survey as written.
|
||||
|
||||
JP's note — *"remove the cancel and close actions at the bottom of pages; Esc is quicker and
|
||||
available on all platforms; keep it where there is a confirm or cancel situation"* — was
|
||||
acted on for the one unambiguous case (the path composer's `Cancel` row, which literally ran
|
||||
`super().handle("escape")`; removed in `4b29248`). The rest of the territory:
|
||||
|
||||
**Already clean.** The three About pages have no exit row at all — prose, Esc, done. Titles
|
||||
carry no emoji. Footer hints are 100% compliant on width, Esc-last, and verb choice (56
|
||||
literal hints audited).
|
||||
|
||||
**Rows that are exactly Esc, still standing:**
|
||||
|
||||
- `back_rows()` — the app-wide **Back** row, 19 sites. Two of them carry the comment
|
||||
`# a visible exit beside Esc` ([`courier_screen.py:242`](../meshterm/ui/courier_screen.py#L242),
|
||||
[`watchtower_screen.py:225`](../meshterm/ui/watchtower_screen.py#L225)) — i.e. the row exists
|
||||
*because* it duplicates Esc, which is precisely the premise now in question.
|
||||
- `ReorderScreen`'s Back row ([`select.py:931`](../meshterm/ui/tui/select.py#L931)) —
|
||||
`super().handle("escape") # Back resolves CANCEL, same as Esc`.
|
||||
- Live feed's Back row, reached by scrolling past the oldest packet
|
||||
([`livefeed_screen.py:41-44`](../meshterm/ui/livefeed_screen.py#L41)).
|
||||
|
||||
Removing **Back** app-wide is a bigger decision than the path composer's Cancel, because:
|
||||
|
||||
- it is written into CLAUDE.md as a binding standard ("the only exit word on rows");
|
||||
- `exit_rows()` builds on it — an editor with staged changes shows `✓ Apply n staged changes`
|
||||
over `✗ Back — discard staged changes`, where Back is *not* redundant: it names a
|
||||
consequence Esc does not;
|
||||
- on PicoCalc, Esc is a real key, so the platform argument does not add anything there.
|
||||
|
||||
**A middle position worth considering:** keep Back exactly where it says something Esc does
|
||||
not (the `exit_rows` discard case), drop it where it says nothing (the plain `back_rows`
|
||||
case). That is roughly 3 sites kept, 16 dropped — and §7's suggestion (Back resolves `CANCEL`)
|
||||
would make the drop mechanical rather than a per-caller edit.
|
||||
|
||||
**Buttons that are exactly Esc:** only one — the `Close` button on the new-record dialog
|
||||
([`trace_screen.py:639`](../meshterm/ui/trace_screen.py#L639)). Left alone deliberately: it
|
||||
is the *default*, so Enter dismisses safely, and this dialog appears unbidden after a trace.
|
||||
Dropping it would leave `Trophy case` as the default and make a stray Enter navigate away.
|
||||
Every other `Cancel` in the app is one half of a real two-way choice.
|
||||
|
||||
---
|
||||
|
||||
## 11. Summary — the shortlist
|
||||
|
||||
| # | Finding | Sites | Weight | Status |
|
||||
|---|---|---|---|---|
|
||||
| H | A picker gathered in `prompt_params` pops before the screen it feeds | 3 | **high** — Esc skipped a whole list | **done** 08-30 |
|
||||
| I | An entry chain of prompts abandons instead of stepping back | 5 | medium — loses typing, never surprises | **done** 08-30 |
|
||||
| A3 | Screen rebuilt per round, losing cursor (and node detail's tab) | 3 | **high** — felt on every visit | **done** 08-30 |
|
||||
| D | Esc peels the filter on 3 screens, leaves on 2 | 5 | **high** — same keys, opposite result | **done** 08-30 |
|
||||
| E | Six spellings of the Back value; callers test three at once | 19 | medium — invisible to users, costly in code | done 08-29 |
|
||||
| C | Pop-then-re-push backdrop workaround | 5 | medium — an API gap, repeated | **done** 08-30 |
|
||||
| B | Peer hand-offs all flatten, but the rule is unwritten and node detail pays for it | 3 | medium | **done** 08-30 |
|
||||
| G | `popup` declared once by hand while the result presenter infers it | 1 | low | open |
|
||||
| A1 | Cursor restore doesn't handle a vanishing row (except watchtower) | 7 | low | **done** 08-30 |
|
||||
| F | `session.reset()` on disconnect — correct, noted for completeness | 1 | none | kept |
|
||||
@@ -5,9 +5,8 @@
|
||||
> that doesn't. Some of what's here has since been fixed and some hasn't; nothing in
|
||||
> it is a to-do list. Read it as "what this looked like then", not "what is wrong now".
|
||||
|
||||
Written 2026-08-22 against `main` @ `4b29248`. Companion to
|
||||
[`survey-navigation.md`](survey-navigation.md), which covers navigation exceptions; this one
|
||||
covers everything else in the CLAUDE.md UX standard. A catalogue, not a change.
|
||||
Written 2026-08-22 against `main` @ `4b29248`. It covers the CLAUDE.md UX standard apart
|
||||
from navigation. A catalogue, not a change.
|
||||
|
||||
**The short version: the standard is very well kept.** Six sweeps found nothing at all;
|
||||
what remains is nine small, specific things. That is the useful result — it means the list
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Terminals, icons, and the Windows console
|
||||
|
||||
Why MeshTerm looks right in some terminals and draws empty boxes in others, what it does
|
||||
about it on Windows, and how to turn all of that off.
|
||||
|
||||
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether you
|
||||
see them is up to your *terminal*, not really your font: a modern terminal, asked for a
|
||||
character its font doesn't have, quietly borrows it from another font on the machine.
|
||||
That's why the app looks right in Windows Terminal, in VS Code's terminal, and on macOS
|
||||
and Linux — usually with a font that contains almost none of it. The exception is macOS's
|
||||
Terminal app, where the charts may draw misaligned: no Mac monospace font has braille, so
|
||||
it is borrowed from a proportional one at the wrong width.
|
||||
|
||||
The classic Windows console — the black `cmd.exe` window a double-click opens on
|
||||
Windows 10 — doesn't borrow. (Windows 11, 22H2 and later, opens Windows Terminal by
|
||||
default instead.) It draws what its one font holds and empty boxes for everything else, and
|
||||
no font fixes the icons there: the only two fonts on a Windows machine with emoji in them
|
||||
are proportional, and a console won't take a proportional font.
|
||||
|
||||
So when MeshTerm lands in that console it just **moves to Windows Terminal** — it says
|
||||
so, and opens there. Nothing is installed and nothing is changed; it's one window instead
|
||||
of another, and it's the whole app exactly as the screenshots show it. Windows Terminal is
|
||||
already on every Windows 11 machine and is a free install on Windows 10.
|
||||
|
||||
Where there's no Windows Terminal to move to, and the console's font can't already draw
|
||||
the charts, MeshTerm offers the next best thing instead — the charts and marks, without
|
||||
the icons. It ships
|
||||
[Cascadia Mono PL](https://github.com/microsoft/cascadia-code), Microsoft's own console
|
||||
font and one of the very few monospace faces that carries braille at all, and will install
|
||||
it just for you: no administrator rights, nothing downloaded. If you already have a font
|
||||
that can draw the charts (Cascadia, or another such as Iosevka), it offers to switch to
|
||||
that one instead. Either way it asks first (`[y/N]`), and the answer defaults to no.
|
||||
|
||||
Preferences → Display → Console setup turns all of this off if you'd rather stay put.
|
||||
|
||||
Where MeshTerm keeps its state, and how to point a trial run somewhere else, is in
|
||||
[Configuring MeshTerm](configuration.md).
|
||||
+79
-33
@@ -71,6 +71,8 @@ The bridge assumes the AIO's wiring by default:
|
||||
| Bandwidth | 62.5 kHz |
|
||||
| Coding rate | 4/5 |
|
||||
|
||||
The radio settings (frequency, spreading factor, bandwidth, coding rate) are MeshCore's
|
||||
US/Canada preset. Anywhere else, set the `MESHCORE_*` variables to your region's preset.
|
||||
Every one of these is overridable with a `MESHCORE_*` environment variable if your board or
|
||||
region differs — see [Hardware knobs](#hardware-knobs).
|
||||
|
||||
@@ -92,8 +94,17 @@ 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
|
||||
- the Linux ARM64 one-file release — three lines, no Python at all:
|
||||
|
||||
```bash
|
||||
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-linux-arm64
|
||||
chmod +x meshterm
|
||||
./meshterm
|
||||
```
|
||||
|
||||
(That URL always fetches whichever release is newest; the
|
||||
[release page](https://github.com/jpmartineau/MeshTerm/releases/latest) has the
|
||||
version-stamped files and their checksums.) 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.
|
||||
@@ -136,16 +147,18 @@ and `spi` and `gpio` in your `groups` output.
|
||||
|
||||
Which route you take depends on your Debian release:
|
||||
|
||||
- **trixie**, with `meshcore-uconsole` installed from the vendor's package: you already
|
||||
- **trixie**, with the `meshcore-uconsole` package (by cwill747) installed: 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:
|
||||
- **bookworm**: the trixie `.deb` of `meshcore-uconsole` 1.12.0 is built on Python 3.13
|
||||
and needs glibc 2.38, so it will not install on bookworm, and the APT repository offers
|
||||
nothing for bookworm. Upstream's GitHub Releases also carry a separate bookworm `.deb`
|
||||
built on Python 3.11; it has not been tested with the bridge. The documented route is to
|
||||
give the bridge a runtime of its own:
|
||||
|
||||
```bash
|
||||
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
|
||||
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
|
||||
```
|
||||
```bash
|
||||
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
|
||||
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
|
||||
```
|
||||
|
||||
This is also the route to take if you'd rather not install the `meshcore-uconsole` package
|
||||
at all.
|
||||
@@ -206,8 +219,11 @@ If the first three checks all pass, you're ready to run it.
|
||||
./meshterm-spi-bridge --run
|
||||
```
|
||||
|
||||
This runs the preflight again, prints it, then starts the bridge in this terminal. A
|
||||
healthy startup logs something like:
|
||||
This runs the preflight again and prints it. Before it starts the bridge, it asks whether
|
||||
to add a `bridge` profile to your MeshTerm config
|
||||
(`` Add a 'bridge' profile to /home/you/.meshterm/config.toml so you can just run `meshterm`? [y/N] ``).
|
||||
Either answer is fine; Step 6 asks again if you say no. Then it starts the bridge in this
|
||||
terminal. A healthy startup logs something like:
|
||||
|
||||
```
|
||||
node runtime openhop_core 1.1.3 (/home/you/.local/share/meshterm-spi-bridge/venv/bin/python)
|
||||
@@ -221,7 +237,8 @@ READY — connect with: meshterm --tcp 127.0.0.1:5000
|
||||
The `radio config` line lists every setting as `key=value`, so the one above is
|
||||
shortened; yours is longer.
|
||||
|
||||
Leave it running, and in another terminal on the same machine try:
|
||||
Once MeshTerm is installed ([Step 7](#step-7--install-meshterm-and-connect)), you can test
|
||||
the bridge this way: leave it running, and in another terminal on the same machine run:
|
||||
|
||||
```bash
|
||||
meshterm --tcp 127.0.0.1:5000
|
||||
@@ -272,8 +289,9 @@ Menu option **4) Show status** prints the same information from inside the scrip
|
||||
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
|
||||
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-linux-arm64
|
||||
chmod +x meshterm
|
||||
sudo mv meshterm /usr/local/bin/meshterm
|
||||
```
|
||||
|
||||
Or install it from the repository with `pipx`:
|
||||
@@ -284,7 +302,8 @@ pipx install git+https://github.com/jpmartineau/MeshTerm
|
||||
pipx ensurepath
|
||||
```
|
||||
|
||||
If you accepted the profile in Step 6, your `config.toml` now has:
|
||||
If you accepted the profile in Step 5 or 6, `~/.meshterm/config.toml` now has this block
|
||||
(the bridge always writes to that path, even if you set `MESHTERM_HOME`):
|
||||
|
||||
```toml
|
||||
[profiles.bridge]
|
||||
@@ -305,13 +324,16 @@ or, without a profile:
|
||||
meshterm --tcp 127.0.0.1:5000
|
||||
```
|
||||
|
||||
You're connected when the device page or dashboard shows a node name (`uConsole` by
|
||||
You're connected when **Device info** or the dashboard shows a node name (`uConsole` by
|
||||
default) instead of a connection error. MeshTerm reports the device model as
|
||||
`pyMC-spi-bridge` — that's how you tell a bridged node from a real companion.
|
||||
|
||||
**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
|
||||
package's identity key, so MeshTerm and the GUI share the same public key. Contacts and
|
||||
radio settings are the bridge's own: it keeps contacts in
|
||||
`~/.local/share/meshterm-spi-bridge/contacts.json`, and builds its radio settings from the
|
||||
`MESHCORE_*` variables and its defaults, never from the GUI's saved settings. Set the
|
||||
`MESHCORE_*` variables to match what the GUI uses. On a library-only install, the bridge mints and keeps its own identity key
|
||||
under `~/.local/share/meshterm-spi-bridge/identity.key`.
|
||||
|
||||
**Only one program may use the radio at a time.** Never run the bridge and the
|
||||
@@ -323,7 +345,15 @@ 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/`).
|
||||
2 again — it overwrites the copy in `~/.local/bin/`). The running service keeps the old
|
||||
code until you restart it:
|
||||
|
||||
```bash
|
||||
systemctl --user restart meshterm-spi-bridge.service
|
||||
```
|
||||
|
||||
Option 2 also rewrites the unit file, so any `Environment=` lines you added to it by hand
|
||||
are lost. Put them in a drop-in instead, as [Hardware knobs](#hardware-knobs) shows.
|
||||
|
||||
To remove the bridge, run the menu and choose **3) Uninstall the service**. This stops and
|
||||
removes the `systemd --user` unit and the installed script copy. It does **not** touch
|
||||
@@ -335,16 +365,31 @@ 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.
|
||||
The pin defaults match the hackergadgets uConsole AIO v1, which needs none of these. The
|
||||
**AIO v2 needs `MESHCORE_EN_PINS=27`**, whether or not the `meshcore-console` package is
|
||||
installed. The radio settings default to the US/Canada preset. For a foreground run, export
|
||||
what differs before launching the bridge. For the service, add a drop-in, which survives a
|
||||
reinstall:
|
||||
|
||||
```bash
|
||||
systemctl --user edit meshterm-spi-bridge.service
|
||||
```
|
||||
|
||||
and put the variables under a `[Service]` heading, one `Environment=` line each:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Environment=MESHCORE_EN_PINS=27
|
||||
```
|
||||
|
||||
Then `systemctl --user restart meshterm-spi-bridge.service`.
|
||||
|
||||
| Variable | What it sets |
|
||||
| --- | --- |
|
||||
| `MESHCORE_BUS_ID`, `MESHCORE_CS_ID`, `MESHCORE_CS_PIN` | which SPI bus, device and chip-select pin the radio is on |
|
||||
| `MESHCORE_RESET_PIN`, `MESHCORE_BUSY_PIN`, `MESHCORE_IRQ_PIN` | the three control lines |
|
||||
| `MESHCORE_FREQUENCY`, `MESHCORE_TX_POWER` | frequency in Hz, power in dBm |
|
||||
| `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining |
|
||||
| `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining. Whole numbers only: bandwidth in Hz (`62500`), coding rate as the denominator (`5` for 4/5). A decimal or `4/5` stops the bridge at startup. |
|
||||
| `MESHCORE_TXEN_PIN`, `MESHCORE_RXEN_PIN`, `MESHCORE_EN_PINS` | the RF-switch and power-enable lines a board may need (`-1` for none; `EN_PINS` is a comma list — the AIO v2 wants `27`) |
|
||||
| `MESHCORE_USE_DIO2_RF`, `MESHCORE_USE_DIO3_TCXO`, `MESHCORE_IS_WAVESHARE` | whether DIO2 drives the RF switch and DIO3 the TCXO (both on for the AIO), and a flag for a different vendor's wiring the runtime knows about |
|
||||
| `MESHCORE_GPIO_CHIP`, `MESHCORE_USE_GPIOD_BACKEND`, `MESHCORE_PREAMBLE_LENGTH` | which gpiochip, whether to drive it through `gpiod`, and the LoRa preamble |
|
||||
@@ -352,10 +397,11 @@ bridge, or put the lines in the service's environment.
|
||||
|
||||
The bridge passes a knob only when the runtime's radio constructor accepts it, so the same
|
||||
environment works under `pymc_core` 1.0.x, which lacks the newer ones. Where the
|
||||
`meshcore-console` package is installed, the bridge builds the radio through *its* hardware
|
||||
configuration, which reads the same names plus a few more of its own — see that package's
|
||||
documentation. On a library-only install the table above is the whole of it, so an AIO v2
|
||||
without that package present may not power its radio.
|
||||
`meshcore-console` package is installed, the bridge builds the radio through that
|
||||
package's environment reader, which reads the same names plus a few more of its own (see
|
||||
that package's documentation) and ignores the GUI's saved presets. On a library-only
|
||||
install the table above is the whole of it. Either way, an AIO v2 without
|
||||
`MESHCORE_EN_PINS=27` will not power its radio.
|
||||
|
||||
---
|
||||
|
||||
@@ -366,10 +412,10 @@ without that package present may not power its radio.
|
||||
| `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. |
|
||||
| Preflight warns `radio in use meshcore-console is running:` | Close the GUI before running or installing the bridge. |
|
||||
| Preflight warns `port 5000 busy something is already on 127.0.0.1:5000` | Something other than the bridge service is listening there (an active service is reported as `bridge service running` instead). Usually it is a foreground `--run` in another terminal; stop that one. |
|
||||
| Bridge exits with `bridge failed: GPIO pins still busy after retries` | The bridge retries `radio.begin()` four times only when the GPIO lines are busy. They may still be held by a session that just closed — wait a few seconds and try again. |
|
||||
| MeshTerm connects but direct messages to an old contact say "not found" | Contacts are restored from the bridge's snapshot file at startup. If the contact is not in it, wait until the contact is heard again. |
|
||||
| Reboot, factory reset, or setting a device PIN silently does nothing | Expected — the radio library has no handler for these three, whatever runtime you're on. |
|
||||
| Traces or the live feed don't show up | These rely on compatibility shims the bridge installs (or, under `openhop_core`, on the library's own equivalents). Check the bridge's log for lines starting `compat:` to confirm they're wired. |
|
||||
|
||||
@@ -381,7 +427,7 @@ This has been verified on a bookworm uConsole with `openhop-core` 1.1.3: the rad
|
||||
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.
|
||||
MeshTerm's test suite does not cover the bridge.
|
||||
|
||||
What has not been confirmed:
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MeshTerm is a full-featured TUI-based MeshCore client for your terminal.
|
||||
"""MeshTerm is a full-featured TUI MeshCore client for your terminal.
|
||||
|
||||
Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Linux.
|
||||
Connects to a companion over USB, Bluetooth or TCP. For Windows, macOS and Linux.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
__version__ = "0.3.7"
|
||||
__version__ = "0.9.0"
|
||||
__author__ = "Jean-Pierre Martineau"
|
||||
|
||||
#: The year MeshTerm was first published; the copyright span starts here.
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
## What it is
|
||||
|
||||
**MeshTerm** is a full-featured **MeshCore** client that lives in your terminal. Plug in a companion device over USB, pair one over Bluetooth, or reach one over TCP. Windows, macOS, and Linux. Free and open-source.
|
||||
**MeshTerm** is a full-featured TUI **MeshCore** client for your terminal. Plug in a companion device over USB, pair one over Bluetooth, or reach one over TCP. Windows, macOS, and Linux. Free and open-source.
|
||||
|
||||
Nearly everything MeshTerm can do thru its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The live pictures stay in the TUI, where they belong — the map, the dashboard, the live feed, the watchtower, the mesh walk — and everything else has a command. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks! Every command prints for a person by default, and speaks JSON with `--json` when a program is reading!
|
||||
Nearly everything MeshTerm can do through its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The live pictures stay in the TUI, where they belong — the map, the dashboard, the live feed, the watchtower, the mesh walk — and everything else has a command. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks! Every command prints for a person by default, and speaks JSON with `--json` when a program is reading!
|
||||
|
||||
Everything the radio overhears is recorded to a local database from the moment it's turned on — adverts, telemetry, signal quality, positions, etc. Features like maps, dashboard, and route graphs read back from it, so the longer it runs, the more it know about the mesh!
|
||||
While MeshTerm is running and connected to your radio, everything the radio overhears is recorded to a local database — adverts, telemetry, signal quality, positions, etc. Features like maps, dashboard, and route graphs read back from it, so the longer it runs, the more it knows about the mesh!
|
||||
|
||||
## Where it came from
|
||||
|
||||
@@ -18,29 +18,29 @@ I wanted something I could run from my computer. I wanted it to run on Windows,
|
||||
|
||||
## Platform-specific developments
|
||||
|
||||
In addition to generic Terminal setup, I wanted it to run on two devices I had lying around on my nerdcave.
|
||||
In addition to a generic terminal setup, I wanted it to run on two devices I had lying around in my nerdcave.
|
||||
|
||||
### Clockwork uConsole
|
||||
MeshTerm is compatible with the **HackerGadgets AIO board v1**. The **AIO** board has just a raw LoRa radio, meaning it can't run MeshCore firmware. MeshTerm includes a script to run the mesh node in software, and expose it using a TCP port which MeshTerm can connect to.
|
||||
### ClockworkPi uConsole
|
||||
MeshTerm is compatible with the **HackerGadgets AIO board (v1 or v2)**. The **AIO** board has just a raw LoRa radio, meaning it can't run MeshCore firmware. MeshTerm includes a script to run the mesh node in software, and expose it using a TCP port which MeshTerm can connect to.
|
||||
|
||||
MeshTerm feels right at home on the **uConsole**. Running MeshTerm on it makes it feel like a piece of gear straight out of a William Gibson novel.
|
||||
|
||||
### Clockwork PicoCalc
|
||||
### ClockworkPi PicoCalc
|
||||
Getting MeshTerm to run on the **PicoCalc** is a bit more involved, but totally worth it:
|
||||
- Swap out the **Raspberry Pi Pico** for a **Luckfox Lyra** to run **Calculinux**.
|
||||
- Add a compatible 3.3v USB wifi dongle (e.g.: **TP-Link TL-WN725N N150 Nano**) to the **Lyra**'s internal USB port.
|
||||
- Add a LoRa device. Here's a few options:
|
||||
- Add a compatible 3.3 V USB Wi-Fi dongle (e.g. **TP-Link TL-WN725N N150 Nano**) to the **Lyra**'s internal USB port.
|
||||
- Add a LoRa device. Here are two options:
|
||||
- Connect a companion via USB-C;
|
||||
- add **Seeed Studio XIAO nRF52840 + Wio-SX1262** internally, soldered to GPIO connectors' legs.
|
||||
- In order to use the XIAO nRF52840 via GPIO, we do need to use a modified firmware. A **PR** *https://github.com/meshcore-dev/MeshCore/pull/3191* has been submitted to MeshCore, but it might take a while for it to go through.
|
||||
- In order to use the XIAO nRF52840 via GPIO, a modified firmware is needed. A **PR** *https://github.com/meshcore-dev/MeshCore/pull/3191* has been submitted to MeshCore, but it might take a while for it to go through.
|
||||
|
||||
The UX is a bit different on the the PicoCalc's 16-color 53-column screen layout, but it's been tailored to make good use of the available space and the PicoCalc's F-keys. The result is, in my humble opinion, the best MeshCore experience you can get on a portable device. With two 18650 batteries, it'll run all day.
|
||||
The UX is a bit different on the PicoCalc's 16-colour 53-column screen layout, but it's been tailored to make good use of the available space and the PicoCalc's F-keys. The result is, in my humble opinion, the best MeshCore experience you can get on a portable device. It runs on one 18650 battery, and a second one roughly doubles the runtime.
|
||||
|
||||
## What the future holds
|
||||
|
||||
MeshTerm will evolve over time, it is a work in progress.
|
||||
MeshTerm will evolve over time. It is a work in progress.
|
||||
|
||||
I've build MeshTerm for myself — I find that's when I produce my best work — but I sincerely hope it will be appreciated by others.
|
||||
I've built MeshTerm for myself — I find that's when I produce my best work — but I sincerely hope it will be appreciated by others.
|
||||
|
||||
If you'd like a specific feature, pitch your idea on the Discord!
|
||||
|
||||
@@ -49,9 +49,9 @@ If you'd like a specific feature, pitch your idea on the Discord!
|
||||
MeshTerm ships under the **Apache License, Version 2.0** — free to use,
|
||||
change, and redistribute, commercially or otherwise, as long as the
|
||||
LICENSE and NOTICE files travel with it. The full terms:
|
||||
**apache.org/licenses/LICENSE-2.0** *https://www.apache.org/licenses/LICENSE-2.0*.
|
||||
*https://www.apache.org/licenses/LICENSE-2.0*.
|
||||
|
||||
The licence covers the code, not the name: **MeshTerm and its logo are
|
||||
The licence does not cover the name: **MeshTerm and its logo are
|
||||
trademarks the author keeps**, so a fork goes out under a name of its own.
|
||||
The donate link in this app supports this project and its original
|
||||
author — a fork that keeps soliciting through it, without pointing it at
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
## Who am I IRL
|
||||
I'm Jean-Pierre Martineau. I live in Montréal, Québec, Canada.
|
||||
I'm Jean-Pierre Martineau. I live in Montréal, Québec, Canada.
|
||||
|
||||
Data expert by day and tinkerer by night, I've spend my career designing solutions and writing code for various industries, from video-games to public transit. I've always had an unquenchable curiosity that drove me to pursue personal projects during my free time. MeshCore has been the focal point of my nights and weekends as of lately.
|
||||
Data expert by day and tinkerer by night, I've spent my career designing solutions and writing code for various industries, from video games to public transit. I've always had an unquenchable curiosity that drove me to pursue personal projects during my free time. MeshCore has been the focal point of my nights and weekends lately.
|
||||
|
||||
## Who am I on the mesh
|
||||
|
||||
@@ -9,11 +9,11 @@ I operate **YUL-Cartierville** and **YUL-Upper-Salaberry** repeaters in Montréa
|
||||
|
||||
## On the subject of AI assisted development
|
||||
|
||||
As a computer scientist, I find AI to be a powerful amplifier, not a substitute—of one's capabilities. For me, it's been a vector of productivity, allowing me to grasp something which would have been out of reach. I can spend my time ideating and let the machine take care of the nitty-gritty. Don't be fooled, sculpting MeshTerm into its current form still took a *lot* of work. Every nook and cranny of the app has been designed, tested, and iterated by me. Hundreds of hours of my own time, augmented by what I learned throughout my sofware developer career.
|
||||
As a computer scientist, I find AI to be a powerful amplifier of one's capabilities. For me, it's been a vector of productivity, allowing me to grasp something which would have been out of reach. I can spend my time ideating and let the machine take care of the nitty-gritty. Don't be fooled: sculpting MeshTerm into its current form still took a *lot* of work. Every nook and cranny of the app has been designed, tested, and iterated by me. It took hundreds of hours of my own time, augmented by what I learned throughout my software developer career.
|
||||
|
||||
MeshTerm would *not* exist without the use of AI tools.
|
||||
|
||||
One detail: the about pages were hand-written by me, and the ANSI art splash screen logo was done by hand — no AI — using **Moebius**.
|
||||
One detail: these pages were hand-written by me, and the ANSI art splash screen logo was done by hand — no AI — using **Moebius**.
|
||||
|
||||
## How to reach me
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
## Why it needs support
|
||||
|
||||
**MeshTerm** is free.
|
||||
**MeshTerm** is free and open source, and it will stay that way.
|
||||
|
||||
The project is a labour of love. I've poured hundreds of hours of my free time into it. Evenings, late nights, weekends, even some of my vacation time!
|
||||
|
||||
As I mentionned in the about pages, this project relies on AI tools to accelerate development. Without them, MeshTerm would absolutely not exist. Subscriptions to these AI tools are a recurring expense!
|
||||
As I mentioned on the Author page, this project relies on AI tools to accelerate development. Without them, MeshTerm would absolutely not exist. Subscriptions to these AI tools are a recurring expense!
|
||||
|
||||
Routers, companion devices, handhelds, antennas, and batteries aren't super expensive individually, but it all adds up quickly!
|
||||
Repeaters, companion devices, handhelds, antennas, and batteries aren't super expensive individually, but it all adds up quickly!
|
||||
|
||||
If you want pitch in and contribute financially, you're more that welcome to do so. Any help will be greatly appreciated and put to good use.
|
||||
If you want to pitch in and contribute financially, you're more than welcome to do so. Any help will be greatly appreciated and put to good use.
|
||||
|
||||
## Chip in
|
||||
|
||||
@@ -20,7 +20,7 @@ If you want pitch in and contribute financially, you're more that welcome to do
|
||||
https://buymeacoffee.com/johnputer
|
||||
```
|
||||
|
||||
### Donate via Paypal
|
||||
### Donate via PayPal
|
||||
|
||||
- *https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6*
|
||||
|
||||
@@ -30,17 +30,17 @@ https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6
|
||||
|
||||
### Become a patron
|
||||
|
||||
- *https://www.patreon.com/c/MeshTerm*
|
||||
- *https://www.patreon.com/cw/MeshTerm*
|
||||
|
||||
```qr
|
||||
https://www.patreon.com/c/MeshTerm
|
||||
https://www.patreon.com/cw/MeshTerm
|
||||
```
|
||||
|
||||
## Other ways to help
|
||||
|
||||
- Spread the word and let others know how awesome **MeshTerm** is!
|
||||
- Join the Discord community!
|
||||
- Give this project a Star **⭐** on **GitHub**!
|
||||
- Give this project a Star **⭐** on **GitHub**!
|
||||
- Open an issue on **GitHub** when you encounter a bug or problem!
|
||||
|
||||
*https://github.com/jpmartineau/MeshTerm*
|
||||
|
||||
+19
-5
@@ -22,7 +22,7 @@ from . import __version__
|
||||
from .context import AppContext
|
||||
from .core import exitcodes, win32dll
|
||||
from .core.admin_store import AdminStore
|
||||
from .core.config import Settings
|
||||
from .core.config import MOCK_DB_FILENAME, Settings
|
||||
from .core.connection import DeviceCommandError, is_connection_lost
|
||||
from .core.device_config import DeviceConfigError
|
||||
from .core.device_store import DeviceStore
|
||||
@@ -121,8 +121,8 @@ app = typer.Typer(
|
||||
# The one description, same as the package summary and the PyPI page. See
|
||||
# `meshterm.__doc__` — if this drifts from that, one of them is lying.
|
||||
help=(
|
||||
"MeshTerm is a full-featured TUI-based MeshCore client for your terminal. "
|
||||
"Supports USB, Bluetooth and TCP companion connection. "
|
||||
"MeshTerm is a full-featured TUI MeshCore client for your terminal. "
|
||||
"Connects to a companion over USB, Bluetooth or TCP. "
|
||||
"For Windows, macOS and Linux."
|
||||
),
|
||||
epilog=EXIT_STATUS_EPILOG,
|
||||
@@ -175,7 +175,9 @@ def main_callback(
|
||||
help="Network address host[:port] of a TCP companion (selects the TCP transport)",
|
||||
),
|
||||
mock: bool = typer.Option(False, "--mock", help="Use the built-in simulator"),
|
||||
db_path: Path | None = typer.Option(None, "--db", help="SQLite database path"),
|
||||
db_path: Path | None = typer.Option(
|
||||
None, "--db", help="SQLite database path (--mock records to meshterm-mock.db)"
|
||||
),
|
||||
json_output: bool = typer.Option(False, "--json", help="Machine-readable output"),
|
||||
absolute: bool = typer.Option(
|
||||
False, "--absolute", help="Print absolute timestamps instead of relative ages"
|
||||
@@ -198,7 +200,8 @@ def main_callback(
|
||||
ble_pin: Optional BLE pairing PIN for the Bluetooth companion.
|
||||
tcp: Explicit network address ``host[:port]``, selecting the TCP transport.
|
||||
mock: Whether to use the simulator instead of real hardware.
|
||||
db_path: Override the database location.
|
||||
db_path: Override the database location. Without it, a ``--mock`` run records to
|
||||
the simulator's own database rather than the real history.
|
||||
json_output: Print the answer as JSON instead of aligned text.
|
||||
absolute: Print times as ISO-8601 instants rather than relative ages, for this run.
|
||||
quiet: Suppress console logging (file logging continues).
|
||||
@@ -232,6 +235,17 @@ def main_callback(
|
||||
settings = Settings.load()
|
||||
if db_path is not None:
|
||||
settings.db_path = db_path
|
||||
elif mock:
|
||||
# The simulator is a fake radio, not a fake MeshTerm: everything it adverts was
|
||||
# written to the real history like anything a companion said, and its four
|
||||
# invented contacts then turned up in the mesh walk, the dashboard and the map of
|
||||
# the mesh you actually run. A pretend radio gets a pretend history —
|
||||
# ``<config_dir>/meshterm-mock.db``, created on first use — while ``--db`` still
|
||||
# wins, because naming a database is a deliberate claim about where a run records.
|
||||
# ``$MESHTERM_HOME`` remains the only thing that isolates a run whole: the contact
|
||||
# and channel caches, the outbox and the remembered devices live beside the
|
||||
# database either way (see docs/cli.md).
|
||||
settings.db_path = settings.config_dir / MOCK_DB_FILENAME
|
||||
|
||||
# Two front ends, two consoles, and which one this process gets is settled here — a
|
||||
# named subcommand is a scripted run, so it prints on the plain, colourless,
|
||||
|
||||
+12
-1
@@ -34,6 +34,17 @@ else: # pragma: no cover - exercised only on 3.10
|
||||
#: can move it between cases.
|
||||
CONFIG_DIR_ENV = "MESHTERM_HOME"
|
||||
|
||||
#: The history's filename inside the config directory, when ``config.toml`` and ``--db``
|
||||
#: both leave it alone.
|
||||
DB_FILENAME = "meshterm.db"
|
||||
|
||||
#: The simulator's history, kept beside the real one and used by ``--mock`` runs that did
|
||||
#: not name a database themselves. ``--mock`` is a fake radio, not a fake MeshTerm: what
|
||||
#: the simulator adverts is recorded like anything a real companion said, so without a
|
||||
#: database of its own its four invented contacts turn up in the mesh walk, the dashboard
|
||||
#: and the map of the mesh you actually run.
|
||||
MOCK_DB_FILENAME = "meshterm-mock.db"
|
||||
|
||||
|
||||
def default_config_dir() -> Path:
|
||||
"""Return the directory MeshTerm uses for config and data.
|
||||
@@ -138,7 +149,7 @@ class Settings:
|
||||
def __post_init__(self) -> None:
|
||||
"""Derive dependent paths that were not explicitly provided."""
|
||||
if self.db_path is None:
|
||||
self.db_path = self.config_dir / "meshterm.db"
|
||||
self.db_path = self.config_dir / DB_FILENAME
|
||||
|
||||
def resolve_profile(self, name: str | None) -> DeviceProfile | None:
|
||||
"""Look up a profile by name, falling back to the default profile.
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""What MeshTerm is running on, read once so a bug report does not have to be interviewed.
|
||||
|
||||
Every value here answers a question that otherwise costs a round trip with whoever
|
||||
reported the problem: which build of MeshTerm, on which OS, in which terminal, how wide,
|
||||
with which colour depth. None of it is about the mesh and none of it is private — this
|
||||
module deliberately knows nothing about contacts, keys, positions or messages, so the
|
||||
block it feeds can be pasted in public without anyone having to read it first.
|
||||
|
||||
Two probes are **ladders over environment variables** rather than protocol queries, which
|
||||
is a concession worth naming: a terminal has no portable way to be asked who it is. The
|
||||
one sequence that comes close (``CSI > q``) needs a live tty, a reply that may never
|
||||
arrive, and a timeout budget on a path that must never hang. So each terminal is
|
||||
identified by the variable it sets about *itself* — which is the same ladder
|
||||
:func:`~meshterm.ui.termfont.detect_terminal_font` already climbs for the font — and
|
||||
anything unrecognised falls back to ``TERM`` rather than to a guess.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
#: The first Windows 11 build. Windows 11 still reports its release as ``"10"`` through
|
||||
#: every documented API, so the build number is the only thing that separates the two.
|
||||
_WINDOWS_11_BUILD = 22000
|
||||
|
||||
#: Terminals that announce themselves, by the variable each one sets. Ordered most to
|
||||
#: least specific: an emulator hosting another (VS Code's terminal, a multiplexer) sets
|
||||
#: its own marker *and* inherits the outer one, so the inner identity has to win.
|
||||
#:
|
||||
#: ``TERM_PROGRAM`` is checked separately, between these and the ``TERM`` fallback,
|
||||
#: because its *value* is the name — it identifies terminals this table never has to list.
|
||||
_TERMINAL_VARS: tuple[tuple[str, str], ...] = (
|
||||
("WT_SESSION", "Windows Terminal"),
|
||||
("ConEmuPID", "ConEmu"),
|
||||
("TERMINAL_EMULATOR", ""), # JetBrains IDEs put the name in the value
|
||||
("KITTY_WINDOW_ID", "kitty"),
|
||||
("ALACRITTY_WINDOW_ID", "Alacritty"),
|
||||
("KONSOLE_VERSION", "Konsole"),
|
||||
("GNOME_TERMINAL_SCREEN", "GNOME Terminal"),
|
||||
("VTE_VERSION", "VTE-based"),
|
||||
)
|
||||
|
||||
#: What ``TERM_PROGRAM`` calls terminals whose own name is not what a person calls them.
|
||||
#: Only the ones that genuinely differ; every other value passes through as it is, which
|
||||
#: is what keeps this from growing into a catalogue of every terminal there is.
|
||||
_TERM_PROGRAM_NAMES: dict[str, str] = {
|
||||
"Apple_Terminal": "Terminal.app",
|
||||
"vscode": "VS Code",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Host:
|
||||
"""The machine and the Python running MeshTerm.
|
||||
|
||||
Attributes:
|
||||
install: How this copy was installed — ``"frozen"`` (a one-file build, which has
|
||||
its own bootloader, its own relaunch rules and no source tree to read),
|
||||
``"source"`` (a checkout, so a commit can be asked for), or ``"package"``
|
||||
(a wheel from an index). The three fail in different ways and the reporter
|
||||
usually does not know which one they have.
|
||||
os: The flavour and its version as a person writes it — ``Windows 11``,
|
||||
``macOS 15.6``, ``Debian GNU/Linux 12 (bookworm)``.
|
||||
os_build: The exact build under that name: a Windows build number, or the kernel
|
||||
release everywhere else. What separates two hosts that call themselves the
|
||||
same thing.
|
||||
arch: The processor architecture, which is what tells an arm64 build from an
|
||||
x86_64 one when a binary misbehaves.
|
||||
python: The interpreter version, three parts.
|
||||
"""
|
||||
|
||||
install: str
|
||||
os: str
|
||||
os_build: str
|
||||
arch: str
|
||||
python: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Terminal:
|
||||
"""The terminal MeshTerm is drawing into, and what it can draw.
|
||||
|
||||
Attributes:
|
||||
program: The emulator's name, or ``TERM``'s value where nothing identified
|
||||
itself, or ``None`` when even that is unset.
|
||||
term: The ``TERM`` value, verbatim. Kept beside ``program`` rather than folded
|
||||
into it: a terminal lying about ``TERM`` is itself a common cause of a
|
||||
rendering report.
|
||||
colorterm: The ``COLORTERM`` value, which is how a terminal claims truecolor and
|
||||
the first thing to check when a screen arrives in the wrong colours.
|
||||
cols: The terminal's width in cells.
|
||||
rows: The terminal's height in cells.
|
||||
over_ssh: Whether this is an ssh session. It changes what is knowable — the font
|
||||
lives on the far client — and it is why several other verdicts here read
|
||||
``unknown`` rather than wrong.
|
||||
"""
|
||||
|
||||
program: str | None
|
||||
term: str | None
|
||||
colorterm: str | None
|
||||
cols: int
|
||||
rows: int
|
||||
over_ssh: bool
|
||||
|
||||
|
||||
def install_kind() -> str:
|
||||
"""How this copy of MeshTerm got here: ``frozen``, ``source`` or ``package``.
|
||||
|
||||
A frozen build is unambiguous — the bootloader sets the flag. Past that, the question
|
||||
is whether there is a repository above the package: a checkout can be asked for its
|
||||
commit and can carry uncommitted work, and a wheel can do neither.
|
||||
|
||||
Returns:
|
||||
One of ``"frozen"``, ``"source"`` or ``"package"``.
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return "frozen"
|
||||
# meshterm/core/hostinfo.py -> meshterm/core -> meshterm -> the tree that holds it
|
||||
if (Path(__file__).resolve().parents[2] / ".git").exists():
|
||||
return "source"
|
||||
return "package"
|
||||
|
||||
|
||||
def host() -> Host:
|
||||
"""Read the machine's own facts.
|
||||
|
||||
Returns:
|
||||
The :class:`Host` block.
|
||||
"""
|
||||
name, build = _os_name_and_build()
|
||||
return Host(
|
||||
install=install_kind(),
|
||||
os=name,
|
||||
os_build=build,
|
||||
arch=platform.machine() or "unknown",
|
||||
python=platform.python_version(),
|
||||
)
|
||||
|
||||
|
||||
def terminal(environ: Mapping[str, str] | None = None) -> Terminal:
|
||||
"""Read the terminal's own facts.
|
||||
|
||||
Args:
|
||||
environ: The environment to inspect (defaults to ``os.environ``), injectable so
|
||||
the ladder can be tested without a terminal of the right kind to hand.
|
||||
|
||||
Returns:
|
||||
The :class:`Terminal` block.
|
||||
"""
|
||||
env = os.environ if environ is None else environ
|
||||
size = shutil.get_terminal_size()
|
||||
return Terminal(
|
||||
program=terminal_program(env),
|
||||
term=env.get("TERM") or None,
|
||||
colorterm=env.get("COLORTERM") or None,
|
||||
cols=size.columns,
|
||||
rows=size.lines,
|
||||
over_ssh=bool(env.get("SSH_TTY") or env.get("SSH_CONNECTION")),
|
||||
)
|
||||
|
||||
|
||||
def terminal_program(environ: Mapping[str, str] | None = None) -> str | None:
|
||||
"""Name the terminal emulator from what it says about itself.
|
||||
|
||||
The ladder: the terminals in :data:`_TERMINAL_VARS` that set a marker variable, then
|
||||
``TERM_PROGRAM`` (whose *value* is the name, so it covers terminals this module has
|
||||
never heard of), then ``TERM`` as the last honest thing there is to say.
|
||||
|
||||
Args:
|
||||
environ: The environment to inspect (defaults to ``os.environ``).
|
||||
|
||||
Returns:
|
||||
The terminal's name, or ``None`` when nothing in the environment names one.
|
||||
"""
|
||||
env = os.environ if environ is None else environ
|
||||
for var, name in _TERMINAL_VARS:
|
||||
value = env.get(var)
|
||||
if value:
|
||||
return name or value
|
||||
program = env.get("TERM_PROGRAM")
|
||||
if program:
|
||||
return _TERM_PROGRAM_NAMES.get(program, program)
|
||||
return env.get("TERM") or None
|
||||
|
||||
|
||||
def _os_name_and_build() -> tuple[str, str]:
|
||||
"""The OS as a person writes it, and the exact build under that name."""
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
return _windows()
|
||||
if system == "Darwin":
|
||||
version = platform.mac_ver()[0]
|
||||
return (f"macOS {version}" if version else "macOS"), platform.release()
|
||||
if system == "Linux":
|
||||
return (_pretty_name() or "Linux"), platform.release()
|
||||
return (system or "unknown"), platform.release()
|
||||
|
||||
|
||||
def _windows() -> tuple[str, str]:
|
||||
"""Windows by the name it is sold under, and its build number.
|
||||
|
||||
``platform.win32_ver`` reports release ``"10"`` on Windows 11 as well — the rename
|
||||
never reached the version APIs — so the build number is what decides which one this
|
||||
is. The build is also the useful half of the pair: it is what a fix is shipped
|
||||
against.
|
||||
"""
|
||||
release, version, _service_pack, _kind = platform.win32_ver()
|
||||
try:
|
||||
build = int(version.rsplit(".", 1)[-1])
|
||||
except ValueError:
|
||||
build = 0
|
||||
name = "Windows 11" if build >= _WINDOWS_11_BUILD else f"Windows {release or '?'}"
|
||||
return name, version or platform.version()
|
||||
|
||||
|
||||
def _pretty_name() -> str | None:
|
||||
"""The distribution's own name for itself, from ``/etc/os-release``.
|
||||
|
||||
The one place a Linux flavour is stated by the system rather than inferred, and the
|
||||
reason a report can say *Calculinux* or *Raspberry Pi OS* instead of ``Linux``.
|
||||
A host without the file (or without permission to read it) simply has no answer.
|
||||
"""
|
||||
try:
|
||||
text = Path("/etc/os-release").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
for line in text.splitlines():
|
||||
key, _, value = line.partition("=")
|
||||
if key == "PRETTY_NAME":
|
||||
return value.strip().strip('"') or None
|
||||
return None
|
||||
@@ -0,0 +1,413 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Reopening the session in a Terminal window that looks like MeshTerm.
|
||||
|
||||
macOS Terminal is not the classic Windows console's problem over again. It falls back
|
||||
generously — ask it for a glyph its font lacks and it finds one somewhere — so nothing here
|
||||
is ever a box. The trouble is *what* it finds: no Mac monospace face carries the Braille
|
||||
Patterns block at all (SF Mono, Menlo, Monaco and Courier all measure zero), so the block
|
||||
the charts and the map's terrain are drawn from is borrowed from a **proportional** face
|
||||
and arrives at the wrong width. The screen is not empty, it is crooked, which is worse —
|
||||
nothing announces itself as broken and the reader has nothing to compare it against.
|
||||
|
||||
The fix is a font, and a font means a Terminal profile that names it. Which raises the
|
||||
question this module exists to answer carefully: **whose terminal is it?**
|
||||
|
||||
MeshTerm's palette is the 16-colour VT set (:data:`~meshterm.ui.theme._VT_SLOTS`) — a
|
||||
deliberately crude, deliberately CGA-ish thing that the app wears on purpose. Nobody should
|
||||
have to live in it to read their mail. So this module never touches the reader's default
|
||||
profile and never edits their preferences: it *adds* a profile named MeshTerm, and opens
|
||||
MeshTerm's own window in it. Their Terminal keeps whatever they chose, down to its
|
||||
translucency; ours is opaque and crude in a window of its own, and quitting leaves nothing
|
||||
behind.
|
||||
|
||||
That also sidesteps a trap worth recording, because it costs an evening to find. Terminal
|
||||
rewrites ``~/Library/Preferences/com.apple.Terminal.plist`` **from memory when it quits**,
|
||||
so anything written into that file while Terminal is running is silently discarded later —
|
||||
and MeshTerm is always running inside it. Nothing here writes that file. A ``.terminal``
|
||||
file is handed to Terminal and *Terminal* does the importing, which is the one path that
|
||||
works from inside a live session.
|
||||
|
||||
The profile is generated rather than shipped, from the same theme constant the app draws
|
||||
itself with, so the window can never disagree with the screen it is holding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import plistlib
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .relaunch import REOPENED_ENV, own_command, wanted_size
|
||||
|
||||
#: The name the profile is installed under, and the name the reader sees in Terminal's
|
||||
#: settings list. Stable: renaming it would strand the copy already on their machine.
|
||||
PROFILE_NAME = "MeshTerm"
|
||||
|
||||
#: The bundled face, by its PostScript name — which is what an ``NSFont`` archive holds,
|
||||
#: and not the family name Font Book shows. Installed by :func:`install_font` from the copy
|
||||
#: in the package; see :mod:`meshterm.core.consolefont` for the Windows half of the same
|
||||
#: idea.
|
||||
FONT_FACE = "CascadiaMonoPL-Regular"
|
||||
|
||||
#: Points. Terminal's own default is 11; 13 is a size the braille charts read cleanly at on
|
||||
#: a Retina panel without the map losing rows.
|
||||
FONT_SIZE = 13.0
|
||||
|
||||
#: Where a user-installed font goes on macOS. No administrator rights, no installer, and
|
||||
#: the reader can undo it by dragging the file out of Font Book.
|
||||
USER_FONT_DIR = Path("~/Library/Fonts").expanduser()
|
||||
|
||||
#: Terminal's key for each palette slot, in :data:`~meshterm.ui.theme._VT_SLOTS` order —
|
||||
#: the standard ANSI 16, dim bank then bright bank.
|
||||
_ANSI_KEYS: tuple[str, ...] = (
|
||||
"ANSIBlackColor",
|
||||
"ANSIRedColor",
|
||||
"ANSIGreenColor",
|
||||
"ANSIYellowColor",
|
||||
"ANSIBlueColor",
|
||||
"ANSIMagentaColor",
|
||||
"ANSICyanColor",
|
||||
"ANSIWhiteColor",
|
||||
"ANSIBrightBlackColor",
|
||||
"ANSIBrightRedColor",
|
||||
"ANSIBrightGreenColor",
|
||||
"ANSIBrightYellowColor",
|
||||
"ANSIBrightBlueColor",
|
||||
"ANSIBrightMagentaColor",
|
||||
"ANSIBrightCyanColor",
|
||||
"ANSIBrightWhiteColor",
|
||||
)
|
||||
|
||||
|
||||
def _archive(objects: list[object]) -> bytes:
|
||||
"""One ``NSKeyedArchiver`` document, as Terminal stores every colour and font.
|
||||
|
||||
Terminal keeps these as nested binary plists inside the profile — an archived
|
||||
``NSColor`` or ``NSFont`` rather than a number and a string, because the profile is
|
||||
written by AppKit rather than for us. The shape is small and fixed, so it is built
|
||||
here rather than reached for through a dependency.
|
||||
|
||||
Args:
|
||||
objects: The ``$objects`` array, with ``$null`` already at index 0.
|
||||
|
||||
Returns:
|
||||
The encoded archive. Binary, because :class:`plistlib.UID` has no XML spelling.
|
||||
"""
|
||||
return plistlib.dumps(
|
||||
{
|
||||
"$archiver": "NSKeyedArchiver",
|
||||
"$objects": objects,
|
||||
"$top": {"root": plistlib.UID(1)},
|
||||
"$version": 100000,
|
||||
},
|
||||
fmt=plistlib.FMT_BINARY,
|
||||
)
|
||||
|
||||
|
||||
def ns_color(rgb: str) -> bytes:
|
||||
"""An archived ``NSColor`` for a ``#rrggbb`` string.
|
||||
|
||||
Args:
|
||||
rgb: The colour, as the theme spells it.
|
||||
|
||||
Returns:
|
||||
The archive Terminal expects in a colour key.
|
||||
"""
|
||||
channels = (int(rgb[index : index + 2], 16) / 255 for index in (1, 3, 5))
|
||||
# Calibrated RGB, space-separated to six places and NUL-terminated: AppKit's own
|
||||
# spelling, matched exactly because Terminal parses it rather than tolerating it.
|
||||
packed = " ".join(f"{value:.6f}" for value in channels).encode("ascii") + b"\x00"
|
||||
return _archive(
|
||||
[
|
||||
"$null",
|
||||
{"$class": plistlib.UID(2), "NSColorSpace": 1, "NSRGB": packed},
|
||||
{"$classes": ["NSColor", "NSObject"], "$classname": "NSColor"},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def ns_font(face: str = FONT_FACE, size: float = FONT_SIZE) -> bytes:
|
||||
"""An archived ``NSFont``.
|
||||
|
||||
Args:
|
||||
face: The PostScript name of the face.
|
||||
size: The size in points.
|
||||
|
||||
Returns:
|
||||
The archive Terminal expects in the ``Font`` key.
|
||||
"""
|
||||
return _archive(
|
||||
[
|
||||
"$null",
|
||||
{
|
||||
"$class": plistlib.UID(3),
|
||||
"NSName": plistlib.UID(2),
|
||||
"NSSize": float(size),
|
||||
"NSfFlags": 16,
|
||||
},
|
||||
face,
|
||||
{"$classes": ["NSFont", "NSObject"], "$classname": "NSFont"},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def launch_command() -> str:
|
||||
"""The shell line that starts this session again, for the profile to run.
|
||||
|
||||
Terminal starts the command from a **fresh login shell**, which is why this is a
|
||||
string rather than an argv and why it carries what it needs explicitly. Nothing of the
|
||||
current process's environment survives the hop — good news for the ``_PYI*`` variables
|
||||
a frozen build must not pass on (see
|
||||
:func:`~meshterm.core.relaunch.child_environment`, which exists for the Windows side
|
||||
where they *do* survive), and bad news for a ``MESHTERM_HOME`` the reader set for this
|
||||
invocation alone, which the README itself suggests doing. So every ``MESHTERM_*``
|
||||
variable currently set is carried across, and the reopened-session marker with them.
|
||||
|
||||
The variables ride on ``/usr/bin/env`` rather than on a ``VAR=value`` prefix, and that
|
||||
is not a stylistic choice. Terminal's own handling of the string is not something to
|
||||
lean on: measured on macOS 26, the same command line runs under ``RunCommandAsShell``
|
||||
*false* and fails under *true*, where Terminal takes the whole thing as the name of a
|
||||
program and puts this on the screen::
|
||||
|
||||
Command not found: MESHTERM_REOPENED=1
|
||||
Could not create a new process and open a pseudo-tty
|
||||
|
||||
``env`` is a real executable, so it is a valid argv *and* a valid shell line, and the
|
||||
window cannot land on that message however Terminal decides to read it. ``env`` execs
|
||||
the program in its own place, so nothing lingers behind it either — the window holds
|
||||
MeshTerm and nothing else, and closing MeshTerm does not drop to a prompt.
|
||||
|
||||
Returns:
|
||||
A command line Terminal can run with or without a shell.
|
||||
"""
|
||||
carried = {name: value for name, value in os.environ.items() if name.startswith("MESHTERM_")}
|
||||
carried[REOPENED_ENV] = "1"
|
||||
assignments = " ".join(
|
||||
f"{name}={shlex.quote(value)}" for name, value in sorted(carried.items())
|
||||
)
|
||||
program = " ".join(shlex.quote(part) for part in own_command())
|
||||
return f"/usr/bin/env {assignments} {program}"
|
||||
|
||||
|
||||
def build_profile(command: str | None = None) -> dict[str, object]:
|
||||
"""The MeshTerm profile, as Terminal's preferences hold it.
|
||||
|
||||
Every colour comes from :data:`~meshterm.ui.theme._VT_SLOTS`, so the window and the
|
||||
app it is holding cannot disagree — change the theme and this follows on the next
|
||||
write. The window size is
|
||||
:func:`~meshterm.core.relaunch.wanted_size`'s, the same one Windows Terminal is asked
|
||||
for.
|
||||
|
||||
Args:
|
||||
command: A shell line for the window to run on open, or ``None`` for a profile
|
||||
that just sits in the list waiting to be chosen.
|
||||
|
||||
Returns:
|
||||
The profile dictionary, ready to write as a ``.terminal`` file.
|
||||
"""
|
||||
from ..ui.theme import _VT_SLOTS
|
||||
|
||||
cols, rows = wanted_size()
|
||||
profile: dict[str, object] = {
|
||||
"name": PROFILE_NAME,
|
||||
"type": "Window Settings",
|
||||
"ProfileCurrentVersion": 2.07,
|
||||
"Font": ns_font(),
|
||||
"FontAntialias": True,
|
||||
"FontWidthSpacing": 1.0,
|
||||
# Opaque on purpose. A braille raster over a translucent ground is unreadable, and
|
||||
# a translucent profile is a perfectly reasonable thing for the reader to have --
|
||||
# which is the whole argument for MeshTerm having a window of its own.
|
||||
"BackgroundColor": ns_color("#000000"),
|
||||
"TextColor": ns_color("#aaaaaa"),
|
||||
"TextBoldColor": ns_color("#ffffff"),
|
||||
"SelectionColor": ns_color("#555555"),
|
||||
"CursorColor": ns_color("#55ffff"),
|
||||
"CursorType": 0,
|
||||
"BlinkText": False,
|
||||
# Bold is bold, not a jump to the bright bank. The theme states brightness as a
|
||||
# colour (see the wordmark's own note); letting Terminal promote it as well would
|
||||
# land a bank too high.
|
||||
"UseBrightBold": False,
|
||||
"columnCount": cols,
|
||||
"rowCount": rows,
|
||||
"ScrollbackLines": 10000,
|
||||
"ShouldLimitScrollback": 0,
|
||||
"ShowActiveProcessInTitle": True,
|
||||
"ShowDimensionsInTitle": False,
|
||||
"ShowWindowSettingsNameInTitle": False,
|
||||
# Close the window when MeshTerm exits cleanly, and keep it when it does not --
|
||||
# a crash the reader never sees is a bug report nobody can write.
|
||||
"shellExitAction": 1,
|
||||
}
|
||||
for key, (_slot, _label, rgb) in zip(_ANSI_KEYS, _VT_SLOTS, strict=True):
|
||||
profile[key] = ns_color(rgb)
|
||||
if command is not None:
|
||||
profile["CommandString"] = command
|
||||
# False, measured. Under *true* Terminal takes the whole string as a program name
|
||||
# and the window says "Command not found: ..."; under false it runs. The opposite
|
||||
# of what the key's name suggests, which is why it is written down.
|
||||
profile["RunCommandAsShell"] = False
|
||||
return profile
|
||||
|
||||
|
||||
def launcher_path() -> Path:
|
||||
"""The little script the profile runs, beside the profile itself."""
|
||||
from .config import default_config_dir
|
||||
|
||||
return default_config_dir() / "launch.sh"
|
||||
|
||||
|
||||
def write_launcher() -> Path:
|
||||
"""Write the launcher, and return where it landed.
|
||||
|
||||
This exists for one reason, and it is the finding that shaped this module. Terminal
|
||||
names an imported settings set **after the file** — ``MeshTerm.terminal`` becomes the
|
||||
profile "MeshTerm" — and re-importing the *same bytes* reuses that profile, while
|
||||
importing **different** bytes under the same filename creates "MeshTerm 1", then
|
||||
"MeshTerm 2". Measured, by opening one file four times: three identical imports left
|
||||
one profile, and the fourth, one word different, left two.
|
||||
|
||||
So the command cannot live in the profile. It carries ``sys.argv`` and the reader's
|
||||
``MESHTERM_*`` variables, which differ between ``meshterm`` and ``meshterm --mock``,
|
||||
and a profile per command line would fill the reader's settings list with junk that
|
||||
only they can remove.
|
||||
|
||||
Moving the variable part behind a fixed path fixes it exactly: the profile says
|
||||
``~/.meshterm/launch.sh`` and never changes, and this file underneath it says whatever
|
||||
this launch happens to need.
|
||||
|
||||
Returns:
|
||||
The path written, executable.
|
||||
"""
|
||||
path = launcher_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"# Written by MeshTerm each time it reopens itself. Safe to delete.\n"
|
||||
f"exec {launch_command()}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
path.chmod(0o755)
|
||||
return path
|
||||
|
||||
|
||||
def profile_path() -> Path:
|
||||
"""Where the generated ``.terminal`` file is kept.
|
||||
|
||||
In MeshTerm's own directory rather than the Desktop: it is ours, it is the one place
|
||||
that already survives an upgrade, and it moves with ``MESHTERM_HOME``.
|
||||
"""
|
||||
from .config import default_config_dir
|
||||
|
||||
return default_config_dir() / f"{PROFILE_NAME}.terminal"
|
||||
|
||||
|
||||
def write_profile(*, command: str | None = None) -> Path:
|
||||
"""Write the profile out, and return where it landed.
|
||||
|
||||
Args:
|
||||
command: A shell line for the window to run, or ``None`` for a bare profile.
|
||||
|
||||
Returns:
|
||||
The path written.
|
||||
"""
|
||||
path = profile_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(plistlib.dumps(build_profile(command), fmt=plistlib.FMT_XML))
|
||||
return path
|
||||
|
||||
|
||||
def font_installed() -> bool:
|
||||
"""Whether the bundled face is already in the reader's font directory."""
|
||||
from .consolefont import BUNDLED_FONT
|
||||
|
||||
return (USER_FONT_DIR / BUNDLED_FONT.name).is_file()
|
||||
|
||||
|
||||
def install_font() -> bool:
|
||||
"""Copy the bundled face into the reader's own font directory.
|
||||
|
||||
The one thing here that writes outside MeshTerm's own folder, which is why it is the
|
||||
one thing worth asking about: a single file, in the reader's own Library, no
|
||||
administrator rights, no installer, removable by dragging it out of Font Book. It
|
||||
changes nothing about how their Terminal looks — it only makes the face available for
|
||||
a profile to name.
|
||||
|
||||
Safe to repeat: an existing copy is left alone.
|
||||
|
||||
Returns:
|
||||
Whether the font is installed and usable, including when it already was.
|
||||
"""
|
||||
from .consolefont import BUNDLED_FONT
|
||||
|
||||
if sys.platform != "darwin" or not BUNDLED_FONT.is_file():
|
||||
return False
|
||||
target = USER_FONT_DIR / BUNDLED_FONT.name
|
||||
if target.is_file():
|
||||
return True
|
||||
try:
|
||||
USER_FONT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(BUNDLED_FONT, target)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def in_apple_terminal() -> bool:
|
||||
"""Whether this session is drawing into macOS Terminal.
|
||||
|
||||
iTerm2, Ghostty, kitty, WezTerm and VS Code all set ``TERM_PROGRAM`` to their own
|
||||
name, and several of them have both truecolor and a font the reader chose deliberately
|
||||
— none of them wants anything this module offers.
|
||||
"""
|
||||
return sys.platform == "darwin" and os.environ.get("TERM_PROGRAM") == "Apple_Terminal"
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
"""Whether reopening in MeshTerm's own window is a thing worth doing here.
|
||||
|
||||
``False`` in a session that was itself reopened, which is what keeps a relaunch from
|
||||
chaining into another one: the profile's own command carries the marker, because
|
||||
Terminal starts it from a fresh shell that inherits nothing from us.
|
||||
"""
|
||||
return in_apple_terminal() and not os.environ.get(REOPENED_ENV)
|
||||
|
||||
|
||||
def reopen() -> bool:
|
||||
"""Open MeshTerm in a window using its own profile, and report whether it started.
|
||||
|
||||
``open`` hands the file to Terminal, and **Terminal** imports the profile and opens the
|
||||
window — the only route that works from inside a live session, since Terminal discards
|
||||
external edits to its preferences when it quits.
|
||||
|
||||
The new window is not a child in any meaningful sense: the caller exits immediately
|
||||
afterwards and the two never talk.
|
||||
|
||||
Two files, not one: the launcher carries what varies and the profile points at it, so
|
||||
the profile's bytes are the same on every launch and Terminal reuses the one settings
|
||||
set instead of adding a numbered copy. :func:`write_launcher` has the measurement.
|
||||
|
||||
Returns:
|
||||
Whether ``open`` was launched. ``False`` leaves the caller exactly where it was,
|
||||
so a failure here is a detour and never a dead end.
|
||||
"""
|
||||
if not available():
|
||||
return False
|
||||
try:
|
||||
launcher = write_launcher()
|
||||
path = write_profile(command=str(launcher))
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
subprocess.Popen( # noqa: S603 - the argv is ours, not the reader's
|
||||
["/usr/bin/open", str(path)],
|
||||
close_fds=True,
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
+12
-2
@@ -48,14 +48,24 @@ NODE_TYPE_ROOM = 3
|
||||
NODE_TYPE_SENSOR = 4
|
||||
|
||||
#: Human-readable label for each advert type, for legends and summaries.
|
||||
#: The one name for each advert type, everywhere MeshTerm prints one and in every
|
||||
#: `--json` answer that carries a role or a type. A type-1 node is a companion: the radio
|
||||
#: someone talks through, the same kind as the one in your hand.
|
||||
NODE_TYPE_LABELS = {
|
||||
NODE_TYPE_CHAT: "node",
|
||||
NODE_TYPE_CHAT: "companion",
|
||||
NODE_TYPE_REPEATER: "repeater",
|
||||
NODE_TYPE_ROOM: "room",
|
||||
NODE_TYPE_ROOM: "room server",
|
||||
NODE_TYPE_SENSOR: "sensor",
|
||||
}
|
||||
|
||||
|
||||
def node_type_label(node_type: int | None) -> str | None:
|
||||
"""The name for an advert type, ``type N`` for one MeshTerm does not know, None for none."""
|
||||
if node_type is None:
|
||||
return None
|
||||
return NODE_TYPE_LABELS.get(int(node_type), f"type {node_type}")
|
||||
|
||||
|
||||
class LoginResult(Enum):
|
||||
"""How an admin login ended — and, crucially, whether the node was there to end it.
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""What every relaunch of MeshTerm needs, whichever terminal it is relaunching into.
|
||||
|
||||
MeshTerm reopens itself when the terminal it was started from cannot draw it, and there
|
||||
are two such terminals for two unrelated reasons. The classic Windows console has no font
|
||||
fallback at all, so an icon it lacks is a box and no font choice fixes it
|
||||
(:mod:`meshterm.core.winterminal`). macOS Terminal falls back generously — too generously:
|
||||
no Mac monospace face carries the Braille Patterns block, so the charts are borrowed from a
|
||||
*proportional* face and land at the wrong width (:mod:`meshterm.core.macterminal`). One
|
||||
host shows nothing, the other shows something crooked.
|
||||
|
||||
The destinations could hardly be less alike. The act is the same one: work out the command
|
||||
that would start this session again, hand it an environment a fresh launch can survive, and
|
||||
let the caller exit. So it is written once, here, and each platform module supplies only
|
||||
the part that is genuinely its own — which terminal, and how to ask it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
#: Set in the reopened session, so a relaunch can never chain into another one. Each
|
||||
#: platform's own gate would usually refuse a second pass anyway — Windows Terminal is not
|
||||
#: a classic console; a window opened from MeshTerm's profile is already in it — but this
|
||||
#: is the belt to those braces, and it costs one environment variable.
|
||||
REOPENED_ENV = "MESHTERM_REOPENED"
|
||||
|
||||
|
||||
def own_command() -> list[str]:
|
||||
"""The command that would start this same session again.
|
||||
|
||||
A frozen build is its own executable and takes the arguments as they were given. A
|
||||
development or ``pip`` install is reached through its interpreter, because the console
|
||||
script's own path is not something the receiving terminal can be relied on to resolve.
|
||||
|
||||
Returns:
|
||||
The program and its arguments, ready to hand to a terminal.
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return [sys.executable, *sys.argv[1:]]
|
||||
return [sys.executable, "-m", "meshterm", *sys.argv[1:]]
|
||||
|
||||
|
||||
def child_environment() -> dict[str, str]:
|
||||
"""The environment for a fresh launch of MeshTerm, minus this bundle's bookkeeping.
|
||||
|
||||
A PyInstaller one-file build runs in two stages: the executable unpacks itself into a
|
||||
temporary directory and then runs itself again as a child, the two halves coordinating
|
||||
through ``_PYI*`` environment variables. Those must not reach a *new* launch of the
|
||||
executable. The bootloader would find them, conclude it is the second stage of a launch
|
||||
it never made, check that its parent is the same program — find the terminal instead —
|
||||
and abort::
|
||||
|
||||
[PYI-33368:ERROR] Security validation failure: parent process has different
|
||||
executable!
|
||||
|
||||
Which is the whole session gone, in a brand new window holding a message nobody can act
|
||||
on, while the window that offered the move has already closed. Removing them makes the
|
||||
new process a first stage, which is what it actually is.
|
||||
|
||||
Returns:
|
||||
The environment to start the new process with.
|
||||
"""
|
||||
environment = dict(os.environ)
|
||||
environment[REOPENED_ENV] = "1"
|
||||
if not getattr(sys, "frozen", False):
|
||||
return environment
|
||||
|
||||
for key in [key for key in environment if key.startswith("_PYI")]:
|
||||
del environment[key]
|
||||
environment.pop("_MEIPASS2", None) # the name older bootloaders used
|
||||
# The bootloader points the loader at the unpacked bundle and parks the caller's own
|
||||
# value beside it. The new process unpacks its own copy, so it wants the original.
|
||||
for name in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"):
|
||||
original = environment.pop(f"{name}_ORIG", None)
|
||||
if original is not None:
|
||||
environment[name] = original
|
||||
else:
|
||||
environment.pop(name, None)
|
||||
return environment
|
||||
|
||||
|
||||
def wanted_size() -> tuple[int, int]:
|
||||
"""The window to ask for, as ``(cols, rows)``.
|
||||
|
||||
Asked for, because a window MeshTerm opens should be one MeshTerm fits in. A session
|
||||
that lands in whatever size the reader's shell happens to launch at can arrive too
|
||||
small for the app, and the failure is quiet and cosmetic: the startup wordmark is 71
|
||||
columns, so at 70 it silently swaps to the narrow one drawn for the PicoCalc, and a
|
||||
short window truncates every description onto the pager.
|
||||
|
||||
The numbers come from the platform's own stated minimum plus a margin, so they follow
|
||||
it rather than repeating it. A terminal is free to clamp to what the display can show,
|
||||
so this is a request and not a demand — on a small screen the app degrades exactly as
|
||||
it would have anyway.
|
||||
|
||||
Returns:
|
||||
The columns and rows to ask the terminal for.
|
||||
"""
|
||||
from ..platforms import get_platform
|
||||
|
||||
platform = get_platform()
|
||||
return platform.readable_cols + 8, platform.readable_rows + 6
|
||||
@@ -30,10 +30,15 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
#: Set in the reopened session, so a relaunch can never chain into another one. Windows
|
||||
#: Terminal is not a classic console and so would not qualify anyway — this is the belt to
|
||||
#: that braces, and it costs one environment variable.
|
||||
REOPENED_ENV = "MESHTERM_REOPENED"
|
||||
from .relaunch import REOPENED_ENV, child_environment, own_command, wanted_size
|
||||
|
||||
__all__ = [
|
||||
"REOPENED_ENV",
|
||||
"available",
|
||||
"child_environment",
|
||||
"own_command",
|
||||
"reopen",
|
||||
]
|
||||
|
||||
|
||||
def available() -> str | None:
|
||||
@@ -47,60 +52,6 @@ def available() -> str | None:
|
||||
return shutil.which("wt.exe")
|
||||
|
||||
|
||||
def own_command() -> list[str]:
|
||||
"""The command that would start this same session again.
|
||||
|
||||
A frozen build is its own executable and takes the arguments as they were given. A
|
||||
development or ``pip`` install is reached through its interpreter, because the console
|
||||
script's own path is not something ``wt`` can be relied on to resolve.
|
||||
|
||||
Returns:
|
||||
The program and its arguments, ready to hand to Windows Terminal.
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return [sys.executable, *sys.argv[1:]]
|
||||
return [sys.executable, "-m", "meshterm", *sys.argv[1:]]
|
||||
|
||||
|
||||
def child_environment() -> dict[str, str]:
|
||||
"""The environment for a fresh launch of MeshTerm, minus this bundle's bookkeeping.
|
||||
|
||||
A PyInstaller one-file build runs in two stages: the executable unpacks itself into a
|
||||
temporary directory and then runs itself again as a child, the two halves coordinating
|
||||
through ``_PYI*`` environment variables. Those must not reach a *new* launch of the
|
||||
executable. The bootloader would find them, conclude it is the second stage of a launch
|
||||
it never made, check that its parent is the same program — find Windows Terminal
|
||||
instead — and abort::
|
||||
|
||||
[PYI-33368:ERROR] Security validation failure: parent process has different
|
||||
executable!
|
||||
|
||||
Which is the whole session gone, in a brand new window holding a message nobody can act
|
||||
on, while the window that offered the move has already closed. Removing them makes the
|
||||
new process a first stage, which is what it actually is.
|
||||
|
||||
Returns:
|
||||
The environment to start the new process with.
|
||||
"""
|
||||
environment = dict(os.environ)
|
||||
environment[REOPENED_ENV] = "1"
|
||||
if not getattr(sys, "frozen", False):
|
||||
return environment
|
||||
|
||||
for key in [key for key in environment if key.startswith("_PYI")]:
|
||||
del environment[key]
|
||||
environment.pop("_MEIPASS2", None) # the name older bootloaders used
|
||||
# The bootloader points the loader at the unpacked bundle and parks the caller's own
|
||||
# value beside it. The new process unpacks its own copy, so it wants the original.
|
||||
for name in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"):
|
||||
original = environment.pop(f"{name}_ORIG", None)
|
||||
if original is not None:
|
||||
environment[name] = original
|
||||
else:
|
||||
environment.pop(name, None)
|
||||
return environment
|
||||
|
||||
|
||||
def reopen() -> bool:
|
||||
"""Start this session again in Windows Terminal, in the same directory.
|
||||
|
||||
@@ -148,20 +99,12 @@ def reopen() -> bool:
|
||||
def _wanted_size() -> str:
|
||||
"""The window to ask Windows Terminal for, as ``cols,rows``.
|
||||
|
||||
Asked for, because a window MeshTerm opens should be one MeshTerm fits in. Without
|
||||
``-w new`` the session lands as a *tab* in whatever window happens to be open and
|
||||
inherits its size; without ``--size`` a new window takes the reader's global launch
|
||||
size, which is a setting about their shell and not about this app. Either way the app
|
||||
can arrive in a window too small for it, and the failure is quiet and cosmetic: the
|
||||
startup wordmark is 71 columns, so at 70 it silently swaps to the narrow one drawn for
|
||||
the PicoCalc, and a short window truncates every description onto the pager.
|
||||
|
||||
The numbers come from the platform's own stated minimum plus a margin, so they follow
|
||||
it rather than repeating it. Windows Terminal clamps to what the display can show, so
|
||||
this is a request and not a demand — on a small screen the app degrades exactly as it
|
||||
would have anyway.
|
||||
Asked for, because without ``-w new`` the session lands as a *tab* in whatever window
|
||||
happens to be open and inherits its size, and without ``--size`` a new window takes the
|
||||
reader's global launch size, which is a setting about their shell and not about this
|
||||
app. The dimensions themselves are
|
||||
:func:`~meshterm.core.relaunch.wanted_size`'s, shared with every other terminal
|
||||
MeshTerm reopens itself in; only the spelling is Windows Terminal's.
|
||||
"""
|
||||
from ..platforms import get_platform
|
||||
|
||||
platform = get_platform()
|
||||
return f"{platform.readable_cols + 8},{platform.readable_rows + 6}"
|
||||
cols, rows = wanted_size()
|
||||
return f"{cols},{rows}"
|
||||
@@ -1068,6 +1068,69 @@ class Repository:
|
||||
row = self._conn.execute("SELECT COUNT(*) AS n FROM observations").fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
def table_counts(self) -> dict[str, int]:
|
||||
"""Return a row count for every table in the database, by name.
|
||||
|
||||
Read off ``sqlite_master`` rather than a list written out here, which is the whole
|
||||
point: a table added to the schema starts being reported the day it lands, and a
|
||||
table removed stops, with nothing to keep in step. The one thing this must never
|
||||
become is a curated set of the counts somebody thought were interesting — the
|
||||
shape of a database is the aggregate, and the interesting one is always the table
|
||||
nobody expected to be full.
|
||||
|
||||
Every value is a *count*. Nothing here reads a row, so the result says how much
|
||||
history a report's author has without saying a word about who they talk to.
|
||||
|
||||
Returns:
|
||||
Table name to row count, alphabetically, SQLite's own internal tables
|
||||
excluded (they describe the file format, not this app's data).
|
||||
"""
|
||||
names = [
|
||||
str(row["name"])
|
||||
for row in self._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||||
"AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
).fetchall()
|
||||
]
|
||||
counts: dict[str, int] = {}
|
||||
for name in names:
|
||||
# The names come from sqlite_master, so they are this database's own tables
|
||||
# and not caller input; quoted anyway, because an identifier cannot be bound.
|
||||
row = self._conn.execute(f'SELECT COUNT(*) AS n FROM "{name}"').fetchone()
|
||||
counts[name] = int(row["n"]) if row else 0
|
||||
return counts
|
||||
|
||||
def failed_run_count(self) -> int:
|
||||
"""Return how many recorded tool runs ended in an error.
|
||||
|
||||
The single most useful number a reporter can hand over: it says whether this
|
||||
install has been failing quietly for weeks or broke for the first time today,
|
||||
and it costs one query rather than an interview.
|
||||
|
||||
Returns:
|
||||
The number of ``runs`` rows whose status is ``error``.
|
||||
"""
|
||||
row = self._conn.execute("SELECT COUNT(*) AS n FROM runs WHERE status = 'error'").fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
def observation_span(self) -> tuple[datetime | None, datetime | None]:
|
||||
"""Return the first and last times anything was overheard.
|
||||
|
||||
How much mesh this database has actually seen. A report whose span is an hour and
|
||||
a report whose span is a year describe different installs, and the difference
|
||||
explains a class of "it works here" before anyone else has to ask for it.
|
||||
|
||||
Returns:
|
||||
``(first, last)`` as aware UTC times, or ``(None, None)`` when nothing has
|
||||
ever been heard.
|
||||
"""
|
||||
row = self._conn.execute(
|
||||
"SELECT MIN(observed_at) AS first, MAX(observed_at) AS last FROM observations"
|
||||
).fetchone()
|
||||
if row is None or row["first"] is None:
|
||||
return None, None
|
||||
return _as_when(row["first"]), _as_when(row["last"])
|
||||
|
||||
def recent_observations(self, *, since: datetime, limit: int = 4000) -> list[Observation]:
|
||||
"""Return raw stored observations in a recent window, oldest first.
|
||||
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``diagnostics`` tool: everything a bug report needs, in one block worth pasting.
|
||||
|
||||
A report that opens with "it crashed" costs two or three round trips before anyone knows
|
||||
which MeshTerm, which OS, which terminal, which radio — and the person who hit the bug is
|
||||
usually the one least able to answer, because half of it is resolved at boot and never
|
||||
shown. This states all of it at once.
|
||||
|
||||
**What it deliberately does not say.** The block is designed to be pasted in public by
|
||||
somebody who has not read it, so nothing that identifies a person or unlocks anything may
|
||||
be in it: no pairing PIN, no admin password, no channel secret, no private key, no
|
||||
position, and no contact's name or key. The mesh is described in **aggregate** — how many
|
||||
rows of each kind, and the span of time they cover — which is the half that explains a bug
|
||||
(a database with four observations and one with four hundred thousand fail differently)
|
||||
without naming anyone the reporter talks to. :func:`~meshterm.core.hostinfo.host` and
|
||||
:meth:`~meshterm.persistence.repository.Repository.table_counts` hold that line on their
|
||||
own side, so it cannot be crossed here by accident.
|
||||
|
||||
**One report, three faces**, which is the whole reason this is a report and not a
|
||||
``print``: the menu draws it as a written page (see :mod:`meshterm.ui.diagnostics`) and
|
||||
saves it as markdown, ``meshterm diagnostics`` prints the same facts as aligned records for
|
||||
anyone who would rather redirect than read, and ``--json`` hands them to whatever files the
|
||||
issue. The document face is the one that leaves this machine, so it is the one written in
|
||||
the format an issue tracker already renders.
|
||||
|
||||
The device is asked but never required. A report about a radio that will not connect is
|
||||
exactly the report that most needs to be filed, so a failure to reach it becomes a fact in
|
||||
the block (``connected``/``error``) rather than an error that replaces it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core.models import node_type_label
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Report
|
||||
|
||||
#: What the saved file is called, in the config directory beside the log it sits next to
|
||||
#: in a bug report. Named for the app rather than just ``diagnostics.md`` because the one
|
||||
#: thing that happens to this file is being moved somewhere else and attached, and by then
|
||||
#: the directory it came from is no longer saying what it is.
|
||||
DIAGNOSTICS_FILENAME = "meshterm-diagnostics.md"
|
||||
|
||||
|
||||
@register
|
||||
class DiagnosticsTool(Tool):
|
||||
"""What MeshTerm, this machine, this terminal and this radio actually are."""
|
||||
|
||||
name = "diagnostics"
|
||||
title = "Diagnostics"
|
||||
icon = "🩺"
|
||||
help = "Version, host, terminal and device facts for a bug report"
|
||||
category = "This app"
|
||||
order = 7 # after Preferences (the row that changes MeshTerm), before the pages
|
||||
|
||||
def register_cli(self, app: Any) -> None:
|
||||
"""Register the command, with the one option that turns the answer into a file.
|
||||
|
||||
``--out`` is spelled the way ``config export-key --out`` already spells it, rather
|
||||
than inventing a second word for writing an answer to a path. Plain redirection
|
||||
still works and is not replaced by it — what the option adds is a file whose
|
||||
columns are laid out at full width instead of at whatever the terminal happened to
|
||||
be, which is the difference between a readable attachment and a folded one.
|
||||
"""
|
||||
import typer
|
||||
|
||||
from ..cli import run_tool_command
|
||||
|
||||
@app.command(name=self.name, help=self.help)
|
||||
def _command(
|
||||
out: Path | None = typer.Option(
|
||||
None, "--out", help="Write the block to this file instead of printing it"
|
||||
),
|
||||
) -> None:
|
||||
run_tool_command(self, {"out": out})
|
||||
|
||||
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
|
||||
"""Open the page in the menu; returning ``None`` completes with no run row.
|
||||
|
||||
The same shape the About pages use — and the same screen, since the page *is* one:
|
||||
the menu's whole interaction is the reading of it, and a run row recording that
|
||||
somebody looked at their own version number would be a line in the log for every
|
||||
glance. Saving from the page is likewise not a run.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
|
||||
Returns:
|
||||
Always ``None`` in the menu; the CLI never reaches this.
|
||||
"""
|
||||
from ..ui.diagnostics import open_diagnostics_page
|
||||
|
||||
report = await build_report(ctx)
|
||||
await open_diagnostics_page(ctx, markdown_source(report), save_path=default_path(ctx))
|
||||
return None
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""State the diagnostics, or write them to a file — only reachable from the CLI.
|
||||
|
||||
With ``--out`` the answer *becomes the path*, the same trade ``config export-key``
|
||||
makes: a caller who asked for a file wants to be told where it is, not handed the
|
||||
contents they just redirected into it.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
params: ``out`` — a destination path, or ``None`` to print the block.
|
||||
|
||||
Returns:
|
||||
A :class:`ToolResult` carrying the block, or the path it was written to.
|
||||
"""
|
||||
report = await build_report(ctx)
|
||||
out = params.get("out")
|
||||
if out is None:
|
||||
return ToolResult(summary={"tool": self.name}, report=report)
|
||||
|
||||
written = write_markdown(markdown_source(report), out)
|
||||
ctx.ui.ack("[ok]✓[/ok] diagnostics written — attach it to the report.")
|
||||
return ToolResult(
|
||||
summary={"tool": self.name, "path": str(written)},
|
||||
artifacts=[str(written)],
|
||||
report=(_written_to(written),),
|
||||
)
|
||||
|
||||
|
||||
async def build_report(ctx: AppContext) -> Report:
|
||||
"""Gather every diagnostic fact, as the report both faces render.
|
||||
|
||||
Seven blocks in the order a reader needs them: what this program is, what it is running
|
||||
on, what it is drawing into, what radio it found, how much history it has, what that
|
||||
history is made of, and what has been changed from the defaults. Each carries a
|
||||
``caption``, which is what the markdown face turns into the page's ``##`` landmarks.
|
||||
|
||||
The five :class:`~meshterm.ui.report.Facts` blocks merge into one flat JSON object and
|
||||
the two :class:`~meshterm.ui.report.Listing` blocks keep a key of their own — which is
|
||||
also why the table counts are a listing, since a table named after a fact would
|
||||
otherwise be able to collide with it.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
|
||||
Returns:
|
||||
The seven-block report.
|
||||
"""
|
||||
return (
|
||||
_app_facts(),
|
||||
_host_facts(),
|
||||
_terminal_facts(),
|
||||
await _device_facts(ctx),
|
||||
_mesh_facts(ctx),
|
||||
_table_listing(ctx),
|
||||
_preference_listing(ctx),
|
||||
)
|
||||
|
||||
|
||||
def markdown_source(report: Report, *, when: datetime | None = None) -> str:
|
||||
"""The whole block as a markdown document — the page's source, and the file's contents.
|
||||
|
||||
Markdown because both places this goes already speak it: the menu draws it through
|
||||
:func:`~meshterm.ui.markdown.render_markdown`, the same renderer behind the About
|
||||
pages, so the page gets real ``##`` landmarks that pin as it scrolls and that its
|
||||
section jumps step by; and an issue tracker renders it as written, with no fence and
|
||||
no apology. The command line keeps its aligned records — that face is a stream a
|
||||
person greps, and headings are furniture on it.
|
||||
|
||||
Args:
|
||||
report: The blocks to write out.
|
||||
when: The moment to stamp in the colophon (defaults to now, local). Injectable so
|
||||
the same report can be rendered twice and compared.
|
||||
|
||||
Returns:
|
||||
Markdown source, ready for :func:`~meshterm.ui.markdown.render_markdown` or a file.
|
||||
"""
|
||||
from .. import __version__
|
||||
from ..ui.renderers import markdown_blocks
|
||||
|
||||
stamp = (when or datetime.now().astimezone()).strftime("%Y-%m-%d %H:%M")
|
||||
return (
|
||||
# The `#` title carries what the frame around it cannot — which is the same reason
|
||||
# `about.md` has one and the other three written pages do not. The screen is already
|
||||
# headed "Diagnostics"; repeating that would spend the page's first line saying
|
||||
# nothing, while the version belongs at the top of a file somebody will open later.
|
||||
# Emphasis makes it the muted qualifier beside the name.
|
||||
f"# MeshTerm diagnostics *v{__version__}*\n\n"
|
||||
# The standfirst sits flush and muted under the title, and says the one thing a
|
||||
# reader needs before reading any of it: this is safe to hand over.
|
||||
"Everything a bug report opens with. Nothing here is private.\n\n"
|
||||
f"{markdown_blocks(report)}\n\n"
|
||||
"---\n\n"
|
||||
f"Taken {stamp}.\n"
|
||||
)
|
||||
|
||||
|
||||
def default_path(ctx: AppContext) -> Path:
|
||||
"""Where the page saves when nobody named a place: beside the log, in the config dir.
|
||||
|
||||
The config directory rather than the working directory, which in the menu is wherever
|
||||
the reader happened to launch from and is not somewhere they can be told to look. It
|
||||
is also where the log already lives, so "send me both" names one folder.
|
||||
"""
|
||||
return ctx.settings.config_dir / DIAGNOSTICS_FILENAME
|
||||
|
||||
|
||||
def write_markdown(source: str, path: Path | str) -> Path:
|
||||
"""Write a markdown document to ``path``, creating its directory.
|
||||
|
||||
**Always markdown**, whatever face the run was printing. This file exists to be
|
||||
attached to an issue and read there, and an issue tracker renders markdown; anyone who
|
||||
wants the machine face can redirect ``--json`` and already has a better name for that
|
||||
file than this one.
|
||||
|
||||
Args:
|
||||
source: The document, from :func:`markdown_source`.
|
||||
path: The destination.
|
||||
|
||||
Returns:
|
||||
The path written.
|
||||
|
||||
Raises:
|
||||
OSError: If the directory cannot be made or the file cannot be written.
|
||||
"""
|
||||
written = Path(path)
|
||||
written.parent.mkdir(parents=True, exist_ok=True)
|
||||
written.write_text(source, encoding="utf-8")
|
||||
return written
|
||||
|
||||
|
||||
def _written_to(written: Path) -> Any:
|
||||
"""The answer a ``--out`` run gives: the path, alone on the plain face."""
|
||||
from ..ui import fields
|
||||
from ..ui.report import BARE, Facts
|
||||
|
||||
return Facts(
|
||||
key="saved",
|
||||
fields=(fields.path("path", "path"),),
|
||||
values={"path": written},
|
||||
shape=BARE,
|
||||
bare="path",
|
||||
)
|
||||
|
||||
|
||||
def _app_facts() -> Any:
|
||||
"""What this program is: version, how it was installed, and where it keeps things."""
|
||||
from .. import __version__
|
||||
from ..core import hostinfo
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
|
||||
return Facts(
|
||||
key="meshterm",
|
||||
caption="Build",
|
||||
fields=(
|
||||
fields.word("meshterm", "meshterm"),
|
||||
fields.word("install", "install"),
|
||||
),
|
||||
values={"meshterm": __version__, "install": hostinfo.install_kind()},
|
||||
)
|
||||
|
||||
|
||||
def _host_facts() -> Any:
|
||||
"""The machine underneath: which OS, which build, which processor, which Python."""
|
||||
from ..core import hostinfo
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
|
||||
machine = hostinfo.host()
|
||||
return Facts(
|
||||
key="host",
|
||||
caption="Host",
|
||||
fields=(
|
||||
fields.word("os", "os"),
|
||||
fields.word("os_build", "os_build"),
|
||||
fields.word("arch", "arch"),
|
||||
fields.word("python", "python"),
|
||||
),
|
||||
values={
|
||||
"os": machine.os,
|
||||
"os_build": machine.os_build,
|
||||
"arch": machine.arch,
|
||||
"python": machine.python,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _terminal_facts() -> Any:
|
||||
"""The terminal, and every verdict about it resolved once at boot.
|
||||
|
||||
Its own section rather than more host facts, because these are what a "it looks wrong"
|
||||
report is actually about, and a reader skimming for them should not have to pass the
|
||||
processor architecture on the way. Whether icons draw, whether the path widget's
|
||||
separators draw, which platform flavour resolved and *why* — each is decided from the
|
||||
environment before the first frame and none of it is visible on any screen.
|
||||
"""
|
||||
from ..core import hostinfo
|
||||
from ..platforms import get_platform
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
from ..ui.termfont import detect_terminal_font, emoji_support, powerline_support
|
||||
|
||||
term = hostinfo.terminal()
|
||||
emoji = emoji_support()
|
||||
powerline = powerline_support()
|
||||
font = detect_terminal_font()
|
||||
return Facts(
|
||||
key="terminal",
|
||||
caption="Terminal",
|
||||
fields=(
|
||||
fields.word("terminal", "terminal"),
|
||||
fields.word("term", "term"),
|
||||
fields.word("colorterm", "colorterm"),
|
||||
fields.word("terminal_size", "terminal_size"),
|
||||
fields.flag("over_ssh", "over_ssh"),
|
||||
fields.word("platform", "platform"),
|
||||
fields.flag("icons", "icons"),
|
||||
fields.word("icons_source", "icons_source"),
|
||||
fields.word("font", "font"),
|
||||
fields.word("powerline", "powerline"),
|
||||
),
|
||||
values={
|
||||
"terminal": term.program,
|
||||
"term": term.term,
|
||||
"colorterm": term.colorterm,
|
||||
"terminal_size": f"{term.cols}x{term.rows}",
|
||||
"over_ssh": term.over_ssh,
|
||||
"platform": get_platform().name,
|
||||
"icons": emoji.supported,
|
||||
"icons_source": emoji.source,
|
||||
# The face, and which terminal it was read from — an unidentified terminal
|
||||
# (or an ssh session, where the font lives on the far client) has no answer,
|
||||
# and saying so is worth more than naming a font nobody here can see.
|
||||
"font": f"{font.face} ({font.source})" if font else None,
|
||||
# Level and source together: `none (font:windows-terminal)` is a measurement
|
||||
# and `unknown (ssh)` is an admission, and they lead somewhere different.
|
||||
"powerline": f"{powerline.level} ({powerline.source})",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _device_facts(ctx: AppContext) -> Any:
|
||||
"""The radio: what it is, how it is attached, and what its link is set to.
|
||||
|
||||
Best-effort by design. Every value here is ``None`` on a machine whose radio is
|
||||
unplugged, asleep, or refusing to pair — and ``connected: false`` with the failure's
|
||||
own words in ``error`` is a better first line for that bug report than a command that
|
||||
refused to produce one.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
|
||||
values: dict[str, Any] = dict.fromkeys(
|
||||
(
|
||||
"connected",
|
||||
"transport",
|
||||
"port",
|
||||
"device_role",
|
||||
"device_model",
|
||||
"firmware",
|
||||
"radio_freq_mhz",
|
||||
"radio_bw_khz",
|
||||
"radio_sf",
|
||||
"radio_cr",
|
||||
"tx_power_dbm",
|
||||
"error",
|
||||
)
|
||||
)
|
||||
values["connected"] = False
|
||||
try:
|
||||
device = await ctx.device()
|
||||
snapshot = await _snapshot(ctx, device)
|
||||
info = await _device_info(device)
|
||||
adv_type = snapshot.get("adv_type")
|
||||
values.update(
|
||||
connected=True,
|
||||
transport="mock" if ctx.mock else (ctx.active_transport or "serial"),
|
||||
port=ctx.active_port,
|
||||
device_role=node_type_label(adv_type),
|
||||
device_model=info.get("model") or None,
|
||||
firmware=" ".join(str(info[k]) for k in ("ver", "fw_build") if info.get(k)) or None,
|
||||
radio_freq_mhz=snapshot.get("radio_freq"),
|
||||
radio_bw_khz=snapshot.get("radio_bw"),
|
||||
radio_sf=snapshot.get("radio_sf"),
|
||||
radio_cr=snapshot.get("radio_cr"),
|
||||
tx_power_dbm=snapshot.get("tx_power"),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - any failure to reach the radio is the fact
|
||||
values["error"] = str(exc) or exc.__class__.__name__
|
||||
|
||||
return Facts(
|
||||
key="device",
|
||||
caption="Radio",
|
||||
fields=(
|
||||
fields.flag("connected", "connected"),
|
||||
fields.word("transport", "transport"),
|
||||
fields.word("port", "port"),
|
||||
fields.word("device_role", "device_role"),
|
||||
fields.word("device_model", "device_model"),
|
||||
fields.word("firmware", "firmware"),
|
||||
fields.decimal("radio_freq_mhz", "radio_freq_mhz", ".4f"),
|
||||
fields.decimal("radio_bw_khz", "radio_bw_khz", ".2f"),
|
||||
fields.integer("radio_sf", "radio_sf"),
|
||||
fields.integer("radio_cr", "radio_cr"),
|
||||
fields.integer("tx_power_dbm", "tx_power_dbm"),
|
||||
fields.free("error", "error"),
|
||||
),
|
||||
values=values,
|
||||
)
|
||||
|
||||
|
||||
def _mesh_facts(ctx: AppContext) -> Any:
|
||||
"""How much history this install holds, and where it holds it.
|
||||
|
||||
Counts and a span, never a row. The span is the fact that sizes every other number
|
||||
here: a thousand observations over two years and a thousand in an afternoon describe
|
||||
two different installs, and only one of them has a radio worth suspecting.
|
||||
"""
|
||||
from ..core.preferences import current
|
||||
from ..persistence.logging import log_path
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
|
||||
preferences = ctx.preferences or current()
|
||||
db_path = ctx.settings.db_path
|
||||
first, last = ctx.repo.observation_span()
|
||||
return Facts(
|
||||
key="mesh",
|
||||
caption="Storage",
|
||||
fields=(
|
||||
fields.path("config_dir", "config_dir"),
|
||||
fields.integer("db_size_kb", "db_size_kb"),
|
||||
fields.word("log_level", "log_level"),
|
||||
fields.integer("log_size_kb", "log_size_kb"),
|
||||
fields.integer("runs_failed", "runs_failed"),
|
||||
fields.instant("first_heard", "first_heard"),
|
||||
fields.instant("last_heard", "last_heard"),
|
||||
),
|
||||
values={
|
||||
"config_dir": ctx.settings.config_dir,
|
||||
"db_size_kb": _size_kb(db_path),
|
||||
"log_level": preferences.log_level,
|
||||
# How much the log holds, so a maintainer asking for it knows what they are
|
||||
# asking for — and so a file still at zero says the level never let it write.
|
||||
"log_size_kb": _size_kb(log_path(ctx.settings.config_dir)),
|
||||
"runs_failed": ctx.repo.failed_run_count(),
|
||||
"first_heard": first,
|
||||
"last_heard": last,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _table_listing(ctx: AppContext) -> Any:
|
||||
"""Every table in the database and how many rows it holds.
|
||||
|
||||
A listing rather than more facts, for two reasons. The keys are discovered from the
|
||||
schema rather than declared, so a table named after a fact could otherwise collide
|
||||
with it in the merged JSON object; and this genuinely is a set of records — one per
|
||||
table — which is the shape a reader and a parser both already know.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Column, Listing
|
||||
|
||||
counts = ctx.repo.table_counts()
|
||||
return Listing(
|
||||
key="tables",
|
||||
caption="Stored rows",
|
||||
columns=(fields.word("table", "TABLE"), Column(key="rows", lanes=(_rows_lane(),))),
|
||||
rows=[{"table": name, "rows": n} for name, n in counts.items()],
|
||||
)
|
||||
|
||||
|
||||
def _preference_listing(ctx: AppContext) -> Any:
|
||||
"""The preferences that differ from their defaults — the whole list, and only those.
|
||||
|
||||
An override is the only part of the preference set that can explain anything: the
|
||||
defaults are in the source, the same for everyone, and dumping all forty would bury
|
||||
the two the reporter actually changed.
|
||||
"""
|
||||
from ..core.preferences import current
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
preferences = ctx.preferences or current()
|
||||
return Listing(
|
||||
key="preferences",
|
||||
caption="Changed preferences",
|
||||
columns=(fields.word("preference", "PREFERENCE"), fields.free("value", "VALUE")),
|
||||
rows=[
|
||||
{"preference": key, "value": str(value)}
|
||||
for key, value in preferences.overrides().items()
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _rows_lane() -> Any:
|
||||
"""The row-count lane: right-aligned, because a column of magnitudes only compares aligned."""
|
||||
from ..ui.report import Lane
|
||||
|
||||
return Lane(header="ROWS", render=lambda v: str(v if v is not None else 0), align="right")
|
||||
|
||||
|
||||
def _size_kb(path: Any) -> int | None:
|
||||
"""A file's size in whole kilobytes, or ``None`` when it is not there to measure."""
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
return round(path.stat().st_size / 1024)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
async def _snapshot(ctx: AppContext, device: Any) -> dict:
|
||||
"""The device's settings snapshot, or an empty one when the read fails."""
|
||||
from ..ui.config_editor import cached_snapshot
|
||||
|
||||
try:
|
||||
return await cached_snapshot(ctx, device)
|
||||
except Exception: # noqa: BLE001 - a settings read that fails leaves the radio facts blank
|
||||
return {}
|
||||
|
||||
|
||||
async def _device_info(device: Any) -> dict:
|
||||
"""The device's self-description, or an empty one when the firmware does not answer."""
|
||||
try:
|
||||
return await device.get_device_info() or {}
|
||||
except Exception: # noqa: BLE001 - firmware predating the query contributes nothing
|
||||
return {}
|
||||
@@ -14,14 +14,12 @@ from rich.text import Text
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core.connection import Device
|
||||
from ..core.models import node_type_label
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Column, Facts
|
||||
|
||||
#: MeshCore advert-type byte -> human-readable node role.
|
||||
_ROLES = {1: "companion", 2: "repeater", 3: "room server", 4: "sensor"}
|
||||
|
||||
|
||||
@register
|
||||
class InfoTool(Tool):
|
||||
@@ -141,7 +139,7 @@ async def _status_panel(device: Device, snapshot: dict) -> Panel:
|
||||
|
||||
adv_type = snapshot.get("adv_type")
|
||||
if adv_type is not None:
|
||||
role = _ROLES.get(int(adv_type), f"type {adv_type}")
|
||||
role = node_type_label(adv_type)
|
||||
rows.append(("role", Text(role, style="brand")))
|
||||
|
||||
if info.get("model"):
|
||||
@@ -239,7 +237,7 @@ async def status_facts(device: Device, snapshot: dict) -> Facts:
|
||||
# `config show`'s answer, not this one.
|
||||
"name": snapshot.get("name"),
|
||||
"public_key": str(snapshot.get("public_key") or "").lower() or None,
|
||||
"role": _ROLES.get(int(adv_type), f"type {adv_type}") if adv_type is not None else None,
|
||||
"role": node_type_label(adv_type),
|
||||
"model": info.get("model"),
|
||||
"firmware": _firmware(info) or None,
|
||||
"battery_v": round(int(level) / 1000, 2) if level else None,
|
||||
|
||||
@@ -28,6 +28,7 @@ is connected to again.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -40,6 +41,8 @@ from ..core.device_store import DeviceStore, RememberedDevice
|
||||
from ..core.discovery import (
|
||||
DEFAULT_TCP_PORT,
|
||||
DiscoveredDevice,
|
||||
discover_ble_devices,
|
||||
discover_devices,
|
||||
parse_tcp_endpoint,
|
||||
serial_device,
|
||||
tcp_device,
|
||||
@@ -298,6 +301,27 @@ async def _smoke_test(
|
||||
return info
|
||||
|
||||
|
||||
#: How often the splash re-enumerates serial ports. `comports()` is a SetupAPI/sysfs
|
||||
#: walk rather than a free read, so it runs off the event loop -- but it is cheap enough
|
||||
#: to repeat at this rate without anyone noticing, and it covers the common case: a USB
|
||||
#: radio plugged in after the splash appeared.
|
||||
_POLL_S = 2.0
|
||||
|
||||
#: Bluetooth is not cheap in the same way. Each scan is a bounded listen, so keeping one
|
||||
#: running means keeping the radio listening for as long as this screen is open -- and
|
||||
#: this screen is the one a reader leaves up while they go and find a cable, on a handheld,
|
||||
#: on a battery. So it gets a duty cycle rather than a loop: a window this often, and only
|
||||
#: while it is still plausible somebody is waiting on one.
|
||||
_BLE_EVERY_S = 15.0
|
||||
_BLE_WINDOW_S = 4.0
|
||||
|
||||
#: How many windows to run before giving up on finding a companion nobody has turned on.
|
||||
#: Once something *is* listed the reader has what they came for; while the list is still
|
||||
#: empty the count is ignored, because an empty splash is exactly where somebody is
|
||||
#: waiting and the cost of another listen is the cost of being useful.
|
||||
_BLE_WINDOWS = 8
|
||||
|
||||
|
||||
async def prompt_device(
|
||||
ui: Ui,
|
||||
devices: list[DiscoveredDevice],
|
||||
@@ -327,11 +351,24 @@ async def prompt_device(
|
||||
leave the picker without selecting one — by pressing Esc or choosing the Quit row —
|
||||
which the caller treats as a request to exit.
|
||||
"""
|
||||
scanned = list(devices)
|
||||
# Serial and Bluetooth are kept apart because they are refreshed on different clocks:
|
||||
# a poll replaces everything attached, a Bluetooth window replaces everything in range,
|
||||
# and one must not wipe the other's findings.
|
||||
wired = [d for d in devices if not d.is_ble]
|
||||
wireless = [d for d in devices if d.is_ble]
|
||||
#: The row the *next* redraw should open on, when the pass just finished moved the list
|
||||
#: under the reader (see :func:`_after_hiding`). Cleared as soon as it is spent.
|
||||
focus: DiscoveredDevice | None = None
|
||||
while True:
|
||||
|
||||
def assemble() -> tuple[
|
||||
list[DiscoveredDevice], list, RememberedDevice | None, dict[str, RememberedDevice]
|
||||
]:
|
||||
"""The list as it stands this instant: what is there, less what is hidden.
|
||||
|
||||
Built in one place because two callers need it now -- the loop below, and the
|
||||
rescan that redraws the rows under the reader while the splash is open.
|
||||
"""
|
||||
scanned = wired + wireless
|
||||
remembered = store.load()
|
||||
# The full registry (not just the single last device) so *every* confirmed companion
|
||||
# can be named, highlighted, and sorted to the top — keyed by stable_id. Reloaded each
|
||||
@@ -353,7 +390,42 @@ async def prompt_device(
|
||||
# only feedback hiding needs.
|
||||
hidden = store.hidden_ids()
|
||||
# In display order, so a shortcut acting on a row can say what the row *after* it is.
|
||||
ordered = _order([d for d in listed if d.stable_id not in hidden], registry)
|
||||
order = _order([d for d in listed if d.stable_id not in hidden], registry)
|
||||
items = _build_items(order, remembered, registry, hidden=len(hidden))
|
||||
return order, items, remembered, registry
|
||||
|
||||
async def keep_looking(redraw: Callable[[list], None]) -> None:
|
||||
"""Re-enumerate while the splash is up, and redraw when the answer changes.
|
||||
|
||||
The screen used to scan once on the way in and never again, so a radio plugged in
|
||||
ten seconds late was invisible until MeshTerm was restarted -- and the screen that
|
||||
said so offered only a network address typed by hand, or quitting. Fetching the
|
||||
cable is the obvious thing to do and it was the one thing that did not work.
|
||||
|
||||
Only a change redraws. A poll that finds the same ports is silence, which matters
|
||||
because the reader may be part-way through arrowing down the list.
|
||||
"""
|
||||
windows = 0
|
||||
waited = 0.0
|
||||
while True:
|
||||
await asyncio.sleep(_POLL_S)
|
||||
before = {d.stable_id for d in wired + wireless}
|
||||
|
||||
# ``comports()`` blocks; off the event loop so the splash stays live.
|
||||
wired[:] = await asyncio.to_thread(discover_devices)
|
||||
|
||||
waited += _POLL_S
|
||||
if waited >= _BLE_EVERY_S and (windows < _BLE_WINDOWS or not wired + wireless):
|
||||
waited = 0.0
|
||||
windows += 1
|
||||
wireless[:] = await discover_ble_devices(_BLE_WINDOW_S)
|
||||
|
||||
if {d.stable_id for d in wired + wireless} != before:
|
||||
_, rows, _, _ = assemble()
|
||||
redraw(rows)
|
||||
|
||||
while True:
|
||||
ordered, items, remembered, registry = assemble()
|
||||
# Preselect the remembered "last known good" device when it is currently attached/in
|
||||
# range — unless the last pass asked for a particular row, which a hide does so the
|
||||
# highlight lands where the vanished row was rather than jumping back to the default.
|
||||
@@ -362,16 +434,17 @@ async def prompt_device(
|
||||
default = focus if focus in ordered else default
|
||||
focus = None
|
||||
|
||||
# Build the rows once so the same list can be redrawn as the backdrop behind a
|
||||
# removal confirm (so it floats over the picker rather than replacing it).
|
||||
items = _build_items(ordered, remembered, registry, hidden=len(hidden))
|
||||
chosen = await ui.select_startup(
|
||||
"Select a companion device",
|
||||
items,
|
||||
default=default,
|
||||
banner=load_logo(),
|
||||
keys=_SHORTCUTS,
|
||||
key_hint=_shortcut_hint(len(hidden)),
|
||||
key_hint=_shortcut_hint(len(store.hidden_ids())),
|
||||
live=keep_looking,
|
||||
# Said outright because the rows no longer imply it: scrolling is switched on by
|
||||
# a row pinning a head block, and none of these do any more.
|
||||
hscroll=True,
|
||||
)
|
||||
# Esc (``None``) and the Quit row both mean "leave the picker" — surface that to the
|
||||
# caller as ``None`` so it can exit the program instead of continuing device-less.
|
||||
@@ -879,11 +952,12 @@ def _build_items(
|
||||
row.append_text(cell)
|
||||
row.append(" " * max(0, type_w - cell.cell_len))
|
||||
row.append(" ")
|
||||
# Everything up to here is the row's fixed lanes — the reader's place in the list —
|
||||
# so it is pinned and only what follows slides under ←→ (see Choice.hscroll_from).
|
||||
# What follows is the hardware model and its tag, which is the part that runs long:
|
||||
# a firmware model string is as long as its vendor felt like making it.
|
||||
head = row.cell_len
|
||||
# Nothing is pinned here, so ←→ slide the whole row. The Trophy case pins its
|
||||
# rank/date/score lanes because those always fit and only the walk overflows, which
|
||||
# makes them the reader's place in a long list. This list inverts that: on a narrow
|
||||
# terminal it is the device name and the address that get cut, so pinning them would
|
||||
# pin the truncation in place and leave the one thing ←→ could not reach being the
|
||||
# text somebody most wants to finish reading.
|
||||
row.append(_pad(_hardware_label(device, registry), hardware_w), style="muted")
|
||||
# Only devices we've actually confirmed are billed as MeshCore companions; a USB
|
||||
# vendor ID (or a BLE advert) is a sort hint, not a claim. A bare serial bridge earns
|
||||
@@ -903,7 +977,7 @@ def _build_items(
|
||||
# Only a network device opts into Delete-to-remove: it's listed solely from its
|
||||
# remembered endpoint, so forgetting it is the only way it leaves the picker. A scanned
|
||||
# serial/BLE device would just reappear, so Delete stays inert on those rows.
|
||||
items.append(Choice(title=row, value=device, deletable=device.is_tcp, hscroll_from=head))
|
||||
items.append(Choice(title=row, value=device, deletable=device.is_tcp))
|
||||
items.extend(_action_rows())
|
||||
return items
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The Diagnostics page: the bug-report block, drawn as one of the app's written pages.
|
||||
|
||||
It is prose-shaped rather than screen-shaped — a title, a standfirst, sections, and a list
|
||||
under each — so it is markdown, and it goes through the renderer the About pages already
|
||||
use (:mod:`meshterm.ui.markdown`). That is not a presentational choice made twice: the same
|
||||
document is what gets **saved**, and an issue tracker renders markdown as written. One
|
||||
source, one look, on screen and in the issue.
|
||||
|
||||
Being a written page buys the two things a long block most needs and neither of which a
|
||||
hand-built screen had: its ``##`` headings are landmarks, so each pins to the top row while
|
||||
its own section scrolls under it and ``^PgUp``/``^PgDn`` step section by section; and the
|
||||
PicoCalc's F-key lane picks up ``Sect ↑``/``Sect ↓`` for the same reason a grouped list
|
||||
does. :class:`~meshterm.ui.about.AboutPage` already does all of it, so this is that page
|
||||
with one verb added.
|
||||
|
||||
The verb is **save** (:data:`SAVE_KEY`), advertised where the platform advertises verbs —
|
||||
in the footer hint on a desktop, on the free F3 slot of the lane on the console — and never
|
||||
in the page's own body, which belongs to the report. It answers in a popup, because the
|
||||
answer is a path the reader may need to read carefully and because a write can fail; the
|
||||
page underneath is unchanged and still there when the popup goes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from .about import AboutPage
|
||||
from .markdown import render_markdown
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..context import AppContext
|
||||
from .tui.session import TuiSession
|
||||
|
||||
#: The key that writes the page to a file. A bare letter, which a read-only page can
|
||||
#: afford because it has no filter and no field for one to mean anything else in.
|
||||
SAVE_KEY = "s"
|
||||
|
||||
#: The action both the key and the lane's chip dispatch, so the chip is not a second
|
||||
#: implementation of the key.
|
||||
SAVE_ACTION = "save"
|
||||
|
||||
|
||||
class DiagnosticsPage(AboutPage):
|
||||
"""The written diagnostics page, plus the one verb that writes it to a file.
|
||||
|
||||
Everything about how it *reads* is :class:`~meshterm.ui.about.AboutPage`: the pager,
|
||||
the sticky ``##`` headings, the section jumps, the lane that dims when there is nothing
|
||||
to page. What is added is the save, on the one slot the About pages leave free.
|
||||
"""
|
||||
|
||||
def __init__(self, source: str, *, session: TuiSession, save_path: Path) -> None:
|
||||
"""Show ``source`` as the page, saving to ``save_path`` on demand.
|
||||
|
||||
Args:
|
||||
source: The markdown document, from
|
||||
:func:`~meshterm.tools.diagnostics.markdown_source`.
|
||||
session: The session, for the popup the save answers in.
|
||||
save_path: Where :data:`SAVE_KEY` writes.
|
||||
"""
|
||||
super().__init__("Diagnostics", render_markdown(source))
|
||||
self._source = source
|
||||
self._session = session
|
||||
self._save_path = save_path
|
||||
self._saving = False
|
||||
|
||||
@property
|
||||
def footer_hint(self) -> str: # type: ignore[override]
|
||||
"""The read-only page's keys, with the save between the pager and Esc.
|
||||
|
||||
Navigation first, then the action, then Esc — the standard sentence shape. The
|
||||
pager atom comes and goes with :attr:`~Screen.content_overflows`, as it does on
|
||||
every read-only page, so the line never names a key that would do nothing.
|
||||
"""
|
||||
scroll = "↑↓ PgUp/PgDn scroll · " if self.content_overflows else ""
|
||||
return f"{scroll}{SAVE_KEY} save · Esc back"
|
||||
|
||||
@property
|
||||
def fkey_lane(self):
|
||||
"""The About page's lane, with ``Save`` on the slot it leaves free.
|
||||
|
||||
F1/F2 are the section pair and F4/F5 the pager, so F3 is the one free primary slot
|
||||
— and on this platform the lane is the *only* place a bare letter can advertise
|
||||
itself at all, which is what makes the chip load-bearing rather than decorative.
|
||||
"""
|
||||
from .tui.fkeys import FPair
|
||||
|
||||
lane = list(super().fkey_lane)
|
||||
lane[2] = FPair("Save", SAVE_ACTION)
|
||||
return lane
|
||||
|
||||
def handle(self, action: str, data: str = "") -> None:
|
||||
"""Save on the key or the chip; scroll, jump or leave on everything else."""
|
||||
if action == SAVE_ACTION or (action == "text" and data.lower() == SAVE_KEY):
|
||||
self._begin_save()
|
||||
return
|
||||
super().handle(action, data)
|
||||
|
||||
def _begin_save(self) -> None:
|
||||
"""Write the file and report it in a popup, off the key handler.
|
||||
|
||||
``run_detached`` because a screen's ``handle`` is synchronous and the popup is a
|
||||
screen: the flow can only be *started* here, and the task it starts sits outside
|
||||
the navigation chain (see :meth:`~meshterm.ui.tui.session.TuiSession.run_detached`).
|
||||
One save at a time, so a held key cannot stack popups over each other.
|
||||
"""
|
||||
if self._saving:
|
||||
return
|
||||
self._saving = True
|
||||
|
||||
async def run() -> None:
|
||||
try:
|
||||
await self._session.message_dialog(self._write(), title="Diagnostics")
|
||||
finally:
|
||||
self._saving = False
|
||||
|
||||
self._session.run_detached(run())
|
||||
|
||||
def _write(self) -> Text:
|
||||
"""Write the document, and say what happened as the popup's message.
|
||||
|
||||
A :class:`~rich.text.Text` with real spans rather than a markup string: the dialog
|
||||
draws the message as given and reads its *spans* to pick a border tone (see
|
||||
``session._message_border``), so markup left as text would both print its own tags
|
||||
and leave a failure framed in the success colour.
|
||||
|
||||
Only the **mark** is styled — ``✓`` or ``✗``, the app's own ok/err pair — which is
|
||||
enough for the border to read the tone and leaves the path itself in body ink,
|
||||
where it stays legible. That is what the mark is for.
|
||||
"""
|
||||
from ..tools.diagnostics import write_markdown
|
||||
|
||||
message = Text()
|
||||
try:
|
||||
written = write_markdown(self._source, self._save_path)
|
||||
except OSError as exc:
|
||||
# A read-only home, a full disk, a path that is not there. The page itself is
|
||||
# untouched and still readable, which is the fallback that matters.
|
||||
message.append("✗ ", style="err")
|
||||
message.append(f"could not save to\n{self._save_path}\n\n")
|
||||
message.append(exc.strerror or str(exc), style="muted")
|
||||
return message
|
||||
message.append("✓ ", style="ok")
|
||||
message.append(f"saved to\n{written}")
|
||||
return message
|
||||
|
||||
|
||||
async def open_diagnostics_page(ctx: AppContext, source: str, *, save_path: Path) -> None:
|
||||
"""Open the Diagnostics page full-screen and hold it until the reader backs out.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context (must be running the interactive TUI).
|
||||
source: The markdown document to show and to save.
|
||||
save_path: Where the page's save key writes.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called outside the interactive menu (no full-screen session).
|
||||
"""
|
||||
from .surface import TuiUi
|
||||
|
||||
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
|
||||
raise RuntimeError("the Diagnostics page is only available in the menu")
|
||||
session = ctx.ui.session
|
||||
await session.run_screen(DiagnosticsPage(source, session=session, save_path=save_path))
|
||||
|
||||
|
||||
__all__ = ["SAVE_ACTION", "SAVE_KEY", "DiagnosticsPage", "open_diagnostics_page"]
|
||||
@@ -332,6 +332,21 @@ def name(key: str, header: str) -> Column:
|
||||
return Column(key=key, lanes=(Lane(header=header, render=script.name),))
|
||||
|
||||
|
||||
def path(key: str, header: str) -> Column:
|
||||
"""A place on this filesystem: a config directory, a database, a file just written.
|
||||
|
||||
Its own constructor rather than :func:`word` because the *typed* value is a
|
||||
:class:`~pathlib.Path` and nothing else in the lane vocabulary turns one into text —
|
||||
``word`` hands it straight to Rich, which cannot render it. The machine face was
|
||||
already right: :func:`~meshterm.ui.report.normalise` has always spelled a Path as its
|
||||
string, so this closes the plain half of a shape that was only ever half-declared.
|
||||
|
||||
Printed as the host writes it, separators and all, since the one thing a reader does
|
||||
with a path is paste it back into their own shell.
|
||||
"""
|
||||
return Column(key=key, lanes=(Lane(header=header, render=_path_cell),))
|
||||
|
||||
|
||||
def free(key: str, header: str) -> Column:
|
||||
"""Free text a stranger filled in: escaped so it can never end its own record.
|
||||
|
||||
@@ -443,6 +458,11 @@ def _rendered_cell(value: Rendered | None) -> str:
|
||||
return script.NONE if value is None else value.text
|
||||
|
||||
|
||||
def _path_cell(value: Any) -> str:
|
||||
"""A path as its own text, or the absent token where there is none."""
|
||||
return script.NONE if value is None else str(value)
|
||||
|
||||
|
||||
def _position_cell(where: Position | None) -> str:
|
||||
"""One position's cell: the pair, or the absent token."""
|
||||
return script.NONE if where is None else script.location(where.lat, where.lon)
|
||||
|
||||
+18
-3
@@ -180,6 +180,7 @@ def _state_intensity(row: str) -> str:
|
||||
"""
|
||||
out: list[str] = []
|
||||
bright = False
|
||||
standing: str | None = None # the dim-bank foreground currently in force
|
||||
at = 0
|
||||
while at < len(row):
|
||||
found = _SGR.match(row, at)
|
||||
@@ -193,17 +194,31 @@ def _state_intensity(row: str) -> str:
|
||||
says = [p for p in parts if p in ("", "0", "1", "22")]
|
||||
for part in says:
|
||||
bright = part == "1"
|
||||
if part in ("", "0"):
|
||||
standing = None # a reset takes the foreground with it
|
||||
# Whether *this* sequence's own foreground is bright: what it says, else what stands.
|
||||
here = bright
|
||||
fg = next((p for p in parts if _DIM_FG.fullmatch(p)), None)
|
||||
if fg is None:
|
||||
# No foreground to restate. A bare "1" has nothing left to do once brightness
|
||||
# travels as a colour, and dropping it keeps bold off glyphs a terminal may
|
||||
# redraw at another weight; a reset still has to go through.
|
||||
# A bare "1" has nothing left to say once brightness travels as a colour, and
|
||||
# dropping it keeps bold off glyphs a terminal may redraw at another weight.
|
||||
kept = [p for p in parts if p != "1"]
|
||||
# But the brightness it was announcing applies to the colour already in force,
|
||||
# not only to the next one named. `ESC[1m` is how the art says "everything after
|
||||
# this is bright", and with the "1" gone that run would carry on in the dim bank
|
||||
# -- two spans meant to read dark then light both coming out dark. So the
|
||||
# standing foreground is restated in whichever bank now applies.
|
||||
if standing is not None and says:
|
||||
if here:
|
||||
kept.append(str(int(standing) + 60))
|
||||
else:
|
||||
kept.append(standing)
|
||||
if "22" not in kept:
|
||||
kept.insert(0, "22")
|
||||
if kept:
|
||||
out.append("\x1b[" + ";".join(kept) + "m")
|
||||
else:
|
||||
standing = fg
|
||||
rewritten = []
|
||||
for part in parts:
|
||||
if part == "1":
|
||||
|
||||
@@ -58,7 +58,7 @@ from .pathline import (
|
||||
path_line,
|
||||
)
|
||||
from .theme import snr_style
|
||||
from .tui.render import crop_cells, render_to_ansi
|
||||
from .tui.render import crop_cells, render_lines, render_to_ansi
|
||||
from .tui.screen import ListWindow, Screen
|
||||
from .widgets import (
|
||||
NameKeyResolver,
|
||||
@@ -293,7 +293,7 @@ class MessagePathsScreen(Screen):
|
||||
style="faint",
|
||||
)
|
||||
lines.append(render_to_ansi(caption, width, no_wrap=True))
|
||||
lines.append(render_to_ansi(node_type_legend(), width, no_wrap=True))
|
||||
lines.extend(render_lines(node_type_legend(width=width), width, no_wrap=True))
|
||||
lines.extend(revisit)
|
||||
lines.append("")
|
||||
|
||||
|
||||
@@ -950,7 +950,7 @@ class NodeDetailScreen(Screen):
|
||||
while lines and not _SGR.sub("", lines[0]).strip():
|
||||
lines.pop(0)
|
||||
if rv.legend:
|
||||
lines.extend(render_lines(node_type_legend(), width, no_wrap=True))
|
||||
lines.extend(render_lines(node_type_legend(width=width), width, no_wrap=True))
|
||||
self._stage_memo = (stage_key, lines)
|
||||
return lines
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from ..core.channels import decrypt_channel_text, identify_channel
|
||||
from ..core.models import NODE_TYPE_LABELS, Observation, utcnow
|
||||
from ..core.models import Observation, node_type_label, utcnow
|
||||
from ..services.trace_runner import NodeResolver
|
||||
from .marks import SELF_MARK, UNKNOWN_MARK
|
||||
from .menus import SEP_COMPACT, SEP_ROOMY
|
||||
@@ -677,7 +677,7 @@ class PacketViewer(Screen):
|
||||
lines.extend(graph)
|
||||
caption = Text("origin → you · labels = hash byte", style="faint")
|
||||
lines.append(render_to_ansi(caption, width, no_wrap=True))
|
||||
lines.append(render_to_ansi(node_type_legend(), width, no_wrap=True))
|
||||
lines.extend(render_lines(node_type_legend(width=width), width, no_wrap=True))
|
||||
if tail:
|
||||
lines.append("")
|
||||
lines.extend(self._grid_lines(tail, width, label_w))
|
||||
@@ -748,8 +748,7 @@ class PacketViewer(Screen):
|
||||
who.append_text(highlighted_hash(entry.node, self._prefix_bytes))
|
||||
rows.append(("from", who))
|
||||
if entry.node_type is not None:
|
||||
label = NODE_TYPE_LABELS.get(entry.node_type, f"type {entry.node_type}")
|
||||
rows.append(("type", Text(label)))
|
||||
rows.append(("type", Text(node_type_label(entry.node_type))))
|
||||
|
||||
if entry.kind == "packet":
|
||||
rows.extend(self._packet_rows(entry))
|
||||
|
||||
@@ -714,7 +714,7 @@ class RecordScreen(Screen):
|
||||
lines = self._graph_lines(width)
|
||||
caption = Text("you → … → you · labels = hash byte", style="faint")
|
||||
lines.append(render_to_ansi(caption, width, no_wrap=True))
|
||||
lines.append(render_to_ansi(node_type_legend(), width, no_wrap=True))
|
||||
lines.extend(render_lines(node_type_legend(width=width), width, no_wrap=True))
|
||||
|
||||
# The walk itself, on THE path widget, across the card's whole width. No ``route``
|
||||
# label lane: the line under a route graph captioned "you → … → you" is the route,
|
||||
|
||||
@@ -33,6 +33,7 @@ compartment inside it.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from enum import Enum
|
||||
@@ -61,6 +62,101 @@ class OutputFormat(str, Enum):
|
||||
JSON = "json"
|
||||
|
||||
|
||||
def markdown_blocks(report: Report | None) -> str:
|
||||
"""Project a report as markdown sections — the third face, and the only *document* one.
|
||||
|
||||
The plain face is a stream of records and the machine face is a document for a
|
||||
program; this one is a page for a person to read, on a screen through
|
||||
:func:`~meshterm.ui.markdown.render_markdown` and in an issue tracker through nothing
|
||||
at all. It is where :attr:`~meshterm.ui.report.Block.caption` finally means something:
|
||||
markdown has headings, and a heading is what a reader navigates by.
|
||||
|
||||
Every value is written as **inline code**, and that is correctness rather than taste. A
|
||||
Windows path is full of backslashes, which markdown reads as escapes, and an error
|
||||
message a stranger's firmware wrote can hold an asterisk or a bracket. Inside a code
|
||||
span none of it is interpreted, so the value that arrives is the value that left — the
|
||||
same reason :func:`~meshterm.ui.script.name` quotes what it cannot vouch for. A value
|
||||
already holding a backtick gets a longer fence rather than a broken one.
|
||||
|
||||
Rows are a **list**, not a table, and that was measured rather than assumed: a markdown
|
||||
table's cells are cropped with an ellipsis when the frame is narrow, and a diagnostics
|
||||
page whose one long value is cut is missing the half that mattered. A list item wraps
|
||||
and hangs under its own text at any width.
|
||||
|
||||
Args:
|
||||
report: The blocks to project, or ``None`` for nothing at all.
|
||||
|
||||
Returns:
|
||||
Markdown source: one ``##`` section per captioned block, blank-line separated.
|
||||
"""
|
||||
sections: list[str] = []
|
||||
for block in report or ():
|
||||
rows = _markdown_rows(block)
|
||||
head = f"## {block.caption}\n\n" if block.caption else ""
|
||||
# A section with nothing in it still says so. An absence is a fact here — "no
|
||||
# preferences changed" is something a maintainer can act on, where a section
|
||||
# silently missing only raises the question of whether it was ever built.
|
||||
sections.append(head + ("\n".join(rows) if rows else "*none*"))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def _markdown_rows(block: Block) -> list[str]:
|
||||
"""One markdown list item per row of a block."""
|
||||
if isinstance(block, Facts):
|
||||
return [f"- **{key}** — {_code(value)}" for key, value in facts_pairs(block)]
|
||||
lanes = block.lanes()
|
||||
if not lanes:
|
||||
return []
|
||||
items: list[str] = []
|
||||
for row in block.rows:
|
||||
# The first lane names the record and the rest describe it, which is what a
|
||||
# two-lane listing (a table and its count, a preference and its value) wants and
|
||||
# what a wider one degrades to gracefully.
|
||||
cells = [lane.render(row.get(column.key)) for column, lane in lanes]
|
||||
described = " · ".join(_code(cell) for cell in cells[1:])
|
||||
items.append(f"- **{cells[0]}** — {described}" if described else f"- **{cells[0]}**")
|
||||
return items
|
||||
|
||||
|
||||
def _code(value: str) -> str:
|
||||
"""One value as a markdown code span, fenced long enough to survive its own backticks."""
|
||||
if not value:
|
||||
return ""
|
||||
longest = max((len(run) for run in re.findall(r"`+", value)), default=0)
|
||||
fence = "`" * (longest + 1)
|
||||
# A span that starts or ends with a backtick needs a space the parser then eats.
|
||||
pad = " " if value.startswith("`") or value.endswith("`") else ""
|
||||
return f"{fence}{pad}{value}{pad}{fence}"
|
||||
|
||||
|
||||
def facts_pairs(block: Facts) -> list[tuple[str, str]]:
|
||||
"""Project a :class:`Facts` block into its ``(key, value)`` text rows.
|
||||
|
||||
The plain face's half of a facts block, lifted out of :class:`PlainRenderer` because
|
||||
one surface in the menu wants the same rows laid out differently: the Diagnostics page
|
||||
hangs a long value under its key rather than cropping it, since a cropped path in a
|
||||
bug report is worse than a wrapped one. Sharing the projection is what keeps the two
|
||||
from drifting — a field added to a report appears on both without anyone remembering.
|
||||
|
||||
Laying the rows out is the caller's business; turning a typed value into text is not,
|
||||
and that stays here, in the declared :class:`~meshterm.ui.report.Lane`.
|
||||
|
||||
Args:
|
||||
block: The facts block to project.
|
||||
|
||||
Returns:
|
||||
One ``(header, cell)`` pair per lane, in the order the block declares them.
|
||||
"""
|
||||
rows: list[tuple[str, str]] = []
|
||||
for column in block.fields:
|
||||
value = block.values.get(column.key)
|
||||
if block.omit_absent and (value is None or value == ""):
|
||||
continue
|
||||
for lane in column.lanes:
|
||||
rows.append((lane.header, lane.render(value)))
|
||||
return rows
|
||||
|
||||
|
||||
class Renderer:
|
||||
"""Turns a report into bytes. One subclass per format."""
|
||||
|
||||
@@ -157,14 +253,7 @@ class PlainRenderer(Renderer):
|
||||
# As a Text, not a markup string: this is a private key, a share URL or a
|
||||
# remote node's own reply, and Rich would read a bracket in one as a style tag.
|
||||
return Text(column.lanes[0].render(value))
|
||||
rows: list[tuple[str, str]] = []
|
||||
for column in block.fields:
|
||||
value = block.values.get(column.key)
|
||||
if block.omit_absent and (value is None or value == ""):
|
||||
continue
|
||||
for lane in column.lanes:
|
||||
rows.append((lane.header, lane.render(value)))
|
||||
return script.pairs(rows)
|
||||
return script.pairs(facts_pairs(block))
|
||||
|
||||
@contextmanager
|
||||
def stream(self, listing: Listing) -> Iterator[Any]:
|
||||
|
||||
@@ -155,6 +155,13 @@ class Block:
|
||||
|
||||
Attributes:
|
||||
key: The name this block goes under in a multi-block document.
|
||||
caption: The heading a format that *has* headings draws above this block. The
|
||||
plain face has none — a heading over a key/value block is furniture on a
|
||||
line-oriented face, which is why ``config show`` drops even its column
|
||||
headers — and the machine face has ``key`` instead. The markdown projection
|
||||
makes it a ``##`` section, which is what gives a written page the landmarks
|
||||
its section jumps and its sticky headings step by. Empty means the block is
|
||||
drawn without one.
|
||||
plain_only: Whether this block is a rendering for a person that the machine face
|
||||
has no use for. The one case is a stream's closing summary: ``monitor`` prints
|
||||
a per-node aggregate under its live capture, and a consumer holding every
|
||||
@@ -164,6 +171,7 @@ class Block:
|
||||
"""
|
||||
|
||||
key: str
|
||||
caption: str = ""
|
||||
plain_only: bool = False
|
||||
|
||||
|
||||
|
||||
+13
-1
@@ -18,7 +18,7 @@ only), so :class:`PlainUi` leaves them unsupported.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Mapping
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
@@ -222,9 +222,15 @@ class Ui:
|
||||
footer_hint: str | None = None,
|
||||
keys: Mapping[str, Any] | None = None,
|
||||
key_hint: Callable[[Any], str] | None = None,
|
||||
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
|
||||
hscroll: bool | None = None,
|
||||
) -> Any:
|
||||
"""Choose one item on a chromeless startup splash; ``None`` if skipped.
|
||||
|
||||
``live`` is work to run while the splash is up, handed a ``redraw(items)`` that
|
||||
swaps the rows under the reader and repaints -- the device picker's rescan. It is
|
||||
cancelled when the splash resolves.
|
||||
|
||||
``keys`` declares bare-key shortcuts the splash answers, resolving with a
|
||||
:class:`~meshterm.ui.tui.select.KeyRequest` (the picker's hide/show-all pair), and
|
||||
``key_hint`` names them per highlighted row so the footer only advertises a key
|
||||
@@ -516,6 +522,8 @@ class PlainUi(Ui):
|
||||
footer_hint: str | None = None,
|
||||
keys: Mapping[str, Any] | None = None,
|
||||
key_hint: Callable[[Any], str] | None = None,
|
||||
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
|
||||
hscroll: bool | None = None,
|
||||
) -> Any:
|
||||
"""Unsupported in scripted CLI mode."""
|
||||
raise self._no_prompt()
|
||||
@@ -779,6 +787,8 @@ class TuiUi(Ui):
|
||||
footer_hint: str | None = None,
|
||||
keys: Mapping[str, Any] | None = None,
|
||||
key_hint: Callable[[Any], str] | None = None,
|
||||
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
|
||||
hscroll: bool | None = None,
|
||||
) -> Any:
|
||||
"""Delegate to the session's chromeless startup select splash."""
|
||||
return await self.session.select_startup(
|
||||
@@ -789,6 +799,8 @@ class TuiUi(Ui):
|
||||
footnote=footnote,
|
||||
keys=keys,
|
||||
key_hint=key_hint,
|
||||
live=live,
|
||||
hscroll=hscroll,
|
||||
**({} if footer_hint is None else {"footer_hint": footer_hint}),
|
||||
)
|
||||
|
||||
|
||||
@@ -695,6 +695,7 @@ _GLYPH_MAP: dict[str, str] = {
|
||||
"📶": "≥", # TX power sweep — the power ramp
|
||||
"🔧": "⚙", # config (parameter concept)
|
||||
"📋": "i", # info
|
||||
"🩺": "?", # diagnostics — the question a diagnosis answers (🎭 shares the mark)
|
||||
"📰": "…", # live feed — a stream of items
|
||||
"🎧": "≈", # monitor — listening to the waveform
|
||||
"🗼": "▲", # repeater admin — the repeater mark itself
|
||||
|
||||
@@ -15,8 +15,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
||||
from contextlib import asynccontextmanager, nullcontext
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
||||
from contextlib import asynccontextmanager, nullcontext, suppress
|
||||
from typing import Any
|
||||
|
||||
from prompt_toolkit.application import Application
|
||||
@@ -917,6 +917,8 @@ class TuiSession:
|
||||
footer_hint: str = "↑↓ move · Enter select · Esc bye",
|
||||
keys: Mapping[str, Any] | None = None,
|
||||
key_hint: Callable[[Any], str] | None = None,
|
||||
live: Callable[[Callable[[list], None]], Awaitable[None]] | None = None,
|
||||
hscroll: bool | None = None,
|
||||
) -> Any:
|
||||
"""Show a chromeless select splash (banner above a content-sized box).
|
||||
|
||||
@@ -931,6 +933,19 @@ class TuiSession:
|
||||
removal key advertises itself exactly where it acts (see
|
||||
:attr:`~meshterm.ui.tui.select.SelectScreen.footer_hint`).
|
||||
|
||||
``live`` is work to do *while* the list is up — the device picker's rescan, which
|
||||
keeps looking for a companion the whole time the splash is open. It is handed one
|
||||
function, ``redraw(items)``, which swaps the rows under the reader and repaints;
|
||||
the screen itself stays in here, since a caller holding one could not repaint it
|
||||
anyway (``invalidate`` is the session's, not the screen's). It runs as a task for
|
||||
exactly as long as the screen does and is cancelled when the screen resolves, so
|
||||
nothing outlives the list it was redrawing and no repaint lands on a dialog that
|
||||
has since opened over it.
|
||||
|
||||
``hscroll`` overrules what the rows imply: a splash whose rows pin no head block
|
||||
still wants ←→ to read a long row to its end, and a row that pins nothing cannot
|
||||
ask for that on its own (see :class:`~meshterm.ui.tui.select.SelectScreen`).
|
||||
|
||||
``keys`` hands the list bare-key shortcuts — which a splash can afford precisely
|
||||
because it does not filter, so every letter is free (see :class:`SelectScreen`) —
|
||||
and ``key_hint`` says what they are called on the row the highlight is standing on,
|
||||
@@ -948,6 +963,7 @@ class TuiSession:
|
||||
filterable=False,
|
||||
keys=keys,
|
||||
key_hint=key_hint,
|
||||
hscroll=hscroll,
|
||||
)
|
||||
screen.chrome = False
|
||||
screen.banner = banner
|
||||
@@ -959,7 +975,20 @@ class TuiSession:
|
||||
# ⇧H only appears at all once something is hidden, which is exactly the state where
|
||||
# a reader needs to be told how to undo it, and by then they have already found h.
|
||||
screen.spare_hint_atoms = ("←→ scroll", "h hide")
|
||||
result = await self.run_screen(screen)
|
||||
|
||||
def redraw(new_items: list) -> None:
|
||||
"""Swap the rows and repaint. The reader keeps their place, by value."""
|
||||
screen.replace_items(new_items)
|
||||
self.invalidate()
|
||||
|
||||
worker = asyncio.ensure_future(live(redraw)) if live else None
|
||||
try:
|
||||
result = await self.run_screen(screen)
|
||||
finally:
|
||||
if worker is not None:
|
||||
worker.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await worker
|
||||
return None if result is CANCEL else result
|
||||
|
||||
async def confirm_startup(
|
||||
|
||||
+24
-8
@@ -11,6 +11,7 @@ from itertools import pairwise
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich import box
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Console, Group
|
||||
from rich.panel import Panel
|
||||
from rich.progress import (
|
||||
@@ -1140,24 +1141,39 @@ def _sort_header(label: str, column: str, sort: ContactsSort) -> str:
|
||||
return f"[cursor]{label} {triangle}[/]"
|
||||
|
||||
|
||||
def node_type_legend(indent: str = "") -> Text:
|
||||
"""The one-line key to the node-type marks: ``★ you ▲ repeater ● node …``.
|
||||
def node_type_legend(indent: str = "", width: int | None = None) -> Text:
|
||||
"""The key to the node-type marks: ``★ you ▲ repeater ● companion …``.
|
||||
|
||||
Every glyph in its shared map colour (see :data:`NODE_GLYPHS`), each named muted after
|
||||
it. THE legend for any surface that draws typed node markers — the contacts list under its
|
||||
table, the route graph under its lanes — so one glyph means one thing app-wide.
|
||||
|
||||
One line where it fits. Where it does not — the full names run to 59 cells, and the
|
||||
PicoCalc has 53 — it breaks onto a second line between entries, never inside one, so
|
||||
a mark is never parted from its name and no name is cropped.
|
||||
|
||||
Args:
|
||||
indent: Leading spaces to sit the legend under a table or graph body.
|
||||
width: The cells available. None keeps the legend on one line.
|
||||
"""
|
||||
legend = Text(indent)
|
||||
legend.append(SELF_MARK[0], style=SELF_MARK[1])
|
||||
legend.append(" you", style="muted")
|
||||
entries = [Text.assemble((SELF_MARK[0], SELF_MARK[1]), (" you", "muted"))]
|
||||
for node_type in (NODE_TYPE_REPEATER, NODE_TYPE_CHAT, NODE_TYPE_ROOM, NODE_TYPE_SENSOR):
|
||||
glyph, color = NODE_GLYPHS[node_type]
|
||||
legend.append(" ")
|
||||
legend.append(glyph, style=color)
|
||||
legend.append(f" {NODE_TYPE_LABELS[node_type]}", style="muted")
|
||||
entries.append(Text.assemble((glyph, color), (f" {NODE_TYPE_LABELS[node_type]}", "muted")))
|
||||
|
||||
gap = " "
|
||||
legend = Text(indent)
|
||||
line = cell_len(indent)
|
||||
for i, entry in enumerate(entries):
|
||||
size = cell_len(entry.plain)
|
||||
if i and width is not None and line + len(gap) + size > width:
|
||||
legend.append("\n" + indent)
|
||||
line = cell_len(indent)
|
||||
elif i:
|
||||
legend.append(gap)
|
||||
line += len(gap)
|
||||
legend.append_text(entry)
|
||||
line += size
|
||||
return legend
|
||||
|
||||
|
||||
|
||||
+3
-1
@@ -12,7 +12,7 @@ build-backend = "hatchling.build"
|
||||
# wheel shipping the `meshterm/` package under this different name.
|
||||
name = "mesh-term"
|
||||
dynamic = ["version"]
|
||||
description = "MeshTerm is a full-featured TUI-based MeshCore client for your terminal. Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Linux."
|
||||
description = "MeshTerm is a full-featured TUI MeshCore client for your terminal. Connects to a companion over USB, Bluetooth or TCP. For Windows, macOS and Linux."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Jean-Pierre Martineau" }]
|
||||
@@ -31,6 +31,8 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Topic :: Communications :: Ham Radio",
|
||||
"Topic :: System :: Networking",
|
||||
]
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ Repo-side tools. None of these ship in the wheel.
|
||||
| Script | What it is for |
|
||||
|---|---|
|
||||
| `basemap-doctor.py` | Why a user's map has no basemap — certificates, DNS, proxy, or colour depth |
|
||||
| `picocalc/` | Calculinux device setup: console font, palette, Wi-Fi, radio bridge |
|
||||
| `picocalc/` | Calculinux device setup: console font, palette, Wi-Fi; `xiao-radio/` is the XIAO radio kit (firmware build, flashing, UART setup) |
|
||||
| `uconsole/` | The uConsole SPI bridge service |
|
||||
|
||||
## basemap-doctor.py
|
||||
|
||||
@@ -4,7 +4,7 @@ A Seeed XIAO nRF52840 + Wio-SX1262 wired to the Luckfox Lyra's UART1, running Me
|
||||
companion firmware on `Serial1`, so MeshTerm on the PicoCalc opens `/dev/ttyS1` like any
|
||||
other serial companion. No BLE, no USB host, no SD-slot sacrifice.
|
||||
|
||||
**The step-by-step guide is [`docs/picocalc.md`](../../../docs/picocalc.md), Phase 3** — parts,
|
||||
**The step-by-step guide is [`docs/picocalc.md`, Phase 3](../../../docs/picocalc.md#phase-3--add-the-radio)** — parts,
|
||||
the firmware build and the patch explained, flashing, wiring, the Lyra setup, and what has
|
||||
only been proven on one bench. What the register poke does is in `uart1-mux.py`'s own
|
||||
header. This page is the bench card.
|
||||
|
||||
@@ -707,6 +707,53 @@ def test_a_closing_message_goes_to_stderr_too(run) -> None: # noqa: ANN001
|
||||
assert "records stored" in result.stderr
|
||||
|
||||
|
||||
# -- the simulator's own history ------------------------------------------------------
|
||||
|
||||
|
||||
def test_mock_records_into_a_database_of_its_own(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A pretend radio gets a pretend history, so a demo run leaves the real mesh alone.
|
||||
|
||||
The simulator is a fake radio, not a fake MeshTerm: what it adverts used to be written
|
||||
to ``meshterm.db`` like anything a companion said, and its invented contacts then sat
|
||||
in the mesh walk, the dashboard and the map of the mesh actually being run. The run
|
||||
still records — the history is how a ``--mock`` session's own tools read back what it
|
||||
heard — just not there.
|
||||
"""
|
||||
from meshterm.cli import app
|
||||
|
||||
home = tmp_path / "home"
|
||||
monkeypatch.setenv("MESHTERM_HOME", str(home))
|
||||
|
||||
result = CliRunner().invoke(app, ["--mock", "info"])
|
||||
assert result.exit_code == exitcodes.OK, result.output
|
||||
assert (home / "meshterm-mock.db").exists()
|
||||
assert not (home / "meshterm.db").exists()
|
||||
|
||||
|
||||
def test_an_explicit_db_still_wins_under_mock(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""``--db`` names one exact database, which is a claim the simulator does not override.
|
||||
|
||||
Unlike ``--port``, it is not a contradictory one: where a run records and which radio
|
||||
it talks to are different questions, so this is a precedence rule and not a usage
|
||||
error. It is also what the suite's own ``run`` fixture depends on.
|
||||
"""
|
||||
from meshterm.cli import app
|
||||
|
||||
home = tmp_path / "home"
|
||||
monkeypatch.setenv("MESHTERM_HOME", str(home))
|
||||
named = tmp_path / "named.db"
|
||||
|
||||
result = CliRunner().invoke(app, ["--mock", "--db", str(named), "info"])
|
||||
assert result.exit_code == exitcodes.OK, result.output
|
||||
assert named.exists()
|
||||
assert not (home / "meshterm-mock.db").exists()
|
||||
assert not (home / "meshterm.db").exists()
|
||||
|
||||
|
||||
# -- devices under --mock -------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
+16
-8
@@ -140,20 +140,28 @@ def test_the_box_does_not_resize_as_it_counts() -> None:
|
||||
def test_the_countdown_resolves_itself_at_zero() -> None:
|
||||
"""Reaching zero is the dialog's own answer: the wait is over, the action goes ahead."""
|
||||
dialog = CountdownDialog("Advert", 5.0)
|
||||
dialog.future = asyncio.get_event_loop_policy().new_event_loop().create_future()
|
||||
dialog.set_remaining(3.0)
|
||||
assert not dialog.future.done()
|
||||
dialog.set_remaining(0.0)
|
||||
assert dialog.future.done() and dialog.future.result() is True
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
dialog.future = loop.create_future()
|
||||
dialog.set_remaining(3.0)
|
||||
assert not dialog.future.done()
|
||||
dialog.set_remaining(0.0)
|
||||
assert dialog.future.done() and dialog.future.result() is True
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["enter", "escape"])
|
||||
def test_either_key_abandons_the_wait(key: str) -> None:
|
||||
"""There is nothing to choose between, so Enter and Esc both mean cancel."""
|
||||
dialog = CountdownDialog("Advert", 30.0)
|
||||
dialog.future = asyncio.get_event_loop_policy().new_event_loop().create_future()
|
||||
dialog.handle(key)
|
||||
assert dialog.future.done() and dialog.future.result() is CANCEL
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
dialog.future = loop.create_future()
|
||||
dialog.handle(key)
|
||||
assert dialog.future.done() and dialog.future.result() is CANCEL
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
# -- the wait --------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The Diagnostics block: what it says, what it refuses to say, and how it says it.
|
||||
|
||||
The privacy tests here are the load-bearing ones. This block exists to be pasted in public
|
||||
by somebody who has not read it, so "no secret is in it" has to be a property of the
|
||||
feature rather than a habit of whoever last edited it. Two tests hold that line from
|
||||
different sides: one plants real secrets in the stores that hold them and asserts none
|
||||
came out, and one refuses the *names* such a field would go under — so a value the suite
|
||||
has no way to plant is still caught by the key it would arrive as.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
from meshterm.context import AppContext
|
||||
from meshterm.core import hostinfo
|
||||
from meshterm.core.admin_store import AdminStore
|
||||
from meshterm.core.channel_store import ChannelStore
|
||||
from meshterm.core.config import Settings
|
||||
from meshterm.core.device_store import DeviceStore
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.core.preferences import Preferences
|
||||
from meshterm.persistence.repository import Repository
|
||||
from meshterm.tools.diagnostics import build_report, markdown_source
|
||||
from meshterm.ui import fields
|
||||
from meshterm.ui.about import AboutPage
|
||||
from meshterm.ui.markdown import render_markdown
|
||||
from meshterm.ui.renderers import (
|
||||
JsonRenderer,
|
||||
PlainRenderer,
|
||||
facts_pairs,
|
||||
markdown_blocks,
|
||||
)
|
||||
from meshterm.ui.report import Facts, Listing
|
||||
|
||||
#: Planted into the stores that really hold this kind of value. Each is the kind of thing
|
||||
#: the block must never carry: one administers a repeater, one decrypts a channel.
|
||||
_ADMIN_PASSWORD = "correct-horse-battery"
|
||||
_CHANNEL_SECRET = "8f" * 16
|
||||
|
||||
#: Key *segments* no field in this block may be built from — matched on the underscore
|
||||
#: parts of a key rather than as substrings, so ``platform`` is not read as a latitude.
|
||||
#: The first row names a secret and the second names a person or a place; ``key`` and
|
||||
#: ``name`` close the set, because the block identifies no node at all, not even ours.
|
||||
_FORBIDDEN_SEGMENTS = frozenset(
|
||||
{
|
||||
"password",
|
||||
"passphrase",
|
||||
"secret",
|
||||
"private",
|
||||
"privkey",
|
||||
"pin",
|
||||
"token",
|
||||
"credential",
|
||||
"contact",
|
||||
"contacts",
|
||||
"lat",
|
||||
"latitude",
|
||||
"lon",
|
||||
"longitude",
|
||||
"position",
|
||||
"message",
|
||||
"messages",
|
||||
"key",
|
||||
"name",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(tmp_path: Path) -> AppContext:
|
||||
"""A context on a throwaway home, with a secret planted in the stores that hold them."""
|
||||
settings = Settings(config_dir=tmp_path)
|
||||
admin_store = AdminStore(tmp_path / "admin.json")
|
||||
admin_store.remember(
|
||||
Contact(name="Hilltop-Repeater", public_key="3d" * 32, key_prefix="3d63c642"),
|
||||
_ADMIN_PASSWORD,
|
||||
)
|
||||
channel_store = ChannelStore(tmp_path / "channels.json")
|
||||
channel_store.remember("ab" * 32, 1, "Lakeside", bytes.fromhex(_CHANNEL_SECRET))
|
||||
return AppContext(
|
||||
console=Console(),
|
||||
settings=settings,
|
||||
repo=Repository(settings.db_path),
|
||||
device_store=DeviceStore(tmp_path / "device.json"),
|
||||
admin_store=admin_store,
|
||||
channel_store=channel_store,
|
||||
preferences=Preferences(tmp_path / "preferences.toml", {"log_level": "DEBUG"}),
|
||||
mock=True,
|
||||
)
|
||||
|
||||
|
||||
# --- what it must never say -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_planted_secret_reaches_either_face(ctx: AppContext) -> None:
|
||||
"""A remembered admin password and a channel secret appear in neither face.
|
||||
|
||||
The point of the feature: a reporter pastes this into a public issue without reading
|
||||
it first.
|
||||
"""
|
||||
report = _report(ctx)
|
||||
for secret in (_ADMIN_PASSWORD, _CHANNEL_SECRET):
|
||||
assert secret not in _plain(report)
|
||||
assert secret not in _machine(report)
|
||||
|
||||
|
||||
def test_no_field_is_named_after_a_secret_or_a_person(ctx: AppContext) -> None:
|
||||
"""No key in the block reads like a credential, a position or somebody's identity.
|
||||
|
||||
The durable half of the guarantee. A value this suite cannot plant — a PIN read live
|
||||
off a radio, a private key exported on demand — still has to arrive under a name, and
|
||||
every name a secret would plausibly take is refused here.
|
||||
"""
|
||||
for key in _keys(_report(ctx)):
|
||||
offending = _FORBIDDEN_SEGMENTS & set(key.lower().split("_"))
|
||||
assert not offending, f"{key!r} is named after {', '.join(sorted(offending))}"
|
||||
|
||||
|
||||
def test_the_mesh_is_described_only_in_aggregate(ctx: AppContext) -> None:
|
||||
"""The mesh reaches the block as counts, never as a row anybody could be named in.
|
||||
|
||||
Every table is *counted*, and the count is an integer: the moment a listing starts
|
||||
carrying a row out of one of those tables, a contact's name or a message body is one
|
||||
edit away from the clipboard.
|
||||
"""
|
||||
tables = _flat(_report(ctx))["tables"]
|
||||
assert tables, "the schema should always have tables to count"
|
||||
assert all(set(row) == {"table", "rows"} for row in tables)
|
||||
assert all(isinstance(row["rows"], int) for row in tables)
|
||||
|
||||
|
||||
# --- what it must say -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_states_the_facts_a_bug_report_opens_with(ctx: AppContext) -> None:
|
||||
"""Version, install shape, host, terminal and device all reach the block."""
|
||||
from meshterm import __version__
|
||||
|
||||
values = _flat(_report(ctx))
|
||||
assert values["meshterm"] == __version__
|
||||
assert values["install"] in {"frozen", "source", "package"}
|
||||
assert values["os"] and values["python"] and values["arch"]
|
||||
assert "x" in values["terminal_size"]
|
||||
assert values["platform"] in {"regular", "picocalc"}
|
||||
# The simulator answers like a radio, so the device half is populated rather than
|
||||
# skipped: this is the shape a real connected run produces.
|
||||
assert values["connected"] is True
|
||||
assert values["transport"] == "mock"
|
||||
assert values["firmware"]
|
||||
|
||||
|
||||
def test_an_unreachable_radio_becomes_a_fact_not_a_failure(
|
||||
ctx: AppContext, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A radio that will not connect leaves the block intact and says why.
|
||||
|
||||
The report about a radio that never connects is exactly the one most worth filing, so
|
||||
the device read is best-effort: the failure lands in ``error`` and every other fact
|
||||
still arrives.
|
||||
"""
|
||||
|
||||
async def refuse(self):
|
||||
raise RuntimeError("no companion answered on COM7")
|
||||
|
||||
monkeypatch.setattr(AppContext, "device", refuse)
|
||||
values = _flat(_report(ctx))
|
||||
assert values["connected"] is False
|
||||
assert "COM7" in values["error"]
|
||||
assert values["firmware"] is None
|
||||
assert values["meshterm"] # the rest of the block is untouched
|
||||
|
||||
|
||||
def test_only_overridden_preferences_are_listed(ctx: AppContext) -> None:
|
||||
"""The preference listing carries the disagreements with the defaults, and only those.
|
||||
|
||||
A dump of all forty would bury the one the reporter actually changed; the defaults are
|
||||
in the source and identical for everyone.
|
||||
"""
|
||||
assert _flat(_report(ctx))["preferences"] == [{"preference": "log_level", "value": "DEBUG"}]
|
||||
|
||||
|
||||
# --- how it says it -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_page_and_the_command_line_state_the_same_facts(ctx: AppContext) -> None:
|
||||
"""Every key/value the page draws is one the scripted face prints, in the same words.
|
||||
|
||||
The two faces share :func:`~meshterm.ui.renderers.facts_pairs` precisely so a field
|
||||
added to the report reaches both without anyone remembering to, and this is what would
|
||||
catch the page growing a projection of its own.
|
||||
"""
|
||||
report = _report(ctx)
|
||||
printed = _plain(report)
|
||||
for block in report:
|
||||
if isinstance(block, Facts):
|
||||
for key, value in facts_pairs(block):
|
||||
assert key in printed
|
||||
assert value in printed
|
||||
|
||||
|
||||
def test_the_page_is_a_written_page_with_landmarks(ctx: AppContext) -> None:
|
||||
"""The page is markdown through the About-page renderer, and its sections are landmarks.
|
||||
|
||||
The sections are the point of the format: a heading pins to the top row while its own
|
||||
rows scroll under it, the section jumps step by them, and the PicoCalc lane earns its
|
||||
``Sect up``/``Sect down`` pair for the same reason a grouped list does.
|
||||
"""
|
||||
page = _page(ctx)
|
||||
assert isinstance(page, AboutPage)
|
||||
assert page.bare is False
|
||||
assert {"Build", "Host", "Terminal", "Radio", "Storage"} <= set(page._doc.sections)
|
||||
|
||||
|
||||
def test_the_save_is_advertised_on_both_platforms(ctx: AppContext) -> None:
|
||||
"""The verb is named in the footer hint and on the lane's one free slot.
|
||||
|
||||
Never in the page's body, which belongs to the report — and never nowhere, which is
|
||||
what a bare frame would have forced. F1/F2 are the section pair and F4/F5 the pager,
|
||||
so F3 is the slot the About pages leave free.
|
||||
"""
|
||||
from meshterm.ui.diagnostics import SAVE_ACTION, SAVE_KEY
|
||||
|
||||
page = _page(ctx)
|
||||
assert f"{SAVE_KEY} save" in page.footer_hint
|
||||
assert page.footer_hint.endswith("Esc back")
|
||||
assert page.fkey_lane[2] is not None
|
||||
assert page.fkey_lane[2].label == "Save"
|
||||
assert page.fkey_lane[2].action == SAVE_ACTION
|
||||
|
||||
|
||||
# --- saving it ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_saved_file_is_the_document_the_page_shows(ctx: AppContext, tmp_path: Path) -> None:
|
||||
"""The file holds the very markdown the page was built from — one source, two places."""
|
||||
from meshterm.tools.diagnostics import markdown_source, write_markdown
|
||||
|
||||
source = markdown_source(_report(ctx))
|
||||
written = write_markdown(source, tmp_path / "out.md")
|
||||
assert written.read_text(encoding="utf-8") == source
|
||||
assert source.startswith("# MeshTerm diagnostics")
|
||||
|
||||
|
||||
def test_saving_creates_the_directory_it_needs(ctx: AppContext, tmp_path: Path) -> None:
|
||||
"""A destination whose parent does not exist yet is made rather than refused."""
|
||||
from meshterm.tools.diagnostics import markdown_source, write_markdown
|
||||
|
||||
written = write_markdown(markdown_source(_report(ctx)), tmp_path / "a" / "b" / "out.md")
|
||||
assert written.is_file()
|
||||
|
||||
|
||||
def test_the_key_and_the_chip_both_save_and_answer_in_a_popup(
|
||||
ctx: AppContext, tmp_path: Path
|
||||
) -> None:
|
||||
"""Either affordance writes the file and reports the path in an acknowledgement popup.
|
||||
|
||||
The chip dispatches the same action the key does, so it is not a second implementation
|
||||
of the same verb.
|
||||
"""
|
||||
from meshterm.ui.diagnostics import SAVE_ACTION, SAVE_KEY
|
||||
|
||||
for trigger, target in ((("text", SAVE_KEY), "key.md"), ((SAVE_ACTION, ""), "chip.md")):
|
||||
path = tmp_path / target
|
||||
session = _Session()
|
||||
page = _page(ctx, session=session, save_path=path)
|
||||
page.handle(*trigger)
|
||||
assert path.is_file()
|
||||
assert len(session.messages) == 1
|
||||
assert "\u2713" in session.messages[0]
|
||||
assert str(path) in session.messages[0]
|
||||
|
||||
|
||||
def test_a_save_that_fails_is_reported_not_raised(ctx: AppContext, tmp_path: Path) -> None:
|
||||
"""An unwritable destination reports itself and leaves the page readable.
|
||||
|
||||
The page is still there to be read and copied off, which is the fallback that matters,
|
||||
so a failed write must never take it down. The mark is also what turns the popup red.
|
||||
"""
|
||||
from meshterm.ui.diagnostics import SAVE_KEY
|
||||
|
||||
# A path whose "directory" is an existing file: mkdir and write both refuse, on every
|
||||
# OS, without needing permissions the suite cannot count on.
|
||||
blocker = tmp_path / "blocker"
|
||||
blocker.write_text("not a directory", encoding="utf-8")
|
||||
session = _Session()
|
||||
page = _page(ctx, session=session, save_path=blocker / "inside" / "out.md")
|
||||
|
||||
page.handle("text", SAVE_KEY)
|
||||
assert "\u2717" in session.messages[0]
|
||||
assert "could not save" in session.messages[0]
|
||||
assert "Build" in _body(page) # the page itself is untouched
|
||||
|
||||
|
||||
def test_the_popup_carries_its_tone_as_spans_not_as_markup(ctx: AppContext, tmp_path: Path) -> None:
|
||||
"""The message is a styled Text, so no tag is ever printed and the border reads right.
|
||||
|
||||
A markup *string* reaches this dialog unparsed: the reader sees the literal tags and a
|
||||
failure is framed in the success colour, because the border is picked from the
|
||||
message's spans. The mark carries the tone; nothing else needs to.
|
||||
"""
|
||||
from meshterm.ui.diagnostics import SAVE_KEY
|
||||
from meshterm.ui.tui.session import _message_border
|
||||
|
||||
for target, expected in ((tmp_path / "ok.md", "accent"), (_blocked(tmp_path), "err")):
|
||||
session = _Session(keep=True)
|
||||
page = _page(ctx, session=session, save_path=target)
|
||||
page.handle("text", SAVE_KEY)
|
||||
message = session.shown[0]
|
||||
assert isinstance(message, Text)
|
||||
assert "[/" not in message.plain and "[ok]" not in message.plain
|
||||
assert _message_border(message) == expected
|
||||
|
||||
|
||||
def test_a_held_save_key_does_not_stack_popups(ctx: AppContext, tmp_path: Path) -> None:
|
||||
"""One save at a time: a second press while one is in flight is ignored."""
|
||||
from meshterm.ui.diagnostics import SAVE_KEY
|
||||
|
||||
session = _Session(defer=True)
|
||||
page = _page(ctx, session=session, save_path=tmp_path / "out.md")
|
||||
page.handle("text", SAVE_KEY)
|
||||
page.handle("text", SAVE_KEY)
|
||||
assert len(session.started) == 1
|
||||
|
||||
|
||||
def test_another_letter_falls_through_to_the_pager(ctx: AppContext, tmp_path: Path) -> None:
|
||||
"""Any other letter is the scroll handler's, not the save's."""
|
||||
session = _Session()
|
||||
page = _page(ctx, session=session, save_path=tmp_path / "out.md")
|
||||
page.handle("text", "q")
|
||||
assert not (tmp_path / "out.md").exists()
|
||||
assert session.messages == []
|
||||
|
||||
|
||||
# --- the markdown face ------------------------------------------------------------------
|
||||
|
||||
#: A value that is markdown in every direction: backslashes markdown reads as escapes,
|
||||
#: asterisks and underscores it reads as emphasis. None of it may be reinterpreted.
|
||||
_HOSTILE = r"C:\Users\*jp*\_meshterm_"
|
||||
|
||||
|
||||
def test_every_value_survives_markdown_untouched() -> None:
|
||||
"""Values are code spans, so a path's backslashes and stars arrive as themselves.
|
||||
|
||||
A path, or an error message a stranger's firmware wrote, is not markdown and must never
|
||||
be silently read as it. A value already holding a backtick gets a longer fence.
|
||||
"""
|
||||
source = markdown_blocks(
|
||||
(
|
||||
Facts(
|
||||
key="x",
|
||||
caption="X",
|
||||
fields=(fields.word("p", "p"), fields.word("q", "q")),
|
||||
values={"p": _HOSTILE, "q": "a `backtick` inside"},
|
||||
),
|
||||
)
|
||||
)
|
||||
drawn = _render(render_markdown(source))
|
||||
assert _HOSTILE in drawn
|
||||
assert "a `backtick` inside" in drawn
|
||||
|
||||
|
||||
def test_a_caption_becomes_the_section_heading() -> None:
|
||||
"""A block's caption is its ``##``; a block without one is drawn with no heading."""
|
||||
assert markdown_blocks((Facts(key="x", caption="Radio", fields=()),)).startswith("## Radio")
|
||||
assert not markdown_blocks((Facts(key="x", fields=()),)).startswith("#")
|
||||
|
||||
|
||||
def test_an_empty_section_says_so_rather_than_vanishing() -> None:
|
||||
"""An absence is a fact here: "no preferences changed" is something to act on.
|
||||
|
||||
The plain face drops an empty listing because a record stream has nothing to say about
|
||||
one. A document does — a section silently missing only raises the question of whether
|
||||
it was ever built.
|
||||
"""
|
||||
empty = Listing(key="preferences", caption="Changed preferences", columns=(), rows=[])
|
||||
assert markdown_blocks((empty,)).endswith("*none*")
|
||||
assert "none" in _render(render_markdown(markdown_blocks((empty,))))
|
||||
|
||||
|
||||
# --- the host probe -------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"environ,expected",
|
||||
[
|
||||
({"WT_SESSION": "abc", "TERM": "xterm"}, "Windows Terminal"),
|
||||
({"TERM_PROGRAM": "Apple_Terminal", "TERM": "xterm-256color"}, "Terminal.app"),
|
||||
({"TERM_PROGRAM": "vscode"}, "VS Code"),
|
||||
# A terminal this module has never heard of still names itself, because
|
||||
# TERM_PROGRAM's *value* is the name — which is why that rung exists at all.
|
||||
({"TERM_PROGRAM": "WezTerm"}, "WezTerm"),
|
||||
({"TERM": "linux"}, "linux"),
|
||||
({}, None),
|
||||
],
|
||||
)
|
||||
def test_the_terminal_is_identified_from_what_it_says_about_itself(
|
||||
environ: dict, expected: str | None
|
||||
) -> None:
|
||||
"""Each rung of the ladder, down to the honest ``None`` when nothing names one."""
|
||||
assert hostinfo.terminal_program(environ) == expected
|
||||
|
||||
|
||||
def test_an_inner_terminal_wins_over_the_one_hosting_it() -> None:
|
||||
"""A marker variable beats ``TERM_PROGRAM``, because the inner emulator is the answer.
|
||||
|
||||
VS Code's terminal inherits the outer terminal's environment and adds its own marker;
|
||||
reporting the outer one would name a terminal that is not drawing anything.
|
||||
"""
|
||||
env = {"WT_SESSION": "abc", "TERM_PROGRAM": "vscode"}
|
||||
assert hostinfo.terminal_program(env) == "Windows Terminal"
|
||||
|
||||
|
||||
def test_ssh_is_read_from_either_variable() -> None:
|
||||
"""Being over ssh is a fact the block states, because it changes what is knowable."""
|
||||
assert hostinfo.terminal({"SSH_TTY": "/dev/pts/0"}).over_ssh is True
|
||||
assert hostinfo.terminal({"SSH_CONNECTION": "10.0.0.2 51000 10.0.0.1 22"}).over_ssh is True
|
||||
assert hostinfo.terminal({}).over_ssh is False
|
||||
|
||||
|
||||
def test_the_host_block_is_populated_on_whatever_runs_the_suite() -> None:
|
||||
"""Every host field has an answer, on any OS CI runs this on."""
|
||||
machine = hostinfo.host()
|
||||
assert machine.install in {"frozen", "source", "package"}
|
||||
assert machine.os and machine.arch and machine.python
|
||||
assert machine.python.count(".") == 2
|
||||
|
||||
|
||||
# --- the repository aggregates --------------------------------------------------------
|
||||
|
||||
|
||||
def test_table_counts_are_discovered_from_the_schema(tmp_path: Path) -> None:
|
||||
"""Every table is counted, and SQLite's own internal tables are not.
|
||||
|
||||
Read off ``sqlite_master`` so a table added to the schema starts being reported the day
|
||||
it lands. A curated list would go stale silently, and the interesting count is always
|
||||
the table nobody expected to be full.
|
||||
"""
|
||||
counts = Repository(tmp_path / "meshterm.db").table_counts()
|
||||
assert {"runs", "observations", "messages", "traces"} <= set(counts)
|
||||
assert not any(name.startswith("sqlite_") for name in counts)
|
||||
assert all(isinstance(n, int) for n in counts.values())
|
||||
|
||||
|
||||
def test_the_observation_span_is_absent_until_something_is_heard(tmp_path: Path) -> None:
|
||||
"""An empty database has no span, rather than a span of nothing."""
|
||||
repo = Repository(tmp_path / "meshterm.db")
|
||||
assert repo.observation_span() == (None, None)
|
||||
assert repo.failed_run_count() == 0
|
||||
|
||||
|
||||
# --- helpers ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _report(ctx: AppContext):
|
||||
"""Build the report, as both faces do."""
|
||||
return asyncio.run(build_report(ctx))
|
||||
|
||||
|
||||
def _flat(report) -> dict:
|
||||
"""Every fact in the report, flattened the way the JSON face flattens it."""
|
||||
flat: dict = {}
|
||||
for block in report:
|
||||
if isinstance(block, Facts):
|
||||
flat.update(block.values)
|
||||
else:
|
||||
flat[block.key] = list(block.rows)
|
||||
return flat
|
||||
|
||||
|
||||
def _keys(report) -> list[str]:
|
||||
"""Every key the block declares, facts and listing columns alike."""
|
||||
keys: list[str] = []
|
||||
for block in report:
|
||||
if isinstance(block, Facts):
|
||||
keys.extend(column.key for column in block.fields)
|
||||
else:
|
||||
keys.extend(column.key for column in block.columns)
|
||||
return keys
|
||||
|
||||
|
||||
def _plain(report) -> str:
|
||||
"""The whole block as the plain face writes it."""
|
||||
console = _console(200)
|
||||
with console.capture() as captured:
|
||||
PlainRenderer(console).render(report)
|
||||
return captured.get()
|
||||
|
||||
|
||||
def _machine(report) -> str:
|
||||
"""The machine face's document, as text."""
|
||||
console = _console(10_000)
|
||||
with console.capture() as captured:
|
||||
JsonRenderer(console).render(report)
|
||||
return captured.get()
|
||||
|
||||
|
||||
def _render(renderable) -> str:
|
||||
"""A renderable as plain text at the standard width."""
|
||||
console = _console(72)
|
||||
with console.capture() as captured:
|
||||
console.print(renderable)
|
||||
return captured.get()
|
||||
|
||||
|
||||
def _console(width: int) -> Console:
|
||||
"""A capture console with nothing between the content and the string."""
|
||||
return Console(width=width, color_system=None, highlight=False, markup=False)
|
||||
|
||||
|
||||
def _blocked(tmp_path: Path) -> Path:
|
||||
"""A destination no OS will accept: a "directory" that is really a file.
|
||||
|
||||
Refused by both the mkdir and the write, everywhere, without needing permissions the
|
||||
suite cannot count on.
|
||||
"""
|
||||
blocker = tmp_path / "blocker"
|
||||
if not blocker.exists():
|
||||
blocker.write_text("not a directory", encoding="utf-8")
|
||||
return blocker / "inside" / "out.md"
|
||||
|
||||
|
||||
def _strip(line: str) -> str:
|
||||
"""One rendered line with its SGR runs removed."""
|
||||
return re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||
|
||||
|
||||
class _Session:
|
||||
"""A session stand-in that runs the save flow inline and records the popup.
|
||||
|
||||
``run_detached`` is where the real session hands a key handler's flow to the event
|
||||
loop; here it simply runs it, so a test can press a key and assert on what the reader
|
||||
would have been shown. ``defer`` holds the coroutine instead, for the one test that
|
||||
needs a save still in flight.
|
||||
"""
|
||||
|
||||
def __init__(self, *, defer: bool = False, keep: bool = False) -> None:
|
||||
"""Start with nothing shown and nothing started."""
|
||||
self.messages: list[str] = []
|
||||
self.shown: list[object] = []
|
||||
self.started: list[object] = []
|
||||
self._defer = defer
|
||||
self._keep = keep
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""A repaint request, which a test has no screen to repaint."""
|
||||
|
||||
def run_detached(self, work):
|
||||
"""Run the flow now (or bank it, with ``defer``)."""
|
||||
self.started.append(work)
|
||||
if self._defer:
|
||||
work.close()
|
||||
return None
|
||||
return asyncio.run(work)
|
||||
|
||||
async def message_dialog(self, message, *, title: str = "") -> None:
|
||||
"""Record what the acknowledgement popup would have said."""
|
||||
self.messages.append(str(message))
|
||||
if self._keep:
|
||||
self.shown.append(message)
|
||||
|
||||
|
||||
def _page(ctx: AppContext, *, session: _Session | None = None, save_path: Path | None = None):
|
||||
"""The page over this context's report, saving wherever the caller wants."""
|
||||
from meshterm.ui.diagnostics import DiagnosticsPage
|
||||
|
||||
target = save_path or (ctx.settings.config_dir / "meshterm-diagnostics.md")
|
||||
return DiagnosticsPage(
|
||||
markdown_source(_report(ctx)), session=session or _Session(), save_path=target
|
||||
)
|
||||
|
||||
|
||||
def _body(page) -> str:
|
||||
"""The page's drawn rows as plain text."""
|
||||
return "\n".join(_strip(line) for line in page.render_body(100))
|
||||
@@ -984,6 +984,87 @@ def _join_discord(cols: int, rows: int) -> Screen:
|
||||
return AboutPage("Join Discord", join_discord())
|
||||
|
||||
|
||||
def _diagnostics(cols: int, rows: int) -> Screen:
|
||||
"""The Diagnostics page, carrying the widest values a real one can.
|
||||
|
||||
A deep Windows config directory and a connection error in the radio's own words are the
|
||||
two fields with no natural bound, and both are here on purpose: a markdown list item
|
||||
hangs its wrapped text under itself at any width, and that is what has to survive 53
|
||||
columns.
|
||||
"""
|
||||
from meshterm.ui.diagnostics import DiagnosticsPage
|
||||
|
||||
return DiagnosticsPage(
|
||||
_diagnostics_source(),
|
||||
session=_GallerySession(cols, rows),
|
||||
save_path=Path("C:/Users/somebody/.meshterm/meshterm-diagnostics.md"),
|
||||
)
|
||||
|
||||
|
||||
def _diagnostics_source() -> str:
|
||||
"""A representative diagnostics document, built the way the tool builds one."""
|
||||
from datetime import datetime
|
||||
|
||||
from meshterm.tools.diagnostics import markdown_source
|
||||
from meshterm.ui import fields
|
||||
from meshterm.ui.report import Column, Facts, Lane, Listing
|
||||
|
||||
report = (
|
||||
Facts(
|
||||
key="meshterm",
|
||||
caption="Build",
|
||||
fields=(fields.word("meshterm", "meshterm"), fields.word("install", "install")),
|
||||
values={"meshterm": "0.3.7", "install": "frozen"},
|
||||
),
|
||||
Facts(
|
||||
key="terminal",
|
||||
caption="Terminal",
|
||||
fields=(
|
||||
fields.word("terminal", "terminal"),
|
||||
fields.word("terminal_size", "terminal_size"),
|
||||
fields.flag("over_ssh", "over_ssh"),
|
||||
fields.word("powerline", "powerline"),
|
||||
),
|
||||
values={
|
||||
"terminal": "Windows Terminal",
|
||||
"terminal_size": "53x26",
|
||||
"over_ssh": True,
|
||||
"powerline": "none (font:windows-terminal)",
|
||||
},
|
||||
),
|
||||
Facts(
|
||||
key="device",
|
||||
caption="Radio",
|
||||
fields=(
|
||||
fields.flag("connected", "connected"),
|
||||
fields.path("config_dir", "config_dir"),
|
||||
fields.free("error", "error"),
|
||||
),
|
||||
values={
|
||||
"connected": False,
|
||||
"config_dir": Path("C:/Users/somebody/AppData/Roaming/meshterm/profiles/handheld"),
|
||||
"error": "no companion answered on COM7 within 10s; the port is open elsewhere",
|
||||
},
|
||||
),
|
||||
Listing(
|
||||
key="tables",
|
||||
caption="Stored rows",
|
||||
columns=(
|
||||
fields.word("table", "TABLE"),
|
||||
Column(key="rows", lanes=(Lane(header="ROWS", render=str, align="right"),)),
|
||||
),
|
||||
rows=[{"table": "observations", "rows": 418_203}, {"table": "runs", "rows": 91}],
|
||||
),
|
||||
Listing(
|
||||
key="preferences",
|
||||
caption="Changed preferences",
|
||||
columns=(fields.word("preference", "PREFERENCE"), fields.free("value", "VALUE")),
|
||||
rows=[{"preference": "log_level", "value": "DEBUG"}],
|
||||
),
|
||||
)
|
||||
return markdown_source(report, when=datetime(2026, 9, 19, 17, 0))
|
||||
|
||||
|
||||
def _share_qr(cols: int, rows: int) -> Screen:
|
||||
"""The share screen: a contact card's code and link on a bare frame (the widest QR)."""
|
||||
from meshterm.ui.qr import QrScreen
|
||||
@@ -1032,6 +1113,7 @@ _ENTRIES: list[_Entry] = [
|
||||
_Entry("join_discord", _join_discord),
|
||||
_Entry("support_project", _support_project),
|
||||
_Entry("share_qr", _share_qr),
|
||||
_Entry("diagnostics", _diagnostics),
|
||||
]
|
||||
|
||||
#: (platform, cols, rows) combos every entry above renders under. PicoCalc gets both its
|
||||
|
||||
@@ -1209,7 +1209,7 @@ def test_sweep_screen_new_sweep_clears_the_old_evidence() -> None:
|
||||
assert "TX 19" not in body and "-2.0" not in body
|
||||
await task
|
||||
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(go())
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_sweep_screen_escape_resolves() -> None:
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The splash art's colour rewriting: brightness said as a colour, never as bold.
|
||||
|
||||
The art is DOS-era ANSI, where `1m` lifts the foreground into the bright bank and every
|
||||
span after it inherits that. Neither half of that survives the trip here: rows are handed
|
||||
to Rich one at a time, so nothing inherits, and on macOS Terminal bold asks for a heavier
|
||||
face and leaves the colour alone. So `_state_intensity` restates brightness as the
|
||||
aixterm bright bank (`9N`), which can only ever mean a colour.
|
||||
|
||||
These pin the part that is easy to get subtly wrong — a sequence that changes the
|
||||
intensity while naming no colour at all, which is exactly how the art announces a bright
|
||||
run. Both splash files are full of bare `1m`, and dropping one silently draws a whole run
|
||||
in the dim bank: two spans meant to read dark then light both come out dark.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from meshterm.ui.logo import _state_intensity
|
||||
|
||||
_SPLASH = Path(__file__).resolve().parent.parent / "meshterm" / "assets" / "splash"
|
||||
|
||||
_SGR = re.compile(r"\x1b\[([0-9;]*)m")
|
||||
|
||||
|
||||
def _params(text: str) -> list[list[str]]:
|
||||
"""Every SGR sequence in `text`, split into its parameters.
|
||||
|
||||
Matched as whole tokens rather than by substring: a pattern like ``[0-9;]*1`` also
|
||||
matches the trailing digit of ``31``, which would read a perfectly ordinary red as a
|
||||
request for bold.
|
||||
"""
|
||||
return [found.group(1).split(";") for found in _SGR.finditer(text)]
|
||||
|
||||
|
||||
def _brights(text: str) -> int:
|
||||
"""How many sequences in `text` name a bright-bank foreground."""
|
||||
return sum(any(p.isdigit() and 90 <= int(p) <= 97 for p in parts) for parts in _params(text))
|
||||
|
||||
|
||||
# -- a bare intensity change ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_bare_bright_lifts_the_colour_already_in_force() -> None:
|
||||
"""`1m` on its own brightens the standing foreground, not just the next one named."""
|
||||
out = _state_intensity("\x1b[0;37mdim\x1b[1mbright")
|
||||
assert "\x1b[97m" in out, out.replace("\x1b", "ESC")
|
||||
|
||||
|
||||
def test_bare_bright_carries_a_background_without_losing_the_foreground() -> None:
|
||||
"""`1;41m` names a background and no foreground; the standing one still brightens."""
|
||||
out = _state_intensity("\x1b[0;32mgreen\x1b[1;41mbright on red")
|
||||
assert "\x1b[41;92m" in out, out.replace("\x1b", "ESC")
|
||||
|
||||
|
||||
def test_returning_to_dim_restates_the_colour_in_the_dim_bank() -> None:
|
||||
"""`22m` has the same problem in reverse: the run would stay in the bright bank."""
|
||||
out = _state_intensity("\x1b[0;31mred\x1b[1mbright\x1b[22mdim again")
|
||||
assert out.endswith("dim again")
|
||||
assert "\x1b[22;31m" in out, out.replace("\x1b", "ESC")
|
||||
|
||||
|
||||
def test_a_reset_takes_the_standing_colour_with_it() -> None:
|
||||
"""After `0m` there is no colour in force, so a later `1m` has nothing to restate."""
|
||||
out = _state_intensity("\x1b[0;36mcyan\x1b[0m\x1b[1mplain")
|
||||
tail = out.split("plain")[0].split("\x1b[0m")[-1]
|
||||
assert _brights(tail) == 0, out.replace("\x1b", "ESC")
|
||||
|
||||
|
||||
def test_a_named_foreground_is_still_rewritten_in_place() -> None:
|
||||
"""The original behaviour: `1;33m` becomes `93m`, with the bold dropped."""
|
||||
out = _state_intensity("\x1b[0m\x1b[1;33myellow")
|
||||
assert "\x1b[93m" in out, out.replace("\x1b", "ESC")
|
||||
|
||||
|
||||
# -- the art itself -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_splash_row_asks_a_terminal_for_bold() -> None:
|
||||
"""Bold is a font weight on macOS Terminal, so none may reach the screen."""
|
||||
for art in sorted(_SPLASH.glob("*.ans")):
|
||||
raw = art.read_bytes().decode("cp437", errors="replace")
|
||||
for number, line in enumerate(raw.split("\n"), start=1):
|
||||
for parts in _params(_state_intensity(line)):
|
||||
assert "1" not in parts, f"{art.name}:{number} still asks for bold"
|
||||
|
||||
|
||||
def test_no_bright_run_in_the_art_is_silently_dropped() -> None:
|
||||
"""A `1m` with a colour standing must leave a bright-bank foreground behind.
|
||||
|
||||
Counting brights across a file cannot catch this -- one `1m` brightens every later
|
||||
sequence, so the totals stay comfortably high while individual runs go missing. The
|
||||
invariant is per sequence: walk the source keeping track of whether a colour is in
|
||||
force, and every sequence that turns brightness on while one is must produce a
|
||||
bright-bank emission in the rewritten row.
|
||||
"""
|
||||
for art in sorted(_SPLASH.glob("*.ans")):
|
||||
raw = art.read_bytes().decode("cp437", errors="replace")
|
||||
for number, line in enumerate(raw.split("\n"), start=1):
|
||||
standing, owed = None, 0
|
||||
for parts in _params(line):
|
||||
for part in parts:
|
||||
if part in ("", "0"):
|
||||
standing = None
|
||||
elif re.fullmatch(r"3[0-7]", part):
|
||||
standing = part
|
||||
if "1" in parts and standing is not None:
|
||||
owed += 1
|
||||
if owed:
|
||||
assert _brights(_state_intensity(line)) >= owed, (
|
||||
f"{art.name}:{number} turns brightness on {owed} time(s) with a "
|
||||
f"colour standing, but the rewrite emits "
|
||||
f"{_brights(_state_intensity(line))} bright foreground(s)"
|
||||
)
|
||||
@@ -0,0 +1,214 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The macOS Terminal profile: what it says, and what it refuses to touch.
|
||||
|
||||
The archive shapes here were not invented. They were read back off a profile installed on
|
||||
a real Mac and confirmed rendering — so the encoder is pinned to bytes that are known to
|
||||
work, rather than to a reading of Apple's format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import plistlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from meshterm.core import macterminal
|
||||
from meshterm.core.relaunch import REOPENED_ENV
|
||||
from meshterm.ui.theme import _VT_SLOTS
|
||||
|
||||
|
||||
def test_color_archive_is_the_shape_appkit_writes() -> None:
|
||||
"""A colour decodes to a calibrated-RGB ``NSColor``, six places and NUL-terminated."""
|
||||
decoded = plistlib.loads(macterminal.ns_color("#aa0000"))
|
||||
|
||||
assert decoded["$archiver"] == "NSKeyedArchiver"
|
||||
assert decoded["$version"] == 100000
|
||||
colour = decoded["$objects"][1]
|
||||
assert colour["NSColorSpace"] == 1
|
||||
assert colour["NSRGB"] == b"0.666667 0.000000 0.000000\x00"
|
||||
assert decoded["$objects"][2]["$classname"] == "NSColor"
|
||||
|
||||
|
||||
def test_font_archive_names_the_bundled_face() -> None:
|
||||
"""The font archive carries the PostScript name, not the family name."""
|
||||
decoded = plistlib.loads(macterminal.ns_font())
|
||||
|
||||
assert macterminal.FONT_FACE in decoded["$objects"]
|
||||
assert decoded["$objects"][1]["NSSize"] == macterminal.FONT_SIZE
|
||||
assert decoded["$objects"][3]["$classname"] == "NSFont"
|
||||
|
||||
|
||||
def test_every_palette_slot_comes_from_the_theme() -> None:
|
||||
"""The window cannot disagree with the screen it is holding.
|
||||
|
||||
Sixteen keys, in the dim-then-bright order Terminal expects, each one the theme's own
|
||||
hex. A slot changed in :data:`~meshterm.ui.theme._VT_SLOTS` lands here on the next
|
||||
write, which is the reason the profile is generated rather than shipped.
|
||||
"""
|
||||
profile = macterminal.build_profile()
|
||||
|
||||
assert len(macterminal._ANSI_KEYS) == len(_VT_SLOTS) == 16
|
||||
for key, (_slot, _label, rgb) in zip(macterminal._ANSI_KEYS, _VT_SLOTS, strict=True):
|
||||
assert profile[key] == macterminal.ns_color(rgb), key
|
||||
|
||||
|
||||
def test_the_window_is_opaque_and_bold_is_not_brightness() -> None:
|
||||
"""The two settings the profile exists to guarantee, beside the font."""
|
||||
profile = macterminal.build_profile()
|
||||
|
||||
assert profile["BackgroundColor"] == macterminal.ns_color("#000000")
|
||||
assert "BackgroundBlur" not in profile
|
||||
assert profile["UseBrightBold"] is False
|
||||
# Tidy itself away on a clean exit, stay put on a crash the reader needs to read.
|
||||
assert profile["shellExitAction"] == 1
|
||||
|
||||
|
||||
def test_the_profile_is_the_same_bytes_whatever_the_command_line() -> None:
|
||||
"""The finding that shaped the module, pinned.
|
||||
|
||||
Terminal names an imported settings set after the *file*, reuses it when the bytes
|
||||
match, and adds "MeshTerm 1" when they do not. So the profile must not carry anything
|
||||
that varies between launches — the launcher path is fixed, and the varying command
|
||||
lives in the file it points at.
|
||||
"""
|
||||
import plistlib
|
||||
|
||||
plain = plistlib.dumps(macterminal.build_profile("/Users/x/.meshterm/launch.sh"))
|
||||
again = plistlib.dumps(macterminal.build_profile("/Users/x/.meshterm/launch.sh"))
|
||||
|
||||
assert plain == again
|
||||
# And what actually goes in is a path, never an argv or an environment.
|
||||
profile = macterminal.build_profile("/Users/x/.meshterm/launch.sh")
|
||||
assert profile["CommandString"] == "/Users/x/.meshterm/launch.sh"
|
||||
assert "--mock" not in str(profile["CommandString"])
|
||||
assert "MESHTERM_" not in str(profile["CommandString"])
|
||||
|
||||
|
||||
def test_the_launcher_carries_the_command_and_is_executable(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""What varies lives here instead, regenerated on every launch."""
|
||||
monkeypatch.setenv("MESHTERM_HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setattr(sys, "argv", ["meshterm", "--mock"])
|
||||
|
||||
path = macterminal.write_launcher()
|
||||
body = path.read_text(encoding="utf-8")
|
||||
|
||||
assert path.name == "launch.sh"
|
||||
assert body.startswith("#!/bin/sh")
|
||||
assert "exec /usr/bin/env " in body
|
||||
assert body.rstrip().endswith("--mock")
|
||||
if sys.platform != "win32": # NTFS has no execute bit for chmod to set
|
||||
assert path.stat().st_mode & 0o111
|
||||
|
||||
|
||||
def test_a_bare_profile_runs_nothing() -> None:
|
||||
"""Without a command it is an entry in the list, not a launcher."""
|
||||
profile = macterminal.build_profile()
|
||||
|
||||
assert "CommandString" not in profile
|
||||
assert "RunCommandAsShell" not in profile
|
||||
|
||||
|
||||
def test_the_profile_round_trips_as_a_terminal_file() -> None:
|
||||
"""What is written is what Terminal reads: a plist, with the nested archives intact."""
|
||||
written = plistlib.dumps(macterminal.build_profile("true"), fmt=plistlib.FMT_XML)
|
||||
reloaded = plistlib.loads(written)
|
||||
|
||||
assert reloaded["name"] == macterminal.PROFILE_NAME
|
||||
assert reloaded["type"] == "Window Settings"
|
||||
assert reloaded["CommandString"] == "true"
|
||||
assert plistlib.loads(reloaded["ANSIRedColor"])["$objects"][1]["NSColorSpace"] == 1
|
||||
|
||||
|
||||
def test_launch_command_carries_meshterm_variables_and_the_marker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Terminal starts the command from a fresh shell, so what it needs travels with it."""
|
||||
monkeypatch.setenv("MESHTERM_HOME", "/Users/someone/meshterm test")
|
||||
monkeypatch.delenv(REOPENED_ENV, raising=False)
|
||||
monkeypatch.setattr(sys, "argv", ["meshterm", "--mock"])
|
||||
|
||||
command = macterminal.launch_command()
|
||||
|
||||
assert f"{REOPENED_ENV}=1" in command
|
||||
# Quoted, because the reader's path may contain a space and the shell would split it.
|
||||
assert "MESHTERM_HOME='/Users/someone/meshterm test'" in command
|
||||
assert command.endswith("--mock")
|
||||
# A real executable, not a `VAR=value` shell prefix: measured on macOS 26, Terminal
|
||||
# reads the string as a bare program name under one setting and as a shell line under
|
||||
# the other, and a prefix puts "Command not found: MESHTERM_REOPENED=1" on the screen.
|
||||
assert command.startswith("/usr/bin/env ")
|
||||
|
||||
|
||||
def test_an_unrelated_variable_is_not_carried(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The hop is not a chance to launder the whole environment into a new shell."""
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "nope")
|
||||
|
||||
assert "AWS_SECRET_ACCESS_KEY" not in macterminal.launch_command()
|
||||
|
||||
|
||||
def test_a_reopened_session_never_reopens_again(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The guard that keeps a relaunch from chaining, since the profile's command sets it."""
|
||||
monkeypatch.setattr(sys, "platform", "darwin")
|
||||
monkeypatch.setenv("TERM_PROGRAM", "Apple_Terminal")
|
||||
monkeypatch.setenv(REOPENED_ENV, "1")
|
||||
|
||||
assert macterminal.available() is False
|
||||
assert macterminal.reopen() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("program", ["iTerm.app", "ghostty", "vscode", "WezTerm"])
|
||||
def test_another_terminal_is_left_alone(program: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Nothing is offered to a terminal that already has truecolor and a chosen font."""
|
||||
monkeypatch.setattr(sys, "platform", "darwin")
|
||||
monkeypatch.delenv(REOPENED_ENV, raising=False)
|
||||
monkeypatch.setenv("TERM_PROGRAM", program)
|
||||
|
||||
assert macterminal.in_apple_terminal() is False
|
||||
assert macterminal.available() is False
|
||||
|
||||
|
||||
def test_nothing_happens_off_macos(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Every entry point is inert on the platforms this module is not about."""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.setenv("TERM_PROGRAM", "Apple_Terminal")
|
||||
|
||||
assert macterminal.in_apple_terminal() is False
|
||||
assert macterminal.available() is False
|
||||
assert macterminal.reopen() is False
|
||||
assert macterminal.install_font() is False
|
||||
|
||||
|
||||
def test_the_profile_is_written_under_meshterm_home(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""It lives in MeshTerm's own directory, and follows ``MESHTERM_HOME`` when set."""
|
||||
monkeypatch.setenv("MESHTERM_HOME", str(tmp_path / "home"))
|
||||
|
||||
path = macterminal.write_profile()
|
||||
|
||||
assert path == tmp_path / "home" / "MeshTerm.terminal"
|
||||
assert plistlib.loads(path.read_bytes())["name"] == "MeshTerm"
|
||||
|
||||
|
||||
def test_the_readers_preferences_are_never_named(tmp_path, monkeypatch) -> None:
|
||||
"""Terminal discards external edits to its prefs on quit, so nothing here writes them.
|
||||
|
||||
A guard against the obvious future shortcut rather than against today's code: the
|
||||
whole design rests on handing Terminal a file and letting it do the importing.
|
||||
"""
|
||||
source = (macterminal.__file__).replace(".pyc", ".py")
|
||||
with open(source, encoding="utf-8") as handle:
|
||||
body = handle.read().split('"""', 2)[2]
|
||||
|
||||
assert "com.apple.Terminal.plist" not in body
|
||||
assert "Library/Preferences" not in body
|
||||
assert "defaults" not in body.replace("default_config_dir", "")
|
||||
|
||||
|
||||
def test_the_font_goes_to_the_users_own_library() -> None:
|
||||
"""No administrator rights, and undoable from Font Book."""
|
||||
assert macterminal.USER_FONT_DIR == Path.home() / "Library" / "Fonts"
|
||||
+16
-5
@@ -184,8 +184,8 @@ def test_main_menu_sections_answer_the_menus_own_question() -> None:
|
||||
someone else's over the mesh, or MeshTerm itself.
|
||||
|
||||
This app is the last of the three scopes and the reason it sorts last: it holds the
|
||||
preferences that change the program and the pages that describe it, neither of which
|
||||
is a thing to do *on the mesh*.
|
||||
preferences that change the program, the diagnostics that state what it currently is,
|
||||
and the pages that describe it — none of which is a thing to do *on the mesh*.
|
||||
"""
|
||||
from meshterm.tools import load_all_tools
|
||||
from meshterm.tools.base import _CATEGORY_ORDER, all_tools
|
||||
@@ -203,11 +203,22 @@ def test_main_menu_sections_answer_the_menus_own_question() -> None:
|
||||
"Explore": ["map", "walk", "trace", "trace-path", "records"],
|
||||
"This node": ["info", "config", "advert"],
|
||||
"Other nodes": ["repeater-admin", "tx-optimize"],
|
||||
"This app": ["preferences", "about", "about-author", "discord", "support"],
|
||||
"This app": [
|
||||
"preferences",
|
||||
"diagnostics",
|
||||
"about",
|
||||
"about-author",
|
||||
"discord",
|
||||
"support",
|
||||
],
|
||||
}
|
||||
# No section is so big it stops being a grouping (the old Mesh bucket held seven of
|
||||
# nineteen rows), and none is a bucket of one.
|
||||
assert all(2 <= len(names) <= 5 for names in sections.values())
|
||||
# nineteen rows), and none is a bucket of one. This app is the one at six: it runs
|
||||
# mutable, then live, then fixed — the row that *changes* MeshTerm, the row that states
|
||||
# what MeshTerm is *right now* on this machine, then the four pages that say what
|
||||
# MeshTerm is in general. Six of twenty-four rows is still a grouping; seven of
|
||||
# nineteen was not.
|
||||
assert all(2 <= len(names) <= 6 for names in sections.values())
|
||||
|
||||
|
||||
#: Two lexicon icons the terminal genuinely draws in different widths — the whole reason
|
||||
|
||||
@@ -169,6 +169,21 @@ def test_record_dialog_shows_the_node_type_legend() -> None:
|
||||
assert "★ you" in body and "▲ repeater" in body and "◉ sensor" in body
|
||||
|
||||
|
||||
def test_record_dialog_legend_breaks_between_entries_on_a_picocalc_width() -> None:
|
||||
"""At 53 cells the legend takes two lines, and no mark is parted from its name.
|
||||
|
||||
The full names run to 59 cells on one line, so cropping would cut "sensor" off the
|
||||
PicoCalc; breaking inside an entry would leave a glyph at the end of one line and its
|
||||
name at the start of the next.
|
||||
"""
|
||||
screen = _dialog(_record())
|
||||
screen.handle("tab") # the Route tab
|
||||
body = _plain(screen.render_body(53))
|
||||
for entry in ("★ you", "▲ repeater", "● companion", "■ room server", "◉ sensor"):
|
||||
assert entry in body
|
||||
assert all(cell_len(line) <= 53 for line in body.splitlines())
|
||||
|
||||
|
||||
def test_record_graph_marks_a_repeater_relay_with_its_triangle() -> None:
|
||||
"""A relay whose type is a repeater draws ▲, not the generic ● dot."""
|
||||
dialog = _dialog(_record(), type_of=lambda h: 2 if h == HUB_ID else None)
|
||||
|
||||
+199
-6
@@ -3344,8 +3344,18 @@ def test_a_hint_too_long_for_its_box_drops_atoms_rather_than_its_tail() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_the_splash_scrolls_the_hardware_column_of_the_row_it_is_on() -> None:
|
||||
"""←→ read a long firmware model to its end; the lanes in front of it stay put."""
|
||||
def test_the_splash_scrolls_the_whole_row_it_is_on() -> None:
|
||||
"""←→ move the entire row, lanes included, rather than sliding the tail under them.
|
||||
|
||||
The Trophy case pins its rank/date/score lanes because those always fit and only the
|
||||
walk overflows, so they are the reader's place in a long list. This list inverts that:
|
||||
on a narrow terminal it is the device name and the address that get cut, so pinning
|
||||
them would pin the truncation in place and the one thing ←→ could not reach would be
|
||||
the text somebody most wants to finish reading.
|
||||
|
||||
Scrolling has to be asked for outright here, because it is a row pinning a head block
|
||||
that normally turns it on and no row does now.
|
||||
"""
|
||||
from meshterm.core.device_store import RememberedDevice
|
||||
from meshterm.core.discovery import serial_device
|
||||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||||
@@ -3367,17 +3377,22 @@ def test_the_splash_scrolls_the_hardware_column_of_the_row_it_is_on() -> None:
|
||||
for platform, width in ((REGULAR, 66), (PICOCALC, 47)):
|
||||
set_platform(platform)
|
||||
items = _build_items([radio], registry[radio.stable_id], registry)
|
||||
screen = SelectScreen("Select a companion device", items, filterable=False)
|
||||
screen = SelectScreen(
|
||||
"Select a companion device", items, filterable=False, hscroll=True
|
||||
)
|
||||
row = next(it for it in items if isinstance(it, Choice))
|
||||
assert row.hscroll_from > 0 # the fixed lanes are pinned...
|
||||
assert row.hscroll_from < width # ...and leave something to scroll into
|
||||
assert not row.hscroll_from, "no lane is pinned on the splash any more"
|
||||
|
||||
before = _plain(screen.render_body(width)[1])
|
||||
for _ in range(3):
|
||||
screen.handle("right")
|
||||
after = _plain(screen.render_body(width)[1])
|
||||
assert before != after, platform.name
|
||||
assert after.startswith(before[: row.hscroll_from - 4]), "the lanes moved"
|
||||
# The head moved with everything else: what was at the left edge is gone.
|
||||
assert not after.startswith(before[:8]), f"the lanes stayed put on {platform.name}"
|
||||
# And the name is what scrolled out of view, which is the point -- it is the
|
||||
# part a narrow terminal cuts, so it has to be reachable.
|
||||
assert "Wardriver" in before
|
||||
finally:
|
||||
set_platform(REGULAR)
|
||||
|
||||
@@ -3425,3 +3440,181 @@ def test_a_shortcut_is_only_honoured_where_a_letter_is_free() -> None:
|
||||
filtering.handle("text", "h")
|
||||
assert len(resolved) == 1 # nothing new resolved
|
||||
assert filtering._filter == "h" # the letter went to the query, where it belongs
|
||||
|
||||
|
||||
# -- the splash keeps looking ---------------------------------------------------------
|
||||
|
||||
|
||||
def _drive_picker(tmp_path, monkeypatch, *, serial_rounds, ble_rounds=None, hide=None):
|
||||
"""Run the picker's rescan against a scripted sequence of scans.
|
||||
|
||||
The fake UI stands in for the splash being open: it is handed the ``live`` callable
|
||||
and runs it until the script is exhausted, recording every redraw. Discovery is
|
||||
replaced rather than mocked at the port level, because what is under test is the
|
||||
rescan's behaviour -- when it redraws, when it keeps quiet -- and not pyserial.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from meshterm.core.device_store import DeviceStore
|
||||
from meshterm.ui import device_picker
|
||||
|
||||
monkeypatch.setattr(device_picker, "_POLL_S", 0.001)
|
||||
monkeypatch.setattr(device_picker, "_BLE_EVERY_S", 0.0025)
|
||||
monkeypatch.setattr(device_picker, "_BLE_WINDOW_S", 0.0)
|
||||
|
||||
serial = list(serial_rounds)
|
||||
ble = list(ble_rounds or [[]])
|
||||
calls = {"serial": 0, "ble": 0}
|
||||
|
||||
def _serial():
|
||||
calls["serial"] += 1
|
||||
return list(serial[min(calls["serial"] - 1, len(serial) - 1)])
|
||||
|
||||
async def _ble(_timeout):
|
||||
calls["ble"] += 1
|
||||
return list(ble[min(calls["ble"] - 1, len(ble) - 1)])
|
||||
|
||||
monkeypatch.setattr(device_picker, "discover_devices", _serial)
|
||||
monkeypatch.setattr(device_picker, "discover_ble_devices", _ble)
|
||||
|
||||
store = DeviceStore(tmp_path / "devices.json")
|
||||
for stable in hide or []:
|
||||
store.hide(stable)
|
||||
|
||||
redraws: list = []
|
||||
|
||||
class _Ui:
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, live=None, **_kw
|
||||
):
|
||||
task = asyncio.ensure_future(live(redraws.append))
|
||||
# Long enough for the script to run several times over at this cadence.
|
||||
await asyncio.sleep(0.05)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def _never(_device):
|
||||
raise AssertionError("verify should not run when selection is skipped")
|
||||
|
||||
asyncio.run(device_picker.prompt_device(_Ui(), list(serial[0]), store, _never))
|
||||
return redraws, calls
|
||||
|
||||
|
||||
def _labels(items) -> list[str]:
|
||||
return [
|
||||
(it.title.plain if hasattr(it.title, "plain") else str(it.title))
|
||||
for it in items
|
||||
if isinstance(it, Choice)
|
||||
]
|
||||
|
||||
|
||||
def test_splash_redraws_when_a_device_is_plugged_in(tmp_path, monkeypatch) -> None:
|
||||
"""A radio attached after the splash opened appears without restarting MeshTerm."""
|
||||
from meshterm.core.discovery import DiscoveredDevice
|
||||
|
||||
first = DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)
|
||||
later = DiscoveredDevice(port="COM9", product="RAK4631", vid=0x239A)
|
||||
|
||||
redraws, _ = _drive_picker(tmp_path, monkeypatch, serial_rounds=[[first], [first, later]])
|
||||
|
||||
assert redraws, "the splash never redrew after the device appeared"
|
||||
assert any("COM9" in label for label in _labels(redraws[-1]))
|
||||
|
||||
|
||||
def test_splash_stays_quiet_when_nothing_changed(tmp_path, monkeypatch) -> None:
|
||||
"""A poll that finds the same ports redraws nothing.
|
||||
|
||||
This is the half that matters for somebody mid-way through arrowing down the list:
|
||||
a redraw they did not ask for, on a list that has not changed, is the screen moving
|
||||
under their hands for no reason.
|
||||
"""
|
||||
from meshterm.core.discovery import DiscoveredDevice
|
||||
|
||||
same = [DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)]
|
||||
redraws, calls = _drive_picker(tmp_path, monkeypatch, serial_rounds=[same])
|
||||
|
||||
assert calls["serial"] > 1, "the rescan never ran"
|
||||
assert redraws == []
|
||||
|
||||
|
||||
def test_splash_notices_a_device_going_away(tmp_path, monkeypatch) -> None:
|
||||
"""Unplugging removes the row, which is the same question asked backwards."""
|
||||
from meshterm.core.discovery import DiscoveredDevice
|
||||
|
||||
one = DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)
|
||||
redraws, _ = _drive_picker(tmp_path, monkeypatch, serial_rounds=[[one], []])
|
||||
|
||||
assert redraws, "the splash never redrew after the device went away"
|
||||
assert not any("COM5" in label for label in _labels(redraws[-1]))
|
||||
|
||||
|
||||
def test_a_rescan_does_not_unhide_what_the_reader_hid(tmp_path, monkeypatch) -> None:
|
||||
"""A refresh is not ⇧H. Whatever `h` hid stays hidden when the list is rebuilt."""
|
||||
from meshterm.core.discovery import DiscoveredDevice
|
||||
|
||||
kept = DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)
|
||||
buried = DiscoveredDevice(port="COM7", product="FT232R USB UART", vid=0x0403)
|
||||
later = DiscoveredDevice(port="COM9", product="RAK4631", vid=0x239A)
|
||||
|
||||
redraws, _ = _drive_picker(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
serial_rounds=[[kept, buried], [kept, buried, later]],
|
||||
hide=[buried.stable_id],
|
||||
)
|
||||
|
||||
assert redraws, "the splash never redrew"
|
||||
labels = _labels(redraws[-1])
|
||||
assert any("COM9" in label for label in labels)
|
||||
assert not any("COM7" in label for label in labels)
|
||||
|
||||
|
||||
def test_bluetooth_is_not_scanned_on_every_poll(tmp_path, monkeypatch) -> None:
|
||||
"""Serial is cheap and polled; Bluetooth is a listen and gets a duty cycle.
|
||||
|
||||
Each BLE scan keeps the radio listening, and this is the screen somebody leaves open
|
||||
on a battery-powered handheld while they go and find a cable -- so it must not be in
|
||||
the poll loop.
|
||||
"""
|
||||
from meshterm.core.discovery import DiscoveredDevice
|
||||
|
||||
same = [DiscoveredDevice(port="COM5", product="Wio SX1262", vid=0x2886)]
|
||||
_, calls = _drive_picker(tmp_path, monkeypatch, serial_rounds=[same])
|
||||
|
||||
assert calls["serial"] > calls["ble"], (
|
||||
f"bluetooth scanned {calls['ble']} time(s) against {calls['serial']} serial poll(s)"
|
||||
)
|
||||
|
||||
|
||||
def test_the_picker_only_passes_arguments_the_splash_surface_accepts(tmp_path) -> None:
|
||||
"""Every keyword `prompt_device` sends must exist on `Ui.select_startup`.
|
||||
|
||||
This is the shape of bug a test double hides. `prompt_device` calls the *surface*,
|
||||
which delegates to the session; adding an argument to the session alone crashes the
|
||||
startup path and nothing else, because every fake Ui in this file absorbs unknown
|
||||
keywords with `**kwargs` and is perfectly happy. So the fake here binds what it is
|
||||
given against the real signature, and a keyword the surface has never heard of raises
|
||||
exactly where a running MeshTerm would.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from meshterm.core.device_store import DeviceStore
|
||||
from meshterm.ui.device_picker import prompt_device
|
||||
from meshterm.ui.surface import Ui
|
||||
|
||||
signature = inspect.signature(Ui.select_startup)
|
||||
|
||||
class _Ui:
|
||||
async def select_startup(self, title, items, **kw): # noqa: ANN001, ANN003, ANN201
|
||||
signature.bind(self, title, items, **kw) # TypeError on an unknown keyword
|
||||
return None
|
||||
|
||||
async def _never(_device):
|
||||
raise AssertionError("verify should not run when selection is skipped")
|
||||
|
||||
store = DeviceStore(tmp_path / "devices.json")
|
||||
asyncio.run(prompt_device(_Ui(), [], store, _never))
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
|
||||
from meshterm.core.models import node_type_label
|
||||
from meshterm.ui.pathline import (
|
||||
POWERLINE_ROUND_CLOSE,
|
||||
POWERLINE_ROUND_OPEN,
|
||||
@@ -276,3 +277,13 @@ def test_name_chip_falls_back_to_the_bare_name_without_powerline(powerline) -> N
|
||||
|
||||
assert chip.plain == "Alice" # exactly what an arrow-mode path line makes of one hop
|
||||
assert str(next(iter(chip.spans)).style) == node_style("a1b2") # still its own hue
|
||||
|
||||
|
||||
def test_every_advert_type_has_one_name() -> None:
|
||||
"""Type 1 is a companion everywhere: info, diagnostics, contacts and the legend agree."""
|
||||
assert node_type_label(1) == "companion"
|
||||
assert node_type_label(2) == "repeater"
|
||||
assert node_type_label(3) == "room server"
|
||||
assert node_type_label(4) == "sensor"
|
||||
assert node_type_label(9) == "type 9" # an advert type MeshTerm does not know yet
|
||||
assert node_type_label(None) is None
|
||||
Reference in new issue
Block a user