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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: release
|
||||
description: Cut a MeshTerm release — pick the version, write the changelog entry from the commits since the last tag, bump `__version__`, commit, tag, and push so the GitHub release builds. Use when JP says to release, cut a release, ship a version, tag a version, or bump the version, and when asked what would go into the next release.
|
||||
description: Cut a MeshTerm release — pick the version, write the changelog entry from the commits since the last tag, bump `__version__`, commit, tag, push so the GitHub release builds, and hand JP a Discord announcement to post. Use when JP says to release, cut a release, ship a version, tag a version, or bump the version, and when asked what would go into the next release.
|
||||
---
|
||||
|
||||
# Cutting a MeshTerm release
|
||||
@@ -48,8 +48,27 @@ A release does not go out on a red suite. If something fails, stop and say so.
|
||||
|
||||
## 2. Decide the version
|
||||
|
||||
Patch unless JP says otherwise. Say the number you arrived at before you write it anywhere,
|
||||
so a wrong assumption costs a sentence instead of a tag.
|
||||
**Always ask, as a multiple choice**, whenever JP asks for a release — even when his
|
||||
request names a bump, because words like "minor" have meant "a small one" before (he
|
||||
asked to "bump minor" and meant the patch). One AskUserQuestion, three choices, each
|
||||
showing the actual number it produces:
|
||||
|
||||
- **Patch — X.Y.(Z+1)** (Recommended)
|
||||
- **Minor — X.(Y+1).0**
|
||||
- **Major — (X+1).0.0**
|
||||
|
||||
**Major asks twice.** If he picks it, ask a second, separate question before writing the
|
||||
number anywhere — "Release (X+1).0.0? A major version is a promise about compatibility
|
||||
that can't be taken back." — with *Yes, (X+1).0.0* and *No, go back to patch* as its
|
||||
choices. Only a second yes makes it major.
|
||||
|
||||
Then say the number you arrived at before you write it anywhere, so a wrong assumption
|
||||
costs a sentence instead of a tag.
|
||||
|
||||
**Don't recommend a bigger bump**, however many features or changed defaults the release
|
||||
carries — not even on the strength of the changelog's SemVer note. 0.10.0 went out that way
|
||||
on a recommendation, and JP would have preferred 0.9.1. A minor or major bump happens only
|
||||
when he names it.
|
||||
|
||||
## 3. Write the entry
|
||||
|
||||
@@ -96,31 +115,36 @@ 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
|
||||
|
||||
```
|
||||
git commit -am "MeshTerm X.Y.Z"
|
||||
git commit -a -m "MeshTerm X.Y.Z" -m "Co-Authored-By: <the model running this skill> <noreply@anthropic.com>"
|
||||
git tag -a vX.Y.Z -m "MeshTerm X.Y.Z"
|
||||
```
|
||||
|
||||
The trailer is not optional: the changelog entry in this commit is prose you wrote, and
|
||||
CONTRIBUTING.md asks every contributor to say when an AI tool wrote part of a change —
|
||||
the project's own commits say it first. Name the model actually running, not a
|
||||
hardcoded one. Seven release commits (0.3.1 through 0.10.0) went out without it because
|
||||
this line used to omit it.
|
||||
|
||||
Commit on `main` — never a topic branch. Then **confirm with JP before pushing**, because
|
||||
the push is the act that builds and publishes the release:
|
||||
|
||||
@@ -143,21 +167,60 @@ 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
|
||||
`gh release edit vX.Y.Z --notes-file` — the binaries are fine and the tag does not move.
|
||||
|
||||
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'
|
||||
```
|
||||
|
||||
If a *build* fails, the tag is already public: fix forward with a new patch version rather
|
||||
than deleting and re-pushing a tag people may have fetched.
|
||||
|
||||
## 7. The Discord announcement — for JP to post
|
||||
|
||||
Once the release has landed and checked out, end by showing JP a short announcement for
|
||||
the Discord server's `#announcements` channel, in a code block he can copy as-is. **He
|
||||
posts it himself** — never post it, never hand it to a bot. Write it for the people on
|
||||
the server, in plain words (they use MeshTerm; they don't read the diff):
|
||||
|
||||
- one bolded opening line naming the version and what it is, in a few words;
|
||||
- two to five short bullets of what changed *for them*, from the changelog entry —
|
||||
the headline items only, not every line;
|
||||
- the release link, and one line on how to get it (the download page, or the README's
|
||||
install line for someone who installed that way);
|
||||
- Discord markdown only (`**bold**`, `- ` bullets, bare links); no headings, no tables,
|
||||
no emoji beyond one at the start if it fits. Well under Discord's 2000 characters —
|
||||
it should fit on a phone screen.
|
||||
|
||||
```
|
||||
**MeshTerm 0.10.1 is out**: three small command-line fixes.
|
||||
|
||||
- `records --width` now tells you when a width doesn't exist, instead of looking empty
|
||||
- The simulator's row in `devices` lost a stray `()`
|
||||
- `platform --help` and `specimen --help` read like the rest
|
||||
|
||||
Download: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.1
|
||||
```
|
||||
|
||||
Say who it was written for, then show it last in the reply, so it's the easiest thing to
|
||||
copy.
|
||||
|
||||
## At 0.9.0 the changelog resets
|
||||
|
||||
The first public release collapses everything below it into a single entry reading "first
|
||||
|
||||
+7
-2
@@ -1,6 +1,11 @@
|
||||
# Kept in step with meshterm/assets/pages/support.md deliberately. If the app offers a
|
||||
# way to chip in and this file doesn't, the two have drifted and one of them is lying.
|
||||
buy_me_a_coffee: johnputer
|
||||
patreon: MeshTerm
|
||||
# PayPal is not one of GitHub's built-in platforms, so it is a custom link, written
|
||||
# without a scheme so the sidebar shows it as cleanly as it can. It stays the donate
|
||||
# button, not a paypal.me link: a payment through the button is recorded as a donation,
|
||||
# which PayPal's purchase protection does not cover, so a donor cannot claim a refund
|
||||
# for an item that never arrived.
|
||||
custom:
|
||||
- "https://www.patreon.com/c/MeshTerm"
|
||||
- "https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6"
|
||||
- "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.
|
||||
@@ -2,16 +2,29 @@
|
||||
|
||||
<!-- The why matters more than the what — the diff already says the what. -->
|
||||
|
||||
## Related issue
|
||||
## Discord
|
||||
|
||||
<!-- 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. -->
|
||||
<!-- Your Discord username on the MeshTerm server, and a link to the message in
|
||||
#contributing where you got a yes. A pull request without them is closed without
|
||||
a review. -->
|
||||
|
||||
Username:
|
||||
Link to the yes:
|
||||
|
||||
## AI use
|
||||
|
||||
<!-- Required. Did an AI tool write any part of this change? If yes: which tool, what it
|
||||
did, and what you checked yourself. Each commit it helped with also needs a line at
|
||||
the end naming it, like `Co-Authored-By:` or `Assisted-by:`. If you used AI and don't
|
||||
say so, this pull request is treated as a bot's and closed. -->
|
||||
|
||||
## Before you mark it ready
|
||||
|
||||
- [ ] I've read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) and agree its licensing terms apply to this contribution
|
||||
- [ ] I've put my Discord username and a link to the yes above
|
||||
- [ ] If I used AI, I said so above and in the commits it helped with
|
||||
- [ ] `python -m pytest -q` passes, including the dual-platform gallery gate
|
||||
- [ ] `ruff check .` and `ruff format --check .` are clean
|
||||
- [ ] New screens, rows and hints go through the helpers in `ui/menus.py`, and follow the
|
||||
- [ ] New screens, rows, and hints go through the helpers in `ui/menus.py`, and follow the
|
||||
lexicon and UX standards in `CLAUDE.md`
|
||||
- [ ] Anything user-visible has a line in `CHANGELOG.md` under `[Unreleased]`
|
||||
@@ -3,7 +3,7 @@
|
||||
# Split in two on purpose. Style is a property of the source, not of the machine, so
|
||||
# `lint` runs once and its failure is unambiguous. Behaviour is a property of the
|
||||
# machine, so `test` runs everywhere we claim to work — the description promises
|
||||
# Windows, macOS and Linux, and until this file existed that promise had only ever been
|
||||
# Windows, macOS, and Linux, and until this file existed that promise had only ever been
|
||||
# checked on one of them.
|
||||
name: CI
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,14 +59,19 @@ jobs:
|
||||
# shows up. It was shaped this way when the repository was private and
|
||||
# minutes were metered; on a public one they are not, so this can widen to
|
||||
# every OS times every version whenever the wait for a full run is worth it.
|
||||
#
|
||||
# 3.10 stays on Ubuntu 24.04. It reaches end of life in October 2026, and a
|
||||
# newer image that ubuntu-latest moves to may not offer it at all. That would
|
||||
# turn the badge red with nothing broken. Drop this leg when the floor rises.
|
||||
include:
|
||||
- { os: ubuntu-latest, python: "3.10" }
|
||||
- { os: ubuntu-24.04, python: "3.10" }
|
||||
- { 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 +102,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.14"
|
||||
|
||||
- run: pip install build twine
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ jobs:
|
||||
# Every platform's artifact, flattened into one directory of files to attach.
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
# The build uploads one artifact nobody should ever download: the unsigned
|
||||
# Windows binary it hands to SignPath. Everything meant for the release page is
|
||||
# named `meshterm-…`, so the pattern is the filter.
|
||||
pattern: meshterm-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
@@ -62,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
|
||||
@@ -92,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
|
||||
@@ -99,6 +135,81 @@ 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
|
||||
|
||||
# One ask, at the end, with the reason. The install commands keep the top of
|
||||
# the page, and the changelog stays a record of what changed.
|
||||
cat > star.md <<'STAR_MD'
|
||||
|
||||
---
|
||||
|
||||
If MeshTerm is useful to you, please give [the repository](https://github.com/jpmartineau/MeshTerm) a ⭐. It helps other MeshCore users find it, because GitHub ranks projects by stars in its search and topic pages.
|
||||
STAR_MD
|
||||
cat install.md notes.md star.md > body.md
|
||||
echo "--- body ---"; cat body.md
|
||||
|
||||
- name: Create it
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -107,7 +218,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"
|
||||
@@ -142,7 +253,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
|
||||
|
||||
@@ -118,6 +119,50 @@ jobs:
|
||||
"$GITHUB_WORKSPACE/$bin" contacts --help > /dev/null
|
||||
echo "runs, finds its assets, and its tools registered"
|
||||
|
||||
# The Windows binary is code-signed, so Windows has a publisher to recognise
|
||||
# instead of an unknown file it has never seen before — SmartScreen's warning eases
|
||||
# as that publisher's downloads accumulate, and antivirus engines, which flag
|
||||
# unsigned PyInstaller one-file builds on sight, are markedly less trigger-happy.
|
||||
# The certificate belongs to SignPath Foundation, who issue it free to open-source
|
||||
# projects and sign on their own infrastructure; nothing secret exists on this
|
||||
# runner, and there is no key here to leak. Its condition is that the artifact
|
||||
# provably came from this repository's CI, which is why the binary is handed over
|
||||
# as a GitHub artifact rather than uploaded: SignPath reads the workflow run behind
|
||||
# that artifact id and refuses anything it cannot trace to an approved branch.
|
||||
#
|
||||
# Signed between the build's own smoke test and the naming step, so what gets
|
||||
# verified is the build and what gets checksummed is the signed file — a checksum
|
||||
# taken before signing would describe a binary nobody ever downloads.
|
||||
#
|
||||
# Both legs are skipped wholesale until the account exists (the variable is unset
|
||||
# until then), so this file builds unsigned Windows binaries in the meantime rather
|
||||
# than failing, and a fork with no SignPath project of its own is unaffected.
|
||||
- name: Hand the Windows binary to SignPath
|
||||
id: unsigned
|
||||
if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != ''
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
# Deliberately not `meshterm-*`: the release workflow attaches every artifact
|
||||
# matching that prefix, and this one is the unsigned draft.
|
||||
name: unsigned-${{ matrix.label }}
|
||||
path: packaging/dist/meshterm.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Sign it
|
||||
if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != ''
|
||||
uses: signpath/github-action-submit-signing-request@v1
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: meshterm
|
||||
signing-policy-slug: release-signing
|
||||
artifact-configuration-slug: exe
|
||||
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
|
||||
# The signed file comes back where the unsigned one was built, under the same
|
||||
# name, so every step after this one is untouched by any of it.
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: packaging/dist
|
||||
|
||||
- name: Name it after the version and the platform
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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. The colon is optional, because
|
||||
# GitHub closes on "Fixes #12" just as it does on "Fixes: #12", and
|
||||
# the keywords are every form GitHub accepts, plus refs and see.
|
||||
refs=$(printf '%s\n' "$msg" \
|
||||
| grep -oiE '^(fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved|refs|see)(:[[:space:]]*|[[: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>/tmp/gh.err); then
|
||||
echo " $short -> #$num ($state)"
|
||||
elif ! grep -q 'HTTP 404' /tmp/gh.err; then
|
||||
# Only a 404 means the issue is missing. Anything else (a rate
|
||||
# limit, an outage) says nothing about the trailer, and a public
|
||||
# comment calling a real issue missing would be worse than silence.
|
||||
echo "::warning::could not check #$num for commit $short: $(head -c 200 /tmp/gh.err)"
|
||||
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
|
||||
|
||||
|
||||
+231
-830
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 |
|
||||
@@ -64,11 +64,14 @@ them instead of hand-rolling:
|
||||
| path | an ordered hop spec you compose or force (`a1,3d,…`) |
|
||||
| route | the concrete node sequence a trace walked or will walk |
|
||||
| via | prefix for a packet/message's relay chain |
|
||||
| region | a named area a repeater relays floods for (`region put yul`); shown bare, never `#yul` — the `#` belongs to key derivation only |
|
||||
| scope | the region a flood is limited to — a packet's, a channel's send scope, the device's default scope. A direct packet has none. MeshCore's sense and the only one: the menu's This node / Other nodes / This app sections name *whose* a setting is, never a "scope" |
|
||||
| unscoped | a plain flood, relayed by any repeater that allows the wildcard `*` |
|
||||
| Back | leave the current screen/list — Esc's word, and a row's only where leaving is a *choice* (see below) |
|
||||
| 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
|
||||
@@ -84,6 +87,19 @@ machine setup — paths and device profiles (`core/config.py`, `config.toml`, a
|
||||
only). A behaviour value belongs in the preference registry, not as a module constant and
|
||||
not in `config.toml`: a code constant a reader can't reach is a preference nobody has.
|
||||
|
||||
What MeshTerm writes, it writes from what it knows: a store reads its file into typed
|
||||
records and writes back out of *them*, never the raw document it read, so a field no
|
||||
record knows (retired, or invented by a hand edit) is gone at the next write — every
|
||||
`*_store.py` and `preferences.toml` work this way, and the settings store keeps only
|
||||
keys `device_config` still registers, since it offers them back to the radio. And
|
||||
**a name that leaves is retired for good**, never reused with another meaning, because a
|
||||
value can outlive its key in a file nobody saved since, and an old `30` that passes a new
|
||||
spec means something else undetectably. A preference key goes in `preferences.RETIRED`
|
||||
(loading refuses it; `report_dropped` logs what the next save will drop); a stored field
|
||||
goes in its record dataclass's own `RETIRED` set — the record is the path, not the leaf.
|
||||
`tests/test_retired.py` fails any live name found in either. Units live in the name
|
||||
(`_s`, `_hours`, `_db`), so a changed unit is a new name by construction.
|
||||
|
||||
Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now ago"),
|
||||
`format_age` for aligned columns ("now", "5m").
|
||||
|
||||
@@ -132,11 +148,21 @@ deliberately, one at a time, and say why in the code.
|
||||
for as long as it is true. Chat is the same shape with a different peelable thing — its
|
||||
first Esc unpicks a selected message. It used to be a 2–2 split, which put opposite
|
||||
outcomes behind the same keystroke on the same affordance.
|
||||
- **^W** unwinds every frame back to the main menu, **^Q** quits from anywhere. Both are
|
||||
answered in `TuiSession._dispatch` and neither is ever advertised — a global verb has no
|
||||
screen to belong to, and the F-key lane has only three free slots per screen. They are
|
||||
stated once, on the About page. `test_navigation` walks every string literal in the
|
||||
package to keep them out of the UI. ^W arms **every frame on the stack**, not just the
|
||||
- **^W** unwinds every frame back to the main menu, **^Q** asks to quit from anywhere: it
|
||||
floats the same confirm the menu's Quit row opens, detached over whatever is up so
|
||||
nothing under it stops, and a second ^Q while it asks leaves at once
|
||||
(`TuiSession.request_quit`; ^Q sits one key from ^W). Both are answered in
|
||||
`TuiSession._dispatch`, and neither is advertised — a global verb has no screen to
|
||||
belong to, and the F-key lane has only three free slots per screen — except `^Q quit?`
|
||||
on the main menu's own hint, where Esc is inert and the reader looks for the way out.
|
||||
On picocalc that atom rides the title bar's tail (`frame._way_out`), and the menu's lane
|
||||
puts the way out on F3: `Quit?` asks, its Shift half F8 `Quit!` leaves without asking
|
||||
(`quit_now`, the one door that skips the confirm, and only on the menu).
|
||||
`test_navigation` walks every string literal in the package to keep them out of the rest
|
||||
of the UI.
|
||||
- **Esc at the main menu does nothing** but peel a typed filter: the menu is the bottom of
|
||||
the stack, so Esc-mashing up to it settles there. Leaving is the Quit row or ^Q, both
|
||||
behind the confirm. ^W arms **every frame on the stack**, not just the
|
||||
top (`request_pop_all`), stopping at anything modal: a screen opened from a key handler
|
||||
— the packet viewer, the chat's delivery paths, a trace path flow — runs in a task no
|
||||
navigation frame awaits, so an unwind sent only to the top died there while the hub
|
||||
@@ -166,6 +192,18 @@ deliberately, one at a time, and say why in the code.
|
||||
shape in `ReorderScreen`. A **Quit** row is likewise kept (main menu, device splash):
|
||||
it *initiates* the app's terminal action behind a confirm, and on the splash it is the
|
||||
only statement that the app can be left at all.
|
||||
- **An editor has one of two shapes, chosen by who holds the change.** Where MeshTerm
|
||||
holds it until told (Device config, Preferences, Repeater admin's settings), edits are
|
||||
*staged* and nothing reaches the device until Apply — the `exit_rows` shape above.
|
||||
Where the **device** holds each edit live in RAM until its own save command (a
|
||||
repeater's region table: every edit takes effect at once, and a reboot undoes whatever
|
||||
`region save` did not persist), edits apply immediately and the page is *live, then
|
||||
save*: the title counts unsaved edits (`· 2 unsaved`), the page ends on the same
|
||||
shape reworded — `✓ Save n changes to the …` over `✗ Back — a reboot undoes them` —
|
||||
and Esc with unsaved edits asks first (amber). Never stage what the device already
|
||||
holds live: the edits depend on each other there (a region must exist before it can be
|
||||
allowed), and a stage would have to re-implement the firmware's rules to order them.
|
||||
`ui/region_editor.py` is the reference.
|
||||
- **A cursor clamps at both ends — nothing in the app rolls over.** ↓ off the last row
|
||||
and ↑ off the first stay put, in a select list, a hand-drawn action list, a reorder
|
||||
list, a dialog's button row, everywhere. The window follows the highlight, so a roll
|
||||
@@ -237,7 +275,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
- ≤72 cells. Sentence shape: navigation keys, then action keys, **Esc last**.
|
||||
- Esc verb by surface: `Esc back` leaves a screen · `Esc close` dismisses a read-only
|
||||
floating view · `Esc cancel` abandons a prompt/dialog · `Esc keep` leaves a value
|
||||
picker unchanged · `Esc quit` only at the main menu · `Esc bye` only on the device
|
||||
picker unchanged · no Esc atom at the main menu, which ends on `^Q quit?` instead ·
|
||||
`Esc bye` only on the device
|
||||
splash, which is the door rather than a screen — nothing has been started there to
|
||||
quit out of. While a find-as-you-type filter is standing the verb becomes `Esc clear`,
|
||||
because that is what the press does then.
|
||||
@@ -296,8 +335,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
|
||||
@@ -338,7 +377,7 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
⚙ parameter · `#` count · ▶ run · ⚡ explore/probe · ★ best/winner · ⭐ watch ·
|
||||
📤 send now · 📨 courier/queue · 💬 chat · 🔔 notify · 🔕 mute (notifications off) ·
|
||||
📱 QR · 🔗 link · ↻ re-read · ↕ reorder · ⇄ reverse (flip a path's direction) ·
|
||||
🏆 trophy case/record · ⌨ command line · 📖 read/about · 💰 support/donate ·
|
||||
🔖 region/scope · 🏆 trophy case/record · ⌨ command line · 📖 read/about · 💰 support/donate ·
|
||||
🚪 quit. Packet-class icons (feed/viewer
|
||||
lane): 📢 advert · 📊 telemetry · 📦 packet · 💬 message · ✅ ack. Raw payload
|
||||
classes (`PAYLOAD_ICONS`): 📻 channel text · 💽 channel data · 📩 direct message
|
||||
@@ -398,6 +437,13 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
- Dialogs anchor slightly above true centre, sized for their populated state.
|
||||
- Radio traffic: single transmissions or a user-chosen sample count with cooldown pacing
|
||||
— never bursts.
|
||||
- **A channel message goes out through `Device.send_channel_in_scope`**, never
|
||||
`send_channel_message` directly. The firmware has no per-channel scope, only one
|
||||
session scope that every flood uses, so a channel's scope is a window: set it, send,
|
||||
restore, under the transmit lock (`Device.transmitting()`) so no other flood slips out
|
||||
inside it. A direct send skips that window without an error and the message floods
|
||||
everywhere; a refused scope raises `FloodScopeError` and sends nothing, never a quiet
|
||||
unscoped send. Any new command that can transmit a flood holds `transmitting()` too.
|
||||
|
||||
### The command line — one answer, two faces
|
||||
|
||||
|
||||
+67
-4
@@ -25,8 +25,66 @@ 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.
|
||||
- Tell us before you start working on something (see below). For anything bigger than a
|
||||
small fix, expect to talk it through first. That saves everyone a rewrite.
|
||||
|
||||
## Who can contribute
|
||||
|
||||
If you use MeshTerm, you're welcome here. Maybe something broke for you, or you wished
|
||||
it could do one more thing, or a screen looked wrong on your radio. Those are exactly
|
||||
the changes we want.
|
||||
|
||||
### How MeshTerm was made
|
||||
|
||||
Some people in the MeshCore community don't trust code written by AI. After some of
|
||||
what has happened lately, that's easy to understand, and it deserves a straight answer.
|
||||
|
||||
Jean-Pierre Martineau, who started MeshTerm, sees it differently. AI was a big help on
|
||||
this project. Honestly, MeshTerm couldn't have been built without it. But the choices
|
||||
were his. He decided what MeshTerm should be, what it should do, and how it should
|
||||
look. He uses it every day. It's handmade, with AI's help.
|
||||
|
||||
The project wants to stay that way. That's what the rules below are for.
|
||||
|
||||
### Come say hello first
|
||||
|
||||
Using AI tools to help you work is fine. What matters is that a real person who cares
|
||||
about MeshTerm is behind the change.
|
||||
|
||||
The MeshTerm community lives on [Discord](https://discord.gg/AZwe5Uvb3S), and everyone
|
||||
is welcome to join. But please join before you change things. Before you start writing
|
||||
code, say hello in the **#contributing** channel and tell us what you'd like to work on.
|
||||
Then wait until we say yes. This goes for small fixes too.
|
||||
|
||||
When you open your pull request, include two things:
|
||||
|
||||
- **Your Discord username**, the one you use on the MeshTerm server.
|
||||
- **A link to the message where we said yes.** In Discord, right-click the message and
|
||||
choose "Copy Message Link."
|
||||
|
||||
If you haven't joined the Discord, or nobody said yes to your idea, your pull request
|
||||
will be closed without a review.
|
||||
|
||||
### No bots
|
||||
|
||||
Some accounts use AI to find open issues, write a fix, and open a pull request, all on
|
||||
their own. There's no person behind it who read the change or can answer questions
|
||||
about it. People call this "AI slop." We close those pull requests without a review,
|
||||
even if the code looks fine. The same goes for issues and bug reports written that way.
|
||||
|
||||
### Tell us if you used AI
|
||||
|
||||
This part is required. If an AI tool wrote any part of your change, say so in two
|
||||
places:
|
||||
|
||||
- **In each commit it helped with.** Add a line at the end of the commit message naming
|
||||
the tool, like `Co-Authored-By: Claude <noreply@anthropic.com>` or
|
||||
`Assisted-by: GitHub Copilot`.
|
||||
- **In your pull request.** Write a sentence about what the tool did and what you
|
||||
checked yourself.
|
||||
|
||||
If you used AI and didn't say so, we'll treat your pull request as a bot's. Saying so
|
||||
costs you nothing. Either way, you're the author, so be ready to explain your change.
|
||||
|
||||
## Making a change
|
||||
|
||||
@@ -61,7 +119,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,13 @@ 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. Want to
|
||||
> change something? Say hello on [Discord](https://discord.gg/AZwe5Uvb3S) first.
|
||||
> [More on how it's run](#how-this-project-is-run).
|
||||
|
||||
https://github.com/user-attachments/assets/f91a6695-14d5-4ae0-8fdc-e65492d0955d
|
||||
|
||||
<p align="center"><sub>Under two minutes on a real mesh.</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%">
|
||||
@@ -53,10 +58,12 @@ your mesh's history is worth.
|
||||
sweep, and a walkable graph of how the mesh actually hangs together.
|
||||
- **It talks.** Live channel and direct messaging, store-and-forward courier delivery for
|
||||
contacts that aren't there yet, and shareable channels as QR codes and `meshcore://` links.
|
||||
- **It connects however your companion does.** Serial (USB), Bluetooth LE, or TCP (a WiFi
|
||||
- **It connects however your companion does.** Serial (USB), Bluetooth LE, or TCP (a Wi-Fi
|
||||
board or a network proxy) — the discoverable transports are auto-found and picked
|
||||
interactively, a network device is named by hand, and every kind reconnects live when a link
|
||||
drops. No radio? A built-in simulator covers development.
|
||||
drops. It pairs a PIN-protected Bluetooth companion itself on Windows and Linux, and when a
|
||||
connection fails it says which step failed and what to do. No radio? A built-in simulator
|
||||
covers development.
|
||||
- **It works offline.** The street map caches OpenStreetMap tiles to disk and falls back to
|
||||
a blank grid when there's no network — it never needs the internet to run.
|
||||
|
||||
@@ -64,76 +71,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.6-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.6-*
|
||||
./meshterm-0.3.6-*
|
||||
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-macos-arm64
|
||||
chmod +x meshterm
|
||||
./meshterm
|
||||
```
|
||||
|
||||
### A word about terminals, on Windows
|
||||
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.
|
||||
|
||||
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 — the same three lines
|
||||
|
||||
`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"*. Clear it with
|
||||
> `xattr -d com.apple.quarantine meshterm-0.3.6-*` and run it again.
|
||||
> - **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.6-* # macOS, Linux
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm # macOS, Linux
|
||||
> ```
|
||||
> ```powershell
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.6-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.
|
||||
@@ -169,12 +192,18 @@ 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`. |
|
||||
| [When a companion won't connect](docs/connecting.md) | Bluetooth pairing on each system, finding the PIN, USB permissions, and every connection error, with what to do about it. |
|
||||
| [MeshTerm on hardware](docs/hardware.md) | Which handheld manual is yours — the [PicoCalc](docs/picocalc.md) build, or the [uConsole](docs/uconsole.md), whose LoRa chip MeshTerm can drive directly. |
|
||||
| [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. |
|
||||
|
||||
@@ -184,12 +213,15 @@ Setting up for development instead? That's in [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
# Interactive full-screen menu (auto-discovers serial + Bluetooth companions)
|
||||
meshterm
|
||||
|
||||
# Connect over Bluetooth (address from `meshterm devices`)
|
||||
meshterm --ble AA:BB:CC:DD:EE:FF info
|
||||
# Connect over Bluetooth (address from `meshterm devices`); add --ble-pin if it has a PIN
|
||||
meshterm --ble AA:BB:CC:DD:EE:FF --ble-pin 123456 info
|
||||
|
||||
# Connect over the network to a TCP companion (host[:port], port defaults to 5000)
|
||||
meshterm --tcp 192.168.1.50 info
|
||||
|
||||
# Drive a LoRa radio on this machine's own SPI bus (the uConsole AIO; Linux only)
|
||||
meshterm --spi info
|
||||
|
||||
# No radio attached? Use the built-in simulator for development.
|
||||
meshterm --mock
|
||||
|
||||
@@ -203,237 +235,56 @@ meshterm specimen
|
||||
Putting MeshTerm on a handheld has its own manuals. **[MeshTerm on the
|
||||
PicoCalc](docs/picocalc.md)** takes a stock PicoCalc from the shopping list to a Linux
|
||||
handheld with a LoRa radio soldered inside; **[MeshTerm on the uConsole](docs/uconsole.md)**
|
||||
connects a uConsole's SPI LoRa board through a small bridge. The device-side scripts both
|
||||
manuals run are in [`scripts/`](scripts/); not sure which manual is yours?
|
||||
drives a uConsole's SPI LoRa board directly with `--spi`, or through a small bridge if you
|
||||
want the node on the mesh all the time. The device-side scripts both manuals run are in
|
||||
[`scripts/`](scripts/); not sure which manual is yours?
|
||||
[`docs/hardware.md`](docs/hardware.md) says.
|
||||
|
||||
## Features
|
||||
A companion that won't connect? **[When a companion won't
|
||||
connect](docs/connecting.md)** covers Bluetooth pairing on Windows, Linux, and macOS, the
|
||||
`dialout` group and ModemManager for USB on Linux, and every error message MeshTerm gives.
|
||||
|
||||
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.
|
||||
## What it does
|
||||
|
||||
### 💬 Message — the people on the other end
|
||||
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 whose it is:
|
||||
**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.
|
||||
|
||||
| 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` |
|
||||
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.
|
||||
|
||||
### 📊 Watch — what the mesh is doing, and what it did
|
||||
**[What MeshTerm does](docs/features.md)** is the full catalogue: every screen, what it
|
||||
does, and its scripted equivalent where one exists.
|
||||
|
||||
| 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` |
|
||||
## Scripting it
|
||||
|
||||
### 🧭 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`, `--spi`, `--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
|
||||
|
||||
@@ -441,18 +292,21 @@ 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
|
||||
[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.
|
||||
**Ask before you write a pull request.** Join the [Discord](https://discord.gg/AZwe5Uvb3S)
|
||||
and say hello in `#contributing`. Tell us what you'd like to change. Then wait for a yes.
|
||||
That goes for small fixes too. 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. AI tools are fine, as long as you say you used them.
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) has the details.
|
||||
|
||||
**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:
|
||||
|
||||
@@ -470,19 +324,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
|
||||
@@ -499,7 +340,7 @@ are decoded with a small built-in reader for the
|
||||
|
||||
Both map surfaces carry the credit themselves. The full-screen map opens with
|
||||
`© OpenMapTiles · Data from OpenStreetMap` in its bottom-right corner and collapses it to
|
||||
`© OpenStreetMap` once you pan, zoom or type — set into the panel's bottom border rule on
|
||||
`© OpenStreetMap` once you pan, zoom, or type — set into the panel's bottom border rule on
|
||||
a desktop terminal, so the drawing itself keeps every cell, and on the map's own last row
|
||||
on the PicoCalc, whose frame has no bottom rule. The node page's location preview shows the
|
||||
short form throughout. The About page inside the app spells out the licence URL.
|
||||
@@ -509,7 +350,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.
|
||||
|
||||
@@ -520,7 +361,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 |
|
||||
@@ -529,7 +370,7 @@ respective project for the authoritative terms):
|
||||
| [prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) | Full-screen interactive TUI | BSD-3-Clause |
|
||||
| [markdown-it-py](https://github.com/executablebooks/markdown-it-py) | CommonMark parser behind the written About pages | MIT |
|
||||
| [Segno](https://github.com/heuer/segno) | Pure-Python QR codes (channel share links) | BSD-3-Clause |
|
||||
| [tomli](https://github.com/hukkin/tomli) / [tomli-w](https://github.com/hukkin/tomli-w) | TOML config, preferences and device-config backups | MIT |
|
||||
| [tomli](https://github.com/hukkin/tomli) / [tomli-w](https://github.com/hukkin/tomli-w) | TOML config, preferences, and device-config backups | MIT |
|
||||
|
||||
## License
|
||||
|
||||
@@ -549,7 +390,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.
|
||||
+30
-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]
|
||||
@@ -57,3 +58,26 @@ host = "192.168.1.50"
|
||||
# tcp_port = 5000
|
||||
# transport = "tcp"
|
||||
description = "Basestation over WiFi"
|
||||
|
||||
# A radio on this machine's own SPI bus: MeshTerm runs its node while it is connected.
|
||||
# The pins default to the uConsole AIO v1's, which needs nothing more than this (and is
|
||||
# listed on the device screen anyway, whenever /dev/spidev1.0 exists).
|
||||
[profiles.aio]
|
||||
transport = "spi"
|
||||
# [profiles.aio.spi]
|
||||
# en_pins = [27] # the AIO v2 powers its radio from pin 27
|
||||
|
||||
# Any other board states its whole wiring — every pin and both switches, because a key
|
||||
# left out keeps the AIO's value. Read them off the board's meshtasticd config; the
|
||||
# values here are an illustration only. docs/configuration.md has the key-by-key mapping.
|
||||
# [profiles.hat.spi]
|
||||
# bus_id = 0 # /dev/spidev0.0
|
||||
# cs_id = 0
|
||||
# reset_pin = 18
|
||||
# busy_pin = 20
|
||||
# irq_pin = 16
|
||||
# txen_pin = 6
|
||||
# rxen_pin = -1
|
||||
# use_dio2_rf = false
|
||||
# use_dio3_tcxo = false
|
||||
# python = "/opt/radio/bin/python" # only to pick the Python that has openhop-core
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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`. |
|
||||
| [When a companion won't connect](connecting.md) | Bluetooth pairing on each system, finding the PIN, and every connection error MeshTerm reports, with what to do about it. |
|
||||
| [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 — MeshTerm drives it directly with `--spi`, or a small bridge fronts it as a companion for an always-on node. |
|
||||
|
||||
## 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) | How to propose a change, the rules on AI use, the dev install, the three checks a change must 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.
|
||||
|
||||
+327
-99
@@ -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)
|
||||
@@ -54,11 +52,12 @@ With no command, MeshTerm launches the interactive menu instead.
|
||||
| `--version` | Print `meshterm <version>` and exit `0`, before anything else is opened. |
|
||||
| `-p`, `--profile NAME` | Use a named device profile from `config.toml`. |
|
||||
| `--port PORT` | Serial port to connect to (`COM5`, `/dev/ttyACM0`), overriding the profile. |
|
||||
| `--ble ADDRESS` | Bluetooth address of a companion; selects the BLE transport. |
|
||||
| `--ble-pin PIN` | Pairing PIN, if the Bluetooth companion asks for one. |
|
||||
| `--ble ADDRESS` | Bluetooth address of a companion; selects the Bluetooth transport. |
|
||||
| `--ble-pin PIN` | Pairing PIN, if the Bluetooth companion asks for one. MeshTerm pairs with it itself on Windows and Linux; on macOS the system asks for the code in its own dialog, and this option has no effect. See [When a companion won't connect](connecting.md#bluetooth). |
|
||||
| `--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). |
|
||||
| `--spi` | The LoRa radio on this machine's own SPI bus; selects the SPI transport. MeshTerm runs the node for the length of the command. With one radio attached, that's the one, profile or not; with none attached, the one SPI profile, else the uConsole AIO's wiring. With two, it refuses (exit `3`) and lists them — pick one with `-p`. See [Adding a radio on the SPI bus](configuration.md#adding-a-radio-on-the-spi-bus). Linux only. |
|
||||
| `--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,31 +96,36 @@ 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. |
|
||||
| `admin.json` | Remembered repeater-admin passwords. |
|
||||
| `devices.json` | The remembered default companion, and every device proven to speak the protocol. |
|
||||
| `contacts.json`, `channels.json`, `settings.json` | Per-device caches of what the radio last told us, so a screen opens without a round-trip. |
|
||||
| `adverts.json` | Per-device background-advert schedules (`config advert-cadence` writes here). |
|
||||
| `adverts.json` | The weekly flood advert's clock: when it was switched on, and when each device's week began. |
|
||||
| `mutes.json`, `watchtower.json`, `remote.json` | Muted channels; watched nodes and their alerts; per-node remote-admin cache and CLI history. |
|
||||
| `regions.json` | The regions known by name — and which repeaters carry each — plus every channel's send scope and each repeater's last answer to "which regions do you carry?". What a scoped packet's region is named against. |
|
||||
| `radio/<spidev>/` | An SPI radio's node, one folder per device node (`radio/spidev1.0/`): its identity key, settings, channels, and contacts, and its log, `node.log`. What firmware would keep in flash. |
|
||||
| `tilecache/` | Downloaded basemap tiles. |
|
||||
| `meshterm.log` | The log file. |
|
||||
| `.lock` | The single-instance lock. |
|
||||
|
||||
**`--db` alone does not isolate a run.** It moves the database, which is most of the
|
||||
weight but none of the identity: the contact and channel caches, the outbox, the stored
|
||||
admin passwords and the remembered device all live beside it in the config directory and
|
||||
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 +144,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 +178,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 +191,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 +261,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 +276,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 +377,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 +428,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 +436,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.
|
||||
@@ -479,11 +482,16 @@ consumer that cannot would have to tell an error document from a result.
|
||||
|
||||
```console
|
||||
$ meshterm --port NOSUCHPORT99 info
|
||||
meshterm: could not open serial port NOSUCHPORT99: could not open port 'NOSUCHPORT99': FileNotFoundError(2, 'The system cannot find the file specified.', None, 2)
|
||||
meshterm: could not open serial port NOSUCHPORT99: NOSUCHPORT99 isn't there — the device was unplugged, or came back under another name.
|
||||
$ echo $?
|
||||
3
|
||||
```
|
||||
|
||||
The sentence names the cause and the remedy: a port that is missing, in use by another
|
||||
program, or not yours to open, and for Bluetooth a link that never opened, a pairing that is
|
||||
out of date, or a PIN that was wrong. [When a companion won't connect](connecting.md) lists
|
||||
every one of them.
|
||||
|
||||
With nothing named and more than one candidate attached, the same `3` arrives as a list
|
||||
and the way out of it:
|
||||
|
||||
@@ -583,8 +591,11 @@ repeat](#the-shapes-that-repeat), the table names the shape rather than restatin
|
||||
|
||||
#### `meshterm devices`
|
||||
|
||||
List every attached serial device and in-range Bluetooth companion. Opens no radio — it
|
||||
only enumerates what is attached or advertising. Under `--mock` it enumerates nothing at
|
||||
List every attached serial device, every radio on this machine's SPI bus, and every
|
||||
in-range Bluetooth companion. Opens no radio — it only enumerates what is attached or
|
||||
advertising. An SPI radio is listed when its `/dev/spidev*` node exists, exactly as the
|
||||
device screen lists it: one per SPI profile, plus the uConsole AIO's `/dev/spidev1.0` when
|
||||
no profile covers it. Under `--mock` it enumerates nothing at
|
||||
all: the simulator is the one device, listed with `--mock` as its target, and neither the
|
||||
serial ports nor the Bluetooth radio are touched — a session that promised no real
|
||||
hardware keeps the promise here too.
|
||||
@@ -602,7 +613,8 @@ COM26 serial USB Serial Device (COM26) Espressif maybe
|
||||
COM1 serial Communications Port (COM1) (Standard port types) no - no
|
||||
```
|
||||
|
||||
`TARGET` is what `--port` and `--ble` take, verbatim. `MESHCORE` is three-valued and stays
|
||||
`TARGET` is what `--port` and `--ble` take, verbatim; an SPI radio's is its device node,
|
||||
reached with `--spi` or its profile's `-p`. `MESHCORE` is three-valued and stays
|
||||
that way: `yes` only once a connection has proved the device speaks the protocol, `maybe`
|
||||
for a USB vendor ID that suggests a LoRa board or a bridge chip, `no` for anything else —
|
||||
a vendor ID is a hint, and the column would be lying if it rounded one up.
|
||||
@@ -617,11 +629,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. |
|
||||
| `address` | string \| null | The Bluetooth address, for a BLE 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 Bluetooth 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 +642,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 +697,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
|
||||
@@ -705,7 +721,6 @@ View and change every device setting. With no subcommand it runs `show`.
|
||||
| `custom KEY VALUE` | Set an experimental custom variable. |
|
||||
| `channel INDEX NAME [--secret HEX]` | Configure a channel slot (see also the `channels` group). |
|
||||
| `advert [--flood]` | Broadcast an advertisement. Zero-hop unless `--flood`. |
|
||||
| `advert-cadence HOURS [--flood]` | How often to auto-advertise in the background. `0` disables. |
|
||||
| `share` | Print this node's contact card as a `meshcore://` URI. |
|
||||
| `sync-clock` | Set the device clock from this computer. |
|
||||
| `export-key [--out PATH]` | Export the private key. **Sensitive.** |
|
||||
@@ -724,10 +739,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
|
||||
@@ -783,12 +800,10 @@ $ meshterm config channel 2 brigade --json
|
||||
{"changes":1,"channel":{"slot":2,"name":"brigade","public":true,"hash":"b5"}}
|
||||
$ meshterm config advert --json
|
||||
{"sent":true,"flood":false}
|
||||
$ meshterm config advert-cadence 6 --json
|
||||
{"changes":1,"flood":false,"hours":6}
|
||||
$ 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 +816,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 +838,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 +862,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 |
|
||||
@@ -871,12 +886,11 @@ the live view.
|
||||
|
||||
```console
|
||||
$ meshterm monitor --seconds 2
|
||||
TIME NODE SNR_DB RSSI_DBM LOCATION NAME
|
||||
2026-09-08T04:26:00-04:00 a1b2c3d4 +7.0 -101 45.50190,-73.56740 Yagi-Repeater
|
||||
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
|
||||
|
||||
TIME NODE SNR_DB RSSI_DBM LOCATION SCOPE NAME
|
||||
2026-09-08T04:26:00-04:00 a1b2c3d4 +7.0 -101 45.50190,-73.56740 - Yagi-Repeater
|
||||
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 +898,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.
|
||||
|
||||
@@ -894,7 +909,7 @@ stream cannot give: a count, a median, a best per node, most recently heard firs
|
||||
same shape, and **no summary document at the end**:
|
||||
|
||||
```json
|
||||
{"observed_at":"2026-09-08T08:30:40Z","node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"kind":"advert","snr_db":7.0,"rssi_dbm":-101.3,"position":{"lat":45.5019,"lon":-73.5674},"path":null}
|
||||
{"observed_at":"2026-09-08T08:30:40Z","node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"kind":"advert","snr_db":7.0,"rssi_dbm":-101.3,"position":{"lat":45.5019,"lon":-73.5674},"scope":null,"path":null}
|
||||
```
|
||||
|
||||
| Field | Type | Meaning |
|
||||
@@ -905,6 +920,7 @@ same shape, and **no summary document at the end**:
|
||||
| `snr_db` | number \| null | |
|
||||
| `rssi_dbm` | number \| null | |
|
||||
| `position` | object \| null | The `position` shape. |
|
||||
| `scope` | object \| null | The region a flood was sent into: `{"state", "region", "code"}`. `state` is `"scoped"` (`region` names it), `"unknown"` (scoped, but no region known here reproduces `code`) or `"unscoped"` (a plain flood). `code` is the frame's transport code as lowercase hex, kept even when unresolved so two packets can be seen to share a region. `null` for a direct packet, or a class whose route was never reported — a repeater never region-filters those. The plain `SCOPE` lane prints the region, `unknown`, `unscoped` or `-`. |
|
||||
| `path` | array of strings \| null | The relay chain the packet arrived by, one lowercase-hex hash per hop, in propagation order. `[]` is a direct reception; `null` means the packet class carries no path at all. This is the one place `path` is an array — it is a *received* relay chain rather than a composed spec. |
|
||||
|
||||
**This is the one place a whole block appears on one face and not the other, and it is
|
||||
@@ -968,7 +984,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.
|
||||
@@ -985,7 +1001,7 @@ The live transcript is a menu screen; from the command line, pick a subcommand.
|
||||
| Subcommand | What it does |
|
||||
| --- | --- |
|
||||
| `send TEXT --to NAME` | Send a direct message. |
|
||||
| `send TEXT --channel N` | Broadcast on a channel slot. |
|
||||
| `send TEXT --channel N [--scope REGION]` | Broadcast on a channel slot, under the channel's scope — or under `REGION` for this one message, `*` for unscoped. |
|
||||
| `history [--to NAME \| --channel N] [--limit N]` | Print a conversation's stored transcript. Default limit 50. |
|
||||
| `list` | Every channel and contact with its unread count and last message. |
|
||||
| `listen [-s SECONDS] [--debug]` | Tail inbound messages live. `-s 0` (the default) runs until Ctrl-C. |
|
||||
@@ -1044,13 +1060,30 @@ 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}
|
||||
```
|
||||
|
||||
`kind` is `"direct"` \| `"channel"` and says which of `node` / `channel` is populated.
|
||||
A channel send also carries `scope`, in the same shape `monitor` gives a received packet's:
|
||||
`{"state":"scoped","region":"harbour","code":null}` for a region, `"unscoped"` for a plain
|
||||
flood, and `null` where it isn't known (a direct message, or a channel with no scope of its
|
||||
own whose device default couldn't be read). `code` is `null` on a send: the code is a hash
|
||||
of the packet, and the radio builds the packet.
|
||||
|
||||
`--scope` applies to a channel send only. A direct message has no scope of its own to give
|
||||
— the radio floods it under the device default like anything else — so `--scope` with
|
||||
`--to` is a usage error (exit `2`), as is a name the firmware would refuse. A radio that
|
||||
can't send under the scope (firmware older than 1.10, or `*` before 1.16 with a default
|
||||
scope set) refuses before anything is transmitted, and that is a failure (exit `1`),
|
||||
never a quiet unscoped send. Quote the `*` so the shell leaves it alone:
|
||||
|
||||
```console
|
||||
$ meshterm chat send "all clear" --channel 0 --scope '*' --json
|
||||
{"kind":"channel","node":null,"channel":{"slot":0,"name":"#0","public":true,"hash":null},"sent":true,"acked":null,"scope":{"state":"unscoped","region":null,"code":null}}
|
||||
```
|
||||
`acked` is `null` on a channel, not `false`: there is no acknowledgement to have, and that
|
||||
is exactly what one absence token is for — it is also why the plain face prints nothing
|
||||
there, having no way to say it.
|
||||
@@ -1095,8 +1128,9 @@ Join on `hash`.
|
||||
| `import INDEX URL` | Import a `meshcore://channel/add` link. |
|
||||
| `share INDEX` | Print a slot's share link. |
|
||||
| `clear INDEX --yes` | Clear a slot, removing the channel from the device. |
|
||||
| `scope INDEX [REGION] [--clear]` | Show or set the region the channel's messages are flooded into. |
|
||||
|
||||
`list` prints `SLOT NAME TYPE HASH`, one record per configured slot, and returns `5` when
|
||||
`list` prints `SLOT NAME TYPE HASH SCOPE`, one record per configured slot, and returns `5` when
|
||||
the device has none. (The simulator these examples run against has none, so there is no
|
||||
captured listing to show; each `--mock` invocation opens a fresh radio, and a slot written
|
||||
by one run is not there for the next.)
|
||||
@@ -1115,9 +1149,19 @@ that is actually the answer.)
|
||||
`clear` is gated behind `--yes`, because a private channel's key is lost with the slot
|
||||
unless it is saved elsewhere.
|
||||
|
||||
`scope` is MeshTerm's to keep, not the radio's: the firmware has no per-channel scope, so
|
||||
MeshTerm sets the companion's scope around each message it sends on the channel. Setting
|
||||
one therefore writes nothing to the device, and it follows the channel to any slot it moves
|
||||
to. With no `REGION`, `scope` prints the channel's region alone, the way `config get` prints
|
||||
one value — and a channel with none prints `-` (`null` in `--json`) and exits `0`: it sends
|
||||
under the device default, which is an answer. Setting (`scope 1 harbour`) or clearing
|
||||
(`scope 1 --clear`) prints nothing plain. Reading an empty slot exits
|
||||
`5`, like `share`; writing to one is a usage error (exit `2`), since there is no channel to
|
||||
scope. `SCOPE` in `list` is `-` for a channel that sends under the device default.
|
||||
|
||||
**`--json`.** `list` is an array whose rows are **flat** — this is the one listing whose
|
||||
record *is* the shared shape rather than carrying one, so a row is
|
||||
`{"slot":0,"name":"Public","type":"public","hash":"11"}`, with `type` the plain column's
|
||||
`{"slot":0,"name":"Public","type":"public","hash":"11","scope":null}`, with `type` the plain column's
|
||||
word rather than the `channel` shape's `public` boolean. Nesting a `channel` key under
|
||||
every row would make `jq '.[].channel.name'` out of a listing whose every column is
|
||||
already the channel.
|
||||
@@ -1329,7 +1373,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
|
||||
@@ -1385,6 +1429,43 @@ node sent multiple lines. It is **not** parsed: MeshTerm does not know the remot
|
||||
CLI grammar, and a document that pretended to would be inventing structure. `reply` is
|
||||
`null` on the exit-`5` no-answer path.
|
||||
|
||||
#### `meshterm regions`
|
||||
|
||||
Ask a repeater which regions it relays floods for (firmware 1.12 or newer).
|
||||
|
||||
```
|
||||
meshterm regions NODE
|
||||
```
|
||||
|
||||
| Argument / option | What it does |
|
||||
| --- | --- |
|
||||
| `NODE` | The repeater's contact name. |
|
||||
|
||||
```console
|
||||
$ meshterm regions Yagi-Repeater
|
||||
node Yagi-Repeater
|
||||
unscoped yes
|
||||
regions lakeside, lakeside-north, harbour
|
||||
```
|
||||
|
||||
One anonymous request — no login — and one transmission. A repeater answers it only when it
|
||||
arrives direct: from a neighbour, or over a route the companion has learned. It also
|
||||
rate-limits the question, so nothing here retries it. `unscoped` says whether it relays plain
|
||||
floods too; `regions` lists the regions it relays *scoped* floods for. The answer is
|
||||
remembered, so the node page and the packet views know it afterwards.
|
||||
|
||||
A repeater that answered and named nothing at all relays no floods, and returns `5`. One that
|
||||
never answered is a device error (`4`). A name no contact matches, and a contact known to be
|
||||
something other than a repeater, are usage errors (`2`): nothing was sent.
|
||||
|
||||
**`--json`:**
|
||||
|
||||
```json
|
||||
{"node":{"name":"Yagi-Repeater","key":"a1b2c3d400000000000000000000000000000000000000000000000000000000","hash":"a1b2c3d4","type":"repeater","self":false},"unscoped":true,"regions":["lakeside","lakeside-north","harbour"]}
|
||||
```
|
||||
|
||||
`regions` never holds the wildcard `*` — that is `unscoped`, since it names no region.
|
||||
|
||||
---
|
||||
|
||||
### MeshTerm itself
|
||||
@@ -1405,20 +1486,35 @@ 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
|
||||
weekly_flood_advert off off Flood an advert after a week without one
|
||||
advert_quiet_s 30 30 Silence to wait for before the weekly 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 +1556,7 @@ each link; the scripted face prints the link alone.
|
||||
**`--json`:**
|
||||
|
||||
```json
|
||||
{"page":"discord","title":"Join Discord","version":"0.3.6","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
|
||||
{"page":"discord","title":"Join Discord","version":"0.10.2","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 +1572,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
|
||||
@@ -1598,18 +1824,20 @@ for i in $(seq 5); do
|
||||
done
|
||||
```
|
||||
|
||||
**Let MeshTerm advertise, rather than cron.** Background adverts are MeshTerm's own job,
|
||||
not the firmware's, and the cadence is a policy you set once:
|
||||
**Let MeshTerm advertise, rather than cron.** A companion never advertises by itself, so
|
||||
MeshTerm can do it for you — once a week, and only when the air is quiet:
|
||||
|
||||
```bash
|
||||
meshterm config advert-cadence 3 # zero-hop, to neighbours, every 3 h
|
||||
meshterm config advert-cadence 6 --flood # mesh-wide, every 6 h
|
||||
meshterm preferences set weekly_flood_advert on # starts the week; sends nothing now
|
||||
meshterm preferences set advert_quiet_s 60 # wait for a minute of silence first
|
||||
```
|
||||
|
||||
A running MeshTerm session sends them on that schedule. **Do not put `config advert
|
||||
--flood` in a tight cron slot**: the cadence picker's floor for a flood is 3 h, and a
|
||||
`*/30` line would be six times tighter than the app will ever let a person choose. A
|
||||
repeater that decides you are bursting can blacklist you.
|
||||
A running MeshTerm session then floods one advert from a device once it has gone a full
|
||||
week without one — a flood advert you send by hand restarts that device's week — and only
|
||||
after it has heard the mesh and then heard nothing for the quiet spell plus a random 0–5 s.
|
||||
**Do not put `config advert --flood` in a cron slot instead**: every repeater in range
|
||||
rebroadcasts a flood advert, and a repeater that decides you are bursting can blacklist
|
||||
you.
|
||||
|
||||
**Cron-friendly quiet.** `--quiet` suppresses console logging entirely, leaving only the
|
||||
command's own output and any error line. Cron's `PATH` is nearly empty, so give it the
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# 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.
|
||||
A LoRa radio on this machine's own SPI bus is listed on the picker when its device node
|
||||
exists, or reached with `--spi`; see [the uConsole manual](uconsole.md).
|
||||
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
|
||||
# db_path = "C:/Users/you/meshterm/meshterm.db" # the history lives elsewhere
|
||||
|
||||
[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 (not used on macOS)
|
||||
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 Wi-Fi"
|
||||
|
||||
# A radio on this machine's own SPI bus (the uConsole AIO): MeshTerm runs its node.
|
||||
# The pins default to the AIO v1's; a `spi` table states only what differs.
|
||||
[profiles.aio]
|
||||
transport = "spi"
|
||||
# [profiles.aio.spi]
|
||||
# en_pins = [27] # the AIO v2 powers its radio from pin 27
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Adding a radio on the SPI bus
|
||||
|
||||
Some boards put the LoRa chip straight on the computer's SPI bus, with no firmware in front
|
||||
of it: the uConsole's AIO board, and LoRa HATs for the Raspberry Pi. MeshTerm drives these
|
||||
itself — it runs the node while it is connected and lets go of the radio when it quits.
|
||||
It needs Linux and the radio library; [the uConsole manual](uconsole.md#step-2--install-the-radio-library)
|
||||
shows how to install that.
|
||||
|
||||
**An AIO v1 needs no configuration.** Its wiring is MeshTerm's default, so whenever
|
||||
`/dev/spidev1.0` exists the device screen lists it as **SPI radio**, and `meshterm --spi`
|
||||
reaches it. Any other board is added with a profile that states how it is wired.
|
||||
|
||||
**Why a profile, and not a prompt on the device screen.** A USB companion describes itself:
|
||||
plug it in and there is nothing to ask. An SPI radio says nothing about how it is wired, and
|
||||
the wiring is a dozen facts — the bus, the chip select, the reset, busy, and interrupt pins,
|
||||
the antenna switch, the TCXO — any one of which, wrong, leaves a radio that comes up deaf
|
||||
rather than failing. Those are facts to look up once and write down, not to type into a
|
||||
dialog. And the radio doesn't come and go the way a cable does: it is soldered, stacked on
|
||||
a header, or built into the case, so its description belongs somewhere as lasting as the
|
||||
hardware is. A file you write once, and can read back when something is off, is that place.
|
||||
|
||||
### 1. Find your board's wiring
|
||||
|
||||
The easiest place to find it is the board's `meshtasticd` configuration, which most vendors
|
||||
publish (usually a `config.yaml` or a `config.d/` fragment with a `Lora:` section). Its keys
|
||||
map one to one:
|
||||
|
||||
| `meshtasticd` (`Lora:`) | MeshTerm (`[profiles.<name>.spi]`) |
|
||||
| --- | --- |
|
||||
| `spidev: spidev0.0` | `bus_id = 0` and `cs_id = 0` — the two numbers of `spidevB.C` |
|
||||
| `CS: 21` | `cs_pin = 21` (only when the board drives chip select from a GPIO) |
|
||||
| `Reset`, `Busy`, `IRQ` | `reset_pin`, `busy_pin`, `irq_pin` |
|
||||
| `TXen`, `RXen` | `txen_pin`, `rxen_pin` |
|
||||
| `DIO2_AS_RF_SWITCH: true` | `use_dio2_rf = true` |
|
||||
| `DIO3_TCXO_VOLTAGE: true` | `use_dio3_tcxo = true` (1.8 V) |
|
||||
| `gpiochip: 4` | `gpio_chip = 4` |
|
||||
|
||||
`Module` must be `sx1262`; that is the chip MeshTerm's node drives. `gpio_chip` is `0` on
|
||||
a Compute Module 4 but differs on a Raspberry Pi 5 and a CM5 — `gpiodetect`, or
|
||||
`ls /dev/gpiochip*`, shows which chip carries the 40-pin header.
|
||||
|
||||
**State every pin and both switches.** Where a `meshtasticd` file leaves a key out, it means
|
||||
"none" or "off". Where the `spi` table leaves a key out, it means *the AIO's value* — IRQ on
|
||||
26, DIO2 and DIO3 on — which is wrong for almost any other board, and a radio wired wrong
|
||||
comes up deaf rather than failing.
|
||||
|
||||
### 2. Write the profile
|
||||
|
||||
In `~/.meshterm/config.toml`, give the radio a name and its wiring. The values below are an
|
||||
illustration, not any particular board's — take yours from step 1:
|
||||
|
||||
```toml
|
||||
[profiles.hat]
|
||||
description = "SX1262 HAT on the Pi header"
|
||||
|
||||
[profiles.hat.spi]
|
||||
bus_id = 0 # /dev/spidev0.0
|
||||
cs_id = 0
|
||||
reset_pin = 18
|
||||
busy_pin = 20
|
||||
irq_pin = 16
|
||||
txen_pin = 6 # this HAT switches its antenna from a GPIO…
|
||||
rxen_pin = -1
|
||||
use_dio2_rf = false # …so DIO2 doesn't
|
||||
use_dio3_tcxo = false # no TCXO on this board
|
||||
```
|
||||
|
||||
A `spi` table is enough to make it an SPI profile; `transport = "spi"` says so outright and
|
||||
is what an AIO profile with no table uses. Every key, with the AIO defaults, is in
|
||||
[the uConsole manual's wiring table](uconsole.md#board-wiring). A key MeshTerm doesn't know,
|
||||
or a value of the wrong type, stops it at startup with the profile named — a misspelt pin
|
||||
is never quietly ignored.
|
||||
|
||||
### 3. Check it
|
||||
|
||||
```bash
|
||||
meshterm -p hat info
|
||||
```
|
||||
|
||||
The first run starts the node, which mints the radio's identity. If something is wrong, the
|
||||
error says what to fix — a missing `/dev/spidev*` (enable SPI and reboot), a permission
|
||||
problem (join the `spi` and `gpio` groups), a missing radio library, or another program
|
||||
already holding the radio. A node that starts but never answers leaves its log in
|
||||
`~/.meshterm/radio/spidev0.0/node.log`. Once it answers, the device screen lists the radio
|
||||
under the profile's name.
|
||||
|
||||
### What the profile doesn't hold
|
||||
|
||||
- **Radio settings.** Frequency, bandwidth, spreading factor, coding rate, and TX power are
|
||||
the node's own settings, saved by the node and changed on **Device config** like any
|
||||
companion's. A new node starts on MeshCore's **US/Canada preset** (910.525 MHz, 62.5 kHz,
|
||||
SF7, CR 4/5, 22 dBm): anywhere else, change it on Device config before you send anything.
|
||||
- **The preamble.** The node follows MeshCore's own rule — 32 symbols up to SF8, 16 above —
|
||||
and a receiver that expects anything else misses most of the mesh. `preamble_length` was
|
||||
once a wiring key and is refused now; delete the line if an old profile has it.
|
||||
- **Identity.** The node's key, contacts, and channels live in
|
||||
`~/.meshterm/radio/<spidev>/`, filed by the device node rather than the profile, so
|
||||
renaming a profile, or reaching the same radio from the device screen, keeps the same node.
|
||||
@@ -0,0 +1,128 @@
|
||||
# When a companion won't connect
|
||||
|
||||
MeshTerm talks to a MeshCore companion over USB, Bluetooth, or TCP, or runs the node itself
|
||||
on a radio on the SPI bus. When a connection fails, MeshTerm says which step failed and what
|
||||
to do about it, in the device picker and on the command line alike. This page collects those
|
||||
messages, so you can look one up before you meet it, or search for the one you have.
|
||||
|
||||
Every failure is also written to the log file, `~/.meshterm/meshterm.log` (see
|
||||
[Where MeshTerm keeps its state](cli.md#where-meshterm-keeps-its-state)). Attach it to a
|
||||
bug report. For more detail, set the **Log detail** preference to `DEBUG` (`meshterm
|
||||
preferences set log_level DEBUG`) and run it again.
|
||||
|
||||
- [Bluetooth](#bluetooth)
|
||||
- [Who does the pairing](#who-does-the-pairing)
|
||||
- [Finding the PIN](#finding-the-pin)
|
||||
- [What each message means](#what-each-bluetooth-message-means)
|
||||
- [Forgetting a pairing](#forgetting-a-pairing)
|
||||
- [USB](#usb)
|
||||
- [TCP](#tcp)
|
||||
- [A radio on the SPI bus](#a-radio-on-the-spi-bus)
|
||||
|
||||
---
|
||||
|
||||
## Bluetooth
|
||||
|
||||
**A companion takes one Bluetooth connection at a time.** If your phone's MeshCore app is
|
||||
connected to it, MeshTerm cannot connect until the phone lets go, and the companion stops
|
||||
advertising while it is connected, so MeshTerm cannot even see it. Disconnect the phone
|
||||
first, or turn its Bluetooth off.
|
||||
|
||||
### Who does the pairing
|
||||
|
||||
A PIN-protected companion must be *paired* before it will talk: the computer and the
|
||||
companion exchange keys once, using the six-digit PIN, and remember them. Who runs that
|
||||
exchange depends on the system.
|
||||
|
||||
| System | Who pairs | What you do |
|
||||
| --- | --- | --- |
|
||||
| Windows | MeshTerm | Give the PIN when MeshTerm asks for it (the picker's PIN dialog, or `--ble-pin` on the command line). You do not need to pair in Settings first. |
|
||||
| Linux | MeshTerm | The same. MeshTerm answers the pairing itself, so it works over SSH and on machines with no desktop, and you do not need `bluetoothctl` or the desktop's Bluetooth settings. |
|
||||
| macOS | macOS | macOS shows its own dialog asking for the code; type the PIN there. `--ble-pin` has no effect on macOS. |
|
||||
|
||||
Once paired, the system remembers it, and later connections need no PIN.
|
||||
|
||||
**On macOS, the terminal needs Bluetooth permission.** macOS grants Bluetooth to the app
|
||||
MeshTerm runs in (Terminal, iTerm2, Ghostty), not to MeshTerm itself. If no pairing dialog
|
||||
appears, or no Bluetooth devices are listed, check System Settings > Privacy & Security >
|
||||
Bluetooth. **Over SSH, macOS denies Bluetooth outright** and never asks, so run MeshTerm in
|
||||
a terminal on the Mac itself.
|
||||
|
||||
### Finding the PIN
|
||||
|
||||
The PIN is shown on the companion's screen, or in the MeshCore app's settings for it. A
|
||||
companion with no screen (a T1000-E, for instance) can tell you over USB instead:
|
||||
|
||||
```console
|
||||
$ meshterm --port /dev/ttyACM0 config get device_pin
|
||||
123456
|
||||
```
|
||||
|
||||
`meshterm config set device_pin <PIN>` changes it, and so does the **Device PIN** row on
|
||||
the menu's Device config page. A changed PIN makes every existing pairing with that
|
||||
companion out of date. See [a stale pairing](#what-each-bluetooth-message-means) below.
|
||||
|
||||
### What each Bluetooth message means
|
||||
|
||||
In the device picker, a pairing problem opens the PIN dialog with a one-line reason under
|
||||
the question. On the command line, it is the error message. Both come from the same
|
||||
diagnosis.
|
||||
|
||||
| The message starts with… | What happened | What to do |
|
||||
| --- | --- | --- |
|
||||
| **couldn't open a Bluetooth link** | The companion was never reached. | Move closer, check it is powered on, and make sure no phone or other computer is connected to it. |
|
||||
| **requires a Bluetooth pairing PIN** | The companion is PIN-protected and not paired with this computer. | Give the PIN: the picker asks for it; on the command line, add `--ble-pin <PIN>`. |
|
||||
| **Windows has … paired** / **Linux has … paired, but the device refused that pairing** | Your computer remembers a pairing that the companion no longer accepts. It was reflashed, reset, or given a new PIN, or it was paired *without* a PIN back when it did not have one. | Give the PIN, and MeshTerm removes the old pairing and pairs again. Or [forget the pairing](#forgetting-a-pairing) yourself and reconnect. |
|
||||
| **rejected the Bluetooth PIN** | The PIN was wrong. | Check the code on the device or in the MeshCore app, and try again. |
|
||||
| **refused to pair** | The companion turned the pairing down. It is usually connected to a phone, or still holds an old pairing for this computer. | Disconnect it from anything else, or restart it, and try again. |
|
||||
| **Windows couldn't pair** / **Linux couldn't pair** | The pairing failed for another reason, named in brackets. | Restart the companion, [forget the pairing](#forgetting-a-pairing), and try again. |
|
||||
| **Windows couldn't reach** / **Linux couldn't reach … to pair it** | The system could not find the companion when it came to pair. | As for a link that never opened: range, power, or another connection. |
|
||||
| **paired with …, but the device still refuses the connection** | Pairing succeeded, yet the companion still refuses. It is holding an old pairing for this computer. | Restart the companion, [forget the pairing](#forgetting-a-pairing), and try again. |
|
||||
| **was not paired. macOS asks for the pairing code in its own dialog** | macOS had not finished pairing. | Type the PIN into the macOS dialog when it appears. If none appears, check the terminal's Bluetooth permission (above). |
|
||||
| **connecting … took longer than** | The link, the pairing, or the service discovery stalled. | Move closer, restart the companion, and try again. |
|
||||
| **connected …, but it didn't answer the identity query** | The link came up and the companion went quiet. | It may be busy with another app. Restart it and try again. |
|
||||
| **connected …, but it never answered as a MeshCore companion** | Something answered, but not as a companion. | It may be running repeater or room server firmware, which has no companion interface. |
|
||||
|
||||
### Forgetting a pairing
|
||||
|
||||
MeshTerm fixes a stale pairing itself when you give it the PIN. To remove one by hand:
|
||||
|
||||
- **Windows:** Settings > Bluetooth & devices, open the companion, and choose *Remove device*.
|
||||
- **Linux:** `bluetoothctl remove AA:BB:CC:DD:EE:FF`, with your companion's address.
|
||||
- **macOS:** System Settings > Bluetooth, the companion's ⓘ button, then *Forget This Device*.
|
||||
|
||||
On Windows and Linux, MeshTerm's quit dialog also offers **Unpair & quit** while a paired
|
||||
Bluetooth companion is connected. It forgets the pairing on the way out, so the next
|
||||
connection asks for the PIN again.
|
||||
|
||||
---
|
||||
|
||||
## USB
|
||||
|
||||
| The message starts with… | What happened | What to do |
|
||||
| --- | --- | --- |
|
||||
| **no permission to open** | Your user may not open the port. On Linux, serial ports belong to a group, `dialout` on Ubuntu and Debian. | `sudo usermod -aG dialout $USER`, then log out and back in. The new group applies only to a new login. |
|
||||
| **… is in use by another program** | Something else has the port open: the MeshCore app, a firmware flasher, or a serial monitor. On Windows this is reported as *Access is denied*. | Close the other program. On Linux, ModemManager probes new USB serial devices and can hold one: `sudo systemctl stop ModemManager`, and try again. |
|
||||
| **… isn't there** | The port does not exist. The device was unplugged, or came back under another name (`/dev/ttyACM0` becoming `/dev/ttyACM1`, `COM5` becoming `COM6`). | `meshterm devices` lists what is attached now. |
|
||||
| **no response from a MeshCore companion on …** | The port opened, but nothing answered as a companion. | It may not be a MeshCore companion (repeater firmware, or another device), or it is powered off or busy. On Linux, ModemManager may be probing it: `sudo systemctl stop ModemManager`, and try again. |
|
||||
|
||||
To stop ModemManager for good rather than for one boot, `sudo systemctl disable --now
|
||||
ModemManager`, if nothing else on the machine needs it. Nothing does unless you have a
|
||||
cellular modem.
|
||||
|
||||
---
|
||||
|
||||
## TCP
|
||||
|
||||
**no response from a MeshCore companion at HOST:PORT** means nothing answered there. Check
|
||||
the host and the port (MeshTerm's default is `5000`), that the companion is powered on and
|
||||
reachable on your network, and that no other client is already connected to it. Like
|
||||
Bluetooth, a network companion serves one client at a time.
|
||||
|
||||
---
|
||||
|
||||
## A radio on the SPI bus
|
||||
|
||||
A radio MeshTerm drives itself (`--spi`) is not a companion. It has no pairing and no port,
|
||||
and its failures are about the node MeshTerm starts. Its own guides cover them:
|
||||
[the uConsole](uconsole.md) and [adding a radio on the SPI bus](configuration.md#adding-a-radio-on-the-spi-bus).
|
||||
@@ -0,0 +1,108 @@
|
||||
# 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 preferences set weekly_flood_advert on # flood an advert once a week, when quiet
|
||||
|
||||
# Passive capture window (records to history; transmits nothing)
|
||||
meshterm monitor --seconds 60
|
||||
```
|
||||
|
||||
Global options — `--profile/-p`, `--port`, `--ble`, `--ble-pin`, `--tcp`, `--spi`, `--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,72 @@
|
||||
# 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 **whose** a setting is: 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. Give a channel a **send scope** and its messages are flooded into one region, relayed only by the repeaters that carry it. | `meshterm channels list / add / join / import / share / clear / scope` |
|
||||
| **📨 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, and the region a scoped flood was sent into. 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 node is heard for the first time. 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. A **Regions** page edits the repeater's region tree: which regions it relays floods for, whether it relays unscoped floods, its home and default scope. | `meshterm repeater-admin <node> <command…>` |
|
||||
| **🔖 Regions** | Not a menu screen. A repeater's node page lists the regions it carries and asks it once, without logging in (it answers only a neighbour, or over a known route); the packet viewer, message paths and live feed name the region a flood was scoped to. | `meshterm regions <node>` |
|
||||
| **📶 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 of the three, 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` |
|
||||
+10
-8
@@ -1,7 +1,7 @@
|
||||
# MeshTerm on hardware
|
||||
|
||||
MeshTerm talks to a **node** — a MeshCore radio that speaks the companion protocol over
|
||||
serial, Bluetooth LE or TCP. Plug a companion board into USB, or pair one over Bluetooth,
|
||||
serial, Bluetooth LE, or TCP. Plug a companion board into USB, or pair one over Bluetooth,
|
||||
and none of this page applies: MeshTerm finds it, and the [README](../README.md) and the
|
||||
[command line manual](cli.md) are all you need.
|
||||
|
||||
@@ -14,8 +14,8 @@ its own step-by-step manual, and this page only tells you which one is yours.
|
||||
| --- | --- |
|
||||
| a ClockworkPi **PicoCalc**, stock or already running Calculinux on a Luckfox Lyra, and I want MeshTerm on its own screen | **[MeshTerm on the PicoCalc](picocalc.md)** — from the shopping list to a working machine |
|
||||
| …and I want a **radio** inside it | the same manual, [Phase 3](picocalc.md#phase-3--add-the-radio) — a XIAO nRF52840 + Wio-SX1262 soldered to the Lyra's UART |
|
||||
| a ClockworkPi **uConsole** with the hackergadgets AIO board — an SX1262 on the host's own SPI bus | **[MeshTerm on the uConsole](uconsole.md)** — the bridge that fronts that chip as a companion |
|
||||
| a MeshCore companion on USB, BLE or Wi-Fi | nothing here — the [README](../README.md) |
|
||||
| a ClockworkPi **uConsole** with the hackergadgets AIO board — an SX1262 on the host's own SPI bus | **[MeshTerm on the uConsole](uconsole.md)** — MeshTerm drives it directly with `--spi`, or a bridge service fronts it as a companion if you want it always on |
|
||||
| a MeshCore companion on USB, Bluetooth, or Wi-Fi | nothing here — the [README](../README.md), and [When a companion won't connect](connecting.md) if it doesn't |
|
||||
|
||||
The two targets have nothing in common but MeshTerm itself. The device-side scripts each
|
||||
manual runs live in [`scripts/`](../scripts/), one folder per handheld; the
|
||||
@@ -28,7 +28,9 @@ script before you run it; most run as root and change the machine they run on.
|
||||
[`picocalc/`](../scripts/picocalc/) for the PicoCalc — bringing up Calculinux on the Lyra,
|
||||
building its console font, and (under [`xiao-radio/`](../scripts/picocalc/xiao-radio/))
|
||||
giving it a MeshCore radio over UART — and [`uconsole/`](../scripts/uconsole/) for the
|
||||
bridge that fronts the uConsole's SPI-attached LoRa chip as a companion. The manual is the
|
||||
always-on bridge that fronts the uConsole's SPI-attached LoRa chip as a companion (MeshTerm
|
||||
can also drive that chip directly, with no script at all — the uConsole manual covers
|
||||
both). The manual is the
|
||||
walkthrough; each script's own header says what it assumes about the machine and why each
|
||||
step is the way it is. On the Lyra, only one program may hold `/dev/ttyS1` — MeshTerm
|
||||
directly, or a pump in front of it, never both.
|
||||
@@ -36,17 +38,17 @@ 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. |
|
||||
| [`xiao-radio/meshcore-uart1.patch`](../scripts/picocalc/xiao-radio/meshcore-uart1.patch) | — | The two firmware hunks: the companion on nRF52 `Serial1`, and I²C moved off the UART pads. Not upstream yet. |
|
||||
| [`xiao-radio/lyra-setup.sh`](../scripts/picocalc/xiao-radio/lyra-setup.sh) | the Lyra (root) | Boot-persistent UART1 → GP4/GP5 mux service, `dialout` membership, and a default MeshTerm serial profile for `/dev/ttyS1`. |
|
||||
| [`xiao-radio/uart1-mux.py`](../scripts/picocalc/xiao-radio/uart1-mux.py) | the Lyra (root) | The matrix-IO register poke that routes UART1 to the header pads, run once per boot by the service above. |
|
||||
| [`uconsole/meshterm-spi-bridge`](../scripts/uconsole/meshterm-spi-bridge) | the radio host (your user) | Runs a mesh node in software over an SPI-attached SX1262 (uConsole AIO, Waveshare HATs) and serves the companion protocol on `127.0.0.1:5000`. Setup menu, preflight, optional `systemd --user` service. Runs on `openhop_core` (preferred) or its predecessor `pymc_core`; see the manual for the venv it wants on a bookworm uConsole. |
|
||||
| [`uconsole/meshterm-spi-bridge`](../scripts/uconsole/meshterm-spi-bridge) | the radio host (your user) | The **always-on** option: runs a mesh node in software over an SPI-attached SX1262 (uConsole AIO, Waveshare HATs) and serves the companion protocol on `127.0.0.1:5000`, so the node stays on the mesh while MeshTerm is closed. Setup menu, preflight, optional `systemd --user` service. Runs on `openhop_core` (preferred) or its predecessor `pymc_core`; see the manual for the venv it wants on a bookworm uConsole. Most people should reach for `meshterm --spi` instead — no script, no service, MeshTerm drives the chip itself while it runs. |
|
||||
|
||||
## The regular and picocalc platforms
|
||||
|
||||
@@ -54,7 +56,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:
|
||||
|
||||
+88
-62
@@ -25,8 +25,8 @@ a radio in the meantime.
|
||||
> errors, and parts of it will go out of date as boards, images and firmware change. It is
|
||||
> up to you to check every fact in it against your own hardware and the current sources
|
||||
> before you act on it, and every decision along the way is yours. The author accepts no
|
||||
> responsibility for any damage, loss or injury that results from following this guide,
|
||||
> and nothing in it is a warranty that your parts, your tools or your hands will behave the
|
||||
> responsibility for any damage, loss, or injury that results from following this guide,
|
||||
> and nothing in it is a warranty that your parts, your tools, or your hands will behave the
|
||||
> way the author's did. If you are not comfortable with any step, stop and get help from
|
||||
> someone who is.
|
||||
|
||||
@@ -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
|
||||
@@ -223,7 +223,7 @@ there is no reason to make the screws tight now.
|
||||
|
||||
Power it on once with the **stock Pico still in place**. You should see the stock BASIC
|
||||
firmware boot on the screen, and the keyboard should respond. This confirms the screen,
|
||||
keyboard and battery are all good before you start swapping boards.
|
||||
keyboard, and battery are all good before you start swapping boards.
|
||||
|
||||
Power it back off.
|
||||
|
||||
@@ -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.
|
||||
@@ -740,7 +756,7 @@ those. Keep the iron on each pin only as long as it takes, and don't press down
|
||||
board. Attach the LoRa antenna to the Wio-SX1262's u.FL connector before you
|
||||
power anything back on — running the radio without an antenna attached can damage it.
|
||||
Wrap the XIAO + Wio-SX1262 stack in Kapton tape so no pad can touch the Lyra, the
|
||||
mainboard or a battery, then tuck it into the shell wherever it fits without straining the
|
||||
mainboard, or a battery, then tuck it into the shell wherever it fits without straining the
|
||||
wires; a strip of foam tape holds it in place against the inside of the case.
|
||||
|
||||
### Step 15 — Set up the Lyra
|
||||
@@ -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:
|
||||
|
||||
@@ -888,7 +914,7 @@ The rest is one bench's findings, and a second device may disagree:
|
||||
on one board, not read from a reference manual.
|
||||
- **That the Calculinux kernel permits those `/dev/mem` writes**, and that the mux oneshot
|
||||
really runs at every boot.
|
||||
- **The header pin numbers** 6, 7, 8 and 36 for GP4, GP5, GND and 3V3. The Pico-side
|
||||
- **The header pin numbers** 6, 7, 8, and 36 for GP4, GP5, GND, and 3V3. The Pico-side
|
||||
numbering is right; the carrier's wiring is a bench observation.
|
||||
- **The MX1.25 socket's pin order.** Taken from the wire colours of Luckfox's own cable,
|
||||
which is why [Step 3](#step-3--attach-the-wi-fi-dongle) has you check pin 1 with a meter.
|
||||
|
||||
@@ -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).
|
||||
+374
-148
@@ -2,40 +2,41 @@
|
||||
|
||||
A ClockworkPi uConsole with the hackergadgets AIO LoRa board has no companion
|
||||
microcontroller and no firmware to flash — the LoRa chip sits straight on the host's SPI
|
||||
bus. So a small bridge runs the mesh node in software on the uConsole itself and puts the
|
||||
standard MeshCore companion protocol in front of it on a local TCP port. MeshTerm then
|
||||
connects to that port like it would any network companion. By the end of this guide you'll
|
||||
have the bridge running as a background service and MeshTerm talking to it.
|
||||
bus. MeshTerm can drive it directly: it starts a mesh node as a child process the moment
|
||||
you connect, talks to it over the standard companion protocol on a loopback port, and ends
|
||||
it when you disconnect or quit. No separate service, no TCP port to remember — just
|
||||
`meshterm --spi`.
|
||||
|
||||
> ⚠ **Read this first.** This guide edits your uConsole's boot configuration, adds your
|
||||
> user to hardware groups, installs a service under your account, and then keys a LoRa
|
||||
> transmitter. It assumes you are comfortable at a Linux shell and know which frequency
|
||||
> plan and power limits apply where you live — what the radio does on air is your
|
||||
> responsibility, not the bridge's. There is a real risk of leaving the system, the radio
|
||||
> or the mesh around you in a worse state if a step goes wrong. You do this at your own
|
||||
> risk.
|
||||
If you'd rather the node stayed on the mesh while MeshTerm is closed, a small bridge can
|
||||
run it as a background service instead, fronting the same chip as a network companion.
|
||||
That's [further down](#always-on-the-bridge) — most people want the section above it.
|
||||
|
||||
> ⚠ **Read this first.** Either route edits your uConsole's boot configuration, adds your
|
||||
> user to hardware groups, and then keys a LoRa transmitter. It assumes you are comfortable
|
||||
> at a Linux shell and know which frequency plan and power limits apply where you live —
|
||||
> what the radio does on air is your responsibility, not MeshTerm's. There is a real risk
|
||||
> of leaving the system, the radio, or the mesh around you in a worse state if a step goes
|
||||
> wrong. You do this at your own risk.
|
||||
>
|
||||
> **Read the whole manual through once before you start.** Which route you take in
|
||||
> Step 2 depends on your Debian release, and whether the GUI is running changes what the
|
||||
> preflight and the service will do — both are easier to get right knowing the later steps.
|
||||
> **Read the whole manual through once before you start.** Which install route you take
|
||||
> depends on how you're running MeshTerm, and that's easier to get right knowing the later
|
||||
> steps.
|
||||
>
|
||||
> This is a guide, not a prescription. Despite the author's best efforts it may contain
|
||||
> errors, and parts of it will go out of date as packages, runtimes and boards change. It
|
||||
> errors, and parts of it will go out of date as packages, runtimes, and boards change. It
|
||||
> is up to you to check every fact in it against your own hardware and the current sources
|
||||
> before you act on it, and every decision along the way is yours. The author accepts no
|
||||
> responsibility for any damage, loss or injury that results from following this guide.
|
||||
> responsibility for any damage, loss, or injury that results from following this guide.
|
||||
|
||||
- [What you need](#what-you-need)
|
||||
- [Step 1 — Prepare the system](#step-1--prepare-the-system)
|
||||
- [Step 2 — Install the radio runtime](#step-2--install-the-radio-runtime)
|
||||
- [Step 3 — Get the bridge](#step-3--get-the-bridge)
|
||||
- [Step 4 — Run the preflight](#step-4--run-the-preflight)
|
||||
- [Step 5 — Run it once in the foreground](#step-5--run-it-once-in-the-foreground)
|
||||
- [Step 6 — Install it as a service](#step-6--install-it-as-a-service)
|
||||
- [Step 7 — Install MeshTerm and connect](#step-7--install-meshterm-and-connect)
|
||||
- [Updating and uninstalling](#updating-and-uninstalling)
|
||||
- [Hardware knobs](#hardware-knobs)
|
||||
- [Step 2 — Install the radio library](#step-2--install-the-radio-library)
|
||||
- [Step 3 — Install MeshTerm](#step-3--install-meshterm)
|
||||
- [Step 4 — Connect](#step-4--connect)
|
||||
- [Moving over from the bridge (0.9.0 and earlier)](#moving-over-from-the-bridge-090-and-earlier)
|
||||
- [Board wiring](#board-wiring)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Always on: the bridge](#always-on-the-bridge)
|
||||
- [What this has been tested on](#what-this-has-been-tested-on)
|
||||
|
||||
---
|
||||
@@ -47,56 +48,31 @@ have the bridge running as a background service and MeshTerm talking to it.
|
||||
| Part | Notes |
|
||||
| --- | --- |
|
||||
| ClockworkPi **uConsole** (CM4 module) | Any uConsole with a Compute Module 4. |
|
||||
| hackergadgets **AIO** expansion board | The board this guide is written for and the one we recommend: an SX1262 on SPI bus 1, plus GPS, an RTL-SDR and a USB hub you don't need here. The bridge's defaults are its wiring. |
|
||||
| hackergadgets **AIO** expansion board | The board this guide is written for and the one we recommend: an SX1262 on SPI bus 1, plus GPS, an RTL-SDR and a USB hub you don't need here. MeshTerm's SPI defaults are its wiring (AIO v1; the v2 needs one extra setting — see [Board wiring](#board-wiring)). |
|
||||
| An antenna for your region's LoRa band | Required. |
|
||||
|
||||
Installing the AIO board into the uConsole is out of scope here — follow
|
||||
[hackergadgets' own setup guide](https://hackergadgets.com/pages/hackergadgets-uconsole-rtl-sdr-lora-gps-rtc-usb-hub-all-in-one-extension-board-setup-guide)
|
||||
for that part.
|
||||
|
||||
The bridge assumes the AIO's wiring by default:
|
||||
|
||||
| Setting | Default |
|
||||
| --- | --- |
|
||||
| SPI bus | 1 |
|
||||
| Reset pin | 25 |
|
||||
| Busy pin | 24 |
|
||||
| IRQ pin | 26 |
|
||||
| TX-enable pin | none |
|
||||
| DIO2 (RF switch) | on |
|
||||
| DIO3 (TCXO) | on |
|
||||
| Frequency | 910.525 MHz |
|
||||
| TX power | 22 dBm |
|
||||
| Spreading factor | 7 |
|
||||
| Bandwidth | 62.5 kHz |
|
||||
| Coding rate | 4/5 |
|
||||
|
||||
Every one of these is overridable with a `MESHCORE_*` environment variable if your board or
|
||||
region differs — see [Hardware knobs](#hardware-knobs).
|
||||
|
||||
**Software:**
|
||||
|
||||
- Raspberry Pi OS / Debian **bookworm** or **trixie** on the uConsole, 64-bit.
|
||||
- Python 3 (the system one is fine — the bridge finds whichever interpreter has the radio
|
||||
library, and re-execs itself under it).
|
||||
- Raspberry Pi OS / Debian **bookworm** or **trixie** on the uConsole, 64-bit. This is
|
||||
Linux-only — driving a radio on the host's own SPI bus is not something Windows or macOS
|
||||
can do.
|
||||
- A `/dev/spidev*` node. This needs `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt`.
|
||||
- `/dev/gpiochip0`, and membership of the `spi` and `gpio` groups so you can read and write
|
||||
both without root.
|
||||
- Optionally, the [`meshcore-uconsole`](https://github.com/cwill747/meshcore-uconsole)
|
||||
package (it brings the `meshcore-console` GUI and a radio runtime). Whether you can use it depends on your Debian release — see
|
||||
[Step 2](#step-2--install-the-radio-runtime).
|
||||
- The radio library, **`openhop-core[hardware]` 1.1.3 or newer** — [Step
|
||||
2](#step-2--install-the-radio-library) gets you there. It doesn't have to be in
|
||||
MeshTerm's own Python; MeshTerm looks in several places for it.
|
||||
- MeshTerm itself, on the uConsole — [Step 3](#step-3--install-meshterm).
|
||||
|
||||
The radio library that actually drives the chip was renamed in 2026: `pymc_core` became
|
||||
`openhop_core`. PyPI's `pymc-core` stopped at 1.0.12; `openhop-core` carries on from 1.1.x.
|
||||
The bridge supports both and prefers the newer one when it finds both installed.
|
||||
|
||||
**MeshTerm itself** — either:
|
||||
|
||||
- the Linux ARM64 one-file release from
|
||||
[the latest release page](https://github.com/jpmartineau/MeshTerm/releases/latest), or
|
||||
- an install from the repository with `pipx` (Python 3.10 or newer). Plain `pip install`
|
||||
into the system Python is refused on bookworm and trixie; `pipx` (`sudo apt install pipx`)
|
||||
gives MeshTerm a virtual environment of its own.
|
||||
A node's own radio settings — frequency, bandwidth, spreading factor, coding rate, TX
|
||||
power — are not part of this setup. They're the node's, saved by the node, and you change
|
||||
them the same way you would on any companion: MeshTerm's Device config page. A brand-new
|
||||
node starts on MeshCore's US/Canada preset (910.525 MHz, 62.5 kHz, SF 7, CR 4/5, 22 dBm)
|
||||
until you change it.
|
||||
|
||||
---
|
||||
|
||||
@@ -132,30 +108,263 @@ and `spi` and `gpio` in your `groups` output.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Install the radio runtime
|
||||
## Step 2 — Install the radio library
|
||||
|
||||
MeshTerm needs `openhop-core[hardware]` 1.1.3 or newer importable by *some* Python on the
|
||||
machine — not necessarily MeshTerm's own. It looks, in order: a `python` named in the
|
||||
profile's `spi` table; MeshTerm's own interpreter; the bridge's venv at
|
||||
`~/.local/share/meshterm-spi-bridge/venv/bin/python`; `/opt/venvs/meshcore-uconsole/bin/python`;
|
||||
then `python3` on your `PATH`. Whichever route you take below, that's the interpreter it
|
||||
lands in.
|
||||
|
||||
- **Installed MeshTerm with `pipx`?** Inject it into MeshTerm's own environment:
|
||||
|
||||
```bash
|
||||
pipx inject mesh-term 'openhop-core[hardware]'
|
||||
```
|
||||
|
||||
- **Installed with `pip`?** Pull in the `spi` extra when you install MeshTerm (see
|
||||
[Step 3](#step-3--install-meshterm)):
|
||||
|
||||
```bash
|
||||
pip install 'mesh-term[spi]'
|
||||
```
|
||||
|
||||
- **Running the one-file Linux ARM64 build?** It's a single frozen binary and can't carry
|
||||
the library, so give it a venv of its own — the same one the bridge guide uses, so it's
|
||||
shared if you ever run both:
|
||||
|
||||
```bash
|
||||
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
|
||||
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
|
||||
```
|
||||
|
||||
MeshTerm finds it there automatically; no configuration needed.
|
||||
|
||||
The older `pymc_core` runtime (the library's name before 2026) is not supported for a
|
||||
direct connection — only the standalone bridge still carries the compatibility shims it
|
||||
needs.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Install MeshTerm
|
||||
|
||||
Same as any other machine — the simplest way is the Linux ARM64 one-file release:
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```bash
|
||||
sudo apt install pipx
|
||||
pipx install git+https://github.com/jpmartineau/MeshTerm
|
||||
pipx ensurepath
|
||||
pipx inject mesh-term 'openhop-core[hardware]'
|
||||
```
|
||||
|
||||
Or with `pip`, pulling in the radio library as its own extra in one line:
|
||||
|
||||
```bash
|
||||
pip install 'mesh-term[spi]'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Connect
|
||||
|
||||
```bash
|
||||
meshterm --spi
|
||||
```
|
||||
|
||||
`--spi` finds the AIO's radio at `/dev/spidev1.0` and starts a node on it. The startup
|
||||
device picker also lists it — a row reading **SPI radio (/dev/spidev1.0)** with a 📍 icon —
|
||||
whenever that device node exists, so `meshterm` with no flags gets you there too.
|
||||
|
||||
You're connected when **Device info** or the dashboard shows a node name instead of a
|
||||
connection error. MeshTerm reports the device model as **MeshTerm SPI node**, and the
|
||||
header's connection label reads **SPI**.
|
||||
|
||||
**What happens underneath.** MeshTerm starts the node when it connects and ends it when
|
||||
you disconnect or quit — including a crash: the node exits with MeshTerm, and the kernel
|
||||
frees the GPIO lines and SPI handle the moment its process does. The trade-off is the one
|
||||
this implies: **the node is off the mesh while MeshTerm is closed.** If you want it on all
|
||||
the time, that's what [the bridge](#always-on-the-bridge) is for.
|
||||
|
||||
**Where its state lives.** Each radio keeps its identity, preferences, channels, and
|
||||
contacts under `~/.meshterm/radio/spidev1.0/` — `identity.key`, `prefs.json`,
|
||||
`channels.json`, `contacts.json` — plus a running log, `node.log` (the previous run's is
|
||||
kept as `node.log.1`). All of it survives a restart.
|
||||
|
||||
**A profile, if you want a name for it:**
|
||||
|
||||
```toml
|
||||
[profiles.aio]
|
||||
transport = "spi"
|
||||
```
|
||||
|
||||
```bash
|
||||
meshterm -p aio
|
||||
```
|
||||
|
||||
The AIO's wiring is the default, so this profile needs nothing beyond `transport = "spi"`.
|
||||
[Board wiring](#board-wiring) is for when yours differs.
|
||||
|
||||
---
|
||||
|
||||
## Moving over from the bridge (0.9.0 and earlier)
|
||||
|
||||
> **Only if you set up the bridge with MeshTerm 0.9.0 or earlier.** Before 0.10.0 the bridge
|
||||
> was the only way to use the uConsole's radio, so an older setup is almost certainly
|
||||
> running one. On a fresh install of 0.10.0 or newer there is nothing to move over — skip
|
||||
> ahead to [Board wiring](#board-wiring).
|
||||
|
||||
Upgraded from 0.9.0 or earlier with the bridge still installed? The first time you connect
|
||||
with `--spi` (or a profile, or the picker row), MeshTerm carries the bridge's node over —
|
||||
**copying**, never moving, so the bridge still works afterwards if you go back to it:
|
||||
|
||||
- the identity key — from the `meshcore-console` GUI's copy first
|
||||
(`~/.local/share/meshcore-uconsole/identity.key`), the way the bridge itself looks for
|
||||
it, else the bridge's own (`~/.local/share/meshterm-spi-bridge/identity.key`);
|
||||
- the bridge's `contacts.json`;
|
||||
- the name `uConsole`;
|
||||
- the bridge's radio settings — frequency, bandwidth, spreading factor, coding rate, TX
|
||||
power — read out of the bridge service's `MESHCORE_*` environment.
|
||||
|
||||
This only happens once, while `~/.meshterm/radio/spidev1.0/` has no `identity.key` yet — so
|
||||
your node keeps its public key, and with it its place in everyone else's contact list.
|
||||
|
||||
**Wiring is not carried over.** If your board needed `MESHCORE_EN_PINS` or another wiring
|
||||
variable for the bridge, put the equivalent in the profile's `spi` table (see
|
||||
[Board wiring](#board-wiring)) — the direct connection reads wiring from there, never from
|
||||
the bridge's environment.
|
||||
|
||||
**Stop the bridge before switching over**, since only one program may hold the radio at a
|
||||
time:
|
||||
|
||||
```bash
|
||||
systemctl --user stop meshterm-spi-bridge
|
||||
systemctl --user disable meshterm-spi-bridge # keep it stopped across reboots
|
||||
```
|
||||
|
||||
If MeshTerm sees the bridge service running, it refuses to start the node and tells you the
|
||||
same two commands.
|
||||
|
||||
---
|
||||
|
||||
## Board wiring
|
||||
|
||||
The defaults match the hackergadgets uConsole AIO **v1**, which needs no `spi` table at
|
||||
all. State only what differs, under `[profiles.<name>.spi]`. For a board that isn't an
|
||||
AIO — a LoRa HAT on a Raspberry Pi, say — [Adding a radio on the SPI
|
||||
bus](configuration.md#adding-a-radio-on-the-spi-bus) walks through a whole profile, including
|
||||
how to read the pins off the board's `meshtasticd` configuration:
|
||||
|
||||
```toml
|
||||
[profiles.aio2]
|
||||
transport = "spi"
|
||||
|
||||
[profiles.aio2.spi]
|
||||
en_pins = [27] # the AIO v2 needs this
|
||||
```
|
||||
|
||||
| Setting | Default | What it is |
|
||||
| --- | --- | --- |
|
||||
| `bus_id`, `cs_id` | `1`, `0` | Which SPI bus and chip-select the radio is on (`/dev/spidev<bus_id>.<cs_id>`). |
|
||||
| `cs_pin` | `-1` | A GPIO driven as chip select by hand, instead of the bus's own. |
|
||||
| `gpio_chip` | `0` | Which `/dev/gpiochip<n>` the pins below are on. |
|
||||
| `use_gpiod_backend` | `false` | Drive the pins through `gpiod` instead of `python-periphery`. |
|
||||
| `reset_pin`, `busy_pin`, `irq_pin` | `25`, `24`, `26` | The chip's reset, busy and interrupt (DIO1) lines. |
|
||||
| `txen_pin`, `rxen_pin` | `-1`, `-1` | An external RF switch's transmit/receive-enable lines, if the board has one. |
|
||||
| `en_pins` | `[]` | Power-enable lines to raise before the chip is touched. The **AIO v2 needs `[27]`.** |
|
||||
| `use_dio2_rf`, `use_dio3_tcxo` | `true`, `true` | Whether DIO2 drives the RF switch and DIO3 a TCXO (both on for the AIO). |
|
||||
| `is_waveshare` | `false` | The Waveshare HAT's wiring quirks, which the radio library knows about. |
|
||||
| `python` | `""` (empty) | An interpreter to run the node under, when the one MeshTerm would find on its own isn't the one you want. Empty lets MeshTerm look. |
|
||||
|
||||
An unknown key, or one of the wrong type, stops MeshTerm at startup with the profile
|
||||
named — a misspelt pin is never silently ignored.
|
||||
|
||||
Radio settings — frequency, bandwidth, spreading factor, coding rate, TX power — are
|
||||
**not** in this table. They're the node's own, on Device config, as noted in
|
||||
[What you need](#what-you-need).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
| --- | --- |
|
||||
| The radio library isn't found | Follow [Step 2](#step-2--install-the-radio-library) for however you installed MeshTerm, or set `python` in the profile's `spi` table to an interpreter that has `openhop-core[hardware]`. |
|
||||
| No `/dev/spidev1.0` | Add `dtoverlay=spi1-1cs` to `/boot/firmware/config.txt` and reboot. |
|
||||
| Permission denied opening the SPI or GPIO device | `sudo usermod -aG spi,gpio $USER`, then log out and back in. |
|
||||
| "the meshterm-spi-bridge service has the radio" | Stop it: `systemctl --user stop meshterm-spi-bridge` (and `disable` it to keep it stopped). |
|
||||
| "meshcore-console has the radio open" | Close the GUI and connect again. |
|
||||
| Some other program has the radio | `sudo lsof /dev/gpiochip0` shows which; only one program may hold the radio at a time. |
|
||||
| The node starts but never answers | Check its log: `~/.meshterm/radio/spidev1.0/node.log`. |
|
||||
| An AIO v2 radio never comes up | It needs `en_pins = [27]` in the profile's `spi` table — see [Board wiring](#board-wiring). |
|
||||
|
||||
---
|
||||
|
||||
## Always on: the bridge
|
||||
|
||||
Most people should use the direct connection above. Use the bridge instead if you want the
|
||||
node reachable on the mesh **while MeshTerm is closed** — it runs as a `systemd --user`
|
||||
service, on its own, independent of whether MeshTerm is even installed. The trade-off runs
|
||||
the other way from the direct connection: once the bridge is running, it holds the radio's
|
||||
pins for good, so no other LoRa program — including a direct `meshterm --spi` — can use
|
||||
them until you stop it.
|
||||
|
||||
The bridge puts the standard MeshCore companion protocol in front of the same SPI-attached
|
||||
chip, on a local TCP port. MeshTerm then connects to that port like it would any network
|
||||
companion.
|
||||
|
||||
- [Bridge step 1 — Install the radio runtime](#bridge-step-1--install-the-radio-runtime)
|
||||
- [Bridge step 2 — Get the bridge](#bridge-step-2--get-the-bridge)
|
||||
- [Bridge step 3 — Run the preflight](#bridge-step-3--run-the-preflight)
|
||||
- [Bridge step 4 — Run it once in the foreground](#bridge-step-4--run-it-once-in-the-foreground)
|
||||
- [Bridge step 5 — Install it as a service](#bridge-step-5--install-it-as-a-service)
|
||||
- [Bridge step 6 — Connect MeshTerm to it](#bridge-step-6--connect-meshterm-to-it)
|
||||
- [Updating and uninstalling the bridge](#updating-and-uninstalling-the-bridge)
|
||||
- [Bridge hardware knobs](#bridge-hardware-knobs)
|
||||
- [Bridge troubleshooting](#bridge-troubleshooting)
|
||||
|
||||
The bridge's own wiring and radio defaults are the same AIO v1 numbers as the direct
|
||||
connection's — see the table in [Board wiring](#board-wiring) — just set through
|
||||
`MESHCORE_*` environment variables instead of a profile's `spi` table.
|
||||
|
||||
The radio library that actually drives the chip was renamed in 2026: `pymc_core` became
|
||||
`openhop_core`. PyPI's `pymc-core` stopped at 1.0.12; `openhop-core` carries on from 1.1.x.
|
||||
The bridge supports both and prefers the newer one when it finds both installed.
|
||||
|
||||
### Bridge step 1 — Install the radio runtime
|
||||
|
||||
Which route you take depends on your Debian release:
|
||||
|
||||
- **trixie**, with `meshcore-uconsole` installed from the vendor's package: you already
|
||||
have a working runtime (Python 3.13-based). Skip to [Step 3](#step-3--get-the-bridge).
|
||||
- **bookworm**: the `meshcore-uconsole` 1.12.0+ package is built for Python 3.13 and will
|
||||
**not run** — its virtualenv points at an interpreter bookworm doesn't have. Don't take
|
||||
the upgrade if apt offers it. Instead give the bridge a runtime of its own:
|
||||
- **trixie**, with the `meshcore-uconsole` package (by cwill747) installed: you already
|
||||
have a working runtime (Python 3.13-based). Skip to
|
||||
[step 2](#bridge-step-2--get-the-bridge).
|
||||
- **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.
|
||||
|
||||
You'll confirm which runtime the bridge actually found in [Step 4](#step-4--run-the-preflight) —
|
||||
its first check line names the interpreter and the runtime.
|
||||
You'll confirm which runtime the bridge actually found in
|
||||
[step 3](#bridge-step-3--run-the-preflight) — its first check line names the interpreter
|
||||
and the runtime.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Get the bridge
|
||||
### Bridge step 2 — Get the bridge
|
||||
|
||||
`scripts/uconsole/meshterm-spi-bridge` is a single, self-contained Python file — no install step of
|
||||
its own. Either clone the MeshTerm repo:
|
||||
@@ -178,9 +387,7 @@ Make it executable:
|
||||
chmod +x meshterm-spi-bridge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Run the preflight
|
||||
### Bridge step 3 — Run the preflight
|
||||
|
||||
```bash
|
||||
./meshterm-spi-bridge --check
|
||||
@@ -191,23 +398,24 @@ what to do if it fails:
|
||||
|
||||
| Check | Meaning if it fails | Fix |
|
||||
| --- | --- | --- |
|
||||
| `node runtime` | Neither `openhop_core` nor `pymc_core` is importable by any interpreter the bridge tried. | Build the venv from [Step 2](#step-2--install-the-radio-runtime), or set `MESHTERM_PYMC_PYTHON` to a python that has one. |
|
||||
| `node runtime` | Neither `openhop_core` nor `pymc_core` is importable by any interpreter the bridge tried. | Build the venv from [step 1](#bridge-step-1--install-the-radio-runtime), or set `MESHTERM_PYMC_PYTHON` to a python that has one. |
|
||||
| `SPI device` | No `/dev/spidev*` node exists, or it exists but you can't read/write it. | No node at all: add `dtoverlay=spi1-1cs` to `/boot/firmware/config.txt` and reboot. Node present but no permission: `sudo usermod -aG spi $USER`, then log out and back in. |
|
||||
| `GPIO chip` | `/dev/gpiochip0` is missing or not read/write for you. | `sudo usermod -aG gpio $USER`, then log out and back in. |
|
||||
| `bridge service` / `radio in use` / `port 5000 busy` / `radio is free` | Tells you whether something already holds the radio: the bridge's own boot service, a running `meshcore-console`, or something else already listening on the TCP port. | Only one program may use the radio at a time — see [Step 7](#step-7--install-meshterm-and-connect). If `meshcore-console` is running, close it before running the bridge. |
|
||||
| `bridge service` / `radio in use` / `port 5000 busy` / `radio is free` | Tells you whether something already holds the radio: the bridge's own boot service, a running `meshcore-console`, or something else already listening on the TCP port. | Only one program may use the radio at a time — see [step 6](#bridge-step-6--connect-meshterm-to-it). If `meshcore-console` is running, close it before running the bridge. |
|
||||
|
||||
If the first three checks all pass, you're ready to run it.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Run it once in the foreground
|
||||
### Bridge step 4 — Run it once in the foreground
|
||||
|
||||
```bash
|
||||
./meshterm-spi-bridge --run
|
||||
```
|
||||
|
||||
This runs the preflight again, prints it, then starts the bridge in this terminal. A
|
||||
healthy startup logs something like:
|
||||
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 5 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 +429,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:
|
||||
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
|
||||
@@ -229,9 +438,7 @@ meshterm --tcp 127.0.0.1:5000
|
||||
|
||||
Back in the bridge's terminal, press **Ctrl+C** to stop it.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Install it as a service
|
||||
### Bridge step 5 — Install it as a service
|
||||
|
||||
Run the bridge with no flags to get the interactive menu:
|
||||
|
||||
@@ -254,7 +461,7 @@ sudo loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
It also offers to add a `[profiles.bridge]` block to your MeshTerm `config.toml` — say yes,
|
||||
it's the profile you'll use in [Step 7](#step-7--install-meshterm-and-connect).
|
||||
it's the profile you'll use next.
|
||||
|
||||
Check on it any time:
|
||||
|
||||
@@ -265,26 +472,14 @@ journalctl --user -u meshterm-spi-bridge.service -f
|
||||
|
||||
Menu option **4) Show status** prints the same information from inside the script.
|
||||
|
||||
---
|
||||
### Bridge step 6 — Connect MeshTerm to it
|
||||
|
||||
## Step 7 — Install MeshTerm and connect
|
||||
Install MeshTerm on the uConsole the same way as [Step 3](#step-3--install-meshterm)
|
||||
above — the bridge doesn't need the `spi` extra or `openhop-core[hardware]` in MeshTerm's
|
||||
own Python, since the bridge is the one running the node.
|
||||
|
||||
Install MeshTerm on the uConsole. The simplest way is the Linux ARM64 one-file release:
|
||||
|
||||
```bash
|
||||
chmod +x meshterm-*-linux-arm64
|
||||
sudo mv meshterm-*-linux-arm64 /usr/local/bin/meshterm
|
||||
```
|
||||
|
||||
Or install it from the repository with `pipx`:
|
||||
|
||||
```bash
|
||||
sudo apt install pipx
|
||||
pipx install git+https://github.com/jpmartineau/MeshTerm
|
||||
pipx ensurepath
|
||||
```
|
||||
|
||||
If you accepted the profile in Step 6, your `config.toml` now has:
|
||||
If you accepted the profile in step 4 or 5, `~/.meshterm/config.toml` now has this block
|
||||
(the bridge always writes to that path, even if you set `MESHTERM_HOME`):
|
||||
|
||||
```toml
|
||||
[profiles.bridge]
|
||||
@@ -305,71 +500,96 @@ 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.
|
||||
`pyMC-spi-bridge` — that's how you tell a bridged node from a real companion or a direct
|
||||
SPI connection.
|
||||
|
||||
**Identity.** If the `meshcore-console` GUI's package is present, the bridge loads *that*
|
||||
package's identity key, so MeshTerm and the GUI are the same node — same contacts, same
|
||||
public key. On a library-only install, the bridge mints and keeps its own identity key
|
||||
under `~/.local/share/meshterm-spi-bridge/identity.key`.
|
||||
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`, its channels in
|
||||
`~/.local/share/meshterm-spi-bridge/channels.json` (both survive a restart), 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
|
||||
`meshcore-console` GUI at the same time — whichever started second will fail to open the
|
||||
radio.
|
||||
radio. The same goes for a direct `meshterm --spi` connection while the bridge is running.
|
||||
|
||||
---
|
||||
|
||||
## Updating and uninstalling
|
||||
### Updating and uninstalling the bridge
|
||||
|
||||
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 [Bridge hardware knobs](#bridge-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
|
||||
`meshcore-console`, the radio library, your node identity, or your saved contacts — those
|
||||
stay put. If you'd added `[profiles.bridge]` to your MeshTerm config, it tells you to remove
|
||||
that block by hand if you no longer want it.
|
||||
`meshcore-console`, the radio library, your node identity, or your saved contacts and
|
||||
channels — those stay put. If you'd added `[profiles.bridge]` to your MeshTerm config, it
|
||||
tells you to remove that block by hand if you no longer want it.
|
||||
|
||||
---
|
||||
### Bridge hardware knobs
|
||||
|
||||
## Hardware knobs
|
||||
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:
|
||||
|
||||
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.
|
||||
```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_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 |
|
||||
| `MESHCORE_GPIO_CHIP`, `MESHCORE_USE_GPIOD_BACKEND`, `MESHCORE_PREAMBLE_LENGTH` | which gpiochip, whether to drive it through `gpiod`, and the LoRa preamble (leave it unset: the bridge follows MeshCore, 32 symbols up to SF8 and 16 above, and a shorter one leaves the radio deaf to most of the mesh) |
|
||||
| `MESHTERM_PYMC_PYTHON` | force a specific interpreter instead of letting the bridge discover one |
|
||||
|
||||
The bridge passes a knob only when the runtime's radio constructor accepts it, so the same
|
||||
environment works under `pymc_core` 1.0.x, which lacks the newer ones. Where the
|
||||
`meshcore-console` package is installed, the bridge builds the radio through *its* hardware
|
||||
configuration, which reads the same names plus a few more of its own — see that package's
|
||||
documentation. On a library-only install the table above is the whole of it, so an AIO v2
|
||||
without that package present may not power its radio.
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
### Bridge troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| `node runtime` fails in the preflight | Did you build the venv in [Step 2](#step-2--install-the-radio-runtime)? Is `MESHTERM_PYMC_PYTHON` pointing at the right interpreter, if set? |
|
||||
| `node runtime` fails in the preflight | Did you build the venv in [step 1](#bridge-step-1--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. |
|
||||
|
||||
@@ -377,14 +597,20 @@ without that package present may not power its radio.
|
||||
|
||||
## What this has been tested on
|
||||
|
||||
This has been verified on a bookworm uConsole with `openhop-core` 1.1.3: the radio comes up,
|
||||
contacts are restored across a restart, and `info` and `contacts` answer correctly over the
|
||||
TCP connection. The older `pymc-core` 1.0.x path, with all three of the bridge's
|
||||
compatibility shims active, has been exercised in full, including traces and the live feed.
|
||||
Every interface the bridge calls in both runtimes is checked by MeshTerm's test suite.
|
||||
**The direct connection** has been tested against the real radio library
|
||||
(`openhop-core`) with a simulated radio; it has **not yet been verified on uConsole
|
||||
hardware**.
|
||||
|
||||
**The bridge** has been verified on a bookworm uConsole with `openhop-core` 1.1.3: the
|
||||
radio comes up, contacts are restored across a restart, and `info` and `contacts` answer
|
||||
correctly over the TCP connection. The older `pymc-core` 1.0.x path, with all three of the
|
||||
bridge's compatibility shims active, has been exercised in full, including traces and the
|
||||
live feed. MeshTerm's test suite does not cover the bridge.
|
||||
|
||||
What has not been confirmed:
|
||||
|
||||
- **The direct connection on real hardware** — everything above has only run against a
|
||||
simulated radio under the real library.
|
||||
- **The bridge under `openhop_core` beyond `info` and `contacts`.** Startup, identity,
|
||||
contact restore and those two reads were exercised; a trace, a message and the live feed
|
||||
under the new runtime were not, though the library's own trace push is what the bridge
|
||||
|
||||
@@ -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.6"
|
||||
__version__ = "0.10.2"
|
||||
__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 can run the mesh node in software itself, straight over the board's SPI bus, for as long as MeshTerm is running — no separate service needed. If you'd rather the node stayed on the mesh while MeshTerm is closed, an optional bridge service can run it all the time instead.
|
||||
|
||||
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
|
||||
@@ -59,7 +59,7 @@ itself, isn't affiliated with MeshTerm.
|
||||
|
||||
Built on **MeshCore** *https://meshcore.io/* — its firmware and companion
|
||||
library — with Rich, prompt_toolkit, Typer, bleak, pyserial, pycryptodome,
|
||||
markdown-it-py, tomli/tomli-w and Segno, each under its own terms.
|
||||
markdown-it-py, tomli/tomli-w, and Segno, each under its own terms.
|
||||
|
||||
Map data **© OpenStreetMap contributors**, served as vector tiles by
|
||||
**OpenFreeMap** *https://openfreemap.org/* on the **© OpenMapTiles** schema
|
||||
|
||||
@@ -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://patreon.com/MeshTerm*
|
||||
|
||||
```qr
|
||||
https://www.patreon.com/c/MeshTerm
|
||||
https://patreon.com/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*
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+63
-17
@@ -22,12 +22,17 @@ 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
|
||||
from .core.instancelock import InstanceBusy, hold_instance_lock
|
||||
from .core.preferences import PREFERENCES_FILENAME, PreferenceError, Preferences
|
||||
from .core.preferences import (
|
||||
PREFERENCES_FILENAME,
|
||||
PreferenceError,
|
||||
Preferences,
|
||||
report_dropped,
|
||||
)
|
||||
from .core.selection import DeviceSelectionError
|
||||
from .persistence.logging import configure_logging, get_logger, level_from_name, log_path
|
||||
from .persistence.repository import Repository
|
||||
@@ -121,9 +126,9 @@ 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. "
|
||||
"For Windows, macOS and Linux."
|
||||
"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,
|
||||
)
|
||||
@@ -174,8 +179,15 @@ def main_callback(
|
||||
"--tcp",
|
||||
help="Network address host[:port] of a TCP companion (selects the TCP transport)",
|
||||
),
|
||||
spi: bool = typer.Option(
|
||||
False,
|
||||
"--spi",
|
||||
help="Run a node on the LoRa radio on this machine's SPI bus (selects the SPI 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"
|
||||
@@ -197,8 +209,10 @@ def main_callback(
|
||||
ble: Explicit Bluetooth address, selecting the BLE transport.
|
||||
ble_pin: Optional BLE pairing PIN for the Bluetooth companion.
|
||||
tcp: Explicit network address ``host[:port]``, selecting the TCP transport.
|
||||
spi: Select the radio on this machine's SPI bus, whose node MeshTerm runs itself.
|
||||
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).
|
||||
@@ -221,17 +235,35 @@ def main_callback(
|
||||
active = without_emoji(active)
|
||||
set_platform(active)
|
||||
|
||||
if mock and (port or ble or tcp):
|
||||
named_radios = [
|
||||
flag
|
||||
for flag, given in (("--port", port), ("--ble", ble), ("--tcp", tcp), ("--spi", spi))
|
||||
if given
|
||||
]
|
||||
if spi and len(named_radios) > 1:
|
||||
# The SPI radio is on this machine and every other flag names a radio that isn't.
|
||||
raise typer.BadParameter(f"--spi and {named_radios[0]} name different devices; pass one")
|
||||
if mock and named_radios:
|
||||
# One flag says "pretend", the other names one exact radio, and `--mock` used to
|
||||
# win silently — so a scheduled `--port COM7 --mock info` reported on a simulator
|
||||
# while reading as though it had reached the radio. Two contradictory claims about
|
||||
# which device to talk to is a bad command line, not a precedence question.
|
||||
named = "--port" if port else ("--ble" if ble else "--tcp")
|
||||
raise typer.BadParameter(f"--mock and {named} name different devices; pass one")
|
||||
raise typer.BadParameter(f"--mock and {named_radios[0]} name different devices; pass one")
|
||||
|
||||
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,
|
||||
@@ -261,6 +293,9 @@ def main_callback(
|
||||
file_level=level_from_name(prefs.log_level),
|
||||
quiet=quiet or json_output,
|
||||
)
|
||||
# What the file held that no preference now takes — reported only now, since the file
|
||||
# was read before there was a log to say so in.
|
||||
report_dropped(prefs, get_logger())
|
||||
|
||||
if profile is not None and settings.resolve_profile(profile) is None:
|
||||
# Falling through to ordinary discovery would pick whichever radio is attached, so
|
||||
@@ -282,13 +317,14 @@ def main_callback(
|
||||
port_override=port,
|
||||
ble_override=ble,
|
||||
tcp_override=tcp,
|
||||
spi_override=spi,
|
||||
ble_pin=ble_pin,
|
||||
output=OutputFormat.JSON if json_output else OutputFormat.PLAIN,
|
||||
# Whether a tool may stop and ask, which is a different question from what its
|
||||
# output looks like. A pipe on stdin means nobody is there to answer.
|
||||
interactive=_stdin_is_a_person(),
|
||||
explicit_selection=(
|
||||
profile is not None or port is not None or ble is not None or tcp is not None
|
||||
profile is not None or port is not None or ble is not None or tcp is not None or spi
|
||||
),
|
||||
)
|
||||
_state = app_ctx
|
||||
@@ -557,9 +593,9 @@ def _report_no_windows_console(console: Console) -> None:
|
||||
console.print()
|
||||
console.print("That usually means one of two things:")
|
||||
console.print()
|
||||
console.print(" • You are in Git Bash, MSYS or Cygwin. Those are not Windows consoles.")
|
||||
console.print(" Open [accent]Windows Terminal[/accent] or [accent]PowerShell[/accent]")
|
||||
console.print(" and run it from there.")
|
||||
console.print(" • You are in Git Bash, MSYS, or Cygwin, which are not Windows")
|
||||
console.print(" consoles. Open [accent]Windows Terminal[/accent] or")
|
||||
console.print(" [accent]PowerShell[/accent] and run it from there.")
|
||||
console.print(" • The output is piped or redirected somewhere. The full-screen menu")
|
||||
console.print(" needs the terminal itself.")
|
||||
console.print()
|
||||
@@ -567,7 +603,10 @@ def _report_no_windows_console(console: Console) -> None:
|
||||
console.print("try [accent]meshterm --help[/accent].")
|
||||
|
||||
|
||||
@app.command(name="platform")
|
||||
@app.command(
|
||||
name="platform",
|
||||
help="Show which platform MeshTerm runs as, and why",
|
||||
)
|
||||
def platform_command() -> None:
|
||||
"""Print the resolved platform and every input the resolution looked at.
|
||||
|
||||
@@ -614,7 +653,10 @@ def platform_command() -> None:
|
||||
)
|
||||
|
||||
|
||||
@app.command(name="specimen")
|
||||
@app.command(
|
||||
name="specimen",
|
||||
help="Print every mark, icon, and colour MeshTerm draws",
|
||||
)
|
||||
def specimen_command() -> None:
|
||||
"""Print the visual-language specimen for the active platform.
|
||||
|
||||
@@ -853,7 +895,11 @@ def main() -> None:
|
||||
run from a shell leaves its output on the screen anyway.
|
||||
"""
|
||||
try:
|
||||
app()
|
||||
# No wildcard expansion on Windows. Click expands an argument holding `*` into
|
||||
# the files it matches — even one the shell passed through quoted, which it can't
|
||||
# tell apart — so `chat send --scope '*'` arrived as the working directory's file
|
||||
# names. No MeshTerm argument is a file glob, and `*` is the wildcard region.
|
||||
app(windows_expand_args=False)
|
||||
except SystemExit as exit_request:
|
||||
if exit_request.code and _owns_its_console():
|
||||
_wait_before_the_window_closes()
|
||||
|
||||
+151
-11
@@ -8,6 +8,7 @@ never open a serial port.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -16,15 +17,16 @@ from rich.console import Console
|
||||
from .core.admin_store import AdminStore
|
||||
from .core.advert_store import AdvertStore
|
||||
from .core.channel_store import ChannelStore
|
||||
from .core.config import DeviceProfile, Settings
|
||||
from .core.config import DeviceProfile, Settings, SpiWiring
|
||||
from .core.connection import Device, make_device
|
||||
from .core.contact_store import ContactStore
|
||||
from .core.courier_store import CourierStore
|
||||
from .core.device_store import DeviceStore
|
||||
from .core.discovery import DiscoveredDevice, discover_devices
|
||||
from .core.mute_store import MuteStore
|
||||
from .core.preferences import PREFERENCES_FILENAME, Preferences
|
||||
from .core.preferences import PREFERENCES_FILENAME, Preferences, report_dropped
|
||||
from .core.preferences import install as install_preferences
|
||||
from .core.region_store import RegionStore
|
||||
from .core.remote_store import RemoteStore
|
||||
from .core.selection import resolve_device
|
||||
from .core.settings_store import SettingsStore
|
||||
@@ -76,6 +78,9 @@ class AppContext:
|
||||
mute_store: Store for muted channel notifications — the channels whose new
|
||||
messages don't raise the unread badge (defaults to
|
||||
``<config_dir>/mutes.json`` when not injected).
|
||||
region_store: Store for the regions known by name and each channel's send scope —
|
||||
what a scoped flood is resolved against (defaults to
|
||||
``<config_dir>/regions.json`` when not injected).
|
||||
channel_store: Store for channels created through MeshTerm, replayed into a device
|
||||
that forgot them (a firmware-less radio bridge). Keyed by device public key;
|
||||
defaults to ``<config_dir>/channels.json`` when not injected.
|
||||
@@ -121,6 +126,7 @@ class AppContext:
|
||||
watch_store: WatchStore | None = None
|
||||
courier_store: CourierStore | None = None
|
||||
mute_store: MuteStore | None = None
|
||||
region_store: RegionStore | None = None
|
||||
channel_store: ChannelStore | None = None
|
||||
settings_store: SettingsStore | None = None
|
||||
contact_store: ContactStore | None = None
|
||||
@@ -129,6 +135,7 @@ class AppContext:
|
||||
port_override: str | None = None
|
||||
ble_override: str | None = None
|
||||
tcp_override: str | None = None
|
||||
spi_override: bool = False
|
||||
ble_pin: str | None = None
|
||||
output: OutputFormat = OutputFormat.PLAIN
|
||||
interactive: bool = True
|
||||
@@ -145,11 +152,15 @@ class AppContext:
|
||||
#: the older handle no longer names it. Typed ``object`` so ``bleak`` stays optional.
|
||||
_ble_handle: object | None = field(default=None, init=False, repr=False)
|
||||
unpair_on_exit: bool = field(default=False, init=False, repr=False)
|
||||
#: Set by the config editor just before it sends a reboot command, so the session's
|
||||
#: disconnect watcher can label the ensuing (expected) link drop as a reboot in
|
||||
#: progress rather than a surprise unplug. Cleared by the reconnect dialog that
|
||||
#: consumes it (see :func:`meshterm.ui.menu._handle_disconnect`).
|
||||
#: Set by :meth:`announce_reboot` once a reboot command has gone out, so the reconnect
|
||||
#: dialog presents the drop as a reboot in progress rather than a surprise unplug.
|
||||
#: Cleared by :meth:`take_reboot` in the dialog that consumes it (see
|
||||
#: :func:`meshterm.ui.menu._handle_disconnect`).
|
||||
reboot_in_progress: bool = field(default=False, init=False, repr=False)
|
||||
#: Set alongside :attr:`reboot_in_progress`: wakes the session's disconnect watcher at
|
||||
#: once, without waiting for a liveness poll to *observe* the drop — which it may never
|
||||
#: do, since a board behind a USB-UART bridge keeps its port through a reboot.
|
||||
_link_going_down: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False)
|
||||
_resume_intent: tuple[bool, bool, bool] | None = field(default=None, init=False, repr=False)
|
||||
_events: EventHub | None = field(default=None, init=False, repr=False)
|
||||
_monitor: MonitorService | None = field(default=None, init=False, repr=False)
|
||||
@@ -172,6 +183,7 @@ class AppContext:
|
||||
"""
|
||||
if self.preferences is None:
|
||||
self.preferences = Preferences.load(self.settings.config_dir / PREFERENCES_FILENAME)
|
||||
report_dropped(self.preferences, self.log)
|
||||
install_preferences(self.preferences)
|
||||
if self.advert_store is None:
|
||||
self.advert_store = AdvertStore(self.settings.config_dir / "adverts.json")
|
||||
@@ -183,6 +195,8 @@ class AppContext:
|
||||
self.courier_store = CourierStore(self.settings.config_dir / "courier.json")
|
||||
if self.mute_store is None:
|
||||
self.mute_store = MuteStore(self.settings.config_dir / "mutes.json")
|
||||
if self.region_store is None:
|
||||
self.region_store = RegionStore(self.settings.config_dir / "regions.json")
|
||||
if self.channel_store is None:
|
||||
self.channel_store = ChannelStore(self.settings.config_dir / "channels.json")
|
||||
if self.settings_store is None:
|
||||
@@ -209,7 +223,7 @@ class AppContext:
|
||||
serial port). Falls back to an explicit ``--port`` override when a connection hasn't
|
||||
recorded one yet.
|
||||
"""
|
||||
if self.mock or self.active_transport in ("ble", "tcp"):
|
||||
if self.mock or self.active_transport in ("ble", "tcp", "spi"):
|
||||
return None
|
||||
return self._active_port or self.port_override
|
||||
|
||||
@@ -241,15 +255,17 @@ class AppContext:
|
||||
def active_transport(self) -> str | None:
|
||||
"""Which transport the current or selected connection uses.
|
||||
|
||||
One of ``"serial"``, ``"ble"``, ``"tcp"``, or ``None`` for the simulator. For a
|
||||
real device it reflects the open connection when one exists, otherwise the
|
||||
One of ``"serial"``, ``"ble"``, ``"tcp"``, ``"spi"``, or ``None`` for the simulator.
|
||||
For a real device it reflects the open connection when one exists, otherwise the
|
||||
transport implied by the pending selection (an explicit ``--tcp`` selects TCP,
|
||||
``--ble`` selects BLE), defaulting to serial.
|
||||
``--ble`` selects BLE, ``--spi`` the SPI radio), defaulting to serial.
|
||||
"""
|
||||
if self.mock:
|
||||
return None
|
||||
if self._active_transport is not None:
|
||||
return self._active_transport
|
||||
if self.spi_override or (self.profile is not None and self.profile.is_spi):
|
||||
return "spi"
|
||||
if self.tcp_override:
|
||||
return "tcp"
|
||||
return "ble" if self.ble_override else "serial"
|
||||
@@ -271,6 +287,34 @@ class AppContext:
|
||||
except Exception: # noqa: BLE001 - a liveness-check failure must not fake a disconnect
|
||||
return True
|
||||
|
||||
def announce_reboot(self) -> None:
|
||||
"""Declare the link down because we just told the companion to reboot.
|
||||
|
||||
Called *after* the reboot command was sent (never before: the watcher it wakes
|
||||
cancels the screen that sent it, and a cancel mid-write could leave the command half
|
||||
out). From then on the device is not ours to talk to until it comes back, so the
|
||||
session's disconnect watcher (:meth:`link_going_down`) fires at once and the
|
||||
reconnect dialog takes over — rather than hoping a liveness poll catches the drop,
|
||||
which misses a quick reboot entirely where the serial port never goes away.
|
||||
"""
|
||||
self.reboot_in_progress = True
|
||||
self._link_going_down.set()
|
||||
|
||||
async def link_going_down(self) -> None:
|
||||
"""Resolve once :meth:`announce_reboot` has declared the link down."""
|
||||
await self._link_going_down.wait()
|
||||
|
||||
def take_reboot(self) -> bool:
|
||||
"""Consume a pending reboot announcement, returning whether there was one.
|
||||
|
||||
Clears both halves, so a later, genuine disconnect goes back to the generic
|
||||
wording and the next watcher waits for a real drop again.
|
||||
"""
|
||||
pending = self.reboot_in_progress
|
||||
self.reboot_in_progress = False
|
||||
self._link_going_down.clear()
|
||||
return pending
|
||||
|
||||
@property
|
||||
def ui(self) -> Ui:
|
||||
"""The active UI surface, defaulting to the plain console surface for the CLI.
|
||||
@@ -519,6 +563,27 @@ class AppContext:
|
||||
await self._open("the simulator")
|
||||
return self._device
|
||||
|
||||
# A radio on the host's own SPI bus (``--spi``, an SPI profile, or the remembered
|
||||
# default) has nothing to find: MeshTerm starts the node that answers on it.
|
||||
spi = self.resolve_spi()
|
||||
if spi is not None:
|
||||
from .core.spiradio import state_dir
|
||||
|
||||
self._device = make_device(
|
||||
mock=False,
|
||||
port=None,
|
||||
transport="spi",
|
||||
spi=spi,
|
||||
state=state_dir(self.settings.config_dir, spi),
|
||||
)
|
||||
self._active_transport = "spi"
|
||||
self._active_endpoint = spi.spidev
|
||||
self._active_port = None
|
||||
self._active_address = None
|
||||
await self._open(f"SPI radio {spi.spidev}")
|
||||
await self._settle_connection()
|
||||
return self._device
|
||||
|
||||
# A network endpoint (an explicit ``--tcp``, a TCP profile, a device picked at startup,
|
||||
# or the remembered TCP default) is opened directly by host:port — TCP companions
|
||||
# aren't discoverable, so this remembered/explicit endpoint is the only way to reach one.
|
||||
@@ -614,8 +679,10 @@ class AppContext:
|
||||
explicit_other = (
|
||||
bool(self.port_override)
|
||||
or bool(self.ble_override)
|
||||
or self.spi_override
|
||||
or (
|
||||
self.profile is not None and (bool(self.profile.port) or bool(self.profile.address))
|
||||
self.profile is not None
|
||||
and (bool(self.profile.port) or bool(self.profile.address) or self.profile.is_spi)
|
||||
)
|
||||
)
|
||||
if explicit_other:
|
||||
@@ -630,6 +697,79 @@ class AppContext:
|
||||
return host, port
|
||||
return None, None
|
||||
|
||||
def resolve_spi(self) -> SpiWiring | None:
|
||||
"""The wiring of the SPI radio to open, or ``None`` when another transport is meant.
|
||||
|
||||
In priority order: an SPI profile, the radio picked on the startup splash, ``--spi``
|
||||
(the one radio attached — see :meth:`_spi_for_flag`), then the remembered default when it
|
||||
was an SPI radio and nothing else was named. Anything else named explicitly — a port,
|
||||
an address, a host, another kind of profile — means this session is not about the SPI
|
||||
radio.
|
||||
"""
|
||||
if self.profile is not None and self.profile.is_spi:
|
||||
return self.profile.spi or SpiWiring()
|
||||
chosen = self.selected_device
|
||||
if chosen is not None and chosen.is_spi:
|
||||
return chosen.spi or self.spi_wiring_for(chosen.port) # type: ignore[return-value]
|
||||
if self.spi_override:
|
||||
return self._spi_for_flag()
|
||||
if self.port_override or self.ble_override or self.tcp_override or self.profile:
|
||||
return None
|
||||
remembered = self.device_store.load()
|
||||
if remembered is None or remembered.transport != "spi" or not remembered.target:
|
||||
return None
|
||||
self.selected_device = None # remembered, not freshly discovered this session
|
||||
return self.spi_wiring_for(remembered.target)
|
||||
|
||||
def _spi_for_flag(self) -> SpiWiring:
|
||||
"""The radio a bare ``--spi`` means: the one there is, and never a guess between two.
|
||||
|
||||
Nearly everyone with a radio on the SPI bus has exactly one, and for them ``--spi``
|
||||
needs nothing more — attached radios are counted first, so a single one is used
|
||||
whether or not a profile names it. Only when two are attached, or (with none
|
||||
attached to count) two SPI profiles are configured, is the flag ambiguous; it used
|
||||
to take whichever profile ``config.toml`` listed first, which is a coin toss that
|
||||
transmits. With none attached and one profile or none, the choice is made from the
|
||||
configuration, so the connect attempt can say what is missing.
|
||||
|
||||
Raises:
|
||||
DeviceSelectionError: When more than one radio could be meant.
|
||||
"""
|
||||
from .core.selection import DeviceSelectionError
|
||||
from .core.spiradio import spi_radios
|
||||
|
||||
profiles = self.settings.profiles
|
||||
attached = spi_radios(profiles)
|
||||
if len(attached) == 1:
|
||||
return attached[0].spi # type: ignore[return-value]
|
||||
configured = [p for p in profiles.values() if p.is_spi]
|
||||
if not attached and len(configured) <= 1:
|
||||
return (configured[0].spi or SpiWiring()) if configured else SpiWiring()
|
||||
if attached:
|
||||
rows = [f" {d.name or '(no profile)':<16} {d.port}" for d in attached]
|
||||
what = "Several radios are attached to the SPI bus"
|
||||
else:
|
||||
rows = [f" {p.name:<16} {(p.spi or SpiWiring()).spidev}" for p in configured]
|
||||
what = "Several SPI profiles are configured"
|
||||
raise DeviceSelectionError(
|
||||
f"{what}, and --spi doesn't say which.\n"
|
||||
+ "\n".join(rows)
|
||||
+ "\nChoose one with -p <PROFILE>. A radio with no profile needs one first — see "
|
||||
"'Adding a radio on the SPI bus' in docs/configuration.md."
|
||||
)
|
||||
|
||||
def spi_wiring_for(self, spidev: str) -> SpiWiring:
|
||||
"""The wiring for the radio on ``spidev``: a profile's that names it, else the AIO's.
|
||||
|
||||
A remembered or listed SPI radio is known by its device node alone, so its pins come
|
||||
from whichever SPI profile is on that node; with none, the defaults are the only
|
||||
wiring there is (and the right one for the board they describe).
|
||||
"""
|
||||
for profile in self.settings.profiles.values():
|
||||
if profile.is_spi and (profile.spi or SpiWiring()).spidev == spidev:
|
||||
return profile.spi or SpiWiring()
|
||||
return SpiWiring()
|
||||
|
||||
def _resolve_ble_endpoint(self) -> tuple[str | None, str | None]:
|
||||
"""Return the ``(address, pin)`` to open over Bluetooth, or ``(None, None)`` for serial.
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ password means the user isn't prompted on every run. Like the remembered device
|
||||
directory (``<config_dir>/admin.json``) rather than the per-invocation SQLite database,
|
||||
because admin credentials are global machine state.
|
||||
|
||||
The file is read into :class:`AdminCredential` records and written back out of them,
|
||||
never round-tripped as the raw JSON it was read as, so a field no record knows is gone
|
||||
after the next write (see :data:`AdminCredential.RETIRED`).
|
||||
|
||||
Passwords are stored in plaintext, so the file is written with owner-only permissions
|
||||
where the platform supports it. This is a local operator tool tuning their own mesh, not
|
||||
a multi-user secret store; treat the file accordingly.
|
||||
@@ -17,6 +21,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .models import Contact, LoginResult, utcnow
|
||||
@@ -43,7 +48,8 @@ class AdminCredential:
|
||||
"""A remembered admin password for one remote node.
|
||||
|
||||
Attributes:
|
||||
key: The :func:`admin_key` the password is stored under.
|
||||
key: The :func:`admin_key` the password is stored under (the record's key in the
|
||||
file, not one of its fields).
|
||||
password: The node's admin password (plaintext).
|
||||
label: A friendly node name for display.
|
||||
last_used: ISO-8601 timestamp of the last successful login.
|
||||
@@ -54,6 +60,26 @@ class AdminCredential:
|
||||
label: str
|
||||
last_used: str
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@classmethod
|
||||
def read(cls, key: str, raw: object) -> AdminCredential | None:
|
||||
"""Build a record from one file entry, or ``None`` when it holds no password."""
|
||||
if not isinstance(raw, dict) or raw.get("password") is None:
|
||||
return None
|
||||
return cls(
|
||||
key=key,
|
||||
password=str(raw["password"]),
|
||||
label=str(raw.get("label") or ""),
|
||||
last_used=str(raw.get("last_used") or ""),
|
||||
)
|
||||
|
||||
def to_json(self) -> dict:
|
||||
"""The record as written: its own fields and nothing it happened to be read with."""
|
||||
return {"password": self.password, "label": self.label, "last_used": self.last_used}
|
||||
|
||||
|
||||
class AdminStore:
|
||||
"""Reads and writes remembered remote-node admin passwords."""
|
||||
@@ -66,13 +92,20 @@ class AdminStore:
|
||||
"""
|
||||
self._path = path
|
||||
|
||||
def _load_all(self) -> dict[str, dict]:
|
||||
"""Return the raw key -> record mapping, or empty on missing/corrupt file."""
|
||||
def _load(self) -> dict[str, AdminCredential]:
|
||||
"""Read every credential, keyed by :func:`admin_key`; empty on a missing/corrupt file."""
|
||||
try:
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
records = {}
|
||||
for key, raw in data.items():
|
||||
record = AdminCredential.read(str(key), raw)
|
||||
if record is not None:
|
||||
records[record.key] = record
|
||||
return records
|
||||
|
||||
def get(self, node: Contact) -> str | None:
|
||||
"""Return the remembered password for ``node``, or ``None`` if not stored.
|
||||
@@ -83,11 +116,8 @@ class AdminStore:
|
||||
Returns:
|
||||
The stored password, or ``None``.
|
||||
"""
|
||||
record = self._load_all().get(admin_key(node))
|
||||
if isinstance(record, dict):
|
||||
password = record.get("password")
|
||||
return str(password) if password is not None else None
|
||||
return None
|
||||
record = self._load().get(admin_key(node))
|
||||
return record.password if record is not None else None
|
||||
|
||||
def remember(self, node: Contact, password: str) -> None:
|
||||
"""Store (or update) the admin password for ``node``.
|
||||
@@ -96,12 +126,11 @@ class AdminStore:
|
||||
node: The contact the password belongs to.
|
||||
password: The admin password to remember.
|
||||
"""
|
||||
records = self._load_all()
|
||||
records[admin_key(node)] = {
|
||||
"password": password,
|
||||
"label": node.name,
|
||||
"last_used": utcnow().isoformat(),
|
||||
}
|
||||
records = self._load()
|
||||
key = admin_key(node)
|
||||
records[key] = AdminCredential(
|
||||
key=key, password=password, label=node.name, last_used=utcnow().isoformat()
|
||||
)
|
||||
self._write(records)
|
||||
|
||||
def record(self, node: Contact, password: str, outcome: LoginResult) -> None:
|
||||
@@ -133,10 +162,11 @@ class AdminStore:
|
||||
Args:
|
||||
node: The contact to forget.
|
||||
"""
|
||||
records = self._load_all()
|
||||
records = self._load()
|
||||
if records.pop(admin_key(node), None) is not None:
|
||||
self._write(records)
|
||||
|
||||
def _write(self, records: dict[str, dict]) -> None:
|
||||
def _write(self, records: dict[str, AdminCredential]) -> None:
|
||||
"""Persist ``records`` atomically with owner-only permissions where supported."""
|
||||
write_atomically(self._path, json.dumps(records, indent=2), owner_only=True)
|
||||
data = {key: record.to_json() for key, record in records.items()}
|
||||
write_atomically(self._path, json.dumps(data, indent=2), owner_only=True)
|
||||
+177
-130
@@ -1,105 +1,133 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for the background-advert schedule.
|
||||
"""Persistence for the weekly flood advert: when each device's week began.
|
||||
|
||||
MeshTerm announces the connected node on a cadence — a zero-hop (direct) advert for the
|
||||
immediate neighbourhood and a flood advert for the wider mesh — so the node stays fresh in
|
||||
other nodes' contact lists without anyone thinking about it. This store remembers, per
|
||||
device (keyed by its public key), the chosen cadence for each advert type and when one was
|
||||
last sent, so the countdown survives restarts and *every* advert counts: a manual send from
|
||||
the advert menu resets the same clock the scheduler reads (see
|
||||
:class:`~meshterm.services.advert_scheduler.AdvertScheduler`).
|
||||
A MeshCore companion never advertises by itself — no timer, not even at boot; only an app
|
||||
asking (``CMD_SEND_SELF_ADVERT``) or a press on the device's own menu puts one on the air.
|
||||
So a node that nobody remembers to advertise slowly drops out of everyone else's contact
|
||||
list. The ``weekly_flood_advert`` preference is MeshTerm's answer, and deliberately a
|
||||
frugal one: at most one flood advert a week from any one device, and only once that device
|
||||
has gone a full week without one (see
|
||||
:class:`~meshterm.services.advert_scheduler.AdvertScheduler`, which sends it).
|
||||
|
||||
This store remembers what that rule needs, and nothing else:
|
||||
|
||||
* **when the preference was turned on** — turning it on starts the week rather than
|
||||
sending, so a week has to pass before the first automatic advert from any device;
|
||||
* **per device** (keyed by its public key), **when its week began** — the last flood
|
||||
advert that went out from it, whoever asked for it, or failing that the first time
|
||||
MeshTerm connected to it.
|
||||
|
||||
A device's advert is due one week after the *latest* of those instants. The clock is real
|
||||
time rather than run time, so a week with the app closed counts, and a due advert that the
|
||||
app did not live to send is still due the next time that device connects.
|
||||
|
||||
Like the remembered devices (:mod:`meshterm.core.device_store`), this is global machine
|
||||
state in a small JSON file (``<config_dir>/adverts.json``) rather than the per-invocation
|
||||
SQLite database.
|
||||
SQLite database, read into typed records and written back out of them (see
|
||||
:class:`AdvertStore`). A file in an older shape (the per-device cadences this replaced)
|
||||
reads as empty: every device simply starts its week again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .models import utcnow
|
||||
|
||||
#: The direct (zero-hop) cadence choices offered in the editor, in hours.
|
||||
DIRECT_CADENCE_HOURS = (1, 2, 3, 4)
|
||||
#: How long a device must go without a flood advert before the weekly one is due.
|
||||
WEEK = timedelta(days=7)
|
||||
|
||||
#: The flood cadence choices offered in the editor, in hours (3 h .. 1 week).
|
||||
FLOOD_CADENCE_HOURS = (3, 6, 12, 24, 48, 168)
|
||||
#: The quiet spells the ``advert_quiet_s`` preference offers, in seconds: how long the air
|
||||
#: must go without a packet, heard or sent, before the due advert goes out.
|
||||
QUIET_CHOICES_S = (5, 15, 30, 60)
|
||||
|
||||
#: Defaults applied when a device has no stored policy: announce to neighbours hourly,
|
||||
#: flood the wider mesh daily.
|
||||
DEFAULT_DIRECT_HOURS = 1
|
||||
DEFAULT_FLOOD_HOURS = 24
|
||||
|
||||
#: The stored cadence meaning "this advert type is off" (0 hours).
|
||||
OFF = 0
|
||||
#: The quiet spell waited for by default, in seconds.
|
||||
DEFAULT_QUIET_S = 30
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdvertPolicy:
|
||||
"""One device's background-advert schedule and its last-sent marks.
|
||||
class _Device:
|
||||
"""One device's record: when its week began.
|
||||
|
||||
Attributes:
|
||||
direct_hours: Hours between zero-hop adverts (:data:`OFF` disables them).
|
||||
flood_hours: Hours between flood adverts (:data:`OFF` disables them).
|
||||
last_direct: When a zero-hop advert (scheduled *or* manual) last went out.
|
||||
last_flood: When a flood advert last went out.
|
||||
since: The last flood advert from it, or its first connection, whichever is
|
||||
later — the instant its week counts from.
|
||||
"""
|
||||
|
||||
direct_hours: int = DEFAULT_DIRECT_HOURS
|
||||
flood_hours: int = DEFAULT_FLOOD_HOURS
|
||||
last_direct: datetime | None = None
|
||||
last_flood: datetime | None = None
|
||||
since: datetime | None = None
|
||||
|
||||
def cadence(self, flood: bool) -> int:
|
||||
"""The cadence in hours for one advert type (:data:`OFF` when disabled)."""
|
||||
return self.flood_hours if flood else self.direct_hours
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset(
|
||||
{
|
||||
# The per-device cadences the weekly flood advert replaced.
|
||||
"direct_hours",
|
||||
"flood_hours",
|
||||
"last_direct",
|
||||
"last_flood",
|
||||
}
|
||||
)
|
||||
|
||||
def last_sent(self, flood: bool) -> datetime | None:
|
||||
"""When an advert of one type last went out, or ``None`` if never recorded."""
|
||||
return self.last_flood if flood else self.last_direct
|
||||
@classmethod
|
||||
def read(cls, raw: object) -> _Device:
|
||||
"""Build a record from the file's JSON, taking only the fields it knows."""
|
||||
record = raw if isinstance(raw, dict) else {}
|
||||
return cls(since=_as_time(record.get("since")))
|
||||
|
||||
def due(self, flood: bool, now: datetime | None = None) -> bool:
|
||||
"""Whether an advert of one type is due at ``now``.
|
||||
def to_json(self) -> dict:
|
||||
"""The record as written: its own fields and nothing it happened to be read with."""
|
||||
return {"since": _stamp(self.since)}
|
||||
|
||||
Never-sent is *not* due: the scheduler arms the clock by recording "now" on its
|
||||
first look (see :meth:`AdvertStore.arm`), so a fresh install waits a full cadence
|
||||
instead of transmitting the moment it starts — adverts are routine, not urgent,
|
||||
and the quiet default is the polite one on a shared mesh.
|
||||
|
||||
Args:
|
||||
flood: ``True`` for the flood clock, ``False`` for the zero-hop one.
|
||||
now: The current time (defaults to :func:`~meshterm.core.models.utcnow`).
|
||||
@dataclass(slots=True)
|
||||
class _Document:
|
||||
"""The whole file: the switch-on instant, and every device's record by public key.
|
||||
|
||||
Returns:
|
||||
``True`` when the type is enabled, armed, and its cadence has elapsed.
|
||||
Attributes:
|
||||
enabled_since: When the weekly advert was last switched on; ``None`` while off.
|
||||
devices: Each device's record, keyed by its normalized public key.
|
||||
"""
|
||||
|
||||
enabled_since: datetime | None = None
|
||||
devices: dict[str, _Device] = field(default_factory=dict)
|
||||
|
||||
#: Top-level fields this document once held; none yet (see :attr:`_Device.RETIRED`).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@classmethod
|
||||
def read(cls, raw: object) -> _Document:
|
||||
"""Build the document from the file's JSON, taking only the fields it knows.
|
||||
|
||||
A file without a ``devices`` map is from before this shape — the per-device
|
||||
cadences — and reads as empty rather than being mined for look-alike fields.
|
||||
"""
|
||||
hours = self.cadence(flood)
|
||||
last = self.last_sent(flood)
|
||||
if hours == OFF or last is None:
|
||||
return False
|
||||
return ((now or utcnow()) - last).total_seconds() >= hours * 3600
|
||||
if not isinstance(raw, dict) or not isinstance(raw.get("devices"), dict):
|
||||
return cls()
|
||||
return cls(
|
||||
enabled_since=_as_time(raw.get("enabled_since")),
|
||||
devices={str(k): _Device.read(v) for k, v in raw["devices"].items()},
|
||||
)
|
||||
|
||||
|
||||
def cadence_label(hours: int) -> str:
|
||||
"""Render a cadence for display: ``"off"``, ``"every hour"``, ``"every 2 h"``, ``"weekly"``."""
|
||||
if hours == OFF:
|
||||
return "off"
|
||||
if hours == 1:
|
||||
return "every hour"
|
||||
if hours == 24:
|
||||
return "daily"
|
||||
if hours == 168:
|
||||
return "weekly"
|
||||
return f"every {hours} h"
|
||||
def to_json(self) -> dict:
|
||||
"""The document as written: known fields only, so a stale one dies at the next save."""
|
||||
return {
|
||||
"enabled_since": _stamp(self.enabled_since),
|
||||
"devices": {key: device.to_json() for key, device in self.devices.items()},
|
||||
}
|
||||
|
||||
|
||||
class AdvertStore:
|
||||
"""Reads and writes per-device background-advert schedules."""
|
||||
"""Reads and writes the weekly-advert clock: the switch-on instant, and each device's.
|
||||
|
||||
The file is read into typed records and written back out of them — never round-tripped
|
||||
as the raw JSON it was read as — so a field no record knows (a retired one, one a hand
|
||||
edit invented) is gone after the next write.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
"""Open the store against a JSON file location.
|
||||
@@ -114,94 +142,113 @@ class AdvertStore:
|
||||
"""Normalize a device public key into the storage key."""
|
||||
return public_key.lower().removeprefix("0x")
|
||||
|
||||
def _load_all(self) -> dict[str, dict]:
|
||||
"""Return the raw key -> record mapping, or empty on missing/corrupt file."""
|
||||
def _load(self) -> _Document:
|
||||
"""Read the file's document; an empty one when it is missing, corrupt or outdated."""
|
||||
try:
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
return _Document()
|
||||
return _Document.read(raw)
|
||||
|
||||
def load(self, public_key: str) -> AdvertPolicy:
|
||||
"""Return the stored policy for a device, or the on-by-default policy.
|
||||
def _write(self, document: _Document) -> None:
|
||||
"""Persist ``document`` atomically (a crash mid-write keeps the previous file)."""
|
||||
write_atomically(self._path, json.dumps(document.to_json(), indent=2))
|
||||
|
||||
# -- the switch ---------------------------------------------------------------------
|
||||
|
||||
def enabled_since(self) -> datetime | None:
|
||||
"""When the weekly advert was last turned on, or ``None`` while it is off."""
|
||||
return self._load().enabled_since
|
||||
|
||||
def set_enabled(self, on: bool, when: datetime | None = None) -> None:
|
||||
"""Record the preference being turned on (starting the week) or off.
|
||||
|
||||
Turning it on always restarts the clock, even when it was already recorded as on:
|
||||
the call means "it was switched on just now", and switching on never sends.
|
||||
|
||||
Args:
|
||||
public_key: The device's public key (hex).
|
||||
|
||||
Returns:
|
||||
The device's :class:`AdvertPolicy`; defaults apply for anything unset.
|
||||
on: Whether the weekly advert is now on.
|
||||
when: The switch-on instant (defaults to now); ignored when turning off.
|
||||
"""
|
||||
record = self._load_all().get(self._key(public_key))
|
||||
if not isinstance(record, dict):
|
||||
return AdvertPolicy()
|
||||
return AdvertPolicy(
|
||||
direct_hours=_as_hours(record.get("direct_hours"), DEFAULT_DIRECT_HOURS),
|
||||
flood_hours=_as_hours(record.get("flood_hours"), DEFAULT_FLOOD_HOURS),
|
||||
last_direct=_as_time(record.get("last_direct")),
|
||||
last_flood=_as_time(record.get("last_flood")),
|
||||
)
|
||||
document = self._load()
|
||||
if on:
|
||||
document.enabled_since = when or utcnow()
|
||||
elif document.enabled_since is not None:
|
||||
document.enabled_since = None
|
||||
else:
|
||||
return # already off; nothing to write
|
||||
self._write(document)
|
||||
|
||||
def set_cadence(self, public_key: str, *, flood: bool, hours: int) -> None:
|
||||
"""Store one advert type's cadence for a device.
|
||||
def sync_enabled(self, on: bool) -> None:
|
||||
"""Bring the recorded switch into line with the preference, where they disagree.
|
||||
|
||||
Args:
|
||||
public_key: The device's public key (hex).
|
||||
flood: ``True`` to set the flood cadence, ``False`` for the zero-hop one.
|
||||
hours: The new cadence in hours (:data:`OFF` disables the type).
|
||||
The Preferences page and ``preferences set`` record the switch as it happens (see
|
||||
:meth:`set_enabled`); this catches the one path neither sees, a hand edit of
|
||||
``preferences.toml``, so a preference found on with no switch-on instant starts its
|
||||
week now instead of never.
|
||||
"""
|
||||
self._update(public_key, {"flood_hours" if flood else "direct_hours": int(hours)})
|
||||
recorded = self.enabled_since() is not None
|
||||
if on != recorded:
|
||||
self.set_enabled(on)
|
||||
|
||||
def mark_sent(self, public_key: str, *, flood: bool, when: datetime | None = None) -> None:
|
||||
"""Record that an advert went out, resetting that type's countdown.
|
||||
# -- the devices ----------------------------------------------------------------------
|
||||
|
||||
Called for scheduled *and* manual sends alike, so the next background advert
|
||||
counts from the most recent announcement of that type, whoever triggered it.
|
||||
def week_began(self, public_key: str) -> datetime | None:
|
||||
"""When a device's own week began, or ``None`` if MeshTerm has never marked it."""
|
||||
device = self._load().devices.get(self._key(public_key))
|
||||
return device.since if device is not None else None
|
||||
|
||||
def arm(self, public_key: str, when: datetime | None = None) -> None:
|
||||
"""Start a device's week on its first connection, without sending anything.
|
||||
|
||||
Only writes for a device with no mark yet, so it is safe to call on every connect.
|
||||
|
||||
Args:
|
||||
public_key: The device's public key (hex).
|
||||
when: The week's start (defaults to now).
|
||||
"""
|
||||
if self.week_began(public_key) is None:
|
||||
self.mark_flood(public_key, when=when)
|
||||
|
||||
def mark_flood(self, public_key: str, when: datetime | None = None) -> None:
|
||||
"""Record a flood advert from a device, restarting its week.
|
||||
|
||||
Called for the weekly advert and for a flood advert sent by hand alike, so the
|
||||
next automatic one is never less than a week after the last of either.
|
||||
|
||||
Args:
|
||||
public_key: The device's public key (hex).
|
||||
flood: Which advert type went out.
|
||||
when: The send time (defaults to now).
|
||||
"""
|
||||
stamp = (when or utcnow()).isoformat()
|
||||
self._update(public_key, {"last_flood" if flood else "last_direct": stamp})
|
||||
document = self._load()
|
||||
document.devices[self._key(public_key)] = _Device(since=when or utcnow())
|
||||
self._write(document)
|
||||
|
||||
def arm(self, public_key: str, *, flood: bool, when: datetime | None = None) -> None:
|
||||
"""Start a never-sent advert type's countdown from ``when`` without sending.
|
||||
# -- the verdict ----------------------------------------------------------------------
|
||||
|
||||
Only writes when no last-sent mark exists, so it is safe to call on every
|
||||
scheduler pass.
|
||||
def due_at(self, public_key: str) -> datetime | None:
|
||||
"""When a device's weekly advert falls due, or ``None`` while it cannot.
|
||||
|
||||
``None`` while the weekly advert is off, and for a device not yet armed.
|
||||
|
||||
Args:
|
||||
public_key: The device's public key (hex).
|
||||
flood: Which advert type to arm.
|
||||
when: The countdown start (defaults to now).
|
||||
"""
|
||||
if self.load(public_key).last_sent(flood) is None:
|
||||
self.mark_sent(public_key, flood=flood, when=when)
|
||||
document = self._load()
|
||||
device = document.devices.get(self._key(public_key))
|
||||
if document.enabled_since is None or device is None or device.since is None:
|
||||
return None
|
||||
return max(document.enabled_since, device.since) + WEEK
|
||||
|
||||
def _update(self, public_key: str, fields: dict) -> None:
|
||||
"""Merge ``fields`` into a device's record and persist the store."""
|
||||
records = self._load_all()
|
||||
key = self._key(public_key)
|
||||
record = records.get(key)
|
||||
if not isinstance(record, dict):
|
||||
record = {}
|
||||
record.update(fields)
|
||||
records[key] = record
|
||||
self._write(records)
|
||||
|
||||
def _write(self, records: dict[str, dict]) -> None:
|
||||
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
|
||||
write_atomically(self._path, json.dumps(records, indent=2))
|
||||
def due(self, public_key: str, now: datetime | None = None) -> bool:
|
||||
"""Whether a device's weekly advert is due at ``now`` (defaults to now)."""
|
||||
at = self.due_at(public_key)
|
||||
return at is not None and (now or utcnow()) >= at
|
||||
|
||||
|
||||
def _as_hours(value: object, default: int) -> int:
|
||||
"""Coerce a stored cadence to a non-negative int, falling back to ``default``."""
|
||||
try:
|
||||
hours = int(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return hours if hours >= 0 else default
|
||||
def _stamp(when: datetime | None) -> str | None:
|
||||
"""A timestamp as the file stores it: ISO-8601, or ``null`` for absent."""
|
||||
return when.isoformat() if when is not None else None
|
||||
|
||||
|
||||
def _as_time(value: object) -> datetime | None:
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Pair a Bluetooth companion with its PIN through BlueZ, the Linux Bluetooth stack.
|
||||
|
||||
A MeshCore companion puts its UART characteristic behind an *authenticated* bond (ENC+MITM),
|
||||
which on a device with a fixed PIN means Passkey Entry: the computer has to type the six
|
||||
digits in. On Linux, bleak's ``pair()`` cannot do that. It calls BlueZ's ``Device1.Pair`` and
|
||||
nothing else, and BlueZ gets a passkey only by asking a **pairing agent** — an object some
|
||||
program registers on the system bus to answer ``RequestPasskey``. On a desktop that agent is
|
||||
the Settings panel, in a terminal it is ``bluetoothctl``, over SSH it is usually nobody. With
|
||||
no agent to ask, BlueZ falls back to "Just Works", the firmware refuses a bond without MITM,
|
||||
and the PIN MeshTerm was handed never reached the radio at all. That is the Linux twin of the
|
||||
Windows gap :meth:`~meshterm.core.connection.MeshCoreDevice._pair_ble_windows` closes.
|
||||
|
||||
So MeshTerm registers its own agent for the length of one pairing, answering with the PIN it
|
||||
was given, and calls ``Pair`` from the *same* bus connection. BlueZ routes a pairing's agent
|
||||
requests to the agent of whoever called ``Pair`` (the system default agent is only the
|
||||
fallback), so this never takes over the desktop's agent and never answers for any other
|
||||
device's pairing.
|
||||
|
||||
The agent answers raw D-Bus messages (:class:`PinAgent`) rather than going through
|
||||
``dbus_fast``'s annotated ``ServiceInterface``: that one reads D-Bus signatures out of
|
||||
annotations like ``device: "o"``, which a linter takes for an undefined name and postponed
|
||||
annotations turn into a quoted string. A message handler has neither problem and can be
|
||||
exercised without a bus.
|
||||
|
||||
``dbus_fast`` is bleak's own dependency on Linux, so nothing new is installed; it is imported
|
||||
lazily, and every entry point here is best-effort — a missing system bus or BlueZ reports an
|
||||
``"error"`` outcome instead of raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_BLUEZ = "org.bluez"
|
||||
_AGENT_IFACE = "org.bluez.Agent1"
|
||||
_AGENT_MANAGER_IFACE = "org.bluez.AgentManager1"
|
||||
_ADAPTER_IFACE = "org.bluez.Adapter1"
|
||||
_DEVICE_IFACE = "org.bluez.Device1"
|
||||
_PROPERTIES_IFACE = "org.freedesktop.DBus.Properties"
|
||||
_OBJECT_MANAGER_IFACE = "org.freedesktop.DBus.ObjectManager"
|
||||
_REJECTED = "org.bluez.Error.Rejected"
|
||||
_ALREADY_EXISTS = "org.bluez.Error.AlreadyExists"
|
||||
_AGENTS = "/org/bluez" # where AgentManager1 lives
|
||||
|
||||
#: The agent's declared input/output capability. The companion is the side with the PIN
|
||||
#: (``DisplayOnly``), so we are the keyboard: ``KeyboardOnly`` makes the pairing Passkey
|
||||
#: Entry with us typing, which is the one method a fixed PIN can satisfy.
|
||||
AGENT_CAPABILITY = "KeyboardOnly"
|
||||
|
||||
#: How long to run discovery for a device BlueZ has forgotten (seconds). BlueZ drops an
|
||||
#: unpaired device it has stopped hearing after about half a minute, so one the picker
|
||||
#: listed a while ago may need hearing again before it can be paired.
|
||||
DISCOVER_S = 6.0
|
||||
|
||||
#: Bound on the ``Pair`` call itself (seconds). A healthy passkey pairing takes one to three
|
||||
#: seconds; this only has to outlast a slow radio, and stays well inside the probe's window.
|
||||
PAIR_TIMEOUT_S = 15.0
|
||||
|
||||
#: Attempts at ``Pair`` when the link under it fails to come up, and the pause between them.
|
||||
#: A Raspberry Pi class radio drops three to seven link attempts per connect
|
||||
#: (``le-connection-abort-by-local``, HCI 0x3e). bleak retries those inside its own
|
||||
#: ``connect()``; BlueZ's ``Pair`` does not, and answers ConnectionAttemptFailed at once.
|
||||
#: Measured on a uConsole: the first Pair failed that way, the connect then went ahead
|
||||
#: unpaired, and the protected subscribe cost 32 s before the repair paired it after all.
|
||||
PAIR_ATTEMPTS = 4
|
||||
PAIR_RETRY_DELAY_S = 0.5
|
||||
|
||||
_LINK_FLAKES = ("org.bluez.Error.ConnectionAttemptFailed",)
|
||||
|
||||
|
||||
def _link_flake(reply: Any) -> bool:
|
||||
"""Whether a ``Pair`` error is the link failing to come up — worth another try."""
|
||||
detail = reply.body[0] if reply.body and isinstance(reply.body[0], str) else ""
|
||||
return reply.error_name in _LINK_FLAKES or "le-connection-abort" in detail
|
||||
|
||||
|
||||
_MAC = re.compile(r"^[0-9A-Fa-f]{2}([:-][0-9A-Fa-f]{2}){5}$")
|
||||
|
||||
|
||||
def is_mac(address: str) -> bool:
|
||||
"""Whether ``address`` is a colon- or dash-separated 48-bit Bluetooth address."""
|
||||
return bool(_MAC.match(address or ""))
|
||||
|
||||
|
||||
def _normal(address: str) -> str:
|
||||
"""An address as BlueZ spells it: upper case, colon-separated."""
|
||||
return address.replace("-", ":").upper()
|
||||
|
||||
|
||||
def status_name(error_name: str, body: list[Any] | None = None) -> str:
|
||||
"""Turn a BlueZ error into the lowercase words a message can quote.
|
||||
|
||||
``org.bluez.Error.AuthenticationFailed`` → ``"authentication failed"``. The bare
|
||||
``org.bluez.Error.Failed`` says nothing on its own, so its text rides along
|
||||
(``"failed: le-connection-abort-by-local"``).
|
||||
|
||||
Args:
|
||||
error_name: The D-Bus error name from the reply.
|
||||
body: The error reply's body, whose first item is BlueZ's detail string.
|
||||
|
||||
Returns:
|
||||
The status, as :data:`~meshterm.core.connection._PAIRING_REFUSED` and its sibling
|
||||
sets spell them.
|
||||
"""
|
||||
leaf = error_name.rsplit(".", 1)[-1]
|
||||
words = re.sub(r"(?<!^)(?=[A-Z])", " ", leaf).lower()
|
||||
detail = body[0] if body and isinstance(body[0], str) else ""
|
||||
if words == "failed" and detail:
|
||||
return f"failed: {detail}"
|
||||
return words
|
||||
|
||||
|
||||
class PinAgent:
|
||||
"""A BlueZ ``Agent1`` that answers one device's pairing with one PIN — or refuses it.
|
||||
|
||||
Installed as a message handler on the bus that calls ``Pair`` (see the module docstring
|
||||
for why that bus), or made BlueZ's default agent for the length of a connect (see
|
||||
:func:`answering`). It answers only calls addressed to its own object path, and only for
|
||||
the device it was built for; anything else is ``Rejected``, so a pairing we did not start
|
||||
can never be approved by us. Without a PIN it refuses every request at once, which is
|
||||
the point of it there: the alternative is BlueZ waiting on an agent that doesn't exist.
|
||||
|
||||
It never approves a pairing *without* the passkey (``RequestAuthorization``, the "Just
|
||||
Works" yes/no). That is how a companion ends up with an unauthenticated bond — encryption
|
||||
works, the UART characteristic still refuses — which is the stale-bond failure itself.
|
||||
|
||||
Attributes:
|
||||
path: The object path the agent is registered at.
|
||||
asked: Whether BlueZ actually asked for the PIN — a pairing that failed without
|
||||
asking was refused before the passkey stage, which is a different story.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str, pin: str | None, device_path: str) -> None:
|
||||
"""Build the agent.
|
||||
|
||||
Args:
|
||||
path: The object path to answer at.
|
||||
pin: The pairing PIN (digits), or ``None`` to refuse every request.
|
||||
device_path: The one device it may answer for: its BlueZ object path, or the
|
||||
path's ``/dev_…`` tail when the adapter isn't known yet (see
|
||||
:func:`device_tail`).
|
||||
"""
|
||||
self.path = path
|
||||
self._pin = pin
|
||||
self._device = device_path
|
||||
self.asked = False
|
||||
|
||||
def handle(self, msg: Any) -> Any:
|
||||
"""Answer one incoming message, or return ``None`` to leave it to other handlers.
|
||||
|
||||
Args:
|
||||
msg: A ``dbus_fast.Message``.
|
||||
|
||||
Returns:
|
||||
A reply ``Message``, or ``None`` when the message is not ours.
|
||||
"""
|
||||
from dbus_fast import Message, MessageType
|
||||
|
||||
if (
|
||||
msg.message_type != MessageType.METHOD_CALL
|
||||
or msg.path != self.path
|
||||
or msg.interface != _AGENT_IFACE
|
||||
):
|
||||
return None
|
||||
member = msg.member
|
||||
if member in ("Release", "Cancel"):
|
||||
return Message.new_method_return(msg)
|
||||
device = msg.body[0] if msg.body else ""
|
||||
if not str(device).endswith(self._device):
|
||||
return Message.new_error(msg, _REJECTED, "not the device MeshTerm is pairing")
|
||||
if member in ("RequestPasskey", "RequestPinCode", "RequestConfirmation") and not self._pin:
|
||||
self.asked = True
|
||||
return Message.new_error(msg, _REJECTED, "no PIN was given for this device")
|
||||
if member == "RequestPasskey":
|
||||
self.asked = True
|
||||
return Message.new_method_return(msg, "u", [int(self._pin)])
|
||||
if member == "RequestPinCode":
|
||||
self.asked = True
|
||||
return Message.new_method_return(msg, "s", [self._pin])
|
||||
if member == "RequestConfirmation":
|
||||
# Numeric Comparison: approve only the number our PIN names. A companion with a
|
||||
# fixed PIN never asks for this; one that does and shows another number is not
|
||||
# the pairing we were told to make.
|
||||
self.asked = True
|
||||
if int(msg.body[1]) == int(self._pin):
|
||||
return Message.new_method_return(msg)
|
||||
return Message.new_error(msg, _REJECTED, "passkey does not match the PIN")
|
||||
if member in ("DisplayPasskey", "DisplayPinCode"):
|
||||
return Message.new_method_return(msg)
|
||||
return Message.new_error(msg, _REJECTED, f"{member} is not something MeshTerm answers")
|
||||
|
||||
|
||||
def device_tail(address: str) -> str:
|
||||
"""The ``/dev_AA_BB_…`` tail of the object path BlueZ gives the device at ``address``."""
|
||||
return "/dev_" + _normal(address).replace(":", "_")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def answering(address: str, pin: str | None) -> AsyncIterator[None]:
|
||||
"""Be BlueZ's default pairing agent for one device while a connect runs.
|
||||
|
||||
BlueZ does not wait to be asked to pair. When the companion refuses the UART subscribe
|
||||
with *Insufficient Authentication*, BlueZ raises the link's security by itself and starts
|
||||
SMP pairing, which needs an agent — and the one it asks is the **default** agent, not
|
||||
ours (that routing is only for a ``Pair`` we call). With none registered, as on any
|
||||
headless machine, it offers the companion ``DisplayYesNo``, lands on "Just Works", asks a
|
||||
yes/no nobody is there to answer, and the companion hangs up on its 30 s SMP timeout.
|
||||
Measured on a uConsole with btmon: 3.3 s to the question, 33.5 s to the hang-up, and only
|
||||
then "requires a PIN". On a desktop the question goes to the desktop's dialog instead,
|
||||
and answering it makes the unauthenticated bond that later refuses the subscribe.
|
||||
|
||||
So for the length of the connect MeshTerm is the default agent, for this device only,
|
||||
declared ``KeyboardOnly`` so the pairing BlueZ starts is Passkey Entry: with a PIN it types
|
||||
it in and the connect pairs on the spot; without one it refuses at once and the refusal
|
||||
arrives in seconds. Another device's pairing in that window is refused, not left hanging.
|
||||
BlueZ keeps default agents as a stack, so unregistering hands the role back to whoever
|
||||
held it. Best-effort: with no system bus it simply does nothing.
|
||||
|
||||
Args:
|
||||
address: The companion's Bluetooth address.
|
||||
pin: Its PIN, or ``None`` to refuse.
|
||||
|
||||
Yields:
|
||||
Nothing; the agent is live for the body of the ``async with``.
|
||||
"""
|
||||
bus = None
|
||||
agent = PinAgent(f"/net/meshterm/connect{os.getpid()}", pin, device_tail(address))
|
||||
registered = False
|
||||
try:
|
||||
bus = await _system_bus()
|
||||
bus.add_message_handler(agent.handle)
|
||||
reply = await _call(
|
||||
bus, _AGENTS, _AGENT_MANAGER_IFACE, "RegisterAgent", "os",
|
||||
[agent.path, AGENT_CAPABILITY],
|
||||
) # fmt: skip
|
||||
registered = not _is_error(reply)
|
||||
if registered:
|
||||
await _call(
|
||||
bus, _AGENTS, _AGENT_MANAGER_IFACE, "RequestDefaultAgent", "o", [agent.path]
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - no bus, no BlueZ: connect without an agent
|
||||
_log.debug("couldn't stand in as the BlueZ agent: %s", exc)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if bus is not None:
|
||||
try:
|
||||
if registered:
|
||||
await _call(
|
||||
bus, _AGENTS, _AGENT_MANAGER_IFACE, "UnregisterAgent", "o", [agent.path]
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - the bus closing drops it anyway
|
||||
_log.debug("unregistering the BlueZ agent failed: %s", exc)
|
||||
bus.remove_message_handler(agent.handle)
|
||||
bus.disconnect()
|
||||
|
||||
|
||||
async def _call(bus: Any, path: str, interface: str, member: str, signature: str = "", body=None):
|
||||
"""Call a BlueZ method and return the raw reply (errors are left for the caller)."""
|
||||
from dbus_fast import Message
|
||||
|
||||
return await bus.call(
|
||||
Message(
|
||||
destination=_BLUEZ,
|
||||
path=path,
|
||||
interface=interface,
|
||||
member=member,
|
||||
signature=signature,
|
||||
body=body or [],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _is_error(reply: Any) -> bool:
|
||||
from dbus_fast import MessageType
|
||||
|
||||
return reply.message_type == MessageType.ERROR
|
||||
|
||||
|
||||
async def _objects(bus: Any) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
"""Every BlueZ object, from ``GetManagedObjects`` (empty when BlueZ didn't answer)."""
|
||||
reply = await _call(bus, "/", _OBJECT_MANAGER_IFACE, "GetManagedObjects")
|
||||
if _is_error(reply) or not reply.body:
|
||||
return {}
|
||||
return reply.body[0]
|
||||
|
||||
|
||||
def _value(props: dict[str, Any], name: str) -> Any:
|
||||
"""A property's plain value, unwrapping the ``Variant`` BlueZ sends."""
|
||||
raw = props.get(name)
|
||||
return getattr(raw, "value", raw)
|
||||
|
||||
|
||||
def _device_in(objects: dict[str, dict[str, dict[str, Any]]], address: str) -> str | None:
|
||||
"""The object path of the device at ``address``, if BlueZ knows it."""
|
||||
want = _normal(address)
|
||||
for path, interfaces in objects.items():
|
||||
props = interfaces.get(_DEVICE_IFACE)
|
||||
if props is not None and str(_value(props, "Address") or "").upper() == want:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _adapter_in(objects: dict[str, dict[str, dict[str, Any]]]) -> str | None:
|
||||
"""The first Bluetooth adapter's object path."""
|
||||
return next((path for path, ifaces in objects.items() if _ADAPTER_IFACE in ifaces), None)
|
||||
|
||||
|
||||
async def _find_device(bus: Any, address: str, discover_s: float) -> tuple[str | None, bool]:
|
||||
"""Find the device's object path, listening for it briefly if BlueZ has forgotten it.
|
||||
|
||||
Returns:
|
||||
``(path, paired)`` — ``path`` is ``None`` when it could not be heard.
|
||||
"""
|
||||
objects = await _objects(bus)
|
||||
path = _device_in(objects, address)
|
||||
if path is None:
|
||||
adapter = _adapter_in(objects)
|
||||
if adapter is None:
|
||||
return None, False
|
||||
# A refusal here is fine: bleak or the desktop may already be discovering, which
|
||||
# is all we need.
|
||||
await _call(bus, adapter, _ADAPTER_IFACE, "StartDiscovery")
|
||||
try:
|
||||
deadline = asyncio.get_running_loop().time() + discover_s
|
||||
while path is None and asyncio.get_running_loop().time() < deadline:
|
||||
await asyncio.sleep(0.5)
|
||||
objects = await _objects(bus)
|
||||
path = _device_in(objects, address)
|
||||
finally:
|
||||
await _call(bus, adapter, _ADAPTER_IFACE, "StopDiscovery")
|
||||
if path is None:
|
||||
return None, False
|
||||
return path, bool(_value(objects[path][_DEVICE_IFACE], "Paired"))
|
||||
|
||||
|
||||
def _adapter_of(objects: dict[str, dict[str, dict[str, Any]]], path: str) -> str | None:
|
||||
"""The adapter a device hangs off — its own ``Adapter`` property, else the first one."""
|
||||
own = _value(objects.get(path, {}).get(_DEVICE_IFACE, {}), "Adapter")
|
||||
return str(own) if own else _adapter_in(objects)
|
||||
|
||||
|
||||
async def _remove(bus: Any, path: str) -> Any:
|
||||
"""Forget a device's bond (``Adapter1.RemoveDevice``), which also forgets the device.
|
||||
|
||||
Returns:
|
||||
The raw reply, or ``None`` when there was no adapter to ask.
|
||||
"""
|
||||
adapter = _adapter_of(await _objects(bus), path)
|
||||
if adapter is None:
|
||||
return None
|
||||
return await _call(bus, adapter, _ADAPTER_IFACE, "RemoveDevice", "o", [path])
|
||||
|
||||
|
||||
async def _system_bus() -> Any:
|
||||
from dbus_fast import BusType
|
||||
from dbus_fast.aio import MessageBus
|
||||
|
||||
return await MessageBus(bus_type=BusType.SYSTEM).connect()
|
||||
|
||||
|
||||
async def pair(
|
||||
address: str,
|
||||
pin: str,
|
||||
*,
|
||||
force: bool,
|
||||
discover_s: float = DISCOVER_S,
|
||||
pair_timeout_s: float = PAIR_TIMEOUT_S,
|
||||
) -> tuple[str, str]:
|
||||
"""Pair the companion at ``address`` using ``pin``, through our own BlueZ agent.
|
||||
|
||||
Mirrors the Windows ceremony's contract: an existing bond is trusted unless ``force``,
|
||||
which tears it down and pairs afresh — the heal for a bond the device has since lost.
|
||||
|
||||
Args:
|
||||
address: The companion's Bluetooth address.
|
||||
pin: Its pairing PIN (six digits).
|
||||
force: Whether to remove an existing bond first.
|
||||
discover_s: How long to listen for a device BlueZ has forgotten.
|
||||
pair_timeout_s: Bound on the ``Pair`` call.
|
||||
|
||||
Returns:
|
||||
``(outcome, status)`` in :class:`~meshterm.core.connection._BlePairing`'s vocabulary:
|
||||
``"reused"``, ``"paired"``, ``"absent"``, ``"failed"`` with the BlueZ status, or
|
||||
``"error"`` with what went wrong.
|
||||
"""
|
||||
if not pin.isdigit():
|
||||
return "failed", "authentication failed" # a passkey is a number; this can't be it
|
||||
try:
|
||||
bus = await _system_bus()
|
||||
except Exception as exc: # noqa: BLE001 - no system bus / no dbus_fast: nothing to pair with
|
||||
return "error", f"couldn't reach BlueZ: {exc}"
|
||||
try:
|
||||
path, paired = await _find_device(bus, address, discover_s)
|
||||
if path is None:
|
||||
return "absent", ""
|
||||
if paired:
|
||||
if not force:
|
||||
return "reused", ""
|
||||
await _remove(bus, path)
|
||||
path, _ = await _find_device(bus, address, discover_s)
|
||||
if path is None:
|
||||
return "absent", ""
|
||||
agent = PinAgent(f"/net/meshterm/agent{os.getpid()}", pin, path)
|
||||
paired_link: str | None = None # set once Pair has been asked to open a link
|
||||
bus.add_message_handler(agent.handle)
|
||||
try:
|
||||
reply = await _call(
|
||||
bus, _AGENTS, _AGENT_MANAGER_IFACE, "RegisterAgent", "os",
|
||||
[agent.path, AGENT_CAPABILITY],
|
||||
) # fmt: skip
|
||||
if _is_error(reply):
|
||||
return "error", status_name(reply.error_name, reply.body)
|
||||
paired_link = path
|
||||
for attempt in range(PAIR_ATTEMPTS):
|
||||
if attempt:
|
||||
await asyncio.sleep(PAIR_RETRY_DELAY_S)
|
||||
try:
|
||||
reply = await asyncio.wait_for(
|
||||
_call(bus, path, _DEVICE_IFACE, "Pair"), pair_timeout_s
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await _call(bus, path, _DEVICE_IFACE, "CancelPairing")
|
||||
return "failed", "authentication timeout"
|
||||
if not (_is_error(reply) and _link_flake(reply)):
|
||||
break
|
||||
_log.debug("BlueZ Pair lost its link (attempt %d/%d)", attempt + 1, PAIR_ATTEMPTS)
|
||||
if _is_error(reply) and reply.error_name != _ALREADY_EXISTS:
|
||||
status = status_name(reply.error_name, reply.body)
|
||||
if not agent.asked and status == "authentication failed":
|
||||
# Refused before the passkey stage: the PIN was never in question.
|
||||
status = "authentication rejected"
|
||||
return "failed", status
|
||||
# Trusted lets later connections through without an agent to authorize them.
|
||||
from dbus_fast import Variant
|
||||
|
||||
await _call(
|
||||
bus, path, _PROPERTIES_IFACE, "Set", "ssv",
|
||||
[_DEVICE_IFACE, "Trusted", Variant("b", True)],
|
||||
) # fmt: skip
|
||||
return "paired", ""
|
||||
finally:
|
||||
if paired_link is not None:
|
||||
# ``Pair`` opens a link to pair over and BlueZ leaves it up. A companion that
|
||||
# is connected stops advertising, and the connect that follows looks the
|
||||
# device up by scanning for it, so it never finds it — measured on a
|
||||
# uConsole: paired, then "couldn't open a link" after two 30 s tries. Put
|
||||
# the link down so the connect meets an advertising device, as it would
|
||||
# have without us.
|
||||
await _call(bus, paired_link, _DEVICE_IFACE, "Disconnect")
|
||||
await _call(bus, _AGENTS, _AGENT_MANAGER_IFACE, "UnregisterAgent", "o", [agent.path])
|
||||
bus.remove_message_handler(agent.handle)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort: the connect reports the refusal
|
||||
return "error", str(exc)
|
||||
finally:
|
||||
bus.disconnect()
|
||||
|
||||
|
||||
async def is_paired(address: str) -> bool:
|
||||
"""Whether BlueZ holds a bond for ``address``. ``False`` on any failure to ask."""
|
||||
try:
|
||||
bus = await _system_bus()
|
||||
except Exception as exc: # noqa: BLE001 - no bus: nothing is known to be paired
|
||||
_log.debug("BlueZ pairing query unavailable: %s", exc)
|
||||
return False
|
||||
try:
|
||||
objects = await _objects(bus)
|
||||
path = _device_in(objects, address)
|
||||
return path is not None and bool(_value(objects[path][_DEVICE_IFACE], "Paired"))
|
||||
except Exception as exc: # noqa: BLE001 - a status hiccup is not a bond
|
||||
_log.debug("BlueZ pairing query failed for %s: %s", address, exc)
|
||||
return False
|
||||
finally:
|
||||
bus.disconnect()
|
||||
|
||||
|
||||
async def unpair(address: str) -> bool:
|
||||
"""Forget BlueZ's bond for ``address``. ``True`` only if a bond was removed."""
|
||||
try:
|
||||
bus = await _system_bus()
|
||||
except Exception as exc: # noqa: BLE001 - no bus: nothing to remove
|
||||
_log.debug("BlueZ unpair unavailable: %s", exc)
|
||||
return False
|
||||
try:
|
||||
objects = await _objects(bus)
|
||||
path = _device_in(objects, address)
|
||||
if path is None or not _value(objects[path][_DEVICE_IFACE], "Paired"):
|
||||
return False
|
||||
reply = await _remove(bus, path)
|
||||
return reply is not None and not _is_error(reply)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort, like the Windows twin
|
||||
_log.debug("BlueZ unpair failed for %s: %s", address, exc)
|
||||
return False
|
||||
finally:
|
||||
bus.disconnect()
|
||||
@@ -29,7 +29,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .channels import (
|
||||
@@ -52,6 +52,10 @@ class RememberedChannel:
|
||||
name: str
|
||||
secret: bytes
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@property
|
||||
def identity(self) -> str:
|
||||
"""The channel's slot-independent intrinsic identity (see :func:`channel_identity`)."""
|
||||
|
||||
+133
-3
@@ -20,7 +20,7 @@ import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
@@ -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.
|
||||
@@ -56,6 +67,104 @@ def default_config_dir() -> Path:
|
||||
return Path.home() / ".meshterm"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SpiWiring:
|
||||
"""How a LoRa chip on the host's SPI bus is wired, for a ``transport = "spi"`` profile.
|
||||
|
||||
The defaults are the hackergadgets uConsole AIO v1's wiring, so that board needs no
|
||||
``[profiles.<name>.spi]`` table at all; any other board states only what differs. These
|
||||
are facts about the *board*, which is why they live in ``config.toml`` with the profile
|
||||
and not among the radio's settings: the frequency, bandwidth and power are the node's
|
||||
own, saved by the node and edited on Device config like any companion's.
|
||||
|
||||
Attributes:
|
||||
bus_id: SPI bus (``/dev/spidev<bus_id>.<cs_id>``).
|
||||
cs_id: SPI chip-select device on that bus.
|
||||
cs_pin: A GPIO driven as chip select by hand, or ``-1`` for the bus's own.
|
||||
gpio_chip: Which ``/dev/gpiochip<n>`` the pins below are on.
|
||||
use_gpiod_backend: Drive the pins through ``gpiod`` instead of ``python-periphery``.
|
||||
reset_pin: The chip's reset line.
|
||||
busy_pin: The chip's busy line.
|
||||
irq_pin: The chip's interrupt line (DIO1).
|
||||
txen_pin: A transmit-enable line for an external RF switch, or ``-1``.
|
||||
rxen_pin: A receive-enable line for an external RF switch, or ``-1``.
|
||||
en_pins: Power-enable lines raised before the chip is touched (the AIO v2 wants
|
||||
``[27]``); empty for none.
|
||||
use_dio2_rf: Whether DIO2 drives the RF switch.
|
||||
use_dio3_tcxo: Whether DIO3 powers a TCXO.
|
||||
is_waveshare: The Waveshare HAT's wiring quirks, which the radio library knows.
|
||||
python: An interpreter to run the node under, when the one MeshTerm would find
|
||||
is not the one you want. Empty to let MeshTerm look.
|
||||
"""
|
||||
|
||||
bus_id: int = 1
|
||||
cs_id: int = 0
|
||||
cs_pin: int = -1
|
||||
gpio_chip: int = 0
|
||||
use_gpiod_backend: bool = False
|
||||
reset_pin: int = 25
|
||||
busy_pin: int = 24
|
||||
irq_pin: int = 26
|
||||
txen_pin: int = -1
|
||||
rxen_pin: int = -1
|
||||
en_pins: tuple[int, ...] = ()
|
||||
use_dio2_rf: bool = True
|
||||
use_dio3_tcxo: bool = True
|
||||
is_waveshare: bool = False
|
||||
python: str = ""
|
||||
|
||||
#: Keys this table once took and must never take again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED`). ``preamble_length`` was never wiring: it is
|
||||
#: protocol, and a fixed 12 against a mesh sending 32 is what left the node deaf to
|
||||
#: most traffic; the node now derives it from the spreading factor as MeshCore does.
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset({"preamble_length"})
|
||||
|
||||
@classmethod
|
||||
def from_toml(cls, table: dict[str, Any]) -> SpiWiring:
|
||||
"""Build the wiring from a ``[profiles.<name>.spi]`` table, defaults for the rest.
|
||||
|
||||
A key this dataclass doesn't know is refused rather than ignored: a misspelt
|
||||
``irq_pn = 22`` would otherwise leave the default in force and a deaf radio with
|
||||
nothing to say why.
|
||||
|
||||
Raises:
|
||||
ValueError: On an unknown key or a value of the wrong type.
|
||||
"""
|
||||
known = {f.name: f for f in cls.__dataclass_fields__.values()}
|
||||
retired = sorted(set(table) & cls.RETIRED)
|
||||
if retired:
|
||||
raise ValueError(
|
||||
f"{', '.join(retired)} is no longer a wiring key — the node sets the "
|
||||
"preamble from the spreading factor, as MeshCore does; delete the line"
|
||||
)
|
||||
unknown = sorted(set(table) - set(known))
|
||||
if unknown:
|
||||
raise ValueError(f"unknown SPI wiring key(s): {', '.join(unknown)}")
|
||||
values: dict[str, Any] = {}
|
||||
for key, raw in table.items():
|
||||
default = getattr(cls(), key)
|
||||
if isinstance(default, bool):
|
||||
ok = isinstance(raw, bool)
|
||||
elif isinstance(default, int):
|
||||
ok = isinstance(raw, int) and not isinstance(raw, bool)
|
||||
elif isinstance(default, tuple):
|
||||
ok = isinstance(raw, list) and all(
|
||||
isinstance(p, int) and not isinstance(p, bool) for p in raw
|
||||
)
|
||||
raw = tuple(raw) if ok else raw
|
||||
else:
|
||||
ok = isinstance(raw, str)
|
||||
if not ok:
|
||||
raise ValueError(f"SPI wiring {key} = {raw!r} is not a {type(default).__name__}")
|
||||
values[key] = raw
|
||||
return cls(**values)
|
||||
|
||||
@property
|
||||
def spidev(self) -> str:
|
||||
"""The SPI device node this wiring opens."""
|
||||
return f"/dev/spidev{self.bus_id}.{self.cs_id}"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeviceProfile:
|
||||
"""Connection defaults for one physical companion device.
|
||||
@@ -77,6 +186,8 @@ class DeviceProfile:
|
||||
host: Hostname or IP of a network companion (e.g. ``"192.168.1.50"``); TCP profiles.
|
||||
tcp_port: TCP port the network companion listens on; TCP profiles (defaults to
|
||||
:data:`~meshterm.core.discovery.DEFAULT_TCP_PORT` when a host is given without one).
|
||||
spi: How the radio is wired, for ``transport = "spi"`` — a LoRa chip on the host's
|
||||
own SPI bus, run by a node MeshTerm starts itself (:mod:`meshterm.core.spiradio`).
|
||||
"""
|
||||
|
||||
name: str
|
||||
@@ -89,6 +200,12 @@ class DeviceProfile:
|
||||
ble_pin: str | None = None
|
||||
host: str | None = None
|
||||
tcp_port: int | None = None
|
||||
spi: SpiWiring | None = None
|
||||
|
||||
@property
|
||||
def is_spi(self) -> bool:
|
||||
"""Whether this profile addresses a radio on the host's own SPI bus."""
|
||||
return self.transport == "spi"
|
||||
|
||||
@property
|
||||
def is_ble(self) -> bool:
|
||||
@@ -138,7 +255,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.
|
||||
@@ -180,9 +297,21 @@ class Settings:
|
||||
# keeps port-only profiles working untouched while a bare ``host``/``address`` is
|
||||
# enough to declare a network/Bluetooth one.
|
||||
transport = pdata.get("transport") or (
|
||||
"tcp" if pdata.get("host") else "ble" if pdata.get("address") else "serial"
|
||||
"spi"
|
||||
if "spi" in pdata
|
||||
else "tcp"
|
||||
if pdata.get("host")
|
||||
else "ble"
|
||||
if pdata.get("address")
|
||||
else "serial"
|
||||
)
|
||||
tcp_port = pdata.get("tcp_port")
|
||||
spi = None
|
||||
if transport == "spi":
|
||||
try:
|
||||
spi = SpiWiring.from_toml(pdata.get("spi") or {})
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"[profiles.{pname}.spi]: {exc}") from exc
|
||||
profiles[pname] = DeviceProfile(
|
||||
name=pname,
|
||||
port=pdata.get("port"),
|
||||
@@ -194,6 +323,7 @@ class Settings:
|
||||
ble_pin=pdata.get("ble_pin"),
|
||||
host=pdata.get("host"),
|
||||
tcp_port=int(tcp_port) if tcp_port is not None else None,
|
||||
spi=spi,
|
||||
)
|
||||
|
||||
return cls(
|
||||
|
||||
+1217
-64
File diff suppressed because it is too large.
Load diff
@@ -56,6 +56,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .models import Contact, advert_time
|
||||
@@ -118,6 +119,10 @@ class RememberedContact:
|
||||
archived_at: int | None = None
|
||||
locked: bool = False
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@property
|
||||
def archived(self) -> bool:
|
||||
"""Whether this contact has been swept off the device but kept here."""
|
||||
|
||||
@@ -19,6 +19,7 @@ import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .models import utcnow
|
||||
@@ -66,6 +67,10 @@ class QueuedMessage:
|
||||
status: str = QUEUED
|
||||
finished: datetime | None = None
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
|
||||
class CourierStore:
|
||||
"""Reads and writes the outbox, memory-first (loaded once, persisted on change)."""
|
||||
|
||||
@@ -18,6 +18,7 @@ from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from . import regions
|
||||
from .connection import Device, repeat_freq_allowed
|
||||
|
||||
# Display categories, in the order the editor and `config show` present them.
|
||||
@@ -377,6 +378,21 @@ async def _client_repeat_apply(device: Device, value: Any, snapshot: dict) -> No
|
||||
snapshot["repeat"] = bool(value)
|
||||
|
||||
|
||||
def _flood_scope_valid(value: Any, snapshot: dict | None) -> str | None:
|
||||
"""Refuse a default scope name the firmware would refuse (see :func:`regions.validate`).
|
||||
|
||||
``max_length`` counts characters; the firmware counts UTF-8 bytes, and a repeater could
|
||||
never list back a name with a space or comma in it. Empty is valid — it clears.
|
||||
"""
|
||||
if not str(value or "").strip():
|
||||
return None
|
||||
try:
|
||||
regions.validate(str(value))
|
||||
except regions.RegionNameError as exc:
|
||||
return str(exc)
|
||||
return None
|
||||
|
||||
|
||||
async def _autoadd_hops_apply(device: Device, value: Any, snapshot: dict) -> None:
|
||||
"""Set the auto-add hop limit, restating the bitmask it travels behind."""
|
||||
flags = snapshot.get("autoadd_config")
|
||||
@@ -744,10 +760,11 @@ DEVICE_SETTINGS: list[SettingSpec] = [
|
||||
SettingSpec(
|
||||
"flood_scope",
|
||||
"Flood scope",
|
||||
"Keep traffic to one group; empty reaches everyone",
|
||||
"Keep floods in one region; empty reaches everyone",
|
||||
"Behavior",
|
||||
"str",
|
||||
max_length=30,
|
||||
validate=_flood_scope_valid,
|
||||
getter=_get("flood_scope"),
|
||||
apply=lambda d, v, s: d.set_default_flood_scope(v),
|
||||
),
|
||||
@@ -795,6 +812,18 @@ DEVICE_SETTINGS: list[SettingSpec] = [
|
||||
|
||||
_BY_KEY: dict[str, SettingSpec] = {s.key: s for s in DEVICE_SETTINGS}
|
||||
|
||||
#: Every key that was once a device setting and no longer is — none yet. A retired key is
|
||||
#: never reused, for the reason :data:`meshterm.core.preferences.RETIRED` gives, and with
|
||||
#: more at stake here: the settings store remembers values to *restore onto the radio*, so a
|
||||
#: key that came back meaning something else would offer to write the old value's number
|
||||
#: into the new setting. ``tests/test_retired.py`` fails a registry that takes one back.
|
||||
RETIRED: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def is_setting_key(key: str) -> bool:
|
||||
"""Whether ``key`` names a device setting in the registry (and not a retired one)."""
|
||||
return key in _BY_KEY
|
||||
|
||||
|
||||
def get_spec(key: str) -> SettingSpec:
|
||||
"""Return the spec for ``key``.
|
||||
|
||||
@@ -28,6 +28,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .discovery import (
|
||||
@@ -75,6 +76,10 @@ class RememberedDevice:
|
||||
host: str = ""
|
||||
tcp_port: int = 0
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@property
|
||||
def is_ble(self) -> bool:
|
||||
"""Whether this remembered device was reached over Bluetooth LE."""
|
||||
|
||||
@@ -36,6 +36,9 @@ _log = logging.getLogger(__name__)
|
||||
TRANSPORT_SERIAL = "serial"
|
||||
TRANSPORT_BLE = "ble"
|
||||
TRANSPORT_TCP = "tcp"
|
||||
#: A LoRa chip on the host's own SPI bus, run by a node MeshTerm starts itself
|
||||
#: (:mod:`meshterm.core.spiradio`). Found by its ``/dev/spidev*`` node, like a serial port.
|
||||
TRANSPORT_SPI = "spi"
|
||||
#: The built-in simulator, as a device inventory reports it under ``--mock``. Never
|
||||
#: discovered — there is nothing to find — but a session that promised no real hardware
|
||||
#: still has exactly one device, and this is what it is called.
|
||||
@@ -53,6 +56,32 @@ BLE_NAME_PREFIX = "MeshCore"
|
||||
#: advertisement, short enough not to stall the startup splash noticeably.
|
||||
BLE_SCAN_TIMEOUT_S = 5.0
|
||||
|
||||
try: # bleak >= 3 only; the floor in pyproject is 0.22, so this must not be assumed
|
||||
from bleak.exc import BleakBluetoothNotAvailableError as _BleakNotAvailable
|
||||
|
||||
_BLE_NOT_AVAILABLE: tuple[type[BaseException], ...] = (_BleakNotAvailable,)
|
||||
except Exception: # noqa: BLE001 - older bleak, or bleak absent entirely
|
||||
# An empty tuple in ``except`` matches nothing, so older installs fall straight through
|
||||
# to the broad handler and behave exactly as they did before.
|
||||
_BLE_NOT_AVAILABLE = ()
|
||||
|
||||
#: Why the last BLE scan could not run, when the cause is something the reader can act on
|
||||
#: (Bluetooth off; on macOS, permission not granted to the terminal running us). ``None``
|
||||
#: when the scan worked — including when it simply heard nothing, which is not a fault.
|
||||
_ble_unavailable: str | None = None
|
||||
|
||||
|
||||
def ble_unavailable_reason() -> str | None:
|
||||
"""Why the last BLE scan was refused, if it was, as a sentence fit to show the reader.
|
||||
|
||||
Returns:
|
||||
The reason the most recent :func:`discover_ble_devices` could not scan, or ``None``
|
||||
if it ran — whether or not it found anything. Reset at the start of every scan, so
|
||||
it always describes the latest attempt rather than a stale one.
|
||||
"""
|
||||
return _ble_unavailable
|
||||
|
||||
|
||||
#: Default seconds to watch for one *known* companion's advertisement while waiting for it to
|
||||
#: come back (see :func:`find_ble_device`). Deliberately longer than the startup scan: nothing
|
||||
#: is waiting on it, the watch ends the instant the device is heard, and a companion that just
|
||||
@@ -101,7 +130,7 @@ class DiscoveredDevice:
|
||||
serial_number: USB serial number, if exposed by the device.
|
||||
manufacturer: USB manufacturer string, if available.
|
||||
product: USB product string, if available.
|
||||
transport: ``"serial"``, ``"ble"``, or ``"tcp"`` — the connection layer for this device.
|
||||
transport: ``"serial"``, ``"ble"``, ``"tcp"``, or ``"spi"`` — the connection layer.
|
||||
address: Bluetooth address for a BLE device (e.g. ``AA:BB:CC:DD:EE:FF``).
|
||||
name: Advertised BLE local name (e.g. ``"MeshCore-Basestation"``); BLE only.
|
||||
ble_device: The live ``bleak.BLEDevice`` the scan produced; BLE only, and only for
|
||||
@@ -113,6 +142,10 @@ class DiscoveredDevice:
|
||||
persisted.
|
||||
host: Hostname or IP of a TCP companion (e.g. ``"192.168.1.50"``); TCP only.
|
||||
tcp_port: TCP port the companion listens on (e.g. ``5000``); TCP only.
|
||||
spi: The :class:`~meshterm.core.config.SpiWiring` of a radio on the host's SPI bus;
|
||||
SPI only. Its ``port`` is the SPI device node (``/dev/spidev1.0``), which is what
|
||||
such a radio is named and remembered by. Typed ``object`` to keep this module
|
||||
free of the config import.
|
||||
"""
|
||||
|
||||
port: str = ""
|
||||
@@ -129,6 +162,12 @@ class DiscoveredDevice:
|
||||
ble_device: object | None = None
|
||||
host: str | None = None
|
||||
tcp_port: int | None = None
|
||||
spi: object | None = None
|
||||
|
||||
@property
|
||||
def is_spi(self) -> bool:
|
||||
"""Whether this device is a radio on the host's own SPI bus, run by MeshTerm's node."""
|
||||
return self.transport == TRANSPORT_SPI
|
||||
|
||||
@property
|
||||
def is_ble(self) -> bool:
|
||||
@@ -168,6 +207,8 @@ class DiscoveredDevice:
|
||||
return f"tcp:{self.host}:{self.tcp_port}"
|
||||
if self.transport == TRANSPORT_MOCK:
|
||||
return "mock:simulator"
|
||||
if self.is_spi:
|
||||
return f"spi:{self.port}"
|
||||
if self.is_ble:
|
||||
return f"ble:{(self.address or self.name or '').lower()}"
|
||||
if self.serial_number:
|
||||
@@ -186,8 +227,8 @@ class DiscoveredDevice:
|
||||
``"bridge"`` for a generic USB-UART chip (a weak hint — could be anything), or
|
||||
``"unknown"`` for an unrecognized adapter.
|
||||
"""
|
||||
if self.is_ble or self.is_tcp:
|
||||
return "board" # a MeshCore BLE advert / a user-named TCP endpoint is confident
|
||||
if self.is_ble or self.is_tcp or self.is_spi:
|
||||
return "board" # a MeshCore advert / a named endpoint / a wired radio is confident
|
||||
if self.vid in NATIVE_LORA_VIDS:
|
||||
return "board"
|
||||
if self.vid in UART_BRIDGE_VIDS:
|
||||
@@ -210,7 +251,7 @@ class DiscoveredDevice:
|
||||
its USB vendor ID to a friendly name, falling back to the driver's manufacturer
|
||||
string.
|
||||
"""
|
||||
if self.is_ble or self.is_tcp:
|
||||
if self.is_ble or self.is_tcp or self.is_spi:
|
||||
return self.manufacturer or ""
|
||||
if self.vid in KNOWN_LORA_VIDS:
|
||||
return KNOWN_LORA_VIDS[self.vid]
|
||||
@@ -229,7 +270,11 @@ class DiscoveredDevice:
|
||||
if self.is_ble:
|
||||
name = self.name or self.product or self.description or "Bluetooth device"
|
||||
return f"{name} (BLE)"
|
||||
if self.is_spi:
|
||||
return f"{self.name or 'SPI radio'} ({self.port})"
|
||||
name = self.product or self.description or self.vendor_label or "Serial device"
|
||||
if not self.port: # the simulator has nothing to point at, so no "()"
|
||||
return name
|
||||
suffix = f"({self.port})"
|
||||
if name.endswith(suffix): # avoid "… (COM11) (COM11)"
|
||||
name = name[: -len(suffix)].rstrip()
|
||||
@@ -393,6 +438,9 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc
|
||||
Returns:
|
||||
Discovered BLE companions (deduplicated by address), sorted by name.
|
||||
"""
|
||||
global _ble_unavailable
|
||||
_ble_unavailable = None
|
||||
|
||||
try:
|
||||
from bleak import BleakScanner
|
||||
except ImportError: # bleak not installed → BLE simply unavailable
|
||||
@@ -400,6 +448,16 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc
|
||||
|
||||
try:
|
||||
found = await BleakScanner.discover(timeout=timeout, return_adv=True)
|
||||
except _BLE_NOT_AVAILABLE as exc:
|
||||
# A *state the reader can fix* — Bluetooth switched off, or (on macOS, routinely)
|
||||
# the terminal running us has not been granted Bluetooth. That is not the "no
|
||||
# adapter / driver hiccup" the broad catch below is for, and returning [] silently
|
||||
# made it indistinguishable from "nothing is nearby": the one message that would
|
||||
# have told the user what to do was thrown away at debug level, under a log that
|
||||
# defaults to WARNING. Keep the sentence bleak wrote — it already names the remedy.
|
||||
_ble_unavailable = str(getattr(exc, "args", [None])[0] or exc)
|
||||
_log.warning("BLE unavailable: %s", _ble_unavailable)
|
||||
return []
|
||||
except Exception as exc: # noqa: BLE001 - no adapter / OS Bluetooth off / driver hiccup
|
||||
_log.debug("BLE scan unavailable: %s", exc)
|
||||
return []
|
||||
@@ -490,3 +548,27 @@ async def discover_all(
|
||||
# (USB serial number vs BLE address), so no cross-transport dedup is needed here.
|
||||
ble_devices = await discover_ble_devices(ble_timeout)
|
||||
return serial + ble_devices
|
||||
|
||||
|
||||
def spi_device(wiring: object, *, name: str = "") -> DiscoveredDevice:
|
||||
"""Build the :class:`DiscoveredDevice` for a radio on the host's own SPI bus.
|
||||
|
||||
Such a radio is "found" the way a serial port is — its ``/dev/spidev*`` node exists —
|
||||
but nothing answers on it until MeshTerm starts a node, so it is listed from its wiring
|
||||
(a profile's, or the AIO's defaults) rather than probed.
|
||||
|
||||
Args:
|
||||
wiring: The radio's :class:`~meshterm.core.config.SpiWiring`.
|
||||
name: Optional friendly name for display (a profile's description, a node name).
|
||||
|
||||
Returns:
|
||||
An SPI :class:`DiscoveredDevice` ready to select or connect.
|
||||
"""
|
||||
return DiscoveredDevice(
|
||||
transport=TRANSPORT_SPI,
|
||||
port=wiring.spidev, # type: ignore[attr-defined]
|
||||
spi=wiring,
|
||||
name=name or None,
|
||||
description=name or "SPI radio",
|
||||
product=name or None,
|
||||
)
|
||||
@@ -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
|
||||
+17
-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.
|
||||
|
||||
@@ -395,6 +405,10 @@ class ChatMessage:
|
||||
created_at: When the message was sent or received.
|
||||
row_id: The ``messages`` table primary key once persisted, used to update an
|
||||
outbound message's delivery state in place on retry; ``None`` until stored.
|
||||
scope: What an outbound channel message was flooded under: the bare region name,
|
||||
:data:`~meshterm.core.regions.WILDCARD` (``*``) for unscoped, or ``None`` where
|
||||
it isn't known — every inbound or direct message, and anything sent before the
|
||||
scope was recorded.
|
||||
"""
|
||||
|
||||
text: str
|
||||
@@ -408,6 +422,7 @@ class ChatMessage:
|
||||
acked: bool | None = None
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
row_id: int | None = None
|
||||
scope: str | None = None
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
|
||||
@@ -40,12 +40,14 @@ Two access paths, deliberately:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .advert_store import DEFAULT_QUIET_S, QUIET_CHOICES_S
|
||||
from .atomicwrite import write_atomically
|
||||
from .courier_store import DONE_CAP
|
||||
from .geo import DEFAULT_VIEW_FRACTION
|
||||
@@ -151,6 +153,10 @@ _SILENCE_CHOICES: dict[int, str] = {
|
||||
OFF: "off",
|
||||
}
|
||||
|
||||
#: How long the air must stay quiet before the weekly flood advert goes out, drawn from the
|
||||
#: scheduler's own choices so the two never drift.
|
||||
_QUIET_CHOICES: dict[int, str] = {s: f"{s} s" for s in QUIET_CHOICES_S}
|
||||
|
||||
#: How the terminal-width reclaim can be asked for: follow the platform's own verdict, or
|
||||
#: overrule it in either direction. The row's description asks a yes/no question ("use the
|
||||
#: column ... ?"), so the values answer it in those words rather than naming the mechanism
|
||||
@@ -241,6 +247,26 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
maximum=3600.0,
|
||||
unit="s",
|
||||
),
|
||||
PrefSpec(
|
||||
key="weekly_flood_advert",
|
||||
label="Weekly advert",
|
||||
help="Flood an advert after a week without one",
|
||||
group="Sending",
|
||||
value_type="bool",
|
||||
# Off: a companion never advertises on its own, so this is the only advert MeshTerm
|
||||
# would ever send unasked, and every repeater in range rebroadcasts it. Turning it
|
||||
# on starts the week rather than sending — see meshterm.services.advert_scheduler.
|
||||
default=False,
|
||||
),
|
||||
PrefSpec(
|
||||
key="advert_quiet_s",
|
||||
label="Advert quiet",
|
||||
help="Silence to wait for before the weekly advert",
|
||||
group="Sending",
|
||||
value_type="enum",
|
||||
default=DEFAULT_QUIET_S,
|
||||
choices=_QUIET_CHOICES,
|
||||
),
|
||||
PrefSpec(
|
||||
key="direct_message_soft_retries",
|
||||
label="Message retries",
|
||||
@@ -449,6 +475,28 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
|
||||
_BY_KEY: dict[str, PrefSpec] = {spec.key: spec for spec in PREFERENCES}
|
||||
|
||||
#: Every key that was once a preference and no longer is. A retired key is never reused:
|
||||
#: a value can outlive its key in a file nobody saved since, and if the name came back
|
||||
#: with a new meaning — seconds become minutes, a range moves — an old ``30`` that still
|
||||
#: passes the new spec would load cleanly and mean something else, which no validation
|
||||
#: can catch. So a preference that returns changed returns under a new name (a unit in the
|
||||
#: key makes that the natural move: ``_s`` to ``_min``), and ``tests/test_retired.py``
|
||||
#: fails any registry that takes one of these back. Loading refuses them outright.
|
||||
RETIRED: frozenset[str] = frozenset(
|
||||
{
|
||||
# Per-device background advert cadences, moved to Device config and since replaced
|
||||
# by the weekly flood advert (``weekly_flood_advert``).
|
||||
"advert_direct_hours",
|
||||
"advert_flood_hours",
|
||||
}
|
||||
)
|
||||
|
||||
#: The reason :attr:`Preferences.dropped` gives for a key found in :data:`RETIRED`.
|
||||
DROPPED_RETIRED = "retired"
|
||||
|
||||
#: The reason :attr:`Preferences.dropped` gives for a key the registry has never held.
|
||||
DROPPED_UNKNOWN = "unknown"
|
||||
|
||||
|
||||
def get_spec(key: str) -> PrefSpec:
|
||||
"""Look up one preference's spec.
|
||||
@@ -465,6 +513,8 @@ def get_spec(key: str) -> PrefSpec:
|
||||
try:
|
||||
return _BY_KEY[key]
|
||||
except KeyError:
|
||||
if key in RETIRED:
|
||||
raise PreferenceError(f"{key!r} is no longer a preference") from None
|
||||
raise PreferenceError(f"unknown preference: {key!r}") from None
|
||||
|
||||
|
||||
@@ -655,8 +705,9 @@ class Preferences:
|
||||
"""
|
||||
self._path = path
|
||||
self._values: dict[str, Any] = {}
|
||||
self._dropped: dict[str, str] = {}
|
||||
if values:
|
||||
self.update(values)
|
||||
self._absorb(values)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> Preferences:
|
||||
@@ -669,6 +720,12 @@ class Preferences:
|
||||
Args:
|
||||
path: The TOML file to read.
|
||||
|
||||
Nothing the file holds that the registry cannot take survives the next
|
||||
:meth:`save`, which writes only the overrides in force: a retired key, a key
|
||||
never registered (a typo, most likely), and a value its spec refuses are all
|
||||
left out, and :attr:`dropped` says which and why, for the caller to log once
|
||||
logging is configured (see :func:`report_dropped`).
|
||||
|
||||
Returns:
|
||||
The loaded preferences, bound to ``path`` for a later :meth:`save`.
|
||||
"""
|
||||
@@ -682,6 +739,27 @@ class Preferences:
|
||||
data = _salvage(text)
|
||||
return cls(path, data)
|
||||
|
||||
def _absorb(self, values: Mapping[str, Any]) -> None:
|
||||
"""Take in a file's entries, recording in :attr:`dropped` each one refused."""
|
||||
for raw_key, value in values.items():
|
||||
key = str(raw_key)
|
||||
if key in RETIRED:
|
||||
self._dropped[key] = DROPPED_RETIRED
|
||||
continue
|
||||
try:
|
||||
self.set(key, value)
|
||||
except PreferenceError as exc:
|
||||
self._dropped[key] = str(exc) if key in _BY_KEY else DROPPED_UNKNOWN
|
||||
|
||||
@property
|
||||
def dropped(self) -> dict[str, str]:
|
||||
"""What loading refused, key by key (a copy).
|
||||
|
||||
The reason is :data:`DROPPED_RETIRED`, :data:`DROPPED_UNKNOWN`, or — for a key
|
||||
that is registered but whose value its spec refused — the refusal itself.
|
||||
"""
|
||||
return dict(self._dropped)
|
||||
|
||||
@property
|
||||
def path(self) -> Path | None:
|
||||
"""Where :meth:`save` writes, or ``None`` for an in-memory set."""
|
||||
@@ -811,6 +889,27 @@ class Preferences:
|
||||
_current: Preferences = Preferences()
|
||||
|
||||
|
||||
def report_dropped(preferences: Preferences, log: logging.Logger) -> None:
|
||||
"""Log what loading the file refused: the entries its next save will leave out.
|
||||
|
||||
A retired key is expected — it was written by an older MeshTerm — so it is noted at
|
||||
INFO. Anything else was probably typed by hand and is not doing what its author
|
||||
meant, so it is a WARNING, and the line names it so the typo can be found.
|
||||
|
||||
Args:
|
||||
preferences: A set built by :meth:`Preferences.load`.
|
||||
log: Where to report.
|
||||
"""
|
||||
name = preferences.path.name if preferences.path else PREFERENCES_FILENAME
|
||||
for key, why in preferences.dropped.items():
|
||||
if why == DROPPED_RETIRED:
|
||||
log.info("%s: ignoring retired preference %r; the next save drops it", name, key)
|
||||
elif why == DROPPED_UNKNOWN:
|
||||
log.warning("%s: ignoring unknown preference %r; the next save drops it", name, key)
|
||||
else:
|
||||
log.warning("%s: ignoring %s; the next save drops it", name, why)
|
||||
|
||||
|
||||
def current() -> Preferences:
|
||||
"""The preferences in force for this process.
|
||||
|
||||
@@ -832,6 +931,7 @@ def install(preferences: Preferences) -> None:
|
||||
__all__ = [
|
||||
"GROUPS",
|
||||
"PREFERENCES",
|
||||
"RETIRED",
|
||||
"PrefSpec",
|
||||
"PreferenceError",
|
||||
"Preferences",
|
||||
@@ -842,4 +942,5 @@ __all__ = [
|
||||
"install",
|
||||
"parse_value",
|
||||
"range_hint",
|
||||
"report_dropped",
|
||||
]
|
||||
@@ -0,0 +1,552 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The software MeshCore node MeshTerm runs on a radio wired straight to the host.
|
||||
|
||||
A board like the uConsole's AIO puts an SX1262 on the host's SPI bus with no
|
||||
microcontroller and no firmware in front of it, so there is no companion to talk to until
|
||||
something runs one. This file is that something: MeshTerm starts it as a **child process**
|
||||
when it connects (:mod:`meshterm.core.spiradio` is the parent's half), talks to it over the
|
||||
standard companion protocol on a loopback port like any network companion, and ends it when
|
||||
it disconnects — so the radio's pins are held exactly as long as MeshTerm is using them.
|
||||
|
||||
**Self-contained on purpose.** Nothing here imports MeshTerm. The radio library
|
||||
(``openhop_core``) is an optional install that often lives in a different interpreter than
|
||||
MeshTerm's — the one-file build cannot import it at all, and on a uConsole it usually sits
|
||||
in its own venv — so the parent runs *this file* by path under whichever Python has the
|
||||
library. Standard library plus ``openhop_core``, and nothing else, is the contract that
|
||||
makes that work; ``tests/test_radionode.py`` holds it.
|
||||
|
||||
**What firmware would remember, this remembers.** The library keeps preferences, channels
|
||||
and contacts in memory only, which is why a node run this way used to come up with no
|
||||
channels after every restart. Everything a firmware companion keeps in flash is kept in the
|
||||
state directory the parent names instead:
|
||||
|
||||
================= =============================================================
|
||||
``identity.key`` the node's private seed — its identity on the mesh
|
||||
``prefs.json`` name, radio settings, TX power, position, the other prefs
|
||||
``channels.json`` the channel table, slot by slot
|
||||
``contacts.json`` the contact list
|
||||
================= =============================================================
|
||||
|
||||
**Talking to the parent.** The parent reads exactly one JSON line from this process's
|
||||
stdout: ``{"event": "ready", "port": …, "public_key": …}`` once the frame server is
|
||||
listening, or ``{"event": "error", "kind": …, "message": …}`` if the radio could not be
|
||||
opened. The library prints its own diagnostics to stdout, so the real stdout is set aside
|
||||
for that one line and everything else is sent to stderr, which the parent keeps as the
|
||||
node's log.
|
||||
|
||||
**Letting go of the radio.** GPIO lines requested through the character device and an open
|
||||
``spidev`` are the kernel's to release, and it releases them when the process ends however
|
||||
it ends. The work is making sure the process *does* end with its parent: it exits when its
|
||||
stdin reaches end-of-file (the parent closing it, or the parent dying and the kernel closing
|
||||
it), and on Linux it also asks for ``SIGTERM`` the moment its parent goes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
#: The radio library this node runs on. ``pymc_core`` (its name before 2026) is not
|
||||
#: supported here: it needs three shims that ``openhop_core`` made unnecessary, and the
|
||||
#: standalone bridge still carries them for anyone on it.
|
||||
RUNTIME = "openhop_core"
|
||||
|
||||
#: How many times ``radio.begin()`` is tried when a GPIO line is busy, and the pause before
|
||||
#: each retry. A node that just exited released its lines as it went, so the retries are for
|
||||
#: a line some *other* program is letting go of — not for waiting out a program that holds it.
|
||||
BEGIN_ATTEMPTS = 3
|
||||
BEGIN_BACKOFF_S = 1.5
|
||||
|
||||
#: The model string the frame server reports, which MeshTerm shows as the device model.
|
||||
DEVICE_MODEL = "MeshTerm SPI node"
|
||||
|
||||
log = logging.getLogger("radionode")
|
||||
|
||||
|
||||
# --- errors the parent is told about -------------------------------------------------------
|
||||
|
||||
|
||||
class NodeError(Exception):
|
||||
"""A failure to bring the node up, with the ``kind`` the parent words its message by.
|
||||
|
||||
Kinds: ``runtime`` (the radio library is missing or too old), ``no-spi`` / ``no-gpio``
|
||||
(the device node doesn't exist), ``permission`` (it exists but this user can't open it),
|
||||
``busy`` (another program holds the radio's pins), and ``failed`` (anything else).
|
||||
"""
|
||||
|
||||
def __init__(self, kind: str, message: str) -> None:
|
||||
"""Carry ``message`` as the error text and ``kind`` as its classification."""
|
||||
super().__init__(message)
|
||||
self.kind = kind
|
||||
|
||||
|
||||
class _Recent(logging.Handler):
|
||||
"""Keeps the library's recent error lines, to tell *why* ``radio.begin()`` gave up.
|
||||
|
||||
The GPIO manager answers a busy or forbidden pin by logging the reason and calling
|
||||
``sys.exit``, so the reason exists only as a log line; this is where it is read back.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(level=logging.WARNING)
|
||||
self.lines: list[str] = []
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
self.lines.append(record.getMessage())
|
||||
del self.lines[:-20]
|
||||
|
||||
|
||||
def classify_begin_failure(lines: list[str]) -> str:
|
||||
"""The error kind a failed ``radio.begin()`` amounts to, from the library's log lines."""
|
||||
text = " ".join(lines).lower()
|
||||
if "already in use" in text or "resource busy" in text:
|
||||
return "busy"
|
||||
if "permission denied" in text:
|
||||
return "permission"
|
||||
return "failed"
|
||||
|
||||
|
||||
# --- the state directory -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_json(path: Path, value: Any) -> None:
|
||||
"""Replace ``path`` with ``value`` as JSON in one rename (a crash keeps the old file)."""
|
||||
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
||||
tmp.write_text(json.dumps(value, indent=2), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any:
|
||||
"""``path`` parsed as JSON, or ``None`` when it is missing or unreadable."""
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def load_identity_seed(state: Path, mint) -> bytes: # noqa: ANN001 - () -> bytes
|
||||
"""The node's private seed from ``identity.key``, minting and saving one if there is none.
|
||||
|
||||
Args:
|
||||
state: The node's state directory.
|
||||
mint: Makes a fresh seed (the library's key generator) when none is saved yet.
|
||||
"""
|
||||
path = state / "identity.key"
|
||||
try:
|
||||
seed = path.read_bytes()
|
||||
except OSError:
|
||||
seed = b""
|
||||
if seed:
|
||||
return seed
|
||||
seed = bytes(mint())
|
||||
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
||||
tmp.write_bytes(seed)
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
tmp.replace(path)
|
||||
log.info("minted a new node identity at %s", path)
|
||||
return seed
|
||||
|
||||
|
||||
def prefs_to_json(prefs: Any) -> dict:
|
||||
"""A ``NodePrefs`` as JSON-safe fields (``bytes`` as hex), for ``prefs.json``."""
|
||||
out = {}
|
||||
for field in dataclasses.fields(prefs):
|
||||
value = getattr(prefs, field.name)
|
||||
out[field.name] = value.hex() if isinstance(value, (bytes, bytearray)) else value
|
||||
return out
|
||||
|
||||
|
||||
def apply_saved_prefs(prefs: Any, saved: Any) -> None:
|
||||
"""Load ``prefs.json`` into a ``NodePrefs`` in place, field by field.
|
||||
|
||||
Only fields the library's ``NodePrefs`` still declares are taken, each coerced to the
|
||||
type its default has, so a field the library dropped is ignored and a hand-edited value
|
||||
of the wrong type is skipped rather than carried into the radio.
|
||||
"""
|
||||
if not isinstance(saved, dict):
|
||||
return
|
||||
for field in dataclasses.fields(prefs):
|
||||
if field.name not in saved:
|
||||
continue
|
||||
current = getattr(prefs, field.name)
|
||||
raw = saved[field.name]
|
||||
try:
|
||||
if isinstance(current, (bytes, bytearray)):
|
||||
value: Any = bytes.fromhex(str(raw))
|
||||
elif isinstance(current, bool):
|
||||
value = bool(raw)
|
||||
elif isinstance(current, int):
|
||||
value = int(raw)
|
||||
elif isinstance(current, float):
|
||||
value = float(raw)
|
||||
else:
|
||||
value = str(raw)
|
||||
except (TypeError, ValueError):
|
||||
log.warning("prefs.json: ignoring %s=%r", field.name, raw)
|
||||
continue
|
||||
setattr(prefs, field.name, value)
|
||||
|
||||
|
||||
def channels_to_json(store: Any) -> list[dict]:
|
||||
"""The channel table as ``[{"idx", "name", "secret"}]`` in slot order."""
|
||||
out = []
|
||||
for idx in range(store.max_channels):
|
||||
channel = store.get(idx)
|
||||
if channel is not None and channel.name:
|
||||
out.append({"idx": idx, "name": channel.name, "secret": bytes(channel.secret).hex()})
|
||||
return out
|
||||
|
||||
|
||||
def saved_channels(saved: Any) -> list[tuple[int, str, bytes]]:
|
||||
"""``channels.json`` as ``(idx, name, secret)`` triples, skipping malformed entries."""
|
||||
out = []
|
||||
for entry in saved if isinstance(saved, list) else []:
|
||||
try:
|
||||
out.append((int(entry["idx"]), str(entry["name"]), bytes.fromhex(entry["secret"])))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
return [c for c in out if c[1]]
|
||||
|
||||
|
||||
# --- the node ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def preamble_for_sf(spreading_factor: int) -> int:
|
||||
"""The LoRa preamble, in symbols, MeshCore uses at a spreading factor: 32 up to SF8, else 16.
|
||||
|
||||
This is MeshCore's own rule (``RadioLibWrapper::preambleLengthForSF``), and the receiver
|
||||
has to follow it, not only the transmitter. The SX1262 waits for the sync word only about
|
||||
as long as the preamble it was told to expect, so a node listening for 12 symbols against
|
||||
a mesh sending 32 locks on, gives up, and locks on again further along the same preamble
|
||||
— and decodes a packet only when it happens to lock on near the end. That was the whole
|
||||
of "the uConsole misses replies other radios hear": the preamble was a fixed 12, the
|
||||
library's default, and at SF7 the mesh sends 32.
|
||||
"""
|
||||
return 32 if spreading_factor <= 8 else 16
|
||||
|
||||
|
||||
def radio_kwargs(signature_params, wiring: dict, prefs: Any) -> dict: # noqa: ANN001
|
||||
"""The ``SX1262Radio`` constructor arguments: the board's wiring plus the saved radio.
|
||||
|
||||
Passes only what this library version's constructor accepts, so a knob it lacks is
|
||||
dropped instead of refusing the call. ``None`` means "not set" and is never passed.
|
||||
"""
|
||||
wanted = dict(wiring)
|
||||
wanted.update(
|
||||
preamble_length=preamble_for_sf(prefs.spreading_factor),
|
||||
frequency=prefs.frequency_hz,
|
||||
bandwidth=prefs.bandwidth_hz,
|
||||
spreading_factor=prefs.spreading_factor,
|
||||
coding_rate=prefs.coding_rate,
|
||||
tx_power=prefs.tx_power_dbm,
|
||||
)
|
||||
return {k: v for k, v in wanted.items() if k in signature_params and v is not None}
|
||||
|
||||
|
||||
def _check_device(path: str, missing_kind: str, what: str) -> None:
|
||||
"""Refuse early, with the right kind, when a device node is absent or unopenable."""
|
||||
if not os.path.exists(path):
|
||||
raise NodeError(missing_kind, f"{path} does not exist — is the {what} enabled?")
|
||||
if not os.access(path, os.R_OK | os.W_OK):
|
||||
raise NodeError("permission", f"no read/write permission on {path}")
|
||||
|
||||
|
||||
def _persist_contacts(store: Any, path: Path) -> None:
|
||||
"""Snapshot the contact list after every change, the way the firmware writes flash."""
|
||||
|
||||
def save() -> None:
|
||||
try:
|
||||
_write_json(path, store.to_dicts())
|
||||
except Exception as exc: # noqa: BLE001 - a failed snapshot must not stop the node
|
||||
log.warning("could not save contacts: %s", exc)
|
||||
|
||||
for name in ("add", "add_or_overwrite", "update", "remove", "clear"):
|
||||
original = getattr(store, name, None)
|
||||
if original is None:
|
||||
continue
|
||||
|
||||
def wrapped(*args, _original=original, **kwargs): # noqa: ANN002, ANN003, ANN202
|
||||
result = _original(*args, **kwargs)
|
||||
save()
|
||||
return result
|
||||
|
||||
setattr(store, name, wrapped)
|
||||
store.save_snapshot = save
|
||||
|
||||
|
||||
async def run_node(config: dict, report) -> int: # noqa: ANN001 - (dict) -> None
|
||||
"""Bring the node up, report ready, and serve until told to stop.
|
||||
|
||||
Args:
|
||||
config: ``state_dir``, ``wiring`` (the board's pins and switches) and ``seed``
|
||||
(name and radio settings for a node with no ``prefs.json`` yet).
|
||||
report: Sends the one status line to the parent.
|
||||
|
||||
Returns:
|
||||
The process exit status.
|
||||
"""
|
||||
import importlib
|
||||
import inspect
|
||||
|
||||
try:
|
||||
rt = importlib.import_module(RUNTIME)
|
||||
companion_mod = importlib.import_module(f"{RUNTIME}.companion")
|
||||
models = importlib.import_module(f"{RUNTIME}.companion.models")
|
||||
identity_mod = importlib.import_module(f"{RUNTIME}.protocol.identity")
|
||||
sx1262 = importlib.import_module(f"{RUNTIME}.hardware.sx1262_wrapper")
|
||||
except ImportError as exc:
|
||||
raise NodeError("runtime", f"{RUNTIME} is not importable here ({exc})") from exc
|
||||
log.info("node runtime %s %s (%s)", RUNTIME, getattr(rt, "__version__", "?"), sys.executable)
|
||||
|
||||
state = Path(config["state_dir"])
|
||||
state.mkdir(parents=True, exist_ok=True)
|
||||
wiring = dict(config.get("wiring") or {})
|
||||
seed = dict(config.get("seed") or {})
|
||||
|
||||
bus, cs = int(wiring.get("bus_id", 0)), int(wiring.get("cs_id", 0))
|
||||
_check_device(f"/dev/spidev{bus}.{cs}", "no-spi", "SPI overlay")
|
||||
_check_device(f"/dev/gpiochip{int(wiring.get('gpio_chip', 0))}", "no-gpio", "GPIO chip")
|
||||
|
||||
# The saved preferences decide the radio the chip is brought up on; the seed only fills
|
||||
# in a node that has never saved any.
|
||||
prefs = models.NodePrefs(
|
||||
node_name=seed.get("node_name", "MeshTerm"),
|
||||
tx_power_dbm=seed.get("tx_power_dbm", 22),
|
||||
frequency_hz=seed.get("frequency_hz", 910_525_000),
|
||||
bandwidth_hz=seed.get("bandwidth_hz", 62_500),
|
||||
spreading_factor=seed.get("spreading_factor", 7),
|
||||
coding_rate=seed.get("coding_rate", 5),
|
||||
)
|
||||
apply_saved_prefs(prefs, _read_json(state / "prefs.json"))
|
||||
|
||||
SX1262Radio = sx1262.SX1262Radio
|
||||
kwargs = radio_kwargs(inspect.signature(SX1262Radio.__init__).parameters, wiring, prefs)
|
||||
log.info("radio config %s", " ".join(f"{k}={v}" for k, v in kwargs.items()))
|
||||
radio = SX1262Radio(**kwargs)
|
||||
|
||||
recent = _Recent()
|
||||
logging.getLogger().addHandler(recent)
|
||||
for attempt in range(BEGIN_ATTEMPTS):
|
||||
recent.lines.clear()
|
||||
try:
|
||||
if radio.begin():
|
||||
break
|
||||
kind = classify_begin_failure(recent.lines)
|
||||
except SystemExit: # the GPIO manager's answer to a busy or forbidden pin
|
||||
kind = classify_begin_failure(recent.lines)
|
||||
except Exception as exc: # noqa: BLE001 - reported to the parent, not raised
|
||||
recent.lines.append(str(exc))
|
||||
kind = classify_begin_failure(recent.lines)
|
||||
try:
|
||||
radio.cleanup()
|
||||
except Exception: # noqa: BLE001, S110 - best-effort release before retrying
|
||||
pass
|
||||
if kind != "busy" or attempt == BEGIN_ATTEMPTS - 1:
|
||||
detail = recent.lines[-1] if recent.lines else "radio.begin() failed"
|
||||
raise NodeError(kind, detail)
|
||||
await asyncio.sleep(BEGIN_BACKOFF_S * (attempt + 1))
|
||||
logging.getLogger().removeHandler(recent)
|
||||
log.info("radio ready")
|
||||
|
||||
try:
|
||||
seed_bytes = load_identity_seed(
|
||||
state, lambda: identity_mod.LocalIdentity().get_signing_key_bytes()
|
||||
)
|
||||
identity = identity_mod.LocalIdentity(seed_bytes)
|
||||
node = _persistent_companion(companion_mod.CompanionRadio, state)(
|
||||
radio,
|
||||
identity,
|
||||
node_name=prefs.node_name,
|
||||
radio_config={
|
||||
"frequency": prefs.frequency_hz,
|
||||
"bandwidth": prefs.bandwidth_hz,
|
||||
"spreading_factor": prefs.spreading_factor,
|
||||
"coding_rate": prefs.coding_rate,
|
||||
"tx_power": prefs.tx_power_dbm,
|
||||
},
|
||||
)
|
||||
apply_saved_prefs(node.prefs, prefs_to_json(prefs))
|
||||
|
||||
for idx, name, secret in saved_channels(_read_json(state / "channels.json")):
|
||||
node.set_channel(idx, name, secret)
|
||||
contacts = _read_json(state / "contacts.json")
|
||||
if isinstance(contacts, list):
|
||||
node.contacts.load_from_dicts(contacts)
|
||||
log.info("restored %d contact(s)", node.contacts.get_count())
|
||||
_persist_contacts(node.contacts, state / "contacts.json")
|
||||
node.add_push_callback(
|
||||
"channel_updated",
|
||||
lambda _idx, _channel: _write_json(
|
||||
state / "channels.json", channels_to_json(node.channels)
|
||||
),
|
||||
)
|
||||
|
||||
await node.start()
|
||||
public_key = node.get_public_key()
|
||||
server = companion_mod.CompanionFrameServer(
|
||||
bridge=node,
|
||||
companion_hash=f"{public_key[0]:02x}",
|
||||
port=0,
|
||||
bind_address="127.0.0.1",
|
||||
local_hash=public_key[0],
|
||||
device_model=DEVICE_MODEL,
|
||||
client_idle_timeout_sec=None,
|
||||
)
|
||||
await server.start()
|
||||
# The one push the library's frame server doesn't subscribe to: every overheard frame
|
||||
# with its SNR/RSSI, which MeshTerm's live feed is built from.
|
||||
node.add_push_callback("rx_log_data", server.push_rx_raw)
|
||||
port = server._server.sockets[0].getsockname()[1]
|
||||
except Exception:
|
||||
radio.cleanup()
|
||||
raise
|
||||
|
||||
log.info("node %s… listening on 127.0.0.1:%d", public_key.hex()[:16], port)
|
||||
report({"event": "ready", "port": port, "public_key": public_key.hex()})
|
||||
|
||||
try:
|
||||
await _until_told_to_stop()
|
||||
finally:
|
||||
log.info("shutting down")
|
||||
for step in (server.stop, node.stop):
|
||||
try:
|
||||
await step()
|
||||
except Exception as exc: # noqa: BLE001 - keep going: the radio still needs freeing
|
||||
log.warning("shutdown step failed: %s", exc)
|
||||
node.contacts.save_snapshot()
|
||||
radio.cleanup()
|
||||
return 0
|
||||
|
||||
|
||||
def apply_preamble(radio: Any, symbols: int) -> None:
|
||||
"""Give a running radio a new preamble length, for both what it sends and what it hears.
|
||||
|
||||
The driver reads ``preamble_length`` afresh for every transmission, but reception keeps
|
||||
the packet parameters it was last given, so those are re-sent from standby and the chip
|
||||
put back to listening.
|
||||
"""
|
||||
if getattr(radio, "preamble_length", symbols) == symbols:
|
||||
return
|
||||
radio.preamble_length = symbols
|
||||
lora = getattr(radio, "lora", None)
|
||||
if lora is None:
|
||||
return
|
||||
try:
|
||||
lora.setStandby(lora.STANDBY_RC)
|
||||
lora.setPacketParamsLoRa(symbols, lora.HEADER_EXPLICIT, 64, lora.CRC_ON, lora.IQ_STANDARD)
|
||||
lora.request(lora.RX_CONTINUOUS)
|
||||
log.info("preamble set to %d symbols", symbols)
|
||||
except Exception as exc: # noqa: BLE001 - the next transmission sets it regardless
|
||||
log.warning("could not apply the new preamble while listening: %s", exc)
|
||||
|
||||
|
||||
def _persistent_companion(base: type, state: Path) -> type:
|
||||
"""``CompanionRadio`` with its preferences written to ``prefs.json`` on every change.
|
||||
|
||||
``_save_prefs`` is the library's own hook for exactly this ("subclasses that need
|
||||
persistence … should override this method"), called after every preference setter.
|
||||
"""
|
||||
|
||||
class PersistentCompanion(base): # type: ignore[misc, valid-type]
|
||||
def set_radio_params(self, freq_hz: int, bw_hz: int, sf: int, cr: int) -> bool:
|
||||
"""Retune, and bring the preamble along when the spreading factor moves it.
|
||||
|
||||
The library retunes the modulation but leaves the packet parameters alone, and
|
||||
the preamble is one of them (see :func:`preamble_for_sf`).
|
||||
"""
|
||||
ok = super().set_radio_params(freq_hz, bw_hz, sf, cr)
|
||||
if ok:
|
||||
apply_preamble(self._radio, preamble_for_sf(sf))
|
||||
return ok
|
||||
|
||||
def _save_prefs(self) -> None:
|
||||
try:
|
||||
_write_json(state / "prefs.json", prefs_to_json(self.prefs))
|
||||
except Exception as exc: # noqa: BLE001 - a failed save must not fail the setter
|
||||
log.warning("could not save prefs: %s", exc)
|
||||
|
||||
return PersistentCompanion
|
||||
|
||||
|
||||
async def _until_told_to_stop() -> None:
|
||||
"""Wait for stdin to close or a SIGTERM/SIGINT — the parent's two ways of saying stop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
stop = asyncio.Event()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
except (NotImplementedError, RuntimeError):
|
||||
pass
|
||||
|
||||
def watch_stdin() -> None:
|
||||
try:
|
||||
while sys.stdin.buffer.read(4096):
|
||||
pass
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
loop.call_soon_threadsafe(stop.set)
|
||||
|
||||
threading.Thread(target=watch_stdin, name="parent-watch", daemon=True).start()
|
||||
await stop.wait()
|
||||
|
||||
|
||||
def _die_with_parent() -> None:
|
||||
"""On Linux, have the kernel send SIGTERM when the parent exits (best-effort)."""
|
||||
if not sys.platform.startswith("linux"):
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
PR_SET_PDEATHSIG = 1
|
||||
ctypes.CDLL("libc.so.6", use_errno=True).prctl(PR_SET_PDEATHSIG, signal.SIGTERM)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Entry point: ``python radionode.py --config '<json>'``."""
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--config", required=True, help="the node's configuration, as JSON")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# One line on the real stdout is the parent's; everything else goes to stderr.
|
||||
status = os.fdopen(os.dup(1), "w", encoding="utf-8")
|
||||
os.dup2(2, 1)
|
||||
sys.stdout = sys.stderr
|
||||
|
||||
def report(message: dict) -> None:
|
||||
status.write(json.dumps(message) + "\n")
|
||||
status.flush()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
stream=sys.stderr,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
_die_with_parent()
|
||||
try:
|
||||
return asyncio.run(run_node(json.loads(args.config), report))
|
||||
except NodeError as exc:
|
||||
log.error("%s: %s", exc.kind, exc)
|
||||
report({"event": "error", "kind": exc.kind, "message": str(exc)})
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001 - the parent gets a sentence, the log the trace
|
||||
log.exception("node failed")
|
||||
report({"event": "error", "kind": "failed", "message": str(exc)})
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,520 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""A repeater's region table as its CLI shows it: the parser, the commands, the edits.
|
||||
|
||||
A MeshCore repeater keeps a small table of regions (at most 32) in a tree under the
|
||||
wildcard ``*``, and relays a *scoped* flood only when the flood's region is one it lists as
|
||||
flood-allowed (see :mod:`~meshterm.core.regions` for the maths that ties a flood to its
|
||||
region). The tree is **organisational only**: a repeater relays exactly the regions it
|
||||
lists as allowed, so allowing ``lakeside`` says nothing about ``lakeside-north`` beneath it.
|
||||
The wildcard's own flag is the unscoped case — whether it relays plain floods at all.
|
||||
|
||||
The admin reads and edits that table over the remote CLI (firmware ``CommonCLI::
|
||||
handleRegionCmd``, ``RegionMap``), and this module is everything about it that is not a
|
||||
screen:
|
||||
|
||||
* **The dump** (:func:`parse_region_dump`). ``region`` answers with the tree, one region per
|
||||
line, each line indented one space per level below the wildcard, the name followed by
|
||||
``^`` on the home region and `` F`` where floods are allowed::
|
||||
|
||||
*^ F
|
||||
lakeside F
|
||||
lakeside-north F
|
||||
lakeside-south
|
||||
harbour F
|
||||
|
||||
* **The cut.** Every CLI reply is written into a 160-byte buffer (``exportTo(reply, 160)``),
|
||||
and the dump simply stops where the buffer ends — mid-line, mid-name, with nothing to say
|
||||
it did. A table of 32 regions with real names does not fit, so a dump near the cap is read
|
||||
as *possibly cut* (:data:`REPLY_CAP_BYTES`, :func:`parse_region_dump`), its unfinished last
|
||||
line is dropped rather than shown as a region with half a name, and the reader is told.
|
||||
The flat lists (``region list allowed`` / ``region list denied``) are how the rest is
|
||||
recovered (:func:`parse_name_list`, :func:`RegionTable.with_lists`) — they carry no parent,
|
||||
so those regions are shown *beyond the cut*, placed nowhere. The lists have a cap of their
|
||||
own and skip a name that would not fit rather than cutting it, so a list close enough to
|
||||
the cap to have skipped one is flagged too (:func:`list_may_be_incomplete`). Nothing here
|
||||
ever claims to hold the whole table when it cannot know.
|
||||
|
||||
* **The commands** (:func:`allow_command` and siblings). Every edit is one remote command,
|
||||
and each is spelled here exactly once. ``allowf``/``denyf``/``home`` match a name by
|
||||
*prefix* on the firmware, preferring an exact match — MeshTerm only ever sends names the
|
||||
repeater itself listed, so the exact match always wins.
|
||||
|
||||
* **The edits** (:meth:`RegionTable.after`). What a command the repeater accepted does to the
|
||||
table, so the editor redraws from the reply rather than spending another round trip on a
|
||||
re-read. ``region put`` of a name that already exists *moves* it on the firmware, which is
|
||||
why the editor refuses a name it can see is taken.
|
||||
|
||||
* **Persistence.** Every edit lives in the repeater's RAM until ``region save``; a reboot
|
||||
undoes whatever was not saved. The one exception is ``region default`` (firmware 1.15+),
|
||||
which saves the whole table itself — its reply (``default scope is now …``) is how an edit
|
||||
says it took the unsaved ones with it (:func:`saves_table`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from .regions import MAX_NAME_BYTES, WILDCARD, RegionNameError, validate
|
||||
|
||||
#: The firmware's CLI reply buffer, in bytes. ``BufStream`` stops one short of it (the NUL),
|
||||
#: so a dump that ran out of room arrives 159 bytes long.
|
||||
REPLY_CAP_BYTES = 160
|
||||
|
||||
#: How close to the cap a dump must come to be read as possibly cut. A cut can land inside
|
||||
#: a multi-byte UTF-8 name, which the decode on the way here may shorten by a few bytes, and
|
||||
#: the transport may strip a trailing newline — so the test leaves room for both rather
|
||||
#: than asking for exactly 159.
|
||||
_CUT_SLACK = 4
|
||||
|
||||
#: The command that dumps the tree.
|
||||
DUMP_COMMAND = "region"
|
||||
#: The command that asks which region is the default scope (firmware 1.15+).
|
||||
DEFAULT_QUERY = "region default"
|
||||
#: The two flat lists — every flood-allowed and every flood-denied name, ``*`` included.
|
||||
LIST_ALLOWED = "region list allowed"
|
||||
LIST_DENIED = "region list denied"
|
||||
#: The command that writes the table to flash. Nothing else survives a reboot.
|
||||
SAVE_COMMAND = "region save"
|
||||
|
||||
#: The token ``region default`` takes to clear the default scope.
|
||||
NULL_DEFAULT = "<null>"
|
||||
|
||||
#: What an empty flat list reads as on the repeater.
|
||||
_EMPTY_LIST = "-none-"
|
||||
|
||||
|
||||
# -- the table -----------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegionRow:
|
||||
"""One region in a repeater's table.
|
||||
|
||||
Attributes:
|
||||
name: The bare region name (``*`` for the wildcard).
|
||||
parent: The parent's name — ``*`` for a top-level region, ``None`` for the wildcard
|
||||
itself and for a region recovered from a flat list, whose place is unknown.
|
||||
depth: Levels below the wildcard (0 for the wildcard, 1 for a top-level region).
|
||||
flood: Whether the repeater relays floods scoped to it (``F``) — for the wildcard,
|
||||
whether it relays unscoped floods.
|
||||
home: Whether it is the repeater's home region (``^``).
|
||||
placed: ``False`` for a region known only from a flat list: it is in the table,
|
||||
but the dump was cut before it, so its parent and depth are not known.
|
||||
"""
|
||||
|
||||
name: str
|
||||
parent: str | None
|
||||
depth: int
|
||||
flood: bool
|
||||
home: bool = False
|
||||
placed: bool = True
|
||||
|
||||
@property
|
||||
def wildcard(self) -> bool:
|
||||
"""Whether this row is the wildcard — the unscoped case, not a region."""
|
||||
return self.name == WILDCARD
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegionTable:
|
||||
"""What MeshTerm knows of one repeater's region table.
|
||||
|
||||
Attributes:
|
||||
rows: The wildcard first (when the dump reached it), then every region in dump order
|
||||
(depth-first, as the firmware prints the tree), then any recovered from the flat
|
||||
lists after a cut (``placed=False``).
|
||||
cut: Whether the dump may have been cut at the reply cap — rows after ``cut_after``
|
||||
may exist that the tree does not show.
|
||||
cut_after: The last region the dump showed whole (``None`` when it showed none).
|
||||
lists_read: Whether the flat lists were read to recover what the cut hid.
|
||||
lists_partial: Whether either flat list came close enough to its own cap that a
|
||||
name may have been skipped — the recovered set may still be short.
|
||||
default: The default-scope region, ``None`` where there is none, and absent
|
||||
knowledge is :attr:`default_known` ``False`` (older firmware has no such query).
|
||||
default_known: Whether ``default`` was actually read.
|
||||
"""
|
||||
|
||||
rows: tuple[RegionRow, ...] = ()
|
||||
cut: bool = False
|
||||
cut_after: str | None = None
|
||||
lists_read: bool = False
|
||||
lists_partial: bool = False
|
||||
default: str | None = None
|
||||
default_known: bool = False
|
||||
|
||||
# -- reading it ---------------------------------------------------------------
|
||||
|
||||
def get(self, name: str) -> RegionRow | None:
|
||||
"""The row for ``name``, or ``None`` when the table (as known) has no such region."""
|
||||
return next((row for row in self.rows if row.name == name), None)
|
||||
|
||||
@property
|
||||
def wildcard(self) -> RegionRow | None:
|
||||
"""The wildcard's row, or ``None`` when the dump never reached it."""
|
||||
return self.get(WILDCARD)
|
||||
|
||||
def regions(self) -> list[RegionRow]:
|
||||
"""Every region row, the wildcard left out."""
|
||||
return [row for row in self.rows if not row.wildcard]
|
||||
|
||||
def children(self, name: str) -> list[RegionRow]:
|
||||
"""The regions directly under ``name`` (placed rows only — nothing else has a parent)."""
|
||||
return [row for row in self.rows if row.parent == name and not row.wildcard]
|
||||
|
||||
@property
|
||||
def home(self) -> str | None:
|
||||
"""The home region's name (``*`` when none was set), or ``None`` when not seen."""
|
||||
return next((row.name for row in self.rows if row.home), None)
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
"""Whether every region the repeater holds is (to the best of our reading) here.
|
||||
|
||||
A clean dump is complete. A cut one is complete again once both flat lists were read
|
||||
and neither came near its own cap — the tree is still partly unplaced, but no name
|
||||
is missing.
|
||||
"""
|
||||
return not self.cut or (self.lists_read and not self.lists_partial)
|
||||
|
||||
def carried(self) -> list[str]:
|
||||
"""What the repeater relays floods for, as the regions request would list it.
|
||||
|
||||
``*`` first when it relays unscoped floods, then every flood-allowed region — the
|
||||
exact shape :meth:`~meshterm.core.region_store.RegionStore.learn_carried` takes.
|
||||
"""
|
||||
wild = self.wildcard
|
||||
head = [WILDCARD] if wild is not None and wild.flood else []
|
||||
return head + [row.name for row in self.regions() if row.flood]
|
||||
|
||||
# -- editing it ---------------------------------------------------------------
|
||||
|
||||
def after(self, command: str, reply: str) -> RegionTable:
|
||||
"""The table as it stands once the repeater accepted ``command``.
|
||||
|
||||
Only the verbs this module spells are understood; anything else (or a refused
|
||||
reply — see :func:`region_refused`) returns the table unchanged, so a caller may
|
||||
fold every accepted reply through here without first asking what it was.
|
||||
|
||||
Args:
|
||||
command: The command sent, as built by this module.
|
||||
reply: The repeater's reply to it.
|
||||
|
||||
Returns:
|
||||
The updated table (a new object; tables are immutable).
|
||||
"""
|
||||
if region_refused(reply):
|
||||
return self
|
||||
parts = command.split()
|
||||
if len(parts) < 2 or parts[0] != "region":
|
||||
return self
|
||||
verb, args = parts[1], parts[2:]
|
||||
if verb in ("allowf", "denyf") and args:
|
||||
return self._with(args[0], flood=verb == "allowf")
|
||||
if verb == "home" and args:
|
||||
if self.get(args[0]) is None:
|
||||
return self
|
||||
rows = tuple(replace(row, home=row.name == args[0]) for row in self.rows)
|
||||
return replace(self, rows=rows)
|
||||
if verb == "default" and args:
|
||||
if args[0] == NULL_DEFAULT:
|
||||
return replace(self, default=None, default_known=True)
|
||||
# The firmware forces flood on for the default region, and creates it at the top
|
||||
# level if it did not exist.
|
||||
table = self if self.get(args[0]) else self._put(args[0], WILDCARD, flood=True)
|
||||
return replace(table._with(args[0], flood=True), default=args[0], default_known=True)
|
||||
if verb == "put" and args:
|
||||
parent = args[1] if len(args) > 1 else WILDCARD
|
||||
# 1.15+ answers "OK - (flood allowed)"; older firmware created it denied.
|
||||
return self._put(args[0], parent, flood="flood allowed" in reply.lower())
|
||||
if verb == "remove" and args:
|
||||
if self.children(args[0]):
|
||||
return self # the firmware refuses this; a reply claiming otherwise is noise
|
||||
rows = tuple(row for row in self.rows if row.name != args[0])
|
||||
default = None if self.default == args[0] else self.default
|
||||
return replace(self, rows=rows, default=default)
|
||||
return self
|
||||
|
||||
def _with(self, name: str, **changes: object) -> RegionTable:
|
||||
"""The table with one row's fields changed (unchanged when the row is not here)."""
|
||||
rows = tuple(replace(row, **changes) if row.name == name else row for row in self.rows)
|
||||
return replace(self, rows=rows)
|
||||
|
||||
def _put(self, name: str, parent: str, *, flood: bool) -> RegionTable:
|
||||
"""The table with a new region placed as the last child of ``parent``.
|
||||
|
||||
Dump order is depth-first, so the new row goes after the parent's last descendant —
|
||||
exactly where the next ``region`` dump would print it.
|
||||
"""
|
||||
if self.get(name) is not None:
|
||||
return self
|
||||
above = self.get(parent)
|
||||
depth = (above.depth + 1) if above is not None and above.placed else 1
|
||||
row = RegionRow(name=name, parent=parent, depth=depth, flood=flood)
|
||||
rows = list(self.rows)
|
||||
at = len(rows)
|
||||
if above is not None and above.placed:
|
||||
at = rows.index(above) + 1
|
||||
while at < len(rows) and rows[at].placed and rows[at].depth > above.depth:
|
||||
at += 1
|
||||
else:
|
||||
# An unplaced parent has no subtree to append to: keep placed rows together and
|
||||
# put the new one ahead of the unplaced tail.
|
||||
at = next((i for i, r in enumerate(rows) if not r.placed), len(rows))
|
||||
rows.insert(at, row)
|
||||
return replace(self, rows=tuple(rows))
|
||||
|
||||
def with_lists(self, allowed: str | None, denied: str | None) -> RegionTable:
|
||||
"""Fold the two flat lists into a cut table, recovering the regions the cut hid.
|
||||
|
||||
A name in a list that the tree did not show joins it unplaced, flooded as its list
|
||||
says. Names the tree already has keep their tree row — the tree has the parent the
|
||||
list lacks — and the wildcard's flag is taken from the lists when the tree never
|
||||
reached it (it always does, being the first line, unless the reply was empty).
|
||||
|
||||
Args:
|
||||
allowed: The reply to :data:`LIST_ALLOWED` (``None`` when it never came).
|
||||
denied: The reply to :data:`LIST_DENIED` (``None`` when it never came).
|
||||
|
||||
Returns:
|
||||
The table with the recovered rows appended; ``lists_read`` is set only when both
|
||||
lists answered, and ``lists_partial`` when either may have skipped a name.
|
||||
"""
|
||||
rows = list(self.rows)
|
||||
partial = False
|
||||
for reply, flood in ((allowed, True), (denied, False)):
|
||||
if reply is None or region_refused(reply):
|
||||
continue
|
||||
partial = partial or list_may_be_incomplete(reply)
|
||||
for name in parse_name_list(reply):
|
||||
if any(row.name == name for row in rows):
|
||||
continue
|
||||
if name == WILDCARD:
|
||||
rows.insert(0, RegionRow(name=WILDCARD, parent=None, depth=0, flood=flood))
|
||||
else:
|
||||
rows.append(
|
||||
RegionRow(name=name, parent=None, depth=1, flood=flood, placed=False)
|
||||
)
|
||||
both = (
|
||||
allowed is not None
|
||||
and denied is not None
|
||||
and not region_refused(allowed)
|
||||
and not region_refused(denied)
|
||||
)
|
||||
return replace(self, rows=tuple(rows), lists_read=both, lists_partial=partial)
|
||||
|
||||
def with_default(self, reply: str | None) -> RegionTable:
|
||||
"""The table with the default scope read from a :data:`DEFAULT_QUERY` reply.
|
||||
|
||||
Unchanged (``default_known`` left ``False``) when there was no reply or the firmware
|
||||
does not know the query — pre-1.15 answers it with its catch-all ``Err - ??``.
|
||||
"""
|
||||
parsed = parse_default_reply(reply)
|
||||
if parsed is None:
|
||||
return self
|
||||
return replace(self, default=parsed or None, default_known=True)
|
||||
|
||||
|
||||
# -- parsing ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def dump_may_be_cut(text: str) -> bool:
|
||||
"""Whether a reply came close enough to the 160-byte cap to have been cut short."""
|
||||
return len(text.encode("utf-8")) >= REPLY_CAP_BYTES - 1 - _CUT_SLACK
|
||||
|
||||
|
||||
def parse_region_dump(text: str | None) -> RegionTable:
|
||||
"""Parse a ``region`` dump into a table, dropping a line the reply cap cut in half.
|
||||
|
||||
Each non-blank line is one region: its leading spaces are its depth, then the name, an
|
||||
optional ``^`` (home), and an optional ``F`` (flood allowed) after a space. A line's
|
||||
parent is the nearest line above it one level up; depth 0 is the wildcard. A line that
|
||||
does not fit the grammar, or whose indentation skips a level, is not guessed at — it
|
||||
ends the tree there and the table is marked cut, since a dump that stops making sense
|
||||
has stopped being the dump.
|
||||
|
||||
Args:
|
||||
text: The reply text (``None`` or empty reads as an empty, uncut table).
|
||||
|
||||
Returns:
|
||||
The table. When the reply came within reach of the cap its last line is dropped
|
||||
unless the reply ended on a newline (so the line is known whole), ``cut`` is set,
|
||||
and ``cut_after`` names the last region shown.
|
||||
"""
|
||||
if not text:
|
||||
return RegionTable()
|
||||
raw = text.replace("\r", "").replace("\x00", "")
|
||||
cut = dump_may_be_cut(raw)
|
||||
lines = raw.split("\n")
|
||||
if cut and not raw.endswith("\n") and lines:
|
||||
lines = lines[:-1] # the line the buffer ran out inside — a name may be half a name
|
||||
rows: list[RegionRow] = []
|
||||
stack: list[str] = [] # the open ancestor at each depth
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
continue
|
||||
depth = len(line) - len(line.lstrip(" "))
|
||||
row = _parse_line(line.strip(), depth, stack)
|
||||
if row is None:
|
||||
cut = True
|
||||
break
|
||||
del stack[depth:]
|
||||
stack.append(row.name)
|
||||
rows.append(row)
|
||||
cut_after = next((row.name for row in reversed(rows)), None) if cut else None
|
||||
return RegionTable(rows=tuple(rows), cut=cut, cut_after=cut_after)
|
||||
|
||||
|
||||
def _parse_line(body: str, depth: int, stack: list[str]) -> RegionRow | None:
|
||||
"""One dump line, or ``None`` when it breaks the grammar (see :func:`parse_region_dump`)."""
|
||||
tokens = body.split()
|
||||
if not tokens or len(tokens) > 2 or (len(tokens) == 2 and tokens[1] != "F"):
|
||||
return None
|
||||
name = tokens[0]
|
||||
home = name.endswith("^")
|
||||
name = name.removesuffix("^")
|
||||
if not name or depth > len(stack):
|
||||
return None
|
||||
if depth == 0 and name != WILDCARD:
|
||||
return None
|
||||
if depth > 0 and name == WILDCARD:
|
||||
return None
|
||||
parent = stack[depth - 1] if depth > 0 else None
|
||||
return RegionRow(name=name, parent=parent, depth=depth, flood=len(tokens) == 2, home=home)
|
||||
|
||||
|
||||
def parse_name_list(text: str | None) -> list[str]:
|
||||
"""The names in a ``region list allowed|denied`` reply (``-none-`` reads as empty)."""
|
||||
if not text or text.strip() == _EMPTY_LIST:
|
||||
return []
|
||||
names: list[str] = []
|
||||
for part in text.replace("\x00", "").split(","):
|
||||
name = part.strip().removeprefix("#")
|
||||
if name and name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def list_may_be_incomplete(text: str) -> bool:
|
||||
"""Whether a flat list came near enough its cap that a name may have been skipped.
|
||||
|
||||
The firmware appends a name only while ``length + len(name) + 2 < 160`` and skips one
|
||||
that would not fit — then carries on with the next, so a later short name can still get
|
||||
in. A skip therefore needs the list to have reached at least ``158 - len(name)`` bytes
|
||||
for some name of at most :data:`~meshterm.core.regions.MAX_NAME_BYTES`, and a list
|
||||
shorter than that skipped nothing.
|
||||
"""
|
||||
return len(text.encode("utf-8")) >= REPLY_CAP_BYTES - 2 - MAX_NAME_BYTES
|
||||
|
||||
|
||||
def parse_default_reply(text: str | None) -> str | None:
|
||||
"""The default scope named by a ``region default`` reply.
|
||||
|
||||
Returns:
|
||||
The region name, ``""`` for none (``<null>``), or ``None`` when the reply is
|
||||
missing, refused, or not a default-scope answer at all.
|
||||
"""
|
||||
if not text or region_refused(text):
|
||||
return None
|
||||
marker = "default scope is"
|
||||
lowered = text.lower()
|
||||
if marker not in lowered:
|
||||
return None
|
||||
rest = text[lowered.index(marker) + len(marker) :].strip()
|
||||
rest = rest.removeprefix("now").strip()
|
||||
name = rest.split()[0] if rest.split() else ""
|
||||
return "" if name in ("", NULL_DEFAULT) else name.removeprefix("#")
|
||||
|
||||
|
||||
def region_refused(reply: str | None) -> bool:
|
||||
"""Whether a reply to a region command is the repeater refusing it.
|
||||
|
||||
The region verbs answer ``Err - …`` (``unknown region``, ``not empty``, ``unable to
|
||||
put``, ``save failed``, and ``??`` for a verb the firmware lacks), which the settings
|
||||
catalog's own error test does not recognise. Older firmware with no ``region`` command
|
||||
at all answers its generic unknown-command text.
|
||||
"""
|
||||
if reply is None:
|
||||
return True
|
||||
lowered = reply.strip().lower()
|
||||
return lowered.startswith(("err", "??", "unknown command", "error"))
|
||||
|
||||
|
||||
def saves_table(command: str, reply: str | None) -> bool:
|
||||
"""Whether an accepted command wrote the whole table to flash on its own.
|
||||
|
||||
``region save`` does, by definition; ``region default`` does too on firmware 1.15+,
|
||||
which is the firmware that answers it with ``default scope is now …``.
|
||||
"""
|
||||
if reply is None or region_refused(reply):
|
||||
return False
|
||||
if command == SAVE_COMMAND:
|
||||
return True
|
||||
return command.startswith("region default ") and "default scope is now" in reply.lower()
|
||||
|
||||
|
||||
# -- the commands ------------------------------------------------------------------
|
||||
|
||||
|
||||
def allow_command(name: str) -> str:
|
||||
"""Relay floods scoped to ``name`` (``*``: relay unscoped floods)."""
|
||||
return f"region allowf {name}"
|
||||
|
||||
|
||||
def deny_command(name: str) -> str:
|
||||
"""Stop relaying floods scoped to ``name`` (``*``: stop relaying unscoped floods)."""
|
||||
return f"region denyf {name}"
|
||||
|
||||
|
||||
def home_command(name: str) -> str:
|
||||
"""Make ``name`` the repeater's home region (``*`` clears it back to none)."""
|
||||
return f"region home {name}"
|
||||
|
||||
|
||||
def default_command(name: str | None) -> str:
|
||||
"""Make ``name`` the default scope for the repeater's own floods, or clear it (``None``)."""
|
||||
return f"region default {name or NULL_DEFAULT}"
|
||||
|
||||
|
||||
def put_command(name: str, parent: str = WILDCARD) -> str:
|
||||
"""Add ``name`` under ``parent`` (top level for ``*``). New regions are flood-allowed."""
|
||||
return f"region put {name}" if parent == WILDCARD else f"region put {name} {parent}"
|
||||
|
||||
|
||||
def remove_command(name: str) -> str:
|
||||
"""Remove ``name`` — refused by the firmware while it still has sub-regions."""
|
||||
return f"region remove {name}"
|
||||
|
||||
|
||||
def _is_name_char(ch: str) -> bool:
|
||||
"""Whether the firmware's ``RegionMap::is_name_char`` accepts ``ch`` in a region name."""
|
||||
return ch in "-$#" or ch.isdigit() or ord(ch) >= ord("A")
|
||||
|
||||
|
||||
def validate_new_name(name: str, taken: Iterable[str] = ()) -> str:
|
||||
"""Check a name for ``region put``, the firmware's own rules and one of MeshTerm's.
|
||||
|
||||
On top of :func:`~meshterm.core.regions.validate` (length, no wildcard, no private
|
||||
``$`` names), the firmware refuses a name with any punctuation but ``-`` — its
|
||||
``is_name_char`` — and ``region put`` of a name that exists *moves* that region rather
|
||||
than adding one, so a name already in the table is refused here instead.
|
||||
|
||||
Args:
|
||||
name: The name as typed.
|
||||
taken: Names already in the table.
|
||||
|
||||
Returns:
|
||||
The bare, valid name.
|
||||
|
||||
Raises:
|
||||
RegionNameError: With the reason, in words a reader can act on.
|
||||
"""
|
||||
bare = validate(name)
|
||||
bad = sorted({ch for ch in bare if not _is_name_char(ch)})
|
||||
if bad:
|
||||
raise RegionNameError(
|
||||
f"a repeater refuses {' '.join(bad)} in a region name — letters, digits, and - only"
|
||||
)
|
||||
if bare in set(taken):
|
||||
raise RegionNameError(f"{bare} is already a region here")
|
||||
return bare
|
||||
@@ -0,0 +1,244 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""A repeater's region table and region CLI, as the simulator speaks them.
|
||||
|
||||
The :class:`~meshterm.core.connection.MockDevice` answers ``region …`` commands and the
|
||||
anonymous regions request from one of these per simulated repeater, so the node page, the
|
||||
region editor and the ``regions`` command are all drivable under ``--mock``.
|
||||
|
||||
It is a transcription of the firmware rather than an idea of it — ``RegionMap`` and
|
||||
``CommonCLI::handleRegionCmd`` as of MeshCore 1.15/1.16 — because the parser it feeds
|
||||
(:mod:`~meshterm.core.region_admin`) is meant for the real thing, and a simulator that
|
||||
answered more kindly than a repeater would let a parser bug through. So it keeps the
|
||||
firmware's awkward edges on purpose: the 160-byte reply buffer that cuts a long dump
|
||||
mid-name, the flat lists that *skip* a name that would not fit, prefix matching on
|
||||
``allowf``/``denyf``/``home``/``get``, ``put`` of an existing name moving it, ``remove``
|
||||
refusing a region with children, new regions flood-allowed, ``default`` creating a missing
|
||||
region and saving the table, and every other edit living in RAM until ``save``
|
||||
(:meth:`SimulatedRegionMap.reboot` puts the saved table back).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .region_admin import REPLY_CAP_BYTES
|
||||
from .regions import WILDCARD
|
||||
|
||||
#: The firmware's table size (``MAX_REGION_ENTRIES``).
|
||||
MAX_REGIONS = 32
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Entry:
|
||||
"""One ``RegionEntry``: an id, its parent's id, the deny-flood flag, and the name."""
|
||||
|
||||
id: int
|
||||
parent: int
|
||||
deny_flood: bool
|
||||
name: str
|
||||
|
||||
|
||||
class SimulatedRegionMap:
|
||||
"""One simulated repeater's region table and the CLI verbs that edit it."""
|
||||
|
||||
def __init__(self, tree: list[tuple[str, str, bool]] | None = None) -> None:
|
||||
"""Build a table, already saved, from ``(name, parent, flood)`` triples in order.
|
||||
|
||||
Args:
|
||||
tree: The regions, each after its parent (``*`` for top level). ``None`` is an
|
||||
empty table: a stock repeater relaying unscoped floods and nothing else.
|
||||
"""
|
||||
self._wild_deny = False
|
||||
self._entries: list[_Entry] = []
|
||||
self._next_id = 1
|
||||
self._home = 0
|
||||
self._default = 0
|
||||
for name, parent, flood in tree or ():
|
||||
parent_id = 0 if parent == WILDCARD else self._by_name(parent).id # type: ignore[union-attr]
|
||||
entry = self._put(name, parent_id)
|
||||
assert entry is not None
|
||||
entry.deny_flood = not flood
|
||||
self._saved = self._snapshot()
|
||||
|
||||
# -- the RegionMap ------------------------------------------------------------
|
||||
|
||||
def _snapshot(self) -> tuple:
|
||||
return copy.deepcopy(
|
||||
(self._wild_deny, self._entries, self._next_id, self._home, self._default)
|
||||
)
|
||||
|
||||
def reboot(self) -> None:
|
||||
"""Drop every unsaved edit, as a power cycle does."""
|
||||
(self._wild_deny, self._entries, self._next_id, self._home, self._default) = copy.deepcopy(
|
||||
self._saved
|
||||
)
|
||||
|
||||
def _by_name(self, name: str) -> _Entry | None:
|
||||
"""``findByName``: an exact match (``*`` is the wildcard, reported as ``None`` here)."""
|
||||
name = name.removeprefix("#")
|
||||
return next((e for e in self._entries if e.name.removeprefix("#") == name), None)
|
||||
|
||||
def _by_prefix(self, prefix: str) -> _Entry | str | None:
|
||||
"""``findByNamePrefix``: exact wins, else the *last* prefix match; ``*`` → ``"*"``."""
|
||||
if prefix == WILDCARD:
|
||||
return WILDCARD
|
||||
prefix = prefix.removeprefix("#")
|
||||
partial = None
|
||||
for entry in self._entries:
|
||||
bare = entry.name.removeprefix("#")
|
||||
if bare == prefix:
|
||||
return entry
|
||||
if bare.startswith(prefix):
|
||||
partial = entry
|
||||
return partial
|
||||
|
||||
def _put(self, name: str, parent_id: int) -> _Entry | None:
|
||||
"""``putRegion``: refuse bad characters, move an existing name, else append."""
|
||||
if not all(ch in "-$#" or ch.isdigit() or ord(ch) >= ord("A") for ch in name):
|
||||
return None
|
||||
entry = self._by_name(name)
|
||||
if entry is not None:
|
||||
if entry.id == parent_id:
|
||||
return None
|
||||
entry.parent = parent_id
|
||||
return entry
|
||||
if len(self._entries) >= MAX_REGIONS:
|
||||
return None
|
||||
entry = _Entry(id=self._next_id, parent=parent_id, deny_flood=True, name=name)
|
||||
self._next_id += 1
|
||||
self._entries.append(entry)
|
||||
return entry
|
||||
|
||||
def _dump(self) -> str:
|
||||
"""``exportTo(reply, 160)``: the tree, cut where the buffer runs out."""
|
||||
out: list[str] = []
|
||||
|
||||
def visit(indent: int, entry_id: int, name: str, deny: bool) -> None:
|
||||
home = "^" if entry_id == self._home else ""
|
||||
out.append(
|
||||
" " * indent + f"{name.removeprefix('#')}{home}" + ("" if deny else " F") + "\n"
|
||||
)
|
||||
for child in self._entries:
|
||||
if child.parent == entry_id:
|
||||
visit(indent + 1, child.id, child.name, child.deny_flood)
|
||||
|
||||
visit(0, 0, WILDCARD, self._wild_deny)
|
||||
return _capped("".join(out))
|
||||
|
||||
def _names(self, *, allowed: bool) -> str:
|
||||
"""``exportNamesTo(reply, 160, …)``: comma-joined, skipping what will not fit."""
|
||||
parts: list[str] = []
|
||||
length = 0
|
||||
if self._wild_deny != allowed:
|
||||
parts.append(WILDCARD)
|
||||
length = 2
|
||||
for entry in self._entries:
|
||||
if entry.deny_flood == allowed:
|
||||
continue
|
||||
bare = entry.name.removeprefix("#")
|
||||
size = len(bare.encode("utf-8"))
|
||||
if length + size + 2 < REPLY_CAP_BYTES:
|
||||
parts.append(bare)
|
||||
length += size + 1
|
||||
return ",".join(parts)
|
||||
|
||||
def allowed_names(self) -> str:
|
||||
"""What the anonymous regions request answers with (``*`` first when unscoped)."""
|
||||
return self._names(allowed=True)
|
||||
|
||||
# -- the CLI ------------------------------------------------------------------
|
||||
|
||||
def command(self, text: str) -> str:
|
||||
"""Answer one ``region …`` command as ``handleRegionCmd`` does."""
|
||||
parts = text.split()
|
||||
n = len(parts)
|
||||
if n == 1:
|
||||
return self._dump()
|
||||
verb = parts[1]
|
||||
if verb == "save":
|
||||
self._saved = self._snapshot()
|
||||
return "OK"
|
||||
if verb in ("allowf", "denyf") and n >= 3:
|
||||
found = self._by_prefix(parts[2])
|
||||
if found is None:
|
||||
return "Err - unknown region"
|
||||
if found == WILDCARD:
|
||||
self._wild_deny = verb == "denyf"
|
||||
else:
|
||||
found.deny_flood = verb == "denyf" # type: ignore[union-attr]
|
||||
return "OK"
|
||||
if verb == "get" and n >= 3:
|
||||
found = self._by_prefix(parts[2])
|
||||
if found is None:
|
||||
return "Err - unknown region"
|
||||
if found == WILDCARD:
|
||||
return f" * {'' if self._wild_deny else 'F'}"
|
||||
parent = next((e for e in self._entries if e.id == found.parent), None) # type: ignore[union-attr]
|
||||
flag = "" if found.deny_flood else "F" # type: ignore[union-attr]
|
||||
if parent is not None:
|
||||
return f" {found.name} ({parent.name}) {flag}" # type: ignore[union-attr]
|
||||
return f" {found.name} {flag}" # type: ignore[union-attr]
|
||||
if verb == "home":
|
||||
if n == 2:
|
||||
home = next((e.name for e in self._entries if e.id == self._home), WILDCARD)
|
||||
return f" home is {home}"
|
||||
found = self._by_prefix(parts[2])
|
||||
if found is None:
|
||||
return "Err - unknown region"
|
||||
self._home = 0 if found == WILDCARD else found.id # type: ignore[union-attr]
|
||||
return f" home is now {WILDCARD if found == WILDCARD else found.name}" # type: ignore[union-attr]
|
||||
if verb == "default":
|
||||
if n == 2:
|
||||
current = next((e.name for e in self._entries if e.id == self._default), None)
|
||||
return f" default scope is {current or '<null>'}"
|
||||
if parts[2] == "<null>":
|
||||
self._default = 0
|
||||
self._saved = self._snapshot()
|
||||
return " default scope is now <null>"
|
||||
found = self._by_prefix(parts[2])
|
||||
if found == WILDCARD:
|
||||
self._wild_deny = False
|
||||
self._default = 0
|
||||
self._saved = self._snapshot()
|
||||
return " default scope is now *"
|
||||
if found is None:
|
||||
found = self._put(parts[2], 0)
|
||||
if found is None:
|
||||
return "Err - region table full"
|
||||
found.deny_flood = False # type: ignore[union-attr]
|
||||
self._default = found.id # type: ignore[union-attr]
|
||||
self._saved = self._snapshot()
|
||||
return f" default scope is now {found.name}" # type: ignore[union-attr]
|
||||
if verb == "put" and n >= 3:
|
||||
if n >= 4:
|
||||
parent = self._by_prefix(parts[3])
|
||||
if parent is None:
|
||||
return "Err - unknown parent"
|
||||
parent_id = 0 if parent == WILDCARD else parent.id # type: ignore[union-attr]
|
||||
else:
|
||||
parent_id = 0
|
||||
entry = self._put(parts[2], parent_id)
|
||||
if entry is None:
|
||||
return "Err - unable to put"
|
||||
entry.deny_flood = False
|
||||
return "OK - (flood allowed)"
|
||||
if verb == "remove" and n >= 3:
|
||||
entry = self._by_name(parts[2])
|
||||
if entry is None:
|
||||
return "Err - not found"
|
||||
if any(e.parent == entry.id for e in self._entries):
|
||||
return "Err - not empty"
|
||||
self._entries.remove(entry)
|
||||
return "OK"
|
||||
if verb == "list" and n >= 3:
|
||||
if parts[2] not in ("allowed", "denied"):
|
||||
return "Err - use 'allowed' or 'denied'"
|
||||
return self._names(allowed=parts[2] == "allowed") or "-none-"
|
||||
return "Err - ??"
|
||||
|
||||
|
||||
def _capped(text: str) -> str:
|
||||
"""``BufStream``: bytes past the 159th are dropped, whatever they were in the middle of."""
|
||||
raw = text.encode("utf-8")[: REPLY_CAP_BYTES - 1]
|
||||
return raw.decode("utf-8", errors="ignore")
|
||||
@@ -0,0 +1,417 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The regions MeshTerm knows by name, and the scope each channel is sent under.
|
||||
|
||||
A scoped flood carries no region name — only a transport code keyed on one (see
|
||||
:mod:`~meshterm.core.regions`) — so *which* region a packet was flooded into can only be
|
||||
answered by trying the names already known. This store is that list. A name arrives from
|
||||
wherever MeshTerm meets one:
|
||||
|
||||
* ``default`` — the companion's own default flood scope, read or set on Device config;
|
||||
* ``channel`` — a scope given to a channel, so its messages stay in its region;
|
||||
* ``repeater`` — a repeater's answer to the regions request, or its ``region`` listing,
|
||||
which also records *which* repeaters carry it (the node page lists them, and a scoped
|
||||
send that nothing relayed can say whether anything in earshot was ever heard to carry
|
||||
its region) — and, beside the regions, each repeater's last whole answer: whether it
|
||||
relays unscoped floods too, and when it said so (:class:`CarriedAnswer`);
|
||||
* ``typed`` — a name the reader entered themselves.
|
||||
|
||||
The store also holds **channel scopes**: the region a channel's messages are sent under.
|
||||
The firmware has no per-channel scope (``// TODO: have per-channel send_scope``), so a
|
||||
client that wants one sets the companion's session scope just before each channel send,
|
||||
which is what every official app does. The scope is keyed by the channel's intrinsic
|
||||
identity (:func:`~meshterm.core.channels.channel_identity`), exactly like a mute, so it
|
||||
follows the channel across slots and two devices sharing the channel share its scope.
|
||||
|
||||
Resolution is memoized here too (:meth:`RegionStore.scope_of`): a packet list repaints
|
||||
often and resolving a scoped frame costs an HMAC per known name, so an answer is kept per
|
||||
``(body, code)`` until the set of names changes.
|
||||
|
||||
Like the other operator state (mutes, watched nodes, admin passwords) this is global
|
||||
machine state in a small JSON file (``<config_dir>/regions.json``), read once and written
|
||||
atomically on each real change. It writes from its typed records, never from the document
|
||||
it read, so a field no record knows is gone at the next save.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .regions import (
|
||||
WILDCARD,
|
||||
RegionNameError,
|
||||
Scope,
|
||||
frame_scope,
|
||||
normalize,
|
||||
raw_scope_body,
|
||||
validate,
|
||||
)
|
||||
|
||||
#: Where a region name was learned — the closed set :attr:`KnownRegion.sources` draws from.
|
||||
SOURCES = ("default", "channel", "repeater", "typed")
|
||||
|
||||
#: Resolutions kept before the memo is cleared wholesale (a long capture meets many bodies;
|
||||
#: a repaint meets the same few hundred again and again).
|
||||
_MEMO_CAP = 4096
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnownRegion:
|
||||
"""One region MeshTerm knows by name.
|
||||
|
||||
Attributes:
|
||||
name: The bare region name (no ``#``).
|
||||
sources: Where it was learned, members of :data:`SOURCES`, in first-learned order.
|
||||
repeaters: The 12-hex node ids of repeaters that were heard to carry it.
|
||||
learned_at: When it was first learned (UTC).
|
||||
"""
|
||||
|
||||
name: str
|
||||
sources: tuple[str, ...] = ()
|
||||
repeaters: tuple[str, ...] = ()
|
||||
learned_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CarriedAnswer:
|
||||
"""The last whole answer one repeater gave about what it carries.
|
||||
|
||||
The regions themselves live on :class:`KnownRegion` (``repeaters``), where resolution
|
||||
wants them; this keeps what does not belong to any one region — whether the repeater
|
||||
also relays *unscoped* floods (the ``*`` a region list leads with, which names no
|
||||
region), and when it said so. A repeater with no record here was never asked, which is
|
||||
a different thing from a repeater that answered with nothing.
|
||||
|
||||
Attributes:
|
||||
node: The repeater's 12-hex node id.
|
||||
unscoped: Whether its answer included the wildcard.
|
||||
answered_at: When it answered (UTC).
|
||||
"""
|
||||
|
||||
node: str
|
||||
unscoped: bool
|
||||
answered_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning.
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
|
||||
class RegionStore:
|
||||
"""Reads and writes the known regions and the channel scopes, memory-first.
|
||||
|
||||
Interact through :meth:`names`/:meth:`regions` (what is known), :meth:`learn` and
|
||||
:meth:`forget` (changing it), :meth:`channel_scope`/:meth:`set_channel_scope` (a
|
||||
channel's send scope), :meth:`carriers` (which repeaters carry a region), and
|
||||
:meth:`scope_of` (what a received frame's scope is). :attr:`revision` bumps on every
|
||||
change, for a screen that memoizes on it.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
"""Open the store against a JSON file location.
|
||||
|
||||
Args:
|
||||
path: Path to the JSON state file (created lazily on the first change).
|
||||
"""
|
||||
self._path = path
|
||||
self._regions: dict[str, KnownRegion] | None = None
|
||||
self._channels: dict[str, str] = {}
|
||||
self._answers: dict[str, CarriedAnswer] = {}
|
||||
self._memo: dict[tuple[bytes, int], str | None] = {}
|
||||
self.revision = 0
|
||||
|
||||
# -- loading ---------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def _state(self) -> dict[str, KnownRegion]:
|
||||
"""The name -> record map, loaded from disk on first access."""
|
||||
if self._regions is None:
|
||||
self._regions, self._channels, self._answers = self._load()
|
||||
return self._regions
|
||||
|
||||
def _load(
|
||||
self,
|
||||
) -> tuple[dict[str, KnownRegion], dict[str, str], dict[str, CarriedAnswer]]:
|
||||
"""Parse the file, or start empty on a missing or corrupt one."""
|
||||
try:
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}, {}, {}
|
||||
if not isinstance(data, dict):
|
||||
return {}, {}, {}
|
||||
regions: dict[str, KnownRegion] = {}
|
||||
for entry in data.get("regions") or []:
|
||||
region = _region_from_json(entry)
|
||||
if region is not None:
|
||||
regions[region.name] = region
|
||||
channels: dict[str, str] = {}
|
||||
for identity, name in (data.get("channels") or {}).items():
|
||||
try:
|
||||
channels[str(identity)] = validate(str(name))
|
||||
except RegionNameError:
|
||||
continue
|
||||
answers: dict[str, CarriedAnswer] = {}
|
||||
for entry in data.get("answers") or []:
|
||||
answer = _answer_from_json(entry)
|
||||
if answer is not None:
|
||||
answers[answer.node] = answer
|
||||
return regions, channels, answers
|
||||
|
||||
# -- what is known ---------------------------------------------------------------
|
||||
|
||||
def regions(self) -> list[KnownRegion]:
|
||||
"""Every known region, in the order names are tried when resolving a frame.
|
||||
|
||||
Names the reader chose (a default scope, a channel scope, a typed name) lead, then
|
||||
names only a repeater has mentioned — the ones this station most likely sends
|
||||
under are the ones its own frames will carry. Ties keep the order learned.
|
||||
"""
|
||||
chosen = [r for r in self._state.values() if set(r.sources) - {"repeater"}]
|
||||
heard = [r for r in self._state.values() if not set(r.sources) - {"repeater"}]
|
||||
return chosen + heard
|
||||
|
||||
def names(self) -> list[str]:
|
||||
"""The known region names, in resolution order (see :meth:`regions`)."""
|
||||
return [r.name for r in self.regions()]
|
||||
|
||||
def get(self, name: str) -> KnownRegion | None:
|
||||
"""The record for one region name, or ``None`` if it is not known."""
|
||||
return self._state.get(normalize(name))
|
||||
|
||||
def carriers(self, name: str) -> tuple[str, ...]:
|
||||
"""The node ids of the repeaters heard to carry a region (empty when none)."""
|
||||
region = self.get(name)
|
||||
return region.repeaters if region else ()
|
||||
|
||||
def carried_by(self, node: str) -> list[str]:
|
||||
"""The regions a repeater was heard to carry, by its node id (12-hex prefix)."""
|
||||
node = node.lower()[:12]
|
||||
return [r.name for r in self.regions() if node in r.repeaters]
|
||||
|
||||
def answer_of(self, node: str) -> CarriedAnswer | None:
|
||||
"""The last whole answer a repeater gave (unscoped too, and when), or ``None``.
|
||||
|
||||
``None`` means it was never asked — or never answered — which the node page states
|
||||
as such, rather than reading an empty :meth:`carried_by` as "carries nothing".
|
||||
"""
|
||||
self._state # noqa: B018 - load on first access
|
||||
return self._answers.get(node.lower()[:12])
|
||||
|
||||
# -- changing it -----------------------------------------------------------------
|
||||
|
||||
def learn(self, name: str, source: str, *, repeater: str | None = None) -> str | None:
|
||||
"""Record a region name and where it came from; persist only a real change.
|
||||
|
||||
Args:
|
||||
name: The name, bare or ``#``-prefixed. The wildcard and names the firmware
|
||||
would refuse are ignored (a repeater listing ``*`` teaches no region).
|
||||
source: One of :data:`SOURCES`.
|
||||
repeater: For ``source="repeater"``, the node id of the repeater that carries it.
|
||||
|
||||
Returns:
|
||||
The bare name recorded, or ``None`` when nothing was learnable.
|
||||
"""
|
||||
if source not in SOURCES:
|
||||
raise ValueError(f"unknown region source {source!r}")
|
||||
try:
|
||||
bare = validate(name)
|
||||
except RegionNameError:
|
||||
return None
|
||||
current = self._state.get(bare) or KnownRegion(name=bare)
|
||||
sources = current.sources if source in current.sources else current.sources + (source,)
|
||||
repeaters = current.repeaters
|
||||
if repeater:
|
||||
node = repeater.lower()[:12]
|
||||
if node not in repeaters:
|
||||
repeaters = repeaters + (node,)
|
||||
updated = replace(current, sources=sources, repeaters=repeaters)
|
||||
if self._state.get(bare) != updated:
|
||||
self._state[bare] = updated
|
||||
self._changed()
|
||||
return bare
|
||||
|
||||
def learn_carried(self, repeater: str, names: Iterable[str]) -> list[str]:
|
||||
"""Record what one repeater says it carries, replacing what it said before.
|
||||
|
||||
A repeater's list is its whole answer, so a region it no longer names loses that
|
||||
repeater (and, if nothing else taught it, the region itself). Whether it listed the
|
||||
wildcard — relays unscoped floods — and when it answered are kept beside the
|
||||
regions (:meth:`answer_of`).
|
||||
|
||||
Args:
|
||||
repeater: The repeater's node id (12-hex prefix or full key).
|
||||
names: The regions it listed (the wildcard is recorded as the unscoped flag).
|
||||
|
||||
Returns:
|
||||
The bare region names learned, in the order given.
|
||||
"""
|
||||
node = repeater.lower()[:12]
|
||||
given = [normalize(x) for x in names]
|
||||
self._state # noqa: B018 - load on first access
|
||||
self._answers[node] = CarriedAnswer(node=node, unscoped=WILDCARD in given)
|
||||
self._changed()
|
||||
wanted = [n for n in given if n and n != WILDCARD]
|
||||
learned: list[str] = []
|
||||
for name in wanted:
|
||||
bare = self.learn(name, "repeater", repeater=node)
|
||||
if bare:
|
||||
learned.append(bare)
|
||||
for region in list(self._state.values()):
|
||||
if node in region.repeaters and region.name not in learned:
|
||||
repeaters = tuple(r for r in region.repeaters if r != node)
|
||||
sources = region.sources
|
||||
if not repeaters:
|
||||
sources = tuple(s for s in sources if s != "repeater")
|
||||
if not sources:
|
||||
del self._state[region.name]
|
||||
else:
|
||||
self._state[region.name] = replace(region, sources=sources, repeaters=repeaters)
|
||||
self._changed()
|
||||
return learned
|
||||
|
||||
def forget(self, name: str) -> None:
|
||||
"""Drop a region name entirely, and any channel scope that used it."""
|
||||
bare = normalize(name)
|
||||
if bare not in self._state and bare not in self._channels.values():
|
||||
return
|
||||
self._state.pop(bare, None)
|
||||
self._channels = {k: v for k, v in self._channels.items() if v != bare}
|
||||
self._changed()
|
||||
|
||||
# -- channel scopes ----------------------------------------------------------------
|
||||
|
||||
def channel_scope(self, channel_id: str | None) -> str | None:
|
||||
"""The region a channel's messages are sent under, or ``None`` for the default."""
|
||||
self._state # noqa: B018 - load on first access
|
||||
return self._channels.get(channel_id) if channel_id else None
|
||||
|
||||
def channel_scopes(self) -> dict[str, str]:
|
||||
"""Every channel scope, channel identity -> region (a copy)."""
|
||||
self._state # noqa: B018 - load on first access
|
||||
return dict(self._channels)
|
||||
|
||||
def set_channel_scope(self, channel_id: str, name: str | None) -> None:
|
||||
"""Give a channel a send scope, or clear it back to the device's default.
|
||||
|
||||
Args:
|
||||
channel_id: The channel's intrinsic identity.
|
||||
name: The region, or ``None``/empty to clear.
|
||||
|
||||
Raises:
|
||||
RegionNameError: If ``name`` is a name the firmware would refuse.
|
||||
"""
|
||||
self._state # noqa: B018 - load on first access
|
||||
if not name or not normalize(name):
|
||||
if self._channels.pop(channel_id, None) is not None:
|
||||
self._changed()
|
||||
return
|
||||
bare = validate(name)
|
||||
self.learn(bare, "channel")
|
||||
if self._channels.get(channel_id) != bare:
|
||||
self._channels[channel_id] = bare
|
||||
self._changed()
|
||||
|
||||
# -- resolution ------------------------------------------------------------------
|
||||
|
||||
def scope_of(self, raw: dict | None) -> Scope | None:
|
||||
"""The scope of a received frame against every known name (memoized).
|
||||
|
||||
Args:
|
||||
raw: The frame's raw payload (live, or restored from history).
|
||||
|
||||
Returns:
|
||||
As :func:`~meshterm.core.regions.frame_scope`: ``None`` where the frame has no
|
||||
scope to state (direct, or a route type never kept).
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
route = str(raw.get("route_typename") or "").upper()
|
||||
if route != "TC_FLOOD":
|
||||
return frame_scope(raw, ())
|
||||
body = raw_scope_body(raw)
|
||||
scope = frame_scope(raw, ()) # parses the code; resolution happens below
|
||||
if scope is None or scope.code is None or body is None:
|
||||
return scope
|
||||
memo_key = (body, int(scope.code, 16))
|
||||
if memo_key not in self._memo:
|
||||
if len(self._memo) >= _MEMO_CAP:
|
||||
self._memo.clear()
|
||||
self._memo[memo_key] = (frame_scope(raw, self.names()) or scope).region
|
||||
region = self._memo[memo_key]
|
||||
return Scope("scoped", region, scope.code) if region else scope
|
||||
|
||||
# -- persistence -----------------------------------------------------------------
|
||||
|
||||
def _changed(self) -> None:
|
||||
"""Bump the revision, drop the memo, and persist."""
|
||||
self.revision += 1
|
||||
self._memo.clear()
|
||||
self._save()
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Persist the whole store atomically (a crash mid-write keeps the old file)."""
|
||||
data = {
|
||||
"regions": [
|
||||
{
|
||||
"name": r.name,
|
||||
"sources": list(r.sources),
|
||||
"repeaters": list(r.repeaters),
|
||||
"learned_at": r.learned_at.astimezone(timezone.utc).isoformat(),
|
||||
}
|
||||
for r in self._state.values()
|
||||
],
|
||||
"channels": dict(sorted(self._channels.items())),
|
||||
"answers": [
|
||||
{
|
||||
"node": a.node,
|
||||
"unscoped": a.unscoped,
|
||||
"answered_at": a.answered_at.astimezone(timezone.utc).isoformat(),
|
||||
}
|
||||
for a in self._answers.values()
|
||||
],
|
||||
}
|
||||
write_atomically(self._path, json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def _answer_from_json(entry: object) -> CarriedAnswer | None:
|
||||
"""Parse one stored repeater answer, or ``None`` if it is malformed."""
|
||||
if not isinstance(entry, dict) or not entry.get("node"):
|
||||
return None
|
||||
try:
|
||||
answered_at = datetime.fromisoformat(str(entry["answered_at"]))
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
if answered_at.tzinfo is None:
|
||||
answered_at = answered_at.replace(tzinfo=timezone.utc)
|
||||
return CarriedAnswer(
|
||||
node=str(entry["node"]).lower()[:12],
|
||||
unscoped=bool(entry.get("unscoped")),
|
||||
answered_at=answered_at,
|
||||
)
|
||||
|
||||
|
||||
def _region_from_json(entry: object) -> KnownRegion | None:
|
||||
"""Parse one stored region, or ``None`` if it is malformed."""
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
try:
|
||||
name = validate(str(entry["name"]))
|
||||
except (KeyError, RegionNameError):
|
||||
return None
|
||||
sources = tuple(s for s in entry.get("sources") or () if s in SOURCES)
|
||||
repeaters = tuple(str(r).lower()[:12] for r in entry.get("repeaters") or () if r)
|
||||
try:
|
||||
learned_at = datetime.fromisoformat(str(entry["learned_at"]))
|
||||
except (KeyError, ValueError):
|
||||
learned_at = datetime.now(timezone.utc)
|
||||
if learned_at.tzinfo is None:
|
||||
learned_at = learned_at.replace(tzinfo=timezone.utc)
|
||||
return KnownRegion(name=name, sources=sources, repeaters=repeaters, learned_at=learned_at)
|
||||
@@ -0,0 +1,305 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Region scopes: the maths that ties a flood to the region it was sent into.
|
||||
|
||||
MeshCore repeaters can be told which *regions* they carry (``region put yul``,
|
||||
``region allowf yul``), and a flood sent *scoped* to a region is relayed only by the
|
||||
repeaters that carry it. The scope travels in the packet as a **transport code**: a
|
||||
``TRANSPORT_FLOOD`` frame (route type 0 — the library's ``TC_FLOOD``) carries two
|
||||
little-endian ``uint16`` codes between its header and its path, and the first is
|
||||
|
||||
HMAC-SHA256(region key, payload_type_byte || payload)[0:2]
|
||||
|
||||
with ``0x0000`` and ``0xFFFF`` reserved (they become ``0x0001`` and ``0xFFFE``). The
|
||||
second code is reserved and always zero today. A region's key is the first 16 bytes of
|
||||
``SHA-256("#" + name)``, the name taken as its UTF-8 bytes — the firmware stores names
|
||||
bare and puts the ``#`` in front when it derives the key (``RegionMap::getTransportKeysFor``,
|
||||
``TransportKeyStore``), and so does every companion app.
|
||||
|
||||
Two consequences shape everything built on this module:
|
||||
|
||||
* **A code names no region.** It is a keyed hash of the packet's own payload, so the one
|
||||
way to say which region a scoped packet belongs to is to try the region names already
|
||||
known and see whose key reproduces it (:func:`resolve`). A packet scoped to a region
|
||||
nobody here has named reads as *scoped, region unknown* — honestly, and for good.
|
||||
* **The code survives the trip.** Neither the header nor the path is in the hash, so every
|
||||
relay's copy carries the sender's code unchanged: one message has one scope however many
|
||||
ways it arrived.
|
||||
|
||||
Direct-routed packets are never region-filtered by a repeater, so a scope matters to a
|
||||
flood alone; a ``TC_DIRECT`` frame's codes are the ``{0, 0}`` "to nowhere" pair a contact
|
||||
share uses, not a region.
|
||||
|
||||
The vocabulary, fixed here because the UI speaks it everywhere: a **region** is a named
|
||||
area a repeater carries; a flood's **scope** is the region it is limited to; a plain flood
|
||||
is **unscoped**, which a repeater's region list spells as the wildcard ``*``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
#: The wildcard — "every region", i.e. unscoped — as repeaters list it and as the send
|
||||
#: scope's explicit-unscoped override takes it.
|
||||
WILDCARD = "*"
|
||||
|
||||
#: The longest region name, in UTF-8 bytes. The firmware's name slot is 31 bytes with the
|
||||
#: terminating NUL (``RegionEntry.name[31]``, the default-scope frame's 31-byte field), and
|
||||
#: ``CMD_SET_DEFAULT_FLOOD_SCOPE`` refuses anything outside 1–30.
|
||||
MAX_NAME_BYTES = 30
|
||||
|
||||
#: Width of a region's transport key, in bytes.
|
||||
KEY_BYTES = 16
|
||||
|
||||
#: The route types that carry transport codes, as the meshcore library names them
|
||||
#: (``ROUTE_TYPENAMES``): a scoped flood and its direct sibling.
|
||||
SCOPED_ROUTES = frozenset({"TC_FLOOD", "TC_DIRECT"})
|
||||
|
||||
#: The route types a repeater filters by region at all — floods, scoped or not. A direct
|
||||
#: packet is never region-filtered, so it has no scope to show.
|
||||
FLOOD_ROUTES = frozenset({"FLOOD", "TC_FLOOD"})
|
||||
|
||||
|
||||
class RegionNameError(ValueError):
|
||||
"""A region name the firmware would refuse, with the reason in words a reader can act on."""
|
||||
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
"""A region name as the firmware stores and shows it: trimmed, with no leading ``#``.
|
||||
|
||||
Apps and older firmware wrote names as ``#yul``; since firmware 1.12 they are stored
|
||||
bare and the ``#`` belongs only to key derivation. Case is kept — the hash is over the
|
||||
exact bytes, so ``YUL`` and ``yul`` are two different regions.
|
||||
|
||||
Args:
|
||||
name: The name as typed, listed by a repeater, or read back from a device.
|
||||
|
||||
Returns:
|
||||
The bare name (possibly empty).
|
||||
"""
|
||||
return name.strip().removeprefix("#").strip()
|
||||
|
||||
|
||||
def validate(name: str) -> str:
|
||||
"""Normalize a region name and refuse one the firmware would refuse.
|
||||
|
||||
Args:
|
||||
name: The name as typed.
|
||||
|
||||
Returns:
|
||||
The bare, valid name.
|
||||
|
||||
Raises:
|
||||
RegionNameError: If the name is empty, too long, private, or the wildcard.
|
||||
"""
|
||||
bare = normalize(name)
|
||||
if not bare:
|
||||
raise RegionNameError("a region needs a name")
|
||||
if bare == WILDCARD:
|
||||
raise RegionNameError("* is every region — leave the scope empty for unscoped")
|
||||
if bare.startswith("$"):
|
||||
# Private regions derive their keys from a hardware keystore the firmware has not
|
||||
# built yet (``RegionMap::loadKeysFor`` is a TODO), so no repeater can match one.
|
||||
raise RegionNameError("$ regions are private and no firmware can match one yet")
|
||||
if len(bare.encode("utf-8")) > MAX_NAME_BYTES:
|
||||
raise RegionNameError(f"a region name is at most {MAX_NAME_BYTES} bytes")
|
||||
if any(ch.isspace() or ch == "," for ch in bare):
|
||||
# A repeater lists its regions comma-separated and parses its CLI on whitespace, so
|
||||
# a name holding either could be stored but never listed or addressed again.
|
||||
raise RegionNameError("a region name has no spaces or commas")
|
||||
return bare
|
||||
|
||||
|
||||
def region_key(name: str) -> bytes:
|
||||
"""The 16-byte transport key of a region, as the firmware derives it.
|
||||
|
||||
Args:
|
||||
name: The region name, with or without its leading ``#``.
|
||||
|
||||
Returns:
|
||||
``SHA-256("#" + bare name)[:16]``, the name taken as UTF-8.
|
||||
"""
|
||||
return hashlib.sha256(("#" + normalize(name)).encode("utf-8")).digest()[:KEY_BYTES]
|
||||
|
||||
|
||||
def transport_code(key: bytes, body: bytes) -> int:
|
||||
"""The transport code a packet carries when scoped under ``key``.
|
||||
|
||||
Args:
|
||||
key: The region's 16-byte transport key (:func:`region_key`).
|
||||
body: The HMAC input: the payload type byte followed by the payload
|
||||
(:func:`scope_body`).
|
||||
|
||||
Returns:
|
||||
The 16-bit code, with the two reserved values stepped off.
|
||||
"""
|
||||
code = int.from_bytes(hmac.new(key, body, hashlib.sha256).digest()[:2], "little")
|
||||
if code == 0x0000:
|
||||
return 0x0001
|
||||
if code == 0xFFFF:
|
||||
return 0xFFFE
|
||||
return code
|
||||
|
||||
|
||||
def scope_body(payload_type: int, payload: bytes) -> bytes:
|
||||
"""The bytes a transport code is computed over: the payload type, then the payload.
|
||||
|
||||
Args:
|
||||
payload_type: The header's 4-bit payload type (``GRP_TXT`` is 5, ``ADVERT`` 4, …).
|
||||
payload: The packet's payload — everything after the path.
|
||||
|
||||
Returns:
|
||||
``bytes([payload_type]) + payload``.
|
||||
"""
|
||||
return bytes([payload_type & 0x0F]) + bytes(payload)
|
||||
|
||||
|
||||
def parse_codes(hex4: str | None) -> tuple[int, int] | None:
|
||||
"""Split a frame's 4-byte transport-codes field into its two little-endian codes.
|
||||
|
||||
Args:
|
||||
hex4: The field as the meshcore library reports it (``transport_code``, 8 hex
|
||||
digits), or ``None`` where the frame carried none.
|
||||
|
||||
Returns:
|
||||
``(code0, code1)``, or ``None`` for an absent or malformed field.
|
||||
"""
|
||||
if not hex4:
|
||||
return None
|
||||
try:
|
||||
raw = bytes.fromhex(str(hex4))
|
||||
except ValueError:
|
||||
return None
|
||||
if len(raw) != 4:
|
||||
return None
|
||||
return int.from_bytes(raw[0:2], "little"), int.from_bytes(raw[2:4], "little")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Scope:
|
||||
"""What a received frame says about the region it was flooded into.
|
||||
|
||||
Attributes:
|
||||
state: ``"unscoped"`` — a plain flood, every repeater may relay it;
|
||||
``"scoped"`` — a scoped flood whose region is :attr:`region`; ``"unknown"`` — a
|
||||
scoped flood whose code matches no region known here.
|
||||
region: The bare region name for a resolved scope, else ``None``.
|
||||
code: The frame's first transport code (lowercase 4-hex), for a scoped frame.
|
||||
"""
|
||||
|
||||
state: str
|
||||
region: str | None = None
|
||||
code: str | None = None
|
||||
|
||||
@property
|
||||
def scoped(self) -> bool:
|
||||
"""Whether the flood was scoped at all (resolved or not)."""
|
||||
return self.state != "unscoped"
|
||||
|
||||
|
||||
#: The one unscoped answer, shared (a Scope is immutable).
|
||||
UNSCOPED = Scope("unscoped")
|
||||
|
||||
|
||||
def resolve(names: Iterable[str], body: bytes, code: int) -> str | None:
|
||||
"""The first known region whose key reproduces ``code`` over ``body``.
|
||||
|
||||
Two distinct regions can collide on a 16-bit code for one payload (one chance in
|
||||
65 534 per pair), and the firmware has the same ambiguity — a repeater that carries
|
||||
both relays the packet under whichever it lists first. The first match in ``names``
|
||||
order is the answer here too; callers order names by how likely they are.
|
||||
|
||||
Args:
|
||||
names: Candidate region names (bare or ``#``-prefixed).
|
||||
body: The HMAC input (:func:`scope_body`).
|
||||
code: The frame's first transport code.
|
||||
|
||||
Returns:
|
||||
The matching bare name, or ``None``.
|
||||
"""
|
||||
for name in names:
|
||||
bare = normalize(name)
|
||||
if bare and bare != WILDCARD and transport_code(region_key(bare), body) == code:
|
||||
return bare
|
||||
return None
|
||||
|
||||
|
||||
def frame_scope(raw: dict, names: Iterable[str]) -> Scope | None:
|
||||
"""The scope of a received frame's raw payload, or ``None`` where it has none.
|
||||
|
||||
Reads the route type and transport codes the meshcore library parses out of an
|
||||
RX-log frame (or that the repository restores for a replayed one), and the HMAC input
|
||||
— built live from ``payload_type``/``pkt_payload``, or restored as ``scope_body``.
|
||||
|
||||
Args:
|
||||
raw: The frame's raw payload mapping.
|
||||
names: Region names to resolve a scoped frame against.
|
||||
|
||||
Returns:
|
||||
:data:`UNSCOPED` for a plain flood, a scoped :class:`Scope` for a transport flood,
|
||||
and ``None`` for a direct-routed frame or one whose route type was never kept —
|
||||
a repeater never region-filters those, so there is no scope to state.
|
||||
"""
|
||||
route = str(raw.get("route_typename") or "").upper()
|
||||
if route == "FLOOD":
|
||||
return UNSCOPED
|
||||
if route != "TC_FLOOD":
|
||||
return None
|
||||
codes = parse_codes(raw.get("transport_code"))
|
||||
if codes is None:
|
||||
return Scope("unknown")
|
||||
code0 = codes[0]
|
||||
body = raw_scope_body(raw)
|
||||
region = resolve(names, body, code0) if body is not None else None
|
||||
return Scope("scoped" if region else "unknown", region, f"{code0:04x}")
|
||||
|
||||
|
||||
def raw_scope_body(raw: dict) -> bytes | None:
|
||||
"""The HMAC input for a frame's raw payload, live or restored from history.
|
||||
|
||||
Args:
|
||||
raw: The frame's raw payload mapping.
|
||||
|
||||
Returns:
|
||||
The body bytes, or ``None`` where neither the live fields nor a stored body exist.
|
||||
"""
|
||||
stored = raw.get("scope_body")
|
||||
if stored:
|
||||
try:
|
||||
return bytes.fromhex(str(stored))
|
||||
except ValueError:
|
||||
return None
|
||||
payload_type = raw.get("payload_type")
|
||||
payload = raw.get("pkt_payload")
|
||||
if payload_type is None or payload is None or int(payload_type) < 0:
|
||||
return None
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = bytes.fromhex(payload)
|
||||
except ValueError:
|
||||
return None
|
||||
return scope_body(int(payload_type), bytes(payload))
|
||||
|
||||
|
||||
def parse_region_list(text: str | None) -> list[str]:
|
||||
"""The regions a repeater's ``REGIONS`` anonymous-request reply names.
|
||||
|
||||
The reply is a comma-separated list of the regions the repeater relays floods for,
|
||||
``*`` first when it relays unscoped floods too (firmware ``RegionMap::exportNamesTo``).
|
||||
|
||||
Args:
|
||||
text: The reply's text (the meshcore library strips the clock ahead of it).
|
||||
|
||||
Returns:
|
||||
The bare names in the order given, the wildcard kept as ``*``, blanks dropped.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
names: list[str] = []
|
||||
for part in str(text).replace("\x00", "").split(","):
|
||||
bare = normalize(part)
|
||||
if bare and bare not in names:
|
||||
names.append(bare)
|
||||
return names
|
||||
@@ -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
|
||||
@@ -28,6 +28,13 @@ three of its shapes only make sense against that source:
|
||||
``af`` both read and write ``airtime_factor``, so writing one stales the other
|
||||
(:attr:`RemoteSetting.overlaps`).
|
||||
|
||||
The catalog is not the whole page, though: it is what *any* MeshCore build might expose,
|
||||
and a node can have settings beyond it (a third-party firmware's own knobs). Those are never
|
||||
listed here and never probed — they are learned one node at a time from commands the reader
|
||||
actually ran (:func:`parse_setting_command`, :func:`discovered_setting`), which is why
|
||||
:attr:`RemoteSetting.discovered` exists and why :func:`learnable_from_get` has to care that
|
||||
``get`` matches keys by prefix and ``set`` does not.
|
||||
|
||||
Replies are parsed *loosely* on purpose: repeater firmware answers tersely and has changed
|
||||
its phrasing across versions (``"> 20"``, ``"tx: 20"``, ``"OK - repeat is now ON"``), so
|
||||
values are extracted rather than pattern-matched. Firmware without a given key answers
|
||||
@@ -45,12 +52,13 @@ from dataclasses import dataclass
|
||||
|
||||
#: Reply text (lowercased) that reads as the firmware refusing/unknowing a command. ``??:``
|
||||
#: is how ``handleGetCmd`` answers a key it doesn't know; ``unknown config:`` is its ``set``
|
||||
#: twin.
|
||||
#: twin. ``err - `` is the ``region`` verbs' own refusal (``Err - not empty``).
|
||||
_ERRORISH = (
|
||||
"??:",
|
||||
"unknown",
|
||||
"error",
|
||||
"err:",
|
||||
"err - ",
|
||||
"invalid",
|
||||
"denied",
|
||||
"bad ",
|
||||
@@ -118,6 +126,9 @@ class RemoteSetting:
|
||||
part: This setting's position within its composite's comma-joined value.
|
||||
overlaps: Keys that are another spelling of the same firmware value, whose cached
|
||||
reading a write to this one makes stale.
|
||||
discovered: Whether this setting is not in the catalog at all, but was learned from
|
||||
a command the reader ran on *one node's* command line (see
|
||||
:func:`discovered_setting`). Never true of a catalog entry.
|
||||
"""
|
||||
|
||||
key: str
|
||||
@@ -139,6 +150,7 @@ class RemoteSetting:
|
||||
composite: str = ""
|
||||
part: int = 0
|
||||
overlaps: tuple[str, ...] = ()
|
||||
discovered: bool = False
|
||||
|
||||
@property
|
||||
def get_command(self) -> str:
|
||||
@@ -531,7 +543,7 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
|
||||
label="Bridge type",
|
||||
category="Bridge",
|
||||
writable=False,
|
||||
help="The bridge this firmware was built with: none, rs232 or espnow",
|
||||
help="The bridge this firmware was built with: none, rs232, or espnow",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="bridge.enabled",
|
||||
@@ -616,12 +628,17 @@ KNOWN_COMMANDS: tuple[str, ...] = (
|
||||
"password ",
|
||||
"poweroff",
|
||||
"reboot",
|
||||
# ``region load`` is left out on purpose: it switches the CLI into a line-by-line
|
||||
# reading mode ended by a blank line, which a remote command line cannot send.
|
||||
"region",
|
||||
"region allowf ",
|
||||
"region def ",
|
||||
"region default",
|
||||
"region denyf ",
|
||||
"region get ",
|
||||
"region home",
|
||||
"region list allowed",
|
||||
"region list denied",
|
||||
"region put ",
|
||||
"region remove ",
|
||||
"region save",
|
||||
@@ -671,6 +688,134 @@ def composite_members(composite: str) -> list[RemoteSetting]:
|
||||
return sorted((s for s in REPEATER_SETTINGS if s.composite == composite), key=lambda s: s.part)
|
||||
|
||||
|
||||
#: The category a discovered setting sorts under, and the heading its section takes.
|
||||
DISCOVERED_CATEGORY = "Extra"
|
||||
|
||||
#: The description lane's text for a discovered setting. The catalog has no explanation to
|
||||
#: give — all it knows is that this node answered the key — so the row says exactly that, and
|
||||
#: says it short: the lane is shared with descriptions written to fit a 72-column screen.
|
||||
DISCOVERED_HELP = "Found on this node"
|
||||
|
||||
#: Keys whose value is never written to disk, however it was asked for. ``prv.key`` is the
|
||||
#: node's own identity: the firmware answers it on the serial console only (``sender_timestamp
|
||||
#: == 0`` in ``handleGetCmd``), it is a fact rather than a setting, and a reply that reached
|
||||
#: us anyway must not be the first place a repeater's private key lands in a file. The
|
||||
#: command line still runs the command and still prints the answer; only the remembering
|
||||
#: stops here.
|
||||
NEVER_STORED = frozenset({"prv.key"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SettingCommand:
|
||||
"""A remote-CLI command recognised as a read or a write of one named setting.
|
||||
|
||||
Attributes:
|
||||
verb: ``"get"`` or ``"set"``.
|
||||
key: The setting's key, as the reader spelled it.
|
||||
value: What a ``set`` writes; ``""`` for a ``get``.
|
||||
"""
|
||||
|
||||
verb: str
|
||||
key: str
|
||||
value: str = ""
|
||||
|
||||
|
||||
def parse_setting_command(command: str) -> SettingCommand | None:
|
||||
"""Read one typed CLI command as a setting read/write, or ``None`` if it is neither.
|
||||
|
||||
The grammar is the filter, and deliberately so: ``get <key>`` and ``set <key> <value>``
|
||||
are the firmware's own way of saying *this is a setting*, so recognising them costs no
|
||||
list of what to exclude. Every top-level verb — ``reboot``, ``advert``, ``clock sync``,
|
||||
``start ota``, ``powersaving``, ``setperm`` — simply isn't this shape and falls out.
|
||||
A key is held to the spelling the firmware's own keys use, so a mistyped line can't
|
||||
invent one.
|
||||
"""
|
||||
parts = command.strip().split()
|
||||
if len(parts) < 2 or parts[0] not in ("get", "set"):
|
||||
return None
|
||||
key = parts[1]
|
||||
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9._]*", key):
|
||||
return None
|
||||
if parts[0] == "get":
|
||||
return SettingCommand("get", key) if len(parts) == 2 else None
|
||||
value = command.strip()[len("set") :].strip()[len(key) :].strip()
|
||||
return SettingCommand("set", key, value) if value else None
|
||||
|
||||
|
||||
def get_match_keys() -> list[str]:
|
||||
"""Every spelling a firmware ``get`` matches on, longest first.
|
||||
|
||||
A composite's members share their composite's key (``get radio``); verb-shaped settings
|
||||
aren't ``get`` keys at all.
|
||||
"""
|
||||
keys = {s.composite or s.key for s in REPEATER_SETTINGS if s.readable and not s.verb}
|
||||
return sorted(keys, key=len, reverse=True)
|
||||
|
||||
|
||||
def shadowing_key(key: str) -> str | None:
|
||||
"""The catalog ``get`` key that would answer a ``get key`` on firmware lacking ``key``.
|
||||
|
||||
``handleGetCmd`` compares with ``memcmp(config, "radio", 5)`` — no trailing space — so a
|
||||
key it doesn't know falls into the branch of any *shorter* key that prefixes it, and
|
||||
answers with that one's value. ``get radio.rxps`` on stock firmware comes back
|
||||
``> 910.525,62.5,7,5``: not an error, and not a value of the key that was asked for.
|
||||
``handleSetCmd`` matches ``"radio "`` *with* the space, so a write has no such shadow.
|
||||
"""
|
||||
return next((k for k in get_match_keys() if key != k and key.startswith(k)), None)
|
||||
|
||||
|
||||
def learnable_from_get(key: str, reply: str, known: Mapping[str, str]) -> bool:
|
||||
"""Whether a ``get key`` reply really came from ``key`` and not from a shorter sibling.
|
||||
|
||||
Only asked of a key the catalog doesn't have, where there is no typing to reject a
|
||||
wrong-shaped answer with. The reply is trusted when nothing shadows the key
|
||||
(:func:`shadowing_key`), or when it cannot be the shadow's answer — it doesn't parse as
|
||||
the shadow's type, or it parses to something other than what the shadow last said. A
|
||||
shadow whose own value was never read leaves the question unanswerable, and an
|
||||
unanswerable question is not a discovery.
|
||||
|
||||
Args:
|
||||
key: The key the reader asked for.
|
||||
reply: What the node answered.
|
||||
known: The node's last-read values, keyed as the cache keys them.
|
||||
"""
|
||||
shadow = shadowing_key(key)
|
||||
if shadow is None:
|
||||
return True
|
||||
members = composite_members(shadow)
|
||||
if not members:
|
||||
spec = get_setting(shadow)
|
||||
members = [spec] if spec is not None else []
|
||||
if not members: # pragma: no cover - every get key is a catalog key
|
||||
return True
|
||||
parsed = {m.key: parse_reply_value(m, reply) for m in members}
|
||||
if any(value is None for value in parsed.values()):
|
||||
return True # the reply isn't even shaped like the shadow's answer
|
||||
if any(known.get(k) is None for k in parsed):
|
||||
return False # it could be the shadow's answer, and there is nothing to compare
|
||||
return any(known[k] != value for k, value in parsed.items())
|
||||
|
||||
|
||||
def discovered_setting(key: str) -> RemoteSetting:
|
||||
"""A catalog entry for a key the catalog hasn't got, which one node said it has.
|
||||
|
||||
Everything downstream of the catalog — the read plan, the reply parser, the editor's
|
||||
lanes, staging, :func:`write_plan` — speaks :class:`RemoteSetting`, so a discovered key
|
||||
becomes one rather than growing a second path beside it. What it carries is only what
|
||||
was actually learned: the key as the node spells it, and a string value. No range, no
|
||||
words, no options — the firmware never said, and guessing is how a row would start
|
||||
lying about a node whose build we have never seen.
|
||||
"""
|
||||
return RemoteSetting(
|
||||
key=key,
|
||||
label=key,
|
||||
help=DISCOVERED_HELP,
|
||||
category=DISCOVERED_CATEGORY,
|
||||
kind="str",
|
||||
discovered=True,
|
||||
)
|
||||
|
||||
|
||||
def reply_is_error(reply: str) -> bool:
|
||||
"""Whether a reply text reads as the firmware refusing the command.
|
||||
|
||||
@@ -884,6 +1029,11 @@ def write_plan(pending: Mapping[str, str], known: Mapping[str, str]) -> list[Wri
|
||||
else:
|
||||
joined = ",".join(str(values[m.key]) for m in members)
|
||||
writes.append(Write(f"set {spec.composite} {joined}", dict(values))) # type: ignore[arg-type]
|
||||
# A staged key the catalog hasn't got is a discovered one (nothing else can stage a
|
||||
# row), and it is written the only way anything knows how: its own plain `set`.
|
||||
for key, value in pending.items():
|
||||
if get_setting(key) is None:
|
||||
writes.append(Write(f"set {key} {value}", {key: value}))
|
||||
return writes
|
||||
|
||||
|
||||
|
||||
+139
-48
@@ -8,14 +8,19 @@ here, per node (keyed like the admin passwords, by public key), in a small JSON
|
||||
the config directory (``<config_dir>/remote.json``) alongside each node's remote
|
||||
command-line history — global machine state, like the other JSON stores, deliberately
|
||||
outside the per-invocation SQLite database.
|
||||
|
||||
The file is read into typed records (:class:`_RemoteNode`, each holding its
|
||||
:class:`CachedValue` settings) and written back out of them, never round-tripped as the
|
||||
raw JSON it was read as, so a field no record knows is gone after the next write.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .admin_store import admin_key
|
||||
from .atomicwrite import write_atomically
|
||||
@@ -35,11 +40,93 @@ class CachedValue:
|
||||
supported: ``False`` when the node answered the read with an error — its firmware
|
||||
has no such setting (a board without a front-end module, a build without a
|
||||
bridge). Kept apart from *never read*, which is no entry at all.
|
||||
discovered: ``True`` for a setting the catalog hasn't got, learned from a command
|
||||
the reader ran on *this node's* command line. Such a row reads ``n/a`` like any
|
||||
other when the node stops answering it, and is removed only by hand: its key is
|
||||
recorded nowhere else, so a misread reply must not be able to delete it.
|
||||
"""
|
||||
|
||||
value: str
|
||||
read_at: datetime | None
|
||||
supported: bool = True
|
||||
discovered: bool = False
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@classmethod
|
||||
def read(cls, raw: object) -> CachedValue | None:
|
||||
"""Build a value from one file entry, or ``None`` when it is malformed.
|
||||
|
||||
A supported entry must carry its value; an unsupported one carries none.
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
supported = not raw.get("unsupported", False)
|
||||
if supported and "value" not in raw:
|
||||
return None
|
||||
return cls(
|
||||
value=str(raw.get("value", "")),
|
||||
read_at=_as_time(raw.get("read_at")),
|
||||
supported=supported,
|
||||
discovered=bool(raw.get("discovered", False)),
|
||||
)
|
||||
|
||||
def to_json(self) -> dict:
|
||||
"""The entry as written, in the file's long-standing spelling.
|
||||
|
||||
A supported value carries ``value``; an unsupported one says ``unsupported`` and
|
||||
carries no value; ``discovered`` appears only when true.
|
||||
"""
|
||||
entry: dict = {"value": self.value} if self.supported else {"unsupported": True}
|
||||
entry["read_at"] = self.read_at.isoformat() if self.read_at is not None else None
|
||||
if self.discovered:
|
||||
entry["discovered"] = True
|
||||
return entry
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _RemoteNode:
|
||||
"""Everything remembered about one remote node.
|
||||
|
||||
Attributes:
|
||||
settings: Its cached settings, keyed by CLI parameter name.
|
||||
history: Its remote command-line history, oldest first.
|
||||
"""
|
||||
|
||||
settings: dict[str, CachedValue] = field(default_factory=dict)
|
||||
history: list[str] = field(default_factory=list)
|
||||
|
||||
#: Fields this record once held; none yet (see :attr:`CachedValue.RETIRED`).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@classmethod
|
||||
def read(cls, raw: object) -> _RemoteNode:
|
||||
"""Build a node's record from its file entry, taking only the fields it knows."""
|
||||
record = raw if isinstance(raw, dict) else {}
|
||||
settings: dict[str, CachedValue] = {}
|
||||
raw_settings = record.get("settings")
|
||||
if isinstance(raw_settings, dict):
|
||||
for key, entry in raw_settings.items():
|
||||
value = CachedValue.read(entry)
|
||||
if value is not None:
|
||||
settings[str(key)] = value
|
||||
raw_history = record.get("history")
|
||||
history = [str(c) for c in raw_history] if isinstance(raw_history, list) else []
|
||||
return cls(settings=settings, history=history)
|
||||
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
"""Whether there is nothing left to remember about this node."""
|
||||
return not self.settings and not self.history
|
||||
|
||||
def to_json(self) -> dict:
|
||||
"""The record as written: its own fields and nothing it happened to be read with."""
|
||||
return {
|
||||
"settings": {key: value.to_json() for key, value in self.settings.items()},
|
||||
"history": list(self.history),
|
||||
}
|
||||
|
||||
|
||||
class RemoteStore:
|
||||
@@ -53,86 +140,90 @@ class RemoteStore:
|
||||
"""
|
||||
self._path = path
|
||||
|
||||
def _load_all(self) -> dict[str, dict]:
|
||||
"""Return the raw key -> record mapping, or empty on missing/corrupt file."""
|
||||
def _load(self) -> dict[str, _RemoteNode]:
|
||||
"""Read every node's record, keyed by :func:`admin_key`; empty on trouble."""
|
||||
try:
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {str(key): _RemoteNode.read(raw) for key, raw in data.items()}
|
||||
|
||||
def _node(self, node: Contact) -> _RemoteNode:
|
||||
"""One node's record, or an empty one when nothing is remembered."""
|
||||
return self._load().get(admin_key(node)) or _RemoteNode()
|
||||
|
||||
# -- the settings cache -------------------------------------------------------
|
||||
|
||||
def settings(self, node: Contact) -> dict[str, CachedValue]:
|
||||
"""Every remembered setting for ``node``, keyed by CLI parameter name."""
|
||||
record = self._load_all().get(admin_key(node))
|
||||
raw = record.get("settings") if isinstance(record, dict) else None
|
||||
out: dict[str, CachedValue] = {}
|
||||
if not isinstance(raw, dict):
|
||||
return out
|
||||
for key, entry in raw.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
supported = not entry.get("unsupported", False)
|
||||
if supported and "value" not in entry:
|
||||
continue
|
||||
read_at: datetime | None = None
|
||||
stamp = entry.get("read_at")
|
||||
if isinstance(stamp, str):
|
||||
try:
|
||||
read_at = datetime.fromisoformat(stamp)
|
||||
except ValueError:
|
||||
read_at = None
|
||||
out[key] = CachedValue(
|
||||
value=str(entry.get("value", "")), read_at=read_at, supported=supported
|
||||
)
|
||||
return out
|
||||
return dict(self._node(node).settings)
|
||||
|
||||
def remember_setting(self, node: Contact, key: str, value: str) -> None:
|
||||
"""Cache one setting's value for ``node``, stamped now."""
|
||||
self._put(node, key, {"value": value, "read_at": utcnow().isoformat()})
|
||||
self._put(node, key, CachedValue(value=value, read_at=utcnow()))
|
||||
|
||||
def remember_discovered(self, node: Contact, key: str, value: str) -> None:
|
||||
"""Cache a setting the catalog hasn't got, which ``node`` has just proved it has."""
|
||||
self._put(node, key, CachedValue(value=value, read_at=utcnow(), discovered=True))
|
||||
|
||||
def remember_unsupported(self, node: Contact, key: str) -> None:
|
||||
"""Record that ``node`` answered a read of ``key`` with an error, stamped now."""
|
||||
self._put(node, key, {"unsupported": True, "read_at": utcnow().isoformat()})
|
||||
self._put(node, key, CachedValue(value="", read_at=utcnow(), supported=False))
|
||||
|
||||
def forget_setting(self, node: Contact, key: str) -> None:
|
||||
"""Drop one cached setting, so the row reads as never read."""
|
||||
records = self._load_all()
|
||||
settings = records.get(admin_key(node), {}).get("settings")
|
||||
if isinstance(settings, dict) and settings.pop(key, None) is not None:
|
||||
records = self._load()
|
||||
record = records.get(admin_key(node))
|
||||
if record is not None and record.settings.pop(key, None) is not None:
|
||||
self._write(records)
|
||||
|
||||
def _put(self, node: Contact, key: str, entry: dict) -> None:
|
||||
"""Store one setting's cache entry for ``node``."""
|
||||
records = self._load_all()
|
||||
record = records.setdefault(admin_key(node), {})
|
||||
settings = record.setdefault("settings", {})
|
||||
settings[key] = entry
|
||||
def _put(self, node: Contact, key: str, value: CachedValue) -> None:
|
||||
"""Store one setting's cache entry for ``node``, keeping it discovered if it was.
|
||||
|
||||
A discovered row is refreshed by the same reads and writes as any other — through
|
||||
:meth:`remember_setting` — and that must not quietly demote it to a catalog row it
|
||||
has no entry for, which would leave a row nothing draws.
|
||||
"""
|
||||
records = self._load()
|
||||
record = records.setdefault(admin_key(node), _RemoteNode())
|
||||
previous = record.settings.get(key)
|
||||
if previous is not None and previous.discovered:
|
||||
value = replace(value, discovered=True)
|
||||
record.settings[key] = value
|
||||
self._write(records)
|
||||
|
||||
# -- the command-line history ---------------------------------------------------
|
||||
|
||||
def history(self, node: Contact) -> list[str]:
|
||||
"""The node's remote CLI history, oldest first."""
|
||||
record = self._load_all().get(admin_key(node))
|
||||
raw = record.get("history") if isinstance(record, dict) else None
|
||||
return [str(c) for c in raw] if isinstance(raw, list) else []
|
||||
return list(self._node(node).history)
|
||||
|
||||
def append_history(self, node: Contact, command: str) -> None:
|
||||
"""Append one sent command to the node's history (dropping an adjacent dupe)."""
|
||||
command = command.strip()
|
||||
if not command:
|
||||
return
|
||||
records = self._load_all()
|
||||
record = records.setdefault(admin_key(node), {})
|
||||
history = record.setdefault("history", [])
|
||||
if history and history[-1] == command:
|
||||
records = self._load()
|
||||
record = records.setdefault(admin_key(node), _RemoteNode())
|
||||
if record.history and record.history[-1] == command:
|
||||
return # re-running the last command shouldn't stutter the recall
|
||||
history.append(command)
|
||||
del history[:-HISTORY_CAP]
|
||||
record.history.append(command)
|
||||
del record.history[:-HISTORY_CAP]
|
||||
self._write(records)
|
||||
|
||||
def _write(self, records: dict[str, dict]) -> None:
|
||||
def _write(self, records: dict[str, _RemoteNode]) -> None:
|
||||
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
|
||||
write_atomically(self._path, json.dumps(records, indent=2))
|
||||
data = {key: record.to_json() for key, record in records.items() if not record.empty}
|
||||
write_atomically(self._path, json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def _as_time(value: object) -> datetime | None:
|
||||
"""Parse a stored ISO-8601 timestamp, or ``None`` if absent or corrupt."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -112,9 +112,17 @@ def resolve_device(
|
||||
3. ``explicit_port`` (an explicit ``--port``).
|
||||
4. A TCP profile's ``host:port``, or a serial profile's ``port``.
|
||||
5. The remembered "last known good" device, if it is currently attached/in range.
|
||||
6. The single attached device, if exactly one is present.
|
||||
6. The single *likely-LoRa* device, if exactly one is present — a port whose USB
|
||||
vendor marks it a board or bridge, or a BLE/TCP endpoint. Ports that look like
|
||||
nothing in particular are ignored at this step, because a platform may present
|
||||
some unconditionally (every Mac carries two) and they would otherwise make the
|
||||
count ambiguous forever.
|
||||
7. The single attached device, if exactly one is present and *none* looked likely —
|
||||
so an unrecognized-but-real adapter still resolves.
|
||||
|
||||
Otherwise a :class:`DeviceSelectionError` is raised listing the candidates.
|
||||
Otherwise a :class:`DeviceSelectionError` is raised listing the candidates: the
|
||||
likely ones where there are any, else everything attached under a message that does
|
||||
not claim they are companions.
|
||||
|
||||
Args:
|
||||
devices: Currently discovered devices (serial and/or BLE).
|
||||
@@ -155,7 +163,23 @@ def resolve_device(
|
||||
if match is not None:
|
||||
return Resolution(match.target, match, "remembered", match.transport)
|
||||
|
||||
if len(devices) == 1:
|
||||
# Prefer the devices that actually look like companions. macOS is why this matters:
|
||||
# every Mac permanently presents /dev/cu.Bluetooth-Incoming-Port and
|
||||
# /dev/cu.debug-console, which carry no USB VID/PID and so score "unknown". Counting
|
||||
# them put `len(devices) == 1` out of reach there — a Mac with exactly one real board
|
||||
# attached saw three devices and refused to choose, so auto-detection could never fire
|
||||
# on that platform and every Mac user met "Multiple companion devices detected" on a
|
||||
# first run. Which device is plausible is already known, and already printed in the
|
||||
# listing below as "[likely LoRa]"; this decides with it instead of only saying it.
|
||||
likely = [d for d in devices if d.is_likely_lora]
|
||||
|
||||
if len(likely) == 1:
|
||||
return Resolution(likely[0].target, likely[0], "only", likely[0].transport)
|
||||
|
||||
# Nothing recognizable: fall back to the whole list rather than narrowing to nothing,
|
||||
# so a lone *unrecognized* adapter — a UART bridge carrying a VID we don't list, which
|
||||
# is a real board more often than not — still connects exactly as it always has.
|
||||
if not likely and len(devices) == 1:
|
||||
return Resolution(devices[0].target, devices[0], "only", devices[0].transport)
|
||||
|
||||
if not devices:
|
||||
@@ -164,9 +188,20 @@ def resolve_device(
|
||||
"or pass --port / --ble explicitly, or use --mock for the simulator."
|
||||
)
|
||||
|
||||
if not likely:
|
||||
# Several ports attached, none of them plausible. "Multiple companion devices" is
|
||||
# simply false here: on a bare Mac it names two virtual ports that are not
|
||||
# companions at all, and then tells the reader to choose one of them.
|
||||
raise DeviceSelectionError(
|
||||
"No companion devices detected among the attached ports.\n"
|
||||
f"{_format_device_list(devices)}\n"
|
||||
"Connect a companion device (USB or Bluetooth), or pass --port / --ble "
|
||||
"explicitly if one of these is your companion, or use --mock for the simulator."
|
||||
)
|
||||
|
||||
raise DeviceSelectionError(
|
||||
"Multiple companion devices detected and no default to fall back on.\n"
|
||||
f"{_format_device_list(devices)}\n"
|
||||
f"{_format_device_list(likely)}\n"
|
||||
"Choose one with --port <PORT> or --ble <ADDRESS> (or run 'meshterm devices' to "
|
||||
"inspect them). The chosen device is remembered as the default after it connects."
|
||||
)
|
||||
@@ -34,7 +34,13 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .device_config import DeviceConfigError, format_value, get_spec, parse_value
|
||||
from .device_config import (
|
||||
DeviceConfigError,
|
||||
format_value,
|
||||
get_spec,
|
||||
is_setting_key,
|
||||
parse_value,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .connection import Device
|
||||
@@ -86,9 +92,14 @@ class SettingsStore:
|
||||
for pubkey, entries in (data.get("devices") or {}).items():
|
||||
if not isinstance(entries, dict):
|
||||
continue
|
||||
# Keep only plain scalar values; a container or null is a malformed entry.
|
||||
# Keep only plain scalar values of settings the registry knows: a container or
|
||||
# null is a malformed entry, and a key that is no longer a setting (or a typo)
|
||||
# is not something to offer to write back onto the radio. Whatever is left out
|
||||
# here is gone from the file at its next save.
|
||||
values = {
|
||||
str(key): value for key, value in entries.items() if isinstance(value, _SCALARS)
|
||||
str(key): value
|
||||
for key, value in entries.items()
|
||||
if isinstance(value, _SCALARS) and is_setting_key(str(key))
|
||||
}
|
||||
if values:
|
||||
devices[_norm(str(pubkey))] = values
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""A LoRa radio on the host's own SPI bus, driven by a node MeshTerm runs only while it runs.
|
||||
|
||||
Some boards — the uConsole's AIO is the one this was built for — put the radio chip straight
|
||||
on the host's SPI bus, with no microcontroller and so no companion firmware to talk to. A
|
||||
software node has to run somewhere. The standalone ``meshterm-spi-bridge`` service runs one
|
||||
all the time, which keeps the node on the mesh while MeshTerm is closed but holds the radio's
|
||||
pins for good, so no other LoRa program can use them until the service is stopped.
|
||||
|
||||
This is the other shape: **MeshTerm runs the node itself, for exactly as long as it is
|
||||
connected.** :class:`SpiRadioDevice` starts the node (:mod:`meshterm.core.radionode`) as a
|
||||
child process when it connects, talks to it over the ordinary companion protocol on a
|
||||
loopback port — so every feature works exactly as it does against a network companion — and
|
||||
ends it when it disconnects. The pins go back when MeshTerm quits, and also when it crashes:
|
||||
the node exits with its parent (see :mod:`~meshterm.core.radionode`), and the kernel frees a
|
||||
process's GPIO lines and SPI handle when it exits.
|
||||
|
||||
**Why a child process and not a library call.** The radio library (``openhop-core``) is an
|
||||
optional install, deliberately not something every desktop MeshTerm carries, and the
|
||||
one-file build can't import it at all. So the node runs under whichever Python *has* the
|
||||
library (:func:`find_runtime`): MeshTerm's own when it was installed with the ``spi``
|
||||
extra, the bridge's venv, or the ``meshcore-uconsole`` package's. A separate process also
|
||||
keeps the node's radio timing off the event loop that draws the screen.
|
||||
|
||||
**One radio, one identity.** A node's state — its identity key, preferences, channels and
|
||||
contacts — lives under ``<config_dir>/radio/<spidev>/``, keyed by the SPI device rather than
|
||||
by a profile name, so reaching the same radio from the picker one day and from a profile the
|
||||
next is still the same node. The first time a radio is used, the bridge's identity and
|
||||
contacts are copied in (:func:`import_bridge_state`) so an existing node keeps its key, and
|
||||
with it its place in everyone else's contact list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import DeviceProfile, SpiWiring
|
||||
from .connection import DeviceCommandError, MeshCoreDevice
|
||||
from .discovery import DiscoveredDevice, spi_device
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
#: The radio library the node needs, as ``pip`` spells it — what every "install it" hint says.
|
||||
RUNTIME_PACKAGE = "openhop-core[hardware]"
|
||||
|
||||
#: How long a starting node gets to report ready. It imports the radio library, brings the
|
||||
#: chip up (retrying a busy pin a few times) and starts listening; on a PicoCalc-class board
|
||||
#: the import alone is several seconds.
|
||||
READY_TIMEOUT_S = 60.0
|
||||
|
||||
#: How long a node gets to shut down on its own after its stdin closes, before it is sent
|
||||
#: SIGTERM, and then again before SIGKILL. Its own shutdown saves contacts and frees the
|
||||
#: radio; the signals are for a node that has stopped answering.
|
||||
STOP_GRACE_S = 8.0
|
||||
TERM_GRACE_S = 3.0
|
||||
|
||||
#: What the node is seeded with the first time a radio is used and it has no preferences of
|
||||
#: its own: MeshCore's US/Canada preset at the AIO's 22 dBm. Afterwards the node's saved
|
||||
#: settings rule, edited on Device config like any companion's.
|
||||
DEFAULT_SEED: dict[str, object] = {
|
||||
"frequency_hz": 910_525_000,
|
||||
"bandwidth_hz": 62_500,
|
||||
"spreading_factor": 7,
|
||||
"coding_rate": 5,
|
||||
"tx_power_dbm": 22,
|
||||
}
|
||||
|
||||
#: The standalone bridge's service name and the directory it keeps its state in.
|
||||
BRIDGE_SERVICE = "meshterm-spi-bridge.service"
|
||||
BRIDGE_NAME = "meshterm-spi-bridge"
|
||||
|
||||
#: ``MESHCORE_*`` variables the bridge reads, mapped to the node preference each seeds.
|
||||
_BRIDGE_RADIO_ENV = {
|
||||
"MESHCORE_FREQUENCY": "frequency_hz",
|
||||
"MESHCORE_BANDWIDTH": "bandwidth_hz",
|
||||
"MESHCORE_SPREADING_FACTOR": "spreading_factor",
|
||||
"MESHCORE_CODING_RATE": "coding_rate",
|
||||
"MESHCORE_TX_POWER": "tx_power_dbm",
|
||||
}
|
||||
|
||||
|
||||
class SpiRadioError(DeviceCommandError):
|
||||
"""The node could not be started, worded for the person who has to fix it.
|
||||
|
||||
A :class:`~meshterm.core.connection.DeviceCommandError`, because that is what the
|
||||
startup picker and the connect path pass through as an *actionable* failure — shown
|
||||
with its remedy — rather than folding it into "didn't answer".
|
||||
|
||||
Attributes:
|
||||
kind: What went wrong — ``unsupported``, ``runtime``, ``no-spi``, ``no-gpio``,
|
||||
``permission``, ``busy``, or ``failed`` (see :func:`explain`).
|
||||
"""
|
||||
|
||||
def __init__(self, kind: str, message: str) -> None:
|
||||
"""Carry ``message`` as the error text and ``kind`` as its classification."""
|
||||
super().__init__(message)
|
||||
self.kind = kind
|
||||
|
||||
|
||||
# --- where things are ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def node_script() -> Path:
|
||||
"""The node's source file, which is run by path under another interpreter.
|
||||
|
||||
It sits beside this module in a checkout and in an install, and the one-file build
|
||||
ships it as a data file at the same relative place (``packaging/meshterm.spec``).
|
||||
"""
|
||||
return Path(__file__).with_name("radionode.py")
|
||||
|
||||
|
||||
def state_dir(config_dir: Path, wiring: SpiWiring) -> Path:
|
||||
"""Where the node on ``wiring``'s SPI device keeps its identity, settings and lists."""
|
||||
return config_dir / "radio" / Path(wiring.spidev).name
|
||||
|
||||
|
||||
def _on_linux() -> bool:
|
||||
"""Whether this is Linux, the one place a radio on the SPI bus can be driven from."""
|
||||
return sys.platform.startswith("linux")
|
||||
|
||||
|
||||
def _xdg_data_home() -> Path:
|
||||
"""``$XDG_DATA_HOME``, or ``~/.local/share`` where it is unset."""
|
||||
raw = os.environ.get("XDG_DATA_HOME", "").strip()
|
||||
return Path(raw) if raw else Path.home() / ".local" / "share"
|
||||
|
||||
|
||||
def spi_present(wiring: SpiWiring) -> bool:
|
||||
"""Whether ``wiring``'s SPI device node exists on this machine (Linux only)."""
|
||||
return _on_linux() and os.path.exists(wiring.spidev)
|
||||
|
||||
|
||||
def spi_radios(
|
||||
profiles: Mapping[str, DeviceProfile] | None, listed: Iterable[DiscoveredDevice] = ()
|
||||
) -> list[DiscoveredDevice]:
|
||||
"""The radios on this machine's own SPI bus, as devices — THE one answer to "which are here".
|
||||
|
||||
One per ``transport = "spi"`` profile whose ``/dev/spidev*`` node exists, named by the
|
||||
profile; and, where no profile covers it, one for the uConsole AIO's device node when it
|
||||
exists, wired with the defaults. Nothing is probed — a radio on the bus has no node
|
||||
running until one is chosen — so a radio is listed exactly when there is a device node
|
||||
to open. The device screen and ``meshterm devices`` both list from here, so the two can
|
||||
never disagree about what is attached.
|
||||
|
||||
Args:
|
||||
profiles: The configured profiles, or ``None`` when none are loaded.
|
||||
listed: Devices already gathered; a radio among them is not listed twice.
|
||||
|
||||
Returns:
|
||||
One SPI :class:`~meshterm.core.discovery.DiscoveredDevice` per radio not in ``listed``.
|
||||
"""
|
||||
seen = {d.stable_id for d in listed}
|
||||
wirings = [(p.spi or SpiWiring(), p.name) for p in (profiles or {}).values() if p.is_spi]
|
||||
covered = {wiring.spidev for wiring, _name in wirings}
|
||||
if SpiWiring().spidev not in covered:
|
||||
wirings.append((SpiWiring(), ""))
|
||||
radios: list[DiscoveredDevice] = []
|
||||
for wiring, name in wirings:
|
||||
if not spi_present(wiring):
|
||||
continue
|
||||
device = spi_device(wiring, name=name)
|
||||
if device.stable_id in seen:
|
||||
continue
|
||||
seen.add(device.stable_id)
|
||||
radios.append(device)
|
||||
return radios
|
||||
|
||||
|
||||
# --- finding a Python with the radio library -----------------------------------------------
|
||||
|
||||
#: Each interpreter probed this session: path -> whether it can import the radio library.
|
||||
_probed: dict[str, bool] = {}
|
||||
|
||||
|
||||
def _runtime_candidates(wiring: SpiWiring) -> list[str]:
|
||||
"""Interpreters worth probing for the radio library, most specific first."""
|
||||
candidates: list[str] = []
|
||||
if wiring.python:
|
||||
candidates.append(os.path.expanduser(wiring.python))
|
||||
if not getattr(sys, "frozen", False):
|
||||
candidates.append(sys.executable) # MeshTerm's own, installed with the ``spi`` extra
|
||||
data = _xdg_data_home()
|
||||
candidates += [
|
||||
str(data / BRIDGE_NAME / "venv" / "bin" / "python"),
|
||||
"/opt/venvs/meshcore-uconsole/bin/python",
|
||||
]
|
||||
system = shutil.which("python3")
|
||||
if system:
|
||||
candidates.append(system)
|
||||
seen: set[str] = set()
|
||||
return [c for c in candidates if c and not (c in seen or seen.add(c))]
|
||||
|
||||
|
||||
def _can_import_runtime(python: str) -> bool:
|
||||
"""Whether ``python`` can import ``openhop_core`` (one short subprocess, then cached)."""
|
||||
if python in _probed:
|
||||
return _probed[python]
|
||||
ok = False
|
||||
if os.path.exists(python):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[python, "-c", "import openhop_core"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=child_env(),
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
ok = result.returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
ok = False
|
||||
_probed[python] = ok
|
||||
return ok
|
||||
|
||||
|
||||
def find_runtime(wiring: SpiWiring) -> str:
|
||||
"""An interpreter that can run the node, or raise :class:`SpiRadioError` (``runtime``).
|
||||
|
||||
A ``python`` named in the wiring is the only candidate when it is set — naming one is a
|
||||
decision, and quietly falling back past it would hide that it doesn't work.
|
||||
"""
|
||||
candidates = _runtime_candidates(wiring)
|
||||
if wiring.python:
|
||||
candidates = candidates[:1]
|
||||
for python in candidates:
|
||||
if _can_import_runtime(python):
|
||||
return python
|
||||
if wiring.python:
|
||||
raise SpiRadioError("runtime", f"{wiring.python} cannot import openhop_core")
|
||||
raise SpiRadioError("runtime", "no Python on this machine has the radio library")
|
||||
|
||||
|
||||
def child_env() -> dict[str, str]:
|
||||
"""The environment for a program MeshTerm starts, minus what a frozen build injected.
|
||||
|
||||
The one-file build's bootloader leaves its own variables behind (``_PYI_*``,
|
||||
``_MEIPASS2``) and points ``LD_LIBRARY_PATH`` at its unpacked libraries; another Python
|
||||
started with those loads MeshTerm's bundled copies of libssl and friends instead of its
|
||||
own. PyInstaller keeps the original under ``LD_LIBRARY_PATH_ORIG``, which is put back.
|
||||
"""
|
||||
env = {k: v for k, v in os.environ.items() if not (k.startswith("_PYI") or k == "_MEIPASS2")}
|
||||
if getattr(sys, "frozen", False):
|
||||
original = env.pop("LD_LIBRARY_PATH_ORIG", None)
|
||||
if original is not None:
|
||||
env["LD_LIBRARY_PATH"] = original
|
||||
else:
|
||||
env.pop("LD_LIBRARY_PATH", None)
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
# --- who else has the radio ----------------------------------------------------------------
|
||||
|
||||
|
||||
def radio_holder() -> str | None:
|
||||
"""Name the program already holding the radio, when it is one we know how to stop.
|
||||
|
||||
Asked before starting the node, because the node's own answer to a held pin is a bare
|
||||
"busy" after several seconds of retries, and the two usual holders each have a one-line
|
||||
fix worth saying instead.
|
||||
"""
|
||||
if not _on_linux():
|
||||
return None
|
||||
systemctl = shutil.which("systemctl")
|
||||
if systemctl:
|
||||
try:
|
||||
active = subprocess.run(
|
||||
[systemctl, "--user", "is-active", "--quiet", BRIDGE_SERVICE],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
if active.returncode == 0:
|
||||
return "bridge"
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
for cmdline in Path("/proc").glob("[0-9]*/cmdline"):
|
||||
try:
|
||||
argv = cmdline.read_bytes().split(b"\0")
|
||||
except OSError:
|
||||
continue
|
||||
if any(os.path.basename(a) == b"meshcore-console" for a in argv[:3]):
|
||||
return "console"
|
||||
return None
|
||||
|
||||
|
||||
def explain(kind: str, detail: str, wiring: SpiWiring, holder: str | None = None) -> str:
|
||||
"""The sentence a failed start is reported with: what is wrong and what to do about it."""
|
||||
if kind == "busy" or holder:
|
||||
if holder == "bridge":
|
||||
return (
|
||||
f"the {BRIDGE_NAME} service has the radio — stop it with "
|
||||
f"`systemctl --user stop {BRIDGE_NAME}` (and `disable` it to keep it stopped)"
|
||||
)
|
||||
if holder == "console":
|
||||
return "meshcore-console has the radio open — close it and connect again"
|
||||
return (
|
||||
"another program has the radio's pins "
|
||||
f"(`sudo lsof /dev/gpiochip{wiring.gpio_chip}` shows which)"
|
||||
)
|
||||
if kind == "runtime":
|
||||
return (
|
||||
f"{detail}. Install it with `pipx inject mesh-term '{RUNTIME_PACKAGE}'`, or into a "
|
||||
"venv of its own and name that venv's python as `python` in the profile's spi table"
|
||||
)
|
||||
if kind == "no-spi":
|
||||
return (
|
||||
f"{wiring.spidev} does not exist — enable SPI (on a uConsole, add "
|
||||
"`dtoverlay=spi1-1cs` to /boot/firmware/config.txt) and reboot"
|
||||
)
|
||||
if kind == "no-gpio":
|
||||
return f"/dev/gpiochip{wiring.gpio_chip} does not exist — check `gpio_chip` in the profile"
|
||||
if kind == "permission":
|
||||
return (
|
||||
f"{detail} — add yourself to the spi and gpio groups "
|
||||
"(`sudo usermod -aG spi,gpio $USER`) and log in again"
|
||||
)
|
||||
if kind == "unsupported":
|
||||
return detail
|
||||
return f"the radio node failed: {detail}"
|
||||
|
||||
|
||||
# --- first use: carrying over the bridge's node --------------------------------------------
|
||||
|
||||
|
||||
def _bridge_radio_seed() -> dict[str, int]:
|
||||
"""The radio settings the bridge service runs with, from its unit's environment.
|
||||
|
||||
The bridge takes its frequency, preset and power from ``MESHCORE_*`` variables, usually
|
||||
set in a drop-in; a node that inherits the bridge's identity should come up on the same
|
||||
channel of air, not on the default preset. Empty when there is no such service.
|
||||
"""
|
||||
systemctl = shutil.which("systemctl")
|
||||
if not systemctl:
|
||||
return {}
|
||||
try:
|
||||
shown = subprocess.run(
|
||||
[systemctl, "--user", "show", "--property=Environment", BRIDGE_SERVICE],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return {}
|
||||
seed: dict[str, int] = {}
|
||||
for word in shown.stdout.removeprefix("Environment=").split():
|
||||
name, _, value = word.partition("=")
|
||||
if name in _BRIDGE_RADIO_ENV:
|
||||
try:
|
||||
seed[_BRIDGE_RADIO_ENV[name]] = int(value)
|
||||
except ValueError:
|
||||
continue
|
||||
return seed
|
||||
|
||||
|
||||
def import_bridge_state(state: Path) -> list[str]:
|
||||
"""Copy the bridge's node into a radio's state directory the first time it is used.
|
||||
|
||||
Runs only while the state directory has no identity yet, and copies rather than moves, so
|
||||
the bridge still works for anyone who goes back to it. The identity is looked for where
|
||||
the bridge itself looks, in its order: the ``meshcore-uconsole`` GUI's key first (the
|
||||
bridge shares it), then the bridge's own. Contacts come from the bridge's snapshot, whose
|
||||
records are the radio library's own contact dicts, so they load unchanged. Radio settings
|
||||
are seeded from the service's environment, and the node keeps the bridge's name.
|
||||
|
||||
Args:
|
||||
state: The radio's state directory.
|
||||
|
||||
Returns:
|
||||
What was carried over, for the log — empty when nothing was.
|
||||
"""
|
||||
if (state / "identity.key").exists():
|
||||
return []
|
||||
data = _xdg_data_home()
|
||||
bridge = data / BRIDGE_NAME
|
||||
carried: list[str] = []
|
||||
for key in (data / "meshcore-uconsole" / "identity.key", bridge / "identity.key"):
|
||||
if key.is_file():
|
||||
state.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(key, state / "identity.key")
|
||||
os.chmod(state / "identity.key", 0o600)
|
||||
carried.append(f"identity from {key}")
|
||||
break
|
||||
if not carried:
|
||||
return []
|
||||
contacts = bridge / "contacts.json"
|
||||
if contacts.is_file() and not (state / "contacts.json").exists():
|
||||
shutil.copyfile(contacts, state / "contacts.json")
|
||||
carried.append(f"contacts from {contacts}")
|
||||
if not (state / "prefs.json").exists():
|
||||
prefs: dict[str, object] = {"node_name": "uConsole", **_bridge_radio_seed()}
|
||||
(state / "prefs.json").write_text(json.dumps(prefs, indent=2), encoding="utf-8")
|
||||
carried.append("the bridge's name and radio settings")
|
||||
return carried
|
||||
|
||||
|
||||
# --- the node process ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeProcess:
|
||||
"""One running node: the child process and the loopback port it serves on.
|
||||
|
||||
Attributes:
|
||||
process: The child.
|
||||
port: The loopback TCP port its frame server listens on.
|
||||
public_key: The node's public key, hex, as it reported it.
|
||||
log_path: Where its stderr goes.
|
||||
"""
|
||||
|
||||
process: asyncio.subprocess.Process
|
||||
port: int
|
||||
public_key: str
|
||||
log_path: Path
|
||||
|
||||
@property
|
||||
def alive(self) -> bool:
|
||||
"""Whether the child is still running."""
|
||||
return self.process.returncode is None
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""End the node and wait for it: close its stdin, then SIGTERM, then SIGKILL.
|
||||
|
||||
Closing stdin is the polite request — the node saves its contacts and frees the radio
|
||||
on the way out. The signals are for a node that has stopped listening, and each wait
|
||||
is bounded so a wedged child can never hold up a quit.
|
||||
"""
|
||||
proc = self.process
|
||||
if proc.returncode is not None:
|
||||
return
|
||||
if proc.stdin is not None:
|
||||
proc.stdin.close()
|
||||
for grace, escalate in ((STOP_GRACE_S, proc.terminate), (TERM_GRACE_S, proc.kill)):
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=grace)
|
||||
return
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
escalate()
|
||||
except ProcessLookupError:
|
||||
return
|
||||
await proc.wait()
|
||||
|
||||
|
||||
def _rotate(log_path: Path) -> None:
|
||||
"""Keep the previous run's node log beside this one's, and no more."""
|
||||
if log_path.exists():
|
||||
try:
|
||||
log_path.replace(log_path.with_suffix(".log.1"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
async def start_node(wiring: SpiWiring, state: Path, *, node_name: str) -> NodeProcess:
|
||||
"""Start the node on ``wiring``'s radio and wait until it is listening.
|
||||
|
||||
Args:
|
||||
wiring: How the radio is wired.
|
||||
state: The radio's state directory.
|
||||
node_name: The name a brand-new node advertises until it is renamed.
|
||||
|
||||
Returns:
|
||||
The running node.
|
||||
|
||||
Raises:
|
||||
SpiRadioError: If it can't be started, with the reason already worded.
|
||||
"""
|
||||
if not _on_linux():
|
||||
raise SpiRadioError("unsupported", "a radio on the SPI bus needs Linux")
|
||||
# Each of these runs a short subprocess or two; none of them belongs on the event loop
|
||||
# that is drawing the "connecting" card.
|
||||
holder = await asyncio.to_thread(radio_holder)
|
||||
if holder:
|
||||
raise SpiRadioError("busy", explain("busy", "", wiring, holder))
|
||||
try:
|
||||
python = await asyncio.to_thread(find_runtime, wiring)
|
||||
except SpiRadioError as exc:
|
||||
raise SpiRadioError(exc.kind, explain(exc.kind, str(exc), wiring)) from None
|
||||
|
||||
state.mkdir(parents=True, exist_ok=True)
|
||||
for line in await asyncio.to_thread(import_bridge_state, state):
|
||||
_log.info("spi radio: carried over %s", line)
|
||||
config = {
|
||||
"state_dir": str(state),
|
||||
"wiring": {
|
||||
"bus_id": wiring.bus_id,
|
||||
"cs_id": wiring.cs_id,
|
||||
"cs_pin": wiring.cs_pin,
|
||||
"gpio_chip": wiring.gpio_chip,
|
||||
"use_gpiod_backend": wiring.use_gpiod_backend,
|
||||
"reset_pin": wiring.reset_pin,
|
||||
"busy_pin": wiring.busy_pin,
|
||||
"irq_pin": wiring.irq_pin,
|
||||
"txen_pin": wiring.txen_pin,
|
||||
"rxen_pin": wiring.rxen_pin,
|
||||
"en_pins": list(wiring.en_pins) or None,
|
||||
"use_dio2_rf": wiring.use_dio2_rf,
|
||||
"use_dio3_tcxo": wiring.use_dio3_tcxo,
|
||||
"is_waveshare": wiring.is_waveshare,
|
||||
},
|
||||
"seed": {"node_name": node_name, **DEFAULT_SEED},
|
||||
}
|
||||
log_path = state / "node.log"
|
||||
_rotate(log_path)
|
||||
_log.info("spi radio: starting the node under %s (log %s)", python, log_path)
|
||||
with log_path.open("wb") as log_file:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
python,
|
||||
str(node_script()),
|
||||
"--config",
|
||||
json.dumps(config),
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=log_file,
|
||||
env=child_env(),
|
||||
start_new_session=True, # ^C in the terminal is MeshTerm's to handle, not the node's
|
||||
)
|
||||
node = NodeProcess(process=proc, port=0, public_key="", log_path=log_path)
|
||||
try:
|
||||
line = await asyncio.wait_for(proc.stdout.readline(), timeout=READY_TIMEOUT_S)
|
||||
except asyncio.CancelledError:
|
||||
proc.kill() # abandoned mid-start (a probe's own deadline): nothing may keep the radio
|
||||
raise
|
||||
except asyncio.TimeoutError:
|
||||
await node.stop()
|
||||
detail = f"it did not start within {READY_TIMEOUT_S:.0f}s (see {log_path})"
|
||||
raise SpiRadioError("failed", explain("failed", detail, wiring)) from None
|
||||
try:
|
||||
status = json.loads(line) if line else {}
|
||||
except ValueError:
|
||||
status = {}
|
||||
if status.get("event") != "ready":
|
||||
await node.stop()
|
||||
kind = str(status.get("kind") or "failed")
|
||||
detail = str(status.get("message") or f"it exited without a word (see {log_path})")
|
||||
holder = await asyncio.to_thread(radio_holder) if kind == "busy" else None
|
||||
raise SpiRadioError(kind, explain(kind, detail, wiring, holder))
|
||||
node.port = int(status["port"])
|
||||
node.public_key = str(status.get("public_key") or "")
|
||||
_log.info("spi radio: node %s… ready on 127.0.0.1:%d", node.public_key[:16], node.port)
|
||||
return node
|
||||
|
||||
|
||||
# --- the device ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SpiRadioDevice(MeshCoreDevice):
|
||||
"""A companion connection to a node MeshTerm starts on the host's own radio.
|
||||
|
||||
Underneath it is exactly a network companion on ``127.0.0.1`` — every command, event
|
||||
and trace goes through :class:`MeshCoreDevice`'s TCP path unchanged — with the node's
|
||||
lifetime tied to the connection's: :meth:`connect` starts it, :meth:`disconnect` ends
|
||||
it, and a reconnect (which disconnects and builds a fresh device) starts a fresh one.
|
||||
"""
|
||||
|
||||
def __init__(self, wiring: SpiWiring, state: Path, *, node_name: str = "MeshTerm") -> None:
|
||||
"""Prepare the device; nothing is started until :meth:`connect`.
|
||||
|
||||
Args:
|
||||
wiring: How the radio is wired.
|
||||
state: The radio's state directory (see :func:`state_dir`).
|
||||
node_name: The name a brand-new node advertises until it is renamed.
|
||||
"""
|
||||
super().__init__(transport="tcp", host="127.0.0.1", tcp_port=0)
|
||||
self._wiring = wiring
|
||||
self._state = state
|
||||
self._node_name = node_name
|
||||
self._node: NodeProcess | None = None
|
||||
|
||||
@property
|
||||
def transport(self) -> str:
|
||||
"""``"spi"`` — the loopback socket underneath is an implementation detail."""
|
||||
return "spi"
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
"""The SPI device the radio is on, which is how the radio is named and remembered."""
|
||||
return self._wiring.spidev
|
||||
|
||||
@property
|
||||
def wiring(self) -> SpiWiring:
|
||||
"""How the radio this device drives is wired."""
|
||||
return self._wiring
|
||||
|
||||
async def connect(self) -> None: # noqa: D102 - inherited docstring
|
||||
if self._mc is not None:
|
||||
return
|
||||
if self._node is None or not self._node.alive:
|
||||
self._node = await start_node(self._wiring, self._state, node_name=self._node_name)
|
||||
self._tcp_port = self._node.port
|
||||
try:
|
||||
await super().connect()
|
||||
except BaseException:
|
||||
await self._stop_node()
|
||||
raise
|
||||
|
||||
async def link_present(self) -> bool: # noqa: D102 - inherited docstring
|
||||
if self._node is not None and not self._node.alive:
|
||||
return False # the node died, so the radio is gone whatever the socket says
|
||||
return await super().link_present()
|
||||
|
||||
async def disconnect(self) -> None: # noqa: D102 - inherited docstring
|
||||
try:
|
||||
await super().disconnect()
|
||||
finally:
|
||||
await self._stop_node()
|
||||
|
||||
async def _stop_node(self) -> None:
|
||||
"""End the node, if one is running, and forget it."""
|
||||
node, self._node = self._node, None
|
||||
if node is not None:
|
||||
await node.stop()
|
||||
|
||||
def _no_response_message(self) -> str:
|
||||
"""The node started but never answered as a companion: point at its log."""
|
||||
where = self._node.log_path if self._node is not None else self._state / "node.log"
|
||||
return f"the radio node on {self._wiring.spidev} did not answer; its log is {where}"
|
||||
@@ -63,6 +63,15 @@ class TransmitGate:
|
||||
if flood_advert:
|
||||
self._last_flood_advert = now
|
||||
|
||||
@property
|
||||
def last_sent(self) -> float | None:
|
||||
"""When anything last went out, on :func:`time.monotonic`'s clock; ``None`` if never.
|
||||
|
||||
For a listener that treats our own transmissions as breaking the air's silence
|
||||
(the weekly advert's wait for a quiet spell) rather than as a cooldown to wait out.
|
||||
"""
|
||||
return self._last_sent
|
||||
|
||||
def remaining(self, *, flood_advert: bool = False) -> float:
|
||||
"""Seconds a caller should wait before transmitting; ``0.0`` when it may go now.
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The transmit lock: what keeps a channel's scope from leaking onto someone else's flood.
|
||||
|
||||
The firmware has no per-channel scope. What it has is a *session* scope — one setting on
|
||||
the companion that every flood it sends goes out under, channel messages, flood DMs,
|
||||
adverts and requests alike (see :meth:`~meshterm.core.connection.Device.set_flood_scope`).
|
||||
A channel scope is therefore three commands, not one: set the session scope, send the
|
||||
channel message, set it back. Anything else the app transmits *between* the first and the
|
||||
third goes out under that channel's region too — a courier retry, a DM typed in another
|
||||
screen, the weekly advert — and would be relayed only by the repeaters carrying a region
|
||||
its sender never chose.
|
||||
|
||||
So every transmission MeshTerm asks of the companion holds this lock for the moment it
|
||||
takes to hand the frame over, and a scoped channel send holds it across all three commands.
|
||||
Nothing can be interleaved into the window, and the window is as short as three round
|
||||
trips; an acknowledgement *wait* is not a transmission and is done outside the lock, so a
|
||||
DM waiting eight seconds for its ack never holds a channel message up behind it.
|
||||
|
||||
The lock is **re-entrant per task**: the scoped send takes it, then calls the ordinary
|
||||
channel send, which takes it again on its own account — and must not wait for itself.
|
||||
|
||||
One leak is out of any client's reach: a DM *received* inside the window is acknowledged
|
||||
by the firmware on its own, and that ack is a flood under whatever the session scope is
|
||||
at that instant. The window being three round trips long is the whole of the mitigation,
|
||||
and it is what the official apps live with too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
class TransmitLock:
|
||||
"""A task-re-entrant :class:`asyncio.Lock` for "something is going on the air now".
|
||||
|
||||
Interact through :meth:`held`, an async context manager. A task already holding the
|
||||
lock enters again at once; any other task waits for the holder to leave.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Start unheld."""
|
||||
self._lock = asyncio.Lock()
|
||||
self._owner: asyncio.Task | None = None
|
||||
self._depth = 0
|
||||
|
||||
@property
|
||||
def locked(self) -> bool:
|
||||
"""Whether any task holds the lock right now."""
|
||||
return self._lock.locked()
|
||||
|
||||
def held_by_me(self) -> bool:
|
||||
"""Whether the calling task is the one holding the lock."""
|
||||
try:
|
||||
task = asyncio.current_task()
|
||||
except RuntimeError: # no running loop: nobody here holds anything
|
||||
return False
|
||||
return task is not None and self._owner is task
|
||||
|
||||
@asynccontextmanager
|
||||
async def held(self) -> AsyncIterator[None]:
|
||||
"""Hold the lock for the body; re-enter at once if this task already holds it."""
|
||||
if self.held_by_me():
|
||||
self._depth += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._depth -= 1
|
||||
return
|
||||
async with self._lock:
|
||||
self._owner = asyncio.current_task()
|
||||
self._depth = 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._owner = None
|
||||
self._depth = 0
|
||||
|
||||
|
||||
__all__ = ["TransmitLock"]
|
||||
@@ -23,6 +23,7 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .models import utcnow
|
||||
|
||||
@@ -73,6 +74,10 @@ class WatchedNode:
|
||||
silent_since: datetime | None = None
|
||||
node_type: int | None = None
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Alert:
|
||||
@@ -94,6 +99,10 @@ class Alert:
|
||||
message: str
|
||||
acked: bool = False
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _State:
|
||||
@@ -105,6 +114,10 @@ class _State:
|
||||
new_node_alerts: bool = True
|
||||
next_id: int = 1
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
|
||||
class WatchStore:
|
||||
"""Reads and writes the Watchtower's state, memory-first."""
|
||||
|
||||
@@ -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}"
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_VERSION = 15
|
||||
SCHEMA_VERSION = 17
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS schema_meta (
|
||||
@@ -140,7 +140,10 @@ CREATE TABLE IF NOT EXISTS observations (
|
||||
src TEXT, -- …the sender's key hash, or its whole key (anon request)
|
||||
tag TEXT, -- …the frame's own token: an ack's checksum, a trace's tag
|
||||
trace_snrs TEXT, -- TRACE rows: comma-separated per-hop SNR readings (dB)
|
||||
route TEXT -- 'packet' rows: FLOOD/DIRECT/… — what `path` MEANS here
|
||||
route TEXT, -- 'packet' rows: FLOOD/DIRECT/… — what `path` MEANS here
|
||||
transport_code TEXT, -- scoped (TC_*) rows: the 4-byte transport-codes field (hex)
|
||||
scope_body TEXT -- TC_FLOOD rows: payload type byte + payload (hex), the
|
||||
-- HMAC input a region name is resolved against
|
||||
);
|
||||
|
||||
-- One chat message, sent or received, on a channel or with a contact. Unlike the other
|
||||
@@ -158,7 +161,9 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
text TEXT NOT NULL,
|
||||
snr REAL,
|
||||
acked INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
scope TEXT -- outbound floods: the region it was sent under, '*' for
|
||||
-- unscoped, NULL where it was never known
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_traces_run ON traces(run_id);
|
||||
@@ -332,3 +337,29 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
# than assumed flooded; the raw headers they arrived in were never persisted, so this
|
||||
# cannot be filled in from history.
|
||||
conn.execute("ALTER TABLE observations ADD COLUMN route TEXT")
|
||||
if "transport_code" not in observation_cols:
|
||||
# v15 -> v16: a scoped flood's region. A TC_FLOOD frame carries a transport code —
|
||||
# an HMAC of its own payload keyed on the region it was sent into — and nothing
|
||||
# else names the region, so which one it was can only be answered by trying the
|
||||
# region names known (see meshterm.core.regions). Names arrive late: a repeater
|
||||
# tells us what it carries long after we overheard traffic scoped to it. So the
|
||||
# code is kept with the exact bytes it was computed over, and a row heard today
|
||||
# resolves against a name learned next week. The body is kept for TC_FLOOD rows
|
||||
# alone — a plain flood has no code, a direct frame is never region-filtered — so
|
||||
# the price is paid only by the traffic it explains. Older rows carry NULL: the
|
||||
# raw frames they arrived in were never persisted, so their scope is lost.
|
||||
conn.execute("ALTER TABLE observations ADD COLUMN transport_code TEXT")
|
||||
conn.execute("ALTER TABLE observations ADD COLUMN scope_body TEXT")
|
||||
if "scope" not in message_cols:
|
||||
# v16 -> v17: the scope a message we sent went out under. A channel can be given a
|
||||
# region its messages are flooded into (a scope is a *setting on the companion*,
|
||||
# made just before the send — see Device.send_channel_in_scope), and the scope can
|
||||
# change between one message and the next, or be dropped for one resend. So the
|
||||
# transcript cannot work out afterwards what a message went out under from what the
|
||||
# channel's scope is *now*; it is kept with the message, the moment it is sent.
|
||||
# Its first reader is the message paths dialog: a scoped message nothing relayed is
|
||||
# most often one no repeater in earshot carries the region of, and saying so needs
|
||||
# the region. Stored as the bare name, '*' for a flood sent unscoped, and NULL for
|
||||
# anything else — inbound messages, direct messages, and every row sent before this
|
||||
# column existed, whose scope was never known and cannot be recovered.
|
||||
conn.execute("ALTER TABLE messages ADD COLUMN scope TEXT")
|
||||
@@ -29,6 +29,7 @@ from ..core.models import (
|
||||
TxLevelResult,
|
||||
utcnow,
|
||||
)
|
||||
from ..core.regions import SCOPED_ROUTES, raw_scope_body
|
||||
from . import db
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -96,6 +97,8 @@ def _packet_raw(row: sqlite3.Row) -> dict | None:
|
||||
tag = _row_value(row, "tag")
|
||||
trace_snrs = _row_value(row, "trace_snrs")
|
||||
route = _row_value(row, "route")
|
||||
transport_code = _row_value(row, "transport_code")
|
||||
scope_body = _row_value(row, "scope_body")
|
||||
if not any((typename, chan_hash, cipher_mac, dest, src, tag, trace_snrs, route)):
|
||||
return None
|
||||
raw: dict = {}
|
||||
@@ -121,6 +124,12 @@ def _packet_raw(row: sqlite3.Row) -> dict | None:
|
||||
raw["trace_snrs"] = [float(v) for v in str(trace_snrs).split(",") if v]
|
||||
if route:
|
||||
raw["route_typename"] = route
|
||||
if transport_code:
|
||||
# A scoped frame's code and the bytes it was computed over, so its region resolves
|
||||
# against names learned after it was heard (see meshterm.core.regions).
|
||||
raw["transport_code"] = transport_code
|
||||
if scope_body:
|
||||
raw["scope_body"] = scope_body
|
||||
return raw
|
||||
|
||||
|
||||
@@ -1024,12 +1033,18 @@ class Repository:
|
||||
trace_snrs = ",".join(f"{v:g}" for v in readings) if readings else None
|
||||
# What the frame's `path` means — see the v15 migration. Only a packet row has one.
|
||||
route = raw.get("route_typename") if obs.kind == "packet" else None
|
||||
# A scoped frame's transport code, and — for a scoped flood — the bytes it was
|
||||
# computed over, so its region can be resolved against a name learned later (see
|
||||
# the v16 migration). Nothing is kept for a frame that carries no code.
|
||||
transport_code = raw.get("transport_code") if route in SCOPED_ROUTES else None
|
||||
body = raw_scope_body(raw) if route == "TC_FLOOD" and transport_code else None
|
||||
scope_body = body.hex() if body is not None else None
|
||||
self._conn.execute(
|
||||
"INSERT INTO observations "
|
||||
"(run_id, node, public_key, name, kind, node_type, snr, rssi, lat, lon, path, "
|
||||
"observed_at, chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, "
|
||||
"trace_snrs, route) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"trace_snrs, route, transport_code, scope_body) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
run_id,
|
||||
obs.node,
|
||||
@@ -1052,6 +1067,8 @@ class Repository:
|
||||
tag,
|
||||
trace_snrs,
|
||||
route,
|
||||
transport_code,
|
||||
scope_body,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
@@ -1068,6 +1085,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.
|
||||
|
||||
@@ -1087,7 +1167,8 @@ class Repository:
|
||||
"""
|
||||
rows = self._conn.execute(
|
||||
"SELECT node, name, kind, node_type, snr, rssi, lat, lon, path, observed_at, "
|
||||
"chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, trace_snrs, route "
|
||||
"chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, trace_snrs, route, "
|
||||
"transport_code, scope_body "
|
||||
"FROM observations WHERE observed_at >= ? ORDER BY observed_at DESC LIMIT ?",
|
||||
(since.isoformat(), limit),
|
||||
).fetchall()
|
||||
@@ -1113,7 +1194,8 @@ class Repository:
|
||||
"""
|
||||
rows = self._conn.execute(
|
||||
"SELECT node, name, kind, node_type, snr, rssi, lat, lon, path, observed_at, "
|
||||
"chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, trace_snrs, route "
|
||||
"chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, trace_snrs, route, "
|
||||
"transport_code, scope_body "
|
||||
"FROM observations WHERE kind = 'packet' AND observed_at >= ? "
|
||||
"AND observed_at <= ? ORDER BY observed_at ASC LIMIT ?",
|
||||
(start.isoformat(), end.isoformat(), limit),
|
||||
@@ -1319,6 +1401,44 @@ class Repository:
|
||||
continue # a malformed stray timestamp simply isn't counted
|
||||
return counts
|
||||
|
||||
def flood_frames(self, *, since: datetime | None = None) -> list[tuple[datetime, dict]]:
|
||||
"""Every stored flood frame, oldest first, with what its scope is read from.
|
||||
|
||||
The Time Machine's scope views: a scoped frame's region is named by recomputing its
|
||||
transport code under each known region's key, which no SQL can do, so the frames
|
||||
come back as the raw mapping :meth:`~meshterm.core.region_store.RegionStore.scope_of`
|
||||
reads (route, transport code, the bytes the code was computed over) for the caller
|
||||
to resolve. Only floods: a direct frame has no scope, and a frame stored before its
|
||||
route was kept cannot say which it was.
|
||||
|
||||
Args:
|
||||
since: Only frames at or after this time, if given.
|
||||
|
||||
Returns:
|
||||
``(observed_at, raw)`` per flood frame, oldest first.
|
||||
"""
|
||||
sql = (
|
||||
"SELECT observed_at, route, transport_code, scope_body FROM observations "
|
||||
"WHERE kind = 'packet' AND upper(route) IN ('FLOOD', 'TC_FLOOD')"
|
||||
)
|
||||
params: list[Any] = []
|
||||
if since is not None:
|
||||
sql += " AND observed_at >= ?"
|
||||
params.append(since.isoformat())
|
||||
sql += " ORDER BY observed_at"
|
||||
frames: list[tuple[datetime, dict]] = []
|
||||
for row in self._conn.execute(sql, params):
|
||||
when = _as_when(row["observed_at"])
|
||||
if when is None:
|
||||
continue # a malformed stray simply isn't counted
|
||||
raw = {"route_typename": row["route"]}
|
||||
if row["transport_code"]:
|
||||
raw["transport_code"] = row["transport_code"]
|
||||
if row["scope_body"]:
|
||||
raw["scope_body"] = row["scope_body"]
|
||||
frames.append((when, raw))
|
||||
return frames
|
||||
|
||||
def self_transmissions(self, *, since: datetime | None = None) -> list[datetime]:
|
||||
"""Timestamps of everything this station put on the air, oldest first.
|
||||
|
||||
@@ -1839,7 +1959,7 @@ class Repository:
|
||||
cur = self._conn.execute(
|
||||
"INSERT INTO messages "
|
||||
"(run_id, outbound, is_channel, channel_id, channel_idx, peer, peer_name, text, "
|
||||
"snr, acked, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"snr, acked, created_at, scope) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
run_id,
|
||||
int(msg.outbound),
|
||||
@@ -1852,6 +1972,7 @@ class Repository:
|
||||
msg.snr,
|
||||
None if msg.acked is None else int(msg.acked),
|
||||
msg.created_at.isoformat(),
|
||||
msg.scope,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
@@ -2145,4 +2266,5 @@ class Repository:
|
||||
acked=None if row["acked"] is None else bool(row["acked"]),
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
row_id=row["id"],
|
||||
scope=row["scope"] if "scope" in row.keys() else None,
|
||||
)
|
||||
@@ -1,58 +1,143 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The background-advert scheduler: keep this node announced without anyone asking.
|
||||
"""The weekly flood advert: keep this node on the mesh's contact lists, once a week at most.
|
||||
|
||||
A quiet session-long task that sends a zero-hop (direct) advert and a flood advert on
|
||||
their configured cadences (see :class:`~meshterm.core.advert_store.AdvertStore`; the
|
||||
cadence rows live in the device-configuration editor). The countdown for each type runs
|
||||
from the *last* advert of that type — scheduled or manual — so using the Send advert menu
|
||||
pushes the next background send out by a full cadence rather than doubling up.
|
||||
A MeshCore companion never advertises by itself — unlike a repeater it has no advert
|
||||
timer, and it does not even announce itself at boot — so a node whose owner never presses
|
||||
*Send advert* gradually falls out of everyone else's contact list. When the
|
||||
``weekly_flood_advert`` preference is on (it is off by default), this session-long task
|
||||
sends one flood advert from the connected device once that device has gone a full week
|
||||
without one. The week is kept per device, in real time, by
|
||||
:class:`~meshterm.core.advert_store.AdvertStore`:
|
||||
|
||||
Deliberately transmission-shy, in keeping with the app's single-transmission rule:
|
||||
* it runs from the **latest** of three instants — the preference being turned on, the last
|
||||
flood advert from that device (automatic *or* sent by hand), and the first time MeshTerm
|
||||
connected to it — so switching the preference on never sends, and a manual flood advert
|
||||
pushes the next automatic one a whole week out;
|
||||
* a due advert the app did not live to send stays due, and goes out on the next run that
|
||||
connects that device.
|
||||
|
||||
* At most **one** advert goes out per pass, even when both types are due (the other
|
||||
follows a pass later), so the scheduler can never burst.
|
||||
* A device with no recorded last-send is *armed* — the countdown starts now — instead of
|
||||
advertised immediately, so a fresh install stays silent for its first full cadence.
|
||||
Being due is not enough to transmit. A flood advert is rebroadcast by every repeater in
|
||||
range, so it waits for the air to be **quiet**:
|
||||
|
||||
The scheduler holds no device subscription; each pass just checks whether a device is
|
||||
connected and skips quietly otherwise. That makes it indifferent to reconnects — it keeps
|
||||
ticking across a link drop and resumes sending once the session's reconnect flow has done
|
||||
its job.
|
||||
* **online first** — each connection (a launch, and every reconnect after a link drop)
|
||||
must *hear* at least one packet before any silence counts, because a link that hears
|
||||
nothing proves nothing about the mesh;
|
||||
* **then a quiet spell** — no packet heard *and* nothing of ours sent for the
|
||||
``advert_quiet_s`` preference (30 s by default) plus a random 0–5 s. The random part is
|
||||
drawn afresh every time the silence breaks: two devices waiting through the same packets
|
||||
would otherwise wait the same time after each one and collide every time, where a fresh
|
||||
draw per break parts them. A busy mesh can hold the advert back indefinitely; it waits.
|
||||
|
||||
Every packet the event hub delivers counts as heard — they are all receptions — and our
|
||||
own transmissions are read off the shared transmit clock
|
||||
(:mod:`meshterm.core.transmit_gate`), which every send marks.
|
||||
|
||||
The scheduler holds no device subscription of its own and never opens the radio: each
|
||||
tick checks for a connected device and skips quietly without one, so it keeps ticking
|
||||
across a link drop and resumes once the session's reconnect flow has done its job. The
|
||||
interactive session starts it; scripted CLI runs never do, so a one-shot command cannot
|
||||
fire a background transmission.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..core import transmit_gate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
from ..core.connection import Device
|
||||
from ..core.events import MeshEvent
|
||||
|
||||
#: Seconds between scheduler passes. Cadences are hours, so a coarse tick is plenty; it
|
||||
#: also spaces consecutive sends (when both types come due together) a comfortable gap
|
||||
#: apart on the shared mesh.
|
||||
POLL_S = 30.0
|
||||
#: Seconds between ticks. The quiet spell is measured to about this precision, which is
|
||||
#: plenty against a 5–60 s spell drawn with seconds of randomness on top.
|
||||
TICK_S = 1.0
|
||||
|
||||
#: The widest random extension of the quiet spell, in seconds (drawn uniformly from 0).
|
||||
JITTER_S = 5.0
|
||||
|
||||
#: Seconds between re-reads of the advert store while an advert is not yet due. The week
|
||||
#: is days long, so a coarse check costs nothing in accuracy and spares the file a read on
|
||||
#: every tick.
|
||||
DUE_CHECK_S = 30.0
|
||||
|
||||
|
||||
#: :attr:`AdvertStatus.state` while no device is connected: no week to count, nothing to send.
|
||||
OFFLINE = "offline"
|
||||
|
||||
#: :attr:`AdvertStatus.state` while the connected device's week is still running.
|
||||
COUNTING = "counting"
|
||||
|
||||
#: :attr:`AdvertStatus.state` once due, while this connection has yet to hear a packet.
|
||||
LISTENING = "listening"
|
||||
|
||||
#: :attr:`AdvertStatus.state` once due and heard, while the air has yet to go quiet.
|
||||
QUIET = "quiet"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AdvertStatus:
|
||||
"""Where the weekly advert stands, for a screen to say so.
|
||||
|
||||
Attributes:
|
||||
state: :data:`OFFLINE`, :data:`COUNTING`, :data:`LISTENING`, or :data:`QUIET`.
|
||||
due_at: When the connected device's advert falls due, while :data:`COUNTING`.
|
||||
"""
|
||||
|
||||
state: str
|
||||
due_at: datetime | None = None
|
||||
|
||||
|
||||
class AdvertScheduler:
|
||||
"""Owns the background-advert loop for an interactive session.
|
||||
"""Owns the weekly-advert loop for an interactive session.
|
||||
|
||||
Interact through the async lifecycle methods (:meth:`start`, :meth:`stop`,
|
||||
:meth:`aclose`); everything else happens on the loop's own schedule.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: AppContext) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
ctx: AppContext,
|
||||
*,
|
||||
rng: random.Random | None = None,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
"""Initialize an idle scheduler bound to an application context.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context, read each pass for the connected
|
||||
device and the advert store. Nothing is touched until :meth:`start`.
|
||||
ctx: The shared application context, read each tick for the connected device,
|
||||
the preferences and the advert store. Nothing is touched until
|
||||
:meth:`start`.
|
||||
rng: The source of the quiet spell's random extension (tests pin it).
|
||||
clock: A monotonic clock in seconds — the transmit clock's own, which the
|
||||
silence is compared against (tests substitute one they can advance).
|
||||
"""
|
||||
self._ctx = ctx
|
||||
self._rng = rng or random.Random()
|
||||
self._clock = clock
|
||||
self._task: asyncio.Task | None = None
|
||||
# The connected device's public key, learned once per connection. Keyed by the
|
||||
# device object's identity so a reconnect (a fresh Device) re-probes it.
|
||||
self._key_for: tuple[int, str] | None = None
|
||||
self._unsubscribe: Callable[[], None] | None = None
|
||||
# The connection the state below belongs to: the Device object itself, held rather
|
||||
# than its id() so a freed one's address can never pass for a fresh link. A
|
||||
# reconnect is a new Device, so it starts over — key, due check, proof of life.
|
||||
self._device: Device | None = None
|
||||
self._key = ""
|
||||
self._due = False
|
||||
self._due_at: datetime | None = None
|
||||
self._checked_at: float | None = None
|
||||
# Which connection last heard a packet, and when anything was last heard.
|
||||
self._heard_on: Device | None = None
|
||||
self._last_heard: float | None = None
|
||||
# The silence break the current random extension was drawn for, and the draw.
|
||||
self._break_at: float | None = None
|
||||
self._jitter = 0.0
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
@@ -60,14 +145,18 @@ class AdvertScheduler:
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the scheduler loop. Idempotent; never touches the radio itself."""
|
||||
"""Start listening and ticking. Idempotent; never touches the radio itself."""
|
||||
if self.active:
|
||||
return
|
||||
self._unsubscribe = self._ctx.events.subscribe(self._on_event)
|
||||
self._task = asyncio.ensure_future(self._run())
|
||||
self._ctx.log.info("advert scheduler started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the scheduler loop. Idempotent."""
|
||||
"""Stop the loop and the listening. Idempotent."""
|
||||
if self._unsubscribe is not None:
|
||||
self._unsubscribe()
|
||||
self._unsubscribe = None
|
||||
task, self._task = self._task, None
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
@@ -81,10 +170,43 @@ class AdvertScheduler:
|
||||
"""Stop the loop at session end (an alias for :meth:`stop`)."""
|
||||
await self.stop()
|
||||
|
||||
def status(self) -> AdvertStatus | None:
|
||||
"""Where the weekly advert stands right now, or ``None`` when there is nothing to say.
|
||||
|
||||
``None`` while the scheduler is not running, while the preference is off, and in
|
||||
the second between a connection and the first look at it. Read from what the loop
|
||||
already keeps, so a screen can ask on every paint without touching the file; a
|
||||
:meth:`recheck` pending leaves the last reading standing until the tick replaces it.
|
||||
"""
|
||||
ctx = self._ctx
|
||||
if not self.active or not ctx.preferences.weekly_flood_advert:
|
||||
return None
|
||||
device = ctx._device
|
||||
if device is None:
|
||||
return AdvertStatus(OFFLINE)
|
||||
if device is not self._device:
|
||||
return None
|
||||
if not self._due:
|
||||
return AdvertStatus(COUNTING, self._due_at) if self._due_at is not None else None
|
||||
return AdvertStatus(LISTENING if self._heard_on is not device else QUIET)
|
||||
|
||||
def recheck(self) -> None:
|
||||
"""Re-read the preference and the store on the next tick, not the next due check.
|
||||
|
||||
For a screen about to report :meth:`status`, which would otherwise show what the
|
||||
loop last read up to :data:`DUE_CHECK_S` ago — before a switch just applied.
|
||||
"""
|
||||
self._checked_at = None
|
||||
|
||||
def _on_event(self, _event: MeshEvent) -> None:
|
||||
"""Note a packet heard: it breaks the silence, and proves this connection is live."""
|
||||
self._last_heard = self._clock()
|
||||
self._heard_on = self._ctx._device
|
||||
|
||||
async def _run(self) -> None:
|
||||
"""Tick forever: sleep, then make one best-effort pass."""
|
||||
while True:
|
||||
await asyncio.sleep(POLL_S)
|
||||
await asyncio.sleep(TICK_S)
|
||||
try:
|
||||
await self._pass()
|
||||
except asyncio.CancelledError:
|
||||
@@ -93,44 +215,74 @@ class AdvertScheduler:
|
||||
self._ctx.log.debug("advert scheduler: pass failed: %s", exc)
|
||||
|
||||
async def _pass(self) -> None:
|
||||
"""One pass: arm never-sent clocks, and send the most overdue advert type if due."""
|
||||
"""One tick: keep the clock current, and send the weekly advert once due and quiet."""
|
||||
ctx = self._ctx
|
||||
if not ctx.is_connected:
|
||||
device = ctx._device
|
||||
if device is None:
|
||||
self._device = None
|
||||
return
|
||||
key = await self._public_key()
|
||||
if not key:
|
||||
return
|
||||
|
||||
store = ctx.advert_store
|
||||
# Arm fresh clocks so "never sent" becomes "counting from now" (no-op otherwise).
|
||||
store.arm(key, flood=False)
|
||||
store.arm(key, flood=True)
|
||||
|
||||
policy = store.load(key)
|
||||
# One send per pass, direct first (the cheaper, zero-hop announcement); a flood
|
||||
# due at the same moment follows on the next pass, POLL_S later.
|
||||
for flood in (False, True):
|
||||
if policy.due(flood):
|
||||
device = await ctx.device()
|
||||
await device.send_advert(flood)
|
||||
store.mark_sent(key, flood=flood)
|
||||
kind = "flood" if flood else "zero-hop"
|
||||
ctx.log.info("advert scheduler: sent scheduled %s advert", kind)
|
||||
if device is not self._device:
|
||||
self._device, self._key, self._due, self._checked_at = device, "", False, None
|
||||
self._due_at = None
|
||||
if not self._key:
|
||||
self._key = await self._public_key()
|
||||
if not self._key:
|
||||
return
|
||||
ctx.advert_store.arm(self._key) # a device never seen before starts its week
|
||||
|
||||
now = self._clock()
|
||||
if self._checked_at is None or now - self._checked_at >= DUE_CHECK_S:
|
||||
self._checked_at = now
|
||||
self._check_due()
|
||||
if not self._due or self._heard_on is not device or not self._quiet(now):
|
||||
return
|
||||
if transmit_gate.remaining(flood_advert=True) > 0:
|
||||
return # a flood advert of ours went out moments ago; the next tick re-judges
|
||||
# The file, not the cached verdict: a flood advert sent by hand since the last
|
||||
# check restarted the week, and the preference may have been switched off.
|
||||
self._check_due()
|
||||
if not self._due:
|
||||
return
|
||||
try:
|
||||
await device.send_advert(True)
|
||||
except Exception:
|
||||
self._due = False # judged again at the next due check, not on every tick
|
||||
raise
|
||||
ctx.advert_store.mark_flood(self._key)
|
||||
self._due = False
|
||||
self._checked_at = None # the next tick reads the new week in
|
||||
ctx.log.info("advert scheduler: sent the weekly flood advert")
|
||||
|
||||
def _check_due(self) -> None:
|
||||
"""Re-read the preference and the store, and settle whether the advert is due."""
|
||||
ctx = self._ctx
|
||||
on = bool(ctx.preferences.weekly_flood_advert)
|
||||
ctx.advert_store.sync_enabled(on)
|
||||
self._due_at = ctx.advert_store.due_at(self._key) if on else None
|
||||
due = on and ctx.advert_store.due(self._key)
|
||||
if due and not self._due:
|
||||
ctx.log.info(
|
||||
"advert scheduler: weekly flood advert due; waiting for %s s of quiet",
|
||||
ctx.preferences.advert_quiet_s,
|
||||
)
|
||||
self._due = due
|
||||
|
||||
def _quiet(self, now: float) -> bool:
|
||||
"""Whether the air has been quiet long enough, drawing a fresh extension per break."""
|
||||
sent = transmit_gate.current().last_sent
|
||||
breaks = [t for t in (self._last_heard, sent) if t is not None]
|
||||
if not breaks:
|
||||
return False
|
||||
break_at = max(breaks)
|
||||
if break_at != self._break_at:
|
||||
self._break_at = break_at
|
||||
self._jitter = self._rng.uniform(0.0, JITTER_S)
|
||||
return now - break_at >= float(self._ctx.preferences.advert_quiet_s) + self._jitter
|
||||
|
||||
async def _public_key(self) -> str:
|
||||
"""The connected device's public key, probed once per connection (best-effort)."""
|
||||
device = self._ctx._device
|
||||
if device is None:
|
||||
return ""
|
||||
ident = id(device)
|
||||
if self._key_for is not None and self._key_for[0] == ident:
|
||||
return self._key_for[1]
|
||||
"""The connected device's public key, from the session cache (best-effort)."""
|
||||
try:
|
||||
info = await device.get_self_info()
|
||||
except Exception: # noqa: BLE001 - identity probe is best-effort; retry next pass
|
||||
info = await self._ctx.devstate.self_info()
|
||||
except Exception: # noqa: BLE001 - identity probe is best-effort; retry next tick
|
||||
return ""
|
||||
key = str(info.get("public_key") or "")
|
||||
if key:
|
||||
self._key_for = (ident, key)
|
||||
return key
|
||||
return str(info.get("public_key") or "")
|
||||
@@ -44,12 +44,14 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Container
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
@@ -109,6 +111,59 @@ _RESOLVE_RETRY_SECONDS = 30.0
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
#: Where the operating system keeps its CA bundle, most specific first. Only consulted when
|
||||
#: the default context came up empty (see :func:`_tls_context`) — macOS and Alpine put it at
|
||||
#: ``/etc/ssl/cert.pem``, Debian and Ubuntu at ``ca-certificates.crt``, Fedora and RHEL under
|
||||
#: ``/etc/pki``, openSUSE at ``ca-bundle.pem``. Files rather than a bundled copy on purpose:
|
||||
#: these are the trust decisions the *machine's administrator* has made, kept current by the
|
||||
#: OS, and shipping our own would quietly freeze a snapshot of them into every release.
|
||||
_CA_BUNDLES = (
|
||||
"/etc/ssl/cert.pem",
|
||||
"/etc/ssl/certs/ca-certificates.crt",
|
||||
"/etc/pki/tls/certs/ca-bundle.crt",
|
||||
"/etc/ssl/ca-bundle.pem",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _tls_context() -> ssl.SSLContext | None:
|
||||
"""The TLS context for tile fetches, with a CA bundle found if the default has none.
|
||||
|
||||
A frozen build can have no trust store to consult. Python's default context asks
|
||||
OpenSSL for one, and on macOS and Linux that is a *filesystem path baked into the
|
||||
interpreter's own build* — which need not exist on a machine that never installed that
|
||||
Python, which is every machine a PyInstaller bundle lands on. Every tile fetch then
|
||||
fails with ``CERTIFICATE_VERIFY_FAILED``; and because a refused fetch is
|
||||
indistinguishable from empty terrain, the map drew no ground under the nodes and said
|
||||
nothing about why. Windows is the exception that hid it for so long: its default
|
||||
context reads the OS certificate store, so the binary there always worked.
|
||||
|
||||
So: keep the default when it actually loaded certificates, and otherwise point it at
|
||||
the bundle the OS ships (:data:`_CA_BUNDLES`). Verification is never weakened — a map
|
||||
tile is not worth teaching the app to skip certificate checks, and an unverified
|
||||
fetcher here would be one import away from being reused somewhere it matters.
|
||||
|
||||
Returns:
|
||||
A context to pass to ``urlopen``, or ``None`` to accept urllib's default — which
|
||||
is right whenever that default already works, and is never worse than the failure
|
||||
it replaces.
|
||||
"""
|
||||
try:
|
||||
context = ssl.create_default_context()
|
||||
if context.get_ca_certs():
|
||||
return None # the platform store answered; leave urllib exactly as it was
|
||||
for path in _CA_BUNDLES:
|
||||
if os.path.isfile(path):
|
||||
context.load_verify_locations(cafile=path)
|
||||
_log.debug("no default CA store; verifying against %s", path)
|
||||
return context
|
||||
_log.debug("no CA bundle found in %s — TLS will likely fail", ", ".join(_CA_BUNDLES))
|
||||
return None
|
||||
except Exception as exc: # noqa: BLE001 - a broken store must not take the map with it
|
||||
_log.debug("could not build a TLS context, using urllib's default: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
class _Response(NamedTuple):
|
||||
"""One HTTP GET's outcome: whether the server answered, and what it said.
|
||||
|
||||
@@ -478,7 +533,7 @@ class BasemapSource:
|
||||
"""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||
with urllib.request.urlopen(req, timeout=self._timeout, context=_tls_context()) as resp:
|
||||
body = resp.read()
|
||||
declared = (resp.headers.get("Content-Length") or "").strip()
|
||||
# A flaky link can end a read early without raising. A body short of its
|
||||
|
||||
@@ -30,6 +30,7 @@ from ..core.models import (
|
||||
is_direct_messageable,
|
||||
utcnow,
|
||||
)
|
||||
from ..core.regions import WILDCARD, normalize
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
@@ -509,28 +510,86 @@ class ChatService:
|
||||
self._ctx.repo.update_chat_ack(message.row_id, message.acked)
|
||||
return message
|
||||
|
||||
async def send_channel(self, index: int, text: str, *, label: str | None = None) -> ChatMessage:
|
||||
"""Broadcast a message on a channel and record it in history.
|
||||
def channel_scope(self, channel_id: str | None) -> str | None:
|
||||
"""The region a channel's messages are sent under, or ``None`` for the device default.
|
||||
|
||||
Read from :class:`~meshterm.core.region_store.RegionStore` by the channel's intrinsic
|
||||
identity, so the scope follows the channel across slots. A context with no store (a
|
||||
test's, or a surface that never built one) has no channel scopes at all.
|
||||
"""
|
||||
store = getattr(self._ctx, "region_store", None)
|
||||
return store.channel_scope(channel_id) if store is not None else None
|
||||
|
||||
async def send_channel(
|
||||
self,
|
||||
index: int,
|
||||
text: str,
|
||||
*,
|
||||
label: str | None = None,
|
||||
scope: str | None = None,
|
||||
) -> ChatMessage:
|
||||
"""Broadcast a message on a channel, under the channel's scope, and record it.
|
||||
|
||||
The channel's identity is resolved *before* the send, because it is what names the
|
||||
scope the message goes out under (:meth:`channel_scope`). The send itself is
|
||||
:meth:`~meshterm.core.connection.Device.send_channel_in_scope` — set the session
|
||||
scope, send, restore, with no other flood let in between — and a scope the radio
|
||||
can't take stops the message rather than sending it some other way.
|
||||
|
||||
Args:
|
||||
index: The channel slot to transmit on.
|
||||
text: The message body.
|
||||
label: A display label for the channel (e.g. ``#general``), stored for the
|
||||
transcript.
|
||||
scope: Overrides the channel's own scope for this one message: a region name,
|
||||
or :data:`~meshterm.core.regions.WILDCARD` (``*``) to send it unscoped —
|
||||
the resend for a scoped message nothing relayed. ``None`` uses the
|
||||
channel's scope, or the device default when the channel has none.
|
||||
|
||||
Returns:
|
||||
The recorded outbound :class:`ChatMessage`.
|
||||
The recorded outbound :class:`ChatMessage`, its :attr:`~ChatMessage.scope` the
|
||||
one it went out under.
|
||||
|
||||
Raises:
|
||||
~meshterm.core.connection.FloodScopeError: If the radio can't send under the
|
||||
scope; nothing was sent and nothing is recorded.
|
||||
"""
|
||||
device = await self._ctx.device()
|
||||
await device.send_channel_message(index, text)
|
||||
channel_id = await self.channel_id_for(index)
|
||||
asked = scope if scope is not None else self.channel_scope(channel_id)
|
||||
await device.send_channel_in_scope(index, text, asked)
|
||||
chat = ChatMessage(
|
||||
text=text,
|
||||
outbound=True,
|
||||
is_channel=True,
|
||||
channel_id=await self.channel_id_for(index),
|
||||
channel_id=channel_id,
|
||||
channel_idx=index,
|
||||
peer_name=label,
|
||||
created_at=utcnow(),
|
||||
scope=await self._sent_under(asked),
|
||||
)
|
||||
self._ctx.repo.record_chat_message(chat, run_id=self._run_id)
|
||||
chat.row_id = self._ctx.repo.record_chat_message(chat, run_id=self._run_id)
|
||||
return chat
|
||||
|
||||
async def _sent_under(self, asked: str | None) -> str | None:
|
||||
"""What a channel send asked to go out under, as the transcript records it.
|
||||
|
||||
A scope that was asked for is what it went under — the send refuses rather than
|
||||
substitute. A plain send went under the device's default scope, which is read (once
|
||||
a session, through the devstate cache): its name when one is set, ``*`` when none
|
||||
is — a plain flood with no default *is* unscoped. A default that can't be read
|
||||
(firmware before 1.15 has none to read; a link blip) records ``None``: unknown is
|
||||
the honest answer, and the transcript never claims a scope it didn't see.
|
||||
"""
|
||||
bare = normalize(asked) if asked else ""
|
||||
if bare:
|
||||
return bare
|
||||
devstate = getattr(self._ctx, "devstate", None)
|
||||
if devstate is None:
|
||||
return None
|
||||
try:
|
||||
default = await devstate.default_scope()
|
||||
except Exception as exc: # noqa: BLE001 - unknown, not a failed send
|
||||
self._ctx.log.debug("chat: default scope unreadable: %s", exc)
|
||||
return None
|
||||
return normalize(default) or WILDCARD
|
||||
@@ -11,6 +11,13 @@ and acknowledges on screen; :class:`ClockSync` calls the same function on its ow
|
||||
per connection, when the ``set_clock_on_connect`` preference asks for it (off by default —
|
||||
writing to a radio that nobody asked to be written to is a choice the owner makes).
|
||||
|
||||
**Forward only.** MeshCore firmware never sets its clock back: a time earlier than the one
|
||||
it holds is refused (:class:`~meshterm.core.connection.ClockAheadError`). So a radio whose
|
||||
clock has run ahead of this computer's — a GPS fix, another app, drift — is read first and
|
||||
not written at all: a few seconds ahead is simply in sync, and more is stated plainly with
|
||||
what resets it, rather than retried every connect into a refusal the firmware's error code
|
||||
calls "malformed".
|
||||
|
||||
The automatic set is a background step, not a startup step: the connect path hands the
|
||||
device over and carries on, and the write happens in a task of its own so a slow or silent
|
||||
firmware never holds the menu. It reports in the **log**, never on screen.
|
||||
@@ -30,23 +37,35 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..core.connection import ClockAheadError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
from ..core.connection import Device
|
||||
|
||||
#: How far ahead a radio's clock may run and still count as in sync. Two clocks a few
|
||||
#: seconds apart — a GPS-disciplined radio beside a PC that is itself a second or so off
|
||||
#: true time — are not a problem to report, and the firmware couldn't close the gap anyway.
|
||||
IN_SYNC_AHEAD_S = 60
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ClockSet:
|
||||
"""What one clock set did.
|
||||
|
||||
Attributes:
|
||||
epoch: The UNIX time written to the device.
|
||||
epoch: The UNIX time written to the device — or, where nothing was written, the
|
||||
time the device already held.
|
||||
drift_s: How far the device's clock was from ours *before* the set (device minus
|
||||
host, seconds), or ``None`` where the firmware would not report its clock.
|
||||
written: Whether the clock was written. ``False`` when it was already a few
|
||||
seconds ahead (within :data:`IN_SYNC_AHEAD_S`), which firmware would refuse to
|
||||
set back and which needs no setting.
|
||||
"""
|
||||
|
||||
epoch: int
|
||||
drift_s: int | None
|
||||
written: bool = True
|
||||
|
||||
@property
|
||||
def set_at(self) -> datetime:
|
||||
@@ -72,13 +91,23 @@ async def set_clock(device: Device) -> ClockSet:
|
||||
The instant written and the drift it corrected.
|
||||
|
||||
Raises:
|
||||
Whatever the device raises when the write itself fails.
|
||||
ClockAheadError: If the device's clock is ahead of this computer's by more than
|
||||
:data:`IN_SYNC_AHEAD_S` (read first) or the firmware refused the write as one
|
||||
that would set it back. Within the margin nothing is written, and the result
|
||||
says so (``written`` false).
|
||||
Whatever the device raises when the write itself fails otherwise.
|
||||
"""
|
||||
try:
|
||||
before = await device.get_time()
|
||||
except Exception: # noqa: BLE001 - optional read; the drift is a nicety
|
||||
before = None
|
||||
epoch = int(time.time())
|
||||
if before is not None and before > epoch:
|
||||
# Firmware only moves its clock forward, so a write here could only be refused.
|
||||
# Close enough is in sync; further ahead is a fact to state, not a write to retry.
|
||||
if before - epoch > IN_SYNC_AHEAD_S:
|
||||
raise ClockAheadError(before - epoch)
|
||||
return ClockSet(epoch=before, drift_s=before - epoch, written=False)
|
||||
await device.set_time(epoch)
|
||||
return ClockSet(epoch=epoch, drift_s=None if before is None else before - epoch)
|
||||
|
||||
@@ -161,10 +190,19 @@ class ClockSync:
|
||||
done = await set_clock(device)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except ClockAheadError as exc:
|
||||
log.warning("clock sync: left the device clock alone: %s", exc)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 - best-effort; the link is not ours to break
|
||||
log.warning("clock sync: could not set the device clock: %s", exc)
|
||||
return
|
||||
if done.drift_s is None:
|
||||
if not done.written:
|
||||
log.info(
|
||||
"clock sync: device clock already in sync (%+d s, ahead of ours; firmware "
|
||||
"never sets it back)",
|
||||
done.drift_s,
|
||||
)
|
||||
elif done.drift_s is None:
|
||||
log.info("clock sync: device clock set to %s (drift unknown)", done.stamp)
|
||||
else:
|
||||
log.info("clock sync: device clock set to %s (was %+d s off)", done.stamp, done.drift_s)
|
||||
@@ -99,6 +99,9 @@ class DeviceState:
|
||||
self._channels: list[ChannelSlot] | None = None
|
||||
self._channels_epoch = 0
|
||||
self._channel_capacity: int | None = None
|
||||
#: The companion's default flood scope — ``""`` for none — once read (see
|
||||
#: :meth:`default_scope`); ``None`` while unread.
|
||||
self._default_scope: str | None = None
|
||||
# One lock per slow fetch so overlapping first-access callers (two screens opened in
|
||||
# quick succession) collapse onto a single round-trip instead of each firing their own.
|
||||
self._contacts_lock = asyncio.Lock()
|
||||
@@ -309,6 +312,36 @@ class DeviceState:
|
||||
return 0
|
||||
return (mode + 1) if isinstance(mode, int) and 0 <= mode <= 3 else 0
|
||||
|
||||
# -- the default flood scope (held until a config write invalidates it) ------
|
||||
|
||||
async def default_scope(self) -> str:
|
||||
"""The companion's persisted default flood scope, ``""`` for none, read once.
|
||||
|
||||
What a plain channel send goes out under, so it is what a channel with no scope of
|
||||
its own records its messages as sent under. One round trip, then held until the
|
||||
config editor writes a setting (:meth:`invalidate_config`) or the link is rebuilt.
|
||||
|
||||
Returns:
|
||||
The bare region name, or ``""`` when no default scope is set.
|
||||
|
||||
Raises:
|
||||
Exception: Like :meth:`~meshterm.core.connection.Device.get_default_flood_scope`
|
||||
— firmware older than 1.15 has no default scope to read. Not cached, so the
|
||||
next call asks again.
|
||||
"""
|
||||
if self._default_scope is None:
|
||||
device = await self._ctx.device()
|
||||
self._default_scope = str(await device.get_default_flood_scope() or "")
|
||||
return self._default_scope
|
||||
|
||||
def note_default_scope(self, name: str | None) -> None:
|
||||
"""Record a default scope just read or written elsewhere (Device config).
|
||||
|
||||
Args:
|
||||
name: The bare name, ``""`` for none, or ``None`` to forget it and re-read.
|
||||
"""
|
||||
self._default_scope = None if name is None else str(name)
|
||||
|
||||
# -- channel slots (held until the channel editor invalidates them) --------
|
||||
|
||||
async def channel_slots(self) -> list[ChannelSlot]:
|
||||
@@ -446,6 +479,7 @@ class DeviceState:
|
||||
"""
|
||||
self.invalidate_self_info()
|
||||
self.invalidate_path_hash_mode()
|
||||
self._default_scope = None
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear the whole cache and cancel any in-flight background refresh.
|
||||
@@ -457,6 +491,7 @@ class DeviceState:
|
||||
self.invalidate_self_info()
|
||||
self.invalidate_path_hash_mode()
|
||||
self.invalidate_channels()
|
||||
self._default_scope = None
|
||||
# Capacity is a hardware constant (not touched by channel edits, so it has no per-write
|
||||
# invalidator), but a reconnect may be to a different device — drop it too.
|
||||
self._channel_capacity = None
|
||||
|
||||
@@ -36,8 +36,8 @@ Nothing here transmits; it is a read-model over the repository.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -46,6 +46,7 @@ from ..core.frames import ENDPOINT_HASH_BYTES
|
||||
from ..core.models import ChatMessage, Observation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.regions import Scope
|
||||
from ..persistence.repository import Repository
|
||||
|
||||
#: How far around a channel message the log is searched. Wide, because an inbound
|
||||
@@ -104,6 +105,9 @@ class Arrival:
|
||||
repeater in earshot, so one message routinely leaves a dozen frames on two
|
||||
paths; listing them individually said "twelve arrivals" where the truth was
|
||||
"two paths, six times each".
|
||||
frame: The logged frame's raw payload — what :func:`message_scope` reads the
|
||||
message's scope from. Not part of an arrival's identity (two copies on one path
|
||||
are one row whatever else their frames carry), so it takes no part in equality.
|
||||
"""
|
||||
|
||||
when: datetime
|
||||
@@ -112,6 +116,7 @@ class Arrival:
|
||||
resend: int = 0
|
||||
routed: bool | None = None
|
||||
copies: int = 1
|
||||
frame: dict | None = field(default=None, compare=False, repr=False)
|
||||
|
||||
@property
|
||||
def route_known(self) -> bool:
|
||||
@@ -196,6 +201,7 @@ def channel_arrivals(
|
||||
snr=frame.snr,
|
||||
resend=decrypted.attempt,
|
||||
routed=_frame_routed(raw),
|
||||
frame=raw,
|
||||
)
|
||||
)
|
||||
return arrivals
|
||||
@@ -337,6 +343,7 @@ def direct_arrivals(
|
||||
hops=_frame_hops(frame),
|
||||
snr=frame.snr,
|
||||
routed=_frame_routed(raw),
|
||||
frame=raw,
|
||||
)
|
||||
mac = str(raw.get("cipher_mac") or "").lower()
|
||||
if mac:
|
||||
@@ -386,6 +393,45 @@ def collapse(arrivals: Sequence[Arrival]) -> list[Arrival]:
|
||||
return list(folded.values())
|
||||
|
||||
|
||||
def message_scope(
|
||||
arrivals: Sequence[Arrival], scope_of: Callable[[dict | None], Scope | None]
|
||||
) -> Scope | None:
|
||||
"""The one scope a message was flooded under, read off its arrivals' frames.
|
||||
|
||||
A transport code is a keyed hash of the payload alone — neither the header nor the
|
||||
path is in it (see :mod:`~meshterm.core.regions`) — so every relay's copy carries the
|
||||
sender's code unchanged, and one message has **one** scope however many ways it
|
||||
arrived. That is why the dialog states it once rather than per arrival row.
|
||||
|
||||
Which frame answers still matters, because not every copy is a flood: a direct-routed
|
||||
copy has no scope at all. So the first scoped reading wins, a *named* one over one no
|
||||
known region reproduces (they are the same code, but a name is the better answer when
|
||||
any copy yields one); failing that, any plain-flood copy makes the message
|
||||
``unscoped``; and a message heard only direct-routed has no scope to state.
|
||||
|
||||
Args:
|
||||
arrivals: The message's arrivals, as the matchers (or :func:`collapse`) return
|
||||
them — each carrying its logged frame.
|
||||
scope_of: Reads a frame's scope (``ctx.region_store.scope_of``).
|
||||
|
||||
Returns:
|
||||
The message's scope, or ``None`` where no copy was a flood.
|
||||
"""
|
||||
unscoped: Scope | None = None
|
||||
unnamed: Scope | None = None
|
||||
for arrival in arrivals:
|
||||
scope = scope_of(arrival.frame)
|
||||
if scope is None:
|
||||
continue
|
||||
if not scope.scoped:
|
||||
unscoped = unscoped or scope
|
||||
elif scope.region:
|
||||
return scope
|
||||
else:
|
||||
unnamed = unnamed or scope
|
||||
return unnamed or unscoped
|
||||
|
||||
|
||||
def distinct_paths(arrivals: list[Arrival]) -> int:
|
||||
"""How many distinct relay paths a set of arrivals covers."""
|
||||
return len({a.hops for a in arrivals})
|
||||
@@ -4,7 +4,7 @@
|
||||
*About MeshTerm*, *About the author*, *Join Discord*, *Support MeshTerm* — in that
|
||||
order, which is the order a stranger asks the questions in: what is this, who made it,
|
||||
where is everyone, how do I help. They share the *This app* section with Preferences,
|
||||
which leads it: the section is the app's own scope, and the one row that *changes*
|
||||
which leads it: the section is the app's own, and the one row that *changes*
|
||||
MeshTerm sits above the four that describe it.
|
||||
Each is a page rather than a feature: it reads nothing, transmits nothing, and needs no
|
||||
device, so it opens straight to its screen (see :mod:`meshterm.ui.about`) instead of
|
||||
|
||||
@@ -202,10 +202,10 @@ _REGISTRY: dict[str, Tool] = {}
|
||||
#: everything that shares it. Message leads (the everyday features, plus the book you
|
||||
#: pick a recipient from), then Watch (what the mesh is doing, what it did, what to be
|
||||
#: told about), Explore (where the nodes are, how they reach each other, and the boards
|
||||
#: that score the walking), then the two scopes a setting can belong to — This node, the
|
||||
#: that score the walking), then the two owners a setting can belong to — This node, the
|
||||
#: radio in your hand, and Other nodes, someone else's over the mesh.
|
||||
#:
|
||||
#: This app closes the list as the third and last *scope*, after the radio in your hand and
|
||||
#: This app closes the list as the third and last *owner*, after the radio in your hand and
|
||||
#: someone else's over the mesh: the program in front of you. It holds the one thing that
|
||||
#: changes how MeshTerm behaves (Preferences) and the four pages that say what MeshTerm is
|
||||
#: — questions none of the doings above can hold, and which nothing on the mesh can answer.
|
||||
|
||||
+121
-4
@@ -4,8 +4,8 @@
|
||||
Interactively it opens a full-screen channel manager (see :mod:`meshterm.ui.channels`) — a
|
||||
first-class, phone-app-style experience for creating private channels, adding public ``#``
|
||||
channels, joining with a key, importing a scanned ``meshcore://`` link, and sharing any
|
||||
channel as a QR code. On the CLI it exposes ``list``, ``add``, ``join``, ``import``, and
|
||||
``share`` subcommands for scripted use.
|
||||
channel as a QR code. On the CLI it exposes ``list``, ``add``, ``join``, ``import``,
|
||||
``share``, ``clear`` and ``scope`` subcommands for scripted use.
|
||||
|
||||
Channels are also *listed* by the ``chat`` tool for picking a conversation; this tool owns
|
||||
everything to do with configuring the slots themselves.
|
||||
@@ -81,6 +81,8 @@ class ChannelsTool(Tool):
|
||||
return await self._cli_share(ctx, params)
|
||||
if action == "clear":
|
||||
return await self._cli_clear(ctx, params)
|
||||
if action == "scope":
|
||||
return await self._cli_scope(ctx, params)
|
||||
return await self._cli_list(ctx)
|
||||
|
||||
async def _cli_list(self, ctx: AppContext) -> ToolResult:
|
||||
@@ -91,7 +93,7 @@ class ChannelsTool(Tool):
|
||||
slots = await read_channel_slots(device)
|
||||
return ToolResult(
|
||||
summary={"channels": len(slots)},
|
||||
report=(_channel_listing(slots),),
|
||||
report=(_channel_listing(slots, ctx.region_store),),
|
||||
exit_code=exitcodes.OK if slots else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
@@ -194,6 +196,52 @@ class ChannelsTool(Tool):
|
||||
report=(_cleared(idx, True),),
|
||||
)
|
||||
|
||||
async def _cli_scope(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Read, set or clear the region a channel's messages are sent under.
|
||||
|
||||
The scriptable face of the detail page's *Send scope…*. The scope is MeshTerm's to
|
||||
keep (the firmware has none per channel; the send sets the companion's session
|
||||
scope around each message), so setting one writes nothing to the radio — the slot
|
||||
is read only to learn which channel is in it, since the scope is keyed by the
|
||||
channel's identity and follows it to any slot.
|
||||
|
||||
With no region, the plain face prints the scope alone, the way ``config get`` prints
|
||||
one value — and a channel with none prints ``-`` and exits 0: "no scope, so the
|
||||
device default" is an answer about a channel that is there, not an empty result. A
|
||||
set or a clear prints nothing, and the document always carries the channel and its
|
||||
scope (``null`` for none). Only an empty slot, with no channel to answer about,
|
||||
exits 5.
|
||||
"""
|
||||
from ..core.channel_probe import read_channel_slots
|
||||
|
||||
device = await ctx.device()
|
||||
idx = int(params["index"])
|
||||
slot = next((s for s in await read_channel_slots(device) if s.idx == idx), None)
|
||||
region = params.get("region")
|
||||
if slot is None:
|
||||
if region is not None or params.get("clear"):
|
||||
# A write names a channel that isn't there: a bad argument, not an empty
|
||||
# answer — exit 5 would tell a script the scope was set and read back empty.
|
||||
import typer
|
||||
|
||||
raise typer.BadParameter(f"slot {idx} is empty; there is no channel to scope")
|
||||
return ToolResult(
|
||||
summary={"index": idx, "scope": None},
|
||||
report=(_scoped(idx, None, None, None, changed=False),),
|
||||
exit_code=exitcodes.NO_RESULT,
|
||||
)
|
||||
store = ctx.region_store
|
||||
changed = bool(params.get("clear")) or region is not None
|
||||
if params.get("clear"):
|
||||
store.set_channel_scope(slot.identity, None)
|
||||
elif region is not None:
|
||||
store.set_channel_scope(slot.identity, region)
|
||||
scope = store.channel_scope(slot.identity)
|
||||
return ToolResult(
|
||||
summary={"index": idx, "scope": scope},
|
||||
report=(_scoped(idx, slot.name, slot.secret, scope, changed=changed),),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _show_qr(ctx: AppContext, name: str, url: str) -> None:
|
||||
"""Draw a channel's share link as a QR code — in the menu, and only there.
|
||||
@@ -274,19 +322,48 @@ class ChannelsTool(Tool):
|
||||
)
|
||||
run_tool_command(self, {"cli_action": "clear", "index": index})
|
||||
|
||||
@channels_app.command("scope", help="Show or set the region a channel sends into")
|
||||
def _scope_cmd(
|
||||
index: int = typer.Argument(..., help="Channel slot index"),
|
||||
region: str | None = typer.Argument(
|
||||
None, help="Region to send this channel's messages into (omit to show it)"
|
||||
),
|
||||
clear: bool = typer.Option(
|
||||
False, "--clear", help="Drop the channel's scope (send under the default)"
|
||||
),
|
||||
) -> None:
|
||||
from ..core.regions import RegionNameError, validate
|
||||
|
||||
if clear and region is not None:
|
||||
raise typer.BadParameter("Pass a region or --clear, not both.")
|
||||
if region is not None:
|
||||
try:
|
||||
region = validate(region)
|
||||
except RegionNameError as exc:
|
||||
raise typer.BadParameter(str(exc)) from exc
|
||||
run_tool_command(
|
||||
self, {"cli_action": "scope", "index": index, "region": region, "clear": clear}
|
||||
)
|
||||
|
||||
app.add_typer(channels_app, name=self.name)
|
||||
|
||||
|
||||
def _channel_listing(slots: list) -> Listing:
|
||||
def _channel_listing(slots: list, store: object | None = None) -> Listing:
|
||||
"""The configured slots, one record each.
|
||||
|
||||
The one listing whose record *is* a shared shape rather than carrying one: a row that
|
||||
nested its only field under a ``channel`` key would make ``jq '.[].channel.name'``
|
||||
out of a listing whose every column is already the channel.
|
||||
|
||||
``SCOPE`` is the region the channel's messages are sent under (``-`` and ``null`` for
|
||||
the device default), read from the region store the send path reads.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
def scope_of(slot) -> str | None: # noqa: ANN001 - a ChannelSlot
|
||||
return store.channel_scope(slot.identity) if store is not None else None
|
||||
|
||||
return Listing(
|
||||
key="channels",
|
||||
columns=(
|
||||
@@ -294,6 +371,7 @@ def _channel_listing(slots: list) -> Listing:
|
||||
fields.name("name", "NAME"),
|
||||
fields.word("type", "TYPE"),
|
||||
fields.hexid("hash", "HASH"),
|
||||
fields.name("scope", "SCOPE"),
|
||||
),
|
||||
rows=[
|
||||
{
|
||||
@@ -301,12 +379,51 @@ def _channel_listing(slots: list) -> Listing:
|
||||
"name": slot.name,
|
||||
"type": "public" if slot.is_public else "private",
|
||||
"hash": slot.hash,
|
||||
"scope": scope_of(slot),
|
||||
}
|
||||
for slot in slots
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _scoped(
|
||||
idx: int, name: str | None, secret: bytes | None, scope: str | None, *, changed: bool
|
||||
) -> Facts:
|
||||
"""What ``channels scope`` read or set: the channel and the region it sends into.
|
||||
|
||||
Args:
|
||||
idx: The slot index.
|
||||
name: The channel name, or ``None`` for a slot that turned out to be empty.
|
||||
secret: The 16-byte key, or ``None`` for an empty slot.
|
||||
scope: The channel's scope now, or ``None`` for the device default.
|
||||
changed: Whether this run set or cleared it — then the plain face is silent, the
|
||||
way every write's is; a read prints the scope alone.
|
||||
|
||||
Returns:
|
||||
The facts block.
|
||||
"""
|
||||
from ..ui import fields, script
|
||||
from ..ui.fields import ChannelRef, Rendered
|
||||
from ..ui.report import BARE, SILENT, Facts
|
||||
|
||||
channel = None
|
||||
if name is not None and secret is not None:
|
||||
public = is_public_channel(name, secret)
|
||||
channel = ChannelRef(slot=idx, name=name, public=public, hash=channel_hash(secret))
|
||||
# A channel with no scope answers `-` plain (and `null` in the document): "the device
|
||||
# default" is an answer about a channel that is there, where the bare form's usual
|
||||
# silence for an absent value would leave only the exit status to say it. An empty
|
||||
# slot has no channel to answer about, so it keeps that silence (and its exit 5).
|
||||
value = Rendered(scope, scope or script.NONE) if channel is not None else None
|
||||
return Facts(
|
||||
key="channel",
|
||||
fields=(fields.channel("channel"), fields.rendered("scope", "scope")),
|
||||
values={"channel": channel, "scope": value},
|
||||
shape=SILENT if changed else BARE,
|
||||
bare="scope",
|
||||
)
|
||||
|
||||
|
||||
def _written(idx: int, name: str | None, secret: bytes | None, *, shown: bool = True) -> Facts:
|
||||
"""What a slot now holds, after ``add``, ``join``, ``import`` or ``share``.
|
||||
|
||||
|
||||
+60
-4
@@ -286,15 +286,22 @@ class ChatTool(Tool):
|
||||
message prints its ``acked`` state, which is the one thing the send does not
|
||||
already tell the caller: the radio accepted it either way, and whether the peer
|
||||
answered is a separate fact.
|
||||
|
||||
A channel message goes out under the channel's send scope, or under ``scope`` for
|
||||
this one message (a region name, or ``*`` for unscoped); the document says which it
|
||||
went under. A radio that can't send under the scope refuses before anything is
|
||||
transmitted, and that is a failure (exit 1), never a quiet unscoped send.
|
||||
"""
|
||||
device = await ctx.device()
|
||||
text = str(params["text"])
|
||||
channel = params.get("channel")
|
||||
if channel is not None:
|
||||
await ctx.chat.send_channel(int(channel), text, label=f"#{channel}")
|
||||
message = await ctx.chat.send_channel(
|
||||
int(channel), text, label=f"#{channel}", scope=params.get("scope") or None
|
||||
)
|
||||
return ToolResult(
|
||||
summary={"channel": channel, "sent": True},
|
||||
report=(_sent(channel=int(channel)),),
|
||||
summary={"channel": channel, "sent": True, "scope": message.scope},
|
||||
report=(_sent(channel=int(channel), scope=message.scope),),
|
||||
)
|
||||
|
||||
contact = _resolve_contact(await device.get_contacts(), str(params["to"]))
|
||||
@@ -433,11 +440,19 @@ class ChatTool(Tool):
|
||||
text: str = typer.Argument(..., help="The message body"),
|
||||
to: str | None = typer.Option(None, "--to", help="Contact name or key prefix"),
|
||||
channel: int | None = typer.Option(None, "--channel", help="Channel slot index"),
|
||||
scope: str | None = typer.Option(
|
||||
None,
|
||||
"--scope",
|
||||
help="Send under this region instead of the channel's scope (* = unscoped)",
|
||||
),
|
||||
) -> None:
|
||||
if (to is None) == (channel is None):
|
||||
raise typer.BadParameter("Pass exactly one of --to / --channel.")
|
||||
if scope is not None:
|
||||
scope = _cli_scope(scope, channel)
|
||||
run_tool_command(
|
||||
self, {"cli_action": "send", "to": to, "channel": channel, "text": text}
|
||||
self,
|
||||
{"cli_action": "send", "to": to, "channel": channel, "text": text, "scope": scope},
|
||||
)
|
||||
|
||||
@chat_app.command("history", help="Show a conversation's stored history")
|
||||
@@ -476,6 +491,28 @@ class ChatTool(Tool):
|
||||
# -- helpers ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cli_scope(scope: str, channel: int | None) -> str:
|
||||
"""Check ``--scope`` before anything is sent: a region the firmware could hold, or ``*``.
|
||||
|
||||
A usage error (exit 2), not a device failure: nothing has been transmitted yet. A
|
||||
direct message has no scope to give it here — the firmware floods a DM under the
|
||||
session scope like anything else, but only a channel send sets one for itself.
|
||||
|
||||
Raises:
|
||||
typer.BadParameter: For a direct send, or a name the firmware would refuse.
|
||||
"""
|
||||
from ..core.regions import WILDCARD, RegionNameError, normalize, validate
|
||||
|
||||
if channel is None:
|
||||
raise typer.BadParameter("--scope applies to a channel send (--channel).")
|
||||
if normalize(scope) == WILDCARD:
|
||||
return WILDCARD
|
||||
try:
|
||||
return validate(scope)
|
||||
except RegionNameError as exc:
|
||||
raise typer.BadParameter(str(exc)) from exc
|
||||
|
||||
|
||||
def _enable_receive_debug() -> None:
|
||||
"""Route the message-pull and library debug logs to the console for diagnosis.
|
||||
|
||||
@@ -1092,6 +1129,7 @@ def _sent(
|
||||
contact: object | None = None,
|
||||
channel: int | None = None,
|
||||
acked: bool | None = None,
|
||||
scope: str | None = None,
|
||||
) -> Facts:
|
||||
"""What one ``chat send`` did.
|
||||
|
||||
@@ -1100,10 +1138,24 @@ def _sent(
|
||||
answered is a separate fact. A channel broadcast prints nothing, because there *is* no
|
||||
acknowledgement on a channel — and the document says that in the one way the plain
|
||||
face never could, with ``null`` rather than ``false``.
|
||||
|
||||
The document also carries the ``scope`` a channel message went out under, in the same
|
||||
shape a received frame's scope takes (:func:`~meshterm.ui.fields.scope`): the region,
|
||||
``unscoped``, or ``null`` where it isn't known (a direct message, or a default scope
|
||||
that couldn't be read).
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
from ..core.regions import UNSCOPED, WILDCARD, Scope
|
||||
from ..ui import fields
|
||||
from ..ui.report import PAIRS, SILENT, Facts
|
||||
|
||||
sent_scope = None
|
||||
if scope == WILDCARD:
|
||||
sent_scope = UNSCOPED
|
||||
elif scope:
|
||||
sent_scope = Scope("scoped", scope)
|
||||
|
||||
node = (
|
||||
NodeRef(
|
||||
name=contact.name,
|
||||
@@ -1125,6 +1177,9 @@ def _sent(
|
||||
fields.channel("channel", lanes=()),
|
||||
fields.hidden("sent"),
|
||||
fields.flag("acked", "acked"),
|
||||
# Document only: a direct message has no scope of its own to print, and a
|
||||
# channel send prints nothing at all.
|
||||
replace(fields.scope("scope", "scope"), lanes=()),
|
||||
),
|
||||
values={
|
||||
"kind": "channel" if channel is not None else "direct",
|
||||
@@ -1134,6 +1189,7 @@ def _sent(
|
||||
),
|
||||
"sent": True,
|
||||
"acked": acked,
|
||||
"scope": sent_scope,
|
||||
},
|
||||
shape=SILENT if channel is not None else PAIRS,
|
||||
)
|
||||
+51
-39
@@ -78,6 +78,7 @@ class ConfigTool(Tool):
|
||||
device = await ctx.device()
|
||||
ops: list[tuple] = list(params.get("ops") or [])
|
||||
snapshot = await build_snapshot(device)
|
||||
learn_default_scope(ctx, snapshot)
|
||||
changes, artifacts, report = await apply_ops(ctx, device, snapshot, ops)
|
||||
|
||||
plural = "" if changes == 1 else "s"
|
||||
@@ -159,20 +160,6 @@ class ConfigTool(Tool):
|
||||
def _share_cmd() -> None:
|
||||
run_tool_command(self, {"ops": [("share",)]})
|
||||
|
||||
@config_app.command(
|
||||
"advert-cadence",
|
||||
help="Set how often this node auto-advertises in the background (0 = off)",
|
||||
)
|
||||
def _advert_cadence_cmd(
|
||||
hours: int = typer.Argument(
|
||||
..., min=0, help="Cadence in hours between background adverts (0 disables)"
|
||||
),
|
||||
flood: bool = typer.Option(
|
||||
False, "--flood", help="Set the flood cadence (else the zero-hop / direct one)"
|
||||
),
|
||||
) -> None:
|
||||
run_tool_command(self, {"ops": [("advert_cadence", flood, hours)]})
|
||||
|
||||
@config_app.command("export-key", help="Export the private key (sensitive)")
|
||||
def _export_key_cmd(
|
||||
out: Path | None = typer.Option(None, "--out", help="Write to file instead of stdout"),
|
||||
@@ -278,44 +265,33 @@ async def apply_ops(
|
||||
elif kind == "advert":
|
||||
flood = len(op) > 1 and bool(op[1])
|
||||
await device.send_advert(flood)
|
||||
# A manual advert resets the background scheduler's countdown for its type,
|
||||
# so the next scheduled send counts from this one (see AdvertScheduler).
|
||||
# A flood advert, however it was asked for, restarts this device's week: the
|
||||
# weekly one only ever goes out a full week after the last (see
|
||||
# AdvertScheduler). A zero-hop reaches only the neighbours, and resets nothing.
|
||||
public_key = str(snapshot.get("public_key") or "")
|
||||
if public_key:
|
||||
ctx.advert_store.mark_sent(public_key, flood=flood)
|
||||
if flood and public_key:
|
||||
ctx.advert_store.mark_flood(public_key)
|
||||
kind_label = "flood" if flood else "zero-hop"
|
||||
ctx.ui.ack(f"[ok]✓[/ok] {kind_label} advertisement sent")
|
||||
report.append(_acted("advert", sent=True, flood=flood))
|
||||
elif kind == "advert_cadence":
|
||||
from ..core.advert_store import cadence_label
|
||||
|
||||
flood, hours = bool(op[1]), int(op[2])
|
||||
public_key = str(snapshot.get("public_key") or "")
|
||||
if public_key:
|
||||
ctx.advert_store.set_cadence(public_key, flood=flood, hours=hours)
|
||||
kind_label = "flood" if flood else "zero-hop"
|
||||
ctx.ui.ack(
|
||||
f"[ok]✓[/ok] background {kind_label} advert: "
|
||||
f"[brand]{cadence_label(hours)}[/brand]"
|
||||
)
|
||||
changes += 1
|
||||
report.append(_acted("cadence", changes=1, flood=flood, hours=hours))
|
||||
else: # pragma: no cover - SELF_INFO always carries the key on real firmware
|
||||
raise DeviceCommandError(
|
||||
"the device reported no public key — the advert cadence was not saved"
|
||||
)
|
||||
elif kind == "share":
|
||||
report.append(await _share_contact(ctx, snapshot))
|
||||
elif kind == "sync_clock":
|
||||
# The same step the on-connect service runs in the background
|
||||
# (:mod:`meshterm.services.clock_sync`); here it is acknowledged and reported.
|
||||
done = await set_clock(device)
|
||||
ctx.ui.ack(f"[ok]✓[/ok] device clock set to [brand]{done.stamp}[/brand]")
|
||||
changes += 1
|
||||
if done.written:
|
||||
ctx.ui.ack(f"[ok]✓[/ok] device clock set to [brand]{done.stamp}[/brand]")
|
||||
else:
|
||||
ctx.ui.ack(
|
||||
f"[ok]✓[/ok] device clock already in sync "
|
||||
f"([muted]{done.drift_s:+d} s, ahead of this computer's[/muted])"
|
||||
)
|
||||
changes += int(done.written)
|
||||
report.append(
|
||||
_acted(
|
||||
"clock",
|
||||
changes=1,
|
||||
changes=int(done.written),
|
||||
set_at=done.set_at,
|
||||
# What the clock was off by *before* the set: the fact worth logging,
|
||||
# and the one this command destroys by succeeding.
|
||||
@@ -326,6 +302,10 @@ async def apply_ops(
|
||||
await device.reboot()
|
||||
ctx.ui.ack("[warn]device rebooting[/warn]")
|
||||
report.append(_acted("reboot", rebooted=True))
|
||||
if ctx.active_transport is not None:
|
||||
# Run from the menu's command line, this hands off to the reconnect dialog
|
||||
# as the config editor's reboot does; a scripted run has no watcher to wake.
|
||||
ctx.announce_reboot()
|
||||
elif kind == "export_key":
|
||||
report.append(await _export_key(ctx, device, op[1] if len(op) > 1 else None, artifacts))
|
||||
elif kind == "import_key":
|
||||
@@ -379,10 +359,42 @@ async def _apply_setting(
|
||||
# meshterm.core.settings_store). Provenance-gated: only values changed through MeshTerm.
|
||||
if ctx.settings_store is not None:
|
||||
ctx.settings_store.remember(str(snapshot.get("public_key") or ""), key, value)
|
||||
if key == "flood_scope":
|
||||
learn_default_scope(ctx, snapshot)
|
||||
ctx.ui.ack(f"[ok]✓[/ok] [brand]{key}[/brand] = {format_value(spec, value)}")
|
||||
return {"key": key, "previous": previous, "value": value}
|
||||
|
||||
|
||||
def learn_default_scope(ctx: AppContext, snapshot: dict) -> None:
|
||||
"""Take in the default flood scope a config read or write has just seen.
|
||||
|
||||
Two things want it. The region store learns the name (source ``default``): it is the
|
||||
region this station's own plain floods carry, so it is the first name any of them will
|
||||
be resolved against. And the session cache holds it (``devstate``), since a channel with
|
||||
no scope of its own records its messages as sent under the default, and this saves that
|
||||
record a round trip.
|
||||
|
||||
A snapshot whose read of the default failed (firmware before 1.15) carries no
|
||||
``flood_scope`` key at all, and teaches nothing.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
snapshot: A :func:`~meshterm.core.device_config.build_snapshot` result, or one an
|
||||
apply has just updated.
|
||||
"""
|
||||
if "flood_scope" not in snapshot:
|
||||
return
|
||||
from ..core.regions import normalize
|
||||
|
||||
name = normalize(str(snapshot.get("flood_scope") or ""))
|
||||
devstate = getattr(ctx, "devstate", None)
|
||||
if devstate is not None:
|
||||
devstate.note_default_scope(name)
|
||||
store = getattr(ctx, "region_store", None)
|
||||
if store is not None and name:
|
||||
store.learn(name, "default")
|
||||
|
||||
|
||||
async def _show(ctx: AppContext, device: Device, snapshot: dict) -> Listing | None:
|
||||
"""Print every current setting, plus any custom variables.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``devices`` tool: enumerate attached serial and in-range Bluetooth companions.
|
||||
"""The ``devices`` tool: enumerate attached serial, SPI, and in-range Bluetooth companions.
|
||||
|
||||
Discovery never opens the radio — it only lists what is attached or advertising. This is a
|
||||
CLI-only, read-only inventory (``menu_visible = False``): by the time the interactive menu is
|
||||
@@ -18,7 +18,15 @@ import typer
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core import exitcodes
|
||||
from ..core.discovery import TRANSPORT_MOCK, DiscoveredDevice, discover_all, discover_devices
|
||||
from ..core.discovery import (
|
||||
TRANSPORT_MOCK,
|
||||
DiscoveredDevice,
|
||||
ble_unavailable_reason,
|
||||
discover_all,
|
||||
discover_devices,
|
||||
)
|
||||
from ..core.selection import DeviceSelectionError
|
||||
from ..core.spiradio import spi_radios
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
@@ -33,11 +41,11 @@ _MAYBE: dict[str, str] = {"board": "maybe", "bridge": "maybe", "unknown": "no"}
|
||||
|
||||
@register
|
||||
class DevicesTool(Tool):
|
||||
"""List attached serial + in-range Bluetooth companions, flag likely LoRa, mark the default."""
|
||||
"""List attached serial, SPI, and in-range Bluetooth companions; mark likely and active."""
|
||||
|
||||
name = "devices"
|
||||
title = "Devices"
|
||||
help = "List attached serial and Bluetooth devices, flagging likely companions"
|
||||
help = "List attached serial, SPI, and Bluetooth devices, flagging likely companions"
|
||||
category = "This node"
|
||||
order = 5
|
||||
menu_visible = False # CLI-only: a startup diagnostic with no place in a connected session
|
||||
@@ -68,10 +76,39 @@ class DevicesTool(Tool):
|
||||
else:
|
||||
scan_ble = params.get("ble", True)
|
||||
devices = await discover_all(ble=scan_ble) if scan_ble else discover_devices()
|
||||
# A radio on the SPI bus is attached the way a serial port is, but nothing
|
||||
# enumerates it: it is listed from its device node, as the device screen lists it.
|
||||
devices += spi_radios(ctx.settings.profiles, devices)
|
||||
known = ctx.device_store.load_all()
|
||||
remembered = ctx.device_store.load()
|
||||
active = ctx.selected_device
|
||||
active_target = ctx.ble_override or ctx.port_override or (active.target if active else None)
|
||||
try:
|
||||
spi = (
|
||||
ctx.resolve_spi()
|
||||
if ctx.spi_override or (ctx.profile is not None and ctx.profile.is_spi)
|
||||
else None
|
||||
)
|
||||
except DeviceSelectionError:
|
||||
spi = None # an ambiguous --spi: the listing is how the reader finds out which
|
||||
|
||||
active_target = (
|
||||
ctx.ble_override
|
||||
or ctx.port_override
|
||||
or (spi.spidev if spi is not None else None)
|
||||
or (active.target if active else None)
|
||||
)
|
||||
|
||||
# A refused Bluetooth scan is not the same as a quiet one, and the difference is
|
||||
# invisible in the listing — both simply lack BLE rows. Say so whether or not
|
||||
# anything else was found: a serial board being present does not make a companion
|
||||
# the reader expected over Bluetooth any less missing.
|
||||
blocked = ble_unavailable_reason()
|
||||
if blocked:
|
||||
from ..ui import script
|
||||
|
||||
script.stderr_console().print(
|
||||
f"meshterm: Bluetooth not scanned — {blocked}", style="warn", highlight=False
|
||||
)
|
||||
|
||||
if not devices:
|
||||
# Not an error: the scan ran and found nothing. Said on stderr so a caller
|
||||
@@ -107,7 +144,7 @@ class DevicesTool(Tool):
|
||||
# The listing used to close with a line explaining its own markers and how to
|
||||
# act on a row. That is help, and this is where help goes.
|
||||
epilog="Select a device with --port TARGET or --ble TARGET, using the "
|
||||
"TARGET column verbatim.",
|
||||
"TARGET column verbatim; an SPI radio with --spi, or -p and its profile.",
|
||||
)
|
||||
def _devices(
|
||||
ble: bool = typer.Option(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -112,6 +112,9 @@ class MonitorTool(Tool):
|
||||
"snr_db": obs.snr,
|
||||
"rssi_dbm": obs.rssi,
|
||||
"position": _position(obs.lat, obs.lon),
|
||||
# The region a flood was sent into, resolved against the names known
|
||||
# right now; a direct frame (or a class with no route) has none.
|
||||
"scope": ctx.region_store.scope_of(obs.raw),
|
||||
"path": _relays(obs.path),
|
||||
}
|
||||
)
|
||||
@@ -188,7 +191,9 @@ def _live_packets() -> Listing:
|
||||
"""The shape the live capture streams: one record per packet, as it lands.
|
||||
|
||||
``TIME`` is absolute where a listing's times are ages, and for the reason the whole
|
||||
rule turns on: every row of a live tail would read ``now``. The **name goes last**, so
|
||||
rule turns on: every row of a live tail would read ``now``. ``SCOPE`` says which region
|
||||
a flood was sent into (:func:`~meshterm.ui.fields.scope`), ``null`` in the document for
|
||||
a frame that has none. The **name goes last**, so
|
||||
the one field with no width cannot push a lane — which is what finally let the two
|
||||
streams have columns at all, and with them the quoting could go.
|
||||
"""
|
||||
@@ -217,9 +222,13 @@ def _live_packets() -> Listing:
|
||||
pin(fields.snr("snr_db", "SNR_DB"), 6),
|
||||
pin(fields.decimal("rssi_dbm", "RSSI_DBM", ".0f"), 8),
|
||||
pin(fields.position(), 19),
|
||||
# The flood's scope — a region name, ``unknown`` or ``unscoped``, ``-`` for a
|
||||
# frame with none. Pinned to hold the two words whole; a long region name
|
||||
# overruns and pushes the row right, which is why NAME still goes last.
|
||||
pin(fields.scope(), 10),
|
||||
fields.hidden("path"),
|
||||
),
|
||||
order=("TIME", "NODE", "SNR_DB", "RSSI_DBM", "LOCATION", "NAME"),
|
||||
order=("TIME", "NODE", "SNR_DB", "RSSI_DBM", "LOCATION", "SCOPE", "NAME"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ staging eleven changes or from a single ``preferences set``.
|
||||
|
||||
This is deliberately the *app's* tool, not the radio's. It reads nothing from the
|
||||
companion, transmits nothing, and works with no device attached at all — which is why it
|
||||
leads the **This app** section (the third scope, after this node and other nodes) rather
|
||||
leads the **This app** section (the third owner, after this node and other nodes) rather
|
||||
than sitting beside Device config.
|
||||
"""
|
||||
|
||||
@@ -127,6 +127,11 @@ class PreferencesTool(Tool):
|
||||
|
||||
if changes:
|
||||
prefs.save()
|
||||
if any(c["key"] == "weekly_flood_advert" for c in applied):
|
||||
# Switching the weekly advert on starts its week rather than sending, so the
|
||||
# instant is recorded as it happens — from the page and a shell alike — and
|
||||
# not whenever the scheduler next looks (see meshterm.core.advert_store).
|
||||
ctx.advert_store.set_enabled(bool(prefs.weekly_flood_advert))
|
||||
if not scripted and any(c["key"] == consolefont.PREFERENCE_KEY for c in applied):
|
||||
# The one preference whose effect is outside the app: the console's font is
|
||||
# the console's, so it takes hold now rather than next launch. The kernel
|
||||
|
||||
@@ -26,6 +26,9 @@ from ..services.records import CATEGORIES, CATEGORY_BY_ID, Category
|
||||
from ..ui.script import NONE
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
#: The hash widths a record can be set at: MeshCore's path hashes are 1, 2 or 4 bytes.
|
||||
WIDTHS = (1, 2, 4)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..persistence.repository import DiscoveredPath
|
||||
from ..ui.fields import NodeRef
|
||||
@@ -183,7 +186,13 @@ class TrophyCaseTool(Tool):
|
||||
f"--category must be one of: {choices} (got {category!r})"
|
||||
)
|
||||
tool_params["category"] = category
|
||||
if width:
|
||||
if width is not None:
|
||||
# The same closed set as --category, for the same reason: `--width 3`
|
||||
# found nothing and exited 5, indistinguishable from an empty database,
|
||||
# and `--width 0` was falsy and so quietly listed every width.
|
||||
if width not in WIDTHS:
|
||||
choices = ", ".join(map(str, WIDTHS))
|
||||
raise typer.BadParameter(f"--width must be one of: {choices} (got {width})")
|
||||
tool_params["width"] = width
|
||||
run_tool_command(self, tool_params)
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``regions`` command: ask a repeater which regions it relays floods for.
|
||||
|
||||
``meshterm regions <repeater>`` sends the anonymous regions request (firmware 1.12+, no
|
||||
login) and prints the answer — the regions it relays scoped floods for, and whether it also
|
||||
relays unscoped ones. One request, one transmission, per invocation: the repeater
|
||||
rate-limits the question, and nothing here retries it.
|
||||
|
||||
It is a command-line face only. In the menu the same question lives on a repeater's node
|
||||
page (*Ask which regions it carries*), beside the answer it last gave, so there is no menu
|
||||
row for it here.
|
||||
|
||||
The answer is learned into the region store either way, exactly as the node page learns it,
|
||||
so a scoped flood heard afterwards can be traced back to a region this repeater named.
|
||||
|
||||
Exit status, per the CLI's rules: ``0`` when it answered with anything (unscoped floods
|
||||
alone are an answer), ``5`` when it answered and named nothing at all — it relays no floods
|
||||
— ``2`` for a contact that does not exist or is known not to be a repeater (nothing was
|
||||
sent), and ``4`` when it never answered, which is what a repeater out of direct reach does:
|
||||
the request is only answered when it arrives from a neighbour or over a known route.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core import exitcodes
|
||||
from ..core.models import NODE_TYPE_LABELS, NODE_TYPE_REPEATER, Contact
|
||||
from ..core.regions import WILDCARD
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Facts
|
||||
|
||||
|
||||
@register
|
||||
class RegionsTool(Tool):
|
||||
"""Ask one repeater which regions it carries (command line only)."""
|
||||
|
||||
name = "regions"
|
||||
title = "Regions"
|
||||
icon = "🔖"
|
||||
help = "Ask a repeater which regions it relays floods for"
|
||||
category = "Other nodes"
|
||||
order = 20
|
||||
menu_visible = False # the menu asks from the repeater's node page
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Send the regions request to one repeater and report its answer.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
params: ``node``, the repeater's contact name.
|
||||
|
||||
Returns:
|
||||
The answer as a report; ``NO_RESULT`` when it named nothing.
|
||||
|
||||
Raises:
|
||||
typer.BadParameter: For an unknown contact, or one known not to be a repeater —
|
||||
both decided before anything is sent.
|
||||
DeviceCommandError: When the repeater never answered.
|
||||
"""
|
||||
device = await ctx.device()
|
||||
contacts = await device.get_contacts()
|
||||
name = str(params["node"])
|
||||
node = next((c for c in contacts if c.name == name), None)
|
||||
if node is None:
|
||||
raise typer.BadParameter(f"unknown contact: {name!r}")
|
||||
if node.node_type is not None and node.node_type != NODE_TYPE_REPEATER:
|
||||
role = NODE_TYPE_LABELS.get(node.node_type, "not a repeater")
|
||||
raise typer.BadParameter(
|
||||
f"{name!r} is a {role}; only a repeater answers the regions request"
|
||||
)
|
||||
names = await device.request_regions(node)
|
||||
node_id = (node.public_key or node.key_prefix or "").lower()[:12]
|
||||
if node_id and ctx.region_store is not None:
|
||||
ctx.region_store.learn_carried(node_id, names)
|
||||
regions = [n for n in names if n != WILDCARD]
|
||||
unscoped = WILDCARD in names
|
||||
return ToolResult(
|
||||
report=(_answer(node, unscoped, regions),),
|
||||
summary={"node": name, "unscoped": unscoped, "regions": regions},
|
||||
exit_code=exitcodes.OK if names else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register the ``regions`` subcommand.
|
||||
|
||||
Args:
|
||||
app: The Typer application.
|
||||
"""
|
||||
from ..cli import run_tool_command
|
||||
|
||||
@app.command(name=self.name, help=self.help)
|
||||
def _regions(
|
||||
node: str = typer.Argument(..., help="The repeater's contact name"),
|
||||
) -> None:
|
||||
run_tool_command(self, {"node": node})
|
||||
|
||||
|
||||
def _answer(node: Contact, unscoped: bool, regions: list[str]) -> Facts:
|
||||
"""The repeater's answer, stated once for both faces.
|
||||
|
||||
Three facts: who answered, whether it relays unscoped floods, and the regions it relays
|
||||
scoped floods for — the wildcard kept out of that list, since it names no region (see
|
||||
:func:`~meshterm.ui.fields.regions`).
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.fields import NodeRef
|
||||
from ..ui.report import Facts
|
||||
|
||||
return Facts(
|
||||
key="regions",
|
||||
fields=(
|
||||
fields.node("node", lanes=(("name", "node"),)),
|
||||
fields.flag("unscoped", "unscoped"),
|
||||
fields.regions("regions", "regions"),
|
||||
),
|
||||
values={
|
||||
"node": NodeRef(
|
||||
name=node.name,
|
||||
key=(node.public_key or "").lower() or None,
|
||||
hash=(node.key_prefix or "").lower() or None,
|
||||
type=NODE_TYPE_LABELS.get(node.node_type),
|
||||
),
|
||||
"unscoped": unscoped,
|
||||
"regions": regions,
|
||||
},
|
||||
)
|
||||
+204
-31
@@ -16,11 +16,19 @@ recorded to history. The mute lives in :class:`~meshterm.core.mute_store.MuteSto
|
||||
the channel's intrinsic identity so it follows the channel across slot moves, and the list
|
||||
row shows a ``🔕`` in its (then always-empty) unread lane to mark it.
|
||||
|
||||
A channel's **send scope** is the other: the region its messages are flooded into, so only
|
||||
the repeaters carrying that region relay them. The firmware has no such thing per channel —
|
||||
the chat sets the companion's session scope around each send instead (see
|
||||
:meth:`~meshterm.core.connection.Device.send_channel_in_scope`) — so it is kept by MeshTerm,
|
||||
in :class:`~meshterm.core.region_store.RegionStore`, keyed the same way a mute is. The
|
||||
detail page's *Send scope…* row picks it from the regions already known here, or takes a
|
||||
typed name.
|
||||
|
||||
The list is laid out like the config editor: fixed, column-aligned lanes under one header
|
||||
line — the openness glyph and name, unread badge, total messages, last-message age, and a
|
||||
braille sparkline of the trailing two hours' traffic — so a glance shows not just *which*
|
||||
channels exist but which ones are alive. Openness beyond the glyph, and the hash, live in
|
||||
the detail views (the title line and Show key), keeping the list lean enough that the
|
||||
line — the openness glyph and name, send scope, unread badge, total messages, last-message
|
||||
age, and a braille sparkline of the trailing two hours' traffic — so a glance shows not
|
||||
just *which* channels exist but which ones are alive. Openness beyond the glyph, and the
|
||||
hash, live in the detail views (the title line and Show key), keeping the list lean enough that the
|
||||
activity lane survives a 72-column terminal. The message statistics come from
|
||||
:meth:`~meshterm.persistence.repository.Repository.channel_stats` (read through a small
|
||||
TTL cache) and the unread counts from the live chat service, and each row is a callable
|
||||
@@ -55,6 +63,8 @@ from ..core.channels import (
|
||||
share_url,
|
||||
)
|
||||
from ..core.connection import Device
|
||||
from ..core.regions import RegionNameError
|
||||
from ..core.regions import validate as validate_region
|
||||
from ..persistence.repository import ACTIVITY_DRAWN_BUCKETS
|
||||
from ..platforms import get_platform
|
||||
from .braillechart import activity_peak, activity_sparkline
|
||||
@@ -93,7 +103,15 @@ _QR = "qr"
|
||||
_KEY = "key"
|
||||
_CHAT = "chat"
|
||||
_MUTE = "mute"
|
||||
_SCOPE = "scope"
|
||||
_EDIT = "edit"
|
||||
|
||||
# Send-scope picker sentinels: clear the channel's scope, or type a region by name.
|
||||
_NO_SCOPE = "__no_scope__"
|
||||
_TYPE_REGION = "__type_region__"
|
||||
|
||||
#: The scope picker's footer: a value picker over a list that can grow, so it filters.
|
||||
_SCOPE_HINT = "↑↓ move · type to filter · Enter set · Esc keep"
|
||||
_CLEAR = "clear"
|
||||
|
||||
|
||||
@@ -450,6 +468,21 @@ class _LiveStats:
|
||||
|
||||
#: Widest the name lane grows (longer names are ellipsized so the lanes stay put).
|
||||
_NAME_WIDTH_MAX = 18
|
||||
#: Cells the name and send-scope lanes share. The scope lane is drawn only while at least
|
||||
#: one channel on the list has a scope — a column of blanks says nothing — and is then only
|
||||
#: as wide as the longest scope (never narrower than its ``SCOPE`` label), taking what the
|
||||
#: names leave of this budget: with a name at :data:`_NAME_WIDTH_MAX` it gets eight cells —
|
||||
#: what the 72-column row had spare before the lane existed, less the last cell an
|
||||
#: ellipsizing row keeps back — so the activity sparkline is never cut to make room; shorter
|
||||
#: names give a long region name more of its own. A 30-byte region name is ellipsized rather
|
||||
#: than let widen the row. On the PicoCalc's 53 the row was already cut inside the activity
|
||||
#: lane, so a scope lane there costs the MSGS count behind it; the detail page states it.
|
||||
#: The leading SLOT lane is paid for out of this budget too (see :func:`_menu_items`), for
|
||||
#: the same reason: the sparkline keeps its cells.
|
||||
_NAME_SCOPE_BUDGET = _NAME_WIDTH_MAX + 8
|
||||
#: Narrowest the right-aligned slot-index lane is drawn (two digits: MeshCore's default
|
||||
#: table holds more than ten slots); a wider table widens it to its highest index.
|
||||
_SLOT_WIDTH_MIN = 2
|
||||
#: Width of the unread-badge lane (fits ``● 999``), matching the conversation picker's.
|
||||
_BADGE_WIDTH = 5
|
||||
#: Width of the right-aligned total-messages lane.
|
||||
@@ -479,17 +512,23 @@ def _activity_sparkline(histogram: tuple[int, ...], peak: float) -> Text:
|
||||
# --- menus -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _lanes_header(name_w: int, width: int) -> str:
|
||||
def _lanes_header(slot_w: int, name_w: int, scope_w: int, width: int) -> str:
|
||||
"""Column headers over the channel list's fixed lanes (see :func:`_slot_text`).
|
||||
|
||||
The indent covers the select screen's pointer column (2 cells, drawn on choice rows but
|
||||
not separators) plus the glyph lane, *measured* rather than assumed: a channel's glyph
|
||||
is two cells on the desktop and one on the console, so a hard-coded indent put every
|
||||
label a column off there. ``UNREAD`` borrows its lane's trailing gap — the badge lane
|
||||
not separators). ``SLOT`` leads — the index every ``channels`` command and the chat's
|
||||
slot references take — and its lane also spans the unlabelled glyph lane after it,
|
||||
*measured* rather than assumed: a channel's glyph is two cells on the desktop and one on
|
||||
the console, so a hard-coded width put every later label a column off there. ``UNREAD``
|
||||
borrows its lane's trailing gap — the badge lane
|
||||
itself is one cell too narrow for the word — which still leaves a space before the
|
||||
message count. (No TYPE or HASH lane: the glyph already carries the openness and the
|
||||
hash lives in Show key, which buys the activity sparkline its room on a 72-column
|
||||
terminal.)
|
||||
terminal.) ``SCOPE`` sits between the name and the badge: it says where the channel's
|
||||
messages go, which is the channel's own fact, before the lanes that count its traffic —
|
||||
and only while some channel has one (``scope_w`` is 0 otherwise). ``LAST`` comes before
|
||||
``MSGS``: how recently a channel spoke is the question a glance down the list asks, and
|
||||
the total is the detail behind it.
|
||||
|
||||
Resolved against the render width, because the header row is pinned and must stay one
|
||||
row: at 53 columns the full line ran to 54 and wrapped, costing a content row out of
|
||||
@@ -498,22 +537,23 @@ def _lanes_header(name_w: int, width: int) -> str:
|
||||
"""
|
||||
return column_header(
|
||||
[
|
||||
Lane("SLOT", slot_w + 2 + cell_len(channel_glyph("Public", None)) + 1),
|
||||
Lane("CHANNEL", name_w + 2),
|
||||
*([Lane("SCOPE", scope_w + 2)] if scope_w else []),
|
||||
Lane("UNREAD", _BADGE_WIDTH + 2),
|
||||
# Right-aligned, because the values under them are: a count and an age are
|
||||
# Right-aligned, because the values under them are: an age and a count are
|
||||
# padded to the right edge of their lane, so a left-aligned label would sit
|
||||
# off the digits it names.
|
||||
Lane(f"{'MSGS':>{_COUNT_WIDTH}}", _COUNT_WIDTH + 2),
|
||||
Lane(f"{'LAST':>{_AGE_WIDTH}}", _AGE_WIDTH + 2),
|
||||
Lane(f"{'MSGS':>{_COUNT_WIDTH}}", _COUNT_WIDTH + 2),
|
||||
Lane(("ACTIVITY", "ACT")),
|
||||
],
|
||||
width,
|
||||
indent=2 + cell_len(channel_glyph("Public", None)) + 1,
|
||||
)
|
||||
|
||||
|
||||
def _slot_row(
|
||||
ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: int
|
||||
ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, slot_w: int, name_w: int, scope_w: int
|
||||
) -> Callable[[], Text]:
|
||||
"""Return a list-row title *callable* the select screen re-renders on each repaint.
|
||||
|
||||
@@ -521,29 +561,41 @@ def _slot_row(
|
||||
:class:`_LiveStats`), so a message arriving while the list sits open updates the row on
|
||||
the next repaint — exactly the conversation picker's behavior.
|
||||
"""
|
||||
return lambda: _slot_text(ctx, slot, stats, name_w)
|
||||
return lambda: _slot_text(ctx, slot, stats, slot_w, name_w, scope_w)
|
||||
|
||||
|
||||
def _slot_text(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: int) -> Text:
|
||||
def _slot_text(
|
||||
ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, slot_w: int, name_w: int, scope_w: int
|
||||
) -> Text:
|
||||
"""Build one channel's list row as fixed-width, colour-coded lanes.
|
||||
|
||||
Alignment carries the readability — glyph, name, unread badge, total messages,
|
||||
last-message age, and the activity sparkline each sit in their own lane under the
|
||||
:func:`_lanes_header` line. Colour stays light and purposeful: the name is the row's
|
||||
focus in the base colour, the descriptive lanes are muted, the unread ``●`` badge is
|
||||
red with its count in warn (the conversation picker's language), and the sparkline
|
||||
Alignment carries the readability — slot index (right-aligned, muted), glyph, name,
|
||||
send scope, unread badge, last-message age, total messages, and the activity
|
||||
sparkline each sit in their own lane under the :func:`_lanes_header` line. Colour
|
||||
stays light and purposeful: the name is the row's focus in the base colour, the
|
||||
descriptive lanes are muted, the unread ``●`` badge is red with its count in warn
|
||||
(the conversation picker's language), and the sparkline
|
||||
draws in the ok green over a faint flatline. A muted channel shows a muted ``🔕`` in
|
||||
the unread lane instead of a count — muting zeros its unread and stops it accruing, so
|
||||
that lane is always free to carry the state. The row is always a Rich
|
||||
:class:`~rich.text.Text` so those spans survive under the select screen's row highlight.
|
||||
that lane is always free to carry the state. The scope is the region name in the
|
||||
``scope`` style, and blank for a channel that sends under the device default — the
|
||||
ordinary case, which a word in every row would only make harder to see past; with
|
||||
``scope_w`` 0 (no channel has one) the lane is not drawn at all. The row is always a
|
||||
Rich :class:`~rich.text.Text` so those spans survive under the select screen's row
|
||||
highlight.
|
||||
"""
|
||||
st = stats.get(slot.identity)
|
||||
muted = _is_muted(ctx, slot)
|
||||
unread = ctx.chat.unread(slot.conversation.key)
|
||||
text = Text(no_wrap=True, overflow="ellipsis")
|
||||
text.append(f"{slot.idx:>{slot_w}}", style="muted")
|
||||
text.append(" ")
|
||||
text.append(f"{channel_glyph(slot.name, slot.secret)} ") # # / 🌐 / 🔒 (2 cells) + gap
|
||||
text.append(fit_cells(slot.name, name_w))
|
||||
text.append(" ")
|
||||
if scope_w:
|
||||
text.append(fit_cells(_channel_scope(ctx, slot) or "", scope_w), style="scope")
|
||||
text.append(" ")
|
||||
if muted:
|
||||
mark = glyph("🔕") # two cells on the desktop, one on the console
|
||||
text.append(mark, style="muted")
|
||||
@@ -554,20 +606,20 @@ def _slot_text(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: in
|
||||
else:
|
||||
text.append(" " * _BADGE_WIDTH)
|
||||
text.append(" ")
|
||||
age = format_age(age_seconds(st.last_at)) if st is not None and st.last_at else ""
|
||||
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
|
||||
text.append(" ")
|
||||
total = st.total if st is not None else 0
|
||||
if total:
|
||||
# Clamped so a pathological backlog can't push the row out of its lanes.
|
||||
text.append(f"{min(total, 99999):>{_COUNT_WIDTH}}")
|
||||
else:
|
||||
# ``○`` is the app's empty mark. ``·`` here was three things at once: the separator
|
||||
# that chains status atoms, the picker's unknown-sender stand-in, and — two cells
|
||||
# away in this very row — what the console folds ``🔕`` to, so a muted channel with
|
||||
# no messages drew the same glyph twice meaning different things.
|
||||
# that chains status atoms, the picker's unknown-sender stand-in, and — in this very
|
||||
# row — what the console folds ``🔕`` to, so a muted channel with no messages drew
|
||||
# the same glyph twice meaning different things.
|
||||
text.append(f"{'○':>{_COUNT_WIDTH}}", style="muted")
|
||||
text.append(" ")
|
||||
age = format_age(age_seconds(st.last_at)) if st is not None and st.last_at else ""
|
||||
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
|
||||
text.append(" ")
|
||||
# The shared peak across all channels, so every row's sparkline uses one scale.
|
||||
text.append_text(_activity_sparkline(st.histogram if st is not None else (), stats.peak()))
|
||||
return text
|
||||
@@ -585,12 +637,23 @@ def _menu_items(
|
||||
"""
|
||||
items: list = []
|
||||
if slots:
|
||||
slot_w = max(_SLOT_WIDTH_MIN, *(len(str(s.idx)) for s in slots))
|
||||
name_w = min(_NAME_WIDTH_MAX, max(len("CHANNEL"), *(len(s.name) for s in slots)))
|
||||
scopes = [cell_len(_channel_scope(ctx, s) or "") for s in slots]
|
||||
# No lane at all while no channel has a scope (see :data:`_NAME_SCOPE_BUDGET`); the
|
||||
# slot lane and its gap come out of the same budget, so the sparkline keeps its room.
|
||||
# A drawn scope lane is never narrower than its label — a long name gives up cells
|
||||
# before the lane would crowd ``SCOPE`` against ``UNREAD``.
|
||||
budget = _NAME_SCOPE_BUDGET - (slot_w + 2)
|
||||
scope_w = max(len("SCOPE"), min(budget - name_w, max(*scopes))) if any(scopes) else 0
|
||||
name_w = min(name_w, budget - scope_w)
|
||||
# The lane names are this block's only landmark (its section carries no ── heading ──),
|
||||
# so they pin overhead while the slots scroll and give way to Organize/Add a channel.
|
||||
items.append(Separator(lambda w: _lanes_header(name_w, w), heading=True))
|
||||
items.append(Separator(lambda w: _lanes_header(slot_w, name_w, scope_w, w), heading=True))
|
||||
for slot in slots:
|
||||
items.append(Choice(title=_slot_row(ctx, slot, stats, name_w), value=slot.idx))
|
||||
items.append(
|
||||
Choice(title=_slot_row(ctx, slot, stats, slot_w, name_w, scope_w), value=slot.idx)
|
||||
)
|
||||
else:
|
||||
items.append(Separator(" no channels yet — add one below"))
|
||||
|
||||
@@ -668,6 +731,11 @@ def _detail_summary(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats) -> st
|
||||
unread = ctx.chat.unread(slot.conversation.key)
|
||||
kind = "public" if slot.is_public else "private"
|
||||
parts = [kind, f"hash {slot.hash}", f"slot {slot.idx}"]
|
||||
# The scope rides with the channel's identity rather than its traffic: it decides who
|
||||
# can hear the next message, so it is kept ahead of the counts when the line sheds.
|
||||
scope = _channel_scope(ctx, slot)
|
||||
if scope:
|
||||
parts.append(f"scope {scope}")
|
||||
if st is None or not st.total:
|
||||
parts.append("no messages yet")
|
||||
else:
|
||||
@@ -698,7 +766,7 @@ def _detail_items(ctx: AppContext, slot: ChannelSlot) -> list:
|
||||
# ✎ and 🗑 are one cell where 📱 🔑 💬 🔔 🔕 are two, so the column is measured once and
|
||||
# every mark padded out to it — otherwise the edit and clear rows start their labels a
|
||||
# column left of the rows above them.
|
||||
lane = icon_lane(("📱", "🔑", "💬", "🔔", "🔕", "✎", "🗑"))
|
||||
lane = icon_lane(("📱", "🔑", "💬", "🔔", "🔕", "🔖", "✎", "🗑"))
|
||||
chat_label = marked_label("💬", "Open in chat", "", lane=lane)
|
||||
if unread:
|
||||
chat_label.append(" ●", style="err")
|
||||
@@ -729,6 +797,7 @@ def _detail_items(ctx: AppContext, slot: ChannelSlot) -> list:
|
||||
),
|
||||
(chat_label, "Read and send messages on this channel", _CHAT),
|
||||
mute_row,
|
||||
_scope_row(ctx, slot, lane),
|
||||
(
|
||||
marked_label("✎", "Rename / change key…", "", lane=lane),
|
||||
"Edit the name or paste a different key",
|
||||
@@ -794,6 +863,10 @@ async def _channel_detail(
|
||||
# A preference, not a slot-config change: toggle in place and loop back to the
|
||||
# detail (whose rows re-render to the new state) without counting a channel change.
|
||||
_toggle_mute(ctx, slot)
|
||||
elif choice == _SCOPE:
|
||||
# MeshTerm's to keep, like the mute: nothing is written to the radio until a
|
||||
# message is sent, so it is not a channel change either.
|
||||
await _pick_scope(ctx, slot)
|
||||
elif choice == _EDIT and await _edit(ctx, device, slot):
|
||||
changes += 1
|
||||
if not await reread(): # pragma: no cover - the slot we just wrote is there
|
||||
@@ -829,6 +902,106 @@ async def _channel_detail(
|
||||
)
|
||||
|
||||
|
||||
# --- send scope ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _channel_scope(ctx: AppContext, slot: ChannelSlot) -> str | None:
|
||||
"""The region this channel's messages are sent under, or ``None`` for the device default."""
|
||||
store = getattr(ctx, "region_store", None)
|
||||
return store.channel_scope(slot.identity) if store is not None else None
|
||||
|
||||
|
||||
def _scope_row(ctx: AppContext, slot: ChannelSlot, lane: int) -> tuple:
|
||||
"""The detail page's *Send scope…* row, its description saying what is set now."""
|
||||
scope = _channel_scope(ctx, slot)
|
||||
description = f"Messages flood in {scope} only" if scope else "Keep messages in one region"
|
||||
return (marked_label("🔖", "Send scope…", "", lane=lane), description, _SCOPE)
|
||||
|
||||
|
||||
def _scope_items(ctx: AppContext, current: str | None) -> list:
|
||||
"""The scope picker's rows: no scope, every region known here, then typing one in.
|
||||
|
||||
A region reads in the ``scope`` style (a region is not a node, so no node hue), with
|
||||
how many repeaters were heard to carry it — the thing that decides whether a message
|
||||
scoped to it goes anywhere at all. The one in force is marked ``current``.
|
||||
"""
|
||||
store = getattr(ctx, "region_store", None)
|
||||
none_row = Text("No scope — the device default")
|
||||
if not current:
|
||||
none_row.append(" · current", style="muted")
|
||||
items: list = [Choice(title=none_row, value=_NO_SCOPE)]
|
||||
for name in store.names() if store is not None else []:
|
||||
row = Text(name, style="scope")
|
||||
carriers = len(store.carriers(name))
|
||||
if carriers:
|
||||
row.append(f" · {carriers} repeater{'s' if carriers != 1 else ''}", style="muted")
|
||||
if name == current:
|
||||
row.append(" · current", style="muted")
|
||||
items.append(Choice(title=row, value=name))
|
||||
items.append(Choice(title=Text("Type a region name…"), value=_TYPE_REGION))
|
||||
return items
|
||||
|
||||
|
||||
async def _pick_scope(ctx: AppContext, slot: ChannelSlot) -> bool:
|
||||
"""Pick the region a channel's messages are sent under; return whether it changed.
|
||||
|
||||
A value picker floated over the detail page, opening on the scope in force. Typing a
|
||||
new name is a second step stacked on the first (the ``run_steps`` shape): Esc on the
|
||||
name field comes back to the list rather than abandoning the pick. A typed name is
|
||||
learned (source ``typed``) as well as set, so the next channel can pick it from the
|
||||
list.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
slot: The channel whose scope is being set.
|
||||
|
||||
Returns:
|
||||
``True`` when the channel's scope changed.
|
||||
"""
|
||||
store = getattr(ctx, "region_store", None)
|
||||
if store is None: # pragma: no cover - every real context builds one
|
||||
return False
|
||||
current = store.channel_scope(slot.identity)
|
||||
session = getattr(ctx.ui, "session", None)
|
||||
while True:
|
||||
items = _scope_items(ctx, current)
|
||||
title = f"Send scope — {slot.name}"
|
||||
prompt = "The region this channel's messages flood into:"
|
||||
default = current or _NO_SCOPE
|
||||
if session is not None:
|
||||
picked = await session.select(
|
||||
title, items, prompt=prompt, default=default, footer_hint=_SCOPE_HINT
|
||||
)
|
||||
else:
|
||||
picked = await ctx.ui.select(title, items, prompt=prompt, default=default)
|
||||
if picked is None:
|
||||
return False
|
||||
if picked == _TYPE_REGION:
|
||||
typed = await ctx.ui.text(
|
||||
"Send scope",
|
||||
prompt="Region name, as its repeaters list it:",
|
||||
default=current or "",
|
||||
validate=_valid_region,
|
||||
)
|
||||
if typed is None:
|
||||
continue # Esc on the name steps back to the list
|
||||
picked = store.learn(typed, "typed") or typed
|
||||
wanted = None if picked == _NO_SCOPE else str(picked)
|
||||
if wanted == current:
|
||||
return False
|
||||
store.set_channel_scope(slot.identity, wanted)
|
||||
return True
|
||||
|
||||
|
||||
def _valid_region(text: str) -> bool | str:
|
||||
"""Require a region name the firmware could hold (see :func:`regions.validate`)."""
|
||||
try:
|
||||
validate_region(text)
|
||||
except RegionNameError as exc:
|
||||
return str(exc)
|
||||
return True
|
||||
|
||||
|
||||
# --- create / join flows -----------------------------------------------------
|
||||
|
||||
|
||||
|
||||
+194
-9
@@ -27,6 +27,7 @@ from ..core.channels import MENTION, split_channel_sender
|
||||
from ..core.connection import ContactNotOnDeviceError
|
||||
from ..core.events import EventKind, MeshEvent
|
||||
from ..core.models import ChatMessage, Contact, Conversation, Message, utcnow
|
||||
from ..core.regions import WILDCARD
|
||||
from .theme import name_style, snr_style
|
||||
from .tui.prompt import (
|
||||
CHANNEL_BYTE_LIMIT,
|
||||
@@ -63,6 +64,11 @@ class ChatScreen(Screen):
|
||||
opens the picked message's paths in either kind — a path belongs to one message, so
|
||||
with nothing picked there is nothing to show. Esc leaves the chat once nothing is
|
||||
picked.
|
||||
|
||||
A channel with a send scope names its region in the title (``#ops · yul``), and ^R there
|
||||
resends a message *unscoped* — the picked one, or with nothing picked the newest — the
|
||||
way out for a scoped message no repeater in earshot carries — after an amber confirm,
|
||||
since an unscoped flood reaches every repeater the scope was keeping it from.
|
||||
"""
|
||||
|
||||
floating = False
|
||||
@@ -85,8 +91,11 @@ class ChatScreen(Screen):
|
||||
|
||||
The F3 pair follows the lane's two claims. *Retry* is absent in a channel — a
|
||||
channel message is never acknowledged, so there is no such thing to retry there —
|
||||
and merely dim in a direct chat with nothing outstanding. *Paths* needs a picked
|
||||
message to have paths of, and both nav slots need a transcript to walk.
|
||||
and merely dim in a direct chat with nothing outstanding. A channel's Shift slot is
|
||||
*Resend* instead — the picked message (or the newest) again, unscoped (see
|
||||
:meth:`_retry_target`) — present where a resend is wired and dim until that message
|
||||
is one of ours that went out under a region. *Paths* needs a picked message to have
|
||||
paths of, and both nav slots need a transcript to walk.
|
||||
|
||||
F1/F2 carry the **day** jump, the transcript's own section step (its dividers are
|
||||
days) — the same claim a grouped select list makes with ``Sect ↑``/``Sect ↓``, on
|
||||
@@ -111,7 +120,12 @@ class ChatScreen(Screen):
|
||||
lane[1] = FPair("Day ↓", "ctrl_pagedown", enabled=days)
|
||||
lane[3] = FPair("Page ↓", "pagedown", "Latest", "ctrl_end", enabled=live, opp_enabled=live)
|
||||
lane[4] = FPair("Page ↑", "pageup", "Oldest", "ctrl_home", enabled=live, opp_enabled=live)
|
||||
retry = ("Retry", "retry") if not self._is_channel else ("", "")
|
||||
if not self._is_channel:
|
||||
retry = ("Retry", "retry")
|
||||
elif self._resend_unscoped is not None:
|
||||
retry = ("Resend", "retry")
|
||||
else:
|
||||
retry = ("", "")
|
||||
lane[2] = FPair(
|
||||
"Paths",
|
||||
"paths",
|
||||
@@ -132,6 +146,8 @@ class ChatScreen(Screen):
|
||||
resend: Callable[[ChatMessage], Awaitable[ChatMessage]] | None = None,
|
||||
paths: Callable[[ChatMessage], Awaitable[None]] | None = None,
|
||||
key_of: Callable[[str], str | None] | None = None,
|
||||
scope: str | None = None,
|
||||
resend_unscoped: Callable[[ChatMessage], Awaitable[ChatMessage | None]] | None = None,
|
||||
) -> None:
|
||||
"""Build the chat screen.
|
||||
|
||||
@@ -152,9 +168,17 @@ class ChatScreen(Screen):
|
||||
:func:`~meshterm.services.trace_runner.make_name_key_resolver`), the seed
|
||||
of the sender's hue; ``None`` (or a name it can't place) leaves senders
|
||||
muted — colour is reserved for keyed identities.
|
||||
scope: The channel's send scope, stated in the title as a bare ``·`` atom
|
||||
(``#ops · yul`` — a channel's title has no other atom a region name could be
|
||||
mistaken for); ``None`` for a channel sending under the device
|
||||
default, and always for a direct chat.
|
||||
resend_unscoped: Async callable that sends a channel message's text again,
|
||||
unscoped, and returns the recorded message (channels only; ``None`` leaves
|
||||
^R inert there).
|
||||
"""
|
||||
super().__init__()
|
||||
self.title = conversation.label
|
||||
self._scope = scope if conversation.is_channel else None
|
||||
self.title = f"{conversation.label} · {self._scope}" if self._scope else conversation.label
|
||||
self._is_channel = conversation.is_channel
|
||||
self._key_of: Callable[[str], str | None] = key_of or (lambda name: None)
|
||||
# A direct thread's one remote sender is the peer; its key colours the header
|
||||
@@ -168,6 +192,7 @@ class ChatScreen(Screen):
|
||||
self._messages = list(messages)
|
||||
self._send = send
|
||||
self._resend = resend
|
||||
self._resend_unscoped = resend_unscoped if conversation.is_channel else None
|
||||
self._paths = paths
|
||||
self._names = names
|
||||
self._session = session
|
||||
@@ -181,6 +206,7 @@ class ChatScreen(Screen):
|
||||
self._stick = True # keep the newest message in view until the user scrolls up
|
||||
self._paths_open = False # one paths dialog at a time
|
||||
self._paste_open = False # one paste-confirm dialog at a time
|
||||
self._resend_open = False # one resend-unscoped confirm at a time
|
||||
# The pick: index of the highlighted message (or None when the compose line is
|
||||
# focused), plus the body line it rendered on so the frame keeps it in view.
|
||||
self._selected: int | None = None
|
||||
@@ -211,10 +237,17 @@ class ChatScreen(Screen):
|
||||
:meth:`_retry_target`) — the same rules the F-key lane dims its slots by.
|
||||
"""
|
||||
if self._selected is not None:
|
||||
if self._is_channel and self._retry_target() is not None:
|
||||
# A picked scoped message of ours can be resent unscoped, and that key has
|
||||
# to be named; the reply's "(@mention)" and ^End give way to keep the line
|
||||
# inside 72 (Esc still cancels the pick, and Enter still says what it does).
|
||||
return "Enter reply · ^P paths · ^R resend unscoped · ↑↓ pick · Esc cancel"
|
||||
if self._is_channel:
|
||||
return "Enter reply (@mention) · ^P paths · ↑↓ pick · ^End/Esc cancel"
|
||||
return "Enter paths · ↑↓ pick · ^End/Esc cancel"
|
||||
if self._retry_target() is not None:
|
||||
if self._is_channel:
|
||||
return "Enter send · ↑ pick a message · ^R resend unscoped · Esc back"
|
||||
return "Enter send · ↑ pick a message · ^R retry failed · Esc back"
|
||||
return "Enter send · ↑ pick a message · Esc back"
|
||||
|
||||
@@ -496,6 +529,7 @@ class ChatScreen(Screen):
|
||||
if message.is_channel:
|
||||
if message.acked is False:
|
||||
text.append(" ⚠ no ack", style="warn")
|
||||
text.append_text(self._scope_note(message))
|
||||
else:
|
||||
text.append(" ")
|
||||
text.append_text(self._delivery_glyph(message.acked))
|
||||
@@ -503,6 +537,20 @@ class ChatScreen(Screen):
|
||||
text.append(f" {message.snr:+.0f} dB", style=snr_style(message.snr))
|
||||
return text
|
||||
|
||||
def _scope_note(self, message: ChatMessage) -> Text:
|
||||
"""A muted tail on a sent message that went out under a scope other than the title's.
|
||||
|
||||
Only in a channel whose title names a scope, and only for the exceptions to it — an
|
||||
unscoped resend, or a message sent before the scope was set or changed. Every other
|
||||
line would repeat the title; and in a channel with no scope of its own, the default
|
||||
its messages went under is the device's business, not news on every line.
|
||||
"""
|
||||
if not self._scope or not message.scope or message.scope == self._scope:
|
||||
return Text()
|
||||
if message.scope == WILDCARD:
|
||||
return Text(" · unscoped", style="muted")
|
||||
return Text(f" · scope {message.scope}", style="muted")
|
||||
|
||||
def _render_mentions(self, body: str, *, selected: bool) -> Text:
|
||||
"""Render body text, rewriting each ``@[Name]`` token to a ``@Name`` in its hue.
|
||||
|
||||
@@ -625,7 +673,9 @@ class ChatScreen(Screen):
|
||||
elif action == "paths":
|
||||
self._open_paths(self._selected)
|
||||
elif action == "retry":
|
||||
if not self._is_channel:
|
||||
if self._is_channel:
|
||||
self._begin_resend_unscoped()
|
||||
else:
|
||||
self._retry()
|
||||
elif action == "escape":
|
||||
if self._selected is not None:
|
||||
@@ -905,10 +955,31 @@ class ChatScreen(Screen):
|
||||
def _retry_target(self) -> ChatMessage | None:
|
||||
"""The message ^R would re-send, or ``None`` when there is nothing to retry.
|
||||
|
||||
The newest outbound direct message that went out and was never acknowledged —
|
||||
and only while a retry could actually start: no send already in flight, a resend
|
||||
path wired (channels have none, since a channel message is never acked).
|
||||
In a direct chat, the newest outbound message that went out and was never
|
||||
acknowledged. In a channel — where nothing is ever acknowledged — a message *we*
|
||||
sent under a region, which ^R sends again unscoped: the **picked** one when a
|
||||
message is picked (the reader pointed at it), else the newest we sent. With a pick,
|
||||
only the pick: a picked message that isn't a scoped one of ours offers nothing,
|
||||
rather than ^R quietly reaching past it to another. With none, only the newest —
|
||||
once its unscoped copy has gone that copy is the newest, and nothing is left to
|
||||
offer. Either way only while a resend could actually start — no send already in
|
||||
flight, and a resend path wired.
|
||||
"""
|
||||
if self._is_channel:
|
||||
if self._sending or self._resend_unscoped is None:
|
||||
return None
|
||||
if self._selected is not None:
|
||||
candidate: ChatMessage | None = self._messages[self._selected]
|
||||
else:
|
||||
candidate = next((m for m in reversed(self._messages) if m.outbound), None)
|
||||
if (
|
||||
candidate is None
|
||||
or not candidate.outbound
|
||||
or not candidate.scope
|
||||
or candidate.scope == WILDCARD
|
||||
):
|
||||
return None
|
||||
return candidate
|
||||
if self._sending or self._resend is None:
|
||||
return None
|
||||
return next(
|
||||
@@ -928,6 +999,61 @@ class ChatScreen(Screen):
|
||||
self._session.invalidate()
|
||||
self._spawn(self._resend_message(target))
|
||||
|
||||
def _begin_resend_unscoped(self) -> None:
|
||||
"""Confirm, then send a scoped channel message again unscoped (^R).
|
||||
|
||||
Amber (``danger``): nothing is lost, but an unscoped flood is relayed by every
|
||||
repeater that allows one — the whole mesh the scope was keeping the message out of —
|
||||
so it is a choice made on purpose, with the region it is leaving named. The resend
|
||||
is a new message on the air (a channel message has no identity to re-deliver), so
|
||||
it appends as one; a radio that can't send unscoped refuses before anything goes
|
||||
out, and the status line says why.
|
||||
"""
|
||||
target = self._retry_target()
|
||||
if target is None or self._resend_open:
|
||||
return
|
||||
self._resend_open = True
|
||||
preview = target.text if len(target.text) <= 32 else target.text[:31] + "…"
|
||||
|
||||
async def run() -> None:
|
||||
try:
|
||||
confirmed = await self._session.button_dialog(
|
||||
Text(
|
||||
f"Send “{preview}” again, unscoped? Every repeater that relays "
|
||||
f"unscoped floods will carry it, not only those in {target.scope}.",
|
||||
style="warn",
|
||||
),
|
||||
[("Cancel", False), ("Resend", True)],
|
||||
title="Resend unscoped",
|
||||
default=1,
|
||||
border_style="warn",
|
||||
footer_hint="←→ choose · Enter select · Esc cancel",
|
||||
)
|
||||
finally:
|
||||
self._resend_open = False
|
||||
if not confirmed or self._sending or self._resend_unscoped is None:
|
||||
self._session.invalidate()
|
||||
return
|
||||
self._sending = True
|
||||
self._status = "resending unscoped…"
|
||||
# The resend lands at the tail as a new message; the pick that chose it is done.
|
||||
self._clear_selection()
|
||||
self._stick = True
|
||||
self._session.invalidate()
|
||||
try:
|
||||
message = await self._resend_unscoped(target)
|
||||
if message is not None:
|
||||
self._messages.append(message)
|
||||
self._status = ""
|
||||
except Exception as exc: # noqa: BLE001 - report inline, keep the chat alive
|
||||
self._status = f"resend failed: {exc}"
|
||||
finally:
|
||||
self._sending = False
|
||||
self._stick = True
|
||||
self._session.invalidate()
|
||||
|
||||
self._spawn(run())
|
||||
|
||||
async def _resend_message(self, message: ChatMessage) -> None:
|
||||
"""Drive a retry to completion, refreshing the message's delivery state in place."""
|
||||
assert self._resend is not None
|
||||
@@ -1010,7 +1136,18 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
|
||||
return await _with_restore(ctx, lambda: ctx.chat.send_direct(conversation.contact, text))
|
||||
|
||||
resend: Callable[[ChatMessage], Awaitable[ChatMessage]] | None = None
|
||||
if not conversation.is_channel:
|
||||
resend_unscoped: Callable[[ChatMessage], Awaitable[ChatMessage | None]] | None = None
|
||||
scope: str | None = None
|
||||
if conversation.is_channel:
|
||||
scope = ctx.chat.channel_scope(conversation.channel_id)
|
||||
|
||||
async def resend_unscoped(message: ChatMessage) -> ChatMessage | None:
|
||||
assert conversation.channel_idx is not None
|
||||
return await ctx.chat.send_channel(
|
||||
conversation.channel_idx, message.text, label=conversation.label, scope=WILDCARD
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
async def resend(message: ChatMessage) -> ChatMessage:
|
||||
assert conversation.contact is not None
|
||||
@@ -1028,6 +1165,8 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
|
||||
resend=resend,
|
||||
paths=paths,
|
||||
key_of=key_of,
|
||||
scope=scope,
|
||||
resend_unscoped=resend_unscoped,
|
||||
)
|
||||
ctx.chat.set_active(conversation.key)
|
||||
|
||||
@@ -1152,6 +1291,49 @@ def _belongs(message: Message, conversation: Conversation) -> bool:
|
||||
# --- message paths (the ^P view) -----------------------------------------------------
|
||||
|
||||
|
||||
def _sent_scope_line(ctx: AppContext, message: ChatMessage, *, relayed: bool) -> Text | None:
|
||||
"""What a message we sent went out under, for the paths dialog — and why it may be lost.
|
||||
|
||||
Built from the scope recorded on the message at send time (:attr:`ChatMessage.scope`),
|
||||
never from the channel's scope now, which may have moved on since. When a scoped
|
||||
message has no relayed copy on record *and* no repeater was ever heard here to carry
|
||||
its region (:meth:`~meshterm.core.region_store.RegionStore.carriers`), the likeliest
|
||||
reason is stated plainly: a scoped flood is relayed only by repeaters carrying its
|
||||
region, and nothing known in earshot does.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context (for the region store).
|
||||
message: The message whose paths are open.
|
||||
relayed: Whether any copy of it was heard coming back.
|
||||
|
||||
Returns:
|
||||
The line, or ``None`` for a message with no recorded scope (inbound, direct, or
|
||||
sent before scopes were recorded).
|
||||
"""
|
||||
from ..core.regions import UNSCOPED, Scope
|
||||
from .widgets import scope_text
|
||||
|
||||
if not message.outbound or not message.scope:
|
||||
return None
|
||||
line = Text("sent ", style="muted")
|
||||
if message.scope == WILDCARD:
|
||||
line.append_text(scope_text(UNSCOPED))
|
||||
return line
|
||||
line.append("under ", style="muted")
|
||||
line.append_text(scope_text(Scope("scoped", message.scope)))
|
||||
store = getattr(ctx, "region_store", None)
|
||||
carriers = len(store.carriers(message.scope)) if store is not None else 0
|
||||
if not relayed and not carriers:
|
||||
line.append(" — no repeater known here carries it", style="warn")
|
||||
elif carriers:
|
||||
line.append(
|
||||
f" · {carriers} known repeater{'s' if carriers != 1 else ''} carr"
|
||||
f"{'y' if carriers != 1 else 'ies'} it",
|
||||
style="muted",
|
||||
)
|
||||
return line
|
||||
|
||||
|
||||
async def _make_paths_presenter(
|
||||
ctx: AppContext,
|
||||
conversation: Conversation,
|
||||
@@ -1180,6 +1362,7 @@ async def _make_paths_presenter(
|
||||
collapse,
|
||||
direct_arrivals,
|
||||
distinct_paths,
|
||||
message_scope,
|
||||
)
|
||||
from .message_paths_screen import MessagePathsScreen
|
||||
|
||||
@@ -1289,6 +1472,8 @@ async def _make_paths_presenter(
|
||||
destination=destination or None,
|
||||
type_of=type_of,
|
||||
key_of=key_of,
|
||||
scope=message_scope(arrivals, ctx.region_store.scope_of),
|
||||
sent_scope=_sent_scope_line(ctx, message, relayed=bool(arrivals)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+41
-139
@@ -9,10 +9,10 @@ radio in your hand reads exactly like configuring one over the mesh.
|
||||
|
||||
* **Settings** — every value the companion firmware lets an app read and write (see
|
||||
:data:`~meshterm.core.device_config.DEVICE_SETTINGS`), each custom variable the device
|
||||
reports, and MeshTerm's own background-advert cadences. Editing a row *stages* the new
|
||||
value and nothing touches the radio until *Apply*, which sends the staged values one at a
|
||||
time and stays on the page: one the device refuses stays staged with its reason, as on
|
||||
the remote page. Backing out with changes staged asks before discarding them.
|
||||
reports. Editing a row *stages* the new value and nothing touches the radio until
|
||||
*Apply*, which sends the staged values one at a time and stays on the page: one the
|
||||
device refuses stays staged with its reason, as on the remote page. Backing out with
|
||||
changes staged asks before discarding them.
|
||||
* **Actions** — the operations on the box itself rather than on a value: re-read, clock
|
||||
sync, backup/restore, the identity key, reboot, and factory reset. These *run
|
||||
immediately*, each behind its own confirmation (destructive ones behind typing a word),
|
||||
@@ -42,13 +42,6 @@ from rich.markup import escape
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from ..core.advert_store import (
|
||||
DIRECT_CADENCE_HOURS,
|
||||
FLOOD_CADENCE_HOURS,
|
||||
OFF,
|
||||
AdvertPolicy,
|
||||
cadence_label,
|
||||
)
|
||||
from ..core.device_config import (
|
||||
DEVICE_SETTINGS,
|
||||
DeviceConfigError,
|
||||
@@ -89,8 +82,6 @@ _PRESETS = "__presets__"
|
||||
_CUSTOM = "__custom__"
|
||||
#: Prefix of a custom variable's row value; the variable's name follows it.
|
||||
_CUSTOM_VAR = "__custom_var__:"
|
||||
_ADVERT_DIRECT = "__advert_direct__"
|
||||
_ADVERT_FLOOD = "__advert_flood__"
|
||||
_READ = "__read__"
|
||||
_SYNC_CLOCK = "__sync_clock__"
|
||||
_REBOOT = "__reboot__"
|
||||
@@ -106,13 +97,11 @@ _OTHER = "__other__"
|
||||
#: The setting keys the map pick sets together (each also has its own row, for a typed value).
|
||||
_COORD_KEYS = ("adv_lat", "adv_lon")
|
||||
|
||||
#: How long the reboot flow waits to *observe* the link actually dropping before handing
|
||||
#: off to the session's reconnect dialog (seconds). A companion normally vanishes from the
|
||||
#: bus well within this; on timeout we hand off anyway.
|
||||
_REBOOT_DROP_TIMEOUT_S = 10.0
|
||||
|
||||
#: Poll cadence while waiting for the rebooting companion's link to drop (seconds).
|
||||
_REBOOT_DROP_POLL_S = 0.25
|
||||
#: How long the reboot flow holds the actions screen after announcing the reboot, waiting
|
||||
#: for the session's disconnect watcher to cancel it and raise the reconnect dialog
|
||||
#: (seconds). The watcher wakes on the announcement itself, so this only runs out where
|
||||
#: no watcher is running.
|
||||
_REBOOT_HANDOFF_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
async def cached_snapshot(ctx: AppContext, device: Device) -> dict:
|
||||
@@ -132,7 +121,11 @@ async def cached_snapshot(ctx: AppContext, device: Device) -> dict:
|
||||
path_hash_mode = await ctx.devstate.path_hash_mode()
|
||||
except Exception: # noqa: BLE001 - fall back to the raw reads below
|
||||
pass
|
||||
return await build_snapshot(device, self_info=self_info, path_hash_mode=path_hash_mode)
|
||||
from ..tools.config import learn_default_scope
|
||||
|
||||
snapshot = await build_snapshot(device, self_info=self_info, path_hash_mode=path_hash_mode)
|
||||
learn_default_scope(ctx, snapshot)
|
||||
return snapshot
|
||||
|
||||
|
||||
async def edit_config(ctx: AppContext) -> dict[str, Any] | None:
|
||||
@@ -158,15 +151,11 @@ async def edit_config(ctx: AppContext) -> dict[str, Any] | None:
|
||||
device = await ctx.device()
|
||||
snapshot = await cached_snapshot(ctx, device)
|
||||
custom = await device.get_custom_vars()
|
||||
# The background-advert cadences are app-side settings (MeshTerm sends the adverts,
|
||||
# not the firmware), but they're staged and applied exactly like device values so
|
||||
# the page stays one coherent surface.
|
||||
policy = ctx.advert_store.load(str(snapshot.get("public_key") or ""))
|
||||
|
||||
session = getattr(ctx.ui, "session", None)
|
||||
if session is None: # pragma: no cover - guarded by the menu-only caller
|
||||
raise RuntimeError("the config editor is only available in the menu")
|
||||
pending: dict[str, Any] = {} # setting key (or cadence sentinel) -> staged new value
|
||||
pending: dict[str, Any] = {} # setting key -> staged new value
|
||||
custom_pending: dict[str, str] = {} # custom variable name -> staged new value
|
||||
applied = 0
|
||||
|
||||
@@ -176,15 +165,14 @@ async def edit_config(ctx: AppContext) -> dict[str, Any] | None:
|
||||
def summary() -> dict[str, Any] | None:
|
||||
return {"applied": applied} if applied else None
|
||||
|
||||
# The rows read ``snapshot``, ``custom`` and ``policy`` through this closure, so a
|
||||
# re-read that rebinds them is exactly what the next refresh draws.
|
||||
# The rows read ``snapshot`` and ``custom`` through this closure, so a re-read that
|
||||
# rebinds them is exactly what the next refresh draws.
|
||||
menu = _ConfigMenu(
|
||||
session,
|
||||
lambda reveal: _menu_items(
|
||||
snapshot,
|
||||
pending,
|
||||
staged(),
|
||||
policy,
|
||||
reveal,
|
||||
custom=custom,
|
||||
custom_pending=custom_pending,
|
||||
@@ -205,7 +193,6 @@ async def edit_config(ctx: AppContext) -> dict[str, Any] | None:
|
||||
if choice == _APPLY:
|
||||
applied += await _apply(ctx, device, snapshot, pending, custom_pending)
|
||||
snapshot, custom = await _reread(ctx, device)
|
||||
policy = ctx.advert_store.load(str(snapshot.get("public_key") or ""))
|
||||
elif choice == _READ:
|
||||
snapshot, custom = await _reread(ctx, device)
|
||||
_unstage_held(snapshot, custom, pending, custom_pending)
|
||||
@@ -236,8 +223,6 @@ async def edit_config(ctx: AppContext) -> dict[str, Any] | None:
|
||||
pending.clear()
|
||||
custom_pending.clear()
|
||||
snapshot, custom = await _reread(ctx, device)
|
||||
elif choice in (_ADVERT_DIRECT, _ADVERT_FLOOD):
|
||||
await _stage_advert_cadence(ctx, choice == _ADVERT_FLOOD, policy, pending)
|
||||
elif choice == _LOCATION:
|
||||
await _stage_location(ctx, snapshot, pending)
|
||||
elif choice == _PRESETS:
|
||||
@@ -578,7 +563,6 @@ def _menu_items(
|
||||
snapshot: dict,
|
||||
pending: dict,
|
||||
staged: int,
|
||||
policy: AdvertPolicy,
|
||||
reveal_pin: bool = False,
|
||||
*,
|
||||
custom: dict[str, str] | None = None,
|
||||
@@ -588,9 +572,9 @@ def _menu_items(
|
||||
|
||||
The settings sit in three aligned lanes — setting, current value (and any staged new
|
||||
value), description — under one header line, grouped by category; the device's custom
|
||||
variables and MeshTerm's own advert cadences follow in the same lanes; and the
|
||||
**Actions** close the list. The same shape as the repeater-admin page (see
|
||||
:func:`meshterm.ui.repeater_admin._menu_items`), so the two read as one editor.
|
||||
variables follow in the same lanes; and the **Actions** close the list. The same shape
|
||||
as the repeater-admin page (see :func:`meshterm.ui.repeater_admin._menu_items`), so
|
||||
the two read as one editor.
|
||||
|
||||
``reveal_pin`` is :class:`_ConfigMenu`'s toggle, passed straight through to the PIN's
|
||||
value lane. ``custom`` holds the variables the device reports and ``custom_pending``
|
||||
@@ -638,29 +622,6 @@ def _menu_items(
|
||||
var_rows.append(("Set by name…", Text(), "Set a raw firmware variable by name", _CUSTOM))
|
||||
sections.append(("Custom variables", var_rows))
|
||||
|
||||
# App-side rows: the background-advert cadences MeshTerm itself runs (see the
|
||||
# advert scheduler). They stage and apply like device settings, so they sit in the
|
||||
# same lanes under their own heading.
|
||||
sections.append(
|
||||
(
|
||||
"Background adverts",
|
||||
[
|
||||
(
|
||||
"Direct advert",
|
||||
_cadence_value(policy, False, pending),
|
||||
"Scheduled zero-hop announce; any manual send resets it",
|
||||
_ADVERT_DIRECT,
|
||||
),
|
||||
(
|
||||
"Flood advert",
|
||||
_cadence_value(policy, True, pending),
|
||||
"Scheduled mesh-wide announce via repeaters",
|
||||
_ADVERT_FLOOD,
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
label_w = max(cell_len(label) for _, rows in sections for label, _, _, _ in rows)
|
||||
value_w = max(cell_len(value.plain) for _, rows in sections for _, value, _, _ in rows)
|
||||
|
||||
@@ -715,15 +676,6 @@ def _menu_items(
|
||||
return title, items
|
||||
|
||||
|
||||
def _cadence_value(policy: AdvertPolicy, flood: bool, pending: dict) -> Text:
|
||||
"""One background-advert row's VALUE lane: ``current [→ staged]``."""
|
||||
key = _ADVERT_FLOOD if flood else _ADVERT_DIRECT
|
||||
value = Text(cadence_label(policy.cadence(flood)))
|
||||
if key in pending:
|
||||
value.append(f" → {cadence_label(pending[key])}", style="warn")
|
||||
return value
|
||||
|
||||
|
||||
# --- staging individual changes ----------------------------------------------
|
||||
|
||||
|
||||
@@ -820,44 +772,6 @@ async def _prompt_value(ctx: AppContext, spec: SettingSpec, current: Any, snapsh
|
||||
return None if raw is None else parse_value(spec, raw, snapshot)
|
||||
|
||||
|
||||
async def _stage_advert_cadence(
|
||||
ctx: AppContext, flood: bool, policy: AdvertPolicy, pending: dict[str, Any]
|
||||
) -> None:
|
||||
"""Pick one background-advert type's cadence and stage it.
|
||||
|
||||
Staged under the type's sentinel key; Apply turns it into an ``advert_cadence`` op
|
||||
(see :func:`~meshterm.tools.config.apply_ops`). Picking the value already in force
|
||||
un-stages the row, matching :func:`_stage_setting`.
|
||||
"""
|
||||
key = _ADVERT_FLOOD if flood else _ADVERT_DIRECT
|
||||
in_force = policy.cadence(flood)
|
||||
current = pending.get(key, in_force)
|
||||
hours_choices = FLOOD_CADENCE_HOURS if flood else DIRECT_CADENCE_HOURS
|
||||
items: list = [
|
||||
Choice(
|
||||
title=cadence_label(hours).capitalize() + (" (current)" if hours == current else ""),
|
||||
value=hours,
|
||||
)
|
||||
for hours in (*hours_choices, OFF)
|
||||
]
|
||||
if flood:
|
||||
prompt = "How often repeaters rebroadcast this node across the mesh:"
|
||||
else:
|
||||
prompt = "How often this node announces itself to neighbours in range:"
|
||||
selected = await ctx.ui.select(
|
||||
"Flood advert" if flood else "Direct advert",
|
||||
items,
|
||||
prompt=prompt,
|
||||
default=current,
|
||||
)
|
||||
if selected is None:
|
||||
return
|
||||
if selected == in_force:
|
||||
pending.pop(key, None) # set back to the value in force — nothing to change
|
||||
else:
|
||||
pending[key] = selected
|
||||
|
||||
|
||||
async def _stage_location(ctx: AppContext, snapshot: dict, pending: dict[str, Any]) -> None:
|
||||
"""Pick the advertised location on the map and stage both coordinates it sets.
|
||||
|
||||
@@ -1042,27 +956,18 @@ def _staged_ops(pending: dict[str, Any], custom_pending: dict[str, str]) -> list
|
||||
"""The staged values as ``(stage key, op)`` pairs, in the order they should be sent.
|
||||
|
||||
Settings go in the registry's order rather than the order they were staged in, so a
|
||||
retuned frequency lands before relaying is switched on for it; the advert cadences and
|
||||
the custom variables follow.
|
||||
retuned frequency lands before relaying is switched on for it; the custom variables
|
||||
follow.
|
||||
"""
|
||||
order = {spec.key: i for i, spec in enumerate(DEVICE_SETTINGS)}
|
||||
ops = [
|
||||
(key, ("set", key, pending[key]))
|
||||
for key in sorted((k for k in pending if k in order), key=order.__getitem__)
|
||||
]
|
||||
ops += [
|
||||
(key, ("advert_cadence", key == _ADVERT_FLOOD, pending[key]))
|
||||
for key in (_ADVERT_DIRECT, _ADVERT_FLOOD)
|
||||
if key in pending
|
||||
]
|
||||
ops += [(name, ("set_custom", name, value)) for name, value in custom_pending.items()]
|
||||
return ops
|
||||
|
||||
|
||||
#: What the result window calls a staged value whose stage key is a sentinel.
|
||||
_STAGE_NAMES = {_ADVERT_DIRECT: "direct advert", _ADVERT_FLOOD: "flood advert"}
|
||||
|
||||
|
||||
async def _apply(
|
||||
ctx: AppContext,
|
||||
device: Device,
|
||||
@@ -1087,7 +992,7 @@ async def _apply(
|
||||
from ..tools.config import apply_ops
|
||||
|
||||
batch = _staged_ops(pending, custom_pending)
|
||||
names = [_STAGE_NAMES.get(key, key) for key, _op in batch]
|
||||
names = [key for key, _op in batch]
|
||||
run_id = ctx.repo.start_run("config", {"mode": "apply", "settings": names}, ctx.profile_name)
|
||||
accepted = 0
|
||||
for (key, op), name in zip(batch, names, strict=True):
|
||||
@@ -1121,10 +1026,13 @@ async def _reread(ctx: AppContext, device: Device) -> tuple[dict, dict[str, str]
|
||||
Raw rather than through the session cache, which is exactly what just went stale — and
|
||||
the cache is dropped on the way, so the next screen that trusts it reads the truth too.
|
||||
"""
|
||||
from ..tools.config import learn_default_scope
|
||||
|
||||
ctx.devstate.invalidate_config()
|
||||
async with ctx.ui.busy_overlay():
|
||||
snapshot = await build_snapshot(device)
|
||||
custom = await device.get_custom_vars()
|
||||
learn_default_scope(ctx, snapshot)
|
||||
return snapshot, custom
|
||||
|
||||
|
||||
@@ -1132,7 +1040,7 @@ def _unstage_held(
|
||||
snapshot: dict, custom: dict[str, str], pending: dict[str, Any], custom_pending: dict[str, str]
|
||||
) -> None:
|
||||
"""Unstage every value the device turns out to hold already — a re-read can settle one."""
|
||||
for key in [k for k in pending if k not in _STAGE_NAMES]:
|
||||
for key in list(pending):
|
||||
if get_spec(key).getter(snapshot) == pending[key]:
|
||||
del pending[key]
|
||||
for name in [n for n, value in custom_pending.items() if custom.get(n) == value]:
|
||||
@@ -1272,10 +1180,11 @@ async def _show_contact_card(ctx: AppContext, snapshot: dict) -> None:
|
||||
async def _reboot(ctx: AppContext, device: Device, snapshot: dict) -> bool:
|
||||
"""Confirm and reboot the device, handing off to the session's reconnect dialog.
|
||||
|
||||
After the command is sent, we wait to actually observe the link dropping — flagging
|
||||
:attr:`~meshterm.context.AppContext.reboot_in_progress` so the session-wide
|
||||
disconnect watcher labels the ensuing dialog as a reboot, waits for the companion to
|
||||
come back, and reconnects — exactly the unplugged-device flow.
|
||||
Once the command is sent the link is declared down
|
||||
(:meth:`~meshterm.context.AppContext.announce_reboot`) rather than waited on, so the
|
||||
session-wide disconnect watcher raises its dialog at once, labelled as a reboot, waits
|
||||
for the companion to come back, and reconnects — exactly the unplugged-device flow,
|
||||
and nothing else is sent to the board while it restarts.
|
||||
|
||||
Returns:
|
||||
``True`` if the reboot was sent and the actions screen should close; ``False``
|
||||
@@ -1298,22 +1207,15 @@ async def _reboot(ctx: AppContext, device: Device, snapshot: dict) -> bool:
|
||||
await ctx.ui.present(title="Reboot")
|
||||
return False
|
||||
|
||||
# Flag the drop as expected *before* sending, so however quickly the watcher fires,
|
||||
# the reconnect dialog already knows to present it as a reboot.
|
||||
ctx.reboot_in_progress = True
|
||||
try:
|
||||
await device.reboot()
|
||||
except Exception:
|
||||
ctx.reboot_in_progress = False
|
||||
raise
|
||||
# Hold here until the link is actually observed down (or a generous timeout), so the
|
||||
# actions screen doesn't flash back to the menu for the second or two before the
|
||||
# watcher notices. The watcher may cancel us mid-wait when it fires — that's the handoff.
|
||||
deadline = asyncio.get_running_loop().time() + _REBOOT_DROP_TIMEOUT_S
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
if not await ctx.link_alive():
|
||||
break
|
||||
await asyncio.sleep(_REBOOT_DROP_POLL_S)
|
||||
await device.reboot()
|
||||
# The command is out, so the link is as good as gone: declare it, rather than wait for
|
||||
# a liveness poll to notice — a quick reboot behind a port that never disappears is
|
||||
# never noticed at all, and the next command would go to a board mid-restart.
|
||||
ctx.announce_reboot()
|
||||
# The session's watcher now cancels us and raises the reconnect dialog; hold here until
|
||||
# it does, so the actions screen doesn't flash back to the menu in between. The timeout
|
||||
# only matters where no watcher is running to take the handoff.
|
||||
await asyncio.sleep(_REBOOT_HANDOFF_TIMEOUT_S)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -98,9 +98,10 @@ class ContactsScreen(ContactListScreen):
|
||||
span :data:`~meshterm.ui.contactlist.SORT_COLUMNS` so the hash sort is
|
||||
reachable.
|
||||
archived: How many contacts are archived off this device. Non-zero adds the
|
||||
``View archived contacts`` row and shows the count on it, so the tally is
|
||||
visible without opening the list; zero draws no row, since a screen should
|
||||
not offer a way into an empty list.
|
||||
``View archived contacts`` row and shows the count on it and in the title
|
||||
(``Contacts · 198 known + 123 archived``), so the tally is visible without
|
||||
opening the list; zero draws no row and leaves the title at ``… known``,
|
||||
since a screen should not offer a way into an empty list.
|
||||
locked: The full keys (lowercase hex) of the contacts locked against archiving;
|
||||
each of those rows closes its name lane on a padlock.
|
||||
"""
|
||||
@@ -132,13 +133,22 @@ class ContactsScreen(ContactListScreen):
|
||||
label = marked_label("📂", "View archived contacts", "", lane=lane)
|
||||
label.append(f" · {archived}", style="muted")
|
||||
tail.append(Choice(title=label, value=_ARCHIVED))
|
||||
# The title counts both halves of what this device's contacts amount to: those on it
|
||||
# and those a sweep moved off it. Where the frame is too narrow for the words, the
|
||||
# figures stay and the words go (``Contacts · 198 + 123``).
|
||||
known = len(contacts)
|
||||
title = f"Contacts · {known} known"
|
||||
if archived:
|
||||
title += f" + {archived} archived"
|
||||
super().__init__(
|
||||
f"Contacts · {len(contacts)} known",
|
||||
title,
|
||||
rows=rows,
|
||||
prefix_bytes=prefix_bytes,
|
||||
sort=sort,
|
||||
tail=tail,
|
||||
)
|
||||
if archived:
|
||||
self.short_title = f"Contacts · {known} + {archived}"
|
||||
|
||||
def _contact_rows_for(self, locked: frozenset[str]) -> list[ContactRow]:
|
||||
"""Our own node, then every contact, each marked locked or not by ``locked``."""
|
||||
|
||||
+177
-59
@@ -12,15 +12,21 @@ stacks three reads of the mesh, coarsest first:
|
||||
drawn from the same :meth:`~meshterm.services.monitor_service.MonitorService`
|
||||
buckets. A pulse line beneath it reads the rate, who's been heard, and the busiest
|
||||
node of the window.
|
||||
* **Traffic** — the session's tallies by packet class, each with its icon and a
|
||||
proportional bar, from the monitor's kind counters — the raw ``packet`` bucket
|
||||
broken out by payload class (channel text, trace, path, …), so every known frame
|
||||
class shows once heard.
|
||||
* **Traffic** — the frames heard on the air, tallied by class (chan text, trace,
|
||||
path, …) under the names and icons the live feed gives them, each with a
|
||||
proportional bar. Every frame counts once: what the device *reported* about a frame
|
||||
(its advert event, a decoded message) is not counted a second time beside it.
|
||||
* **RF health** — the trailing window's reception quality: median SNR (on the trace
|
||||
tool's quality bar) and RSSI from stored observations, plus the radio's own live
|
||||
numbers — noise floor, last RSSI/SNR, airtime, battery — polled from the device the
|
||||
way Device info reads them.
|
||||
|
||||
**Scope** narrows the whole screen to one region's floods: ``s`` (the PicoCalc's F3 chip)
|
||||
cycles every view — all, unscoped, each region heard, unknown scopes — as the Time
|
||||
Machine's ``w`` cycles its windows. A narrowed view reads the screen's own trailing
|
||||
window (stored + live, 2 h), since a region is named per frame by a cryptographic check
|
||||
the database cannot run; the radio's own numbers stay whole, being the device's.
|
||||
|
||||
The per-packet stream that used to close this screen is now its own tool — the Live
|
||||
feed (see :mod:`meshterm.ui.livefeed_screen`), right under the dashboard in the menu —
|
||||
so this screen is pure overview: it scrolls as one body under the arrow/page keys.
|
||||
@@ -33,9 +39,11 @@ tears them all down when the screen resolves. Esc backs out directly.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections import Counter, deque
|
||||
from collections.abc import Callable
|
||||
from statistics import median
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.table import Table
|
||||
@@ -43,16 +51,13 @@ from rich.text import Text
|
||||
|
||||
from ..core.events import MeshEvent
|
||||
from ..core.models import NODE_TYPE_REPEATER, Observation, utcnow
|
||||
from ..core.regions import Scope
|
||||
from ..persistence.repository import OBSERVATION_WINDOW
|
||||
from ..services.monitor_service import ACTIVITY_BUCKET_S, ACTIVITY_BUCKETS
|
||||
from .braillechart import axis_chart, axis_chrome, axis_label_w, meter, timeline_rows
|
||||
from .packet_viewer import (
|
||||
_PAYLOAD_GLOSS,
|
||||
DEFAULT_ICON,
|
||||
KIND_ICONS,
|
||||
KIND_STYLES,
|
||||
PAYLOAD_ICONS,
|
||||
)
|
||||
from .theme import glyph, snr_style
|
||||
from .packet_viewer import KIND_STYLES, payload_marks
|
||||
from .scopering import ScopeKey, ring, scope_atom, scope_chip, scope_key, step
|
||||
from .theme import snr_style
|
||||
from .trace_screen import snr_bar
|
||||
from .tui.render import render_lines
|
||||
from .tui.screen import Screen
|
||||
@@ -69,13 +74,10 @@ _STATS_POLL_S = 10.0
|
||||
#: How many braille rows tall the activity chart draws (each row is four dot rows).
|
||||
_CHART_ROWS = 3
|
||||
|
||||
#: The order the traffic panel lists its buckets in (heard ones not listed sort last,
|
||||
#: alphabetically): the decoded families first, then the raw ``packet:<TYPENAME>``
|
||||
#: classes expanding in the old lone-``packet`` slot — chat-ish frames before
|
||||
#: protocol-ish ones — with the class-less ``packet`` closing the raw block.
|
||||
#: The order the traffic panel lists its classes in (heard ones not listed sort last,
|
||||
#: alphabetically): chat-ish frames before protocol-ish ones, the class-less ``packet``
|
||||
#: closing the list.
|
||||
_TRAFFIC_ORDER = (
|
||||
"advert",
|
||||
"telemetry",
|
||||
"packet:GRP_TXT",
|
||||
"packet:GRP_DATA",
|
||||
"packet:TEXT_MSG",
|
||||
@@ -89,31 +91,27 @@ _TRAFFIC_ORDER = (
|
||||
"packet:MULTIPART",
|
||||
"packet:CONTROL",
|
||||
"packet",
|
||||
"message",
|
||||
"ack",
|
||||
)
|
||||
|
||||
#: Raw payload glosses that would read identically to a decoded family's row, retold
|
||||
#: so two meters never share a label (an overheard advert frame vs. the advert event).
|
||||
_RAW_GLOSS_CLASH = {"advert": "raw advert", "ack": "raw ack"}
|
||||
|
||||
def _is_frame_bucket(bucket: str) -> bool:
|
||||
"""Whether a tally bucket counts frames heard on the air (``packet[:<TYPENAME>]``).
|
||||
|
||||
The monitor also tallies what the *device* reported — ``advert``, ``telemetry``,
|
||||
``message``, ``ack`` — but each of those is a frame already counted under its own
|
||||
class (the advert event *and* its RX-logged ``ADVERT`` frame), so the traffic panel
|
||||
counts frames only: every packet once, under the name the live feed gives it.
|
||||
"""
|
||||
return bucket == "packet" or bucket.startswith("packet:")
|
||||
|
||||
|
||||
def _traffic_chrome(bucket: str) -> tuple[str, str, str]:
|
||||
"""One traffic bucket's ``(icon, label, meter style)``.
|
||||
def _traffic_chrome(bucket: str) -> tuple[str, str]:
|
||||
"""One traffic bucket's ``(icon, label)`` — the live feed's, from the same function.
|
||||
|
||||
A decoded family keeps its kind icon, name, and colour; a ``packet:<TYPENAME>``
|
||||
bucket takes the payload class's icon and gloss over the calm muted ``packet``
|
||||
meter — colour keeps marking the decoded families, raw overheard stays quiet.
|
||||
Icons are mapped to single glyphs on PICOCALC via :func:`~meshterm.ui.theme.glyph`.
|
||||
"""
|
||||
if bucket.startswith("packet:"):
|
||||
typename = bucket.split(":", 1)[1]
|
||||
gloss = _PAYLOAD_GLOSS.get(typename, typename.lower())
|
||||
label = _RAW_GLOSS_CLASH.get(gloss, gloss)
|
||||
emoji = PAYLOAD_ICONS.get(typename, DEFAULT_ICON)
|
||||
return glyph(emoji), label, KIND_STYLES["packet"]
|
||||
emoji = KIND_ICONS.get(bucket, DEFAULT_ICON)
|
||||
return glyph(emoji), bucket, KIND_STYLES.get(bucket, "brand")
|
||||
typename = bucket.split(":", 1)[1] if ":" in bucket else None
|
||||
return payload_marks(typename)
|
||||
|
||||
|
||||
#: How many character cells a traffic lane's meter spans (48 half-step levels).
|
||||
@@ -124,6 +122,26 @@ _TRAFFIC_METER_CELLS = 24
|
||||
_GRID_LABEL_W = 9
|
||||
|
||||
|
||||
#: The screen's title, before any scope atom.
|
||||
_TITLE = "Dashboard — mesh overview"
|
||||
|
||||
|
||||
class _ScopeDigest(NamedTuple):
|
||||
"""One scope view's numbers over the window, in the shapes the sections draw.
|
||||
|
||||
Attributes:
|
||||
histogram: Frames per minute, newest first, the monitor's bucket shape.
|
||||
counts: Frames by traffic bucket (``packet:<TYPENAME>``).
|
||||
snrs: Every SNR reading among them.
|
||||
rssis: Every RSSI reading among them.
|
||||
"""
|
||||
|
||||
histogram: tuple[int, ...]
|
||||
counts: dict[str, int]
|
||||
snrs: list[float]
|
||||
rssis: list[float]
|
||||
|
||||
|
||||
def _span_label(minutes: int) -> str:
|
||||
"""A compact duration — ``45 min`` under two hours, else ``2.5 h`` / ``3 h``."""
|
||||
if minutes < 120:
|
||||
@@ -147,21 +165,28 @@ class DashboardScreen(Screen):
|
||||
:attr:`~meshterm.ui.tui.screen.Screen.content_overflows`); otherwise Esc is the only
|
||||
key that acts.
|
||||
"""
|
||||
if self.content_overflows:
|
||||
return "↑↓ PgUp/PgDn scroll · Esc back"
|
||||
return "Esc back"
|
||||
atoms = ["↑↓ PgUp/PgDn scroll"] if self.content_overflows else []
|
||||
if len(self._ring()) > 1:
|
||||
atoms.append("s scope")
|
||||
return " · ".join([*atoms, "Esc back"])
|
||||
|
||||
@property
|
||||
def fkey_lane(self):
|
||||
"""The shared lane, dimmed on the same gate the hint above uses.
|
||||
"""The shared lane, dimmed on the same gate the hint above uses, plus the scope cycle.
|
||||
|
||||
The PicoCalc draws no hint line at all — the lane *is* the footer — so the rule
|
||||
the hint follows has to hold there too: an overview that fits whole has nothing
|
||||
for Top/End or the paging pair to move.
|
||||
for Top/End or the paging pair to move. The scope cycle takes F3, as the Time
|
||||
Machine's window cycle does, its chip naming the view a press goes *to* (the title
|
||||
already says where you are); with no flood heard to narrow to, the slot stays empty.
|
||||
"""
|
||||
from .tui.fkeys import default_lane
|
||||
from .tui.fkeys import FPair, default_lane
|
||||
|
||||
return default_lane(nav=self.content_overflows)
|
||||
lane = list(default_lane(nav=self.content_overflows))
|
||||
views = self._ring()
|
||||
if len(views) > 1:
|
||||
lane[2] = FPair(scope_chip(step(views, self._scope)), "scope")
|
||||
return lane
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -172,6 +197,7 @@ class DashboardScreen(Screen):
|
||||
activity: Any,
|
||||
activity_flags: Any,
|
||||
kind_counts: Any,
|
||||
scope_of: Callable[[dict | None], Scope | None] | None = None,
|
||||
) -> None:
|
||||
"""Create the dashboard over its data feeds.
|
||||
|
||||
@@ -183,9 +209,15 @@ class DashboardScreen(Screen):
|
||||
activity_flags: Zero-arg callable returning the histogram's per-bucket
|
||||
this-session flags (seeded history draws grey, live traffic green).
|
||||
kind_counts: Zero-arg callable returning the monitor's kind tallies.
|
||||
scope_of: Names a frame's scope from its raw payload
|
||||
(:meth:`~meshterm.core.region_store.RegionStore.scope_of`); ``None`` offers
|
||||
no scope views.
|
||||
"""
|
||||
super().__init__()
|
||||
self.title = "Dashboard — mesh overview"
|
||||
self._scope_of = scope_of
|
||||
#: The scope view on screen; ``None`` is every packet heard.
|
||||
self._scope: ScopeKey | None = None
|
||||
self._set_title()
|
||||
self._session = session
|
||||
self._resolve = resolve
|
||||
self._activity = activity
|
||||
@@ -209,6 +241,10 @@ class DashboardScreen(Screen):
|
||||
self._win_counts: Counter = Counter()
|
||||
self._win_snrs: list[float] = []
|
||||
self._win_rssis: list[float] = []
|
||||
#: The window's frames that have a scope, each with the view it counts in.
|
||||
self._win_scoped: list[tuple[ScopeKey, Observation]] = []
|
||||
#: The narrowed view's numbers and what they were computed for — see :meth:`_scoped`.
|
||||
self._scoped_memo: tuple[tuple, _ScopeDigest] | None = None
|
||||
#: The last activity chart and the inputs it was drawn from — see
|
||||
#: :meth:`_activity_section`.
|
||||
self._chart_memo: tuple[tuple, list[RenderableType]] | None = None
|
||||
@@ -247,9 +283,63 @@ class DashboardScreen(Screen):
|
||||
self.scroll_to_top()
|
||||
elif action in ("end", "ctrl_end"):
|
||||
self.scroll_to_bottom()
|
||||
elif action == "scope" or (action == "text" and data.lower() == "s"):
|
||||
self._cycle_scope()
|
||||
elif action == "escape":
|
||||
self.resolve(None)
|
||||
|
||||
# --- scope -----------------------------------------------------------------------
|
||||
|
||||
def _ring(self) -> list[ScopeKey | None]:
|
||||
"""Every view on offer: all, then each scope heard in the window."""
|
||||
self._digest_window()
|
||||
return ring((key for key, _ in self._win_scoped), self._scope)
|
||||
|
||||
def _cycle_scope(self) -> None:
|
||||
"""Step to the next view (see :func:`~meshterm.ui.scopering.step`)."""
|
||||
views = self._ring()
|
||||
if len(views) < 2:
|
||||
return
|
||||
self._scope = step(views, self._scope)
|
||||
self._set_title()
|
||||
self.scroll_to_top()
|
||||
self._session.invalidate()
|
||||
|
||||
def _set_title(self) -> None:
|
||||
"""Title the screen for its view; a narrowed one says which, tightened when narrow."""
|
||||
if self._scope is None:
|
||||
self.title, self.short_title = _TITLE, ""
|
||||
return
|
||||
atom = scope_atom(self._scope)
|
||||
self.title = f"{_TITLE} · {atom}"
|
||||
self.short_title = f"Dashboard · {atom}"
|
||||
|
||||
def _scoped(self) -> _ScopeDigest:
|
||||
"""The narrowed view's numbers, recomputed only when the window, view or minute moves."""
|
||||
minute = int(time.time() // ACTIVITY_BUCKET_S)
|
||||
memo_key = (self._window_rev, self._scope, minute)
|
||||
if self._scoped_memo is not None and self._scoped_memo[0] == memo_key:
|
||||
return self._scoped_memo[1]
|
||||
histogram = [0] * ACTIVITY_BUCKETS
|
||||
counts: Counter = Counter()
|
||||
snrs: list[float] = []
|
||||
rssis: list[float] = []
|
||||
for key, o in self._win_scoped:
|
||||
if key != self._scope:
|
||||
continue
|
||||
age = minute - int(o.observed_at.timestamp() // ACTIVITY_BUCKET_S)
|
||||
if 0 <= age < ACTIVITY_BUCKETS:
|
||||
histogram[age] += 1
|
||||
typename = (o.raw or {}).get("payload_typename")
|
||||
counts[f"packet:{typename}" if typename else "packet"] += 1
|
||||
if o.snr is not None:
|
||||
snrs.append(o.snr)
|
||||
if o.rssi is not None:
|
||||
rssis.append(o.rssi)
|
||||
digest = _ScopeDigest(tuple(histogram), dict(counts), snrs, rssis)
|
||||
self._scoped_memo = (memo_key, digest)
|
||||
return digest
|
||||
|
||||
# --- rendering ---------------------------------------------------------------------
|
||||
|
||||
def render_body(self, width: int) -> list[str]:
|
||||
@@ -290,10 +380,15 @@ class DashboardScreen(Screen):
|
||||
counts: Counter = Counter()
|
||||
snrs: list[float] = []
|
||||
rssis: list[float] = []
|
||||
scoped: list[tuple[ScopeKey, Observation]] = []
|
||||
for o in self._window:
|
||||
if o.node and o.node_type == NODE_TYPE_REPEATER:
|
||||
repeaters.add(o.node)
|
||||
if o.kind == "packet":
|
||||
# The region store memoizes by frame, so a frame is checked once per visit.
|
||||
key = scope_key(self._scope_of(o.raw)) if self._scope_of else None
|
||||
if key is not None:
|
||||
scoped.append((key, o))
|
||||
continue
|
||||
if o.node:
|
||||
nodes.add(o.node)
|
||||
@@ -307,6 +402,7 @@ class DashboardScreen(Screen):
|
||||
self._win_counts = counts
|
||||
self._win_snrs = snrs
|
||||
self._win_rssis = rssis
|
||||
self._win_scoped = scoped
|
||||
|
||||
# -- activity --
|
||||
|
||||
@@ -320,7 +416,8 @@ class DashboardScreen(Screen):
|
||||
terminal simply shows more history. Time runs oldest→now left to right,
|
||||
every MeshTerm timeline's direction.
|
||||
"""
|
||||
histogram = list(self._activity()) # newest first, one count per minute
|
||||
# Newest first, one count per minute.
|
||||
histogram = list(self._scoped().histogram if self._scope else self._activity())
|
||||
# Size the label lane from the whole histogram's peak (not just the visible
|
||||
# slice) so the gutters never shift as a burst scrolls out of view.
|
||||
label_w = axis_label_w(max(histogram, default=0), _CHART_ROWS)
|
||||
@@ -330,7 +427,14 @@ class DashboardScreen(Screen):
|
||||
peak = max(shown)
|
||||
|
||||
heading = Text("Activity", style="accent")
|
||||
heading.append(" · every packet heard · one minute per dot column", style="muted")
|
||||
# A narrowed view spends the aside on what it counts, which the reader has to be
|
||||
# told; the minute per column is on the axis beneath either way.
|
||||
what = (
|
||||
"floods in this scope · last 2 h"
|
||||
if self._scope
|
||||
else "every packet heard · one minute per dot column"
|
||||
)
|
||||
heading.append(f" · {what}", style="muted")
|
||||
# Buckets seeded from a previous session's stored history draw grey; only
|
||||
# what this session heard itself pulses green.
|
||||
flags = (tuple(self._activity_flags()) + (True,) * minutes)[:minutes]
|
||||
@@ -402,6 +506,8 @@ class DashboardScreen(Screen):
|
||||
line = Text()
|
||||
line.append(f"{recent:.1f} pkt/min", style="brand")
|
||||
line.append(f" (15 m) · {overall:.1f} ({span})", style="muted")
|
||||
if self._scope:
|
||||
return line # who was heard is read off adverts, which carry no scope
|
||||
line.append(" · ", style="muted")
|
||||
line.append(str(len(nodes)))
|
||||
suffix = f" node{'s' if len(nodes) != 1 else ''}"
|
||||
@@ -417,7 +523,7 @@ class DashboardScreen(Screen):
|
||||
if len(line.plain) + _GRID_LABEL_W > width:
|
||||
line = compose(heard=False)
|
||||
rows = [("pulse", line)]
|
||||
busiest = self._busiest()
|
||||
busiest = None if self._scope else self._busiest()
|
||||
if busiest is not None:
|
||||
name, count = busiest
|
||||
value = Text(name, style="brand")
|
||||
@@ -439,26 +545,34 @@ class DashboardScreen(Screen):
|
||||
# -- traffic --
|
||||
|
||||
def _traffic_section(self) -> list[RenderableType]:
|
||||
"""Tallies by packet class, each with an icon and a proportional braille meter.
|
||||
"""Frames heard on the air, by class, each with an icon and a proportional meter.
|
||||
|
||||
Every known class shows once heard — the raw ``packet`` bucket is broken out
|
||||
by payload class (channel text, trace, path, …) instead of lumping every
|
||||
overheard frame together; classes never heard stay hidden.
|
||||
Every class shows once heard, named as the live feed names it; classes never
|
||||
heard stay hidden. Every bar is the one colour the feed draws a frame's class in
|
||||
(``packet``'s): the icon and the label say which class a row is, so colour has
|
||||
nothing left to say here.
|
||||
"""
|
||||
heading = Text("Traffic", style="accent")
|
||||
heading.append(" · by packet class · stored history + live", style="muted")
|
||||
counts = self._kind_counts()
|
||||
if self._scope:
|
||||
heading.append(" · by packet class · floods in the last 2 h", style="muted")
|
||||
counts = self._scoped().counts
|
||||
empty = "nothing heard in this scope in the last 2 h"
|
||||
else:
|
||||
heading.append(" · by packet class · stored history + live", style="muted")
|
||||
counts = {k: n for k, n in self._kind_counts().items() if _is_frame_bucket(k)}
|
||||
empty = "nothing heard yet"
|
||||
if not counts:
|
||||
return [heading, Text("nothing heard yet", style="muted")]
|
||||
return [heading, Text(empty, style="muted")]
|
||||
order = {k: i for i, k in enumerate(_TRAFFIC_ORDER)}
|
||||
peak = max(counts.values())
|
||||
chrome = {bucket: _traffic_chrome(bucket) for bucket in counts}
|
||||
label_w = max(len(label) for _, label, _ in chrome.values())
|
||||
label_w = max(len(label) for _, label in chrome.values())
|
||||
count_w = len(str(peak))
|
||||
style = KIND_STYLES["packet"]
|
||||
rows: list[RenderableType] = [heading]
|
||||
for bucket in sorted(counts, key=lambda k: (order.get(k, len(order)), k)):
|
||||
count = counts[bucket]
|
||||
icon, label, style = chrome[bucket]
|
||||
icon, label = chrome[bucket]
|
||||
row = Text(f"{icon} ")
|
||||
row.append(f"{label.ljust(label_w)} ", style="muted")
|
||||
row.append(f"{count:>{count_w}} ")
|
||||
@@ -480,8 +594,11 @@ class DashboardScreen(Screen):
|
||||
heading.append(" · reception over 2 h · radio live", style="muted")
|
||||
rows: list[tuple[str, Text]] = []
|
||||
|
||||
snrs = self._win_snrs # the one-pass window digest (see _digest_window)
|
||||
rssis = self._win_rssis
|
||||
# The one-pass window digest (see _digest_window), or the narrowed view's frames.
|
||||
if self._scope:
|
||||
snrs, rssis = self._scoped().snrs, self._scoped().rssis
|
||||
else:
|
||||
snrs, rssis = self._win_snrs, self._win_rssis
|
||||
if snrs:
|
||||
med = median(snrs)
|
||||
line = Text(f"{med:+.1f} dB median ", style=snr_style(med))
|
||||
@@ -570,6 +687,7 @@ async def open_dashboard(ctx: AppContext) -> None:
|
||||
activity=ctx.monitor.activity_histogram,
|
||||
activity_flags=ctx.monitor.activity_session_flags,
|
||||
kind_counts=ctx.monitor.kind_counts,
|
||||
scope_of=ctx.region_store.scope_of if ctx.region_store is not None else None,
|
||||
)
|
||||
|
||||
unsubscribe = ctx.events.subscribe(screen.on_event)
|
||||
|
||||
+191
-36
@@ -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,10 +41,13 @@ 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,
|
||||
)
|
||||
from ..core.spiradio import spi_radios
|
||||
from ..platforms import get_platform
|
||||
from .logo import load_logo
|
||||
from .menus import Lane, align_icons, column_header, fit_cells
|
||||
@@ -87,6 +91,10 @@ _SERIAL_ICON = "🔌"
|
||||
#: rather than a wired or Bluetooth link. Two cells like the serial plug, so it aligns the same.
|
||||
_TCP_ICON = "🌐"
|
||||
|
||||
#: TYPE-column glyph for a radio on the host's own SPI bus — a pin, because the radio is
|
||||
#: right here, on this machine. Two cells, aligning with the plug and the globe.
|
||||
_SPI_ICON = "📍"
|
||||
|
||||
#: Half-block glyphs that taper the Bluetooth badge: ``▐`` fills a cell's right half (so it
|
||||
#: hugs the rune's left edge) and ``▌`` its left half (hugging the right edge). Drawn in the
|
||||
#: badge's blue over the terminal background, they widen the blue by half a cell on each side.
|
||||
@@ -107,6 +115,8 @@ def _type_cell(device: DiscoveredDevice) -> Text:
|
||||
"""
|
||||
if device.is_tcp:
|
||||
return Text(" " + _TCP_ICON)
|
||||
if device.is_spi:
|
||||
return Text(" " + _SPI_ICON)
|
||||
if not device.is_ble:
|
||||
return Text(" " + _SERIAL_ICON)
|
||||
cell = Text()
|
||||
@@ -127,6 +137,8 @@ def _hardware_name(device: DiscoveredDevice) -> str:
|
||||
return device.name or device.product or device.description or "Network device"
|
||||
if device.is_ble:
|
||||
return device.name or device.product or device.description or "Bluetooth device"
|
||||
if device.is_spi:
|
||||
return device.name or "SPI radio"
|
||||
name = device.product or device.description or device.vendor_label or "Serial device"
|
||||
suffix = f"({device.port})"
|
||||
if name.endswith(suffix):
|
||||
@@ -153,6 +165,36 @@ def _where(device: DiscoveredDevice) -> str:
|
||||
return device.target
|
||||
|
||||
|
||||
def _fit_target(text: str, width: int) -> str:
|
||||
"""Fit a connection target into ``width`` cells, keeping its **end**.
|
||||
|
||||
Deliberately not :func:`~meshterm.ui.menus.fit_cells`, which keeps the head: what tells
|
||||
two targets apart is their *tail* — ``usbmodem1101`` from ``usbmodem1102``,
|
||||
``ttyUSB0`` from ``ttyUSB1`` — while the head they share (``/dev/cu.``) is boilerplate.
|
||||
Cutting the front is the cut that leaves the column able to do its one job in a picker,
|
||||
and cutting the back would render two different ports identically. A CoreBluetooth UUID
|
||||
is equally identifiable from either end, so the rule that is right for ports costs the
|
||||
platform that needed the cap nothing.
|
||||
|
||||
Args:
|
||||
text: The target to fit.
|
||||
width: The cells available. Text already inside it is returned unchanged, so the
|
||||
platforms whose targets already fit the lane are untouched.
|
||||
|
||||
Returns:
|
||||
The target, or its tail behind a leading ``…``, measuring at most ``width`` cells.
|
||||
"""
|
||||
if cell_len(text) <= width:
|
||||
return text
|
||||
keep = max(0, width - 1) # the leading ellipsis costs a cell
|
||||
tail = ""
|
||||
for ch in reversed(text):
|
||||
if cell_len(tail) + cell_len(ch) > keep:
|
||||
break
|
||||
tail = ch + tail
|
||||
return "…" + tail
|
||||
|
||||
|
||||
def _hardware_label(device: DiscoveredDevice, registry: dict[str, RememberedDevice]) -> str:
|
||||
"""The HARDWARE column text: the remembered firmware model, else the USB vendor.
|
||||
|
||||
@@ -209,7 +251,6 @@ async def _smoke_test(
|
||||
unrecoverable failure, or a cancelled PIN prompt).
|
||||
"""
|
||||
pin: str | None = None
|
||||
pin_error = "" # empty on the first ask; set once a PIN has been rejected
|
||||
while True:
|
||||
# BLE connect and service discovery take a few seconds, so the spinner matters most here.
|
||||
try:
|
||||
@@ -219,19 +260,21 @@ async def _smoke_test(
|
||||
title="Checking companion",
|
||||
banner=load_logo(),
|
||||
)
|
||||
except DeviceAuthenticationError:
|
||||
except DeviceAuthenticationError as exc:
|
||||
# The device answered the scan but won't connect until it's bonded (or the last PIN
|
||||
# was wrong). Collect one in the popup and loop to retry; Esc returns to the list.
|
||||
# The error says *which* refusal it was — a stale bond, a wrong PIN, a device that
|
||||
# turned the pairing down — and the dialog carries that line under its question,
|
||||
# rather than calling every refusal a rejected PIN.
|
||||
entered = await ui.prompt_pin_startup(
|
||||
name,
|
||||
error=pin_error,
|
||||
error=exc.hint,
|
||||
help_text="The 6-digit code shown on the device or in the MeshCore app",
|
||||
banner=load_logo(),
|
||||
)
|
||||
if entered is None:
|
||||
return None # the user gave up → back to the device list
|
||||
pin = entered
|
||||
pin_error = "That PIN was rejected — check the code and try again."
|
||||
continue
|
||||
except DeviceCommandError as exc:
|
||||
# A different actionable failure (not a PIN): show its remedy verbatim, then back to
|
||||
@@ -248,6 +291,11 @@ async def _smoke_test(
|
||||
"Check the host and port — it may be unreachable, powered off, already "
|
||||
"connected elsewhere, or busy."
|
||||
)
|
||||
elif chosen.is_spi:
|
||||
reason = (
|
||||
"Its node started but never answered — its log is node.log in "
|
||||
"MeshTerm's radio folder."
|
||||
)
|
||||
elif chosen.is_ble:
|
||||
reason = (
|
||||
"It may be out of range, powered off, already connected elsewhere, or busy."
|
||||
@@ -268,6 +316,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],
|
||||
@@ -297,11 +366,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
|
||||
@@ -318,12 +400,50 @@ async def prompt_device(
|
||||
# produced by the scan, so fold those in too — after the scanned set, so a port
|
||||
# pyserial *does* enumerate keeps its richer scanned row rather than the bare profile.
|
||||
listed += _profile_serial_devices(listed, profiles)
|
||||
# A radio on the SPI bus answers nothing until MeshTerm starts its node, so it is
|
||||
# listed from the device node's presence, the way a serial port is.
|
||||
listed += spi_radios(profiles, listed)
|
||||
# Devices the user has told this splash to stop showing (h). Read each pass, so a
|
||||
# row hidden a moment ago is gone the next time the list is drawn — which is the
|
||||
# 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.
|
||||
@@ -332,16 +452,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.
|
||||
@@ -670,34 +791,66 @@ async def _remove_network_device(
|
||||
#: Below this a model string says nothing at all, and the lane is better spent on the name.
|
||||
_HARDWARE_MIN = 10
|
||||
|
||||
#: The narrowest the PORT / ADDRESS lane is worth shrinking to before DEVICE has to give.
|
||||
#: This is the lane that adapts: a target is how you tell two similar rows apart, and ten
|
||||
#: cells still does that (the tail of a UUID, the end of a port path), while a *name* is
|
||||
#: what the reader actually came to read. macOS is why any of this is needed —
|
||||
#: CoreBluetooth reports no MAC at all but a per-machine 36-cell UUID, and even its ports
|
||||
#: run long (every Mac carries a 31-cell ``/dev/cu.Bluetooth-Incoming-Port``) — where a MAC
|
||||
#: is 17 and ``/dev/ttyUSB0`` is 12, so no other platform ever presses on the row at all.
|
||||
_ADDRESS_MIN = 10
|
||||
|
||||
def _name_width(
|
||||
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice], other_lanes: int
|
||||
) -> int:
|
||||
"""The DEVICE lane's width: the longest name, capped so HARDWARE keeps a readable column.
|
||||
|
||||
A USB adapter's own product string runs to forty-odd characters ("CP2102 USB to UART
|
||||
Bridge Controller"), and the lane used to size itself to whichever of those was longest.
|
||||
On a 53-column console that pushed every other lane off the right-hand edge — the row
|
||||
ended mid-port, and the HARDWARE column that says what the thing *is* was not on screen
|
||||
at all, nor reachable by scrolling, because the pinned head was already wider than the
|
||||
box. So the name is the lane that gives: it ellipsizes, and the columns after it survive.
|
||||
def _lane_widths(
|
||||
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice]
|
||||
) -> tuple[int, int]:
|
||||
"""The DEVICE and PORT / ADDRESS widths: the name in full, the address taking the rest.
|
||||
|
||||
The row has a fixed budget, and something has to give when the two lanes together
|
||||
outrun it. **The address gives first.** A name is what the reader came to read and what
|
||||
they recognise their own radio by; an address is a disambiguator, and a disambiguator
|
||||
does its whole job from its last ten cells (:func:`_fit_target` keeps the tail for
|
||||
exactly this reason). So DEVICE is sized to the longest name it has, and ADDRESS takes
|
||||
whatever is left over, down to :data:`_ADDRESS_MIN`.
|
||||
|
||||
Only once the address is down to that floor does the name start ellipsizing, and it
|
||||
still must: a USB adapter's own product string runs to forty-odd characters ("CP2102
|
||||
USB to UART Bridge Controller"), and a lane sized to *that* pushed everything after it
|
||||
off the right-hand edge — the row ended mid-port and the HARDWARE column that says what
|
||||
the thing actually *is* was not on screen, nor reachable by scrolling, because the
|
||||
pinned head was already wider than the box. HARDWARE keeps :data:`_HARDWARE_MIN` out of
|
||||
the budget so it always has a readable stub; it is the row's scrolling tail, so beyond
|
||||
that stub it is free to run long.
|
||||
|
||||
Args:
|
||||
devices: The devices being listed.
|
||||
registry: Confirmed companions, for their remembered names.
|
||||
other_lanes: Cells the PORT and TYPE lanes take between them.
|
||||
|
||||
Returns:
|
||||
The name lane's width in cells.
|
||||
``(name_w, port_w)`` in cells.
|
||||
"""
|
||||
platform = get_platform()
|
||||
# What the splash's box gives a row: the terminal, less the gutter it floats over, less
|
||||
# its own border and padding, less the pointer and star columns the row leads with.
|
||||
content = platform.readable_cols - platform.dialog_margin - 4 - 4
|
||||
fixed = other_lanes + 6 # the three two-cell gaps between the four lanes
|
||||
longest = max(cell_len(_display_name(d, registry)) for d in devices)
|
||||
return max(len("DEVICE"), min(longest, content - fixed - _HARDWARE_MIN))
|
||||
# ...less the three two-cell gaps, the TYPE badge, and HARDWARE's readable stub.
|
||||
budget = content - 6 - len("TYPE") - _HARDWARE_MIN
|
||||
|
||||
name_natural = max(cell_len(_display_name(d, registry)) for d in devices)
|
||||
port_natural = max(cell_len(_where(d)) for d in devices)
|
||||
# Never reserve more room for the address than it actually wants: a lone "COM3" should
|
||||
# hand its slack to the name rather than sit in a ten-cell lane holding four cells.
|
||||
floor = min(port_natural, _ADDRESS_MIN)
|
||||
|
||||
name_w = max(len("DEVICE"), min(name_natural, budget - floor))
|
||||
port_w = max(floor, min(port_natural, budget - name_w))
|
||||
|
||||
if name_w < name_natural and port_w < port_natural:
|
||||
# The name is being ellipsized whatever happens, so spending two more of its cells
|
||||
# to keep the address whole trades one cut for none rather than adding a second.
|
||||
port_w = min(port_natural, budget - len("DEVICE"))
|
||||
name_w = max(len("DEVICE"), budget - port_w)
|
||||
return name_w, port_w
|
||||
|
||||
|
||||
def _order(
|
||||
@@ -765,12 +918,13 @@ def _build_items(
|
||||
port_label = (
|
||||
"PORT / ADDRESS" if has_serial and has_address else "ADDRESS" if has_address else "PORT"
|
||||
)
|
||||
port_w = max(cell_len(_where(d)) for d in devices)
|
||||
port_w = max(port_w, len(port_label))
|
||||
# The TYPE column holds a small transport badge (at most 3 cells); its heading is wider,
|
||||
# so the four-cell "TYPE" label sets the column width and every badge pads out to it.
|
||||
type_w = len("TYPE")
|
||||
name_w = _name_width(devices, registry, port_w + type_w)
|
||||
# DEVICE in full, ADDRESS taking what is left (see _lane_widths). The heading does not
|
||||
# set a floor here the way it used to: the lane may end up narrower than "PORT / ADDRESS"
|
||||
# spells, and column_header already carries the short form to fall back on.
|
||||
name_w, port_w = _lane_widths(devices, registry)
|
||||
hardware_w = max(cell_len(_hardware_label(d, registry)) for d in devices)
|
||||
hardware_w = max(hardware_w, len("HARDWARE"))
|
||||
|
||||
@@ -806,7 +960,7 @@ def _build_items(
|
||||
style="device.known" if is_known else "",
|
||||
)
|
||||
row.append(" ")
|
||||
row.append(_pad(_where(device), port_w), style="muted")
|
||||
row.append(_pad(_fit_target(_where(device), port_w), port_w), style="muted")
|
||||
row.append(" ")
|
||||
# The TYPE badge marks the transport: a plug emoji for serial, or the Bluetooth rune
|
||||
# on its blue badge for BLE. It's built with its own colours, then the column is
|
||||
@@ -816,11 +970,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
|
||||
@@ -840,7 +995,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"]
|
||||
+80
-1
@@ -32,11 +32,14 @@ from __future__ import annotations
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from . import script
|
||||
from .report import Column, Lane, normalise
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.regions import Scope
|
||||
|
||||
# -- the shared value shapes -----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -240,6 +243,55 @@ def channel(
|
||||
)
|
||||
|
||||
|
||||
def scope_json(scope: Scope | None) -> dict[str, Any] | None:
|
||||
"""A flood's scope as its machine object, or ``null`` for a frame that has none.
|
||||
|
||||
``state`` is ``scoped`` (``region`` names it), ``unknown`` (scoped, no known region
|
||||
reproduces ``code``) or ``unscoped``; ``code`` is the frame's first transport code as
|
||||
lowercase hex, kept even when unresolved so two frames can be seen to share a region.
|
||||
A direct frame is ``null`` — never region-filtered, so it has no scope to report.
|
||||
"""
|
||||
if scope is None:
|
||||
return None
|
||||
return {"state": scope.state, "region": scope.region, "code": scope.code}
|
||||
|
||||
|
||||
def _scope_cell(scope: Scope | None) -> str:
|
||||
"""A scope's plain word: the region, ``unknown`` for an unresolved one, or ``unscoped``."""
|
||||
if scope is None:
|
||||
return script.NONE
|
||||
if scope.state == "scoped" and scope.region:
|
||||
return script.name(scope.region)
|
||||
return "unknown" if scope.scoped else "unscoped"
|
||||
|
||||
|
||||
def scope(key: str = "scope", header: str = "SCOPE") -> Column:
|
||||
"""A flood's scope — the region a repeater relays it in — as one lane and one object.
|
||||
|
||||
The row holds a :class:`~meshterm.core.regions.Scope` (or ``None`` for a direct frame).
|
||||
The plain lane is a word, never the code: a four-digit hash in a column of region
|
||||
names reads as a region nobody has, and the document carries the code for whoever
|
||||
needs it.
|
||||
"""
|
||||
return Column(key=key, lanes=(Lane(header=header, render=_scope_cell),), json=scope_json)
|
||||
|
||||
|
||||
def regions(key: str = "regions", header: str = "REGIONS") -> Column:
|
||||
"""A set of region names — what a repeater relays: joined plain, an array in the document.
|
||||
|
||||
The row holds a sequence of bare names, never the wildcard: *unscoped* is its own
|
||||
concept (a :func:`flag` beside this column), because ``*`` names no region and a
|
||||
consumer reading this array for names would otherwise have to know to skip it. An empty
|
||||
sequence is a real answer — *no regions* — so it reads as the word ``none`` plain and
|
||||
stays ``[]`` in the document; ``None`` is *not known*, the absent token and ``null``.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=_regions_cell),),
|
||||
json=lambda names: None if names is None else [str(n) for n in names],
|
||||
)
|
||||
|
||||
|
||||
def when(key: str, header: str, *, absent: str = script.NONE) -> Column:
|
||||
"""A time the reader reads as an age: ``5m``, ``3h``, ``never``.
|
||||
|
||||
@@ -332,6 +384,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.
|
||||
|
||||
@@ -438,11 +505,23 @@ def hidden(key: str) -> Column:
|
||||
return Column(key=key)
|
||||
|
||||
|
||||
def _regions_cell(names: Sequence[str] | None) -> str:
|
||||
"""A region set's plain cell: the names comma-joined, ``none`` when empty, else absent."""
|
||||
if names is None:
|
||||
return script.NONE
|
||||
return ", ".join(script.name(n) for n in names) if names else "none"
|
||||
|
||||
|
||||
def _rendered_cell(value: Rendered | None) -> str:
|
||||
"""One setting's cell: the text its own spec produced, or the absent token."""
|
||||
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)
|
||||
|
||||
+216
-62
@@ -3,7 +3,7 @@
|
||||
|
||||
The interactive face of the ``livefeed`` tool — the dashboard's old feed panel,
|
||||
promoted to a first-class screen. One always-repainting list streams the latest
|
||||
packets, newest first: time, class (icon + label, icon alone on a narrow terminal),
|
||||
packets, newest first: time, class (its name, beside an icon where the platform draws one),
|
||||
subject, SNR/RSSI, and a message's conversation. What a row deliberately does *not* carry
|
||||
is the relay path a frame rode in on: a route is a shape, not a lane, and squeezing one
|
||||
into the cells left at the right edge only ever produced a stub — so the route belongs
|
||||
@@ -11,6 +11,12 @@ to the packet viewer, which has the room to draw it as a wrapped path line over
|
||||
graph. The feed's job is to say what arrived and how well it was heard; Enter says how
|
||||
it got here.
|
||||
|
||||
A **scope** lane sits between the subject and the readings, at every width: the region a
|
||||
flood was sent into (``harbour``), ``? 3fa1`` for a region nobody here has named, a muted
|
||||
``-`` for a plain flood, and nothing for a direct frame, which no repeater region-filters.
|
||||
It never collapses; the class label beside the icon is what gives way on a narrow terminal
|
||||
(see :func:`_lanes_for`).
|
||||
|
||||
The subject lane is contextual: it holds whatever the *class* of packet is about (see
|
||||
:meth:`LiveFeedScreen._feed_subject`). An advert or a telemetry frame is about the node
|
||||
that sent it, so the lane names the node — but a channel text is about its channel, a
|
||||
@@ -54,7 +60,7 @@ import asyncio
|
||||
from collections import deque
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
@@ -62,22 +68,27 @@ from ..core.channels import identify_channel, split_channel_sender
|
||||
from ..core.events import EventKind, MeshEvent
|
||||
from ..core.frames import CHANNEL_CLASSES, ENDPOINT_HASH_BYTES
|
||||
from ..core.models import Observation, utcnow
|
||||
from ..platforms import Platform, on_platform
|
||||
from .menus import fit_cells
|
||||
from .packet_viewer import (
|
||||
KIND_STYLES,
|
||||
MessageScopeOf,
|
||||
PacketEntry,
|
||||
PacketViewer,
|
||||
ScopeOf,
|
||||
class_marks,
|
||||
node_label,
|
||||
unnamed_scope,
|
||||
)
|
||||
from .pathline import SELF_GLYPH, PathHop, PathLine
|
||||
from .theme import name_style, snr_style
|
||||
from .tui.render import crop_cells, render_to_ansi
|
||||
from .tui.screen import ListWindow, Screen
|
||||
from .widgets import NameKeyResolver, TypeOf
|
||||
from .widgets import NameKeyResolver, TypeOf, scope_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
from ..core.regions import Scope
|
||||
|
||||
#: Seconds between full repaints while the feed is open (keeps age-sensitive chrome
|
||||
#: honest even between hub events).
|
||||
@@ -116,8 +127,10 @@ _FEED_SEED_FLOOR = datetime.min.replace(tzinfo=timezone.utc)
|
||||
_FEED_SUBJECT_WIDTH = 18
|
||||
|
||||
#: The class lane's fixed width — wide enough for the longest class the app files a
|
||||
#: packet under (``direct message``), so every label reads whole and the lanes hold.
|
||||
_FEED_CLASS_WIDTH = 14
|
||||
#: packet under (``telemetry``, ``chan text``, ``multipart``), so every label reads whole.
|
||||
#: The names are kept short on purpose (``dir messg``, ``anon req`` — JP, 2026-09-25): the
|
||||
#: lane never collapses, so every cell it spends is one the row's readings scroll off for.
|
||||
_FEED_CLASS_WIDTH = 9
|
||||
|
||||
# The remaining lanes, as the row and its column header both measure them — one set of
|
||||
# numbers so a header label can never drift off the values it names, and so a lane a
|
||||
@@ -126,31 +139,77 @@ _FEED_CLASS_WIDTH = 14
|
||||
_LANE_GAP = 2
|
||||
#: ``HH:MM:SS`` plus the gap that follows it.
|
||||
_TIME_LANE = 8 + _LANE_GAP
|
||||
#: The class icon and its trailing space (all the class lane keeps on a narrow terminal).
|
||||
#: The class icon and its trailing space — or nothing, where the platform draws no icons
|
||||
#: (bound by :func:`_bind_class_icon`).
|
||||
_ICON_LANE = 3
|
||||
#: Cells a reception reading right-aligns its number in — the ``%+5.1f``/``%5.0f`` field
|
||||
#: both lanes are built on, and the slot their column headers sit over.
|
||||
_READING_W = 5
|
||||
#: ``+13.2 dB`` — the number field, then its unit.
|
||||
_SNR_LANE = _READING_W + len(" dB")
|
||||
#: `` -61 dBm`` — the lane gap, the number field, then its unit.
|
||||
_RSSI_LANE = _LANE_GAP + _READING_W + len(" dBm")
|
||||
|
||||
#: Terminal width below which the feed drops the textual class label and keeps only the
|
||||
#: two-cell icon. It is exactly the width the full row occupies — with no route to make
|
||||
#: room for, every lane fits a 72-column screen, so the label (the row's most informative
|
||||
#: field now that it names the real class) is only ever dropped when it truly can't fit.
|
||||
_FEED_LABEL_MIN_WIDTH = (
|
||||
2
|
||||
+ _TIME_LANE
|
||||
+ _ICON_LANE
|
||||
+ _FEED_CLASS_WIDTH
|
||||
+ _LANE_GAP
|
||||
+ _FEED_SUBJECT_WIDTH
|
||||
+ _LANE_GAP
|
||||
+ _SNR_LANE
|
||||
+ _RSSI_LANE
|
||||
)
|
||||
|
||||
@on_platform
|
||||
def _bind_class_icon(platform: Platform) -> None:
|
||||
"""Bind the class icon lane to the platform (runs now and on every switch).
|
||||
|
||||
The class *name* is always drawn (it never collapses — JP, 2026-09-25), so the icon
|
||||
beside it is a family cue rather than the information, which is exactly what
|
||||
:attr:`~meshterm.platforms.Platform.menu_icons` governs: on the PicoCalc, where a class
|
||||
icon can only be a one-glyph stand-in, the lane is dropped and its three cells go to
|
||||
the row.
|
||||
"""
|
||||
global _ICON_LANE
|
||||
_ICON_LANE = 3 if platform.menu_icons else 0
|
||||
|
||||
|
||||
#: Cells an SNR right-aligns its number in: ``+13.2``, ``-20.5`` — sign, two digits, tenth.
|
||||
_SNR_W = 5
|
||||
#: Cells an RSSI right-aligns its number in: ``-120`` at its weakest, whole dBm. Sized to
|
||||
#: the reading rather than borrowed from the SNR's field, whose extra cell used to sit
|
||||
#: between the two readings as a gap twice the width of every other gap on the row.
|
||||
_RSSI_W = 4
|
||||
#: ``+13.2 dB`` — the number field, then its unit.
|
||||
_SNR_LANE = _SNR_W + len(" dB")
|
||||
#: `` -105 dBm`` — the lane gap, the number field, then its unit.
|
||||
_RSSI_LANE = _LANE_GAP + _RSSI_W + len(" dBm")
|
||||
|
||||
#: The scope lane's width. Room for an unnamed scope's ``? 3fa1`` and a short region name
|
||||
#: whole; a longer name ellipsizes — its whole name is on the viewer's ``scope`` row, one
|
||||
#: keypress away, like everything else a lane cuts.
|
||||
_FEED_SCOPE_WIDTH = 10
|
||||
|
||||
#: The scope lane with the gap that sets it off from the readings after it.
|
||||
_SCOPE_LANE = _FEED_SCOPE_WIDTH + _LANE_GAP
|
||||
|
||||
|
||||
def _feed_scope(scope: Scope | None) -> Text:
|
||||
"""One row's scope cell: the region, ``? 3fa1`` unnamed, a muted dash for unscoped.
|
||||
|
||||
Blank for a direct frame (or a row that is no frame at all), which has no scope: a
|
||||
dash there would claim a plain flood it never was.
|
||||
"""
|
||||
if scope is not None and not scope.scoped:
|
||||
return Text("-", style="muted")
|
||||
return scope_text(scope, bare=True)
|
||||
|
||||
|
||||
class _Lanes(NamedTuple):
|
||||
"""The lane widths one paint lays its rows out in.
|
||||
|
||||
Attributes:
|
||||
subject: The subject lane's width (:data:`_FEED_SUBJECT_WIDTH`).
|
||||
"""
|
||||
|
||||
subject: int
|
||||
|
||||
|
||||
def _lanes_for(width: int) -> _Lanes:
|
||||
"""The lanes a row carries at ``width`` — all of them, at every width.
|
||||
|
||||
No lane collapses (JP, 2026-09-25): the class name and the scope were each dropped on
|
||||
a narrow terminal in turn, and each time the lane that went was one a reader came to
|
||||
the feed for. A row wider than the screen is read the app-wide way instead — the
|
||||
highlighted row slides sideways under ``←→`` — which on a 72-column terminal reaches
|
||||
the readings at the row's end, and on the PicoCalc's 53 everything past the subject.
|
||||
"""
|
||||
return _Lanes(_FEED_SUBJECT_WIDTH)
|
||||
|
||||
|
||||
#: Cells one ←/→ press shifts the highlighted row by — the app-wide select list's own
|
||||
#: step (:attr:`~meshterm.ui.tui.select.SelectScreen._HSCROLL_STEP`), so a row here
|
||||
@@ -236,6 +295,8 @@ class LiveFeedScreen(Screen):
|
||||
channel_names: Mapping[int, str] | None = None,
|
||||
type_of: TypeOf | None = None,
|
||||
key_of: NameKeyResolver | None = None,
|
||||
scope_of: ScopeOf | None = None,
|
||||
message_scope: MessageScopeOf | None = None,
|
||||
) -> None:
|
||||
"""Create the feed over its data feeds.
|
||||
|
||||
@@ -259,6 +320,14 @@ class LiveFeedScreen(Screen):
|
||||
:func:`~meshterm.services.trace_runner.make_name_key_resolver`), so a
|
||||
channel sender the contacts or the recorder know takes its key-derived
|
||||
hue; an unresolvable name stays muted.
|
||||
scope_of: Reads a flood's scope off its raw frame against the regions known by
|
||||
name (``ctx.region_store.scope_of``) — for the scope lane, and handed to
|
||||
each opened viewer for its ``scope`` row. ``None`` still tells a scoped
|
||||
flood from a plain one, showing a scoped one's code where a name would go
|
||||
(:func:`~meshterm.ui.packet_viewer.unnamed_scope`).
|
||||
message_scope: Reads a decoded channel message's scope off its copies in the
|
||||
packet log (:func:`message_scope_reader`), handed to each opened viewer so
|
||||
a message's card has a ``scope`` row too. ``None``: no row for messages.
|
||||
"""
|
||||
super().__init__()
|
||||
self.title = "Live feed"
|
||||
@@ -270,6 +339,11 @@ class LiveFeedScreen(Screen):
|
||||
self._channel_names: Mapping[int, str] = channel_names or {}
|
||||
self._type_of = type_of
|
||||
self._key_of: NameKeyResolver = key_of or (lambda name: None)
|
||||
self._scope_of: ScopeOf = scope_of or unnamed_scope
|
||||
self._message_scope = message_scope
|
||||
#: The lanes the last paint carried (see :func:`_lanes_for`) — read by the subject
|
||||
#: builders, whose crowded-lane fallbacks measure against the lane actually drawn.
|
||||
self._lanes = _lanes_for(0)
|
||||
#: The feed: latest events of every class as data, newest first — rendered
|
||||
#: fresh each paint (rows adapt to width) and handed whole to the viewer.
|
||||
self._feed: deque[PacketEntry] = deque(maxlen=_FEED_CAP)
|
||||
@@ -476,6 +550,8 @@ class LiveFeedScreen(Screen):
|
||||
channels=self._channels,
|
||||
type_of=self._type_of,
|
||||
key_of=self._key_of,
|
||||
scope_of=self._scope_of,
|
||||
message_scope=self._message_scope,
|
||||
# The live feed itself (newest first), so the viewer keeps up with packets
|
||||
# that arrive while it is open instead of freezing at this snapshot.
|
||||
source=lambda: list(self._feed),
|
||||
@@ -497,17 +573,17 @@ class LiveFeedScreen(Screen):
|
||||
if self._selected is None:
|
||||
self._hmax = 0 # nothing highlighted scrolls, so nothing advertises ←→
|
||||
self._pinned = False # a feed with no rows has nothing to pin to
|
||||
show_label = width >= _FEED_LABEL_MIN_WIDTH
|
||||
lanes = self._lanes = _lanes_for(width)
|
||||
lines: list[str] = []
|
||||
if self._feed: # a header over nothing is noise; the empty note speaks for itself
|
||||
lines.append(render_to_ansi(self._column_header(show_label), width, no_wrap=True))
|
||||
lines.append(render_to_ansi(self._column_header(lanes), width, no_wrap=True))
|
||||
win = max(1, self._scroll_viewport - len(lines))
|
||||
lines.extend(self._feed_lines(width, win, show_label))
|
||||
lines.extend(self._feed_lines(width, win, lanes))
|
||||
self._scroll_total = max(1, len(lines))
|
||||
return lines
|
||||
|
||||
@staticmethod
|
||||
def _column_header(show_label: bool) -> Text:
|
||||
def _column_header(lanes: _Lanes) -> Text:
|
||||
"""The lane names, in the app's uppercase muted column-header voice.
|
||||
|
||||
Laid out lane for lane against :meth:`_feed_row`, the pointer column included, so
|
||||
@@ -525,22 +601,17 @@ class LiveFeedScreen(Screen):
|
||||
"""
|
||||
header = Text(" ", style="muted") # the pointer lane
|
||||
header.append(fit_cells("TIME", _TIME_LANE))
|
||||
header.append(
|
||||
fit_cells("CLASS", _ICON_LANE + _FEED_CLASS_WIDTH + _LANE_GAP)
|
||||
if show_label
|
||||
else " " * _ICON_LANE
|
||||
)
|
||||
header.append(fit_cells("SUBJECT", _FEED_SUBJECT_WIDTH + _LANE_GAP))
|
||||
# The two readings right-align their number, so their labels do too — each sits
|
||||
# over the digits it names rather than over the sign column ahead of them.
|
||||
header.append(fit_cells("SNR", _READING_W, align="right"))
|
||||
header.append(" " * (_SNR_LANE - _READING_W))
|
||||
header.append(" " * _LANE_GAP)
|
||||
header.append(fit_cells("RSSI", _READING_W, align="right"))
|
||||
header.append(" " * (_RSSI_LANE - _LANE_GAP - _READING_W))
|
||||
header.append(fit_cells("CLASS", _ICON_LANE + _FEED_CLASS_WIDTH + _LANE_GAP))
|
||||
header.append(fit_cells("SUBJECT", lanes.subject + _LANE_GAP))
|
||||
header.append(fit_cells("SCOPE", _SCOPE_LANE))
|
||||
# Each reading's label starts where its lane does, as every other label on the row
|
||||
# does. Right-aligned over the number instead, it ended mid-value with the unit
|
||||
# trailing past it, and read as floating between two columns rather than naming one.
|
||||
header.append(fit_cells("SNR", _SNR_LANE + _LANE_GAP))
|
||||
header.append(fit_cells("RSSI", _RSSI_LANE - _LANE_GAP))
|
||||
return header
|
||||
|
||||
def _feed_lines(self, width: int, win: int, show_label: bool) -> list[str]:
|
||||
def _feed_lines(self, width: int, win: int, lanes: _Lanes) -> list[str]:
|
||||
"""The feed's windowed rows: newest first, ``↑/↓ n more`` at the edges."""
|
||||
if not self._feed:
|
||||
return [render_to_ansi(Text("nothing heard yet", style="muted"), width)]
|
||||
@@ -550,22 +621,22 @@ class LiveFeedScreen(Screen):
|
||||
if top > 0:
|
||||
out.append(render_to_ansi(ListWindow.marker(top, "above"), width))
|
||||
for i in range(top, top + count):
|
||||
row = self._feed_row(entries[i], i == self._selected, show_label, width)
|
||||
row = self._feed_row(entries[i], i == self._selected, lanes, width)
|
||||
out.append(render_to_ansi(row, width, no_wrap=True))
|
||||
below = len(entries) - top - count
|
||||
if below > 0:
|
||||
out.append(render_to_ansi(ListWindow.marker(below, "below"), width))
|
||||
return out
|
||||
|
||||
def _feed_row(self, entry: PacketEntry, selected: bool, show_label: bool, width: int) -> Text:
|
||||
def _feed_row(self, entry: PacketEntry, selected: bool, lanes: _Lanes, width: int) -> Text:
|
||||
"""Lay one feed row out in fixed lanes: time, class, subject, reception, detail.
|
||||
|
||||
The class lane says what the packet *is*, straight from
|
||||
:func:`~meshterm.ui.packet_viewer.class_marks` — so a raw frame reads
|
||||
``📻 channel text``, the class the viewer's card headlines, rather than the
|
||||
``📦 packet`` event family it merely arrived in. Its icon always shows; the
|
||||
textual label beside it is dropped wholesale on a narrow terminal
|
||||
(``show_label``), keeping the lanes aligned either way. The subject lane beside it
|
||||
``📦 packet`` event family it merely arrived in. The name always shows; the icon
|
||||
beside it only where the platform draws icons (:func:`_bind_class_icon`). The
|
||||
subject lane beside it
|
||||
then answers what the packet is *about*, in whatever terms its class deals in
|
||||
(:meth:`_feed_subject`). No relay path rides here — the whole row fits a
|
||||
72-column screen precisely because it doesn't try to, and the route is drawn
|
||||
@@ -599,25 +670,33 @@ class LiveFeedScreen(Screen):
|
||||
style="muted",
|
||||
)
|
||||
icon, class_label = class_marks(entry)
|
||||
body.append(fit_cells(icon, _ICON_LANE))
|
||||
if show_label:
|
||||
body.append(
|
||||
fit_cells(class_label, _FEED_CLASS_WIDTH),
|
||||
style=KIND_STYLES.get(entry.kind, "brand"),
|
||||
)
|
||||
body.append(" " * _LANE_GAP) # the lane's gutter — the longest class fills it
|
||||
if _ICON_LANE:
|
||||
body.append(fit_cells(icon, _ICON_LANE))
|
||||
body.append(
|
||||
fit_cells(class_label, _FEED_CLASS_WIDTH),
|
||||
style=KIND_STYLES.get(entry.kind, "brand"),
|
||||
)
|
||||
body.append(" " * _LANE_GAP) # the lane's gutter — the longest class fills it
|
||||
subject = self._feed_subject(entry)
|
||||
# The lane fits like every other: cells, not characters, ellipsis on overflow —
|
||||
# but as a Text, so a subject built of several styled pieces keeps them.
|
||||
subject.truncate(_FEED_SUBJECT_WIDTH, overflow="ellipsis", pad=True)
|
||||
subject.truncate(lanes.subject, overflow="ellipsis", pad=True)
|
||||
body.append_text(subject)
|
||||
body.append(" " * _LANE_GAP)
|
||||
# Beside the subject, ahead of the readings: where a flood was allowed to go is a
|
||||
# fact about the packet, as its subject is, and the readings are about how it
|
||||
# reached us. A plain flood — most rows — is a muted dash rather than the word, so
|
||||
# the lane is quiet until a row is actually scoped.
|
||||
scope = _feed_scope(self._entry_scope(entry))
|
||||
scope.truncate(_FEED_SCOPE_WIDTH, overflow="ellipsis", pad=True)
|
||||
body.append_text(scope)
|
||||
body.append(" " * _LANE_GAP)
|
||||
body.append(
|
||||
f"{entry.snr:+{_READING_W}.1f} dB" if entry.snr is not None else " " * _SNR_LANE,
|
||||
f"{entry.snr:+{_SNR_W}.1f} dB" if entry.snr is not None else " " * _SNR_LANE,
|
||||
style=snr_style(entry.snr) if entry.snr is not None else "muted",
|
||||
)
|
||||
body.append(
|
||||
f"{'':{_LANE_GAP}}{entry.rssi:{_READING_W}.0f} dBm"
|
||||
f"{'':{_LANE_GAP}}{entry.rssi:{_RSSI_W}.0f} dBm"
|
||||
if entry.rssi is not None
|
||||
else " " * _RSSI_LANE,
|
||||
style="muted",
|
||||
@@ -759,7 +838,7 @@ class LiveFeedScreen(Screen):
|
||||
if not src:
|
||||
return self._token("to", dest)
|
||||
named = PathLine([self._hop(src), self._hop(dest)], mode="plain").text()
|
||||
if named.cell_len <= _FEED_SUBJECT_WIDTH:
|
||||
if named.cell_len <= self._lanes.subject:
|
||||
return named
|
||||
# Too wide: the sender drops to its hash, so the addressee keeps its name whole (or
|
||||
# as much of it as the lane holds) instead of being the half that gets amputated.
|
||||
@@ -799,6 +878,20 @@ class LiveFeedScreen(Screen):
|
||||
text.append(value, style="muted")
|
||||
return text
|
||||
|
||||
def _entry_scope(self, entry: PacketEntry) -> Scope | None:
|
||||
"""The scope of a row's frame, or ``None`` where it has none to state.
|
||||
|
||||
Only a raw ``packet`` frame carries the route type and transport codes a scope is
|
||||
read from, and only a flood has one; the lane is left blank for everything else —
|
||||
a direct frame is never region-filtered, so a word there would claim a meaning it
|
||||
lacks. The lane is drawn ``bare``: its heading already says ``SCOPE``, so a known
|
||||
region reads as its name alone, while ``unscoped`` and ``unknown scope 3fa1`` stay the
|
||||
words they are everywhere (:func:`~meshterm.ui.widgets.scope_text`).
|
||||
"""
|
||||
if entry.kind != "packet" or not isinstance(entry.raw, dict):
|
||||
return None
|
||||
return self._scope_of(entry.raw)
|
||||
|
||||
def _feed_note(self, entry: PacketEntry) -> Text | None:
|
||||
"""The row's trailing detail — a message's conversation, and nothing else.
|
||||
|
||||
@@ -850,6 +943,7 @@ async def open_livefeed(ctx: AppContext) -> None:
|
||||
self_name: str | None = None
|
||||
channels: list[tuple[str, bytes]] = []
|
||||
channel_names: dict[int, str] = {}
|
||||
slots_by_idx: dict[int, tuple[str, bytes]] = {}
|
||||
try:
|
||||
if ctx.is_connected or ctx.settings.connect_on_start:
|
||||
# Through the session cache: contacts and the channel-slot probe are the two
|
||||
@@ -860,6 +954,7 @@ async def open_livefeed(ctx: AppContext) -> None:
|
||||
slots = await ctx.devstate.channel_slots()
|
||||
channels = [(s.name, s.secret) for s in slots]
|
||||
channel_names = {s.idx: s.name for s in slots}
|
||||
slots_by_idx = {s.idx: (s.name, s.secret) for s in slots}
|
||||
except Exception: # noqa: BLE001 - the feed renders fine without contact names
|
||||
contacts = []
|
||||
# Contacts first, every name the recorder ever overheard as the fallback — the
|
||||
@@ -883,6 +978,8 @@ async def open_livefeed(ctx: AppContext) -> None:
|
||||
channel_names=channel_names,
|
||||
type_of=type_of,
|
||||
key_of=key_of,
|
||||
scope_of=ctx.region_store.scope_of,
|
||||
message_scope=message_scope_reader(ctx, slots_by_idx),
|
||||
)
|
||||
|
||||
unsubscribe = ctx.events.subscribe(screen.on_event)
|
||||
@@ -905,3 +1002,60 @@ async def open_livefeed(ctx: AppContext) -> None:
|
||||
pass
|
||||
except Exception: # noqa: BLE001 - teardown must never surface a tick hiccup
|
||||
pass
|
||||
|
||||
|
||||
def message_scope_reader(
|
||||
ctx: AppContext, slots_by_idx: Mapping[int, tuple[str, bytes]]
|
||||
) -> MessageScopeOf:
|
||||
"""A reader for a decoded channel message's scope, off its copies in the packet log.
|
||||
|
||||
The radio hands a received message over as text, not as the frame it arrived in, so a
|
||||
``message`` row carries no transport code. Its flooded copies are in the packet log,
|
||||
though, and the message paths dialog already finds them by decrypting and matching
|
||||
the text (:func:`~meshterm.services.message_paths.channel_arrivals`); every copy
|
||||
carries the sender's one code, so their scope is the message's
|
||||
(:func:`~meshterm.services.message_paths.message_scope`).
|
||||
|
||||
The copies found are kept per entry — a card repaints every second and the lookup is a
|
||||
windowed query plus a decrypt per frame — but only once some were found: a message
|
||||
can be told to the feed a beat before its frame is written to the log, and a miss
|
||||
cached then would be a miss for good. The region is resolved on every call, against
|
||||
the store's memo, so a name learned while the card is open still names it.
|
||||
|
||||
A direct message has no reader: its copies are encrypted end to end and a routed one
|
||||
has no scope anyway.
|
||||
|
||||
Args:
|
||||
ctx: The application context (its repository and region store).
|
||||
slots_by_idx: The device's channels by slot, as ``(name, secret)``.
|
||||
|
||||
Returns:
|
||||
The reader, answering ``None`` for anything it can't place.
|
||||
"""
|
||||
from ..core.models import ChatMessage
|
||||
from ..services.message_paths import channel_arrivals, message_scope
|
||||
|
||||
found: dict[int, list] = {}
|
||||
|
||||
def read(entry: PacketEntry) -> Scope | None:
|
||||
if entry.kind != "message" or entry.channel is None:
|
||||
return None
|
||||
channel = slots_by_idx.get(entry.channel)
|
||||
if channel is None or not entry.text:
|
||||
return None
|
||||
arrivals = found.get(id(entry))
|
||||
if arrivals is None:
|
||||
message = ChatMessage(
|
||||
text=entry.text, outbound=False, is_channel=True, created_at=entry.when
|
||||
)
|
||||
try:
|
||||
arrivals = channel_arrivals(
|
||||
ctx.repo, message, channel_name=channel[0], secret=channel[1]
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a card without a scope row, never a crash
|
||||
return None
|
||||
if arrivals:
|
||||
found[id(entry)] = arrivals
|
||||
return message_scope(arrivals, ctx.region_store.scope_of)
|
||||
|
||||
return read
|
||||
+68
-15
@@ -64,6 +64,10 @@ _SGR = re.compile(r"\x1b\[[0-9;]*m")
|
||||
#: lands a bank too high — the theme's own styles state their intent for the same reason.
|
||||
_DIM_FG = re.compile(r"3[0-7]")
|
||||
|
||||
#: The foreground a DOS console stands on after a reset: grey, slot 7 — which bold lifts
|
||||
#: to white.
|
||||
_DEFAULT_FG = 37
|
||||
|
||||
#: Parameters that settle the intensity question themselves, so a span carrying one needs
|
||||
#: no help: a reset, bold, faint, or an explicit return to normal.
|
||||
_SAYS_INTENSITY = frozenset({"", "0", "1", "2", "22"})
|
||||
@@ -158,16 +162,29 @@ def _rewrap(line: str, width: int) -> list[str]:
|
||||
|
||||
|
||||
def _state_intensity(row: str) -> str:
|
||||
"""Rewrite the row's colour changes so every one says whether it is bright.
|
||||
"""Rewrite the row's colour changes so brightness is stated as a colour, not as bold.
|
||||
|
||||
An art editor spells brightness the DOS way — ``1m`` lifts the bank and each span after
|
||||
it inherits that — which reads correctly only while the escapes stay one unbroken
|
||||
stream. They don't here: a row is handed to Rich on its own, and the console draws bold
|
||||
*as* the bright bank, so a span that never mentions intensity is read against whatever
|
||||
happened to be set. Saying it outright changes no colour; it just stops one drifting.
|
||||
An art editor spells brightness the DOS way — ``1m`` lifts the foreground into the
|
||||
bright bank, and each span after it inherits that. Two things then have to be settled
|
||||
before the row can be drawn anywhere else.
|
||||
|
||||
The first is that the inheritance does not survive: a row is handed to Rich on its own,
|
||||
so a span that never mentions intensity is read against whatever the *previous row* left
|
||||
set. That is resolved by working the state out here and saying it outright.
|
||||
|
||||
The second is that **bold is not brightness everywhere**. It is on the PicoCalc console
|
||||
and on a DOS one; it is not on macOS Terminal, where bold asks for a heavier face and
|
||||
leaves the colour alone. There ``1;30`` is not dark grey but plain black — and this art
|
||||
leans on it, seventy-nine spans of the wide mark being bold-black, much of that on a
|
||||
black ground. The mark came out muted, and the ``░▒▓`` dithers that fade one colour into
|
||||
another faded toward the wrong end. So brightness is emitted as the colour it means:
|
||||
``9N`` (the aixterm bright bank), which needs no bold and is also how the PicoCalc's own
|
||||
console spells bright. Nothing about *which* colour is being asked for changes; only the
|
||||
spelling, from one a terminal may read as a font weight to one that can only be a colour.
|
||||
"""
|
||||
out: list[str] = []
|
||||
bright = False
|
||||
standing: str | None = None # the dim-bank foreground currently in force
|
||||
at = 0
|
||||
while at < len(row):
|
||||
found = _SGR.match(row, at)
|
||||
@@ -177,16 +194,52 @@ def _state_intensity(row: str) -> str:
|
||||
continue
|
||||
params = found.group()[2:-1]
|
||||
parts = params.split(";") if params else [""]
|
||||
for part in parts: # what this sequence leaves the intensity set to
|
||||
if part in ("", "0", "22"):
|
||||
bright = False
|
||||
elif part == "1":
|
||||
bright = True
|
||||
dim = any(_DIM_FG.fullmatch(p) for p in parts)
|
||||
if dim and not any(p in _SAYS_INTENSITY for p in parts):
|
||||
out.append("\x1b[" + ";".join(["1" if bright else "22"] + parts) + "m")
|
||||
# What this sequence leaves the intensity set to, for the spans that follow it.
|
||||
says = [p for p in parts if p in ("", "0", "1", "22")]
|
||||
for part in says:
|
||||
bright = part == "1"
|
||||
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:
|
||||
# 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")
|
||||
elif says and here:
|
||||
# No colour named since a reset: the one in force is the console's default,
|
||||
# grey (7), and brightening it gives white. ``ESC[0m`` then ``ESC[1m`` is how
|
||||
# the art says "white", and dropping the "1" without restating anything left
|
||||
# those spans in the terminal's own default grey — a white run drawn grey.
|
||||
kept.append(str(_DEFAULT_FG + 60))
|
||||
if kept:
|
||||
out.append("\x1b[" + ";".join(kept) + "m")
|
||||
else:
|
||||
out.append(found.group())
|
||||
standing = fg
|
||||
rewritten = []
|
||||
for part in parts:
|
||||
if part == "1":
|
||||
continue # brightness is in the colour now
|
||||
if part == fg:
|
||||
rewritten.append(str(int(fg) + 60) if here else fg)
|
||||
else:
|
||||
rewritten.append(part)
|
||||
if not here and "22" not in rewritten:
|
||||
rewritten.insert(0, "22") # say dim outright; never inherit a bank
|
||||
out.append("\x1b[" + ";".join(rewritten) + "m")
|
||||
at = found.end()
|
||||
return "".join(out)
|
||||
|
||||
|
||||
+154
-55
@@ -39,7 +39,6 @@ from .menus import (
|
||||
from .surface import TuiUi
|
||||
from .theme import make_console
|
||||
from .tui import (
|
||||
CANCEL,
|
||||
Choice,
|
||||
PopToMenu,
|
||||
ReconnectDialog,
|
||||
@@ -49,6 +48,7 @@ from .tui import (
|
||||
TuiSession,
|
||||
)
|
||||
from .tui.emoji_width import install as install_emoji_widths
|
||||
from .tui.fkeys import FPair
|
||||
from .tui.spinner import spinner_interval
|
||||
from .widgets import battery_cell
|
||||
|
||||
@@ -79,6 +79,12 @@ _BLE_PRESENCE_SCAN_S = 8.0
|
||||
#: stops the loop spinning when scanning fails instantly (no adapter, Bluetooth switched off).
|
||||
_BLE_RESCAN_PAUSE_S = 1.0
|
||||
|
||||
#: After a reboot we sent, how long to leave the companion alone before the first reconnect
|
||||
#: attempt (seconds). The dialog is up the instant the command is out, which is before the
|
||||
#: board has necessarily started restarting; a port that stays put through the reboot would
|
||||
#: otherwise be reopened against the old session, which dies under it a moment later.
|
||||
_REBOOT_SETTLE_S = 1.5
|
||||
|
||||
|
||||
def _arm_exit_watchdog(seconds: float = _EXIT_WATCHDOG_S) -> None:
|
||||
"""Guarantee the process terminates even if the exit path wedges.
|
||||
@@ -363,6 +369,8 @@ def _device_label(ctx: AppContext, cache: dict) -> tuple[str, str]:
|
||||
where = "BLE"
|
||||
elif ctx.active_transport == "tcp":
|
||||
where = ctx.active_endpoint or "TCP"
|
||||
elif ctx.active_transport == "spi":
|
||||
where = "SPI"
|
||||
else:
|
||||
where = ctx.active_port or (sel.port if sel is not None else "") or ""
|
||||
cache["key"], cache["value"] = key, (name, where)
|
||||
@@ -390,6 +398,8 @@ async def run_menu(ctx: AppContext) -> None:
|
||||
header_cache: dict = {}
|
||||
session = TuiSession(header=lambda cols: _header(ctx, header_cache, cols))
|
||||
ctx.ui = TuiUi(session)
|
||||
# The one quit confirm, asked by the menu's Quit row and by the quit chord from anywhere.
|
||||
session.set_quit_confirm(lambda: _confirm_quit(ctx, session))
|
||||
|
||||
async def main() -> None:
|
||||
try:
|
||||
@@ -474,11 +484,95 @@ async def _unpair_on_exit(ctx: AppContext) -> None:
|
||||
#: can show, the same as any tool's.
|
||||
_QUIT_ICON = "🚪"
|
||||
|
||||
#: The value the Quit row resolves with — Esc's ``CANCEL`` and this both open the quit
|
||||
#: confirm (see :func:`_menu_round`).
|
||||
#: The value the Quit row resolves with, which opens the quit confirm (see
|
||||
#: :func:`_menu_round`). Esc at the menu does not: it is inert there (see :class:`_MainMenu`).
|
||||
_QUIT_VALUE = "__quit__"
|
||||
|
||||
|
||||
async def _confirm_quit(ctx: AppContext, session: TuiSession) -> bool:
|
||||
"""Float the quit confirm over whatever is up; return whether to leave.
|
||||
|
||||
One dialog for both doors — the menu's Quit row and the quit chord, which asks from
|
||||
anywhere (see :meth:`~meshterm.ui.tui.session.TuiSession.request_quit`). Cancel (Esc)
|
||||
sits left of Quit (Enter), and Quit starts highlighted so Enter commits it.
|
||||
|
||||
It offers to drop the OS pairing on the way out, but only when there is a live Bluetooth
|
||||
bond to drop — never on serial, an open (PIN-less) companion, or a platform we can't
|
||||
unpair. That button sits between Cancel and Quit and is never the default, so it takes a
|
||||
deliberate choice, not a stray Enter. The unpair itself is deferred to teardown (see
|
||||
:func:`run_menu`), because the link has to be disconnected first; the remembered-device
|
||||
record is intentionally kept, so the device just asks for its PIN again.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context (``unpair_on_exit`` is set on *Unpair & quit*).
|
||||
session: The running TUI session.
|
||||
|
||||
Returns:
|
||||
``True`` if the reader chose Quit or Unpair & quit.
|
||||
"""
|
||||
buttons = [("Cancel", "cancel")]
|
||||
if await _can_unpair(ctx):
|
||||
buttons.append(("Unpair & quit", "unpair"))
|
||||
buttons.append(("Quit", "quit"))
|
||||
choice = await session.button_dialog(
|
||||
"Are you sure you want to quit?",
|
||||
buttons,
|
||||
title="Quit MeshTerm",
|
||||
default=len(buttons) - 1, # highlight Quit
|
||||
# The hint stays the dialog default: Enter commits *the highlighted button*,
|
||||
# which is Quit only until ←→ moves it — and with a third button in the row,
|
||||
# naming the key that moves it is the point.
|
||||
prompt_style="warn",
|
||||
button_style="selected",
|
||||
button_idle_style="muted",
|
||||
border_style="warn",
|
||||
)
|
||||
if choice == "unpair":
|
||||
ctx.unpair_on_exit = True
|
||||
return choice in ("unpair", "quit")
|
||||
|
||||
|
||||
class _MainMenu(SelectScreen):
|
||||
"""The main menu's list, on which Esc peels a typed filter and otherwise does nothing.
|
||||
|
||||
Pressing Esc over and over is the natural climb back to the menu, and when Esc at the
|
||||
top opened the quit confirm, the presses that landed after the climb opened it too — the
|
||||
reader asked for the menu and got a question about leaving (issue #22). The menu is the
|
||||
bottom of the stack, so there is nothing for Esc to go back to; leaving is a choice made
|
||||
on the Quit row, or with the quit chord, both of which ask first.
|
||||
|
||||
The footer therefore ends on the quit chord rather than on Esc, until a filter is
|
||||
standing and ``Esc clear`` joins it last, because that is what the press does then —
|
||||
the same atom every filtering list shows.
|
||||
"""
|
||||
|
||||
def handle(self, action: str, data: str = "") -> None:
|
||||
"""Answer every key as a select list does, except a bare Esc, which is inert."""
|
||||
if action == "escape" and not self._filter:
|
||||
return
|
||||
super().handle(action, data)
|
||||
|
||||
@property
|
||||
def fkey_lane(self):
|
||||
"""The list's own lane, with the way out on F3: ``Quit?`` asks, ``Quit!`` doesn't.
|
||||
|
||||
The PicoCalc has no hint line, so the lane is where the menu's way out has to be
|
||||
stated. F3 is the list's one free slot (F1/F2 are the section jumps; a menu row is
|
||||
never deletable). ``Quit?`` opens the same confirm as the Quit row and ^Q; its Shift
|
||||
half, F8, leaves without asking — a two-key reach on the menu alone, for the reader
|
||||
who already decided (JP, 2026-09-29).
|
||||
"""
|
||||
lane = list(super().fkey_lane)
|
||||
lane[2] = FPair("Quit?", "quit", "Quit!", "quit_now")
|
||||
return lane
|
||||
|
||||
@property
|
||||
def footer_hint(self) -> str:
|
||||
"""The list's own hint, gaining ``Esc clear`` only while a filter is standing."""
|
||||
base = super().footer_hint
|
||||
return f"{base} · Esc clear" if self._filter else base
|
||||
|
||||
|
||||
def _menu_lane(tools: Sequence[Any]) -> int:
|
||||
"""The main menu's icon column in cells: every tool's mark *and* the Quit row's.
|
||||
|
||||
@@ -606,18 +700,20 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None:
|
||||
# Drive the menu list ourselves (rather than via session.select) so it stays on the
|
||||
# stack while the quit dialog floats over it: the confirm is drawn as a centered box
|
||||
# on top of the still-visible menu, not as a screen that replaces it.
|
||||
menu = SelectScreen(
|
||||
menu = _MainMenu(
|
||||
"What would you like to do?",
|
||||
items,
|
||||
footer_hint="↑↓ move · type to filter · Enter select · Esc quit",
|
||||
# The one place the quit chord is named: the menu is where a reader looks for the
|
||||
# way out, and with Esc inert here the footer would otherwise name none. Everywhere
|
||||
# else it stays unadvertised, like ^W (see session._CTRL_LETTER_CHORDS).
|
||||
footer_hint="↑↓ move · type to filter · Enter select · ^Q quit?",
|
||||
)
|
||||
session.set_root(menu)
|
||||
loop = asyncio.get_running_loop()
|
||||
while True:
|
||||
try:
|
||||
await _menu_round(ctx, session, menu, tools, loop)
|
||||
except _Quit as chosen:
|
||||
ctx.unpair_on_exit = chosen.unpair
|
||||
except _Quit:
|
||||
return
|
||||
except PopToMenu:
|
||||
# ^W from somewhere deep. Every frame between there and here has already
|
||||
@@ -627,18 +723,14 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None:
|
||||
|
||||
|
||||
class _Quit(Exception):
|
||||
"""The user confirmed the quit dialog; ``unpair`` says whether to drop the OS bond too.
|
||||
"""The user confirmed the quit dialog from the menu's Quit row.
|
||||
|
||||
A menu round runs a whole tool inside itself, so "the user chose Quit" cannot be spelled
|
||||
as a ``return`` — the loop would just show the menu again. It is raised instead, and
|
||||
caught in the one frame that owns the decision to leave.
|
||||
caught in the one frame that owns the decision to leave. Whether to drop the OS bond too
|
||||
is already recorded on the context by the confirm (see :func:`_confirm_quit`).
|
||||
"""
|
||||
|
||||
def __init__(self, *, unpair: bool) -> None:
|
||||
"""Record whether the OS pairing should be dropped on the way out."""
|
||||
super().__init__("quit")
|
||||
self.unpair = unpair
|
||||
|
||||
|
||||
async def _menu_round(
|
||||
ctx: AppContext,
|
||||
@@ -669,41 +761,11 @@ async def _menu_round(
|
||||
ran_over_menu = False
|
||||
try:
|
||||
selection = await menu.future
|
||||
if selection is CANCEL: # Esc at the top level
|
||||
selection = None
|
||||
# Both picking "quit" and pressing Esc ask to leave; confirm on a dialog floating
|
||||
# over the (still-pushed) menu so a stray key doesn't drop the user out. Cancel
|
||||
# (Esc) sits left of Quit (Enter); Quit starts highlighted so Enter commits it.
|
||||
if selection in (None, _QUIT_VALUE):
|
||||
# Offer to drop the OS pairing on the way out, but only when there is a live
|
||||
# Bluetooth bond to drop — never on serial, an open (PIN-less) companion, or a
|
||||
# platform we can't unpair. The extra button sits between Cancel and Quit and is
|
||||
# never the default, so it takes a deliberate choice, not a stray Enter.
|
||||
unpairable = await _can_unpair(ctx)
|
||||
buttons = [("Cancel", "cancel")]
|
||||
if unpairable:
|
||||
buttons.append(("Unpair & quit", "unpair"))
|
||||
buttons.append(("Quit", "quit"))
|
||||
choice = await session.button_dialog(
|
||||
"Are you sure you want to quit?",
|
||||
buttons,
|
||||
title="Quit MeshTerm",
|
||||
default=len(buttons) - 1, # highlight Quit
|
||||
# The hint stays the dialog default: Enter commits *the highlighted
|
||||
# button*, which is Quit only until ←→ moves it — and with a third
|
||||
# button in the row, naming the key that moves it is the point.
|
||||
prompt_style="warn",
|
||||
button_style="selected",
|
||||
button_idle_style="muted",
|
||||
border_style="warn",
|
||||
)
|
||||
# "Unpair & quit" forgets the OS bond as we leave; the disconnect must happen
|
||||
# first, so the actual unpair is deferred to teardown (see run_menu). The
|
||||
# remembered-device record is intentionally kept — the device just asks for its
|
||||
# PIN again. Cancel (button or Esc → None) ends the round and redraws the menu,
|
||||
# whose cursor never moved off the row they came from.
|
||||
if choice in ("unpair", "quit"):
|
||||
raise _Quit(unpair=choice == "unpair")
|
||||
# The Quit row asks first, on a dialog floating over the (still-pushed) menu; Cancel
|
||||
# ends the round and redraws the menu, whose cursor never moved off the row.
|
||||
if selection == _QUIT_VALUE:
|
||||
if await session.confirm_quit():
|
||||
raise _Quit()
|
||||
return
|
||||
# A popup tool runs while the menu is still pushed, so its prompts and
|
||||
# result float over it as modal dialogs instead of replacing the screen.
|
||||
@@ -730,6 +792,7 @@ async def _startup(ctx: AppContext) -> bool:
|
||||
if not (ctx.mock or ctx.explicit_selection):
|
||||
from ..core.connection import probe_device
|
||||
from ..core.discovery import DiscoveredDevice, discover_all
|
||||
from ..core.spiradio import state_dir
|
||||
from .device_picker import prompt_device
|
||||
from .logo import load_logo
|
||||
|
||||
@@ -750,7 +813,12 @@ async def _startup(ctx: AppContext) -> bool:
|
||||
# ``pin`` is what the picker's PIN dialog collected on a retry; fall back to any
|
||||
# ``--ble-pin`` supplied on the CLI for the first attempt.
|
||||
used = pin if pin is not None else ctx.ble_pin
|
||||
result = await probe_device(device, baudrate=baudrate, pin=used)
|
||||
spi_state = (
|
||||
state_dir(ctx.settings.config_dir, device.spi or ctx.spi_wiring_for(device.port))
|
||||
if device.is_spi
|
||||
else None
|
||||
)
|
||||
result = await probe_device(device, baudrate=baudrate, pin=used, spi_state=spi_state)
|
||||
if result is None:
|
||||
return None
|
||||
connection, info = result
|
||||
@@ -770,7 +838,14 @@ async def _startup(ctx: AppContext) -> bool:
|
||||
if chosen is None:
|
||||
return False # the user quit at the splash — exit without opening the menu
|
||||
ctx.selected_device = chosen
|
||||
if chosen.is_tcp:
|
||||
if chosen.is_spi:
|
||||
# A reconnect rebuilds the device from the context, so it has to know which radio.
|
||||
# An SPI profile names its own; a bare row is the AIO's, which ``--spi`` resolves.
|
||||
ctx.spi_override = True
|
||||
ctx.tcp_override = None
|
||||
ctx.ble_override = None
|
||||
ctx.port_override = None
|
||||
elif chosen.is_tcp:
|
||||
ctx.tcp_override = chosen.target
|
||||
ctx.ble_override = None
|
||||
ctx.port_override = None
|
||||
@@ -1029,6 +1104,20 @@ async def _wait_for_disconnect(ctx: AppContext) -> None:
|
||||
if ctx.mock:
|
||||
await asyncio.Event().wait() # the simulator is never "unplugged"; wait forever
|
||||
return
|
||||
# A reboot we sent is a drop we already know about: race it against the poll so the
|
||||
# reconnect dialog is up the moment the command is out, not whenever (or if ever) a
|
||||
# poll happens to land inside the reboot.
|
||||
announced = asyncio.ensure_future(ctx.link_going_down())
|
||||
polled = asyncio.ensure_future(_poll_for_disconnect(ctx))
|
||||
try:
|
||||
await asyncio.wait({announced, polled}, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
await _cancel_and_wait(announced)
|
||||
await _cancel_and_wait(polled)
|
||||
|
||||
|
||||
async def _poll_for_disconnect(ctx: AppContext) -> None:
|
||||
"""Resolve once the liveness check sees the link gone, debounced against a blip."""
|
||||
while True:
|
||||
await asyncio.sleep(_LIVENESS_POLL_S)
|
||||
if not ctx.is_connected:
|
||||
@@ -1061,8 +1150,8 @@ async def _handle_disconnect(ctx: AppContext, session: TuiSession) -> bool:
|
||||
# A drop the config editor announced (it just sent a reboot command) is expected, so
|
||||
# the dialog says what is actually happening instead of implying an unplug. The flag
|
||||
# is consumed here so a later, genuine disconnect goes back to the generic wording.
|
||||
if ctx.reboot_in_progress:
|
||||
ctx.reboot_in_progress = False
|
||||
rebooting = ctx.take_reboot()
|
||||
if rebooting:
|
||||
dialog = ReconnectDialog(
|
||||
"Rebooting — waiting for the device to come back…",
|
||||
title="Device rebooting",
|
||||
@@ -1081,7 +1170,9 @@ async def _handle_disconnect(ctx: AppContext, session: TuiSession) -> bool:
|
||||
session.push(base)
|
||||
session.push(dialog)
|
||||
animator = asyncio.ensure_future(_animate_dialog(session, dialog))
|
||||
reconnector = asyncio.ensure_future(_auto_reconnect(ctx, dialog))
|
||||
reconnector = asyncio.ensure_future(
|
||||
_auto_reconnect(ctx, dialog, settle_s=_REBOOT_SETTLE_S if rebooting else 0.0)
|
||||
)
|
||||
try:
|
||||
result = await dialog.future
|
||||
finally:
|
||||
@@ -1100,7 +1191,9 @@ async def _animate_dialog(session: TuiSession, dialog: ReconnectDialog) -> None:
|
||||
session.invalidate()
|
||||
|
||||
|
||||
async def _auto_reconnect(ctx: AppContext, dialog: ReconnectDialog) -> None:
|
||||
async def _auto_reconnect(
|
||||
ctx: AppContext, dialog: ReconnectDialog, *, settle_s: float = 0.0
|
||||
) -> None:
|
||||
"""Poll for the device to return, reconnect when it does, then dismiss ``dialog``.
|
||||
|
||||
The dead link is released up front (:meth:`~meshterm.context.AppContext.release_link`) so
|
||||
@@ -1126,6 +1219,10 @@ async def _auto_reconnect(ctx: AppContext, dialog: ReconnectDialog) -> None:
|
||||
Args:
|
||||
ctx: The shared application context.
|
||||
dialog: The reconnect dialog to dismiss once the link is back.
|
||||
settle_s: How long to leave the device alone after releasing the link, before the
|
||||
first attempt. Nonzero only for a reboot we sent: the watcher fires the instant
|
||||
the command is out, and a serial port that never went away would otherwise be
|
||||
reopened against a board that has not yet begun to restart.
|
||||
"""
|
||||
from ..core.connection import serial_port_present
|
||||
from ..core.discovery import find_ble_device
|
||||
@@ -1134,6 +1231,8 @@ async def _auto_reconnect(ctx: AppContext, dialog: ReconnectDialog) -> None:
|
||||
# still nominally connected to does not advertise, so the scan below would wait out a
|
||||
# peripheral that is powered on and silent purely because we never let go of it.
|
||||
await ctx.release_link()
|
||||
if settle_s:
|
||||
await asyncio.sleep(settle_s)
|
||||
|
||||
while True:
|
||||
ble_device: object | None = None
|
||||
|
||||
@@ -35,12 +35,19 @@ detail read together:
|
||||
under a cracked chip at whichever edge continues, and snaps back the moment the
|
||||
selection moves on. A row you are *not* on is cut the same way — it just cannot slide.
|
||||
|
||||
The message's **scope** — the region it was flooded into — rides the title as a status
|
||||
atom (``Message paths · scope harbour``), stated once for the message rather than per
|
||||
arrival: every copy carries its sender's transport code unchanged (see
|
||||
:func:`~meshterm.services.message_paths.message_scope`).
|
||||
|
||||
Nothing here transmits; like the service beneath it, this is a read-model over what
|
||||
the radio already heard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from ..core.models import ChatMessage
|
||||
@@ -58,7 +65,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,
|
||||
@@ -67,8 +74,12 @@ from .widgets import (
|
||||
node_type_legend,
|
||||
revisit_note,
|
||||
route_graph_style,
|
||||
scope_text,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.regions import Scope
|
||||
|
||||
#: Cells one ←/→ press shifts the selected row by.
|
||||
_HSTEP = 4
|
||||
|
||||
@@ -108,6 +119,8 @@ class MessagePathsScreen(Screen):
|
||||
destination: str | None = None,
|
||||
type_of: TypeOf | None = None,
|
||||
key_of: NameKeyResolver | None = None,
|
||||
scope: Scope | None = None,
|
||||
sent_scope: Text | None = None,
|
||||
) -> None:
|
||||
"""Build the dialog over one message's matched arrivals.
|
||||
|
||||
@@ -135,9 +148,15 @@ class MessagePathsScreen(Screen):
|
||||
key_of: Maps the origin's display name back to its node's key, so the
|
||||
graph's left endpoint takes its key-derived hue; ``None`` (or an
|
||||
unresolvable name) leaves it muted.
|
||||
scope: The region the message was flooded into, read off its arrivals'
|
||||
frames (:func:`~meshterm.services.message_paths.message_scope`); ``None``
|
||||
where no copy was a flood, and the title then states nothing.
|
||||
sent_scope: For a message we sent, the line saying what it was flooded under
|
||||
(``sent under scope yul``, and why nothing relayed it where that is
|
||||
known), drawn under the summary; ``None`` draws nothing.
|
||||
"""
|
||||
super().__init__()
|
||||
self.title = "Message paths"
|
||||
self.title = self.titled(scope)
|
||||
self._message = message
|
||||
self._arrivals = arrivals
|
||||
self._matched = matched
|
||||
@@ -149,6 +168,7 @@ class MessagePathsScreen(Screen):
|
||||
self._destination = destination
|
||||
self._type_of = type_of
|
||||
self._key_of = key_of
|
||||
self._sent_scope = sent_scope
|
||||
self._index = 0
|
||||
#: Cells the selected row is shifted left by (reset whenever ↑↓ move), and
|
||||
#: how far it *can* shift, measured against the width of the last render.
|
||||
@@ -161,6 +181,23 @@ class MessagePathsScreen(Screen):
|
||||
self._list = ListWindow()
|
||||
self._list_hidden = False
|
||||
|
||||
@staticmethod
|
||||
def titled(scope: Scope | None) -> str:
|
||||
"""The dialog's title: its name, then the message's scope as a status atom.
|
||||
|
||||
Every copy of one message carries its sender's transport code (the code is over the
|
||||
payload, which no relay touches), so the scope is a fact about the *message*, not
|
||||
about any one arrival — stated once, up here, rather than repeated down a list whose
|
||||
rows differ only by path. It rides the title because the title is where a screen's
|
||||
status atoms chain (``Message paths · scope harbour``) and it costs the body no
|
||||
line: on the PicoCalc's 26 rows a line spent here is a lane of the fan or a row of
|
||||
the list. The words are :func:`~meshterm.ui.widgets.scope_text`'s, plain — a title
|
||||
carries no styling — so ``unscoped`` and ``unknown scope 3fa1`` read here exactly as they
|
||||
do on the packet viewer's ``route`` row.
|
||||
"""
|
||||
atom = scope_text(scope).plain
|
||||
return f"Message paths · {atom}" if atom else "Message paths"
|
||||
|
||||
# --- input ---------------------------------------------------------------
|
||||
|
||||
@property
|
||||
@@ -255,6 +292,9 @@ class MessagePathsScreen(Screen):
|
||||
render_to_ansi(Text(f"“{quoted}”"), width, no_wrap=True),
|
||||
render_to_ansi(stamp, width, no_wrap=True),
|
||||
]
|
||||
if self._sent_scope is not None:
|
||||
# Wrapped rather than cut: its tail is the reason a message went nowhere.
|
||||
lines.extend(render_lines(self._sent_scope, width))
|
||||
self._cursor = None
|
||||
self._list_hidden = False
|
||||
if not self._arrivals:
|
||||
@@ -265,7 +305,9 @@ class MessagePathsScreen(Screen):
|
||||
if self._matched
|
||||
else "No direct-message frames logged in the window."
|
||||
)
|
||||
lines.append(render_to_ansi(Text(note, style="muted"), width))
|
||||
# One entry per drawn row: a wrapped render_to_ansi is one string holding a
|
||||
# newline, which the frame counts as a single line and measures as one.
|
||||
lines.extend(render_lines(Text(note, style="muted"), width))
|
||||
self._scroll_total = len(lines)
|
||||
return lines
|
||||
|
||||
@@ -293,7 +335,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("")
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ the node itself, in full:
|
||||
|
||||
* **Info** — the node's vitals as labelled rows (its key with the routing hash lit, when
|
||||
it was first and last heard, how many packets we've overheard, its reception SNR and
|
||||
last RSSI, and where it sits). The key is a single lane, never wrapped: a full public
|
||||
last RSSI, where it sits, and — for a repeater — the regions it was last heard to relay
|
||||
floods for, whether it relays unscoped floods too, and when it said so; see
|
||||
:func:`regions_value`). The key is a single lane, never wrapped: a full public
|
||||
key outruns the row on any terminal we target, so ``←→`` scroll it a whole four bytes
|
||||
at a time under faint ``…`` edge marks, exactly as a long pathline scrolls on the
|
||||
Routes tab. Then — when the node has advertised a location — a
|
||||
@@ -24,7 +26,10 @@ the node itself, in full:
|
||||
map opens centred here with its find filter seeded to this node, so it lights among
|
||||
the rest), ``Time machine``, ``Share contact`` — a popup contact card (QR code +
|
||||
``meshcore://`` link, see :func:`~meshterm.ui.config_editor.show_contact_card`),
|
||||
offered whenever the node's full key is known — and, last, ``Remove contact``: the
|
||||
offered whenever the node's full key is known — ``Ask which regions it carries`` on a
|
||||
repeater (one anonymous request, answered only when it arrives direct; the answer is
|
||||
learned into the region store and the row redraws in place) — and, last,
|
||||
``Remove contact``: the
|
||||
single-contact counterpart to the Contacts list's bulk archive (see
|
||||
:mod:`~meshterm.ui.contacts_screen`), dropping *this* node from the device's contact
|
||||
table behind a red confirm. It is the one thing on the page that changes anything, so
|
||||
@@ -68,7 +73,8 @@ The screen is a pure read-and-route view: it renders already-resolved display da
|
||||
resolves an action token (the Trace token carrying the selected route's spec via
|
||||
:meth:`NodeDetailScreen.selected_spec`); :func:`open_node_detail` owns the data-gathering
|
||||
and runs the sub-flows each action opens, then re-shows the page — the same loop the Time
|
||||
Machine and Contacts list use. Nothing here transmits.
|
||||
Machine and Contacts list use. The screen itself never transmits; the one action that does
|
||||
is the regions question, asked once, by the opener, when the reader commits its row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -85,9 +91,9 @@ from typing import TYPE_CHECKING
|
||||
from rich.cells import cell_len
|
||||
from rich.text import Text
|
||||
|
||||
from ..core.connection import ContactNotOnDeviceError
|
||||
from ..core.connection import ContactNotOnDeviceError, DeviceCommandError
|
||||
from ..core.geo import haversine_km, usable_fix
|
||||
from ..core.models import NODE_TYPE_LABELS, Contact, utcnow
|
||||
from ..core.models import NODE_TYPE_LABELS, NODE_TYPE_REPEATER, Contact, utcnow
|
||||
from ..platforms import Platform, on_platform
|
||||
from . import menus
|
||||
from .mapcanvas import RGB
|
||||
@@ -576,6 +582,18 @@ class NodeDetailScreen(Screen):
|
||||
trace = [self._trace_action] if self._trace_action else []
|
||||
return menus.icon_lane(action.glyph for action in (*self._info_actions, *trace))
|
||||
|
||||
def replace_info_row(self, label: str, value: Text) -> None:
|
||||
"""Swap one vital's value in place — the regions row, once the repeater has answered.
|
||||
|
||||
The row keeps its place in the block (a label not already there is appended), and
|
||||
the cursor stays on whatever it was on: the page is the same visit, one fact newer.
|
||||
"""
|
||||
for i, (existing, _value) in enumerate(self._info_rows):
|
||||
if existing == label:
|
||||
self._info_rows[i] = (label, value)
|
||||
return
|
||||
self._info_rows.append((label, value))
|
||||
|
||||
def replace_info_actions(self, actions: list[_Action]) -> None:
|
||||
"""Swap the Info tab's action rows in place, keeping the cursor on the row it was on.
|
||||
|
||||
@@ -950,7 +968,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
|
||||
|
||||
@@ -1279,6 +1297,20 @@ async def open_node_detail(
|
||||
info_rows.append(("signal", signal))
|
||||
if lat is not None and lon is not None:
|
||||
info_rows.append(("where", _range_text(lat, lon, self_lat, self_lon)))
|
||||
# A repeater's regions: what it was last heard to relay, from the region store (its own
|
||||
# answer to the regions request, or its table read on the admin page). Only a repeater
|
||||
# answers the request, so only a repeater's page asks it.
|
||||
region_store = getattr(ctx, "region_store", None)
|
||||
asks_regions = (
|
||||
not you
|
||||
and contact is not None
|
||||
and node_type == NODE_TYPE_REPEATER
|
||||
and bool(node_id)
|
||||
and region_store is not None
|
||||
)
|
||||
routed = contact is not None and contact.route_hops is not None
|
||||
if asks_regions:
|
||||
info_rows.append(("regions", regions_value(region_store, node_id, routed=routed)))
|
||||
|
||||
# -- the location preview (only when the node advertised a fix).
|
||||
minimap: MiniMap | None = None
|
||||
@@ -1347,6 +1379,9 @@ async def open_node_detail(
|
||||
full_key = key.lower().removeprefix("0x")
|
||||
if len(full_key) == 64 and is_path_hash(full_key):
|
||||
info_actions.append(_Action("share", "📱", "", "Share contact — QR / link"))
|
||||
if asks_regions:
|
||||
# Acts at once — one anonymous request, one transmission — so no trailing "…".
|
||||
info_actions.append(_Action("regions", "🔖", "", "Ask which regions it carries"))
|
||||
# -- contact management, the page's last group and the only actions that end the visit.
|
||||
# Withheld entirely when the caller opened the page to look rather than to act (see
|
||||
# ``manage``), and never offered for our own node or for a contact carrying no key at
|
||||
@@ -1450,6 +1485,13 @@ async def open_node_detail(
|
||||
)
|
||||
elif action == "share":
|
||||
await show_contact_card(ctx, label, full_key, adv_type)
|
||||
elif action == "regions":
|
||||
assert contact is not None and region_store is not None # only offered so
|
||||
failed = await _ask_regions(ctx, contact, node_id, label, routed=routed)
|
||||
screen.replace_info_row(
|
||||
"regions",
|
||||
regions_value(region_store, node_id, routed=routed, failed=failed),
|
||||
)
|
||||
elif action == "timemachine":
|
||||
if you:
|
||||
await open_timemachine_self(ctx)
|
||||
@@ -1556,7 +1598,7 @@ async def _archive_contact(ctx: AppContext, contact: Contact, self_key: str, lab
|
||||
)
|
||||
prompt.append(
|
||||
"? It comes off this device's contact list, freeing a slot for a new one. MeshTerm "
|
||||
"keeps it — with its key, reception history and messages — and you can restore it "
|
||||
"keeps it — with its key, reception history, and messages — and you can restore it "
|
||||
"at any time.",
|
||||
style="warn",
|
||||
)
|
||||
@@ -1727,6 +1769,88 @@ async def _remove_contact(ctx: AppContext, contact: Contact, self_key: str, labe
|
||||
return True
|
||||
|
||||
|
||||
#: Why a repeater may not answer the regions request, in the words the page uses for it.
|
||||
_REGIONS_REACH = "it answers only a neighbour, or over a known route"
|
||||
|
||||
|
||||
def regions_value(store, node_id: str, *, routed: bool, failed: bool = False) -> Text: # noqa: ANN001 - RegionStore
|
||||
"""A repeater's ``regions`` vital: what it relays, whether unscoped too, and how fresh.
|
||||
|
||||
Four states, each in its own words so none reads as another:
|
||||
|
||||
* **answered** — the regions it named, then whether it also relays *unscoped* floods
|
||||
(the ``*`` its answer leads with, which names no region), then when it said so. A
|
||||
repeater that relays unscoped floods and no region reads ``unscoped floods only``, and
|
||||
one that named nothing at all reads that it relays no floods;
|
||||
* **not asked** — nothing on record; a repeater with no known route also says why the
|
||||
question may go unanswered (the request is only answered when it arrives direct);
|
||||
* **no answer** — the question was just asked and nothing came back, on a repeater that
|
||||
never answered before. One that answered earlier keeps its answer, marked stale by its
|
||||
age: an unanswered repeat says nothing about what it carries.
|
||||
|
||||
Args:
|
||||
store: The region store.
|
||||
node_id: The repeater's 12-hex id.
|
||||
routed: Whether the device holds a route to it (a neighbour or a learned path).
|
||||
failed: Whether the ask this visit went unanswered.
|
||||
|
||||
Returns:
|
||||
The row's value.
|
||||
"""
|
||||
answer = store.answer_of(node_id)
|
||||
if answer is None:
|
||||
if failed:
|
||||
text = Text("no answer", style="warn")
|
||||
text.append(f" — {_REGIONS_REACH}", style="muted")
|
||||
return text
|
||||
text = Text("not asked yet", style="muted")
|
||||
if not routed:
|
||||
text.append(f" — {_REGIONS_REACH}", style="muted")
|
||||
return text
|
||||
names = store.carried_by(node_id)
|
||||
text = Text()
|
||||
if names:
|
||||
text.append(", ".join(names))
|
||||
text.append(" · " + ("unscoped too" if answer.unscoped else "scoped only"), style="muted")
|
||||
elif answer.unscoped:
|
||||
text.append("unscoped floods only")
|
||||
else:
|
||||
text.append("none — it relays no floods", style="warn")
|
||||
text.append(f" · answered {format_ago(age_seconds(answer.answered_at))}", style="muted")
|
||||
if failed:
|
||||
text.append(" · no answer now", style="warn")
|
||||
return text
|
||||
|
||||
|
||||
async def _ask_regions(
|
||||
ctx: AppContext, contact: Contact, node_id: str, label: str, *, routed: bool
|
||||
) -> bool:
|
||||
"""Ask a repeater which regions it carries — once — and learn the answer.
|
||||
|
||||
One anonymous request, one transmission, under the busy overlay; the answer replaces
|
||||
what the store held for this repeater (:meth:`~meshterm.core.region_store.RegionStore.
|
||||
learn_carried`). An unanswered request is said in a popup with the reason it most
|
||||
likely went unanswered — the request is dropped unless it arrives direct, and the
|
||||
repeater rate-limits it — and nothing retries it.
|
||||
|
||||
Returns:
|
||||
``True`` when the repeater did not answer.
|
||||
"""
|
||||
device = await ctx.device()
|
||||
try:
|
||||
async with ctx.ui.busy_overlay():
|
||||
names = await device.request_regions(contact)
|
||||
except DeviceCommandError as exc:
|
||||
where = "" if routed else " There is no known route to it, so only a neighbour answers."
|
||||
await ctx.ui.session.message_dialog(
|
||||
Text(f"{exc}{where}", style="warn"),
|
||||
title=f"Regions — {label}",
|
||||
)
|
||||
return True
|
||||
ctx.region_store.learn_carried(node_id, names)
|
||||
return False
|
||||
|
||||
|
||||
def _signal_row(hn) -> Text | None: # noqa: ANN001 - Optional[HeardNode]
|
||||
"""Median/best reception SNR and last RSSI, or ``None`` when nothing was measured."""
|
||||
if hn is None:
|
||||
|
||||
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user