Compare commits
No files matched your search
@@ -135,10 +135,36 @@ alias to the gather step instead.
|
||||
What *does* still carry the number: `docs/cli/README.md` has a sample document with a `"version"`
|
||||
field, which is the live `{version}` placeholder and must show the new one.
|
||||
|
||||
### The screenshots and the video — made again, after the bump
|
||||
|
||||
Every release refreshes the screenshots (JP, 2026-10-16: "make it so the images are
|
||||
automatically updated when we release") and the website's demo video (JP, 2026-10-17).
|
||||
Both show the version in their header, so they run **after** `__version__` changes and
|
||||
before the commit: follow the **`website-media` skill in release mode**. It runs
|
||||
`scripts/screenshots.py` (~1 min) then `scripts/video.py` (~4 min), and says what to look
|
||||
at before anything is committed.
|
||||
|
||||
The two land in different repositories:
|
||||
|
||||
- The screenshots (`docs/screenshots/`) ride in the release commit, so the tag carries
|
||||
them and the `website` job builds meshterm.net from them. So does `README.md`, whose
|
||||
video line `scripts/video.py --readme` points at the new upload.
|
||||
- The video, its poster, and its play card go to the site checkout beside this one
|
||||
(`../meshterm.net/src/`) and get a commit there ("The demo video of MeshTerm X.Y.Z"). The
|
||||
`website` job builds from the site's `main`, so that commit is **pushed before the tag**
|
||||
(step 5).
|
||||
|
||||
If either fails, stop and fix it, or ask JP whether to release with last release's
|
||||
images and video — don't commit a half-written set.
|
||||
|
||||
## 5. Commit, tag, push
|
||||
|
||||
Stage by name, not with `-a`: other sessions work in the same tree, and `-a` would sweep
|
||||
their unfinished edits into the release.
|
||||
|
||||
```
|
||||
git commit -a -m "MeshTerm X.Y.Z" -m "Co-Authored-By: <the model running this skill> <noreply@anthropic.com>"
|
||||
git add CHANGELOG.md meshterm/__init__.py docs/cli/README.md docs/screenshots README.md
|
||||
git commit -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"
|
||||
```
|
||||
|
||||
@@ -149,12 +175,17 @@ hardcoded one. Seven release commits (0.3.1 through 0.10.0) went out without it
|
||||
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:
|
||||
the push is the act that builds and publishes the release. The site goes first, so the
|
||||
`website` job finds the new video on its `main`:
|
||||
|
||||
```
|
||||
git -C ../meshterm.net push origin main
|
||||
git push origin main && git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
The one confirmation covers both pushes: say that the site push carries the new video
|
||||
(and any other site commits that are ahead of origin, by subject) when you ask.
|
||||
|
||||
This is the one workflow where pushing is expected. It is still asked for, once, in that
|
||||
moment — the standing rule against unasked pushes is not suspended by the skill, it is
|
||||
satisfied by the answer.
|
||||
@@ -167,20 +198,29 @@ gh release view vX.Y.Z
|
||||
```
|
||||
|
||||
Five platform builds, the release job, and `website` — which rebuilds meshterm.net from the
|
||||
tag so the site shows the new version — so it takes a few minutes. If only `website` fails,
|
||||
tag so the site shows the new version, the screenshots, and the video of step 4 — so it
|
||||
takes a few minutes. Once `website` has pushed its "MeshTerm X.Y.Z" commit, bring the
|
||||
local site up to date: `git -C ../meshterm.net pull --ff-only`. `website` runs the site repo's own `build.py`, so a change to the site's sources
|
||||
must be pushed to `meshterm.net` before the tag, or the job builds with the old ones. 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.
|
||||
|
||||
`store` also runs after publishing, so its failure leaves the release fine too. It opens a
|
||||
pull request on `CardputerZero/packages` (from JP's fork), and the store merges an update
|
||||
by itself once its own check passes — no video after the first release. Confirm the PR
|
||||
exists and its check passed:
|
||||
by itself once its own check passes — no video after the first release. **The store is
|
||||
part of every release** (JP, 2026-10-14: "when we release, include the Cardputer Zero App
|
||||
Store"), so a release is not done until that PR is merged. Confirm the PR exists, its
|
||||
check passed, and it merged:
|
||||
|
||||
```
|
||||
gh pr list -R CardputerZero/packages --author jpmartineau --state all --limit 3
|
||||
```
|
||||
|
||||
If it is still open, wait for the store's check (`gh pr checks <n> -R
|
||||
CardputerZero/packages --watch`). A failed check is a release problem to fix, not a note
|
||||
to leave for later.
|
||||
|
||||
If `store` failed, the usual causes are the `CARDPUTER_STORE_TOKEN` secret (missing, or the
|
||||
token revoked) or a change in czdev: the job pins AppBuilder to one commit because
|
||||
`scripts/cardputer-zero/store-publish.py` corrects two of its functions, and the script
|
||||
@@ -232,9 +272,15 @@ the server, in plain words (they use MeshTerm; they don't read the diff):
|
||||
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);
|
||||
- one line for Cardputer Zero owners: the update is in M5Stack's app store on the device.
|
||||
Say so only once the store PR has merged; while it is open, say it reaches the store
|
||||
soon;
|
||||
- right under the download link, a kind ask for a star on GitHub, opening on ⭐, with
|
||||
the reason in a few words: stars give the project credibility and visibility, so more
|
||||
MeshCore users find it;
|
||||
- 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.
|
||||
no emoji beyond one at the start if it fits and the star line's ⭐. Well under
|
||||
Discord's 2000 characters — it should fit on a phone screen.
|
||||
|
||||
```
|
||||
**MeshTerm 0.10.1 is out**: three small command-line fixes.
|
||||
@@ -244,6 +290,8 @@ the server, in plain words (they use MeshTerm; they don't read the diff):
|
||||
- `platform --help` and `specimen --help` read like the rest
|
||||
|
||||
Download: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.1
|
||||
Cardputer Zero: update MeshTerm from the app store on the device.
|
||||
⭐ If you enjoy MeshTerm, please star it on GitHub: https://github.com/jpmartineau/MeshTerm. Every star gives the project more credibility and helps other MeshCore users find it. Thank you!
|
||||
```
|
||||
|
||||
Say who it was written for, then show it last in the reply, so it's the easiest thing to
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Preflight for a MeshTerm release, and the answer to "what's in the next one?".
|
||||
|
||||
Reports the six things worth knowing before a version number is written down, and says
|
||||
Reports the seven things worth knowing before a version number is written down, and says
|
||||
nothing else: the current version and the tag it belongs to, the commits since, whether
|
||||
there is an `[Unreleased]` section to rename, which changelog link refs are missing, and
|
||||
whether the working tree is clean and level with `origin/main`.
|
||||
there is an `[Unreleased]` section to rename, which changelog link refs are missing,
|
||||
whether the working tree is clean and level with `origin/main`, and the same for the site
|
||||
checkout beside it, where the release's demo video goes (the `website-media` skill).
|
||||
|
||||
It changes nothing. Every finding is a line a person acts on, not a gate that blocks.
|
||||
"""
|
||||
@@ -19,13 +20,14 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
INIT = ROOT / "meshterm" / "__init__.py"
|
||||
CHANGELOG = ROOT / "CHANGELOG.md"
|
||||
SITE = ROOT.parent / "meshterm.net"
|
||||
|
||||
OK, WARN, INFO = "ok ", "warn", " "
|
||||
|
||||
|
||||
def git(*args: str) -> str:
|
||||
"""Run a git command in the repo, returning stripped stdout ('' on failure)."""
|
||||
done = subprocess.run(["git", "-C", str(ROOT), *args], capture_output=True, text=True)
|
||||
def git(*args: str, repo: Path = ROOT) -> str:
|
||||
"""Run a git command in ``repo``, returning stripped stdout ('' on failure)."""
|
||||
done = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True)
|
||||
return done.stdout.strip() if done.returncode == 0 else ""
|
||||
|
||||
|
||||
@@ -115,6 +117,24 @@ def main() -> int:
|
||||
behind, ahead = counts.split()
|
||||
say(OK if behind == "0" else WARN, f"{ahead} ahead / {behind} behind origin/main")
|
||||
|
||||
print(f"\nSite ({SITE.name}, where the demo video goes)")
|
||||
if not (SITE / ".git").exists():
|
||||
say(WARN, f"no checkout at {SITE}: the video cannot be made")
|
||||
else:
|
||||
dirty = git("status", "--porcelain", "--", "src", repo=SITE)
|
||||
count = len(dirty.splitlines())
|
||||
say(OK if not dirty else WARN, "src/ clean" if not dirty else f"src/: {count} change(s)")
|
||||
git("fetch", "--quiet", "origin", repo=SITE)
|
||||
counts = git("rev-list", "--left-right", "--count", "origin/main...HEAD", repo=SITE)
|
||||
if counts:
|
||||
behind, ahead = counts.split()
|
||||
say(OK if behind == "0" else WARN, f"{ahead} ahead / {behind} behind origin/main")
|
||||
if behind != "0":
|
||||
say(INFO, "pull --ff-only before making the video")
|
||||
# These go out with the release's site push, before the tag.
|
||||
for subject in git("log", "--format=%s", "origin/main..HEAD", repo=SITE).splitlines():
|
||||
say(INFO, f"ahead: {subject}")
|
||||
|
||||
print("\nRun the gates before tagging:")
|
||||
print(" python -m pytest -q && ruff check . && ruff format --check .\n")
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
---
|
||||
name: website-media
|
||||
description: Make the website's demo video and screenshots again from the demo mesh — the terminal video on meshterm.net and in MeshTerm's README (scripts/video.py) and the twelve screenshots of the README and the website (scripts/screenshots.py) — check them, upload the video for the README, and commit them in both repositories. Use when JP asks to make, redo, refresh, or update the video, the demo recording, the screenshots, the website's images, or the website after a visible change; the release skill also runs it after each version bump.
|
||||
---
|
||||
|
||||
# Making the website's video and screenshots
|
||||
|
||||
The two always go together (JP, 2026-10-17: "include it with the screenshot updates when we
|
||||
update the website, manually or automatically when we release"). Both come from the demo
|
||||
mesh (`meshterm --demo`), headless, and both show the version in their header, so a set
|
||||
made before a bump is already stale.
|
||||
|
||||
| What | Script | Where it goes | Time |
|
||||
|---|---|---|---|
|
||||
| 12 screenshots + 2 splash wordmarks | `scripts/screenshots.py` | MeshTerm `docs/screenshots/` | ~1 min |
|
||||
| The demo video, its poster, its play card | `scripts/video.py` | meshterm.net `src/demo.mp4`, `src/demo-poster.png`, `src/demo-play.png` | ~4 min |
|
||||
| The README's copy of the video | `scripts/video.py --readme` | a GitHub attachment, and its URL in MeshTerm `README.md` | ~10 s |
|
||||
|
||||
**The video is terminal-only, on purpose** (JP, 2026-10-17: "it's marketing after all").
|
||||
Don't offer handheld videos again.
|
||||
|
||||
**Why the video lives in the site repository and not in the tag:** it is ~4 MB, and it
|
||||
changes at every release (the version is on screen). In MeshTerm, that would grow every
|
||||
clone of the code by 4 MB a release. The site repository already keeps the copy it serves.
|
||||
The cost is one more push at release time (see *Release mode*).
|
||||
|
||||
## Before you start
|
||||
|
||||
- The site checkout must sit beside this one, at `../meshterm.net`. The video script
|
||||
writes there by default and stops if the folder is missing.
|
||||
- The site must be in sync and clean where the video goes:
|
||||
|
||||
```
|
||||
git -C ../meshterm.net fetch -q
|
||||
git -C ../meshterm.net status -sb
|
||||
```
|
||||
|
||||
If it is behind (the release workflow's `website` job pushes a "MeshTerm X.Y.Z" commit
|
||||
each release), `git -C ../meshterm.net pull --ff-only`. If `src/demo*` has uncommitted
|
||||
changes you didn't make, stop and ask.
|
||||
- Needs: the `dev` extras (Pillow, fontTools, time-machine), the emulator's Terminus font
|
||||
(`meshterm emulate --fetch-fonts`, once), Windows' Cascadia Mono and Segoe UI fonts, the
|
||||
network for the map's tiles the first time, and ffmpeg with libx264 on the `PATH`.
|
||||
|
||||
## 1. Run both, one after the other, in the background
|
||||
|
||||
```
|
||||
.venv/Scripts/python scripts/screenshots.py
|
||||
.venv/Scripts/python scripts/video.py
|
||||
```
|
||||
|
||||
One background command for both, in this order. **Never in parallel**, and don't run the
|
||||
test suite beside them: the video's tour runs on the real clock and its painter uses every
|
||||
core. Both are driven by waits on screen text, so a slow machine only makes them slower.
|
||||
|
||||
The video prints its length and size at the end (`recorded 118.9 s, 189 screens`, then
|
||||
`demo.mp4 (4001 KB)`).
|
||||
|
||||
## 2. Look at what you made
|
||||
|
||||
A green run is not a correct picture. Check both:
|
||||
|
||||
- **Screenshots:** `git status --short docs/screenshots`, then open one image of each set.
|
||||
Only the version in the header should change, plus whatever this change set out to
|
||||
change on those screens. Anything else is a bug to understand, not a picture to ship.
|
||||
- **Video length:** under **2:00**. The site's caption says "Under two minutes". If it
|
||||
went over, shorten holds in `_tour` (`p.hold(...)`), never the scenes JP asked for.
|
||||
- **Video content:** make a contact sheet and look at it:
|
||||
|
||||
```
|
||||
ffmpeg -v error -y -i ../meshterm.net/src/demo.mp4 -vf "fps=1/4,scale=400:-1,tile=6x5" -frames:v 1 <scratchpad>/sheet.png
|
||||
```
|
||||
|
||||
The tour, in order (JP chose each part; keep them all):
|
||||
1. the main menu;
|
||||
2. Chat → Public → the paths of Gabriel's message from Beloeil, selected 1, 2, 1, 2;
|
||||
3. Chloé's message arrives live; we type "Hey, I wonder if @[Dave-Lachine] is coming
|
||||
Saturday?" — the mention turns into a chip when the `]` is typed — and Dave answers
|
||||
with a mention of us, so his message carries our white ★;
|
||||
4. Trace target → Brossard, 2 bytes per hop (our node uses 3; a trace collapses it to
|
||||
2), the "5 new records" dialog, then the per-hop table;
|
||||
5. the map: pan, ^L to our node, zoom 10 → 16 until street names and buildings show;
|
||||
6. Repeater admin on Mont-Royal: Read settings (the video jumps over most of it), then
|
||||
Send advert;
|
||||
7. the live feed, one packet opened, and back to a clean main menu.
|
||||
|
||||
Also open `src/demo-poster.png`: it must show the paths dialog with the **second** path
|
||||
selected.
|
||||
|
||||
## 2b. Upload it for the README — only once the video is right
|
||||
|
||||
MeshTerm's README plays its own copy of the video: GitHub plays a video in a README only
|
||||
when the file is one of its own attachments. Once the video passed step 2:
|
||||
|
||||
```
|
||||
.venv/Scripts/python scripts/video.py --readme
|
||||
git diff README.md
|
||||
```
|
||||
|
||||
It uploads `../meshterm.net/src/demo.mp4` through GitHub's undocumented attachment endpoint
|
||||
(`uploads.github.com/user-attachments/assets`, with the `gh` token: no issue, no browser),
|
||||
and replaces the one `https://github.com/user-attachments/assets/…` line of `README.md`.
|
||||
The diff must be that one line. Each upload is public and permanent (GitHub cannot delete
|
||||
an attachment, and the old ones stay, unlinked), so **never run it on a video you haven't
|
||||
checked**, and never in a loop.
|
||||
|
||||
If GitHub refuses it (the endpoint is undocumented: it can change or go away), the README
|
||||
keeps the previous video, which is wrong but not broken. Tell JP. The fallback is the web
|
||||
page: he drops `src/demo.mp4` into a comment box on the repository and gives you the URL to
|
||||
put on that line.
|
||||
|
||||
## 3. When it fails
|
||||
|
||||
`video.py` prints `the recording failed (1)` after a traceback. Read the traceback's last
|
||||
line: a `TimeoutError` carries the whole screen as it was.
|
||||
|
||||
- **A wait never matched** (`'…' did not appear`): a UX string the tour waits for changed,
|
||||
or a dialog the tour doesn't expect is in the way. The video's own history: the trace
|
||||
once got a records dialog when the path width changed, and a menu filter "live" matched
|
||||
Courier first. Fix the tour in `scripts/video.py`, not the app — unless the screen shows
|
||||
an app bug, which then gets fixed and reported to JP.
|
||||
- **The trace says "no reply":** the demo's route to that target includes a link the
|
||||
world doesn't have. Brossard is the target because its route always works; see the
|
||||
`_tour` comment before changing it.
|
||||
- **To iterate on painting or encoding** without recording again, pass
|
||||
`--recording <scratchpad>/rec.pickle`: the first run keeps the recording there, a later
|
||||
run with the same path only paints and encodes. Never keep a recording across a change
|
||||
to the app.
|
||||
|
||||
If you cannot make it work, stop and ask JP whether to keep last time's video. Never ship
|
||||
half a set.
|
||||
|
||||
## 4. Commit
|
||||
|
||||
MeshTerm, by name (other sessions share the tree):
|
||||
|
||||
```
|
||||
git add docs/screenshots README.md
|
||||
```
|
||||
|
||||
The site, the three source files only:
|
||||
|
||||
```
|
||||
git -C ../meshterm.net add src/demo.mp4 src/demo-poster.png src/demo-play.png
|
||||
git -C ../meshterm.net commit -m "The demo video of MeshTerm X.Y.Z" -m "Co-Authored-By: <the model running this> <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
Then one of the two modes.
|
||||
|
||||
### Manual mode — JP asked for the website now
|
||||
|
||||
Commit the screenshots and the README's new video URL in MeshTerm ("The screenshots
|
||||
show …", what changed).
|
||||
Then rebuild the site so its `docs/` serves the new files:
|
||||
|
||||
```
|
||||
cd ../meshterm.net && ../MeshTerm/.venv/Scripts/python build.py
|
||||
```
|
||||
|
||||
`build.py` writes seven derived images again with new bytes even when nothing changed.
|
||||
Unless the splash art changed (`docs/screenshots/splash/` in MeshTerm), restore them:
|
||||
|
||||
```
|
||||
git -C ../meshterm.net checkout -- docs/assets/apple-touch-icon.png docs/assets/logo-96.png docs/assets/og.png docs/assets/social-preview.png docs/assets/splash-narrow.png docs/assets/splash.png docs/favicon.ico
|
||||
```
|
||||
|
||||
Commit the built `docs/` with the source files (one commit). **Don't push**: tell JP the
|
||||
site is committed and ahead of origin. The live site changes only when he pushes it. Note
|
||||
that the screenshots the site shows come from the MeshTerm checkout, so they are the
|
||||
working tree's, not a release's.
|
||||
|
||||
### Release mode — called from the release skill, after the bump
|
||||
|
||||
Don't build the site locally: the release workflow's `website` job builds it from the tag
|
||||
and from the site's `main`. Commit the site's three source files as above, and the
|
||||
screenshots and the README ride in the release commit (the release skill stages
|
||||
`docs/screenshots` and `README.md`).
|
||||
|
||||
**The site's commit must be pushed before the tag**, or the job builds with last
|
||||
release's video. The release skill's push step does it, in the same confirmed push. After
|
||||
the job has run, `git -C ../meshterm.net pull --ff-only` picks up its "MeshTerm X.Y.Z"
|
||||
commit.
|
||||
@@ -22,6 +22,13 @@ body:
|
||||
The **Diagnostics** page in the menu saves the same file next to the log. Press `s`,
|
||||
or F3 on the PicoCalc.
|
||||
|
||||
The file and the log can show your account name (in the path of your home folder)
|
||||
and the address of your radio. To mask them, add `--anonymize` to the command, or
|
||||
press `a` on the page. To mask them in the log too, turn on **Anonymize** on the
|
||||
Preferences page (`meshterm preferences set anonymize on`) before you make the
|
||||
problem occur. Each masked value keeps a few characters, so that I can still tell
|
||||
two radios apart.
|
||||
|
||||
The rest of this form asks only for the information that the file cannot know.
|
||||
|
||||
- type: textarea
|
||||
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
|
||||
---
|
||||
|
||||
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.
|
||||
You ❤️ MeshTerm? 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
|
||||
|
||||
@@ -9,6 +9,204 @@ can change how things behave. It does not only add new things.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.11.5] - 2026-10-22
|
||||
|
||||
**The Courier outbox is a table, and Enter opens the message of an entry.** Each section of
|
||||
the outbox has column headers, and each row shows its full message. A table that is wider
|
||||
than the screen pans with ←→, and does not cut its rows. A footer hint that is too wide
|
||||
keeps its Esc.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The Courier outbox is a table with column headers.** A waiting entry shows TARGET,
|
||||
TRIGGER, and MESSAGE. A finished entry shows TARGET, OUTCOME, and MESSAGE. Nothing is
|
||||
shortened to fit. When the table is wider than the screen, ←→ pan the rows and their
|
||||
headers together, and the footer says so. The column headers stay at the top with their
|
||||
section heading while the section scrolls. Typing still narrows the outbox.
|
||||
- **Enter on a Courier entry opens its message.** On a waiting entry, Enter opens the
|
||||
message so that you can edit it. The courier holds the entry while the prompt is open, so
|
||||
the old text cannot go out while you edit. On a finished entry, Enter shows the message.
|
||||
The menu of an entry is gone: ^R sends a waiting entry now, and Del deletes an entry after
|
||||
a red confirm. On the PicoCalc and the Cardputer Zero, `Send` is on F3, with `Delete`
|
||||
behind it on Shift+F3.
|
||||
- The message field of the Courier has the colours of the chat's compose line: a white
|
||||
prompt mark and white text.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A footer hint that is too wide keeps its Esc.** The footer line cut a hint that was too
|
||||
wide at the right edge, and Esc, the last atom of each hint, went with the cut. Now the
|
||||
footer removes atoms in front of Esc, as the splash already did.
|
||||
- **A cut in the text next to a chip ends on an ellipsis.** When a row had a chip and then
|
||||
text, and the text ran past the screen, the cut drew a half block in the colour of the
|
||||
chip at the right edge. Now the half block is only for a cut inside a path, and a cut in
|
||||
plain text ends on `…`.
|
||||
|
||||
## [0.11.4] - 2026-10-20
|
||||
|
||||
**You can lock a contact from more places, and a serial radio that stops when your laptop
|
||||
wakes from sleep opens the reconnect dialog.** In the archive preview, ⇧Del keeps a contact
|
||||
out of each sweep that follows. In a conversation and on the Contacts list, ^K locks a
|
||||
contact or unlocks it, and a locked sender has a padlock on its chip. The battery gauge of
|
||||
the Cardputer Zero stays on with the Cap.
|
||||
|
||||
### Added
|
||||
|
||||
- **In the archive preview, Shift+Delete keeps a contact out of the sweep and locks it.**
|
||||
Delete keeps a contact out of this sweep only. ⇧Del also locks the contact, so that no
|
||||
sweep that follows can take it. MeshTerm writes the lock when you select Apply, with the
|
||||
archive. Esc and Back ask before they discard a lock that is not written yet. If you lock
|
||||
each contact in the preview, Apply only writes the locks. On the PicoCalc and the
|
||||
Cardputer Zero, `Lock` is on Shift+F3, behind `Delete`.
|
||||
- **^K locks the author of a message in a channel, a direct chat, and a room.** Select a
|
||||
message and press ^K. Press it again to unlock the author. The status line tells you what
|
||||
the key did. On the PicoCalc and the Cardputer Zero, `Lock` is on Shift+F2 in a channel
|
||||
and in a direct chat. In a room, Shift+F2 is `Log in`, so there the page of the author
|
||||
(Shift+F3) has `Lock contact` or `Unlock contact`.
|
||||
- **^K on the Contacts list locks the highlighted contact or unlocks it.** The padlock of
|
||||
the row changes at once. On the handhelds, `Lock` is on Shift+F2.
|
||||
- **A locked sender has a padlock on its chip in a chat.** The chip reads `🔒 Name`
|
||||
(`⚿ Name` on the PicoCalc), in the place where our own chip has its `★`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The selected message of a chat makes its chips white.** The text of the selected
|
||||
message was already white. Now the mention chips in it, and the chip of its author, have
|
||||
a white label too. Each chip keeps its fill, so you can still tell the nodes apart. A
|
||||
mention of an unknown node is white on its grey fill.
|
||||
- In the archive preview, the footer says `Enter open` on a contact, because Enter opens
|
||||
its node page.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A serial radio that stops when your laptop wakes from sleep opens the reconnect
|
||||
dialog.** When a laptop wakes, the kernel can reset the USB device and close the open
|
||||
port, but keep the same device file. MeshTerm still saw the port in the list of ports, so
|
||||
it thought that the link was up. No reconnect dialog opened, and each command failed with
|
||||
`no_event_received` (issue #32). Now the check also reads the connection flag of the
|
||||
client, as the Bluetooth and TCP checks already did.
|
||||
- **The battery gauge of the Cardputer Zero stays on with the Cap.** On battery with the
|
||||
Cap, most reads of the gauge failed, sometimes for almost two minutes. The gauge read
|
||||
three times in 11 seconds and then waited 20 seconds, so it missed the good periods.
|
||||
After seven failed reads, the gauge went away. Now MeshTerm tries again 5.5 seconds after
|
||||
a failed read, and the last good reading stays for 10 minutes. A change between charging
|
||||
and discharging must be read two times, because a bad read said "Charging" while the
|
||||
battery discharged.
|
||||
|
||||
## [0.11.3] - 2026-10-18
|
||||
|
||||
**No log keeps a password or a secret, and a bug report can hide your home folder and the
|
||||
address of your radio.** Passwords, channel secrets, and pairing PINs went into the log and
|
||||
the run log as plain text, and this release removes them, also from the old records. The new
|
||||
**Anonymize** preference masks your personal details in the log and in the diagnostics. The
|
||||
demo mesh now looks more like a real one.
|
||||
|
||||
### Added
|
||||
|
||||
- **The Anonymize preference masks your personal details in the log and in the
|
||||
diagnostics.** It is in the Diagnostics group of the Preferences page, and it is off by
|
||||
default. When it is on, MeshTerm masks the account name in your home folder, the
|
||||
Bluetooth address of your radio, IP addresses, and the name of a network companion. Each
|
||||
masked value keeps a few characters, in the way that a card number prints as
|
||||
`51xx xxxx xxxx 4571`: `C:\Users\j*********u`, `DA:xx:xx:xx:7C:7C`, `192.xxx.x.180`, and
|
||||
`m******e.local`. Thus a maintainer can still tell two radios apart. The log masks each
|
||||
line as MeshTerm writes it, and a change applies at once. A line that MeshTerm wrote
|
||||
before you turned the preference on stays as it is.
|
||||
- **The diagnostics can be anonymized for one report.** `meshterm diagnostics --anonymize`
|
||||
masks the report, and `--no-anonymize` shows it in full when the preference is on. On the
|
||||
Diagnostics page, press `a` (Shift+F3 on the PicoCalc) to change between the two forms,
|
||||
and `s` saves the form that you see. A new fact, `anonymized`, says which form a report
|
||||
is.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The demo mesh uses the regions of the real mesh.** The regions are `na`, `can`,
|
||||
`onqc`, `qc`, and `yul`, each one inside the one before it. The `#montreal` channel sends
|
||||
in `yul`. The invented regions are gone.
|
||||
- **In the demo, messages arrive as they do on a real mesh.** Most messages arrive two
|
||||
times, through the two repeaters that our node hears, and most paths use three bytes for
|
||||
each hop. A node that our node mentions in a channel answers a few seconds later. TX
|
||||
optimize now has something to find: the power of a repeater changes how well the next
|
||||
node hears it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The log and the run log no longer keep passwords or secrets.** At the DEBUG level, the
|
||||
log file showed the parameters of each command as you typed them, and the run log in the
|
||||
database kept them at each level. Thus they held room and admin passwords, channel
|
||||
secrets, channel links, and the pairing PIN of `config set device_pin`. Each chat run also
|
||||
stored the secret of its channel. Now MeshTerm writes `<secret>` in the place of each
|
||||
one, in the log file, on the console, and in the run log, always. The rule finds a secret
|
||||
by its name, by its form (a remote `password` command, or `secret=` in a link), and by
|
||||
its value, when MeshTerm has read or sent that secret before. The first start of this
|
||||
version removes the secrets that earlier versions stored in the run log.
|
||||
- **The remote command line no longer stores a new password in its history.** The history
|
||||
keeps `password` and `set guest.password` without the value, so ↑ still recalls the
|
||||
start of the command.
|
||||
- An error about the device PIN no longer repeats the PIN that you typed.
|
||||
|
||||
## [0.11.2] - 2026-10-16
|
||||
|
||||
**In a chat, your messages show your name, MeshTerm has a demo mesh, and the colours are
|
||||
calmer.** Your messages and each mention of you are now easy to find in a chat.
|
||||
`meshterm --demo` runs a whole mesh over Montréal with no radio. The greys have no blue in
|
||||
them, the borders are grey, and the node marks are white.
|
||||
|
||||
### Added
|
||||
|
||||
- **`meshterm --demo` runs MeshTerm in a demo mesh over Montréal.** No radio is
|
||||
necessary. The demo has approximately forty invented nodes: repeaters on the high points
|
||||
of the city, room servers, sensors, and the people of the mesh. It has a week of traffic,
|
||||
conversations on the channels, in a room, and in direct messages, and traces with their
|
||||
records in the trophy case. Each run starts from the same state, in a directory of its
|
||||
own, so your history never gets a node of the demo. The screenshots in the README and on
|
||||
the website come from it.
|
||||
|
||||
### Changed
|
||||
|
||||
- **In a chat, your messages show your name.** A channel, a direct chat, and a room put
|
||||
your messages under `★` and your node's name, and the Messages list shows them in the
|
||||
same way. Your name is white, on the same colour of box that your key gives each chip,
|
||||
where each other sender has the colour of its key as the text. A mention of your name
|
||||
has the same white text, and the message that has it gets a white `★` at the left of
|
||||
its time. Thus the messages that name you are easy to find down the left side of the
|
||||
chat. Before, your messages showed only the `★`.
|
||||
- **Your node's star is white.** The `★` that marks your node on the map, in routes, in
|
||||
lists, and in a chat is white, the same white as your name in the lists. Before, it was
|
||||
yellow. A star in a faded part of a route stays grey.
|
||||
- **The greys are neutral, and the borders are grey.** Each grey of MeshTerm is now a
|
||||
neutral grey, with no blue in it, at the same lightness as before. A plain border is
|
||||
grey, where it was indigo. The amber and red borders of a caution keep their colours.
|
||||
- **Each node mark is white.** The marks of a companion, a repeater, a room server, and a
|
||||
sensor (`●`, `▲`, `■`, `◉`) are white, because the shape already shows the type. Before,
|
||||
they had four colours. The mark of an unknown node (`○`) stays grey.
|
||||
- A section heading has the colour of the title of its frame, and it has no icon.
|
||||
- **In a chat, a name that MeshTerm cannot match to a node is black on grey.** The box
|
||||
around a sender or a mention with no known node has black text on a grey box. Thus it
|
||||
does not stand out among the coloured names. Before, it was light blue on slate.
|
||||
- **A mention has no `@` in front of it.** The coloured box around the name already says
|
||||
that it is a mention. On a terminal that cannot draw the boxes, a mention keeps its `@`.
|
||||
- **The line where you write a message shows each mention as a box.** When you type the
|
||||
`]` of `@[Name]`, or reply to a message, the mention changes into the same coloured box
|
||||
that the chat shows after you send it. The arrow keys move over the box in one step, and
|
||||
one Backspace removes all of it. A reply now puts its mention where the cursor is, so you
|
||||
can name more than one person in one message. The text that you type is white.
|
||||
- **On the uConsole and the Cardputer Zero, an imported seed stays a seed.** MeshTerm saves
|
||||
a 32-byte seed in the node's `identity.key` as you give it. This is the same form that
|
||||
the node uses when it makes its own key. A firmware companion still gets the full 64-byte
|
||||
key, because it accepts only that form.
|
||||
- **A blank line comes before each section.** In each list that has sections (the main
|
||||
menu, the Messages list, the Preferences page, Device config, and the others), the line
|
||||
comes before each section heading. In a chat, it comes after the messages of each author
|
||||
and before each new day. Thus the parts of a screen are easier to find, and a
|
||||
conversation is easier to follow when many people write. By default, the line shows on a
|
||||
terminal of 24 rows or more. A smaller terminal, and the 14 rows of the Cardputer Zero,
|
||||
keep each row for content. The new **Section spacing** preference in the Display group
|
||||
sets the smallest terminal that gets the line: any height, 20, 24, 30, or 40 rows, or
|
||||
never.
|
||||
- The About page has a section on the Cardputer Zero. It says that MeshTerm runs on the
|
||||
Cardputer Zero and on the Cardputer Zero Lite.
|
||||
|
||||
## [0.11.1] - 2026-10-13
|
||||
|
||||
**Show private key shows the key again, and the uConsole and the Cardputer Zero can import
|
||||
@@ -540,7 +738,11 @@ commands.
|
||||
MeshTerm is free and open source, under the Apache 2.0 licence. The licence does not cover
|
||||
the name and the logo (refer to `NOTICE`). A fork is welcome under its own name.
|
||||
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.11.1...HEAD
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.11.5...HEAD
|
||||
[0.11.5]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.11.5
|
||||
[0.11.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.11.4
|
||||
[0.11.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.11.3
|
||||
[0.11.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.11.2
|
||||
[0.11.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.11.1
|
||||
[0.11.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.11.0
|
||||
[0.10.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.3
|
||||
|
||||
@@ -269,19 +269,32 @@ deliberately, one at a time, and say why in the code.
|
||||
screen's results were once windowed under its actions: the page came out exactly the
|
||||
screen's height, so ↓ on the last action did nothing while `↓ n more` sat under it.
|
||||
- Grouped-list section headings use `section_heading("Label")` → `── Label ──` in the
|
||||
bold `heading` grey, never the frame's accent: a rule in the border's colour reads as
|
||||
part of the frame. A chat's day divider wears the same dress. That is also what makes a heading *sticky* (it pins to the top row while its section
|
||||
bold `heading` style — the frame title's colour, `title.border` (JP, 2026-10-15) —
|
||||
never the `border` grey: a rule in the border's colour reads as
|
||||
part of the frame. A heading is words only, never an icon — icons live in rows (JP,
|
||||
2026-10-15: Chat's `📡 Channels` and the trophy case's marks came off for a cleaner
|
||||
look). A chat's day divider wears the same dress. That is also what makes a heading *sticky* (it pins to the top row while its section
|
||||
scrolls, and the ^PgUp/^PgDn jumps step by it), so build them through it — a hand-rolled
|
||||
`Separator` is no landmark. Prose written *directly under* a heading (a description of
|
||||
what the section holds) is its preamble and pins with it, in order, as each row scrolls
|
||||
off; prose after the section's first row labels nothing and never pins.
|
||||
A heading is also *spaced*: a blank line goes before it wherever the terminal is at
|
||||
least `section_spacing_min_rows` tall (one preference, read through
|
||||
`Screen.section_spacing`, which the chat's author and day breaks read too) — never at
|
||||
the top, after a blank line, or under another heading or a column header. So don't
|
||||
hand-place a `Separator(" ")` before a heading for space: it would show where the
|
||||
preference says no. A blank separator there is only ever the seam between two
|
||||
*different* lists, each with its own icon column (the region editor's table and its
|
||||
actions); the heading then adds no second line.
|
||||
- Label + description command rows go through `menu_rows` (two cell-aligned lanes,
|
||||
description muted). Editor rows (setting/value/description) go through `lane_row`.
|
||||
- A row that opens further prompts ends with `…`; a row that acts immediately doesn't.
|
||||
- **A bare-letter shortcut only where nothing is typed.** A screen that filters, finds or
|
||||
composes as you type has no free letters, so its verbs are Ctrl chords — one table,
|
||||
`session._CTRL_LETTER_CHORDS`, each letter the mnemonic of its action where it can be
|
||||
(`^P` paths, `^U` URL, `^L` locate, `^O` open the author's page; `^G` logs in to a room,
|
||||
(`^P` paths, `^U` URL, `^L` locate, `^O` open the author's page, `^K` toggle the lock of
|
||||
the author or of the Contacts list's highlighted contact, `Lock` on Shift+F2 in both —
|
||||
JP, 2026-10-19; `^G` logs in to a room,
|
||||
JP's pick, 2026-10-11). One verb per letter across screens is the preference, not the
|
||||
rule (JP, 2026-10-10): a screen that has no use for a letter's verb may give the
|
||||
letter a verb of its own, so long as nothing between the keyboard and the app takes it —
|
||||
@@ -404,7 +417,9 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
- Modal popups over the pushed backdrop (never full-screen replacements).
|
||||
- Buttons are reverse-video chips (` Label `, `selected` style): safe way out on the
|
||||
left, the committing verb on the right **and default**, so Enter commits and Esc backs
|
||||
out. Two escalating caution tiers theme the frame: `danger=True` (amber) for a
|
||||
out. A plain border is the `border` grey (`title.border`/`hint.border` its title and
|
||||
hint) — never a hue: a border is chrome (JP, 2026-10-14; it was the indigo `accent`).
|
||||
Two escalating caution tiers theme the frame: `danger=True` (amber) for a
|
||||
disruptive choice — discard edits, reboot, show private key — and `destructive=True`
|
||||
(the reserved red) for irreversible **data loss**, so a delete confirm reads red like
|
||||
its `typed_confirm` sibling. Bulk or irreversible deletions gate behind `typed_confirm`
|
||||
@@ -417,8 +432,17 @@ 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) · `●` companion · `▲` repeater ·
|
||||
`■` room server · `◉` sensor · `○` unknown.
|
||||
- Node types (shared with the map): `★` you (white) · `●` companion · `▲` repeater ·
|
||||
`■` room server · `◉` sensor · `○` unknown. **Every type mark is white** (JP,
|
||||
2026-10-14; they were pink, violet, white, orange): the shape is the type. Each keeps its
|
||||
own `type.*` style so one can regain a colour in one place. `○` keeps `node.unknown`
|
||||
grey, which says "unidentified". On the map a crowd in one cell no longer glows — a white
|
||||
marker has nowhere brighter to go, and JP chose to drop the signal.
|
||||
- **Our `★` is white, everywhere** (JP, 2026-10-14): map, routes, lists, legends, chat.
|
||||
`marks.SELF_MARK` carries the theme name `you` — the white of our name — so the console
|
||||
picks its own white, and a raster resolves it through `mark_rgb`. It was the map's
|
||||
yellow, then briefly our key's hue (set from the self-info at run time), and both were
|
||||
dropped. A star something *mutes* keeps its grey — a faded hop, a dimmed return leg.
|
||||
- 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
|
||||
@@ -429,7 +453,7 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
origin and destination, never the first and last *relay*. Our own end is always the
|
||||
app-wide `★` (`pathline.SELF_GLYPH`, taken straight from `marks.SELF_MARK`), never our
|
||||
name: the one node the reader never has to be told, and the cells belong to the hops
|
||||
that differ from row to row. As a chip it is the map's yellow star on neutral dark
|
||||
that differ from row to row. As a chip it is the map's white star on neutral dark
|
||||
grey, padded like every other chip.
|
||||
- **A path chip is the node's name colour on a darker ground of itself.** The label is the
|
||||
hue `node_style` gives the name on every other surface, so a node is one colour in a
|
||||
@@ -466,7 +490,9 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
- **A lone chip can stand at one end of a route it doesn't draw** (`widgets.hop_chip` /
|
||||
`name_chip(role=…)`). A message goes from its author toward the nodes it names, so a
|
||||
sender is an `ORIGIN` — square (or round) on the left, the point on the right — and an
|
||||
`@mention` is a `DESTINATION` chip, `@` kept, opening on the notch and ending square. No
|
||||
`@mention` is a `DESTINATION` chip (`widgets.mention_chip`), opening on the notch and
|
||||
ending square, its `@` dropped because the chip already says "mention" (JP, 2026-10-13;
|
||||
arrow mode, with no chip, keeps `@Name`). No
|
||||
role is a node alone, square at both ends. Where it applies: the chat's sender chip and
|
||||
mentions, the Messages list's preview (author chip, no colon; mentions), the packet
|
||||
viewer's `from` (origin) and `to` (destination) rows around the `via` chain, the live
|
||||
@@ -477,6 +503,33 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
`sender → recipient`), and a lone hop carries no `PATH_INK`, so a cursor row whitens it
|
||||
like any name. A chip in prose never breaks across lines (`render_hanging` holds every
|
||||
`whole` run together, spaces and all).
|
||||
- **In a chat, we go by our name** (channel, DM, room, and the Messages list's preview —
|
||||
nowhere else; JP, 2026-10-13). A route's bare `★` says "you already know who this is",
|
||||
but a chat is a conversation between names, so our sender chip and every mention of our
|
||||
name wear our name **in white on the normal fill of our key's hue** — the fill every
|
||||
known sender gets, with white where they have their hue (`PathHop.white_ink`; JP,
|
||||
2026-10-14, after a turned-over light chip read wrong). The PicoCalc draws the same
|
||||
pair, white on the hue's dim twin. Our **sender** chip is `★ Name`
|
||||
(`widgets.self_chip`), the star in the white label; the star on the page right of the
|
||||
chip was tried and put back (JP, 2026-10-13). A **locked** sender's chip carries the
|
||||
padlock in the same place, `🔒 Name` (`author_chip(locked=…)`, `marks.LOCK_ICON`, `⚿` on
|
||||
the PicoCalc; JP, 2026-10-19). A **mention** of us is the same chip
|
||||
with no star (`mention_chip(you=True)`); the message carrying it gets **our white
|
||||
`★`** left of its time instead (`widgets.mention_mark`, the same on every platform and
|
||||
in arrow mode). The map's yellow star, the chip's notch-and-point pair, its point alone
|
||||
and a star in our hue were all tried there and dropped (JP, 2026-10-13/14). Every time sits two cells right to
|
||||
keep the column (`❯`, a space, the star, a space, then `12:34 body`), and the Messages
|
||||
list puts the star left of the row's age. Arrow mode draws the chip as a padded block
|
||||
in the same pair. With no name or key from the device, the bare `★` chip stays. Path
|
||||
lines (delivery paths, the packet viewer, routes) keep `★`.
|
||||
- **The compose line draws a mention as its chip** (`ui/compose.py`, `MentionEditor`): the
|
||||
moment `]` completes an `@[Name]` token it renders as the same chip the transcript will
|
||||
show, and from then on it is an **atom** — ←/→ step over it, ^←/^→ never stop inside
|
||||
it, Backspace at its end and Delete at its start remove all of it. The cursor on a chip
|
||||
turns it into the composer's white slot chip; a chip past the byte limit goes red. The
|
||||
text underneath is unchanged (`@[Name]` is what is sent and counted). Enter on a picked
|
||||
message inserts its mention **at the cursor**, so one message can name several nodes.
|
||||
The `›` wears `cursor` white and the typed text the `compose` white (JP, 2026-10-13).
|
||||
- Concept icons: 📡 advert · 🕒 clock/sync · 🔄 reboot · 💾 backup · 📂 restore ·
|
||||
🔑 channel/credential key · 🔐 identity/auth secret ·
|
||||
🔒 lock (a locked contact, a private channel) · 🔓 unlock · 🗑 clear/delete · ✎ compose/edit ·
|
||||
@@ -492,6 +545,13 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
|
||||
### Colour
|
||||
|
||||
- **A grey is neutral: r = g = b** (JP, 2026-10-14). Every grey MeshTerm defines — chrome,
|
||||
a keyless chip, a minor road, the emulator's window — has no tint, warm or cold; the
|
||||
Tailwind slates were each re-made at their own OKLab lightness (`#94a3b8` → `#a2a2a2`,
|
||||
`#64748b` → `#737373`, `#334155` → `#404040`), so every contrast stayed put. A colour
|
||||
that has a meaning gets real chroma instead. `tests/test_neutral_greys.py` fails any
|
||||
`#rrggbb` literal under OKLab chroma 0.045 that isn't r=g=b. Part of a wider pass: a
|
||||
colour that carries no meaning is on its way out.
|
||||
- Node names are always coloured: `name_style(name, key)` palette hue, derived from the
|
||||
node's key (its first byte — any known prefix agrees) so a rename keeps the colour.
|
||||
One rule on **every** platform; only the resolution changes (see **Platforms**).
|
||||
@@ -501,12 +561,19 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
live claimants; outside that window the newest key wins, because a reflash makes a new
|
||||
key routinely; JP, 2026-10-11), a bare hash standing in as a name, an `○` ring — takes
|
||||
`node.unknown`, THE light grey for an unidentified node, everywhere and on both
|
||||
platforms. Colour is reserved for keyed identities, never seeded from a name's
|
||||
platforms. One exception, a chat's **chips** (`PathHop.quiet`, via `widgets.author_chip`
|
||||
and `mention_hop`): an unknown sender or mention there is **black** on a grey fill
|
||||
(`#cccccc`, the grey of plain text, at `CHIP_LIGHTNESS`), so it stands back among the
|
||||
coloured senders (JP, 2026-10-14; a plain-text grey label read as still lit); routes
|
||||
keep the keyless chip, arrow mode keeps `node.unknown`, and the PicoCalc draws black on
|
||||
its keyless light grey.
|
||||
Colour is reserved for keyed identities, never seeded from a name's
|
||||
characters. `node.unknown` is deliberately not `muted`: muted is chrome and may sit a
|
||||
step darker, while an unidentified node is content you can still act on. Our own node
|
||||
is the pure-white `you` style; a context colouring (chart quality) may still win.
|
||||
- **White is what "you picked this" looks like**, and nothing else in the app is allowed
|
||||
to say it. The cursor row — the row `❯` points at, in a select list or in a screen
|
||||
to say it — beside the `you` white and the white node-type marks, whose shape carries
|
||||
their meaning. The cursor row — the row `❯` points at, in a select list or in a screen
|
||||
drawing its own rows — wears `cursor` (white), never `brand`: the wordmark's teal is the
|
||||
app's identity, not a selection, and teal/cyan is itself a node hue, so a cyan-keyed
|
||||
node used to vanish into its own highlight. White is outside the node spectrum, so it
|
||||
@@ -525,9 +592,14 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
what a route graph's one-byte labels are matched by — so `pathline` stamps its own extent
|
||||
(`PATH_INK`, a style that draws nothing) and the fold skips what lies inside it. That is
|
||||
what makes the arrow form say what the chip form always said, its fills having never been
|
||||
in the fold's vocabulary. Reverse-video chips — a dialog's committing button,
|
||||
in the fold's vocabulary. The one exception is a chat's **selected message**: its mention
|
||||
chips and its group's author chip go white with its text (JP, 2026-10-19) — the label
|
||||
only, the fill kept (`widgets.picked_hop`: `white_ink` on a chip, `cursor` in arrow mode).
|
||||
An unknown node goes white too: its grey fill already says it is unknown, and white reads
|
||||
better on it than its black label. A mention or an author is a name in a conversation,
|
||||
not a hop of a route. Reverse-video chips — a dialog's committing button,
|
||||
a running action's Abort, the arrow-mode composer slot, the editor cursor — are the
|
||||
`selected` fill, a **grey** block (`muted`'s slate; light grey slot 7 on the VT, the one
|
||||
`selected` fill, a **grey** block (`muted`'s grey; light grey slot 7 on the VT, the one
|
||||
grey a reverse can put behind text there, and the same fill the F-key lane uses). A chip
|
||||
marks where a press lands; it is chrome, and it does not get a hue.
|
||||
Recency heat colours heard/first-heard ages, never names — seven steps on plain human
|
||||
@@ -705,17 +777,21 @@ derived sweep that runs **every** registered command twice, once plain and once
|
||||
|
||||
One codebase, three flavours: **regular** (desktop/ssh, 72 cols, truecolor, emoji),
|
||||
**picocalc-lyra** (the PicoCalc's 53×26/53×40 framebuffer console, 16 palette slots, a
|
||||
512-glyph font, no emoji), and **cardputer-zero** (M5Stack's Cardputer Zero, 53×14 in 6×12
|
||||
cells on a 320×170 panel MeshTerm paints itself, truecolor, no emoji — hardware not
|
||||
yet in hand, so it is chosen by flag only).
|
||||
512-glyph font, no emoji), and **cardputer-zero** (M5Stack's Cardputer Zero, both the
|
||||
standard model and the Lite, 53×14 in 6×12 cells on a 320×170 panel MeshTerm paints
|
||||
itself, truecolor, no emoji — chosen by flag only: its device-tree model is the CM0's
|
||||
generic string, so MeshTerm's own console passes the flag). Never hot-swap a Cap on it,
|
||||
the LoRa one included: M5Stack says it may make the device malfunction, so the docs say
|
||||
to power off first.
|
||||
A frozen `Platform` spec (`meshterm/platforms.py`) resolves
|
||||
once at boot; consumers bind at platform-switch time via `platforms.on_platform` — never
|
||||
branch on the platform per frame, and never `from meshterm.platforms import PLATFORM`.
|
||||
|
||||
- The borderless title bar (`frame._title_bar`) is this platform's whole panel border:
|
||||
`↑↓ ──── Title ─────── Esc back`. The **clip arrows lead the row and are always both
|
||||
drawn** — colour reads the scroll (the border's `accent` where that direction has more,
|
||||
`muted` where it doesn't), the same live/dim language the lane draws one row below. A
|
||||
drawn** — colour reads the scroll (lit like the title, `title.border`, where that
|
||||
direction has more, `faint` where it doesn't; a `border`-grey arrow once sat too close
|
||||
to `faint` to say it), the same live/dim language the lane draws one row below. A
|
||||
pair that appeared and vanished, half of it a blank cell, asked the reader to compare
|
||||
the row against a memory of itself; fixed furniture means the bar never changes width
|
||||
as the body scrolls either.
|
||||
@@ -754,7 +830,7 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
|
||||
steps. Nothing loses its colour for being on the console. Marks whose hue is *fixed*
|
||||
rather than derived go through a theme name so the slot is chosen deliberately (the
|
||||
node types' `type.*`; a raster resolves the same entry via `theme.mark_rgb`) — a naive
|
||||
downsample greys the repeater's violet. Footer hints are replaced by the **F-key lane**
|
||||
downsample greys a dim violet. Footer hints are replaced by the **F-key lane**
|
||||
(`ui/tui/fkeys.py`): five `FPair` slots per screen, F1–F5 primary and F6–F10 each
|
||||
slot's *opposite number* (physical Shift+F1..F5), labels ≤6 cells. `DEFAULT_LANE` claims
|
||||
only **F4/F5 — the pager**, which has no physical key at all; **the jump to either end
|
||||
@@ -786,7 +862,12 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
|
||||
on Shift+F2. F4/F5 stay the pager, with `Latest`/`Oldest` behind Shift. A verb new to
|
||||
one of them goes on the Shift bank; one all three gain goes on the plain bank — and once
|
||||
that is full, on Shift+F3: `Author` (^O, the author's node page, `ContactPages`) rides
|
||||
there behind `Map`, its twin about the same author.
|
||||
there behind `Map`, its twin about the same author. `Lock` (^K, a toggle that reads
|
||||
`Unlock` on a locked author) is the one exception, by JP's choice (2026-10-19): it sits on
|
||||
Shift+F2 where that is free (channel, DM), and in a room, where `Log in` holds it, the
|
||||
`Author` page carries it — a conversation opens that page with its lock row and no other
|
||||
management verb. Its hint atom goes in only where the hint has room without dropping
|
||||
another atom.
|
||||
- **Each handheld deals its own lane.** Everything about the lane that belongs to one
|
||||
keyboard is a `LaneDeck` (`fkeys.PICOCALC_LYRA_DECK`, `fkeys.CARDPUTER_ZERO_DECK`), named by
|
||||
`Platform.lane_deck`: which keycodes drive the slots, where the chips sit on the row, how
|
||||
|
||||
@@ -27,22 +27,25 @@ run MeshTerm, the more the history of your mesh is worth.
|
||||
> change something, first say hello on [Discord](https://discord.gg/AZwe5Uvb3S).
|
||||
> [Read more about how the project is run](#how-this-project-is-run).
|
||||
|
||||
https://github.com/user-attachments/assets/f91a6695-14d5-4ae0-8fdc-e65492d0955d
|
||||
https://github.com/user-attachments/assets/9921c756-c096-4224-9560-bb6180d895c8
|
||||
|
||||
<p align="center"><sub>A video of less than two minutes, made on a real mesh.</sub></p>
|
||||
<p align="center"><sub>A video of less than two minutes, in the demo mesh over Montréal (<code>meshterm --demo</code>).</sub></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/dashboard.png" alt="The dashboard: a timeline of the packets for each minute over the last two hours, and a count for each class of packet" width="49%">
|
||||
<img src="docs/screenshots/map.png" alt="The map: repeaters with their names, on an OpenStreetMap basemap of the Lachine Canal" width="49%">
|
||||
<img src="docs/screenshots/terminal/menu.png" alt="The main menu: the tools in groups (Message, Watch, Explore), and a header that shows one unread message and three alerts" width="49%">
|
||||
<img src="docs/screenshots/terminal/chat.png" alt="The chat on the Public channel: each sender in a chip of the colour of their key, with the time and the SNR of each message" width="49%">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/trace.png" alt="A trace: the route that the trace went through, drawn as chips, then the SNR of each hop" width="49%">
|
||||
<img src="docs/screenshots/path.png" alt="Message paths: each route over which a channel message was heard, drawn on the map" width="49%">
|
||||
<img src="docs/screenshots/terminal/paths.png" alt="Message paths: the five paths on which one channel message arrived, drawn as a graph and as chips" width="49%">
|
||||
<img src="docs/screenshots/terminal/map.png" alt="The map: the nodes of the demo mesh over Montréal, with their names, on an OpenStreetMap basemap" width="49%">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/picocalc.png" alt="MeshTerm on the PicoCalc, a console that is 53 columns wide, with a radio installed inside it" width="38%">
|
||||
</p>
|
||||
<p align="center"><sub>The dashboard, the map, a trace, the paths of a message, and the whole app on a PicoCalc.</sub></p>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/cardputer-zero.png" alt="MeshTerm on the display of a Cardputer Zero: the wordmark, then the list of devices, with the Cap LoRa-1262 at the top" width="60%">
|
||||
</p>
|
||||
<p align="center"><sub>The main menu, the chat, the paths of a message, and the map, in the demo mesh over Montréal (<code>meshterm --demo</code>). Then the whole app on a PicoCalc, and the start screen on a Cardputer Zero.</sub></p>
|
||||
|
||||
---
|
||||
|
||||
@@ -198,6 +201,7 @@ mesh. You can look at the app before you buy anything.
|
||||
### On the Cardputer Zero
|
||||
|
||||
Install MeshTerm from M5Stack's app store on the handheld. Its share code is `MTRM`.
|
||||
MeshTerm works on both models, the Cardputer Zero and the Cardputer Zero Lite.
|
||||
[MeshTerm on the Cardputer Zero](docs/devices/cardputer-zero.md) has the details.
|
||||
|
||||
### Coming
|
||||
@@ -248,6 +252,10 @@ meshterm --mock
|
||||
# custom console font, and an F-key hint lane. Preview it anywhere, or inspect it:
|
||||
meshterm --mock --platform picocalc-lyra
|
||||
meshterm specimen
|
||||
|
||||
# On a Cardputer Zero, MeshTerm draws its 53×14 display itself. See that display in a
|
||||
# window of its own (download its font once with `meshterm emulate --fetch-fonts`):
|
||||
meshterm emulate cardputer-zero --mock
|
||||
```
|
||||
|
||||
MeshTerm has its own manuals for handhelds. **[MeshTerm on the
|
||||
|
||||
@@ -37,7 +37,7 @@ MeshCore companion on USB, Bluetooth, or Wi-Fi does not need them.
|
||||
| **[MeshTerm on hardware](devices/README.md)** | Start here. It says which of the manuals below is the correct one for you, and what the scripts on the device do. |
|
||||
| [MeshTerm on the PicoCalc](devices/picocalc-lyra.md) | How to change a stock ClockworkPi PicoCalc into a Linux handheld that has a LoRa radio soldered inside: the shopping list, the change to a Luckfox Lyra core, Calculinux, and the radio. |
|
||||
| [MeshTerm on the uConsole](devices/uconsole.md) | A uConsole (CM4 or CM5) with the hackergadgets AIO LoRa board. Its SX1262 is on the computer's own SPI bus. MeshTerm can control it directly with `--spi`. Or a small bridge can make it a companion for a node that is always on. |
|
||||
| [MeshTerm on the Cardputer Zero](devices/cardputer-zero.md) | M5Stack's Linux Cardputer: how to install MeshTerm from the app store, use the Cap LoRa-1262 radio, and try the layout in the desktop emulator. |
|
||||
| [MeshTerm on the Cardputer Zero](devices/cardputer-zero.md) | M5Stack's Linux Cardputer, the standard model or the Lite: how to install MeshTerm from the app store, use the Cap LoRa-1262 radio, and try the layout in the desktop emulator. |
|
||||
|
||||
## Reading the code
|
||||
|
||||
@@ -63,4 +63,7 @@ the survey does not say which.
|
||||
|
||||
`screenshots/` has the captures that the README shows. It also has the captures from which
|
||||
[meshterm.net](https://meshterm.net) builds its landing page. Thus more than one place
|
||||
refers to each file in this folder.
|
||||
refers to each file in this folder. The folders `terminal/`, `cardputer-zero/`, and
|
||||
`picocalc-lyra/` hold the same four screens on each platform. `scripts/screenshots.py`
|
||||
makes them from the demo mesh, and they are the same at each run (refer to
|
||||
`scripts/README.md`).
|
||||
@@ -57,6 +57,7 @@ If you give no command, MeshTerm starts the interactive menu.
|
||||
| `--tcp HOST[:PORT]` | The network address of a TCP companion. It selects the TCP transport. The default port is 5000. |
|
||||
| `--spi` | The LoRa radio on the SPI bus of this machine. It selects the SPI transport. MeshTerm runs the node for the length of the command. If one radio is attached, MeshTerm uses that radio, with a profile or without one. If no radio is attached, MeshTerm uses the one SPI profile, or else the wiring of the uConsole AIO. If two radios are attached, MeshTerm refuses (exit `3`) and lists them. Select one with `-p`. Refer to [Adding a radio on the SPI bus](../guide/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` instead of your real history. |
|
||||
| `--demo` | Use the simulator in a demo mesh over Montréal: about forty invented nodes, a week of traffic, conversations, traces, and records. The demo has its own directory (`demo/` in the MeshTerm directory), and MeshTerm writes it again at each start. Thus each run starts from the same state, and your history never gets a node of the demo. It cannot take `--db`. |
|
||||
| `--db PATH` | Use this SQLite database instead of `~/.meshterm/meshterm.db` (or `meshterm-mock.db`, with `--mock`). **It moves the database and nothing else.** Refer to [Where MeshTerm keeps its state](#where-meshterm-keeps-its-state). |
|
||||
| `--json` | Print the answer as JSON instead of aligned text. |
|
||||
| `--absolute` | For this run, print times as ISO-8601 instants instead of relative ages. |
|
||||
@@ -99,6 +100,7 @@ radios from two directories in the same shell.
|
||||
| --- | --- |
|
||||
| `meshterm.db` | The history: each packet that MeshTerm heard, each trace that it walked, and each message. **This is the one file that `--db` moves.** |
|
||||
| `meshterm-mock.db` | The same history for the simulator. A `--mock` run records here when the run does not name a database. The file is absent until you do a `--mock` run. |
|
||||
| `demo/` | The directory of the demo (`--demo`). MeshTerm writes everything in it again at each start of the demo, except the tile cache of the map and the log. |
|
||||
| `config.toml` | The setup of the machine: device profiles, and where the database is. You write this file. MeshTerm only reads it. |
|
||||
| `preferences.toml` | Your overrides of the behaviour of MeshTerm. It lists only what you changed. |
|
||||
| `courier.json` | The outbox: queued messages, both waiting and finished. |
|
||||
@@ -113,6 +115,14 @@ radios from two directories in the same shell.
|
||||
| `meshterm.log` | The log file. |
|
||||
| `.lock` | The lock for a single instance. |
|
||||
|
||||
**No log keeps a password or a secret.** The log file, the run log in the database (the
|
||||
arguments and the result of each command), and the remote command history of
|
||||
`remote.json` write `<secret>` in the place of a password, a channel secret, a pairing
|
||||
PIN, or a private key. This is always true, at each log level and with or without the
|
||||
**Anonymize** preference. The history keeps `password ` without its value, so ↑ still
|
||||
recalls the start of the command. `admin.json` keeps the admin passwords that you asked
|
||||
MeshTerm to remember, because that is its purpose.
|
||||
|
||||
**`--db` alone does not isolate a run.** It moves the database. The database is most of
|
||||
the data, but it does not hold the identity. The contact caches, the channel caches, the
|
||||
outbox, the stored admin passwords, and the remembered device are all in the config
|
||||
@@ -1735,6 +1745,7 @@ weekly_flood_advert off off
|
||||
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
|
||||
message_parts_max 3 3 Send a long message as this many parts at most
|
||||
room_relogin_minutes 30 30 Opening a room this quiet logs in again
|
||||
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
|
||||
@@ -1752,7 +1763,9 @@ color_depth auto auto
|
||||
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
|
||||
name_overlap_days 7 7 Grey a name two nodes used within this many days
|
||||
section_spacing_min_rows 24 24 A blank line between sections and between chat authors
|
||||
log_level WARNING WARNING How much MeshTerm writes to its log file (next launch)
|
||||
anonymize off off Mask the home folder and addresses in the log and diagnostics
|
||||
```
|
||||
|
||||
**`DESCRIPTION`** says what each preference does, because the name of a key does not give
|
||||
@@ -1801,7 +1814,7 @@ code beside each link. The scripted face prints only the link.
|
||||
**`--json`:**
|
||||
|
||||
```json
|
||||
{"page":"discord","title":"Join Discord","version":"0.11.1","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.11.5","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 |
|
||||
@@ -1865,6 +1878,7 @@ tx_power_dbm 20
|
||||
error -
|
||||
|
||||
config_dir C:\Users\you\.meshterm
|
||||
anonymized no
|
||||
db_size_kb 41280
|
||||
log_level DEBUG
|
||||
log_size_kb 96
|
||||
@@ -1916,6 +1930,29 @@ public without reading it. `tests/test_diagnostics.py` plants real secrets in ea
|
||||
that holds one. It fails if one of them, or a field that is only *named* like one,
|
||||
reaches either face.
|
||||
|
||||
**`--anonymize` masks the personal details, and keeps a tidbit of each one.** Some facts
|
||||
are not secrets, but they are about you and your machine: the home folder in
|
||||
`config_dir`, which has your account name, and the address of a radio or a host that an
|
||||
`error` can repeat. `--anonymize` masks them in the way that a card number prints as
|
||||
`51xx xxxx xxxx 4571`:
|
||||
|
||||
| Detail | As it is | Masked |
|
||||
| --- | --- | --- |
|
||||
| The account name in a home folder | `C:\Users\jpmartineau` | `C:\Users\j*********u` |
|
||||
| A Bluetooth address | `DA:C5:21:6B:7C:7C` | `DA:xx:xx:xx:7C:7C` |
|
||||
| An IPv4 address | `192.168.0.180` | `192.xxx.x.180` |
|
||||
| The name of a network companion | `meshcore.local` | `m******e.local` |
|
||||
|
||||
Each masked value keeps its first and its last characters, and its length. Thus two radios
|
||||
in one report stay two different radios, and a maintainer can match the address in the
|
||||
report with the address in the log. The `anonymized` fact says which form the report is.
|
||||
|
||||
The **Anonymize** preference (`meshterm preferences set anonymize on`) makes the masked
|
||||
report the default, and `--no-anonymize` overrides it for one run. The preference also
|
||||
masks the log file, `meshterm.log`, as MeshTerm writes each line, so you can attach the
|
||||
log with the report. A line that MeshTerm wrote before you turned the preference on stays
|
||||
as it is. Node names, message text, and keys are not masked.
|
||||
|
||||
With `--json`, the five blocks of facts merge into one flat object, and the two listings
|
||||
keep a key of their own:
|
||||
|
||||
@@ -1951,6 +1988,11 @@ to the report. The save writes `meshterm-diagnostics.md` into the config directo
|
||||
the log, and it answers in a dialog that names the path. If the write fails, the same
|
||||
dialog says so, in red, and the page stays readable.
|
||||
|
||||
`a` changes the page between the report as it is and the anonymized report, and the title
|
||||
says `Diagnostics · anonymized` while the masks are visible. The page opens in the form
|
||||
that the **Anonymize** preference gives, and `s` saves the form that you see. On the
|
||||
PicoCalc, the key is **Shift+F3**, behind `Save`.
|
||||
|
||||
#### `meshterm platform`
|
||||
|
||||
A smaller command than `diagnostics`. It shows which UI flavour a run resolves to, and
|
||||
|
||||
@@ -194,6 +194,7 @@ and in quoted text.
|
||||
| packet | One radio transmission | frame (for radio data) |
|
||||
| protocol frame | One unit of the companion protocol on USB, Bluetooth, or TCP: a command, a reply, or a push from the companion. In code that handles only this protocol, "frame" alone is permitted | packet (for this) |
|
||||
| message | A text message, direct or on a channel | |
|
||||
| reaction | An emoji that a node sends to answer one message. It is a message on the wire, and the chat shows it under the message that it names | |
|
||||
| part | One of the messages that MeshTerm sends for a message that is too long for one packet | chunk, segment |
|
||||
| part number | The `[1/3]` at the end of a part | marker (for this) |
|
||||
| send | MeshTerm gives a message, a packet, or a command to the device | emit |
|
||||
@@ -220,6 +221,7 @@ and in quoted text.
|
||||
| prompt | A dialog or a line that asks for typed text | |
|
||||
| page | All the content of a screen, which can be taller than the terminal. Also a written page | |
|
||||
| frame | The full image that MeshTerm composes for the terminal in one paint: the title bar, the panel, the body, and the footer | |
|
||||
| video frame | One image of a video, such as the demo video that `scripts/video.py` makes | frame (for this) |
|
||||
| paint | One full update of the terminal | redraw, repaint, refresh (for the terminal) |
|
||||
| animation step | One image of an animation (a spinner) | frame (for this) |
|
||||
| panel | The box with a border around the body of a screen | panel (for the hardware) |
|
||||
@@ -282,6 +284,11 @@ and in quoted text.
|
||||
| save | Do an explicit save: the user's Save, or the device's save command | |
|
||||
| record | One stored data item | |
|
||||
| delete | Remove stored data permanently | purge (except the Purge feature) |
|
||||
| secret | A value that unlocks something: a password, a channel secret, a pairing PIN, a private key. No log keeps one: it is hidden as `<secret>` | credential (except in a name) |
|
||||
| hide (a secret) | Replace a secret with `<secret>` in a log or in the run log | redact, scrub |
|
||||
| personal detail | A value that identifies the user or the equipment of the user, but is not a secret: the account name in a home folder, the address of a radio, the address of a host | PII, private data |
|
||||
| mask | Replace most characters of a personal detail, and keep its first and last characters: `j*********u`, `DA:xx:xx:xx:7C:7C` | redact, scrub, obfuscate |
|
||||
| anonymize | Mask each personal detail in a text: the `anonymize` preference does it to the log and the diagnostics | |
|
||||
| remove | Take an item out of a collection or a text | drop, strip |
|
||||
| clear | Make empty | |
|
||||
| download | Get a file from the network | fetch |
|
||||
|
||||
@@ -15,7 +15,7 @@ has its own manual with numbered steps. This page tells you which manual is your
|
||||
| a ClockworkPi **PicoCalc**, stock or already running Calculinux on a Luckfox Lyra, and I want MeshTerm on its own display | **[MeshTerm on the PicoCalc](picocalc-lyra.md)**: from the shopping list to a working machine |
|
||||
| …and I want a **radio** inside it | the same manual, [Phase 3](picocalc-lyra.md#phase-3-add-the-radio): a XIAO nRF52840 + Wio-SX1262 soldered to the UART of the Lyra |
|
||||
| a ClockworkPi **uConsole** with the hackergadgets AIO board (an SX1262 on the SPI bus of the host) | **[MeshTerm on the uConsole](uconsole.md)**: MeshTerm drives the radio directly with `--spi`. A bridge service can also present the radio as a companion, if you want it always on |
|
||||
| an M5Stack **Cardputer Zero**, the one with a Raspberry Pi inside (not the ESP32 Cardputer or the Cardputer-Adv) | **[MeshTerm on the Cardputer Zero](cardputer-zero.md)**: install MeshTerm from M5Stack's app store. MeshTerm drives the Cap LoRa-1262 directly, or it uses a companion |
|
||||
| an M5Stack **Cardputer Zero** or **Cardputer Zero Lite**, the models with a Raspberry Pi inside (not the ESP32 Cardputer or the Cardputer-Adv) | **[MeshTerm on the Cardputer Zero](cardputer-zero.md)**: install MeshTerm from M5Stack's app store. MeshTerm drives the Cap LoRa-1262 directly, or it uses a companion |
|
||||
| a MeshCore companion on USB, Bluetooth, or Wi-Fi | nothing here. Read the [README](../../README.md). If the companion does not connect, read [When a companion does not connect](../guide/connecting.md) |
|
||||
|
||||
The handhelds have nothing in common except MeshTerm. Each manual runs scripts on the
|
||||
|
||||
@@ -7,7 +7,8 @@ this page applies to them.
|
||||
|
||||
MeshTerm is an app in M5Stack's app store for the Cardputer Zero. It starts from the
|
||||
launcher of the handheld and draws on its display. It can drive M5Stack's Cap LoRa-1262
|
||||
radio directly, or it can use a MeshCore companion.
|
||||
radio directly, or it can use a MeshCore companion. MeshTerm supports both models of the
|
||||
handheld: the Cardputer Zero and the Cardputer Zero Lite.
|
||||
|
||||
- [The device](#the-device)
|
||||
- [What works](#what-works)
|
||||
@@ -21,11 +22,18 @@ radio directly, or it can use a MeshCore companion.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Models | Cardputer Zero (C154) and Cardputer Zero Lite (C155). MeshTerm supports both |
|
||||
| Computer | Raspberry Pi Compute Module 0: four 1 GHz cores, 512 MB of memory |
|
||||
| Screen | 1.9 inches, 320×170 pixels |
|
||||
| Keyboard | 46 keys, with Shift, Fn and Sym layers |
|
||||
| System | Raspberry Pi OS "Trixie", with M5Stack's own app launcher |
|
||||
|
||||
The two models have the same computer, display, keyboard, battery, and expansion header.
|
||||
The Cardputer Zero also has a camera, motion sensors, and a 32 GB microSD card. MeshTerm
|
||||
uses none of these three parts, thus it works in the same way on both models. The Lite
|
||||
comes without a microSD card. It needs a card with M5Stack's system on it before it can
|
||||
start (refer to [M5Stack's documentation](https://docs.m5stack.com/en/CardputerZero)).
|
||||
|
||||
MeshTerm draws the display in 53 columns by 14 rows. The width is the same as on the
|
||||
PicoCalc, but the height is approximately half. Thus many screens have a more compact
|
||||
layout here.
|
||||
@@ -66,8 +74,9 @@ MeshTerm now has an icon in the launcher. The package has all that MeshTerm need
|
||||
Python libraries, the radio library for the Cap, and the font of the display. Each new
|
||||
version of MeshTerm also goes to the store.
|
||||
|
||||
M5Stack examines a new app before the store shows it. If the store does not have MeshTerm
|
||||
yet, install the same package from GitHub.
|
||||
The store gets a new version a short time after its release, when the check of the store
|
||||
passes. If you want a new version before the store has it, install the same package from
|
||||
GitHub.
|
||||
|
||||
### From GitHub
|
||||
|
||||
@@ -176,8 +185,18 @@ The Cardputer Zero has no LoRa radio built in. You can add one in three ways:
|
||||
|
||||
### The Cap LoRa-1262
|
||||
|
||||
1. Push the Cap onto the expansion header at the back.
|
||||
2. Start MeshTerm.
|
||||
**CAUTION:** Turn off the Cardputer Zero before you install or remove a Cap, also the Cap
|
||||
LoRa-1262. M5Stack does not support hot swapping of a Cap on the Cardputer Zero. In
|
||||
M5Stack's words, "hot-swapping may cause the device to malfunction" (refer to
|
||||
[M5Stack's documentation](https://docs.m5stack.com/en/CardputerZero)).
|
||||
|
||||
**CAUTION:** Install the antenna on the Cap before you turn on the Cardputer Zero. M5Stack
|
||||
says that power without the antenna can permanently damage the radio.
|
||||
|
||||
1. Turn off the Cardputer Zero.
|
||||
2. Install the antenna on the Cap.
|
||||
3. Push the Cap onto the expansion header at the back.
|
||||
4. Turn on the Cardputer Zero, and start MeshTerm.
|
||||
|
||||
The device screen lists the Cap as **Cap LoRa-1262**. `meshterm --spi` also reaches it from the
|
||||
command line.
|
||||
@@ -188,7 +207,7 @@ radio library. The package from the store has it. If you install MeshTerm with p
|
||||
install the library next to it:
|
||||
|
||||
```bash
|
||||
pip install 'mesh-term[spi]'
|
||||
pip install 'mesh-term[spi] @ git+https://github.com/jpmartineau/MeshTerm'
|
||||
```
|
||||
|
||||
When MeshTerm connects, it does three things that the Cardputer Zero needs before the
|
||||
@@ -201,7 +220,8 @@ radio answers:
|
||||
- It switches on the antenna path of the Cap.
|
||||
|
||||
When MeshTerm quits, it switches the power of the header off. Thus the Cap does not drain
|
||||
the battery while nothing uses it.
|
||||
the battery while nothing uses it. But this does not make it safe to remove the Cap. Turn
|
||||
off the Cardputer Zero first.
|
||||
|
||||
A new node starts with the US/Canada settings of MeshCore (910.525 MHz). In other regions,
|
||||
change the settings on **Device config** before you send anything. MeshTerm keeps the key,
|
||||
|
||||
@@ -138,7 +138,7 @@ the route that agrees with how you installed MeshTerm.
|
||||
(refer to [Step 3](#step-3-install-meshterm)):
|
||||
|
||||
```bash
|
||||
pip install 'mesh-term[spi]'
|
||||
pip install 'mesh-term[spi] @ git+https://github.com/jpmartineau/MeshTerm'
|
||||
```
|
||||
|
||||
- **Do you run the one-file Linux ARM64 build?** It is a single frozen binary, and it
|
||||
@@ -181,7 +181,7 @@ pipx inject mesh-term 'openhop-core[hardware]'
|
||||
Or use `pip`, which adds the radio library as its own extra in one line:
|
||||
|
||||
```bash
|
||||
pip install 'mesh-term[spi]'
|
||||
pip install 'mesh-term[spi] @ git+https://github.com/jpmartineau/MeshTerm'
|
||||
```
|
||||
|
||||
---
|
||||
@@ -227,7 +227,8 @@ the mesh. On Device config, **Identity key…** shows the key, exports it, and i
|
||||
one, as for each companion. An export has the form that a firmware companion uses: 64
|
||||
bytes, or 128 hex digits. When the node makes its own key, it keeps a shorter form in
|
||||
`identity.key`: a 32-byte seed. The two forms are the same identity, and an import accepts
|
||||
each of them. The node reads its key only when it starts. Thus, after an import, MeshTerm
|
||||
each of them. MeshTerm saves an imported key in the form that you give, so an imported
|
||||
seed stays a seed. The node reads its key only when it starts. Thus, after an import, MeshTerm
|
||||
restarts the node, and the reconnect dialog shows until the node is ready again.
|
||||
|
||||
**A profile, if you want a name for the connection:**
|
||||
|
||||
@@ -20,7 +20,10 @@ You see the same message wherever the connection fails:
|
||||
MeshTerm also writes each failure to the log file, `~/.meshterm/meshterm.log` (refer to
|
||||
[Where MeshTerm keeps its state](../cli/README.md#where-meshterm-keeps-its-state)). Attach
|
||||
the log file to a bug report. To get more detail, set the **Log detail** preference to
|
||||
`DEBUG` (`meshterm preferences set log_level DEBUG`), and run the command again.
|
||||
`DEBUG` (`meshterm preferences set log_level DEBUG`), and run the command again. The log
|
||||
never holds a password or a secret, at any level. It can show your account name and the
|
||||
address of your radio. To mask them as MeshTerm writes
|
||||
each line, turn on the **Anonymize** preference (`meshterm preferences set anonymize on`).
|
||||
|
||||
Sometimes MeshTerm meets a failure that it has no name for. Then the message is the error
|
||||
in its own words, and a second line tells where the log file is. The log has the full
|
||||
|
||||
@@ -15,7 +15,7 @@ This page lists each interactive screen with its scripted equivalent, where one
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **💬 Chat** | Live full-screen messages on channels and to contacts. The transcript scrolls, the input line stays at the bottom, and sent and received messages appear together. The rooms that you joined are also here. Each post shows under the name of its author. A message that is too long for one packet goes out as parts, each ending with its number (`[1/3]`). MeshTerm stores each message. Unread counts show in the menu header. | `meshterm chat send / history / list / listen` |
|
||||
| **💬 Chat** | Live full-screen messages on channels and to contacts. The transcript scrolls, the input line stays at the bottom, and sent and received messages appear together. The rooms that you joined are also here. Each post shows under the name of its author. Select a message and press **^E** to react to it with an emoji. The reaction shows under the message, and MeshCore One and meshcore-open show it too. The emoji that ^E offers are a preference. A message that is too long for one packet goes out as parts, each ending with its number (`[1/3]`). MeshTerm stores each message. Unread counts show in the menu header. | `meshterm chat send / history / list / listen` |
|
||||
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels. You can share a channel with a QR code or a `meshcore://` link. If you give a channel a **send scope**, its messages are flooded into one region. Only the repeaters that carry that region relay them. When you change to a new device, export all the channels to a file, then import the file on the new device. The **channel guide** lists the public channels that MeshTerm heard, also the channels that are not on your device, and adds one with Enter. | `meshterm channels list / add / join / import / share / clear / scope / export / guide` |
|
||||
| **📌 Rooms** | Each room server that your radio knows. A room server is a message board on a radio. It keeps the latest posts and sends you the posts that you missed. Join a room with its password. If a room does not answer, MeshTerm tells you what it knows and offers the next step to try. For example, it can send again by flood when the route that your radio learned is no longer good. | `meshterm rooms list / join / forget` |
|
||||
| **📨 Courier** | A store-and-forward outbox for contacts that you cannot reach yet. Queue a message. MeshTerm sends it when the contact is next heard, or at a time that you set. It tracks the acks, and it waits longer between each try (exponential backoff). | `meshterm courier queue / list / send / cancel / clear` |
|
||||
@@ -67,7 +67,7 @@ only describe it. The written pages are at the end.
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **⚙ Preferences** | How MeshTerm behaves, in eight groups: Device, Sending, TX optimize, Watchtower, Map, History, Display, and Diagnostics. The page stages your changes, and you save them with one action at the bottom. MeshTerm writes only the values that you changed to `preferences.toml`. [Configuring MeshTerm](configuration.md) has the detail. | `meshterm preferences show / set / reset` |
|
||||
| **🩺 Diagnostics** | One block of information that each bug report starts with. It shows which build you have and how you installed it, the operating system, the terminal and its size, and the radio and its link. It also shows the verdicts from boot time that no other screen shows (icons, powerline, platform flavour, and the reasons), and how much history MeshTerm stored. It has nothing private: no keys, passwords, positions, or contact names. It describes the mesh only as counts for each table and the span of time that the tables cover. Press `s` to save the block beside the log, to attach to a report. | `meshterm diagnostics [--out PATH]` |
|
||||
| **🩺 Diagnostics** | One block of information that each bug report starts with. It shows which build you have and how you installed it, the operating system, the terminal and its size, and the radio and its link. It also shows the verdicts from boot time that no other screen shows (icons, powerline, platform flavour, and the reasons), and how much history MeshTerm stored. It has nothing private: no keys, passwords, positions, or contact names. It describes the mesh only as counts for each table and the span of time that the tables cover. Press `a` to anonymize it: your account name in the home folder and the addresses of your radio and hosts are masked, and each keeps a tidbit (`j*********u`, `DA:xx:xx:xx:7C:7C`). Press `s` to save the block beside the log, to attach to a report. The **Anonymize** preference opens the page anonymized, and masks the log too. | `meshterm diagnostics [--out PATH] [--anonymize]` |
|
||||
| **📖 About MeshTerm** | What MeshTerm is, where it came from, and the terms of its licence. It includes the map credit, the font credit, and the licence URL. | `meshterm about` |
|
||||
| **👤 About the author** | The person behind MeshTerm, on the mesh and away from it. | `meshterm about-author` |
|
||||
| **🔗 Join Discord** | The community server. The page has one invite link and a QR code of the link, which a phone can read. | `meshterm discord` |
|
||||
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 40 KiB |
@@ -6,7 +6,7 @@ Connects to a companion over USB, Bluetooth, or TCP. For Windows, macOS, and Lin
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
__version__ = "0.11.1"
|
||||
__version__ = "0.11.5"
|
||||
__author__ = "Jean-Pierre Martineau"
|
||||
|
||||
#: The year when MeshTerm was first published. The copyright span starts in this year.
|
||||
|
||||
@@ -18,7 +18,7 @@ I wanted something I could run from my computer. I wanted it to run on Windows,
|
||||
|
||||
## Platform-specific developments
|
||||
|
||||
In addition to a generic terminal setup, I wanted it to run on two devices I had lying around in my nerdcave.
|
||||
In addition to a generic terminal setup, I wanted it to run on two devices I had lying around in my nerdcave. Then a third one showed up.
|
||||
|
||||
### 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.
|
||||
@@ -36,6 +36,14 @@ Getting MeshTerm to run on the **PicoCalc** is a bit more involved, but totally
|
||||
|
||||
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.
|
||||
|
||||
### M5Stack Cardputer Zero
|
||||
The **Cardputer Zero** is the easiest of the three: MeshTerm is in **M5Stack**'s app store, so installing it takes a few keypresses. Both models work, the standard **Cardputer Zero** and the **Cardputer Zero Lite**.
|
||||
- Add the **Cap LoRa-1262**, and MeshTerm runs the mesh node itself, straight over SPI, just like on the uConsole. The Cap's GPS works too.
|
||||
- Power the Cardputer off before you plug in or remove a Cap — the LoRa one included. M5Stack doesn't support hot-swapping Caps, and says it may cause the device to malfunction.
|
||||
- Put the antenna on the Cap before you power it on. M5Stack warns that the radio can be permanently damaged without it.
|
||||
|
||||
The screen is tiny — 53 columns by 14 rows — so MeshTerm draws every pixel itself, and every screen has been squeezed to fit. The F-key labels run along the bottom of the screen, right above the number keys that press them. It's a mesh terminal that fits in your pocket!
|
||||
|
||||
## What the future holds
|
||||
|
||||
MeshTerm will evolve over time. It is a work in progress.
|
||||
|
||||
@@ -23,17 +23,19 @@ from . import __version__
|
||||
from .context import AppContext
|
||||
from .core import exitcodes, win32dll
|
||||
from .core.admin_store import AdminStore
|
||||
from .core.config import MOCK_DB_FILENAME, Settings
|
||||
from .core.config import DB_FILENAME, 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.models import utcnow
|
||||
from .core.preferences import (
|
||||
PREFERENCES_FILENAME,
|
||||
PreferenceError,
|
||||
Preferences,
|
||||
report_dropped,
|
||||
)
|
||||
from .core.preferences import install as install_preferences
|
||||
from .core.selection import DeviceSelectionError
|
||||
from .persistence.logging import configure_logging, get_logger, level_from_name, log_path
|
||||
from .persistence.repository import Repository
|
||||
@@ -189,6 +191,9 @@ def main_callback(
|
||||
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"),
|
||||
demo: bool = typer.Option(
|
||||
False, "--demo", help="Run the simulator in a demo mesh over Montréal, with its own data"
|
||||
),
|
||||
db_path: Path | None = typer.Option(
|
||||
None, "--db", help="SQLite database path (--mock records to meshterm-mock.db)"
|
||||
),
|
||||
@@ -218,6 +223,8 @@ def main_callback(
|
||||
tcp: An explicit network address ``host[:port]``, which selects the TCP transport.
|
||||
spi: Select the radio on the SPI bus of this machine. MeshTerm itself runs the node.
|
||||
mock: Whether to use the simulator instead of real hardware.
|
||||
demo: Run the simulator in the demo world (:mod:`meshterm.demo`), with a data
|
||||
directory of its own that MeshTerm writes again at each start.
|
||||
db_path: Override the location of the database. If it is not set, a ``--mock`` run
|
||||
stores to the simulator's own database and not to the real history.
|
||||
json_output: Print the answer as JSON instead of aligned text.
|
||||
@@ -258,9 +265,30 @@ def main_callback(
|
||||
# contradict each other about the device to talk to are a bad command line. They are
|
||||
# not a question of precedence.
|
||||
raise typer.BadParameter(f"--mock and {named_radios[0]} name different devices; pass one")
|
||||
if demo and named_radios:
|
||||
raise typer.BadParameter(f"--demo and {named_radios[0]} name different devices; pass one")
|
||||
if demo and db_path is not None:
|
||||
# The demo writes its database again at each start. If it took --db, a typing error
|
||||
# would put a demo history over the history of the user.
|
||||
raise typer.BadParameter("--demo writes its own database; drop --db")
|
||||
|
||||
settings = Settings.load()
|
||||
if db_path is not None:
|
||||
if demo:
|
||||
# The demo is a whole MeshTerm of its own: its own directory, which it writes again
|
||||
# at each start. The directory of the user never sees a node of the demo. The
|
||||
# ``emulate`` command does not prepare it, because the MeshTerm in its window gets
|
||||
# the same flag and prepares it there.
|
||||
from .demo.home import demo_home, prepare
|
||||
|
||||
mock = True
|
||||
settings.config_dir = demo_home(settings.config_dir)
|
||||
settings.db_path = settings.config_dir / DB_FILENAME
|
||||
if ctx.invoked_subcommand != "emulate":
|
||||
try:
|
||||
prepare(settings.config_dir, utcnow())
|
||||
except InstanceBusy as exc:
|
||||
raise typer.BadParameter(str(exc), param_hint="--demo") from None
|
||||
elif db_path is not None:
|
||||
settings.db_path = db_path
|
||||
elif mock:
|
||||
# The simulator is a fake radio, but it is not a fake MeshTerm. MeshTerm stored all
|
||||
@@ -292,6 +320,11 @@ def main_callback(
|
||||
# registry would be a behaviour that a user cannot find. MeshTerm sets it and never
|
||||
# saves it, because it is a claim about this run.
|
||||
prefs.set("cli_time_format", "absolute")
|
||||
# The log file reads the ``anonymize`` preference at each record, through the
|
||||
# process-wide set. The context installs the same set again later. If MeshTerm installs
|
||||
# it only there, the records before the context (the dropped preferences below) are not
|
||||
# masked when the user asked for it.
|
||||
install_preferences(prefs)
|
||||
configure_logging(
|
||||
# The stdout of a scripted run carries its answer and nothing else. Thus the log
|
||||
# records go to stderr, and they do not mix with the data that a caller parses.
|
||||
@@ -327,6 +360,7 @@ def main_callback(
|
||||
admin_store=AdminStore(settings.config_dir / "admin.json"),
|
||||
profile=settings.resolve_profile(profile),
|
||||
mock=mock,
|
||||
demo=demo,
|
||||
port_override=port,
|
||||
ble_override=ble,
|
||||
tcp_override=tcp,
|
||||
|
||||
@@ -102,6 +102,8 @@ class AppContext:
|
||||
room and the access that it granted (if the caller does not inject it, the
|
||||
default is ``<config_dir>/rooms.json``).
|
||||
mock: Whether the context uses the simulator device.
|
||||
demo: Whether the simulator is the demo world of ``--demo``
|
||||
(:mod:`meshterm.demo`), which implies ``mock``.
|
||||
port_override: An explicit serial port (from ``--port`` or the interactive picker),
|
||||
which overrides the profile.
|
||||
ble_override: An explicit Bluetooth address (from ``--ble`` or the interactive
|
||||
@@ -145,6 +147,7 @@ class AppContext:
|
||||
room_store: RoomStore | None = None
|
||||
profile: DeviceProfile | None = None
|
||||
mock: bool = False
|
||||
demo: bool = False
|
||||
port_override: str | None = None
|
||||
ble_override: str | None = None
|
||||
tcp_override: str | None = None
|
||||
@@ -298,11 +301,12 @@ class AppContext:
|
||||
|
||||
This is a best-effort check, and it does not disturb the link. The method calls
|
||||
:meth:`~meshterm.core.connection.Device.link_present` of the connected device. For
|
||||
serial, this is the enumeration of OS ports. For Bluetooth, it is the connection flag
|
||||
of the BLE client. Thus the liveness watcher of the session does not depend on the
|
||||
transport. The method returns ``False`` when nothing is connected, because there is no
|
||||
live link. If the check fails, it returns ``True``. Thus a temporary failure of the
|
||||
lookup never gives a false disconnect.
|
||||
serial, this is the connection flag of the client and the enumeration of OS ports.
|
||||
For Bluetooth, it is the connection flag of the BLE client. Thus the liveness
|
||||
watcher of the session does not depend on the transport. The method returns
|
||||
``False`` when nothing is connected, because there is no live link. If the check
|
||||
fails, it returns ``True``. Thus a temporary failure of the lookup never gives a
|
||||
false disconnect.
|
||||
"""
|
||||
device = self._device
|
||||
if device is None:
|
||||
@@ -615,7 +619,7 @@ class AppContext:
|
||||
return self._device
|
||||
|
||||
if self.mock:
|
||||
self._device = make_device(mock=True, port=None)
|
||||
self._device = make_device(mock=True, port=None, demo=self.demo)
|
||||
self._active_transport = None
|
||||
self._active_port = None
|
||||
self._active_address = None
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
r"""Anonymize a text: mask each personal detail in it, and keep a tidbit of each one.
|
||||
|
||||
The log and the diagnostics block go into public bug reports. A user who reads their own
|
||||
log needs each detail as it is, so MeshTerm masks nothing by default. When the
|
||||
``anonymize`` preference is on, :func:`anonymize` masks three types of personal detail:
|
||||
|
||||
- **The account name in a home folder.** ``C:\Users\jpmartineau`` becomes
|
||||
``C:\Users\j*********u``, and ``/home/jp`` becomes ``/home/j*``.
|
||||
- **The address of a radio.** A Bluetooth address ``DA:C5:21:6B:7C:7C`` becomes
|
||||
``DA:xx:xx:xx:7C:7C``. A macOS peripheral id (a UUID) keeps its first two and its last
|
||||
four hex digits in the same way.
|
||||
- **The address of a host.** An IPv4 address ``192.168.0.180`` becomes ``192.xxx.x.180``.
|
||||
The name of a network companion (``meshcore.local``) becomes ``m******e.local``, because
|
||||
MeshTerm remembers each host that it opens (:func:`remember_host`).
|
||||
|
||||
**A mask keeps a tidbit, and never removes the value** (JP, 2026-10-17). A card number
|
||||
prints as ``51xx xxxx xxxx 4571``, and a masked value keeps its first and its last
|
||||
characters in the same way. Thus two radios in one log stay two different radios, and a
|
||||
maintainer can still match the address in a log line with the address in the
|
||||
diagnostics. Also, a value keeps its length and its separators, so the line keeps its
|
||||
shape. A digit masks to ``x``, the mark that a person expects in a number. A letter
|
||||
masks to ``*``, because an ``x`` in a name is a letter.
|
||||
|
||||
The masks are idempotent. A masked value does not match its own rule again, or it masks
|
||||
to itself. Thus a text can go through :func:`anonymize` two times with no change.
|
||||
|
||||
Node names, message text, and keys are not personal details in this sense, and this module
|
||||
does not mask them. The diagnostics never contain them (refer to
|
||||
:mod:`meshterm.tools.diagnostics`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
#: The mark that replaces a masked letter of a name.
|
||||
NAME_MASK = "*"
|
||||
|
||||
#: The mark that replaces a masked digit of a number or an address.
|
||||
DIGIT_MASK = "x"
|
||||
|
||||
_HEX = frozenset("0123456789abcdefABCDEF")
|
||||
|
||||
#: A Bluetooth address: six hex pairs, all with the same separator. The lookarounds keep a
|
||||
#: longer run of hex pairs from matching in its middle. They refuse a separator only when
|
||||
#: a hex digit is on its far side, because a log line can put a colon after an address
|
||||
#: ("BLE companion DA:C5:21:6B:7C:7C: connected to ...").
|
||||
_MAC = re.compile(
|
||||
r"(?<![0-9A-Fa-f])(?<![0-9A-Fa-f][:-])"
|
||||
r"[0-9A-Fa-f]{2}([:-])(?:[0-9A-Fa-f]{2}\1){4}[0-9A-Fa-f]{2}"
|
||||
r"(?![0-9A-Fa-f])(?![:-][0-9A-Fa-f])"
|
||||
)
|
||||
|
||||
#: A UUID: the form in which macOS names a Bluetooth peripheral.
|
||||
_UUID = re.compile(
|
||||
r"(?<![0-9A-Fa-f])(?<![0-9A-Fa-f]-)"
|
||||
r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}"
|
||||
r"(?![0-9A-Fa-f])(?!-[0-9A-Fa-f])"
|
||||
)
|
||||
|
||||
#: An IPv4 address. The octets are checked after the match (refer to :func:`_mask_ipv4`).
|
||||
#: Thus a version with four parts and a large part (``10.0.19045.1234``) stays as it is.
|
||||
_IPV4 = re.compile(r"(?<![\d.])(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?!\.?\d)")
|
||||
|
||||
#: A home folder in the standard place of each OS: ``C:\Users\<name>``, ``/home/<name>``,
|
||||
#: and ``/Users/<name>``. The separators match in each spelling: a backslash, a slash, and
|
||||
#: the doubled backslash of a ``repr``. This rule also masks the short form of a long
|
||||
#: account name on Windows (``JPMART~1``), which the exact rule cannot know. The account
|
||||
#: can contain ``*``, so that a name that is already masked masks to itself.
|
||||
_HOME_ROOT = re.compile(
|
||||
r"(?:(?<!\w)[A-Za-z]:(?:\\|/)+[Uu][Ss][Ee][Rr][Ss](?:\\|/)+|(?<![\w.])/(?:home|Users)/)"
|
||||
r"(?P<account>[^\\/\s'\"<>|:,;()\[\]]+)"
|
||||
)
|
||||
|
||||
#: The hosts that :func:`remember_host` registered, and the pattern that finds them.
|
||||
_hosts: dict[str, str] = {}
|
||||
_host_pattern: re.Pattern[str] | None = None
|
||||
_hosts_lock = threading.Lock()
|
||||
|
||||
|
||||
def mask_name(name: str) -> str:
|
||||
"""Mask a name, and keep its first and its last characters: ``j*********u``.
|
||||
|
||||
The masked name has the same length as the name. A name of two characters keeps only
|
||||
its first character, and a name of one character keeps nothing.
|
||||
|
||||
Args:
|
||||
name: The name to mask.
|
||||
|
||||
Returns:
|
||||
The masked name.
|
||||
"""
|
||||
if len(name) <= 1:
|
||||
return NAME_MASK * len(name)
|
||||
if len(name) == 2:
|
||||
return name[0] + NAME_MASK
|
||||
return name[0] + NAME_MASK * (len(name) - 2) + name[-1]
|
||||
|
||||
|
||||
def mask_host(host: str) -> str:
|
||||
"""Mask a host: an IP address as a number, or a host name as a name.
|
||||
|
||||
An IPv4 address keeps its first and its last octets (``192.xxx.x.180``). An IPv6
|
||||
address keeps its first and its last groups. A host name keeps its last label, which is
|
||||
the domain that tells a maintainer what type of network it is (``.local``, ``.lan``),
|
||||
and masks each other label as a name. A host name of one label is masked as a name.
|
||||
|
||||
Args:
|
||||
host: The host, with no port and no brackets.
|
||||
|
||||
Returns:
|
||||
The masked host.
|
||||
"""
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
labels = host.split(".")
|
||||
if len(labels) == 1:
|
||||
return mask_name(host)
|
||||
return ".".join([mask_name(label) for label in labels[:-1]] + [labels[-1]])
|
||||
if address.version == 4:
|
||||
return _IPV4.sub(_mask_ipv4, host)
|
||||
groups = host.split(":")
|
||||
return ":".join([groups[0]] + [_mask_digits(group) for group in groups[1:-1]] + [groups[-1]])
|
||||
|
||||
|
||||
def remember_host(host: str) -> None:
|
||||
"""Register a network host, so that :func:`anonymize` masks it each time it occurs.
|
||||
|
||||
No pattern can find a host name in free text. Thus MeshTerm registers each host that it
|
||||
opens, and the mask finds that exact text. :class:`~meshterm.core.connection.MeshCoreDevice`
|
||||
calls this function for each TCP companion. An IPv4 address needs no registration,
|
||||
because its own rule finds it.
|
||||
|
||||
Args:
|
||||
host: A host name or an IP address. Brackets around an IPv6 address are removed.
|
||||
"""
|
||||
global _host_pattern
|
||||
text = host.strip().strip("[]")
|
||||
if not text or _IPV4.fullmatch(text):
|
||||
return
|
||||
with _hosts_lock:
|
||||
if text.lower() in _hosts:
|
||||
return
|
||||
_hosts[text.lower()] = mask_host(text)
|
||||
# The longest host first, so that a host never masks a part of a longer host. A
|
||||
# colon can follow a host, because the port comes after it ("meshcore.local:5000").
|
||||
alternatives = "|".join(re.escape(h) for h in sorted(_hosts, key=len, reverse=True))
|
||||
_host_pattern = re.compile(rf"(?<![\w.-])(?:{alternatives})(?![\w-])", re.IGNORECASE)
|
||||
|
||||
|
||||
def anonymize(text: str) -> str:
|
||||
"""Mask each personal detail in ``text``: home folders, radio addresses, and hosts.
|
||||
|
||||
Refer to the module docstring for each rule. The home folder of this machine is masked
|
||||
also when it is not in the standard place (``/root``, or a folder that a user chose).
|
||||
|
||||
Args:
|
||||
text: Any text: a log record with its traceback, or a value of the diagnostics.
|
||||
|
||||
Returns:
|
||||
The text with each personal detail masked.
|
||||
"""
|
||||
home = _home()
|
||||
if home:
|
||||
text = _exact_home_rule(home, sys.platform == "win32").sub(_mask_account, text)
|
||||
text = _HOME_ROOT.sub(_mask_account, text)
|
||||
text = _MAC.sub(_mask_address, text)
|
||||
text = _UUID.sub(_mask_address, text)
|
||||
text = _IPV4.sub(_mask_ipv4, text)
|
||||
hosts = _host_pattern
|
||||
if hosts is not None:
|
||||
text = hosts.sub(lambda m: _hosts.get(m.group(0).lower(), m.group(0)), text)
|
||||
return text
|
||||
|
||||
|
||||
def _home() -> str:
|
||||
"""The home folder of this machine, or ``""`` when the OS cannot say where it is."""
|
||||
try:
|
||||
return str(Path.home())
|
||||
except (RuntimeError, KeyError, OSError):
|
||||
return ""
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _exact_home_rule(home: str, fold_case: bool) -> re.Pattern[str]:
|
||||
"""The pattern that finds ``home`` in a text, with the separators in each spelling.
|
||||
|
||||
The last folder of the home path is the account name. Only that folder is masked, so
|
||||
the masked path still shows where MeshTerm keeps its data. A home with no folder in it
|
||||
(``/``) gives a pattern that never matches.
|
||||
|
||||
Args:
|
||||
home: The home folder of this machine.
|
||||
fold_case: Whether to ignore case, because Windows paths ignore it.
|
||||
|
||||
Returns:
|
||||
A pattern with the group ``account``.
|
||||
"""
|
||||
folders = [part for part in re.split(r"[\\/]+", home) if part]
|
||||
if not folders:
|
||||
return re.compile(r"(?!)")
|
||||
separator = r"(?:\\|/)+"
|
||||
lead = separator if home[:1] in ("/", "\\") else ""
|
||||
head = separator.join(re.escape(part) for part in folders[:-1])
|
||||
pattern = (
|
||||
rf"(?<![\w.]){lead}{head}{separator if head else ''}"
|
||||
rf"(?P<account>{re.escape(folders[-1])})(?![\w-])"
|
||||
)
|
||||
return re.compile(pattern, re.IGNORECASE if fold_case else 0)
|
||||
|
||||
|
||||
def _mask_account(match: re.Match[str]) -> str:
|
||||
"""Mask the ``account`` group of a home-folder match, and keep the rest of the match."""
|
||||
whole = match.group(0)
|
||||
start = match.start("account") - match.start()
|
||||
end = match.end("account") - match.start()
|
||||
return whole[:start] + mask_name(match.group("account")) + whole[end:]
|
||||
|
||||
|
||||
def _mask_address(match: re.Match[str]) -> str:
|
||||
"""Mask a radio address: keep the first two and the last four hex digits."""
|
||||
return _mask_digits(match.group(0), keep_head=2, keep_tail=4)
|
||||
|
||||
|
||||
def _mask_ipv4(match: re.Match[str]) -> str:
|
||||
"""Mask the two middle octets of an IPv4 address, or keep a match that is not one."""
|
||||
octets = match.groups()
|
||||
if any(int(octet) > 255 for octet in octets):
|
||||
return match.group(0)
|
||||
first, second, third, last = octets
|
||||
return ".".join((first, _mask_digits(second), _mask_digits(third), last))
|
||||
|
||||
|
||||
def _mask_digits(text: str, *, keep_head: int = 0, keep_tail: int = 0) -> str:
|
||||
"""Replace the hex digits of ``text`` with :data:`DIGIT_MASK`, but the ones to keep.
|
||||
|
||||
The separators stay where they are, so the value keeps its shape.
|
||||
|
||||
Args:
|
||||
text: The value to mask.
|
||||
keep_head: How many digits to keep at the start.
|
||||
keep_tail: How many digits to keep at the end.
|
||||
|
||||
Returns:
|
||||
The masked value.
|
||||
"""
|
||||
digits = [i for i, char in enumerate(text) if char in _HEX]
|
||||
masked = set(digits[keep_head : len(digits) - keep_tail])
|
||||
return "".join(DIGIT_MASK if i in masked else char for i, char in enumerate(text))
|
||||
|
||||
|
||||
__all__ = ["DIGIT_MASK", "NAME_MASK", "anonymize", "mask_host", "mask_name", "remember_host"]
|
||||
@@ -35,17 +35,21 @@ from urllib.parse import parse_qs, quote, urlsplit
|
||||
# not one time for each candidate channel. Thus the reception path pays almost nothing.
|
||||
|
||||
#: Matches the ``Name: message`` convention that channel senders use to identify themselves
|
||||
#: (the protocol has no sender field). The name is 1–20 characters that are not colons, and
|
||||
#: (the protocol has no sender field). The name is 1–31 characters that are not colons, and
|
||||
#: ``": "`` must follow it. This rule is strict enough that it does not match ``http://…``
|
||||
#: or ``note:x``.
|
||||
SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,19}):[ \t]+(.*)$", re.DOTALL)
|
||||
#: or ``note:x``. The firmware keeps a node name in 32 bytes with its terminator, so 31 is
|
||||
#: the longest name that a sender can have. A limit of 20 left
|
||||
#: a longer name in the body. Then the transcript showed no sender for it, and a reaction
|
||||
#: from it did not read as a reaction.
|
||||
SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,30}):[ \t]+(.*)$", re.DOTALL)
|
||||
|
||||
#: Matches an ``@[Name]`` mention token, which the reply flow puts into the compose line in
|
||||
#: advance. A transcript renders each token as a bare ``@Name`` in the hue of that sender,
|
||||
#: and does not show the brackets. The history face of the CLI also takes out the same
|
||||
#: names. Thus the token is defined here, with the remainder of the message-body parsing, and
|
||||
#: not in one of the two renderers. The name is 1–20 characters that are not ``]``.
|
||||
MENTION = re.compile(r"@\[([^\]]{1,20})\]")
|
||||
#: not in one of the two renderers. The name is 1–31 characters that are not ``]``, the
|
||||
#: same limit as :data:`SENDER_PREFIX`.
|
||||
MENTION = re.compile(r"@\[([^\]]{1,31})\]")
|
||||
|
||||
|
||||
def split_channel_sender(text: str) -> tuple[str | None, str]:
|
||||
|
||||
@@ -52,11 +52,13 @@ from .models import (
|
||||
advert_time,
|
||||
utcnow,
|
||||
)
|
||||
from .privatekey import normalize as normalize_key
|
||||
from .region_sim import SimulatedRegionMap
|
||||
from .regions import WILDCARD as REGION_WILDCARD
|
||||
from .regions import normalize as normalize_region
|
||||
from .regions import parse_region_list, region_key
|
||||
from .regions import validate as validate_region
|
||||
from .secrets import remember as remember_secret
|
||||
from .tracing import path_hash_flags, trace_timeout
|
||||
from .transmit_lock import TransmitLock
|
||||
|
||||
@@ -1237,8 +1239,8 @@ class Device(ABC):
|
||||
never opens a new handle. Thus the interactive session can poll it while the device
|
||||
is in use, to find a lost link during the session (a serial cable pulled, a
|
||||
companion powered off, a BLE peripheral out of range). Each transport uses the
|
||||
signal that correctly shows its link state (the OS port list for serial, the
|
||||
connection flag of the BLE client for Bluetooth). It returns ``True`` when it
|
||||
signal that correctly shows its link state (the connection flag of the client, and
|
||||
for serial also the OS port list). It returns ``True`` when it
|
||||
cannot find the presence, so a short lookup problem never gives a false disconnect.
|
||||
|
||||
Returns:
|
||||
@@ -1557,12 +1559,21 @@ class Device(ABC):
|
||||
# -- messaging ---------------------------------------------------------------
|
||||
|
||||
@abstractmethod
|
||||
async def send_direct_message(self, contact: Contact, text: str) -> Delivery:
|
||||
async def send_direct_message(
|
||||
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
|
||||
) -> Delivery:
|
||||
"""Send a direct text message to a contact, and wait some time for its ack.
|
||||
|
||||
Args:
|
||||
contact: The recipient. Its ``public_key`` addresses the message.
|
||||
text: The message body.
|
||||
timestamp: The timestamp that the message carries, in seconds. ``None`` uses
|
||||
the clock of this computer. The recipient sees this value, and a reaction
|
||||
names the message by it (refer to :mod:`~meshterm.core.reactions`). Thus the
|
||||
caller gives it when it must know the value.
|
||||
attempt: The number of the try (0 to 3). A retry of the same message has the
|
||||
same timestamp and the next number, as the protocol specifies. The number
|
||||
makes the packet of the retry different from the first one.
|
||||
|
||||
Returns:
|
||||
The :class:`~meshterm.core.models.Delivery`: the ack code that the radio
|
||||
@@ -1574,12 +1585,16 @@ class Device(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def send_channel_message(self, index: int, text: str) -> None:
|
||||
async def send_channel_message(
|
||||
self, index: int, text: str, *, timestamp: int | None = None
|
||||
) -> None:
|
||||
"""Broadcast a text message on a channel slot.
|
||||
|
||||
Args:
|
||||
index: The zero-based channel slot on which to transmit.
|
||||
text: The message body.
|
||||
timestamp: The timestamp that the message carries, in seconds. ``None`` uses
|
||||
the clock of this computer (refer to :meth:`send_direct_message`).
|
||||
|
||||
Raises:
|
||||
DeviceCommandError: If the companion rejected the send.
|
||||
@@ -1631,7 +1646,9 @@ class Device(ABC):
|
||||
self._scope_leaked = None
|
||||
yield
|
||||
|
||||
async def send_channel_in_scope(self, index: int, text: str, scope: str | None) -> None:
|
||||
async def send_channel_in_scope(
|
||||
self, index: int, text: str, scope: str | None, *, timestamp: int | None = None
|
||||
) -> None:
|
||||
"""Broadcast on a channel in a given scope, and leave the session scope as it was.
|
||||
|
||||
This is the only way that MeshTerm sends a channel message. ``None`` is the plain
|
||||
@@ -1659,6 +1676,8 @@ class Device(ABC):
|
||||
text: The message body.
|
||||
scope: A region name, :data:`~meshterm.core.regions.WILDCARD` for unscoped, or
|
||||
``None`` for the default of the device.
|
||||
timestamp: The timestamp that the message carries (refer to
|
||||
:meth:`send_channel_message`).
|
||||
|
||||
Raises:
|
||||
FloodScopeError: If the companion cannot send in ``scope``. Nothing was sent.
|
||||
@@ -1671,10 +1690,10 @@ class Device(ABC):
|
||||
bare = validate_region(bare)
|
||||
async with self.transmitting():
|
||||
if not bare:
|
||||
await self.send_channel_message(index, text)
|
||||
await self.send_channel_message(index, text, timestamp=timestamp)
|
||||
return
|
||||
if bare == REGION_WILDCARD and await self._unscoped_is_plain():
|
||||
await self.send_channel_message(index, text)
|
||||
await self.send_channel_message(index, text, timestamp=timestamp)
|
||||
return
|
||||
try:
|
||||
await self.set_flood_scope(bare)
|
||||
@@ -1682,7 +1701,7 @@ class Device(ABC):
|
||||
raise FloodScopeError(bare, _scope_refusal(bare, exc)) from exc
|
||||
self._scope_active = True
|
||||
try:
|
||||
await self.send_channel_message(index, text)
|
||||
await self.send_channel_message(index, text, timestamp=timestamp)
|
||||
finally:
|
||||
self._scope_active = False
|
||||
try:
|
||||
@@ -2003,9 +2022,12 @@ class Device(ABC):
|
||||
async def import_private_key(self, key_hex: str) -> None:
|
||||
"""Import a private key, and replace the identity of the device with it.
|
||||
|
||||
Each device takes the form that it keeps. A firmware companion accepts only the
|
||||
64-byte key, so it expands a seed first (:func:`meshterm.core.privatekey.normalize`).
|
||||
The node of an SPI radio keeps the form that it gets.
|
||||
|
||||
Args:
|
||||
key_hex: The 64-byte key in hex, as :func:`meshterm.core.privatekey.normalize`
|
||||
makes it. A caller that has a seed expands it first.
|
||||
key_hex: The key in hex: the 64-byte key, or a 32-byte seed.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@@ -2095,6 +2117,15 @@ class MeshCoreDevice(Device):
|
||||
self._ble_device = ble_device
|
||||
self._host = host
|
||||
self._tcp_port = tcp_port
|
||||
if host:
|
||||
# No pattern can find a host name in a log line. Thus each host that MeshTerm
|
||||
# opens is registered here, and the ``anonymize`` preference can mask it.
|
||||
from .anonymize import remember_host
|
||||
|
||||
remember_host(host)
|
||||
# The pairing PIN is a secret. No log may repeat it (refer to
|
||||
# meshterm.core.secrets), so it is registered before the first connect.
|
||||
remember_secret(pin)
|
||||
self._mc = None # type: ignore[var-annotated] # meshcore.MeshCore
|
||||
#: The ``bleak.BleakClient`` of a BLE link. MeshTerm keeps it because ``meshcore``
|
||||
#: releases its own reference at the moment the peripheral disappears. Refer to
|
||||
@@ -3059,15 +3090,22 @@ class MeshCoreDevice(Device):
|
||||
async def link_present(self) -> bool: # noqa: D102 - inherited docstring
|
||||
if self._mc is None:
|
||||
return True # not connected yet, or torn down already: nothing to declare lost
|
||||
# The meshcore client sets ``is_connected`` to False at the moment the transport is
|
||||
# lost (bleak's disconnect callback for BLE, a broken socket for TCP, a fatal read
|
||||
# or write error for serial). Thus it is a fast liveness read that does not transmit.
|
||||
try:
|
||||
connected = bool(self._mc.is_connected)
|
||||
except Exception: # noqa: BLE001 - a status hiccup must not fake a disconnect
|
||||
connected = True
|
||||
if self._transport in ("ble", "tcp"):
|
||||
# The meshcore client sets ``is_connected`` to False at the moment the transport
|
||||
# is lost (bleak's disconnect callback for BLE, a broken socket for TCP). Thus
|
||||
# this is the network and Bluetooth equivalent of the check of the serial port
|
||||
# list: a fast liveness read that does not transmit.
|
||||
try:
|
||||
return bool(self._mc.is_connected)
|
||||
except Exception: # noqa: BLE001 - a status hiccup must not fake a disconnect
|
||||
return True
|
||||
return connected
|
||||
# A serial port can stay listed after its link is dead. When a laptop wakes from
|
||||
# sleep, the kernel can reset the USB device and hang up the open port, but it keeps
|
||||
# the same device file. The next write fails with EIO, and the serial transport
|
||||
# closes itself. Before we checked this flag, the port list alone said that the link
|
||||
# was up, so no reconnect dialog opened, and each command failed (GitHub issue #32).
|
||||
if not connected:
|
||||
return False
|
||||
if not self._port:
|
||||
return True # no port stored (it should not occur after a connect): cannot tell
|
||||
# Run this off the event loop, as all the other callers of the port walk do. On the
|
||||
@@ -3187,7 +3225,9 @@ class MeshCoreDevice(Device):
|
||||
result = await mc.commands.send_device_query()
|
||||
except Exception: # noqa: BLE001 - older firmware has no query: unknown, not fatal
|
||||
return {}
|
||||
return dict(getattr(result, "payload", {}) or {})
|
||||
payload = dict(getattr(result, "payload", {}) or {})
|
||||
remember_secret(payload.get("ble_pin")) # the PIN with which a phone pairs
|
||||
return payload
|
||||
|
||||
#: The maximum silence of the contacts stream before MeshTerm considers a read as
|
||||
#: failed (seconds). The library's own ``get_contacts`` arms one future for the *full*
|
||||
@@ -3457,6 +3497,9 @@ class MeshCoreDevice(Device):
|
||||
"""
|
||||
from meshcore import EventType
|
||||
|
||||
# Each password that MeshTerm sends is a secret that no log may repeat (refer to
|
||||
# meshterm.core.secrets): an admin password, a room password, or a guess.
|
||||
remember_secret(password)
|
||||
mc = self._require()
|
||||
pub = self._node_pubkey(node)
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -4117,7 +4160,7 @@ class MeshCoreDevice(Device):
|
||||
return stop_pump
|
||||
|
||||
async def send_direct_message( # noqa: D102 - inherited docstring
|
||||
self, contact: Contact, text: str
|
||||
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
|
||||
) -> Delivery:
|
||||
transmit_gate.mark()
|
||||
from meshcore import EventType
|
||||
@@ -4146,7 +4189,9 @@ class MeshCoreDevice(Device):
|
||||
try:
|
||||
# Only the hand-over holds the transmit lock. The ack wait below is not a send.
|
||||
async with self.transmitting():
|
||||
result = await mc.commands.send_msg(pub, text)
|
||||
result = await mc.commands.send_msg(
|
||||
pub, text, timestamp=timestamp, attempt=attempt & 3
|
||||
)
|
||||
if result is None or getattr(result, "is_error", lambda: False)():
|
||||
# A recipient for which the firmware has no entry is the only rejection with
|
||||
# an obvious repair. Thus it gets its own class, so the chat screen can offer
|
||||
@@ -4202,11 +4247,11 @@ class MeshCoreDevice(Device):
|
||||
return Delivery(code[0], ack, flood)
|
||||
|
||||
async def send_channel_message( # noqa: D102 - inherited docstring
|
||||
self, index: int, text: str
|
||||
self, index: int, text: str, *, timestamp: int | None = None
|
||||
) -> None:
|
||||
transmit_gate.mark()
|
||||
async with self.transmitting():
|
||||
self._ok(await self._require().commands.send_chan_msg(index, text))
|
||||
self._ok(await self._require().commands.send_chan_msg(index, text, timestamp))
|
||||
|
||||
@staticmethod
|
||||
def _ok(event): # type: ignore[no-untyped-def]
|
||||
@@ -4374,6 +4419,7 @@ class MeshCoreDevice(Device):
|
||||
raise DeviceCommandError(f"channel read for slot {index} returned slot {got}")
|
||||
if not payload.get("channel_name"):
|
||||
return None
|
||||
remember_secret(payload.get("channel_secret"))
|
||||
return payload
|
||||
|
||||
async def _channel_event(self, index: int): # noqa: ANN202 - a meshcore Event
|
||||
@@ -4563,6 +4609,7 @@ class MeshCoreDevice(Device):
|
||||
key = payload.get("private_key")
|
||||
if key is None:
|
||||
raise RuntimeError("device did not return a private key (export may be disabled)")
|
||||
remember_secret(key)
|
||||
return key.hex() if isinstance(key, (bytes, bytearray)) else str(key)
|
||||
|
||||
async def import_private_key(self, key_hex: str) -> None: # noqa: D102
|
||||
@@ -4573,7 +4620,7 @@ class MeshCoreDevice(Device):
|
||||
# error, thus it waited for its full timeout and returned a refusal with no cause.
|
||||
# This call waits for the third answer too, and names it.
|
||||
event = await self._require().commands.send(
|
||||
b"\x18" + bytes.fromhex(key_hex),
|
||||
b"\x18" + normalize_key(bytes.fromhex(key_hex)),
|
||||
[EventType.OK, EventType.ERROR, EventType.DISABLED],
|
||||
)
|
||||
if getattr(event, "type", None) == EventType.DISABLED:
|
||||
@@ -4886,7 +4933,7 @@ class MockDevice(Device):
|
||||
self._tx_power = value
|
||||
|
||||
async def send_direct_message( # noqa: D102 - inherited docstring
|
||||
self, contact: Contact, text: str
|
||||
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
|
||||
) -> Ack | None:
|
||||
transmit_gate.mark()
|
||||
async with self.transmitting():
|
||||
@@ -4931,7 +4978,7 @@ class MockDevice(Device):
|
||||
task.add_done_callback(self._bg_tasks.discard)
|
||||
|
||||
async def send_channel_message( # noqa: D102 - inherited docstring
|
||||
self, index: int, text: str
|
||||
self, index: int, text: str, *, timestamp: int | None = None
|
||||
) -> None:
|
||||
transmit_gate.mark()
|
||||
async with self.transmitting():
|
||||
@@ -5418,7 +5465,8 @@ class MockDevice(Device):
|
||||
return self._private_key
|
||||
|
||||
async def import_private_key(self, key_hex: str) -> None: # noqa: D102
|
||||
self._private_key = bytes.fromhex(key_hex).hex() # checks the hex, and normalizes it
|
||||
# As a firmware does: it keeps only the 64-byte key, so a seed is expanded.
|
||||
self._private_key = normalize_key(bytes.fromhex(key_hex)).hex()
|
||||
|
||||
async def factory_reset(self) -> None: # noqa: D102 - inherited docstring
|
||||
self._custom_vars.clear()
|
||||
@@ -5993,6 +6041,7 @@ def make_device(
|
||||
tcp_port: int | None = None,
|
||||
spi: SpiWiring | None = None,
|
||||
state: Path | None = None,
|
||||
demo: bool = False,
|
||||
) -> Device:
|
||||
"""Make the correct :class:`Device` for the current run.
|
||||
|
||||
@@ -6018,6 +6067,8 @@ def make_device(
|
||||
state: The state directory of the SPI radio (refer to
|
||||
:func:`meshterm.core.spiradio.state_dir`). It is necessary when
|
||||
``transport="spi"``.
|
||||
demo: With ``mock``, return the simulator of the demo world
|
||||
(:class:`~meshterm.demo.device.DemoDevice`) instead of the test world.
|
||||
|
||||
Returns:
|
||||
A :class:`Device` instance that connects when you enter it.
|
||||
@@ -6026,6 +6077,10 @@ def make_device(
|
||||
ValueError: If a real device is requested without an endpoint that its transport
|
||||
can use.
|
||||
"""
|
||||
if mock and demo:
|
||||
from ..demo.device import DemoDevice # deferred: the demo builds on this module
|
||||
|
||||
return DemoDevice()
|
||||
if mock:
|
||||
return MockDevice(optimal_tx=mock_optimal_tx)
|
||||
if transport == "ble":
|
||||
|
||||
@@ -235,6 +235,40 @@ class CourierStore:
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def edit(self, ident: int, text: str) -> bool:
|
||||
"""Replace the body of a waiting entry. Its schedule and its attempts stay.
|
||||
|
||||
A finished entry is history: its body is what went out (or what MeshTerm stopped
|
||||
trying to send), so it does not change.
|
||||
|
||||
Returns:
|
||||
Whether a waiting entry got the new body.
|
||||
"""
|
||||
message = self.get(ident)
|
||||
if message is None or message.status != QUEUED:
|
||||
return False
|
||||
if message.text != text:
|
||||
message.text = text
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def delete(self, ident: int) -> bool:
|
||||
"""Remove one entry, waiting or finished.
|
||||
|
||||
For a waiting entry, this is the same as :meth:`cancel`. The outbox screen deletes
|
||||
one row of either section with the Delete key.
|
||||
|
||||
Returns:
|
||||
Whether an entry was removed.
|
||||
"""
|
||||
messages = self._load()
|
||||
kept = [m for m in messages if m.ident != ident]
|
||||
if len(kept) == len(messages):
|
||||
return False
|
||||
self._messages = kept
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def clear_done(self) -> None:
|
||||
"""Remove all the finished entries, and do not change the waiting queue."""
|
||||
messages = self._load()
|
||||
|
||||
@@ -145,7 +145,7 @@ def _parse_typed(spec: SettingSpec, raw: Any, snapshot: dict | None) -> Any:
|
||||
return True
|
||||
if low in _FALSE:
|
||||
return False
|
||||
raise DeviceConfigError(f"{spec.key}: expected a boolean, got {raw!r}")
|
||||
raise DeviceConfigError(f"{spec.key}: expected a boolean{_got(spec, repr(raw))}")
|
||||
|
||||
# Numeric (int / enum / float): the try wraps only the conversion. Thus this handler
|
||||
# does not catch and wrap again a DeviceConfigError from the range and enum checks
|
||||
@@ -158,7 +158,9 @@ def _parse_typed(spec: SettingSpec, raw: Any, snapshot: dict | None) -> Any:
|
||||
else: # int or enum
|
||||
value = int(text, 0) if isinstance(raw, str) else int(raw)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise DeviceConfigError(f"{spec.key}: invalid {spec.value_type} value {raw!r}") from exc
|
||||
raise DeviceConfigError(
|
||||
f"{spec.key}: invalid {spec.value_type} value{_got(spec, repr(raw), before=' ')}"
|
||||
) from exc
|
||||
|
||||
if (
|
||||
spec.value_type == "enum"
|
||||
@@ -167,15 +169,35 @@ def _parse_typed(spec: SettingSpec, raw: Any, snapshot: dict | None) -> Any:
|
||||
and value not in spec.choices
|
||||
):
|
||||
allowed = ", ".join(str(k) for k in spec.choices)
|
||||
raise DeviceConfigError(f"{spec.key}: must be one of {allowed}, got {value}")
|
||||
raise DeviceConfigError(f"{spec.key}: must be one of {allowed}{_got(spec, value)}")
|
||||
if spec.minimum is not None and value < spec.minimum:
|
||||
raise DeviceConfigError(f"{spec.key}: must be >= {spec.minimum}, got {value}")
|
||||
raise DeviceConfigError(f"{spec.key}: must be >= {spec.minimum}{_got(spec, value)}")
|
||||
maximum = effective_maximum(spec, snapshot)
|
||||
if maximum is not None and value > maximum:
|
||||
raise DeviceConfigError(f"{spec.key}: must be <= {maximum:g}, got {value}")
|
||||
raise DeviceConfigError(f"{spec.key}: must be <= {maximum:g}{_got(spec, value)}")
|
||||
return value
|
||||
|
||||
|
||||
def _got(spec: SettingSpec, shown: Any, *, before: str = ", got ") -> str:
|
||||
"""The end of an error message that repeats the refused value, or nothing for a secret.
|
||||
|
||||
An error goes to the log and to the run log, and the value of a secret setting (the
|
||||
pairing PIN) must not (refer to :mod:`meshterm.core.secrets`). Thus the message of a
|
||||
secret setting states the rule and not the value.
|
||||
|
||||
Args:
|
||||
spec: The setting that refused the value.
|
||||
shown: The refused value, as the message writes it.
|
||||
before: The text before the value: ``", got "``, or ``" "`` for "value X".
|
||||
|
||||
Returns:
|
||||
``", got 1234"``, ``" '12a'"``, or ``""`` for a secret setting.
|
||||
"""
|
||||
from .secrets import names_secret
|
||||
|
||||
return "" if names_secret(spec.key) else f"{before}{shown}"
|
||||
|
||||
|
||||
def effective_maximum(spec: SettingSpec, snapshot: dict | None) -> float | None:
|
||||
"""The inclusive maximum for ``spec``: the device value if it is known, else the static one.
|
||||
|
||||
@@ -430,7 +452,8 @@ def _pin_rule(value: Any, snapshot: dict | None) -> str | None:
|
||||
"""The firmware accepts only a pairing PIN of zero (no PIN) or of exactly six digits."""
|
||||
if value == 0 or 100000 <= value <= 999999:
|
||||
return None
|
||||
return f"must be 0 (no PIN) or six digits, got {value}"
|
||||
# No "got": the PIN is a secret, and the message goes to the log (refer to _got).
|
||||
return "must be 0 (no PIN) or six digits"
|
||||
|
||||
|
||||
def _repeat_rule(value: Any, snapshot: dict | None) -> str | None:
|
||||
|
||||
@@ -55,6 +55,7 @@ from .atomicwrite import write_atomically
|
||||
from .courier_store import DONE_CAP
|
||||
from .geo import DEFAULT_VIEW_FRACTION
|
||||
from .models import NAME_OVERLAP_DAYS
|
||||
from .reactions import DEFAULT_REACTIONS
|
||||
from .watch_store import ALERT_CAP, DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
@@ -173,6 +174,22 @@ _QUIET_CHOICES: dict[int, str] = {s: f"{s} s" for s in QUIET_CHOICES_S}
|
||||
#: column, and for the cost when MeshTerm reclaims a column that is not hidden.
|
||||
_WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
|
||||
|
||||
#: The smallest terminal, in rows, that gets a blank line before each section. The two
|
||||
#: ends are numbers too, so that one comparison (``rows >= value``) gives each choice: 0 is
|
||||
#: lower than each terminal, and :data:`_SPACING_NEVER` is higher than each terminal. The
|
||||
#: steps between them fall between the heights that MeshTerm knows: the 14 rows of the
|
||||
#: Cardputer Zero, a standard terminal of 24, the 26 and 40 rows of the two console fonts of
|
||||
#: the PicoCalc, and a tall window on a desktop.
|
||||
_SPACING_NEVER = 10_000
|
||||
_SPACING_CHOICES: dict[int, str] = {
|
||||
0: "any height",
|
||||
20: "20+ rows",
|
||||
24: "24+ rows",
|
||||
30: "30+ rows",
|
||||
40: "40+ rows",
|
||||
_SPACING_NEVER: "never",
|
||||
}
|
||||
|
||||
#: How many colours to send to the terminal. ``auto`` is the honest default, and it is the
|
||||
#: only value that is not a depth. It means "take prompt_toolkit's verdict unless we can
|
||||
#: positively establish better". This rule is the difference between a repaired host and a
|
||||
@@ -290,6 +307,16 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
minimum=0,
|
||||
maximum=2,
|
||||
),
|
||||
PrefSpec(
|
||||
key="reaction_emoji",
|
||||
label="Reactions",
|
||||
help="Emoji to offer when you react to a message",
|
||||
group="Sending",
|
||||
value_type="str",
|
||||
# The default set of MeshCore One. A word that is not one emoji is ignored when the
|
||||
# chat reads the list (refer to meshterm.core.reactions.reaction_set).
|
||||
default=DEFAULT_REACTIONS,
|
||||
),
|
||||
PrefSpec(
|
||||
key="message_parts_max",
|
||||
label="Message parts",
|
||||
@@ -517,6 +544,21 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
maximum=3650,
|
||||
unit="days",
|
||||
),
|
||||
PrefSpec(
|
||||
key="section_spacing_min_rows",
|
||||
label="Section spacing",
|
||||
help="A blank line between sections and between chat authors",
|
||||
group="Display",
|
||||
value_type="enum",
|
||||
# The line costs one row for each section. A standard terminal (24 rows) and the
|
||||
# PicoCalc (26) have the rows. The 14 rows of the Cardputer Zero do not: there a
|
||||
# blank line is a row less of the list. The test is the real height of the
|
||||
# terminal, so a small window on a desktop drops the line too (JP, 2026-10-13). It
|
||||
# applies to each section heading of a list, to the day dividers of a chat, and to
|
||||
# the messages of each author in a chat (JP, 2026-10-15).
|
||||
default=24,
|
||||
choices=_SPACING_CHOICES,
|
||||
),
|
||||
# --- Diagnostics ---------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="log_level",
|
||||
@@ -531,6 +573,18 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
choices=_LOG_LEVEL_CHOICES,
|
||||
relaunch=True,
|
||||
),
|
||||
PrefSpec(
|
||||
key="anonymize",
|
||||
label="Anonymize",
|
||||
help="Mask the home folder and addresses in the log and diagnostics",
|
||||
group="Diagnostics",
|
||||
value_type="bool",
|
||||
# Off: the person who reads the log is usually its owner, who needs each detail as
|
||||
# it is. A user who posts the log and the diagnostics in public turns it on (refer
|
||||
# to meshterm.core.anonymize). The log reads it at each record, so a change applies
|
||||
# at once and not at the next start.
|
||||
default=False,
|
||||
),
|
||||
)
|
||||
|
||||
_BY_KEY: dict[str, PrefSpec] = {spec.key: spec for spec in PREFERENCES}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The private key of our node: the forms that MeshTerm accepts, and the one form that it sends.
|
||||
"""The private key of our node: the two forms that MeshTerm accepts, and where each one goes.
|
||||
|
||||
A MeshCore companion exports and imports its private key in one form only: 64 bytes, the
|
||||
expanded Ed25519 key of the orlp library that the firmware uses (a 32-byte scalar, then a
|
||||
@@ -7,10 +7,16 @@ expanded Ed25519 key of the orlp library that the firmware uses (a 32-byte scala
|
||||
|
||||
A key can also exist in a shorter form: the 32-byte Ed25519 seed from which the 64 bytes
|
||||
come. The node that MeshTerm runs on an SPI radio keeps such a seed in its ``identity.key``
|
||||
when it makes its own key. A seed is the same identity, but a firmware companion does not
|
||||
accept it. Thus MeshTerm expands a seed before it sends it (:func:`expand_seed`), in the
|
||||
same way that the firmware makes its own key. The user can give either form, and the
|
||||
device always gets the 64 bytes.
|
||||
when it makes its own key, because its radio library keeps keys in that form. A seed is
|
||||
the same identity, and it is the more complete form: a seed always gives the 64 bytes,
|
||||
and the 64 bytes never give back the seed.
|
||||
|
||||
The user can give either form, and the parsers here return it as given
|
||||
(:func:`parse_private_key`, :func:`read_private_key_file`). Each device then takes the
|
||||
form that it keeps. A firmware companion accepts only the 64 bytes, so it gets a seed
|
||||
expanded (:func:`normalize`, in the same way that the firmware makes its own key). The
|
||||
node of an SPI radio keeps the form that it gets, so an imported seed stays a seed there
|
||||
(JP, 2026-10-13).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +28,7 @@ from pathlib import Path
|
||||
__all__ = [
|
||||
"KEY_BYTES",
|
||||
"SEED_BYTES",
|
||||
"checked",
|
||||
"expand_seed",
|
||||
"normalize",
|
||||
"parse_private_key",
|
||||
@@ -68,10 +75,18 @@ def normalize(raw: bytes) -> bytes:
|
||||
Raises:
|
||||
ValueError: If ``raw`` is neither a key nor a seed. The message is for the user.
|
||||
"""
|
||||
if len(raw) == KEY_BYTES:
|
||||
raw = checked(raw)
|
||||
return expand_seed(raw) if len(raw) == SEED_BYTES else raw
|
||||
|
||||
|
||||
def checked(raw: bytes) -> bytes:
|
||||
"""``raw`` itself, if it is a 64-byte key or a 32-byte seed.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``raw`` is neither a key nor a seed. The message is for the user.
|
||||
"""
|
||||
if len(raw) in (KEY_BYTES, SEED_BYTES):
|
||||
return bytes(raw)
|
||||
if len(raw) == SEED_BYTES:
|
||||
return expand_seed(raw)
|
||||
raise ValueError(
|
||||
f"A private key has {KEY_BYTES} bytes ({KEY_BYTES * 2} hex digits), or "
|
||||
f"{SEED_BYTES} for a seed. This one has {len(raw)}."
|
||||
@@ -79,7 +94,7 @@ def normalize(raw: bytes) -> bytes:
|
||||
|
||||
|
||||
def parse_private_key(text: str) -> bytes:
|
||||
"""The 64-byte key in ``text``, which the user typed or pasted as hex.
|
||||
"""The key in ``text``, which the user typed or pasted as hex: 64 bytes, or a 32-byte seed.
|
||||
|
||||
The function ignores spaces and line breaks, because a key that the user copies from a
|
||||
terminal often has them where the terminal wrapped the line.
|
||||
@@ -95,11 +110,11 @@ def parse_private_key(text: str) -> bytes:
|
||||
raise ValueError(
|
||||
f"A private key has an even number of hex digits. This one has {len(digits)}."
|
||||
)
|
||||
return normalize(bytes.fromhex(digits))
|
||||
return checked(bytes.fromhex(digits))
|
||||
|
||||
|
||||
def read_private_key_file(path: Path) -> bytes:
|
||||
"""The 64-byte key in the file at ``path``: hex text, or the raw bytes of a key or a seed.
|
||||
"""The key in the file at ``path``, in its own form: hex text, or the raw bytes of a key.
|
||||
|
||||
``config export-key --out`` writes hex text. The ``identity.key`` of a node keeps raw
|
||||
bytes. The function accepts the two forms. It reads the file as hex text first. A raw
|
||||
@@ -121,7 +136,7 @@ def read_private_key_file(path: Path) -> bytes:
|
||||
if digits and _HEX.fullmatch(digits):
|
||||
return parse_private_key(digits)
|
||||
if len(data) in (KEY_BYTES, SEED_BYTES):
|
||||
return normalize(data)
|
||||
return checked(data)
|
||||
raise ValueError(
|
||||
f"{path.name} does not hold a private key. A key file holds the key as hex "
|
||||
f"text, or {KEY_BYTES} or {SEED_BYTES} raw bytes."
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Emoji reactions to chat messages, in the wire format of MeshCore One.
|
||||
|
||||
A reaction is a usual text message. The firmware and the repeaters do not know the format.
|
||||
Only the apps do. MeshTerm writes the format of MeshCore One, and reads it and the two
|
||||
formats of meshcore-open. The protocol is in ``docs/Reactions.md`` of MeshCore One. In short:
|
||||
|
||||
* A reaction names its target message by a hash of the body of the target and the
|
||||
timestamp of its sender: SHA-256 of the UTF-8 body and the timestamp as a little-endian
|
||||
32-bit integer, the first five bytes, in Crockford Base32 (eight characters). Each node
|
||||
that received the target calculates the same hash, because the sender's timestamp travels
|
||||
with the message.
|
||||
* On a channel, the reaction also names the author of the target, because two nodes can
|
||||
send the same body in the same second: ``@[Author]👍`` then a newline and the hash.
|
||||
Before MeshCore One 1.4.1, the emoji came first (``👍@[Author]``). MeshTerm reads the two
|
||||
orders and writes the new one.
|
||||
* In a direct exchange, there are two persons only, so the reaction is ``👍``, a newline,
|
||||
and the hash.
|
||||
|
||||
A body that only starts with an emoji is a usual message. The emoji part must be exactly
|
||||
one emoji (one glyph) for the body to be a reaction.
|
||||
|
||||
MeshTerm stores a reaction as the usual message that it is. Thus the history keeps all that
|
||||
was received. The chat screen folds the reactions of a transcript onto their targets when it
|
||||
shows the transcript (refer to :class:`ReactionFold`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from .channels import split_channel_sender
|
||||
from .models import ChatMessage
|
||||
|
||||
#: The reactions that the reaction dialog offers when the preference is empty. They are the
|
||||
#: default set of MeshCore One, in its order.
|
||||
DEFAULT_REACTIONS = "👍 👎 ❤️ 😂 😮 😢"
|
||||
|
||||
#: How far apart in time, in seconds, a reaction of meshcore-open and its target can be. The
|
||||
#: short hash of meshcore-open (16 bits) matches many messages in a long history. Thus
|
||||
#: MeshTerm compares it only with the messages near the reaction in time. MeshCore One uses
|
||||
#: the same window.
|
||||
WINDOW_S = 300
|
||||
|
||||
#: The maximum number of reactions that wait for a target that did not arrive yet.
|
||||
PENDING_CAP = 100
|
||||
|
||||
# -- Crockford Base32 ---------------------------------------------------------------------
|
||||
|
||||
_CROCKFORD = "0123456789abcdefghjkmnpqrstvwxyz"
|
||||
_DECODE = {char: char for char in _CROCKFORD}
|
||||
_DECODE.update({char.upper(): char for char in _CROCKFORD})
|
||||
# The letters that a person can mistake for a digit read as that digit.
|
||||
_DECODE.update({"o": "0", "O": "0", "i": "1", "I": "1", "l": "1", "L": "1"})
|
||||
|
||||
|
||||
def _crockford(data: bytes) -> str:
|
||||
"""Encode five bytes as eight Crockford Base32 characters, the most significant first."""
|
||||
bits = int.from_bytes(data[:5], "big")
|
||||
return "".join(_CROCKFORD[(bits >> shift) & 0x1F] for shift in range(35, -5, -5))
|
||||
|
||||
|
||||
def _normal_hash(raw: str) -> str | None:
|
||||
"""The canonical (lowercase) form of an eight-character hash, or ``None`` if it is not one."""
|
||||
if len(raw) != 8:
|
||||
return None
|
||||
out = [_DECODE.get(char) for char in raw]
|
||||
if None in out:
|
||||
return None
|
||||
return "".join(out) # type: ignore[arg-type] - None is excluded above
|
||||
|
||||
|
||||
def message_hash(body: str, timestamp: int) -> str:
|
||||
"""The hash with which a reaction names its target message.
|
||||
|
||||
Args:
|
||||
body: The body of the target. On a channel, this is the text after the ``Name: ``
|
||||
prefix of the sender.
|
||||
timestamp: The timestamp that the sender gave the target, in seconds.
|
||||
|
||||
Returns:
|
||||
Eight lowercase Crockford Base32 characters.
|
||||
"""
|
||||
data = body.encode("utf-8") + (timestamp & 0xFFFFFFFF).to_bytes(4, "little")
|
||||
return _crockford(hashlib.sha256(data).digest())
|
||||
|
||||
|
||||
def wire_timestamp(when: datetime) -> int:
|
||||
"""The timestamp of a message as its sender put it on the wire: whole seconds."""
|
||||
return int(when.timestamp())
|
||||
|
||||
|
||||
# -- the emoji part -----------------------------------------------------------------------
|
||||
|
||||
_VS = frozenset("︎️")
|
||||
_ZWJ = ""
|
||||
_KEYCAP = "⃣"
|
||||
_TONES = frozenset(chr(cp) for cp in range(0x1F3FB, 0x1F400))
|
||||
_TAGS = frozenset(chr(cp) for cp in range(0xE0020, 0xE0080))
|
||||
|
||||
|
||||
def _is_regional(char: str) -> bool:
|
||||
"""Whether ``char`` is a Regional Indicator (one half of a flag)."""
|
||||
return 0x1F1E6 <= ord(char) <= 0x1F1FF
|
||||
|
||||
|
||||
def _is_pictograph(char: str) -> bool:
|
||||
"""Whether ``char`` can start an emoji: a symbol outside the Latin and punctuation blocks."""
|
||||
return ord(char) >= 0x2000 and unicodedata.category(char) == "So"
|
||||
|
||||
|
||||
def is_reaction_emoji(value: str) -> bool:
|
||||
"""Whether ``value`` is exactly one emoji: the emoji part of a reaction.
|
||||
|
||||
One emoji is one glyph. It can be a flag (two Regional Indicators), a keycap (``1️⃣``),
|
||||
or pictographs joined with zero-width joiners (``❤️🔥``). Each pictograph can have a
|
||||
variation selector, a skin tone, and a tag sequence. Text, or an emoji with text after
|
||||
it, is not a reaction.
|
||||
"""
|
||||
if not value or value.isascii():
|
||||
return False
|
||||
if len(value) == 2 and _is_regional(value[0]) and _is_regional(value[1]):
|
||||
return True
|
||||
if value[0] in "0123456789#*":
|
||||
return value[1:] in (_KEYCAP, "️" + _KEYCAP)
|
||||
for part in value.split(_ZWJ):
|
||||
if not part or not _is_pictograph(part[0]):
|
||||
return False
|
||||
if any(char not in _VS and char not in _TONES and char not in _TAGS for char in part[1:]):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def emoji_name(emoji: str) -> str:
|
||||
"""A name for an emoji, for a person to read: its Unicode name in lowercase."""
|
||||
try:
|
||||
return unicodedata.name(emoji[0]).lower()
|
||||
except (ValueError, IndexError):
|
||||
return "emoji"
|
||||
|
||||
|
||||
def reaction_set(preference: str) -> list[str]:
|
||||
"""The emoji that the reaction dialog offers, from the ``reaction_emoji`` preference.
|
||||
|
||||
The preference is a list of emoji with spaces between them. A word that is not one emoji
|
||||
is ignored, and so is a second copy of an emoji. An empty result gives
|
||||
:data:`DEFAULT_REACTIONS`, because a reaction dialog with no rows is of no use.
|
||||
"""
|
||||
seen: list[str] = []
|
||||
for word in (preference or "").split():
|
||||
if is_reaction_emoji(word) and word not in seen:
|
||||
seen.append(word)
|
||||
return seen or DEFAULT_REACTIONS.split()
|
||||
|
||||
|
||||
# -- the wire format ----------------------------------------------------------------------
|
||||
|
||||
|
||||
#: The three forms of a reaction on the wire.
|
||||
MC1 = "mc1" # MeshCore One: an eight-character hash
|
||||
MCO = "mco" # meshcore-open v3: a 16-bit hash, and an index into its table of emoji
|
||||
MCO_V1 = "mco1" # meshcore-open v1 (before January 2026): the time and two 30-bit hashes
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Reaction:
|
||||
"""A reaction, as read from the body of a message.
|
||||
|
||||
Attributes:
|
||||
emoji: The emoji of the reaction.
|
||||
form: :data:`MC1`, :data:`MCO`, or :data:`MCO_V1`.
|
||||
hash: How the reaction names its target. For :data:`MC1`, the eight-character hash
|
||||
(refer to :func:`message_hash`). For :data:`MCO`, four lowercase hex digits.
|
||||
For :data:`MCO_V1`, the message id of meshcore-open.
|
||||
author: For a channel reaction of :data:`MC1`, the name of the author of the
|
||||
target. ``None`` in all other cases.
|
||||
timestamp: For :data:`MCO_V1`, the timestamp of the target, in seconds.
|
||||
author_hash: For :data:`MCO_V1`, the Dart hash of the name of the author.
|
||||
body_hash: For :data:`MCO_V1`, the Dart hash of the body of the target.
|
||||
"""
|
||||
|
||||
emoji: str
|
||||
form: str
|
||||
hash: str
|
||||
author: str | None = None
|
||||
timestamp: int | None = None
|
||||
author_hash: int | None = None
|
||||
body_hash: int | None = None
|
||||
|
||||
|
||||
def parse(body: str, *, channel: bool) -> Reaction | None:
|
||||
"""Read a message body as a reaction.
|
||||
|
||||
Args:
|
||||
body: The body of the message. On a channel, this is the text after the
|
||||
``Name: `` prefix of the sender.
|
||||
channel: Whether the message is on a channel. A channel reaction of MeshCore One
|
||||
names the author of its target, and a direct one does not.
|
||||
|
||||
Returns:
|
||||
The reaction, or ``None`` when the body is a usual message.
|
||||
"""
|
||||
if body.startswith("r:"):
|
||||
return _parse_mco(body) or _parse_mco_v1(body)
|
||||
head, newline, tail = body.rpartition("\n")
|
||||
if not newline:
|
||||
return None
|
||||
digest = _normal_hash(tail)
|
||||
if digest is None:
|
||||
return None
|
||||
if not channel:
|
||||
if "@[" in head or not is_reaction_emoji(head):
|
||||
return None
|
||||
return Reaction(head, MC1, digest)
|
||||
before, mark, after = head.partition("@[")
|
||||
if not mark:
|
||||
return None
|
||||
if before:
|
||||
# The order before MeshCore One 1.4.1: the emoji, then the mention.
|
||||
if not after.endswith("]"):
|
||||
return None
|
||||
emoji, author = before, after[:-1]
|
||||
else:
|
||||
author, close, emoji = after.partition("]")
|
||||
if not close:
|
||||
return None
|
||||
if not author or not is_reaction_emoji(emoji):
|
||||
return None
|
||||
return Reaction(emoji, MC1, digest, author=author)
|
||||
|
||||
|
||||
def channel_text(emoji: str, author: str, body: str, timestamp: int) -> str:
|
||||
"""The body of a channel reaction to the message ``body`` of ``author``.
|
||||
|
||||
Args:
|
||||
emoji: The emoji of the reaction.
|
||||
author: The name of the author of the target, as the target carried it.
|
||||
body: The body of the target, without the ``Name: `` prefix.
|
||||
timestamp: The timestamp that the author gave the target.
|
||||
"""
|
||||
return f"@[{author}]{emoji}\n{message_hash(body, timestamp)}"
|
||||
|
||||
|
||||
def direct_text(emoji: str, body: str, timestamp: int) -> str:
|
||||
"""The body of a direct reaction to the message ``body`` sent at ``timestamp``."""
|
||||
return f"{emoji}\n{message_hash(body, timestamp)}"
|
||||
|
||||
|
||||
# -- meshcore-open ------------------------------------------------------------------------
|
||||
|
||||
#: The table of emoji of meshcore-open (its ``emoji_picker.dart``), in order: the quick set,
|
||||
#: then the smileys, gestures, hearts, and objects. A reaction of v3 sends an index into it.
|
||||
# fmt: off
|
||||
MCO_EMOJI: tuple[str, ...] = (
|
||||
"👍", "❤️", "😂", "🎉", "👏", "🔥",
|
||||
"😀", "😃", "😄", "😁", "😅", "😂", "🤣", "😊",
|
||||
"😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘",
|
||||
"😗", "😙", "😚", "😋", "😛", "😝", "😜", "🤪",
|
||||
"🤨", "🧐", "🤓", "😎", "🥸", "🤩", "🥳", "😏",
|
||||
"😒", "😞", "😔", "😟", "😕", "🙁", "😣", "😖",
|
||||
"😫", "😩", "🥺", "😢", "😭", "😤", "😠", "😡",
|
||||
"🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰",
|
||||
"😥", "😓", "🤗", "🤔", "🤭", "🤫", "🤥", "😶",
|
||||
"👍", "👎", "👊", "✊", "🤛", "🤜", "🤞", "✌️",
|
||||
"🤟", "🤘", "👌", "🤌", "🤏", "👈", "👉", "👆",
|
||||
"👇", "☝️", "👋", "🤚", "🖐️", "✋", "🖖", "👏",
|
||||
"🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳",
|
||||
"💪",
|
||||
"❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍",
|
||||
"🤎", "💔", "❤️🔥", "❤️🩹", "💕", "💞", "💓", "💗",
|
||||
"💖", "💘", "💝", "💟", "💌", "💢", "💥", "💫",
|
||||
"💦", "💨", "🕳️", "💬", "👁️🗨️", "🗨️", "🗯️", "💭",
|
||||
"🎉", "🎊", "🎈", "🎁", "🎀", "🪅", "🪆", "🏆",
|
||||
"🥇", "🥈", "🥉", "⚽", "⚾", "🥎", "🏀", "🏐",
|
||||
"🏈", "🏉", "🎾", "🥏", "🎳", "🏏", "🏑", "🏒",
|
||||
"🥍", "🏓", "🏸", "🥊", "🥋", "🥅", "⛳", "🔥",
|
||||
"⭐", "🌟", "✨", "⚡", "💡", "🔦", "🏮", "🪔",
|
||||
"📱", "💻", "⌚", "📷", "📺", "📻", "🎵", "🎶",
|
||||
"🚀",
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
_HEX = frozenset("0123456789abcdef")
|
||||
|
||||
|
||||
def _parse_mco(body: str) -> Reaction | None:
|
||||
"""Read ``r:{hash}:{index}``, a reaction of meshcore-open v3 (four and two hex digits)."""
|
||||
if len(body) != 9 or body[6] != ":":
|
||||
return None
|
||||
digest, index = body[2:6], body[7:]
|
||||
if not set(digest + index) <= _HEX:
|
||||
return None
|
||||
position = int(index, 16)
|
||||
if position >= len(MCO_EMOJI):
|
||||
return None
|
||||
return Reaction(MCO_EMOJI[position], MCO, digest)
|
||||
|
||||
|
||||
def _parse_mco_v1(body: str) -> Reaction | None:
|
||||
"""Read ``r:{millis}_{author hash}_{body hash}:{emoji}``, a reaction of meshcore-open v1."""
|
||||
ident, colon, emoji = body[2:].rpartition(":")
|
||||
if not colon or not ident or not emoji:
|
||||
return None
|
||||
parts = ident.split("_")
|
||||
if len(parts) != 3 or not all(part.isdigit() for part in parts):
|
||||
return None
|
||||
seconds = int(parts[0]) // 1000
|
||||
if seconds > 0xFFFFFFFF:
|
||||
return None
|
||||
return Reaction(
|
||||
emoji,
|
||||
MCO_V1,
|
||||
ident,
|
||||
timestamp=seconds,
|
||||
author_hash=int(parts[1]),
|
||||
body_hash=int(parts[2]),
|
||||
)
|
||||
|
||||
|
||||
def dart_hash(units: list[int]) -> int:
|
||||
"""The ``String.hashCode`` of the Dart VM over UTF-16 code units: a 30-bit value, never 0."""
|
||||
mask = 0xFFFFFFFF
|
||||
value = 0
|
||||
for unit in units:
|
||||
value = (value + unit) & mask
|
||||
value = (value + (value << 10)) & mask
|
||||
value ^= value >> 6
|
||||
value = (value + (value << 3)) & mask
|
||||
value ^= value >> 11
|
||||
value = (value + (value << 15)) & mask
|
||||
value &= (1 << 30) - 1
|
||||
return value or 1
|
||||
|
||||
|
||||
def _units(text: str) -> list[int]:
|
||||
"""The UTF-16 code units of ``text``."""
|
||||
raw = text.encode("utf-16-le")
|
||||
return [int.from_bytes(raw[i : i + 2], "little") for i in range(0, len(raw), 2)]
|
||||
|
||||
|
||||
def mco_hash(timestamp: int, author: str | None, body: str) -> str:
|
||||
"""The 16-bit hash with which meshcore-open v3 names a target, as four hex digits.
|
||||
|
||||
It is the Dart hash of the timestamp in decimal, the name of the author (on a channel
|
||||
only), and the first five UTF-16 code units of the body.
|
||||
"""
|
||||
units = _units(str(timestamp)) + (_units(author) if author else []) + _units(body)[:5]
|
||||
return f"{dart_hash(units) & 0xFFFF:04x}"
|
||||
|
||||
|
||||
# -- the fold -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Reactor:
|
||||
"""Who sent a reaction: our node, or another node by the name that it gave.
|
||||
|
||||
Attributes:
|
||||
name: The name of the node. ``None`` for our node, and for the peer of a direct
|
||||
exchange (the conversation names the peer).
|
||||
ours: Whether our node sent the reaction.
|
||||
"""
|
||||
|
||||
name: str | None
|
||||
ours: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Badge:
|
||||
"""One emoji on one message, and the nodes that reacted with it, the first first."""
|
||||
|
||||
emoji: str
|
||||
reactors: list[Reactor] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ours(self) -> bool:
|
||||
"""Whether our node is one of the nodes that reacted with this emoji."""
|
||||
return any(reactor.ours for reactor in self.reactors)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Target:
|
||||
"""A message of the transcript, as the fold matches reactions against it."""
|
||||
|
||||
message: ChatMessage
|
||||
author: str | None
|
||||
body: str
|
||||
timestamp: int
|
||||
|
||||
|
||||
class ReactionFold:
|
||||
"""Fold the reactions of one conversation onto the messages that they react to.
|
||||
|
||||
Give each message of the transcript to :meth:`take`, in the order of arrival. A message
|
||||
that is not a reaction becomes a target, and :meth:`take` returns ``False``. A reaction
|
||||
attaches to its target, and :meth:`take` returns ``True``. Then the transcript does not
|
||||
show it as a message. A reaction whose target is not known yet waits (at most
|
||||
:data:`PENDING_CAP` of them), because on a mesh a reaction can arrive before its target.
|
||||
:meth:`badges` gives the reactions on one message.
|
||||
|
||||
The fold counts each emoji one time for each node. A node can send the same reaction
|
||||
again, and a repeater can deliver a copy two times.
|
||||
"""
|
||||
|
||||
def __init__(self, *, channel: bool, self_name: str | None = None) -> None:
|
||||
"""Make an empty fold.
|
||||
|
||||
Args:
|
||||
channel: Whether the conversation is a channel. A channel reaction names the
|
||||
author of its target, and a direct one does not.
|
||||
self_name: The name of our node. On a channel, it is the author of each message
|
||||
that we sent. Without it, the reactions to our channel messages of
|
||||
MeshCore One cannot find their target.
|
||||
"""
|
||||
self._channel = channel
|
||||
self._self_name = self_name
|
||||
self._targets: list[_Target] = []
|
||||
self._by_id: dict[int, _Target] = {}
|
||||
self._by_hash: dict[tuple[str | None, str], _Target] = {}
|
||||
self._badges: dict[int, list[Badge]] = {}
|
||||
self._pending: list[tuple[Reaction, Reactor]] = []
|
||||
|
||||
def take(self, message: ChatMessage) -> bool:
|
||||
"""Fold one message in, and return whether it is a reaction.
|
||||
|
||||
Args:
|
||||
message: The next message of the transcript.
|
||||
|
||||
Returns:
|
||||
``True`` when the message is a reaction. The transcript then does not show it.
|
||||
``False`` when it is a usual message, which other reactions can now name.
|
||||
"""
|
||||
author, body = self._author_and_body(message)
|
||||
reaction = parse(body, channel=self._channel)
|
||||
if reaction is None:
|
||||
self._index(message, author, body)
|
||||
return False
|
||||
reactor = Reactor(
|
||||
None if message.outbound else (author if self._channel else None), ours=message.outbound
|
||||
)
|
||||
target = self._find(reaction, wire_timestamp(message.created_at))
|
||||
if target is not None:
|
||||
self._attach(target.message, reaction.emoji, reactor)
|
||||
elif reaction.form == MC1:
|
||||
self._pending.append((reaction, reactor))
|
||||
del self._pending[:-PENDING_CAP]
|
||||
return True
|
||||
|
||||
def badges(self, message: ChatMessage) -> list[Badge]:
|
||||
"""The reactions on ``message``: the most frequent emoji first, then the earliest."""
|
||||
badges = self._badges.get(id(message), [])
|
||||
order = {id(badge): index for index, badge in enumerate(badges)}
|
||||
return sorted(badges, key=lambda badge: (-len(badge.reactors), order[id(badge)]))
|
||||
|
||||
def count(self, message: ChatMessage) -> int:
|
||||
"""The number of reactions on ``message``. A render cache compares it to see a change."""
|
||||
return sum(len(badge.reactors) for badge in self._badges.get(id(message), []))
|
||||
|
||||
def reacted(self, message: ChatMessage, emoji: str) -> bool:
|
||||
"""Whether our node already reacted to ``message`` with ``emoji``."""
|
||||
return any(
|
||||
badge.emoji == emoji and badge.ours for badge in self._badges.get(id(message), [])
|
||||
)
|
||||
|
||||
def target_of(self, message: ChatMessage) -> tuple[str | None, str, int] | None:
|
||||
"""The author, the body, and the timestamp with which a reaction names ``message``.
|
||||
|
||||
``None`` when ``message`` is not a target of this fold (a reaction, or a message
|
||||
that the fold never took).
|
||||
"""
|
||||
target = self._by_id.get(id(message))
|
||||
if target is None or target.message is not message:
|
||||
return None
|
||||
return target.author, target.body, target.timestamp
|
||||
|
||||
def _author_and_body(self, message: ChatMessage) -> tuple[str | None, str]:
|
||||
"""The author and the body of a message, as the hash of a reaction sees them.
|
||||
|
||||
On a channel, the body of a received message is the text after the ``Name: ``
|
||||
prefix, without the spaces at its two ends. Our channel messages have no prefix in
|
||||
the history, and their author is our node.
|
||||
"""
|
||||
if not self._channel:
|
||||
return None, message.text
|
||||
if message.outbound:
|
||||
return self._self_name, message.text.strip(" \t")
|
||||
name, body = split_channel_sender(message.text)
|
||||
return name, body.strip(" \t")
|
||||
|
||||
def _index(self, message: ChatMessage, author: str | None, body: str) -> None:
|
||||
"""Make ``message`` a target, and attach the reactions that waited for it."""
|
||||
target = _Target(message, author, body, wire_timestamp(message.created_at))
|
||||
self._targets.append(target)
|
||||
self._by_id[id(message)] = target
|
||||
if self._channel and not author:
|
||||
return # a channel reaction of MeshCore One must name the author
|
||||
key = (author if self._channel else None, message_hash(body, target.timestamp))
|
||||
self._by_hash[key] = target
|
||||
waiting = [entry for entry in self._pending if (entry[0].author, entry[0].hash) == key]
|
||||
if waiting:
|
||||
self._pending = [entry for entry in self._pending if entry not in waiting]
|
||||
for reaction, reactor in waiting:
|
||||
self._attach(message, reaction.emoji, reactor)
|
||||
|
||||
def _find(self, reaction: Reaction, at: int) -> _Target | None:
|
||||
"""The target that ``reaction`` names, or ``None``. The newest target wins a tie."""
|
||||
if reaction.form == MC1:
|
||||
return self._by_hash.get((reaction.author, reaction.hash))
|
||||
if reaction.form == MCO:
|
||||
anchor = at
|
||||
else:
|
||||
anchor = reaction.timestamp or 0
|
||||
for target in reversed(self._targets):
|
||||
if abs(target.timestamp - anchor) > WINDOW_S:
|
||||
continue
|
||||
if reaction.form == MCO:
|
||||
author = target.author if self._channel else None
|
||||
if mco_hash(target.timestamp, author, target.body) == reaction.hash:
|
||||
return target
|
||||
elif dart_hash(_units(target.body)) == reaction.body_hash:
|
||||
if self._channel and target.author is not None:
|
||||
if dart_hash(_units(target.author)) != reaction.author_hash:
|
||||
continue
|
||||
return target
|
||||
return None
|
||||
|
||||
def _attach(self, message: ChatMessage, emoji: str, reactor: Reactor) -> None:
|
||||
"""Add one reaction to ``message``, if that node did not already send that emoji."""
|
||||
badges = self._badges.setdefault(id(message), [])
|
||||
badge = next((b for b in badges if b.emoji == emoji), None)
|
||||
if badge is None:
|
||||
badge = Badge(emoji)
|
||||
badges.append(badge)
|
||||
if reactor not in badge.reactors:
|
||||
badge.reactors.append(reactor)
|
||||
@@ -28,6 +28,8 @@ from typing import ClassVar
|
||||
from .admin_store import admin_key
|
||||
from .atomicwrite import write_atomically
|
||||
from .models import Contact, utcnow
|
||||
from .secrets import names_secret, without_secret
|
||||
from .secrets import remember as remember_secret
|
||||
|
||||
#: How many command-line entries the store keeps for each node (newest last).
|
||||
HISTORY_CAP = 100
|
||||
@@ -121,7 +123,11 @@ class _RemoteNode:
|
||||
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 []
|
||||
# A history that an earlier version wrote can hold a password. The record keeps
|
||||
# the command without it, so the next write removes it from the file.
|
||||
history = (
|
||||
[without_secret(str(c)) for c in raw_history] if isinstance(raw_history, list) else []
|
||||
)
|
||||
return cls(settings=settings, history=history)
|
||||
|
||||
@property
|
||||
@@ -169,11 +175,20 @@ class RemoteStore:
|
||||
return dict(self._node(node).settings)
|
||||
|
||||
def remember_setting(self, node: Contact, key: str, value: str) -> None:
|
||||
"""Cache the value of one setting for ``node``, with the time now."""
|
||||
"""Cache the value of one setting for ``node``, with the time now.
|
||||
|
||||
The value of a setting that is a secret (``guest.password``, ``bridge.secret``) is
|
||||
also registered, so that no log can repeat it (refer to
|
||||
:mod:`meshterm.core.secrets`).
|
||||
"""
|
||||
if names_secret(key):
|
||||
remember_secret(value)
|
||||
self._put(node, key, CachedValue(value=value, read_at=utcnow()))
|
||||
|
||||
def remember_discovered(self, node: Contact, key: str, value: str) -> None:
|
||||
"""Cache a setting that is not in the catalog, which ``node`` proved that it has."""
|
||||
if names_secret(key):
|
||||
remember_secret(value)
|
||||
self._put(node, key, CachedValue(value=value, read_at=utcnow(), discovered=True))
|
||||
|
||||
def remember_unsupported(self, node: Contact, key: str) -> None:
|
||||
@@ -209,10 +224,17 @@ class RemoteStore:
|
||||
return list(self._node(node).history)
|
||||
|
||||
def append_history(self, node: Contact, command: str) -> None:
|
||||
"""Add one sent command to the history of the node (not a repeat of the last command)."""
|
||||
"""Add one sent command to the history of the node (not a repeat of the last command).
|
||||
|
||||
A command that sets a password or a secret is kept without its value
|
||||
(:func:`~meshterm.core.secrets.without_secret`): ``password hunter2`` is kept as
|
||||
``password``, with the space after it. Thus ↑ recalls the start of the command, and
|
||||
the file never holds the secret.
|
||||
"""
|
||||
command = command.strip()
|
||||
if not command:
|
||||
return
|
||||
command = without_secret(command)
|
||||
records = self._load()
|
||||
record = records.setdefault(admin_key(node), _RemoteNode())
|
||||
if record.history and record.history[-1] == command:
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Keep each password and each secret out of each log: the log file and the run log.
|
||||
|
||||
MeshTerm keeps a record of what it does in two places: the log file (``meshterm.log``) and
|
||||
the ``runs`` table, which stores the parameters and the summary of each run. Neither place
|
||||
may ever hold a password, a channel secret, a pairing PIN, or a private key (JP,
|
||||
2026-10-18: "NEVER log passwords or secrets!"). Each one is written as :data:`SECRET_TEXT`
|
||||
instead. This rule does not depend on a preference, and the ``anonymize`` preference
|
||||
(refer to :mod:`meshterm.core.anonymize`) adds to it and does not replace it.
|
||||
|
||||
A secret gets into a record in many shapes, and this module has one rule for each shape.
|
||||
No rule depends on a list of the tools or of their parameters. Thus a tool that is added
|
||||
later is covered on the day that it arrives:
|
||||
|
||||
- **A named value.** A value is a secret when its name says so (:func:`names_secret`):
|
||||
a mapping key (``{"password": …}``), a dataclass field (``Conversation.secret``), the
|
||||
``key`` of a record (``{"key": "device_pin", "value": …}``), the name in an operation
|
||||
(``("set", "device_pin", "123456")``), or the name before ``=`` in a text
|
||||
(``…&secret=8f…``, ``secret=b'…'``).
|
||||
- **A wrapped value.** A tool can wrap a value in :class:`Secret` when nothing names it.
|
||||
- **Bytes.** The parameters of a run carry bytes only for keys and secrets. Thus each
|
||||
``bytes`` value is a secret.
|
||||
- **A remote command.** ``password <new>`` and ``set guest.password <new>`` carry the
|
||||
secret as their last word.
|
||||
- **A known value in free text.** Each value that a rule above hid, and each secret that
|
||||
MeshTerm reads (a stored password, a channel secret, a pairing PIN), is registered
|
||||
(:func:`remember`). :func:`hide` then finds it in any text: an error message that
|
||||
repeats it, or a frame that a library writes in hex.
|
||||
|
||||
:func:`loggable` applies the rules to a structure (the parameters and the summary of a
|
||||
run). :func:`hide` applies them to a text (a log record).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import fields, is_dataclass
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from pathlib import PurePath
|
||||
from typing import Any
|
||||
|
||||
#: What a log and the run log show in the place of a secret.
|
||||
SECRET_TEXT = "<secret>"
|
||||
|
||||
#: The words that make a name the name of a secret. A name matches when one of its words
|
||||
#: is in this set. The words of a name are the parts between the characters that are not
|
||||
#: letters or digits. Thus ``device_pin``, ``guest.password``, and ``bridge.secret`` match,
|
||||
#: and ``pinned`` and ``spin`` do not. ``prv`` is the private key of a repeater
|
||||
#: (``prv.key``). ``key`` alone is not a secret word, because most keys in MeshTerm are
|
||||
#: public keys.
|
||||
SECRET_WORDS = frozenset(
|
||||
{
|
||||
"password",
|
||||
"passwd",
|
||||
"passphrase",
|
||||
"secret",
|
||||
"pin",
|
||||
"private",
|
||||
"privkey",
|
||||
"prv",
|
||||
"psk",
|
||||
"token",
|
||||
"credential",
|
||||
"credentials",
|
||||
}
|
||||
)
|
||||
|
||||
#: The entries of a record whose ``key`` names a secret, and which hold its value.
|
||||
_RECORD_VALUES = frozenset({"value", "previous", "default", "old", "new"})
|
||||
|
||||
#: The entries of a record that name the thing that the record is about.
|
||||
_RECORD_NAMES = ("key", "setting")
|
||||
|
||||
#: The shortest text that :func:`remember` registers. A shorter value matches too much
|
||||
#: ordinary text in a log (``abc``, ``123``). The structural rules still hide it where it
|
||||
#: has a name.
|
||||
_MIN_REMEMBERED = 4
|
||||
|
||||
#: The shortest hex value that :func:`hide` finds inside a longer run of hex. A channel
|
||||
#: secret has 32 hex digits. With 16 or more, a match by chance is not probable.
|
||||
_MIN_HEX_SUBSTRING = 16
|
||||
|
||||
_WORD_SPLIT = re.compile(r"[^a-z0-9]+")
|
||||
_HEX = re.compile(r"[0-9a-fA-F]+")
|
||||
|
||||
#: ``name = value`` in a text: a URL query, a ``repr`` of an object, or a ``key=value``
|
||||
#: log line. A name with no quotes takes ``=``. A quoted name (a ``repr`` of a dict, or
|
||||
#: JSON) also takes ``:``. Prose ("device_pin: must be six digits") is not an assignment.
|
||||
#: The value is a quoted literal (also a ``bytes`` literal), or a run of characters up to
|
||||
#: the end of the value.
|
||||
_ASSIGNMENT = re.compile(
|
||||
r"(?P<name>(?<![\w.-])[A-Za-z_][\w.-]*)"
|
||||
r"(?P<sep>\s*=\s*|['\"]\s*[=:]\s*)"
|
||||
r"(?P<value>b?'(?:[^'\\]|\\.)*'|b?\"(?:[^\"\\]|\\.)*\"|<secret>|[^\s'\",;)}\]&<>]+)"
|
||||
)
|
||||
|
||||
#: A remote command that sets a password: ``password <new>``.
|
||||
_PASSWORD_COMMAND = re.compile(r"^(?P<head>\s*password\s+)(?P<value>\S.*)$", re.IGNORECASE)
|
||||
|
||||
#: A remote command that sets a setting: ``set <name> <value>``.
|
||||
_SET_COMMAND = re.compile(r"^(?P<head>\s*set\s+(?P<name>\S+)\s+)(?P<value>\S.*)$", re.IGNORECASE)
|
||||
|
||||
_known: set[str] = set()
|
||||
_known_pattern: re.Pattern[str] | None = None
|
||||
_known_lock = threading.Lock()
|
||||
|
||||
|
||||
class Secret:
|
||||
"""A value that no log may keep, for example a private key or a password.
|
||||
|
||||
:meth:`~meshterm.tools.base.Tool.execute` stores the parameters of each run in the
|
||||
``runs`` table, and logs them at debug level. A tool that has a secret that nothing
|
||||
names (a key in a tuple) puts it in this wrapper. :func:`loggable` and each text form
|
||||
of this object show :data:`SECRET_TEXT`, and the tool reads :attr:`value` (refer to
|
||||
:func:`reveal`).
|
||||
|
||||
The class does not derive from ``str``, because ``json.dumps`` writes the value of a
|
||||
``str`` subclass and never calls its ``default`` for it.
|
||||
|
||||
Attributes:
|
||||
value: The secret itself.
|
||||
"""
|
||||
|
||||
__slots__ = ("value",)
|
||||
|
||||
def __init__(self, value: str) -> None:
|
||||
"""Keep ``value`` out of each text form of this object, and register it."""
|
||||
self.value = value
|
||||
remember(value)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
""":data:`SECRET_TEXT`, never the value."""
|
||||
return SECRET_TEXT
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
|
||||
def reveal(value: Any) -> Any:
|
||||
"""The value inside a :class:`Secret`, or ``value`` itself if it is not a secret."""
|
||||
return value.value if isinstance(value, Secret) else value
|
||||
|
||||
|
||||
def names_secret(name: str) -> bool:
|
||||
"""Whether ``name`` is the name of a secret (refer to :data:`SECRET_WORDS`).
|
||||
|
||||
Args:
|
||||
name: A parameter name, a field name, a setting key, or a word of a text.
|
||||
|
||||
Returns:
|
||||
``True`` when one of the words of ``name`` is a secret word.
|
||||
"""
|
||||
return not SECRET_WORDS.isdisjoint(_WORD_SPLIT.split(name.lower()))
|
||||
|
||||
|
||||
def remember(value: Any) -> None:
|
||||
"""Register a secret, so that :func:`hide` finds it in any text after this call.
|
||||
|
||||
A value of bytes registers as hex, the form in which a log writes bytes. A value that
|
||||
is too short to find safely, or that is a secret word (a password of ``password``), is
|
||||
not registered: each mention of the word in the log would become the mark. Each other
|
||||
rule still hides such a value where it has a name.
|
||||
|
||||
Args:
|
||||
value: The secret: text, a number (a PIN), or bytes.
|
||||
"""
|
||||
global _known_pattern
|
||||
if value is None or isinstance(value, bool):
|
||||
return
|
||||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||
text = bytes(value).hex()
|
||||
else:
|
||||
text = str(value).strip()
|
||||
if len(text) < _MIN_REMEMBERED or text == SECRET_TEXT or text.lower() in SECRET_WORDS:
|
||||
return
|
||||
with _known_lock:
|
||||
if text in _known:
|
||||
return
|
||||
_known.add(text)
|
||||
_known_pattern = _compile_known(_known)
|
||||
|
||||
|
||||
def hide(text: str) -> str:
|
||||
"""Replace each secret in ``text`` with :data:`SECRET_TEXT`.
|
||||
|
||||
Args:
|
||||
text: A log record, an error message, a remote command, or a URL.
|
||||
|
||||
Returns:
|
||||
The text with each secret that a rule finds replaced.
|
||||
"""
|
||||
match = _PASSWORD_COMMAND.match(text)
|
||||
if match is not None:
|
||||
_remember_text(match.group("value"))
|
||||
return match.group("head") + SECRET_TEXT
|
||||
match = _SET_COMMAND.match(text)
|
||||
if match is not None and names_secret(match.group("name")):
|
||||
_remember_text(match.group("value"))
|
||||
return match.group("head") + SECRET_TEXT
|
||||
text = _ASSIGNMENT.sub(_hide_assignment, text)
|
||||
known = _known_pattern
|
||||
if known is not None:
|
||||
text = known.sub(SECRET_TEXT, text)
|
||||
return text
|
||||
|
||||
|
||||
def without_secret(command: str) -> str:
|
||||
"""A remote command with its secret value removed, for a history to recall.
|
||||
|
||||
The history of the remote command line keeps ``password `` for ``password hunter2``.
|
||||
Thus ↑ recalls the start of the command, and the user types the new value again. If
|
||||
the history kept :data:`SECRET_TEXT`, a recall and Enter would set the password to
|
||||
that text.
|
||||
|
||||
Args:
|
||||
command: A command as the user typed it.
|
||||
|
||||
Returns:
|
||||
The command up to its secret value, or the command itself when it has none.
|
||||
"""
|
||||
for pattern in (_PASSWORD_COMMAND, _SET_COMMAND):
|
||||
match = pattern.match(command)
|
||||
if match is not None and (pattern is _PASSWORD_COMMAND or names_secret(match["name"])):
|
||||
return match.group("head")
|
||||
return command
|
||||
|
||||
|
||||
def loggable(value: Any) -> Any:
|
||||
"""A copy of ``value`` that a log can keep: each secret in it is :data:`SECRET_TEXT`.
|
||||
|
||||
The copy has the same shape: mappings stay mappings, and lists and tuples keep their
|
||||
types. A dataclass becomes an object whose text form is the ``repr`` of the dataclass
|
||||
with its secret fields hidden, so the log line looks as it did. Each text goes through
|
||||
:func:`hide`. Each value that a rule hides is registered (:func:`remember`). Thus the
|
||||
same value is also hidden when an error message repeats it later in the run.
|
||||
|
||||
Args:
|
||||
value: The parameters or the summary of a run, or any part of them.
|
||||
|
||||
Returns:
|
||||
The copy, ready for a log line or for ``json.dumps(..., default=str)``.
|
||||
"""
|
||||
if isinstance(value, Secret):
|
||||
remember(value.value)
|
||||
return SECRET_TEXT
|
||||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||
return _hidden(value)
|
||||
if isinstance(value, str):
|
||||
return hide(value)
|
||||
if value is None or isinstance(value, (bool, int, float, Enum, PurePath, date, datetime)):
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
return _loggable_mapping(value)
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
items = []
|
||||
for f in fields(value):
|
||||
item = getattr(value, f.name)
|
||||
items.append((f.name, _hidden(item) if names_secret(f.name) else loggable(item)))
|
||||
return _Shown(type(value).__name__, items)
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
items = _loggable_sequence(list(value))
|
||||
return tuple(items) if isinstance(value, tuple) else items
|
||||
return _Shown(hide(repr(value)))
|
||||
|
||||
|
||||
class _Shown:
|
||||
"""An object in a log, with its secrets hidden: its ``repr`` and its ``str`` are the same.
|
||||
|
||||
``json.dumps(..., default=str)`` writes an object as its ``str``, and a log line writes
|
||||
a value in a dict as its ``repr``. Both forms of the original object can hold the
|
||||
secret, so both forms of this object are the hidden form.
|
||||
"""
|
||||
|
||||
__slots__ = ("_text",)
|
||||
|
||||
def __init__(self, name: str, items: list[tuple[str, Any]] | None = None) -> None:
|
||||
"""Build the form ``Name(a=1, b=<secret>)``, or keep ``name`` as the whole text."""
|
||||
if items is None:
|
||||
self._text = name
|
||||
else:
|
||||
inner = ", ".join(f"{field}={_repr(value)}" for field, value in items)
|
||||
self._text = f"{name}({inner})"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""The hidden form."""
|
||||
return self._text
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
|
||||
def _repr(value: Any) -> str:
|
||||
"""The ``repr`` of a value in a hidden form, with :data:`SECRET_TEXT` not in quotes."""
|
||||
return SECRET_TEXT if value == SECRET_TEXT else repr(value)
|
||||
|
||||
|
||||
def _hidden(value: Any) -> Any:
|
||||
""":data:`SECRET_TEXT` for a value that a name or a type makes secret, after it is registered.
|
||||
|
||||
An absent value (``None``, or an empty text) stays as it is. That a password was not
|
||||
given is a fact that a maintainer can use, and it hides nothing.
|
||||
"""
|
||||
if value is None or value == "" or value == b"":
|
||||
return value
|
||||
remember(reveal(value))
|
||||
return SECRET_TEXT
|
||||
|
||||
|
||||
def _loggable_mapping(value: Mapping) -> dict:
|
||||
""":func:`loggable` for a mapping: hide each value that its key or its record names secret."""
|
||||
record_names = [value.get(name) for name in _RECORD_NAMES]
|
||||
secret_record = any(isinstance(name, str) and names_secret(name) for name in record_names)
|
||||
shown: dict = {}
|
||||
for key, item in value.items():
|
||||
named = names_secret(str(key)) or (secret_record and key in _RECORD_VALUES)
|
||||
shown[key] = _hidden(item) if named else loggable(item)
|
||||
return shown
|
||||
|
||||
|
||||
def _loggable_sequence(items: list) -> list:
|
||||
""":func:`loggable` for a sequence, and for an operation that sets a secret.
|
||||
|
||||
An operation that sets a value names the setting before the value:
|
||||
``("set", "device_pin", "123456")``. Thus, in a sequence of three items that starts
|
||||
with ``set`` and whose second item names a secret, the third item is the secret. No
|
||||
other sequence has a value after a name. A list of setting names
|
||||
(``["name", "device_pin", "radio_freq"]``) stays as it is.
|
||||
"""
|
||||
shown = [loggable(item) for item in items]
|
||||
verb, name = (items[0], items[1]) if len(items) == 3 else (None, None)
|
||||
if isinstance(verb, str) and verb.lower() == "set" and isinstance(name, str):
|
||||
if names_secret(name):
|
||||
shown[2] = _hidden(items[2])
|
||||
return shown
|
||||
|
||||
|
||||
def _hide_assignment(match: re.Match[str]) -> str:
|
||||
"""Hide the value of ``name = value`` when the name is the name of a secret."""
|
||||
value = match.group("value")
|
||||
if not names_secret(match.group("name")) or value in (SECRET_TEXT, "None", "null"):
|
||||
return match.group(0)
|
||||
if value in ("''", '""', "b''", 'b""'):
|
||||
return match.group(0)
|
||||
_remember_text(value)
|
||||
return match.group("name") + match.group("sep") + SECRET_TEXT
|
||||
|
||||
|
||||
def _remember_text(value: str) -> None:
|
||||
"""Register a value that a text rule found, without the quotes of a literal."""
|
||||
text = value.strip()
|
||||
if len(text) >= 2 and text[-1] in "'\"" and text.lstrip("b")[:1] == text[-1]:
|
||||
text = text.lstrip("b")[1:-1]
|
||||
remember(text)
|
||||
|
||||
|
||||
def _compile_known(known: set[str]) -> re.Pattern[str]:
|
||||
"""The pattern that finds each registered secret.
|
||||
|
||||
A long hex value matches also inside a longer run of hex, in any letter case, because a
|
||||
library writes a whole frame as one run of hex. Each other value matches only as a
|
||||
whole word, so a PIN of ``123456`` does not hide a part of ``71234567``.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for text in sorted(known, key=len, reverse=True):
|
||||
if len(text) >= _MIN_HEX_SUBSTRING and _HEX.fullmatch(text):
|
||||
parts.append(f"(?i:{re.escape(text)})")
|
||||
else:
|
||||
parts.append(rf"(?<![\w]){re.escape(text)}(?![\w])")
|
||||
return re.compile("|".join(parts))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SECRET_TEXT",
|
||||
"SECRET_WORDS",
|
||||
"Secret",
|
||||
"hide",
|
||||
"loggable",
|
||||
"names_secret",
|
||||
"remember",
|
||||
"reveal",
|
||||
"without_secret",
|
||||
]
|
||||
@@ -53,7 +53,7 @@ from .atomicwrite import write_atomically
|
||||
from .config import DeviceProfile, SpiWiring
|
||||
from .connection import DeviceCommandError, MeshCoreDevice
|
||||
from .discovery import TRANSPORT_SERIAL, DiscoveredDevice, spi_device
|
||||
from .privatekey import KEY_BYTES, SEED_BYTES
|
||||
from .privatekey import checked
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
@@ -949,15 +949,19 @@ class SpiRadioDevice(MeshCoreDevice):
|
||||
menu announces a reconnect, which stops this node and starts a new one, and a run
|
||||
on the command line stops the node when it ends.
|
||||
|
||||
The file gets the key in the form that the user gave. An imported seed stays a seed,
|
||||
because the radio library keeps keys in that form, and a seed is the more complete
|
||||
form (JP, 2026-10-13). A 64-byte key from a firmware companion has no seed, and the
|
||||
library reads it in that form too.
|
||||
|
||||
The method does not restart the node itself. The services of the session subscribe
|
||||
to the events of this connection, and a new connection under them would leave them
|
||||
deaf. The reconnect of the session is the one path that rebuilds them.
|
||||
"""
|
||||
key = bytes.fromhex(key_hex)
|
||||
if len(key) not in (KEY_BYTES, SEED_BYTES):
|
||||
raise DeviceCommandError(
|
||||
f"a private key has {KEY_BYTES} or {SEED_BYTES} bytes, not {len(key)}"
|
||||
)
|
||||
try:
|
||||
key = checked(bytes.fromhex(key_hex))
|
||||
except ValueError as exc:
|
||||
raise DeviceCommandError(str(exc)) from None
|
||||
await asyncio.to_thread(
|
||||
write_atomically, self._state / "identity.key", key, owner_only=True
|
||||
)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The demo: a synthetic Montréal mesh that the ``--demo`` flag runs MeshTerm against."""
|
||||
@@ -0,0 +1,452 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The device of the demo: the companion of our node in the Montréal world.
|
||||
|
||||
:class:`DemoDevice` is the simulator (:class:`~meshterm.core.connection.MockDevice`) with
|
||||
the demo world in place of its small test world. It holds the contacts, the channels, and
|
||||
the settings of a companion that has lived on the mesh for months. It answers a trace
|
||||
from the link model, a repeater from the region tables and the neighbours of the world,
|
||||
and it plays the live traffic of the world while the demo runs: the adverts, the packets
|
||||
of the background, and the lines of the script that arrive after the start
|
||||
(:data:`~meshterm.demo.script.LIVE_DIRECT`, :data:`~meshterm.demo.script.LIVE_CHANNEL`).
|
||||
When a channel message of our node mentions a node that has an answer in the script
|
||||
(:data:`~meshterm.demo.script.REPLIES`), that node answers it.
|
||||
|
||||
Everything that the device sends comes from a fixed seed and a time after the start (or
|
||||
after the message that it answers). Thus two runs play the same traffic at the same
|
||||
moments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
from ..core import transmit_gate
|
||||
from ..core.channels import MENTION
|
||||
from ..core.connection import EventCallback, MockDevice, Unsubscribe
|
||||
from ..core.events import MeshEvent
|
||||
from ..core.models import Contact, Hop, Message, NeighbourInfo, RoomAccess, TraceResult, utcnow
|
||||
from ..core.region_sim import SimulatedRegionMap
|
||||
from . import script, traffic, world
|
||||
from .history import TX_POWER
|
||||
|
||||
#: The length of one part of the live traffic (seconds). The device makes the traffic one
|
||||
#: part at a time, each from its own seed, so a long session never waits for all of it.
|
||||
_PART_S = 3600.0
|
||||
|
||||
#: Whether the device plays the background traffic of the world while the demo runs. The
|
||||
#: screenshots turn it off (``scripts/screenshots.py``): a packet that arrives during a
|
||||
#: capture changes the activity bars of the header, so two captures would not be the same.
|
||||
#: The lines of the script still arrive.
|
||||
PLAY_BACKGROUND = True
|
||||
|
||||
#: The TX power of each repeater of the world, as a read of its settings shows it.
|
||||
REMOTE_TX = 22
|
||||
|
||||
#: The TX power at which a repeater of the world is best heard. It is lower than
|
||||
#: :data:`REMOTE_TX`. Thus TX optimize finds a lower power for a repeater, with a better
|
||||
#: signal at its target (refer to :meth:`DemoDevice._tuned`).
|
||||
REMOTE_TX_BEST = 19
|
||||
|
||||
|
||||
class DemoDevice(MockDevice):
|
||||
"""The simulator, in the demo world (refer to the module docstring)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Build the companion of our node, with the state that the demo starts from."""
|
||||
super().__init__(
|
||||
seed=hash_seed("device"),
|
||||
optimal_tx=TX_POWER,
|
||||
optimal_remote_tx=REMOTE_TX_BEST,
|
||||
admin_password=world.ADMIN_PASSWORD,
|
||||
)
|
||||
now = utcnow()
|
||||
self._tx_power = TX_POWER
|
||||
self._default_remote_tx = REMOTE_TX
|
||||
self._contacts = [_contact(node) for node in world.NODES if node.added]
|
||||
room = world.node(world.JOINED_ROOM)
|
||||
# The room knows our node as a member. Its posts until now are in the history
|
||||
# already, so a login brings only the posts that come after the start.
|
||||
self._room_board = []
|
||||
self._room_access = {room.public_key: RoomAccess.MEMBER}
|
||||
self._room_synced = {room.public_key: now}
|
||||
self._neighbour_tables = {}
|
||||
self._region_maps = {
|
||||
node.node_id: SimulatedRegionMap(world.REGION_TABLES[node.name])
|
||||
for node in world.REPEATERS
|
||||
}
|
||||
self._info.update(
|
||||
name=world.SELF.name,
|
||||
public_key=world.SELF.public_key,
|
||||
adv_lat=world.SELF.lat,
|
||||
adv_lon=world.SELF.lon,
|
||||
adv_loc_policy=1,
|
||||
max_tx_power=TX_POWER,
|
||||
# The preset of North America, which the mesh of Montréal uses.
|
||||
radio_freq=910.525,
|
||||
radio_bw=62.5,
|
||||
radio_sf=7,
|
||||
radio_cr=5,
|
||||
)
|
||||
self._channels = {
|
||||
channel.slot: {
|
||||
"channel_idx": channel.slot,
|
||||
"channel_name": channel.name,
|
||||
"channel_secret": channel.secret,
|
||||
}
|
||||
for channel in world.CHANNELS
|
||||
}
|
||||
self._clock_offset = 0
|
||||
# Our node uses the path width of most of the mesh. A trace collapses it to the
|
||||
# widest width that it can encode (three bytes become two).
|
||||
self._path_hash_mode = world.path_width(world.SELF) - 1
|
||||
self._answers = 0
|
||||
|
||||
#: The route that a flood login teaches the device: one hop, through Mont-Royal.
|
||||
_LEARNED_ROUTE = (world.node("Mont-Royal").hash,)
|
||||
|
||||
async def get_device_info(self) -> dict: # noqa: D102 - inherited docstring
|
||||
return {
|
||||
"model": "MeshCore Simulator",
|
||||
"ver": "v1.15.0",
|
||||
"fw_build": "demo",
|
||||
"ble_pin": self._device_pin,
|
||||
"repeat": self._client_repeat,
|
||||
}
|
||||
|
||||
async def get_battery(self) -> dict: # noqa: D102 - inherited docstring
|
||||
return {"level": 3980, "used_kb": 412, "total_kb": 1024}
|
||||
|
||||
async def get_stats(self) -> dict: # noqa: D102 - inherited docstring
|
||||
return {
|
||||
"battery_mv": 3980,
|
||||
"uptime_secs": 273_640, # 3 d 4 h
|
||||
"errors": 0,
|
||||
"queue_len": 0,
|
||||
"noise_floor": -112,
|
||||
"last_rssi": -96,
|
||||
"last_snr": 7.25,
|
||||
"tx_air_secs": 318,
|
||||
"rx_air_secs": 9_412,
|
||||
"recv": 14_803,
|
||||
"sent": 1_212,
|
||||
"flood_tx": 388,
|
||||
"direct_tx": 824,
|
||||
"flood_rx": 11_590,
|
||||
"direct_rx": 3_213,
|
||||
"recv_errors": 41,
|
||||
}
|
||||
|
||||
# -- the remote nodes -----------------------------------------------------------
|
||||
|
||||
def _remote_config(self, node: Contact) -> dict[str, str]:
|
||||
"""The settings of a node of the world, as its CLI shows them."""
|
||||
cfg = super()._remote_config(node)
|
||||
known = _node_of(node)
|
||||
if known is not None and cfg.get("lat") == "0":
|
||||
cfg.update(
|
||||
lat=f"{known.lat:.4f}",
|
||||
lon=f"{known.lon:.4f}",
|
||||
freq="910.525",
|
||||
sf="7",
|
||||
**{"owner.info": "Mesh Montréal · #montreal", "advert.interval": "240"},
|
||||
)
|
||||
return cfg
|
||||
|
||||
async def send_remote_command( # noqa: D102 - inherited docstring
|
||||
self, node: Contact, command: str, *, timeout: float = 8.0
|
||||
) -> str | None:
|
||||
verb = command.strip().split()[0].lower() if command.strip() else ""
|
||||
if verb == "neighbors" and self._mock_key(node) in self._admin_sessions:
|
||||
transmit_gate.mark()
|
||||
await asyncio.sleep(0)
|
||||
table = self._neighbours_of(node)
|
||||
return "\n".join(f"{n.node} {n.snr:+.1f}dB {_ago(n)}s" for n in table) or "none"
|
||||
return await super().send_remote_command(node, command, timeout=timeout)
|
||||
|
||||
async def fetch_neighbours(self, node: Contact) -> list[NeighbourInfo]: # noqa: D102
|
||||
await asyncio.sleep(0)
|
||||
if self._mock_key(node) not in self._admin_sessions:
|
||||
return await super().fetch_neighbours(node) # the same refusal as the simulator
|
||||
return self._neighbours_of(node)
|
||||
|
||||
def _neighbours_of(self, node: Contact) -> list[NeighbourInfo]:
|
||||
"""The neighbour table of a repeater of the world: each node that it hears now."""
|
||||
repeater = _node_of(node)
|
||||
if repeater is None or not repeater.is_repeater:
|
||||
return []
|
||||
now = utcnow()
|
||||
table = []
|
||||
for other in (world.SELF, *world.NODES):
|
||||
snr = world.link_snr(other, repeater)
|
||||
if snr is None or not (other is world.SELF or traffic.active(other, 0.0)):
|
||||
continue
|
||||
heard_at = now - timedelta(seconds=self._rng.uniform(30.0, 3600.0))
|
||||
table.append(
|
||||
NeighbourInfo(other.public_key[:8], world.reading(snr, self._rng), heard_at)
|
||||
)
|
||||
return table
|
||||
|
||||
# -- the answers -------------------------------------------------------------------
|
||||
|
||||
async def send_channel_message(self, index: int, text: str) -> None: # noqa: D102
|
||||
await super().send_channel_message(index, text)
|
||||
self._answer(index, text)
|
||||
|
||||
def _answer(self, index: int, text: str) -> None:
|
||||
"""Let a node that ``text`` mentions answer it, on the same channel.
|
||||
|
||||
The first mentioned node that has an answer in :data:`~meshterm.demo.script.REPLIES`
|
||||
answers after :data:`~meshterm.demo.script.REPLY_DELAY_S`. The answer arrives as a
|
||||
live line of the script does: the copies of its flood, then the message.
|
||||
"""
|
||||
channel = next((c for c in world.CHANNELS if c.slot == index), None)
|
||||
name = next((n for n in MENTION.findall(text) if n in script.REPLIES), None)
|
||||
if channel is None or name is None:
|
||||
return
|
||||
self._answers += 1
|
||||
rng = random.Random(f"meshterm-demo/answer/{self._answers}")
|
||||
line = script.Line(0.0, name, script.REPLIES[name])
|
||||
items = _live_channel(script.REPLY_DELAY_S, channel.name, line, rng)
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
|
||||
async def play() -> None:
|
||||
for item in items:
|
||||
await asyncio.sleep(max(0.0, item.offset_s - (loop.time() - start)))
|
||||
for listener in list(self._listeners):
|
||||
_deliver(item, listener)
|
||||
|
||||
task = asyncio.create_task(play())
|
||||
self._bg_tasks.add(task)
|
||||
task.add_done_callback(self._bg_tasks.discard)
|
||||
|
||||
# -- traces -----------------------------------------------------------------------
|
||||
|
||||
async def run_trace( # noqa: D102 - inherited docstring
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
path: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> TraceResult:
|
||||
transmit_gate.mark()
|
||||
await asyncio.sleep(0.4) # the time on the air, so that the progress is visible
|
||||
relays = _relays(target, path)
|
||||
width = len(path.split(",")[0].strip()) // 2 if path else 1
|
||||
if relays is None:
|
||||
return TraceResult(target=target, success=False, tx_power=self._tx_power)
|
||||
walked = world.walk(relays, self._rng)
|
||||
snrs = self._tuned(relays, walked.snrs) if walked.success else None
|
||||
if snrs is None:
|
||||
return TraceResult(
|
||||
target=target, success=False, tx_power=self._tx_power, path_hash_bytes=width
|
||||
)
|
||||
hops = [
|
||||
Hop(index, relay.public_key[: 2 * width], snr)
|
||||
for index, (relay, snr) in enumerate(zip(relays, snrs, strict=False))
|
||||
]
|
||||
hops.append(Hop(len(relays), None, snrs[-1]))
|
||||
return TraceResult(
|
||||
target=target,
|
||||
success=True,
|
||||
hops=hops,
|
||||
round_trip_ms=walked.rtt_ms,
|
||||
tx_power=self._tx_power,
|
||||
path_hash_bytes=width,
|
||||
)
|
||||
|
||||
def _tuned(self, relays: list[world.Node], snrs: list[float]) -> list[float] | None:
|
||||
"""The readings of a trace, with the TX power that TX optimize set on its relays.
|
||||
|
||||
The world gives each link its SNR at :data:`REMOTE_TX_BEST`. A relay that
|
||||
transmits at another power is heard less well on the link that it transmits into.
|
||||
The loss follows the curve of the simulator, which has its peak at the best power
|
||||
(:meth:`~meshterm.core.connection.MockDevice._expected_remote_snr`). Thus the sweep
|
||||
of TX optimize finds that peak, and a power far from it loses traces: a reading
|
||||
under :data:`~meshterm.demo.world.FLOOR_DB` always does. A relay that nobody tuned
|
||||
keeps the readings of the world.
|
||||
|
||||
Args:
|
||||
relays: The forced path, as nodes.
|
||||
snrs: The readings of the world: one for each link, the last one back to us.
|
||||
|
||||
Returns:
|
||||
The readings, or ``None`` if a weaker link lost the trace.
|
||||
"""
|
||||
best = self._expected_remote_snr(self.optimal_remote_tx)
|
||||
readings = list(snrs)
|
||||
for index, relay in enumerate(relays):
|
||||
tx = self._remote_tx_for(relay.public_key)
|
||||
if tx is None:
|
||||
continue
|
||||
snr = readings[index + 1] + self._expected_remote_snr(tx) - best
|
||||
# Below the floor, the radio cannot decode the packet at all.
|
||||
if snr < world.FLOOR_DB or self._rng.random() < world.loss_chance(snr):
|
||||
return None
|
||||
readings[index + 1] = round(snr * 4) / 4
|
||||
return readings
|
||||
|
||||
# -- the live traffic ---------------------------------------------------------------
|
||||
|
||||
async def subscribe_events( # noqa: D102 - inherited docstring
|
||||
self, on_event: EventCallback
|
||||
) -> Unsubscribe:
|
||||
# Each subscriber gets the traffic from the moment that it subscribes, in parts of
|
||||
# one hour. A part comes from its own seed, so the same moment of each run has the
|
||||
# same packet.
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
|
||||
async def play() -> None:
|
||||
part = 0
|
||||
while not stop.is_set():
|
||||
for item in _live_part(part):
|
||||
wait = item.offset_s - (loop.time() - start)
|
||||
if wait > 0:
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), wait)
|
||||
except asyncio.TimeoutError:
|
||||
pass # the moment of the next item came
|
||||
if stop.is_set():
|
||||
return
|
||||
_deliver(item, on_event)
|
||||
part += 1
|
||||
|
||||
task = asyncio.create_task(play())
|
||||
self._bg_tasks.add(task)
|
||||
task.add_done_callback(self._bg_tasks.discard)
|
||||
self._listeners.append(on_event)
|
||||
|
||||
def unsubscribe() -> None:
|
||||
stop.set()
|
||||
if on_event in self._listeners:
|
||||
self._listeners.remove(on_event)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
|
||||
def hash_seed(name: str) -> int:
|
||||
"""A fixed integer seed for one random source of the demo device."""
|
||||
return int.from_bytes(hashlib.sha256(f"meshterm-demo/{name}".encode()).digest()[:4], "big")
|
||||
|
||||
|
||||
def _contact(node: world.Node) -> Contact:
|
||||
"""The contact that the demo device holds for ``node``.
|
||||
|
||||
The contact has no time of last reception. The app takes it from the history instead,
|
||||
where it is the time of the last advert that our node heard from the node (refer to
|
||||
:class:`~meshterm.services.device_state.DeviceState`). A real device knows no more.
|
||||
"""
|
||||
return Contact(
|
||||
name=node.name,
|
||||
public_key=node.public_key,
|
||||
key_prefix=node.node_id,
|
||||
node_type=node.node_type,
|
||||
lat=node.lat if node.shares_location else None,
|
||||
lon=node.lon if node.shares_location else None,
|
||||
route_hops=world.device_route(node),
|
||||
)
|
||||
|
||||
|
||||
def _node_of(contact: Contact) -> world.Node | None:
|
||||
"""The node of the world that a contact names, by its key (or by its name)."""
|
||||
key = (contact.public_key or contact.key_prefix or "").lower()
|
||||
for node in (world.SELF, *world.NODES):
|
||||
if key and node.public_key.startswith(key[:12]):
|
||||
return node
|
||||
return world.BY_NAME.get(contact.name)
|
||||
|
||||
|
||||
def _relays(target: str, path: str | None) -> list[world.Node] | None:
|
||||
"""The relays of a trace: the forced path, or the route out to ``target`` and back.
|
||||
|
||||
Returns:
|
||||
The relays, or ``None`` when a hash of the path names no repeater of the world
|
||||
(then no relay answers, and the trace times out).
|
||||
"""
|
||||
if path:
|
||||
relays = []
|
||||
for token in (part.strip().lower() for part in path.split(",") if part.strip()):
|
||||
relay = next((r for r in world.REPEATERS if r.public_key.startswith(token)), None)
|
||||
if relay is None:
|
||||
return None
|
||||
relays.append(relay)
|
||||
return relays
|
||||
node = world.BY_NAME.get(target)
|
||||
if node is None or not node.is_repeater:
|
||||
return None
|
||||
route = world.best_route(world.SELF, node)
|
||||
if route is None:
|
||||
return None
|
||||
return [*route, node, *reversed(route)]
|
||||
|
||||
|
||||
def _ago(entry: NeighbourInfo) -> int:
|
||||
"""The seconds since a repeater last heard a neighbour, as its CLI writes them."""
|
||||
if entry.heard_at is None:
|
||||
return 0
|
||||
return max(0, int((utcnow() - entry.heard_at).total_seconds()))
|
||||
|
||||
|
||||
def _live_part(part: int) -> list[traffic.Heard]:
|
||||
"""The live traffic of one part: the background, then the lines of the script in it."""
|
||||
start, end = part * _PART_S, (part + 1) * _PART_S
|
||||
heard = traffic.traffic(start, end, f"live/{part}") if PLAY_BACKGROUND else []
|
||||
rng = random.Random(f"meshterm-demo/live-lines/{part}")
|
||||
for offset, line in script.LIVE_DIRECT:
|
||||
if start <= offset < end:
|
||||
heard.extend(_live_direct(offset, line, rng))
|
||||
for offset, name, line in script.LIVE_CHANNEL:
|
||||
if start <= offset < end:
|
||||
heard.extend(_live_channel(offset, name, line, rng))
|
||||
heard.sort(key=lambda item: item.offset_s)
|
||||
return heard
|
||||
|
||||
|
||||
def _live_direct(offset: float, line: script.Line, rng: random.Random) -> list[traffic.Heard]:
|
||||
"""A direct message that arrives during the demo, and the packets that carried it."""
|
||||
sender = world.node(line.sender)
|
||||
body = {
|
||||
"payload_typename": "TEXT_MSG",
|
||||
"dest_hash": world.SELF.hash,
|
||||
"src_hash": sender.hash,
|
||||
"cipher_mac": f"{rng.getrandbits(16):04x}",
|
||||
}
|
||||
copies = traffic.heard_flood(sender, offset, body, rng)
|
||||
first = copies[0].offset_s if copies else offset + 1.0
|
||||
snr = copies[0].observation.snr if copies and copies[0].observation else None
|
||||
message = Message(text=line.text, sender=sender.node_id, snr=snr)
|
||||
return [*copies, traffic.Heard(first + 0.05, message=message)]
|
||||
|
||||
|
||||
def _live_channel(
|
||||
offset: float, name: str, line: script.Line, rng: random.Random
|
||||
) -> list[traffic.Heard]:
|
||||
"""A channel message that arrives during the demo, and the packets that carried it."""
|
||||
channel = next(c for c in world.CHANNELS if c.name == name)
|
||||
sender = world.node(line.sender)
|
||||
wire = f"{sender.name}: {line.text}"
|
||||
body = traffic.grp_txt_body(channel.secret, wire, utcnow() + timedelta(seconds=offset))
|
||||
if channel.scope:
|
||||
body = traffic.scoped(body, channel.scope)
|
||||
copies = traffic.heard_flood(sender, offset, body, rng, region=channel.scope)
|
||||
first = copies[0].offset_s if copies else offset + 1.0
|
||||
snr = copies[0].observation.snr if copies and copies[0].observation else None
|
||||
message = Message(text=wire, channel=channel.slot, is_channel=True, snr=snr)
|
||||
return [*copies, traffic.Heard(first + 0.05, message=message)]
|
||||
|
||||
|
||||
def _deliver(item: traffic.Heard, on_event: EventCallback) -> None:
|
||||
"""Send one live item to the subscriber, stamped with the time of now."""
|
||||
now = utcnow()
|
||||
if item.observation is not None:
|
||||
item.observation.observed_at = now
|
||||
on_event(MeshEvent.observation_event(item.observation))
|
||||
if item.message is not None:
|
||||
item.message.received_at = now
|
||||
item.message.sender_timestamp = now
|
||||
on_event(MeshEvent.message_event(item.message))
|
||||
@@ -0,0 +1,371 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The history of the demo: what our node heard and did before the demo started.
|
||||
|
||||
At each start, ``--demo`` writes a new database with this module. The database holds what
|
||||
the companion of our node would have stored after months on the mesh of Montréal:
|
||||
|
||||
* the adverts of each node since our node first heard it, and the traffic of the last
|
||||
eight days, packet by packet, each copy on its own path (:mod:`meshterm.demo.traffic`),
|
||||
* the conversations of :mod:`meshterm.demo.script`: the stored messages, and the packets
|
||||
that carried them, encrypted as the firmware encrypts them, so that the paths of a
|
||||
message can be found again,
|
||||
* the traces that our node ran (:data:`~meshterm.demo.script.TRACES`), each try in the
|
||||
link model, and the records that the walks set in the trophy case,
|
||||
* the neighbour tables of the repeaters that our node administers.
|
||||
|
||||
Each time comes from ``now`` and a fixed age, and each random value comes from a fixed
|
||||
seed. Thus two runs give the same database, with only the times moved to the new now.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .. import __version__
|
||||
from ..core.channels import channel_identity
|
||||
from ..core.models import (
|
||||
PATH_TRACE_TARGET,
|
||||
ChatMessage,
|
||||
Hop,
|
||||
NeighbourInfo,
|
||||
Observation,
|
||||
TraceResult,
|
||||
)
|
||||
from ..persistence.repository import Repository
|
||||
from ..services.records import CATEGORY_BY_ID, walk_from_trace, walk_scores
|
||||
from . import script, traffic, world
|
||||
|
||||
#: How many days of history the demo writes. The traffic of the last eight days is
|
||||
#: complete. Each older day holds one advert from each node (refer to
|
||||
#: :func:`~meshterm.demo.traffic.adverts`).
|
||||
HISTORY_DAYS = 300
|
||||
|
||||
#: The transmit power of the demo device (dBm), stored with each trace.
|
||||
TX_POWER = 22
|
||||
|
||||
#: The map viewport that the demo opens on: the island of Montréal, with the two shores.
|
||||
MAP_VIEW = (45.5150, -73.5900, 10)
|
||||
|
||||
|
||||
def seed(repo: Repository, now: datetime) -> None:
|
||||
"""Write the full history of the demo into ``repo``.
|
||||
|
||||
Args:
|
||||
repo: An empty database.
|
||||
now: The moment that the history ends at: the start of the demo.
|
||||
"""
|
||||
rng = random.Random("meshterm-demo/history")
|
||||
heard = traffic.traffic(-HISTORY_DAYS * 86400.0, 0.0, "history")
|
||||
messages, carried = _conversations(now, rng)
|
||||
heard.extend(carried)
|
||||
heard.sort(key=lambda item: item.offset_s)
|
||||
traffic.stamp(heard, now)
|
||||
_store_observations(repo, now, [item.observation for item in heard if item.observation])
|
||||
# The transcripts are in the order of the row ids. Thus the oldest message goes first.
|
||||
for message in sorted(messages, key=lambda m: m.created_at):
|
||||
repo.record_chat_message(message)
|
||||
_store_traces(repo, now, rng)
|
||||
_store_neighbours(repo, now, rng)
|
||||
repo.set_map_view(*MAP_VIEW)
|
||||
|
||||
|
||||
def _store_observations(repo: Repository, now: datetime, observations: list[Observation]) -> None:
|
||||
"""Store the observations, in one ``monitor`` run for each day, as the app makes them.
|
||||
|
||||
The app opens one run each time it starts to record. The demo writes one for each of
|
||||
the dense days, and one for all the older days.
|
||||
"""
|
||||
by_day: dict[int, list[Observation]] = defaultdict(list)
|
||||
for obs in observations:
|
||||
age_days = (now - obs.observed_at).total_seconds() / 86400.0
|
||||
by_day[min(int(age_days), 8)].append(obs)
|
||||
for _, rows in sorted(by_day.items(), reverse=True):
|
||||
rows.sort(key=lambda obs: obs.observed_at)
|
||||
run_id = repo.start_run("monitor", {"mode": "background"}, at=rows[0].observed_at)
|
||||
count = repo.record_observations(run_id, rows)
|
||||
repo.finish_run(run_id, "ok", {"observations": count}, at=rows[-1].observed_at)
|
||||
|
||||
|
||||
def _first_copy(origin: world.Node, offset: float, body: dict, rng: random.Random, **kwargs):
|
||||
"""The copies of a flood that our node must have received, because the script says so.
|
||||
|
||||
A flood can lose all its copies on weak links. A line of the script arrived by
|
||||
definition. Thus the function runs the flood again until a copy arrives.
|
||||
"""
|
||||
for _ in range(12):
|
||||
copies = traffic.heard_flood(origin, offset, body, rng, **kwargs)
|
||||
if copies:
|
||||
return copies
|
||||
raise RuntimeError(f"no flood from {origin.name} reaches our node")
|
||||
|
||||
|
||||
def _conversations(
|
||||
now: datetime, rng: random.Random
|
||||
) -> tuple[list[ChatMessage], list[traffic.Heard]]:
|
||||
"""The stored messages of the script, and the packets that carried them.
|
||||
|
||||
Args:
|
||||
now: The end of the history.
|
||||
rng: The source of the delays, the losses, and the noise.
|
||||
|
||||
Returns:
|
||||
The messages, and the packets that our node heard of them.
|
||||
"""
|
||||
messages: list[ChatMessage] = []
|
||||
heard: list[traffic.Heard] = _greetings(rng)
|
||||
for channel in world.CHANNELS:
|
||||
identity = channel_identity(channel.name, channel.secret)
|
||||
for line in script.CHANNEL_LINES.get(channel.name, ()):
|
||||
offset = -line.age_min * 60.0
|
||||
sender = world.node(line.sender)
|
||||
wire = f"{sender.name}: {line.text}"
|
||||
when = now + timedelta(seconds=offset)
|
||||
body = traffic.grp_txt_body(channel.secret, wire, when)
|
||||
if channel.scope:
|
||||
body = traffic.scoped(body, channel.scope)
|
||||
if line.sender == script.ME:
|
||||
heard.extend(traffic.heard_flood(sender, offset, body, rng, region=channel.scope))
|
||||
messages.append(
|
||||
ChatMessage(
|
||||
text=line.text,
|
||||
outbound=True,
|
||||
is_channel=True,
|
||||
channel_id=identity,
|
||||
channel_idx=channel.slot,
|
||||
peer_name=channel.name,
|
||||
scope=channel.scope or "*",
|
||||
created_at=when,
|
||||
)
|
||||
)
|
||||
continue
|
||||
copies = _first_copy(sender, offset, body, rng, region=channel.scope)
|
||||
heard.extend(copies)
|
||||
messages.append(
|
||||
ChatMessage(
|
||||
text=wire,
|
||||
is_channel=True,
|
||||
channel_id=identity,
|
||||
channel_idx=channel.slot,
|
||||
snr=copies[0].observation.snr if copies[0].observation else None,
|
||||
created_at=when,
|
||||
)
|
||||
)
|
||||
for peer_name, lines in script.DIRECT_LINES.items():
|
||||
peer = world.node(peer_name)
|
||||
for line in lines:
|
||||
offset = -line.age_min * 60.0
|
||||
when = now + timedelta(seconds=offset)
|
||||
mac = f"{rng.getrandbits(16):04x}"
|
||||
if line.sender == script.ME:
|
||||
body = {
|
||||
"payload_typename": "TEXT_MSG",
|
||||
"dest_hash": peer.hash,
|
||||
"src_hash": world.SELF.hash,
|
||||
"cipher_mac": mac,
|
||||
}
|
||||
heard.extend(_send(world.SELF, peer, offset, body, rng))
|
||||
if line.acked:
|
||||
ack = {"payload_typename": "ACK", "ack_crc": f"{rng.getrandbits(32):08x}"}
|
||||
heard.extend(_send(peer, world.SELF, offset + 2.5, ack, rng))
|
||||
messages.append(
|
||||
ChatMessage(
|
||||
text=line.text,
|
||||
outbound=True,
|
||||
peer=peer.node_id,
|
||||
peer_name=peer.name,
|
||||
acked=line.acked,
|
||||
created_at=when,
|
||||
)
|
||||
)
|
||||
continue
|
||||
body = {
|
||||
"payload_typename": "TEXT_MSG",
|
||||
"dest_hash": world.SELF.hash,
|
||||
"src_hash": peer.hash,
|
||||
"cipher_mac": mac,
|
||||
}
|
||||
copies = _first_copy(peer, offset, body, rng)
|
||||
heard.extend(copies)
|
||||
snr = copies[0].observation.snr if copies[0].observation else None
|
||||
messages.append(
|
||||
ChatMessage(text=line.text, peer=peer.node_id, snr=snr, created_at=when)
|
||||
)
|
||||
for room_name, lines in script.ROOM_LINES.items():
|
||||
room = world.node(room_name)
|
||||
link = world.link_snr(room, world.SELF) or 0.0
|
||||
for line in lines:
|
||||
when = now - timedelta(minutes=line.age_min)
|
||||
if line.sender == script.ME:
|
||||
messages.append(
|
||||
ChatMessage(
|
||||
text=line.text,
|
||||
outbound=True,
|
||||
peer=room.node_id,
|
||||
peer_name=room.name,
|
||||
acked=line.acked,
|
||||
created_at=when,
|
||||
)
|
||||
)
|
||||
continue
|
||||
author = world.BY_NAME.get(line.sender)
|
||||
messages.append(
|
||||
ChatMessage(
|
||||
text=line.text,
|
||||
peer=room.node_id,
|
||||
snr=world.reading(link, rng),
|
||||
author=author.public_key[:8] if author is not None else line.sender,
|
||||
created_at=when,
|
||||
)
|
||||
)
|
||||
return messages, heard
|
||||
|
||||
|
||||
def _greetings(rng: random.Random) -> list[traffic.Heard]:
|
||||
"""The adverts that the people of the script send when they come online to talk.
|
||||
|
||||
A person who opens the app to write often sends an advert first. Thus a node that
|
||||
spoke a few minutes ago was also heard a few minutes ago, and the lists show it as
|
||||
recent. The function adds one advert before the first line of each sitting in the last
|
||||
two days (a sitting is a run of lines less than an hour apart).
|
||||
"""
|
||||
lines = [
|
||||
line
|
||||
for group in (script.CHANNEL_LINES, script.DIRECT_LINES)
|
||||
for conversation in group.values()
|
||||
for line in conversation
|
||||
if line.sender != script.ME and line.age_min < 2 * 1440
|
||||
]
|
||||
last_advert: dict[str, float] = {}
|
||||
heard: list[traffic.Heard] = []
|
||||
for line in sorted(lines, key=lambda line: -line.age_min):
|
||||
offset = -line.age_min * 60.0
|
||||
if offset - last_advert.get(line.sender, -1e12) < 3600.0:
|
||||
continue
|
||||
sender = world.node(line.sender)
|
||||
moment = offset - rng.uniform(60.0, 900.0)
|
||||
heard.extend(
|
||||
traffic.heard_flood(sender, moment, traffic.advert_body(sender), rng, advert=True)
|
||||
)
|
||||
last_advert[line.sender] = offset
|
||||
return heard
|
||||
|
||||
|
||||
def _send(
|
||||
src: world.Node, dst: world.Node, offset: float, body: dict, rng: random.Random
|
||||
) -> list[traffic.Heard]:
|
||||
"""The packets that our node hears from a direct message (or its ack) from ``src`` to ``dst``.
|
||||
|
||||
The message follows the route that the device holds when it holds one, else it floods.
|
||||
"""
|
||||
holder, target = (dst, src) if dst is world.SELF else (src, dst)
|
||||
route = world.device_route(target) if holder is world.SELF else None
|
||||
if route is None:
|
||||
return traffic.heard_flood(src, offset, body, rng)
|
||||
relays = tuple(world.BY_NAME[_name_of(h)] for h in route)
|
||||
if src is not world.SELF:
|
||||
relays = tuple(reversed(relays))
|
||||
return traffic.heard_direct(relays, src, offset, body, rng)
|
||||
|
||||
|
||||
def _name_of(hash_hex: str) -> str:
|
||||
"""The name of the repeater with this path hash."""
|
||||
return next(node.name for node in world.REPEATERS if node.hash == hash_hex)
|
||||
|
||||
|
||||
def _store_traces(repo: Repository, now: datetime, rng: random.Random) -> None:
|
||||
"""Run each trace of the script in the link model, store each try, and score the walks.
|
||||
|
||||
A trace that does not come back is tried again a minute later, as a person does, up to
|
||||
five times. Each try is a run of its own, as in the trace screen. A walk that comes
|
||||
back goes to the trophy case, through the same scoring as a live trace.
|
||||
"""
|
||||
canonical = {node.hash: node.node_id for node in world.NODES}
|
||||
positions = {node.node_id: (node.lat, node.lon) for node in world.NODES if node.shares_location}
|
||||
self_pos = (world.SELF.lat, world.SELF.lon)
|
||||
for age_min, target, names in script.TRACES:
|
||||
relays = [world.node(name) for name in names]
|
||||
spec = ",".join(relay.hash for relay in relays)
|
||||
label = target or PATH_TRACE_TARGET
|
||||
for attempt in range(5):
|
||||
when = now - timedelta(minutes=age_min) + timedelta(seconds=75 * attempt)
|
||||
walked = world.walk(relays, rng)
|
||||
# The last reading of a walk that came back is the link to our node: the hop
|
||||
# without a hash that the firmware adds at the end.
|
||||
hops = [
|
||||
Hop(i, relay.hash, snr)
|
||||
for i, (relay, snr) in enumerate(zip(relays, walked.snrs, strict=False))
|
||||
]
|
||||
if walked.success:
|
||||
hops.append(Hop(len(relays), None, walked.snrs[-1]))
|
||||
result = TraceResult(
|
||||
target=label,
|
||||
success=walked.success,
|
||||
hops=hops if walked.success else [],
|
||||
round_trip_ms=walked.rtt_ms,
|
||||
tx_power=TX_POWER,
|
||||
path_hash_bytes=1,
|
||||
timestamp=when,
|
||||
)
|
||||
params = {"target": label, "path": spec}
|
||||
run_id = repo.start_run("trace", params, at=when - timedelta(seconds=2))
|
||||
repo.record_trace(run_id, result)
|
||||
placed: list[str] = []
|
||||
derived = walk_from_trace(
|
||||
result, canonical=canonical.get, positions=positions, self_pos=self_pos
|
||||
)
|
||||
if derived is not None:
|
||||
walk_spec, route, stats = derived
|
||||
for category_id, score in walk_scores(stats).items():
|
||||
category = CATEGORY_BY_ID[category_id]
|
||||
row = repo.record_discovery(
|
||||
category_id,
|
||||
1,
|
||||
walk_spec,
|
||||
route,
|
||||
score=score,
|
||||
stats=stats.as_dict(),
|
||||
app_version=__version__,
|
||||
ascending=category.ascending,
|
||||
at=when,
|
||||
)
|
||||
if row is not None:
|
||||
placed.append(f"{category.title} — {category.format_score(score)}")
|
||||
summary = {
|
||||
"target": label,
|
||||
"success": result.success,
|
||||
"min_snr": result.min_snr,
|
||||
"rtt_ms": result.round_trip_ms,
|
||||
"records": placed or None,
|
||||
}
|
||||
repo.finish_run(run_id, "ok", summary, at=when)
|
||||
if walked.success:
|
||||
break
|
||||
|
||||
|
||||
def _store_neighbours(repo: Repository, now: datetime, rng: random.Random) -> None:
|
||||
"""Store two fetches of the neighbour table of each repeater that our node administers.
|
||||
|
||||
A repeater lists each node that it hears directly, with the SNR that it measured, the
|
||||
same as the simulated device answers a live fetch.
|
||||
"""
|
||||
for name in world.ADMINISTERED:
|
||||
repeater = world.node(name)
|
||||
for age_h in (30.0, 2.0):
|
||||
fetched = now - timedelta(hours=age_h)
|
||||
offset = -age_h * 3600.0
|
||||
entries = []
|
||||
for other in (world.SELF, *world.NODES):
|
||||
snr = world.link_snr(other, repeater)
|
||||
if snr is None or not (other is world.SELF or traffic.active(other, offset)):
|
||||
continue
|
||||
heard_at = fetched - timedelta(seconds=rng.uniform(60.0, 4 * 3600.0))
|
||||
entries.append(
|
||||
NeighbourInfo(other.public_key[:8], world.reading(snr, rng), heard_at)
|
||||
)
|
||||
params = {"mode": "neighbours", "repeater": repeater.name}
|
||||
run_id = repo.start_run("trace", params, at=fetched - timedelta(seconds=3))
|
||||
repo.record_neighbours(run_id, repeater.node_id, entries, at=fetched)
|
||||
summary = {"repeater": repeater.name, "neighbours": len(entries)}
|
||||
repo.finish_run(run_id, "ok", summary, at=fetched)
|
||||
@@ -0,0 +1,159 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The home of the demo: its own data directory, written again at each start.
|
||||
|
||||
``--demo`` never uses the data directory of the user. It uses ``<config_dir>/demo``, and
|
||||
before the app opens, :func:`prepare` writes everything in it again from the demo world:
|
||||
the database (:mod:`meshterm.demo.history`) and the stores of the config directory (the
|
||||
joined room, the admin passwords, the regions, the Watchtower). Thus each run starts from
|
||||
the same state, whatever the previous run changed, and the history of the user never
|
||||
gets a node of the demo.
|
||||
|
||||
Two things in the directory stay from one run to the next: the tile cache of the map,
|
||||
because the tiles are the same and a download is slow, and the log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.admin_store import AdminStore
|
||||
from ..core.channels import channel_identity
|
||||
from ..core.config import DB_FILENAME
|
||||
from ..core.instancelock import hold_instance_lock
|
||||
from ..core.models import Contact, LoginResult, RoomAccess, RoomLogin
|
||||
from ..core.region_sim import SimulatedRegionMap
|
||||
from ..core.region_store import RegionStore
|
||||
from ..core.regions import WILDCARD, parse_region_list
|
||||
from ..core.room_store import RoomStore
|
||||
from ..core.watch_store import WatchStore
|
||||
from ..persistence.repository import Repository
|
||||
from . import history, script, world
|
||||
|
||||
#: The name of the demo directory, under the config directory of the user.
|
||||
DEMO_DIRNAME = "demo"
|
||||
|
||||
#: The files that the demo writes again at each start. A store that the demo does not
|
||||
#: fill is deleted, so that nothing that a previous run changed stays.
|
||||
_STORES = (
|
||||
"preferences.toml",
|
||||
"contacts.json",
|
||||
"channels.json",
|
||||
"rooms.json",
|
||||
"admin.json",
|
||||
"regions.json",
|
||||
"watchtower.json",
|
||||
"courier.json",
|
||||
"mutes.json",
|
||||
"remote.json",
|
||||
"settings.json",
|
||||
"adverts.json",
|
||||
"devices.json",
|
||||
)
|
||||
|
||||
|
||||
def demo_home(config_dir: Path) -> Path:
|
||||
"""The data directory of the demo, under the config directory of the user."""
|
||||
return config_dir / DEMO_DIRNAME
|
||||
|
||||
|
||||
def prepare(home: Path, now: datetime) -> None:
|
||||
"""Write the demo directory again: a new history that ends at ``now``, and new stores.
|
||||
|
||||
The function holds the instance lock of the directory while it writes. Thus it never
|
||||
writes under a demo that runs in another window.
|
||||
|
||||
Args:
|
||||
home: The demo directory (refer to :func:`demo_home`).
|
||||
now: The moment that the history ends at.
|
||||
|
||||
Raises:
|
||||
InstanceBusy: Another MeshTerm runs the demo now.
|
||||
"""
|
||||
with hold_instance_lock(home):
|
||||
for name in _STORES:
|
||||
(home / name).unlink(missing_ok=True)
|
||||
db_path = home / DB_FILENAME
|
||||
_empty(db_path)
|
||||
repo = Repository(db_path)
|
||||
try:
|
||||
history.seed(repo, now)
|
||||
finally:
|
||||
repo.close()
|
||||
_write_stores(home, now)
|
||||
|
||||
|
||||
def _empty(db_path: Path) -> None:
|
||||
"""Delete each row of an existing database, and keep the file.
|
||||
|
||||
The function does not delete the file, because another process can have it open: the
|
||||
``emulate`` command opens the database of the demo, then starts the MeshTerm that runs
|
||||
in its window, which prepares the demo. Windows does not let a process delete a file
|
||||
that is open.
|
||||
"""
|
||||
if not db_path.exists():
|
||||
return
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
tables = [
|
||||
row[0]
|
||||
for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
if row[0] != "sqlite_sequence" and not row[0].startswith("sqlite_")
|
||||
]
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
for table in tables:
|
||||
conn.execute(f'DELETE FROM "{table}"')
|
||||
if conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'sqlite_sequence'").fetchone():
|
||||
conn.execute("DELETE FROM sqlite_sequence")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def contact(node: world.Node) -> Contact:
|
||||
"""The contact that the demo device holds for ``node``, as a store keys it."""
|
||||
return Contact(
|
||||
name=node.name,
|
||||
public_key=node.public_key,
|
||||
key_prefix=node.node_id,
|
||||
node_type=node.node_type,
|
||||
)
|
||||
|
||||
|
||||
def _write_stores(home: Path, now: datetime) -> None:
|
||||
"""Write the stores of the config directory that the demo fills.
|
||||
|
||||
Each store writes its own file through its own methods, so that the files have the
|
||||
form that the app reads.
|
||||
|
||||
Args:
|
||||
home: The demo directory.
|
||||
now: The start of the demo, from which the times of the alerts come.
|
||||
"""
|
||||
room = world.node(world.JOINED_ROOM)
|
||||
RoomStore(home / "rooms.json").record(
|
||||
contact(room),
|
||||
world.ROOM_PASSWORD,
|
||||
RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER, flood=False),
|
||||
)
|
||||
admins = AdminStore(home / "admin.json")
|
||||
for name in world.ADMINISTERED:
|
||||
admins.remember(contact(world.node(name)), world.ADMIN_PASSWORD)
|
||||
regions = RegionStore(home / "regions.json")
|
||||
for repeater in world.REPEATERS:
|
||||
listed = SimulatedRegionMap(world.REGION_TABLES.get(repeater.name)).allowed_names()
|
||||
regions.learn_carried(repeater.node_id, [WILDCARD, *parse_region_list(listed)])
|
||||
for channel in world.CHANNELS:
|
||||
if channel.scope:
|
||||
regions.set_channel_scope(channel_identity(channel.name, channel.secret), channel.scope)
|
||||
watch = WatchStore(home / "watchtower.json")
|
||||
for name in script.WATCHED:
|
||||
node = world.node(name)
|
||||
watch.watch(node.node_id, node.name, node_type=node.node_type)
|
||||
for age_min, kind, name, message in script.ALERTS:
|
||||
watch.add_alert(kind, name, message, when=now - timedelta(minutes=age_min))
|
||||
for node in world.NODES:
|
||||
if node.quiet_hours is not None:
|
||||
watch.remember_known(node.node_id)
|
||||
watch.flush()
|
||||
@@ -0,0 +1,444 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""What the people of the demo world say: the channels, the direct messages, and the room.
|
||||
|
||||
The conversations are in English, and they are about ordinary things: the mesh, a
|
||||
meetup, a bike ride, the weather, a family dinner. Each line has a time on a virtual clock
|
||||
(refer to :func:`at`). The virtual clock sets "now" at :data:`NOW_LOCAL`, a late morning
|
||||
on a weekday. The history writes each line at its age before the real time of the run.
|
||||
Thus the ages on the screens ("12m ago") are the same at each run. When the clock of the
|
||||
run is also set to that morning (the screenshots do this), the times of day are correct
|
||||
too: the plan for lunch is made before lunch.
|
||||
|
||||
The script only holds text and times. :mod:`meshterm.demo.history` turns each line into
|
||||
the stored message and into the packets that our node heard when the line went over the
|
||||
air.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .world import SELF
|
||||
|
||||
#: The local time of day that "now" stands for on the virtual clock (hours): 11:42.
|
||||
NOW_LOCAL = 11 + 42 / 60
|
||||
|
||||
|
||||
def at(days_ago: int, clock: str) -> float:
|
||||
"""The age (minutes before now) of a moment on the virtual clock.
|
||||
|
||||
Args:
|
||||
days_ago: 0 for today, 1 for yesterday, and so on.
|
||||
clock: The local time of day, as ``"HH:MM"``.
|
||||
|
||||
Returns:
|
||||
The minutes from that moment to now. A time later than :data:`NOW_LOCAL` on day
|
||||
0 is a bug in the script, because it is in the future.
|
||||
"""
|
||||
hours, minutes = (int(part) for part in clock.split(":"))
|
||||
age = days_ago * 1440.0 + NOW_LOCAL * 60.0 - (hours * 60.0 + minutes)
|
||||
if age < 0:
|
||||
raise ValueError(f"{clock} today is after now on the virtual clock")
|
||||
return age
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Line:
|
||||
"""One line of a conversation.
|
||||
|
||||
Attributes:
|
||||
age_min: How many minutes before now the line was sent.
|
||||
sender: The name of the node that sent it: a node of the world, :data:`ME` for our
|
||||
node, or, in a room, the room itself (a notice of its admin) or an 8-hex key
|
||||
prefix (an author that no contact names).
|
||||
text: The body.
|
||||
acked: For our own direct message or room post: whether the ack came back.
|
||||
"""
|
||||
|
||||
age_min: float
|
||||
sender: str
|
||||
text: str
|
||||
acked: bool = True
|
||||
|
||||
|
||||
#: The sender name of our own lines.
|
||||
ME = SELF.name
|
||||
|
||||
#: The channel conversations, by channel name, oldest line first.
|
||||
CHANNEL_LINES: dict[str, tuple[Line, ...]] = {
|
||||
"Public": (
|
||||
Line(
|
||||
at(6, "20:12"),
|
||||
"Félix",
|
||||
"New repeater up in Longueuil, on the roof of my building. Can anyone hear it?",
|
||||
),
|
||||
Line(at(6, "20:15"), "Steve K.", "Loud and clear from Old Longueuil, +9 dB"),
|
||||
Line(
|
||||
at(6, "20:21"),
|
||||
"Ana Brossard",
|
||||
"Heard it from Brossard too, two hops through Mont-Royal",
|
||||
),
|
||||
Line(at(6, "20:24"), "Félix", "Thanks! I'll leave it in test mode this week"),
|
||||
Line(at(5, "21:40"), "Kim-NDG", "Anyone else getting duplicates through Oratoire tonight?"),
|
||||
Line(
|
||||
at(5, "21:46"),
|
||||
"Jean-Seb",
|
||||
"That's expected, Oratoire and Mont-Royal hear each other really well. Nothing broke.",
|
||||
),
|
||||
Line(at(5, "21:48"), "Kim-NDG", "ok good, I thought my T-Deck was haunted 👻"),
|
||||
Line(
|
||||
at(4, "07:55"),
|
||||
"Sam Verdun",
|
||||
"Verdun-Quai is offline, flat battery. I'll go up on the roof tomorrow morning",
|
||||
),
|
||||
Line(at(3, "08:31"), "Sam Verdun", "Verdun-Quai is back with a new battery 🔋"),
|
||||
Line(at(3, "08:40"), ME, "Thanks Sam! Getting it through Mont-Royal at +3 dB"),
|
||||
Line(
|
||||
at(2, "21:05"),
|
||||
"Dave-Lachine",
|
||||
"Lachine path is solid tonight: Lachine → Mont-Royal → Stade, worst link -1.5 dB",
|
||||
),
|
||||
Line(
|
||||
at(2, "21:19"),
|
||||
"Nico R.",
|
||||
"Biking up Berri with a node in my bag, pinging every 2 min. Zero drops so far 🚲",
|
||||
),
|
||||
Line(
|
||||
at(1, "12:10"),
|
||||
"Chloé L.",
|
||||
"The leaves are peaking on the mountain, great day for a walk",
|
||||
),
|
||||
Line(
|
||||
at(1, "12:25"),
|
||||
"Jean-Seb",
|
||||
"And the repeater at the chalet hears everyone, even through the trees 😄",
|
||||
),
|
||||
Line(at(0, "07:48"), "Noor", "First test from Côte-des-Neiges, can anyone hear me?"),
|
||||
Line(at(0, "07:52"), "Marie-Ève", "@[Noor] got you through Mont-Royal, +6 dB. Welcome!"),
|
||||
Line(
|
||||
at(0, "10:05"), "Mathilde", "Hi everyone! First time on MeshCore, I'm in Saint-Henri 👋"
|
||||
),
|
||||
Line(
|
||||
at(0, "10:08"),
|
||||
"Marie-Ève",
|
||||
"Welcome @[Mathilde]! Which repeater are you going through?",
|
||||
),
|
||||
Line(at(0, "10:12"), "Mathilde", "Mont-Royal I think, or Oratoire. How do I check?"),
|
||||
Line(
|
||||
at(0, "10:15"),
|
||||
ME,
|
||||
"Your advert reaches me through Mont-Royal, then Rosemont. Trace path shows it all",
|
||||
),
|
||||
Line(
|
||||
at(0, "10:40"),
|
||||
"Jean-Seb",
|
||||
"Reminder: mesh meetup Saturday at 2 pm, Jean-Talon market ☕",
|
||||
),
|
||||
Line(
|
||||
at(0, "11:01"),
|
||||
"Noor",
|
||||
"@[Alex Base] do you still have the coverage map? We'd love to see it Saturday",
|
||||
),
|
||||
Line(
|
||||
at(0, "11:04"),
|
||||
ME,
|
||||
"Yes! https://www.openstreetmap.org/#map=11/45.5200/-73.6400 I'll bring a printout",
|
||||
),
|
||||
Line(at(0, "11:30"), "Steve K.", "I'll bring a spare antenna for anyone who needs one"),
|
||||
Line(
|
||||
at(0, "11:33"),
|
||||
"Gabriel",
|
||||
"Hello from Beloeil! My messages get to Montréal through Mont-St-Hilaire",
|
||||
),
|
||||
Line(at(0, "11:39"), "Félix", "Longueuil ↔ Stade at +6 dB since the new antenna, finally!"),
|
||||
),
|
||||
"#montreal": (
|
||||
Line(
|
||||
at(6, "18:40"),
|
||||
"Raphaël",
|
||||
"Pont Viau is closed tonight for roadwork, take Papineau instead",
|
||||
),
|
||||
Line(
|
||||
at(4, "16:10"),
|
||||
"Olivier.T",
|
||||
"Anyone know if the Jean-Talon market is open late on Thursday?",
|
||||
),
|
||||
Line(at(4, "16:21"), "Marie-Ève", "Until 6 I think, and 8 on Fridays"),
|
||||
Line(at(3, "18:05"), "Olivier.T", "Bike stations are all full downtown, good luck tonight"),
|
||||
Line(
|
||||
at(3, "20:05"),
|
||||
"Jean-Seb",
|
||||
"Power outage in Ahuntsic. The nodes on batteries are holding up fine 💪",
|
||||
),
|
||||
Line(at(3, "20:13"), "Félix", "Same in Hochelaga, but Stade-Olympique didn't even notice"),
|
||||
Line(
|
||||
at(1, "09:15"),
|
||||
"Chloé L.",
|
||||
"Snow in the forecast next week. Time to bring in the balcony antennas ⛄",
|
||||
),
|
||||
Line(
|
||||
at(0, "07:40"),
|
||||
ME,
|
||||
"Anyone seen the crane on Saint-Denis? It's blocking my link to Stade 😑",
|
||||
),
|
||||
Line(at(0, "07:48"), "Félix", "Ha, the crane has a better SNR than the rest of us"),
|
||||
Line(
|
||||
at(0, "08:10"),
|
||||
"Raphaël",
|
||||
"Orange line is running slow between Henri-Bourassa and Montmorency",
|
||||
),
|
||||
Line(
|
||||
at(0, "11:20"), "Kim-NDG", "Anyone up for lunch near the Atwater market? Around 12:30"
|
||||
),
|
||||
Line(at(0, "11:24"), "Steve K.", "Can't today, working from home"),
|
||||
Line(at(0, "11:27"), "Sam Verdun", "I'm in! Meet at the main entrance?"),
|
||||
),
|
||||
"#cycling": (
|
||||
Line(
|
||||
at(5, "10:20"),
|
||||
"Nico R.",
|
||||
"Lachine Canal ride on Sunday at 9, leaving from the Atwater market",
|
||||
),
|
||||
Line(
|
||||
at(5, "10:31"), "Dave-Lachine", "Count me in. I'll bring the T-Deck for a coverage test"
|
||||
),
|
||||
Line(at(5, "10:52"), "Sam Verdun", "I'll be there with the cargo bike 🚲"),
|
||||
Line(
|
||||
at(3, "13:30"),
|
||||
"Nico R.",
|
||||
"Ride results: 11 repeaters heard between Atwater and Lachine",
|
||||
),
|
||||
Line(
|
||||
at(3, "13:42"), "Dave-Lachine", "And the Lachine repeater heard all of us. Great test!"
|
||||
),
|
||||
Line(
|
||||
at(1, "16:20"),
|
||||
"Sam Verdun",
|
||||
"The Saint-Denis bike lane is still under construction near Jean-Talon",
|
||||
),
|
||||
Line(at(0, "09:30"), "Nico R.", "Anyone up for a ride up the mountain tomorrow morning?"),
|
||||
),
|
||||
"Family": (
|
||||
Line(at(4, "19:02"), "Léa", "Dinner at Grandma's on Sunday, 5 pm"),
|
||||
Line(at(4, "19:10"), ME, "Perfect, I'll bring dessert"),
|
||||
Line(at(4, "19:12"), "Léa", "Don't forget the folding chairs!"),
|
||||
Line(at(1, "17:41"), "Léa", "Where are you? Dinner's ready 😄"),
|
||||
Line(at(1, "17:44"), ME, "Almost there, just around the corner"),
|
||||
Line(at(0, "08:15"), "Léa", "It's 2 degrees this morning, dress warm!"),
|
||||
Line(at(0, "08:18"), ME, "Too late, I'm already outside 🥶"),
|
||||
),
|
||||
}
|
||||
|
||||
#: The direct conversations, by the name of the other node, oldest line first.
|
||||
DIRECT_LINES: dict[str, tuple[Line, ...]] = {
|
||||
"Jean-Seb": (
|
||||
Line(
|
||||
at(7, "09:12"),
|
||||
"Jean-Seb",
|
||||
"Can you reboot Stade-Olympique? It stopped answering logins",
|
||||
),
|
||||
Line(at(7, "09:31"), ME, "Done, it's back"),
|
||||
),
|
||||
"Nico R.": (
|
||||
Line(at(2, "17:02"), "Nico R.", "Is the link to Mont-St-Hilaire holding up?"),
|
||||
Line(at(2, "17:06"), ME, "Yes, -5 dB from Mont-Royal. Fragile, but it works"),
|
||||
),
|
||||
"Dave-Lachine": (
|
||||
Line(
|
||||
at(2, "20:20"),
|
||||
ME,
|
||||
"Can you trace me from Lachine tonight? Testing the path through Oratoire",
|
||||
),
|
||||
Line(at(2, "20:51"), "Dave-Lachine", "Done: 3 hops, worst link -2.5 dB at Oratoire"),
|
||||
Line(at(2, "20:55"), ME, "Thanks Dave!", acked=False),
|
||||
),
|
||||
"Marie-Ève": (
|
||||
Line(at(1, "18:20"), "Marie-Ève", "Are you coming to the meetup on Saturday?"),
|
||||
Line(at(1, "18:31"), ME, "Yes! I'm bringing two spare nodes for the newcomers"),
|
||||
Line(at(1, "18:40"), "Marie-Ève", "Great. I'll book a table at the café in the market"),
|
||||
Line(at(1, "18:44"), ME, "Perfect, see you Saturday 👍"),
|
||||
Line(at(0, "10:50"), "Marie-Ève", "Mathilde is new, could you help her set up her Heltec?"),
|
||||
Line(at(0, "10:53"), ME, "Of course, I'll message her"),
|
||||
),
|
||||
"Mathilde": (
|
||||
Line(
|
||||
at(0, "10:55"),
|
||||
ME,
|
||||
"Hi! Marie-Ève said you're just getting started. Need a hand with the Heltec?",
|
||||
),
|
||||
Line(at(0, "11:10"), "Mathilde", "Yes please! I'm not sure which frequency to use"),
|
||||
Line(at(0, "11:12"), ME, "910.525 MHz, BW 62.5, SF7. That's what the Montréal mesh uses"),
|
||||
Line(at(0, "11:20"), "Mathilde", "That worked! I can see three repeaters now 🎉"),
|
||||
),
|
||||
}
|
||||
|
||||
#: The board of each room, oldest post first. The room name as the sender is a notice of
|
||||
#: the room admin. ``5e1d9c07`` is a member that no contact of our node names.
|
||||
ROOM_LINES: dict[str, tuple[Line, ...]] = {
|
||||
"MTL BBS": (
|
||||
Line(
|
||||
at(2, "19:30"),
|
||||
"Jean-Seb",
|
||||
"Mesh meetup Saturday at 2 pm, at the café in the Jean-Talon market. Bring your nodes!",
|
||||
),
|
||||
Line(at(2, "19:50"), "Marie-Ève", "I'll be there, with a few antennas to give away"),
|
||||
Line(
|
||||
at(1, "12:50"),
|
||||
"Noor",
|
||||
"Lost: a Heltec V3 in a yellow case, somewhere in La Fontaine Park. Reward offered",
|
||||
),
|
||||
Line(at(1, "13:00"), "Steve K.", "I'll keep an eye out, I walk through there every day"),
|
||||
Line(
|
||||
at(1, "22:50"),
|
||||
"MTL BBS",
|
||||
"Mont-Royal maintenance on Tuesday from 10 to 11 pm. Expect detours.",
|
||||
),
|
||||
Line(at(0, "07:50"), "5e1d9c07", "Does anyone have an SMA adapter I could borrow?"),
|
||||
Line(at(0, "08:40"), "Félix", "I do, come by Hochelaga anytime"),
|
||||
Line(at(0, "08:50"), ME, "I'm bringing the antenna analyzer on Saturday"),
|
||||
Line(
|
||||
at(0, "10:30"), "Kim-NDG", "Slides from the last meetup are on the board, ask Jean-Seb"
|
||||
),
|
||||
Line(at(0, "11:25"), "Jean-Seb", "Six spots left for Saturday"),
|
||||
),
|
||||
}
|
||||
|
||||
#: The lines that arrive while the demo runs, as ``(seconds after the start, line)``. The
|
||||
#: first one is a direct message, so the header shows one unread message soon after the
|
||||
#: start. The age of each line is unused.
|
||||
LIVE_DIRECT: tuple[tuple[float, Line], ...] = (
|
||||
(6.0, Line(0.0, "Nico R.", "Did you see the record? 34 km to Mont-St-Hilaire 🔥")),
|
||||
)
|
||||
LIVE_CHANNEL: tuple[tuple[float, str, Line], ...] = (
|
||||
(
|
||||
45.0,
|
||||
"Public",
|
||||
Line(0.0, "Chloé L.", "Anyone heading up the mountain tomorrow for a range test?"),
|
||||
),
|
||||
(160.0, "#montreal", Line(0.0, "Kim-NDG", "The 80 bus is late again on Parc 🙄")),
|
||||
)
|
||||
|
||||
#: The answers of the people of the demo world to a channel message of our node that
|
||||
#: mentions them, by node name. The node answers on the same channel, a few seconds later
|
||||
#: (:data:`REPLY_DELAY_S`), and its answer mentions our node. A node that is not in this
|
||||
#: table does not answer. The demo video asks if Dave comes to the meetup
|
||||
#: (``scripts/video.py``).
|
||||
REPLIES: dict[str, str] = {
|
||||
"Dave-Lachine": f"@[{ME}] Yes! I'll be there at 2, with the T-Deck",
|
||||
}
|
||||
|
||||
#: The seconds from a message of our node to the answer of a node that it mentions (refer
|
||||
#: to :data:`REPLIES`).
|
||||
REPLY_DELAY_S = 4.0
|
||||
|
||||
#: The traces that our node ran in the past days, as ``(age in minutes, target, path)``.
|
||||
#: The target is a repeater name for a target trace, or ``None`` for a composed path. A
|
||||
#: path names the relays, without our node at the two ends. The history runs each one in
|
||||
#: the link model, tries again a few times when the trace does not come back (as a person
|
||||
#: does), and stores each try.
|
||||
TRACES: tuple[tuple[float, str | None, tuple[str, ...]], ...] = (
|
||||
(
|
||||
at(6, "21:30"),
|
||||
"Longueuil",
|
||||
("Rosemont", "Stade-Olympique", "Longueuil", "Stade-Olympique", "Rosemont"),
|
||||
),
|
||||
(
|
||||
at(5, "20:10"),
|
||||
"Pointe-Claire",
|
||||
("Villeray", "Mont-Royal", "Pointe-Claire", "Mont-Royal", "Villeray"),
|
||||
),
|
||||
(
|
||||
at(5, "20:20"),
|
||||
None,
|
||||
(
|
||||
"Villeray",
|
||||
"Mont-Royal",
|
||||
"Pointe-Claire",
|
||||
"Île-Bizard",
|
||||
"Pointe-Claire",
|
||||
"Mont-Royal",
|
||||
"Villeray",
|
||||
),
|
||||
),
|
||||
(
|
||||
at(4, "21:00"),
|
||||
"Laval-Chomedey",
|
||||
("Villeray", "Ahuntsic", "Laval-Chomedey", "Ahuntsic", "Villeray"),
|
||||
),
|
||||
(at(3, "22:15"), None, ("Rosemont", "Mont-Royal", "Mont-St-Hilaire", "Mont-Royal", "Rosemont")),
|
||||
(at(2, "20:40"), None, ("Villeray", "Oratoire", "Lachine", "Oratoire", "Villeray")),
|
||||
(
|
||||
at(2, "21:50"),
|
||||
None,
|
||||
(
|
||||
"Rosemont",
|
||||
"Stade-Olympique",
|
||||
"Mont-St-Bruno",
|
||||
"Mont-St-Hilaire",
|
||||
"Mont-St-Bruno",
|
||||
"Stade-Olympique",
|
||||
"Rosemont",
|
||||
),
|
||||
),
|
||||
(
|
||||
at(1, "20:30"),
|
||||
None,
|
||||
("Villeray", "Mont-Royal", "Laval-Chomedey", "Ahuntsic", "Stade-Olympique", "Rosemont"),
|
||||
),
|
||||
(
|
||||
at(1, "21:10"),
|
||||
None,
|
||||
(
|
||||
"Rosemont",
|
||||
"Centre-Ville",
|
||||
"Verdun-Quai",
|
||||
"Brossard",
|
||||
"Mont-St-Bruno",
|
||||
"Mont-St-Hilaire",
|
||||
"Mont-St-Bruno",
|
||||
"Longueuil",
|
||||
"Stade-Olympique",
|
||||
"Ahuntsic",
|
||||
"Laval-Chomedey",
|
||||
"Mont-Royal",
|
||||
"Oratoire",
|
||||
"Lachine",
|
||||
"Pointe-Claire",
|
||||
"Mont-Royal",
|
||||
"Villeray",
|
||||
),
|
||||
),
|
||||
(
|
||||
at(1, "21:40"),
|
||||
None,
|
||||
(
|
||||
"Villeray",
|
||||
"Oratoire",
|
||||
"Lachine",
|
||||
"Pointe-Claire",
|
||||
"Mont-Royal",
|
||||
"Brossard",
|
||||
"Mont-St-Bruno",
|
||||
"Mont-St-Hilaire",
|
||||
"Stade-Olympique",
|
||||
"Rosemont",
|
||||
),
|
||||
),
|
||||
(at(0, "09:05"), "Brossard", ("Rosemont", "Mont-Royal", "Brossard", "Mont-Royal", "Rosemont")),
|
||||
(at(0, "10:20"), None, ("Rosemont", "Longueuil", "Stade-Olympique", "Villeray")),
|
||||
)
|
||||
|
||||
#: The Watchtower: the nodes that our node watches, and the alerts that it raised and the
|
||||
#: user did not acknowledge yet, as ``(age in minutes, kind, node name, message)``. The
|
||||
#: messages are the words of :mod:`meshterm.services.watchtower`.
|
||||
WATCHED: tuple[str, ...] = ("Mont-Royal", "Mont-St-Hilaire", "Verdun-Quai", "Longueuil")
|
||||
ALERTS: tuple[tuple[float, str, str, str], ...] = (
|
||||
(at(4, "07:40"), "silence", "Verdun-Quai", "nothing heard for 6 h"),
|
||||
(
|
||||
at(3, "08:31"),
|
||||
"recovered",
|
||||
"Verdun-Quai",
|
||||
"back on the air — heard again after a silence alarm",
|
||||
),
|
||||
(at(0, "09:55"), "new-node", "Mathilde", "first appearance — never heard on this mesh before"),
|
||||
)
|
||||
@@ -0,0 +1,563 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The packets of the demo world: what our node hears from the mesh, packet by packet.
|
||||
|
||||
Two parts of the demo use this module. The history (:mod:`meshterm.demo.history`) asks it
|
||||
for the packets of the past days, and stores them. The simulated device
|
||||
(:mod:`meshterm.demo.device`) asks it for the packets of the next hour, and sends each one
|
||||
as a live event at its time. Thus the live feed continues the stored history without a
|
||||
seam.
|
||||
|
||||
Each transmission on the mesh goes through the link model of :mod:`meshterm.demo.world`.
|
||||
A flood reaches our node on each path that the race of the relays gives
|
||||
(:func:`~meshterm.demo.world.flood`), so one transmission is often heard several times,
|
||||
on several paths, as on a real mesh. A direct packet follows the route of its sender, and
|
||||
our node hears it only where a relay of that route is in its range. Each packet that our
|
||||
node hears becomes one ``packet`` observation, with the fields that the meshcore library
|
||||
gives for an overheard packet (refer to
|
||||
:func:`~meshterm.core.connection.packet_observation_from_event`). An advert also becomes
|
||||
one ``advert`` observation, as the companion reports it.
|
||||
|
||||
The rate of the traffic follows the hour of the virtual clock (:data:`RHYTHM`): the mesh is
|
||||
quiet at night and busy in the evening.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..core.channels import channel_hash
|
||||
from ..core.models import (
|
||||
NODE_TYPE_REPEATER,
|
||||
NODE_TYPE_ROOM,
|
||||
NODE_TYPE_SENSOR,
|
||||
Message,
|
||||
Observation,
|
||||
)
|
||||
from ..core.regions import region_key, scope_body, transport_code
|
||||
from .script import NOW_LOCAL
|
||||
from .world import (
|
||||
FOREIGN_CHANNELS,
|
||||
NODES,
|
||||
REGION_TABLES,
|
||||
REPEATERS,
|
||||
SELF,
|
||||
Copy,
|
||||
Node,
|
||||
best_route,
|
||||
flood,
|
||||
link_snr,
|
||||
loss_chance,
|
||||
path_width,
|
||||
reading,
|
||||
)
|
||||
|
||||
#: The relative rate of the traffic for each local hour of the day, from midnight.
|
||||
RHYTHM: tuple[float, ...] = (
|
||||
0.35, 0.2, 0.15, 0.12, 0.12, 0.18, 0.35, 0.6, 0.75, 0.7, 0.65, 0.7,
|
||||
0.8, 0.7, 0.65, 0.7, 0.8, 0.95, 1.0, 1.0, 1.0, 0.9, 0.7, 0.5,
|
||||
) # fmt: skip
|
||||
|
||||
#: The number of transmissions that start on the mesh in each minute at the busiest hour.
|
||||
#: The adverts and the lines of the script come on top of this rate.
|
||||
PEAK_PER_MIN = 1.1
|
||||
|
||||
#: The payload classes of the background traffic, with their relative weights. A flood
|
||||
#: is heard several times. Thus the counts of the stored packets have other proportions:
|
||||
#: the channel texts and the requests lead, as on the real mesh of Montréal.
|
||||
_KINDS: tuple[tuple[str, float], ...] = (
|
||||
("GRP_TXT", 18.0),
|
||||
("REQ", 16.0),
|
||||
("RESPONSE", 10.0),
|
||||
("TEXT_MSG", 9.0),
|
||||
("ACK", 5.0),
|
||||
("ANON_REQ", 4.0),
|
||||
("PATH", 3.0),
|
||||
("TRACE", 2.0),
|
||||
("GRP_DATA", 0.3),
|
||||
("CONTROL", 0.3),
|
||||
("MULTIPART", 0.05),
|
||||
)
|
||||
|
||||
#: The 4-bit payload type of each class in the packet header (MeshCore's ``Packet.h``). The
|
||||
#: transport code of a scoped flood comes from it.
|
||||
_PAYLOAD_TYPE = {"GRP_TXT": 5, "ADVERT": 4}
|
||||
|
||||
#: How often each type of node sends a flood advert (hours), and how often a repeater
|
||||
#: sends a local advert that only its neighbours hear (hours).
|
||||
_ADVERT_EVERY_H = {"repeater": 12.0, "room": 12.0, "sensor": 6.0, "companion": 9.0}
|
||||
_LOCAL_ADVERT_EVERY_H = 4.0
|
||||
|
||||
|
||||
def rhythm(local_hour: float) -> float:
|
||||
"""The relative rate of the traffic at a local hour of the virtual clock."""
|
||||
return RHYTHM[int(local_hour) % 24]
|
||||
|
||||
|
||||
def local_hour(offset_s: float) -> float:
|
||||
"""The local hour on the virtual clock, ``offset_s`` seconds after now (negative: before)."""
|
||||
return (NOW_LOCAL + offset_s / 3600.0) % 24.0
|
||||
|
||||
|
||||
def _rssi(snr: float, rng: random.Random) -> float:
|
||||
"""A plausible RSSI (dBm) for a reception with this SNR, over a noise floor near -110 dBm."""
|
||||
return float(round(-108.0 + 1.1 * snr + rng.gauss(0.0, 1.5)))
|
||||
|
||||
|
||||
def active(node: Node, offset_s: float) -> bool:
|
||||
"""Whether ``node`` transmits at this moment (``offset_s`` seconds after now).
|
||||
|
||||
A node transmits after our node first heard it, and before it went quiet. A node that
|
||||
is active now stays active after now, so the live traffic continues the history. A
|
||||
contact that our node never heard never transmits.
|
||||
"""
|
||||
if node.quiet_hours is None:
|
||||
return False
|
||||
first = -node.first_heard_days * 86400.0
|
||||
if node.quiet_hours <= 0:
|
||||
return first <= offset_s
|
||||
return first <= offset_s <= -node.quiet_hours * 3600.0
|
||||
|
||||
|
||||
def _kind_of(node: Node) -> str:
|
||||
"""The advert schedule that a node follows (refer to :data:`_ADVERT_EVERY_H`)."""
|
||||
return {
|
||||
NODE_TYPE_REPEATER: "repeater",
|
||||
NODE_TYPE_ROOM: "room",
|
||||
NODE_TYPE_SENSOR: "sensor",
|
||||
}.get(node.node_type, "companion")
|
||||
|
||||
|
||||
def _phase(node: Node, salt: str) -> float:
|
||||
"""A fixed fraction in ``[0, 1)`` for a node: where its periodic schedule starts."""
|
||||
return hashlib.sha256(f"{salt}/{node.name}".encode()).digest()[0] / 256.0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- packets
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Heard:
|
||||
"""One event that our node hears: an observation or a received message.
|
||||
|
||||
Attributes:
|
||||
offset_s: When it is heard, in seconds after now (negative: in the past).
|
||||
observation: The observation, or ``None``.
|
||||
message: The received message, or ``None``.
|
||||
"""
|
||||
|
||||
offset_s: float
|
||||
observation: Observation | None = None
|
||||
message: Message | None = None
|
||||
|
||||
|
||||
def _mac(rng: random.Random) -> str:
|
||||
"""A random 2-byte MAC (hex): the tag of an encrypted body that our node cannot read."""
|
||||
return f"{rng.getrandbits(16):04x}"
|
||||
|
||||
|
||||
def grp_txt_body(secret: bytes, text: str, sent_at: datetime, attempt: int = 0) -> dict:
|
||||
"""The fields of a GRP_TXT packet for ``text`` on the channel with ``secret``.
|
||||
|
||||
The body is what the firmware sends: the timestamp of the sender and the attempt byte,
|
||||
then the text, padded to whole AES blocks and encrypted in ECB mode with the key of the
|
||||
channel. The MAC is the first two bytes of the HMAC-SHA256 of the ciphertext. Thus
|
||||
MeshTerm decrypts a stored copy exactly as it decrypts a real one (refer to
|
||||
:func:`~meshterm.core.channels.decrypt_channel_text`).
|
||||
|
||||
Args:
|
||||
secret: The 16-byte key of the channel.
|
||||
text: The text of the message, with the ``Name: `` prefix of the sender.
|
||||
sent_at: The time on the clock of the sender.
|
||||
attempt: The resend counter of the sender.
|
||||
|
||||
Returns:
|
||||
The ``payload_typename``, ``chan_hash``, ``cipher_mac``, and ``crypted`` fields.
|
||||
"""
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Hash import HMAC, SHA256
|
||||
|
||||
plain = int(sent_at.timestamp()).to_bytes(4, "little") + bytes([attempt & 0x03])
|
||||
plain += text.encode("utf-8")
|
||||
plain += b"\x00" * (-len(plain) % 16)
|
||||
crypted = AES.new(secret, AES.MODE_ECB).encrypt(plain)
|
||||
mac = HMAC.new(secret, digestmod=SHA256)
|
||||
mac.update(crypted)
|
||||
return {
|
||||
"payload_typename": "GRP_TXT",
|
||||
"chan_hash": channel_hash(secret),
|
||||
"cipher_mac": mac.digest()[:2].hex(),
|
||||
"crypted": crypted.hex(),
|
||||
}
|
||||
|
||||
|
||||
def scoped(body: dict, region: str) -> dict:
|
||||
"""``body`` as a scoped flood: the route type, the transport code, and the HMAC input.
|
||||
|
||||
Args:
|
||||
body: The fields of a GRP_TXT packet (refer to :func:`grp_txt_body`).
|
||||
region: The bare region name.
|
||||
|
||||
Returns:
|
||||
A copy of ``body`` with ``route_typename``, ``transport_code``, and ``scope_body``.
|
||||
"""
|
||||
payload = (
|
||||
bytes.fromhex(body["chan_hash"])
|
||||
+ bytes.fromhex(body["cipher_mac"])
|
||||
+ bytes.fromhex(body["crypted"])
|
||||
)
|
||||
hmac_input = scope_body(_PAYLOAD_TYPE["GRP_TXT"], payload)
|
||||
code = transport_code(region_key(region), hmac_input)
|
||||
return {
|
||||
**body,
|
||||
"route_typename": "TC_FLOOD",
|
||||
"transport_code": code.to_bytes(2, "little").hex() + "0000",
|
||||
"scope_body": hmac_input.hex(),
|
||||
}
|
||||
|
||||
|
||||
def _relays_region(relay: Node, region: str | None) -> bool:
|
||||
"""Whether ``relay`` relays a flood in ``region`` (``None``: an unscoped flood)."""
|
||||
if region is None:
|
||||
return True
|
||||
return any(name == region and allowed for name, _, allowed in REGION_TABLES.get(relay.name, ()))
|
||||
|
||||
|
||||
def heard_flood(
|
||||
origin: Node,
|
||||
start_s: float,
|
||||
body: dict,
|
||||
rng: random.Random,
|
||||
*,
|
||||
region: str | None = None,
|
||||
advert: bool = False,
|
||||
) -> list[Heard]:
|
||||
"""The packets that our node hears from one flood.
|
||||
|
||||
Args:
|
||||
origin: The node that transmits the flood.
|
||||
start_s: When it transmits (seconds after now).
|
||||
body: The payload fields of the packet.
|
||||
rng: The source of the delays, the losses, and the noise.
|
||||
region: The scope of the flood, or ``None`` for an unscoped flood. Only the
|
||||
repeaters that carry the region relay it.
|
||||
advert: Whether the packet is an advert. Then the companion also reports one
|
||||
``advert`` observation, with the SNR of the first copy.
|
||||
|
||||
Returns:
|
||||
One :class:`Heard` for each copy, and one more for the advert report.
|
||||
"""
|
||||
copies = [
|
||||
copy
|
||||
for copy in flood(origin, rng)
|
||||
if all(_relays_region(relay, region) for relay in copy.relays)
|
||||
]
|
||||
route = "TC_FLOOD" if region else "FLOOD"
|
||||
width = path_width(origin)
|
||||
out = [
|
||||
_packet(
|
||||
origin if advert else None,
|
||||
start_s,
|
||||
copy,
|
||||
{"route_typename": route, **body},
|
||||
rng,
|
||||
width=width,
|
||||
)
|
||||
for copy in copies
|
||||
]
|
||||
if advert and copies:
|
||||
first = copies[0]
|
||||
out.append(Heard(start_s + first.delay_s, observation=_advert_report(origin, first, rng)))
|
||||
return out
|
||||
|
||||
|
||||
def _packet(
|
||||
origin: Node | None,
|
||||
start_s: float,
|
||||
copy: Copy,
|
||||
body: dict,
|
||||
rng: random.Random,
|
||||
*,
|
||||
width: int = 1,
|
||||
) -> Heard:
|
||||
"""One ``packet`` observation for one copy of a transmission, with ``width``-byte hashes."""
|
||||
path = ",".join(relay.public_key[: 2 * width] for relay in copy.relays)
|
||||
raw = {**body, "path_len": len(copy.relays), "path_hash_size": width}
|
||||
share = origin is not None and origin.shares_location
|
||||
return Heard(
|
||||
start_s + copy.delay_s,
|
||||
observation=Observation(
|
||||
node=origin.node_id if origin is not None else None,
|
||||
public_key=origin.public_key if origin is not None else None,
|
||||
name=origin.name if origin is not None else None,
|
||||
kind="packet",
|
||||
node_type=origin.node_type if origin is not None else None,
|
||||
snr=copy.snr,
|
||||
rssi=_rssi(copy.snr, rng),
|
||||
lat=origin.lat if share else None,
|
||||
lon=origin.lon if share else None,
|
||||
path=path,
|
||||
raw=raw,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _advert_report(origin: Node, copy: Copy, rng: random.Random) -> Observation:
|
||||
"""The ``advert`` observation that the companion reports for an advert that it heard."""
|
||||
return Observation(
|
||||
node=origin.node_id,
|
||||
public_key=origin.public_key,
|
||||
name=origin.name,
|
||||
kind="advert",
|
||||
node_type=origin.node_type,
|
||||
snr=copy.snr,
|
||||
rssi=_rssi(copy.snr, rng),
|
||||
lat=origin.lat if origin.shares_location else None,
|
||||
lon=origin.lon if origin.shares_location else None,
|
||||
)
|
||||
|
||||
|
||||
def advert_body(origin: Node) -> dict:
|
||||
"""The decoded fields of an advert packet from ``origin``."""
|
||||
body: dict = {
|
||||
"payload_typename": "ADVERT",
|
||||
"adv_key": origin.public_key,
|
||||
"adv_name": origin.name,
|
||||
"adv_type": origin.node_type,
|
||||
}
|
||||
if origin.shares_location:
|
||||
body.update(adv_lat=origin.lat, adv_lon=origin.lon)
|
||||
return body
|
||||
|
||||
|
||||
def heard_direct(
|
||||
route: tuple[Node, ...], src: Node, start_s: float, body: dict, rng: random.Random
|
||||
) -> list[Heard]:
|
||||
"""The packets that our node hears from one direct packet along ``route``.
|
||||
|
||||
The sender writes the route into the packet, and each relay removes itself before it
|
||||
transmits. Thus our node hears the sender with the full route, and each relay with
|
||||
the part of the route that is still in front of the packet.
|
||||
|
||||
Args:
|
||||
route: The relays between the sender and the recipient.
|
||||
src: The sender.
|
||||
start_s: When the sender transmits (seconds after now).
|
||||
body: The payload fields of the packet.
|
||||
rng: The source of the delays, the losses, and the noise.
|
||||
|
||||
Returns:
|
||||
One :class:`Heard` for each transmission that our node hears.
|
||||
"""
|
||||
out: list[Heard] = []
|
||||
elapsed = 0.0
|
||||
for index, transmitter in enumerate((src, *route)):
|
||||
elapsed += rng.uniform(0.2, 0.9)
|
||||
if transmitter.name == SELF.name:
|
||||
continue
|
||||
snr = link_snr(transmitter, SELF)
|
||||
if snr is None or rng.random() < loss_chance(snr):
|
||||
continue
|
||||
remaining = route[index:]
|
||||
copy = Copy(remaining, reading(snr, rng), elapsed)
|
||||
direct = {"route_typename": "DIRECT", **body}
|
||||
out.append(_packet(None, start_s, copy, direct, rng, width=path_width(src)))
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- traffic
|
||||
|
||||
|
||||
def _pick(nodes: list[Node], rng: random.Random) -> Node:
|
||||
"""A node, with the weight of its :attr:`~meshterm.demo.world.Node.talk`."""
|
||||
return rng.choices(nodes, weights=[node.talk for node in nodes])[0]
|
||||
|
||||
|
||||
def adverts(start_s: float, end_s: float, rng: random.Random) -> Iterator[Heard]:
|
||||
"""The adverts that our node hears between two moments (seconds after now).
|
||||
|
||||
Each node sends a flood advert on a fixed period, with a fixed phase and some jitter.
|
||||
A repeater also sends local adverts, which only its neighbours hear. More than eight
|
||||
days before now, the history keeps only one advert of each node each day: enough for
|
||||
the dates of first and last reception, and not a full week of packets for each day of
|
||||
the year.
|
||||
"""
|
||||
dense_from = -8 * 86400.0
|
||||
for origin in (*NODES, SELF):
|
||||
kind = _kind_of(origin)
|
||||
period = _ADVERT_EVERY_H[kind] * 3600.0
|
||||
moment = start_s - (start_s % period) + _phase(origin, "advert") * period
|
||||
while moment < end_s:
|
||||
sparse = moment < dense_from
|
||||
when = moment + rng.uniform(-0.1, 0.1) * period
|
||||
step = 86400.0 if sparse else period
|
||||
if start_s <= when < end_s and (origin is SELF or active(origin, when)):
|
||||
if origin is SELF:
|
||||
# Our node hears its own advert only where the repeaters relay it. The
|
||||
# history keeps these packets only for the recent days.
|
||||
if not sparse:
|
||||
yield from heard_flood(origin, when, advert_body(origin), rng)
|
||||
elif sparse:
|
||||
copies = flood(origin, rng)
|
||||
if copies:
|
||||
yield Heard(
|
||||
when + copies[0].delay_s,
|
||||
observation=_advert_report(origin, copies[0], rng),
|
||||
)
|
||||
else:
|
||||
yield from heard_flood(origin, when, advert_body(origin), rng, advert=True)
|
||||
moment += step
|
||||
if kind != "repeater" or link_snr(origin, SELF) is None:
|
||||
continue
|
||||
period = _LOCAL_ADVERT_EVERY_H * 3600.0
|
||||
moment = max(start_s, dense_from)
|
||||
moment = moment - (moment % period) + _phase(origin, "local") * period
|
||||
while moment < end_s:
|
||||
if start_s <= moment and active(origin, moment):
|
||||
snr = link_snr(origin, SELF)
|
||||
if snr is not None and rng.random() >= loss_chance(snr):
|
||||
copy = Copy((), reading(snr, rng), 0.3)
|
||||
body = {"route_typename": "FLOOD", **advert_body(origin)}
|
||||
yield _packet(origin, moment, copy, body, rng)
|
||||
yield Heard(moment + 0.3, observation=_advert_report(origin, copy, rng))
|
||||
moment += period
|
||||
|
||||
|
||||
def background(start_s: float, end_s: float, rng: random.Random) -> Iterator[Heard]:
|
||||
"""The background traffic of the mesh between two moments (seconds after now).
|
||||
|
||||
The transmissions arrive as a Poisson process, with the rate of the hour
|
||||
(:func:`rhythm`). Each one has a class (:data:`_KINDS`), a sender, and a recipient
|
||||
where its class has one. Then it becomes the packets that our node hears.
|
||||
"""
|
||||
kinds = [kind for kind, _ in _KINDS]
|
||||
weights = [weight for _, weight in _KINDS]
|
||||
moment = start_s
|
||||
while True:
|
||||
rate_s = PEAK_PER_MIN * rhythm(local_hour(moment)) / 60.0
|
||||
moment += rng.expovariate(max(rate_s, 1e-6))
|
||||
if moment >= end_s:
|
||||
return
|
||||
awake = [node for node in NODES if active(node, moment)]
|
||||
people = [node for node in awake if node.node_type == SELF.node_type]
|
||||
servers = [node for node in awake if node.node_type != SELF.node_type]
|
||||
if not people or not servers:
|
||||
continue
|
||||
kind = rng.choices(kinds, weights=weights)[0]
|
||||
yield from _transmission(kind, moment, people, servers, rng)
|
||||
|
||||
|
||||
def _transmission(
|
||||
kind: str, moment: float, people: list[Node], servers: list[Node], rng: random.Random
|
||||
) -> Iterator[Heard]:
|
||||
"""The packets that our node hears from one background transmission of class ``kind``."""
|
||||
sender = _pick(people, rng)
|
||||
if kind in ("GRP_TXT", "GRP_DATA"):
|
||||
secret = rng.choice(FOREIGN_CHANNELS)
|
||||
body = {
|
||||
"payload_typename": kind,
|
||||
"chan_hash": channel_hash(secret),
|
||||
"cipher_mac": _mac(rng),
|
||||
"crypted": rng.randbytes(16 * rng.randint(2, 6)).hex(),
|
||||
}
|
||||
yield from heard_flood(sender, moment, body, rng)
|
||||
return
|
||||
if kind == "CONTROL":
|
||||
# A discovery request: one hop, heard only near its sender.
|
||||
snr = link_snr(sender, SELF)
|
||||
if snr is not None:
|
||||
copy = Copy((), reading(snr, rng), 0.2)
|
||||
yield _packet(
|
||||
None, moment, copy, {"route_typename": "DIRECT", "payload_typename": kind}, rng
|
||||
)
|
||||
return
|
||||
if kind == "MULTIPART":
|
||||
yield from heard_flood(sender, moment, {"payload_typename": kind}, rng)
|
||||
return
|
||||
if kind == "TRACE":
|
||||
yield from _trace(moment, rng)
|
||||
return
|
||||
if kind in ("REQ", "ANON_REQ"):
|
||||
target = _pick(servers, rng)
|
||||
else:
|
||||
target = _pick([node for node in people if node is not sender] or people, rng)
|
||||
if kind == "RESPONSE":
|
||||
sender, target = _pick(servers, rng), sender
|
||||
body: dict = {"payload_typename": kind, "dest_hash": target.hash, "cipher_mac": _mac(rng)}
|
||||
if kind == "ANON_REQ":
|
||||
body["src_key"] = sender.public_key
|
||||
elif kind != "ACK":
|
||||
body["src_hash"] = sender.hash
|
||||
if kind == "ACK":
|
||||
body = {"payload_typename": kind, "ack_crc": f"{rng.getrandbits(32):08x}"}
|
||||
route = best_route(sender, target)
|
||||
if route is not None and kind in ("ACK", "RESPONSE", "TEXT_MSG", "REQ") and rng.random() < 0.55:
|
||||
yield from heard_direct(route, sender, moment, body, rng)
|
||||
else:
|
||||
yield from heard_flood(sender, moment, body, rng)
|
||||
|
||||
|
||||
def _trace(moment: float, rng: random.Random) -> Iterator[Heard]:
|
||||
"""A trace that another node runs, as our node hears it pass.
|
||||
|
||||
The trace walks a few repeaters, out and back. Our node hears each relay of the walk
|
||||
that is in its range, with the SNR readings that the packet carries at that point.
|
||||
"""
|
||||
start = rng.choice(REPEATERS)
|
||||
out = [start]
|
||||
for _ in range(rng.randint(1, 3)):
|
||||
options = [r for r in REPEATERS if r not in out and link_snr(out[-1], r) is not None]
|
||||
if not options:
|
||||
break
|
||||
out.append(rng.choice(options))
|
||||
walk = [*out, *reversed(out[:-1])]
|
||||
snrs: list[float] = []
|
||||
elapsed = 0.0
|
||||
tag = f"{rng.getrandbits(32):08x}"
|
||||
for a, b in zip(walk, walk[1:], strict=False):
|
||||
snr = link_snr(a, b)
|
||||
if snr is None or rng.random() < loss_chance(snr):
|
||||
return
|
||||
snrs.append(reading(snr, rng))
|
||||
elapsed += rng.uniform(0.2, 0.6)
|
||||
heard = link_snr(b, SELF)
|
||||
if heard is not None and rng.random() >= loss_chance(heard):
|
||||
body = {
|
||||
"route_typename": "DIRECT",
|
||||
"payload_typename": "TRACE",
|
||||
"trace_tag": tag,
|
||||
"trace_snrs": list(snrs),
|
||||
}
|
||||
yield _packet(None, moment, Copy((), reading(heard, rng), elapsed), body, rng)
|
||||
|
||||
|
||||
def traffic(start_s: float, end_s: float, seed: str) -> list[Heard]:
|
||||
"""All the traffic that our node hears between two moments, in time order.
|
||||
|
||||
Args:
|
||||
start_s: The start, in seconds after now (negative: in the past).
|
||||
end_s: The end, in seconds after now.
|
||||
seed: The seed of the random source. The same seed gives the same traffic.
|
||||
|
||||
Returns:
|
||||
The adverts and the background packets, sorted by time.
|
||||
"""
|
||||
rng = random.Random(f"meshterm-demo/{seed}")
|
||||
heard = [*adverts(start_s, end_s, rng)]
|
||||
dense_from = max(start_s, -8 * 86400.0)
|
||||
if dense_from < end_s:
|
||||
heard.extend(background(dense_from, end_s, rng))
|
||||
heard = [item for item in heard if start_s <= item.offset_s < end_s]
|
||||
heard.sort(key=lambda item: item.offset_s)
|
||||
return heard
|
||||
|
||||
|
||||
def stamp(heard: list[Heard], now: datetime) -> list[Heard]:
|
||||
"""Give each observation in ``heard`` its time, from ``now`` and its offset."""
|
||||
for item in heard:
|
||||
if item.observation is not None:
|
||||
item.observation.observed_at = now + timedelta(seconds=item.offset_s)
|
||||
return heard
|
||||
@@ -0,0 +1,629 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The demo world: a synthetic MeshCore mesh over Montréal.
|
||||
|
||||
The ``--demo`` flag runs MeshTerm against this world. Two parts of the demo read it. The
|
||||
simulated device (:mod:`meshterm.demo.device`) answers as the companion of our node. The
|
||||
history (:mod:`meshterm.demo.history`) fills the database with what that companion heard
|
||||
in the days before. Thus each screen shows one consistent mesh: a node on the map is a
|
||||
node that the contacts, the chat, and the routes also know, and a route that a trace
|
||||
walks is a route that the floods also use.
|
||||
|
||||
The world is fixed data and pure functions. Nothing in this module reads the clock, and
|
||||
each random value comes from a generator with a fixed seed. Thus the world is the same at
|
||||
each run, and the screenshots of the website show the same mesh each time that somebody
|
||||
makes them again.
|
||||
|
||||
All the names are invented, and no node is a real node. The places are real: the
|
||||
repeaters stand on the high points of the Montréal area, and the links between them come
|
||||
from a simple model of the radio path (refer to :func:`link_snr`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import heapq
|
||||
import math
|
||||
import random
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import pairwise
|
||||
|
||||
from ..core.channels import DEFAULT_PUBLIC_SECRET, derive_secret
|
||||
from ..core.geo import haversine_km
|
||||
from ..core.models import (
|
||||
NODE_TYPE_CHAT,
|
||||
NODE_TYPE_REPEATER,
|
||||
NODE_TYPE_ROOM,
|
||||
NODE_TYPE_SENSOR,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- the nodes
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, eq=False)
|
||||
class Node:
|
||||
"""One node of the demo world.
|
||||
|
||||
Each node exists once, in :data:`NODES` or as :data:`SELF`. Thus two nodes are equal
|
||||
only when they are the same object, and the hash of a node is cheap: the link model
|
||||
keys its caches on nodes millions of times.
|
||||
|
||||
Attributes:
|
||||
name: The name that the node advertises.
|
||||
hue: The first byte of its public key. This byte is the hash of the node in a
|
||||
path, and it selects the hue of its name on each screen. The world gives
|
||||
each node its own byte, so that no two nodes of the world have the same hash,
|
||||
and the hues of the repeaters stay far apart.
|
||||
node_type: The advert type (``NODE_TYPE_*``).
|
||||
lat: The latitude of the node. The link model always uses it.
|
||||
lon: The longitude of the node.
|
||||
site_db: The gain of the site, in dB, against a node at street level: a mountain
|
||||
top is worth more than a roof, and a roof more than a pocket.
|
||||
shares_location: Whether the adverts of the node carry its position. A node that
|
||||
does not share it still has one, because the link model needs it.
|
||||
added: Whether the node is in the contact list of our device. A node that is not
|
||||
added is a discovered node: MeshTerm only heard it.
|
||||
first_heard_days: How many days before now our node first heard this node.
|
||||
quiet_hours: How many hours before now the node transmitted for the last time.
|
||||
Zero is a node that is active now. ``None`` is a contact that our node never
|
||||
heard (a contact added from a shared card).
|
||||
talk: How often the node starts a transmission, relative to an ordinary node.
|
||||
public_key: The full public key (64 hex digits): the hue byte, then a fixed digest
|
||||
of the name.
|
||||
"""
|
||||
|
||||
name: str
|
||||
hue: int
|
||||
node_type: int
|
||||
lat: float
|
||||
lon: float
|
||||
site_db: float
|
||||
shares_location: bool = True
|
||||
added: bool = True
|
||||
first_heard_days: float = 120.0
|
||||
quiet_hours: float | None = 0.0
|
||||
talk: float = 1.0
|
||||
public_key: str = field(init=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Derive the public key from the hue byte and the name."""
|
||||
tail = hashlib.sha256(f"meshterm-demo/{self.name}".encode()).digest()[1:]
|
||||
object.__setattr__(self, "public_key", (bytes([self.hue]) + tail).hex())
|
||||
|
||||
@property
|
||||
def node_id(self) -> str:
|
||||
"""The canonical id of the node in the history: the first 12 hex digits of its key."""
|
||||
return self.public_key[:12]
|
||||
|
||||
@property
|
||||
def hash(self) -> str:
|
||||
"""The one-byte path hash of the node (2 hex digits)."""
|
||||
return self.public_key[:2]
|
||||
|
||||
@property
|
||||
def is_repeater(self) -> bool:
|
||||
"""Whether the node relays floods."""
|
||||
return self.node_type == NODE_TYPE_REPEATER
|
||||
|
||||
|
||||
_R, _C, _ROOM, _S = NODE_TYPE_REPEATER, NODE_TYPE_CHAT, NODE_TYPE_ROOM, NODE_TYPE_SENSOR
|
||||
|
||||
#: Our node: the companion that the demo device is. It stands in La Petite-Patrie, near the
|
||||
#: Jean-Talon market, with an antenna on the roof of a triplex. The buildings around it
|
||||
#: hide the big repeaters (refer to :data:`_OBSTRUCTED`). It hears two small repeaters on
|
||||
#: the roofs nearby, Villeray and Rosemont, as a companion in the city usually does.
|
||||
SELF = Node("Alex Base", 0x91, _C, 45.5350, -73.6030, 2.0, first_heard_days=400.0)
|
||||
|
||||
# fmt: off
|
||||
#: The other nodes of the world. The repeaters stand on the high points. The companions are
|
||||
#: the people of the mesh. The order is the order of the contact list on the device.
|
||||
NODES: tuple[Node, ...] = (
|
||||
# The repeaters, on the mountains, the towers, and the roofs.
|
||||
Node("Mont-Royal", 0x2A, _R, 45.5060, -73.5880, 13.0, first_heard_days=380.0),
|
||||
Node("Oratoire", 0x4F, _R, 45.4924, -73.6178, 8.0, first_heard_days=300.0),
|
||||
Node("Centre-Ville", 0xBC, _R, 45.5015, -73.5690, 8.0, first_heard_days=350.0),
|
||||
Node("Stade-Olympique", 0x98, _R, 45.5580, -73.5517, 10.0, first_heard_days=360.0),
|
||||
Node("Ahuntsic", 0x05, _R, 45.5560, -73.6600, 6.0, first_heard_days=210.0),
|
||||
Node("Laval-Chomedey", 0xCF, _R, 45.5640, -73.7450, 7.0, first_heard_days=190.0),
|
||||
Node("Verdun-Quai", 0xF3, _R, 45.4555, -73.5640, 5.0, first_heard_days=240.0),
|
||||
Node("Lachine", 0x85, _R, 45.4340, -73.6800, 7.0, first_heard_days=160.0),
|
||||
Node("Pointe-Claire", 0xAA, _R, 45.4485, -73.8165, 8.0, first_heard_days=130.0),
|
||||
Node("Île-Bizard", 0x3C, _R, 45.4950, -73.8900, 6.0, first_heard_days=95.0),
|
||||
Node("Longueuil", 0x73, _R, 45.5300, -73.5150, 6.0, first_heard_days=6.0),
|
||||
Node("Brossard", 0x18, _R, 45.4570, -73.4660, 6.0, first_heard_days=70.0),
|
||||
Node("Mont-St-Bruno", 0xE1, _R, 45.5530, -73.3260, 13.0, first_heard_days=280.0),
|
||||
Node("Mont-St-Hilaire", 0x61, _R, 45.5490, -73.1570, 15.0, first_heard_days=45.0),
|
||||
Node("Villeray", 0x70, _R, 45.5470, -73.6170, 4.0, first_heard_days=250.0),
|
||||
Node("Rosemont", 0xD8, _R, 45.5420, -73.5850, 4.0, first_heard_days=170.0),
|
||||
# The room servers.
|
||||
Node("MTL BBS", 0x3B, _ROOM, 45.5225, -73.5840, 4.0, first_heard_days=330.0),
|
||||
Node("Café Mesh", 0x64, _ROOM, 45.5250, -73.5990, 3.0, first_heard_days=60.0,
|
||||
quiet_hours=9 * 24.0),
|
||||
# The sensors.
|
||||
Node("Verdun Weather", 0x58, _S, 45.4585, -73.5660, 3.0, first_heard_days=200.0,
|
||||
talk=0.6),
|
||||
Node("Beaver Lake", 0x9E, _S, 45.4985, -73.5970, 4.0, first_heard_days=150.0,
|
||||
talk=0.6),
|
||||
Node("River Gauge", 0xCB, _S, 45.5110, -73.5470, 2.0, first_heard_days=110.0,
|
||||
talk=0.6, quiet_hours=30.0),
|
||||
# The companions: the people of the mesh.
|
||||
Node("Marie-Ève", 0xE6, _C, 45.5255, -73.5790, 1.0, first_heard_days=320.0,
|
||||
talk=1.6),
|
||||
Node("Jean-Seb", 0xAD, _C, 45.5590, -73.6650, 2.0, first_heard_days=300.0,
|
||||
talk=1.4),
|
||||
Node("Félix", 0x1A, _C, 45.5450, -73.5440, 2.0, first_heard_days=280.0,
|
||||
talk=1.2),
|
||||
Node("Dave-Lachine", 0x84, _C, 45.4385, -73.6710, 2.0, first_heard_days=150.0),
|
||||
Node("Kim-NDG", 0xBF, _C, 45.4735, -73.6145, 1.0, first_heard_days=260.0),
|
||||
Node("Nico R.", 0x94, _C, 45.5110, -73.5695, 0.0, shares_location=False,
|
||||
first_heard_days=90.0, talk=1.3),
|
||||
Node("Noor", 0x50, _C, 45.4960, -73.6300, 1.0, first_heard_days=75.0),
|
||||
Node("Chloé L.", 0xC9, _C, 45.5170, -73.6060, 1.0, first_heard_days=220.0),
|
||||
Node("Sam Verdun", 0x68, _C, 45.4610, -73.5690, 1.0, first_heard_days=240.0),
|
||||
Node("Steve K.", 0x22, _C, 45.5340, -73.5050, 1.0, first_heard_days=40.0),
|
||||
Node("Olivier.T", 0x33, _C, 45.5455, -73.5770, 1.0, first_heard_days=180.0,
|
||||
quiet_hours=50.0),
|
||||
Node("Raphaël", 0x0B, _C, 45.5810, -73.7480, 1.0, first_heard_days=130.0,
|
||||
quiet_hours=3.0),
|
||||
Node("Ana Brossard", 0x3F, _C, 45.4550, -73.4710, 1.0, first_heard_days=65.0,
|
||||
quiet_hours=14.0),
|
||||
Node("Léa", 0xDD, _C, 45.5450, -73.6240, 1.0, shares_location=False,
|
||||
first_heard_days=200.0, talk=0.5),
|
||||
Node("Rachid", 0x60, _C, 45.5240, -73.6000, 1.0, shares_location=False,
|
||||
first_heard_days=100.0, quiet_hours=4 * 24.0, talk=0.5),
|
||||
Node("Tomás", 0x77, _C, 45.4520, -73.8120, 1.0, first_heard_days=160.0,
|
||||
quiet_hours=62 * 24.0, talk=0.5),
|
||||
Node("Mathilde", 0xF7, _C, 45.4780, -73.5870, 0.0, first_heard_days=2 / 24),
|
||||
Node("Gabriel", 0x7F, _C, 45.5650, -73.2050, 1.0, first_heard_days=20.0),
|
||||
Node("Sylvie-Gaspésie", 0x6C, _C, 48.8300, -64.4800, 0.0, shares_location=False,
|
||||
first_heard_days=0.0, quiet_hours=None),
|
||||
# Discovered nodes: heard, but nobody added them.
|
||||
Node("T1000-E", 0x4C, _C, 45.5600, -73.7100, 0.0, added=False,
|
||||
first_heard_days=3.0, talk=0.4),
|
||||
Node("Wio Tracker", 0xA0, _C, 45.4650, -73.5480, 0.0, added=False,
|
||||
first_heard_days=12.0, quiet_hours=20.0, talk=0.4),
|
||||
Node("Mobile Node", 0xB2, _C, 45.5200, -73.5300, 0.0, shares_location=False,
|
||||
added=False, first_heard_days=1.5, talk=0.4),
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
#: Each node of the world by name, our node included.
|
||||
BY_NAME: dict[str, Node] = {node.name: node for node in (SELF, *NODES)}
|
||||
|
||||
#: The repeaters, in the order of :data:`NODES`.
|
||||
REPEATERS: tuple[Node, ...] = tuple(node for node in NODES if node.is_repeater)
|
||||
|
||||
|
||||
def node(name: str) -> Node:
|
||||
"""The node of the world with this name. A name that the world does not have is a bug."""
|
||||
return BY_NAME[name]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- the radio
|
||||
|
||||
#: The weakest SNR (dB) at which the radio still decodes a packet. The demo device uses the
|
||||
#: North American preset (SF7 at 62.5 kHz), whose demodulator stops near -7.5 dB.
|
||||
FLOOR_DB = -7.5
|
||||
|
||||
#: The SNR (dB) of a link between two nodes at street level, one kilometre apart.
|
||||
_ONE_KM_DB = 5.0
|
||||
|
||||
#: The loss (dB) for each decade of distance. Free space loses 20 dB for each decade. A
|
||||
#: city loses more, because of the buildings.
|
||||
_DECADE_DB = 25.0
|
||||
|
||||
#: Above this SNR (dB), a reading grows slowly: a receiver gives no SNR much above +12 dB,
|
||||
#: whatever the strength of the signal.
|
||||
_SOFT_CAP_DB = 10.0
|
||||
|
||||
|
||||
#: The extra loss (dB) of a link from or to a companion of the people of the mesh. Such a
|
||||
#: node is indoors, or in a pocket, at street level, so the buildings around it take more
|
||||
#: than the distance explains. A link between two companions takes it two times. Thus a
|
||||
#: companion reaches the repeaters near it, and another companion only when the two are
|
||||
#: close, as on a real mesh. Our node has its antenna on a roof, and the room servers and
|
||||
#: the sensors are outdoors, so they do not take it.
|
||||
_GROUND_DB = 7.0
|
||||
|
||||
#: Links that the terrain blocks more than the model knows, by the names of their two
|
||||
#: ends, in dB of extra loss. The buildings around our node hide the big repeaters from
|
||||
#: it. Thus each flood reaches our node through the two small repeaters near it, usually
|
||||
#: one copy from each. This is what a real companion in Montréal hears: two copies of most
|
||||
#: messages, on paths that share their start and divide near the end.
|
||||
_OBSTRUCTED: dict[frozenset[str], float] = {
|
||||
frozenset((SELF.name, name)): 14.0
|
||||
for name in ("Mont-Royal", "Oratoire", "Centre-Ville", "Stade-Olympique", "Ahuntsic")
|
||||
}
|
||||
|
||||
|
||||
def _ground_loss(a: Node, b: Node) -> float:
|
||||
"""The loss that the street level adds to a link (refer to :data:`_GROUND_DB`)."""
|
||||
return _GROUND_DB * (_indoors(a) + _indoors(b))
|
||||
|
||||
|
||||
def _indoors(node: Node) -> bool:
|
||||
"""Whether ``node`` is a companion at street level: each one except our node."""
|
||||
return node.node_type == NODE_TYPE_CHAT and node is not SELF
|
||||
|
||||
|
||||
def _skew(a: Node, b: Node) -> float:
|
||||
"""A fixed offset (dB) for the link from ``a`` to ``b``: the terrain that no model knows.
|
||||
|
||||
Two parts make the offset. The larger part is the same in each direction (a hill or a
|
||||
tower between the two sites). The smaller part depends on the direction, because the
|
||||
two radios are not the same. Thus a pair of nodes never has exactly the same SNR in the
|
||||
two directions, as on a real mesh.
|
||||
"""
|
||||
pair = "|".join(sorted((a.name, b.name)))
|
||||
common = hashlib.sha256(f"terrain/{pair}".encode()).digest()
|
||||
oneway = hashlib.sha256(f"radio/{a.name}>{b.name}".encode()).digest()
|
||||
return (common[0] / 255 - 0.5) * 3.0 + (oneway[0] / 255 - 0.5) * 1.0
|
||||
|
||||
|
||||
@functools.cache
|
||||
def link_snr(a: Node, b: Node) -> float | None:
|
||||
"""The mean SNR (dB) at ``b`` of a packet that ``a`` transmits.
|
||||
|
||||
The model is a log-distance path loss: :data:`_ONE_KM_DB` at one kilometre, less
|
||||
:data:`_DECADE_DB` for each decade of distance, plus the gain of the two sites and the
|
||||
fixed terrain offset of the link (:func:`_skew`). It is simple, but it gives what a
|
||||
real mesh shows: the mountains hear each other over 30 km, and two pockets in the city
|
||||
hear each other over one or two kilometres.
|
||||
|
||||
The world is fixed, so the function keeps each answer in a cache. The history asks for
|
||||
the same links almost two million times.
|
||||
|
||||
Args:
|
||||
a: The node that transmits.
|
||||
b: The node that receives.
|
||||
|
||||
Returns:
|
||||
The mean SNR in dB, or ``None`` when ``b`` cannot decode ``a`` (below
|
||||
:data:`FLOOR_DB`, or the same node).
|
||||
"""
|
||||
if a.name == b.name:
|
||||
return None
|
||||
km = max(haversine_km(a.lat, a.lon, b.lat, b.lon), 0.05)
|
||||
snr = _ONE_KM_DB - _DECADE_DB * math.log10(km) + a.site_db + b.site_db + _skew(a, b)
|
||||
snr -= _ground_loss(a, b) + _OBSTRUCTED.get(frozenset((a.name, b.name)), 0.0)
|
||||
if snr > _SOFT_CAP_DB:
|
||||
snr = _SOFT_CAP_DB + (snr - _SOFT_CAP_DB) * 0.25
|
||||
return round(snr, 2) if snr >= FLOOR_DB else None
|
||||
|
||||
|
||||
def loss_chance(snr: float) -> float:
|
||||
"""The probability that one packet on a link with this mean SNR is lost.
|
||||
|
||||
A link well above the floor loses nothing. A link near the floor loses most packets.
|
||||
"""
|
||||
return max(0.0, min(0.9, (FLOOR_DB + 4.0 - snr) / 8.0))
|
||||
|
||||
|
||||
def reading(snr: float, rng: random.Random) -> float:
|
||||
"""One SNR reading of a link: the mean, plus noise, in the quarter-dB steps of the firmware."""
|
||||
return round((snr + rng.gauss(0.0, 1.0)) * 4) / 4
|
||||
|
||||
|
||||
def hears(a: Node, b: Node) -> bool:
|
||||
"""Whether ``b`` can decode ``a``."""
|
||||
return link_snr(a, b) is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- routes
|
||||
|
||||
|
||||
@functools.cache
|
||||
def best_route(origin: Node, target: Node) -> tuple[Node, ...] | None:
|
||||
"""The repeaters on the best route from ``origin`` to ``target``, in order.
|
||||
|
||||
The cost of each link is one hop, plus a penalty for a weak link. Thus the route has
|
||||
few hops, but it does not go over a link that loses half of its packets when a strong
|
||||
detour of the same length exists. A device learns a route of this type from the floods
|
||||
that it receives.
|
||||
|
||||
Args:
|
||||
origin: The node at the start.
|
||||
target: The node at the end.
|
||||
|
||||
Returns:
|
||||
The relays between the two ends (an empty tuple when they hear each other
|
||||
directly), or ``None`` when no route connects them.
|
||||
"""
|
||||
direct = link_snr(origin, target)
|
||||
if direct is not None and direct > FLOOR_DB + 3.0:
|
||||
return ()
|
||||
costs: dict[str, float] = {}
|
||||
queue: list[tuple[float, int, str, tuple[Node, ...]]] = []
|
||||
order = 0
|
||||
for relay in REPEATERS:
|
||||
snr = link_snr(origin, relay)
|
||||
if snr is not None and relay.name != origin.name:
|
||||
order += 1
|
||||
heapq.heappush(queue, (_link_cost(snr), order, relay.name, (relay,)))
|
||||
best: tuple[float, tuple[Node, ...]] | None = None
|
||||
if direct is not None:
|
||||
best = (_link_cost(direct), ())
|
||||
while queue:
|
||||
cost, _, name, route = heapq.heappop(queue)
|
||||
if name in costs:
|
||||
continue
|
||||
costs[name] = cost
|
||||
last = route[-1]
|
||||
tail = link_snr(last, target)
|
||||
if tail is not None:
|
||||
total = cost + _link_cost(tail)
|
||||
if best is None or total < best[0]:
|
||||
best = (total, route)
|
||||
for relay in REPEATERS:
|
||||
if relay.name in costs or relay.name == origin.name:
|
||||
continue
|
||||
snr = link_snr(last, relay)
|
||||
if snr is not None:
|
||||
order += 1
|
||||
heapq.heappush(queue, (cost + _link_cost(snr), order, relay.name, (*route, relay)))
|
||||
return best[1] if best is not None else None
|
||||
|
||||
|
||||
def _link_cost(snr: float) -> float:
|
||||
"""The cost of one link for :func:`best_route`: one hop, more when the link is weak."""
|
||||
return 1.0 + 4.0 * loss_chance(snr)
|
||||
|
||||
|
||||
#: The nodes whose floods do not use three-byte hashes, and the width that they use. Our
|
||||
#: node uses three bytes, as most of the mesh does.
|
||||
_NARROW_PATHS: dict[str, int] = {
|
||||
"Steve K.": 2,
|
||||
"Raphaël": 2,
|
||||
"Olivier.T": 1,
|
||||
"Tomás": 1,
|
||||
"T1000-E": 1,
|
||||
"Wio Tracker": 1,
|
||||
"Mobile Node": 1,
|
||||
}
|
||||
|
||||
|
||||
def path_width(origin: Node) -> int:
|
||||
"""The bytes of each hash in the path of a flood from ``origin``.
|
||||
|
||||
The node that starts a flood sets the width, and each relay adds its hash at that
|
||||
width. Most nodes of the mesh of Montréal use three bytes, a few use two, and some
|
||||
older ones still use one (:data:`_NARROW_PATHS`).
|
||||
"""
|
||||
return _NARROW_PATHS.get(origin.name, 3)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Copy:
|
||||
"""One copy of a flood that our node hears.
|
||||
|
||||
Attributes:
|
||||
relays: The repeaters that the copy went through, in order. Empty when our node
|
||||
heard the origin directly.
|
||||
snr: The SNR of the reception at our node (dB), from the last relay.
|
||||
delay_s: The time from the transmission at the origin to the reception (seconds).
|
||||
"""
|
||||
|
||||
relays: tuple[Node, ...]
|
||||
snr: float
|
||||
delay_s: float
|
||||
|
||||
|
||||
def flood(origin: Node, rng: random.Random, *, listener: Node = SELF) -> list[Copy]:
|
||||
"""The copies of one flood from ``origin`` that ``listener`` hears, earliest first.
|
||||
|
||||
Each repeater relays the first copy that it receives, and only that copy. Thus the
|
||||
function runs the flood as a race: each link adds the airtime of the packet and the
|
||||
random delay of the relay, and each repeater keeps the route of its first copy. Our node
|
||||
hears the origin directly when it is in range, and each repeater in range that the
|
||||
flood reached. A weak link can lose a copy (:func:`loss_chance`). Thus two floods from
|
||||
the same node do not always arrive on the same paths.
|
||||
|
||||
Args:
|
||||
origin: The node that transmits the flood.
|
||||
rng: The source of the delays, the losses, and the noise.
|
||||
listener: The node that hears the copies (our node by default).
|
||||
|
||||
Returns:
|
||||
The copies that the listener receives.
|
||||
"""
|
||||
arrivals: dict[Node, tuple[float, tuple[Node, ...]]] = {}
|
||||
queue: list[tuple[float, int, Node, tuple[Node, ...]]] = []
|
||||
skip = (origin, listener)
|
||||
order = 0
|
||||
for relay, snr in _hearers(origin):
|
||||
if relay in skip or rng.random() < loss_chance(snr):
|
||||
continue
|
||||
order += 1
|
||||
heapq.heappush(queue, (_airtime(rng), order, relay, (relay,)))
|
||||
while queue:
|
||||
when, _, relay, route = heapq.heappop(queue)
|
||||
if relay in arrivals:
|
||||
continue
|
||||
arrivals[relay] = (when, route)
|
||||
sent = when + rng.uniform(0.2, 1.4) # the random transmit delay of the repeater
|
||||
for other, snr in _hearers(relay):
|
||||
if other in arrivals or other in skip or rng.random() < loss_chance(snr):
|
||||
continue
|
||||
order += 1
|
||||
heapq.heappush(queue, (sent + _airtime(rng), order, other, (*route, other)))
|
||||
copies: list[Copy] = []
|
||||
direct = link_snr(origin, listener)
|
||||
if direct is not None and rng.random() >= loss_chance(direct):
|
||||
copies.append(Copy((), reading(direct, rng), _airtime(rng)))
|
||||
for when, route in arrivals.values():
|
||||
relay = route[-1]
|
||||
snr = link_snr(relay, listener)
|
||||
if snr is None or rng.random() < loss_chance(snr):
|
||||
continue
|
||||
sent = when + rng.uniform(0.2, 1.4)
|
||||
copies.append(Copy(route, reading(snr, rng), sent + _airtime(rng)))
|
||||
copies.sort(key=lambda copy: copy.delay_s)
|
||||
return copies
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _hearers(node: Node) -> tuple[tuple[Node, float], ...]:
|
||||
"""The repeaters that hear ``node``, each with the mean SNR of the link."""
|
||||
return tuple((relay, snr) for relay in REPEATERS if (snr := link_snr(node, relay)) is not None)
|
||||
|
||||
|
||||
def _airtime(rng: random.Random) -> float:
|
||||
"""The time on the air of one short packet at SF7, with the time to decode it (seconds)."""
|
||||
return rng.uniform(0.15, 0.4)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TraceWalk:
|
||||
"""The result of one simulated trace along a forced path.
|
||||
|
||||
Attributes:
|
||||
snrs: The SNR reading at the receiving end of each link, in order: the first relay,
|
||||
then each next relay, then our node for the last link. It is shorter than the
|
||||
walk when the trace died on the way.
|
||||
success: Whether the trace came back to our node.
|
||||
rtt_ms: The round trip of the trace (milliseconds), when it came back.
|
||||
"""
|
||||
|
||||
snrs: list[float] = field(default_factory=list)
|
||||
success: bool = False
|
||||
rtt_ms: float | None = None
|
||||
|
||||
|
||||
def walk(relays: Sequence[Node], rng: random.Random) -> TraceWalk:
|
||||
"""Simulate one trace from our node through ``relays`` and back to our node.
|
||||
|
||||
A trace is a direct packet: each relay transmits it to the next node of the path, and
|
||||
measures the SNR of what it received. The last link comes back to our node. A link
|
||||
that does not exist, or a link that loses the packet, stops the trace.
|
||||
|
||||
Args:
|
||||
relays: The forced path, as nodes.
|
||||
rng: The source of the noise and the losses.
|
||||
|
||||
Returns:
|
||||
The readings, and whether the trace came back.
|
||||
"""
|
||||
chain = [SELF, *relays, SELF]
|
||||
snrs: list[float] = []
|
||||
elapsed = 0.0
|
||||
for a, b in pairwise(chain):
|
||||
snr = link_snr(a, b)
|
||||
if snr is None or rng.random() < loss_chance(snr):
|
||||
return TraceWalk(snrs, False, None)
|
||||
snrs.append(reading(snr, rng))
|
||||
elapsed += _airtime(rng) + rng.uniform(0.05, 0.25)
|
||||
return TraceWalk(snrs, True, round(elapsed * 1000.0, 1))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- channels
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Channel:
|
||||
"""One channel in a slot of the demo device.
|
||||
|
||||
Attributes:
|
||||
slot: The slot index on the device.
|
||||
name: The name of the channel as the device stores it.
|
||||
secret: The 16-byte secret.
|
||||
scope: The region under which MeshTerm sends on this channel, or ``None`` for the
|
||||
default scope of the device.
|
||||
"""
|
||||
|
||||
slot: int
|
||||
name: str
|
||||
secret: bytes
|
||||
scope: str | None = None
|
||||
|
||||
|
||||
#: The channels of the demo device. ``Family`` is a private channel, with a fixed secret
|
||||
#: that is not derived from its name.
|
||||
CHANNELS: tuple[Channel, ...] = (
|
||||
Channel(0, "Public", DEFAULT_PUBLIC_SECRET),
|
||||
Channel(1, "#montreal", derive_secret("#montreal"), scope="yul"),
|
||||
Channel(2, "#cycling", derive_secret("#cycling")),
|
||||
Channel(3, "Family", hashlib.sha256(b"meshterm-demo/Family").digest()[:16]),
|
||||
)
|
||||
|
||||
#: Hashtag channels that other people of the mesh use and our device does not have. Our
|
||||
#: node hears their packets, but it cannot read them.
|
||||
FOREIGN_CHANNELS: tuple[bytes, ...] = tuple(
|
||||
derive_secret(name) for name in ("#quebec", "#radioamateur", "#test", "#plateau")
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- regions
|
||||
|
||||
#: The region table of each repeater, as ``(name, parent, allows floods)`` rows, in the
|
||||
#: form that :class:`~meshterm.core.region_sim.SimulatedRegionMap` takes. The regions are
|
||||
#: the ones of the real mesh, each in the one before it: ``na`` (North America), ``can``
|
||||
#: (Canada), ``onqc`` (Ontario and Québec), ``qc`` (Québec), and ``yul`` (Montréal).
|
||||
#: Each repeater of Montréal, Laval, and the south shore carries all five.
|
||||
#: Mont-St-Hilaire is outside the city: it has ``yul`` in its table, but it does not
|
||||
#: relay its floods.
|
||||
REGION_TABLES: dict[str, list[tuple[str, str, bool]]] = {}
|
||||
|
||||
_REGIONS = [
|
||||
("na", "*", True),
|
||||
("can", "na", True),
|
||||
("onqc", "can", True),
|
||||
("qc", "onqc", True),
|
||||
("yul", "qc", True),
|
||||
]
|
||||
for _name in (
|
||||
"Oratoire",
|
||||
"Centre-Ville",
|
||||
"Stade-Olympique",
|
||||
"Verdun-Quai",
|
||||
"Ahuntsic",
|
||||
"Villeray",
|
||||
"Rosemont",
|
||||
"Mont-Royal",
|
||||
"Lachine",
|
||||
"Pointe-Claire",
|
||||
"Île-Bizard",
|
||||
"Laval-Chomedey",
|
||||
"Longueuil",
|
||||
"Brossard",
|
||||
"Mont-St-Bruno",
|
||||
):
|
||||
REGION_TABLES[_name] = _REGIONS
|
||||
REGION_TABLES["Mont-St-Hilaire"] = [*_REGIONS[:-1], ("yul", "qc", False)]
|
||||
|
||||
#: The contacts to which the device learned a route. A real device learns few routes: it
|
||||
#: learns one only from a flood that it received from the node, and forgets it when a send
|
||||
#: fails. Thus most contacts of the demo stay on flood, as on hardware.
|
||||
ROUTED: tuple[str, ...] = (
|
||||
"Mont-Royal",
|
||||
"Stade-Olympique",
|
||||
"MTL BBS",
|
||||
"Marie-Ève",
|
||||
"Jean-Seb",
|
||||
"Dave-Lachine",
|
||||
"Nico R.",
|
||||
"Mathilde",
|
||||
)
|
||||
|
||||
|
||||
def device_route(target: Node) -> tuple[str, ...] | None:
|
||||
"""The route that the demo device holds for ``target``, as path hashes.
|
||||
|
||||
Returns:
|
||||
The hashes of the relays (empty for a direct neighbour), or ``None`` when the
|
||||
device holds no route (the contact is on flood).
|
||||
"""
|
||||
if target.name not in ROUTED:
|
||||
return None
|
||||
route = best_route(SELF, target)
|
||||
return None if route is None else tuple(relay.hash for relay in route)
|
||||
|
||||
|
||||
#: The repeaters for which our node remembers the admin password. The demo device accepts
|
||||
#: this password on each repeater, as the simulator does.
|
||||
ADMIN_PASSWORD = "admin"
|
||||
ADMINISTERED: tuple[str, ...] = ("Mont-Royal", "Stade-Olympique")
|
||||
|
||||
#: The room that our node joined, and its room password (the stock password of a room
|
||||
#: server, which the simulator accepts).
|
||||
JOINED_ROOM = "MTL BBS"
|
||||
ROOM_PASSWORD = "hello"
|
||||
@@ -81,8 +81,8 @@ _SHIFTS = ("Shift_L", "Shift_R")
|
||||
_REPEAT_GAP_MS = 30
|
||||
|
||||
#: The cap of a drawn lane key, and the ink of the digit that is printed on it.
|
||||
_KEY_BODY = "#1d2025"
|
||||
_KEY_DIGIT = "#eef1f4"
|
||||
_KEY_BODY = "#202020"
|
||||
_KEY_DIGIT = "#f1f1f1"
|
||||
|
||||
#: The modifier bits of Tk: Shift and Control on all platforms, Alt where the platform puts it.
|
||||
_SHIFT_BIT = 0x0001
|
||||
@@ -231,12 +231,12 @@ class EmulatorWindow:
|
||||
keys_h = 26 * scale if self._device.lane_keys else 0
|
||||
root = tk.Tk()
|
||||
root.title(f"MeshTerm — {self._device.name} emulator")
|
||||
root.configure(background="#2b2f36")
|
||||
root.configure(background="#2f2f2f")
|
||||
root.resizable(False, False)
|
||||
width = panel_w * scale + 2 * margin
|
||||
height = panel_h * scale + 2 * margin + keys_h
|
||||
canvas = tk.Canvas(
|
||||
root, width=width, height=height, background="#2b2f36", highlightthickness=0
|
||||
root, width=width, height=height, background="#2f2f2f", highlightthickness=0
|
||||
)
|
||||
canvas.pack()
|
||||
base = tk.PhotoImage(width=panel_w, height=panel_h)
|
||||
|
||||
@@ -12,7 +12,7 @@ import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_VERSION = 19
|
||||
SCHEMA_VERSION = 20
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS schema_meta (
|
||||
@@ -404,6 +404,37 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
# that the run log writes as "<secret>" (refer to meshterm.tools.base.Secret).
|
||||
# This step removes the keys that the earlier versions stored, one time.
|
||||
_forget_imported_keys(conn)
|
||||
if stored is not None and int(stored["value"]) < 20:
|
||||
# v19 -> v20: no secret of any kind in the run log. The parameters and summaries
|
||||
# of the runs held channel secrets (in each chat run, inside its Conversation),
|
||||
# room passwords, admin passwords, and pairing PINs. The run log now stores each
|
||||
# one as "<secret>" (refer to meshterm.core.secrets). This step hides the secrets
|
||||
# that the earlier versions stored, one time.
|
||||
_hide_stored_secrets(conn)
|
||||
|
||||
|
||||
def _hide_stored_secrets(conn: sqlite3.Connection) -> None:
|
||||
"""Hide each secret in the stored arguments and summaries of the runs.
|
||||
|
||||
The rules are the rules that the run log applies to a new run
|
||||
(:func:`~meshterm.core.secrets.loggable`). The stored JSON has lost the types of the
|
||||
objects: a ``Conversation`` is the text of its ``repr``. The text rules find the
|
||||
secret in that text (``secret=b'...'``). A row that does not change is not written.
|
||||
"""
|
||||
from ..core.secrets import loggable
|
||||
|
||||
rows = conn.execute("SELECT id, args_json, summary_json FROM runs").fetchall()
|
||||
for row in rows:
|
||||
for column in ("args_json", "summary_json"):
|
||||
stored = row[column]
|
||||
if not stored:
|
||||
continue
|
||||
try:
|
||||
hidden = json.dumps(loggable(json.loads(stored)), default=str)
|
||||
except ValueError:
|
||||
continue
|
||||
if hidden != json.dumps(json.loads(stored)):
|
||||
conn.execute(f"UPDATE runs SET {column} = ? WHERE id = ?", (hidden, row["id"]))
|
||||
|
||||
|
||||
def _forget_imported_keys(conn: sqlite3.Connection) -> None:
|
||||
|
||||
@@ -18,6 +18,16 @@ space on a handheld, and it is too large to attach to a bug report.
|
||||
A preference sets how much MeshTerm writes: ``log_level``, with the default ``WARNING``.
|
||||
Thus the file has the problems, not a full account of a session that works. When you
|
||||
look for the cause of a problem, set it lower, to ``INFO`` or ``DEBUG``.
|
||||
|
||||
No log ever holds a password or a secret. Both handlers hide each secret that
|
||||
:mod:`meshterm.core.secrets` finds in a record, and the file formatter does it again on
|
||||
the traceback. This does not depend on a preference.
|
||||
|
||||
A preference, ``anonymize``, masks the personal details of each line before it
|
||||
goes into the file: the account name in a home folder, the address of a radio, and the
|
||||
address of a host (refer to :mod:`meshterm.core.anonymize`). The mask applies as each line
|
||||
is written, so the file never holds what it masks. A line that was written before the
|
||||
preference was turned on stays as it is.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -136,6 +146,7 @@ def configure_logging(
|
||||
console=console, rich_tracebacks=True, show_path=False, markup=False
|
||||
)
|
||||
rich_handler.setLevel(level)
|
||||
rich_handler.addFilter(_HIDE_SECRETS)
|
||||
logger.addHandler(rich_handler)
|
||||
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -146,7 +157,8 @@ def configure_logging(
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setLevel(file_level)
|
||||
file_handler.setFormatter(logging.Formatter(_LINE_FORMAT, datefmt=_TIME_FORMAT))
|
||||
file_handler.addFilter(_HIDE_SECRETS)
|
||||
file_handler.setFormatter(_FileFormatter(_LINE_FORMAT, datefmt=_TIME_FORMAT))
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
_quiet_library_console(file_handler)
|
||||
@@ -154,6 +166,68 @@ def configure_logging(
|
||||
return logger
|
||||
|
||||
|
||||
class _HideSecrets(logging.Filter):
|
||||
"""Hide each password and secret in the message of a record, for every handler.
|
||||
|
||||
A filter on a handler sees each record that the handler gets, also the records of a
|
||||
child logger and of the libraries in :data:`_LIBRARY_LOGGERS`. A filter on a logger
|
||||
sees only the records of that logger. The filter writes the hidden message back into
|
||||
the record, so each handler after it gets the hidden form. A second pass finds nothing
|
||||
more, so the order of the handlers does not matter.
|
||||
|
||||
The text of an exception can repeat a secret too. The console handler draws a
|
||||
traceback from the exception itself, and not from text that a filter can change. Thus,
|
||||
when the traceback of a record holds a secret, the filter adds the hidden traceback to
|
||||
the message as text and removes the exception from the record. Only such a record
|
||||
loses the drawn traceback of the console.
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""Replace the message of ``record`` with its hidden form, and keep the record."""
|
||||
from ..core.secrets import hide
|
||||
|
||||
try:
|
||||
message = record.getMessage()
|
||||
except Exception: # noqa: BLE001 - a bad format is the problem of the handler
|
||||
return True
|
||||
hidden = hide(message)
|
||||
if record.exc_info and record.exc_info[0] is not None:
|
||||
traceback = logging.Formatter().formatException(record.exc_info)
|
||||
hidden_traceback = hide(traceback)
|
||||
if hidden_traceback != traceback:
|
||||
hidden = f"{hidden}\n{hidden_traceback}"
|
||||
record.exc_info, record.exc_text = None, None
|
||||
if hidden != message or record.args:
|
||||
record.msg, record.args = hidden, None
|
||||
return True
|
||||
|
||||
|
||||
_HIDE_SECRETS = _HideSecrets()
|
||||
|
||||
|
||||
class _FileFormatter(logging.Formatter):
|
||||
"""The line format of the file, with no secret, and anonymized when the user asks.
|
||||
|
||||
The formatter works on the whole formatted text: the message, and the traceback under
|
||||
it. :class:`_HideSecrets` already hid the message. The text of an exception in a
|
||||
traceback can repeat a secret too, so the formatter hides secrets again here, always.
|
||||
Then it masks the personal details if the ``anonymize`` preference is on. A traceback
|
||||
holds the most home folders, because each frame names its file. The formatter reads
|
||||
the preference at each record, so a change on the Preferences page applies to the
|
||||
next line. The mask does not change the record. The console handler gets the same
|
||||
record and shows it unmasked, because the console belongs to the user who reads it.
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Format ``record`` as one line (and its traceback), with no secret in it."""
|
||||
from ..core.anonymize import anonymize
|
||||
from ..core.preferences import current
|
||||
from ..core.secrets import hide
|
||||
|
||||
text = hide(super().format(record))
|
||||
return anonymize(text) if current().anonymize else text
|
||||
|
||||
|
||||
#: Third-party loggers that must never write to the terminal, because their records print
|
||||
#: over the full-screen TUI. This module routes them to the file handler instead, so that
|
||||
#: the records stay available for debugging. ``meshcore`` is the main cause: it calls
|
||||
|
||||
@@ -30,6 +30,7 @@ from ..core.models import (
|
||||
utcnow,
|
||||
)
|
||||
from ..core.regions import SCOPED_ROUTES, raw_scope_body
|
||||
from ..core.secrets import loggable
|
||||
from . import db
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -75,6 +76,17 @@ ACTIVITY_DRAWN_BUCKETS = 24
|
||||
#: recent past gets several readings for a median.
|
||||
HOP_EVIDENCE_LIMIT = 20000
|
||||
|
||||
#: The statement that stores one observation. :meth:`Repository.record_observation` and
|
||||
#: :meth:`Repository.record_observations` share it, with the values of
|
||||
#: :meth:`Repository._observation_row`.
|
||||
_INSERT_OBSERVATION = (
|
||||
"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, transport_code, scope_body) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
|
||||
|
||||
def _packet_raw(row: sqlite3.Row) -> dict | None:
|
||||
"""Rebuild the minimum raw payload with which MeshTerm reads a stored ``packet`` row.
|
||||
@@ -350,40 +362,65 @@ class Repository:
|
||||
|
||||
# -- runs -------------------------------------------------------------------
|
||||
|
||||
def start_run(self, tool: str, args: dict[str, Any], profile: str | None = None) -> int:
|
||||
def start_run(
|
||||
self,
|
||||
tool: str,
|
||||
args: dict[str, Any],
|
||||
profile: str | None = None,
|
||||
*,
|
||||
at: datetime | None = None,
|
||||
) -> int:
|
||||
"""Store the start of a tool run.
|
||||
|
||||
The run log never keeps a secret. The arguments go through
|
||||
:func:`~meshterm.core.secrets.loggable`, so a password, a channel secret, or a PIN
|
||||
is stored as ``<secret>``.
|
||||
|
||||
Args:
|
||||
tool: The name of the tool.
|
||||
args: The arguments with which the tool was called (they must be
|
||||
JSON-serializable).
|
||||
profile: The name of the active device profile, if any.
|
||||
at: When the run started. The default is now. Only the demo history gives
|
||||
another time, because it writes the runs of past days.
|
||||
|
||||
Returns:
|
||||
The primary key of the new run.
|
||||
"""
|
||||
stored = json.dumps(loggable(args), default=str)
|
||||
cur = self._conn.execute(
|
||||
"INSERT INTO runs (tool, profile, args_json, status, started_at) "
|
||||
"VALUES (?, ?, ?, 'running', ?)",
|
||||
(tool, profile, json.dumps(args, default=str), utcnow().isoformat()),
|
||||
(tool, profile, stored, (at or utcnow()).isoformat()),
|
||||
)
|
||||
self._conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
def finish_run(self, run_id: int, status: str, summary: dict | None = None) -> None:
|
||||
def finish_run(
|
||||
self,
|
||||
run_id: int,
|
||||
status: str,
|
||||
summary: dict | None = None,
|
||||
*,
|
||||
at: datetime | None = None,
|
||||
) -> None:
|
||||
"""Mark a run as finished.
|
||||
|
||||
The summary goes through :func:`~meshterm.core.secrets.loggable`, as the arguments
|
||||
do in :meth:`start_run`. An error message that repeats a secret is hidden too.
|
||||
|
||||
Args:
|
||||
run_id: The run to update.
|
||||
status: The final status (``ok`` or ``error``).
|
||||
summary: An optional JSON-serializable summary of the result.
|
||||
at: When the run finished. The default is now (refer to :meth:`start_run`).
|
||||
"""
|
||||
self._conn.execute(
|
||||
"UPDATE runs SET status = ?, summary_json = ?, finished_at = ? WHERE id = ?",
|
||||
(
|
||||
status,
|
||||
json.dumps(summary, default=str) if summary is not None else None,
|
||||
utcnow().isoformat(),
|
||||
json.dumps(loggable(summary), default=str) if summary is not None else None,
|
||||
(at or utcnow()).isoformat(),
|
||||
run_id,
|
||||
),
|
||||
)
|
||||
@@ -683,7 +720,12 @@ class Repository:
|
||||
return paths
|
||||
|
||||
def record_neighbours(
|
||||
self, run_id: int, repeater: str, neighbours: list[NeighbourInfo]
|
||||
self,
|
||||
run_id: int,
|
||||
repeater: str,
|
||||
neighbours: list[NeighbourInfo],
|
||||
*,
|
||||
at: datetime | None = None,
|
||||
) -> None:
|
||||
"""Store one snapshot of the neighbour table that MeshTerm got from a remote repeater.
|
||||
|
||||
@@ -699,8 +741,10 @@ class Repository:
|
||||
repeater: The canonical id of the repeater that sent the table.
|
||||
neighbours: The entries that MeshTerm got (this list can be empty: then no
|
||||
rows are stored).
|
||||
at: When MeshTerm got the table. The default is now (refer to
|
||||
:meth:`start_run`).
|
||||
"""
|
||||
fetched_at = utcnow().isoformat()
|
||||
fetched_at = (at or utcnow()).isoformat()
|
||||
self._conn.executemany(
|
||||
"INSERT INTO neighbour_reports "
|
||||
"(run_id, repeater, neighbour, snr, heard_at, fetched_at) "
|
||||
@@ -840,6 +884,7 @@ class Repository:
|
||||
app_version: str,
|
||||
keep: int = 5,
|
||||
ascending: bool = False,
|
||||
at: datetime | None = None,
|
||||
) -> int | None:
|
||||
"""Offer one walk to a category leaderboard. Store it only if it gets a place.
|
||||
|
||||
@@ -862,11 +907,13 @@ class Repository:
|
||||
keep: The board size (the rows kept for each category and width).
|
||||
ascending: ``True`` for categories in which lower scores win
|
||||
(Thin thread looks for the weakest link that survives).
|
||||
at: When the walk was made. The default is now (refer to :meth:`start_run`).
|
||||
|
||||
Returns:
|
||||
The id of the stored row when the walk got a place (a new row, or a better
|
||||
score on a known route), or ``None`` when the walk did not get on the board.
|
||||
"""
|
||||
when = (at or utcnow()).isoformat()
|
||||
|
||||
def beats(challenger: float, standing: float) -> bool:
|
||||
return challenger < standing if ascending else challenger > standing
|
||||
@@ -888,7 +935,7 @@ class Repository:
|
||||
score,
|
||||
json.dumps(stats),
|
||||
app_version,
|
||||
utcnow().isoformat(),
|
||||
when,
|
||||
existing["id"],
|
||||
),
|
||||
)
|
||||
@@ -906,7 +953,7 @@ class Repository:
|
||||
score,
|
||||
json.dumps(stats),
|
||||
app_version,
|
||||
utcnow().isoformat(),
|
||||
when,
|
||||
),
|
||||
)
|
||||
new_id = int(cursor.lastrowid)
|
||||
@@ -1043,6 +1090,32 @@ class Repository:
|
||||
run_id: The run that owns the observation.
|
||||
obs: The observation to store.
|
||||
"""
|
||||
self._conn.execute(_INSERT_OBSERVATION, self._observation_row(run_id, obs))
|
||||
self._conn.commit()
|
||||
|
||||
def record_observations(self, run_id: int, observations: Iterable[Observation]) -> int:
|
||||
"""Store many overheard packets of one run in one transaction.
|
||||
|
||||
The rows are the same as the rows of :meth:`record_observation`. The difference is
|
||||
the commit: one for all the rows, not one for each row. The demo history writes
|
||||
tens of thousands of packets at each start, and a commit for each row costs more
|
||||
than a second for each thousand rows on an SD card.
|
||||
|
||||
Args:
|
||||
run_id: The run that owns the observations.
|
||||
observations: The observations to store.
|
||||
|
||||
Returns:
|
||||
The number of stored rows.
|
||||
"""
|
||||
rows = [self._observation_row(run_id, obs) for obs in observations]
|
||||
self._conn.executemany(_INSERT_OBSERVATION, rows)
|
||||
self._conn.commit()
|
||||
return len(rows)
|
||||
|
||||
@staticmethod
|
||||
def _observation_row(run_id: int, obs: Observation) -> tuple:
|
||||
"""The values of one ``observations`` row, in the order of :data:`_INSERT_OBSERVATION`."""
|
||||
chan_hash = cipher_mac = crypted = None
|
||||
raw = obs.raw if isinstance(obs.raw, dict) else {}
|
||||
# The payload class identifies a relayed 'packet' that names no origin node. Keep
|
||||
@@ -1082,39 +1155,31 @@ class Repository:
|
||||
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, transport_code, scope_body) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
run_id,
|
||||
obs.node,
|
||||
obs.public_key,
|
||||
obs.name,
|
||||
obs.kind,
|
||||
obs.node_type,
|
||||
obs.snr,
|
||||
obs.rssi,
|
||||
obs.lat,
|
||||
obs.lon,
|
||||
obs.path,
|
||||
obs.observed_at.isoformat(),
|
||||
chan_hash,
|
||||
cipher_mac,
|
||||
crypted,
|
||||
typename,
|
||||
dest,
|
||||
src,
|
||||
tag,
|
||||
trace_snrs,
|
||||
route,
|
||||
transport_code,
|
||||
scope_body,
|
||||
),
|
||||
return (
|
||||
run_id,
|
||||
obs.node,
|
||||
obs.public_key,
|
||||
obs.name,
|
||||
obs.kind,
|
||||
obs.node_type,
|
||||
obs.snr,
|
||||
obs.rssi,
|
||||
obs.lat,
|
||||
obs.lon,
|
||||
obs.path,
|
||||
obs.observed_at.isoformat(),
|
||||
chan_hash,
|
||||
cipher_mac,
|
||||
crypted,
|
||||
typename,
|
||||
dest,
|
||||
src,
|
||||
tag,
|
||||
trace_snrs,
|
||||
route,
|
||||
transport_code,
|
||||
scope_body,
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def observation_count(self) -> int:
|
||||
"""Return the total number of overheard packets stored in all the runs.
|
||||
|
||||
@@ -274,13 +274,17 @@ PICOCALC_LYRA = Platform(
|
||||
|
||||
#: The Cardputer Zero of M5Stack. Its display is 320×170 pixels, drawn in cells of 6×12
|
||||
#: (53×14). Under it is a keyboard of 46 keys, and the number keys 4 to 8 are right below
|
||||
#: the display. The hardware was not in hand on 2026-10-04. Thus only ``--platform
|
||||
#: cardputer-zero`` or ``MESHTERM_PLATFORM`` chooses this platform. There is no
|
||||
#: device-tree auto-detection until the device reports its own model string.
|
||||
#: the display. Both models are this platform: the Cardputer Zero and the Cardputer Zero
|
||||
#: Lite. They differ only in parts that MeshTerm does not use (a camera, motion sensors,
|
||||
#: and a microSD card in the box). Only ``--platform cardputer-zero`` or
|
||||
#: ``MESHTERM_PLATFORM`` chooses this platform. There is no device-tree auto-detection,
|
||||
#: because the model string of the handheld is the generic string of its compute module
|
||||
#: ("Raspberry Pi Compute Module 0 Rev 1.0", seen on the device on 2026-10-05). On the
|
||||
#: handheld, MeshTerm's own console (:mod:`meshterm.emulator`) supplies the platform.
|
||||
#:
|
||||
#: It has the same 53 columns as the PicoCalc, so most of the flavour of the PicoCalc
|
||||
#: applies. The new constraint is the number of rows. It has its own F-key lane deck (Fn+4…8,
|
||||
#: with the chips centred over their keys). For now, the deck deals the lanes of the PicoCalc
|
||||
#: with the chips placed as on the PicoCalc). For now, the deck deals the lanes of the PicoCalc
|
||||
#: (JP, 2026-09-30). MeshTerm draws the display itself in RGB565 (:mod:`meshterm.emulator`),
|
||||
#: so the 16-slot palette does not limit it (``truecolor``). These values are provisional
|
||||
#: until MeshTerm measures them on the device: the cadences, which come from the PicoCalc,
|
||||
|
||||
@@ -59,7 +59,8 @@ if TYPE_CHECKING:
|
||||
|
||||
#: The time (seconds) between two battery reads. The charge of a pack changes slowly, thus a
|
||||
#: long interval is sufficient. The interval is also short enough that the charging trend
|
||||
#: (measured over :data:`_TREND_WINDOW_S`) has several samples to use.
|
||||
#: (measured over :data:`_TREND_WINDOW_S`) has several samples to use. After a failed read of
|
||||
#: the host pack, the next read comes sooner (refer to :data:`_HOST_RETRY_S`).
|
||||
POLL_S = 20.0
|
||||
|
||||
#: The minimum level (millivolts) at which a reading counts as a real battery. A device with
|
||||
@@ -96,26 +97,34 @@ _TREND_SAMPLES = int(_TREND_WINDOW_S / POLL_S) + 2
|
||||
#: ``bq27220-0``, both qualify by their type, not by a name in a list.
|
||||
_POWER_SUPPLIES = Path("/sys/class/power_supply")
|
||||
|
||||
#: The number of times that a poll asks for the percent of the pack before it stops, and
|
||||
#: the pause between two requests. The BQ27220 of the Cardputer Zero shares I2C bus 1 with
|
||||
#: the keyboard and the clock. While the Cap on its header has power, that bus is noisy.
|
||||
#: Measurements on the handheld gave these results: one quarter to three quarters of the
|
||||
#: reads of the gauge fail with an I/O error (one in ten of the reads of the clock). With
|
||||
#: the header off, no read fails. Some reads that "succeed" are garbled, for example a
|
||||
#: ``capacity`` of 56414. The ``bq27xxx`` driver of the kernel caches what it read for five
|
||||
#: seconds, a failure or a garbled value the same. Thus a new request before that time gets
|
||||
#: the same answer, and the pause is a little longer than five seconds.
|
||||
_HOST_READ_ATTEMPTS = 3
|
||||
#: The pause (seconds) before the next read of the host pack, after a read that failed or
|
||||
#: that gave a change that a second read must confirm. The BQ27220 of the Cardputer Zero
|
||||
#: shares I2C bus 1 with the keyboard and the clock. While the Cap on its header has power,
|
||||
#: that bus is noisy. The first measurements on the handheld (2026-10-05) gave these
|
||||
#: results: one quarter to three quarters of the reads of the gauge fail with an I/O error
|
||||
#: (one in ten of the reads of the clock). With the header off, no read fails. Some reads
|
||||
#: that "succeed" are garbled, for example a ``capacity`` of 56414. The ``bq27xxx`` driver
|
||||
#: of the kernel caches what it read for five seconds, a failure or a garbled value the
|
||||
#: same. Thus a new read before that time gets the same answer, and the pause is a little
|
||||
#: longer than five seconds.
|
||||
#:
|
||||
#: A failed read does not wait for the full :data:`POLL_S`. On battery with the Cap on, 79%
|
||||
#: of 791 reads failed (2026-10-19). The failures came in groups of up to 110 s, with short
|
||||
#: good periods between them. A poll every 20 s missed most of these periods, and the gauge
|
||||
#: went away. A read every 5.5 s finds them.
|
||||
_HOST_RETRY_S = 5.5
|
||||
|
||||
#: The number of consecutive polls that can fail to read a host pack before its gauge is
|
||||
#: removed. A pack does not change in two minutes. Thus the last reading stays, and the
|
||||
#: gauge does not blink out.
|
||||
_HOST_MISSES_KEPT = 6
|
||||
#: The time (seconds) that the last good reading of the host pack stays while the reads
|
||||
#: after it fail. After this time, the gauge goes away, because the pack is gone, not only
|
||||
#: difficult to read. On 2026-10-19, with the Cap on, the pack of the Cardputer Zero went
|
||||
#: from 96% to 83% in 26 minutes. Thus a reading from ten minutes before is still correct to
|
||||
#: approximately five points. The limit was once 7 failed polls (approximately 3.5 minutes).
|
||||
#: On battery with the Cap on, a group of failures was that long, and the gauge went away.
|
||||
_HOST_STALE_S = 600.0
|
||||
|
||||
#: The maximum change of a percent between two polls that MeshTerm accepts immediately. A
|
||||
#: The maximum change of a percent between two reads that MeshTerm accepts immediately. A
|
||||
#: pack does not gain or lose this much in :data:`POLL_S`. MeshTerm holds a larger step
|
||||
#: until the next poll gives the same value. This catches a garbled read that is in the
|
||||
#: until the next read gives the same value. This catches a garbled read that is in the
|
||||
#: range 0–100 by chance.
|
||||
_HOST_STEP_PCT = 5
|
||||
|
||||
@@ -131,27 +140,22 @@ async def _sysfs(path: Path) -> str:
|
||||
|
||||
|
||||
async def _read_percent(path: Path) -> int:
|
||||
"""The percent of the pack from ``capacity``, asked again while it is not read or garbled.
|
||||
"""The percent of the pack from ``capacity``, read one time.
|
||||
|
||||
Here, an I/O error and a percent outside 0–100 have the same meaning: no reading. The
|
||||
function asks again after :data:`_HOST_RETRY_S`. At that time, the driver reads the
|
||||
gauge again.
|
||||
Here, an I/O error, a value that is not a number, and a percent outside 0–100 have the
|
||||
same meaning: no reading. The poll reads again after :data:`_HOST_RETRY_S`, when the
|
||||
driver reads the gauge again.
|
||||
|
||||
Raises:
|
||||
OSError: When :data:`_HOST_READ_ATTEMPTS` tries gave no percent.
|
||||
OSError: When the read gave no percent.
|
||||
"""
|
||||
for attempt in range(_HOST_READ_ATTEMPTS):
|
||||
if attempt:
|
||||
await asyncio.sleep(_HOST_RETRY_S)
|
||||
try:
|
||||
value = int(await _sysfs(path))
|
||||
except FileNotFoundError:
|
||||
raise # the value does not exist: a new request cannot make it
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if 0 <= value <= 100:
|
||||
return value
|
||||
raise OSError(f"{path} gave no percent")
|
||||
try:
|
||||
value = int(await _sysfs(path))
|
||||
except ValueError as exc:
|
||||
raise OSError(f"{path} gave no number") from exc
|
||||
if not 0 <= value <= 100:
|
||||
raise OSError(f"{path} gave {value}, which is not a percent")
|
||||
return value
|
||||
|
||||
|
||||
def host_supply(root: Path = _POWER_SUPPLIES) -> Path | None:
|
||||
@@ -271,12 +275,15 @@ class BatteryService:
|
||||
self._reading: BatteryReading | None = None
|
||||
#: The recent ``(monotonic_time, millivolts)`` samples, for the charging trend.
|
||||
self._history: deque[tuple[float, int]] = deque(maxlen=_TREND_SAMPLES)
|
||||
#: The number of consecutive reads of the host pack that failed (refer to
|
||||
#: :data:`_HOST_MISSES_KEPT`).
|
||||
self._host_misses = 0
|
||||
#: The monotonic time of the last good read of the host pack (refer to
|
||||
#: :data:`_HOST_STALE_S`).
|
||||
self._host_read_at = 0.0
|
||||
#: A host percent that is too far from the shown percent to accept yet
|
||||
#: (:data:`_HOST_STEP_PCT`).
|
||||
self._host_unconfirmed: int | None = None
|
||||
#: A host charging flag that is not the shown flag, and that the next read must
|
||||
#: confirm before the gauge shows it.
|
||||
self._host_status_unconfirmed: bool | None = None
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
@@ -310,17 +317,22 @@ class BatteryService:
|
||||
await self.stop()
|
||||
|
||||
async def _run(self) -> None:
|
||||
"""Read one time immediately, then tick with no end: a best-effort pass, then a sleep."""
|
||||
"""Read one time immediately, then tick with no end: a best-effort pass, then a sleep.
|
||||
|
||||
Each pass returns the time until the next pass. A pass that raised an error waits
|
||||
for the full :data:`POLL_S`.
|
||||
"""
|
||||
while True:
|
||||
delay = POLL_S
|
||||
try:
|
||||
await self._poll()
|
||||
delay = await self._poll()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - a bad read must not stop the loop
|
||||
self._ctx.log.debug("battery poller: read failed: %s", exc)
|
||||
await asyncio.sleep(POLL_S)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async def _poll(self) -> None:
|
||||
async def _poll(self) -> float:
|
||||
"""One pass: read the pack, estimate the charge, and calculate the charging trend again.
|
||||
|
||||
The platform (``Platform.battery``) sets which pack the pass reads. The PicoCalc
|
||||
@@ -328,13 +340,16 @@ class BatteryService:
|
||||
driver of the kernel. That driver gives a percent and a real charging flag, and no
|
||||
estimate is necessary. The regular platform polls the connected companion over the
|
||||
mesh link.
|
||||
|
||||
Returns:
|
||||
The seconds until the next pass: :data:`POLL_S`, or a shorter time when the host
|
||||
pack must be read again soon (refer to :meth:`_poll_host`).
|
||||
"""
|
||||
if get_platform().battery == "host":
|
||||
await self._poll_host()
|
||||
return
|
||||
return await self._poll_host()
|
||||
ctx = self._ctx
|
||||
if not ctx.is_connected:
|
||||
return
|
||||
return POLL_S
|
||||
device = await ctx.device()
|
||||
info = await device.get_battery()
|
||||
mv = int(info.get("level") or 0)
|
||||
@@ -344,7 +359,7 @@ class BatteryService:
|
||||
# different device.
|
||||
self._reading = None
|
||||
self._history.clear()
|
||||
return
|
||||
return POLL_S
|
||||
now = time.monotonic()
|
||||
self._history.append((now, mv))
|
||||
# When the device gives a charging flag from its firmware (the standard BLE Battery
|
||||
@@ -357,45 +372,65 @@ class BatteryService:
|
||||
percent=battery_percent(mv),
|
||||
charging=charging,
|
||||
)
|
||||
return POLL_S
|
||||
|
||||
async def _poll_host(self) -> None:
|
||||
async def _poll_host(self) -> float:
|
||||
"""Read the pack of the host from sysfs (:func:`host_supply`).
|
||||
|
||||
The driver is a standard ``power_supply``: ``capacity`` is a true percent, and
|
||||
``status`` is a real charging flag (checked in P0). Thus the voltage curve and the
|
||||
trend estimate of the companion path do not apply. A supply that cannot be read
|
||||
(no driver, or no permission) reports *absent*, and the header draws no gauge. But
|
||||
this occurs only after :data:`_HOST_MISSES_KEPT` consecutive polls failed. A failed
|
||||
poll after a good poll keeps the good reading.
|
||||
trend estimate of the companion path do not apply. When there is no such supply, the
|
||||
reading is absent at once, and the header draws no gauge.
|
||||
|
||||
The method asks for the percent again while it cannot be read or is out of range
|
||||
(:func:`_read_percent`). A step that is larger than :data:`_HOST_STEP_PCT` waits
|
||||
for the next poll to confirm it. ``status`` is read from the same driver snapshot
|
||||
as the percent. When ``status`` cannot be read, the charging verdict of the last
|
||||
good reading stays. The percent is the important value, and the flag is only
|
||||
extra information.
|
||||
On a noisy bus (refer to :data:`_HOST_RETRY_S`), a read can fail or give a wrong
|
||||
value. The method guards the reading in three ways:
|
||||
|
||||
* After a failed read, the next read comes after :data:`_HOST_RETRY_S`, not after
|
||||
:data:`POLL_S`. The last good reading stays for :data:`_HOST_STALE_S`, then it
|
||||
goes.
|
||||
* A change of the percent that is larger than :data:`_HOST_STEP_PCT` waits for a
|
||||
second read that gives the same value.
|
||||
* A change of the charging flag waits for a second read that gives the same flag. A
|
||||
garbled read can say ``Charging`` while the pack discharges (seen on 2026-10-19).
|
||||
When ``status`` cannot be read, the flag of the last good reading stays. The
|
||||
percent is the important value, and the flag is only extra information.
|
||||
|
||||
Returns:
|
||||
The seconds until the next pass: :data:`_HOST_RETRY_S` after a failed read or
|
||||
while a change waits for its second read, and :data:`POLL_S` in all other cases.
|
||||
"""
|
||||
supply = host_supply(_POWER_SUPPLIES)
|
||||
if supply is None:
|
||||
self._reading = None
|
||||
return POLL_S
|
||||
now = time.monotonic()
|
||||
shown = self._reading
|
||||
try:
|
||||
if supply is None:
|
||||
raise OSError("no battery power_supply")
|
||||
percent = await _read_percent(supply / "capacity")
|
||||
except OSError:
|
||||
self._host_misses += 1
|
||||
if self._host_misses > _HOST_MISSES_KEPT:
|
||||
if shown is not None and now - self._host_read_at > _HOST_STALE_S:
|
||||
self._reading = None
|
||||
return
|
||||
self._host_misses = 0
|
||||
shown = self._reading
|
||||
return _HOST_RETRY_S
|
||||
if shown is not None and abs(percent - shown.percent) > _HOST_STEP_PCT:
|
||||
if percent != self._host_unconfirmed:
|
||||
self._host_unconfirmed = percent # accepted when the next poll gives it too
|
||||
return
|
||||
self._host_unconfirmed = percent # accepted when the next read gives it too
|
||||
return _HOST_RETRY_S
|
||||
self._host_unconfirmed = None
|
||||
self._host_read_at = now
|
||||
delay = POLL_S
|
||||
try:
|
||||
charging = await _sysfs(supply / "status") == "Charging"
|
||||
except OSError:
|
||||
charging = shown.charging if shown is not None else False
|
||||
else:
|
||||
if shown is None or charging == shown.charging:
|
||||
self._host_status_unconfirmed = None
|
||||
elif charging == self._host_status_unconfirmed:
|
||||
self._host_status_unconfirmed = None # the second read agrees: accept it
|
||||
else:
|
||||
self._host_status_unconfirmed = charging # accepted when the next read agrees
|
||||
charging = shown.charging
|
||||
delay = _HOST_RETRY_S
|
||||
try: # only for information. The header draws the percent and the charging flag.
|
||||
mv = int(await _sysfs(supply / "voltage_now")) // 1000
|
||||
except (OSError, ValueError):
|
||||
@@ -405,6 +440,7 @@ class BatteryService:
|
||||
percent=percent,
|
||||
charging=charging,
|
||||
)
|
||||
return delay
|
||||
|
||||
def _charging(self, now: float) -> bool:
|
||||
"""Infer from the recent trend of the terminal voltage if the pack is charging.
|
||||
|
||||
@@ -19,11 +19,14 @@ MeshTerm always stores them while a device listens.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..core.channels import channel_identity
|
||||
from ..core import reactions
|
||||
from ..core.channels import channel_identity, split_channel_sender
|
||||
from ..core.connection import Unsubscribe
|
||||
from ..core.events import EventKind, MeshEvent
|
||||
from ..core.models import (
|
||||
@@ -49,6 +52,29 @@ _AWAITING_CAP = 16
|
||||
#: Called with a message when its ack arrives after the send stopped its wait for the ack.
|
||||
LateAck = Callable[[ChatMessage], None]
|
||||
|
||||
#: The highest try number that a direct message can carry. The protocol keeps it in two bits.
|
||||
_LAST_ATTEMPT = 3
|
||||
|
||||
|
||||
def _stamp() -> tuple[int, datetime]:
|
||||
"""The timestamp for a message that MeshTerm is about to send, and the same instant as a date.
|
||||
|
||||
MeshTerm gives the timestamp to the device, and does not let the library read the clock
|
||||
a second time. Thus the stored time of the message is exactly the time that the
|
||||
recipients see. A reaction names its target by that time (refer to
|
||||
:mod:`~meshterm.core.reactions`), so the two must agree to the second.
|
||||
"""
|
||||
seconds = int(time.time())
|
||||
return seconds, datetime.fromtimestamp(seconds, tz=timezone.utc)
|
||||
|
||||
|
||||
def _is_reaction(message: Message) -> bool:
|
||||
"""Whether an inbound message is a reaction. A room post never is one (rooms have none)."""
|
||||
if message.is_post:
|
||||
return False
|
||||
body = split_channel_sender(message.text)[1] if message.is_channel else message.text
|
||||
return reactions.parse(body.strip(" \t"), channel=message.is_channel) is not None
|
||||
|
||||
|
||||
def _fallback_channel_id(idx: int | None) -> str:
|
||||
"""The identity of a channel that MeshTerm cannot read.
|
||||
@@ -103,6 +129,9 @@ class ChatService:
|
||||
# that wants to know when its ack arrives (refer to :meth:`_settle_ack`).
|
||||
self._awaiting: OrderedDict[str, tuple[ChatMessage, LateAck | None]] = OrderedDict()
|
||||
self._ack_unsubscribe: Unsubscribe | None = None
|
||||
# The next try number of each direct message that was sent (by its row id). A
|
||||
# retry with ^R sends the same message again: the same timestamp, the next number.
|
||||
self._next_attempt: dict[int, int] = {}
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
@@ -408,6 +437,9 @@ class ChatService:
|
||||
that comes from the slot.
|
||||
* A muted channel (refer to :class:`~meshterm.core.mute_store.MuteStore`). This is
|
||||
the one suppression that the user asks for, not one that the device implies.
|
||||
* A reaction (refer to :mod:`~meshterm.core.reactions`). The transcript shows it on
|
||||
the message that it reacts to, not as a new message. Thus a badge for it would
|
||||
point at nothing new.
|
||||
|
||||
The method asks the device through the session cache. Thus this rule costs nothing
|
||||
for each message, more than the channel read that the identity already made.
|
||||
@@ -422,6 +454,8 @@ class ChatService:
|
||||
Returns:
|
||||
``True`` to increase the unread count of the conversation.
|
||||
"""
|
||||
if _is_reaction(message):
|
||||
return False
|
||||
if message.is_channel:
|
||||
store = getattr(self._ctx, "mute_store", None)
|
||||
if store is not None and store.is_muted(channel_id):
|
||||
@@ -481,7 +515,9 @@ class ChatService:
|
||||
return is_direct_messageable(contact.node_type)
|
||||
return False
|
||||
|
||||
async def _deliver_direct(self, contact: Contact, text: str) -> tuple[bool, list[str]]:
|
||||
async def _deliver_direct(
|
||||
self, contact: Contact, text: str, *, timestamp: int, first_attempt: int = 0
|
||||
) -> tuple[bool, list[str], int]:
|
||||
"""Transmit a direct message, and try again softly until it is acknowledged.
|
||||
|
||||
One logical send makes one first transmission, plus up to
|
||||
@@ -496,13 +532,22 @@ class ChatService:
|
||||
stores the message as unacknowledged. Then Ctrl-R in the chat screen is the retry
|
||||
that the user starts, in addition to these automatic retries.
|
||||
|
||||
Each try carries the same timestamp and the next try number, as the protocol
|
||||
specifies. Thus the recipient sees one message with one timestamp, whichever try
|
||||
arrives. A reaction to it names that timestamp. The try number makes each packet
|
||||
different, so a repeater does not discard a retry as a copy of the first packet. The
|
||||
number stops at 3 (the protocol has two bits for it).
|
||||
|
||||
Args:
|
||||
contact: The recipient.
|
||||
text: The message body.
|
||||
timestamp: The timestamp of the message, the same for each try.
|
||||
first_attempt: The try number of the first transmission.
|
||||
|
||||
Returns:
|
||||
Whether an attempt was acknowledged in its wait, and the ack code of each
|
||||
attempt. A late ack can still carry any of these codes (the device keeps them).
|
||||
Whether an attempt was acknowledged in its wait, the ack code of each attempt (a
|
||||
late ack can still carry any of these codes, because the device keeps them),
|
||||
and the try number for the next retry.
|
||||
|
||||
Raises:
|
||||
Exception: A hard send failure (the companion rejects the send) goes to the
|
||||
@@ -512,12 +557,16 @@ class ChatService:
|
||||
device = await self._ctx.device()
|
||||
attempts = max(0, self._ctx.preferences.direct_message_soft_retries) + 1
|
||||
codes: list[str] = []
|
||||
number = first_attempt
|
||||
for attempt in range(1, attempts + 1):
|
||||
delivery: Delivery = await device.send_direct_message(contact, text)
|
||||
delivery: Delivery = await device.send_direct_message(
|
||||
contact, text, timestamp=timestamp, attempt=min(number, _LAST_ATTEMPT)
|
||||
)
|
||||
number += 1
|
||||
if delivery.code:
|
||||
codes.append(delivery.code)
|
||||
if delivery.acked:
|
||||
return True, codes
|
||||
return True, codes, number
|
||||
if attempt < attempts:
|
||||
self._ctx.log.debug(
|
||||
"chat: DM to %s unacked on try %d/%d; soft-retrying",
|
||||
@@ -525,7 +574,7 @@ class ChatService:
|
||||
attempt,
|
||||
attempts,
|
||||
)
|
||||
return False, codes
|
||||
return False, codes, number
|
||||
|
||||
async def send_direct(
|
||||
self, contact: Contact, text: str, *, on_late_ack: LateAck | None = None
|
||||
@@ -549,7 +598,8 @@ class ChatService:
|
||||
The stored outbound :class:`ChatMessage`. Its ``acked`` shows whether a
|
||||
delivery acknowledgement arrived in the wait, until now.
|
||||
"""
|
||||
acked, codes = await self._deliver_direct(contact, text)
|
||||
seconds, sent_at = _stamp()
|
||||
acked, codes, number = await self._deliver_direct(contact, text, timestamp=seconds)
|
||||
chat = ChatMessage(
|
||||
text=text,
|
||||
outbound=True,
|
||||
@@ -557,10 +607,11 @@ class ChatService:
|
||||
peer=contact.key_prefix or contact.public_key[:12] or None,
|
||||
peer_name=contact.name,
|
||||
acked=acked,
|
||||
created_at=utcnow(),
|
||||
created_at=sent_at,
|
||||
)
|
||||
chat.row_id = self._ctx.repo.record_chat_message(chat, run_id=self._run_id)
|
||||
if not acked:
|
||||
self._next_attempt[chat.row_id] = number
|
||||
self._await_ack(chat, codes, on_late_ack)
|
||||
return chat
|
||||
|
||||
@@ -583,7 +634,13 @@ class ChatService:
|
||||
Returns:
|
||||
The same ``message``, with :attr:`~ChatMessage.acked` updated.
|
||||
"""
|
||||
acked, codes = await self._deliver_direct(contact, message.text)
|
||||
seconds = int(message.created_at.timestamp())
|
||||
first = self._next_attempt.get(message.row_id, 1) if message.row_id is not None else 1
|
||||
acked, codes, number = await self._deliver_direct(
|
||||
contact, message.text, timestamp=seconds, first_attempt=first
|
||||
)
|
||||
if message.row_id is not None:
|
||||
self._next_attempt[message.row_id] = number
|
||||
if message.acked:
|
||||
return message # a late ack for an earlier attempt settled it during this send
|
||||
message.acked = acked
|
||||
@@ -756,7 +813,8 @@ class ChatService:
|
||||
device = await self._ctx.device()
|
||||
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)
|
||||
seconds, sent_at = _stamp()
|
||||
await device.send_channel_in_scope(index, text, asked, timestamp=seconds)
|
||||
chat = ChatMessage(
|
||||
text=text,
|
||||
outbound=True,
|
||||
@@ -764,7 +822,7 @@ class ChatService:
|
||||
channel_id=channel_id,
|
||||
channel_idx=index,
|
||||
peer_name=label,
|
||||
created_at=utcnow(),
|
||||
created_at=sent_at,
|
||||
scope=await self._sent_under(asked),
|
||||
)
|
||||
chat.row_id = self._ctx.repo.record_chat_message(chat, run_id=self._run_id)
|
||||
|
||||
@@ -29,6 +29,8 @@ the header comes on when a delivery during the night is at last complete.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -81,8 +83,11 @@ class CourierService:
|
||||
#: The time when MeshTerm last heard each node id in this session (the freshness
|
||||
#: map).
|
||||
self._heard: dict[str, datetime] = {}
|
||||
#: Re-entry guard: only one try at a time is in progress, from a pass or forced.
|
||||
self._sending = False
|
||||
#: Re-entry guard: only one try at a time is in progress, from a pass or forced. It
|
||||
#: is the id of the entry that the try sends, or ``None`` when no try runs.
|
||||
self._in_flight: int | None = None
|
||||
#: The ids of the entries that the user edits now. A pass does not try them.
|
||||
self._held: set[int] = set()
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
@@ -93,6 +98,24 @@ class CourierService:
|
||||
"""How many messages wait in the outbox."""
|
||||
return self._ctx.courier_store.pending_count()
|
||||
|
||||
def in_flight(self, ident: int) -> bool:
|
||||
"""Whether a delivery try of the entry ``ident`` is in progress now."""
|
||||
return self._in_flight == ident
|
||||
|
||||
@contextmanager
|
||||
def holding(self, ident: int) -> Iterator[None]:
|
||||
"""Keep the passes off one entry while the user edits its body.
|
||||
|
||||
Without the hold, a pass can send the old body while the edit prompt is open. Then
|
||||
the edit changes the text of a message that already went out. A forced try
|
||||
(:meth:`attempt_now`) does not read the hold, because the user asks for it.
|
||||
"""
|
||||
self._held.add(ident)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._held.discard(ident)
|
||||
|
||||
# --- lifecycle ---------------------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
@@ -191,11 +214,11 @@ class CourierService:
|
||||
|
||||
async def _pass(self) -> None:
|
||||
"""One pass: try the oldest eligible entry, if there is one (one for each pass)."""
|
||||
if not self._ctx.is_connected or self._sending:
|
||||
if not self._ctx.is_connected or self._in_flight is not None:
|
||||
return
|
||||
now = utcnow()
|
||||
for message in self._ctx.courier_store.pending():
|
||||
if self.eligible(message, now):
|
||||
if message.ident not in self._held and self.eligible(message, now):
|
||||
await self._attempt(message)
|
||||
return
|
||||
|
||||
@@ -212,14 +235,14 @@ class CourierService:
|
||||
message = self._ctx.courier_store.get(ident)
|
||||
if message is None or message.status != QUEUED:
|
||||
return "gone"
|
||||
if self._sending:
|
||||
if self._in_flight is not None:
|
||||
return "busy"
|
||||
return await self._attempt(message)
|
||||
|
||||
async def _attempt(self, message: QueuedMessage) -> str:
|
||||
"""Run one delivery try, and set the new state of the entry."""
|
||||
store = self._ctx.courier_store
|
||||
self._sending = True
|
||||
self._in_flight = message.ident
|
||||
try:
|
||||
contact = await self._resolve(message)
|
||||
if contact is None:
|
||||
@@ -258,7 +281,7 @@ class CourierService:
|
||||
)
|
||||
return "no ack"
|
||||
finally:
|
||||
self._sending = False
|
||||
self._in_flight = None
|
||||
|
||||
def _delivered_late(self, ident: int) -> None:
|
||||
"""The ack of a try arrived after the try stopped its wait: the message was delivered.
|
||||
|
||||
@@ -16,6 +16,13 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..core import exitcodes
|
||||
|
||||
# The wrapper for a secret parameter, and its reader. They live with the other rules that
|
||||
# keep secrets out of the logs (refer to meshterm.core.secrets). The tools import them
|
||||
# from here, next to Tool and ToolResult.
|
||||
from ..core.secrets import Secret as Secret
|
||||
from ..core.secrets import loggable
|
||||
from ..core.secrets import reveal as reveal
|
||||
|
||||
if TYPE_CHECKING: # do not import typer or the context at module load, for a fast start
|
||||
import typer
|
||||
|
||||
@@ -23,40 +30,6 @@ if TYPE_CHECKING: # do not import typer or the context at module load, for a fa
|
||||
from ..ui.report import Report
|
||||
|
||||
|
||||
class Secret:
|
||||
"""A parameter of a run that the run log must not keep, for example a private key.
|
||||
|
||||
:meth:`Tool.execute` stores the parameters of each run in the ``runs`` table, and logs
|
||||
them at debug level. A secret in the parameters was thus stored as plain text: the key
|
||||
of ``config import-key`` stayed in the database. A secret goes into the parameters in
|
||||
this wrapper. The JSON of the run log and the log line show ``<secret>``, and the tool
|
||||
reads :attr:`value`.
|
||||
|
||||
The class does not derive from ``str``, because ``json.dumps`` writes the value of a
|
||||
``str`` subclass and never calls its ``default`` for it.
|
||||
|
||||
Attributes:
|
||||
value: The secret itself.
|
||||
"""
|
||||
|
||||
__slots__ = ("value",)
|
||||
|
||||
def __init__(self, value: str) -> None:
|
||||
"""Keep ``value`` out of each text form of this object."""
|
||||
self.value = value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""``<secret>``, never the value."""
|
||||
return "<secret>"
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
|
||||
def reveal(value: Any) -> Any:
|
||||
"""The value inside a :class:`Secret`, or ``value`` itself if it is not a secret."""
|
||||
return value.value if isinstance(value, Secret) else value
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolResult:
|
||||
"""The result of a run of a tool.
|
||||
@@ -202,7 +175,9 @@ class Tool(ABC):
|
||||
from ..core.selection import DeviceSelectionError
|
||||
|
||||
run_id = ctx.repo.start_run(self.name, params, ctx.profile_name)
|
||||
ctx.log.debug("run %s start: tool=%s params=%s", run_id, self.name, params)
|
||||
# The copy with each secret hidden: a password, a channel secret, or a PIN in the
|
||||
# parameters must never reach the log (refer to meshterm.core.secrets).
|
||||
ctx.log.debug("run %s start: tool=%s params=%s", run_id, self.name, loggable(params))
|
||||
try:
|
||||
result = await self.run(ctx, {**params, "_run_id": run_id})
|
||||
except (
|
||||
|
||||
@@ -27,7 +27,7 @@ from rich.cells import cell_len
|
||||
from rich.text import Text
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core import exitcodes
|
||||
from ..core import exitcodes, reactions
|
||||
from ..core.channels import (
|
||||
CHANNEL_SLOT_PROBE_CAP,
|
||||
DEFAULT_PUBLIC_SECRET,
|
||||
@@ -58,19 +58,19 @@ from ..services.trace_runner import (
|
||||
from ..ui import renderers
|
||||
from ..ui.fields import ChannelRef, NodeRef
|
||||
from ..ui.menus import Lane, column_header, fit_cells, section_heading
|
||||
from ..ui.pathline import SELF_GLYPH, PathHop
|
||||
from ..ui.termfont import powerline_enabled
|
||||
from ..ui.theme import name_style
|
||||
from ..ui.theme import emoticon, name_style
|
||||
from ..ui.tui import Choice, DeleteRequest, Separator
|
||||
from ..ui.widgets import (
|
||||
DESTINATION,
|
||||
NODE_GLYPHS,
|
||||
ORIGIN,
|
||||
age_seconds,
|
||||
author_chip,
|
||||
channel_glyph,
|
||||
format_age,
|
||||
hop_chip,
|
||||
name_chip,
|
||||
mention_chip,
|
||||
mention_mark,
|
||||
self_chip,
|
||||
)
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
@@ -230,6 +230,10 @@ class ChatTool(Tool):
|
||||
stored_names, named_at = ctx.repo.node_names_with_times()
|
||||
key_of = make_name_key_resolver(contacts, stored_names, heard=named_at)
|
||||
name_of = make_node_resolver(contacts, stored_names)
|
||||
# Our name and key, for our own messages and the mentions of us in the previews.
|
||||
from ..ui.chat import our_node
|
||||
|
||||
us = await our_node(ctx)
|
||||
|
||||
# List the contacts by recency: first the contacts with messages, with the newest
|
||||
# exchange at the top. Then the contacts with no messages, in alphabetical order
|
||||
@@ -248,23 +252,25 @@ class ChatTool(Tool):
|
||||
# Refer to Screen.sticky_rows.
|
||||
items: list = [
|
||||
Separator(lambda w: _picker_header(lanes, w), pinned=True),
|
||||
section_heading("📡 Channels"),
|
||||
section_heading("Channels"),
|
||||
]
|
||||
for conversation in channels:
|
||||
items.append(
|
||||
Choice(
|
||||
title=_row_title(ctx, conversation, live, key_of, lanes),
|
||||
title=_row_title(ctx, conversation, live, key_of, lanes, us=us),
|
||||
value=conversation,
|
||||
# ←→ move only the message. The lanes before it stay (_Lanes.head).
|
||||
hscroll_from=lanes.head,
|
||||
)
|
||||
)
|
||||
|
||||
items.append(section_heading("📌 Rooms"))
|
||||
items.append(section_heading("Rooms"))
|
||||
for conversation in rooms:
|
||||
items.append(
|
||||
Choice(
|
||||
title=_row_title(ctx, conversation, live, key_of, lanes, name_of=name_of),
|
||||
title=_row_title(
|
||||
ctx, conversation, live, key_of, lanes, name_of=name_of, us=us
|
||||
),
|
||||
value=conversation,
|
||||
deletable=lasts.get(conversation.key) is not None,
|
||||
hscroll_from=lanes.head,
|
||||
@@ -275,12 +281,12 @@ class ChatTool(Tool):
|
||||
# a room?", and an empty section tells them where to go.
|
||||
items.append(Separator(" no rooms joined — join one on the Rooms page"))
|
||||
|
||||
items.append(section_heading("👤 Direct"))
|
||||
items.append(section_heading("Direct"))
|
||||
if companions:
|
||||
for conversation in direct:
|
||||
items.append(
|
||||
Choice(
|
||||
title=_row_title(ctx, conversation, live, key_of, lanes),
|
||||
title=_row_title(ctx, conversation, live, key_of, lanes, us=us),
|
||||
value=conversation,
|
||||
# Del offers to delete the stored history of this thread, only
|
||||
# where there is history to delete.
|
||||
@@ -895,6 +901,7 @@ def _row_title(
|
||||
lanes: _Lanes,
|
||||
*,
|
||||
name_of: NodeResolver | None = None,
|
||||
us: tuple[str | None, str | None] = (None, None),
|
||||
) -> Callable[[], str | Text]:
|
||||
"""Return the row title as a *callable* that the select screen renders on each paint.
|
||||
|
||||
@@ -911,11 +918,13 @@ def _row_title(
|
||||
key_of: Maps a sender name back to the key of its node, for the hues of the preview.
|
||||
lanes: The measured lane widths of the list.
|
||||
name_of: Maps the author key of a room post to a name (only for rooms).
|
||||
us: The name and the key of our node (refer to
|
||||
:func:`~meshterm.ui.chat.our_node`), for our own chip in the preview.
|
||||
|
||||
Returns:
|
||||
A callable with no arguments, which makes the current row title.
|
||||
"""
|
||||
return lambda: _title(ctx, conversation, lasts, key_of, lanes, name_of=name_of)
|
||||
return lambda: _title(ctx, conversation, lasts, key_of, lanes, name_of=name_of, us=us)
|
||||
|
||||
|
||||
def _title(
|
||||
@@ -926,6 +935,7 @@ def _title(
|
||||
lanes: _Lanes,
|
||||
*,
|
||||
name_of: NodeResolver | None = None,
|
||||
us: tuple[str | None, str | None] = (None, None),
|
||||
) -> str | Text:
|
||||
"""Make a picker row from lanes of fixed width, with colour codes.
|
||||
|
||||
@@ -935,7 +945,7 @@ def _title(
|
||||
|
||||
- The name of a direct contact gets the palette hue that comes from its key (a channel
|
||||
label keeps the base style).
|
||||
- The first dot is the standard companion pink, and its shape shows the history.
|
||||
- The first dot has the colour of the companion mark, and its shape shows the history.
|
||||
- The unread ``●`` badge is red, and the age is muted.
|
||||
- The preview mutes its body, and lights the sender names and the ``@mentions`` in the
|
||||
hues that come from their keys. These are the same colours as in the live transcript.
|
||||
@@ -951,6 +961,7 @@ def _title(
|
||||
node.
|
||||
lanes: The measured lane widths of the list.
|
||||
name_of: Maps the author key of a room post to a name (only for rooms).
|
||||
us: The name and the key of our node, for our own chip in the preview.
|
||||
|
||||
Returns:
|
||||
The row title as a styled :class:`~rich.text.Text`.
|
||||
@@ -973,19 +984,27 @@ def _title(
|
||||
else:
|
||||
text.append(" " * _BADGE_WIDTH)
|
||||
# The relative age lane (right-aligned) is between the badge and the message text. Thus
|
||||
# the ages are in one neat column, and all the previews start at the same position.
|
||||
# the ages are in one neat column, and all the previews start at the same position. A
|
||||
# last message that names us has our white ``★`` at the left of its age, the same as
|
||||
# the transcript has it at the left of the time (refer to ChatScreen._body_lines).
|
||||
age = _ago(last.created_at) if last is not None else ""
|
||||
text.append(" ")
|
||||
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
|
||||
lane = f" {age:>{_AGE_WIDTH}}"
|
||||
if age and last is not None and _names_us(last, us):
|
||||
mark = len(lane) - len(age) - 2 # the mark, a space, then the age
|
||||
text.append(lane[:mark])
|
||||
text.append_text(mention_mark(us[1]))
|
||||
text.append(lane[mark + 1 :], style="muted")
|
||||
else:
|
||||
text.append(lane, style="muted")
|
||||
text.append(" ")
|
||||
if last is not None:
|
||||
text.append_text(_preview_text(last, key_of, name_of))
|
||||
text.append_text(_preview_text(last, key_of, name_of, us=us))
|
||||
return text
|
||||
|
||||
|
||||
#: The standard companion pink (the shared colour of the plain node ``●``) is the hue of the
|
||||
#: contact dot. The shape (filled or hollow) shows the history, the colour shows "a
|
||||
#: companion", and the name next to it has the hue that comes from the key of the person.
|
||||
#: The colour of the companion mark (the shared colour of the plain node ``●``) is the
|
||||
#: colour of the contact dot. The shape (filled or hollow) shows the history, and the name
|
||||
#: next to it has the hue that comes from the key of the person.
|
||||
_COMPANION_DOT_STYLE = NODE_GLYPHS[NODE_TYPE_CHAT][1]
|
||||
|
||||
#: The marker of a room: the ``■`` of the room server, in the colour of its type. The map
|
||||
@@ -1001,7 +1020,7 @@ def _append_marker(
|
||||
"""Add the first marker of the row (a channel glyph or a contact dot) in its own lane.
|
||||
|
||||
A channel keeps its marker for open or private (# / 🌐 / 🔒). A contact gets a small
|
||||
circle in the standard companion pink. It is filled (``●``) after we exchanged
|
||||
circle in the colour of the companion mark. It is filled (``●``) after we exchanged
|
||||
messages, and a hollow ring (``○``) before that. Thus the shape (hollow or filled)
|
||||
shows whether there is history, and the name itself has the hue that comes from the
|
||||
key of the person.
|
||||
@@ -1025,19 +1044,26 @@ def _append_marker(
|
||||
|
||||
|
||||
def _preview_text(
|
||||
last: ChatMessage, key_of: NameKeyResolver, name_of: NodeResolver | None = None
|
||||
last: ChatMessage,
|
||||
key_of: NameKeyResolver,
|
||||
name_of: NodeResolver | None = None,
|
||||
*,
|
||||
us: tuple[str | None, str | None] = (None, None),
|
||||
) -> Text:
|
||||
"""A muted preview of the last message, with sender names and ``@mentions`` lit in their hue.
|
||||
|
||||
It is the same as the live transcript:
|
||||
|
||||
- Our own messages start with our author (refer to :func:`_append_author`).
|
||||
- Our own messages start with our chip, ``★ Name`` in white (refer to
|
||||
:func:`~meshterm.ui.widgets.self_chip`). A mention of our name has the chip that is
|
||||
white label, with no star. A message that names us has our white ``★`` at the
|
||||
left of the age of the row (refer to :func:`_title`).
|
||||
- In a received channel message, the inline ``Name:`` sender is the author, in the hue
|
||||
that comes from its key (the node grey when no known node has the name).
|
||||
- A room post starts with its author, named by key in the same way as the room screen
|
||||
names authors (:func:`~meshterm.ui.room.author_label`).
|
||||
- Each ``@[Name]`` mention shows as ``@Name``, in the same way (refer to
|
||||
:func:`_append_body`).
|
||||
- Each ``@[Name]`` mention shows as the chip of the node that it names, in the same
|
||||
way (refer to :func:`_append_body`).
|
||||
|
||||
Thus the list and the chat use the same colour language, and where the terminal draws
|
||||
chips, the same chips: the author closes on the point and a mention opens on the notch.
|
||||
@@ -1047,32 +1073,64 @@ def _preview_text(
|
||||
was cut before to approximately the visible lane has nothing more to show. The list
|
||||
cuts it at the right edge, the same as any other row, and the scroll goes past that.
|
||||
"""
|
||||
body_raw = last.text.replace("\n", " ")
|
||||
body_raw = _reaction_line(last) or last.text.replace("\n", " ")
|
||||
text = Text()
|
||||
if last.outbound:
|
||||
_append_author(text, PathHop(SELF_GLYPH, you=True), Text("you: ", style="accent"))
|
||||
_append_body(text, body_raw, key_of)
|
||||
name, key = us
|
||||
chip = self_chip(name, key, role=ORIGIN)
|
||||
# Where no chips are drawn, the chip is a padded block, white on our fill. Our node
|
||||
# with no name keeps the old byline.
|
||||
byline = chip + Text(" ") if name and key else Text("you: ", style="accent")
|
||||
_append_author(text, chip, byline)
|
||||
_append_body(text, body_raw, key_of, us)
|
||||
elif last.is_channel:
|
||||
name, body = split_channel_sender(body_raw)
|
||||
if name is not None:
|
||||
key = key_of(name)
|
||||
_append_author(text, PathHop(name, key=key), _byline(name, key))
|
||||
_append_body(text, body, key_of)
|
||||
_append_author(text, _origin(name, key), _byline(name, key))
|
||||
_append_body(text, body, key_of, us)
|
||||
else:
|
||||
_append_body(text, body_raw, key_of)
|
||||
_append_body(text, body_raw, key_of, us)
|
||||
elif last.author and name_of is not None:
|
||||
from ..ui.room import author_label
|
||||
|
||||
name, key = author_label(last.author, name_of)
|
||||
_append_author(text, PathHop(name, key=key), _byline(name, key))
|
||||
_append_body(text, body_raw, key_of)
|
||||
_append_author(text, _origin(name, key), _byline(name, key))
|
||||
_append_body(text, body_raw, key_of, us)
|
||||
else:
|
||||
_append_body(text, body_raw, key_of)
|
||||
_append_body(text, body_raw, key_of, us)
|
||||
return text
|
||||
|
||||
|
||||
def _append_author(text: Text, hop: PathHop, byline: Text) -> None:
|
||||
"""Append the author of a preview: an origin chip where chips are drawn, else ``byline``.
|
||||
def _reaction_line(last: ChatMessage) -> str | None:
|
||||
"""The preview body of a reaction, ``reacted 👍``, or ``None`` for a usual message.
|
||||
|
||||
A reaction is a message on the wire, and it can be the latest message of a
|
||||
conversation. Its raw body (a mention, a newline, and a hash) means nothing to a person.
|
||||
The name of the node keeps its place in front of the body, as for each message.
|
||||
"""
|
||||
if last.author is not None:
|
||||
return None # a room post: rooms have no reactions
|
||||
body = (
|
||||
split_channel_sender(last.text)[1] if last.is_channel and not last.outbound else last.text
|
||||
)
|
||||
reaction = reactions.parse(body.strip(" \t"), channel=last.is_channel)
|
||||
if reaction is None:
|
||||
return None
|
||||
line = f"reacted {emoticon(reaction.emoji)}"
|
||||
if last.is_channel and not last.outbound:
|
||||
name = split_channel_sender(last.text)[0]
|
||||
return f"{name}: {line}" if name else line
|
||||
return line
|
||||
|
||||
|
||||
def _origin(name: str, key: str | None) -> Text:
|
||||
"""The chip of the author of a preview: it closes on the point, as in the transcript."""
|
||||
return author_chip(name, key)
|
||||
|
||||
|
||||
def _append_author(text: Text, chip: Text, byline: Text) -> None:
|
||||
"""Append the author of a preview: its origin ``chip`` where chips are drawn, else ``byline``.
|
||||
|
||||
The chip closes on the point, the same as the sender chip of the transcript, and the
|
||||
body follows it after one space. The point says what the colon of ``Name:`` says, so
|
||||
@@ -1080,7 +1138,7 @@ def _append_author(text: Text, hop: PathHop, byline: Text) -> None:
|
||||
``byline`` (``Name: ``, or ``you: `` for us), as it always was (JP, 2026-10-10).
|
||||
"""
|
||||
if powerline_enabled():
|
||||
text.append_text(hop_chip(hop, role=ORIGIN))
|
||||
text.append_text(chip)
|
||||
text.append(" ")
|
||||
else:
|
||||
text.append_text(byline)
|
||||
@@ -1093,24 +1151,40 @@ def _byline(name: str, key: str | None) -> Text:
|
||||
return text
|
||||
|
||||
|
||||
def _append_body(text: Text, body: str, key_of: NameKeyResolver) -> None:
|
||||
def _append_body(
|
||||
text: Text,
|
||||
body: str,
|
||||
key_of: NameKeyResolver,
|
||||
us: tuple[str | None, str | None] = (None, None),
|
||||
) -> None:
|
||||
"""Append ``body`` to ``text``, muted, and light the names that it mentions.
|
||||
|
||||
Each ``@[Name]`` is drawn in the hue that comes from the key of that node, as a chip
|
||||
that opens on the notch where the terminal draws chips (the same as in the transcript).
|
||||
It takes the node grey when the name resolves to no node that we know.
|
||||
that opens on the notch where the terminal draws chips (the same as in the transcript,
|
||||
refer to :func:`~meshterm.ui.widgets.mention_chip`). It takes the node grey when the
|
||||
name resolves to no node that we know. A mention of our own name (``us``) has the chip
|
||||
of our key with a white label, as in the transcript.
|
||||
"""
|
||||
pos = 0
|
||||
for match in MENTION.finditer(body):
|
||||
if match.start() > pos:
|
||||
text.append(body[pos : match.start()], style="muted")
|
||||
name = match.group(1)
|
||||
text.append_text(name_chip(f"@{name}", key_of(name), role=DESTINATION))
|
||||
if us[0] and name == us[0]:
|
||||
text.append_text(mention_chip(name, us[1], you=True))
|
||||
else:
|
||||
text.append_text(mention_chip(name, key_of(name)))
|
||||
pos = match.end()
|
||||
if pos < len(body):
|
||||
text.append(body[pos:], style="muted")
|
||||
|
||||
|
||||
def _names_us(last: ChatMessage, us: tuple[str | None, str | None]) -> bool:
|
||||
"""Whether the message ``last`` has a mention of our name (``us``)."""
|
||||
name = us[0]
|
||||
return bool(name) and any(match.group(1) == name for match in MENTION.finditer(last.text))
|
||||
|
||||
|
||||
def _ago(when: Any) -> str:
|
||||
"""The column age for a message time, through the only grammar (``format_age``).
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ from ..core.device_config import (
|
||||
parse_value,
|
||||
settings_by_category,
|
||||
)
|
||||
from ..core.privatekey import normalize, parse_private_key, read_private_key_file
|
||||
from ..core.privatekey import checked, parse_private_key, read_private_key_file
|
||||
from ..services.clock_sync import set_clock
|
||||
from .base import Secret, Tool, ToolResult, register, reveal
|
||||
|
||||
@@ -320,9 +320,9 @@ async def apply_ops(
|
||||
elif kind == "export_key":
|
||||
report.append(await _export_key(ctx, device, op[1] if len(op) > 1 else None, artifacts))
|
||||
elif kind == "import_key":
|
||||
# The callers check the key before they confirm. This expands a seed that a
|
||||
# caller gives as it is, because a firmware companion accepts only 64 bytes.
|
||||
await device.import_private_key(normalize(bytes.fromhex(reveal(op[1]))).hex())
|
||||
# The callers check the key before they confirm. The key goes on in the form
|
||||
# that the user gave: each device takes the form that it keeps.
|
||||
await device.import_private_key(checked(bytes.fromhex(reveal(op[1]))).hex())
|
||||
if device.import_restarts:
|
||||
ctx.ui.ack("[ok]✓[/ok] private key imported — the node uses it from its next start")
|
||||
else:
|
||||
|
||||
@@ -17,8 +17,8 @@ On the CLI, subcommands give script access to the same outbox:
|
||||
- ``send`` forces one delivery try now.
|
||||
- ``list``, ``cancel``, and ``clear`` examine the outbox and remove entries from it.
|
||||
|
||||
These subcommands are the script form of the Send-now, Cancel, and Clear-finished actions
|
||||
of the outbox screen.
|
||||
These subcommands are the script form of the actions of the outbox screen: Send now
|
||||
(``^R``), Delete (``cancel`` deletes only a waiting entry), and Clear finished.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -28,10 +28,18 @@ The tool asks the device, but the device is not necessary. A report about a radi
|
||||
does not connect is the report that it is most necessary to file. Thus, when the tool
|
||||
cannot get to the device, that failure becomes a fact in the block (``connected`` and
|
||||
``error``), and not an error that replaces the block.
|
||||
|
||||
**Anonymized on request.** Some facts are about the machine of the user, and not about a
|
||||
secret: the home folder in ``config_dir``, and the address of a radio or a host that an
|
||||
``error`` can repeat. The ``anonymize`` preference masks them, and keeps a tidbit of each
|
||||
one (refer to :mod:`meshterm.core.anonymize`). ``--anonymize`` and ``--no-anonymize``
|
||||
override the preference for one run, and the page has a key that does the same. The
|
||||
``anonymized`` fact tells the person who reads the report which form it is.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -43,6 +51,9 @@ from .base import Tool, ToolResult, register
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Report
|
||||
|
||||
#: The fact that tells whether the personal details of the report are masked.
|
||||
ANONYMIZED = "anonymized"
|
||||
|
||||
#: The name of the saved file, in the config directory, next to the log that it goes with
|
||||
#: in a bug report. The name includes the app name, and is not only ``diagnostics.md``.
|
||||
#: The reason: the only thing that occurs to this file is that a person moves it to a
|
||||
@@ -63,13 +74,17 @@ class DiagnosticsTool(Tool):
|
||||
order = 7 # after Preferences (the row that changes MeshTerm), and before the pages
|
||||
|
||||
def register_cli(self, app: Any) -> None:
|
||||
"""Register the command, with the one option that changes the answer into a file.
|
||||
"""Register the command, with the option that writes a file and the mask override.
|
||||
|
||||
``--out`` has the same spelling as ``config export-key --out``. We did not make a
|
||||
second word to write an answer to a path. Plain redirection continues to work, and
|
||||
the option does not replace it. The option adds a file whose columns have the full
|
||||
width, and not the width that the terminal had at that time. That is the
|
||||
difference between an attachment that a person can read and a folded one.
|
||||
|
||||
``--anonymize`` and ``--no-anonymize`` override the ``anonymize`` preference for
|
||||
this run, in the way that ``--absolute`` overrides ``cli_time_format``. With
|
||||
neither, the preference decides.
|
||||
"""
|
||||
import typer
|
||||
|
||||
@@ -80,8 +95,13 @@ class DiagnosticsTool(Tool):
|
||||
out: Path | None = typer.Option(
|
||||
None, "--out", help="Write the block to this file instead of printing it"
|
||||
),
|
||||
anonymize: bool | None = typer.Option(
|
||||
None,
|
||||
"--anonymize/--no-anonymize",
|
||||
help="Mask the home folder and addresses (default: the anonymize preference)",
|
||||
),
|
||||
) -> None:
|
||||
run_tool_command(self, {"out": out})
|
||||
run_tool_command(self, {"out": out, "anonymize": anonymize})
|
||||
|
||||
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
|
||||
"""Open the page in the menu. The return value ``None`` ends with no run row.
|
||||
@@ -91,6 +111,10 @@ class DiagnosticsTool(Tool):
|
||||
person looked at their own version number, the log gets a line for each look. A
|
||||
save from the page is also not a run.
|
||||
|
||||
The page gets both forms of the document, so that its key can change from one to
|
||||
the other with no second read of the device. It opens in the form that the
|
||||
``anonymize`` preference gives.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context.
|
||||
|
||||
@@ -99,8 +123,15 @@ class DiagnosticsTool(Tool):
|
||||
"""
|
||||
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))
|
||||
report = await build_report(ctx, anonymize=False)
|
||||
when = datetime.now().astimezone()
|
||||
await open_diagnostics_page(
|
||||
ctx,
|
||||
markdown_source(report, when=when),
|
||||
save_path=default_path(ctx),
|
||||
anonymized_source=markdown_source(anonymized(report), when=when),
|
||||
anonymized=_anonymizes(ctx),
|
||||
)
|
||||
return None
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
@@ -113,11 +144,13 @@ class DiagnosticsTool(Tool):
|
||||
Args:
|
||||
ctx: The shared application context.
|
||||
params: ``out``: a destination path, or ``None`` to print the block.
|
||||
``anonymize``: ``True`` or ``False`` to override the preference, or
|
||||
``None`` to obey it.
|
||||
|
||||
Returns:
|
||||
A :class:`ToolResult` that holds the block, or the path of the written file.
|
||||
"""
|
||||
report = await build_report(ctx)
|
||||
report = await build_report(ctx, anonymize=params.get("anonymize"))
|
||||
out = params.get("out")
|
||||
if out is None:
|
||||
return ToolResult(summary={"tool": self.name}, report=report)
|
||||
@@ -131,7 +164,7 @@ class DiagnosticsTool(Tool):
|
||||
)
|
||||
|
||||
|
||||
async def build_report(ctx: AppContext) -> Report:
|
||||
async def build_report(ctx: AppContext, *, anonymize: bool | None = None) -> Report:
|
||||
"""Collect all the diagnostic facts, as the report that the two faces render.
|
||||
|
||||
Seven blocks, in the order that is most useful to the user: what this program is, the
|
||||
@@ -147,11 +180,13 @@ async def build_report(ctx: AppContext) -> Report:
|
||||
|
||||
Args:
|
||||
ctx: The shared application context.
|
||||
anonymize: ``True`` to mask the personal details (refer to :func:`anonymized`),
|
||||
``False`` to keep them, or ``None`` to obey the ``anonymize`` preference.
|
||||
|
||||
Returns:
|
||||
The report of seven blocks.
|
||||
"""
|
||||
return (
|
||||
report = (
|
||||
_app_facts(),
|
||||
_host_facts(),
|
||||
_terminal_facts(),
|
||||
@@ -160,6 +195,55 @@ async def build_report(ctx: AppContext) -> Report:
|
||||
_table_listing(ctx),
|
||||
_preference_listing(ctx),
|
||||
)
|
||||
masks = _anonymizes(ctx) if anonymize is None else anonymize
|
||||
return anonymized(report) if masks else report
|
||||
|
||||
|
||||
def anonymized(report: Report) -> Report:
|
||||
"""The same report, with each personal detail masked and the ``anonymized`` fact on.
|
||||
|
||||
The mask goes through each text value of each block, and does not choose fields. A
|
||||
field that someone adds later is masked on the day that it arrives, and nobody must
|
||||
remember to add it to a list. A value keeps its type: a path stays a
|
||||
:class:`~pathlib.Path`, so its lane renders it as before. Each value that is not text
|
||||
(a count, a flag, an instant) stays as it is.
|
||||
|
||||
Args:
|
||||
report: A report from :func:`build_report`.
|
||||
|
||||
Returns:
|
||||
The masked report. ``report`` itself does not change.
|
||||
"""
|
||||
from ..core.anonymize import anonymize
|
||||
from ..ui.report import Facts, Listing
|
||||
|
||||
def mask(value: Any) -> Any:
|
||||
if isinstance(value, Path):
|
||||
return Path(anonymize(str(value)))
|
||||
if isinstance(value, str):
|
||||
return anonymize(value)
|
||||
return value
|
||||
|
||||
blocks: list[Any] = []
|
||||
for block in report:
|
||||
if isinstance(block, Facts):
|
||||
values = {key: mask(value) for key, value in block.values.items()}
|
||||
if ANONYMIZED in values:
|
||||
values[ANONYMIZED] = True
|
||||
blocks.append(replace(block, values=values))
|
||||
elif isinstance(block, Listing):
|
||||
rows = [{key: mask(value) for key, value in row.items()} for row in block.rows]
|
||||
blocks.append(replace(block, rows=rows))
|
||||
else:
|
||||
blocks.append(block)
|
||||
return tuple(blocks)
|
||||
|
||||
|
||||
def _anonymizes(ctx: AppContext) -> bool:
|
||||
"""Whether the ``anonymize`` preference asks for the masked report."""
|
||||
from ..core.preferences import current
|
||||
|
||||
return bool((ctx.preferences or current()).anonymize)
|
||||
|
||||
|
||||
def markdown_source(report: Report, *, when: datetime | None = None) -> str:
|
||||
@@ -443,6 +527,7 @@ def _mesh_facts(ctx: AppContext) -> Any:
|
||||
caption="Storage",
|
||||
fields=(
|
||||
fields.path("config_dir", "config_dir"),
|
||||
fields.flag(ANONYMIZED, ANONYMIZED),
|
||||
fields.integer("db_size_kb", "db_size_kb"),
|
||||
fields.word("log_level", "log_level"),
|
||||
fields.integer("log_size_kb", "log_size_kb"),
|
||||
@@ -452,6 +537,9 @@ def _mesh_facts(ctx: AppContext) -> Any:
|
||||
),
|
||||
values={
|
||||
"config_dir": ctx.settings.config_dir,
|
||||
# Next to the value that it changes most. :func:`anonymized` sets it, so that a
|
||||
# maintainer who reads `j*********u` knows that it is a mask and not a fault.
|
||||
ANONYMIZED: False,
|
||||
"db_size_kb": _size_kb(db_path),
|
||||
"log_level": preferences.log_level,
|
||||
# How much the log holds. Thus a maintainer who asks for it knows what to expect.
|
||||
|
||||
@@ -190,7 +190,7 @@ async def _status_panel(device: Device, snapshot: dict) -> Panel:
|
||||
body.append_text(value)
|
||||
if not rows:
|
||||
body = Text("no status reported", style="muted")
|
||||
return Panel(body, title="[accent]Status[/accent]", border_style="accent", expand=False)
|
||||
return Panel(body, title="[title.border]Status[/]", border_style="border", expand=False)
|
||||
|
||||
|
||||
def _firmware(info: dict) -> str:
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The compose line of a chat: a line editor that shows each mention as a chip.
|
||||
|
||||
The reply flow puts an ``@[Name]`` token in the compose line at the cursor (refer to
|
||||
:meth:`~meshterm.ui.chat.ChatScreen._begin_reply`), and the user can also type one. When
|
||||
the ``]`` completes a token, the line shows it as the chip of the node that it names: the
|
||||
same chip as in the transcript (refer to :func:`~meshterm.ui.widgets.mention_chip`). After
|
||||
that, the chip is one atom. The cursor moves over it in one step, and Backspace or Delete
|
||||
removes all of the mention. The text of the message does not change. The line sends the
|
||||
tokens as they are, and the byte gauge counts them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from .pathline import PathHop
|
||||
from .tui.prompt import LineEditor
|
||||
from .widgets import DESTINATION, hop_chip
|
||||
|
||||
#: A complete mention in the compose line. It is :data:`~meshterm.core.channels.MENTION`
|
||||
#: with one more rule: the name has no ``[``. Thus an ``@[`` that the user types in front
|
||||
#: of a chip does not join the chip into one long token, and the chip stays as it is.
|
||||
ATOM = re.compile(r"@\[([^\[\]]{1,20})\]")
|
||||
|
||||
#: The actions that move the cursor to the left. A cursor that one of them puts inside a
|
||||
#: chip goes to the start of the chip. Each other action puts it at the end.
|
||||
_BACKWARD = frozenset({"left", "ctrl_left"})
|
||||
|
||||
|
||||
class MentionEditor(LineEditor):
|
||||
"""The line editor of a chat. Each complete ``@[Name]`` token in it is one atom.
|
||||
|
||||
The cursor is never inside an atom. ← and → go over an atom in one step, and a word
|
||||
jump (^← and ^→) that stops inside one goes to its start or to its end. Backspace at
|
||||
the end of an atom, and Delete at its start, remove all of it. A typed ``]`` that
|
||||
completes a token makes it an atom immediately, with the cursor after it.
|
||||
"""
|
||||
|
||||
def atoms(self) -> list[tuple[int, int, str]]:
|
||||
"""The atoms of the line, from left to right, as ``(start, end, name)``."""
|
||||
return [(m.start(), m.end(), m.group(1)) for m in ATOM.finditer(self.text)]
|
||||
|
||||
def insert(self, run: str) -> None:
|
||||
"""Insert ``run`` at the cursor, as typed text, and put the cursor after it."""
|
||||
self.edit("text", run)
|
||||
|
||||
def edit(self, action: str, data: str = "") -> bool:
|
||||
"""Apply an edit action, with each atom as one character. Return ``True`` if it acted.
|
||||
|
||||
Args:
|
||||
action: The normalized key action.
|
||||
data: The typed character, or the pasted run.
|
||||
|
||||
Returns:
|
||||
``True`` if the action was an edit action that this method handled.
|
||||
"""
|
||||
atoms = self.atoms()
|
||||
if action == "backspace":
|
||||
for start, end, _name in atoms:
|
||||
if end == self.cursor:
|
||||
return self._remove(start, end)
|
||||
elif action == "delete":
|
||||
for start, end, _name in atoms:
|
||||
if start == self.cursor:
|
||||
return self._remove(start, end)
|
||||
elif action == "left":
|
||||
for start, end, _name in atoms:
|
||||
if end == self.cursor:
|
||||
self.cursor = start
|
||||
return True
|
||||
elif action == "right":
|
||||
for start, end, _name in atoms:
|
||||
if start == self.cursor:
|
||||
self.cursor = end
|
||||
return True
|
||||
if not super().edit(action, data):
|
||||
return False
|
||||
# The edit can complete a token around the cursor (a typed ``]``, or an ``@`` in
|
||||
# front of ``[Name]``), and a word jump can stop at a space inside a name. Thus the
|
||||
# cursor goes out of the atom, in the direction of the action.
|
||||
for start, end, _name in self.atoms():
|
||||
if start < self.cursor < end:
|
||||
self.cursor = start if action in _BACKWARD else end
|
||||
return True
|
||||
|
||||
def _remove(self, start: int, end: int) -> bool:
|
||||
"""Remove the atom at ``[start, end)`` and put the cursor where it was."""
|
||||
self.text = self.text[:start] + self.text[end:]
|
||||
self.cursor = start
|
||||
return True
|
||||
|
||||
def render_line(
|
||||
self, hop_of: Callable[[str], PathHop], *, overflow_at: int | None = None
|
||||
) -> Text:
|
||||
"""Render the line, with each atom as the chip of the node that it names.
|
||||
|
||||
The text has the ``compose`` white, and the ``›`` has the ``cursor`` white. When the
|
||||
cursor is at the start of an atom, the chip is the white chip of an insertion slot
|
||||
(refer to :attr:`~meshterm.ui.pathline.PathHop.cursor`). It is the cursor block of
|
||||
the line, as wide as the atom: Delete removes it all. A chip that goes past the byte
|
||||
limit is red, the same as each character past the limit.
|
||||
|
||||
Args:
|
||||
hop_of: Makes the hop of a mention from its name (refer to
|
||||
:func:`~meshterm.ui.widgets.mention_hop`).
|
||||
overflow_at: The index of the first character past the byte limit, or ``None``.
|
||||
|
||||
Returns:
|
||||
The line as one :class:`Text`. Each chip in it has the
|
||||
:data:`~meshterm.ui.pathline.PATH_INK` span, so that a wrap can keep it whole.
|
||||
"""
|
||||
# The style goes on the ``›`` alone. The base style of a Text applies to all of the
|
||||
# line, and the typed text took the colour of the ``›`` when it was there.
|
||||
text = Text()
|
||||
text.append("› ", style="cursor")
|
||||
pos = 0
|
||||
for start, end, name in self.atoms():
|
||||
self._append_run(text, self.text, pos, start, overflow_at, "compose")
|
||||
hop = hop_of(name)
|
||||
if self.cursor == start:
|
||||
hop = replace(hop, cursor=True, white_ink=False)
|
||||
elif overflow_at is not None and end > overflow_at:
|
||||
hop = replace(hop, style="err", white_ink=False)
|
||||
text.append_text(hop_chip(hop, role=DESTINATION))
|
||||
pos = end
|
||||
self._append_run(text, self.text, pos, len(self.text), overflow_at, "compose")
|
||||
if self.cursor >= len(self.text): # the cursor after the last character
|
||||
text.append(" ", style="reverse")
|
||||
return text
|
||||
@@ -38,7 +38,7 @@ from typing import Any
|
||||
from rich.cells import cell_len
|
||||
from rich.text import Text
|
||||
|
||||
from .marks import SELF_MARK
|
||||
from .marks import LOCK_ICON, SELF_MARK
|
||||
from .menus import fit_cells
|
||||
from .theme import glyph, name_style
|
||||
from .tui.select import Choice, SelectScreen, Separator
|
||||
@@ -134,14 +134,6 @@ TRACE_LANES: tuple[ContactLane, ...] = (TRACED_LANE, HEARD_LANE, PKTS_LANE)
|
||||
#: what the contact still transmits. It is about when the contact left.
|
||||
ARCHIVED_LANES: tuple[ContactLane, ...] = (ARCHIVED_LANE,)
|
||||
|
||||
#: The padlock at the start of the row of a locked contact (refer to
|
||||
#: :attr:`ContactRow.locked`). It is the private-channel ``🔒`` of the icon lexicon, because
|
||||
#: it means the same thing (this item is closed to something). On the PicoCalc,
|
||||
#: :func:`~meshterm.ui.theme.glyph` gives the hand-drawn padlock ``⚿`` of the font. An
|
||||
#: unlocked contact draws nothing. The mark is an exception that the user must notice, and a
|
||||
#: lane of open padlocks makes the few closed padlocks hard to see.
|
||||
_LOCK_ICON = "🔒"
|
||||
|
||||
|
||||
def _lock_lane_width(rows: list[ContactRow]) -> int:
|
||||
"""The width of the lock column in cells: the platform padlock and its gap, or ``0``.
|
||||
@@ -152,7 +144,7 @@ def _lock_lane_width(rows: list[ContactRow]) -> int:
|
||||
"""
|
||||
if not any(row.locked for row in rows):
|
||||
return 0
|
||||
return cell_len(glyph(_LOCK_ICON)) + 1
|
||||
return cell_len(glyph(LOCK_ICON)) + 1
|
||||
|
||||
|
||||
def _lock_cell(row: ContactRow, lock_w: int) -> str:
|
||||
@@ -161,7 +153,7 @@ def _lock_cell(row: ContactRow, lock_w: int) -> str:
|
||||
return ""
|
||||
if not row.locked:
|
||||
return " " * lock_w
|
||||
mark = glyph(_LOCK_ICON)
|
||||
mark = glyph(LOCK_ICON)
|
||||
return mark + " " * (lock_w - cell_len(mark))
|
||||
|
||||
|
||||
@@ -244,7 +236,7 @@ class ContactRow:
|
||||
a muted ``?``.
|
||||
node_type: The node type of the contact, for the leading glyph (``None`` = the
|
||||
plain node ``●``). The row of our node ignores it, and always starts with the
|
||||
yellow ``★``.
|
||||
white ``★``.
|
||||
last_seen: When MeshTerm last heard the contact (aware UTC). It fills the heard
|
||||
lane, in the colour of the recency heat. ``None`` shows ``never`` in the cold
|
||||
style.
|
||||
@@ -361,7 +353,7 @@ def _you_lane(
|
||||
"""The lane of our node, with the same layout as the usual rows of :func:`_lane`.
|
||||
|
||||
It is drawn as the map and the static table draw our node: the ``★`` self marker
|
||||
(yellow), the name in the pure-white ``you`` style with a muted ``(you)`` tag, and our
|
||||
(white), the name in the pure-white ``you`` style with a muted ``(you)`` tag, and our
|
||||
key with its hash lit at the routing width, in the flexible key lane. Our 64-hex key is
|
||||
longer than any lane, so it shows as many digits as fit, and then an ellipsis. **Each
|
||||
middle lane shows a faint ``—``**, whatever lanes the list declares. MeshTerm never
|
||||
|
||||
@@ -9,7 +9,8 @@ type-to-filter. Thus the whole app has one contact list, for each source of data
|
||||
highlighted node (:mod:`~meshterm.ui.node_detail_screen`). That page shows the identity of
|
||||
the node, a preview of its location, the routes that MeshTerm observed to it, and the ways
|
||||
to go further (trace, map, Time Machine). When the user backs out of that page, the list is
|
||||
as it was. ^L opens the map on the highlighted node, when it has a location. The one-shot
|
||||
as it was. ^L opens the map on the highlighted node, when it has a location. ^K locks the
|
||||
highlighted contact against the archive sweep, or unlocks it. The one-shot
|
||||
CLI (``meshterm contacts --sort …``) still renders the static
|
||||
:func:`~meshterm.ui.widgets.contacts_table`. Only the menu gets the live list.
|
||||
|
||||
@@ -44,7 +45,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from .archived_screen import open_archived
|
||||
from .contactlist import ContactListScreen, ContactRow
|
||||
from .menus import icon_lane, marked_label
|
||||
from .menus import icon_lane, marked_label, splice_if_room
|
||||
from .sweep_screen import archive_contacts
|
||||
from .tui import Choice, KeyRequest, Separator
|
||||
from .tui.screen import CANCEL
|
||||
@@ -79,32 +80,31 @@ class ContactsScreen(ContactListScreen):
|
||||
Enter resolves the highlighted row, our node (:data:`YOU`) or a
|
||||
:class:`~meshterm.core.models.Contact`, so that :func:`open_contacts` opens it in the
|
||||
Node detail page. ^L resolves a :class:`~meshterm.ui.tui.select.KeyRequest` with the
|
||||
marker of the highlighted node, so that :func:`open_contacts` opens the map on it. The
|
||||
shared list handles the sort, the filter, and the navigation.
|
||||
marker of the highlighted node, so that :func:`open_contacts` opens the map on it. ^K
|
||||
resolves one with the highlighted contact, so that :func:`open_contacts` toggles its
|
||||
lock. The shared list handles the sort, the filter, and the navigation.
|
||||
"""
|
||||
|
||||
@property
|
||||
def picocalc_lyra_lane(self):
|
||||
"""The lane of the contact list, with ``Map`` (^L) on the Shift half of F1.
|
||||
"""The lane of the contact list, with ``Map`` (^L) and ``Lock`` (^K) on the Shift bank.
|
||||
|
||||
This is the key of ``Map`` in the chat too. F1 itself keeps ``Sect ↑`` when the
|
||||
list has sections (the contacts, and the archive rows under them), and it stays
|
||||
empty when the list has none. ``Map`` is lit while the highlighted node has a
|
||||
location.
|
||||
``Map`` is on the Shift half of F1. F1 itself keeps ``Sect ↑`` when the list has
|
||||
sections (the contacts, and the archive rows under them), and it stays empty when
|
||||
the list has none. ``Map`` is lit while the highlighted node has a location.
|
||||
|
||||
``Lock`` is on the Shift half of F2, behind ``Sect ↓``: the same key as in a channel
|
||||
and a DM. It toggles the lock of the highlighted contact, so it reads ``Unlock`` on
|
||||
a locked contact. It is lit on a contact that can have a lock, and dim on our node
|
||||
and on the rows of the tail. It is empty where MeshTerm cannot store a lock.
|
||||
"""
|
||||
from .tui.fkeys import FPair
|
||||
|
||||
lane = list(super().picocalc_lyra_lane)
|
||||
if self._locator is not None:
|
||||
sect = lane[0]
|
||||
lane[0] = FPair(
|
||||
sect.label if sect else "",
|
||||
sect.action if sect else "",
|
||||
"Map",
|
||||
"locate",
|
||||
enabled=sect.enabled if sect else False,
|
||||
opp_enabled=self._highlighted_marker() is not None,
|
||||
)
|
||||
lane[0] = _with_opp(lane[0], "Map", "locate", self._highlighted_marker() is not None)
|
||||
if self._can_lock:
|
||||
target = self._lock_target()
|
||||
label = "Unlock" if target is not None and target[1] else "Lock"
|
||||
lane[1] = _with_opp(lane[1], label, "lock", target is not None)
|
||||
return lane
|
||||
|
||||
def __init__(
|
||||
@@ -118,6 +118,7 @@ class ContactsScreen(ContactListScreen):
|
||||
archived: int = 0,
|
||||
locked: frozenset[str] = frozenset(),
|
||||
locator: Locator | None = None,
|
||||
can_lock: bool = False,
|
||||
) -> None:
|
||||
"""Create the contacts screen over contact data that MeshTerm already got.
|
||||
|
||||
@@ -142,9 +143,13 @@ class ContactsScreen(ContactListScreen):
|
||||
archiving. Each of those rows ends its name lane with a padlock.
|
||||
locator: The located nodes, for ^L: the map on the highlighted node (refer to
|
||||
:class:`~meshterm.ui.map_screen.Locator`). With ``None``, ^L does nothing.
|
||||
can_lock: Whether the caller can store a lock, for ^K. With ``False``, ^K does
|
||||
nothing.
|
||||
"""
|
||||
self._self_name, self._self_key = self_name, self_key
|
||||
self._locator = locator
|
||||
self._can_lock = can_lock
|
||||
self._locked = locked
|
||||
self._contacts, self._counts = contacts, counts
|
||||
rows = self._contact_rows_for(locked)
|
||||
# The maintenance actions close the list, after each contact, for each sort. First
|
||||
@@ -224,13 +229,46 @@ class ContactsScreen(ContactListScreen):
|
||||
current = self._current_choice()
|
||||
return self._marker_of(current.value) if current is not None else None
|
||||
|
||||
def _lock_target(self) -> tuple[Contact, bool] | None:
|
||||
"""The contact that ^K locks or unlocks, and whether it is locked now.
|
||||
|
||||
``None`` when ^K does nothing: on our node, on a row of the tail, on a contact with
|
||||
no full key (the store attaches a lock to the full key), or when the caller cannot
|
||||
store a lock.
|
||||
"""
|
||||
current = self._current_choice()
|
||||
if not self._can_lock or current is None:
|
||||
return None
|
||||
contact = current.value
|
||||
key = (getattr(contact, "public_key", None) or "").lower().removeprefix("0x")
|
||||
if contact == YOU or len(key) != 64:
|
||||
return None
|
||||
return contact, key in self._locked
|
||||
|
||||
@property
|
||||
def footer_hint(self) -> str:
|
||||
"""The hint of the list, with ``^K lock`` or ``^K unlock`` where it has room for it."""
|
||||
hint = super().footer_hint
|
||||
target = self._lock_target()
|
||||
if target is None:
|
||||
return hint
|
||||
return splice_if_room(hint, "^K unlock" if target[1] else "^K lock")
|
||||
|
||||
def handle(self, action: str, data: str = "") -> None:
|
||||
"""^L asks :func:`open_contacts` to open the map. The list handles all other actions."""
|
||||
"""^L asks :func:`open_contacts` to open the map, and ^K to toggle the lock.
|
||||
|
||||
The list handles all other actions.
|
||||
"""
|
||||
if action == "locate":
|
||||
marker = self._highlighted_marker()
|
||||
if marker is not None:
|
||||
self.resolve(KeyRequest("locate", marker))
|
||||
return
|
||||
if action == "lock":
|
||||
target = self._lock_target()
|
||||
if target is not None:
|
||||
self.resolve(KeyRequest("lock", target[0]))
|
||||
return
|
||||
super().handle(action, data)
|
||||
|
||||
def refresh_locks(self, locked: frozenset[str]) -> None:
|
||||
@@ -241,9 +279,28 @@ class ContactsScreen(ContactListScreen):
|
||||
new screen. The highlight, the sort, the scroll, and a typed filter all stay where
|
||||
they were.
|
||||
"""
|
||||
self._locked = locked
|
||||
self.update_rows(self._contact_rows_for(locked))
|
||||
|
||||
|
||||
def _with_opp(slot, label: str, action: str, enabled: bool): # noqa: ANN001, ANN202 - an FPair
|
||||
"""``slot`` (an F-key slot, or ``None``) with ``label`` and ``action`` as its Shift half.
|
||||
|
||||
The plain half stays as the list gave it: ``Sect ↑`` or ``Sect ↓`` when the list has
|
||||
sections, or an empty half when it has none.
|
||||
"""
|
||||
from .tui.fkeys import FPair
|
||||
|
||||
return FPair(
|
||||
slot.label if slot else "",
|
||||
slot.action if slot else "",
|
||||
label,
|
||||
action,
|
||||
enabled=slot.enabled if slot else False,
|
||||
opp_enabled=enabled,
|
||||
)
|
||||
|
||||
|
||||
async def open_contacts(
|
||||
ctx: AppContext,
|
||||
self_name: str,
|
||||
@@ -302,6 +359,7 @@ async def open_contacts(
|
||||
archived=_archived_count(ctx, self_key),
|
||||
locked=_locked_keys(ctx, self_key),
|
||||
locator=locator,
|
||||
can_lock=_can_lock(ctx, self_key),
|
||||
)
|
||||
|
||||
screen = build()
|
||||
@@ -312,6 +370,13 @@ async def open_contacts(
|
||||
chosen = await visit.result()
|
||||
if chosen is CANCEL or chosen is None: # Esc
|
||||
return
|
||||
if isinstance(chosen, KeyRequest) and chosen.action == "lock":
|
||||
# ^K: no confirm and no notice, as on the node page. The next ^K
|
||||
# reverses it, and the padlock that the row gains or loses is the
|
||||
# acknowledgement. The highlight, the sort, and the filter stay.
|
||||
_toggle_lock(ctx, self_key, chosen.value)
|
||||
screen.refresh_locks(_locked_keys(ctx, self_key))
|
||||
continue
|
||||
if isinstance(chosen, KeyRequest):
|
||||
# ^L: the map goes on the stack above the list, and Esc on the map comes
|
||||
# back to the same row.
|
||||
@@ -327,6 +392,9 @@ async def open_contacts(
|
||||
if await archive_contacts(ctx, self_key):
|
||||
rebuild = True
|
||||
break
|
||||
# A sweep can lock contacts and archive none. Then the rows stay, and
|
||||
# only their padlocks are drawn again.
|
||||
screen.refresh_locks(_locked_keys(ctx, self_key))
|
||||
continue
|
||||
if chosen == _ARCHIVED:
|
||||
# The archived list is a screen and not a section. It nests above this
|
||||
@@ -364,6 +432,19 @@ def _archived_count(ctx: AppContext, self_key: str) -> int:
|
||||
return len(store.archived(dev_pub))
|
||||
|
||||
|
||||
def _can_lock(ctx: AppContext, self_key: str) -> bool:
|
||||
"""Whether a lock can be stored on this device: a contact store, and the key of the device."""
|
||||
store = getattr(ctx, "contact_store", None)
|
||||
return store is not None and bool((self_key or "").lower().removeprefix("0x"))
|
||||
|
||||
|
||||
def _toggle_lock(ctx: AppContext, self_key: str, contact: Contact) -> None:
|
||||
"""Lock ``contact`` on this device, or unlock it when it is locked (^K)."""
|
||||
store = ctx.contact_store
|
||||
dev_pub = self_key.lower().removeprefix("0x")
|
||||
store.set_locked(dev_pub, contact, not store.is_locked(dev_pub, contact.public_key))
|
||||
|
||||
|
||||
def _locked_keys(ctx: AppContext, self_key: str) -> frozenset[str]:
|
||||
"""The keys of the contacts locked on this device. These rows draw a padlock."""
|
||||
store = getattr(ctx, "contact_store", None)
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
The interactive face of the ``courier`` tool. One select-list screen holds the whole
|
||||
feature (the persistent-backdrop pattern). It has these parts:
|
||||
|
||||
- The waiting outbox. Each entry shows its live state: it waits to hear the contact, it is
|
||||
scheduled for a time, or it backs off between retries.
|
||||
- The finished history (delivered or given up).
|
||||
- The waiting outbox. Each row shows its target (the recipient), its trigger (what the
|
||||
entry waits for before it goes out), and its message. It waits to hear the contact, it
|
||||
is scheduled for a time, or it backs off between retries.
|
||||
- The finished history (delivered or given up), with the outcome of each entry.
|
||||
- The queueing flow: select a contact, write the message, choose when to send it.
|
||||
|
||||
Enter on a waiting entry offers *Send now* (one forced attempt, and the outcome is in a
|
||||
dialog) and *Cancel*.
|
||||
The entries are a table that ←→ pan as one unit, under the column headers. Nothing in a
|
||||
row is shortened: a long name or a long message is all there, and the pan brings it into
|
||||
view. Enter on a waiting entry opens the message to edit, and Enter on a finished entry
|
||||
opens it to read. ``^R`` on a waiting entry is *Send now* (one forced attempt, and the
|
||||
outcome is in a dialog). Delete removes an entry of either section, after a confirm.
|
||||
|
||||
The outbox is **live** while it is open. Each row is a callable title that the code
|
||||
computes again at each paint, so "retry in ~N m" counts down in real time. A ticker
|
||||
@@ -28,6 +32,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -37,9 +42,16 @@ from rich.text import Text
|
||||
from ..core.courier_store import DELIVERED, QUEUED, QueuedMessage
|
||||
from ..core.models import Contact, is_direct_messageable, utcnow
|
||||
from .contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING, ContactListScreen, ContactRow
|
||||
from .menus import icon_lane, marked_label, run_steps, section_heading
|
||||
from .menus import (
|
||||
Lane,
|
||||
column_header,
|
||||
icon_lane,
|
||||
marked_label,
|
||||
run_steps,
|
||||
section_heading,
|
||||
)
|
||||
from .pathline import PathHop
|
||||
from .tui import CANCEL, DM_BYTE_LIMIT, Choice, SelectScreen, Separator
|
||||
from .tui import CANCEL, DM_BYTE_LIMIT, Choice, DeleteRequest, SelectScreen, Separator
|
||||
from .watchtower_screen import contact_watch_key
|
||||
from .widgets import (
|
||||
DESTINATION,
|
||||
@@ -58,6 +70,9 @@ if TYPE_CHECKING:
|
||||
_QUEUE = ("queue",)
|
||||
_CLEAR = ("clear",)
|
||||
|
||||
#: The first item of the value with which ``^R`` resolves the outbox: ``("send", ident)``.
|
||||
_SEND = "send"
|
||||
|
||||
#: The status marks that an outbox entry starts with: waiting, delivered, gave up. They are
|
||||
#: the outcome of the row and not decoration. Thus, unlike the icon of a command row, they
|
||||
#: are drawn on each platform. The code measures them raw, because the render fold of the
|
||||
@@ -75,14 +90,19 @@ _COMMAND_ICONS = ("📨", "🗑")
|
||||
#: The seconds between the refresh ticks of the open outbox (shape check and paint).
|
||||
_REFRESH_S = 1.0
|
||||
|
||||
#: The maximum width at which a message body renders in a row, before the row gets an
|
||||
#: ellipsis.
|
||||
_TEXT_W = 36
|
||||
#: The cells between two lanes of the table.
|
||||
_GAP = 2
|
||||
|
||||
#: How many finished entries the history section shows. The store keeps more (refer to the
|
||||
#: ``courier_history_kept`` preference).
|
||||
_DONE_SHOWN = 15
|
||||
|
||||
def _shorten(text: str, width: int = _TEXT_W) -> str:
|
||||
"""The message body, shortened for the display in a row."""
|
||||
return text if len(text) <= width else text[: width - 1] + "…"
|
||||
#: The footer of the outbox. :meth:`CourierOutboxScreen._key_atoms` adds the atoms of the
|
||||
#: keys that act on an entry, only while the highlight is on such an entry. The select
|
||||
#: list adds ``←→ scroll`` while the table is wider than the screen.
|
||||
_OUTBOX_HINT = "↑↓ move · type to filter · Enter open · Esc back"
|
||||
_SEND_HINT = "^R send"
|
||||
_DELETE_HINT = "Del delete"
|
||||
|
||||
|
||||
def _local_stamp(when: datetime) -> str:
|
||||
@@ -150,7 +170,7 @@ async def open_courier(ctx: AppContext) -> dict[str, Any] | None:
|
||||
# how to compose its sections again around the highlight
|
||||
# (:meth:`CourierOutboxScreen.refresh`). The ticker calls this method once each second
|
||||
# while the list is open. The code runs the screen through a visit. Thus each sub-flow
|
||||
# (queue a message, the actions of an entry, the clear confirm) floats over the list and
|
||||
# (queue a message, the message of an entry, a send, a confirm) floats over the list and
|
||||
# returns to the row from which it opened. Also, the code starts the ticker one time,
|
||||
# and not for each round.
|
||||
menu = CourierOutboxScreen(ctx)
|
||||
@@ -182,8 +202,12 @@ async def open_courier(ctx: AppContext) -> dict[str, Any] | None:
|
||||
destructive=True,
|
||||
):
|
||||
store.clear_done()
|
||||
elif isinstance(choice, DeleteRequest):
|
||||
await _delete_entry(ctx, int(choice.value[1]))
|
||||
elif isinstance(choice, tuple) and choice[0] == _SEND:
|
||||
await _send_now(ctx, int(choice[1]))
|
||||
elif isinstance(choice, tuple) and choice[0] == "msg":
|
||||
await _entry_actions(ctx, int(choice[1]))
|
||||
await _open_entry(ctx, int(choice[1]))
|
||||
# Take in the effect of the flow at once, and do not wait for a tick. Thus
|
||||
# the list to which the user returns already shows what the user did.
|
||||
menu.refresh()
|
||||
@@ -212,8 +236,23 @@ class CourierOutboxScreen(SelectScreen):
|
||||
store. It composes the sections again in place only when the fingerprint changed, and
|
||||
the highlight stays on its entry (the rebuild idiom of
|
||||
:class:`~meshterm.ui.contactlist.ContactListScreen`).
|
||||
|
||||
Three keys act on the highlighted entry. Enter opens its message (refer to
|
||||
:func:`_open_entry`), Delete asks to remove it, and ``^R`` sends a waiting entry now.
|
||||
The footer and the F-key lane name ``^R`` and Delete only on the rows on which they act.
|
||||
``^R`` is the chord that the app uses for "send again" in each conversation, and a
|
||||
chord, because this list filters as the user types.
|
||||
|
||||
On an entry row that pans, the full footer is wider than the 72 cells of a terminal.
|
||||
That is a known exception to the limit (JP, 2026-10-22: "bring the filter back, tough
|
||||
luck if it doesn't fit in the footer"). The hint line then removes ``Del delete``
|
||||
first (:attr:`spare_hint_atoms`, refer to :func:`~meshterm.ui.tui.frame.fitted_footer`),
|
||||
because the user finds the Delete key without a hint, and keeps Esc.
|
||||
"""
|
||||
|
||||
#: The atoms that the hint line removes first when the footer is too wide.
|
||||
spare_hint_atoms = (_DELETE_HINT,)
|
||||
|
||||
def __init__(self, ctx: AppContext, *, default: Any = None) -> None:
|
||||
"""Open the outbox on the current entries of the store, and keep their shape.
|
||||
|
||||
@@ -226,9 +265,69 @@ class CourierOutboxScreen(SelectScreen):
|
||||
"Courier — store-and-forward outbox",
|
||||
_menu_items(ctx, ctx.courier_store.entries()),
|
||||
default=default,
|
||||
footer_hint="↑↓ move · Enter select · Esc back",
|
||||
footer_hint=_OUTBOX_HINT,
|
||||
key_hint=self._key_atoms,
|
||||
)
|
||||
|
||||
def _waiting(self, value: Any) -> int | None:
|
||||
"""The id of the entry that ``value`` names, if that entry waits in the outbox."""
|
||||
ident = _entry_ident(value)
|
||||
if ident is None:
|
||||
return None
|
||||
message = self._ctx.courier_store.get(ident)
|
||||
return ident if message is not None and message.status == QUEUED else None
|
||||
|
||||
def _highlighted_waiting(self) -> int | None:
|
||||
"""The id of the highlighted entry, if it waits in the outbox."""
|
||||
current = self._current_choice()
|
||||
return self._waiting(current.value) if current is not None else None
|
||||
|
||||
def _key_atoms(self, value: Any) -> str:
|
||||
"""The footer atoms of the keys that act on the row of ``value``.
|
||||
|
||||
``^R`` acts only on a waiting entry, because a finished entry has left the outbox.
|
||||
Delete acts on each entry, and on no command row.
|
||||
"""
|
||||
atoms = []
|
||||
if self._waiting(value) is not None:
|
||||
atoms.append(_SEND_HINT)
|
||||
if _entry_ident(value) is not None:
|
||||
atoms.append(_DELETE_HINT)
|
||||
return " · ".join(atoms)
|
||||
|
||||
@property
|
||||
def picocalc_lyra_lane(self):
|
||||
"""The lane of the list, with ``Send`` on F3 and ``Delete`` behind it.
|
||||
|
||||
F1/F2 are the section jumps, and F4/F5 are the pager. F3 is the slot that a select
|
||||
list uses for its own verb. Send and Delete have the same subject (the highlighted
|
||||
entry). Thus they are the two halves of that slot, as Read and Remove are in the
|
||||
region editor. Each is dim where it does not act. On a command row, both are dim,
|
||||
not empty: the two actions exist on this screen, but not for that row.
|
||||
"""
|
||||
from .tui.fkeys import FPair
|
||||
|
||||
lane = list(super().picocalc_lyra_lane)
|
||||
current = self._current_choice()
|
||||
lane[2] = FPair(
|
||||
"Send",
|
||||
"retry",
|
||||
opp_label="Delete",
|
||||
opp_action="delete",
|
||||
enabled=self._highlighted_waiting() is not None,
|
||||
opp_enabled=current is not None and current.deletable,
|
||||
)
|
||||
return lane
|
||||
|
||||
def handle(self, action: str, data: str = "") -> None:
|
||||
"""Send the highlighted waiting entry now on ``^R``. Other keys act as in each list."""
|
||||
if action == "retry":
|
||||
ident = self._highlighted_waiting()
|
||||
if ident is not None:
|
||||
self.resolve((_SEND, ident))
|
||||
return
|
||||
super().handle(action, data)
|
||||
|
||||
def _fingerprint(self) -> tuple:
|
||||
"""The shape of the store: which entries exist and the status of each."""
|
||||
return tuple((m.ident, m.status) for m in self._ctx.courier_store.entries())
|
||||
@@ -254,14 +353,29 @@ class CourierOutboxScreen(SelectScreen):
|
||||
self._index = max(0, min(self._index, len(choices) - 1)) if choices else 0
|
||||
|
||||
|
||||
def _entry_ident(value: Any) -> int | None:
|
||||
"""The id of the entry that a row value names (``("msg", ident)``), or ``None``."""
|
||||
if isinstance(value, tuple) and len(value) == 2 and value[0] == "msg":
|
||||
return int(value[1])
|
||||
return None
|
||||
|
||||
|
||||
def _menu_items(ctx: AppContext, entries: list[QueuedMessage]) -> list:
|
||||
"""Build the rows of the screen: the waiting outbox, then the finished history.
|
||||
|
||||
The entry rows are callables with no argument, so that their live state renders again at
|
||||
each paint. The fixed action rows stay plain strings.
|
||||
Each section is a table under its own column header: the status mark, the TARGET (the
|
||||
recipient), a second lane, and the MESSAGE. In the outbox, the second lane is the
|
||||
TRIGGER: what the entry waits for before it goes out. In the history, it is the
|
||||
OUTCOME. The rows and the headers pan as one unit (:attr:`Choice.pans`), so each lane
|
||||
is at its full width and nothing is shortened. The two command rows do not pan.
|
||||
|
||||
The entry rows are callables with no argument, so that their live state renders again
|
||||
at each paint. The second lane is as wide as its widest text at that paint (refer to
|
||||
``second_w`` below), in both sections. Thus MESSAGE starts in the same cell in each
|
||||
section, and it moves when a countdown changes its number of digits.
|
||||
"""
|
||||
waiting = [m for m in entries if m.status == QUEUED]
|
||||
done = [m for m in entries if m.status != QUEUED]
|
||||
done = [m for m in entries if m.status != QUEUED][:_DONE_SHOWN]
|
||||
|
||||
# One icon column for the whole list, across both sections. The terminal draws ⏳ and
|
||||
# 📨 in two cells, but ✓ ✗ 🗑 in one cell. Thus rows that were written as
|
||||
@@ -271,25 +385,82 @@ def _menu_items(ctx: AppContext, entries: list[QueuedMessage]) -> list:
|
||||
# command icons go where the platform draws no icon lane, and marked_label leaves those
|
||||
# two labels bare. It does not pad them for a mark that is not there.
|
||||
lane = max(icon_lane(_COMMAND_ICONS), _MARK_LANE)
|
||||
# One TARGET lane for both sections, so that the lane after it starts in the same cell
|
||||
# in each. It is never narrower than its label, so the label never runs into the next.
|
||||
target_w = max([cell_len("TARGET")] + [_target_chip(m).cell_len for m in waiting + done])
|
||||
|
||||
def second_w() -> int:
|
||||
"""The width of the second lane at this paint: its widest label, trigger, or outcome.
|
||||
|
||||
The rows hold the live records of the store, so a trigger that changes (a
|
||||
countdown, a contact that was heard) gives its new width at the next paint.
|
||||
"""
|
||||
cells = [cell_len("TRIGGER"), cell_len("OUTCOME")]
|
||||
cells += [_trigger(ctx, m).cell_len for m in waiting]
|
||||
cells += [cell_len(_outcome(m)) for m in done]
|
||||
return max(cells)
|
||||
|
||||
items: list = [section_heading("Outbox")]
|
||||
if not waiting:
|
||||
if waiting:
|
||||
items.append(_column_header("TRIGGER", lane, target_w, second_w))
|
||||
else:
|
||||
items.append(Separator(" empty — queued messages wait here for their moment"))
|
||||
for message in waiting:
|
||||
items.append(Choice(lambda m=message: _waiting_row(ctx, m, lane), ("msg", message.ident)))
|
||||
items.append(
|
||||
Choice(
|
||||
lambda m=message: _waiting_row(ctx, m, lane, target_w, second_w()),
|
||||
("msg", message.ident),
|
||||
deletable=True,
|
||||
pans=True,
|
||||
)
|
||||
)
|
||||
items.append(Separator(" ")) # put space between the action and the outbox rows above it
|
||||
items.append(Choice(marked_label("📨", "Queue a message…", "", lane=lane), _QUEUE))
|
||||
|
||||
if done:
|
||||
items.append(Separator(" "))
|
||||
items.append(section_heading("Finished"))
|
||||
for message in done[:15]:
|
||||
items.append(Choice(lambda m=message: _done_row(m, lane), ("msg", message.ident)))
|
||||
items.append(_column_header("OUTCOME", lane, target_w, second_w))
|
||||
for message in done:
|
||||
items.append(
|
||||
Choice(
|
||||
lambda m=message: _done_row(m, lane, target_w, second_w()),
|
||||
("msg", message.ident),
|
||||
deletable=True,
|
||||
pans=True,
|
||||
)
|
||||
)
|
||||
items.append(Choice(marked_label("🗑", "Clear finished", "", lane=lane), _CLEAR))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _column_header(label: str, lane: int, target_w: int, second_w: Callable[[], int]) -> Separator:
|
||||
"""The column header of one section: ``TARGET``, then ``label``, then ``MESSAGE``.
|
||||
|
||||
It comes immediately after the heading of its section. Thus it pins with the heading
|
||||
while the rows of the section scroll. It starts after the pointer, the mark, and the
|
||||
space after the mark, so that ``TARGET`` is over the first cell of each chip. It pans
|
||||
with the rows, so each label stays over its lane.
|
||||
|
||||
Args:
|
||||
label: The name of the second lane: ``TRIGGER`` in the outbox, ``OUTCOME`` in the
|
||||
history.
|
||||
lane: The icon column of the list in cells (refer to :func:`_menu_items`).
|
||||
target_w: The width of the widest chip in cells, without the gap after it.
|
||||
second_w: Gives the width of the second lane at this paint, without the gap.
|
||||
"""
|
||||
|
||||
def header(width: int) -> str:
|
||||
lanes = [
|
||||
Lane("TARGET", target_w + _GAP),
|
||||
Lane(label, second_w() + _GAP),
|
||||
Lane("MESSAGE"),
|
||||
]
|
||||
return column_header(lanes, width, indent=2 + lane + 1)
|
||||
|
||||
return Separator(header, pans=True)
|
||||
|
||||
|
||||
def _entry_mark(mark: str, style: str, lane: int) -> Text:
|
||||
"""The status mark of an entry, with a tint, and padded to the icon column of the outbox.
|
||||
|
||||
@@ -303,62 +474,108 @@ def _entry_mark(mark: str, style: str, lane: int) -> Text:
|
||||
lane: The icon column of the list in cells (refer to :func:`_menu_items`).
|
||||
|
||||
Returns:
|
||||
The mark, followed by enough spaces to start the words in the column after it.
|
||||
The mark, followed by enough spaces to start the words in the column after it. The
|
||||
style is a span on the mark only. As the base style of the row, it made each lane
|
||||
after the mark bold.
|
||||
"""
|
||||
text = Text(mark, style=style)
|
||||
text = Text()
|
||||
text.append(mark, style=style)
|
||||
text.append(" " * (lane - cell_len(mark) + 1))
|
||||
return text
|
||||
|
||||
|
||||
def _waiting_row(ctx: AppContext, message: QueuedMessage, lane: int = _MARK_LANE) -> Text:
|
||||
"""One waiting entry: the recipient, the body, and what the entry waits for.
|
||||
def _target_chip(message: QueuedMessage) -> Text:
|
||||
"""The TARGET of an entry: its recipient, as a destination chip.
|
||||
|
||||
The recipient is the destination of the message, so it is a chip that opens on the
|
||||
notch (refer to :func:`~meshterm.ui.widgets.hop_chip`), in the hue of its key.
|
||||
The recipient is the destination of the message, so the chip opens on the notch (refer
|
||||
to :func:`~meshterm.ui.widgets.hop_chip`). A waiting recipient has the hue of its key. A
|
||||
finished entry is history, and all of its row is muted. Thus its chip is muted too.
|
||||
"""
|
||||
if message.status == QUEUED:
|
||||
return name_chip(message.node_name, message.node_key, role=DESTINATION)
|
||||
return hop_chip(PathHop(message.node_name, style="muted"), role=DESTINATION)
|
||||
|
||||
|
||||
def _row(
|
||||
mark: Text,
|
||||
chip: Text,
|
||||
target_w: int,
|
||||
second: Text,
|
||||
second_w: int,
|
||||
message: QueuedMessage,
|
||||
style: str,
|
||||
) -> Text:
|
||||
"""One entry row: the mark, the chip, the second lane, and the full message.
|
||||
|
||||
Each lane is padded to its width, and nothing is shortened. The pan of the list brings
|
||||
a part that is past the edge into view. A line break in the message becomes a space,
|
||||
because a row is one line.
|
||||
"""
|
||||
mark.append_text(chip)
|
||||
mark.append(" " * (max(0, target_w - chip.cell_len) + _GAP))
|
||||
mark.append_text(second)
|
||||
mark.append(" " * (max(0, second_w - second.cell_len) + _GAP))
|
||||
mark.append(" ".join(message.text.splitlines()), style=style)
|
||||
return mark
|
||||
|
||||
|
||||
def _trigger(ctx: AppContext, message: QueuedMessage) -> Text:
|
||||
"""The TRIGGER of a waiting entry: what it waits for before it goes out.
|
||||
|
||||
The rules are those of :meth:`~meshterm.services.courier.CourierService.eligible`, in
|
||||
the same order: a schedule, then the backoff after a failed try, then a sign of life.
|
||||
"""
|
||||
row = _entry_mark("⏳", "warn", lane)
|
||||
row.append_text(name_chip(message.node_name, message.node_key, role=DESTINATION))
|
||||
row.append(f" “{_shorten(message.text)}”", style="muted")
|
||||
row.append(" · ", style="muted")
|
||||
now = utcnow()
|
||||
if message.not_before is not None and now < message.not_before:
|
||||
row.append(f"scheduled {_local_stamp(message.not_before)}", style="brand")
|
||||
else:
|
||||
retry = ctx.courier.next_retry_s(message, now)
|
||||
if retry is not None:
|
||||
row.append(
|
||||
f"try {message.attempts} · retry in ~{max(1, round(retry / 60))} m",
|
||||
style="warn",
|
||||
)
|
||||
elif message.attempts > 0:
|
||||
row.append(f"try {message.attempts} · waiting to hear it again", style="muted")
|
||||
elif ctx.courier.heard_recently(message.node_key, now):
|
||||
row.append("contact is fresh — next pass", style="ok")
|
||||
else:
|
||||
row.append("waiting to hear the contact", style="muted")
|
||||
return row
|
||||
return Text(f"at {_local_stamp(message.not_before)}", style="brand")
|
||||
retry = ctx.courier.next_retry_s(message, now)
|
||||
if retry is not None:
|
||||
minutes = max(1, round(retry / 60))
|
||||
return Text(f"retry in ~{minutes} m · try {message.attempts}", style="warn")
|
||||
if message.attempts > 0:
|
||||
return Text(f"when heard again · try {message.attempts}", style="muted")
|
||||
if ctx.courier.heard_recently(message.node_key, now):
|
||||
return Text("next pass — contact is fresh", style="ok")
|
||||
return Text("when heard", style="muted")
|
||||
|
||||
|
||||
def _done_row(message: QueuedMessage, lane: int = _MARK_LANE) -> Text:
|
||||
"""One finished entry: the outcome mark, the recipient, the body, and when it ended.
|
||||
|
||||
The recipient is a destination chip, the same as in the outbox. But a finished entry is
|
||||
history, and all of its row is muted. Thus the chip is muted too.
|
||||
"""
|
||||
if message.status == DELIVERED:
|
||||
row = _entry_mark("✓", "ok", lane)
|
||||
else:
|
||||
row = _entry_mark("✗", "err", lane)
|
||||
row.append_text(hop_chip(PathHop(message.node_name, style="muted"), role=DESTINATION))
|
||||
row.append(f" “{_shorten(message.text)}”", style="muted")
|
||||
def _outcome(message: QueuedMessage) -> str:
|
||||
"""The OUTCOME of a finished entry: how it ended, when, and after how many tries."""
|
||||
when = message.finished or message.created
|
||||
verb = "delivered" if message.status == DELIVERED else "gave up"
|
||||
row.append(
|
||||
f" · {verb} {format_ago(age_seconds(when))}"
|
||||
f" · {message.attempts} attempt{'s' if message.attempts != 1 else ''}",
|
||||
style="muted",
|
||||
)
|
||||
return row
|
||||
tries = f"{message.attempts} attempt{'s' if message.attempts != 1 else ''}"
|
||||
return f"{verb} {format_ago(age_seconds(when))} · {tries}"
|
||||
|
||||
|
||||
def _waiting_row(
|
||||
ctx: AppContext,
|
||||
message: QueuedMessage,
|
||||
lane: int = _MARK_LANE,
|
||||
target_w: int = 0,
|
||||
second_w: int = 0,
|
||||
) -> Text:
|
||||
"""One waiting entry: the mark, the TARGET, the TRIGGER, and the MESSAGE.
|
||||
|
||||
The message has the colour of plain text, because it is the content of the row.
|
||||
"""
|
||||
mark = _entry_mark("⏳", "warn", lane)
|
||||
trigger = _trigger(ctx, message)
|
||||
return _row(mark, _target_chip(message), target_w, trigger, second_w, message, "")
|
||||
|
||||
|
||||
def _done_row(
|
||||
message: QueuedMessage, lane: int = _MARK_LANE, target_w: int = 0, second_w: int = 0
|
||||
) -> Text:
|
||||
"""One finished entry: the outcome mark, the TARGET, the OUTCOME, and the MESSAGE.
|
||||
|
||||
A finished entry is history, so each lane after the mark is muted.
|
||||
"""
|
||||
if message.status == DELIVERED:
|
||||
mark = _entry_mark("✓", "ok", lane)
|
||||
else:
|
||||
mark = _entry_mark("✗", "err", lane)
|
||||
outcome = Text(_outcome(message), style="muted")
|
||||
return _row(mark, _target_chip(message), target_w, outcome, second_w, message, "muted")
|
||||
|
||||
|
||||
# --- the flows --------------------------------------------------------------------------
|
||||
@@ -368,6 +585,10 @@ def _done_row(message: QueuedMessage, lane: int = _MARK_LANE) -> Text:
|
||||
#: an Enter that commits. Esc cancels the queueing step in which the picker is.
|
||||
_PICK_HINT = "↑↓ move · ^←→↑↓ sort · type to filter · Enter select · Esc cancel"
|
||||
|
||||
#: The instruction above the message field: the same when the user writes the message and
|
||||
#: when the user edits it.
|
||||
_MESSAGE_PROMPT = "Delivered as a normal direct message when its moment comes."
|
||||
|
||||
|
||||
class CourierRecipientScreen(ContactListScreen):
|
||||
"""The recipient picker on the shared contact list.
|
||||
@@ -468,8 +689,9 @@ async def _queue_flow(ctx: AppContext, contacts: list[Contact]) -> None:
|
||||
lambda vals: _next_recipient(visit),
|
||||
lambda vals: session.text(
|
||||
f"Message for {vals[0].name}",
|
||||
prompt="Delivered as a normal direct message when its moment comes.",
|
||||
prompt=_MESSAGE_PROMPT,
|
||||
byte_limit=DM_BYTE_LIMIT,
|
||||
compose=True,
|
||||
default=vals[1] or "",
|
||||
),
|
||||
lambda vals: _schedule_step(ctx, vals[0].name),
|
||||
@@ -570,60 +792,97 @@ async def _pick_schedule(ctx: AppContext, name: str):
|
||||
return when
|
||||
|
||||
|
||||
async def _entry_actions(ctx: AppContext, ident: int) -> None:
|
||||
"""Float the action menu of one entry: send now, cancel, or only look at it."""
|
||||
def _not_blank(text: str) -> bool | str:
|
||||
"""The validator of an edited message: an empty message is refused, not sent."""
|
||||
return True if text.strip() else "The message is empty — Esc keeps it as it was."
|
||||
|
||||
|
||||
async def _open_entry(ctx: AppContext, ident: int) -> None:
|
||||
"""Open the message of one entry: to edit while it waits, to read after it finished.
|
||||
|
||||
The edit holds the entry (refer to
|
||||
:meth:`~meshterm.services.courier.CourierService.holding`). Without the hold, a pass
|
||||
can send the old text while the prompt is open. A try that is already in progress
|
||||
cannot be stopped, so the message then opens to read, with a note.
|
||||
"""
|
||||
session = ctx.ui.session
|
||||
store = ctx.courier_store
|
||||
message = store.get(ident)
|
||||
if message is None:
|
||||
return
|
||||
if message.status != QUEUED:
|
||||
# A finished entry has no actions. Show its full text instead.
|
||||
title = f"Message for {message.node_name}"
|
||||
if message.status != QUEUED or ctx.courier.in_flight(ident):
|
||||
body = Text(message.text)
|
||||
body.append(
|
||||
f"\n\n{message.status} · {message.attempts} attempt"
|
||||
f"{'s' if message.attempts != 1 else ''}",
|
||||
style="muted",
|
||||
)
|
||||
await session.message_dialog(body, title=message.node_name)
|
||||
if message.status != QUEUED:
|
||||
body.append(f"\n\n{_outcome(message)}", style="muted")
|
||||
else:
|
||||
body.append("\n\nsending now — edit it after this attempt", style="muted")
|
||||
await session.message_dialog(body, title=title)
|
||||
return
|
||||
# One measured column for both rows. 📤 draws two cells and ✗ draws one cell. The Cancel
|
||||
# row measured only its own mark, so its words started one column to the left of the
|
||||
# words of Send now. Where the platform draws no icon lane, both rows have no icon, and
|
||||
# the err tint of Cancel moves onto its words.
|
||||
lane = icon_lane(("📤", "✗"))
|
||||
items = [
|
||||
Choice(marked_label("📤", "Send now — one forced attempt", "", lane=lane), "send"),
|
||||
Choice(marked_label("✗", "Cancel this message", "err", lane=lane), "cancel"),
|
||||
]
|
||||
picked = await session.select(
|
||||
f"{message.node_name} — “{_shorten(message.text, 28)}”",
|
||||
items,
|
||||
filterable=False,
|
||||
footer_hint="↑↓ move · Enter select · Esc back",
|
||||
)
|
||||
if picked == "cancel":
|
||||
store.cancel(ident)
|
||||
elif picked == "send":
|
||||
try:
|
||||
async with ctx.ui.busy_overlay(f"sending to {message.node_name}…"):
|
||||
outcome = await ctx.courier.attempt_now(ident)
|
||||
except Exception as exc: # noqa: BLE001 - show the failure, and keep the queue
|
||||
await session.message_dialog(Text(f"send failed: {exc}", style="err"), title="Courier")
|
||||
return
|
||||
notes = {
|
||||
"delivered": Text("✓ delivered — acknowledged by the contact", style="ok"),
|
||||
"no ack": Text(
|
||||
"sent, but no acknowledgement — it stays queued and the courier "
|
||||
"will retry with backoff",
|
||||
style="warn",
|
||||
),
|
||||
"gave up": Text("no acknowledgement — the retry budget is spent", style="err"),
|
||||
"unknown contact": Text(
|
||||
"the device's contact list doesn't know this contact yet; it stays queued",
|
||||
style="warn",
|
||||
),
|
||||
"busy": Text("another delivery is in flight — try again in a moment", style="muted"),
|
||||
"gone": Text("this entry is no longer queued", style="muted"),
|
||||
}
|
||||
await session.message_dialog(notes.get(outcome, Text(outcome)), title="Courier")
|
||||
with ctx.courier.holding(ident):
|
||||
text = await session.text(
|
||||
title,
|
||||
prompt=_MESSAGE_PROMPT,
|
||||
default=message.text,
|
||||
validate=_not_blank,
|
||||
byte_limit=DM_BYTE_LIMIT,
|
||||
compose=True,
|
||||
)
|
||||
if text is None or text == message.text:
|
||||
return
|
||||
if not store.edit(ident, text):
|
||||
# A late acknowledgement can finish the entry while the prompt is open.
|
||||
await session.message_dialog(
|
||||
Text("this message left the outbox during the edit — it was not changed", style="warn"),
|
||||
title="Courier",
|
||||
)
|
||||
|
||||
|
||||
async def _delete_entry(ctx: AppContext, ident: int) -> None:
|
||||
"""Ask to delete one entry (waiting or finished), with the red confirm of a delete."""
|
||||
store = ctx.courier_store
|
||||
message = store.get(ident)
|
||||
if message is None:
|
||||
return
|
||||
if message.status == QUEUED:
|
||||
prompt = f"Delete the message for {message.node_name}? It will not be sent."
|
||||
else:
|
||||
prompt = f"Delete the message for {message.node_name} from the history?"
|
||||
if await ctx.ui.dialog(
|
||||
prompt,
|
||||
[("Cancel", False), ("Delete", True)],
|
||||
title="Delete message",
|
||||
default=1,
|
||||
destructive=True,
|
||||
):
|
||||
store.delete(ident)
|
||||
|
||||
|
||||
async def _send_now(ctx: AppContext, ident: int) -> None:
|
||||
"""Force one delivery try of a waiting entry, and show the outcome in a dialog."""
|
||||
session = ctx.ui.session
|
||||
message = ctx.courier_store.get(ident)
|
||||
if message is None or message.status != QUEUED:
|
||||
return
|
||||
try:
|
||||
async with ctx.ui.busy_overlay(f"sending to {message.node_name}…"):
|
||||
outcome = await ctx.courier.attempt_now(ident)
|
||||
except Exception as exc: # noqa: BLE001 - show the failure, and keep the queue
|
||||
await session.message_dialog(Text(f"send failed: {exc}", style="err"), title="Courier")
|
||||
return
|
||||
notes = {
|
||||
"delivered": Text("✓ delivered — acknowledged by the contact", style="ok"),
|
||||
"no ack": Text(
|
||||
"sent, but no acknowledgement — it stays queued and the courier "
|
||||
"will retry with backoff",
|
||||
style="warn",
|
||||
),
|
||||
"gave up": Text("no acknowledgement — the retry budget is spent", style="err"),
|
||||
"unknown contact": Text(
|
||||
"the device's contact list doesn't know this contact yet; it stays queued",
|
||||
style="warn",
|
||||
),
|
||||
"busy": Text("another delivery is in flight — try again in a moment", style="muted"),
|
||||
"gone": Text("this entry is no longer queued", style="muted"),
|
||||
}
|
||||
await session.message_dialog(notes.get(outcome, Text(outcome)), title="Courier")
|
||||
@@ -25,6 +25,13 @@ page never shows it in its own body, because the body belongs to the report. The
|
||||
answers in a dialog, because the answer is a path that the user may have to read
|
||||
carefully, and because a write can fail. The page under the dialog does not change, and
|
||||
it is still there when the dialog closes.
|
||||
|
||||
A second verb, **anonymize** (:data:`ANONYMIZE_KEY`), changes the page between the
|
||||
report as it is and the report with its personal details masked. It is the menu half of
|
||||
``meshterm diagnostics --anonymize``. The ``anonymize`` preference chooses the form in
|
||||
which the page opens, and the key changes only this visit. The save writes the form that
|
||||
is visible, so the user saves what the user reads. The title says ``anonymized`` while the
|
||||
masks are visible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -50,17 +57,34 @@ SAVE_KEY = "s"
|
||||
#: is not a second implementation of the keyboard key.
|
||||
SAVE_ACTION = "save"
|
||||
|
||||
#: The keyboard key that changes the page between the report and the anonymized report. A
|
||||
#: bare letter, for the same reason as :data:`SAVE_KEY`.
|
||||
ANONYMIZE_KEY = "a"
|
||||
|
||||
#: The action that :data:`ANONYMIZE_KEY` and its chip on the lane both dispatch.
|
||||
ANONYMIZE_ACTION = "anonymize"
|
||||
|
||||
_TITLE = "Diagnostics"
|
||||
|
||||
|
||||
class DiagnosticsPage(AboutPage):
|
||||
"""The written diagnostics page, plus the one verb that writes it to a file.
|
||||
"""The written diagnostics page, plus the verbs that anonymize it and write it to a file.
|
||||
|
||||
All of the behaviour for reading comes from :class:`~meshterm.ui.about.AboutPage`: the
|
||||
pager, the sticky ``##`` headings, the section jumps, and the lane that dims when there
|
||||
is nothing to page. This class adds only the save, on the one slot that the About pages
|
||||
leave free.
|
||||
is nothing to page. This class adds the save, on the one slot that the About pages
|
||||
leave free, and the anonymize key, on the Shift half of that slot.
|
||||
"""
|
||||
|
||||
def __init__(self, source: str, *, session: TuiSession, save_path: Path) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
source: str,
|
||||
*,
|
||||
session: TuiSession,
|
||||
save_path: Path,
|
||||
anonymized_source: str | None = None,
|
||||
anonymized: bool = False,
|
||||
) -> None:
|
||||
"""Show ``source`` as the page, and save it to ``save_path`` when the user asks.
|
||||
|
||||
Args:
|
||||
@@ -68,24 +92,48 @@ class DiagnosticsPage(AboutPage):
|
||||
:func:`~meshterm.tools.diagnostics.markdown_source`.
|
||||
session: The session, for the dialog in which the save answers.
|
||||
save_path: The file to which :data:`SAVE_KEY` writes.
|
||||
anonymized_source: The same document with its personal details masked, from
|
||||
:func:`~meshterm.tools.diagnostics.anonymized`. ``None`` gives a page with
|
||||
no anonymize key.
|
||||
anonymized: Whether the page opens on ``anonymized_source``.
|
||||
"""
|
||||
super().__init__("Diagnostics", render_markdown(source))
|
||||
self._source = source
|
||||
self._sources = {False: source, True: anonymized_source}
|
||||
self._anonymized = anonymized and anonymized_source is not None
|
||||
super().__init__(self._title(), render_markdown(self._source))
|
||||
self._session = session
|
||||
self._save_path = save_path
|
||||
self._saving = False
|
||||
|
||||
@property
|
||||
def footer_hint(self) -> str: # type: ignore[override]
|
||||
"""The keys of the read-only page, with the save between the pager and Esc.
|
||||
def _source(self) -> str:
|
||||
"""The document that the page shows now, and that the save writes."""
|
||||
return self._sources[self._anonymized] or self._sources[False] or ""
|
||||
|
||||
The hint has the standard sentence shape: navigation first, then the action, then
|
||||
@property
|
||||
def _can_anonymize(self) -> bool:
|
||||
"""Whether the page has the second form of the document to change to."""
|
||||
return self._sources[True] is not None
|
||||
|
||||
def _title(self) -> str:
|
||||
"""The title, with ``· anonymized`` while the masks are visible."""
|
||||
return f"{_TITLE} · anonymized" if self._anonymized else _TITLE
|
||||
|
||||
@property
|
||||
def footer_hint(self) -> str: # type: ignore[override]
|
||||
"""The keys of the read-only page, with the verbs between the pager and Esc.
|
||||
|
||||
The hint has the standard sentence shape: navigation first, then the actions, then
|
||||
Esc. The pager atom is present only while :attr:`~Screen.content_overflows` is
|
||||
true, as on each read-only page. Thus the line never names a keyboard key that does
|
||||
nothing.
|
||||
nothing. The anonymize atom names what the key does next: ``anonymize`` on the
|
||||
report as it is, and ``reveal`` on the masked report.
|
||||
"""
|
||||
scroll = "↑↓ PgUp/PgDn scroll · " if self.content_overflows else ""
|
||||
return f"{scroll}{SAVE_KEY} save · Esc back"
|
||||
toggle = ""
|
||||
if self._can_anonymize:
|
||||
verb = "reveal" if self._anonymized else "anonymize"
|
||||
toggle = f"{ANONYMIZE_KEY} {verb} · "
|
||||
return f"{scroll}{toggle}{SAVE_KEY} save · Esc back"
|
||||
|
||||
@property
|
||||
def picocalc_lyra_lane(self):
|
||||
@@ -93,21 +141,41 @@ class DiagnosticsPage(AboutPage):
|
||||
|
||||
F1/F2 are the section pair and F4/F5 are the pager. Thus F3 is the one free primary
|
||||
slot. On this platform, the lane is the only place that can show a bare-letter
|
||||
keyboard key. Thus the chip is necessary, and not only a decoration.
|
||||
keyboard key. Thus the chip is necessary, and not only a decoration. The anonymize
|
||||
key goes on the Shift half of the same slot, because the two verbs are both about
|
||||
what leaves the machine. Its label names what the chip does next.
|
||||
"""
|
||||
from .tui.fkeys import FPair
|
||||
|
||||
lane = list(super().picocalc_lyra_lane)
|
||||
lane[2] = FPair("Save", SAVE_ACTION)
|
||||
if self._can_anonymize:
|
||||
label = "Reveal" if self._anonymized else "Anon"
|
||||
lane[2] = FPair("Save", SAVE_ACTION, label, ANONYMIZE_ACTION)
|
||||
else:
|
||||
lane[2] = FPair("Save", SAVE_ACTION)
|
||||
return lane
|
||||
|
||||
def handle(self, action: str, data: str = "") -> None:
|
||||
"""Save on the keyboard key or the chip. Scroll, jump, or leave on all other actions."""
|
||||
if action == SAVE_ACTION or (action == "text" and data.lower() == SAVE_KEY):
|
||||
"""Save or anonymize on a keyboard key or a chip. Scroll, jump, or leave on all others."""
|
||||
typed = data.lower() if action == "text" else ""
|
||||
if action == SAVE_ACTION or typed == SAVE_KEY:
|
||||
self._begin_save()
|
||||
return
|
||||
if self._can_anonymize and (action == ANONYMIZE_ACTION or typed == ANONYMIZE_KEY):
|
||||
self._toggle_anonymized()
|
||||
return
|
||||
super().handle(action, data)
|
||||
|
||||
def _toggle_anonymized(self) -> None:
|
||||
"""Show the other form of the document, at the same scroll position.
|
||||
|
||||
Both forms have the same lines in the same order, because a mask keeps the length
|
||||
of each value. Thus the user stays at the same place in the page.
|
||||
"""
|
||||
self._anonymized = not self._anonymized
|
||||
self._doc = render_markdown(self._source)
|
||||
self.title = self._title()
|
||||
|
||||
def _begin_save(self) -> None:
|
||||
"""Write the file and report the result in a dialog, outside the key handler.
|
||||
|
||||
@@ -159,13 +227,22 @@ class DiagnosticsPage(AboutPage):
|
||||
return message
|
||||
|
||||
|
||||
async def open_diagnostics_page(ctx: AppContext, source: str, *, save_path: Path) -> None:
|
||||
async def open_diagnostics_page(
|
||||
ctx: AppContext,
|
||||
source: str,
|
||||
*,
|
||||
save_path: Path,
|
||||
anonymized_source: str | None = None,
|
||||
anonymized: bool = False,
|
||||
) -> None:
|
||||
"""Open the Diagnostics page full-screen, and keep it until the user leaves it.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context. The interactive TUI must be in use.
|
||||
source: The markdown document to show and to save.
|
||||
save_path: The file to which the save key of the page writes.
|
||||
anonymized_source: The same document with its personal details masked.
|
||||
anonymized: Whether the page opens on ``anonymized_source``.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the call is outside the interactive menu (there is no
|
||||
@@ -176,7 +253,21 @@ async def open_diagnostics_page(ctx: AppContext, source: str, *, save_path: Path
|
||||
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - the menu-only caller guards this
|
||||
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))
|
||||
page = DiagnosticsPage(
|
||||
source,
|
||||
session=session,
|
||||
save_path=save_path,
|
||||
anonymized_source=anonymized_source,
|
||||
anonymized=anonymized,
|
||||
)
|
||||
await session.run_screen(page)
|
||||
|
||||
|
||||
__all__ = ["SAVE_ACTION", "SAVE_KEY", "DiagnosticsPage", "open_diagnostics_page"]
|
||||
__all__ = [
|
||||
"ANONYMIZE_ACTION",
|
||||
"ANONYMIZE_KEY",
|
||||
"SAVE_ACTION",
|
||||
"SAVE_KEY",
|
||||
"DiagnosticsPage",
|
||||
"open_diagnostics_page",
|
||||
]
|
||||
@@ -25,7 +25,6 @@ platform.
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from operator import itemgetter
|
||||
|
||||
@@ -34,7 +33,7 @@ from ..core.models import MapMarker
|
||||
from ..core.mvt import GEOM_LINE, GEOM_POLYGON, Layer
|
||||
from ..platforms import Platform, on_platform
|
||||
from .mapcanvas import MapCanvas, Raster
|
||||
from .marks import NODE_MARK, REPEATER_MARK, RGB, SELF_MARK, UNKNOWN_MARK, parse_hex
|
||||
from .marks import NODE_MARK, REPEATER_MARK, RGB, SELF_MARK, UNKNOWN_MARK
|
||||
from .theme import mark_rgb
|
||||
|
||||
# The marker palette, shared across all the app. The canonical tuples are in ui.marks.
|
||||
@@ -59,20 +58,20 @@ _ROAD_STYLE: dict[str, tuple[str, int]] = {
|
||||
"trunk": ("map.highway", 26),
|
||||
"primary": ("#e8b24a", 25),
|
||||
"secondary": ("#d7c257", 24),
|
||||
"tertiary": ("#a7adb8", 23),
|
||||
"minor": ("#828894", 21),
|
||||
"unclassified": ("#828894", 21),
|
||||
"residential": ("#828894", 21),
|
||||
"living_street": ("#767c88", 20),
|
||||
"service": ("#5f646e", 19),
|
||||
"pedestrian": ("#5a5f69", 15),
|
||||
"path": ("#4b5560", 12),
|
||||
"track": ("#4b5560", 12),
|
||||
"footway": ("#454e58", 11),
|
||||
"cycleway": ("#455864", 12),
|
||||
"tertiary": ("#adadad", 23),
|
||||
"minor": ("#888888", 21),
|
||||
"unclassified": ("#888888", 21),
|
||||
"residential": ("#888888", 21),
|
||||
"living_street": ("#7c7c7c", 20),
|
||||
"service": ("#646464", 19),
|
||||
"pedestrian": ("#5f5f5f", 15),
|
||||
"path": ("#545454", 12),
|
||||
"track": ("#545454", 12),
|
||||
"footway": ("#4d4d4d", 11),
|
||||
"cycleway": ("#555555", 12),
|
||||
}
|
||||
_ROAD_DEFAULT = ("#5f646e", 18)
|
||||
_RAIL = ("#8a8f98", 22)
|
||||
_ROAD_DEFAULT = ("#646464", 18)
|
||||
_RAIL = ("#8f8f8f", 22)
|
||||
|
||||
# Waterway class -> (colour, priority).
|
||||
_WATERWAY_STYLE: dict[str, tuple[str, int]] = {
|
||||
@@ -90,16 +89,16 @@ _GREEN_CLASSES = {"wood", "forest", "grass", "park", "meadow", "scrub", "wetland
|
||||
# with a lower rank get screen space first. The minimum zoom hides the fine detail until
|
||||
# you zoom in.
|
||||
_PLACE_STYLE: dict[str, tuple[str, bool, int, int]] = {
|
||||
"city": ("#f8fafc", True, 0, 0),
|
||||
"town": ("#e6ebf2", True, 1, 9),
|
||||
"village": ("#cdd6e2", False, 2, 12),
|
||||
"suburb": ("#9fb0c4", False, 3, 12),
|
||||
"quarter": ("#94a5ba", False, 4, 13),
|
||||
"neighbourhood": ("#8496ab", False, 5, 13),
|
||||
"hamlet": ("#8496ab", False, 6, 14),
|
||||
"city": ("#fafafa", True, 0, 0),
|
||||
"town": ("#eaeaea", True, 1, 9),
|
||||
"village": ("#d5d5d5", False, 2, 12),
|
||||
"suburb": ("#aeaeae", False, 3, 12),
|
||||
"quarter": ("#a3a3a3", False, 4, 13),
|
||||
"neighbourhood": ("#949494", False, 5, 13),
|
||||
"hamlet": ("#949494", False, 6, 14),
|
||||
}
|
||||
_WATER_LABEL = ("#7dd3fc", False, 2)
|
||||
_STREET_LABEL = ("#9aa0aa", False, 7)
|
||||
_STREET_LABEL = ("#9f9f9f", False, 7)
|
||||
|
||||
#: The zoom at which street names appear. Below it, a canvas 53 cells wide cannot hold one
|
||||
#: name for each street. The few names that fit label a grid that is too coarse to show
|
||||
@@ -126,7 +125,7 @@ _FILL_LAYERS = ("water", "landcover", "landuse", "park")
|
||||
#: Below the zoom gate, a typical footprint is approximately two dots wide (less than half
|
||||
#: a cell). Thus the full layer becomes a haze that makes the streets hard to read, and it
|
||||
#: shows nothing that the street pattern does not already show.
|
||||
_BUILDING_FILL = ("#4a4f5a", 8)
|
||||
_BUILDING_FILL = ("#4f4f4f", 8)
|
||||
_BUILDING_STIPPLE = 2
|
||||
_BUILDING_MIN_ZOOM = 15
|
||||
|
||||
@@ -759,30 +758,6 @@ def _marker_style(marker: MapMarker) -> tuple[str, str]:
|
||||
return _NODE
|
||||
|
||||
|
||||
#: The number of piled nodes at which the glyph of a marker gets full brightness.
|
||||
_PILE_FULL = 8
|
||||
|
||||
|
||||
def _pile_color(color: RGB, count: int, *, cap: int = _PILE_FULL) -> RGB:
|
||||
"""Make the colour of a marker brighter, by the number of nodes that share its cell.
|
||||
|
||||
One cell can show only one glyph. Thus, where many nodes are at the same position, the
|
||||
map would hide the crowd behind one marker. Instead, we keep the glyph of the top node,
|
||||
and move its colour toward white as the pile grows. Thus a bright marker shows a busy
|
||||
cluster. The ramp is logarithmic (2 nodes → a clear increase, then saturation near
|
||||
``cap``). Thus it stays informative, and one extra node does not look like a crowd.
|
||||
"""
|
||||
if count <= 1:
|
||||
return color
|
||||
t = min(1.0, math.log2(count) / math.log2(cap))
|
||||
r, g, b = color
|
||||
return (
|
||||
round(r + (255 - r) * t),
|
||||
round(g + (255 - g) * t),
|
||||
round(b + (255 - b) * t),
|
||||
)
|
||||
|
||||
|
||||
#: How much a node outside an active find filter dims (a multiplier for the glyph colour).
|
||||
_FIND_DIM = 0.35
|
||||
|
||||
@@ -801,9 +776,10 @@ def _draw_nodes(
|
||||
"""Draw the mesh nodes on top: each glyph, then labels by importance until they collide.
|
||||
|
||||
The glyphs are drawn lowest priority first, so that our node and the repeaters are on
|
||||
top of the leaf nodes. Where several nodes share a cell, the glyph that stays is made
|
||||
brighter by the pile size (:func:`_pile_color`). Thus crowded positions glow, and do
|
||||
not silently hide the crowd. Then the labels are placed highest priority first (our
|
||||
top of the leaf nodes. One cell shows one glyph, so where several nodes share a cell,
|
||||
the glyph of the top node stays. (A crowd once made that glyph brighter. Each marker
|
||||
is white now, and white cannot get brighter, so JP removed that signal on
|
||||
2026-10-14.) Then the labels are placed highest priority first (our
|
||||
node, then repeaters, then leaf nodes), each only if it fits with no overlap. Thus, on
|
||||
a crowded map, the important labels get the available space, and the other nodes show
|
||||
as a bare marker (with no overlap).
|
||||
@@ -823,23 +799,18 @@ def _draw_nodes(
|
||||
matched = not needle or needle in marker.label.casefold()
|
||||
placed.append((marker, ix, iy, matched))
|
||||
|
||||
# A cell is (dot_x >> 1, dot_y >> 2). Count how many nodes are in each cell.
|
||||
pile = Counter((ix >> 1, iy >> 2) for _, ix, iy, _m in placed)
|
||||
|
||||
# Matches draw after (over) the dimmed nodes that do not match, at any rank. Thus the
|
||||
# node that the user looks for is never hidden below a brighter neighbour.
|
||||
for marker, ix, iy, matched in sorted(placed, key=lambda p: (p[3], p[0]._rank())):
|
||||
glyph, color = _marker_style(marker)
|
||||
rgb = parse_hex(color)
|
||||
rgb = mark_rgb(color)
|
||||
if not matched:
|
||||
rgb = _dimmed(rgb, _FIND_DIM)
|
||||
else:
|
||||
rgb = _pile_color(rgb, pile[(ix >> 1, iy >> 2)])
|
||||
canvas.marker(ix, iy, glyph, rgb)
|
||||
|
||||
# A node that the filter removes is context: a bare dim glyph with no label. Labels get
|
||||
# the name hue of the node, which comes from its key (the colour rule for all the app).
|
||||
# The label of our node is the pure ``you`` white (the ★ glyph keeps its yellow). An
|
||||
# The label of our node is the pure ``you`` white, the same as the ★ glyph. An
|
||||
# active find filter still makes the label of each match full white, so that the node
|
||||
# that the user looks for is easy to see.
|
||||
from .widgets import name_rgb # widgets imports this module, so bind late to avoid the cycle
|
||||
|
||||
@@ -522,7 +522,7 @@ class MapCanvas:
|
||||
:data:`_BOLD`). The kernel VT draws ``bold`` as brightness. If a bold run has a
|
||||
colour that was just quantized into the dim bank, the VT changes it to the bright
|
||||
partner of that slot. A light-grey unknown label then shows as the white "you", and a
|
||||
purple repeater shows as pink. The colour that a marker has is the important part.
|
||||
brown highway shows as yellow. The colour that a marker has is the important part.
|
||||
The bold is not.
|
||||
"""
|
||||
from .theme import fold_text
|
||||
|
||||
@@ -27,20 +27,22 @@ def parse_hex(color: str) -> RGB:
|
||||
return (int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16))
|
||||
|
||||
|
||||
# The palette of markers, shared in the whole app. The basemap is blue (water), green
|
||||
# (parks), and warm amber (roads). Thus the markers use the hues that a map never has,
|
||||
# pink and violet, so that they are easy to see against it. Ordinary nodes are the
|
||||
# loudest (bright pink). Repeaters are less loud (a calmer violet). Our node is always the
|
||||
# yellow star. A node that was heard but never identified is a light grey ring. Each glyph
|
||||
# is in the PicoCalc console font.
|
||||
# The palette of markers, shared in the whole app. Each marker is white, and its shape
|
||||
# tells what it is (JP, 2026-10-14): a ``●`` for an ordinary node and a ``▲`` for a
|
||||
# repeater, in the ``type.*`` white of the theme. They were pink and violet, the hues that
|
||||
# a map never has. Our node is the star, in the white of ``you`` (JP, 2026-10-14). It was
|
||||
# yellow, and then the hue of our key. A node that was heard but never identified is a
|
||||
# light grey ring. Each glyph is in the PicoCalc console font.
|
||||
#
|
||||
# The colour of a mark is any value that :func:`~meshterm.ui.theme.mark_rgb` takes: a
|
||||
# literal hex, or the name of a theme style when the platform must choose the shade. The
|
||||
# unknown ring has a name (``node.unknown``), so that the ring and the grey label beside
|
||||
# it can never be different.
|
||||
SELF_MARK = ("★", "#facc15")
|
||||
REPEATER_MARK = ("▲", "#a78bfa")
|
||||
NODE_MARK = ("●", "#f472b6")
|
||||
# it can never be different. Our star has the name ``you``, the white of our node in each
|
||||
# list, and the two type marks have their ``type.*`` names, so that the console chooses
|
||||
# its own white.
|
||||
SELF_MARK = ("★", "you")
|
||||
REPEATER_MARK = ("▲", "type.repeater")
|
||||
NODE_MARK = ("●", "type.node")
|
||||
UNKNOWN_MARK = ("○", "node.unknown")
|
||||
|
||||
#: The only concealed character. One bullet replaces one character of a value that the
|
||||
@@ -50,6 +52,15 @@ UNKNOWN_MARK = ("○", "node.unknown")
|
||||
#: purpose. Those marks have a meaning, and this one means "not shown".
|
||||
MASK_MARK = "•"
|
||||
|
||||
#: The padlock of a locked contact: at the start of its row in the Contacts list, and before
|
||||
#: its name on its sender chip in a chat. It is the private-channel ``🔒`` of the icon
|
||||
#: lexicon, because it means the same thing (this item is closed to something). It goes
|
||||
#: through :func:`~meshterm.ui.theme.glyph`, which gives the hand-drawn padlock ``⚿`` of the
|
||||
#: font on the PicoCalc. An unlocked contact draws nothing. The mark is an exception that
|
||||
#: the user must notice, and a lane of open padlocks makes the few closed padlocks hard to
|
||||
#: see.
|
||||
LOCK_ICON = "🔒"
|
||||
|
||||
#: Sentinels that name the endpoints of a path on the route graph. ``\x00`` never occurs
|
||||
#: in a node hash, so the sentinels can share the namespace of node ids and not collide
|
||||
#: with an id.
|
||||
|
||||
@@ -348,7 +348,12 @@ def _header_segments(ctx: AppContext, cache: dict) -> list[Text]:
|
||||
|
||||
if "device" in atoms:
|
||||
device = Text()
|
||||
if ctx.mock:
|
||||
if getattr(ctx, "demo", False):
|
||||
# The demo shows the name of its node, as a real session does, and says that
|
||||
# it is the demo where a real session says how the device is connected.
|
||||
device.append(_demo_name())
|
||||
device.append(" (demo)", style="muted")
|
||||
elif ctx.mock:
|
||||
device.append("simulator", style="warn")
|
||||
else:
|
||||
name, where = _device_label(ctx, cache)
|
||||
@@ -375,6 +380,13 @@ def _header_segments(ctx: AppContext, cache: dict) -> list[Text]:
|
||||
return segments
|
||||
|
||||
|
||||
def _demo_name() -> str:
|
||||
"""The name of our node in the demo world."""
|
||||
from ..demo.world import SELF
|
||||
|
||||
return SELF.name
|
||||
|
||||
|
||||
def _device_label(ctx: AppContext, cache: dict) -> tuple[str, str]:
|
||||
"""The device segment of the header: ``(node name, where)``, cached for each connection.
|
||||
|
||||
@@ -1292,9 +1304,11 @@ async def _wait_for_disconnect(ctx: AppContext) -> None:
|
||||
(:meth:`AppContext.link_alive`), and does not wait for a failed command. After a
|
||||
serial disconnect, the ``meshcore`` client continues to give cached data and never
|
||||
raises an error (this was confirmed on hardware). Thus an active liveness check is the
|
||||
only reliable way to find a disconnected cable. The same check covers Bluetooth:
|
||||
there, it reads the connection flag of the BLE client (which changes immediately when
|
||||
the peripheral disconnects or goes out of range). The function never resolves for the
|
||||
only reliable way to find a disconnected cable. The check also reads the connection
|
||||
flag of the ``meshcore`` client, which changes immediately when the transport closes.
|
||||
For Bluetooth, that flag is the only signal (the peripheral disconnects or goes out of
|
||||
range). For serial, it finds a port that stays listed after its link is dead (a laptop
|
||||
that wakes from sleep can hang up the port). The function never resolves for the
|
||||
simulator (it cannot be disconnected), or before a real device opens.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -56,6 +56,7 @@ from ..platforms import get_platform
|
||||
from .theme import glyph
|
||||
from .tui import Choice, Separator
|
||||
from .tui.emoji_width import cut_cells, drawable
|
||||
from .tui.select import splice_hint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
@@ -477,6 +478,22 @@ PICK_LOCATION_LABEL = "Pick location on map…"
|
||||
PICK_LOCATION_HELP = "Point at the map to set both coordinates"
|
||||
|
||||
|
||||
#: The maximum width of a footer hint, in cells (refer to the UX standards).
|
||||
HINT_CELLS = 72
|
||||
|
||||
|
||||
def splice_if_room(hint: str, atom: str) -> str:
|
||||
"""``hint`` with ``atom`` before its ``Esc`` atom, but only when the result fits.
|
||||
|
||||
This is for an atom that is good to show but that the hint can do without: the lock
|
||||
key (JP, 2026-10-19: "If footer too long, don't put it in the footer. If there's room,
|
||||
put it."). The result must stay in :data:`HINT_CELLS`. No other atom leaves the hint to
|
||||
make space for it, so a hint that is too full stays as it was.
|
||||
"""
|
||||
longer = splice_hint(hint, atom)
|
||||
return longer if cell_len(longer) <= HINT_CELLS else hint
|
||||
|
||||
|
||||
def changes_phrase(count: int) -> str:
|
||||
"""``"1 staged change"`` / ``"3 staged changes"`` for dialogs and menu rows."""
|
||||
return f"{count} staged change{'' if count == 1 else 's'}"
|
||||
@@ -650,15 +667,19 @@ def section_heading(label: str) -> Separator:
|
||||
to heading. A row built by hand does not get this behaviour. Thus always use this
|
||||
function.
|
||||
|
||||
A heading that starts with an icon (``📡 Channels``, the mark of a trophy discipline)
|
||||
loses the icon on a platform that draws no icon lane, the same as a command row. A
|
||||
heading is the same type of label. Also, a substitute glyph next to the rule, which
|
||||
already draws ``──``, looks like noise (refer to :func:`command_label`).
|
||||
A heading is words only, with no icon. Icons go on rows. The headings of Chat
|
||||
(``📡 Channels``) and of the trophy case (the mark of each discipline) had icons. We
|
||||
removed them for a cleaner look (JP, 2026-10-15).
|
||||
|
||||
Grey, never the accent of the frame: a rule in the colour of the border looks like a
|
||||
part of the frame, not like a heading in it.
|
||||
|
||||
The heading is also *spaced*: a blank line comes before it where the
|
||||
``section_spacing_min_rows`` preference asks for one (refer to
|
||||
:attr:`~meshterm.ui.tui.select.Separator.spaced`). Thus a list does not put a blank
|
||||
separator before a heading for space.
|
||||
"""
|
||||
return Separator(f"── {command_label(label)} ──", style="heading", heading=True)
|
||||
return Separator(f"── {label} ──", style="heading", heading=True, spaced=True)
|
||||
|
||||
|
||||
def fit_cells(text: str, width: int, *, align: str = "left") -> str:
|
||||
|
||||
@@ -43,7 +43,8 @@ screen is the node itself, in full:
|
||||
where the sweep archives. After a red confirm, it removes this node from the contact
|
||||
table of the device and from the contacts that MeshTerm remembers for the device.
|
||||
Then the page closes, because the contact that the page shows does not exist any
|
||||
more. An archive and a restore also close the page. A lock does not.
|
||||
more. An archive and a restore also close the page. A lock does not. A page that a
|
||||
conversation opens has only the lock row of this group.
|
||||
* **Routes**: the routes on which the device really heard the node arrive. The shared
|
||||
route graph (:mod:`~meshterm.ui.pathgraph`) draws them from the node to our node, in
|
||||
the inbound direction of the packets: the contact on the left, our node on the right.
|
||||
@@ -1252,29 +1253,38 @@ class ContactPages:
|
||||
twin of :class:`~meshterm.ui.map_screen.Locator`, which ^L uses for the map.
|
||||
|
||||
The contacts come from the device one time, when the screen opens.
|
||||
|
||||
It is also the ^K key (``lock``) of a conversation: the lock of the author, which
|
||||
:meth:`lock_state` reads and :meth:`set_locked` writes. The store keeps a lock under
|
||||
the key of the device, so the pages hold that key too. The state is read from the store
|
||||
at each paint and never kept here, because the node page can change it too.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: AppContext, contacts: list[Contact]) -> None:
|
||||
def __init__(self, ctx: AppContext, contacts: list[Contact], *, self_key: str = "") -> None:
|
||||
"""Hold the contacts that :meth:`find` searches.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context (it must be in the interactive menu).
|
||||
contacts: The contacts of the device.
|
||||
self_key: The public key of the device (hex). The store keeps the locks under
|
||||
it. With ``""``, no contact can be locked from here.
|
||||
"""
|
||||
self._ctx = ctx
|
||||
self._contacts = contacts
|
||||
self._self_key = self_key
|
||||
self._dev_pub = self_key.lower().removeprefix("0x")
|
||||
# The result of each key that find() got. A screen asks at each paint, and the
|
||||
# answer for one key does not change while the contacts stay the same.
|
||||
self._found: dict[str, Contact | None] = {}
|
||||
|
||||
@classmethod
|
||||
async def gather(cls, ctx: AppContext) -> ContactPages:
|
||||
async def gather(cls, ctx: AppContext, *, self_key: str = "") -> ContactPages:
|
||||
"""The pages of the contacts that the device has now. With no contacts, nothing is found."""
|
||||
try:
|
||||
contacts = await ctx.devstate.contacts()
|
||||
except Exception: # noqa: BLE001 - with no contacts, ^O opens only our own page
|
||||
contacts = []
|
||||
return cls(ctx, list(contacts))
|
||||
return cls(ctx, list(contacts), self_key=self_key)
|
||||
|
||||
def find(self, key: str | None) -> Contact | None:
|
||||
"""The contact with ``key`` (a full key or a key prefix), or ``None``.
|
||||
@@ -1294,14 +1304,49 @@ class ContactPages:
|
||||
)
|
||||
return self._found[needle]
|
||||
|
||||
@property
|
||||
def can_lock(self) -> bool:
|
||||
"""Whether a lock can be written from here at all: a store, and the key of the device."""
|
||||
return getattr(self._ctx, "contact_store", None) is not None and bool(self._dev_pub)
|
||||
|
||||
def lock_state(self, contact: Contact | None) -> bool | None:
|
||||
"""Whether ``contact`` is locked, or ``None`` when it cannot have a lock.
|
||||
|
||||
A lock needs the full key of a live contact, as on the node page (refer to
|
||||
:meth:`~meshterm.core.contact_store.ContactStore.set_locked`). An archived contact
|
||||
has no lock, because the lock exists to keep a contact out of the archive.
|
||||
"""
|
||||
if contact is None or not self.can_lock:
|
||||
return None
|
||||
key = (contact.public_key or "").lower().removeprefix("0x")
|
||||
if len(key) != 64 or _is_archived(self._ctx, contact, self._self_key):
|
||||
return None
|
||||
return self._ctx.contact_store.is_locked(self._dev_pub, key)
|
||||
|
||||
def locked_keys(self) -> frozenset[str]:
|
||||
"""The full keys of the contacts locked on this device, or none where no lock is stored.
|
||||
|
||||
A conversation compares it at each paint, to draw again the sender chips that gained
|
||||
or lost their padlock (refer to :class:`~meshterm.ui.chat.ChatScreen`).
|
||||
"""
|
||||
if not self.can_lock:
|
||||
return frozenset()
|
||||
return self._ctx.contact_store.locked_keys(self._dev_pub)
|
||||
|
||||
def set_locked(self, contact: Contact, locked: bool) -> None:
|
||||
"""Lock or unlock ``contact``. The caller asked :meth:`lock_state` first."""
|
||||
if self.can_lock:
|
||||
self._ctx.contact_store.set_locked(self._dev_pub, contact, locked)
|
||||
|
||||
async def show(self, contact: Contact | None) -> None:
|
||||
"""Open the node page of ``contact``, or of our node when it is ``None``.
|
||||
|
||||
The page opens to look, not to manage (``manage=False``). The management verbs
|
||||
(lock, archive, restore, delete) belong to the list that the contact is in, and a
|
||||
conversation is not that list.
|
||||
The page opens to look, not to manage (``manage=False``). The verbs that remove a
|
||||
contact (archive, restore, delete) belong to the list that the contact is in, and a
|
||||
conversation is not that list. The lock is the exception (``lock=True``): it removes
|
||||
nothing, and a conversation can already toggle it with ^K (JP, 2026-10-19).
|
||||
"""
|
||||
await open_node_detail(self._ctx, contact, manage=False)
|
||||
await open_node_detail(self._ctx, contact, manage=False, lock=True)
|
||||
|
||||
|
||||
def _same_key(contact_key: str | None, needle: str) -> bool:
|
||||
@@ -1311,7 +1356,7 @@ def _same_key(contact_key: str | None, needle: str) -> bool:
|
||||
|
||||
|
||||
async def open_node_detail(
|
||||
ctx: AppContext, contact: Contact | None, *, manage: bool = True
|
||||
ctx: AppContext, contact: Contact | None, *, manage: bool = True, lock: bool | None = None
|
||||
) -> bool:
|
||||
"""Open the Node detail page for a contact (or our node) and run its action loop.
|
||||
|
||||
@@ -1343,6 +1388,11 @@ async def open_node_detail(
|
||||
is really in. They never belong to a page opened from a list that is about to act on
|
||||
all its rows together.
|
||||
|
||||
With ``lock``, a caller that does not manage still lets the page lock and unlock. A
|
||||
conversation gives ``True`` (refer to :class:`ContactPages`): the lock removes nothing,
|
||||
and the page that a chat opens is then the way to lock in a room, where the F-key lane
|
||||
has no free chip for it.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context (the interactive TUI must be running).
|
||||
contact: The contact to show, or ``None`` for our node (a page of identity and
|
||||
@@ -1351,6 +1401,8 @@ async def open_node_detail(
|
||||
manage: Whether to show the contact-management actions (lock, archive, restore,
|
||||
delete). ``False`` draws none of them, and then the page can only return
|
||||
``False``.
|
||||
lock: Whether to show ``Lock contact`` or ``Unlock contact``. ``None`` follows
|
||||
``manage``.
|
||||
|
||||
Returns:
|
||||
``True`` if the visit ended because the user archived, restored, or deleted the
|
||||
@@ -1594,48 +1646,32 @@ async def open_node_detail(
|
||||
info_actions.append(_Action("regions", "🔖", "", "Ask which regions it carries"))
|
||||
# -- contact management: the last group of the page, and the only actions that end the
|
||||
# visit. None of them shows when the caller opened the page to look, not to act (refer
|
||||
# to ``manage``). They never show for our node, or for a contact with no key at
|
||||
# all, because the write has nothing to use as its address.
|
||||
archived = manage and _is_archived(ctx, contact, self_key)
|
||||
manageable = (
|
||||
manage
|
||||
and not you
|
||||
and contact is not None
|
||||
and bool(contact.public_key or contact.key_prefix)
|
||||
)
|
||||
# to ``manage``), except the lock when the caller lets the page lock (refer to
|
||||
# ``lock``). They never show for our node, or for a contact with no key at all,
|
||||
# because the write has nothing to use as its address.
|
||||
may_lock = manage if lock is None else lock
|
||||
archived = (manage or may_lock) and _is_archived(ctx, contact, self_key)
|
||||
keyed = not you and contact is not None and bool(contact.public_key or contact.key_prefix)
|
||||
manageable = manage and keyed
|
||||
store = getattr(ctx, "contact_store", None)
|
||||
dev_pub = self_key.lower().removeprefix("0x")
|
||||
whole_key = contact is not None and len(contact.public_key.lower().removeprefix("0x")) == 64
|
||||
# A lock is a mark of MeshTerm itself, attached to the full key in the contact store, and
|
||||
# only a live contact can have one. Its purpose is to keep a contact out of the archive.
|
||||
lockable = manageable and not archived and whole_key and store is not None and bool(dev_pub)
|
||||
lockable = (
|
||||
may_lock and keyed and not archived and whole_key and store is not None and bool(dev_pub)
|
||||
)
|
||||
base_actions = list(info_actions)
|
||||
|
||||
def management(locked: bool) -> list[_Action]:
|
||||
"""The action rows of the page, with the management group for this lock state last."""
|
||||
rows = list(base_actions)
|
||||
if not manageable:
|
||||
return rows
|
||||
# The lock is first. It changes nothing on the device and ends nothing, so it is
|
||||
# the safest row of the group for the highlight. Then the constructive verb, then
|
||||
# the destructive verb. Thus a key press by mistake most probably goes to the row
|
||||
# that the user can undo. Archive and restore are the two halves of one move that
|
||||
# the user can reverse. They use the ``💾``/``📂`` pair that the lexicon already
|
||||
# gives to "put away" and "bring back". Only a contact with a full key can be
|
||||
# written back to the device. Thus a contact with only a key prefix never gets the
|
||||
# archive, because the archive leaves it with no way back. A locked contact
|
||||
# never gets the archive at all, and that is the purpose of the lock.
|
||||
if lockable:
|
||||
if locked:
|
||||
rows.append(_Action("unlock", "🔓", "", "Unlock contact"))
|
||||
else:
|
||||
rows.append(_Action("lock", "🔒", "", "Lock contact"))
|
||||
if archived:
|
||||
rows.append(_Action("restore", "📂", "ok", "Restore to device"))
|
||||
elif whole_key and not locked:
|
||||
rows.append(_Action("archive", "💾", "", "Archive contact"))
|
||||
rows.append(_Action("remove", "🗑", "err", "Delete contact…"))
|
||||
return rows
|
||||
return base_actions + _management_rows(
|
||||
locked=locked,
|
||||
lockable=lockable,
|
||||
manageable=manageable,
|
||||
archived=archived,
|
||||
whole_key=whole_key,
|
||||
)
|
||||
|
||||
locked = bool(lockable and store.is_locked(dev_pub, contact.public_key))
|
||||
info_actions = management(locked)
|
||||
@@ -1747,6 +1783,47 @@ async def open_node_detail(
|
||||
return contact_removed
|
||||
|
||||
|
||||
def _management_rows(
|
||||
*, locked: bool, lockable: bool, manageable: bool, archived: bool, whole_key: bool
|
||||
) -> list[_Action]:
|
||||
"""The contact-management group of the node page, for one lock state.
|
||||
|
||||
The lock is first. It changes nothing on the device and ends nothing, so it is the
|
||||
safest row of the group for the highlight. Then the constructive verb, then the
|
||||
destructive verb. Thus a key press by mistake most probably goes to the row that the
|
||||
user can undo. Archive and restore are the two halves of one move that the user can
|
||||
reverse. They use the ``💾``/``📂`` pair that the lexicon already gives to "put away"
|
||||
and "bring back". Only a contact with a full key can be written back to the device.
|
||||
Thus a contact with only a key prefix never gets the archive, because the archive
|
||||
leaves it with no way back. A locked contact never gets the archive at all, and that
|
||||
is the purpose of the lock.
|
||||
|
||||
A page that may lock but not manage (a page that a conversation opens) has only the
|
||||
lock row.
|
||||
|
||||
Args:
|
||||
locked: Whether the contact is locked now.
|
||||
lockable: Whether the page shows the lock row.
|
||||
manageable: Whether the page shows archive, restore, and delete.
|
||||
archived: Whether the contact is archived. Then restore takes the place of archive.
|
||||
whole_key: Whether the full key of the contact is known, which an archive must have.
|
||||
"""
|
||||
rows: list[_Action] = []
|
||||
if lockable:
|
||||
if locked:
|
||||
rows.append(_Action("unlock", "🔓", "", "Unlock contact"))
|
||||
else:
|
||||
rows.append(_Action("lock", "🔒", "", "Lock contact"))
|
||||
if not manageable:
|
||||
return rows
|
||||
if archived:
|
||||
rows.append(_Action("restore", "📂", "ok", "Restore to device"))
|
||||
elif whole_key and not locked:
|
||||
rows.append(_Action("archive", "💾", "", "Archive contact"))
|
||||
rows.append(_Action("remove", "🗑", "err", "Delete contact…"))
|
||||
return rows
|
||||
|
||||
|
||||
def _is_archived(ctx: AppContext, contact: Contact | None, self_key: str) -> bool:
|
||||
"""Whether the sweep removed this contact from the device, and MeshTerm keeps it.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ surface that the survey found can in the end use it.
|
||||
look, and each seam uses one cell. The label of the chip of a node has the hue that
|
||||
comes from its hash (:func:`~meshterm.ui.theme.node_style`). This is the colour that its
|
||||
name has on each surface. The fill is that hue, but darker (:data:`CHIP_LIGHTNESS`). Our
|
||||
node is the yellow ``★`` of the map on a neutral dark grey. A faded hop is dark slate. A
|
||||
node is the white ``★`` of the map on a neutral dark grey. A faded hop is dark grey. A
|
||||
keyless hop is light grey on grey. (On the 16-colour console of the PicoCalc, the fill
|
||||
is the dim twin of the hue. Refer to :func:`_slot_colours`.)
|
||||
|
||||
@@ -87,7 +87,7 @@ surface that the survey found can in the end use it.
|
||||
``★`` of the app. The user already knows who both ends are, and the cells are for the
|
||||
hops that *are* news. These stars fade where the route is being *composed*, because our
|
||||
two ends are fixtures and not choices. A surface that only *shows* a walk passes
|
||||
``dim_self=False`` and keeps us in the ``you`` white.
|
||||
``dim_self=False`` and keeps our stars in the ``you`` white.
|
||||
|
||||
The existing call sites still render through ``path_text``. A separate pass for each
|
||||
surface will move them here. The insertion cursor of the composer is a hop in its own
|
||||
@@ -140,8 +140,9 @@ POWERLINE_ROUND_CLOSE = ""
|
||||
#: Our node, where a surface asks for it without its name and hash (``bare_self``): the
|
||||
#: ``★`` of the app. It is the map's own marker, taken from
|
||||
#: :data:`~meshterm.ui.marks.SELF_MARK`. Thus the star in a route and the star on the map
|
||||
#: can never be different, in glyph or in hue.
|
||||
SELF_GLYPH, _SELF_INK = SELF_MARK
|
||||
#: can never be different, in glyph or in colour: the white of ``you``.
|
||||
SELF_GLYPH = SELF_MARK[0]
|
||||
|
||||
|
||||
#: The insertion cursor of the composer. It stands in the route as a hop of its own: the
|
||||
#: empty slot into which the next chosen hop goes. It is a ``+``, because this is what
|
||||
@@ -191,23 +192,33 @@ def _chip_label(fill: str) -> str:
|
||||
return oklab.to_hex(oklab.fitted((min(1.0, lightness / CHIP_LIGHTNESS), a, b)))
|
||||
|
||||
|
||||
#: The text of the composer's slot: near-black slate on the white chip.
|
||||
_CHIP_FG = "#0f172a"
|
||||
#: The text of the composer's slot: a grey that is almost black, on the white chip.
|
||||
_CHIP_FG = "#181818"
|
||||
#: The chip fill for a keyless hop: the ``faint`` grey. Colour is only for identities that
|
||||
#: have a key, in chips and in arrow mode.
|
||||
_KEYLESS_BG = "#64748b"
|
||||
_KEYLESS_BG = "#737373"
|
||||
#: The label of a keyless chip: the same pair as the chip of a node, in grey.
|
||||
_KEYLESS_FG = _chip_label(_KEYLESS_BG)
|
||||
#: The chip fill of our node: a neutral dark grey. Thus the yellow ``★`` on it reads as the
|
||||
#: marker of the map and not as the hue of a node (JP, 2026-08-09). It is *not* a colour
|
||||
#: The fill of a quiet chip (:attr:`PathHop.quiet`): the grey of plain text (``#cccccc``,
|
||||
#: the default foreground of Windows Terminal and of the terminal of VS Code), darker by
|
||||
#: the rule of the chip of a node (:data:`CHIP_LIGHTNESS`).
|
||||
_QUIET_BG = _chip_fill("#cccccc")
|
||||
#: The label of a quiet chip: black. A grey label on the grey fill read as a name that the
|
||||
#: chip still lit, and black reads as a name that it does not (JP, 2026-10-14).
|
||||
_QUIET_FG = "#000000"
|
||||
#: The label of our chip in a chat (:attr:`PathHop.white_ink`): white, on the fill that
|
||||
#: our hue gives each chip.
|
||||
_WHITE = "#ffffff"
|
||||
#: The chip fill of our node: a neutral dark grey. Thus the ``★`` on it reads as the
|
||||
#: marker of the map and not as a chip of a node (JP, 2026-08-09). It is *not* a colour
|
||||
#: of the spectrum on purpose, and it is not the white that it was before. Our end of a
|
||||
#: route is a fixture that the user already knows. It stays back and lets the hops that
|
||||
#: differ carry the colour.
|
||||
_YOU_BG = "#3f3f46"
|
||||
#: The chip of a dimmed hop: a dark slate fill with muted ink. It recedes like ``faint``
|
||||
_YOU_BG = "#404040"
|
||||
#: The chip of a dimmed hop: a dark grey fill with muted ink. It recedes like ``faint``
|
||||
#: text.
|
||||
_DIM_BG = "#334155"
|
||||
_DIM_FG = "#94a3b8"
|
||||
_DIM_BG = "#404040"
|
||||
_DIM_FG = "#a2a2a2"
|
||||
|
||||
#: On the 16-colour console, the page: slot 0, black. It is the fill of a chip that the
|
||||
#: console cannot fill, because the VT has no dark grey background. That chip is our ``★``,
|
||||
@@ -286,9 +297,9 @@ class PathHop:
|
||||
comes from the hash (its first byte, so each prefix gives the same hue).
|
||||
``None`` renders muted and grey, because colour is only for identities that
|
||||
have a key.
|
||||
you: Our node. Arrows draw it in the pure-white ``you`` style. Chips draw it in the
|
||||
own yellow of the map on the neutral dark grey :data:`_YOU_BG`, with padding
|
||||
like each other chip.
|
||||
you: Our node. Arrows draw it in the pure-white ``you`` style. Chips draw the white
|
||||
``★`` on the neutral dark grey :data:`_YOU_BG`, with padding like each other
|
||||
chip.
|
||||
annotation: The hash note for traces. It is rendered ``" (3d)"`` after the label in
|
||||
both modes (pass the bare ``3d``, without parentheses).
|
||||
lit_bytes: For a hash label: the leading *bytes* that are drawn in the hue (the two
|
||||
@@ -309,6 +320,19 @@ class PathHop:
|
||||
continues again". A chip says the opposite: that a node in the route has the
|
||||
name ``⋯``. Arrow mode does not need a special case, because a bare label
|
||||
between two arrows is already the same thing.
|
||||
white_ink: Draw the label in white, on the fill that the hue of ``key`` gives each
|
||||
chip. It is our node in a chat (refer to :func:`~meshterm.ui.widgets.self_chip`):
|
||||
the fill is the same as the fill of each known sender, and the white label
|
||||
tells it from them (JP, 2026-10-14). Arrow mode draws the same pair, with the
|
||||
padding of a chip. The PicoCalc draws it too, white on the dim twin of the hue.
|
||||
On a quiet chip, it is a white label on the grey fill: a node with no key on the
|
||||
selected message of a chat (refer to :func:`~meshterm.ui.widgets.picked_hop`).
|
||||
quiet: A node with no key in a chat: a sender or a mention that nobody can
|
||||
identify. Its chip is black on a grey fill (:data:`_QUIET_BG`), so it does not
|
||||
stand out among the senders that have a colour (JP, 2026-10-14). A hop with a
|
||||
key keeps its hue, and a route keeps the keyless chip. Arrow mode keeps the
|
||||
``node.unknown`` grey. The PicoCalc keeps the light grey fill of its keyless chip,
|
||||
with the black label.
|
||||
"""
|
||||
|
||||
label: str
|
||||
@@ -320,6 +344,8 @@ class PathHop:
|
||||
style: str | None = None
|
||||
cursor: bool = False
|
||||
gap: bool = False
|
||||
white_ink: bool = False
|
||||
quiet: bool = False
|
||||
|
||||
|
||||
def elision_hop() -> PathHop:
|
||||
@@ -394,6 +420,31 @@ def _fills(line: Text) -> list[str | None]:
|
||||
return fills
|
||||
|
||||
|
||||
def _path_extent(line: Text, index: int) -> range | None:
|
||||
"""The characters of the path line in ``line`` that holds character ``index``.
|
||||
|
||||
Each path line stamps its own extent with :data:`PATH_INK` (refer to
|
||||
:meth:`PathLine._render`). A row that appends a path carries the stamp as a span, and
|
||||
the cells after it (a status, an age) are not path. A line whose base style is the
|
||||
stamp is all path. So is a line with no stamp at all, because then nothing can say
|
||||
where its path stops.
|
||||
|
||||
Returns:
|
||||
The range of the characters of that path, or ``None`` when ``index`` is outside
|
||||
each path of a stamped row.
|
||||
"""
|
||||
whole = range(len(line.plain))
|
||||
if str(line.style) == PATH_INK:
|
||||
return whole
|
||||
stamps = [span for span in line.spans if str(span.style) == PATH_INK]
|
||||
if not stamps:
|
||||
return whole
|
||||
for span in stamps:
|
||||
if span.start <= index < span.end:
|
||||
return range(max(0, span.start), min(len(line.plain), span.end))
|
||||
return None
|
||||
|
||||
|
||||
def _char_of_cell(plain: str, cell: int) -> int:
|
||||
"""The index of the character in which a display cell falls, limited to the string."""
|
||||
if cell <= 0:
|
||||
@@ -420,6 +471,12 @@ def cut_mark(line: Text, at: int, side: str) -> Text:
|
||||
seam or on the closing edge still cracks in the colour of the chip that the user can
|
||||
see, and not in nothing.
|
||||
|
||||
The scan stays inside the path that holds the cut (refer to :func:`_path_extent`). A
|
||||
row can be a chip, then text: the recipient of a Courier entry, then what the entry
|
||||
waits for. A cut in that text once found the chip far to its left, and drew a half
|
||||
block in the fill of the chip at the right edge of the row, away from each chip. A cut
|
||||
outside a path is a cut of text, so it gets the ellipsis.
|
||||
|
||||
Args:
|
||||
line: The rendered line that MeshTerm cuts (whole, not shifted, with absolute cells).
|
||||
at: The index of the nearest *visible* cell on the side of the mark. For
|
||||
@@ -435,7 +492,13 @@ def cut_mark(line: Text, at: int, side: str) -> Text:
|
||||
if not fills: # there is nothing drawn to shear: a caller cut an empty line
|
||||
return Text(_ELLIPSIS, style="muted")
|
||||
index = _char_of_cell(line.plain, at)
|
||||
steps = range(index, len(fills)) if side == ELIDE_HEAD else range(index, -1, -1)
|
||||
extent = _path_extent(line, index)
|
||||
if extent is None: # the cut is in text after (or between) the paths of the row
|
||||
return Text(_ELLIPSIS, style="muted")
|
||||
if side == ELIDE_HEAD:
|
||||
steps = range(index, extent.stop)
|
||||
else:
|
||||
steps = range(index, extent.start - 1, -1)
|
||||
fill = next((fills[i] for i in steps if fills[i]), None)
|
||||
if fill is None:
|
||||
return Text(_ELLIPSIS, style="muted")
|
||||
@@ -1001,6 +1064,9 @@ class PathLine:
|
||||
text.append(hop.label, style="selected")
|
||||
elif hop.dim:
|
||||
text.append(hop.label, style="faint")
|
||||
elif hop.white_ink and hop.key:
|
||||
fill, ink, _soft = _colours_of(hop)
|
||||
text.append(f" {hop.label} ", style=f"{_WEIGHT}{ink} on {fill}")
|
||||
elif hop.style:
|
||||
text.append(hop.label, style=hop.style)
|
||||
elif hop.you:
|
||||
@@ -1161,9 +1227,9 @@ class PathLine:
|
||||
Each chip has padding on both sides, also the ``★`` (JP, 2026-08-09). The pads are
|
||||
not there to make room for a long *word*. They are the chip's own shape. A star
|
||||
that is squeezed between two chevrons reads as a glyph that is wedged into the seam,
|
||||
and not as a segment of the route. The ink of our chip is the yellow of the map on
|
||||
the neutral dark grey :data:`_YOU_BG`. Thus the star in the line and the star on the
|
||||
map read as one mark.
|
||||
and not as a segment of the route. The ink of our chip is the white of the star of
|
||||
the map, on the neutral dark grey :data:`_YOU_BG`. Thus the star in the line and the
|
||||
star on the map read as one mark.
|
||||
|
||||
The colours come from the palette of the platform (:func:`_colours_of`).
|
||||
"""
|
||||
@@ -1199,8 +1265,12 @@ def _spectrum_colours(hop: PathHop) -> _ChipColours:
|
||||
readable on it.) It is the only light chip on the ribbon. The fade is next, and it wins
|
||||
over the identity, also our own. A dimmed hop is a hop that nobody composed (an
|
||||
automatic return home, a mirrored return leg). Our end must recede with the rest of
|
||||
that automatic half, and not keep its yellow among the greys. Plain mode says the same
|
||||
that automatic half, and not keep its hue among the greys. Plain mode says the same
|
||||
thing when it fades the name.
|
||||
|
||||
Our chip in a chat (``white_ink``) has the fill of the chip of its key, and a white
|
||||
label. A quiet chip (a node with no key in a chat) is black on a grey fill, or white on
|
||||
it with ``white_ink``.
|
||||
"""
|
||||
if hop.cursor:
|
||||
return _ChipColours(_style_hex("cursor") or _KEYLESS_BG, _CHIP_FG, _CHIP_FG)
|
||||
@@ -1209,11 +1279,16 @@ def _spectrum_colours(hop: PathHop) -> _ChipColours:
|
||||
return _ChipColours(_chip_fill(resolved), resolved, resolved)
|
||||
if hop.dim:
|
||||
return _ChipColours(_DIM_BG, _DIM_FG, _DIM_FG)
|
||||
if hop.you:
|
||||
return _ChipColours(_YOU_BG, _SELF_INK, _SELF_INK)
|
||||
hue = _style_hex(node_style(hop.key)) if hop.key else None
|
||||
if hop.white_ink and hue:
|
||||
return _ChipColours(_chip_fill(hue), _WHITE, _WHITE)
|
||||
if hop.you:
|
||||
return _ChipColours(_YOU_BG, _WHITE, _WHITE)
|
||||
if hue:
|
||||
return _ChipColours(_chip_fill(hue), hue, hue)
|
||||
if hop.quiet:
|
||||
ink = _WHITE if hop.white_ink else _QUIET_FG
|
||||
return _ChipColours(_QUIET_BG, ink, ink)
|
||||
return _ChipColours(_KEYLESS_BG, _KEYLESS_FG, _KEYLESS_FG)
|
||||
|
||||
|
||||
@@ -1229,10 +1304,14 @@ def _slot_colours(hop: PathHop) -> _ChipColours:
|
||||
|
||||
A keyless chip is white on light grey, the same pair one bank down. Our ``★``, a faded
|
||||
hop, and the slot of the composer have no fill of their own, because the console has no
|
||||
dark grey background. They stand on the page (:data:`_SLOT_PAGE`): the star in its
|
||||
yellow, a faded hop in the dark grey, and the slot as a white ``+`` in a break of the
|
||||
dark grey background. They stand on the page (:data:`_SLOT_PAGE`): the star in our
|
||||
hue, a faded hop in the dark grey, and the slot as a white ``+`` in a break of the
|
||||
ribbon. The order of the rules is the same as on a truecolor terminal
|
||||
(:func:`_spectrum_colours`).
|
||||
|
||||
Our chip in a chat (``white_ink``) is white on the dim twin of its hue, the same rule as
|
||||
on a truecolor terminal. A quiet chip is black on the light grey of the keyless chip, or
|
||||
white on it with ``white_ink``.
|
||||
"""
|
||||
if hop.cursor:
|
||||
return _ChipColours(_SLOT_PAGE, _SLOT_WHITE, _SLOT_WHITE)
|
||||
@@ -1245,9 +1324,14 @@ def _slot_colours(hop: PathHop) -> _ChipColours:
|
||||
if hop.dim:
|
||||
return _ChipColours(_SLOT_PAGE, _SLOT_FAINT, _SLOT_FAINT)
|
||||
if hop.you:
|
||||
return _ChipColours(_SLOT_PAGE, _SELF_INK, _SELF_INK)
|
||||
return _ChipColours(_SLOT_PAGE, _SLOT_WHITE, _SLOT_WHITE)
|
||||
hue = _style_hex(node_style(hop.key)) if hop.key else None
|
||||
ink = hue if hue in DIM_TWIN else _SLOT_WHITE
|
||||
if hop.white_ink and hue:
|
||||
return _ChipColours(DIM_TWIN[ink], _SLOT_WHITE, _SLOT_WHITE)
|
||||
if hop.quiet and not hue:
|
||||
label = _SLOT_WHITE if hop.white_ink else _SLOT_PAGE
|
||||
return _ChipColours(DIM_TWIN[ink], label, label)
|
||||
return _ChipColours(DIM_TWIN[ink], ink, ink)
|
||||
|
||||
|
||||
|
||||
@@ -88,11 +88,11 @@ _WALK_EDGE = (255, 255, 255)
|
||||
#: around a scored walk. A fan grows past this number by itself, but nothing here fans.
|
||||
_GRAPH_ROWS = 3
|
||||
|
||||
#: The one body tone of the area drawing: a plain muted slate, for the enclosed region
|
||||
#: The one body tone of the area drawing: a plain muted grey, for the enclosed region
|
||||
#: and for its loop. It is one shade on purpose (no dimmer fill inside a brighter edge).
|
||||
#: An even-odd fill shades only the enclosed side, and the coloured node pins give the
|
||||
#: colour. Thus the shape shows as one flat silhouette, not as two brightnesses.
|
||||
_AREA_TONE: RGB = (148, 163, 184)
|
||||
_AREA_TONE: RGB = (162, 162, 162)
|
||||
|
||||
#: The cell box of the area drawing, which is the full stage of the Area tab. Its width
|
||||
#: is the width of the page. (The proportions are kept, thus a wider page gives a bigger
|
||||
@@ -127,9 +127,11 @@ _LABEL_W = 12
|
||||
def discipline_label(category: Category, lane: int) -> str:
|
||||
"""The mark of one discipline, padded to ``lane`` cells, then its title.
|
||||
|
||||
This is the only way to write the name of a board. Thus the seven headings (and the
|
||||
seven rows of the list that selects a discipline to delete) start their titles in one
|
||||
column, not in two. The marks do not all have the same width. ``🛣`` and ``🕸`` are
|
||||
This is the only way to write the name of a board with its mark: on the seven rows of
|
||||
the list that selects a discipline to delete, and on the header of the record page.
|
||||
Thus the seven rows start their titles in one column, not in two. A board heading has
|
||||
no mark. It is only the title (refer to :func:`~meshterm.ui.menus.section_heading`).
|
||||
The marks do not all have the same width. ``🛣`` and ``🕸`` are
|
||||
not in Emoji_Presentation, and they are one cell wide, while the other five are two
|
||||
cells wide. Because of this, ``🛣 Longest distance`` was one column away from
|
||||
``🎯 Farthest node``. Its gap also looked closed on a terminal that paints the road
|
||||
@@ -157,7 +159,7 @@ def discipline_lane() -> int:
|
||||
|
||||
Refer to :func:`discipline_label`. The width is measured, never written as a constant.
|
||||
The marks change with the platform. If a substitution becomes narrower later, each
|
||||
heading becomes one cell narrower, and nothing else moves.
|
||||
label becomes one cell narrower, and nothing else moves.
|
||||
"""
|
||||
return max(cell_len(glyph(category.icon)) for category in CATEGORIES)
|
||||
|
||||
@@ -448,7 +450,7 @@ class RecordScreen(Screen):
|
||||
|
||||
The record page has this line where the node page has its identity header. It
|
||||
reads as one sentence: ``🛣 Longest distance — 5th place: 112.0 km``. The
|
||||
discipline is written the same as its board heading writes it
|
||||
discipline is written the same as its row in the delete list writes it
|
||||
(:func:`discipline_label`: the mark is padded to the measured lane, so that ``🛣``
|
||||
and ``🎯`` start their titles in the same cell). The standing is the rank of the
|
||||
record on that board. The metric is the score in the unit of the discipline
|
||||
@@ -913,10 +915,6 @@ async def open_records(ctx: AppContext) -> dict:
|
||||
# node is matched against these keys.
|
||||
target_counts = ctx.repo.target_trace_counts()
|
||||
|
||||
# The mark column after which each board heading writes its title (refer to
|
||||
# discipline_label).
|
||||
mark_lane = discipline_lane()
|
||||
|
||||
# The node positions and types for the area drawing of a record. They are collected
|
||||
# live, the same as the trace tools collect them to score a walk (refer to
|
||||
# node_geo_lookup). The entry list does not change while the screen is open.
|
||||
@@ -1001,7 +999,7 @@ async def open_records(ctx: AppContext) -> dict:
|
||||
|
||||
def describe(category: Category) -> list:
|
||||
"""The heading and its wrapped description, as rows that the user cannot select."""
|
||||
rows: list = [section_heading(discipline_label(category, mark_lane))]
|
||||
rows: list = [section_heading(category.title)]
|
||||
desc = category.description
|
||||
if category.needs_positions and self_pos is None:
|
||||
desc += " — needs your location (set it in Config) to score"
|
||||
|
||||
@@ -237,7 +237,9 @@ def region_items(node: Contact, table: RegionTable, unsaved: int) -> tuple[str,
|
||||
# A blank line between the table and the commands of the page, because they are two lists
|
||||
# and not one. The first row of the tree is the wildcard. Code that measures icon
|
||||
# columns reads its name ``*`` as a leading mark. But the name is data, and it does not
|
||||
# belong to a column that the actions keep.
|
||||
# belong to a column that the actions keep. This blank line is the seam between two
|
||||
# lists, not the space of a section. It stays when the section spacing is off, and the
|
||||
# list adds no second line after it (Separator.spaced).
|
||||
items.append(Separator(" "))
|
||||
items.append(section_heading("Actions"))
|
||||
items.extend(
|
||||
|
||||
@@ -47,7 +47,14 @@ from ..core.models import (
|
||||
RoomAccess,
|
||||
RoomLogin,
|
||||
)
|
||||
from .chat import ChatScreen, _belongs, _contact_names, _make_paths_presenter, _with_restore
|
||||
from .chat import (
|
||||
ChatScreen,
|
||||
_belongs,
|
||||
_contact_names,
|
||||
_make_paths_presenter,
|
||||
_with_restore,
|
||||
our_node,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
@@ -141,6 +148,8 @@ class RoomScreen(ChatScreen):
|
||||
key_of: Callable[[str], str | None] | None = None,
|
||||
locator: Locator | None = None,
|
||||
pages: ContactPages | None = None,
|
||||
self_name: str | None = None,
|
||||
self_key: str | None = None,
|
||||
) -> None:
|
||||
"""Build the room screen.
|
||||
|
||||
@@ -169,6 +178,8 @@ class RoomScreen(ChatScreen):
|
||||
key_of: Gives the key of its node for a name, for the hues of ``@mention``.
|
||||
locator: The located nodes, for ^L: the map on the author of a post.
|
||||
pages: The contacts, for ^O: the node page of the author of a post.
|
||||
self_name: The name of our node, on our posts and on the mentions of us.
|
||||
self_key: Our public key, which gives the hue of that chip.
|
||||
"""
|
||||
super().__init__(
|
||||
conversation,
|
||||
@@ -181,6 +192,8 @@ class RoomScreen(ChatScreen):
|
||||
key_of=key_of,
|
||||
locator=locator,
|
||||
pages=pages,
|
||||
self_name=self_name,
|
||||
self_key=self_key,
|
||||
)
|
||||
self._label = conversation.label
|
||||
self._resolve = resolve
|
||||
@@ -425,6 +438,7 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
|
||||
async def join(ask: bool) -> RoomLogin | None:
|
||||
return await join_room(ctx, await fresh(), ask=ask)
|
||||
|
||||
self_name, self_key = await our_node(ctx)
|
||||
screen = RoomScreen(
|
||||
conversation,
|
||||
board,
|
||||
@@ -440,7 +454,9 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
|
||||
paths=await _make_paths_presenter(ctx, conversation, device),
|
||||
key_of=trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at),
|
||||
locator=await Locator.gather(ctx),
|
||||
pages=ContactPages(ctx, list(contacts)),
|
||||
pages=ContactPages(ctx, list(contacts), self_key=self_key or ""),
|
||||
self_name=self_name,
|
||||
self_key=self_key,
|
||||
)
|
||||
ctx.chat.set_active(conversation.key)
|
||||
|
||||
|
||||
@@ -20,7 +20,15 @@ from ..platforms import get_platform
|
||||
from .packet_viewer import KIND_ICONS, PAYLOAD_ICONS
|
||||
from .theme import _VT_SLOTS, fold_text, glyph, name_style, snr_style
|
||||
from .tui.fkeys import FPair, active_deck
|
||||
from .widgets import DESTINATION, ORIGIN, _recency_style, channel_glyph, name_chip
|
||||
from .widgets import (
|
||||
ORIGIN,
|
||||
_recency_style,
|
||||
author_chip,
|
||||
channel_glyph,
|
||||
mention_chip,
|
||||
name_chip,
|
||||
self_chip,
|
||||
)
|
||||
|
||||
#: Demonstration identities for the row of name colours: ``(name, key)``. The keys are
|
||||
#: synthetic. We chose them so that their first bytes are in three different sectors of
|
||||
@@ -28,6 +36,9 @@ from .widgets import DESTINATION, ORIGIN, _recency_style, channel_glyph, name_ch
|
||||
#: ``theme._NODE_SLOT_HEXES``).
|
||||
_DEMO_NAMES = (("Alice", "a1b2"), ("Lakeside", "3d63"), ("Lounge", "cc10"))
|
||||
|
||||
#: The key of our own node in the specimen, which gives the hue of its chat chip.
|
||||
_DEMO_SELF_KEY = "5e7f"
|
||||
|
||||
|
||||
def _palette_row() -> Text:
|
||||
"""The 16 slots as filled blocks: first the dim bank, then the bright bank.
|
||||
@@ -133,20 +144,21 @@ def specimen_lines() -> list[RenderableType]:
|
||||
|
||||
# The same names as chips, as the chat draws them. A sender label closes on the point,
|
||||
# because a message goes on from its author. The mention at the end opens on the
|
||||
# notch, because the node that it names is where the message goes. The path line's
|
||||
# own segment draws them, so this row is also a one-hop sample of the route language:
|
||||
# the hue in the fill, the star for our end, and the keyless grey for a node that
|
||||
# nobody can identify.
|
||||
# notch, because the node that it names is where the message goes, and it has no ``@``.
|
||||
# The path line's own segment draws them, so this row is also a one-hop sample of the
|
||||
# route language: the hue in the fill, and black on grey for a node that nobody can
|
||||
# identify. Our own messages go under ``★ me``, the one chip with a white label, on
|
||||
# the fill of our hue.
|
||||
chips = Text("chips ")
|
||||
for name, key in _DEMO_NAMES[:2]:
|
||||
chips.append_text(name_chip(name, key, role=ORIGIN))
|
||||
chips.append(" ")
|
||||
chips.append_text(name_chip("", you=True, role=ORIGIN))
|
||||
chips.append_text(self_chip("me", _DEMO_SELF_KEY, role=ORIGIN))
|
||||
chips.append(" ")
|
||||
chips.append_text(name_chip("·", role=ORIGIN))
|
||||
chips.append_text(author_chip("·", None))
|
||||
chips.append(" ")
|
||||
name, key = _DEMO_NAMES[2]
|
||||
chips.append_text(name_chip(f"@{name}", key, role=DESTINATION))
|
||||
chips.append_text(mention_chip(name, key))
|
||||
lines.append(chips)
|
||||
|
||||
lines.append(Text("heard ").append_text(_ages_row()))
|
||||
|
||||
@@ -971,7 +971,7 @@ class TuiUi(Ui):
|
||||
default=default,
|
||||
keys=keys,
|
||||
prompt_style=tier,
|
||||
border_style=tier or "accent",
|
||||
border_style=tier or "border",
|
||||
)
|
||||
|
||||
async def typed_confirm(self, warning: str, word: str, *, title: str = "Are you sure?") -> bool:
|
||||
|
||||
@@ -19,14 +19,19 @@ flow gets that free space back, in three steps over the pushed Contacts list:
|
||||
rungs show this list too. An age threshold cannot check itself, and to see what the
|
||||
contacts that it caught did is exactly that check. ``Delete`` removes a row from the
|
||||
list. Thus the user can spare one contact, and does not have to cancel the full sweep
|
||||
and select a less aggressive rung. ``←→`` scroll a row that is too wide for the
|
||||
terminal. Enter on a contact opens its detail page, only to examine the contact. The
|
||||
page does not show its own verbs that manage contacts (refer to
|
||||
and select a less aggressive rung. ``Shift+Delete`` also removes the row, and it stages
|
||||
a lock on the contact. Apply writes the lock, and then no later sweep takes that
|
||||
contact (refer to :meth:`~meshterm.core.contact_store.ContactStore.set_locked`). Back
|
||||
and Esc discard the staged locks, after a confirm, as an editor discards its staged
|
||||
values. ``←→`` scroll a row that is too wide for the terminal. Enter on a contact opens
|
||||
its detail page, only to examine the contact. The page does not show its own verbs
|
||||
that manage contacts (refer to
|
||||
:func:`~meshterm.ui.node_detail_screen.open_node_detail`). A screen where the user
|
||||
chooses what to archive must not also offer an archive or a delete of one contact from
|
||||
inside its own list of candidates. The list ends with the Apply/Back pair that
|
||||
:func:`~meshterm.ui.menus.exit_rows` draws for staged changes, because this list is
|
||||
that type of choice: a choice with a cost on each side, not an exit.
|
||||
inside its own list of candidates. A lock is not such a verb: it gives the same answer
|
||||
as ``Delete``, for all the sweeps that follow. The list ends with the Apply/Back pair
|
||||
that :func:`~meshterm.ui.menus.exit_rows` draws for staged changes, because this list
|
||||
is that type of choice: a choice with a cost on each side, not an exit.
|
||||
3. **Confirm.** An amber Cancel/Archive dialog, not the red typed gate of a deletion,
|
||||
because this operation is reversible. Each contact keeps its key, its reception history,
|
||||
and its transcripts. The Archived list is one row away on the Contacts screen, and a
|
||||
@@ -63,7 +68,7 @@ import math
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.text import Text
|
||||
@@ -75,10 +80,18 @@ from ..core.contact_score import (
|
||||
sweep_candidates,
|
||||
)
|
||||
from ..core.models import Contact
|
||||
from .menus import Lane, column_header, fit_cells, section_heading
|
||||
from .menus import (
|
||||
Lane,
|
||||
column_header,
|
||||
confirm_discard,
|
||||
fit_cells,
|
||||
section_heading,
|
||||
splice_if_room,
|
||||
)
|
||||
from .theme import name_style
|
||||
from .tui import Choice, Separator
|
||||
from .tui import Choice, SelectScreen, Separator
|
||||
from .tui.screen import CANCEL
|
||||
from .tui.select import DeleteRequest, KeyRequest
|
||||
from .widgets import DEFAULT_GLYPH, NODE_GLYPHS, _recency_style, format_age
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -98,6 +111,14 @@ KEEP_RUNGS: tuple[int, ...] = (90, 75, 60, 50, 25)
|
||||
_APPLY = ("apply",)
|
||||
_BACK = ("back",)
|
||||
|
||||
#: The token of the lock key, in the :class:`~meshterm.ui.tui.select.KeyRequest` that the
|
||||
#: preview resolves with (refer to :class:`_PreviewScreen`).
|
||||
_LOCK = "lock"
|
||||
|
||||
#: The footer atom of the lock key. The preview shows it only where the hint has room for it
|
||||
#: (refer to :func:`~meshterm.ui.menus.splice_if_room`).
|
||||
_LOCK_HINT = "⇧Del lock"
|
||||
|
||||
#: One day in seconds, for the age rungs.
|
||||
_DAY = 86400
|
||||
|
||||
@@ -416,6 +437,7 @@ async def archive_contacts(ctx: AppContext, self_key: str) -> int:
|
||||
|
||||
Returns:
|
||||
How many contacts were archived (``0`` if the user cancelled or no contact matched).
|
||||
A sweep that only locked contacts also returns ``0``.
|
||||
"""
|
||||
from .surface import TuiUi
|
||||
|
||||
@@ -430,6 +452,9 @@ async def archive_contacts(ctx: AppContext, self_key: str) -> int:
|
||||
if not sweepable:
|
||||
ctx.ui.note("[muted]every contact is protected — nothing to sweep[/muted]")
|
||||
return 0
|
||||
# The store keeps a lock under the key of the device. Without the two, the preview has
|
||||
# no lock key.
|
||||
can_lock = getattr(ctx, "contact_store", None) is not None and bool(self_key)
|
||||
|
||||
while True:
|
||||
picked = await session.run_screen(_target_screen(ranked, sweepable))
|
||||
@@ -439,8 +464,9 @@ async def archive_contacts(ctx: AppContext, self_key: str) -> int:
|
||||
if not victims:
|
||||
ctx.ui.note("[muted]nothing matches that — no contacts to archive[/muted]")
|
||||
continue
|
||||
if await _preview(ctx, victims):
|
||||
return await _sweep(ctx, self_key, victims)
|
||||
locks: list[ScoredContact] | None = [] if can_lock else None
|
||||
if await _preview(ctx, victims, locks):
|
||||
return await _sweep(ctx, self_key, victims, locks or [])
|
||||
|
||||
|
||||
def age_seconds(scored: ScoredContact) -> float | None:
|
||||
@@ -495,8 +521,6 @@ def _target_screen(ranked: list[ScoredContact], sweepable: list[ScoredContact]):
|
||||
one heading to the next. Also, the two types of rung look like two ways to answer one
|
||||
question, not like two separate features that share a screen by chance.
|
||||
"""
|
||||
from .tui import SelectScreen
|
||||
|
||||
protected = len(ranked) - len(sweepable)
|
||||
sections: list[tuple[str, list[tuple[str, tuple]]]] = [
|
||||
(
|
||||
@@ -539,7 +563,7 @@ def _target_screen(ranked: list[ScoredContact], sweepable: list[ScoredContact]):
|
||||
)
|
||||
|
||||
|
||||
def _preview_items(victims: list[ScoredContact]) -> list:
|
||||
def _preview_items(victims: list[ScoredContact], locks: list[ScoredContact] | None = None) -> list:
|
||||
"""The preview's rows: a pinned column header, one line for each victim, then Apply/Back.
|
||||
|
||||
Each contact row is ``deletable``, so ``Delete`` removes it from the sweep. Each row also
|
||||
@@ -550,39 +574,146 @@ def _preview_items(victims: list[ScoredContact]) -> list:
|
||||
This function measures the lanes across all the victims. That is also why a spared row
|
||||
makes this function build all the rows again, instead of a removal of one row: the
|
||||
value that left may have set a width.
|
||||
|
||||
A locked contact has no row: its row left the list when the user locked it, the same
|
||||
as a spared row. The Apply row counts the staged locks, and the Back row tells that
|
||||
they go too. When the user removed each victim and some locks are staged, an empty
|
||||
state stands in place of the rows, and Apply writes only the locks.
|
||||
"""
|
||||
widths = _lane_widths(victims)
|
||||
staged = len(locks or ())
|
||||
if victims:
|
||||
widths = _lane_widths(victims)
|
||||
rows: list = [
|
||||
Separator(lambda w: _preview_header(widths, w), pinned=True),
|
||||
*(
|
||||
Choice(
|
||||
title=_victim_row(v, widths),
|
||||
value=v,
|
||||
deletable=True,
|
||||
hscroll_from=_MARK_W + _NAME_W,
|
||||
)
|
||||
for v in victims
|
||||
),
|
||||
]
|
||||
else:
|
||||
rows = [Separator("no contacts left to archive", style="muted")]
|
||||
if not staged:
|
||||
apply = f"Archive {_count_desc(len(victims))}"
|
||||
elif victims:
|
||||
apply = f"Archive {_count_desc(len(victims))} and lock {staged}"
|
||||
else:
|
||||
apply = f"Lock {_count_desc(staged)}"
|
||||
return [
|
||||
Separator(lambda w: _preview_header(widths, w), pinned=True),
|
||||
*(
|
||||
Choice(
|
||||
title=_victim_row(v, widths),
|
||||
value=v,
|
||||
deletable=True,
|
||||
hscroll_from=_MARK_W + _NAME_W,
|
||||
)
|
||||
for v in victims
|
||||
),
|
||||
*rows,
|
||||
# The same shape that ``exit_rows`` draws, in the words of this flow. Apply has no
|
||||
# keyboard key of its own, so it must have a visible partner row that tells the cost
|
||||
# of the other way out. This is not ``exit_rows`` itself, because these rows are not
|
||||
# staged changes to a set of values, and the pair must say what it archives.
|
||||
Separator(" "),
|
||||
Choice(title=Text.assemble(("✓ ", "ok"), apply), value=_APPLY),
|
||||
Choice(
|
||||
title=Text.assemble(("✓ ", "ok"), f"Archive {_count_desc(len(victims))}"),
|
||||
value=_APPLY,
|
||||
title=Text.assemble(
|
||||
("✗ ", "err"), "Back — change nothing" if staged else "Back — keep them all"
|
||||
),
|
||||
value=_BACK,
|
||||
),
|
||||
Choice(title=Text.assemble(("✗ ", "err"), "Back — keep them all"), value=_BACK),
|
||||
]
|
||||
|
||||
|
||||
def _preview_screen(victims: list[ScoredContact]):
|
||||
"""The preview screen for a victim list, with a size and words that agree with its rows."""
|
||||
from .tui import SelectScreen
|
||||
def _has_full_key(value: object) -> bool:
|
||||
"""Whether a preview row holds a contact with its full key, which a lock must have.
|
||||
|
||||
return SelectScreen(
|
||||
f"Archive contacts — {_count_desc(len(victims))} to archive",
|
||||
_preview_items(victims),
|
||||
The store attaches a lock to the full key (refer to
|
||||
:meth:`~meshterm.core.contact_store.ContactStore.set_locked`). The node page has the
|
||||
same rule for its ``Lock contact`` row.
|
||||
"""
|
||||
if not isinstance(value, ScoredContact):
|
||||
return False
|
||||
return len((value.contact.public_key or "").lower().removeprefix("0x")) == 64
|
||||
|
||||
|
||||
class _PreviewScreen(SelectScreen):
|
||||
"""The preview list, with a lock key beside the spare key.
|
||||
|
||||
``Shift+Delete`` on a contact row resolves the list with a
|
||||
:class:`~meshterm.ui.tui.select.KeyRequest` for :data:`_LOCK`. The caller then stages the
|
||||
lock and removes the row (refer to :func:`_preview`). The key acts only when the caller
|
||||
can store a lock (``can_lock``), and only on a contact with a full key.
|
||||
|
||||
Two surfaces show the key. On a handheld, the ``Delete`` chip on F3 gets ``Lock`` as its
|
||||
Shift half. On the desktop, the footer gets the ``⇧Del lock`` atom after ``Del keep``,
|
||||
but only when the full hint stays in 72 cells. No other atom leaves the hint
|
||||
to make space for it.
|
||||
|
||||
A contact row also says ``Enter open`` instead of ``Enter select``, because Enter on it
|
||||
opens the node page. The Apply and Back rows keep ``Enter select``.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, can_lock: bool, **kwargs: Any) -> None:
|
||||
self._can_lock = can_lock
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _locks_here(self) -> bool:
|
||||
"""Whether the lock key acts on the highlighted row now."""
|
||||
current = self._current_choice()
|
||||
return (
|
||||
self._can_lock
|
||||
and current is not None
|
||||
and current.deletable
|
||||
and _has_full_key(current.value)
|
||||
)
|
||||
|
||||
def handle(self, action: str, data: str = "") -> None:
|
||||
"""Resolve ``Shift+Delete`` as a lock request. The list handles all other actions."""
|
||||
if action == "shift_delete":
|
||||
current = self._current_choice()
|
||||
if current is not None and self._locks_here():
|
||||
self.resolve(KeyRequest(_LOCK, current.value))
|
||||
return
|
||||
super().handle(action, data)
|
||||
|
||||
@property
|
||||
def footer_hint(self) -> str:
|
||||
"""The hint of the list, with ``Enter open`` and, if it fits, ``⇧Del lock`` on a contact."""
|
||||
hint = super().footer_hint
|
||||
current = self._current_choice()
|
||||
if current is None or not current.deletable:
|
||||
return hint
|
||||
hint = hint.replace("Enter select", "Enter open")
|
||||
if self._locks_here():
|
||||
hint = splice_if_room(hint, _LOCK_HINT)
|
||||
return hint
|
||||
|
||||
@property
|
||||
def picocalc_lyra_lane(self):
|
||||
"""The lane of the list, with ``Lock`` behind the ``Delete`` chip on F3."""
|
||||
lane = list(super().picocalc_lyra_lane)
|
||||
if self._can_lock and lane[2] is not None:
|
||||
lane[2] = replace(
|
||||
lane[2],
|
||||
opp_label="Lock",
|
||||
opp_action="shift_delete",
|
||||
opp_enabled=self._locks_here(),
|
||||
)
|
||||
return lane
|
||||
|
||||
|
||||
def _preview_title(victims: list[ScoredContact]) -> str:
|
||||
"""The title of the preview. It counts the victims, so it changes with them."""
|
||||
return f"Archive contacts — {_count_desc(len(victims))} to archive"
|
||||
|
||||
|
||||
def _preview_screen(
|
||||
victims: list[ScoredContact], locks: list[ScoredContact] | None = None
|
||||
) -> _PreviewScreen:
|
||||
"""The preview screen for a victim list, with a size and words that agree with its rows.
|
||||
|
||||
``locks`` is the list of staged locks, or ``None`` when the caller cannot store a lock.
|
||||
Then the screen has no lock key.
|
||||
"""
|
||||
return _PreviewScreen(
|
||||
_preview_title(victims),
|
||||
_preview_items(victims, locks),
|
||||
prompt="Archived contacts leave the device but stay here, with their history.",
|
||||
# The base hint does not include the scroll atom or the keep atom. The select list
|
||||
# adds each atom only when its keyboard key can act: ``←→`` only while the
|
||||
@@ -594,10 +725,13 @@ def _preview_screen(victims: list[ScoredContact]):
|
||||
default=_APPLY,
|
||||
hscroll=True,
|
||||
hscroll_hint="←→ scroll",
|
||||
can_lock=locks is not None,
|
||||
)
|
||||
|
||||
|
||||
async def _preview(ctx: AppContext, victims: list[ScoredContact]) -> bool:
|
||||
async def _preview(
|
||||
ctx: AppContext, victims: list[ScoredContact], locks: list[ScoredContact] | None = None
|
||||
) -> bool:
|
||||
"""Show exactly which contacts will go, weakest first. Return whether the user agreed.
|
||||
|
||||
This is the audit step, and the reason why it is safe to offer the sweep. If MeshTerm
|
||||
@@ -613,18 +747,27 @@ async def _preview(ctx: AppContext, victims: list[ScoredContact]) -> bool:
|
||||
value, and the typed filter stays. The screen is not built again, because that would put
|
||||
the user back at the top of a list that they were halfway down.
|
||||
|
||||
``Shift+Delete`` removes a contact in the same way, and it also stages a lock on it. The
|
||||
lock is staged, not written, because the user decides on Apply (JP, 2026-10-19). Thus
|
||||
Esc and the Back row discard the locks, and they ask first, as an editor with staged
|
||||
values does (:func:`~meshterm.ui.menus.confirm_discard`). A spared row is not a staged
|
||||
change: Esc discarded it before the lock existed, and it does so now.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context.
|
||||
victims: The candidates, weakest first. This function **changes** the list when the
|
||||
user removes rows, so the list of the caller is the list that the sweep archives.
|
||||
locks: The staged locks. This function adds to the list of the caller in the same
|
||||
way. ``None`` when the caller cannot store a lock: then the preview has no lock
|
||||
key.
|
||||
|
||||
Returns:
|
||||
Whether the user selected the archive of the contacts that remain after the edits.
|
||||
Whether the user selected Apply for the contacts and the locks that remain after the
|
||||
edits.
|
||||
"""
|
||||
from .node_detail_screen import open_node_detail
|
||||
from .tui.select import DeleteRequest
|
||||
|
||||
screen = _preview_screen(victims)
|
||||
screen = _preview_screen(victims, locks)
|
||||
# A hub: it owns a loop, so it is pushed one time and stays for the full visit. That is
|
||||
# why a spared row can refresh the rows below the user's highlight. It is also why a
|
||||
# detail page can nest above this screen, and this screen keeps its place.
|
||||
@@ -632,24 +775,23 @@ async def _preview(ctx: AppContext, victims: list[ScoredContact]) -> bool:
|
||||
while True:
|
||||
chosen = await visit.result()
|
||||
if chosen is CANCEL or chosen is None or chosen == _BACK:
|
||||
if locks and not await confirm_discard(ctx, len(locks), verb="applying"):
|
||||
continue # continue to edit: the same list, at the same place in it
|
||||
return False
|
||||
if chosen == _APPLY:
|
||||
return bool(victims)
|
||||
if isinstance(chosen, DeleteRequest):
|
||||
spared = chosen.value
|
||||
if spared in victims:
|
||||
victims.remove(spared)
|
||||
if not victims:
|
||||
return bool(victims or locks)
|
||||
if isinstance(chosen, DeleteRequest | KeyRequest):
|
||||
removed = chosen.value
|
||||
if removed in victims:
|
||||
victims.remove(removed)
|
||||
if isinstance(chosen, KeyRequest) and locks is not None and removed not in locks:
|
||||
locks.append(removed)
|
||||
if not victims and not locks:
|
||||
# The user spared all the candidates. Thus there is no sweep left to
|
||||
# preview. An empty list with an "Archive no contacts" row would be a
|
||||
# screen that asks the user to confirm nothing.
|
||||
return False
|
||||
# The title shows the count of victims, so it is content too, and it
|
||||
# changes with them.
|
||||
screen.replace_items(
|
||||
_preview_items(victims),
|
||||
title=f"Archive contacts — {_count_desc(len(victims))} to archive",
|
||||
)
|
||||
screen.replace_items(_preview_items(victims, locks), title=_preview_title(victims))
|
||||
continue
|
||||
# Each other row is a contact. Enter opens its node detail page, so a user who
|
||||
# does not recognize a name can examine the node before MeshTerm archives it. The
|
||||
@@ -668,7 +810,12 @@ def _count_desc(n: int) -> str:
|
||||
return f"{n} contact{'' if n == 1 else 's'}"
|
||||
|
||||
|
||||
async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -> int:
|
||||
async def _sweep(
|
||||
ctx: AppContext,
|
||||
self_key: str,
|
||||
victims: list[ScoredContact],
|
||||
locks: list[ScoredContact] | None = None,
|
||||
) -> int:
|
||||
"""Confirm, then remove each victim from the device and archive it here. Return the count.
|
||||
|
||||
The confirm is **amber, and the user types nothing**. A bulk deletion has a red typed
|
||||
@@ -682,52 +829,77 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
|
||||
The store write comes after each successful removal, not in one batch at the end. Thus,
|
||||
if the sweep is interrupted, the state stays consistent: each contact that is off the
|
||||
device is stored as archived.
|
||||
"""
|
||||
if not await ctx.ui.dialog(
|
||||
f"Archive {_count_desc(len(victims))}? They come off this device's contact list, "
|
||||
"freeing space for new ones. MeshTerm keeps them — with their keys, reception "
|
||||
"history, and messages — under Archived contacts, and any of them can be restored.",
|
||||
[("Cancel", False), ("Archive", True)],
|
||||
title="Archive contacts",
|
||||
default=1,
|
||||
danger=True,
|
||||
):
|
||||
return 0
|
||||
|
||||
device = await ctx.device()
|
||||
The staged ``locks`` are written after the confirm and before the removals. A lock is a
|
||||
mark of MeshTerm, in the contact store, and nothing on the device. Thus it cannot fail
|
||||
as a removal can, and it does not wait for one. When only locks are staged, there is
|
||||
no confirm: a lock archives nothing, and the node page reverses it.
|
||||
"""
|
||||
locks = locks or []
|
||||
if victims:
|
||||
also = (
|
||||
f" MeshTerm also locks {_count_desc(len(locks))}, so that no sweep takes them."
|
||||
if locks
|
||||
else ""
|
||||
)
|
||||
if not await ctx.ui.dialog(
|
||||
f"Archive {_count_desc(len(victims))}? They come off this device's contact list, "
|
||||
"freeing space for new ones. MeshTerm keeps them — with their keys, reception "
|
||||
"history, and messages — under Archived contacts, and any of them can be "
|
||||
f"restored.{also}",
|
||||
[("Cancel", False), ("Archive", True)],
|
||||
title="Archive contacts",
|
||||
default=1,
|
||||
danger=True,
|
||||
):
|
||||
return 0
|
||||
|
||||
dev_pub = (self_key or "").lower().removeprefix("0x")
|
||||
store = ctx.contact_store
|
||||
stamp = int(time.time())
|
||||
archived = failed = 0
|
||||
with ctx.ui.progress("Archive contacts") as progress:
|
||||
task = progress.add_task("Archiving", total=len(victims))
|
||||
for scored in victims:
|
||||
contact = scored.contact
|
||||
try:
|
||||
await device.remove_contact(contact)
|
||||
except ContactNotOnDeviceError:
|
||||
# The contact is already absent from the device. Thus the work of the sweep
|
||||
# on the device is done, and the archive here is what removes the row. This
|
||||
# is not a failure.
|
||||
ctx.log.debug("archive: %s was not on the device; archiving ours", contact.name)
|
||||
except Exception as exc: # noqa: BLE001 - one bad removal must not stop the sweep
|
||||
failed += 1
|
||||
ctx.log.debug("archive: could not remove %s: %s", contact.name, exc)
|
||||
continue
|
||||
finally:
|
||||
progress.advance(task)
|
||||
if store is not None and dev_pub:
|
||||
store.archive(dev_pub, contact, when=stamp)
|
||||
archived += 1
|
||||
ctx.devstate.invalidate_contacts()
|
||||
locked = 0
|
||||
if store is not None and dev_pub:
|
||||
for scored in locks:
|
||||
store.set_locked(dev_pub, scored.contact, True)
|
||||
locked += 1
|
||||
|
||||
if archived and not failed:
|
||||
outcome = Text(f"✓ archived {_count_desc(archived)}", style="ok")
|
||||
archived = failed = 0
|
||||
if victims:
|
||||
device = await ctx.device()
|
||||
stamp = int(time.time())
|
||||
with ctx.ui.progress("Archive contacts") as progress:
|
||||
task = progress.add_task("Archiving", total=len(victims))
|
||||
for scored in victims:
|
||||
contact = scored.contact
|
||||
try:
|
||||
await device.remove_contact(contact)
|
||||
except ContactNotOnDeviceError:
|
||||
# The contact is already absent from the device. Thus the work of the
|
||||
# sweep on the device is done, and the archive here is what removes the
|
||||
# row. This is not a failure.
|
||||
ctx.log.debug("archive: %s was not on the device; archiving ours", contact.name)
|
||||
except Exception as exc: # noqa: BLE001 - one bad removal must not stop the sweep
|
||||
failed += 1
|
||||
ctx.log.debug("archive: could not remove %s: %s", contact.name, exc)
|
||||
continue
|
||||
finally:
|
||||
progress.advance(task)
|
||||
if store is not None and dev_pub:
|
||||
store.archive(dev_pub, contact, when=stamp)
|
||||
archived += 1
|
||||
ctx.devstate.invalidate_contacts()
|
||||
|
||||
and_locked = f" and locked {locked}" if locked else ""
|
||||
if not victims:
|
||||
outcome = Text(f"✓ locked {_count_desc(locked)}", style="ok")
|
||||
elif archived and not failed:
|
||||
outcome = Text(f"✓ archived {_count_desc(archived)}{and_locked}", style="ok")
|
||||
elif archived:
|
||||
outcome = Text(
|
||||
f"archived {_count_desc(archived)}; {failed} could not be removed", style="warn"
|
||||
f"archived {_count_desc(archived)}{and_locked}; {failed} could not be removed",
|
||||
style="warn",
|
||||
)
|
||||
else:
|
||||
outcome = Text("no contacts could be archived", style="err")
|
||||
locked_note = f"; locked {_count_desc(locked)}" if locked else ""
|
||||
outcome = Text(f"no contacts could be archived{locked_note}", style="err")
|
||||
await ctx.ui.session.message_dialog(outcome, title="Archive contacts")
|
||||
return archived
|
||||
@@ -29,18 +29,26 @@ MESH_THEME = Theme(
|
||||
{
|
||||
"brand": "bold #5eead4",
|
||||
"accent": "bold #818cf8",
|
||||
# The border of each panel and dialog that has no caution tier: the base frame, a
|
||||
# floating dialog, the title bar of a handheld, and a tab strip. It is the grey of
|
||||
# ``muted``, and not a hue. A border is chrome, and a blue border was one more colour
|
||||
# on each screen that had no meaning (JP, 2026-10-14). The two caution tiers keep
|
||||
# their colours, because there the colour is the meaning: ``warn`` (amber) and
|
||||
# ``err`` (red). It is darker than ``muted``, so the frame stays behind the text that
|
||||
# it holds. JP chose it between #a2a2a2 (too light) and #7c7c7c (too dark).
|
||||
"border": "#8f8f8f",
|
||||
# The only reverse-video chip: the committing button of a dialog, the Abort of a
|
||||
# running action, and the insertion slot of the composer where no chip fill carries
|
||||
# it. It is a *grey* block, and the ink of the page shows through it. It was cyan
|
||||
# before. That cyan was the teal of the wordmark with a third job (identity, a
|
||||
# node hue, and now a selection). A chip is chrome. It shows where a press lands,
|
||||
# and it does not claim a colour. The slate is the slate of ``muted``. It is light
|
||||
# and it does not claim a colour. The grey is the grey of ``muted``. It is light
|
||||
# enough that the reversed ink reads on it, in any background of the terminal. It
|
||||
# must be a *single* theme name. Rich removes without a message a style string that
|
||||
# mixes a theme name with an attribute (for example "reverse muted" renders as plain
|
||||
# text). Thus the reverse is part of the definition here, and the call site does
|
||||
# not add it.
|
||||
"selected": "reverse bold #94a3b8",
|
||||
"selected": "reverse bold #a2a2a2",
|
||||
# The only ink for "this is the one that the user selected". It is the row that the
|
||||
# ❯ points at, in each select list and in each screen that draws its own rows
|
||||
# (feed, routes, records, walk, composer). It is also the lit column heading one
|
||||
@@ -79,6 +87,11 @@ MESH_THEME = Theme(
|
||||
# of the node hues on purpose (refer to node_style). Thus the user can always find
|
||||
# "you" easily.
|
||||
"you": "bold #ffffff",
|
||||
# The text that the user types in the compose line of a chat: pure white, not bold.
|
||||
# It is more visible than the transcript above it (JP, 2026-10-13). The ``›`` in
|
||||
# front of it has the ``cursor`` white, the same as the ``❯`` of a selected
|
||||
# message.
|
||||
"compose": "#ffffff",
|
||||
# The name of a confirmed companion on the picker for the startup device: pure
|
||||
# white. Thus the devices that we talked to before are more visible than the ports
|
||||
# that MeshTerm only detected.
|
||||
@@ -100,7 +113,7 @@ MESH_THEME = Theme(
|
||||
"ok": "bold #4ade80",
|
||||
"warn": "bold #fbbf24",
|
||||
"err": "bold #f87171",
|
||||
"muted": "#94a3b8",
|
||||
"muted": "#a2a2a2",
|
||||
# A node that MeshTerm cannot identify: its ``○`` ring, and its label where a bare
|
||||
# key or hash is the name (refer to the return of name_style when there is no key).
|
||||
# It has its own name and does not use ``muted``, because the two styles must not
|
||||
@@ -108,13 +121,13 @@ MESH_THEME = Theme(
|
||||
# text. An unidentified node is *content* on which the user can still act. On the
|
||||
# console this difference is the whole ladder: chrome takes the dark grey slot,
|
||||
# and this style takes the light grey slot.
|
||||
"node.unknown": "#94a3b8",
|
||||
"node.unknown": "#a2a2a2",
|
||||
# The titles of panels and dialogs: the same hue as the border that they are in,
|
||||
# one shade brighter. Thus the title reads as part of its frame, and it is still
|
||||
# different from the frame. There is one entry for each border style that the frame
|
||||
# compositor receives (refer to theme.title_style).
|
||||
"title.accent": "bold #a5b4fc",
|
||||
"title.muted": "bold #cbd5e1",
|
||||
"title.border": "bold #d4d4d4",
|
||||
"title.muted": "bold #d4d4d4",
|
||||
"title.warn": "bold #fcd34d",
|
||||
"title.err": "bold #fca5a5",
|
||||
"title.ok": "bold #86efac",
|
||||
@@ -123,25 +136,25 @@ MESH_THEME = Theme(
|
||||
# (and not bold). This is the opposite of the brightening of the ``title.*`` styles.
|
||||
# The hint reads as part of the frame, and it stays behind the frame. There is one
|
||||
# entry for each border style (refer to theme.hint_style).
|
||||
"hint.accent": "#6366f1",
|
||||
"hint.muted": "#64748b",
|
||||
"hint.border": "#737373",
|
||||
"hint.muted": "#737373",
|
||||
"hint.warn": "#f59e0b",
|
||||
"hint.err": "#ef4444",
|
||||
"hint.ok": "#22c55e",
|
||||
"hint.brand": "#2dd4bf",
|
||||
# A step darker than ``muted``, for placeholder dashes (the missing packet count or
|
||||
# age of a node). They must stay behind the real, muted values around them.
|
||||
"faint": "#64748b",
|
||||
"faint": "#737373",
|
||||
# The ``── Label ──`` of a section heading: in a grouped list, and the day divider
|
||||
# of a chat. It is a dark grey, and never the accent of the frame. When the rule of
|
||||
# a heading had the colour of the border, it looked like part of the frame that it
|
||||
# is in (JP, 2026-10-02). It is bold, so the label is still a landmark at that
|
||||
# shade.
|
||||
"heading": "bold #64748b",
|
||||
# of a chat. It has the colour of the title of the frame (``title.border``, JP,
|
||||
# 2026-10-15). It was a dark grey before. It is never the grey of the border itself:
|
||||
# when the rule of a heading had the colour of the border, it looked like part of
|
||||
# the frame that it is in (JP, 2026-10-02).
|
||||
"heading": "bold #d4d4d4",
|
||||
# A further step darker than ``faint``, for the unlit track of a meter (the SNR
|
||||
# quality bars). It is dark enough to look like a background, and not like a
|
||||
# dimmer version of the reading.
|
||||
"track": "#334155",
|
||||
"track": "#404040",
|
||||
"snr.good": "bold #4ade80",
|
||||
"snr.ok": "bold #fbbf24",
|
||||
"snr.bad": "bold #f87171",
|
||||
@@ -158,20 +171,19 @@ MESH_THEME = Theme(
|
||||
"batt.low": "bold #f87171 on #991b1b",
|
||||
"batt.flash": "not bold #000000 on #dc2626",
|
||||
"batt.flash.off": "bold #f87171",
|
||||
# The node-type *marks*: the only colours for ● ▲ ■ ◉ where a node with a type is
|
||||
# drawn (refer to ui.widgets.NODE_GLYPHS). They are also the language of the markers
|
||||
# of the map: clients are the loud pink, repeaters are the calmer violet, rooms are
|
||||
# a white square, and sensors are an orange dot with a ring. They are named styles
|
||||
# and not raw hex, so the 16-slot console chooses its slot *on purpose*. A naive
|
||||
# downsample of the violet gives grey, and a repeater looks like an unknown node.
|
||||
"type.node": "#f472b6",
|
||||
"type.repeater": "#a78bfa",
|
||||
# The node-type *marks*: the colours for ● ▲ ■ ◉ where a node with a type is drawn
|
||||
# (refer to ui.widgets.NODE_GLYPHS), and the markers of the map. All four are white.
|
||||
# The shape tells the type, so a hue for each type was a colour with no more
|
||||
# information (JP, 2026-10-14). They were pink, violet, white, and orange. Each type
|
||||
# keeps its own style name, so that one type can get a colour again in one place.
|
||||
"type.node": "#ffffff",
|
||||
"type.repeater": "#ffffff",
|
||||
"type.room": "#ffffff",
|
||||
"type.sensor": "#fb923c",
|
||||
"type.sensor": "#ffffff",
|
||||
# A region name (the scope of a flood). It is not a node, so it has no hue from the
|
||||
# node wheel. It is a light slate. Its slant makes it different from prose, as a
|
||||
# node wheel. It is a light grey. Its slant makes it different from prose, as a
|
||||
# tag is different from a sentence.
|
||||
"scope": "italic #cbd5e1",
|
||||
"scope": "italic #d4d4d4",
|
||||
# The features of the basemap for which the *hue* is the information: water is
|
||||
# blue, parks are green, and a highway is the warm colour. A naive downsample
|
||||
# damages them (the dark blue becomes grey, the dark green becomes black, and the
|
||||
@@ -200,13 +212,13 @@ MESH_THEME = Theme(
|
||||
"heat.hours": "#f87171", # 1 hour
|
||||
"heat.days": "#b45309", # 1 day
|
||||
"heat.weeks": "#dc2626", # 1 week
|
||||
"heat.months": "#94a3b8", # 1 month
|
||||
"heat.never": "#64748b", # 1 year, and never heard
|
||||
"heat.months": "#a2a2a2", # 1 month
|
||||
"heat.never": "#737373", # 1 year, and never heard
|
||||
# The chip fills of the F-key lane of the PicoCalc (refer to ui.tui.fkeys): grey for
|
||||
# the plain F1-F5 bank, and green while the Shift watcher reports F6-F10. The text
|
||||
# is white on both. They are also defined here so that the style names resolve on
|
||||
# each platform, although only footer_fkeys of the PicoCalc asks for them.
|
||||
"fkey.chip": "bold #ffffff on #475569",
|
||||
"fkey.chip": "bold #ffffff on #545454",
|
||||
"fkey.chip.shift": "bold #ffffff on #16a34a",
|
||||
# The fills of the Cardputer Zero deck, taken from its own keyboard (JP,
|
||||
# 2026-09-30). The normal fill is the orange-red in which the fn key and its F4–F8
|
||||
@@ -225,14 +237,14 @@ MESH_THEME = Theme(
|
||||
# and links have the two hues that nothing else in a body uses. A struck-out run
|
||||
# goes back to the placeholder grey. The bullet and the rail are the chrome on which
|
||||
# a list or a quote hangs.
|
||||
"md.strong": "bold #e2e8f0",
|
||||
"md.em": "italic #cbd5e1",
|
||||
"md.strong": "bold #e7e7e7",
|
||||
"md.em": "italic #d4d4d4",
|
||||
"md.code": "#7dd3fc",
|
||||
"md.link": "underline #a5b4fc",
|
||||
"md.quote": "italic #cbd5e1",
|
||||
"md.strike": "strike #64748b",
|
||||
"md.quote": "italic #d4d4d4",
|
||||
"md.strike": "strike #737373",
|
||||
"md.bullet": "#818cf8",
|
||||
"md.rail": "#475569",
|
||||
"md.rail": "#545454",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -246,12 +258,13 @@ MESH_THEME = Theme(
|
||||
#: moves it to slot N+8. Thus a style for a dim slot must say what it means about
|
||||
#: bold. It cannot leave the question open, because Rich merges a *base* style into
|
||||
#: each span that it wraps. Then the ``bold`` of a selected row changes the colour of
|
||||
#: the span completely (a light-grey unknown hash becomes the white "you", and a purple
|
||||
#: repeater becomes pink). Thus each style for a dim slot is explicitly ``not bold``
|
||||
#: the span completely (a light-grey unknown hash becomes the white "you", and a brown
|
||||
#: highway becomes yellow). Thus each style for a dim slot is explicitly ``not bold``
|
||||
#: (the colour is necessary, so keep it) or explicitly ``bold`` (the promotion is the
|
||||
#: intent, and only ``title.muted`` has it). A style is never silent. Never use bold on
|
||||
#: 5 and 6, because *different* meanings use their partners here (5 purple = the
|
||||
#: repeater mark, 13 pink = the node mark. 6 cyan = hint.brand, 14 bright cyan = brand).
|
||||
#: intent, and only ``title.muted``, ``title.border``, and ``heading`` have it). A
|
||||
#: style is never silent. Never use bold on 5 and 6, because *different* meanings use
|
||||
#: their partners here (5 purple = the chip fill under a pink name, 13 pink = a node
|
||||
#: hue. 6 cyan = hint.brand, 14 bright cyan = brand).
|
||||
#: * Backgrounds can use only slots 0–7 (SGR 40–47).
|
||||
#: * The six *chromatic bright* slots (9-14) are the place where the spectrum of node
|
||||
#: names lands. They are the console rendering of the hue wheel that comes from the key
|
||||
@@ -261,8 +274,8 @@ _VT_SLOTS: tuple[tuple[int, str, str], ...] = (
|
||||
(1, "red (hint.err)", "#aa0000"),
|
||||
(2, "green (hint.ok)", "#00aa00"),
|
||||
(3, "brown/orange (hint.warn, batt.mid, heat.cool, the sensor mark)", "#aa5500"),
|
||||
(4, "blue (hint.accent, bluetooth bg)", "#0000aa"),
|
||||
(5, "purple (the repeater mark)", "#aa00aa"),
|
||||
(4, "blue (bluetooth bg, map.water)", "#0000aa"),
|
||||
(5, "purple (the chip fill of node hue 300°)", "#aa00aa"),
|
||||
(6, "cyan (hint.brand)", "#00aaaa"),
|
||||
(7, "light grey (default text, heat.cold)", "#aaaaaa"),
|
||||
(8, "dark grey (muted, faint, track)", "#555555"),
|
||||
@@ -270,9 +283,9 @@ _VT_SLOTS: tuple[tuple[int, str, str], ...] = (
|
||||
(10, "bright green (ok; node hue 120°)", "#55ff55"),
|
||||
(11, "bright yellow (warn, heat.warm; node hue 60°)", "#ffff55"),
|
||||
(12, "bright blue (accent; node hue 240°)", "#5555ff"),
|
||||
(13, "bright pink (the node mark — the map's pink, for free; node hue 300°)", "#ff55ff"),
|
||||
(13, "bright pink (node hue 300°)", "#ff55ff"),
|
||||
(14, "bright cyan (brand; node hue 180°)", "#55ffff"),
|
||||
(15, "white (you, heat.hot, the room mark)", "#ffffff"),
|
||||
(15, "white (you, heat.hot, the node-type marks)", "#ffffff"),
|
||||
)
|
||||
|
||||
|
||||
@@ -336,14 +349,15 @@ def vtrgb_lines() -> str:
|
||||
#: palette. The theme is literal and not derived, so a reviewer can see each slot choice
|
||||
#: next to its meaning. The rules for bold-brightness and for backgrounds, which the
|
||||
#: theme must obey, are in the documentation of :data:`_VT_SLOTS`, and tests pin them.
|
||||
#: The stock palette gives two important results. Bright pink (13) is the colour of the
|
||||
#: clients on the map, at no cost. Dim purple (5) replaces the violet of the repeaters
|
||||
#: on the map. The cost is that the three greys (muted, faint, track) all become slot 8,
|
||||
#: The stock palette has a cost: the three greys (muted, faint, track) all become slot 8,
|
||||
#: because the stock palette has only one dark grey.
|
||||
MESH_THEME_16 = Theme(
|
||||
{
|
||||
"brand": "bold color(14)",
|
||||
"accent": "bold color(12)",
|
||||
# The border: the light grey, which is the nearest slot to the grey of the regular
|
||||
# theme. It is ``not bold`` because 7 is a dim slot (refer to the bold rule).
|
||||
"border": "not bold color(7)",
|
||||
# Slot 7 is the only grey that the VT can put *behind* a reverse. Backgrounds stop at
|
||||
# the dim bank, and the dark grey of slot 8 becomes black there. It is the same
|
||||
# light grey that the F-key lane already uses to fill its chips. This is the
|
||||
@@ -360,6 +374,8 @@ MESH_THEME_16 = Theme(
|
||||
"pathline": "none",
|
||||
"err.reverse": "reverse bold color(9)",
|
||||
"you": "bold color(15)",
|
||||
# Slot 15 is already bright, so it needs no bold to be white.
|
||||
"compose": "color(15)",
|
||||
"device.known": "bold color(15)",
|
||||
"qr": "bold color(15) on color(0)",
|
||||
"bluetooth": "bold color(15) on color(4)",
|
||||
@@ -371,24 +387,24 @@ MESH_THEME_16 = Theme(
|
||||
# Light grey, a step *above* the dark grey of muted. The console has only two greys,
|
||||
# and the hash of an unidentified node is content, not chrome.
|
||||
"node.unknown": "not bold color(7)",
|
||||
"title.accent": "bold color(12)",
|
||||
# The one promotion on purpose: bold moves slot 7 to 15. This title needs a colour
|
||||
# that is almost white (the regular theme writes it as #cbd5e1).
|
||||
# Two of the promotions on purpose (``heading`` is the third): bold moves slot 7 to
|
||||
# 15. These titles need a colour that is almost white (the regular theme writes it
|
||||
# as #d4d4d4).
|
||||
"title.border": "bold color(7)",
|
||||
"title.muted": "bold color(7)",
|
||||
"title.warn": "bold color(11)",
|
||||
"title.err": "bold color(9)",
|
||||
"title.ok": "bold color(10)",
|
||||
"title.brand": "bold color(14)",
|
||||
"hint.accent": "not bold color(4)",
|
||||
"hint.border": "color(8)",
|
||||
"hint.muted": "color(8)",
|
||||
"hint.warn": "not bold color(3)",
|
||||
"hint.err": "not bold color(1)",
|
||||
"hint.ok": "not bold color(2)",
|
||||
"hint.brand": "not bold color(6)",
|
||||
"faint": "color(8)",
|
||||
# The one dark grey of the console. Slot 8 is already in the bright bank, so bold
|
||||
# changes nothing.
|
||||
"heading": "bold color(8)",
|
||||
# The colour of the title of the frame (``title.border``): bold moves slot 7 to 15.
|
||||
"heading": "bold color(7)",
|
||||
"track": "color(8)",
|
||||
"snr.good": "bold color(10)",
|
||||
"snr.ok": "bold color(11)",
|
||||
@@ -401,10 +417,10 @@ MESH_THEME_16 = Theme(
|
||||
"batt.low": "bold color(9) on color(1)",
|
||||
"batt.flash": "not bold color(0) on color(1)",
|
||||
"batt.flash.off": "bold color(9)",
|
||||
"type.node": "color(13)",
|
||||
"type.repeater": "not bold color(5)",
|
||||
"type.node": "color(15)",
|
||||
"type.repeater": "color(15)",
|
||||
"type.room": "color(15)",
|
||||
"type.sensor": "not bold color(3)",
|
||||
"type.sensor": "color(15)",
|
||||
# A region name: the VT has no italic, and each chromatic slot is a node hue. Thus
|
||||
# the style is the plain light grey. The word "scope" in front of it makes it
|
||||
# different from other text.
|
||||
@@ -476,9 +492,10 @@ def mark_rgb(colour: str) -> tuple[int, int, int]:
|
||||
A canvas paints in RGB and not in styles (refer to :mod:`~meshterm.ui.mapcanvas`). A
|
||||
marker that must agree with its twin that Rich draws gets the *same colour* through
|
||||
this function. The function accepts both encodings that the marker language uses. One
|
||||
is a literal ``#rrggbb`` (the fixed map marks in :mod:`~meshterm.ui.marks`). The other
|
||||
is a theme style name (the node-type marks, which have names so that each platform
|
||||
chooses its own colour, refer to ``type.*`` of the theme).
|
||||
is a literal ``#rrggbb`` (for example a grey of the basemap in
|
||||
:mod:`~meshterm.ui.map_render`). The other is a theme style name (the marks in
|
||||
:mod:`~meshterm.ui.marks` and the node-type marks, which have names so that each
|
||||
platform chooses its own colour, refer to ``type.*`` of the theme).
|
||||
|
||||
On the 16-slot theme, a ``color(N)`` entry gives the :data:`_VT_SLOTS` RGB of that
|
||||
slot, and not the stock triple of Rich. The palette of Rich is a shade different from
|
||||
@@ -538,7 +555,7 @@ def title_style(border_style: str) -> str:
|
||||
|
||||
Args:
|
||||
border_style: The theme name in which the border of the panel is drawn
|
||||
(``"accent"``, ``"warn"``, and other names).
|
||||
(``"border"``, ``"warn"``, and other names).
|
||||
|
||||
Returns:
|
||||
The matching ``title.*`` theme name. If no brighter variant is defined, it is
|
||||
@@ -558,7 +575,7 @@ def hint_style(border_style: str) -> str:
|
||||
|
||||
Args:
|
||||
border_style: The theme name in which the border of the panel is drawn
|
||||
(``"accent"``, ``"warn"``, and other names).
|
||||
(``"border"``, ``"warn"``, and other names).
|
||||
|
||||
Returns:
|
||||
The matching ``hint.*`` theme name. If no variant is defined, it is ``"muted"``.
|
||||
@@ -825,6 +842,36 @@ def _glyph_identity(icon: str) -> str:
|
||||
return icon
|
||||
|
||||
|
||||
#: The emoticons that stand in for the default reactions where the font has no emoji (the
|
||||
#: PicoCalc and the Cardputer). Each default reaction is two cells wide, and so is each
|
||||
#: emoticon. Thus a badge keeps its width. A reaction that is not here goes through the fold
|
||||
#: at the render boundary, as each other emoji does.
|
||||
# fmt: off
|
||||
_EMOTICONS: dict[str, str] = {
|
||||
"👍": "+1", "👎": "-1", "❤️": "<3", "❤": "<3", "😂": ":D", "😮": ":O", "😢": ":(",
|
||||
}
|
||||
# fmt: on
|
||||
|
||||
|
||||
def emoticon(emoji: str) -> str:
|
||||
"""The rendering of a reaction on the platform: the emoji itself, or its emoticon.
|
||||
|
||||
Args:
|
||||
emoji: The emoji of a reaction (one glyph, refer to
|
||||
:func:`~meshterm.core.reactions.is_reaction_emoji`).
|
||||
|
||||
Returns:
|
||||
The emoji on a platform that draws emoji. Else its emoticon from
|
||||
:data:`_EMOTICONS`, or the emoji unchanged when it has none.
|
||||
"""
|
||||
return _emoticon_impl(emoji)
|
||||
|
||||
|
||||
def _emoticon_compact(emoji: str) -> str:
|
||||
"""A platform with no emoji: the emoticon of a reaction, when it has one."""
|
||||
return _EMOTICONS.get(emoji, emoji)
|
||||
|
||||
|
||||
def _glyph_compact(icon: str) -> str:
|
||||
"""The PicoCalc: icons become their single-cell console-font glyph."""
|
||||
return _GLYPH_MAP.get(icon, icon)
|
||||
@@ -915,7 +962,7 @@ def _nearest_slot_params(background: bool, r: int, g: int, b: int) -> str:
|
||||
the console spells bright. Thus a bare ``31`` immediately after a ``90`` inherits the
|
||||
intensity bit, and renders as ``91`` without a message. Quantized art is a run of
|
||||
colour spans that are next to each other, and no style boundary between them resets
|
||||
the intensity (the bevel of the wordmark has a slate span in every other span, and a
|
||||
the intensity (the bevel of the wordmark has a grey span in every other span, and a
|
||||
map raster has neighbouring cells). Thus the promotion happens in the middle of a row
|
||||
and gives the wrong colour to half a row. The fourth row of the narrow wordmark (its
|
||||
only row with a dim slot) was part red and part light red. The split was at each span
|
||||
@@ -1099,6 +1146,7 @@ def snr_style(snr: float | None) -> str:
|
||||
_ACTIVE_THEME: Theme = MESH_THEME
|
||||
_node_impl: Callable[[str], str] = _node_style_spectrum
|
||||
_glyph_impl: Callable[[str], str] = _glyph_identity
|
||||
_emoticon_impl: Callable[[str], str] = _glyph_identity
|
||||
_fold_impl: Callable[[str], str] = _no_fold
|
||||
|
||||
#: The map from a marker colour to RGB. :func:`mark_rgb` fills it, and a platform switch
|
||||
@@ -1109,11 +1157,12 @@ _STYLE_RGB: dict[str, tuple[int, int, int]] = {}
|
||||
@on_platform
|
||||
def _bind(platform: Platform) -> None:
|
||||
"""Bind the choices of the theme that depend on the platform (runs now and at each switch)."""
|
||||
global _ACTIVE_THEME, _node_impl, _glyph_impl, _fold_impl, _FOLD_TABLE
|
||||
global _ACTIVE_THEME, _node_impl, _glyph_impl, _emoticon_impl, _fold_impl, _FOLD_TABLE
|
||||
global _FOLD_FONT, _FOLD_ALLOWED, _FOLD_QUANTIZE
|
||||
_ACTIVE_THEME = MESH_THEME if platform.truecolor else MESH_THEME_16
|
||||
_node_impl = _node_style_spectrum if platform.truecolor else _node_style_quantized
|
||||
_glyph_impl = _glyph_identity if platform.emoji else _glyph_compact
|
||||
_emoticon_impl = _glyph_identity if platform.emoji else _emoticon_compact
|
||||
font = FONTS.get(platform.font)
|
||||
if font is not None:
|
||||
_FOLD_FONT = font
|
||||
|
||||
@@ -63,7 +63,7 @@ from .scopering import ScopeKey, ring, scope_atom, scope_chip, scope_key, step
|
||||
from .theme import name_style, snr_style
|
||||
from .tui.render import render_lines
|
||||
from .tui.screen import CANCEL, Screen
|
||||
from .tui.select import Choice, Separator
|
||||
from .tui.select import Choice
|
||||
from .widgets import (
|
||||
ContactsSort,
|
||||
_recency_style,
|
||||
@@ -1504,7 +1504,6 @@ class TimeMachinePickerScreen(ContactListScreen):
|
||||
prompt=prompt,
|
||||
lead=[
|
||||
Choice(command_label("🌐 The whole mesh — days, arrivals, the ledger"), MESH),
|
||||
Separator(" "),
|
||||
section_heading("Nodes"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -364,6 +364,8 @@ _HINT_KEY_ACTIONS: dict[str, str] = {
|
||||
"^Home": "ctrl_home",
|
||||
"^End": "ctrl_end",
|
||||
"Tab": "tab",
|
||||
"Del": "delete",
|
||||
"⇧Del": "shift_delete",
|
||||
}
|
||||
|
||||
#: Marks a word as a key notation of some type, also where this module cannot map it: a
|
||||
|
||||
@@ -206,7 +206,7 @@ def _panel_box(
|
||||
visible: The ANSI lines of the viewport, already sliced and padded to height.
|
||||
more_above: Whether content continues above the slice.
|
||||
more_below: Whether content continues below the slice.
|
||||
border: The name of the border style (``"accent"`` for the focused screen).
|
||||
border: The name of the border style (``"border"`` for the focused screen).
|
||||
caption: A short muted credit for the right end of the bottom rule (empty for none).
|
||||
flush: Remove the one-cell padding inside each side border, for a body that fills
|
||||
the frame (refer to :attr:`~meshterm.ui.tui.screen.Screen.flush`).
|
||||
@@ -309,15 +309,17 @@ def _title_bar(
|
||||
memory of it, to learn something that the row can state directly. (When only one arrow
|
||||
showed, a blank cell stood in for the other.) Thus the pair is fixed at the left edge,
|
||||
where the eye starts, and **colour** gives the information. An arrow whose direction
|
||||
has more content takes the ``accent`` of the border, so it looks like part of the
|
||||
frame. An arrow with nothing in its direction becomes ``muted``. This is the same
|
||||
has more content is lit as the title is (``title.border``). An arrow with nothing in
|
||||
its direction becomes ``faint``. The live arrow once had the grey of the border. When
|
||||
the border was #7c7c7c, that grey was almost the grey of ``faint``, and the two arrows
|
||||
looked the same. A lit arrow is clear at each border grey. This is the same
|
||||
live/dim language that the F-key lane draws one row below (a dim chip keeps its label
|
||||
and loses its fill). The two are the only indicators of the platform.
|
||||
|
||||
The title is centred in the rule between the arrows and the tail, exactly as the Rich
|
||||
``Panel`` centres its own ``title``. (That panel is the frame with a border on the
|
||||
desktop, which this bar replaces.) The rule itself takes ``border_style``
|
||||
(``"accent"``) directly: the same bold weight that a real border has. It does not take
|
||||
(``"border"``) directly: the same style that a real border has. It does not take
|
||||
the muted variant of :func:`~meshterm.ui.theme.hint_style`, which is for secondary text
|
||||
next to a border (the Esc atom below, a footer hint on the other platform).
|
||||
|
||||
@@ -350,7 +352,7 @@ def _title_bar(
|
||||
status: The atoms of the header, for a platform without a header row. ``None`` (or
|
||||
empty: nothing unread, no pack) draws the bar exactly as it is with a header.
|
||||
"""
|
||||
border = "accent"
|
||||
border = "border"
|
||||
head_span = 3 # the arrow pair, plus the space between it and the rule
|
||||
status = status if status is not None and status.cell_len else None
|
||||
stat_span = status.cell_len + 1 if status else 0 # the space before the status
|
||||
@@ -371,8 +373,9 @@ def _title_bar(
|
||||
label_w = heading.cell_len + 2
|
||||
|
||||
bar = Text()
|
||||
bar.append("↑", style=border if more_above else "muted")
|
||||
bar.append("↓", style=border if more_below else "muted")
|
||||
lit = title_style(border)
|
||||
bar.append("↑", style=lit if more_above else "faint")
|
||||
bar.append("↓", style=lit if more_below else "faint")
|
||||
bar.append(" ")
|
||||
if title:
|
||||
left = max(1, (rule_span - label_w) // 2)
|
||||
@@ -453,6 +456,7 @@ def compose_base(
|
||||
# (refer to :class:`~meshterm.ui.tui.screen.ListWindow`) can size its chrome to the
|
||||
# frame into which it will be sliced.
|
||||
base.note_viewport(viewport)
|
||||
base.note_terminal_rows(rows)
|
||||
body_lines = base.render_body(cols - panel_inset(base.flush))
|
||||
visible, more_above, more_below = _visible_slice(base, body_lines, viewport)
|
||||
# The panel wrap is a pure function of what is between its borders. Thus cache it,
|
||||
@@ -472,7 +476,7 @@ def compose_base(
|
||||
if footer_lane is None and _BASE_BOX_CACHE is not None and _BASE_BOX_CACHE[0] == key:
|
||||
body = _BASE_BOX_CACHE[1]
|
||||
else:
|
||||
panel = _panel_box(title, visible, more_above, more_below, "accent", caption, flush)
|
||||
panel = _panel_box(title, visible, more_above, more_below, "border", caption, flush)
|
||||
body = render_lines(Group(panel, footer_row()), cols)
|
||||
if footer_lane is None:
|
||||
_BASE_BOX_CACHE = (key, body)
|
||||
@@ -481,6 +485,7 @@ def compose_base(
|
||||
# the Panel. The body gets the full terminal width and one more row.
|
||||
viewport = max(1, rows - header_h - 1 - 1) # minus footer(1) and title bar(1)
|
||||
base.note_viewport(viewport)
|
||||
base.note_terminal_rows(rows)
|
||||
body_lines = base.render_body(cols)
|
||||
visible, more_above, more_below = _visible_slice(base, body_lines, viewport)
|
||||
status = None if platform.header_row else header
|
||||
@@ -612,6 +617,27 @@ def fit_hint(hint: str, width: int, *, shed_first: Sequence[str] = ()) -> str:
|
||||
return crop_cells(Text(fitted), 0, max(1, width - 1)).plain + "…"
|
||||
|
||||
|
||||
def fitted_footer(screen: Screen, width: int) -> str:
|
||||
"""The footer hint of ``screen`` as the hint line draws it: fitted to ``width`` cells.
|
||||
|
||||
A hint that fits is returned as it is. A wider hint loses atoms through
|
||||
:func:`fit_hint`, and keeps its Esc atom. Before, the hint line cut a wide hint at the
|
||||
edge, and the cut took the Esc atom first. The width is measured on the plain text,
|
||||
because a hint can carry markup (the ``[warn]offline[/warn]`` of the map).
|
||||
|
||||
Args:
|
||||
screen: The screen whose hint the footer draws: the top screen, also when it floats.
|
||||
width: The cells of the hint line.
|
||||
|
||||
Returns:
|
||||
The hint to draw.
|
||||
"""
|
||||
hint = screen.footer_hint
|
||||
if cell_len(Text.from_markup(hint).plain) <= width:
|
||||
return hint
|
||||
return fit_hint(hint, width, shed_first=getattr(screen, "spare_hint_atoms", ()))
|
||||
|
||||
|
||||
def compose_startup(screen: Screen, cols: int, rows: int) -> str:
|
||||
"""Compose a chromeless splash: a centred wordmark above a panel of content size.
|
||||
|
||||
@@ -642,6 +668,7 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
|
||||
# measure). It is never wider than the terminal, and never narrower than the title or the
|
||||
# hint that it must show.
|
||||
probe = max(10, min(cols - 6, 100))
|
||||
screen.note_terminal_rows(rows)
|
||||
probe_lines = screen.render_body(probe)
|
||||
measured = max((_ansi_width(line) for line in probe_lines), default=10)
|
||||
# Size to the widest footer possible, not to the footer of this frame. A screen whose
|
||||
@@ -695,16 +722,16 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
|
||||
visible, more_above, more_below = _visible_slice(screen, body_lines, viewport)
|
||||
|
||||
body = Text.from_ansi("\n".join(visible))
|
||||
hint = hint_style("accent")
|
||||
hint = hint_style("border")
|
||||
subtitle = f"[{hint}]{hint_text}[/]"
|
||||
if more_above or more_below:
|
||||
arrow = ("↑" if more_above else " ") + ("↓" if more_below else " ")
|
||||
subtitle = f"[{hint}]{arrow} · {fit_hint(hint_text, inner_w - 5)}[/]"
|
||||
panel = Panel(
|
||||
body,
|
||||
title=f"[{title_style('accent')}]{screen.title}[/]" if screen.title else None,
|
||||
title=f"[{title_style('border')}]{screen.title}[/]" if screen.title else None,
|
||||
subtitle=subtitle,
|
||||
border_style="accent",
|
||||
border_style="border",
|
||||
padding=(vpad, 1),
|
||||
width=inner_w + 4,
|
||||
)
|
||||
@@ -752,6 +779,7 @@ def compose_bare(screen: Screen, cols: int, rows: int) -> str:
|
||||
# share screen fits its code to the rows). Thus the height is recorded before the body
|
||||
# is requested, not after, as the composers with a frame also do.
|
||||
screen.note_viewport(rows)
|
||||
screen.note_terminal_rows(rows)
|
||||
body_lines = screen.render_body(cols)
|
||||
while body_lines and not _ansi_width(body_lines[-1]):
|
||||
body_lines.pop() # a trailing blank row pushes the block off its anchor
|
||||
@@ -780,6 +808,7 @@ def _dialog_layout(screen: Screen, cols: int, rows: int) -> tuple[int, int, int,
|
||||
# with a list window (the path composer) can size to what it can use. After that, the
|
||||
# slice records the real viewport, sized to the body.
|
||||
screen.note_viewport(budget)
|
||||
screen.note_terminal_rows(rows)
|
||||
body_lines = screen.render_body(max_w - 4)
|
||||
# A grow-only screen (the packet viewer, when it pages between packets) sizes to the
|
||||
# tallest body that it has shown, not to this body. Thus a shorter body keeps the larger
|
||||
@@ -865,7 +894,7 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str:
|
||||
"""
|
||||
max_w, vpad, viewport, body_lines = _dialog_layout(screen, cols, rows)
|
||||
visible, more_above, more_below = _visible_slice(screen, body_lines, viewport)
|
||||
border = getattr(screen, "border_style", "accent")
|
||||
border = getattr(screen, "border_style", "border")
|
||||
# The resolved hint, not the hint of the screen: it is what the border draws. It changes
|
||||
# with the lane below, and also with the hint of the screen (refer to _dialog_hint).
|
||||
hint = _dialog_hint(screen)
|
||||
|
||||
@@ -31,16 +31,16 @@ from rich.text import Text
|
||||
from .render import render_to_ansi
|
||||
from .spinner import Spinner
|
||||
|
||||
#: The colour of the title line (the theme's ``title.accent``, in hex so that the fade can
|
||||
#: dim it).
|
||||
_TITLE_COLOR = "bold #a5b4fc"
|
||||
#: The colour of the title line (the theme's ``title.border``, in hex so that the fade can
|
||||
#: dim it). It is the title of a dialog, which the card stands in for.
|
||||
_TITLE_COLOR = "bold #d4d4d4"
|
||||
|
||||
#: The colour of the working chip (the theme's ``accent``). The chip has the same one-cell
|
||||
#: spinner as the other dialogs.
|
||||
_CHIP_COLOR = "bold #818cf8"
|
||||
|
||||
#: The colour of the caption (the theme's ``muted``, in hex so that the fade can dim it).
|
||||
_CAPTION_COLOR = "#94a3b8"
|
||||
_CAPTION_COLOR = "#a2a2a2"
|
||||
|
||||
|
||||
class BusyOverlay:
|
||||
|
||||
@@ -187,7 +187,12 @@ class LineEditor:
|
||||
return i
|
||||
|
||||
def render(
|
||||
self, mask: bool = False, *, overflow_at: int | None = None, slots: int | None = None
|
||||
self,
|
||||
mask: bool = False,
|
||||
*,
|
||||
overflow_at: int | None = None,
|
||||
slots: int | None = None,
|
||||
compose: bool = False,
|
||||
) -> Text:
|
||||
"""Render the current line with a reverse-video cursor cell.
|
||||
|
||||
@@ -200,21 +205,47 @@ class LineEditor:
|
||||
slots: When set, render exactly this number of fixed positions (a masked PIN
|
||||
field). A typed position shows a bullet, and a blank position shows a muted
|
||||
centre dot. This argument has priority over ``mask`` and ``overflow_at``.
|
||||
compose: When ``True``, draw the line as the compose line of a chat draws it:
|
||||
the ``›`` in the ``cursor`` white and the text in the ``compose`` white
|
||||
(refer to :meth:`~meshterm.ui.compose.MentionEditor.render_line`). Thus a
|
||||
message that the user writes outside a chat looks the same as in a chat.
|
||||
"""
|
||||
if slots is not None:
|
||||
return self._render_slots(slots)
|
||||
shown = MASK_MARK * len(self.text) if mask else self.text
|
||||
text = Text("› ", style="accent")
|
||||
for i, ch in enumerate(shown):
|
||||
over = overflow_at is not None and i >= overflow_at
|
||||
if i == self.cursor:
|
||||
text.append(ch, style="err.reverse" if over else "reverse")
|
||||
else:
|
||||
text.append(ch, style="err" if over else None)
|
||||
if compose:
|
||||
# The style goes on the ``›`` alone, as in the chat. As the base style of the
|
||||
# line, it also colours the typed text, and the compose white is then bold.
|
||||
text = Text()
|
||||
text.append("› ", style="cursor")
|
||||
else:
|
||||
text = Text("› ", style="accent")
|
||||
self._append_run(text, shown, 0, len(shown), overflow_at, "compose" if compose else None)
|
||||
if self.cursor >= len(shown): # cursor past the last character → trailing block
|
||||
text.append(" ", style="reverse")
|
||||
return text
|
||||
|
||||
def _append_run(
|
||||
self,
|
||||
text: Text,
|
||||
shown: str,
|
||||
start: int,
|
||||
end: int,
|
||||
overflow_at: int | None,
|
||||
style: str | None = None,
|
||||
) -> None:
|
||||
"""Append the characters ``shown[start:end]`` to ``text``, one cell for each character.
|
||||
|
||||
The character at the cursor is in reverse video. Each character at or after
|
||||
``overflow_at`` is in the error style. Each other character has ``style``.
|
||||
"""
|
||||
for i in range(start, end):
|
||||
over = overflow_at is not None and i >= overflow_at
|
||||
if i == self.cursor:
|
||||
text.append(shown[i], style="err.reverse" if over else "reverse")
|
||||
else:
|
||||
text.append(shown[i], style="err" if over else style)
|
||||
|
||||
def _render_slots(self, slots: int) -> Text:
|
||||
"""Render a fixed-width masked field: filled bullets and centred dots for blanks.
|
||||
|
||||
@@ -277,6 +308,7 @@ class TextScreen(_KeylessDialog):
|
||||
help_text: str = "",
|
||||
password: bool = False,
|
||||
byte_limit: int | None = None,
|
||||
compose: bool = False,
|
||||
footer_hint: str = "Enter accept · Esc cancel",
|
||||
) -> None:
|
||||
"""Build a text prompt.
|
||||
@@ -296,6 +328,8 @@ class TextScreen(_KeylessDialog):
|
||||
submission when the text is longer than the limit. Use it for a field that
|
||||
feeds a packet with a size limit (a direct message, a courier entry). Keep
|
||||
``None`` for a field with no limit.
|
||||
compose: When ``True``, the field is a message, and it has the colours of the
|
||||
compose line of a chat (refer to :meth:`LineEditor.render`).
|
||||
footer_hint: The key hint in the footer.
|
||||
"""
|
||||
super().__init__()
|
||||
@@ -307,6 +341,7 @@ class TextScreen(_KeylessDialog):
|
||||
self._help = help_text
|
||||
self._password = password
|
||||
self._byte_limit = byte_limit
|
||||
self._compose = compose
|
||||
self._error = ""
|
||||
|
||||
@property
|
||||
@@ -342,7 +377,7 @@ class TextScreen(_KeylessDialog):
|
||||
if self._prompt:
|
||||
parts.append(Text(self._prompt))
|
||||
parts.append(Text(""))
|
||||
field = self._editor.render(mask=self._password)
|
||||
field = self._editor.render(mask=self._password, compose=self._compose)
|
||||
if self._byte_limit is not None:
|
||||
# The gauge pins to the right edge of the field: a steady fuel gauge in the corner,
|
||||
# exactly as the chat compose bar draws it (both through ``byte_counter``).
|
||||
@@ -523,7 +558,7 @@ class ButtonDialog(_KeylessDialog):
|
||||
prompt_style: str = "",
|
||||
button_style: str = "selected",
|
||||
button_idle_style: str = "muted",
|
||||
border_style: str = "accent",
|
||||
border_style: str = "border",
|
||||
lane: Sequence[FPair | None] | None = None,
|
||||
) -> None:
|
||||
"""Build a button dialog.
|
||||
|
||||
@@ -301,7 +301,9 @@ def query_text(query: str) -> Text:
|
||||
return Text(f"/{query}", style="warn", no_wrap=True)
|
||||
|
||||
|
||||
def right_aligned_tail(body: Text, tail: Text, width: int) -> Text:
|
||||
def right_aligned_tail(
|
||||
body: Text, tail: Text, width: int, *, whole: Sequence[tuple[int, int]] = ()
|
||||
) -> Text:
|
||||
"""Lay out ``body`` with ``tail`` pinned to the right edge of its last line.
|
||||
|
||||
``body`` wraps at ``width`` as usual. ``tail`` is a short status, for example the byte
|
||||
@@ -315,13 +317,20 @@ def right_aligned_tail(body: Text, tail: Text, width: int) -> Text:
|
||||
cursor block).
|
||||
tail: The short run to pin to the right edge.
|
||||
width: The total render width, in cells.
|
||||
whole: ``(start, end)`` offsets into ``body.plain`` of the runs that the wrap must
|
||||
not break at a space inside them: the chip of a mention in the compose line.
|
||||
A run moves to the next line as one word (refer to :func:`render_hanging`).
|
||||
|
||||
Returns:
|
||||
A single :class:`Text` with embedded newlines, ready for :func:`render_lines`.
|
||||
"""
|
||||
width = max(1, width)
|
||||
console = _console(width)
|
||||
lines = list(body.wrap(console, width)) or [Text("")]
|
||||
held, stand_in = _held(body, whole)
|
||||
lines = list(held.wrap(console, width)) or [Text("")]
|
||||
if stand_in:
|
||||
for line in lines:
|
||||
line.plain = line.plain.replace(stand_in, " ")
|
||||
combined = Text()
|
||||
for line in lines[:-1]:
|
||||
combined.append_text(line)
|
||||
|
||||
@@ -43,7 +43,7 @@ POP_ALL = object()
|
||||
#: anything.
|
||||
_EDGE_STEPS = {"up": -1, "down": 1}
|
||||
_EDGE_ENDS = {"home": -1, "ctrl_home": -1, "end": 1, "ctrl_end": 1}
|
||||
_SNAP_BACK_ACTIONS = frozenset({"enter", "delete"})
|
||||
_SNAP_BACK_ACTIONS = frozenset({"enter", "delete", "shift_delete"})
|
||||
|
||||
|
||||
class PopToMenu(BaseException):
|
||||
@@ -155,6 +155,9 @@ class Screen:
|
||||
#: first and the last row, and the page follows to its ends. It is off where these keys
|
||||
#: move a text caret instead: the compose line of the chat, the field of a prompt.
|
||||
home_end_jumps: bool = True
|
||||
#: The rows of the terminal at the last paint, or ``None`` before the first paint. The
|
||||
#: frame records it before it asks for the body (refer to :meth:`note_terminal_rows`).
|
||||
_terminal_rows: int | None = None
|
||||
|
||||
@property
|
||||
def fkey_lane(self):
|
||||
@@ -537,6 +540,38 @@ class Screen:
|
||||
"""Record only the viewport height (for callers that do not know the body total)."""
|
||||
self._scroll_viewport = max(1, viewport)
|
||||
|
||||
def note_terminal_rows(self, rows: int) -> None:
|
||||
"""Record the rows of the terminal, before the body of this paint renders.
|
||||
|
||||
The frame calls this method, together with :meth:`note_viewport`, for each type of
|
||||
frame. :meth:`section_spacing` reads it. The rows must come before the body: rows
|
||||
recorded after a paint come one paint late, and the first paint of a screen then
|
||||
comes out without its blank lines and then jumps.
|
||||
"""
|
||||
self._terminal_rows = rows
|
||||
|
||||
def section_spacing(self) -> bool:
|
||||
"""Whether a blank line comes before each section of the page, at this paint.
|
||||
|
||||
The ``section_spacing_min_rows`` preference decides. A terminal of that many rows
|
||||
or more gets the line. The line costs one row for each section, so a short
|
||||
terminal (the Cardputer Zero, or a small window) keeps its rows for content. A
|
||||
screen that the frame has not painted yet uses the design minimum of the platform
|
||||
(:attr:`~meshterm.platforms.Platform.readable_rows`). The preference is read at
|
||||
each paint, so a change on the Preferences page applies when the user comes back.
|
||||
|
||||
One rule for each place that has sections: the section headings of a select list
|
||||
(refer to :attr:`~meshterm.ui.tui.select.Separator.spaced`), and the day dividers
|
||||
and the authors of a chat.
|
||||
"""
|
||||
from ...core.preferences import current as current_preferences
|
||||
from ...platforms import get_platform
|
||||
|
||||
rows = self._terminal_rows
|
||||
if rows is None:
|
||||
rows = get_platform().readable_rows
|
||||
return rows >= current_preferences().section_spacing_min_rows
|
||||
|
||||
@property
|
||||
def _page_step(self) -> int:
|
||||
"""The lines that PageUp/PageDown move: the viewport, less one line for continuity."""
|
||||
|
||||
@@ -255,6 +255,16 @@ class Separator:
|
||||
nothing, it takes a content line, and it hides the heading above it. It is set
|
||||
through :func:`~meshterm.ui.menus.section_heading`, or by
|
||||
hand on a column header that is the only landmark of its block.
|
||||
spaced: Whether a blank line comes before this row, when the
|
||||
``section_spacing_min_rows`` preference asks for one at this paint (refer to
|
||||
:meth:`~meshterm.ui.tui.screen.Screen.section_spacing`). It is set through
|
||||
:func:`~meshterm.ui.menus.section_heading`, so that each section of each list
|
||||
gets the same space. A list does not put a blank separator before a heading
|
||||
for space, because then the space shows also where the preference says no. A
|
||||
blank separator there is only the seam between two different lists, which
|
||||
keep their own icon columns (the table and the actions of the region editor).
|
||||
The line never comes at the top of the list, after a blank line, or under
|
||||
another heading or a column header, because there it separates nothing.
|
||||
pans: The column header of a table that ←→ pan as one unit (refer to
|
||||
:attr:`Choice.pans`). It is drawn in its fullest form, and it slides with the
|
||||
rows under it. Thus each label stays above its lane. Its layout is the same as a
|
||||
@@ -267,6 +277,7 @@ class Separator:
|
||||
style: str = "muted"
|
||||
pinned: bool = False
|
||||
heading: bool = False
|
||||
spaced: bool = False
|
||||
pans: bool = False
|
||||
|
||||
def text(self, width: int) -> str | Text:
|
||||
@@ -288,6 +299,23 @@ _UNBOUNDED = 10_000
|
||||
_POINTER = 2
|
||||
|
||||
|
||||
def _spaces(previous: Choice | Separator | None) -> bool:
|
||||
"""Whether a blank line before a section separates it from ``previous``.
|
||||
|
||||
The line separates a section from the content above it: a row, or a note. At the top
|
||||
of the list there is nothing to separate. After a blank line, the space is already
|
||||
there. Under a heading or a column header, the heading belongs with the rows below it.
|
||||
"""
|
||||
if previous is None:
|
||||
return False
|
||||
if isinstance(previous, Choice):
|
||||
return True
|
||||
if previous.heading or previous.pinned:
|
||||
return False
|
||||
title = previous.title
|
||||
return callable(title) or bool(str(title).strip())
|
||||
|
||||
|
||||
def _pans(items: list) -> bool:
|
||||
"""Whether one of ``items`` is a table row or a header that pans (:attr:`Choice.pans`)."""
|
||||
return any(getattr(item, "pans", False) for item in items)
|
||||
@@ -830,7 +858,15 @@ class SelectScreen(Screen):
|
||||
# Thus the code follows the line of the highlight while it draws the rows, and does
|
||||
# not calculate it from the row index.
|
||||
cursor_at: int | None = None
|
||||
spacing = self.section_spacing()
|
||||
previous: Choice | Separator | None = None # the row drawn before this one, if any
|
||||
for item in rows:
|
||||
if isinstance(item, Separator) and item.spaced and spacing and _spaces(previous):
|
||||
# The blank line before a section. It is not an item, so it never joins
|
||||
# the block of the heading above it, and the heading still pins at its
|
||||
# own line.
|
||||
lines.append("")
|
||||
previous = item
|
||||
if isinstance(item, Separator):
|
||||
# A Text title has its own spans (a column header with two colours). A plain
|
||||
# string is drawn all in the style of the separator. A separator scrolls
|
||||
@@ -849,6 +885,10 @@ class SelectScreen(Screen):
|
||||
elif item.heading:
|
||||
block = list(drawn)
|
||||
self._sticky_headers.append((len(lines), block))
|
||||
elif block is not None:
|
||||
# The column header of a section, under its heading: it pins
|
||||
# with the heading, as the preamble below does.
|
||||
block.extend(drawn)
|
||||
elif item.pinned:
|
||||
# A pinned header is drawn outside the body slice and is exactly one
|
||||
# line tall. Thus it crops like a row and does not wrap. A column header
|
||||
|
||||
@@ -102,9 +102,16 @@ from .spinner import spinner_interval
|
||||
#: ``IXON``/``IXOFF``, so ^Q is not XON here.
|
||||
_CTRL_LETTER_CHORDS: dict[str, str] = {
|
||||
"c": "quit",
|
||||
# ^E reacts to the selected message with an emoji. The raw mode of prompt_toolkit has no
|
||||
# line editing, so ^E is not "end of line" here (the End key moves the caret).
|
||||
"e": "react",
|
||||
# ^G logs in to the room that a room screen shows. In the input, ^G is not a special key:
|
||||
# it is the bell only in output, and no terminal or multiplexer takes it.
|
||||
"g": "login",
|
||||
# ^K locks or unlocks the author of the selected message in a conversation (JP,
|
||||
# 2026-10-19). No terminal or multiplexer takes it, and the raw mode of prompt_toolkit
|
||||
# has no binding for it.
|
||||
"k": "lock",
|
||||
"l": "locate",
|
||||
# ^O opens the node page of the author of the selected message. The raw mode of
|
||||
# prompt_toolkit clears IEXTEN, so a terminal on macOS or BSD does not take ^O to
|
||||
@@ -157,6 +164,7 @@ _KEY_ACTIONS: dict[Any, str] = {
|
||||
Keys.Escape: "escape",
|
||||
Keys.Backspace: "backspace",
|
||||
Keys.Delete: "delete",
|
||||
Keys.ShiftDelete: "shift_delete",
|
||||
Keys.Tab: "tab",
|
||||
Keys.BackTab: "shift_tab",
|
||||
# The function keys, for the F-key lane of a handheld. This table binds all of them,
|
||||
@@ -488,8 +496,8 @@ def _message_border(message: Text | str) -> str:
|
||||
A note about a result has its severity as theme spans (``[err]``, ``[warn]``, ``[ok]``).
|
||||
Thus the border of the dialog can show the same severity. An error message gets the
|
||||
``err`` border. A warning (for example "device rebooting") gets the caution border. All
|
||||
the other messages, also the success messages, get the standard accent. A plain string
|
||||
has no spans, and it is always neutral.
|
||||
the other messages, also the success messages, get the grey of the standard border. A
|
||||
plain string has no spans, and it is always neutral.
|
||||
|
||||
Args:
|
||||
message: The message of the dialog, with styles or plain.
|
||||
@@ -503,7 +511,7 @@ def _message_border(message: Text | str) -> str:
|
||||
return "err"
|
||||
if "warn" in styles:
|
||||
return "warn"
|
||||
return "accent"
|
||||
return "border"
|
||||
|
||||
|
||||
class Visit:
|
||||
@@ -1521,6 +1529,7 @@ class TuiSession:
|
||||
help_text: str = "",
|
||||
password: bool = False,
|
||||
byte_limit: int | None = None,
|
||||
compose: bool = False,
|
||||
floating: bool = False,
|
||||
) -> str | None:
|
||||
"""Show a text prompt. Return the string, or ``None`` if the user cancelled.
|
||||
@@ -1534,6 +1543,9 @@ class TuiSession:
|
||||
``byte_limit`` puts the shared UTF-8 byte gauge on the field, and blocks a submission
|
||||
that is longer than the limit. Use it for a field that goes into a packet with a
|
||||
maximum size (refer to :class:`TextScreen`).
|
||||
|
||||
``compose`` gives the field the colours of the compose line of a chat. Use it for a
|
||||
field that holds a message.
|
||||
"""
|
||||
screen = TextScreen(
|
||||
title,
|
||||
@@ -1543,6 +1555,7 @@ class TuiSession:
|
||||
help_text=help_text,
|
||||
password=password,
|
||||
byte_limit=byte_limit,
|
||||
compose=compose,
|
||||
)
|
||||
runner = self.run_dialog if floating else self.run_screen
|
||||
result = await runner(screen)
|
||||
@@ -1565,7 +1578,7 @@ class TuiSession:
|
||||
prompt_style: str = "",
|
||||
button_style: str = "selected",
|
||||
button_idle_style: str = "muted",
|
||||
border_style: str = "accent",
|
||||
border_style: str = "border",
|
||||
lane: Sequence[fkeys.FPair | None] | None = None,
|
||||
) -> Any:
|
||||
"""Show a centred button dialog. Return the chosen value, or ``None`` if cancelled.
|
||||
@@ -1654,7 +1667,7 @@ class TuiSession:
|
||||
("✓ flood advertisement sent") is too small for a full result screen. Thus it floats
|
||||
as a small dialog over the screen below it. Enter (OK) or Esc closes it. The border
|
||||
takes the strongest tone of the message (refer to :func:`_message_border`). Thus an
|
||||
error shows in red, and a success stays in the standard accent. The method calls
|
||||
error shows in red, and a success stays in the grey of the standard border. The method calls
|
||||
:meth:`button_dialog`, which floats the dialog over a blank base when the stack is
|
||||
empty (a tool that runs directly from the menu, which is popped while the tool
|
||||
runs).
|
||||
@@ -2209,7 +2222,7 @@ class TuiSession:
|
||||
# centred below its banner, instead of stretched across the terminal.
|
||||
if not base.chrome:
|
||||
return self._emit(frame.compose_startup(base, cols, rows))
|
||||
footer = self.top.footer_hint if self.top else base.footer_hint
|
||||
footer = frame.fitted_footer(self.top or base, cols)
|
||||
lane = self._fkey_lane(self.top or base)
|
||||
return self._emit(
|
||||
frame.compose_base(self._header(cols), base, footer, cols, rows, footer_lane=lane)
|
||||
|
||||