Compare commits
No files matched your search
+7
-24
@@ -1,31 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python -)",
|
||||
"Bash(python -m pytest *)",
|
||||
"Bash(.venv/Scripts/python.exe -m pytest *)",
|
||||
"Bash(./.venv/Scripts/python.exe -m pytest *)",
|
||||
"Bash(python -m ruff check *)",
|
||||
"Bash(python -m ruff format *)",
|
||||
"Bash(ruff check *)",
|
||||
"Bash(ruff format *)",
|
||||
"PowerShell(python -m pytest *)",
|
||||
"PowerShell(.venv\\Scripts\\python.exe -m pytest *)",
|
||||
"PowerShell(.\\.venv\\Scripts\\python.exe -m pytest *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"PowerShell(git add *)",
|
||||
"PowerShell(git commit *)",
|
||||
"Bash(PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -c ' *)",
|
||||
"Bash(python -m pytest tests/test_tui.py -q -k \"progress\")",
|
||||
"Bash(python -m pytest tests/test_tui.py -q -k \"overlay or progress\")",
|
||||
"PowerShell([console]::beep\\(880,150\\); Start-Sleep -Milliseconds 60; [console]::beep\\(1175,200\\))",
|
||||
"Bash(PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -m pytest tests/test_tui.py -q -k \"busy_overlay or scanner or dim_color or overlay_fade\")",
|
||||
"Bash(PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -m pytest -q)",
|
||||
"Bash(python -c ' *)",
|
||||
"Bash(grep -v \"^$\")",
|
||||
"Bash(PYTHONIOENCODING=utf-8 PYTHONUTF8=1 python -c ' *)",
|
||||
"Bash(python -m pytest tests/test_channels.py -q)",
|
||||
"Bash(git commit -m ' *)",
|
||||
"Bash(.venv/Scripts/python.exe -m meshterm --mock trace --target Alice --profile yagi)",
|
||||
"Bash(echo \"=== EXIT: $? ===\")"
|
||||
],
|
||||
"defaultMode": "dontAsk"
|
||||
"PowerShell(ruff check *)",
|
||||
"PowerShell(ruff format *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
name: add-narrow-emoji
|
||||
description: Fix a MeshTerm row whose layout an emoji breaks — a right border pulled a column short, or a glyph overrunning its lane — because the terminal draws it in a different number of cells than Rich/prompt_toolkit measure. Works in both directions: a glyph drawn narrower than measured joins the narrow allowlist, one drawn wider joins the wide set. Classifies the emoji (lone codepoint, country flag, VS16 sequence, or ZWJ/other cluster), says whether it is already listed, and adds it the correct way. Use whenever an emoji "fucks up the layout", notches a panel border, or smears a row.
|
||||
---
|
||||
|
||||
# Adding an emoji to MeshTerm's terminal-aligned width sets
|
||||
|
||||
MeshTerm aligns Rich's and prompt_toolkit's glyph widths with how the terminal
|
||||
*actually draws* an emoji, so a row frames flush instead of notching its right border a
|
||||
column short or overrunning its lane. The machinery lives in
|
||||
[emoji_width.py](../../..//meshterm/ui/tui/emoji_width.py); the design is in that module's
|
||||
docstring and in the `glyph-width-alignment` memory. Background: the terminal's
|
||||
narrow/wide split is **per-glyph and font-driven** — there is no rule and no way to probe
|
||||
it (a cursor probe reads the PTY, not the renderer) — so the exceptions are two
|
||||
hand-curated sets, extended one confirmed glyph at a time. This skill does that one
|
||||
addition correctly.
|
||||
|
||||
## When to run
|
||||
|
||||
An emoji in a row breaks its layout, in either of two ways:
|
||||
|
||||
- **Border pulled in** (a blank sits between the border and the frame edge), or a smear to
|
||||
the right of the glyph — the terminal draws it in *fewer* cells than the authorities
|
||||
reserve. → the narrow allowlist (`_DEFAULT_NARROW_LONE`).
|
||||
- **The row overruns**, pushing what follows a column right — the terminal draws it in
|
||||
*more* cells than the authorities reserve. → the wide set (`_DEFAULT_WIDE_BASE`). In
|
||||
practice this only happens to a **VS16 sequence** the narrow verdict got wrong; a *bare*
|
||||
codepoint both authorities call one cell is measured correctly and must be left alone.
|
||||
|
||||
Step 1 tells you which; it also tells you when the glyph is **already listed**, which is
|
||||
the commonest answer to "please add this one".
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Classify the emoji.** Run, from the repo root:
|
||||
|
||||
The interpreter below is `$DEV_VENV_PYTHON` from `.dev.env` at the repo root (gitignored;
|
||||
`.dev.env.example` shows the shape) — read it first.
|
||||
|
||||
```
|
||||
$DEV_VENV_PYTHON .claude/skills/add-narrow-emoji/classify.py <emoji>
|
||||
```
|
||||
|
||||
It prints the codepoints, how each authority measures it now (`Rich now`, `pt now`)
|
||||
and after the fix (`Rich fixed`, `pt fixed`), the class, and the action. `pt now` is
|
||||
the number that matters — prompt_toolkit places the panel border.
|
||||
|
||||
2. **Act on the class.** The machinery runs in *both* directions — a glyph drawn narrower
|
||||
than measured joins `_DEFAULT_NARROW_LONE`, one drawn wider joins `_DEFAULT_WIDE_BASE`
|
||||
— and the classifier names which, including "already listed", the commonest answer:
|
||||
- **already listed** (either set): nothing to add. If a row still misaligns, some
|
||||
*other* glyph on it is the culprit — classify that one.
|
||||
- **lone wide codepoint** (e.g. `👋`, `👍`): the narrow-allowlist case. Only add it **if
|
||||
the terminal draws it in one cell** — confirm visually first. Append the character to
|
||||
`_DEFAULT_NARROW_LONE` in [emoji_width.py](../../..//meshterm/ui/tui/emoji_width.py):
|
||||
`_DEFAULT_NARROW_LONE = "👋👍"`. (For a throwaway session test instead of a code
|
||||
change, set `MESHTERM_NARROW_EMOJI="👋👍"` in the environment.)
|
||||
- **lone narrow codepoint, bare** (e.g. `🛣` U+1F6E3, `🕸` U+1F578): **no change**, and
|
||||
resist the pull to "fix" it. Both authorities say one because the codepoint is outside
|
||||
Emoji_Presentation, and with no VS16 asking for emoji presentation the font draws a
|
||||
one-cell *text* glyph — they are right. Forcing it into `_DEFAULT_WIDE_BASE` reserves a
|
||||
cell the terminal never draws and pulls the row's border a column **in**; that is
|
||||
exactly what listing `🛣` did to the Trophy case, while `🕸` — same class, one board
|
||||
down, never listed — framed flush throughout. Both authorities already agreeing is not
|
||||
a bug to correct.
|
||||
- **VS16 emoji-presentation sequence** (e.g. `☀️`): **handled by default** (the variation
|
||||
selector is skipped, so the base is measured alone). But the renderer is not uniform —
|
||||
if *this* one paints two cells wide, add its **base** codepoint to `_DEFAULT_WIDE_BASE`,
|
||||
as `🛩️` (U+1F6E9) needed. Keying on the base makes the bare form ride along.
|
||||
- **flag (Regional Indicator pair)** (e.g. `🇨🇦`): **already handled** as a whole
|
||||
category — no entry. Confirm `pt fixed` is 2 and stop. Never list an individual flag:
|
||||
indicators are shared, so it would half-fix that flag's neighbours and skip the rest.
|
||||
- **ZWJ sequence / keycap / skin-tone / other cluster** (e.g. `👨👩👧`, `1️⃣`, `👋🏽`):
|
||||
**not covered** by the lone/flag mechanism — resizing its parts individually would
|
||||
misalign it. Do not force it into either set; note it for bespoke handling.
|
||||
|
||||
3. **Extend the tests.** In [tests/test_emoji_width.py](../../..//tests/test_emoji_width.py),
|
||||
add the new glyph to `test_rich_cell_len_narrows_only_allowlisted_lone_emoji` and
|
||||
`test_pt_cache_narrows_only_allowlisted_lone_emoji` (both authorities → one cell for a
|
||||
narrow addition, two for a wide one), and to the width-1 calibrate test. Keep a
|
||||
known-wide emoji (`📡`) asserted at two, so a future over-broad change is caught.
|
||||
|
||||
4. **Run the tests:**
|
||||
|
||||
```
|
||||
$DEV_VENV_PYTHON -m pytest tests/test_emoji_width.py -q
|
||||
```
|
||||
|
||||
5. **Confirm visually.** The fix can't be reproduced headlessly — a non-interactive run
|
||||
keeps Rich's defaults, and CI can't see the renderer. Ask the person on the affected
|
||||
terminal to send a chat message with the glyph and confirm the border is now flush and
|
||||
nothing shifted. Only then is the addition trustworthy.
|
||||
|
||||
6. **Commit** with a message naming the glyph and why (see the repo's commit style).
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Never narrow a glyph the terminal draws two wide** (a menu icon like `📡`, `💬`), and
|
||||
never widen one it draws in one cell. Each set only helps glyphs mismeasured in its own
|
||||
direction; a wrong entry *introduces* the misalignment it was meant to remove. When
|
||||
unsure, leave it out.
|
||||
- **One glyph, confirmed, at a time.** An unlisted emoji is no worse than before (the
|
||||
`erase_down` full-repaint still prevents a narrow one's smear); a wrongly-listed one
|
||||
breaks a border. Bias to omission.
|
||||
- **Do not touch Rich/prompt_toolkit width globally.** A blanket emoji remap has been tried
|
||||
and reverted twice — it wrecks the menu icons the terminal genuinely draws two wide. The
|
||||
curated allowlist and the flag category are the only sanctioned levers.
|
||||
@@ -1,141 +0,0 @@
|
||||
r"""Diagnose one emoji for the MeshTerm terminal-aligned width machinery.
|
||||
|
||||
Given an emoji on the command line, report its codepoints and how the three width
|
||||
authorities measure it (wcwidth, Rich, prompt_toolkit), then classify it and print the
|
||||
exact action the `add-narrow-emoji` skill should take. Run from the repo root:
|
||||
|
||||
d:\\vibe\\MeshTerm\\.venv\\Scripts\\python.exe .claude\\skills\\add-narrow-emoji\\classify.py 👋
|
||||
|
||||
The point is to remove the guesswork the skill's judgement call depends on: a lone wide
|
||||
codepoint gets an allowlist entry, a flag is already handled by category, and a ZWJ /
|
||||
keycap / modifier cluster is *not* covered and must not be shoehorned in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
# The chat border fix only ever engages on a terminal that draws emoji narrow; measure
|
||||
# there or trust the maintainer's eyes. This script just classifies — it never patches.
|
||||
sys.stdout.reconfigure(encoding="utf-8") # print emoji safely on a cp1252 console
|
||||
# Run from anywhere: put the repo root (four levels up) on the path so `meshterm` imports.
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3]))
|
||||
|
||||
import rich.cells as rc # noqa: E402
|
||||
from prompt_toolkit.utils import get_cwidth # noqa: E402
|
||||
from wcwidth import wcwidth # noqa: E402
|
||||
|
||||
from meshterm.ui.tui.emoji_width import ( # noqa: E402
|
||||
_DEFAULT_NARROW_LONE,
|
||||
_DEFAULT_WIDE_BASE,
|
||||
_is_regional_indicator,
|
||||
_make_cell_len,
|
||||
_make_pt_cache,
|
||||
)
|
||||
|
||||
_ZWJ = ""
|
||||
_VS16 = "️"
|
||||
|
||||
_NARROW = frozenset(_DEFAULT_NARROW_LONE)
|
||||
_WIDE = frozenset(_DEFAULT_WIDE_BASE) - frozenset((_ZWJ, _VS16))
|
||||
|
||||
|
||||
def _base(emoji: str) -> str:
|
||||
"""The emoji's base codepoint — what the wide set is keyed on (selectors dropped)."""
|
||||
others = [c for c in emoji if c not in (_VS16, _ZWJ)]
|
||||
return others[0] if len(others) == 1 else ""
|
||||
|
||||
|
||||
def _classify(emoji: str) -> tuple[str, str]:
|
||||
"""Return ``(label, action)`` for ``emoji`` — what it is and what the skill should do."""
|
||||
if not emoji:
|
||||
return "empty", "Nothing to classify — pass an emoji as the argument."
|
||||
|
||||
# Already curated? Say so first — it is the commonest answer to "please add this one",
|
||||
# and every branch below would otherwise re-derive advice for a decision already made.
|
||||
base = _base(emoji)
|
||||
if base and base in _WIDE:
|
||||
return (
|
||||
"lone codepoint drawn WIDE — already listed",
|
||||
"ALREADY IN _DEFAULT_WIDE_BASE (both authorities measure it 2, bare and with its "
|
||||
"VS16 selector). Nothing to add. If a row still misaligns, the culprit is another "
|
||||
"glyph on it — classify that one instead.",
|
||||
)
|
||||
if len(emoji) == 1 and emoji in _NARROW:
|
||||
return (
|
||||
"lone codepoint drawn NARROW — already listed",
|
||||
"ALREADY IN _DEFAULT_NARROW_LONE (both authorities measure it 1). Nothing to add.",
|
||||
)
|
||||
|
||||
if len(emoji) == 2 and all(_is_regional_indicator(c) for c in emoji):
|
||||
return (
|
||||
"flag (Regional Indicator pair)",
|
||||
"ALREADY HANDLED as a category — no allowlist entry. Verify pt width is 2 below; "
|
||||
"if so there is nothing to add. Never list a single flag (it half-fixes its "
|
||||
"neighbours that share an indicator).",
|
||||
)
|
||||
if _ZWJ in emoji:
|
||||
return (
|
||||
"ZWJ sequence (multi-glyph cluster)",
|
||||
"NOT covered by the lone/flag mechanism (a family, a profession, a flag-with-tag). "
|
||||
"Do not add it to either set — the parts would be resized individually and "
|
||||
"misalign. Flag this to a maintainer for bespoke handling.",
|
||||
)
|
||||
if len(emoji) == 1:
|
||||
if wcwidth(emoji) == 2:
|
||||
return (
|
||||
"lone wide codepoint",
|
||||
"IF your terminal draws it in ONE cell, add it to _DEFAULT_NARROW_LONE in "
|
||||
"meshterm/ui/tui/emoji_width.py (or MESHTERM_NARROW_EMOJI for a session test). "
|
||||
"If your terminal draws it TWO wide, leave it — narrowing would break its border.",
|
||||
)
|
||||
return (
|
||||
"lone narrow codepoint (bare, text presentation)",
|
||||
"NO CHANGE — and resist the pull to 'fix' it. Both authorities measure it one "
|
||||
"because it sits outside Emoji_Presentation, and with no VS16 asking for emoji "
|
||||
"presentation the font draws a one-cell text glyph: they are right. Forcing it "
|
||||
"to two reserves a cell the terminal never draws and pulls the row's border a "
|
||||
"column IN — the mistake U+1F6E3 🛣 cost the Trophy case. If this row really is "
|
||||
"misaligned, another glyph on it is the culprit; classify that one.",
|
||||
)
|
||||
if base and _VS16 in emoji:
|
||||
return (
|
||||
"VS16 emoji-presentation sequence",
|
||||
"HANDLED BY DEFAULT (the variation selector is skipped, so the base is measured "
|
||||
"alone and the narrow-VS16 verdict applies). If your terminal draws THIS one two "
|
||||
"cells wide anyway — the renderer is not uniform — add its BASE codepoint to "
|
||||
"_DEFAULT_WIDE_BASE (or MESHTERM_WIDE_EMOJI), as U+1F6E9 🛩️ needed.",
|
||||
)
|
||||
return (
|
||||
"multi-codepoint cluster (keycap / skin-tone / other)",
|
||||
"NOT a plain lone codepoint — the lone/flag mechanism does not cover it. Flag this to a "
|
||||
"maintainer rather than adding parts to either set.",
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2:
|
||||
print("usage: classify.py <emoji>")
|
||||
return 2
|
||||
emoji = argv[1]
|
||||
label, action = _classify(emoji)
|
||||
|
||||
print(f"emoji : {emoji}")
|
||||
print("codepoints : " + " ".join(f"U+{ord(c):04X}" for c in emoji))
|
||||
print(f"grapheme : {len(emoji)} codepoint(s)")
|
||||
print(f"wcwidth : {sum(max(0, wcwidth(c)) for c in emoji)} (per char: "
|
||||
+ ", ".join(str(wcwidth(c)) for c in emoji) + ")")
|
||||
print(f"Rich now : {rc.cell_len(emoji)}")
|
||||
print(f"pt now : {get_cwidth(emoji)} <- the authority that places the panel border")
|
||||
# "fixed" = what the width-1 calibration path already produces today, both sets applied.
|
||||
print(f"Rich fixed : {_make_cell_len(_NARROW, _WIDE)(emoji)}")
|
||||
print(f"pt fixed : {_make_pt_cache(_NARROW, _WIDE)[emoji]}")
|
||||
print()
|
||||
print(f"class : {label}")
|
||||
print(f"action : {action}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: read-logs
|
||||
description: Investigate a MeshTerm problem from its log — find what went wrong, when, and where in the code, and say whether it is still live on HEAD. Use when JP says to check, read or look at the logs, asks why something crashed, froze, disconnected or did something odd, pastes an error or a log excerpt, or reports a bug from the PicoCalc or another machine.
|
||||
---
|
||||
|
||||
# Reading the logs to find an issue
|
||||
|
||||
The log is evidence, not a diagnosis. The job runs **symptom → the records that show it →
|
||||
the code that wrote them → whether HEAD still does it**. Stop at the diagnosis and propose
|
||||
the fix; make it only when JP asks for it.
|
||||
|
||||
## What there is to read
|
||||
|
||||
| Source | Where | Clock |
|
||||
|---|---|---|
|
||||
| `meshterm.log`, rotated at 2 MB into `.1`–`.3` | `$MESHTERM_HOME`, else `~/.meshterm/` | local, of the machine that wrote it |
|
||||
| `meshterm.log.jsonl` — the log before 2026-09-07 15:00, JSON Lines | same directory; nothing reads it but `--legacy` | local |
|
||||
| the `runs` table — a row per tool run: `status` ok / error / running, `summary_json.error` | `meshterm.db`, same directory | **UTC** |
|
||||
|
||||
The **run id** joins the log to the table, never the timestamp.
|
||||
|
||||
How much the file holds is the `log_level` preference (**Log detail** on the Preferences
|
||||
page), default **WARNING**. JP's machine runs at DEBUG; a bug report from anyone else
|
||||
probably doesn't. At WARNING there are no `run N start`/`ok` records and no quiet failures,
|
||||
so a missing record proves nothing. The summary says so when it sees no DEBUG/INFO at all.
|
||||
When the question needs the narration, ask for Log detail at DEBUG (it relaunches) and a
|
||||
reproduction.
|
||||
|
||||
## 1. Pin the question
|
||||
|
||||
Before opening anything, settle four things:
|
||||
|
||||
- what went wrong: the screen or command, and the words on it
|
||||
- roughly when, in local time: "just now" is `--since 30m`
|
||||
- which machine: this one, the PicoCalc, or an attached file
|
||||
- menu or CLI
|
||||
|
||||
A window turns five thousand records into forty.
|
||||
|
||||
## 2. Get the log
|
||||
|
||||
- **This machine:** nothing to fetch.
|
||||
- **The PicoCalc:** read `.dev.env` for the `$DEV_PICOCALC_*` values, then copy the set into
|
||||
the scratchpad and point `--home` at it:
|
||||
```
|
||||
scp -i $DEV_PICOCALC_SSH_KEY "$DEV_PICOCALC_USER@$DEV_PICOCALC_HOST:.meshterm/meshterm.log*" <scratchpad>/picocalc/
|
||||
```
|
||||
Bring `meshterm.db` too only when the runs table matters; it is tens of MB. Those records
|
||||
are in the device's own local time.
|
||||
- **A pasted or attached log:** save it to the scratchpad and pass `--log <file>`.
|
||||
Repeat `--log` for a rotated set, oldest first.
|
||||
|
||||
## 3. Summary first
|
||||
|
||||
```
|
||||
python .claude/skills/read-logs/logscan.py summary --since "2026-09-11 03:00"
|
||||
```
|
||||
|
||||
Stdlib only, so any Python runs it, the PicoCalc's included. It prints, in order:
|
||||
|
||||
- **header**: files, span, window, records kept and how many test records it hid, the
|
||||
level mix
|
||||
- **Activity**: blocks of records split by silences (`--gap`, default 20 min), each with
|
||||
its warning and error count, which is where "when did it go wrong" usually shows first
|
||||
- **Tracebacks**, grouped by the exception they ended on, with first → last and the
|
||||
record they hang under; the CLI's second copy of a failed run is folded in
|
||||
- **Warnings and errors without a traceback**, grouped with first → last
|
||||
- **Quiet failures**: DEBUG/INFO records reporting a failure, which a WARNING-and-up
|
||||
read never sees
|
||||
- **Runs table**: status counts, the latest errors, and runs never finished
|
||||
|
||||
Time forms everywhere: `30m`, `2h`, `3d`, `03:54` (today), `2026-09-11 03:54[:10]`.
|
||||
|
||||
## 4. Then narrow
|
||||
|
||||
```
|
||||
logscan.py around "2026-09-11 03:54:47" --minutes 2 # every level, both sides of a moment
|
||||
logscan.py run 3379 # its runs row, its records, what ran beside it
|
||||
logscan.py show --level WARNING --grep "BLE|link" # verbatim records, filtered
|
||||
```
|
||||
|
||||
`--grep` searches message and traceback, case-insensitive. `--fixtures` brings the hidden
|
||||
test records back. `--no-db` skips the table.
|
||||
|
||||
## Reading it right
|
||||
|
||||
- **The logger name rarely says who spoke.** Nearly everything logs through `ctx.log`, the
|
||||
bare `meshterm` logger, so the component is the message's own prefix: `devstate:`,
|
||||
`courier:`, `event hub:`, `chat:`, `run N`. Only `meshterm.core.connection`,
|
||||
`meshterm.core.discovery`, `meshcore` (the library) and `asyncio` are loggers of their
|
||||
own.
|
||||
- **A count means nothing without its span.** 204× `event hub: subscriber raised: [Errno
|
||||
22]` reads as chronic, and it was three seconds on 09-08. Read first → last before
|
||||
calling anything recurring.
|
||||
- **A run's life** is `run N start: tool=… params=…` followed by exactly one ending
|
||||
(`tools/base.py`):
|
||||
- `run N ok`, at DEBUG.
|
||||
- `run N aborted: …`, at DEBUG. An *expected* refusal: a bad argument, no device, a
|
||||
device error. It has no traceback, and the CLI also logs the one line it printed as
|
||||
`<tool> failed: …` at WARNING.
|
||||
- `run N lost the device connection: …`, at WARNING.
|
||||
- `run N failed: …`, at ERROR with a traceback. The CLI logs the same exception again as
|
||||
`<tool> raised an unhandled error`.
|
||||
|
||||
`the interactive session raised an unhandled error` is the TUI itself dying.
|
||||
- **A run left `running` in the table** never reached an ending. The process was killed,
|
||||
the window closed, it died without a traceback, or it is still running now. A background
|
||||
`chat`/`monitor` row (`{"mode": "background"}`) left open means that *session* didn't
|
||||
tear down. A foreground tool left open died mid-command. `run N` on it shows what
|
||||
followed its start.
|
||||
- **Quiet failures have no stack.** Background loops log `"…: %s", exc` at DEBUG
|
||||
(`devstate: … failed`, `battery poller: read failed`, `courier: pass failed`, `event hub:
|
||||
subscriber raised`). The message survives and where it was raised does not:
|
||||
`[Errno 22] Invalid argument` names no file. Grep the literal prefix to find the call
|
||||
site and read what its `try` covers. Or reproduce with a temporary `exc_info=True` on
|
||||
that call, and never commit it.
|
||||
- **A connection has a lifecycle, a session doesn't.** No record marks the app starting.
|
||||
A link opens as `meshcore: BLE Connection started`, then `event hub started`, then the
|
||||
services (`passive monitor recording`, `advert scheduler started`, `courier started`, …).
|
||||
It closes with `event hub stopped`. A stop/start pair seconds apart is a reconnect, so
|
||||
read the record just before it (`BLE write failed`, `Client is not connected`).
|
||||
- **Radio refusals are usually a symptom.** A storm of `ERR_CODE_BAD_STATE` from contact
|
||||
refreshes and battery polls while an admin login or another exchange is in flight means
|
||||
the companion was busy. Look for what held it, not at the refresh.
|
||||
- **An `asyncio` record is a real bug.** prompt_toolkit's loop handler is disabled, so a
|
||||
stray background-task exception (`Task exception was never retrieved`) lands in the file
|
||||
and nowhere else. No screen ever showed it.
|
||||
- **Test noise before 2026-09-08 02:52.** Until 59f10f9 the suite wrote into the real
|
||||
`~/.meshterm/meshterm.log`. The summary hides it by fingerprint, and hides runs whose ids
|
||||
came from another database, and says how many records went. Anything on this machine
|
||||
from before then that still looks synthetic probably is. A hand-run `--mock` session
|
||||
still writes into the real log *and* the real database, so run `purge-test-data` after
|
||||
one.
|
||||
|
||||
## From record to code
|
||||
|
||||
1. **Find the call site.** Grep the message's literal text, the part before its first
|
||||
formatted value, under `meshterm/`. Read the `try` it sits in.
|
||||
2. **Check it is still live.** A traceback from last week may already be fixed, and
|
||||
finding the name in the source proves nothing: the attribute an `AttributeError`
|
||||
complains of usually still exists, on a different class. Read the frame's own line at
|
||||
HEAD, and use `git log -L` on that function (or `git log -S "<literal>"`) to see whether
|
||||
it changed after the record's date. A fixed one gets named with its commit, not fixed
|
||||
twice.
|
||||
3. **Reproduce a live one.** If it doesn't need hardware, reproduce it (the `verify`
|
||||
skill; `--mock` needs `purge-test-data` after). If it needs the radio, tell JP what to
|
||||
do and at what Log detail.
|
||||
|
||||
## Report
|
||||
|
||||
- **Symptom**, in JP's words.
|
||||
- **Evidence**: the few decisive records, quoted with their timestamps, not the summary
|
||||
dump.
|
||||
- **Cause**: `file:line` and the mechanism.
|
||||
- **Status**: live on HEAD, fixed in `<hash>`, or unknown without a reproduction (and what
|
||||
that reproduction needs).
|
||||
- **Fix**: proposed, not applied, unless JP asked.
|
||||
|
||||
Log excerpts carry node and channel names, and the legacy `.jsonl` carries tool `params`
|
||||
logged verbatim, **admin passwords among them**. Quote only what a finding needs, and never
|
||||
put log text anywhere public (an issue, Discord) without JP reading it first.
|
||||
|
||||
## Keeping the script honest
|
||||
|
||||
- `RECORD` in `logscan.py` parses `_LINE_FORMAT` from `meshterm/persistence/logging.py`.
|
||||
Change one, change the other.
|
||||
- `FIXTURE` carries `MockDevice`'s identities. Keep them in step with
|
||||
`.claude/skills/purge-test-data/purge.py`.
|
||||
@@ -0,0 +1,632 @@
|
||||
"""Read MeshTerm's log the way an investigation needs it: grouped, windowed, joined to runs.
|
||||
|
||||
MeshTerm writes ``<config_dir>/meshterm.log`` (see ``meshterm/persistence/logging.py``):
|
||||
one record per line — ``2026-09-07 14:23:11 WARNING meshterm: message`` — with a
|
||||
traceback indented under the record that raised it, rotated at 2MB into ``.1``..``.3``.
|
||||
Before 2026-09-07 it was JSON Lines, and that file is still beside it as
|
||||
``meshterm.log.jsonl`` (``--legacy``).
|
||||
|
||||
Read raw, the file misleads in predictable ways, and this script takes them off the table
|
||||
before any reasoning starts:
|
||||
|
||||
- **A count without its spread.** Two hundred identical records inside one hour are one
|
||||
incident, not a chronic fault — so every group carries its first and last time.
|
||||
- **The same failure twice.** A tool that raises logs ``run N failed`` with a traceback, and
|
||||
the CLI then logs ``<tool> raised an unhandled error`` with the *same* traceback. The
|
||||
second is folded into the first.
|
||||
- **Test runs.** Until ``tests/conftest.py`` isolated ``MESHTERM_HOME`` the suite wrote into
|
||||
the developer's real log, and ``--mock`` sessions still can. Records carrying their
|
||||
fingerprints, and runs numbered by another database, are hidden unless ``--fixtures``
|
||||
asks for them, and the summary says how many went.
|
||||
- **Two clocks.** Log records are the writing machine's *local* time; the ``runs`` table is
|
||||
UTC. Both carry the run id, which is the join — the table's times are converted to this
|
||||
machine's local time for display only.
|
||||
|
||||
Stdlib only, so it runs anywhere MeshTerm does, the PicoCalc included.
|
||||
|
||||
Usage::
|
||||
|
||||
logscan.py [summary] the whole picture, grouped
|
||||
logscan.py show --level WARNING matching records, verbatim
|
||||
logscan.py around "2026-09-11 03:54" --minutes 3
|
||||
logscan.py run 3379 one run's records, its neighbours, its runs row
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
#: ``_LINE_FORMAT`` / ``_TIME_FORMAT`` in meshterm/persistence/logging.py, as a parser.
|
||||
RECORD = re.compile(
|
||||
r"^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) (DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+): (.*)$"
|
||||
)
|
||||
TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}
|
||||
|
||||
#: Test-suite and simulator fingerprints: the CLI-contract fixtures (``config get
|
||||
#: nosuchkey``/``no_such_key``, ``--port COM99``/``NOPE1``/``xx``, ``chat --to Nobody``, the
|
||||
#: ``[/]Bob`` contact planted to prove markup escaping), the discovery tests' BLE address,
|
||||
#: and ``MockDevice``'s identities — keep the last in step with
|
||||
#: ``.claude/skills/purge-test-data/purge.py``. Every one of them reached the real log only
|
||||
#: before 59f10f9 (2026-09-08 02:52 local) moved the suite into its own ``MESHTERM_HOME``;
|
||||
#: after that, only a ``--mock`` session run by hand can add more.
|
||||
FIXTURE = re.compile(
|
||||
r"nosuchkey|no_such_key|COM99|NOPE\d*|'Nobody'|'nope'|'xx'|\[/\]|AA:BB:CC:DD:EE:FF"
|
||||
r"|\bAlice\b|Yagi-Repeater|Local-Repeater|Observer-Bot"
|
||||
r"|MockCompanion|\bhub-1\b|\brelay-[12]\b|a1b2c3d4|b2c3d4e5|c3d4e5f6|d4e5f6a7"
|
||||
)
|
||||
|
||||
RUN_REF = re.compile(r"\brun (\d+)\b")
|
||||
RUN_START = re.compile(r"^run (\d+) start: tool=([\w-]+)")
|
||||
RUN_FAILED = re.compile(r"^run (\d+) failed: ")
|
||||
RUN_END = re.compile(r"^run (\d+) (?:ok|aborted|failed|lost)\b")
|
||||
#: ``tools/base.py``'s endings that the CLI goes on to report in a line of its own.
|
||||
RUN_REPORTED = re.compile(r"^run (\d+) (?:aborted|failed|lost)\b")
|
||||
#: ``cli.py``'s two reports of a run that ended badly: the one line it printed on stderr
|
||||
#: (``_reported``), and the traceback of one it could not explain.
|
||||
CLI_LINE = re.compile(r"^([\w-]+) failed: ")
|
||||
CLI_COPY = re.compile(r"^([\w-]+) raised an unhandled error$")
|
||||
|
||||
#: A DEBUG/INFO record that reports a failure. Most of the package's background loops log
|
||||
#: ``"...: %s", exc`` at DEBUG — the message survives, the stack does not — so these are
|
||||
#: the failures a WARNING-and-up read never sees.
|
||||
QUIET = re.compile(
|
||||
r"fail|raised|error|could not|couldn't|timed out|timeout|rejected|refused|lost|skipped"
|
||||
r"|invalid|unexpected",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Record:
|
||||
"""One log record and the lines hanging under it (a traceback, a wrapped message)."""
|
||||
|
||||
when: datetime
|
||||
level: str
|
||||
logger: str
|
||||
message: str
|
||||
extra: list[str] = field(default_factory=list)
|
||||
#: The run-ending record this one reports again — the CLI's line or traceback for it.
|
||||
twin_of: Record | None = None
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""The message and everything under it, for searching."""
|
||||
return "\n".join([self.message, *self.extra])
|
||||
|
||||
@property
|
||||
def rank(self) -> int:
|
||||
"""The level as a number, for ``--level``; an unknown level ranks lowest."""
|
||||
return LEVELS.get(self.level, 0)
|
||||
|
||||
def run_id(self) -> int | None:
|
||||
"""The run this record speaks about, if it names one."""
|
||||
m = RUN_REF.search(self.message)
|
||||
return int(m[1]) if m else None
|
||||
|
||||
def exception(self) -> str | None:
|
||||
"""The line a traceback ended on — the exception itself — or ``None``."""
|
||||
if not any(line.startswith("Traceback") for line in self.extra):
|
||||
return None
|
||||
tail = [
|
||||
line
|
||||
for line in self.extra
|
||||
if line.strip()
|
||||
and not line.startswith(
|
||||
(" ", "\t", "Traceback", "During handling", "The above exception")
|
||||
)
|
||||
]
|
||||
return tail[-1] if tail else None
|
||||
|
||||
def render(self, mark: str = "") -> str:
|
||||
"""The record as the file spelt it, behind an optional two-cell ``mark``."""
|
||||
stamp = self.when.strftime(TIME_FORMAT)
|
||||
head = f"{mark}{stamp} {self.level:<8} {self.logger}: {self.message}"
|
||||
return "\n".join([head, *self.extra])
|
||||
|
||||
|
||||
# --- loading --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def default_home() -> Path:
|
||||
"""``meshterm.core.config.default_config_dir``, without importing the package."""
|
||||
env = os.environ.get("MESHTERM_HOME", "").strip()
|
||||
return Path(env).expanduser() if env else Path.home() / ".meshterm"
|
||||
|
||||
|
||||
def log_files(home: Path, *, legacy: bool) -> list[Path]:
|
||||
"""Every log file in ``home``, oldest first: ``.jsonl``, ``.3``, ``.2``, ``.1``, live."""
|
||||
rotated = sorted(
|
||||
(p for p in home.glob("meshterm.log.*") if p.suffix[1:].isdigit()),
|
||||
key=lambda p: int(p.suffix[1:]),
|
||||
reverse=True,
|
||||
)
|
||||
files = [*rotated]
|
||||
if (home / "meshterm.log").exists():
|
||||
files.append(home / "meshterm.log")
|
||||
if legacy and (home / "meshterm.log.jsonl").exists():
|
||||
files.insert(0, home / "meshterm.log.jsonl")
|
||||
return files
|
||||
|
||||
|
||||
def load(files: list[Path]) -> tuple[list[Record], int]:
|
||||
"""Parse ``files`` in order. Returns the records and the count of orphan lines.
|
||||
|
||||
An orphan is a line before a file's first record — rotation cut a traceback away from
|
||||
the record it hangs under. It cannot be placed, so it is counted rather than guessed at.
|
||||
"""
|
||||
records: list[Record] = []
|
||||
orphans = 0
|
||||
for path in files:
|
||||
if path.suffix == ".jsonl":
|
||||
records.extend(_load_jsonl(path))
|
||||
continue
|
||||
with path.open(encoding="utf-8", errors="replace") as fh:
|
||||
here: list[Record] = []
|
||||
for raw in fh:
|
||||
line = raw.rstrip("\r\n")
|
||||
m = RECORD.match(line)
|
||||
if m:
|
||||
when = datetime.strptime(m[1], TIME_FORMAT)
|
||||
here.append(Record(when, m[2], m[3], m[4]))
|
||||
elif here:
|
||||
here[-1].extra.append(line)
|
||||
elif line.strip():
|
||||
orphans += 1
|
||||
records.extend(here)
|
||||
for r in records:
|
||||
while r.extra and not r.extra[-1].strip():
|
||||
r.extra.pop()
|
||||
_pair_cli_reports(records)
|
||||
return records, orphans
|
||||
|
||||
|
||||
def _load_jsonl(path: Path) -> list[Record]:
|
||||
"""The pre-2026-09-07 JSON Lines log: ``ts``/``level``/``logger``/``msg``, and ``exc``.
|
||||
|
||||
A ``msg`` may carry newlines (asyncio's ``Task was destroyed…`` puts the task on a
|
||||
second line), which the text log would have indented under the record; they go to
|
||||
``extra`` here for the same reason, so a group is keyed on the first line alone.
|
||||
"""
|
||||
out: list[Record] = []
|
||||
with path.open(encoding="utf-8", errors="replace") as fh:
|
||||
for raw in fh:
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
when = datetime.fromisoformat(obj["ts"]).replace(tzinfo=None)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
continue
|
||||
first, *more = str(obj.get("msg", "")).splitlines() or [""]
|
||||
extra = [f" {line}" for line in more]
|
||||
for key, value in obj.items():
|
||||
if key in ("ts", "level", "logger", "msg"):
|
||||
continue
|
||||
text = str(value)
|
||||
extra.extend(text.splitlines() if "Traceback" in text else [f" {key}: {text}"])
|
||||
level = str(obj.get("level", "?"))
|
||||
out.append(Record(when, level, str(obj.get("logger", "?")), first, extra))
|
||||
return out
|
||||
|
||||
|
||||
def _pair_cli_reports(records: list[Record]) -> None:
|
||||
"""Point each of the CLI's reports of a run at the run-ending record it repeats.
|
||||
|
||||
``tools/base.py`` ends a bad run with ``run N aborted``/``lost``/``failed``; ``cli.py``
|
||||
then reports the same run once more, naming the tool rather than the run — a
|
||||
``<tool> failed: …`` line, or a ``<tool> raised an unhandled error`` traceback. The
|
||||
report carries no run id, so it is matched to the latest bad ending of a run of that
|
||||
tool within a few seconds. That is what lets a test run's report be hidden with the
|
||||
run, and a traceback be counted once.
|
||||
"""
|
||||
tools: dict[int, str] = {}
|
||||
ended: dict[str, Record] = {}
|
||||
for r in records:
|
||||
if m := RUN_START.match(r.message):
|
||||
tools[int(m[1])] = m[2]
|
||||
elif m := RUN_REPORTED.match(r.message):
|
||||
tool = tools.get(int(m[1]))
|
||||
if tool is not None:
|
||||
ended[tool] = r
|
||||
elif m := CLI_LINE.match(r.message) or CLI_COPY.match(r.message):
|
||||
end = ended.get(m[1])
|
||||
if end is not None and r.when - end.when <= timedelta(seconds=5):
|
||||
r.twin_of = end
|
||||
|
||||
|
||||
def drop_fixtures(records: list[Record]) -> tuple[list[Record], int]:
|
||||
"""Remove test/simulator records: fingerprinted ones, and every record of their runs.
|
||||
|
||||
A run is a fixture when its start record carries a fingerprint, or when its id is lower
|
||||
than one this log has already started. One database hands out increasing ids, so a
|
||||
``run 1`` after ``run 3212`` was written by *another* database — the suite's temporary
|
||||
one, or a ``--db`` run pointed at a scratch file. Ids repeat across databases, so a
|
||||
record belongs to the latest start of its id, never to every run that ever had it.
|
||||
|
||||
A record naming a run whose start this log never saw (a background recorder opens its
|
||||
run without one; ``log_level`` above DEBUG drops them all) is foreign only when it is
|
||||
far below the highest id — a real background run can trail the foreground ids of its
|
||||
own session by a little, never by a thousand.
|
||||
"""
|
||||
highest = 0
|
||||
flagged: dict[int, bool] = {}
|
||||
dropped: set[int] = set()
|
||||
kept: list[Record] = []
|
||||
for r in records:
|
||||
rid = r.run_id()
|
||||
if m := RUN_START.match(r.message):
|
||||
rid = int(m[1])
|
||||
foreign = bool(FIXTURE.search(r.message)) or rid < highest
|
||||
flagged[rid] = foreign
|
||||
if not foreign:
|
||||
highest = max(highest, rid)
|
||||
if rid is not None and rid in flagged:
|
||||
by_run = flagged[rid]
|
||||
else:
|
||||
by_run = rid is not None and highest - rid > 1000
|
||||
if by_run or FIXTURE.search(r.text) or (r.twin_of is not None and id(r.twin_of) in dropped):
|
||||
dropped.add(id(r))
|
||||
else:
|
||||
kept.append(r)
|
||||
return kept, len(records) - len(kept)
|
||||
|
||||
|
||||
# --- time -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_when(text: str, now: datetime) -> datetime:
|
||||
"""``30m`` / ``2h`` / ``3d`` ago, ``03:54`` today, or ``2026-09-11[ 03:54[:10]]``."""
|
||||
text = text.strip()
|
||||
if m := re.fullmatch(r"(\d+(?:\.\d+)?)([mhd])", text):
|
||||
unit = {"m": "minutes", "h": "hours", "d": "days"}[m[2]]
|
||||
return now - timedelta(**{unit: float(m[1])})
|
||||
if m := re.fullmatch(r"(\d\d?):(\d\d)(?::(\d\d))?", text):
|
||||
return datetime.combine(now.date(), time(int(m[1]), int(m[2]), int(m[3] or 0)))
|
||||
return datetime.fromisoformat(text)
|
||||
|
||||
|
||||
def utc_to_local(stamp: str | None) -> datetime | None:
|
||||
"""A ``runs`` timestamp (UTC, RFC 3339) in this machine's local time, naive."""
|
||||
if not stamp:
|
||||
return None
|
||||
when = datetime.fromisoformat(stamp)
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=timezone.utc)
|
||||
return when.astimezone().replace(tzinfo=None)
|
||||
|
||||
|
||||
def short(when: datetime | None) -> str:
|
||||
"""A time narrow enough to sit in a grouped row: month-day and clock, no year."""
|
||||
return when.strftime("%m-%d %H:%M:%S") if when else "-"
|
||||
|
||||
|
||||
# --- grouping -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def shape(message: str) -> str:
|
||||
"""A message's first line with its variable parts blanked, so repeats group together."""
|
||||
s = (message.splitlines() or [""])[0]
|
||||
s = re.sub(r"\b[0-9a-fA-F]{6,}\b", "<hex>", s)
|
||||
s = re.sub(r"\d+(?:\.\d+)?", "#", s)
|
||||
return s if len(s) <= 150 else s[:149] + "…"
|
||||
|
||||
|
||||
class Groups:
|
||||
"""Count, first time and last time per key."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Start empty."""
|
||||
self.items: dict[object, list] = {}
|
||||
|
||||
def add(self, key: object, r: Record) -> None:
|
||||
"""Count ``r`` under ``key`` and stretch the key's span to cover it."""
|
||||
g = self.items.setdefault(key, [0, r.when, r.when])
|
||||
g[0] += 1
|
||||
g[2] = r.when
|
||||
|
||||
def rows(self, top: int) -> list[tuple[object, list]]:
|
||||
"""The ``top`` biggest groups, largest first, earliest first among equals."""
|
||||
ranked = sorted(self.items.items(), key=lambda kv: (-kv[1][0], kv[1][1]))
|
||||
return ranked[:top]
|
||||
|
||||
|
||||
def blocks(records: list[Record], gap: timedelta) -> list[list[Record]]:
|
||||
"""Split into runs of activity separated by at least ``gap`` of silence."""
|
||||
out: list[list[Record]] = []
|
||||
for r in records:
|
||||
if not out or r.when - out[-1][-1].when >= gap:
|
||||
out.append([])
|
||||
out[-1].append(r)
|
||||
return out
|
||||
|
||||
|
||||
# --- the runs table -------------------------------------------------------------------
|
||||
|
||||
|
||||
def open_db(path: Path | None) -> sqlite3.Connection | None:
|
||||
"""Open the database read-only, or ``None`` when there is no usable ``runs`` table."""
|
||||
if path is None or not path.exists():
|
||||
return None
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{path.resolve().as_posix()}?mode=ro", uri=True)
|
||||
con.execute("SELECT 1 FROM runs LIMIT 1")
|
||||
return con
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
|
||||
|
||||
def _error_of(summary_json: str | None) -> str:
|
||||
"""The ``error`` a failed run's summary recorded."""
|
||||
try:
|
||||
return str(json.loads(summary_json or "{}").get("error", ""))
|
||||
except (ValueError, AttributeError):
|
||||
return summary_json or ""
|
||||
|
||||
|
||||
def runs_section(
|
||||
con: sqlite3.Connection,
|
||||
since: datetime | None,
|
||||
until: datetime | None,
|
||||
fixtures: bool,
|
||||
top: int,
|
||||
) -> None:
|
||||
"""Print the window's runs: status counts, the latest errors, and runs never finished."""
|
||||
rows = []
|
||||
for rid, tool, status, args_json, summary_json, started, finished in con.execute(
|
||||
"SELECT id, tool, status, args_json, summary_json, started_at, finished_at FROM runs"
|
||||
):
|
||||
start = utc_to_local(started)
|
||||
if start is None or (since and start < since) or (until and start > until):
|
||||
continue
|
||||
if not fixtures and FIXTURE.search(f"{args_json} {summary_json}"):
|
||||
continue
|
||||
rows.append((rid, tool, status, start, utc_to_local(finished), args_json, summary_json))
|
||||
print("\n== Runs table (meshterm.db; UTC shown as this machine's local time)")
|
||||
if not rows:
|
||||
print(" no runs in the window")
|
||||
return
|
||||
counts = Counter(r[2] for r in rows)
|
||||
print(" " + " · ".join(f"{k} {v}" for k, v in counts.most_common()))
|
||||
errors = [r for r in rows if r[2] == "error"][-top:]
|
||||
if errors:
|
||||
print(" errors, latest last — a raised error the CLI reported cleanly has no traceback:")
|
||||
for rid, tool, _, start, _, _, summary in errors:
|
||||
print(f" #{rid:<6} {short(start)} {tool:<14} {_error_of(summary)[:100]}")
|
||||
open_rows = [r for r in rows if r[2] == "running"][-top:]
|
||||
if open_rows:
|
||||
print(" never finished — the process died, was killed, or is still running now:")
|
||||
for rid, tool, _, start, _, args, _ in open_rows:
|
||||
print(f" #{rid:<6} {short(start)} {tool:<14} {args[:80]}")
|
||||
|
||||
|
||||
# --- modes ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_summary(
|
||||
records: list[Record],
|
||||
args: argparse.Namespace,
|
||||
window: tuple[datetime | None, datetime | None],
|
||||
hidden: int,
|
||||
orphans: int,
|
||||
files: list[Path],
|
||||
con: sqlite3.Connection | None,
|
||||
) -> None:
|
||||
"""Print the grouped picture: activity, tracebacks, warnings, quiet failures, runs."""
|
||||
since, until = window
|
||||
print("files " + ", ".join(f"{p.name} ({p.stat().st_size // 1024} KB)" for p in files))
|
||||
if records:
|
||||
print(f"span {records[0].when} → {records[-1].when}")
|
||||
if since or until:
|
||||
print(f"window {since or '…'} → {until or '…'}")
|
||||
note = (
|
||||
f" · {hidden} test, mock and other-database records hidden (--fixtures)" if hidden else ""
|
||||
)
|
||||
if orphans:
|
||||
note += f" · {orphans} orphan lines (rotation cut)"
|
||||
print(f"records {len(records)}{note}")
|
||||
levels = Counter(r.level for r in records)
|
||||
print("levels " + " · ".join(f"{k} {levels[k]}" for k in LEVELS if levels[k]))
|
||||
if levels and not levels["DEBUG"] and not levels["INFO"]:
|
||||
print(
|
||||
" no DEBUG/INFO at all: log_level is probably WARNING — silence proves nothing"
|
||||
)
|
||||
if not records:
|
||||
return
|
||||
|
||||
print(f"\n== Activity (a new block after {args.gap:g} min of silence; latest {args.top})")
|
||||
for chunk in blocks(records, timedelta(minutes=args.gap))[-args.top :]:
|
||||
lv = Counter(r.level for r in chunk)
|
||||
errors = lv["ERROR"] + lv["CRITICAL"]
|
||||
bad = (f" ⚠ {lv['WARNING']}" if lv["WARNING"] else "") + (
|
||||
f" ✗ {errors}" if errors else ""
|
||||
)
|
||||
print(f" {short(chunk[0].when)} → {short(chunk[-1].when)} {len(chunk):>5} records{bad}")
|
||||
|
||||
problems, tracebacks, quiet = Groups(), Groups(), Groups()
|
||||
copies: Counter = Counter()
|
||||
for r in records:
|
||||
exc = r.exception()
|
||||
if r.twin_of is not None and exc and exc == r.twin_of.exception():
|
||||
copies[(exc, shape(r.twin_of.message))] += 1
|
||||
continue
|
||||
if exc:
|
||||
tracebacks.add((exc, shape(r.message)), r)
|
||||
elif r.rank >= LEVELS["WARNING"]:
|
||||
problems.add((r.level, r.logger, shape(r.message)), r)
|
||||
elif QUIET.search(r.message) and not RUN_START.match(r.message):
|
||||
quiet.add((r.logger, shape(r.message)), r)
|
||||
|
||||
print("\n== Tracebacks (grouped by the exception they ended on)")
|
||||
if not tracebacks.items:
|
||||
print(" none")
|
||||
for (exc, under), (n, first, last) in tracebacks.rows(args.top):
|
||||
folded = copies[(exc, under)]
|
||||
tail = f" (+{folded} CLI copies folded)" if folded else ""
|
||||
print(f" {n:>4}× {short(first)} → {short(last)} {exc[:120]}")
|
||||
print(f" under: {under}{tail}")
|
||||
|
||||
print("\n== Warnings and errors without a traceback")
|
||||
if not problems.items:
|
||||
print(" none")
|
||||
for (level, logger, msg), (n, first, last) in problems.rows(args.top):
|
||||
print(f" {n:>4}× {short(first)} → {short(last)} {level:<7} {logger}: {msg}")
|
||||
|
||||
print("\n== Quiet failures (DEBUG/INFO that report a failure — message kept, stack lost)")
|
||||
if not quiet.items:
|
||||
print(" none (or log_level is above DEBUG)")
|
||||
for (logger, msg), (n, first, last) in quiet.rows(args.top):
|
||||
print(f" {n:>4}× {short(first)} → {short(last)} {logger}: {msg}")
|
||||
|
||||
if con is not None:
|
||||
runs_section(con, since, until, args.fixtures, min(args.top, 10))
|
||||
|
||||
|
||||
def cmd_show(records: list[Record], args: argparse.Namespace) -> None:
|
||||
"""Print the filtered records verbatim — the last ``--limit`` of them."""
|
||||
picked = records[-args.limit :] if args.limit else records
|
||||
if len(picked) < len(records):
|
||||
print(f"… {len(records) - len(picked)} earlier records not shown (--limit)")
|
||||
for r in picked:
|
||||
print(r.render())
|
||||
|
||||
|
||||
def cmd_around(records: list[Record], centre: datetime, args: argparse.Namespace) -> None:
|
||||
"""Print every record within ``--minutes`` of ``centre``; the nearest 30s are marked."""
|
||||
span = timedelta(minutes=args.minutes)
|
||||
print(f"-- {centre - span} → {centre + span}")
|
||||
for r in records:
|
||||
if centre - span <= r.when <= centre + span:
|
||||
print(r.render("» " if abs(r.when - centre) < timedelta(seconds=30) else " "))
|
||||
|
||||
|
||||
def cmd_run(
|
||||
all_records: list[Record],
|
||||
records: list[Record],
|
||||
run_id: int,
|
||||
args: argparse.Namespace,
|
||||
con: sqlite3.Connection | None,
|
||||
) -> None:
|
||||
"""Print one run: its ``runs`` row, then every record from its start to its end.
|
||||
|
||||
The run's own records are marked; the rest are what the process was doing beside it.
|
||||
A run with no end record gets ``--minutes`` after its start instead — the run died or
|
||||
is still going, and what came next is the evidence either way.
|
||||
"""
|
||||
if con is not None:
|
||||
row = con.execute(
|
||||
"SELECT tool, profile, status, args_json, summary_json, started_at, finished_at"
|
||||
" FROM runs WHERE id = ?",
|
||||
(run_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
tool, profile, status, args_json, summary_json, started, finished = row
|
||||
start, end = utc_to_local(started), utc_to_local(finished)
|
||||
took = f" ({(end - start).total_seconds():.1f}s)" if start and end else ""
|
||||
print(f"run #{run_id} {tool} status={status} profile={profile or '-'}")
|
||||
print(f" started {start} finished {end or 'never'}{took} (local)")
|
||||
print(f" args {args_json}")
|
||||
print(f" summary {(summary_json or '-')[:300]}")
|
||||
else:
|
||||
print(f"run #{run_id}: no row in the runs table")
|
||||
own = [r for r in all_records if r.run_id() == run_id]
|
||||
if not own:
|
||||
print(f"no log record names run {run_id} — its tool logs nothing at this log_level")
|
||||
return
|
||||
start = own[0].when
|
||||
ended = any(RUN_END.match(r.message) for r in own)
|
||||
end = own[-1].when if ended else max(own[-1].when, start + timedelta(minutes=args.minutes))
|
||||
if not ended:
|
||||
print(f" no end record: the {args.minutes:g} min after its start follow")
|
||||
slack = timedelta(seconds=2)
|
||||
window = [r for r in records if start - slack <= r.when <= end + slack]
|
||||
if args.limit and len(window) > args.limit:
|
||||
print(f" {len(window)} records in the window; the first {args.limit} (--limit)")
|
||||
window = window[: args.limit]
|
||||
ids = {id(r) for r in own}
|
||||
for r in window:
|
||||
print(r.render("» " if id(r) in ids else " "))
|
||||
|
||||
|
||||
# --- entry ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Parse the arguments, load and filter the log, and run the chosen mode."""
|
||||
ap = argparse.ArgumentParser(description="Read MeshTerm's log: grouped, windowed, joined.")
|
||||
ap.add_argument(
|
||||
"mode", nargs="?", default="summary", choices=("summary", "show", "around", "run")
|
||||
)
|
||||
ap.add_argument("target", nargs="?", help="a time for `around`, a run id for `run`")
|
||||
ap.add_argument("--home", type=Path, help="config dir (default $MESHTERM_HOME or ~/.meshterm)")
|
||||
ap.add_argument(
|
||||
"--log", type=Path, action="append", help="read this file; repeatable, oldest first"
|
||||
)
|
||||
ap.add_argument("--db", type=Path, help="runs table source (default <home>/meshterm.db)")
|
||||
ap.add_argument("--no-db", action="store_true", help="skip the runs table")
|
||||
ap.add_argument(
|
||||
"--legacy", action="store_true", help="include the pre-09-07 meshterm.log.jsonl"
|
||||
)
|
||||
ap.add_argument("--since", help="30m | 2h | 3d | 03:54 | 2026-09-11 03:54 (local)")
|
||||
ap.add_argument("--until", help="same forms as --since")
|
||||
ap.add_argument("--level", help="minimum level: DEBUG INFO WARNING ERROR")
|
||||
ap.add_argument("--grep", help="regex over message and traceback, case-insensitive")
|
||||
ap.add_argument("--fixtures", action="store_true", help="keep test-suite and --mock records")
|
||||
ap.add_argument("--minutes", type=float, default=5, help="`around` half-width; open-run tail")
|
||||
ap.add_argument("--gap", type=float, default=20, help="minutes of silence that start a block")
|
||||
ap.add_argument("--top", type=int, default=25, help="rows per summary section")
|
||||
ap.add_argument("--limit", type=int, default=200, help="most records `show`/`run` print")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
home = (args.home or default_home()).expanduser()
|
||||
files = [f for f in (args.log or log_files(home, legacy=args.legacy)) if f.exists()]
|
||||
if not files:
|
||||
print(f"no log files found (looked in {home})", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
now = datetime.now()
|
||||
since = parse_when(args.since, now) if args.since else None
|
||||
until = parse_when(args.until, now) if args.until else None
|
||||
|
||||
all_records, orphans = load(files)
|
||||
records, hidden = (all_records, 0) if args.fixtures else drop_fixtures(all_records)
|
||||
minimum = LEVELS.get((args.level or "").upper(), 0)
|
||||
grep = re.compile(args.grep, re.IGNORECASE) if args.grep else None
|
||||
records = [
|
||||
r
|
||||
for r in records
|
||||
if (not since or r.when >= since)
|
||||
and (not until or r.when <= until)
|
||||
and r.rank >= minimum
|
||||
and (not grep or grep.search(r.text))
|
||||
]
|
||||
|
||||
con = open_db(None if args.no_db else (args.db or home / "meshterm.db"))
|
||||
|
||||
if args.mode == "summary":
|
||||
cmd_summary(records, args, (since, until), hidden, orphans, files, con)
|
||||
elif args.mode == "show":
|
||||
cmd_show(records, args)
|
||||
elif args.mode == "around":
|
||||
if not args.target:
|
||||
ap.error("around needs a time")
|
||||
cmd_around(records, parse_when(args.target, now), args)
|
||||
else:
|
||||
if not (args.target or "").isdigit():
|
||||
ap.error("run needs a run id")
|
||||
cmd_run(all_records, records, int(args.target), args, con)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,231 @@
|
||||
---
|
||||
name: release
|
||||
description: Cut a MeshTerm release — pick the version, write the changelog entry from the commits since the last tag, bump `__version__`, commit, tag, push so the GitHub release builds, and hand JP a Discord announcement to post. Use when JP says to release, cut a release, ship a version, tag a version, or bump the version, and when asked what would go into the next release.
|
||||
---
|
||||
|
||||
# Cutting a MeshTerm release
|
||||
|
||||
A release is a **rename and a tag**, not a week of git archaeology — that is the whole
|
||||
point of keeping an `[Unreleased]` section. The one part that takes real work is writing
|
||||
the entry, because the changelog is prose someone reads, not a list of commit subjects.
|
||||
|
||||
Pushing the tag fires [github-release.yml](../../../.github/workflows/github-release.yml),
|
||||
which calls `installers.yml`, builds all five platforms, attaches them with a combined
|
||||
`SHA256SUMS`, and takes the release notes **from that version's changelog section**. So the
|
||||
changelog is not documentation of the release; it *is* the release notes. An empty section
|
||||
publishes "No changelog entry for X.Y.Z."
|
||||
|
||||
## Two things this skill never does
|
||||
|
||||
- **Never runs `release.yml`.** That is the **PyPI** publish, and it is deliberately hard
|
||||
to fire: no automatic trigger, a typed version that must match the build, and a `pypi`
|
||||
environment gate. PyPI has no rename and no true delete. It runs only when JP says so in
|
||||
that moment, from the Actions tab, by hand. The name `meshterm` is on PyPI's prohibited
|
||||
list anyway — the project publishes as `mesh-term`.
|
||||
- **Never picks the version silently when it isn't a patch.** Default is a **patch** bump
|
||||
(`0.3.0` → `0.3.1`). Anything else — minor, major — is JP's call, and he says which part.
|
||||
Bumping one part zeroes the ones after it. While the major is `0`, a **minor** bump is
|
||||
allowed to change behaviour, not just add to it.
|
||||
|
||||
## 1. Preflight
|
||||
|
||||
```
|
||||
python .claude/skills/release/release_check.py
|
||||
```
|
||||
|
||||
It prints the current version, the last tag, every commit since it, the state of the
|
||||
`[Unreleased]` section, any changelog link refs that are missing, and whether the tree is
|
||||
clean and in sync with `origin/main`. Read it before touching anything — it is also the
|
||||
answer to "what would go into the next release?" on its own.
|
||||
|
||||
Then the gates `CONTRIBUTING.md` asks contributors to pass:
|
||||
|
||||
```
|
||||
python -m pytest -q && ruff check . && ruff format --check .
|
||||
```
|
||||
|
||||
A release does not go out on a red suite. If something fails, stop and say so.
|
||||
|
||||
## 2. Decide the version
|
||||
|
||||
**Always ask, as a multiple choice**, whenever JP asks for a release — even when his
|
||||
request names a bump, because words like "minor" have meant "a small one" before (he
|
||||
asked to "bump minor" and meant the patch). One AskUserQuestion, three choices, each
|
||||
showing the actual number it produces:
|
||||
|
||||
- **Patch — X.Y.(Z+1)** (Recommended)
|
||||
- **Minor — X.(Y+1).0**
|
||||
- **Major — (X+1).0.0**
|
||||
|
||||
**Major asks twice.** If he picks it, ask a second, separate question before writing the
|
||||
number anywhere — "Release (X+1).0.0? A major version is a promise about compatibility
|
||||
that can't be taken back." — with *Yes, (X+1).0.0* and *No, go back to patch* as its
|
||||
choices. Only a second yes makes it major.
|
||||
|
||||
Then say the number you arrived at before you write it anywhere, so a wrong assumption
|
||||
costs a sentence instead of a tag.
|
||||
|
||||
**Don't recommend a bigger bump**, however many features or changed defaults the release
|
||||
carries — not even on the strength of the changelog's SemVer note. 0.10.0 went out that way
|
||||
on a recommendation, and JP would have preferred 0.9.1. A minor or major bump happens only
|
||||
when he names it.
|
||||
|
||||
## 3. Write the entry
|
||||
|
||||
This is the work. Read the commits since the last tag (`git log --stat v<last>..HEAD`) and
|
||||
write **what changed for someone using MeshTerm**, in the changelog's existing voice:
|
||||
|
||||
- **Categories are Keep a Changelog's** — `### Added`, `### Changed`, `### Fixed`,
|
||||
`### Removed` — in that order, and only the ones that have entries.
|
||||
- **A bullet opens with a bolded sentence stating the user-visible fact**, then a paragraph
|
||||
saying what was actually wrong and what it now does. Read the `0.2.6` and `0.2.8` entries
|
||||
for the register: the failure is described from the reader's side first, the mechanism
|
||||
second, and the lesson last if there is one.
|
||||
- **Small entries are one line and no bold.** Not every commit earns a paragraph, and
|
||||
several commits often collapse into one bullet — group by what the reader experienced,
|
||||
not by what the diff touched.
|
||||
- **A commit that changes nothing a user can see does not appear.** Refactors that move
|
||||
code behind an unchanged surface, test-only commits, skill and tooling commits: leave
|
||||
them out. The changelog is not a shadow git log.
|
||||
- Wrap at the file's width (~95 columns) and match its em-dash-and-clause rhythm.
|
||||
|
||||
Insert the section directly under the HTML comment at the top of `CHANGELOG.md`:
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
## [X.Y.Z] — YYYY-MM-DD
|
||||
```
|
||||
|
||||
A fresh empty `[Unreleased]` heading goes back in above it — that is what makes the *next*
|
||||
release a rename. Then add the link refs at the bottom, repointing `[Unreleased]` at the
|
||||
new tag:
|
||||
|
||||
```
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/vX.Y.Z...HEAD
|
||||
[X.Y.Z]: https://github.com/jpmartineau/MeshTerm/releases/tag/vX.Y.Z
|
||||
```
|
||||
|
||||
The date is the real date — check it rather than copying the one above.
|
||||
|
||||
## 4. Bump the version
|
||||
|
||||
`__version__` in [meshterm/__init__.py](../../../meshterm/__init__.py) is **the only place a
|
||||
version is written**. `[project] version` in `pyproject.toml` is `dynamic` and hatchling
|
||||
reads the attribute, so the number in a built wheel cannot drift from the number in the
|
||||
source. Do not add a second one.
|
||||
|
||||
### The README needs nothing — deliberately
|
||||
|
||||
It used to. `installers.yml` names each asset `meshterm-${version}-<label><ext>`, so every
|
||||
download command in the README named a version, and every release meant grepping the old
|
||||
number out of five of them. That step failed quietly when it was missed: a README naming
|
||||
last release's file still reads perfectly well, and only breaks for the one person who
|
||||
types it.
|
||||
|
||||
`github-release.yml` now attaches a **version-less copy of every binary** beside the
|
||||
stamped one, so `releases/latest/download/meshterm-macos-arm64` is a URL that never moves —
|
||||
which is what the README and `docs/uconsole.md` use. Nothing in either names a version.
|
||||
Leave them alone, and if you find yourself adding a version to an install command, add an
|
||||
alias to the gather step instead.
|
||||
|
||||
What *does* still carry the number: `docs/cli.md` has a sample document with a `"version"`
|
||||
field, which is the live `{version}` placeholder and must show the new one.
|
||||
|
||||
## 5. Commit, tag, push
|
||||
|
||||
```
|
||||
git commit -a -m "MeshTerm X.Y.Z" -m "Co-Authored-By: <the model running this skill> <noreply@anthropic.com>"
|
||||
git tag -a vX.Y.Z -m "MeshTerm X.Y.Z"
|
||||
```
|
||||
|
||||
The trailer is not optional: the changelog entry in this commit is prose you wrote, and
|
||||
CONTRIBUTING.md asks every contributor to say when an AI tool wrote part of a change —
|
||||
the project's own commits say it first. Name the model actually running, not a
|
||||
hardcoded one. Seven release commits (0.3.1 through 0.10.0) went out without it because
|
||||
this line used to omit it.
|
||||
|
||||
Commit on `main` — never a topic branch. Then **confirm with JP before pushing**, because
|
||||
the push is the act that builds and publishes the release:
|
||||
|
||||
```
|
||||
git push origin main && git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## 6. Watch it land
|
||||
|
||||
```
|
||||
gh run watch --exit-status
|
||||
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,
|
||||
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.
|
||||
|
||||
The notes open with an install block the workflow writes from the tag — the same commands
|
||||
as the README, but pinned to *this* release's own assets — and the changelog follows under
|
||||
`## What changed`. **Check that second half actually came out of the changelog**, because a
|
||||
green run is not proof: the fallback is a successful step.
|
||||
|
||||
```
|
||||
gh release view vX.Y.Z --json body --jq .body | sed -n '/## What changed/,$p' | head -5
|
||||
```
|
||||
|
||||
If that reads "No changelog entry for X.Y.Z.", the extraction failed rather than the
|
||||
changelog being empty. Fix the workflow, then repair the published notes in place with
|
||||
`gh release edit vX.Y.Z --notes-file` — the binaries are fine and the tag does not move.
|
||||
|
||||
Fourteen assets is the right count — five version-stamped binaries, five version-less
|
||||
copies of them, `SHA256SUMS`, and the three licence files:
|
||||
|
||||
```
|
||||
gh release view vX.Y.Z --json assets --jq '.assets | length'
|
||||
```
|
||||
|
||||
If a *build* fails, the tag is already public: fix forward with a new patch version rather
|
||||
than deleting and re-pushing a tag people may have fetched.
|
||||
|
||||
## 7. The Discord announcement — for JP to post
|
||||
|
||||
Once the release has landed and checked out, end by showing JP a short announcement for
|
||||
the Discord server's `#announcements` channel, in a code block he can copy as-is. **He
|
||||
posts it himself** — never post it, never hand it to a bot. Write it for the people on
|
||||
the server, in plain words (they use MeshTerm; they don't read the diff):
|
||||
|
||||
- one bolded opening line naming the version and what it is, in a few words;
|
||||
- two to five short bullets of what changed *for them*, from the changelog entry —
|
||||
the headline items only, not every line;
|
||||
- the release link, and one line on how to get it (the download page, or the README's
|
||||
install line for someone who installed that way);
|
||||
- Discord markdown only (`**bold**`, `- ` bullets, bare links); no headings, no tables,
|
||||
no emoji beyond one at the start if it fits. Well under Discord's 2000 characters —
|
||||
it should fit on a phone screen.
|
||||
|
||||
```
|
||||
**MeshTerm 0.10.1 is out**: three small command-line fixes.
|
||||
|
||||
- `records --width` now tells you when a width doesn't exist, instead of looking empty
|
||||
- The simulator's row in `devices` lost a stray `()`
|
||||
- `platform --help` and `specimen --help` read like the rest
|
||||
|
||||
Download: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.1
|
||||
```
|
||||
|
||||
Say who it was written for, then show it last in the reply, so it's the easiest thing to
|
||||
copy.
|
||||
|
||||
## At 0.9.0 the changelog resets
|
||||
|
||||
The first public release collapses everything below it into a single entry reading "first
|
||||
public release" with the headline features under it. Every version under 0.9.0 was released
|
||||
nowhere, and its entries are notes to ourselves about getting ready — nobody arriving on
|
||||
launch day wants a changelog of the fortnight before it. **Keep the dates and the tags;
|
||||
replace the prose.** The same note is an HTML comment at the top of `CHANGELOG.md`, where
|
||||
whoever cuts it will be looking.
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/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
|
||||
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`.
|
||||
|
||||
It changes nothing. Every finding is a line a person acts on, not a gate that blocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
INIT = ROOT / "meshterm" / "__init__.py"
|
||||
CHANGELOG = ROOT / "CHANGELOG.md"
|
||||
|
||||
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)
|
||||
return done.stdout.strip() if done.returncode == 0 else ""
|
||||
|
||||
|
||||
def say(mark: str, line: str) -> None:
|
||||
"""Print one finding, indented under its heading; a blank mark is a plain note."""
|
||||
print(f" {mark} {line}" if mark.strip() else f" {line}")
|
||||
|
||||
|
||||
def current_version() -> str:
|
||||
"""The version `meshterm/__init__.py` declares - the only place one is written."""
|
||||
found = re.search(r'^__version__\s*=\s*"([^"]+)"', INIT.read_text(encoding="utf-8"), re.M)
|
||||
return found.group(1) if found else "?"
|
||||
|
||||
|
||||
def latest_tag() -> str:
|
||||
"""The highest v-tag by version order, not by date — a re-tag must not reorder this."""
|
||||
tags = [t for t in git("tag", "-l", "v*").splitlines() if t]
|
||||
if not tags:
|
||||
return ""
|
||||
return sorted(tags, key=lambda t: [int(p) for p in re.findall(r"\d+", t)])[-1]
|
||||
|
||||
|
||||
def next_patch(version: str) -> str:
|
||||
"""The default bump. Any other part is JP's call, and he says which."""
|
||||
parts = version.split(".")
|
||||
if len(parts) != 3 or not parts[2].isdigit():
|
||||
return "?"
|
||||
return f"{parts[0]}.{parts[1]}.{int(parts[2]) + 1}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Print the report. Nothing here blocks; every line is for a person to act on."""
|
||||
version, tag = current_version(), latest_tag()
|
||||
text = CHANGELOG.read_text(encoding="utf-8")
|
||||
|
||||
print(f"\nMeshTerm {version} | last tag {tag or '(none)'}\n")
|
||||
|
||||
print("Version")
|
||||
if tag and tag != f"v{version}":
|
||||
say(WARN, f"__version__ is {version} but the last tag is {tag} - already bumped?")
|
||||
else:
|
||||
say(OK, f"__version__ {version} matches the last tag")
|
||||
say(INFO, f"next patch would be {next_patch(version)} (JP names anything else)")
|
||||
|
||||
print("\nCommits since the tag")
|
||||
log = git("log", "--oneline", f"{tag}..HEAD") if tag else git("log", "--oneline")
|
||||
lines = [ln for ln in log.splitlines() if ln]
|
||||
if not lines:
|
||||
say(WARN, "none - there is nothing to release")
|
||||
else:
|
||||
say(INFO, f"{len(lines)} commit(s):")
|
||||
for ln in lines:
|
||||
print(f" {ln}")
|
||||
|
||||
print("\nChangelog")
|
||||
if re.search(r"^## \[Unreleased\]", text, re.M):
|
||||
body = text.split("## [Unreleased]", 1)[1].split("\n## [", 1)[0].strip()
|
||||
if body:
|
||||
say(OK, "an [Unreleased] section with entries - rename it to the new version")
|
||||
else:
|
||||
say(WARN, "an [Unreleased] heading with nothing under it - the entry is unwritten")
|
||||
else:
|
||||
say(WARN, "no [Unreleased] heading - write the section from the commits above")
|
||||
|
||||
versions = set(re.findall(r"^## \[(\d[^\]]*)\]", text, re.M))
|
||||
refs = set(re.findall(r"^\[(\d[^\]]*)\]:", text, re.M))
|
||||
missing = sorted(versions - refs, key=lambda v: [int(p) for p in re.findall(r"\d+", v)])
|
||||
if missing:
|
||||
# Cosmetic only: the heading renders as literal brackets instead of a link. The
|
||||
# release notes come from the section body, so a published release is unaffected.
|
||||
say(WARN, f"link refs missing at the bottom for: {', '.join(missing)}")
|
||||
say(INFO, "(cosmetic - the heading loses its link; release notes are unaffected)")
|
||||
else:
|
||||
say(OK, "every version heading has its link ref")
|
||||
|
||||
print("\nTree")
|
||||
dirty = git("status", "--porcelain")
|
||||
say(
|
||||
OK if not dirty else WARN,
|
||||
"clean" if not dirty else f"{len(dirty.splitlines())} uncommitted change(s)",
|
||||
)
|
||||
branch = git("rev-parse", "--abbrev-ref", "HEAD")
|
||||
say(OK if branch == "main" else WARN, f"on {branch}")
|
||||
git("fetch", "--quiet", "origin")
|
||||
counts = git("rev-list", "--left-right", "--count", "origin/main...HEAD")
|
||||
if counts:
|
||||
behind, ahead = counts.split()
|
||||
say(OK if behind == "0" else WARN, f"{ahead} ahead / {behind} behind origin/main")
|
||||
|
||||
print("\nRun the gates before tagging:")
|
||||
print(" python -m pytest -q && ruff check . && ruff format --check .\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+7
-2
@@ -1,6 +1,11 @@
|
||||
# Kept in step with meshterm/assets/pages/support.md deliberately. If the app offers a
|
||||
# way to chip in and this file doesn't, the two have drifted and one of them is lying.
|
||||
buy_me_a_coffee: johnputer
|
||||
patreon: MeshTerm
|
||||
# PayPal is not one of GitHub's built-in platforms, so it is a custom link, written
|
||||
# without a scheme so the sidebar shows it as cleanly as it can. It stays the donate
|
||||
# button, not a paypal.me link: a payment through the button is recorded as a donation,
|
||||
# which PayPal's purchase protection does not cover, so a donor cannot claim a refund
|
||||
# for an item that never arrived.
|
||||
custom:
|
||||
- "https://www.patreon.com/c/MeshTerm"
|
||||
- "https://tinyurl.com/SupportMeshTerm"
|
||||
- "paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6"
|
||||
+66
-106
@@ -7,12 +7,22 @@ body:
|
||||
value: |
|
||||
Hopefully you never need this form.
|
||||
|
||||
If you do: how much of this you fill in really does decide what happens next. A
|
||||
report I can act on gets worked on. A vague one usually doesn't, because I can't
|
||||
do anything with it. If it isn't worth describing properly, it's probably not
|
||||
worth fixing.
|
||||
If you do: start by running this, and attach the file it writes.
|
||||
|
||||
Most of these take ten seconds each.
|
||||
```
|
||||
meshterm diagnostics --out bug.md
|
||||
```
|
||||
|
||||
It answers which MeshTerm, on which machine, in which terminal, talking to which
|
||||
radio over which transport and firmware — most of what I would otherwise ask one
|
||||
question at a time, and the details people most often end up guessing at. Nothing
|
||||
in it is private: no keys, no pairing PINs, no positions, no contacts. That is a
|
||||
tested property, not a promise.
|
||||
|
||||
The **Diagnostics** page in the menu saves the same file (with `s`, or F3 on the
|
||||
PicoCalc), next to the log.
|
||||
|
||||
The rest of this form asks only for what that file cannot know.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
@@ -49,118 +59,62 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: workaround
|
||||
attributes:
|
||||
label: Is there a way around it
|
||||
description: Anything that avoids it, or gets you unstuck. Say so even if it's ugly.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: MeshTerm version
|
||||
description: It's on the splash screen when you start, and on the About page.
|
||||
placeholder: "0.2.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: hardware
|
||||
attributes:
|
||||
label: What are you running MeshTerm on
|
||||
description: >
|
||||
Desktop, laptop, Mac, or a handheld — and if it's a handheld, which one.
|
||||
placeholder: "ClockworkPi uConsole (CM4), or: desktop PC"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Which OS, exact version
|
||||
placeholder: "Windows 11 24H2 / macOS 15.3 / Debian 12"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: terminal
|
||||
attributes:
|
||||
label: Which terminal, and how big
|
||||
description: >
|
||||
PowerShell, Windows Terminal, iTerm2, the PicoCalc console, ssh from something.
|
||||
Size in columns x rows if you know it — a lot of layout bugs are really terminal
|
||||
bugs.
|
||||
placeholder: "Windows Terminal, 120x40"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: companion
|
||||
attributes:
|
||||
label: Which companion device
|
||||
description: >
|
||||
Be precise. There are a lot of these and they don't behave the same. "A Xiao"
|
||||
doesn't narrow it down — "Seeed XIAO nRF52840 + Wio-SX1262" does. Leave blank if
|
||||
no radio was involved.
|
||||
placeholder: "Heltec V3 / RAK4631 / XIAO nRF52840 + Wio-SX1262"
|
||||
|
||||
- type: input
|
||||
id: firmware
|
||||
attributes:
|
||||
label: Which firmware version is on it
|
||||
placeholder: "MeshCore 1.15.0"
|
||||
|
||||
- type: dropdown
|
||||
id: transport
|
||||
attributes:
|
||||
label: How is it connected
|
||||
options:
|
||||
- USB / serial
|
||||
- Bluetooth
|
||||
- TCP
|
||||
- SPI-to-TCP bridge
|
||||
- Something else (say so below)
|
||||
- No radio — simulator / --mock
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: flavour
|
||||
attributes:
|
||||
label: Regular or PicoCalc
|
||||
description: >
|
||||
MeshTerm draws itself differently on a PicoCalc — narrower, fewer colours, a
|
||||
different font. A bug in one often doesn't exist in the other, so this saves a
|
||||
round of guessing.
|
||||
options:
|
||||
- Regular (desktop, laptop, ssh)
|
||||
- PicoCalc handheld
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: mock
|
||||
attributes:
|
||||
label: Does it still happen with `meshterm --mock`
|
||||
description: >
|
||||
`--mock` runs the whole app against a fake radio. If the bug survives that, it's
|
||||
in MeshTerm and I can chase it without hardware. If it doesn't, it's in the link
|
||||
or the radio, which is a different hunt. Skip it if the bug is about connecting
|
||||
in the first place.
|
||||
`--mock` runs the whole app against a fake radio. If the bug survives that, it is
|
||||
in MeshTerm and I can chase it without owning your hardware. If it doesn't, it is
|
||||
in the link or the radio, which is a different hunt. This one question decides
|
||||
which of those two jobs this is, so it is worth the minute it takes.
|
||||
options:
|
||||
- "Yes, still happens with --mock"
|
||||
- "No, --mock is fine"
|
||||
- "Haven't tried"
|
||||
- "Doesn't apply"
|
||||
- "Doesn't apply — the problem is connecting"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: diagnostics
|
||||
attributes:
|
||||
label: The diagnostics block
|
||||
description: >
|
||||
Attach `bug.md` by dragging it in, or paste its contents here. If you genuinely
|
||||
cannot produce one, because MeshTerm will not start at all, write down why instead,
|
||||
along with your version, and I will work with that.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: handheld
|
||||
attributes:
|
||||
label: Which handheld, if it is a handheld
|
||||
description: >
|
||||
Only if you are running on something the diagnostics file cannot name. It knows a
|
||||
PicoCalc from a regular terminal, but a uConsole and a laptop look much the same to
|
||||
it. Leave blank on a desktop, laptop, or Mac.
|
||||
placeholder: "ClockworkPi uConsole (CM4)"
|
||||
|
||||
- type: input
|
||||
id: radio
|
||||
attributes:
|
||||
label: Which radio and firmware, if MeshTerm could not reach it
|
||||
description: >
|
||||
The diagnostics file reads the model and firmware off the radio itself, so when the
|
||||
problem *is* connecting, it has nothing to read. That is the one case where I need
|
||||
you to tell me. Be precise — "a Xiao" covers several boards that do not behave
|
||||
alike.
|
||||
placeholder: "Seeed XIAO nRF52840 + Wio-SX1262, MeshCore 1.15.0"
|
||||
|
||||
- type: textarea
|
||||
id: error
|
||||
attributes:
|
||||
label: The exact error, if there was one
|
||||
description: >
|
||||
Copy it as text rather than photographing the screen — text can be searched, and
|
||||
a photo of a terminal is hard to read. Paste the whole traceback if it crashed.
|
||||
Copy it as text rather than photographing the screen — text can be searched, and a
|
||||
photo of a terminal is hard to read. Paste the whole traceback if it crashed.
|
||||
render: text
|
||||
|
||||
- type: textarea
|
||||
@@ -169,10 +123,16 @@ body:
|
||||
label: The log file
|
||||
description: >
|
||||
MeshTerm keeps one at `~/.meshterm/meshterm.log` (or wherever `MESHTERM_HOME`
|
||||
points). Attach it, or paste the last twenty lines. If the problem is hard to
|
||||
catch, set **Log detail** to `DEBUG` on the Preferences page, do it once more,
|
||||
and send that instead — it records a great deal more.
|
||||
render: text
|
||||
points). Attach the file, or paste the last twenty lines inside a ``` fence. If the
|
||||
problem is hard to catch, set **Log detail** to Debug on the Preferences page,
|
||||
restart MeshTerm, do it once more, and send that instead. It records a great deal
|
||||
more.
|
||||
|
||||
- type: textarea
|
||||
id: workaround
|
||||
attributes:
|
||||
label: Is there a way around it
|
||||
description: Anything that avoids it, or gets you unstuck. Say so even if it's ugly.
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
|
||||
@@ -4,10 +4,12 @@ contact_links:
|
||||
url: https://discord.gg/AZwe5Uvb3S
|
||||
about: >
|
||||
Setup trouble, "is it supposed to do this", or anything you want a second opinion
|
||||
on. The support forum is the place. Someone running your radio has probably hit it.
|
||||
on. The #support forum is the place. Someone running your radio has probably hit it.
|
||||
|
||||
- name: No GitHub account? Report it on Discord instead
|
||||
- name: "No GitHub account? Report it in the #bugs forum instead"
|
||||
url: https://discord.gg/AZwe5Uvb3S
|
||||
about: >
|
||||
Drop reproducible bugs in #bugs and they'll get moved here. Being told beats
|
||||
One post per bug, tagged, with the diagnostics file attached. Real ones get opened
|
||||
here and your post is renamed with the issue number, so you can follow it without an
|
||||
account — and something comes back to tell you when it ships. Being told beats
|
||||
filing it tidily.
|
||||
@@ -2,15 +2,29 @@
|
||||
|
||||
<!-- The why matters more than the what — the diff already says the what. -->
|
||||
|
||||
## Related issue
|
||||
## Discord
|
||||
|
||||
<!-- CONTRIBUTING asks for an issue first on anything non-trivial, so a large PR with no
|
||||
issue behind it will get a conversation before it gets a review. Link it here. -->
|
||||
<!-- Your Discord username on the MeshTerm server, and a link to the message in
|
||||
#contributing where you got a yes. A pull request without them is closed without
|
||||
a review. -->
|
||||
|
||||
Username:
|
||||
Link to the yes:
|
||||
|
||||
## AI use
|
||||
|
||||
<!-- Required. Did an AI tool write any part of this change? If yes: which tool, what it
|
||||
did, and what you checked yourself. Each commit it helped with also needs a line at
|
||||
the end naming it, like `Co-Authored-By:` or `Assisted-by:`. If you used AI and don't
|
||||
say so, this pull request is treated as a bot's and closed. -->
|
||||
|
||||
## Before you mark it ready
|
||||
|
||||
- [ ] I've read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) and agree its licensing terms apply to this contribution
|
||||
- [ ] I've put my Discord username and a link to the yes above
|
||||
- [ ] If I used AI, I said so above and in the commits it helped with
|
||||
- [ ] `python -m pytest -q` passes, including the dual-platform gallery gate
|
||||
- [ ] `ruff check .` and `ruff format --check .` are clean
|
||||
- [ ] New screens, rows and hints go through the helpers in `ui/menus.py`, and follow the
|
||||
- [ ] New screens, rows, and hints go through the helpers in `ui/menus.py`, and follow the
|
||||
lexicon and UX standards in `CLAUDE.md`
|
||||
- [ ] Anything user-visible has a line in `CHANGELOG.md` under `[Unreleased]`
|
||||
+16
-13
@@ -3,7 +3,7 @@
|
||||
# Split in two on purpose. Style is a property of the source, not of the machine, so
|
||||
# `lint` runs once and its failure is unambiguous. Behaviour is a property of the
|
||||
# machine, so `test` runs everywhere we claim to work — the description promises
|
||||
# Windows, macOS and Linux, and until this file existed that promise had only ever been
|
||||
# Windows, macOS, and Linux, and until this file existed that promise had only ever been
|
||||
# checked on one of them.
|
||||
name: CI
|
||||
|
||||
@@ -18,7 +18,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
# A second push while the first is still running makes the first answer obsolete.
|
||||
# Cancel it — on a private repo those are metered minutes spent on a stale commit.
|
||||
# Cancel it — a run on a stale commit is a queue slot and a wait spent on nothing.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.14"
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
|
||||
@@ -54,21 +54,24 @@ jobs:
|
||||
# and one red leg cancelling its siblings hides the rest until the next push.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Shaped for a *private* repo, where minutes are metered and billed by a
|
||||
# multiplier: Linux 1x, Windows 2x, macOS 10x. So Linux carries the full
|
||||
# Python range and the costly runners cover the ends of it, which is where a
|
||||
# version-dependent break actually shows up.
|
||||
# Linux carries the full Python range and the slower-to-schedule runners
|
||||
# cover the ends of it, which is where a version-dependent break actually
|
||||
# shows up. It was shaped this way when the repository was private and
|
||||
# minutes were metered; on a public one they are not, so this can widen to
|
||||
# every OS times every version whenever the wait for a full run is worth it.
|
||||
#
|
||||
# Once the repo is public, standard runners are unmetered: widen this to every
|
||||
# OS times every version and stop thinking about it.
|
||||
# 3.10 stays on Ubuntu 24.04. It reaches end of life in October 2026, and a
|
||||
# newer image that ubuntu-latest moves to may not offer it at all. That would
|
||||
# turn the badge red with nothing broken. Drop this leg when the floor rises.
|
||||
include:
|
||||
- { os: ubuntu-latest, python: "3.10" }
|
||||
- { os: ubuntu-24.04, python: "3.10" }
|
||||
- { os: ubuntu-latest, python: "3.11" }
|
||||
- { os: ubuntu-latest, python: "3.12" }
|
||||
- { os: ubuntu-latest, python: "3.13" }
|
||||
- { os: ubuntu-latest, python: "3.14" }
|
||||
- { os: windows-latest, python: "3.10" }
|
||||
- { os: windows-latest, python: "3.13" }
|
||||
- { os: macos-latest, python: "3.13" }
|
||||
- { os: windows-latest, python: "3.14" }
|
||||
- { os: macos-latest, python: "3.14" }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -99,7 +102,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.14"
|
||||
|
||||
- run: pip install build twine
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Puts the downloadable builds on the repository's Releases page.
|
||||
#
|
||||
# Nothing here leaves GitHub. While the repository is private the release is private too,
|
||||
# visible and downloadable only by people who can see the repo. This is *not* the PyPI
|
||||
# publish — that is `release.yml`, it is a separate act, and it does not run from here.
|
||||
# Nothing here leaves GitHub: a release is exactly as visible as the repository it sits
|
||||
# on. This is *not* the PyPI publish — that is `release.yml`, it is a separate act, and it
|
||||
# does not run from here.
|
||||
#
|
||||
# Triggered by pushing a version tag, which is the moment a release exists in git anyway:
|
||||
#
|
||||
@@ -11,6 +11,11 @@
|
||||
#
|
||||
# The binaries are built by `installers.yml`, called rather than copied, so the files
|
||||
# people download are made exactly the way the ones CI checks on every push are.
|
||||
#
|
||||
# Once the release exists, `website` puts it on meshterm.net: it rebuilds that site from
|
||||
# the tagged tree — the landing and About pages carry the version — and pushes the result
|
||||
# to jpmartineau/meshterm.net, which GitHub Pages serves. It writes with a deploy key
|
||||
# scoped to that one repository (secret SITE_DEPLOY_KEY); that repo's README makes one.
|
||||
name: GitHub release
|
||||
|
||||
on:
|
||||
@@ -50,6 +55,10 @@ jobs:
|
||||
# Every platform's artifact, flattened into one directory of files to attach.
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
# The build uploads one artifact nobody should ever download: the unsigned
|
||||
# Windows binary it hands to SignPath. Everything meant for the release page is
|
||||
# named `meshterm-…`, so the pattern is the filter.
|
||||
pattern: meshterm-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
@@ -57,14 +66,45 @@ jobs:
|
||||
id: gather
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ inputs.tag || github.ref_name }}"
|
||||
version="${tag#v}"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
mkdir -p release
|
||||
find artifacts -type f -exec mv {} release/ \;
|
||||
ls -lh release/
|
||||
# One combined checksum file is easier to verify against than five loose ones.
|
||||
(cd release && cat *.sha256 > SHA256SUMS && rm -f *.sha256)
|
||||
tag="${{ inputs.tag || github.ref_name }}"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
|
||||
cd release
|
||||
|
||||
# A second, version-less copy of every binary. GitHub's evergreen download URL,
|
||||
# `releases/latest/download/<name>`, resolves against an exact asset name and
|
||||
# nothing else — so a link that always fetches the newest build needs a name
|
||||
# that never moves. That is what lets the README, the website and anyone's
|
||||
# install script say `meshterm-macos-arm64` once instead of naming a version
|
||||
# that goes stale the moment the next release lands. The version-stamped file
|
||||
# stays beside it: it is the one you want on disk when you keep several builds
|
||||
# around, and it is what the release-notes install block points at, since that
|
||||
# page is about this release rather than the latest one.
|
||||
made=0
|
||||
for versioned in "meshterm-$version"-*; do
|
||||
case "$versioned" in *.sha256) continue ;; esac
|
||||
[ -e "$versioned" ] || continue
|
||||
stable="meshterm-${versioned#meshterm-$version-}"
|
||||
cp "$versioned" "$stable"
|
||||
sha256sum "$stable" > "$stable.sha256"
|
||||
made=$((made + 1))
|
||||
done
|
||||
# Nothing matched means the tag and `meshterm.__version__` disagree, which is a
|
||||
# broken release either way: the notes below are about to name files by version
|
||||
# that the build did not produce. Fail here, where it is one line to read.
|
||||
if [ "$made" -eq 0 ]; then
|
||||
echo "::error::No binary named meshterm-$version-*; $tag and meshterm.__version__ disagree."
|
||||
exit 1
|
||||
fi
|
||||
echo "$made version-less aliases made"
|
||||
|
||||
# One combined checksum file is easier to verify against than ten loose ones.
|
||||
cat *.sha256 > SHA256SUMS && rm -f *.sha256
|
||||
|
||||
- name: Take the release notes from the changelog
|
||||
id: notes
|
||||
@@ -74,21 +114,102 @@ jobs:
|
||||
run: |
|
||||
# The section for this version, between its own heading and the next one. If the
|
||||
# changelog has nothing for it, say so plainly rather than inventing notes.
|
||||
if awk -v v="$VERSION" '
|
||||
$0 ~ "^## \\[" v "\\]" {found=1; next}
|
||||
found && /^## \[/ {exit}
|
||||
found {print}
|
||||
' CHANGELOG.md | sed '/^$/d' | head -1 | grep -q .; then
|
||||
awk -v v="$VERSION" '
|
||||
$0 ~ "^## \\[" v "\\]" {found=1; next}
|
||||
found && /^## \[/ {exit}
|
||||
found {print}
|
||||
' CHANGELOG.md > notes.md
|
||||
else
|
||||
#
|
||||
# Extracted once, into the file, and the *file* is what gets tested. It used to
|
||||
# run twice — once down a `| sed | head -1 | grep -q .` pipeline to decide, then
|
||||
# again for real — and this step runs under `bash -e -o pipefail`, where that
|
||||
# pipeline is a race it can lose. `head -1` exits the moment it has its line and
|
||||
# closes the pipe; if `sed` still has bytes to write it dies on SIGPIPE, pipefail
|
||||
# promotes that to a failed condition, and a version with a perfectly good
|
||||
# changelog section publishes "No changelog entry" instead. It only bites once a
|
||||
# section outgrows one buffered write, which is why every entry under 4 KB was
|
||||
# fine and 0.3.1 was not — while 0.3.0, the same size, happened to win.
|
||||
awk -v v="$VERSION" '
|
||||
$0 ~ "^## \\[" v "\\]" {found=1; next}
|
||||
found && /^## \[/ {exit}
|
||||
found && /^\[[^]]+\]: / {next}
|
||||
found {print}
|
||||
' CHANGELOG.md > notes.md
|
||||
if ! grep -q '[^[:space:]]' notes.md; then
|
||||
echo "No changelog entry for $VERSION." > notes.md
|
||||
fi
|
||||
echo "--- notes ---"; cat notes.md
|
||||
|
||||
# Most people who open a release page came to install the thing, so the commands go
|
||||
# above the changelog rather than in a link to a README that names some other
|
||||
# version. Written here rather than kept as prose anywhere means they can only ever
|
||||
# name *this* release: the tag interpolates, so the URLs are the assets attached to
|
||||
# the very page they are printed on, and there is no example version to go stale.
|
||||
#
|
||||
# The heredoc is quoted, so the markdown's own backticks stay backticks instead of
|
||||
# becoming command substitution, and the two placeholders are filled by `sed`
|
||||
# afterwards. Neither a tag nor a version can contain a slash, so the substitution
|
||||
# needs no escaping.
|
||||
- name: Open the notes with this release's install commands
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ steps.gather.outputs.tag }}
|
||||
VERSION: ${{ steps.gather.outputs.version }}
|
||||
run: |
|
||||
cat > install.md.in <<'INSTALL_MD'
|
||||
## Install
|
||||
|
||||
One file, no Python needed.
|
||||
|
||||
**macOS** — paste these into Terminal. Use `curl`, **not your browser**: a browser
|
||||
flags the download and macOS then refuses to open it at all, and on Sequoia and
|
||||
later right-click → Open no longer gets you past it. `curl` flags nothing, so it
|
||||
simply runs.
|
||||
|
||||
```bash
|
||||
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/download/@TAG@/meshterm-@VERSION@-macos-arm64
|
||||
chmod +x meshterm
|
||||
./meshterm
|
||||
```
|
||||
|
||||
That is the build for Apple silicon Macs (M1 and later). On an Intel Mac, swap
|
||||
`arm64` for `x64`.
|
||||
|
||||
**Linux** — the same three lines with `linux-x64`, or `linux-arm64` on a uConsole
|
||||
or another 64-bit ARM handheld.
|
||||
|
||||
```bash
|
||||
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/download/@TAG@/meshterm-@VERSION@-linux-x64
|
||||
chmod +x meshterm
|
||||
./meshterm
|
||||
```
|
||||
|
||||
**Windows** — download [meshterm-@VERSION@-windows-x64.exe](https://github.com/jpmartineau/MeshTerm/releases/download/@TAG@/meshterm-@VERSION@-windows-x64.exe), then run it from Windows
|
||||
Terminal or PowerShell rather than by double-clicking, so you can read anything it
|
||||
says on the way up.
|
||||
|
||||
```powershell
|
||||
.\meshterm-@VERSION@-windows-x64.exe
|
||||
```
|
||||
|
||||
Every binary below is listed in `SHA256SUMS`. The version-less copies —
|
||||
`meshterm-macos-arm64` and its four siblings — are the same binaries under a name
|
||||
that never changes, for anything that wants whichever release is newest without
|
||||
being told its number. [Installing with pip or pipx, and the rest of the
|
||||
detail](https://github.com/jpmartineau/MeshTerm#install).
|
||||
|
||||
---
|
||||
|
||||
## What changed
|
||||
INSTALL_MD
|
||||
sed -e "s/@TAG@/$TAG/g" -e "s/@VERSION@/$VERSION/g" install.md.in > install.md
|
||||
|
||||
# One ask, at the end, with the reason. The install commands keep the top of
|
||||
# the page, and the changelog stays a record of what changed.
|
||||
cat > star.md <<'STAR_MD'
|
||||
|
||||
---
|
||||
|
||||
If MeshTerm is useful to you, please give [the repository](https://github.com/jpmartineau/MeshTerm) a ⭐. It helps other MeshCore users find it, because GitHub ranks projects by stars in its search and topic pages.
|
||||
STAR_MD
|
||||
cat install.md notes.md star.md > body.md
|
||||
echo "--- body ---"; cat body.md
|
||||
|
||||
- name: Create it
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -97,7 +218,59 @@ jobs:
|
||||
run: |
|
||||
gh release create "$TAG" release/* \
|
||||
--title "MeshTerm ${TAG#v}" \
|
||||
--notes-file notes.md \
|
||||
--notes-file body.md \
|
||||
--verify-tag \
|
||||
$PRERELEASE
|
||||
echo "https://github.com/${{ github.repository }}/releases/tag/$TAG"
|
||||
|
||||
website:
|
||||
name: put the version on meshterm.net
|
||||
needs: publish
|
||||
# A pre-release stays out of "Latest", so it stays off the landing page too.
|
||||
if: ${{ !inputs.prerelease }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check for the deploy key
|
||||
env:
|
||||
SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }}
|
||||
run: |
|
||||
if [ -z "$SITE_DEPLOY_KEY" ]; then
|
||||
echo "::error::SITE_DEPLOY_KEY is not set; the meshterm.net README says how to make it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
path: meshterm
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: jpmartineau/meshterm.net
|
||||
ssh-key: ${{ secrets.SITE_DEPLOY_KEY }}
|
||||
path: site
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Rebuild the site from this release
|
||||
run: |
|
||||
python -m pip install --quiet -r site/requirements.txt
|
||||
python site/build.py --meshterm meshterm
|
||||
|
||||
- name: Publish it
|
||||
working-directory: site
|
||||
env:
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "meshterm.net already shows ${TAG#v}."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898341+github-actions[bot]@users.noreply.github.com"
|
||||
git commit -m "MeshTerm ${TAG#v}"
|
||||
git push
|
||||
@@ -14,12 +14,12 @@ on:
|
||||
# Callable, so the release workflow builds these binaries rather than keeping a second
|
||||
# copy of the matrix that would drift from this one.
|
||||
workflow_call:
|
||||
# Only when the packaging itself changes. Three operating systems is not something to
|
||||
# spend on every commit while the repository is private and minutes are metered.
|
||||
# Only when the packaging itself changes. A five-platform build takes the better part
|
||||
# of half an hour, and a commit that touched nothing it freezes has nothing to learn
|
||||
# from one; the release build is what proves the binaries.
|
||||
push:
|
||||
# `main` only. Without this, every Dependabot branch that touches this file — which
|
||||
# is every action bump — starts a five-platform build, and three of those legs are
|
||||
# billed at 2x and 10x.
|
||||
# is every action bump — starts a five-platform build of its own.
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packaging/**"
|
||||
@@ -50,18 +50,19 @@ jobs:
|
||||
# fail, it queues forever and holds the whole run open behind it.)
|
||||
- { os: macos-15-intel, label: macos-x64, ext: "" }
|
||||
- { os: ubuntu-22.04, label: linux-x64, ext: "" }
|
||||
# For the uConsole and other 64-bit ARM handhelds. ARM runners are free on
|
||||
# public repositories; on a private one this leg may sit unclaimed until the
|
||||
# repo is public. Drop the line if it blocks you.
|
||||
# For the uConsole and other 64-bit ARM handhelds. The ARM runners are the
|
||||
# public-repository ones; a private fork of this file may find the leg sits
|
||||
# unclaimed, and can drop the line.
|
||||
- { os: ubuntu-22.04-arm, label: linux-arm64, ext: "" }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
# Oldest supported version on purpose: a binary built against an older glibc
|
||||
# and an older Python runs on more machines than one built against the newest.
|
||||
python-version: "3.10"
|
||||
# The Python the project builds with, bundled into every binary. How widely a
|
||||
# Linux binary runs is set by the runner's glibc, which is why the Linux legs
|
||||
# stay on ubuntu-22.04 rather than ubuntu-latest.
|
||||
python-version: "3.14"
|
||||
|
||||
- run: pip install . pyinstaller
|
||||
|
||||
@@ -69,6 +70,37 @@ jobs:
|
||||
working-directory: packaging
|
||||
run: pyinstaller --clean --noconfirm meshterm.spec
|
||||
|
||||
# `packaging/meshterm.spec` regenerates `THIRD-PARTY-NOTICES.txt` fresh on every
|
||||
# leg's own build (see `packaging/notices.py`) and bundles it, along with LICENSE
|
||||
# and NOTICE, inside that leg's own installer -- so each downloaded binary always
|
||||
# carries an accurate copy of what it actually contains. This step is a separate
|
||||
# convenience: the same three files land on the release page itself, next to the
|
||||
# downloads, so nobody has to extract an installer just to read a license. One
|
||||
# copy serves every platform there (LICENSE/NOTICE never vary, and the runtime
|
||||
# dependency closures differ only in a handful of platform-specific packages), so
|
||||
# it rides on this leg alone rather than being uploaded -- and attached to the
|
||||
# release -- five identical-in-substance times over. linux-x64 was picked
|
||||
# arbitrarily among the five; nothing here is Linux-specific.
|
||||
- name: Upload the licences for the release page
|
||||
if: matrix.label == 'linux-x64'
|
||||
shell: bash
|
||||
run: |
|
||||
# `build/<specname>/` is PyInstaller's own workpath layout (see the spec's
|
||||
# comment on `workpath`), not something worth this step knowing by heart
|
||||
# twice -- copy it flat, next to LICENSE and NOTICE, so the artifact (and,
|
||||
# via `find`, the release assets built from it) is three plain files.
|
||||
cp packaging/build/meshterm/THIRD-PARTY-NOTICES.txt .
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: matrix.label == 'linux-x64'
|
||||
with:
|
||||
name: meshterm-licenses
|
||||
path: |
|
||||
LICENSE
|
||||
NOTICE
|
||||
THIRD-PARTY-NOTICES.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Check it actually runs
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -87,6 +119,50 @@ jobs:
|
||||
"$GITHUB_WORKSPACE/$bin" contacts --help > /dev/null
|
||||
echo "runs, finds its assets, and its tools registered"
|
||||
|
||||
# The Windows binary is code-signed, so Windows has a publisher to recognise
|
||||
# instead of an unknown file it has never seen before — SmartScreen's warning eases
|
||||
# as that publisher's downloads accumulate, and antivirus engines, which flag
|
||||
# unsigned PyInstaller one-file builds on sight, are markedly less trigger-happy.
|
||||
# The certificate belongs to SignPath Foundation, who issue it free to open-source
|
||||
# projects and sign on their own infrastructure; nothing secret exists on this
|
||||
# runner, and there is no key here to leak. Its condition is that the artifact
|
||||
# provably came from this repository's CI, which is why the binary is handed over
|
||||
# as a GitHub artifact rather than uploaded: SignPath reads the workflow run behind
|
||||
# that artifact id and refuses anything it cannot trace to an approved branch.
|
||||
#
|
||||
# Signed between the build's own smoke test and the naming step, so what gets
|
||||
# verified is the build and what gets checksummed is the signed file — a checksum
|
||||
# taken before signing would describe a binary nobody ever downloads.
|
||||
#
|
||||
# Both legs are skipped wholesale until the account exists (the variable is unset
|
||||
# until then), so this file builds unsigned Windows binaries in the meantime rather
|
||||
# than failing, and a fork with no SignPath project of its own is unaffected.
|
||||
- name: Hand the Windows binary to SignPath
|
||||
id: unsigned
|
||||
if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != ''
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
# Deliberately not `meshterm-*`: the release workflow attaches every artifact
|
||||
# matching that prefix, and this one is the unsigned draft.
|
||||
name: unsigned-${{ matrix.label }}
|
||||
path: packaging/dist/meshterm.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Sign it
|
||||
if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != ''
|
||||
uses: signpath/github-action-submit-signing-request@v1
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: meshterm
|
||||
signing-policy-slug: release-signing
|
||||
artifact-configuration-slug: exe
|
||||
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
|
||||
# The signed file comes back where the unsigned one was built, under the same
|
||||
# name, so every step after this one is untouched by any of it.
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: packaging/dist
|
||||
|
||||
- name: Name it after the version and the platform
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
name: Issue trailers
|
||||
|
||||
# A commit that names an issue is the only thing connecting a fix to the person
|
||||
# who reported it. GitHub resolves that link silently: `Fixes: #42` against an
|
||||
# issue that does not exist is not an error, it is nothing at all -- the issue
|
||||
# stays open, the reporter is never told, and the commit looks like it did the
|
||||
# job. A typo costs a whole loop and announces itself nowhere.
|
||||
#
|
||||
# So this checks the trailers that ARE there rather than nagging about the ones
|
||||
# that are not. Most commits here are internal work that legitimately closes
|
||||
# nothing, and a workflow that complains about all of them gets muted in a week.
|
||||
#
|
||||
# Cross-repo trailers (`Fixes: owner/repo#12`) are reported but not verified:
|
||||
# GITHUB_TOKEN only reaches this repository.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write # to leave a comment on the offending commit
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
steps:
|
||||
- name: Verify every issue named in a trailer exists
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
COMMITS: ${{ toJSON(github.event.commits) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "$COMMITS" > /tmp/commits.json
|
||||
count=$(jq 'length' /tmp/commits.json)
|
||||
echo "checking $count commit(s)"
|
||||
|
||||
problems=0
|
||||
|
||||
for i in $(seq 0 $((count - 1))); do
|
||||
sha=$(jq -r ".[$i].id" /tmp/commits.json)
|
||||
msg=$(jq -r ".[$i].message" /tmp/commits.json)
|
||||
short="${sha:0:8}"
|
||||
|
||||
# Trailers only: a line that starts with the keyword. A bare "#12"
|
||||
# in prose is a reference someone wrote on purpose, not a claim
|
||||
# that this commit closes anything. The colon is optional, because
|
||||
# GitHub closes on "Fixes #12" just as it does on "Fixes: #12", and
|
||||
# the keywords are every form GitHub accepts, plus refs and see.
|
||||
refs=$(printf '%s\n' "$msg" \
|
||||
| grep -oiE '^(fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved|refs|see)(:[[:space:]]*|[[:space:]]+)([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)?#[0-9]+' \
|
||||
|| true)
|
||||
|
||||
[ -z "$refs" ] && continue
|
||||
|
||||
while IFS= read -r ref; do
|
||||
[ -z "$ref" ] && continue
|
||||
target=$(printf '%s' "$ref" | sed -E 's/^[A-Za-z]+:?[[:space:]]*//')
|
||||
num="${target##*#}"
|
||||
|
||||
if [ "$target" != "#${num}" ]; then
|
||||
repo="${target%%#*}"
|
||||
echo " $short -> $target (other repository, not verified here)"
|
||||
continue
|
||||
fi
|
||||
|
||||
if state=$(gh api "repos/$REPO/issues/$num" --jq .state 2>/tmp/gh.err); then
|
||||
echo " $short -> #$num ($state)"
|
||||
elif ! grep -q 'HTTP 404' /tmp/gh.err; then
|
||||
# Only a 404 means the issue is missing. Anything else (a rate
|
||||
# limit, an outage) says nothing about the trailer, and a public
|
||||
# comment calling a real issue missing would be worse than silence.
|
||||
echo "::warning::could not check #$num for commit $short: $(head -c 200 /tmp/gh.err)"
|
||||
else
|
||||
echo "::error::commit $short names #$num, which does not exist in $REPO"
|
||||
problems=$((problems + 1))
|
||||
gh api "repos/$REPO/commits/$sha/comments" \
|
||||
-f body="This commit's trailer names **#$num**, which does not exist in \`$REPO\`.
|
||||
|
||||
GitHub resolves a closing keyword silently, so nothing failed and nothing was linked — the issue this was meant to close is still open, and whoever reported it has not been told. Worth a follow-up commit with the right number, or a note on the issue itself." \
|
||||
>/dev/null
|
||||
fi
|
||||
done <<< "$refs"
|
||||
done
|
||||
|
||||
if [ "$problems" -gt 0 ]; then
|
||||
echo "::warning::$problems trailer(s) pointed at issues that do not exist"
|
||||
fi
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.14"
|
||||
|
||||
- run: pip install build twine
|
||||
|
||||
|
||||
+4
-3
@@ -12,6 +12,7 @@ dist/
|
||||
# planning that names hardware and unshipped ideas.
|
||||
!.claude/
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/plans/
|
||||
.claude/housekeeping.md
|
||||
# ...and except the GitHub configuration, which only works where GitHub looks for
|
||||
@@ -30,6 +31,6 @@ output/
|
||||
# addresses, logins, the local venv path). .dev.env.example is tracked and shows the shape.
|
||||
.dev.env
|
||||
|
||||
# Built radio firmware (scripts/xiao-radio/build-firmware.sh writes it here)
|
||||
scripts/xiao-radio/*.uf2
|
||||
scripts/xiao-radio/_meshcore-build/
|
||||
# Built radio firmware (scripts/picocalc/xiao-radio/build-firmware.sh writes it here)
|
||||
scripts/picocalc/xiao-radio/*.uf2
|
||||
scripts/picocalc/xiao-radio/_meshcore-build/
|
||||
+222
-235
@@ -2,272 +2,259 @@
|
||||
|
||||
Notable changes to MeshTerm, newest first.
|
||||
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the version
|
||||
numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with the caveat
|
||||
SemVer itself makes for a leading zero: while the major version is `0`, a **minor** bump is
|
||||
allowed to change behaviour, not just add to it.
|
||||
|
||||
<!--
|
||||
At 0.9.0 — the first public release — everything below collapses into a single entry
|
||||
reading "first public release", with the headline features under it. Nobody arriving at
|
||||
a project on its launch day wants a changelog of the fortnight before it: the versions
|
||||
under 0.9.0 were never released anywhere, and their entries are notes to ourselves about
|
||||
getting ready. Keep the dates and the tags; replace the prose.
|
||||
-->
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
|
||||
version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with
|
||||
one caveat SemVer makes for a leading zero: while the major version is still `0`, a
|
||||
**minor** bump is allowed to change how things behave, not just add to them.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.8] — 2026-09-07
|
||||
## [0.10.2] — 2026-09-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The window MeshTerm opened for itself was too small, and it showed.** The startup
|
||||
wordmark is 71 columns wide; below that the splash quietly swaps to the narrow one drawn
|
||||
for the PicoCalc — so a desktop session came up wearing the handheld's mark, with every
|
||||
menu description truncated onto the pager underneath it.
|
||||
|
||||
MeshTerm was opening a *tab* in whatever Windows Terminal window happened to be around,
|
||||
inheriting its size (64 columns on the machine this was found on). It now asks for a
|
||||
window of its own, sized from the platform's own minimum plus a margin. Windows Terminal
|
||||
clamps that to what the display can show, so a small screen degrades exactly as before.
|
||||
|
||||
- The tab it opens is titled **MeshTerm**, not the executable's full path — which on a
|
||||
downloaded build was a line of Downloads folder.
|
||||
|
||||
## [0.2.7] — 2026-09-07
|
||||
|
||||
### Changed
|
||||
|
||||
- **MeshTerm moves to Windows Terminal rather than asking to.** It still says so, and
|
||||
still tells you how to stop it, but it doesn't put the question. The classic console
|
||||
can't draw a single icon whatever font it's given, so there was only one sensible
|
||||
answer — and it was being asked of someone who hadn't seen the app yet and had nothing
|
||||
to judge it by.
|
||||
|
||||
Installing a **font** still asks, because that writes a file to your machine. Moving
|
||||
window doesn't. Preferences → Display → Console setup stops both.
|
||||
|
||||
## [0.2.6] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Accepting "reopen in Windows Terminal" killed the session** in the downloadable
|
||||
builds. A new window opened, printed `Security validation failure: parent process has
|
||||
different executable!`, and exited — while the window that made the offer had already
|
||||
closed. Worse than never offering.
|
||||
|
||||
A one-file build unpacks itself and re-runs itself, the two halves coordinating through
|
||||
private environment variables. Those were being handed to the new copy, which concluded
|
||||
it was the second half of a launch it never made, checked that its parent was the same
|
||||
program, found Windows Terminal instead, and stopped. They are stripped now.
|
||||
|
||||
It worked perfectly when run from source, which is the whole lesson: the packaged build
|
||||
is its own platform.
|
||||
|
||||
## [0.2.5] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Every icon was a box in the classic Windows console.** That console — which is what a
|
||||
double-clicked build still gets — draws no emoji at all, whatever font it is set to, so
|
||||
the menu came up as rows of replacement characters.
|
||||
|
||||
MeshTerm now works out which console it is running in and, on that one, draws its icons
|
||||
through the compact single-character table it already keeps for the PicoCalc: `☼` for an
|
||||
advert, `¶` for a message, `★` for the trophy case. Nothing else about the desktop
|
||||
layout changes — same width, same colours, same accented names. Running in Windows
|
||||
Terminal or VS Code's terminal keeps the emoji, and `MESHTERM_EMOJI=1` or `=0` forces
|
||||
the matter either way. `meshterm platform` reports which it chose and why.
|
||||
**Harder to leave by accident, a scope picker on the dashboard and the Time Machine, and
|
||||
T-Deck Bluetooth pairing.** Esc no longer walks you into the quit dialog, ^Q asks before it
|
||||
quits, both traffic screens can narrow to one region, and a T-Deck on MeshOS or wadamesh
|
||||
pairs like any other companion.
|
||||
|
||||
### Added
|
||||
|
||||
- **MeshTerm offers to reopen itself in Windows Terminal.** The classic Windows console —
|
||||
what you get from a double-click — can't show the emoji icons at all, whatever font it
|
||||
is given: the only two Windows fonts with emoji in them are proportional, and a console
|
||||
won't take one. A terminal is the only thing that fixes it, so MeshTerm now offers to
|
||||
move there. One keypress, nothing installed, nothing changed — and you get the app as
|
||||
it was drawn: icons, charts, path chips and all.
|
||||
|
||||
- **MeshTerm can install a font its charts will actually draw in** — for anyone who stays
|
||||
in the classic console. That console does no font fallback at all, and none of the fonts
|
||||
it offers can draw braille, which every timeline and activity graph in MeshTerm is made
|
||||
of. Neither can Hack Nerd Font, JetBrains Mono, Fira Code, Source Code Pro, or even
|
||||
DejaVu Sans Mono; the usual advice works only because most terminals quietly fall back
|
||||
to a proportional font.
|
||||
|
||||
So MeshTerm now ships Cascadia Mono PL — Microsoft's console font, SIL OFL — and on
|
||||
that console offers to use it. If you already have Cascadia (every Windows 11 machine,
|
||||
and anyone with Windows Terminal) it just switches to it. If not, it offers to install
|
||||
it for you: no administrator rights, nothing downloaded, 723 KB. Saying no explains
|
||||
what saying no looks like and asks once more; a second no is remembered and never
|
||||
raised again. Preferences → Display → Console setup changes your mind about either
|
||||
offer.
|
||||
|
||||
## [0.2.4] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MeshTerm crashed on startup on Windows.** Building the screen raised
|
||||
`ctypes.ArgumentError: expected LP__CSBI instance instead of pointer to
|
||||
CONSOLE_SCREEN_BUFFER_INFO`, and on a double-clicked build the traceback and the window
|
||||
it was printed in were destroyed together.
|
||||
|
||||
`ctypes.windll.kernel32` is a cache: every caller in a process gets the same object, and
|
||||
each function on it is a shared attribute. The emoji-width probe declared
|
||||
`GetConsoleScreenBufferInfo` as taking a pointer to *its* copy of the screen-buffer
|
||||
struct — which rewrote the signature for prompt_toolkit, which then called that same
|
||||
function with a pointer to *its* copy. Same layout, different class; ctypes refused.
|
||||
|
||||
Nothing in MeshTerm reaches for `ctypes.windll` any more. Five call sites now take
|
||||
private handles from `core/win32dll.py`, so a signature declared for one purpose is
|
||||
invisible to everything else in the process.
|
||||
- **The dashboard and the Time Machine can show one scope at a time.** Press `s` (F3 on the
|
||||
dashboard, F2 on the Time Machine, on the PicoCalc) to step through all traffic, unscoped
|
||||
floods, each region heard, and floods of an unknown scope. The charts, counts, and signal
|
||||
figures narrow to that scope, and the title says which one you are looking at. A direct
|
||||
packet has no scope, so it only counts under all.
|
||||
- The Channels list leads with each channel's slot number.
|
||||
- The Contacts title counts archived contacts too, and shortens itself on a narrow screen.
|
||||
|
||||
### Changed
|
||||
|
||||
- The log-detail preference offers the plain level names — Error, Warning, Info, Debug —
|
||||
rather than descriptions of them. Someone being talked through a problem is told "set it
|
||||
to debug", not "set it to everything".
|
||||
- **Esc at the main menu no longer opens the quit dialog.** Pressing Esc again and again is
|
||||
the natural way back to the menu, and the presses that landed after you got there used to
|
||||
ask whether you wanted to quit. Now Esc stops at the menu (it still clears a typed
|
||||
filter), and you leave through the Quit row or ^Q.
|
||||
- **^Q asks before it quits, from any screen.** It sits right next to ^W, which goes back to
|
||||
the menu, so one slip used to close the app, along with any capture or queued message.
|
||||
Now it opens the same dialog as the Quit row, whatever you were doing keeps running
|
||||
behind it, and Esc puts you back. Pressing ^Q a second time quits straight away. The main
|
||||
menu names it: `^Q quit?` on the hint line, or, on the PicoCalc, in the title bar, with
|
||||
**Quit?** on F3 and **Quit!** (no question) on Shift+F3.
|
||||
- **The dashboard counts each packet once.** Its traffic list used to count an advert twice,
|
||||
once as the device reported it and once as the frame it arrived in. It now counts frames
|
||||
heard on the air, under the same names and colours as the live feed.
|
||||
- The splash screen has new art, and its white letters no longer come out grey.
|
||||
- The Support page's Patreon link is shorter, and so is its QR code.
|
||||
|
||||
## [0.2.3] — 2026-09-07
|
||||
### Fixed
|
||||
|
||||
- **A T-Deck running MeshOS or wadamesh pairs over Bluetooth.** Those firmwares refuse the
|
||||
first message until the device is paired, and MeshTerm read the silence as "this isn't a
|
||||
MeshCore companion", so the PIN prompt never came. It now recognises the refusal and
|
||||
pairs the way it does with any other companion. On Windows, a mistyped PIN also no longer
|
||||
leaves behind a half-made pairing that made the next, correct attempt fail.
|
||||
- **Rebooting from Device config shows the reconnect dialog straight away.** A board behind
|
||||
a USB serial adapter could restart without MeshTerm noticing, and it kept sending
|
||||
commands to a board that was mid-restart. Over Bluetooth, the reboot also no longer waits
|
||||
a second or more for a reply the restarting board never sends.
|
||||
|
||||
## [0.10.1] — 2026-09-27
|
||||
|
||||
**Three small fixes to the command line.** A mistyped `records --width` now says so instead
|
||||
of looking like an empty result, the simulator's row in `devices` loses a stray `()`, and
|
||||
two help pages read like the rest.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`meshterm records --width` refuses a width that doesn't exist.** Hash widths are 1, 2,
|
||||
or 4. Any other number used to look like an empty result, so a typo and an empty
|
||||
database gave the same answer, and `--width 0` was ignored and listed every width. Now
|
||||
it's a usage error that names the three choices, like `--category` already was.
|
||||
- **The simulator's row in `meshterm --mock devices` no longer ends in an empty `()`.**
|
||||
The brackets hold a serial port, and the simulator doesn't have one.
|
||||
- **`meshterm platform --help` and `meshterm specimen --help` read like the others.** They
|
||||
used to print notes meant for someone reading the source code, markup and all, and cut
|
||||
their summaries short in `meshterm --help`. Now each has one plain line.
|
||||
|
||||
## [0.10.0] — 2026-09-25
|
||||
|
||||
**Regions, a uConsole with no bridge, and Bluetooth that works on Linux.** MeshTerm now
|
||||
speaks MeshCore's regions: it reads and edits them on a repeater, keeps a channel's messages
|
||||
inside one, and tells you which region any flood it hears was sent into. It drives the
|
||||
uConsole's LoRa radio itself. And connecting is far less of a mystery: a PIN-protected
|
||||
companion pairs on Linux, and a connection that fails says which step failed and what to do.
|
||||
|
||||
### Added
|
||||
|
||||
- **MeshTerm drives the uConsole's LoRa radio itself, without the bridge service.** A radio
|
||||
on this machine's own SPI bus is now a connection like USB, Bluetooth, or TCP: pick
|
||||
**SPI radio** on the device screen, or run `meshterm --spi`, and `meshterm devices` lists
|
||||
it beside everything else. MeshTerm starts the node when it connects and stops it when it
|
||||
quits, so the radio is free for other programs whenever MeshTerm is closed — even after a
|
||||
crash. The node keeps its identity, name, radio settings, channels, and contacts in
|
||||
`~/.meshterm/radio/`, and the first time it runs it takes over the bridge's identity and
|
||||
contacts, so it stays the node everyone already knows. It needs the radio library
|
||||
(`pipx inject mesh-term 'openhop-core[hardware]'`), and a board wired differently from
|
||||
the AIO v1 states its pins in a profile's `spi` table. The bridge is still there for a
|
||||
node that should stay on the mesh while MeshTerm is closed.
|
||||
([#21](https://github.com/jpmartineau/MeshTerm/issues/21))
|
||||
- **A repeater's regions, read and edited.** A repeater's node page shows the regions it
|
||||
relays floods for, and whether it relays unscoped floods too; **Ask which regions it
|
||||
carries** asks it once (a repeater answers only a neighbour, or over a known route).
|
||||
Repeater admin gains a **Regions** page for the whole tree: allow or deny floods per region
|
||||
and for unscoped traffic, set the home and default scope, add and remove regions, and save.
|
||||
Every edit takes effect at once on the repeater, and a reboot undoes whatever was not
|
||||
saved, so the page counts your unsaved edits and asks before you leave with any. A tree
|
||||
too big for one reply is said to be cut, and the rest is recovered from the repeater's own
|
||||
lists. On the command line, `meshterm regions NODE` asks the same question.
|
||||
- **A channel can be kept to one region.** Give a channel a **Send scope** on its page, and
|
||||
its messages are flooded into that region alone — only repeaters that carry it relay them.
|
||||
The chat's title names the scope, and **^R** resends the message you picked (or your last
|
||||
one) unscoped when the region is getting it nowhere. A radio that can't send under the
|
||||
scope refuses before anything goes out, rather than quietly sending it everywhere. On the
|
||||
command line, `channels scope INDEX [REGION]` reads or sets it, `channels list` gains a
|
||||
`SCOPE` column, and `chat send --channel N --scope REGION` (or `--scope '*'` for unscoped)
|
||||
overrides it for one message. Scoped sending needs firmware 1.10, and `*` needs 1.16.
|
||||
- **Every flood says which region it was sent into.** The packet viewer has a `scope` row,
|
||||
the live feed a `SCOPE` lane, and a received channel message's card names its region too.
|
||||
Where no region known here matches, the code is shown instead, and it turns into a name
|
||||
as soon as a repeater or a channel teaches it one. `monitor` gains the same `SCOPE` column
|
||||
and a `scope` field under `--json`. Packets recorded before this version have no scope.
|
||||
- **A message you sent says what it was sent under.** Its message paths dialog names the
|
||||
scope, and when nothing relayed it and no repeater known here carries that region, it
|
||||
says so — which is usually the answer to "why did nobody hear that?"
|
||||
|
||||
### Changed
|
||||
|
||||
- The log is a plain text file, `~/.meshterm/meshterm.log`, one readable line per record
|
||||
with the traceback under the line that raised it. It was JSON Lines on the theory that
|
||||
something would replay it; nothing ever did, and the job it actually has is being opened
|
||||
by a person who has hit a problem.
|
||||
- It rotates, at 2 MB across three files. It used to grow without limit — a real one
|
||||
reached 11 MB in ten weeks, which is a lot of disk on a handheld and an unreasonable
|
||||
thing to attach to an issue.
|
||||
- **How much it keeps is a preference** — `log_level`, under a new Diagnostics group,
|
||||
defaulting to `WARNING`. The file now holds the problems rather than a narration of a
|
||||
working session: of the last twenty thousand records in a real log, twelve were a
|
||||
warning or an error.
|
||||
|
||||
### Added
|
||||
|
||||
- One interactive MeshTerm per data directory. A second one is turned away with the way
|
||||
out in the message, rather than quietly overwriting the first one's contacts and
|
||||
settings. The lock is held by the operating system, so a crash releases it — a process
|
||||
id in a file would leave a lock nobody could explain.
|
||||
- Failures are written down. Every unhandled error reaches the log with its traceback and
|
||||
the terminal says where to find it; expected errors go in as warnings, because a log
|
||||
that only holds crashes cannot answer "what happened just before". The bug report form
|
||||
asks for the file.
|
||||
- The window stays open after a crash when MeshTerm owns it. Double-clicking the
|
||||
executable on Windows gives the process its own console, which is destroyed the instant
|
||||
the process ends — so an error was drawn and erased together and all anyone saw was a
|
||||
flash. It now waits for a keypress, but only on that path: a run from a shell leaves its
|
||||
output on screen already, and a successful run should not make anyone press a key.
|
||||
- A readable answer when there is no Windows console. Git Bash, MSYS and Cygwin are not
|
||||
Windows consoles, and prompt_toolkit's own error says so in the language of its
|
||||
internals, arriving as a traceback that reads like a broken app rather than the
|
||||
instruction it is.
|
||||
- **MeshTerm sends at most one automatic advert a week, and none unless you ask.** A
|
||||
MeshCore companion never advertises by itself, so the hourly zero-hop and daily flood
|
||||
adverts MeshTerm sent by default were the only automatic adverts on the air — more than
|
||||
a companion needs, on a shared channel. They are gone from Device config, along with
|
||||
`config advert-cadence`. In their place is one preference, **Weekly advert**, off by
|
||||
default: once a device has gone a week without a flood advert, MeshTerm floods one, after
|
||||
it has heard the mesh and then waited for **Advert quiet** seconds of silence (5, 15, 30,
|
||||
or 60; 30 by default) plus a random 0–5 s, so two waiting radios do not collide. A line
|
||||
under the setting says where the week stands. Switching it on starts the week rather than
|
||||
sending; a flood advert sent by hand restarts it; each device keeps its own week, and one
|
||||
that falls due while MeshTerm is closed goes out the next time that device connects. The
|
||||
two old cadence preferences are dropped from `preferences.toml` the next time it is saved.
|
||||
- The live feed's packet classes take shorter names in a narrower column that never cuts
|
||||
them off, and the SNR and RSSI labels line up with their lanes. On the PicoCalc the class
|
||||
drops its icon to make room.
|
||||
- The Channels list reads **LAST** before **MSGS**, and shows a **SCOPE** column only when a
|
||||
channel has one.
|
||||
|
||||
### Fixed
|
||||
|
||||
- All ten stores write through one helper, and the file each writes to first is named
|
||||
for the process writing it. They shared a fixed `.tmp` name, so two copies of MeshTerm
|
||||
saving at the same moment took turns inside one scratch file and then each renamed
|
||||
whatever was in it over the real one. The rename was always atomic; what was being
|
||||
renamed was not.
|
||||
- **Bluetooth on Linux works with a PIN-protected companion.** The PIN never reached the
|
||||
radio: pairing fell back to a no-PIN ceremony the firmware refuses, so a correct PIN was
|
||||
reported as rejected, again and again. MeshTerm now runs the pairing itself and answers
|
||||
with the PIN, so it works over SSH and on machines with no desktop, and a companion that
|
||||
was paired before it had a PIN is paired again properly. An unpaired companion with no PIN
|
||||
given is now reported in seconds rather than after 30. **Unpair & quit** works on Linux
|
||||
too.
|
||||
- **A Bluetooth connect on Linux no longer loses its own link.** When the radio needed a few
|
||||
tries to open a link — routine on a Raspberry Pi — MeshTerm lost track of the one that
|
||||
finally opened. The companion, still connected, stopped advertising, so the retry could
|
||||
not find it and MeshTerm reported it out of range.
|
||||
- **A connection that fails says which step failed.** Bluetooth now tells a link that never
|
||||
opened from a stale saved pairing, a wrong PIN, a companion that refused to pair, and a
|
||||
connect that timed out, and the PIN dialog shows the reason instead of calling every
|
||||
refusal a wrong PIN. A USB port says whether it is missing, in use by another program
|
||||
(ModemManager, on Linux), or not yours to open (the `dialout` group). Every message, and
|
||||
what to do about it, is in
|
||||
[When a companion won't connect](https://github.com/jpmartineau/MeshTerm/blob/main/docs/connecting.md).
|
||||
- **Clock sync explains a radio whose clock is ahead, instead of calling it malformed.**
|
||||
MeshCore firmware never sets its clock back, so once a radio's clock ran ahead of the
|
||||
computer's — a GPS fix, another app, drift — every sync was refused and logged as "the
|
||||
device rejected the request as malformed". MeshTerm now reads the radio's clock first: a
|
||||
few seconds ahead counts as in sync, and further ahead says by how much and that rebooting
|
||||
the radio resets it.
|
||||
- **The Channels page no longer loses channels at connect.** When the radio refused another
|
||||
command while MeshTerm was reading its channel slots, the refusal was taken for "no such
|
||||
slot", and every channel after it was missing for the rest of the session.
|
||||
- **The uConsole's radio hears the whole mesh.** Its SPI node, and the bridge, listened for a
|
||||
shorter preamble than MeshCore sends, so the chip gave up on most packets part-way through
|
||||
and decoded one only when it happened to catch the end. It now expects the preamble the
|
||||
mesh uses for the spreading factor in force.
|
||||
- **The default flood scope is stored the way the firmware and the MeshCore app store it.**
|
||||
MeshTerm saved it as `#name` where they save `name`, refused a 30-character name the
|
||||
firmware accepts, and mis-framed a name with an accent. Device config now also refuses a
|
||||
name no repeater could list back (spaces, commas, or a private `$` region).
|
||||
- **The SPI bridge remembers its channels across restarts.** It kept them only in memory, so
|
||||
after every reboot the Channels page came up empty. For this fix and the preamble one,
|
||||
run the bridge's setup menu again and choose **2) Install as a service**.
|
||||
|
||||
## [0.2.2] — 2026-09-07
|
||||
## [0.9.0] — 2026-09-22
|
||||
|
||||
### Fixed
|
||||
**The first public release.** Everything in MeshTerm is new today, so instead of a list of
|
||||
changes, here is what it does.
|
||||
|
||||
- **The downloadable builds were missing every tool.** They started, printed help and drew
|
||||
the specimen card, and had a menu with nothing in it but Quit — no contacts, no map, no
|
||||
trace, no chat. Tools are found at runtime with `pkgutil.iter_modules`, which finds
|
||||
nothing inside a frozen bundle, so none of them registered. The packaging now collects
|
||||
them, and the smoke tests run a tool-provided subcommand instead of only `--help`, which
|
||||
a gutted build passes perfectly well.
|
||||
MeshTerm is a program you run in a terminal to work with a **MeshCore** radio — one of the
|
||||
small LoRa boards people use to build long-range mesh networks. A mesh like that needs no
|
||||
internet, no phone signal, and nobody in the middle: the radios pass messages along to each
|
||||
other until they arrive. You plug one into your computer, start MeshTerm, and it shows you
|
||||
what the mesh is doing.
|
||||
|
||||
### Added
|
||||
### What you can do with it
|
||||
|
||||
- `MESHTERM_HOME` points config and data somewhere other than `~/.meshterm`. Every copy of
|
||||
MeshTerm shared one directory, so trying a downloaded build meant letting it open the
|
||||
same history database as the one you use — the valuable half of an install, with no way
|
||||
to keep a second copy away from it. Also useful for a portable install, or two radios
|
||||
kept apart.
|
||||
- **Run it two ways, and it behaves the same either way.** Type `meshterm` on its own and
|
||||
you get a full-screen menu you drive with the arrow keys. Type a command instead —
|
||||
`meshterm contacts`, `meshterm info` — and it prints an answer and exits, which is what you
|
||||
want in a script. Add `--json` to nearly any command and the answer comes back as data
|
||||
for another program to read.
|
||||
|
||||
## [0.2.1] — 2026-09-07
|
||||
- **It listens all the time, and writes down what it hears.** Radios on a mesh announce
|
||||
themselves. From the moment yours is connected, MeshTerm notes down every announcement it
|
||||
overhears: which radio it was, how strong the signal was, and where it said it was. All of
|
||||
it is saved on your own computer. That is why the lists and charts can show you not only
|
||||
what is happening now, but what was happening last Tuesday.
|
||||
|
||||
### Fixed
|
||||
- **You can see where everyone is.** There is a street map, drawn in the terminal, with the
|
||||
radios on it. You can pan it, zoom it, and search it. There is also a Time Machine: charts of
|
||||
everything you have ever overheard, so you can see when a radio usually talks and how
|
||||
well you have heard it, over a day, a week, a month, or all of it.
|
||||
|
||||
- The formatter is pinned. `ruff>=0.4` meant CI installed a newer ruff than a laptop had,
|
||||
and the two disagreed about whether the tree was clean — 0.16 formats Python blocks
|
||||
inside Markdown and 0.15 doesn't, so the first CI run went red on a file that was fine
|
||||
locally. An unpinned formatter isn't a gate.
|
||||
- `docs/` is out of ruff's reach. The survey documents quote code as it looked on a past
|
||||
date, in fragments that were never whole programs; a formatter rewriting a quotation
|
||||
edits the record.
|
||||
- The installer builds only run for `main`. Every Dependabot branch that bumped an action
|
||||
touched the workflow file, matched its path filter, and started a five-platform build.
|
||||
- **You can talk to people.** Group channels and one-to-one messages, both live. If someone
|
||||
is out of range right now, the courier holds your message and delivers it when they come
|
||||
back. Channels can be shared as a QR code someone else scans, or as a link.
|
||||
|
||||
### Changed
|
||||
- **You can measure the mesh, not just guess at it.** Trace a message's route and see which
|
||||
hop is the weak one. Sweep your transmit power from coarse to fine to find the lowest
|
||||
setting that still gets through. Walk a map of how the whole mesh actually hangs together,
|
||||
which is often not how you thought it did.
|
||||
|
||||
- The distribution is named `mesh-term`. PyPI prohibits `meshterm` (pypi/support#12162),
|
||||
and the name you install is the only thing that changes: the import package and the
|
||||
command are both still `meshterm`.
|
||||
- `pyproject.toml` carries the metadata a stranger sees on a package page — project URLs,
|
||||
trove classifiers, and the licence as a PEP 639 expression.
|
||||
- **It talks to your radio however your radio talks.** USB cable, Bluetooth, or over the
|
||||
network. It finds the ones it can find on its own and asks you to pick. If a connection
|
||||
drops it reconnects by itself. No radio yet? `meshterm --mock` runs the whole program
|
||||
against a simulated mesh, and records what it hears in a history database of its own,
|
||||
apart from your real one.
|
||||
|
||||
### Added
|
||||
- **It runs on the small machines too.** The PicoCalc gets a layout of its own, built
|
||||
for its 53-column screen, with its own fonts, colours and on-screen key labels. The
|
||||
uConsole has room for the full desktop layout, and runs that.
|
||||
|
||||
- Continuous integration. Lint once, tests across seven OS and Python combinations, and a
|
||||
packaging job that installs the built wheel into a clean environment and runs the command
|
||||
from outside the source tree.
|
||||
- A release workflow with no automatic trigger, which publishes nothing until a human runs
|
||||
it on purpose and types the version.
|
||||
- A security policy, a code of conduct, issue and pull request templates, weekly
|
||||
dependency updates, and the funding links the app's own support page already offers.
|
||||
- A Releases page. Pushing a version tag builds the binaries and attaches them, with a
|
||||
combined `SHA256SUMS` and release notes taken from this file. It stays inside GitHub
|
||||
and is not the PyPI publish, which remains a separate and deliberate act.
|
||||
- Standalone builds. A PyInstaller spec and a workflow that builds a single runnable
|
||||
file for Windows, macOS (Intel and Apple silicon) and Linux (x64 and ARM64), checks
|
||||
each one runs from outside the source tree, and writes a checksum beside it. Nothing
|
||||
is published; the files are workflow artifacts until someone attaches them to a
|
||||
release.
|
||||
- **It works with no internet.** Map tiles are kept on disk once fetched, and when there is
|
||||
no network at all the map falls back to a plain grid. Nothing about MeshTerm needs to
|
||||
phone home.
|
||||
|
||||
## [0.2.0] — 2026-09-07
|
||||
- **When something goes wrong, it can describe itself.** `meshterm diagnostics`, or the
|
||||
Diagnostics page in the menu, puts everything a bug report opens with in one block: which
|
||||
build this is and how it was installed, the operating system, the terminal and how big it
|
||||
is, and whether the radio answered. It names nothing private — no keys, no passwords, no
|
||||
positions, no contacts — and describes your mesh as counts rather than names. One keypress
|
||||
saves it to a file you can attach to an issue.
|
||||
|
||||
The first version to carry a changelog. Everything before it is in the commit log and is
|
||||
deliberately not reconstructed here.
|
||||
### Getting it
|
||||
|
||||
### Added
|
||||
One file to download, with nothing else to install, for Windows, macOS (both Intel and
|
||||
Apple silicon), and Linux (including 64-bit ARM, which covers the uConsole). If you would
|
||||
rather use Python, `pipx install` works too, on Python 3.10 to 3.14. The README has the
|
||||
exact commands.
|
||||
|
||||
- This file.
|
||||
MeshTerm is free and open source under the Apache 2.0 licence. The name and the logo are
|
||||
not covered by the licence (see `NOTICE`), so a fork is welcome under its own name.
|
||||
|
||||
### Changed
|
||||
|
||||
- The project description now says what MeshTerm is, how it reaches a radio, and which
|
||||
systems it runs on, instead of naming a category. It is the PyPI summary and the package
|
||||
docstring, which no longer disagree.
|
||||
- The version is written in exactly one place, `meshterm/__init__.py`. The distribution
|
||||
metadata reads it from there rather than repeating it, so the number on the splash screen
|
||||
and the number in a built wheel cannot drift apart.
|
||||
- The basemap user agent identifies the real repository and reports the running version.
|
||||
It previously sent a bare `https://github.com/` and a hardcoded `0.1` to OpenFreeMap,
|
||||
which is the only handle a tile operator has on a client.
|
||||
- `macOS` and `Bluetooth` are spelled one way across the project. `BLE` stays where it is
|
||||
an identifier — the transport name, the config keys, the CLI.
|
||||
- `meshterm --help` opens with the same description as the package summary and the
|
||||
PyPI page, instead of a fourth wording of its own.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The three commands `CONTRIBUTING.md` asks contributors to run all pass:
|
||||
`python -m pytest -q`, `ruff check .`, and `ruff format --check .`. The lint gate had been
|
||||
reporting 2222 errors and the format gate 154 files, so a first contributor could not tell
|
||||
their own diff from the background.
|
||||
- Two `TYPE_CHECKING` imports the test suite referenced but never imported, on paths that
|
||||
happened never to run.
|
||||
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.2.4...HEAD
|
||||
[0.2.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.4
|
||||
[0.2.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.3
|
||||
[0.2.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.2
|
||||
[0.2.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.1
|
||||
[0.2.0]: https://github.com/jpmartineau/MeshTerm/tree/v0.2.0
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.10.2...HEAD
|
||||
[0.10.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.2
|
||||
[0.10.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.1
|
||||
[0.10.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.0
|
||||
[0.9.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.9.0
|
||||
@@ -1,6 +1,6 @@
|
||||
# MeshTerm — working notes for Claude
|
||||
|
||||
MeshTerm is a full-screen terminal companion for MeshCore LoRa mesh devices. The UI is a
|
||||
MeshTerm is a full-featured TUI MeshCore client for your terminal. The UI is a
|
||||
prompt_toolkit session rendering Rich content (`meshterm/ui/`), tools register into a
|
||||
menu (`meshterm/tools/`), services run in the background (`meshterm/services/`), and
|
||||
everything heard is recorded to SQLite (`meshterm/persistence/`).
|
||||
@@ -16,6 +16,26 @@ Commit directly on `main` — do not create topic branches unless explicitly ask
|
||||
is a solo repo; branches just create divergence to merge back later). Commit freely, but
|
||||
never `git push` unless explicitly asked in that moment.
|
||||
|
||||
## Licensing — what every file and every build carries
|
||||
|
||||
- **Every Python file opens with `# SPDX-License-Identifier: Apache-2.0`** (line 2 after a
|
||||
shebang). `tests/test_spdx.py` is the gate. Anything under another licence lives beside
|
||||
its own text and says so in its header — the GPL-2.0-only console-font script, the
|
||||
MeshCore MIT notice beside the XIAO patch — and never enters the distributed package.
|
||||
- **The map credits OpenStreetMap in the map's own bottom-right corner**
|
||||
(`ui/attribution.py`): the full OpenFreeMap line on arrival, `© OpenStreetMap` after the
|
||||
first keystroke, `muted`, on both platforms and in the same words. *Which* corner is the
|
||||
frame's, not the drawing's, wherever the frame has one — **regular** sets the credit into
|
||||
the panel's **bottom border rule**, right-justified, one rule cell before the corner, the
|
||||
way a title sits in the top rule (`Screen.bottom_caption` → `frame._panel_box`), so the
|
||||
map's cells are untouched; **picocalc**, whose frame has no bottom rule at all, stamps it
|
||||
over the right end of the drawing's last row instead, overprinting what it covers. The
|
||||
minimap always stamps. The split is bound once via `platforms.on_platform`, never asked
|
||||
per frame. Never a title atom, never a footer character, never a key. The licence and URL
|
||||
live on the About page.
|
||||
- The name and the wordmark are trademarks reserved in `NOTICE`, which Apache §4(d) makes
|
||||
every fork carry; `NOTICE` holds only what must travel with a redistribution.
|
||||
|
||||
## UX standards
|
||||
|
||||
These are binding. Every new screen, dialog, row, or hint follows them; when you touch an
|
||||
@@ -28,7 +48,7 @@ them instead of hand-rolling:
|
||||
the language below).
|
||||
- `ui/widgets.py` — `highlighted_hash` (THE key widget — shows a key, lights its hash),
|
||||
`format_ago` (prose ages),
|
||||
`_format_age` (column ages), `channel_glyph`, `_NODE_GLYPHS`, heard-age heat colouring.
|
||||
`format_age` (column ages), `channel_glyph`, `NODE_GLYPHS`, heard-age heat colouring.
|
||||
- `ui/theme.py` — `name_style`/`node_style` (per-node hues, hash-derived), `snr_style`,
|
||||
the `you` white.
|
||||
|
||||
@@ -36,7 +56,7 @@ them instead of hand-rolling:
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| node | any device on the mesh — a radio broadcasting packets; roles: client, repeater, room server. The umbrella term |
|
||||
| node | any device on the mesh — a radio broadcasting packets; roles: companion, repeater, room server, sensor. The umbrella term |
|
||||
| contact | a node your device knows: **discovered** (heard broadcasting, not yet added) or **added** (in the contact list, messageable). Every contact is a node; not every node is a contact |
|
||||
| heard | received from ("last heard", "first heard") — never "seen" in UX text |
|
||||
| key | the full fixed-length value — a node's public key, a channel secret |
|
||||
@@ -44,11 +64,14 @@ them instead of hand-rolling:
|
||||
| path | an ordered hop spec you compose or force (`a1,3d,…`) |
|
||||
| route | the concrete node sequence a trace walked or will walk |
|
||||
| via | prefix for a packet/message's relay chain |
|
||||
| region | a named area a repeater relays floods for (`region put yul`); shown bare, never `#yul` — the `#` belongs to key derivation only |
|
||||
| scope | the region a flood is limited to — a packet's, a channel's send scope, the device's default scope. A direct packet has none. MeshCore's sense and the only one: the menu's This node / Other nodes / This app sections name *whose* a setting is, never a "scope" |
|
||||
| unscoped | a plain flood, relayed by any repeater that allows the wildcard `*` |
|
||||
| Back | leave the current screen/list — Esc's word, and a row's only where leaving is a *choice* (see below) |
|
||||
| Quit | leave the app (main menu, device splash) — nowhere else |
|
||||
|
||||
Node vs contact — the boundary: **node** is the hardware/participant sense — the map,
|
||||
mesh walk, heard-nodes, relay hops, graph vertices, and node *types* (client/repeater/room/
|
||||
mesh walk, heard-nodes, relay hops, graph vertices, and node *types* (companion/repeater/room server/
|
||||
sensor) all speak "node". **Contact** is the saved-identity sense — the Contacts screen,
|
||||
the courier recipient, anything you *address*. The reception/persistence layer
|
||||
(`observations.node`, `HeardNode`, `heard_nodes()`, `trace_hops.node`) stays "node"; the
|
||||
@@ -58,14 +81,27 @@ sortable list you pick from is `contactlist.py` (`ContactListScreen`/`ContactRow
|
||||
Setting vs preference — the other boundary, three-way and never blurred: a **setting** is
|
||||
the *radio's* (`core/device_config.py`, read live from the companion, edited on Device
|
||||
config); a **preference** is *MeshTerm's own behaviour* (`core/preferences.py`, defaults in
|
||||
code, overrides in `preferences.yaml`, edited on the Preferences page — `ctx.preferences`,
|
||||
code, overrides in `preferences.toml`, edited on the Preferences page — `ctx.preferences`,
|
||||
or `preferences.current()` where there is no context to reach through); and **config** is
|
||||
machine setup — paths and device profiles (`core/config.py`, `config.toml`, a text editor
|
||||
only). A behaviour value belongs in the preference registry, not as a module constant and
|
||||
not in `config.toml`: a code constant a reader can't reach is a preference nobody has.
|
||||
|
||||
What MeshTerm writes, it writes from what it knows: a store reads its file into typed
|
||||
records and writes back out of *them*, never the raw document it read, so a field no
|
||||
record knows (retired, or invented by a hand edit) is gone at the next write — every
|
||||
`*_store.py` and `preferences.toml` work this way, and the settings store keeps only
|
||||
keys `device_config` still registers, since it offers them back to the radio. And
|
||||
**a name that leaves is retired for good**, never reused with another meaning, because a
|
||||
value can outlive its key in a file nobody saved since, and an old `30` that passes a new
|
||||
spec means something else undetectably. A preference key goes in `preferences.RETIRED`
|
||||
(loading refuses it; `report_dropped` logs what the next save will drop); a stored field
|
||||
goes in its record dataclass's own `RETIRED` set — the record is the path, not the leaf.
|
||||
`tests/test_retired.py` fails any live name found in either. Units live in the name
|
||||
(`_s`, `_hours`, `_db`), so a changed unit is a new name by construction.
|
||||
|
||||
Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now ago"),
|
||||
`_format_age` for aligned columns ("now", "5m").
|
||||
`format_age` for aligned columns ("now", "5m").
|
||||
|
||||
### Navigation — the stack
|
||||
|
||||
@@ -85,11 +121,26 @@ deliberately, one at a time, and say why in the code.
|
||||
refreshes in place with `SelectScreen.replace_items`, which follows the highlighted row
|
||||
by value and keeps the filter. Rebuilding the screen is for when the rows it was holding
|
||||
a place in are genuinely gone (a purge, a delete) — and then say so.
|
||||
- **A popup is not a place.** The stack is for *places* — full-frame screens the reader
|
||||
is *in* (a menu, a map, a list that is the tool's own page, an editor). A popup
|
||||
*informs, confirms, or asks*, and then it is gone: it is never kept pushed as a hub
|
||||
under the screen it was gathering for, so Esc from that screen lands where the tool
|
||||
was launched from, not on a picker. The test is whether the reader is in it or being
|
||||
asked something — a `SelectScreen` can be either (Channels is a place; "node to
|
||||
manage" is a question). A question **draws as a box** even when it is the only frame
|
||||
on the stack (the main menu is popped while a tool runs, and a lone floating screen
|
||||
would otherwise be painted full-frame): `session.select(floating=True)`,
|
||||
`session.text(floating=True)`, `session.stay(screen, dialog=True)` — all through
|
||||
`TuiSession._floated`, which puts a blank base under it.
|
||||
- **An entry chain is a stack too.** A flow that asks two or more things in a row runs
|
||||
through `menus.run_steps`: each step gets the answers so far (to label itself, and to
|
||||
offer its own previous answer as its default) and returns `None` to step back, so Esc
|
||||
undoes one step instead of the whole flow. A trailing Cancel/verb confirm is still a
|
||||
decision, not a step — its Cancel abandons.
|
||||
decision, not a step — its Cancel abandons. Where the steps are *lists to pick from*,
|
||||
the chain is **one popup that turns its page** — `menus.run_wizard` over `WizardPage`s
|
||||
(`SelectScreen.turn_page`; title `Feature — subject · step 1 of 2`), never two popups
|
||||
stacked. Esc on a later page turns back one, with that page's previous answer
|
||||
highlighted; the box is popped before whatever the answers were for opens.
|
||||
- **Esc peels before it leaves.** A screen carrying a find-as-you-type filter treats the
|
||||
typed query as the most recent thing the reader entered: the first Esc clears it and the
|
||||
screen stays, the second leaves. One rule on all four (`SelectScreen` and everything
|
||||
@@ -97,11 +148,21 @@ deliberately, one at a time, and say why in the code.
|
||||
for as long as it is true. Chat is the same shape with a different peelable thing — its
|
||||
first Esc unpicks a selected message. It used to be a 2–2 split, which put opposite
|
||||
outcomes behind the same keystroke on the same affordance.
|
||||
- **^W** unwinds every frame back to the main menu, **^Q** quits from anywhere. Both are
|
||||
answered in `TuiSession._dispatch` and neither is ever advertised — a global verb has no
|
||||
screen to belong to, and the F-key lane has only three free slots per screen. They are
|
||||
stated once, on the About page. `test_navigation` walks every string literal in the
|
||||
package to keep them out of the UI. ^W arms **every frame on the stack**, not just the
|
||||
- **^W** unwinds every frame back to the main menu, **^Q** asks to quit from anywhere: it
|
||||
floats the same confirm the menu's Quit row opens, detached over whatever is up so
|
||||
nothing under it stops, and a second ^Q while it asks leaves at once
|
||||
(`TuiSession.request_quit`; ^Q sits one key from ^W). Both are answered in
|
||||
`TuiSession._dispatch`, and neither is advertised — a global verb has no screen to
|
||||
belong to, and the F-key lane has only three free slots per screen — except `^Q quit?`
|
||||
on the main menu's own hint, where Esc is inert and the reader looks for the way out.
|
||||
On picocalc that atom rides the title bar's tail (`frame._way_out`), and the menu's lane
|
||||
puts the way out on F3: `Quit?` asks, its Shift half F8 `Quit!` leaves without asking
|
||||
(`quit_now`, the one door that skips the confirm, and only on the menu).
|
||||
`test_navigation` walks every string literal in the package to keep them out of the rest
|
||||
of the UI.
|
||||
- **Esc at the main menu does nothing** but peel a typed filter: the menu is the bottom of
|
||||
the stack, so Esc-mashing up to it settles there. Leaving is the Quit row or ^Q, both
|
||||
behind the confirm. ^W arms **every frame on the stack**, not just the
|
||||
top (`request_pop_all`), stopping at anything modal: a screen opened from a key handler
|
||||
— the packet viewer, the chat's delivery paths, a trace path flow — runs in a task no
|
||||
navigation frame awaits, so an unwind sent only to the top died there while the hub
|
||||
@@ -131,6 +192,18 @@ deliberately, one at a time, and say why in the code.
|
||||
shape in `ReorderScreen`. A **Quit** row is likewise kept (main menu, device splash):
|
||||
it *initiates* the app's terminal action behind a confirm, and on the splash it is the
|
||||
only statement that the app can be left at all.
|
||||
- **An editor has one of two shapes, chosen by who holds the change.** Where MeshTerm
|
||||
holds it until told (Device config, Preferences, Repeater admin's settings), edits are
|
||||
*staged* and nothing reaches the device until Apply — the `exit_rows` shape above.
|
||||
Where the **device** holds each edit live in RAM until its own save command (a
|
||||
repeater's region table: every edit takes effect at once, and a reboot undoes whatever
|
||||
`region save` did not persist), edits apply immediately and the page is *live, then
|
||||
save*: the title counts unsaved edits (`· 2 unsaved`), the page ends on the same
|
||||
shape reworded — `✓ Save n changes to the …` over `✗ Back — a reboot undoes them` —
|
||||
and Esc with unsaved edits asks first (amber). Never stage what the device already
|
||||
holds live: the edits depend on each other there (a region must exist before it can be
|
||||
allowed), and a stage would have to re-implement the firmware's rules to order them.
|
||||
`ui/region_editor.py` is the reference.
|
||||
- **A cursor clamps at both ends — nothing in the app rolls over.** ↓ off the last row
|
||||
and ↑ off the first stay put, in a select list, a hand-drawn action list, a reorder
|
||||
list, a dialog's button row, everywhere. The window follows the highlight, so a roll
|
||||
@@ -157,6 +230,21 @@ deliberately, one at a time, and say why in the code.
|
||||
the query in its footer hint draws the body line only where the footer isn't drawn
|
||||
(`Platform.footer_fkeys`), never both: the query must be visible on every platform, and
|
||||
twice on none.
|
||||
- **A QR code is white on black, and when it is the answer it is the whole screen.**
|
||||
Modules are pure white ink on a pure black field whatever theme the terminal runs
|
||||
(`qr._STYLE`, both ends named) — a camera reads contrast, light-on-dark is what a
|
||||
scanner expects of a screen — and never black on white. A share screen
|
||||
(`qr.share_screen` → `qr.QrScreen`: the channel share, the contact card, the CLI's
|
||||
share commands in the menu) is a **bare frame**: `Screen.bare` / `frame.compose_bare`
|
||||
draw the body alone on blank rows — no header, no footer, no title bar, no box, no
|
||||
hint, no F-key lane, no instruction — with nothing on it but the code and the URL it
|
||||
encodes, sat a little above centre; Esc leaves, as everywhere. The code **fits itself
|
||||
to the frame every paint**, width and rows both: a lighter error level, then a
|
||||
narrower quiet zone, before it would ever be cut (a contact card is 57 cells and 29
|
||||
rows at the standard fit; the PicoCalc is 53 across and a regular terminal 24 tall),
|
||||
and when code and URL can't share the frame the code is whole at the top and the URL
|
||||
a page down. The one exception is a ` ```qr ` fence in a written page, where the code
|
||||
is an illustration drawn in the prose where the fence is.
|
||||
|
||||
### Written pages
|
||||
|
||||
@@ -187,8 +275,16 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
- ≤72 cells. Sentence shape: navigation keys, then action keys, **Esc last**.
|
||||
- Esc verb by surface: `Esc back` leaves a screen · `Esc close` dismisses a read-only
|
||||
floating view · `Esc cancel` abandons a prompt/dialog · `Esc keep` leaves a value
|
||||
picker unchanged · `Esc quit` only at the main menu. While a find-as-you-type filter
|
||||
is standing the verb becomes `Esc clear`, because that is what the press does then.
|
||||
picker unchanged · no Esc atom at the main menu, which ends on `^Q quit?` instead ·
|
||||
`Esc bye` only on the device
|
||||
splash, which is the door rather than a screen — nothing has been started there to
|
||||
quit out of. While a find-as-you-type filter is standing the verb becomes `Esc clear`,
|
||||
because that is what the press does then.
|
||||
- A hint that outgrows the surface drawing it **sheds atoms rather than being cut off**,
|
||||
because the cut takes the end and the end is Esc (`frame.fit_hint`, the chromeless
|
||||
splash). They go from the right, in front of Esc; a screen may name the ones it can
|
||||
spare first (`spare_hint_atoms`) — a key the reader would find anyway, like the ←→ its
|
||||
move atom already named, before one that is unguessable and advertised nowhere else.
|
||||
- Enter verb by action: `Enter open` when the row pushes a screen or dialog ·
|
||||
`Enter select` when it picks a value or action row · `Enter set` in a value picker.
|
||||
A more specific committing verb (`Enter adopt path`, `Enter add`) is fine; a synonym
|
||||
@@ -239,8 +335,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
|
||||
- Status marks (single-width, themed): `✓` ok · `✗` err · `⚠`/`?` warn · `●` unread/
|
||||
unacked (err) · `○` acked/empty (muted). Never `✔`, `✖`, `✅`, `❌` as status.
|
||||
- Node types (shared with the map): `★` you (yellow) · `●` node · `▲` repeater ·
|
||||
`■` room · `◉` sensor · `○` unknown.
|
||||
- Node types (shared with the map): `★` you (yellow) · `●` companion · `▲` repeater ·
|
||||
`■` room server · `◉` sensor · `○` unknown.
|
||||
- Path cuts: a path line drawn as **chips** that gets cut — a lane that ran out, a row
|
||||
scrolled past its edge — breaks the chip off on a half block in that chip's own fill
|
||||
(`▐` ends a line, `▌` opens one; `pathline.cut_mark`/`cut_to`), so half the cell is
|
||||
@@ -254,15 +350,34 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
that differ from row to row. As a chip it is the map's yellow star on neutral dark
|
||||
grey, padded like every other chip.
|
||||
- Chip seams are **one** interlocked chevron (previous fill on next). Two chips of the
|
||||
same fill are the exception the interlock can't draw: the point drops its background so
|
||||
the page cuts the wedge. An elision breaks the ribbon rather than joining it — bare `⋯`
|
||||
same fill are the exception the interlock can't draw — and so are two fills the eye
|
||||
can't tell apart: under `pathline.SEAM_BLUR` apart in OKLab (`ui/oklab.py`, THE
|
||||
perceptual colour distance — never a hue gap, never sRGB, both of which mis-size the
|
||||
greens against the cyans). There the seam is the **thin** chevron (`POWERLINE_THIN`)
|
||||
in the previous chip's own fill shaded `SEAM_SHADE` darker (lighter for a dark fill),
|
||||
drawn on the next, so the ribbon runs on unbroken and the join is a line the chip draws
|
||||
on itself, never a wedge of page cut out of the route and never a third colour. An
|
||||
elision breaks the ribbon rather than joining it — bare `⋯`
|
||||
on the page between a closing point and the next chip's notch, no fill, no padding.
|
||||
- The ribbon's **outer ends**: the chevron means *the route continues*, so an end that is
|
||||
the route's own never wears one. It ends **square** — nothing appended, the last chip's
|
||||
own pad is the edge — and opens square the same way; a full Nerd Font rounds such ends
|
||||
into a lozenge instead (`POWERLINE_ROUND_*`, drawn only where `termfont.powerline_full`).
|
||||
Two things put the chevron back, and either is enough. A *wrapped* line the path outruns
|
||||
closes on the point: there the route really does go on. And a line drawing only the
|
||||
**middle** of a route — a packet's `via` chain, the TX sweep's composed relays: hops that
|
||||
are neither the node the frame came from nor the one it reached — opens on the notch and
|
||||
closes on the point, because a square end is the promise that the chip beside it is
|
||||
where the route began or ended (`PathLine(from_origin=…, to_destination=…)`, defaulting
|
||||
to a whole route). Arrow mode spells the same claim with the separator alone, a leading
|
||||
or trailing `→` with no hop on the far side of it.
|
||||
- Concept icons: 📡 advert · 🕒 clock/sync · 🔄 reboot · 💾 backup · 📂 restore ·
|
||||
🔑 channel/credential key · 🔐 identity/auth secret · 🗑 clear/delete · ✎ compose/edit ·
|
||||
🔑 channel/credential key · 🔐 identity/auth secret ·
|
||||
🔒 lock (a locked contact, a private channel) · 🔓 unlock · 🗑 clear/delete · ✎ compose/edit ·
|
||||
⚙ parameter · `#` count · ▶ run · ⚡ explore/probe · ★ best/winner · ⭐ watch ·
|
||||
📤 send now · 📨 courier/queue · 💬 chat · 🔔 notify · 🔕 mute (notifications off) ·
|
||||
📱 QR · 🔗 link · ↻ re-read · ↕ reorder · ⇄ reverse (flip a path's direction) ·
|
||||
🏆 trophy case/record · ⌨ command line · 📖 read/about · 💰 support/donate ·
|
||||
🔖 region/scope · 🏆 trophy case/record · ⌨ command line · 📖 read/about · 💰 support/donate ·
|
||||
🚪 quit. Packet-class icons (feed/viewer
|
||||
lane): 📢 advert · 📊 telemetry · 📦 packet · 💬 message · ✅ ack. Raw payload
|
||||
classes (`PAYLOAD_ICONS`): 📻 channel text · 💽 channel data · 📩 direct message
|
||||
@@ -322,6 +437,145 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
- Dialogs anchor slightly above true centre, sized for their populated state.
|
||||
- Radio traffic: single transmissions or a user-chosen sample count with cooldown pacing
|
||||
— never bursts.
|
||||
- **A channel message goes out through `Device.send_channel_in_scope`**, never
|
||||
`send_channel_message` directly. The firmware has no per-channel scope, only one
|
||||
session scope that every flood uses, so a channel's scope is a window: set it, send,
|
||||
restore, under the transmit lock (`Device.transmitting()`) so no other flood slips out
|
||||
inside it. A direct send skips that window without an error and the message floods
|
||||
everywhere; a refused scope raises `FloodScopeError` and sends nothing, never a quiet
|
||||
unscoped send. Any new command that can transmit a flood holds `transmitting()` too.
|
||||
|
||||
### The command line — one answer, two faces
|
||||
|
||||
Everything above describes the **menu**, which is read by a person sitting in front of it.
|
||||
The command line has two readers and it no longer pretends they are the same one: the
|
||||
**plain face** is for somebody at a prompt who typed a command to find something out, and
|
||||
the **JSON face** (`--json`) is for a program, often on another machine and often later.
|
||||
|
||||
The seam that makes two faces possible is the rule everything else here hangs off:
|
||||
|
||||
> **A tool states its answer as data. A renderer turns data into bytes. The CLI boundary
|
||||
> picks the renderer.**
|
||||
|
||||
`exit_code` already worked this way — the tool states it, the boundary turns it into a
|
||||
process status, no tool calls `sys.exit`. The answer travels the same way now. A tool that
|
||||
*prints* its answer has given it away: it is rendered and gone by the time anything could
|
||||
offer it in another format, which is why `--json` reached two commands out of twenty and
|
||||
stopped, each one growing an `if ctx.json_output:` branch above the rendering that restated
|
||||
the whole answer in a dialect nobody else could reuse.
|
||||
|
||||
- `ui/report.py` — `Listing` (records) and `Facts` (one thing, key by key). **A row holds
|
||||
the typed value** (`6.0`, `None`, a `datetime`), never a formatted cell; each `Column`
|
||||
carries both projections, a plain `Lane` and a JSON function. A `Facts` block also covers
|
||||
the one-scalar answer, through `shape=BARE` (`config get` prints its value alone because
|
||||
the caller named the key) and the acknowledgement, through `shape=SILENT`.
|
||||
- `ui/fields.py` — **the shared shapes, one constructor per concept**: `node`, `channel`,
|
||||
`position`, `route`, `spec`, `when`, `instant`, `snr`, `flag`, `free`. A listing that
|
||||
mentions a node asks for `fields.node`, so every document speaks the same five-key object
|
||||
and every listing names its lanes the same way — by construction, not by review.
|
||||
`fields.node` is the one that earns the design: **one machine key expands to as many
|
||||
plain columns as the surface has room for**, four in `contacts` and one in a hop table.
|
||||
- `ui/renderers.py` — `PlainRenderer` and `JsonRenderer`, chosen by `ctx.output`.
|
||||
**A third format is a new renderer and nothing else**: no tool is touched, no report
|
||||
changes. `ctx.output` is an `OutputFormat`, never a boolean, because the boolean was
|
||||
already being asked questions it could not answer.
|
||||
- `ToolResult.report` is `None` for the menu path and for a feature with no scripted face
|
||||
(the map, the dashboard). It is **not** `summary`: that is the run log's record of what an
|
||||
invocation did, written to the `runs` table on every execution, and folding a five-hour
|
||||
`monitor` capture into it would be the price of one field fewer.
|
||||
- **`ui/surface.py` is untouched by all of this.** `PlainUi`/`TuiUi` is the *menu-vs-terminal*
|
||||
split and stays exactly what it is; a report is **returned**, not shown, so `ctx.ui` never
|
||||
grows a `report()` method and `TuiUi` never sees one.
|
||||
- **Streaming is a second, narrower seam** and deliberately not the general path:
|
||||
`monitor` and `chat listen` run until a window closes, so their answer cannot be a value
|
||||
handed back at the end. They open `renderers.stream(...)` and emit one typed row per
|
||||
record. Two commands use it; anything that can build a whole report builds one.
|
||||
|
||||
#### The plain face — for a person at a prompt
|
||||
|
||||
It is still a Unix utility's output — one record per line, no colour, no frames, stdout is
|
||||
the answer, the exit status is half the report — but every rule that existed *only* to make
|
||||
splitting safe has gone, because splitting is the machine face's job now.
|
||||
`meshterm/ui/script.py` is the whole vocabulary and carries the reasoning; the rules:
|
||||
|
||||
- **Alignment is the delimiter.** A name is bare (`script.name`). The *escaping* under the
|
||||
quoting stays and always will (`script._escaped`): a node broadcasts its own name and a
|
||||
stranger fills in a message body, and neither may end the record it sits in.
|
||||
- **A time is an age** — `now`, `5m`, `3h`, `never` (`script.age`, delegating to
|
||||
`widgets.format_age` so the two faces cannot drift). An absolute instant survives where
|
||||
the instant *is* the fact (`script.stamp`, `fields.instant`): the device clock, an
|
||||
appointment set with `--at`, a live capture's own `TIME`, every column under `--absolute`.
|
||||
`--absolute` is an override of the `cli_time_format` **preference**, not a switch beside
|
||||
it — behaviour belongs in the registry.
|
||||
- **A route is drawn and a path is typed.** `script.route` joins hops with ` → `; a hop is
|
||||
`Name (hash)`, or whichever half is known, never an empty `()`. `script.spec` is the
|
||||
comma-joined hex `--path` takes back, and it is the one line on either face that
|
||||
round-trips, so nothing creeps into it. **Our own node is a hop like any other** — the
|
||||
menu's `★` says "you already know who this is", which is true of the reader and false of
|
||||
whoever opens the file afterwards.
|
||||
- **`unknown` is a word and `-` is an absence.** `script.NONE` is the one token for absent;
|
||||
`never` is a *value* (a node not yet heard is a fact). A node type stays the word
|
||||
`repeater` — a monochrome `▲` would need a legend and the CLI has no legends.
|
||||
- **A live stream pins its lanes** (`script.stream`), because a capture cannot measure
|
||||
columns it has not seen. A value wider than its lane overruns and pushes the row right;
|
||||
nothing is elided, and the one unbounded field goes **last** so it can push nothing.
|
||||
- **A value that round-trips is untouchable.** `config show` prints what `config set` takes
|
||||
— an enum's number, `""` for an empty string — and gains nothing cosmetic. `fields.rendered`
|
||||
carries the typed value beside the text its own spec produced, so neither face parses the
|
||||
other's output.
|
||||
- **No wrapping, with one exception**: the four written pages (`about`, `about-author`,
|
||||
`discord`, `support`) draw on a `script.PAGE_WIDTH` console. The no-wrap rule exists so a
|
||||
*record* is never split with its fields under the wrong headings; a paragraph has no
|
||||
fields, and unwrapped it is a 600-cell line no terminal can read. **Never wrap a listing.**
|
||||
- **stdout is the answer; everything else is stderr** — errors (`meshterm: what went
|
||||
wrong`), progress bars, log records, `ui.ack`, and a `ToolResult.message`. The last two
|
||||
used to be dropped; stderr keeps the promise the dropping was made to keep (a redirect
|
||||
catches only the answer) while giving the person at the prompt back their ✓ and their
|
||||
count. `script.stderr_console()` stops wrapping off a terminal, because a sentence folded
|
||||
at 80 columns is a sentence `grep` cannot find.
|
||||
|
||||
#### The JSON face — the machine contract
|
||||
|
||||
`--json` prints **the answer itself**, and the report a caller needs is still `$?`.
|
||||
|
||||
- **No envelope.** An array for a listing, an object for a set of facts, so
|
||||
`contacts --json | jq '.[].node.name'` reads what it looks like. A multi-block report is
|
||||
one object: each `Listing` under its own key, each `Facts` merged at the top level.
|
||||
- **One compact line, `\n`-terminated**, UTF-8 with no ASCII escaping, keys in the order the
|
||||
report declares them. A stream is one document per record, **all the same shape** — no
|
||||
discriminator, because every reader would pay for it and only a stream would use it.
|
||||
- **Absent is `null`, never an omitted key**, and typed values throughout: `9` not `"9"`,
|
||||
`false` not `"false"`, a body raw and unescaped. `unknown` becomes `null`; a consumer
|
||||
already has one spelling for "nothing here".
|
||||
- **Timestamps are UTC, RFC 3339, `Z`, to the second** — twenty characters, so string
|
||||
comparison is time comparison. `--absolute` does not touch this: a local offset is a fact
|
||||
about the machine that ran the command, not about the event.
|
||||
- **A numeric field carries its unit in its key** (`snr_db`, `uptime_s`, `rtt_ms`), never in
|
||||
its value, and a key/hash is lowercase hex. **A key is never truncated** — a truncated key
|
||||
cannot go back into `--to` or `--path`, so a short id is a `hash`.
|
||||
- **`--json` changes the rendering, never the report.** Same exit status, same records. An
|
||||
empty result prints its empty document (`[]`, or the object with its `null`s) **and still
|
||||
exits 5**; a failure prints **nothing on stdout** and keeps its sentence on stderr.
|
||||
- **"Prints nothing" is never a legitimate JSON answer.** A command whose plain face is
|
||||
silent (`config advert`, `channels join`, `courier cancel`) still emits what it did.
|
||||
- **A command with no data face refuses, loudly.** `--json specimen` is a usage error (exit
|
||||
2): its output *is* the colour, and a document of it would be a lie or an empty gesture.
|
||||
So is `--json` with no subcommand. The map, the dashboard, the live feed, the watchtower
|
||||
and the mesh walk have no subcommand to refuse from, and that stays the honest answer
|
||||
rather than a degraded one.
|
||||
|
||||
#### The exit status is the report
|
||||
|
||||
`core/exitcodes.py`, and the `--help` epilog: 0 ok · 1 failure · 2 usage · 3 no device ·
|
||||
4 device failed · 5 nothing to report. A tool that ran fine and found nothing returns
|
||||
`NO_RESULT`; a failure is *raised*, never returned. Two classifications worth restating
|
||||
because both were wrong somewhere: **nothing transmitted is not a device failure** (a bad
|
||||
argument is exit 2), and **a rendering flag never moves the status**.
|
||||
|
||||
`tests/test_report.py` is the enforcement point for the seam, `tests/test_script_output.py`
|
||||
for the plain vocabulary, and `tests/test_cli_contract.py` for the commands — including a
|
||||
derived sweep that runs **every** registered command twice, once plain and once under
|
||||
`--json`, and pins that the two agree about the exit status.
|
||||
|
||||
### Platforms
|
||||
|
||||
@@ -344,7 +598,7 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
|
||||
`MapCanvas.to_ansi_lines`) — but the fold is the safety net, not the design.
|
||||
- The glyph contract is `ui/fontset.py` — the installed console font's exact codepoint
|
||||
inventory, device-verified. A character outside it is a test failure, not a tofu box
|
||||
found on-device. The font itself is built by `scripts/calculinux-console-font.sh`;
|
||||
found on-device. The font itself is built by `scripts/picocalc/calculinux-console-font-6x12.sh`;
|
||||
the two files move in the same commit.
|
||||
- The 16-slot palette is `theme._VT_SLOTS` (programmed via `/etc/vtrgb`; same script).
|
||||
`MESH_THEME_16` speaks `color(0..15)` only; backgrounds stop at slot 7. Both themes
|
||||
@@ -355,7 +609,7 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
|
||||
base style into every span, so a silent one changes colour inside a selected row. Same
|
||||
trap outside the theme: `MapCanvas` drops emphasis entirely where bold is brightness,
|
||||
since its colours arrive quantized and it can't know which bank they landed in, and the
|
||||
fold's quantizer (`theme._nearest_slot_sgr`) states intent for it — a dim slot leaves as
|
||||
fold's quantizer (`theme._nearest_slot_params`) states intent for it — a dim slot leaves as
|
||||
`22;3N`, never a bare `3N`, because `9N` is *how* the console spells bright and adjacent
|
||||
art spans (the wordmark's bevels, a raster's neighbouring cells) reset nothing between
|
||||
them, so a bare one inherits the intensity bit and lands a bank too high mid-row.
|
||||
|
||||
+67
-4
@@ -25,8 +25,66 @@ this repository.
|
||||
seen, etc.), UX standards for screens/dialogs/menus, and where the reusable building
|
||||
blocks live in code (`ui/menus.py`, `ui/markdown.py`, `ui/widgets.py`, `ui/theme.py`).
|
||||
New code should read like it always belonged here.
|
||||
- For anything bigger than a small fix, open an issue first to talk it through before
|
||||
writing a lot of code — saves both of us a rewrite.
|
||||
- Tell us before you start working on something (see below). For anything bigger than a
|
||||
small fix, expect to talk it through first. That saves everyone a rewrite.
|
||||
|
||||
## Who can contribute
|
||||
|
||||
If you use MeshTerm, you're welcome here. Maybe something broke for you, or you wished
|
||||
it could do one more thing, or a screen looked wrong on your radio. Those are exactly
|
||||
the changes we want.
|
||||
|
||||
### How MeshTerm was made
|
||||
|
||||
Some people in the MeshCore community don't trust code written by AI. After some of
|
||||
what has happened lately, that's easy to understand, and it deserves a straight answer.
|
||||
|
||||
Jean-Pierre Martineau, who started MeshTerm, sees it differently. AI was a big help on
|
||||
this project. Honestly, MeshTerm couldn't have been built without it. But the choices
|
||||
were his. He decided what MeshTerm should be, what it should do, and how it should
|
||||
look. He uses it every day. It's handmade, with AI's help.
|
||||
|
||||
The project wants to stay that way. That's what the rules below are for.
|
||||
|
||||
### Come say hello first
|
||||
|
||||
Using AI tools to help you work is fine. What matters is that a real person who cares
|
||||
about MeshTerm is behind the change.
|
||||
|
||||
The MeshTerm community lives on [Discord](https://discord.gg/AZwe5Uvb3S), and everyone
|
||||
is welcome to join. But please join before you change things. Before you start writing
|
||||
code, say hello in the **#contributing** channel and tell us what you'd like to work on.
|
||||
Then wait until we say yes. This goes for small fixes too.
|
||||
|
||||
When you open your pull request, include two things:
|
||||
|
||||
- **Your Discord username**, the one you use on the MeshTerm server.
|
||||
- **A link to the message where we said yes.** In Discord, right-click the message and
|
||||
choose "Copy Message Link."
|
||||
|
||||
If you haven't joined the Discord, or nobody said yes to your idea, your pull request
|
||||
will be closed without a review.
|
||||
|
||||
### No bots
|
||||
|
||||
Some accounts use AI to find open issues, write a fix, and open a pull request, all on
|
||||
their own. There's no person behind it who read the change or can answer questions
|
||||
about it. People call this "AI slop." We close those pull requests without a review,
|
||||
even if the code looks fine. The same goes for issues and bug reports written that way.
|
||||
|
||||
### Tell us if you used AI
|
||||
|
||||
This part is required. If an AI tool wrote any part of your change, say so in two
|
||||
places:
|
||||
|
||||
- **In each commit it helped with.** Add a line at the end of the commit message naming
|
||||
the tool, like `Co-Authored-By: Claude <noreply@anthropic.com>` or
|
||||
`Assisted-by: GitHub Copilot`.
|
||||
- **In your pull request.** Write a sentence about what the tool did and what you
|
||||
checked yourself.
|
||||
|
||||
If you used AI and didn't say so, we'll treat your pull request as a bot's. Saying so
|
||||
costs you nothing. Either way, you're the author, so be ready to explain your change.
|
||||
|
||||
## Making a change
|
||||
|
||||
@@ -61,7 +119,12 @@ this repository.
|
||||
Open a GitHub issue. For bugs, include what you expected, what happened, and enough to
|
||||
reproduce it — device/platform, MeshCore firmware version if relevant, and steps.
|
||||
|
||||
The [bug form](https://github.com/jpmartineau/MeshTerm/issues/new?template=bug.yml) asks
|
||||
for all of this. Attach the file that `meshterm diagnostics --out bug.md` writes. No
|
||||
GitHub account? Post it in the #bugs forum on [Discord](https://discord.gg/AZwe5Uvb3S)
|
||||
instead.
|
||||
|
||||
---
|
||||
|
||||
Questions about any of this, licensing included, are welcome as a GitHub issue or
|
||||
discussion before you put work in.
|
||||
Questions about any of this, licensing included, are welcome as a GitHub issue before you
|
||||
put work in.
|
||||
@@ -25,6 +25,14 @@ project and is not authorized to do so.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Device script: scripts/picocalc/calculinux-console-font-6x8.sh embeds the Linux kernel's
|
||||
font_6x8 bitmap (lib/fonts/font_6x8.c) and is therefore licensed GPL-2.0-only,
|
||||
as is the console font it builds; its terms are included beside it as
|
||||
scripts/picocalc/LICENSE.GPL-2.0 and are its only license -- the Apache License above
|
||||
does not apply to it. Nothing GPL-2.0 reaches the MeshTerm package: that script
|
||||
is excluded from the distribution, runs on the device, and produces a PSF data
|
||||
file the Linux console driver loads, which MeshTerm neither links nor bundles.
|
||||
|
||||
Bundled font: meshterm/assets/fonts/CascadiaMonoPL.ttf is Cascadia Mono PL,
|
||||
Copyright (c) 2019 - Present, Microsoft Corporation, with Reserved Font Name
|
||||
Cascadia Code. It is redistributed here unmodified under the SIL Open Font
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# MeshTerm
|
||||
|
||||
**A full-screen terminal companion for [MeshCore](https://meshcore.io/) LoRa mesh
|
||||
devices** — tune your radio, chat across the mesh, watch the network live, and keep a
|
||||
longitudinal record of everything you overhear, all from a connected serial, Bluetooth, or
|
||||
network (TCP) companion.
|
||||
**A full-featured TUI [MeshCore](https://meshcore.io/) client for your terminal** — tune
|
||||
your radio, chat across the mesh, watch the network live, and keep a longitudinal record
|
||||
of everything you overhear, all from a connected serial, Bluetooth, or network (TCP)
|
||||
companion.
|
||||
|
||||
MeshTerm is interactive by default: a modern, keyboard-driven TUI built on
|
||||
[Rich](https://github.com/Textualize/rich) and
|
||||
[prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit). It is also
|
||||
fully scriptable — **every screen has a mirror `meshterm` subcommand** — so the same
|
||||
fully scriptable — **most screens have a mirror `meshterm` subcommand** — so the same
|
||||
capabilities drive both an evening of exploring the mesh and a cron job. Every packet the
|
||||
radio overhears is recorded to a local SQLite database, so the longer you run it, the more
|
||||
your mesh's history is worth.
|
||||
@@ -23,14 +23,26 @@ your mesh's history is worth.
|
||||
<a href="https://discord.gg/AZwe5Uvb3S"><img alt="Discord" src="https://img.shields.io/badge/chat-Discord-5865F2"></a>
|
||||
</p>
|
||||
|
||||
> MeshTerm is a side project, not a product. Bug reports are very welcome — but please
|
||||
> open an issue before writing a pull request. [More on how it's run](#how-this-project-is-run).
|
||||
> MeshTerm is a side project, run by one person. Bug reports are very welcome. Want to
|
||||
> change something? Say hello on [Discord](https://discord.gg/AZwe5Uvb3S) first.
|
||||
> [More on how it's run](#how-this-project-is-run).
|
||||
|
||||
<!--
|
||||
Screenshots welcome here. Drop PNGs into docs/ and reference them, e.g.:
|
||||

|
||||

|
||||
-->
|
||||
https://github.com/user-attachments/assets/f91a6695-14d5-4ae0-8fdc-e65492d0955d
|
||||
|
||||
<p align="center"><sub>Under two minutes on a real mesh.</sub></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/dashboard.png" alt="The dashboard: a packets-per-minute timeline over the last two hours, and every packet class counted" width="49%">
|
||||
<img src="docs/screenshots/map.png" alt="The map: repeaters named over an OpenStreetMap basemap of the Lachine Canal" width="49%">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/trace.png" alt="A trace: the route it walked drawn as chips, then the SNR of every hop" width="49%">
|
||||
<img src="docs/screenshots/path.png" alt="Message paths: every route a channel message was heard over, drawn on the ground" width="49%">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/picocalc.png" alt="MeshTerm on the PicoCalc, a 53-column console with a radio wired inside" width="38%">
|
||||
</p>
|
||||
<p align="center"><sub>The dashboard, the map, a trace, the paths a message took — and the whole thing on a PicoCalc.</sub></p>
|
||||
|
||||
---
|
||||
|
||||
@@ -46,10 +58,12 @@ your mesh's history is worth.
|
||||
sweep, and a walkable graph of how the mesh actually hangs together.
|
||||
- **It talks.** Live channel and direct messaging, store-and-forward courier delivery for
|
||||
contacts that aren't there yet, and shareable channels as QR codes and `meshcore://` links.
|
||||
- **It connects however your companion does.** Serial (USB), Bluetooth LE, or TCP (a WiFi
|
||||
- **It connects however your companion does.** Serial (USB), Bluetooth LE, or TCP (a Wi-Fi
|
||||
board or a network proxy) — the discoverable transports are auto-found and picked
|
||||
interactively, a network device is named by hand, and every kind reconnects live when a link
|
||||
drops. No radio? A built-in simulator covers development.
|
||||
drops. It pairs a PIN-protected Bluetooth companion itself on Windows and Linux, and when a
|
||||
connection fails it says which step failed and what to do. No radio? A built-in simulator
|
||||
covers development.
|
||||
- **It works offline.** The street map caches OpenStreetMap tiles to disk and falls back to
|
||||
a blank grid when there's no network — it never needs the internet to run.
|
||||
|
||||
@@ -57,76 +71,92 @@ your mesh's history is worth.
|
||||
|
||||
### Download a build
|
||||
|
||||
**[⬇ Latest release](https://github.com/jpmartineau/MeshTerm/releases/latest)** — one file,
|
||||
no Python needed. Windows, macOS (Intel and Apple silicon), and Linux (x64 and ARM64, so
|
||||
the uConsole is covered).
|
||||
One file, no Python needed — Windows, macOS (Intel and Apple silicon), and Linux (x64 and
|
||||
ARM64, so the uConsole is covered). Every command below fetches the
|
||||
**[⬇ latest release](https://github.com/jpmartineau/MeshTerm/releases/latest)**, whichever
|
||||
one that is, so none of them goes stale.
|
||||
|
||||
MeshTerm is a terminal program, so **open a terminal and run it from there.** You can
|
||||
double-click it and it will work, but you'll get whatever console your system picks, and
|
||||
if anything goes wrong at startup the window closes before you can read why.
|
||||
|
||||
**Windows** — open Windows Terminal or PowerShell, `cd` to your downloads, then:
|
||||
#### macOS — download it with `curl`
|
||||
|
||||
```powershell
|
||||
.\meshterm-0.2.8-windows-x64.exe
|
||||
```
|
||||
|
||||
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
|
||||
**Don't use your browser.** A browser flags the file as downloaded, and macOS then refuses
|
||||
to open it at all — on Sequoia and later, right-click → Open no longer gets you past that
|
||||
either. `curl` flags nothing, so what it hands you simply runs. Open Terminal and paste:
|
||||
|
||||
```bash
|
||||
chmod +x meshterm-0.2.8-*
|
||||
./meshterm-0.2.8-*
|
||||
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-macos-arm64
|
||||
chmod +x meshterm
|
||||
./meshterm
|
||||
```
|
||||
|
||||
### A word about terminals, on Windows
|
||||
That's the build for Apple silicon Macs (M1 and later). On an **Intel** Mac, swap `arm64`
|
||||
for `x64`. And if you already downloaded it with a browser, you don't have to start over:
|
||||
`xattr -d com.apple.quarantine meshterm` clears the flag.
|
||||
|
||||
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether you
|
||||
see them is up to your *terminal*, not really your font: a modern terminal, asked for a
|
||||
character its font doesn't have, quietly borrows it from another font on the machine.
|
||||
That's why the app looks right in Windows Terminal, in VS Code's terminal, and on macOS
|
||||
and Linux — usually with a font that contains almost none of it.
|
||||
#### Linux — the same three lines
|
||||
|
||||
`linux-x64` on a PC, `linux-arm64` on a uConsole or another 64-bit ARM handheld.
|
||||
|
||||
```bash
|
||||
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-linux-x64
|
||||
chmod +x meshterm
|
||||
./meshterm
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
Download
|
||||
**[meshterm-windows-x64.exe](https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-windows-x64.exe)**,
|
||||
then open Windows Terminal or PowerShell, `cd` to your downloads, and run it:
|
||||
|
||||
```powershell
|
||||
.\meshterm-windows-x64.exe
|
||||
```
|
||||
|
||||
Windows says *"Windows protected your PC"* the first time. Click **More info → Run anyway**.
|
||||
|
||||
Whichever you took, you now have a single file: `meshterm` on macOS and Linux,
|
||||
`meshterm-windows-x64.exe` on Windows. Put it somewhere on your `PATH` (renamed to
|
||||
`meshterm.exe` on Windows) and it's just `meshterm` from anywhere.
|
||||
|
||||
> **These builds aren't code-signed**, which is why macOS and Windows both push back the
|
||||
> first time; signing costs real money on both platforms and this is a free side project.
|
||||
> Every release ships a `SHA256SUMS` file if you'd rather check what you got, and
|
||||
> [installing with pip or pipx](#or-install-with-pip) sidesteps the whole business, since
|
||||
> nothing arrives as a downloaded binary.
|
||||
>
|
||||
> Each release page also carries these commands written out for **that exact version**,
|
||||
> next to its own downloads.
|
||||
|
||||
### If your terminal draws empty boxes
|
||||
|
||||
MeshTerm is drawn with emoji icons, braille charts, and powerline path chips, and whether
|
||||
you see them is up to your *terminal* rather than your font. Windows Terminal, VS Code's
|
||||
terminal, and anything modern on macOS and Linux all draw them correctly.
|
||||
|
||||
The classic Windows console — the black `cmd.exe` window you get from a double-click —
|
||||
doesn't borrow. It draws what its one font holds and empty boxes for everything else, and
|
||||
no font fixes the icons there: the only two fonts on a Windows machine with emoji in them
|
||||
are proportional, and a console won't take a proportional font.
|
||||
|
||||
So when MeshTerm lands in that console it just **moves to Windows Terminal** — it says
|
||||
so, and opens there. Nothing is installed and nothing is changed; it's one window instead
|
||||
of another, and it's the whole app exactly as the screenshots show it. Windows Terminal is
|
||||
already on every Windows 11 machine and is a free install on Windows 10.
|
||||
|
||||
Where there's no Windows Terminal to move to, MeshTerm offers the next best thing instead
|
||||
— the charts and marks, without the icons. It ships
|
||||
[Cascadia Mono PL](https://github.com/microsoft/cascadia-code), Microsoft's own console
|
||||
font and one of the very few monospace faces that carries braille at all, and will install
|
||||
it just for you: no administrator rights, nothing downloaded. If you already have Cascadia
|
||||
it simply switches to it.
|
||||
does not. So when MeshTerm lands there it **moves to Windows Terminal**, says so, and
|
||||
opens there; nothing is installed and nothing is changed. Where there's no Windows
|
||||
Terminal to move to, it offers the charts and marks without the icons instead, and will
|
||||
install Microsoft's [Cascadia Mono PL](https://github.com/microsoft/cascadia-code) just
|
||||
for you — no administrator rights, nothing downloaded.
|
||||
|
||||
Preferences → Display → Console setup turns all of this off if you'd rather stay put.
|
||||
|
||||
Rename it to something you don't mind typing and put it somewhere on your `PATH`, and it
|
||||
becomes just `meshterm` from anywhere.
|
||||
|
||||
> **Your system will complain the first time, and it's right to.** These builds aren't
|
||||
> code-signed — signing costs real money on both platforms and this is a free side
|
||||
> project.
|
||||
>
|
||||
> - **macOS**: *"cannot be opened because the developer cannot be verified"*. Clear it with
|
||||
> `xattr -d com.apple.quarantine meshterm-0.2.8-*` and run it again.
|
||||
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
|
||||
>
|
||||
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
|
||||
[Terminals, icons, and the Windows console](docs/terminals.md) explains why any of it is
|
||||
necessary.
|
||||
|
||||
> **Trying a build without touching your real data.** MeshTerm keeps everything in
|
||||
> `~/.meshterm` — your history database, contacts, channel keys — and *every* copy of
|
||||
> MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation:
|
||||
>
|
||||
> ```bash
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.2.8-* # macOS, Linux
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm # macOS, Linux
|
||||
> ```
|
||||
> ```powershell
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.8-windows-x64.exe
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-windows-x64.exe
|
||||
> ```
|
||||
>
|
||||
> Same variable if you run two radios and want them kept apart.
|
||||
@@ -160,18 +190,38 @@ question is still being sorted out ([why](https://github.com/pypi/support/issues
|
||||
|
||||
Setting up for development instead? That's in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
**[The documentation index](docs/README.md)** lists everything, including the two code
|
||||
surveys kept as a historical record. The short version:
|
||||
|
||||
| Read | For |
|
||||
| --- | --- |
|
||||
| **[The command line](docs/cli.md)** | Every subcommand and option, what each prints on the plain and JSON faces, the exit statuses, recipes. |
|
||||
| **[What MeshTerm does](docs/features.md)** | Every screen the menu offers, and the command that does the same job without it. |
|
||||
| [The CLI cookbook](docs/cookbook.md) | Common one-liners, by the thing you're trying to do. |
|
||||
| [Configuring MeshTerm](docs/configuration.md) | Preferences, device profiles, and everything kept under `~/.meshterm`. |
|
||||
| [When a companion won't connect](docs/connecting.md) | Bluetooth pairing on each system, finding the PIN, USB permissions, and every connection error, with what to do about it. |
|
||||
| [MeshTerm on hardware](docs/hardware.md) | Which handheld manual is yours — the [PicoCalc](docs/picocalc.md) build, or the [uConsole](docs/uconsole.md), whose LoRa chip MeshTerm can drive directly. |
|
||||
| [How the code is laid out](docs/architecture.md) | The layering, and where a new feature goes. |
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | The development setup and the house rules. |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | What changed, newest first. |
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Interactive full-screen menu (auto-discovers serial + Bluetooth companions)
|
||||
meshterm
|
||||
|
||||
# Connect over Bluetooth (address from `meshterm devices`)
|
||||
meshterm --ble AA:BB:CC:DD:EE:FF info
|
||||
# Connect over Bluetooth (address from `meshterm devices`); add --ble-pin if it has a PIN
|
||||
meshterm --ble AA:BB:CC:DD:EE:FF --ble-pin 123456 info
|
||||
|
||||
# Connect over the network to a TCP companion (host[:port], port defaults to 5000)
|
||||
meshterm --tcp 192.168.1.50 info
|
||||
|
||||
# Drive a LoRa radio on this machine's own SPI bus (the uConsole AIO; Linux only)
|
||||
meshterm --spi info
|
||||
|
||||
# No radio attached? Use the built-in simulator for development.
|
||||
meshterm --mock
|
||||
|
||||
@@ -182,175 +232,59 @@ meshterm --mock --platform picocalc
|
||||
meshterm specimen
|
||||
```
|
||||
|
||||
Handheld bring-up (fonts, palette, boot services) lives in
|
||||
`scripts/calculinux-setup.sh` + `scripts/calculinux-console-font.sh`.
|
||||
Putting MeshTerm on a handheld has its own manuals. **[MeshTerm on the
|
||||
PicoCalc](docs/picocalc.md)** takes a stock PicoCalc from the shopping list to a Linux
|
||||
handheld with a LoRa radio soldered inside; **[MeshTerm on the uConsole](docs/uconsole.md)**
|
||||
drives a uConsole's SPI LoRa board directly with `--spi`, or through a small bridge if you
|
||||
want the node on the mesh all the time. The device-side scripts both manuals run are in
|
||||
[`scripts/`](scripts/); not sure which manual is yours?
|
||||
[`docs/hardware.md`](docs/hardware.md) says.
|
||||
|
||||
## Features
|
||||
A companion that won't connect? **[When a companion won't
|
||||
connect](docs/connecting.md)** covers Bluetooth pairing on Windows, Linux, and macOS, the
|
||||
`dialout` group and ModemManager for USB on Linux, and every error message MeshTerm gives.
|
||||
|
||||
MeshTerm's menu answers one question — *what would you like to do?* — so its five
|
||||
sections are named for the doing, not the subject. Every interactive screen is listed
|
||||
here with its scripted equivalent, where one exists.
|
||||
## What it does
|
||||
|
||||
### 💬 Message — the people on the other end
|
||||
The menu asks one question — *what would you like to do?* — and answers it in two halves.
|
||||
Three sections name a doing: **Message** is chat, channels, the courier outbox, and
|
||||
contacts; **Watch** is the dashboard, the live feed, the watchtower, and the time machine;
|
||||
**Explore** is the map, the mesh walk, tracing, and the trophy case. Three name whose it is:
|
||||
**This node** is the radio in your hand, **Other nodes** is someone else's over the mesh,
|
||||
and **This app** is MeshTerm itself — preferences, diagnostics, and the written pages.
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **💬 Chat** | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. Every message is logged; unread counts show in the menu header. | `meshterm chat send / history / list` |
|
||||
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels — with QR codes and `meshcore://` share links. | `meshterm channels list / add / join / import / share / clear` |
|
||||
| **📨 Courier** | Store-and-forward outbox for contacts that aren't reachable yet. Queue a message; it goes out (with ack tracking and polite exponential backoff) the moment the contact is next heard, or at a scheduled time. | `meshterm courier queue / list / send / cancel / clear` |
|
||||
| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. | `meshterm contacts` |
|
||||
Nearly every one of them has a mirror `meshterm` subcommand — the same registry builds
|
||||
the menu and the CLI, so they can't drift apart. The live screens, such as the map and
|
||||
the dashboard, are only in the menu.
|
||||
|
||||
### 📊 Watch — what the mesh is doing, and what it did
|
||||
**[What MeshTerm does](docs/features.md)** is the full catalogue: every screen, what it
|
||||
does, and its scripted equivalent where one exists.
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **📊 Dashboard** | The live mesh overview: a two-hour all-packet activity chart with a pulse line, session traffic tallies by packet class, and window RF health beside the radio's own live numbers. Repaints every second. | — |
|
||||
| **📰 Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* — the node for an advert, the channel for a channel text, sender → recipient for a direct message or a request, the tag for a trace — with reception quality beside it. Enter opens any row in the packet viewer, route graph and all. | — |
|
||||
| **🚨 Watchtower** | A passive sentinel over the nodes you star: silence alarms when a watched node goes quiet, SNR-sag warnings when reception degrades, recovery notes when it returns, and a heads-up when a never-before-seen node appears. Runs in the background all session; unacked alerts show as a header badge. | — |
|
||||
| **⏳ Time machine** | Everything the recorder ever heard, as braille charts: reception volume, the median-SNR band, hour-of-day rhythm, packets and nodes per day, and first-ever arrivals — over a switchable 24h / 7d / 30d / all-time window, per node or mesh-wide. | — |
|
||||
| **🎧 Monitor** | Passive recording is always on in the app. On the CLI, capture a bounded foreground window, tailing each overheard packet and summarising when it ends. It transmits nothing — it only listens. | `meshterm monitor --seconds 60` |
|
||||
## Scripting it
|
||||
|
||||
### 🧭 Explore — where the nodes are and how they reach each other
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | `meshterm map` (one-shot render) |
|
||||
| **🌐 Mesh walk** | The mesh's *observed shape*, walked one node at a time: an evidence graph built from trace walks, firmware routes, overheard relay chains, and repeater neighbour tables. SNR-coloured braille edges, quality bars, Enter to walk, ⌫ to backtrack, type to find any node. The map answers *where*; the walk answers *how it hangs together*. | — |
|
||||
| **🎯 Trace target** | A live trace screen: pick a target and watch each trace stream in hop by hop, with running per-hop medians and reliability. Compose or force a route through specific repeaters. A trace transmits **exactly once** (repeaters can blacklist nodes that burst) — sample more by running it again. | `meshterm trace --target …` |
|
||||
| **👣 Trace path** | The other half of tracing: compose the whole circuit by hand — out and back whichever way you choose — and walk it. The path composer suggests each next hop from the links actually observed, strongest first, and can fetch a repeater's neighbour table over the mesh when you hold its admin password. | `meshterm trace --path …` |
|
||||
| **🏆 Trophy case** | Every trace that comes home is scored, on seven boards: longest distance, farthest node, longest single leg, most nodes (with and without revisits), weakest surviving link, and biggest enclosed loop. Records are kept per hash width, and a record walk must be a *trail* — no link crossed twice the same way. | `meshterm records` |
|
||||
|
||||
### 🔧 This node — the radio in your hand
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **📋 Device info** | The connected companion's identity and full radio configuration at a glance. | `meshterm info` |
|
||||
| **🔧 Device config** | View and *stage* changes to every setting (name, radio, behaviour, experimental) for review before applying, with TOML backup/restore. | `meshterm config` / `config set <key> <value>` |
|
||||
| **🔨 Device actions** | Immediate operations on the box itself: clock sync, TOML backup/restore, the identity key, reboot, and factory reset — each acting the moment it's confirmed, with destructive ops gated. | `config backup` / `restore` / `reboot` / … |
|
||||
| **📡 Send advert** | Announce this node to the mesh — a zero-hop or flood advertisement, or share this node's contact card as a QR code. | `meshterm config advert` / `config share` |
|
||||
| **🔌 Device discovery** | Enumerate serial *and* Bluetooth LE companions, pick one interactively (or auto-select the only one present), add a network (TCP) companion by host:port, and remember the last good default. A dropped link is detected live and offers to reconnect. | `meshterm devices` |
|
||||
|
||||
### 🗼 Other nodes — someone else's radio, over the mesh
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **🗼 Repeater admin** | Set up remote repeaters and room servers over the mesh: log in (remembered or prompted password), then a config-style editor speaking the node's text CLI — including repeater-only knobs (TX delay, airtime factor, advert intervals) — plus one-shot actions and a readline remote command line. | `meshterm repeater-admin <node> <command…>` |
|
||||
| **📶 TX optimize** | Sweep a remote node's transmit power live — coarse, then refine, then verify — watch each level land, and decide whether to apply the winner. | `meshterm tx-optimize --path … [--apply]` |
|
||||
|
||||
## CLI cookbook
|
||||
|
||||
Every menu option is also a Typer subcommand — ideal for scripting, cron, and bots.
|
||||
Nearly every command speaks `--json`, and the global options — `--profile/-p`, `--port`,
|
||||
`--ble`, `--ble-pin`, `--tcp`, `--spi`, `--mock`, `--db`, `--json`, `--absolute`, `--quiet/-q`,
|
||||
`--platform` — may be typed before or after the subcommand.
|
||||
|
||||
```bash
|
||||
# Trace — a trace transmits exactly once; run it again to sample more.
|
||||
# Every repeater's public key, for a script
|
||||
meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
|
||||
|
||||
# A trace transmits exactly once; run it again to sample more
|
||||
meshterm trace --target Alice --profile yagi
|
||||
|
||||
# Force a route through specific repeaters (MeshCore-app style): comma-separated
|
||||
# contact names and/or hex key prefixes, mixed freely. Blank lets the device route.
|
||||
meshterm trace --target Alice --path "3d,f2,3d"
|
||||
meshterm trace --target Alice --path "3d,Bravo-Repeater,f2"
|
||||
|
||||
# Sweep and apply a remote node's TX power
|
||||
meshterm tx-optimize --path "Bravo-Repeater,Alice" --samples 6 --step 3 --apply
|
||||
|
||||
# Messaging: live chat in the menu, or scripted from the CLI
|
||||
meshterm chat # interactive: pick a conversation, chat live
|
||||
meshterm chat send --to Alice "on my way" # direct message
|
||||
meshterm chat send --channel 0 "net in 5" # channel broadcast
|
||||
meshterm chat history --to Alice
|
||||
meshterm chat list
|
||||
|
||||
# Store-and-forward: queue for a contact that's offline right now
|
||||
# Messaging, and store-and-forward for a contact who isn't there yet
|
||||
meshterm chat send --channel 0 "net in 5"
|
||||
meshterm courier queue Alice "ping me when you're back" --at 18:30
|
||||
meshterm courier list # the outbox — waiting and finished
|
||||
meshterm courier send 3 # force one delivery attempt now
|
||||
|
||||
# Remote repeater admin (one transmission per invocation)
|
||||
meshterm repeater-admin Bravo-Repeater "get name"
|
||||
|
||||
# Device configuration: view, set, back up, restore
|
||||
meshterm config # show all current settings
|
||||
meshterm config set radio_sf 9 # change one setting
|
||||
meshterm config backup node.toml # archive every setting to TOML
|
||||
meshterm config restore node.toml --dry-run
|
||||
meshterm config advert-cadence 2 # auto-advert to neighbours every 2 h (0 = off)
|
||||
meshterm config advert-cadence 24 --flood # flood the wider mesh daily
|
||||
|
||||
# Passive capture window (records to history; transmits nothing)
|
||||
# The radio's own settings, and a passive capture window
|
||||
meshterm config set radio_sf 9
|
||||
meshterm monitor --seconds 60
|
||||
```
|
||||
|
||||
Global options (before the subcommand): `--profile/-p`, `--port`, `--ble`, `--ble-pin`,
|
||||
`--tcp`, `--mock`, `--db`, `--json`, `--quiet/-q`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Two files, two jobs — and you need neither to run.
|
||||
|
||||
**Preferences** are how MeshTerm behaves: the pause it leaves between transmissions, how
|
||||
hard it retries a message, how far back it keeps history, how a map frames itself. Every one has a built-in default, so change them only where you disagree — from
|
||||
the **Preferences** page in the menu (grouped, staged, saved by one action at the bottom),
|
||||
or from a shell:
|
||||
|
||||
```console
|
||||
$ meshterm preferences show # every preference, its value, its default
|
||||
$ meshterm preferences set history_days 90
|
||||
$ meshterm preferences reset --yes # back to the built-in defaults
|
||||
```
|
||||
|
||||
They are kept in `~/.meshterm/preferences.yaml`, which lists only what you have changed;
|
||||
delete a line and the default takes over again.
|
||||
|
||||
**Config** is where things live and which device to talk to. MeshTerm runs with none of it
|
||||
— it discovers attached serial devices and nearby Bluetooth companions, lets you pick one,
|
||||
and remembers the last good default. A network (TCP) companion isn't discoverable, so reach
|
||||
it with `--tcp host:port`, a TCP profile, or the picker's "add a network device" prompt.
|
||||
Write a config file only to give your hardware stable aliases.
|
||||
|
||||
Copy [`config.example.toml`](config.example.toml) to `~/.meshterm/config.toml`:
|
||||
|
||||
```toml
|
||||
default_profile = "s3"
|
||||
connect_on_start = true # false opens the radio link lazily instead of at launch
|
||||
|
||||
[profiles.s3]
|
||||
port = "COM5"
|
||||
baudrate = 115200
|
||||
default_tx_power = 20
|
||||
description = "XIAO ESP32-S3 + Wio SX1262 serial companion"
|
||||
|
||||
# A Bluetooth LE companion: give it an `address` instead of a `port`.
|
||||
[profiles.handheld]
|
||||
address = "AA:BB:CC:DD:EE:FF"
|
||||
# ble_pin = "123456" # only if your device requires a pairing PIN
|
||||
description = "Pocket handheld over Bluetooth"
|
||||
|
||||
# A network (TCP) companion: give it a `host` (and optional `tcp_port`, default 5000).
|
||||
[profiles.wifi]
|
||||
host = "192.168.1.50"
|
||||
description = "Basestation over WiFi"
|
||||
```
|
||||
|
||||
Timestamps are stored as UTC and rendered in your local time. History older than the
|
||||
`history_days` preference is pruned once at session start.
|
||||
|
||||
## Architecture
|
||||
|
||||
MeshTerm is layered so a new feature is one file in `meshterm/tools/`:
|
||||
|
||||
```
|
||||
cli.py Typer app; no subcommand -> interactive menu
|
||||
context.py AppContext (console, config, repository, device) dependency container
|
||||
core/ Domain: models, preferences + device profiles, connection abstraction (+ mock)
|
||||
tools/ Pluggable "menu options"; each self-registers and gets logging for free
|
||||
services/ Background algorithms (monitor, trace, tx search, courier, watchtower) — no UI
|
||||
persistence/ SQLite schema, repository, structured logging
|
||||
ui/ Rich theme/widgets + the full-screen TUI (screens, dialogs, map, chat)
|
||||
```
|
||||
|
||||
Adding a feature means subclassing `Tool`, decorating it with `@register`, and
|
||||
implementing `run()`. The same registry builds both the CLI and the menu, and the base
|
||||
class wraps every execution in a logged `runs` row automatically.
|
||||
**[The command line](docs/cli.md)** is the manual — every command and option, what each
|
||||
prints on both faces, and the exit statuses.
|
||||
**[The CLI cookbook](docs/cookbook.md)** has more one-liners like these.
|
||||
|
||||
## How this project is run
|
||||
|
||||
@@ -358,18 +292,21 @@ MeshTerm is one person working evenings and weekends. I'd rather tell you that u
|
||||
than have you guess from how long things take.
|
||||
|
||||
**Issues are welcome — all of them.** Bugs, questions, "is this supposed to do that". The
|
||||
[bug form](.github/ISSUE_TEMPLATE/bug.yml) asks for a fair bit, and that's on purpose: how
|
||||
well a problem is described really does decide whether I can do anything with it. If I can
|
||||
reproduce it, I'll usually chase it. If I can't, I'm mostly guessing.
|
||||
[bug form](https://github.com/jpmartineau/MeshTerm/issues/new?template=bug.yml) asks for a
|
||||
fair bit, and that's on purpose: how well a problem is described really does decide whether
|
||||
I can do anything with it. If I can reproduce it, I'll usually chase it. If I can't, I'm
|
||||
mostly guessing.
|
||||
|
||||
**Ask before you write a pull request.** Open an issue first and wait for a yes. I'm not
|
||||
being precious — MeshTerm has firm house rules about how screens get built (they're in
|
||||
[CLAUDE.md](CLAUDE.md)), and I'd hate for you to spend a weekend on something I then ask
|
||||
you to rewrite. A quick conversation first saves us both.
|
||||
**Ask before you write a pull request.** Join the [Discord](https://discord.gg/AZwe5Uvb3S)
|
||||
and say hello in `#contributing`. Tell us what you'd like to change. Then wait for a yes.
|
||||
That goes for small fixes too. I'm not being precious. MeshTerm has firm house rules
|
||||
about how screens get built (they're in [CLAUDE.md](CLAUDE.md)), and I'd hate for you to
|
||||
spend a weekend on something I then ask you to rewrite. A quick conversation first saves
|
||||
us both. AI tools are fine, as long as you say you used them.
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) has the details.
|
||||
|
||||
**I can't promise timelines.** Some things get fixed the same night. Some sit for a month
|
||||
because life happened. If your issue goes quiet, a nudge is completely fine — it's not
|
||||
rude, it's helpful.
|
||||
because life happened. If your issue goes quiet, please give it a nudge. That helps me.
|
||||
|
||||
**Where to report things.** Either works:
|
||||
|
||||
@@ -387,19 +324,6 @@ Also here: [CONTRIBUTING.md](CONTRIBUTING.md) ·
|
||||
[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
|
||||
[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
python -m pytest -q # tests
|
||||
ruff check . # lint
|
||||
ruff format . # format
|
||||
```
|
||||
|
||||
Screens are designed to stay readable at **72 columns**.
|
||||
|
||||
Want to send a pull request? See [CONTRIBUTING.md](CONTRIBUTING.md) first.
|
||||
|
||||
## Acknowledgements & attribution
|
||||
|
||||
MeshTerm stands on the [MeshCore](https://meshcore.io/) project — its firmware and the
|
||||
@@ -408,17 +332,25 @@ MeshTerm stands on the [MeshCore](https://meshcore.io/) project — its firmware
|
||||
### Map data
|
||||
|
||||
The street map is rendered from **© OpenStreetMap contributors** data, served as vector
|
||||
tiles by [OpenFreeMap](https://openfreemap.org/). OpenStreetMap data is available under the
|
||||
[Open Database License (ODbL)](https://www.openstreetmap.org/copyright). Tiles are decoded
|
||||
with a small built-in reader for the [Mapbox Vector Tile](https://github.com/mapbox/vector-tile-spec)
|
||||
format.
|
||||
tiles by [OpenFreeMap](https://openfreemap.org/) on the unmodified
|
||||
**© OpenMapTiles** [schema](https://openmaptiles.org/). OpenStreetMap data is available
|
||||
under the [Open Database License (ODbL)](https://www.openstreetmap.org/copyright). Tiles
|
||||
are decoded with a small built-in reader for the
|
||||
[Mapbox Vector Tile](https://github.com/mapbox/vector-tile-spec) format.
|
||||
|
||||
Both map surfaces carry the credit themselves. The full-screen map opens with
|
||||
`© OpenMapTiles · Data from OpenStreetMap` in its bottom-right corner and collapses it to
|
||||
`© OpenStreetMap` once you pan, zoom, or type — set into the panel's bottom border rule on
|
||||
a desktop terminal, so the drawing itself keeps every cell, and on the map's own last row
|
||||
on the PicoCalc, whose frame has no bottom rule. The node page's location preview shows the
|
||||
short form throughout. The About page inside the app spells out the licence URL.
|
||||
|
||||
### Bundled font
|
||||
|
||||
MeshTerm ships **Cascadia Mono PL**, © 2019–present Microsoft Corporation, redistributed
|
||||
unmodified under the [SIL Open Font License 1.1](meshterm/assets/fonts/CascadiaMono-OFL.txt).
|
||||
It's offered to Windows users whose console can't draw the charts — see
|
||||
[A word about fonts](#a-word-about-fonts). Microsoft doesn't endorse MeshTerm; the font is
|
||||
[Terminals, icons, and the Windows console](docs/terminals.md). Microsoft doesn't endorse MeshTerm; the font is
|
||||
simply the right tool, being one of the very few monospace faces that carries the braille
|
||||
block the timelines are drawn from.
|
||||
|
||||
@@ -429,15 +361,16 @@ respective project for the authoritative terms):
|
||||
|
||||
| Library | Role | License |
|
||||
| --- | --- | --- |
|
||||
| [meshcore](https://pypi.org/project/meshcore/) | Companion-device protocol (serial / BLE) | MIT |
|
||||
| [meshcore](https://pypi.org/project/meshcore/) | Companion-device protocol (serial / BLE / TCP) | MIT |
|
||||
| [pycryptodome](https://www.pycryptodome.org/) | AES/HMAC for decrypting overheard channel packets | BSD-2-Clause / Public Domain |
|
||||
| [pyserial](https://github.com/pyserial/pyserial) | Serial-port enumeration and I/O | BSD-3-Clause |
|
||||
| [bleak](https://github.com/hbldh/bleak) | Bluetooth LE scanning + connection | MIT |
|
||||
| [Typer](https://typer.tiangolo.com/) | Scripted CLI (subcommands mirror the menu) | MIT |
|
||||
| [Rich](https://github.com/Textualize/rich) | Console rendering | MIT |
|
||||
| [prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) | Full-screen interactive TUI | BSD-3-Clause |
|
||||
| [markdown-it-py](https://github.com/executablebooks/markdown-it-py) | CommonMark parser behind the written About pages | MIT |
|
||||
| [Segno](https://github.com/heuer/segno) | Pure-Python QR codes (channel share links) | BSD-3-Clause |
|
||||
| [tomli](https://github.com/hukkin/tomli) / [tomli-w](https://github.com/hukkin/tomli-w) | TOML config read/write | MIT |
|
||||
| [tomli](https://github.com/hukkin/tomli) / [tomli-w](https://github.com/hukkin/tomli-w) | TOML config, preferences, and device-config backups | MIT |
|
||||
|
||||
## License
|
||||
|
||||
@@ -456,3 +389,9 @@ is Microsoft's Cascadia Mono PL, redistributed unmodified under the SIL Open Fon
|
||||
1.1, which travels with it as
|
||||
[CascadiaMono-OFL.txt](meshterm/assets/fonts/CascadiaMono-OFL.txt) and stays its only
|
||||
license.
|
||||
|
||||
Each standalone build is a single file with `LICENSE`, `NOTICE`, and a generated
|
||||
`THIRD-PARTY-NOTICES.txt` (every dependency's own license text) bundled inside it. The same
|
||||
three files are also attached to each release on the
|
||||
[releases page](https://github.com/jpmartineau/MeshTerm/releases), so you can read them
|
||||
without running anything.
|
||||
+30
-6
@@ -2,10 +2,9 @@
|
||||
# Copy to ~/.meshterm/config.toml (or pass --db / --port on the CLI).
|
||||
#
|
||||
# How MeshTerm itself behaves — transmit cooldown, message retries, how much history it
|
||||
# keeps, whether it connects at launch — is *not* here. Those are preferences: they live
|
||||
# in ~/.meshterm/preferences.yaml, and are edited on the Preferences page (or with
|
||||
# `meshterm preferences set <key> <value>`). Every one has a built-in default, so that
|
||||
# file only ever lists what you have changed.
|
||||
# keeps — is *not* here. Those are preferences: they live in ~/.meshterm/preferences.toml,
|
||||
# and are edited on the Preferences page (or with `meshterm preferences set <key> <value>`).
|
||||
# Every one has a built-in default, so that file only ever lists what you have changed.
|
||||
#
|
||||
# Profiles are optional. With no profile, no --port/--ble/--tcp, MeshTerm discovers attached
|
||||
# serial devices and nearby Bluetooth companions, lets you pick one (interactively) or uses the
|
||||
@@ -14,7 +13,10 @@
|
||||
# the picker's "add a network device" prompt. Define profiles below only to give your hardware
|
||||
# stable aliases (e.g. --profile yagi, --profile handheld for Bluetooth, --profile wifi for TCP).
|
||||
|
||||
default_profile = "s3"
|
||||
# Uncomment and name one of your own profiles below to make it the default. Left
|
||||
# commented, MeshTerm finds the device itself, as described above.
|
||||
# default_profile = "s3"
|
||||
|
||||
# Open the radio link as soon as the menu appears. False opens it lazily instead — only
|
||||
# once monitoring is turned on or a tool first needs the radio.
|
||||
connect_on_start = true
|
||||
@@ -24,7 +26,6 @@ connect_on_start = true
|
||||
[profiles.s3]
|
||||
port = "COM5"
|
||||
baudrate = 115200
|
||||
default_tx_power = 20
|
||||
description = "XIAO ESP32-S3 + Wio SX1262 serial companion"
|
||||
|
||||
[profiles.yagi]
|
||||
@@ -57,3 +58,26 @@ host = "192.168.1.50"
|
||||
# tcp_port = 5000
|
||||
# transport = "tcp"
|
||||
description = "Basestation over WiFi"
|
||||
|
||||
# A radio on this machine's own SPI bus: MeshTerm runs its node while it is connected.
|
||||
# The pins default to the uConsole AIO v1's, which needs nothing more than this (and is
|
||||
# listed on the device screen anyway, whenever /dev/spidev1.0 exists).
|
||||
[profiles.aio]
|
||||
transport = "spi"
|
||||
# [profiles.aio.spi]
|
||||
# en_pins = [27] # the AIO v2 powers its radio from pin 27
|
||||
|
||||
# Any other board states its whole wiring — every pin and both switches, because a key
|
||||
# left out keeps the AIO's value. Read them off the board's meshtasticd config; the
|
||||
# values here are an illustration only. docs/configuration.md has the key-by-key mapping.
|
||||
# [profiles.hat.spi]
|
||||
# bus_id = 0 # /dev/spidev0.0
|
||||
# cs_id = 0
|
||||
# reset_pin = 18
|
||||
# busy_pin = 20
|
||||
# irq_pin = 16
|
||||
# txen_pin = 6
|
||||
# rxen_pin = -1
|
||||
# use_dio2_rf = false
|
||||
# use_dio3_tcxo = false
|
||||
# python = "/opt/radio/bin/python" # only to pick the Python that has openhop-core
|
||||
@@ -0,0 +1,51 @@
|
||||
# MeshTerm documentation
|
||||
|
||||
Everything that didn't belong on the front page. The [README](../README.md) is the front
|
||||
door — what MeshTerm is, how to install it, and enough to get a first session running.
|
||||
This folder is the rest.
|
||||
|
||||
## Using MeshTerm
|
||||
|
||||
| Read | For |
|
||||
| --- | --- |
|
||||
| **[What MeshTerm does](features.md)** | Every screen the menu offers, grouped the way the menu groups them, with the command that does the same job without it. |
|
||||
| **[The command line](cli.md)** | The manual. Every subcommand and option, what each prints on the plain and JSON faces, the exit statuses, and recipes built on `jq`. |
|
||||
| [The CLI cookbook](cookbook.md) | Common one-liners by task, for when you want the shape of a command rather than its specification. |
|
||||
| [Configuring MeshTerm](configuration.md) | Preferences versus config, device profiles, and everything kept under `~/.meshterm`. |
|
||||
| [When a companion won't connect](connecting.md) | Bluetooth pairing on each system, finding the PIN, and every connection error MeshTerm reports, with what to do about it. |
|
||||
| [Terminals, icons, and the Windows console](terminals.md) | Why the icons and charts draw on some terminals and not others, and what MeshTerm does about it on Windows. |
|
||||
|
||||
## Putting it on hardware
|
||||
|
||||
Only if you want MeshTerm running on a handheld's own screen. A MeshCore companion on
|
||||
USB, Bluetooth, or Wi-Fi needs none of this.
|
||||
|
||||
| Read | For |
|
||||
| --- | --- |
|
||||
| **[MeshTerm on hardware](hardware.md)** | Start here — which of the two manuals below is yours, and what the device-side scripts do. |
|
||||
| [MeshTerm on the PicoCalc](picocalc.md) | A stock ClockworkPi PicoCalc to a Linux handheld with a LoRa radio soldered inside: the shopping list, the Lyra swap, Calculinux, and the radio. |
|
||||
| [MeshTerm on the uConsole](uconsole.md) | A uConsole with the hackergadgets AIO LoRa board, whose SX1262 sits on the host's own SPI bus — MeshTerm drives it directly with `--spi`, or a small bridge fronts it as a companion for an always-on node. |
|
||||
|
||||
## Reading the code
|
||||
|
||||
| Read | For |
|
||||
| --- | --- |
|
||||
| [How the code is laid out](architecture.md) | The layering, and where a new feature goes. |
|
||||
| [CLAUDE.md](../CLAUDE.md) | The actual style guide — terminology, UX standards, and the reusable building blocks. Written for AI assistants, and the house rules for everyone. |
|
||||
| [CONTRIBUTING.md](../CONTRIBUTING.md) | How to propose a change, the rules on AI use, the dev install, the three checks a change must pass, and what happens to your contribution's licensing. |
|
||||
|
||||
## Historical record
|
||||
|
||||
A survey of the code, kept because a project that writes down its own problems is easier
|
||||
to trust than one that doesn't — but it is a **snapshot of a date, not a to-do list**.
|
||||
Some of what it describes has been fixed since, and it does not mark which.
|
||||
|
||||
| Read | For |
|
||||
| --- | --- |
|
||||
| [UI consistency survey](survey-ui-consistency.md) | Everything else in the CLAUDE.md UX standard — six sweeps found nothing, which is the useful part. |
|
||||
|
||||
## Also here
|
||||
|
||||
`screenshots/` holds the captures the README shows and the ones
|
||||
[meshterm.net](https://meshterm.net) builds its landing page from, so a file in there is
|
||||
referenced from more than one place.
|
||||
@@ -0,0 +1,23 @@
|
||||
# How the code is laid out
|
||||
|
||||
A map of the package for anyone reading it for the first time. The house rules that
|
||||
govern what goes where are in [CLAUDE.md](../CLAUDE.md), which is the real style guide;
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md) has the dev install and the gates a change has to
|
||||
pass.
|
||||
|
||||
MeshTerm is layered so a new feature is one file in `meshterm/tools/`:
|
||||
|
||||
```
|
||||
cli.py Typer app; no subcommand -> interactive menu
|
||||
context.py AppContext (console, config, repository, device) dependency container
|
||||
core/ Domain: models, preferences + device profiles, connection abstraction (+ mock)
|
||||
tools/ Pluggable "menu options"; each self-registers and gets logging for free
|
||||
services/ Background algorithms (monitor, trace, tx search, courier, watchtower) — no UI
|
||||
persistence/ SQLite schema, repository, structured logging
|
||||
ui/ Rich theme/widgets + the full-screen TUI (screens, dialogs, map, chat)
|
||||
```
|
||||
|
||||
Adding a feature means subclassing `Tool`, decorating it with `@register`, and
|
||||
implementing `run()`. The same registry builds both the CLI and the menu, and the base
|
||||
class wraps every execution in a logged `runs` row automatically.
|
||||
|
||||
+1868
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,171 @@
|
||||
# Configuring MeshTerm
|
||||
|
||||
Preferences, device profiles, and where everything is kept. You need none of it to run
|
||||
MeshTerm: it discovers a companion, lets you pick one, and remembers the choice.
|
||||
|
||||
Two files, two jobs — and you need neither to run.
|
||||
|
||||
**Preferences** are how MeshTerm behaves: the pause it leaves between transmissions, how
|
||||
hard it retries a message, how far back it keeps history, how a map frames itself. Every one has a built-in default, so change them only where you disagree — from
|
||||
the **Preferences** page in the menu (grouped, staged, saved by one action at the bottom),
|
||||
or from a shell:
|
||||
|
||||
```console
|
||||
$ meshterm preferences show # every preference, its value, its default
|
||||
$ meshterm preferences set history_days 90
|
||||
$ meshterm preferences reset --yes # back to the built-in defaults
|
||||
```
|
||||
|
||||
They are kept in `~/.meshterm/preferences.toml`, which lists only what you have changed;
|
||||
delete a line and the default takes over again.
|
||||
|
||||
**Config** is where things live and which device to talk to. MeshTerm runs with none of it
|
||||
— it discovers attached serial devices and nearby Bluetooth companions, lets you pick one,
|
||||
and remembers the last good default. A network (TCP) companion isn't discoverable, so reach
|
||||
it with `--tcp host:port`, a TCP profile, or the picker's "add a network device" prompt.
|
||||
A LoRa radio on this machine's own SPI bus is listed on the picker when its device node
|
||||
exists, or reached with `--spi`; see [the uConsole manual](uconsole.md).
|
||||
Write a config file only to give your hardware stable aliases.
|
||||
|
||||
Copy [`config.example.toml`](../config.example.toml) to `~/.meshterm/config.toml`:
|
||||
|
||||
```toml
|
||||
# default_profile = "s3" # uncomment and name your own profile to make it the default
|
||||
connect_on_start = true # false opens the radio link lazily instead of at launch
|
||||
# db_path = "C:/Users/you/meshterm/meshterm.db" # the history lives elsewhere
|
||||
|
||||
[profiles.s3]
|
||||
port = "COM5"
|
||||
baudrate = 115200
|
||||
description = "XIAO ESP32-S3 + Wio SX1262 serial companion"
|
||||
|
||||
# A Bluetooth LE companion: give it an `address` instead of a `port`.
|
||||
[profiles.handheld]
|
||||
address = "AA:BB:CC:DD:EE:FF"
|
||||
# ble_pin = "123456" # only if your device requires a pairing PIN (not used on macOS)
|
||||
description = "Pocket handheld over Bluetooth"
|
||||
|
||||
# A network (TCP) companion: give it a `host` (and optional `tcp_port`, default 5000).
|
||||
[profiles.wifi]
|
||||
host = "192.168.1.50"
|
||||
description = "Basestation over Wi-Fi"
|
||||
|
||||
# A radio on this machine's own SPI bus (the uConsole AIO): MeshTerm runs its node.
|
||||
# The pins default to the AIO v1's; a `spi` table states only what differs.
|
||||
[profiles.aio]
|
||||
transport = "spi"
|
||||
# [profiles.aio.spi]
|
||||
# en_pins = [27] # the AIO v2 powers its radio from pin 27
|
||||
```
|
||||
|
||||
Both live in `~/.meshterm`, along with everything else MeshTerm remembers: the SQLite
|
||||
history, the outbox, the contact and channel caches, stored admin passwords, and the log.
|
||||
**`$MESHTERM_HOME` moves the whole directory**, which is the way to run a second radio —
|
||||
or a `--mock` session — without touching the one you use every day. `--db` moves the
|
||||
database alone; the rest stays where it was. A `--mock` run that names neither writes to
|
||||
`meshterm-mock.db` beside the real history, so the simulator's invented nodes never enter
|
||||
it. [`docs/cli.md`](cli.md#where-meshterm-keeps-its-state)
|
||||
lists every file.
|
||||
|
||||
Timestamps are stored as UTC and rendered in your local time. Heard packets older than the
|
||||
`history_days` preference are deleted when monitoring starts: once in each menu session,
|
||||
and at the start of each `meshterm monitor` run. Nothing else is pruned.
|
||||
|
||||
## Adding a radio on the SPI bus
|
||||
|
||||
Some boards put the LoRa chip straight on the computer's SPI bus, with no firmware in front
|
||||
of it: the uConsole's AIO board, and LoRa HATs for the Raspberry Pi. MeshTerm drives these
|
||||
itself — it runs the node while it is connected and lets go of the radio when it quits.
|
||||
It needs Linux and the radio library; [the uConsole manual](uconsole.md#step-2--install-the-radio-library)
|
||||
shows how to install that.
|
||||
|
||||
**An AIO v1 needs no configuration.** Its wiring is MeshTerm's default, so whenever
|
||||
`/dev/spidev1.0` exists the device screen lists it as **SPI radio**, and `meshterm --spi`
|
||||
reaches it. Any other board is added with a profile that states how it is wired.
|
||||
|
||||
**Why a profile, and not a prompt on the device screen.** A USB companion describes itself:
|
||||
plug it in and there is nothing to ask. An SPI radio says nothing about how it is wired, and
|
||||
the wiring is a dozen facts — the bus, the chip select, the reset, busy, and interrupt pins,
|
||||
the antenna switch, the TCXO — any one of which, wrong, leaves a radio that comes up deaf
|
||||
rather than failing. Those are facts to look up once and write down, not to type into a
|
||||
dialog. And the radio doesn't come and go the way a cable does: it is soldered, stacked on
|
||||
a header, or built into the case, so its description belongs somewhere as lasting as the
|
||||
hardware is. A file you write once, and can read back when something is off, is that place.
|
||||
|
||||
### 1. Find your board's wiring
|
||||
|
||||
The easiest place to find it is the board's `meshtasticd` configuration, which most vendors
|
||||
publish (usually a `config.yaml` or a `config.d/` fragment with a `Lora:` section). Its keys
|
||||
map one to one:
|
||||
|
||||
| `meshtasticd` (`Lora:`) | MeshTerm (`[profiles.<name>.spi]`) |
|
||||
| --- | --- |
|
||||
| `spidev: spidev0.0` | `bus_id = 0` and `cs_id = 0` — the two numbers of `spidevB.C` |
|
||||
| `CS: 21` | `cs_pin = 21` (only when the board drives chip select from a GPIO) |
|
||||
| `Reset`, `Busy`, `IRQ` | `reset_pin`, `busy_pin`, `irq_pin` |
|
||||
| `TXen`, `RXen` | `txen_pin`, `rxen_pin` |
|
||||
| `DIO2_AS_RF_SWITCH: true` | `use_dio2_rf = true` |
|
||||
| `DIO3_TCXO_VOLTAGE: true` | `use_dio3_tcxo = true` (1.8 V) |
|
||||
| `gpiochip: 4` | `gpio_chip = 4` |
|
||||
|
||||
`Module` must be `sx1262`; that is the chip MeshTerm's node drives. `gpio_chip` is `0` on
|
||||
a Compute Module 4 but differs on a Raspberry Pi 5 and a CM5 — `gpiodetect`, or
|
||||
`ls /dev/gpiochip*`, shows which chip carries the 40-pin header.
|
||||
|
||||
**State every pin and both switches.** Where a `meshtasticd` file leaves a key out, it means
|
||||
"none" or "off". Where the `spi` table leaves a key out, it means *the AIO's value* — IRQ on
|
||||
26, DIO2 and DIO3 on — which is wrong for almost any other board, and a radio wired wrong
|
||||
comes up deaf rather than failing.
|
||||
|
||||
### 2. Write the profile
|
||||
|
||||
In `~/.meshterm/config.toml`, give the radio a name and its wiring. The values below are an
|
||||
illustration, not any particular board's — take yours from step 1:
|
||||
|
||||
```toml
|
||||
[profiles.hat]
|
||||
description = "SX1262 HAT on the Pi header"
|
||||
|
||||
[profiles.hat.spi]
|
||||
bus_id = 0 # /dev/spidev0.0
|
||||
cs_id = 0
|
||||
reset_pin = 18
|
||||
busy_pin = 20
|
||||
irq_pin = 16
|
||||
txen_pin = 6 # this HAT switches its antenna from a GPIO…
|
||||
rxen_pin = -1
|
||||
use_dio2_rf = false # …so DIO2 doesn't
|
||||
use_dio3_tcxo = false # no TCXO on this board
|
||||
```
|
||||
|
||||
A `spi` table is enough to make it an SPI profile; `transport = "spi"` says so outright and
|
||||
is what an AIO profile with no table uses. Every key, with the AIO defaults, is in
|
||||
[the uConsole manual's wiring table](uconsole.md#board-wiring). A key MeshTerm doesn't know,
|
||||
or a value of the wrong type, stops it at startup with the profile named — a misspelt pin
|
||||
is never quietly ignored.
|
||||
|
||||
### 3. Check it
|
||||
|
||||
```bash
|
||||
meshterm -p hat info
|
||||
```
|
||||
|
||||
The first run starts the node, which mints the radio's identity. If something is wrong, the
|
||||
error says what to fix — a missing `/dev/spidev*` (enable SPI and reboot), a permission
|
||||
problem (join the `spi` and `gpio` groups), a missing radio library, or another program
|
||||
already holding the radio. A node that starts but never answers leaves its log in
|
||||
`~/.meshterm/radio/spidev0.0/node.log`. Once it answers, the device screen lists the radio
|
||||
under the profile's name.
|
||||
|
||||
### What the profile doesn't hold
|
||||
|
||||
- **Radio settings.** Frequency, bandwidth, spreading factor, coding rate, and TX power are
|
||||
the node's own settings, saved by the node and changed on **Device config** like any
|
||||
companion's. A new node starts on MeshCore's **US/Canada preset** (910.525 MHz, 62.5 kHz,
|
||||
SF7, CR 4/5, 22 dBm): anywhere else, change it on Device config before you send anything.
|
||||
- **The preamble.** The node follows MeshCore's own rule — 32 symbols up to SF8, 16 above —
|
||||
and a receiver that expects anything else misses most of the mesh. `preamble_length` was
|
||||
once a wiring key and is refused now; delete the line if an old profile has it.
|
||||
- **Identity.** The node's key, contacts, and channels live in
|
||||
`~/.meshterm/radio/<spidev>/`, filed by the device node rather than the profile, so
|
||||
renaming a profile, or reaching the same radio from the device screen, keeps the same node.
|
||||
@@ -0,0 +1,128 @@
|
||||
# When a companion won't connect
|
||||
|
||||
MeshTerm talks to a MeshCore companion over USB, Bluetooth, or TCP, or runs the node itself
|
||||
on a radio on the SPI bus. When a connection fails, MeshTerm says which step failed and what
|
||||
to do about it, in the device picker and on the command line alike. This page collects those
|
||||
messages, so you can look one up before you meet it, or search for the one you have.
|
||||
|
||||
Every failure is also written to the log file, `~/.meshterm/meshterm.log` (see
|
||||
[Where MeshTerm keeps its state](cli.md#where-meshterm-keeps-its-state)). Attach it to a
|
||||
bug report. For more detail, set the **Log detail** preference to `DEBUG` (`meshterm
|
||||
preferences set log_level DEBUG`) and run it again.
|
||||
|
||||
- [Bluetooth](#bluetooth)
|
||||
- [Who does the pairing](#who-does-the-pairing)
|
||||
- [Finding the PIN](#finding-the-pin)
|
||||
- [What each message means](#what-each-bluetooth-message-means)
|
||||
- [Forgetting a pairing](#forgetting-a-pairing)
|
||||
- [USB](#usb)
|
||||
- [TCP](#tcp)
|
||||
- [A radio on the SPI bus](#a-radio-on-the-spi-bus)
|
||||
|
||||
---
|
||||
|
||||
## Bluetooth
|
||||
|
||||
**A companion takes one Bluetooth connection at a time.** If your phone's MeshCore app is
|
||||
connected to it, MeshTerm cannot connect until the phone lets go, and the companion stops
|
||||
advertising while it is connected, so MeshTerm cannot even see it. Disconnect the phone
|
||||
first, or turn its Bluetooth off.
|
||||
|
||||
### Who does the pairing
|
||||
|
||||
A PIN-protected companion must be *paired* before it will talk: the computer and the
|
||||
companion exchange keys once, using the six-digit PIN, and remember them. Who runs that
|
||||
exchange depends on the system.
|
||||
|
||||
| System | Who pairs | What you do |
|
||||
| --- | --- | --- |
|
||||
| Windows | MeshTerm | Give the PIN when MeshTerm asks for it (the picker's PIN dialog, or `--ble-pin` on the command line). You do not need to pair in Settings first. |
|
||||
| Linux | MeshTerm | The same. MeshTerm answers the pairing itself, so it works over SSH and on machines with no desktop, and you do not need `bluetoothctl` or the desktop's Bluetooth settings. |
|
||||
| macOS | macOS | macOS shows its own dialog asking for the code; type the PIN there. `--ble-pin` has no effect on macOS. |
|
||||
|
||||
Once paired, the system remembers it, and later connections need no PIN.
|
||||
|
||||
**On macOS, the terminal needs Bluetooth permission.** macOS grants Bluetooth to the app
|
||||
MeshTerm runs in (Terminal, iTerm2, Ghostty), not to MeshTerm itself. If no pairing dialog
|
||||
appears, or no Bluetooth devices are listed, check System Settings > Privacy & Security >
|
||||
Bluetooth. **Over SSH, macOS denies Bluetooth outright** and never asks, so run MeshTerm in
|
||||
a terminal on the Mac itself.
|
||||
|
||||
### Finding the PIN
|
||||
|
||||
The PIN is shown on the companion's screen, or in the MeshCore app's settings for it. A
|
||||
companion with no screen (a T1000-E, for instance) can tell you over USB instead:
|
||||
|
||||
```console
|
||||
$ meshterm --port /dev/ttyACM0 config get device_pin
|
||||
123456
|
||||
```
|
||||
|
||||
`meshterm config set device_pin <PIN>` changes it, and so does the **Device PIN** row on
|
||||
the menu's Device config page. A changed PIN makes every existing pairing with that
|
||||
companion out of date. See [a stale pairing](#what-each-bluetooth-message-means) below.
|
||||
|
||||
### What each Bluetooth message means
|
||||
|
||||
In the device picker, a pairing problem opens the PIN dialog with a one-line reason under
|
||||
the question. On the command line, it is the error message. Both come from the same
|
||||
diagnosis.
|
||||
|
||||
| The message starts with… | What happened | What to do |
|
||||
| --- | --- | --- |
|
||||
| **couldn't open a Bluetooth link** | The companion was never reached. | Move closer, check it is powered on, and make sure no phone or other computer is connected to it. |
|
||||
| **requires a Bluetooth pairing PIN** | The companion is PIN-protected and not paired with this computer. | Give the PIN: the picker asks for it; on the command line, add `--ble-pin <PIN>`. |
|
||||
| **Windows has … paired** / **Linux has … paired, but the device refused that pairing** | Your computer remembers a pairing that the companion no longer accepts. It was reflashed, reset, or given a new PIN, or it was paired *without* a PIN back when it did not have one. | Give the PIN, and MeshTerm removes the old pairing and pairs again. Or [forget the pairing](#forgetting-a-pairing) yourself and reconnect. |
|
||||
| **rejected the Bluetooth PIN** | The PIN was wrong. | Check the code on the device or in the MeshCore app, and try again. |
|
||||
| **refused to pair** | The companion turned the pairing down. It is usually connected to a phone, or still holds an old pairing for this computer. | Disconnect it from anything else, or restart it, and try again. |
|
||||
| **Windows couldn't pair** / **Linux couldn't pair** | The pairing failed for another reason, named in brackets. | Restart the companion, [forget the pairing](#forgetting-a-pairing), and try again. |
|
||||
| **Windows couldn't reach** / **Linux couldn't reach … to pair it** | The system could not find the companion when it came to pair. | As for a link that never opened: range, power, or another connection. |
|
||||
| **paired with …, but the device still refuses the connection** | Pairing succeeded, yet the companion still refuses. It is holding an old pairing for this computer. | Restart the companion, [forget the pairing](#forgetting-a-pairing), and try again. |
|
||||
| **was not paired. macOS asks for the pairing code in its own dialog** | macOS had not finished pairing. | Type the PIN into the macOS dialog when it appears. If none appears, check the terminal's Bluetooth permission (above). |
|
||||
| **connecting … took longer than** | The link, the pairing, or the service discovery stalled. | Move closer, restart the companion, and try again. |
|
||||
| **connected …, but it didn't answer the identity query** | The link came up and the companion went quiet. | It may be busy with another app. Restart it and try again. |
|
||||
| **connected …, but it never answered as a MeshCore companion** | Something answered, but not as a companion. | It may be running repeater or room server firmware, which has no companion interface. |
|
||||
|
||||
### Forgetting a pairing
|
||||
|
||||
MeshTerm fixes a stale pairing itself when you give it the PIN. To remove one by hand:
|
||||
|
||||
- **Windows:** Settings > Bluetooth & devices, open the companion, and choose *Remove device*.
|
||||
- **Linux:** `bluetoothctl remove AA:BB:CC:DD:EE:FF`, with your companion's address.
|
||||
- **macOS:** System Settings > Bluetooth, the companion's ⓘ button, then *Forget This Device*.
|
||||
|
||||
On Windows and Linux, MeshTerm's quit dialog also offers **Unpair & quit** while a paired
|
||||
Bluetooth companion is connected. It forgets the pairing on the way out, so the next
|
||||
connection asks for the PIN again.
|
||||
|
||||
---
|
||||
|
||||
## USB
|
||||
|
||||
| The message starts with… | What happened | What to do |
|
||||
| --- | --- | --- |
|
||||
| **no permission to open** | Your user may not open the port. On Linux, serial ports belong to a group, `dialout` on Ubuntu and Debian. | `sudo usermod -aG dialout $USER`, then log out and back in. The new group applies only to a new login. |
|
||||
| **… is in use by another program** | Something else has the port open: the MeshCore app, a firmware flasher, or a serial monitor. On Windows this is reported as *Access is denied*. | Close the other program. On Linux, ModemManager probes new USB serial devices and can hold one: `sudo systemctl stop ModemManager`, and try again. |
|
||||
| **… isn't there** | The port does not exist. The device was unplugged, or came back under another name (`/dev/ttyACM0` becoming `/dev/ttyACM1`, `COM5` becoming `COM6`). | `meshterm devices` lists what is attached now. |
|
||||
| **no response from a MeshCore companion on …** | The port opened, but nothing answered as a companion. | It may not be a MeshCore companion (repeater firmware, or another device), or it is powered off or busy. On Linux, ModemManager may be probing it: `sudo systemctl stop ModemManager`, and try again. |
|
||||
|
||||
To stop ModemManager for good rather than for one boot, `sudo systemctl disable --now
|
||||
ModemManager`, if nothing else on the machine needs it. Nothing does unless you have a
|
||||
cellular modem.
|
||||
|
||||
---
|
||||
|
||||
## TCP
|
||||
|
||||
**no response from a MeshCore companion at HOST:PORT** means nothing answered there. Check
|
||||
the host and the port (MeshTerm's default is `5000`), that the companion is powered on and
|
||||
reachable on your network, and that no other client is already connected to it. Like
|
||||
Bluetooth, a network companion serves one client at a time.
|
||||
|
||||
---
|
||||
|
||||
## A radio on the SPI bus
|
||||
|
||||
A radio MeshTerm drives itself (`--spi`) is not a companion. It has no pairing and no port,
|
||||
and its failures are about the node MeshTerm starts. Its own guides cover them:
|
||||
[the uConsole](uconsole.md) and [adding a radio on the SPI bus](configuration.md#adding-a-radio-on-the-spi-bus).
|
||||
@@ -0,0 +1,108 @@
|
||||
# The CLI cookbook
|
||||
|
||||
Common one-liners, by the thing you are trying to do. [The command line](cli.md) is the
|
||||
full manual — every command, every option, both output faces, the exit statuses — and its
|
||||
own [Recipes](cli.md#recipes) section goes further than this one, into `jq` pipelines and
|
||||
shell loops.
|
||||
|
||||
Nearly every menu option is also a subcommand — ideal for scripting, cron, and bots. The
|
||||
live pictures stay in the menu, where their meaning is (the map, dashboard, live feed,
|
||||
watchtower, and mesh walk); everything else has a command, and every command speaks
|
||||
`--json`. **[`docs/cli.md`](cli.md) is the full manual**: every command and option,
|
||||
what each one prints on both faces, and the exit statuses. A taste:
|
||||
|
||||
```bash
|
||||
# Trace — a trace transmits exactly once; run it again to sample more.
|
||||
meshterm trace --target Alice --profile yagi
|
||||
|
||||
# Force a route through specific repeaters (MeshCore-app style): comma-separated
|
||||
# contact names and/or hex key prefixes, mixed freely. Blank lets the device route.
|
||||
meshterm trace --target Alice --path "3d,f2,3d"
|
||||
meshterm trace --target Alice --path "3d,Bravo-Repeater,f2"
|
||||
|
||||
# Walk a composed circuit with no target at all — out and back your own way
|
||||
meshterm trace-path --path "3d,f2,3d"
|
||||
|
||||
# Sweep and apply a remote node's TX power
|
||||
meshterm tx-optimize --path "Bravo-Repeater,Alice" --samples 6 --step 3 --apply
|
||||
|
||||
# Messaging: the live transcript is a menu screen; the CLI takes a subcommand
|
||||
meshterm chat send --to Alice "on my way" # direct message
|
||||
meshterm chat send --channel 0 "net in 5" # channel broadcast
|
||||
meshterm chat history --to Alice
|
||||
meshterm chat list
|
||||
|
||||
# Store-and-forward: queue for a contact that's offline right now
|
||||
meshterm courier queue Alice "ping me when you're back" --at 18:30
|
||||
meshterm courier list # the outbox — waiting and finished
|
||||
meshterm courier send 3 # force one delivery attempt now
|
||||
|
||||
# Remote repeater admin (one transmission per invocation). --password is needed
|
||||
# until an interactive login has stored one.
|
||||
meshterm repeater-admin Bravo-Repeater "get name" --password YOUR-ADMIN-PASSWORD
|
||||
|
||||
# Device configuration: view, set, back up, restore
|
||||
meshterm config # show all current settings, one `key value` per line
|
||||
meshterm config get name # just the value, ready for $(...)
|
||||
meshterm config set radio_sf 9 # change one setting
|
||||
meshterm config backup node.toml # archive every setting to TOML
|
||||
meshterm config restore node.toml --dry-run
|
||||
meshterm preferences set weekly_flood_advert on # flood an advert once a week, when quiet
|
||||
|
||||
# Passive capture window (records to history; transmits nothing)
|
||||
meshterm monitor --seconds 60
|
||||
```
|
||||
|
||||
Global options — `--profile/-p`, `--port`, `--ble`, `--ble-pin`, `--tcp`, `--spi`, `--mock`,
|
||||
`--db`, `--json`, `--absolute`, `--quiet/-q`, `--platform` — may be typed **before or
|
||||
after** the subcommand: `meshterm contacts --json` and `meshterm --json contacts` are the
|
||||
same run.
|
||||
|
||||
## Two output faces
|
||||
|
||||
*(The short version — [`docs/cli.md`](cli.md) has the whole of it.)*
|
||||
|
||||
The **plain face** is for a person at a prompt. It prints like a standard Unix utility —
|
||||
no colour, no borders, one record per line, nothing wrapped except the four written
|
||||
pages (`about`, `about-author`, `discord`, `support`), which wrap at 72 cells — and it is
|
||||
allowed to be comfortable about it: listings are `ps`-style aligned records with **bare names**
|
||||
(alignment is the delimiter), times are **relative ages** (`now`, `5m`, `never`), and a
|
||||
route is drawn with arrows — `MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4)`.
|
||||
`--absolute` swaps every age back for an ISO-8601 instant. A *path*, the spec `--path`
|
||||
takes back, stays comma-separated hex. `-` is the one token for absent. Errors,
|
||||
acknowledgements, and progress all go to stderr, so a redirect catches only the answer.
|
||||
|
||||
The **JSON face** is the machine contract, and `--json` works on **every** command:
|
||||
|
||||
```console
|
||||
$ meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
|
||||
b2c3d4e500000000000000000000000000000000000000000000000000000000
|
||||
a1b2c3d400000000000000000000000000000000000000000000000000000000
|
||||
```
|
||||
|
||||
No envelope — an array for a listing, an object for a set of facts. One compact line;
|
||||
`monitor` and `chat listen` stream one document per record. Values are typed, absent is
|
||||
`null` and never an omitted key, and timestamps are always UTC to the second regardless of
|
||||
`--absolute`. `--json` changes the rendering, never the report: same records, same exit
|
||||
status.
|
||||
|
||||
Anything structural should go through `--json` and `jq`. The plain face is for looking at.
|
||||
|
||||
## Exit status
|
||||
|
||||
`0` success · `1` failure · `2` usage error · `3` no device found (nothing was
|
||||
transmitted) · `4` the device was reached but the operation failed · `5` nothing to
|
||||
report. The table with its full wording is printed under `meshterm --help` and explained
|
||||
in [`docs/cli.md`](cli.md#exit-status).
|
||||
|
||||
`5` is the one worth knowing about: it lets a script tell "found nothing" from "worked"
|
||||
without counting output lines.
|
||||
|
||||
```bash
|
||||
if meshterm contacts > contacts.txt; then
|
||||
echo "$(($(wc -l < contacts.txt) - 1)) contacts" # minus the header
|
||||
elif [ $? -eq 5 ]; then
|
||||
echo "no contacts yet"
|
||||
fi
|
||||
```
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# What MeshTerm does
|
||||
|
||||
Every screen the menu offers, and the command that does the same job without it. This is
|
||||
the catalogue; [the command line](cli.md) is the manual for the scripted half of it.
|
||||
|
||||
MeshTerm's menu asks one question — *what would you like to do?* — and its six sections
|
||||
answer it in two halves. The first three name a **doing**: Message, Watch, Explore. The
|
||||
last three name **whose** a setting is: This node, the radio in your hand;
|
||||
Other nodes, someone else's over the mesh; and This app, the program in front of you.
|
||||
|
||||
Every interactive screen is listed here with its scripted equivalent, where one exists.
|
||||
|
||||
## 💬 Message — the people on the other end
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **💬 Chat** | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. Every message is logged; unread counts show in the menu header. | `meshterm chat send / history / list` |
|
||||
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels — with QR codes and `meshcore://` share links. Give a channel a **send scope** and its messages are flooded into one region, relayed only by the repeaters that carry it. | `meshterm channels list / add / join / import / share / clear / scope` |
|
||||
| **📨 Courier** | Store-and-forward outbox for contacts that aren't reachable yet. Queue a message; it goes out (with ack tracking and polite exponential backoff) the moment the contact is next heard, or at a scheduled time. | `meshterm courier queue / list / send / cancel / clear` |
|
||||
| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. The scripted listing is the contacts alone (this node is `meshterm info`'s answer, in far more detail). | `meshterm contacts` |
|
||||
|
||||
## 📊 Watch — what the mesh is doing, and what it did
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **📊 Dashboard** | The live mesh overview: a two-hour all-packet activity chart with a pulse line, session traffic tallies by packet class, and window RF health beside the radio's own live numbers. Repaints every second. | — |
|
||||
| **📰 Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* — the node for an advert, the channel for a channel text, sender → recipient for a direct message or a request, the tag for a trace — with reception quality beside it, and the region a scoped flood was sent into. Enter opens any row in the packet viewer, route graph and all. | — |
|
||||
| **🚨 Watchtower** | A passive sentinel over the nodes you star: silence alarms when a watched node goes quiet, SNR-sag warnings when reception degrades, recovery notes when it returns, and a heads-up when a node is heard for the first time. Runs in the background all session; unacked alerts show as a header badge. | — |
|
||||
| **⏳ Time machine** | Everything the recorder ever heard, as braille charts: reception volume, the median-SNR band, hour-of-day rhythm, packets and nodes per day, and first-ever arrivals — over a switchable 24h / 7d / 30d / all-time window (on the PicoCalc the windows stop at 30d), per node or mesh-wide. | — |
|
||||
| **🎧 Monitor** | Passive recording is always on in the app. On the CLI, capture a bounded foreground window, tailing each overheard packet and summarising when it ends. It transmits nothing — it only listens. | `meshterm monitor --seconds 60` |
|
||||
|
||||
## 🧭 Explore — where the nodes are and how they reach each other
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | — (a map is a picture; `meshterm contacts` lists the same nodes) |
|
||||
| **🌐 Mesh walk** | The mesh's *observed shape*, walked one node at a time: an evidence graph built from trace walks, firmware routes, overheard relay chains, and repeater neighbour tables. SNR-coloured braille edges, quality bars, Enter to walk, ⌫ to backtrack, type to find any node. The map answers *where*; the walk answers *how it hangs together*. | — |
|
||||
| **🎯 Trace target** | A live trace screen: pick a target and watch each trace stream in hop by hop, with running per-hop medians and reliability. Compose or force a route through specific repeaters. A trace transmits **exactly once** (repeaters can blacklist nodes that burst) — sample more by running it again. | `meshterm trace --target …` |
|
||||
| **👣 Trace path** | The other half of tracing: compose the whole circuit by hand — out and back whichever way you choose — and walk it. The path composer suggests each next hop from the links actually observed, strongest first, and can fetch a repeater's neighbour table over the mesh when you hold its admin password. | `meshterm trace-path --path …` |
|
||||
| **🏆 Trophy case** | Every trace that comes home is scored, on seven boards: longest distance, farthest node, longest single leg, most nodes (with and without revisits), weakest surviving link, and biggest enclosed loop. Records are kept per hash width, and a record walk must be a *trail* — no link crossed twice the same way. | `meshterm records` |
|
||||
|
||||
## 🔧 This node — the radio in your hand
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **📋 Device info** | The connected companion's identity and full radio configuration at a glance. | `meshterm info` |
|
||||
| **🔧 Device config** | Every setting the companion firmware exposes — name, radio, client repeat, auto-add, telemetry, custom variables — *staged* for review and applied in place, plus the operations on the box itself: clock sync, TOML backup/restore, the identity key, reboot, and factory reset, each acting the moment it's confirmed, with destructive ops gated. Laid out like Repeater admin, so the radio in your hand and one over the mesh are configured the same way. | `meshterm config` / `config set <key> <value>` / `config backup` / `config reboot` / … |
|
||||
| **📡 Send advert** | Announce this node to the mesh — a zero-hop or flood advertisement, or share this node's contact card as a QR code. | `meshterm config advert` / `config share` |
|
||||
| **🔌 Devices** | Not a menu screen. The companion is picked on the startup splash, which enumerates serial *and* Bluetooth LE companions (or auto-selects the only one present), adds a network (TCP) companion by host:port, and remembers the last good default. A dropped link is detected live and offers to reconnect. `meshterm devices` lists what discovery finds. | `meshterm devices` |
|
||||
|
||||
## 🗼 Other nodes — someone else's radio, over the mesh
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **🗼 Repeater admin** | Set up remote repeaters and room servers over the mesh: log in (remembered or prompted password), then a config-style editor speaking the node's text CLI — including repeater-only knobs (TX delay, airtime factor, advert intervals) — plus one-shot actions and a readline remote command line. A **Regions** page edits the repeater's region tree: which regions it relays floods for, whether it relays unscoped floods, its home and default scope. | `meshterm repeater-admin <node> <command…>` |
|
||||
| **🔖 Regions** | Not a menu screen. A repeater's node page lists the regions it carries and asks it once, without logging in (it answers only a neighbour, or over a known route); the packet viewer, message paths and live feed name the region a flood was scoped to. | `meshterm regions <node>` |
|
||||
| **📶 TX optimize** | Sweep a remote node's transmit power live — coarse, then refine, then verify — watch each level land, and decide whether to apply the winner. The scripted form applies the winner unless told not to. | `meshterm tx-optimize --path … [--no-apply]` |
|
||||
|
||||
## ⚙ This app — the program in front of you
|
||||
|
||||
The last of the three, and the one nothing on the mesh can answer. Preferences leads it because
|
||||
it is the only row here that *changes* MeshTerm rather than describing it; the written
|
||||
pages close it.
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **⚙ Preferences** | How MeshTerm behaves, in eight groups: Device, Sending, TX optimize, Watchtower, Map, History, Display, and Diagnostics. Grouped and staged, saved by one action at the bottom, and written to `preferences.toml` as only the values you changed. [Configuring MeshTerm](configuration.md) has the detail. | `meshterm preferences show / set / reset` |
|
||||
| **🩺 Diagnostics** | Everything a bug report opens with, in one block: which build this is and how it was installed, the OS, the terminal and its size, the boot-time verdicts that show on no screen (icons, powerline, platform flavour, and why), the radio and its link, and how much history is stored. Nothing private — no keys, passwords, positions, or contact names; the mesh is described as per-table counts and the span they cover. `s` saves it beside the log for attaching. | `meshterm diagnostics [--out PATH]` |
|
||||
| **📖 About MeshTerm** | What MeshTerm is, where it came from, and the terms it ships under — including the map and font credits and the licence URL. | `meshterm about` |
|
||||
| **👤 About the author** | The person behind MeshTerm, on the mesh and off it. | `meshterm about-author` |
|
||||
| **🔗 Join Discord** | The community server: one invite link, and a QR of it for a phone to read. | `meshterm discord` |
|
||||
| **💰 Support MeshTerm** | What keeps MeshTerm going, and the ways — paid and unpaid — to help it along. | `meshterm support` |
|
||||
@@ -0,0 +1,72 @@
|
||||
# MeshTerm on hardware
|
||||
|
||||
MeshTerm talks to a **node** — a MeshCore radio that speaks the companion protocol over
|
||||
serial, Bluetooth LE, or TCP. Plug a companion board into USB, or pair one over Bluetooth,
|
||||
and none of this page applies: MeshTerm finds it, and the [README](../README.md) and the
|
||||
[command line manual](cli.md) are all you need.
|
||||
|
||||
This page is for the hardware that *doesn't* present a node that way. Each such device has
|
||||
its own step-by-step manual, and this page only tells you which one is yours.
|
||||
|
||||
## Which guide is yours
|
||||
|
||||
| I have… | Read |
|
||||
| --- | --- |
|
||||
| a ClockworkPi **PicoCalc**, stock or already running Calculinux on a Luckfox Lyra, and I want MeshTerm on its own screen | **[MeshTerm on the PicoCalc](picocalc.md)** — from the shopping list to a working machine |
|
||||
| …and I want a **radio** inside it | the same manual, [Phase 3](picocalc.md#phase-3--add-the-radio) — a XIAO nRF52840 + Wio-SX1262 soldered to the Lyra's UART |
|
||||
| a ClockworkPi **uConsole** with the hackergadgets AIO board — an SX1262 on the host's own SPI bus | **[MeshTerm on the uConsole](uconsole.md)** — MeshTerm drives it directly with `--spi`, or a bridge service fronts it as a companion if you want it always on |
|
||||
| a MeshCore companion on USB, Bluetooth, or Wi-Fi | nothing here — the [README](../README.md), and [When a companion won't connect](connecting.md) if it doesn't |
|
||||
|
||||
The two targets have nothing in common but MeshTerm itself. The device-side scripts each
|
||||
manual runs live in [`scripts/`](../scripts/), one folder per handheld; the
|
||||
[index below](#the-scripts) says what every file does and which machine it runs on. Read a
|
||||
script before you run it; most run as root and change the machine they run on.
|
||||
|
||||
## The scripts
|
||||
|
||||
[`scripts/`](../scripts/) holds the device-side helpers, one folder per manual:
|
||||
[`picocalc/`](../scripts/picocalc/) for the PicoCalc — bringing up Calculinux on the Lyra,
|
||||
building its console font, and (under [`xiao-radio/`](../scripts/picocalc/xiao-radio/))
|
||||
giving it a MeshCore radio over UART — and [`uconsole/`](../scripts/uconsole/) for the
|
||||
always-on bridge that fronts the uConsole's SPI-attached LoRa chip as a companion (MeshTerm
|
||||
can also drive that chip directly, with no script at all — the uConsole manual covers
|
||||
both). The manual is the
|
||||
walkthrough; each script's own header says what it assumes about the machine and why each
|
||||
step is the way it is. On the Lyra, only one program may hold `/dev/ttyS1` — MeshTerm
|
||||
directly, or a pump in front of it, never both.
|
||||
|
||||
| File | Runs on | What it does |
|
||||
| --- | --- | --- |
|
||||
| [`picocalc/calculinux-setup.sh`](../scripts/picocalc/calculinux-setup.sh) | the Lyra (root) | One-time bring-up: python packages, the Wi-Fi boot-scan kick, a boot clock sync (there is no RTC), the deploy user, the checkout, the venv and `pip install -e .`, the login PATH, and the console font. Idempotent. |
|
||||
| [`picocalc/calculinux-console-font-6x12.sh`](../scripts/picocalc/calculinux-console-font-6x12.sh) | the Lyra (root) | Just the console font: builds, installs and persists the 512-glyph **meshterm** PSF (braille, node marks, rounded frame corners, the list cursor) and restores the stock 16-slot palette. Calls the 6×8 build below when it sits alongside. Run it alone after a MeshTerm update, from the pulled checkout (`/home/meshterm/MeshTerm/scripts/picocalc/`) rather than the copy made at install time. |
|
||||
| [`picocalc/calculinux-console-font-6x8.sh`](../scripts/picocalc/calculinux-console-font-6x8.sh) | the Lyra (root) | The shorter-cell companion font, **meshterm8** (6×8 → 53×40), for anyone who wants more rows than the 6×12 default gives. Its base bitmap is the kernel's `font_6x8`, so this one file and the PSF it builds are **GPL-2.0-only** ([`LICENSE.GPL-2.0`](../scripts/picocalc/LICENSE.GPL-2.0) sits beside it); it reads the donor/alias/keeper tables out of the script above rather than keeping a second copy. Pick between the two fonts from MeshTerm's Preferences page. |
|
||||
| [`picocalc/calculinux-wifi-set.sh`](../scripts/picocalc/calculinux-wifi-set.sh) | the Lyra (sudo) | Interactive Wi-Fi join: prompts for an SSID and passphrase, writes iwd's credentials, connects now. This is how a network *becomes* known to the boot kick. |
|
||||
| [`picocalc/calculinux-radio-bridge.sh`](../scripts/picocalc/calculinux-radio-bridge.sh) | the Lyra (root) | **Unfinished, unsupported.** An early UART-to-TCP pump: it serves `/dev/ttyS1` on TCP port 5000 and writes a `radio` TCP profile. It does not route UART1 to the header pads. Superseded by `xiao-radio/lyra-setup.sh`, which does the routing and points MeshTerm at the serial port directly. |
|
||||
| [`picocalc/xiao-radio/`](../scripts/picocalc/xiao-radio/) | see below | Everything for the XIAO nRF52840 + Wio-SX1262 radio wired to the Lyra's UART1; its [bench card](../scripts/picocalc/xiao-radio/README.md) has the wiring table. The firmware is MeshCore, MIT-licensed — [`LICENSE.MeshCore`](../scripts/picocalc/xiao-radio/LICENSE.MeshCore) sits beside the patch. |
|
||||
| [`xiao-radio/build-firmware.sh`](../scripts/picocalc/xiao-radio/build-firmware.sh) | dev machine | Clones and patches MeshCore, builds the `Xiao_nrf52_companion_radio_serial` environment, emits the `.uf2`. |
|
||||
| [`xiao-radio/flash.py`](../scripts/picocalc/xiao-radio/flash.py) | dev machine | Flashes the `.uf2` to the XIAO over its UF2 bootloader, refusing the wrong board or SoftDevice; prints the serial-DFU command when the bootloader offers no drive. |
|
||||
| [`xiao-radio/meshcore-uart1.patch`](../scripts/picocalc/xiao-radio/meshcore-uart1.patch) | — | The two firmware hunks: the companion on nRF52 `Serial1`, and I²C moved off the UART pads. Not upstream yet. |
|
||||
| [`xiao-radio/lyra-setup.sh`](../scripts/picocalc/xiao-radio/lyra-setup.sh) | the Lyra (root) | Boot-persistent UART1 → GP4/GP5 mux service, `dialout` membership, and a default MeshTerm serial profile for `/dev/ttyS1`. |
|
||||
| [`xiao-radio/uart1-mux.py`](../scripts/picocalc/xiao-radio/uart1-mux.py) | the Lyra (root) | The matrix-IO register poke that routes UART1 to the header pads, run once per boot by the service above. |
|
||||
| [`uconsole/meshterm-spi-bridge`](../scripts/uconsole/meshterm-spi-bridge) | the radio host (your user) | The **always-on** option: runs a mesh node in software over an SPI-attached SX1262 (uConsole AIO, Waveshare HATs) and serves the companion protocol on `127.0.0.1:5000`, so the node stays on the mesh while MeshTerm is closed. Setup menu, preflight, optional `systemd --user` service. Runs on `openhop_core` (preferred) or its predecessor `pymc_core`; see the manual for the venv it wants on a bookworm uConsole. Most people should reach for `meshterm --spi` instead — no script, no service, MeshTerm drives the chip itself while it runs. |
|
||||
|
||||
## The regular and picocalc platforms
|
||||
|
||||
MeshTerm resolves one frozen **platform** at boot and draws everything through it. The
|
||||
**regular** platform is a desktop or ssh terminal: 72 readable columns, truecolour, emoji,
|
||||
a footer hint line. The **picocalc** platform is a framebuffer console: 53 columns, no
|
||||
emoji, an F-key lane instead of a hint line, and sixteen colour slots rather than a colour
|
||||
space. Nothing loses its colour there: the node hue and the recency heat gradient
|
||||
quantise to those slots instead. It is detected from the device tree on the Lyra, and
|
||||
`--platform picocalc` or `MESHTERM_PLATFORM=picocalc` forces it anywhere, which is how you
|
||||
preview the handheld layout on a desktop:
|
||||
|
||||
```bash
|
||||
meshterm --mock --platform picocalc # the whole app, simulated radio, handheld layout
|
||||
meshterm specimen # the visual language on one card
|
||||
meshterm platform # which platform this terminal resolved to
|
||||
```
|
||||
|
||||
What that platform needs from the console — a font with the glyphs the interface draws,
|
||||
and the stock sixteen-slot palette — is installed by the PicoCalc manual's setup script;
|
||||
the details are in that manual and in the scripts' own headers.
|
||||
@@ -0,0 +1,940 @@
|
||||
# MeshTerm on the PicoCalc
|
||||
|
||||
You start with a stock ClockworkPi PicoCalc — the kit that ships with a Raspberry Pi Pico —
|
||||
and a short shopping list. You end with a Linux handheld: MeshTerm running full-screen on
|
||||
the PicoCalc's own display and keyboard, a LoRa radio soldered inside, and no laptop needed
|
||||
to use it day to day. There are three phases: build the machine (swap the Pico for a Linux
|
||||
board), install the OS and MeshTerm, and add the radio. Budget an afternoon for the first
|
||||
two phases, most of it spent waiting for a toolchain to download; the radio phase needs a
|
||||
soldering iron and can be done later — MeshTerm's `--mock` mode runs perfectly well without
|
||||
a radio in the meantime.
|
||||
|
||||
> ⚠ **Read this first.** This build takes the PicoCalc apart, swaps its core board, and
|
||||
> solders to it. It assumes you can already solder cleanly, use a multimeter, and work with
|
||||
> small electronics without forcing anything. There is a real risk of damaging the
|
||||
> hardware if a step goes wrong — a cracked screen, a snapped shell post, a dead board, or
|
||||
> an overcharged lithium cell — and some of that damage is not repairable. You do this at
|
||||
> your own risk.
|
||||
>
|
||||
> **Read the whole manual through once before you buy anything or open anything.** Several
|
||||
> steps only make sense in light of a later one — where the SD card goes, what must be out
|
||||
> of the case before a board is pressed in, which USB port is never to be powered — and the
|
||||
> shopping list is easier to get right when you know what each part is for.
|
||||
>
|
||||
> This is a guide, not a prescription. Despite the author's best efforts it may contain
|
||||
> errors, and parts of it will go out of date as boards, images and firmware change. It is
|
||||
> up to you to check every fact in it against your own hardware and the current sources
|
||||
> before you act on it, and every decision along the way is yours. The author accepts no
|
||||
> responsibility for any damage, loss, or injury that results from following this guide,
|
||||
> and nothing in it is a warranty that your parts, your tools, or your hands will behave the
|
||||
> way the author's did. If you are not comfortable with any step, stop and get help from
|
||||
> someone who is.
|
||||
|
||||
- [Shopping list](#shopping-list)
|
||||
- [Before you start](#before-you-start)
|
||||
- [Three ways to break it](#three-ways-to-break-it)
|
||||
- [Phase 1 — Build the machine](#phase-1--build-the-machine)
|
||||
- [Step 1 — Assemble the PicoCalc](#step-1--assemble-the-picocalc)
|
||||
- [Step 2 — Swap the Pico for the Lyra](#step-2--swap-the-pico-for-the-lyra)
|
||||
- [Step 3 — Attach the Wi-Fi dongle](#step-3--attach-the-wi-fi-dongle)
|
||||
- [Phase 2 — Install Calculinux and MeshTerm](#phase-2--install-calculinux-and-meshterm)
|
||||
- [Step 4 — Write the image to the microSD](#step-4--write-the-image-to-the-microsd)
|
||||
- [Step 5 — First boot](#step-5--first-boot)
|
||||
- [Step 6 — Open the serial console](#step-6--open-the-serial-console)
|
||||
- [Step 7 — Join Wi-Fi](#step-7--join-wi-fi)
|
||||
- [Step 8 — Copy the setup scripts to the device](#step-8--copy-the-setup-scripts-to-the-device)
|
||||
- [Step 9 — Run calculinux-setup.sh](#step-9--run-calculinux-setupsh)
|
||||
- [Step 10 — Log in as meshterm](#step-10--log-in-as-meshterm)
|
||||
- [Phase 3 — Add the radio](#phase-3--add-the-radio)
|
||||
- [Step 11 — Install PlatformIO](#step-11--install-platformio)
|
||||
- [Step 12 — Build the firmware](#step-12--build-the-firmware)
|
||||
- [Step 13 — Flash the XIAO](#step-13--flash-the-xiao)
|
||||
- [Step 14 — Wire the radio](#step-14--wire-the-radio)
|
||||
- [Step 15 — Set up the Lyra](#step-15--set-up-the-lyra)
|
||||
- [Step 16 — Run it](#step-16--run-it)
|
||||
- [Day-to-day](#day-to-day)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [What has been tested](#what-has-been-tested)
|
||||
|
||||
---
|
||||
|
||||
## Shopping list
|
||||
|
||||
### The machine
|
||||
|
||||
| Item | Product / what to search for | Approx. price | Link | Note |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| PicoCalc kit | ClockworkPi PicoCalc | $89 | [clockworkpi.com/product-page/picocalc](https://www.clockworkpi.com/product-page/picocalc) | Comes with a Raspberry Pi Pico 1H, screen, keyboard, shell, hex key and a 32 GB SD card. **18650 batteries are not included.** |
|
||||
| Luckfox Lyra | "Luckfox Lyra 128MB, pre-soldered header, no NAND" | $20–25 | [luckfox.com/Luckfox-Lyra](https://www.luckfox.com/Luckfox-Lyra) | Buy the **plain 128 MB RAM, Pico-form-factor** variant, **with the header pre-soldered**. Other RAM sizes have incompatible pinouts, so this is the one part of the list where the wrong SKU won't work. |
|
||||
| microSD card | 16 GB+, Class 10 or better | $6–10 | — | The PicoCalc's stock 32 GB card works fine — it's more than the 8 GB minimum. You can reuse it instead of buying a new one. |
|
||||
| An 18650 battery | e.g. Samsung 30Q, Molicel P26A, Sony VTC6 (examples, not endorsements) | $5–10 | — | **Unprotected**, flat-top or button-top, Ø18 × 65–69 mm. One is enough to run; a second is optional (below). See [the battery note](#a-note-on-batteries) below. |
|
||||
| USB Wi-Fi dongle | TP-Link TL-WN725N (an RTL8188EU nano dongle), or search the **chipset name**: RTL8192CU, R8712U, RTL8188EU | $5–10 | [amazon.ca/dp/B008IFXQFU](https://www.amazon.ca/dp/B008IFXQFU) | The TL-WN725N is the dongle this guide was built with. Must run at **3.3 V** — see [Step 3](#step-3--attach-the-wi-fi-dongle). Only needed for setup and updates; MeshTerm itself runs offline. |
|
||||
| MX1.25 4-pin pigtail | "MX1.25 4P cable with leads" (a pack of pre-crimped pigtails, 10–15 cm) | $2–4 | — | One end of the DIY USB lead in [Step 3](#step-3--attach-the-wi-fi-dongle). Pre-crimped, because MX1.25 contacts need a crimper you don't otherwise own. |
|
||||
| USB-A female socket | "USB 2.0 type A female socket, solder / through-hole" | $2–3 | — | The other end of the DIY lead. A ready-made [MX1.25 4P to USB-A cable](https://spotpear.com/shop/Luckfox-Lyra-MX1.25-4P-To-USB-A-Cable.html) (about $1, 30 cm) works too, but it is a lot of cable to fold into the shell. |
|
||||
|
||||
### The radio
|
||||
|
||||
| Item | Product / what to search for | Approx. price | Link | Note |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| XIAO nRF52840 + Wio-SX1262 kit | "XIAO nRF52840 & Wio-SX1262 Kit for Meshtastic", SKU 102010710 | $13.49 | [seeedstudio.com](https://www.seeedstudio.com/XIAO-nRF52840-Wio-SX1262-Kit-for-Meshtastic-p-6400.html) | Includes the XIAO nRF52840, the Wio-SX1262 LoRa board (stacked and wired together) and an antenna. Covers both 868 and 915 MHz. Plain or Sense XIAO both work. **Do not** buy the "-N" variant — it has no antenna connector. |
|
||||
|
||||
### Tools and consumables
|
||||
|
||||
| Item | Note |
|
||||
| --- | --- |
|
||||
| Soldering iron, fine tip, and solder | For the four radio wires. |
|
||||
| Hookup wire, 26–30 AWG, four short lengths | TX and RX get crossed — see [Step 14](#step-14--wire-the-radio). |
|
||||
| 2.5 mm hex key | Comes with the PicoCalc kit. |
|
||||
| USB-C data cable | For the serial console, and later for flashing the XIAO. A charge-only cable won't do; it has to carry data. |
|
||||
| microSD card reader | For writing the Calculinux image from your PC. |
|
||||
| Small side cutters | For trimming wire ends, and a section of the case grille if the Wi-Fi lead needs the room. |
|
||||
| Small needle file | To open the back panel's USB-C cutout a little if the Lyra's connector doesn't clear it. |
|
||||
| Kapton (polyimide) tape | Wraps the XIAO stack and the DIY USB lead so nothing bare touches the board or the shell. Heat-proof, thin, and it peels off cleanly. |
|
||||
| A PC (Windows/macOS/Linux) with Python 3 and git | Runs the image-writing, firmware-build and flashing steps. |
|
||||
|
||||
### Optional
|
||||
|
||||
| Item | Why |
|
||||
| --- | --- |
|
||||
| A second 18650 battery | The holder takes two; one is enough to run, two roughly double your runtime. |
|
||||
| Powered USB hub | If you want the Wi-Fi dongle and something else on the Lyra's single USB host socket at once, or a 5 V dongle. |
|
||||
| Foam tape | To secure the XIAO board inside the shell once it's wired in. |
|
||||
|
||||
Approximate total: **$130–150** for the machine and radio together, before batteries — most
|
||||
of that is the PicoCalc kit ($89), the Lyra (~$22) and the radio kit ($13.49).
|
||||
|
||||
**What you do NOT need:**
|
||||
|
||||
- A level shifter. Every signal in this build — the Lyra's header, the XIAO, the SX1262 — is
|
||||
3.3 V.
|
||||
- A separate USB-to-serial adapter. The PicoCalc mainboard already has one built in.
|
||||
- Bluetooth anything. Neither the Lyra nor this radio path uses it.
|
||||
- A new SD card, if your stock 32 GB card is handy — it's well over the 8 GB minimum
|
||||
Calculinux asks for.
|
||||
|
||||
### A note on batteries
|
||||
|
||||
ClockworkPi has not published an official battery spec for the PicoCalc — a
|
||||
[still-open GitHub issue](https://github.com/clockworkpi/PicoCalc/issues/6) asking for one
|
||||
has gone unanswered. What's known from the kit itself: the holder takes two 18650 cells, one
|
||||
cell is enough to power the board, and the physical envelope is about Ø18 mm × 65–69 mm,
|
||||
which rules out most *protected* cells — the protection circuit adds length that doesn't
|
||||
fit. Unprotected flat-top or button-top cells both work; the PicoCalc mainboard has its own
|
||||
charge circuit built in. Buy from a reputable brand rather than a cheap unbranded listing —
|
||||
Samsung 30Q, Molicel P26A and Sony VTC6 are commonly recommended examples, not an
|
||||
endorsement of any one of them.
|
||||
|
||||
---
|
||||
|
||||
## Before you start
|
||||
|
||||
Work happens on two machines, and it helps to keep straight which is which:
|
||||
|
||||
- **Your PC** writes the Calculinux image to the microSD card, builds the radio firmware,
|
||||
and flashes the XIAO. Nothing in these three jobs touches the PicoCalc itself.
|
||||
- **The PicoCalc** (really, the Luckfox Lyra inside it) does everything else — setup,
|
||||
Wi-Fi, running MeshTerm — either over the serial console from your PC or on its own
|
||||
screen and keyboard. Both work identically; the serial console is just easier for pasting
|
||||
long commands.
|
||||
|
||||
The device has two logins you'll use for different things: **`root`**, which you use once
|
||||
to run the setup script and fix Wi-Fi, and **`meshterm`**, the unprivileged account
|
||||
MeshTerm actually runs under day to day.
|
||||
|
||||
Calculinux is a console-only Linux — no desktop, no window manager. That's deliberate: the
|
||||
PicoCalc's screen and keyboard are a terminal, and MeshTerm is a full-screen terminal
|
||||
program built for exactly that.
|
||||
|
||||
---
|
||||
|
||||
## Three ways to break it
|
||||
|
||||
The PicoCalc is a kit, and its forum has a well-worn list of the ways people have damaged
|
||||
theirs. Three of them apply directly to this build, and the first two are not theoretical:
|
||||
the author of this guide **cracked a screen and snapped screw posts** building the very
|
||||
machine it describes. Read this section before you open the shell.
|
||||
|
||||
> ⚠ **The screen is extremely fragile.** It is a bare glass panel with no frame of its own,
|
||||
> and when the case is closed it sits directly against the mainboard. Anything that flexes
|
||||
> the mainboard flexes the glass: pushing a board into the Pico socket, prying one out,
|
||||
> closing the shell with the screen not sitting square in its opening, or pressing a key
|
||||
> while the halves are being screwed together. Forum members have cracked screens doing
|
||||
> each of those, and the swap in [Step 2](#step-2--swap-the-pico-for-the-lyra) is the
|
||||
> classic one — pressing a new board into the socket with the mainboard still in the case
|
||||
> is enough on its own. That is exactly how the screen in this guide's own PicoCalc broke.
|
||||
> ([Before replacing the Pico](https://forum.clockworkpi.com/t/before-replacing-the-pico-read-this-to-avoid-cracked-screen/16666),
|
||||
> [Avoid breaking your screen](https://forum.clockworkpi.com/t/avoid-breaking-your-screen/18805))
|
||||
>
|
||||
> What to do instead:
|
||||
>
|
||||
> - **Never push a board into the socket, or pull one out, with the mainboard in the case.**
|
||||
> Unplug the display's ribbon cable (the FPC) from the mainboard, lift the mainboard out,
|
||||
> and do the swap on a flat, padded surface — a folded microfibre cloth is ideal. Ease a
|
||||
> board out a little at each end in turn, never by levering one end.
|
||||
> - **Secure the screen with Kapton tape** along its edges so it cannot shift in the
|
||||
> opening while you work and while the case is closed. Kapton is the right tape: thin,
|
||||
> it stays put, and it peels off cleanly. Don't use anything that sets hard.
|
||||
> - **Before the back goes on, check the screen sits flush and square** in the front half's
|
||||
> opening with no pressure on any corner. If the mainboard doesn't sit perfectly flush on
|
||||
> the front half, stop and find out why before you add the back.
|
||||
> - Handle the panel as little as possible, and keep your fingers off the keyboard while
|
||||
> closing the shell.
|
||||
|
||||
> ⚠ **Don't overtighten the screws.** The shell's screw posts are brass inserts moulded
|
||||
> into plastic, and forcing a screw snaps the post out of the shell — a repair that needs
|
||||
> epoxy and never quite comes back
|
||||
> ([Broken screw thread](https://forum.clockworkpi.com/t/broken-screw-thread/20726)).
|
||||
> This guide's author snapped several on the first assembly; it takes less force than you
|
||||
> would think.
|
||||
> Turn each screw until it just seats, then stop. If a screw won't go in easily, the halves
|
||||
> aren't aligned, or something inside is in the way; more force is never the answer.
|
||||
|
||||
> ⚠ **Never power the Lyra through its own USB-C while the batteries are in.** The
|
||||
> PicoCalc charges its cells only through the **mainboard's** USB-C port, under the control
|
||||
> of its power-management chip (an AXP2101). Power fed in through the core board's USB port
|
||||
> — the Pico's originally, the Lyra's now — arrives on the header's system-power pin and
|
||||
> bypasses that charge control. Forum members who powered the Pico's port directly measured
|
||||
> their cells at **4.46 V and 4.74 V** afterwards, well over the 4.2 V a lithium cell is
|
||||
> allowed to reach
|
||||
> ([New PicoCalc — battery charging](https://forum.clockworkpi.com/t/new-picocalc-battery-charging/22330)).
|
||||
> An overcharged cell is a fire risk, not just a worn one.
|
||||
>
|
||||
> Nothing in this guide needs the Lyra's USB-C: the serial console, charging and power all
|
||||
> use the mainboard's port on the side of the case, and the Wi-Fi dongle uses the 4-pin
|
||||
> socket on top of the Lyra. If you ever must plug the Lyra's USB-C into a PC — to
|
||||
> reflash it with Luckfox's tools, say — **take both batteries out first.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Build the machine
|
||||
|
||||
### Step 1 — Assemble the PicoCalc
|
||||
|
||||
Follow ClockworkPi's own
|
||||
[assembly guidelines](https://github.com/clockworkpi/PicoCalc/blob/master/Clockwork_PicoCalc_Assembly_Guidelines.pdf)
|
||||
(a PDF in the [PicoCalc repository](https://github.com/clockworkpi/PicoCalc), which also
|
||||
has a wiki) to put the kit together: mainboard, screen, keyboard, speakers, and shell. Insert your 18650
|
||||
battery (or two), watching the `+`/`-` marks in the battery compartment.
|
||||
|
||||
⚠ Two of the warnings in [Three ways to break it](#three-ways-to-break-it) apply here: tape
|
||||
the screen's edges with Kapton before you close the shell and check it sits square, and
|
||||
turn the screws only until they seat. You will open the shell again in the next step, so
|
||||
there is no reason to make the screws tight now.
|
||||
|
||||
Power it on once with the **stock Pico still in place**. You should see the stock BASIC
|
||||
firmware boot on the screen, and the keyboard should respond. This confirms the screen,
|
||||
keyboard, and battery are all good before you start swapping boards.
|
||||
|
||||
Power it back off.
|
||||
|
||||
### Step 2 — Swap the Pico for the Lyra
|
||||
|
||||
Remove the back of the shell with the hex key.
|
||||
|
||||
⚠ **Do the swap with the mainboard out of the case.** This is the step that cracks screens
|
||||
(see [Three ways to break it](#three-ways-to-break-it)). Unplug the display's ribbon cable
|
||||
from the mainboard, lift the mainboard out, and lay it on a padded surface. Only then ease
|
||||
the Raspberry Pi Pico out of its socket, a little at each end in turn — **keep it**, you're
|
||||
just setting it aside.
|
||||
|
||||
If your Luckfox Lyra has SPI NAND on board (a "Lyra B"), erase it first — the boot ROM
|
||||
otherwise ignores the SD card entirely and boots the on-board flash instead. See
|
||||
[Calculinux's hardware requirements page](https://calculinux.org/getting-started/hardware-requirements/)
|
||||
for how.
|
||||
|
||||
Seat the Lyra into the same socket the Pico came out of, in the same orientation — its
|
||||
USB-C port at the end where the Pico's USB port was, so it lines up with the cutout in the
|
||||
back of the shell. Press it down fully with the mainboard still on the padded surface; a
|
||||
Lyra that isn't fully seated won't boot. Then reconnect the ribbon cable and set the
|
||||
mainboard back into the front half, checking the screen sits square before anything is
|
||||
screwed down.
|
||||
|
||||
You should see the Lyra sitting flat on the socket with no gap at either end, and, once
|
||||
the mainboard is back in, its USB-C port centred in the back-panel cutout.
|
||||
|
||||
The cutout was made for the Pico's micro-USB, and the Lyra's USB-C connector is a
|
||||
different shape. Depending on the connector Luckfox fitted to your board, the back panel
|
||||
may not close over it. If it catches, open the hole a little with a small needle file,
|
||||
testing the fit as you go, until the panel sits down without pressing on the board.
|
||||
|
||||
Leave the back off for now. The microSD card goes into the **Lyra's own slot** in
|
||||
[Step 4](#step-4--write-the-image-to-the-microsd), and that slot is easier to reach with the
|
||||
board exposed.
|
||||
|
||||
### Step 3 — Attach the Wi-Fi dongle
|
||||
|
||||
The Lyra has two USB connectors. The USB-C on its edge is for power and flashing and is
|
||||
not used here. The **tiny 4-pin socket on top of the board** (an MX1.25 connector, next to
|
||||
the two buttons by the USB-C end) is the USB host. The Wi-Fi dongle hangs off it through a
|
||||
short lead you make yourself: a ready-made cable is 30 cm long and a struggle to fold into
|
||||
the shell, and you have the soldering iron out for the radio anyway.
|
||||
|
||||
**Make the lead.** Solder the four wires of an MX1.25 pigtail to a USB-A female socket,
|
||||
keeping the run short — 5 to 8 cm is enough to reach a spot where the dongle sits flat.
|
||||
The pigtail's pins are in the standard USB order, which the pre-made cable's wire colours
|
||||
follow:
|
||||
|
||||
| MX1.25 pin | USB-A socket pin | Signal | Pigtail wire colour (usually) |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 1 | VBUS (**3.3 V** on this board) | red |
|
||||
| 2 | 2 | D− | white |
|
||||
| 3 | 3 | D+ | green |
|
||||
| 4 | 4 | GND | black |
|
||||
|
||||
USB-A socket pins are numbered 1 to 4 across the connector's opening: VBUS on one edge,
|
||||
GND on the other, the data pair between them. Colours on a pigtail are a convention, not a
|
||||
promise, so check pin 1 before you plug anything in: with the Lyra powered, the socket's
|
||||
pin 1 reads **3.3 V** against a GND header pin and the pin at the other end reads 0 V.
|
||||
Swapping VBUS and GND is the one mistake that damages the dongle.
|
||||
|
||||
Once soldered, wrap the socket's solder side and the exposed wire in Kapton tape. Plug the
|
||||
MX1.25 end into the socket on the Lyra — it is keyed and only goes in one way — and the
|
||||
Wi-Fi dongle into the USB-A end. Lead and dongle stay inside the shell; tuck them where
|
||||
they don't press on the board.
|
||||
|
||||
The MX1.25 socket sits on top of the Lyra, and the lead leaves it upward, which is where
|
||||
the back panel's grille is. The wire may interfere with the grille when the back goes on.
|
||||
If it does, snip a small section out of the grille with the side cutters where the wire
|
||||
passes and shape the edge until nothing presses on the plug or the wire. Better to remove a
|
||||
sliver of plastic than to close the case against a connector.
|
||||
|
||||
> **The Lyra's USB port is 3.3 V, not 5 V.** A standard 5 V USB Wi-Fi dongle will not work
|
||||
> and may damage the board. Stick to the tested chipsets — RTL8192CU, R8712U, or RTL8188EU
|
||||
> — which is why the shopping list names a known dongle (the TP-Link TL-WN725N) and
|
||||
> otherwise tells you to search by chipset rather than by brand.
|
||||
|
||||
Don't power the device on yet — Wi-Fi is set up later, once Calculinux is installed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Install Calculinux and MeshTerm
|
||||
|
||||
### Step 4 — Write the image to the microSD
|
||||
|
||||
On your PC, download `calculinux-image-luckfox-lyra.rootfs-<timestamp>.wic.gz` from the
|
||||
newest release on the
|
||||
[Calculinux releases page](https://github.com/Calculinux/meta-calculinux/releases). Every
|
||||
Calculinux release is marked **Pre-release**, so there is no "Latest" badge to follow; take
|
||||
the newest pre-release.
|
||||
|
||||
Decompress it:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
gunzip calculinux-image-luckfox-lyra.rootfs-*.wic.gz
|
||||
```
|
||||
|
||||
(The Calculinux installation page mentions `unxz`, but the file is a `.gz`, so use
|
||||
`gunzip`. 7-Zip works on any platform if you'd rather not use the command line.)
|
||||
|
||||
Write the resulting `.wic` file to your microSD card. Three options:
|
||||
|
||||
- **Balena Etcher** (recommended, all platforms) — open it, select the `.wic` file, select
|
||||
your SD card, click Flash.
|
||||
- **`dd`**, on Linux/macOS. Check the device name first — writing to the wrong device
|
||||
destroys its contents:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
lsblk # find your SD card's device, e.g. /dev/sdb
|
||||
sudo dd if=calculinux-image-luckfox-lyra.rootfs-*.wic of=/dev/sdX bs=4M status=progress conv=fsync
|
||||
```
|
||||
|
||||
- **Rufus**, on Windows — select the device and the image, choose the **MBR** partition
|
||||
scheme, and start.
|
||||
|
||||
This erases everything already on the card. Once it finishes, eject it and put it in the
|
||||
**Luckfox Lyra's own microSD slot** — not the PicoCalc's front-facing slot. The Lyra boots
|
||||
from its own slot; the front slot is only reachable once MeshTerm (or anything else) is
|
||||
already running on Linux.
|
||||
|
||||
Leave the front slot **empty** for the first boot, as Calculinux's own instructions warn: a
|
||||
second card there with duplicate partition labels leaves the system read-only.
|
||||
|
||||
### Step 5 — First boot
|
||||
|
||||
With the card in the Lyra, power the PicoCalc on. Give it 30–60 seconds — the first boot is
|
||||
slower than later ones. You should see boot messages scroll on the PicoCalc's own screen,
|
||||
ending in a `Calculinux GNU/Linux` login prompt.
|
||||
|
||||
Log in:
|
||||
|
||||
```
|
||||
login: root
|
||||
Password: root
|
||||
```
|
||||
|
||||
Change the password immediately:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
passwd
|
||||
```
|
||||
|
||||
### Step 6 — Open the serial console
|
||||
|
||||
The PicoCalc's own screen and keyboard work fine, so you can skip this step. The serial
|
||||
console makes pasting the longer commands in this guide much easier, though, so it is
|
||||
recommended.
|
||||
|
||||
The PicoCalc mainboard's USB-C port carries a USB-to-UART bridge (a CH340 chip). Plug a
|
||||
USB-C cable from that port into your PC. It shows up as:
|
||||
|
||||
- `/dev/ttyUSB0` on Linux (you may need to be in the `dialout` group),
|
||||
- a `COM` port on Windows,
|
||||
- `/dev/cu.usbserial-*` on macOS.
|
||||
|
||||
Settings: **1500000 8N1**, no flow control. Using `pyserial`'s `miniterm`:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
pip install pyserial
|
||||
python3 -m serial.tools.miniterm /dev/ttyUSB0 1500000
|
||||
```
|
||||
|
||||
minicom and PuTTY work too, at the same settings. To leave `miniterm`, press **Ctrl+]**.
|
||||
|
||||
You should see the same login prompt you saw on the PicoCalc's screen, now in your PC's
|
||||
terminal.
|
||||
|
||||
### Step 7 — Join Wi-Fi
|
||||
|
||||
Calculinux manages Wi-Fi with `iwd`. The easiest way is its text-mode picker: run `uwific`,
|
||||
highlight your network, press Enter and type the passphrase (`Q` quits). Or do the same
|
||||
from the command line:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
iwctl station wlan0 scan
|
||||
iwctl station wlan0 get-networks
|
||||
iwctl station wlan0 connect "<your SSID>"
|
||||
```
|
||||
|
||||
It'll prompt for the passphrase. Check you're online:
|
||||
|
||||
```bash
|
||||
ip addr show wlan0
|
||||
ping -c 3 1.1.1.1
|
||||
```
|
||||
|
||||
You should see an `inet` address on `wlan0`, and replies from the ping. `iwd` remembers
|
||||
the network, so this is a one-time step; to change networks later, run `uwific` again.
|
||||
|
||||
### Step 8 — Copy the setup scripts to the device
|
||||
|
||||
You need this repository's `scripts/` directory on the Lyra — its `picocalc/` folder is the
|
||||
part the Lyra runs. Two ways to get it there, once Wi-Fi is up:
|
||||
|
||||
- **`scp` from your PC**. Find the Lyra's IP address with `ip addr show wlan0` on the
|
||||
device, then from your PC:
|
||||
|
||||
```bash
|
||||
# on the PC, from your MeshTerm checkout
|
||||
scp -r scripts root@<lyra-ip>:/root/
|
||||
```
|
||||
|
||||
If root is refused over SSH, copy as the `pico` user (password `calc`) instead, then
|
||||
move the directory as root on the device:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
scp -r scripts pico@<lyra-ip>:/home/pico/
|
||||
# on the PicoCalc, as root
|
||||
mv /home/pico/scripts /root/
|
||||
```
|
||||
|
||||
- **`git clone` on the device.** `git` ships in the Calculinux base image, so as root you
|
||||
can run `git clone https://github.com/jpmartineau/MeshTerm` and use the `scripts/`
|
||||
folder inside the clone.
|
||||
|
||||
`scp` is the recommended route.
|
||||
|
||||
### Step 9 — Run calculinux-setup.sh
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root, with scripts/ copied over
|
||||
cd scripts/picocalc
|
||||
sh calculinux-setup.sh
|
||||
```
|
||||
|
||||
It's idempotent — safe to re-run — and it checks before it acts. It reads these
|
||||
environment variables at the top of the script:
|
||||
|
||||
| Knob | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `DEPLOY_USER` | `meshterm` | the login MeshTerm runs under |
|
||||
| `TIMEZONE` | `America/Toronto` | any IANA zone. Set it if you are not on Eastern time. |
|
||||
| `REPO_URL` | the project's HTTPS URL | cloned when there's no deploy key |
|
||||
| `REPO_SSH` | the project's SSH URL | cloned when there is one |
|
||||
| `KEY_PATH` | `/home/$DEPLOY_USER/.ssh/id_ed25519` | a read-only GitHub deploy key, if you want SSH instead of HTTPS |
|
||||
| `WIFI_SSID`, `WIFI_PSK` | unset | set both and the script writes `iwd`'s credentials for that network, as an alternative to Step 7 |
|
||||
|
||||
The defaults clone MeshTerm's public repository over plain HTTPS, which needs no credential
|
||||
at all. The one you are likely to need is `TIMEZONE`, if you live outside Eastern time:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
TIMEZONE=America/Vancouver sh calculinux-setup.sh
|
||||
```
|
||||
|
||||
The script installs only the Americas time-zone data, so a zone elsewhere may not take. If
|
||||
it cannot set the zone, it warns and the clock stays on UTC.
|
||||
|
||||
What it does, one line per phase:
|
||||
|
||||
- **opkg packages** — installs `python3-modules` and `python3-pip` (and `git` and `kbd`,
|
||||
if the image lacks them), because Calculinux ships a stripped Python missing `pip`,
|
||||
`venv` and several stdlib modules.
|
||||
- **Wi-Fi kick** — installs a boot-time service that re-scans until the dongle joins the
|
||||
network `iwd` already knows, because on a cold boot the dongle's firmware finishes
|
||||
loading after `iwd`'s first scan and the link would otherwise never come up.
|
||||
- **Time sync** — installs a boot-time service that sets the clock over the network, since
|
||||
this board has no battery-backed real-time clock.
|
||||
- **Timezone** — sets `$TIMEZONE` (`America/Toronto` by default).
|
||||
- **Deploy user** — creates the `meshterm` login and puts it in the groups it needs.
|
||||
- **Clone** — checks out MeshTerm to `/home/meshterm/MeshTerm`.
|
||||
- **venv and install** — builds a Python virtual environment and `pip install -e .`s
|
||||
MeshTerm into it.
|
||||
- **Login PATH** — puts that venv's `bin` on the `meshterm` user's PATH.
|
||||
- **Console font** — builds and installs the console font MeshTerm's UI needs (braille
|
||||
charts, node glyphs, rounded corners).
|
||||
|
||||
This takes a few minutes on a good connection — most of it is `pip install` compiling
|
||||
things. It ends with `done` and tells you to log in as `meshterm`. That user has no
|
||||
password yet, so set one first:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
passwd meshterm
|
||||
```
|
||||
|
||||
> **With no Wi-Fi dongle attached, the next boot can take up to about eleven minutes.**
|
||||
> The Wi-Fi kick keeps trying for about six before it gives up, and the time sync then
|
||||
> waits up to five more for a network route. Nothing is broken. If the device is going to
|
||||
> live offline, see [Day-to-day](#day-to-day) for how to switch both off.
|
||||
|
||||
### Step 10 — Log in as meshterm
|
||||
|
||||
Log out of `root`, and log in as `meshterm` with the password you just set. Try the app:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as meshterm
|
||||
meshterm --mock
|
||||
```
|
||||
|
||||
You should see MeshTerm's full-screen interface come up at **53 columns**, with a simulated
|
||||
radio behind it, drawing braille charts and node glyphs cleanly — no empty tofu boxes. Along
|
||||
the bottom you should see the **F-key lane**: five labelled chips instead of the footer hint
|
||||
line a desktop terminal shows.
|
||||
|
||||
Have a look around. **Esc** backs out of any screen, and **Quit** on the main menu leaves
|
||||
the app.
|
||||
|
||||
Then look at the whole visual language on one card:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as meshterm
|
||||
meshterm specimen
|
||||
```
|
||||
|
||||
There are two console fonts available — a 6×12 one (53 × 26) and a 6×8 one (53 × 40,
|
||||
more rows but no Cyrillic glyphs). Try both from *Preferences → Display → Console font* —
|
||||
picking one previews it immediately, and the screen repaints at the new row count so you can
|
||||
see the choice before you keep it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Add the radio
|
||||
|
||||
This phase can wait. Everything above already gives you a working MeshTerm in `--mock`
|
||||
mode. Come back to this when you have the soldering iron out.
|
||||
|
||||
### Step 11 — Install PlatformIO
|
||||
|
||||
On your PC:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
pip install platformio
|
||||
```
|
||||
|
||||
(or use the
|
||||
[official installer script](https://docs.platformio.org/en/latest/core/installation/methods/installer-script.html)
|
||||
if you'd rather not touch your system Python). Confirm `pio` is on your PATH:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
pio --version
|
||||
```
|
||||
|
||||
The first firmware build downloads the nRF52 toolchain — several hundred megabytes — so do
|
||||
this somewhere with a decent connection.
|
||||
|
||||
### Step 12 — Build the firmware
|
||||
|
||||
```bash
|
||||
# on the PC (Git Bash or WSL on Windows)
|
||||
cd scripts/picocalc/xiao-radio
|
||||
sh build-firmware.sh
|
||||
```
|
||||
|
||||
You should see it clone MeshCore, apply a patch, build, and finish with:
|
||||
|
||||
```
|
||||
DONE. Firmware: /path/to/scripts/picocalc/xiao-radio/meshcore-xiao-radio.uf2
|
||||
Next: put the XIAO in bootloader (double-tap reset) and run: python flash.py
|
||||
```
|
||||
|
||||
Here's what it did, so you could do it by hand if you needed to:
|
||||
|
||||
1. Cloned [MeshCore](https://github.com/meshcore-dev/MeshCore) into
|
||||
`scripts/picocalc/xiao-radio/_meshcore-build`.
|
||||
2. Checked out the pinned commit `e9edfc8e` on the `dev` branch — the commit this patch is
|
||||
known to apply cleanly to and build against.
|
||||
3. Applied `meshcore-uart1.patch`.
|
||||
4. Built the `Xiao_nrf52_companion_radio_serial` PlatformIO environment:
|
||||
`pio run -e Xiao_nrf52_companion_radio_serial`.
|
||||
5. Converted the resulting `.hex` to a `.uf2` with MeshCore's own converter, using the
|
||||
nRF52840 UF2 family id:
|
||||
`python bin/uf2conv/uf2conv.py firmware.hex -c -f 0xADA52840 -o meshcore-xiao-radio.uf2`.
|
||||
|
||||
#### What the patch changes, and why it is still needed
|
||||
|
||||
`meshcore-uart1.patch` touches two files, and both hunks are required:
|
||||
|
||||
- **`examples/companion_radio/main.cpp`.** MeshCore's companion-over-serial code declares
|
||||
`HardwareSerial companion_serial(1)` — a numbered constructor. On the Adafruit nRF52 core,
|
||||
`HardwareSerial` is an abstract base class with no numbered constructor at all, so this
|
||||
line simply doesn't compile for the XIAO nRF52840. The patch adds an `#if
|
||||
defined(NRF52_PLATFORM)` branch that binds `companion_serial` to `Serial1` instead — the
|
||||
concrete UART object the nRF52 core always provides. `Uart::setPins(rx, tx)`, called later
|
||||
in the same file, takes the same arguments either way, so nothing else needs to change.
|
||||
- **`variants/xiao_nrf52/platformio.ini`.** This adds a new
|
||||
`Xiao_nrf52_companion_radio_serial` build environment that puts the companion serial link
|
||||
on pins **D6 (TX)** and **D7 (RX)**. It also **moves I²C off those same two pads**, onto
|
||||
two internal pins, 16 and 17, that nothing in this build uses. This second change is the one that matters: the
|
||||
stock `Xiao_nrf52` environment maps I²C onto D6/D7 by default, and both the board's
|
||||
`begin()` and the sensor code start the I²C bus on them automatically. Without the move,
|
||||
I²C seizes the UART pins at boot — the wiring is correct, the firmware builds and runs,
|
||||
and the serial link is still completely silent, because something else already owns the
|
||||
pins.
|
||||
|
||||
Both changes have been submitted upstream to MeshCore, but as of this writing they are
|
||||
**not merged**, and there's no sign that will happen soon — upstream `dev` still declares
|
||||
the bare `HardwareSerial(1)` with no nRF52 branch, and the XIAO's `platformio.ini` still has
|
||||
no `_serial` companion environment. Building with the patch is the normal route here, not a
|
||||
stopgap; when the patch does land, the `git apply` step (and the patch file itself) can be
|
||||
deleted.
|
||||
|
||||
#### Building by hand
|
||||
|
||||
If you want to track newer MeshCore instead of the pinned commit:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
git clone https://github.com/meshcore-dev/MeshCore.git
|
||||
cd MeshCore
|
||||
git checkout dev # or MESHCORE_COMMIT=dev sh build-firmware.sh
|
||||
git apply /path/to/scripts/picocalc/xiao-radio/meshcore-uart1.patch
|
||||
pio run -e Xiao_nrf52_companion_radio_serial
|
||||
python bin/uf2conv/uf2conv.py .pio/build/Xiao_nrf52_companion_radio_serial/firmware.hex \
|
||||
-c -f 0xADA52840 -o meshcore-xiao-radio.uf2
|
||||
```
|
||||
|
||||
If `git apply` fails — upstream has moved and the patch context no longer lines up — the two
|
||||
hunks are small enough to make by hand:
|
||||
|
||||
1. In `examples/companion_radio/main.cpp`, find the line
|
||||
`HardwareSerial companion_serial(1);` and replace it with:
|
||||
|
||||
```cpp
|
||||
#if defined(NRF52_PLATFORM)
|
||||
Uart& companion_serial = Serial1;
|
||||
#else
|
||||
HardwareSerial companion_serial(1);
|
||||
#endif
|
||||
```
|
||||
|
||||
2. At the end of `variants/xiao_nrf52/platformio.ini`, add a new environment:
|
||||
|
||||
```ini
|
||||
[env:Xiao_nrf52_companion_radio_serial]
|
||||
extends = Xiao_nrf52
|
||||
board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld
|
||||
board_upload.maximum_size = 708608
|
||||
build_flags =
|
||||
${Xiao_nrf52.build_flags}
|
||||
-I examples/companion_radio/ui-orig
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D QSPIFLASH=1
|
||||
-D SERIAL_TX=D6
|
||||
-D SERIAL_RX=D7
|
||||
-D PIN_WIRE_SCL=16
|
||||
-D PIN_WIRE_SDA=17
|
||||
build_unflags =
|
||||
-D PIN_WIRE_SCL=D6
|
||||
-D PIN_WIRE_SDA=D7
|
||||
build_src_filter = ${Xiao_nrf52.build_src_filter}
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-orig/*.cpp>
|
||||
lib_deps =
|
||||
${Xiao_nrf52.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
```
|
||||
|
||||
Only the pinned commit (`e9edfc8e`) is known to build cleanly with this patch — a newer
|
||||
`dev` may need small further adjustments.
|
||||
|
||||
### Step 13 — Flash the XIAO
|
||||
|
||||
Plug the **XIAO's own USB-C port** into your PC — it doesn't matter yet whether it's also
|
||||
wired to the Lyra. Then:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
python flash.py
|
||||
```
|
||||
|
||||
It first tries a 1200-baud "touch" over USB serial to reset the XIAO into its bootloader —
|
||||
this only ever targets Seeed's own USB vendor id, so it won't disturb another nRF52840 board
|
||||
on the same bench. If that doesn't take, **double-tap the XIAO's reset button** and run it
|
||||
again.
|
||||
|
||||
Once it finds the bootloader, it checks two things before writing anything:
|
||||
|
||||
- **`INFO_UF2.TXT`'s `Model` field** must say a XIAO — a drive letter isn't an identity, and
|
||||
another board could have inherited the letter your XIAO just gave up.
|
||||
- **`SoftDevice` must not be `6.1.1`.** This firmware links for SoftDevice S140 v7 (app at
|
||||
`0x27000`); a S140 6.1.1 bootloader wants the app at `0x26000` and would flash successfully
|
||||
and then simply never boot.
|
||||
|
||||
`--force` overrides both checks, if you're certain. A clean flash ends with:
|
||||
|
||||
```
|
||||
DONE. XIAO flashed and rebooting into the radio firmware.
|
||||
```
|
||||
|
||||
**If no UF2 drive ever appears**, that's not necessarily wrong — some XIAO bootloaders
|
||||
expose only a serial port, no mass-storage drive at all. `flash.py` recognises this case and
|
||||
prints the fallback command instead of failing silently:
|
||||
|
||||
```bash
|
||||
# on the PC, from your MeshCore checkout
|
||||
pio run -e Xiao_nrf52_companion_radio_serial -t upload --upload-port <bootloader port>
|
||||
```
|
||||
|
||||
That command ends in `Device programmed.` and the XIAO reboots itself into the radio
|
||||
firmware.
|
||||
|
||||
Afterwards, the XIAO's USB serial port goes **silent** to companion frames — the companion
|
||||
protocol now lives on D6/D7, not on USB. That silence is correct; it doesn't mean the flash
|
||||
failed.
|
||||
|
||||
### Step 14 — Wire the radio
|
||||
|
||||
Four wires, TX and RX crossed:
|
||||
|
||||
| XIAO pad | → | Lyra header | Physical pin | Carries |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **D7** (Serial1 RX) | → | **GP4** | pin **6** | Lyra UART1 **TX** → XIAO RX |
|
||||
| **D6** (Serial1 TX) | → | **GP5** | pin **7** | XIAO **TX** → Lyra UART1 RX |
|
||||
| **GND** | → | **GND** | pin **8** | common ground |
|
||||
| **3V3** | → | **3V3 OUT** | pin **36** | power, so it runs without USB |
|
||||
|
||||
> **The Lyra does not use Raspberry Pi Pico GPIO numbering.** The 40-pin header is
|
||||
> physically Pico-shaped, but each pad's function is Luckfox's own RK3506 mapping: GP4 is
|
||||
> `gpio0-0` and GP5 is `gpio0-1`. Don't reach for a Pico pinout for anything else on this
|
||||
> header. Every signal here is 3.3 V, and there is **no 5 V rail** on this board — power the
|
||||
> XIAO from 3V3 only.
|
||||
|
||||
With the PicoCalc powered off and the back of the shell removed, solder the four wires
|
||||
between the XIAO's pads and the Lyra's header pins as above. The Lyra can stay seated in
|
||||
its socket: the tops of its header pins are reachable from inside the case, so solder to
|
||||
those. Keep the iron on each pin only as long as it takes, and don't press down on the
|
||||
board. Attach the LoRa antenna to the Wio-SX1262's u.FL connector before you
|
||||
power anything back on — running the radio without an antenna attached can damage it.
|
||||
Wrap the XIAO + Wio-SX1262 stack in Kapton tape so no pad can touch the Lyra, the
|
||||
mainboard, or a battery, then tuck it into the shell wherever it fits without straining the
|
||||
wires; a strip of foam tape holds it in place against the inside of the case.
|
||||
|
||||
### Step 15 — Set up the Lyra
|
||||
|
||||
With the wiring done and the shell closed back up, copy `scripts/` to the device if you
|
||||
haven't already (Step 8), then:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
sh scripts/picocalc/xiao-radio/lyra-setup.sh # MT_USER=meshterm by default
|
||||
```
|
||||
|
||||
It does three idempotent things:
|
||||
|
||||
1. Installs `uart1-mux.py` as `/usr/local/bin/uart1-radio-mux.py`, run by a oneshot
|
||||
`uart1-radio-mux.service` on every boot. This is the part that actually makes
|
||||
`/dev/ttyS1` reach the header pins — Calculinux enables the UART1 controller but never
|
||||
wires it to a physical pad on its own, and the RK3506's flexible pin matrix needs a
|
||||
direct register poke to route it onto GP4/GP5 instead.
|
||||
2. Adds `$MT_USER` to the `dialout` group, so it can open the serial port. **This needs a
|
||||
fresh login before it takes effect.**
|
||||
3. Writes a MeshTerm profile, *only if none exists yet*, at
|
||||
`/home/$MT_USER/.meshterm/config.toml`:
|
||||
|
||||
```toml
|
||||
default_profile = "picocalc"
|
||||
|
||||
[profiles.picocalc]
|
||||
port = "/dev/ttyS1"
|
||||
baudrate = 115200
|
||||
transport = "serial"
|
||||
default_tx_power = 22
|
||||
description = "XIAO nRF52840 + SX1262 via Lyra UART1 on GP4/GP5"
|
||||
```
|
||||
|
||||
If a `config.toml` already exists, it leaves your file untouched and prints only the
|
||||
`default_profile` line, the `[profiles.picocalc]` heading and the `port`. Copy the whole
|
||||
block above into your file yourself.
|
||||
|
||||
### Step 16 — Run it
|
||||
|
||||
Log out and back in as `meshterm` (so the `dialout` group membership takes effect), then:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as meshterm
|
||||
meshterm
|
||||
```
|
||||
|
||||
A bare `meshterm` opens on the **Select a companion device** splash, with the `picocalc`
|
||||
profile listed as a row; pick it. To skip the splash, run `meshterm -p picocalc`. You
|
||||
should see your node come up under whatever name the firmware advertises — both the device
|
||||
page and the dashboard header show it.
|
||||
|
||||
If you want to prove the wiring itself before suspecting anything else, there's a deeper
|
||||
check: open `/dev/ttyS1` at 115200 as root and send a raw `APP_START` frame (`3c 0e 00 01`,
|
||||
seven zero bytes, then a name) — a live radio answers with a framed `05`, `SELF_INFO`. The
|
||||
command byte and the reply code are the companion protocol's; the `<`/`>` and
|
||||
little-endian-length framing around them come from the firmware and are worth confirming
|
||||
against it. It's a lower-level check than running MeshTerm and mostly useful for isolating
|
||||
a wiring problem.
|
||||
|
||||
---
|
||||
|
||||
## Day-to-day
|
||||
|
||||
**Charging.** Only through the mainboard's USB-C port on the side of the case. ⚠ Never
|
||||
through the Lyra's own USB-C at the back — see
|
||||
[Three ways to break it](#three-ways-to-break-it).
|
||||
|
||||
**Powering off.** Shut down cleanly rather than pulling the batteries:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc
|
||||
sudo poweroff
|
||||
```
|
||||
|
||||
**Updating MeshTerm.** The copy of `scripts/` you made in Step 8 goes stale as soon as
|
||||
the checkout moves on, so run the scripts inside the checkout instead. Either pull and
|
||||
re-run the setup script from root (it reinstalls and rebuilds the console font):
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
su - meshterm -c 'git -C ~/MeshTerm pull'
|
||||
sh /home/meshterm/MeshTerm/scripts/picocalc/calculinux-setup.sh
|
||||
```
|
||||
|
||||
or, as the `meshterm` user, do just the update:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as meshterm
|
||||
cd ~/MeshTerm && git pull && TMPDIR=$HOME/tmp .venv/bin/pip install -e .
|
||||
```
|
||||
|
||||
If an update adds new glyphs to the console font, rebuild it on its own afterwards:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
sh /home/meshterm/MeshTerm/scripts/picocalc/calculinux-console-font-6x12.sh
|
||||
```
|
||||
|
||||
**Changing Wi-Fi.** Run `uwific` as root, as in [Step 7](#step-7--join-wi-fi). The boot-time
|
||||
kick picks up the new network on its own; it only needs `iwd` to know it.
|
||||
|
||||
**The slow boot without a dongle.** If the device is going to run permanently offline,
|
||||
the Wi-Fi kick and the time sync are wasted time on every boot. Disabling the kick is not
|
||||
enough, because the time sync pulls it back in, so mask it. Then disable the time sync,
|
||||
which would otherwise still wait up to five minutes for a network:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
systemctl mask --now wifi-kick.service
|
||||
systemctl disable --now time-sync.service
|
||||
```
|
||||
|
||||
To undo it later, `systemctl unmask wifi-kick.service` and
|
||||
`systemctl enable time-sync.service`.
|
||||
|
||||
**The console font restores itself.** MeshTerm saves whatever font the console was using
|
||||
before it starts, and puts it back when it exits — on a normal quit and on a crash alike.
|
||||
You don't need to do anything to keep the shell's own font intact.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| Nothing on the PicoCalc's screen at all | Is the SD card in the **Lyra's own slot**, not the PicoCalc's front slot? Is a "Lyra B" (SPI NAND) erased? Is it the **128 MB RAM, Pico-form-factor** variant — other RAM sizes have incompatible pinouts? |
|
||||
| Wi-Fi never comes up | Is the dongle one of the tested chipsets (RTL8192CU / R8712U / RTL8188EU)? Remember the Lyra's USB port is **3.3 V** — a 5 V dongle won't work. Give it the full six minutes on a cold boot before assuming it's stuck. |
|
||||
| `pip install` fails with `ENOSPC` | `/tmp` is a small RAM disk. `calculinux-setup.sh` already sets `TMPDIR=$HOME/tmp` for its own install; if you're running `pip` by hand, do the same. |
|
||||
| `meshterm: command not found` after setup | Log out and back in — the PATH line is added to `~/.profile`, which only takes effect on a fresh login shell. |
|
||||
| Tofu boxes instead of braille charts or node glyphs | The console font script hasn't run, or didn't persist. Re-run `sh calculinux-console-font-6x12.sh`, and check `/etc/vconsole.conf` has a `FONT=` line. |
|
||||
| `meshterm` can't open `/dev/ttyS1` | Is the `meshterm` user in `dialout` (`groups`)? Group changes need a fresh login to take effect. |
|
||||
| Port opens but the radio never answers | Re-flash with the **`_serial`** environment, not `_ble` or `_usb` — only it defines `SERIAL_RX`/`SERIAL_TX`. The XIAO's USB serial should be silent once it's running the radio firmware; that's expected, not a fault. |
|
||||
| Still silent with wiring confirmed | Confirm the firmware actually carries the I²C remap (`PIN_WIRE_SCL=16`, `PIN_WIRE_SDA=17`) — a build without the patch's second hunk looks identical until you check this. |
|
||||
| Flashed fine, XIAO never comes back | Wrong board, or the wrong SoftDevice — `INFO_UF2.TXT` must report a XIAO and **S140 v7**, not `6.1.1`. `flash.py` refuses to flash either mismatch without `--force`. |
|
||||
| `flash.py` finds no UF2 drive | Expected on a CDC-only bootloader — use the `pio … -t upload --upload-port` command it prints instead. |
|
||||
| Nothing on `/dev/ttyS1` after a reboot | `systemctl status uart1-radio-mux`, then `journalctl -b -u uart1-radio-mux` — the pin-mux poke has to run every boot, and a failed oneshot says why. |
|
||||
|
||||
---
|
||||
|
||||
## What has been tested
|
||||
|
||||
This whole path — from a stock PicoCalc kit through a running radio — has been exercised on
|
||||
one maintainer's bench: one Luckfox Lyra, one PicoCalc, one XIAO + Wio-SX1262 pair. It
|
||||
works. One part is also checked in code: a test (`tests/test_theme16.py`) parses the font
|
||||
script and pins it to MeshTerm's glyph inventory. The pinned MeshCore commit, the need for
|
||||
the patch's two hunks on `dev`, and the nRF52840 UF2 family id and XIAO bootloader ids
|
||||
against Adafruit's board files were checked by hand when this was written.
|
||||
|
||||
The rest is one bench's findings, and a second device may disagree:
|
||||
|
||||
- **The RK3506 register map** in `uart1-mux.py` — the two register blocks, the UART1 signal
|
||||
ids, matrix mode, and that `gpio0-0`/`gpio0-1` are header GP4 and GP5 — was probed live
|
||||
on one board, not read from a reference manual.
|
||||
- **That the Calculinux kernel permits those `/dev/mem` writes**, and that the mux oneshot
|
||||
really runs at every boot.
|
||||
- **The header pin numbers** 6, 7, 8, and 36 for GP4, GP5, GND, and 3V3. The Pico-side
|
||||
numbering is right; the carrier's wiring is a bench observation.
|
||||
- **The MX1.25 socket's pin order.** Taken from the wire colours of Luckfox's own cable,
|
||||
which is why [Step 3](#step-3--attach-the-wi-fi-dongle) has you check pin 1 with a meter.
|
||||
- **The 5 V path through the Lyra's USB-C.** The mechanism is read off the mainboard
|
||||
schematic and two forum measurements on the Pico; nobody has measured it with a Lyra.
|
||||
Treat the warning as the safe assumption it is.
|
||||
- **The Calculinux specifics the scripts assert**: the stripped Python and the package
|
||||
names that fix it, which overlays are writable, that `/tmp` is a small RAM tmpfs, the
|
||||
dongle's cold-boot race with `iwd`, the opkg feed's occasional 404, that the stock
|
||||
Terminus console font is installed, and that `kbd` brings `setvtrgb`.
|
||||
- **That this board has no RTC.** Only the script says so; the clock-sync phase follows.
|
||||
- **The XIAO bootloader that exposed no mass storage**, and the S140 v7 / `0x27000`
|
||||
requirement — public references agree, but neither is a primary source.
|
||||
- **Whether the firmware patch still applies at MeshCore `dev` HEAD.** Only the pinned
|
||||
commit is known to build.
|
||||
- **MeshCore's serial framing** around the raw `APP_START` check in
|
||||
[Step 16](#step-16--run-it): the command byte and reply code are from the protocol
|
||||
documentation; the framing bytes are not.
|
||||
- **The framebuffer console's 512-glyph cap.** Well known, taken as given, not re-measured.
|
||||
|
||||
If you build this and something disagrees with what's written here, that's worth
|
||||
reporting — a second board is the only way to learn which parts of this were about the
|
||||
hardware and which were about the one unit it was built on.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -1,444 +0,0 @@
|
||||
# Navigation survey — every exception to "Esc pops the stack"
|
||||
|
||||
> **A historical record.** This was a snapshot of the code on the date below, kept
|
||||
> because a project that writes down its own problems is easier to trust than one
|
||||
> that doesn't. Some of what's here has since been fixed and some hasn't; nothing in
|
||||
> it is a to-do list. Read it as "what this looked like then", not "what is wrong now".
|
||||
|
||||
Written 2026-08-22 against `main` @ `4b29248`. A catalogue, not a change: nothing here had
|
||||
been fixed at the time of writing. Each finding says where it is, what it does, and what
|
||||
makes it a question.
|
||||
|
||||
**Resolved since:** findings **E** and **§10** — 2026-08-29. Exit rows were retired
|
||||
app-wide: `back_rows()` is gone, `exit_rows()` draws nothing while clean, and every
|
||||
caller's three-way Back test collapsed to `is CANCEL` / `is None`. What is kept is stated
|
||||
in CLAUDE.md.
|
||||
|
||||
**Resolved 2026-08-30 — A, B, C, and the shortcut that made B possible.** Navigation is
|
||||
a strict stack now; the rules are in CLAUDE.md under *Navigation — the stack*.
|
||||
|
||||
- **A1/A2/A3.** A2 is the rule, and it grew a second half. A hub stays pushed for the
|
||||
whole visit (`session.stay` / `Visit.result`), so the *object* — not a `default=`
|
||||
restore — carries the cursor, the sort, the scroll and the filter. Where the rows are
|
||||
genuinely data (the editors, the outbox, the watchtower) they are swapped in place with
|
||||
`SelectScreen.replace_items`, which follows the highlighted row by value and clamps to
|
||||
its position when that row is gone. A1's vanishing-row problem is answered by that
|
||||
clamp, everywhere, rather than by watchtower's one hand-written case. A screen is
|
||||
rebuilt only when the rows it was holding a place in no longer exist — a purge, a
|
||||
removal, a record count that moved — and each such site says so.
|
||||
- **B1/B2/B3.** They no longer flatten. Both hand-built escapes existed because a deep
|
||||
stack had no way out; **^W** is the way out, so trace ↔ trophy case and node detail's
|
||||
peers all nest, and Esc from any of them is one pop back onto the screen that opened
|
||||
them. The trace screen takes an `open_trophy_case` callback instead of resolving a
|
||||
sentinel its opener had to interpret, which is also what lets the two open each other.
|
||||
- **C.** All five backdrop re-pushes are gone, and so are the six hand-rolled
|
||||
push/await/pop loops the survey paired them with — the same need, spelled one way.
|
||||
Repeater admin's was the worst of them: a second, identical `SelectScreen` drawn as a
|
||||
static stand-in for the real one. `admin_node_visit` keeps the real list up instead.
|
||||
- **F.** Still the one place the stack is cleared rather than unwound, and it now also
|
||||
disarms a pending ^W: a disconnect has already abandoned the flow that unwind was for.
|
||||
|
||||
**Resolved 2026-08-30 (second pass) — D, plus H and I, found by re-running the survey.**
|
||||
D is closed by peeling *everywhere*: the select list and the path composer now clear a
|
||||
standing filter on the first Esc and leave on the second, as the map and the walk always
|
||||
did, and the footer swaps its trailing verb to `Esc clear` while that is true. Two
|
||||
exceptions the first survey missed are closed with it — see **H** and **I** below.
|
||||
|
||||
**Still open: G** (`popup` declared by hand). It is not navigation-visible: one tool
|
||||
floats over the menu, the rest replace it, and the result presenter separately infers the
|
||||
same thing from content.
|
||||
|
||||
The point of the survey is that MeshTerm's navigation is *almost* uniform. The exceptions
|
||||
are few enough to list exhaustively, which means they can be decided one at a time rather
|
||||
than discovered one at a time.
|
||||
|
||||
---
|
||||
|
||||
## 0. Exceptions H and I — found 2026-08-30
|
||||
|
||||
Two more shapes, neither in the original catalogue, both closed the day they were found.
|
||||
|
||||
**H. A picker gathered in `prompt_params` pops before the screen it feeds.** A tool's
|
||||
`prompt_params` is a *one-shot*: whatever it opens resolves and pops before `run` is
|
||||
called. Three tools picked something there and then opened a screen, so the screen sat
|
||||
directly on the main menu and Esc from it skipped the list it had just been chosen from.
|
||||
|
||||
| Site | What Esc did | Now |
|
||||
|---|---|---|
|
||||
| Trace target | sweep → **main menu**, skipping the target list | picker stays; Esc lands on the row it was launched from |
|
||||
| TX optimize | sweep → **main menu**; and the target list → **main menu**, not the node list | both pickers stay, nested; Esc walks the flow back one list at a time |
|
||||
| Chat | landed on a picker, but a rebuilt one (filter lost, cursor by `default=`) | one picker for the visit, rows swapped in place |
|
||||
|
||||
The fix is A2's rule applied one level up: the loop that owns the picker moves into `run`,
|
||||
and `prompt_params` returns only the marker for the interactive path. Trace's picker also
|
||||
re-reads its `TRACED` lane after each walk (`ContactListScreen.update_rows`), because that
|
||||
is the column the list is sorted by and a returning trace changes it.
|
||||
|
||||
**I. An entry chain of prompts was not a stack.** Five flows asked two or more things in a
|
||||
row, and Esc on any of them abandoned the whole flow rather than stepping back one — a
|
||||
mistyped 32-hex channel key cost the name typed before it. `menus.run_steps` now runs a
|
||||
chain as a stack; a trailing Cancel/verb confirm is deliberately left as a decision rather
|
||||
than a step. Sites: `channels._join_with_key`, `channels._edit`,
|
||||
`config_editor._stage_custom_var`, `courier._queue_flow`, `courier._pick_schedule`.
|
||||
|
||||
Also closed in the same pass: the channel detail was the last screen rebuilt per round
|
||||
(A1's `default=cursor`, now `stay` + `replace_items`), which left `_menu_round` with no
|
||||
session path at all and shrank it to the sessionless select-and-dispatch it always was
|
||||
underneath — the last of §5's hand-rolled loops.
|
||||
|
||||
---
|
||||
|
||||
## 1. The machinery, in one screenful
|
||||
|
||||
Three primitives, all in [`ui/tui/`](../meshterm/ui/tui/):
|
||||
|
||||
| Call | What it does |
|
||||
|---|---|
|
||||
| `session.push(screen)` | screen becomes the top of the stack and is drawn |
|
||||
| `session.pop(screen)` | that screen leaves the stack |
|
||||
| `screen.resolve(value)` | the screen's future completes; the awaiting caller decides what happens next |
|
||||
|
||||
`session.run_screen(screen)` is push + await + pop as one call — the normal way to show a
|
||||
screen. `Screen.handle("escape")` resolves with the `CANCEL` sentinel
|
||||
([`screen.py:243`](../meshterm/ui/tui/screen.py#L243)); every screen inherits that unless it
|
||||
overrides `handle`.
|
||||
|
||||
So the app has no "navigator". A screen never decides where the user goes — it resolves a
|
||||
value and its *caller* decides. Every exception below is a caller deciding something other
|
||||
than "pop and carry on".
|
||||
|
||||
---
|
||||
|
||||
## 2. What "normal" looks like
|
||||
|
||||
The reference flow is the main menu ([`menu.py:466-559`](../meshterm/ui/menu.py#L466)):
|
||||
|
||||
1. build the row list
|
||||
2. `push` the menu
|
||||
3. await a choice
|
||||
4. `pop` the menu in a `finally`
|
||||
5. run whatever was chosen
|
||||
6. loop — rebuilding the menu, **re-highlighting the row just used** (`default=last_selection`)
|
||||
|
||||
Point 6 is the part worth naming: *returning from a thing lands you on the thing you
|
||||
returned from.* It is the behaviour every list should have, and most do.
|
||||
|
||||
---
|
||||
|
||||
## 3. Exception A — cursor position on re-entry
|
||||
|
||||
**The single largest inconsistency in the app.** Three groups, three behaviours:
|
||||
|
||||
### A1. Restores the cursor — the intended behaviour (7 sites)
|
||||
|
||||
| Screen | Where | How |
|
||||
|---|---|---|
|
||||
| Main menu | [`menu.py:502`](../meshterm/ui/menu.py#L502) | `default=last_selection` |
|
||||
| Channels (manager) | [`channels.py:214`](../meshterm/ui/channels.py#L214) | `default=highlight` |
|
||||
| Channels (one channel) | [`channels.py:728`](../meshterm/ui/channels.py#L728) | `default=cursor` |
|
||||
| Device config editor | [`config_editor.py:161`](../meshterm/ui/config_editor.py#L161) | `default=cursor` |
|
||||
| Device actions | [`config_editor.py:688`](../meshterm/ui/config_editor.py#L688) | `default=cursor` |
|
||||
| Repeater admin | [`repeater_admin.py:172`](../meshterm/ui/repeater_admin.py#L172) | `default=cursor` |
|
||||
| Courier outbox | [`courier_screen.py:120`](../meshterm/ui/courier_screen.py#L120) | `default=cursor` |
|
||||
| Watchtower | [`watchtower_screen.py:137`](../meshterm/ui/watchtower_screen.py#L137) | `cursor = choice` |
|
||||
|
||||
Watchtower has the most careful version of it — it *drops* the restore when the chosen row
|
||||
is about to vanish ([`watchtower_screen.py:145-146`](../meshterm/ui/watchtower_screen.py#L145)):
|
||||
|
||||
```python
|
||||
elif choice == _CLEAR:
|
||||
store.clear_acked()
|
||||
cursor = None # the row itself disappears
|
||||
```
|
||||
|
||||
That refinement exists in exactly one place. Anywhere else a row can disappear, the restore
|
||||
either silently fails to match (harmless — falls back to the top) or lands on whatever row
|
||||
slid into the vacated position (not harmless).
|
||||
|
||||
### A2. Reuses the screen object, so it keeps everything (4 sites)
|
||||
|
||||
Contacts states the principle outright
|
||||
([`contacts_screen.py:200-201`](../meshterm/ui/contacts_screen.py#L200)):
|
||||
|
||||
> One screen for the whole visit: re-running it keeps the highlight (and any sort or
|
||||
> filter) on the row the user just opened a detail for, rather than snapping to the top.
|
||||
|
||||
Also chat, live feed, dashboard. Strictly better than A1 — it preserves sort and filter too,
|
||||
not just the cursor — and it costs less code.
|
||||
|
||||
Contacts also shows the honest exception: after a purge changes the data, it deliberately
|
||||
*does* rebuild ([`contacts_screen.py:217-219`](../meshterm/ui/contacts_screen.py#L217)), and
|
||||
says why.
|
||||
|
||||
### A3. Rebuilds and loses your place (3 sites) — **the actual finding**
|
||||
|
||||
| Screen | Where | What is lost |
|
||||
|---|---|---|
|
||||
| Trophy case | [`records_screen.py:851`](../meshterm/ui/records_screen.py#L851) | cursor — a fresh `SelectScreen` per round, no `default=` |
|
||||
| Time Machine picker | [`timemachine_screen.py:1323`](../meshterm/ui/timemachine_screen.py#L1323) | cursor (sort *is* carried) |
|
||||
| Node detail | [`node_detail_screen.py:1360`](../meshterm/ui/node_detail_screen.py#L1360) | cursor **and the open tab** |
|
||||
|
||||
Node detail is the sharpest case. `NodeDetailScreen.__init__` sets `self._tab_index = 0`
|
||||
([`node_detail_screen.py:446`](../meshterm/ui/node_detail_screen.py#L446)) and the opener
|
||||
constructs a new one every iteration. So: open a contact → Tab to **Routes** → pick a route
|
||||
→ Enter to trace it → come back → **you are on Info, at the top**. The route you were
|
||||
working through is two keypresses away again, every time.
|
||||
|
||||
All three are a one-line fix in the A2 direction (hoist the construction out of the loop).
|
||||
|
||||
> **Question for JP:** is A2 the rule? If so these three are bugs. If a *deliberate* reset
|
||||
> is wanted anywhere, Contacts' purge branch is the model for how to say so.
|
||||
|
||||
---
|
||||
|
||||
## 4. Exception B — hand-offs that flatten the stack instead of nesting
|
||||
|
||||
Two places let one screen hand off to a *peer* screen, and both deliberately unwind first so
|
||||
the user does not end up deep in a stack they have to climb.
|
||||
|
||||
**B1. Trace → Trophy case.** A trace that sets a record raises a dialog offering the trophy
|
||||
case. Choosing it resolves the sentinel `OPEN_TROPHY_CASE`
|
||||
([`trace_screen.py:128`](../meshterm/ui/trace_screen.py#L128)), the trace screen comes down,
|
||||
and only then does the caller open records
|
||||
([`trace_screen.py:2193-2199`](../meshterm/ui/trace_screen.py#L2193)):
|
||||
|
||||
> the trace screen (and every prompt over it) is already down, so the trophy case opens
|
||||
> over a clean stack and Esc from it unwinds straight to the main menu — never back into
|
||||
> this session.
|
||||
|
||||
**B2. Trophy case → Trace.** The mirror image, and the comment is even more explicit
|
||||
([`records_screen.py:905-909`](../meshterm/ui/records_screen.py#L905)):
|
||||
|
||||
> the browser does not reopen behind it: when the trace screen closes, this whole flow
|
||||
> returns, landing the user on the main menu instead of a trophy-case → trace →
|
||||
> trophy-case stack that takes many Escs to climb out of.
|
||||
|
||||
These two are the same rule discovered twice: **a hand-off between peers flattens; only a
|
||||
sub-view nests.** It is not written down anywhere.
|
||||
|
||||
**B3. Node detail → Trace / Map / Time Machine / Share** ([`node_detail_screen.py:1372-1397`](../meshterm/ui/node_detail_screen.py#L1372))
|
||||
follows the same rule *without saying so*, and that is where §3's A3 comes from. Because
|
||||
`run_screen` pops on resolve, node detail is already down when the peer opens — so the peer
|
||||
gets a clean stack, exactly as B1 and B2 arrange by hand. But unlike B1/B2, node detail then
|
||||
**comes back**, by constructing a fresh screen at the top of the loop.
|
||||
|
||||
So the three sites agree on flattening and differ on what happens afterwards:
|
||||
|
||||
| | flattens before the peer | returns afterwards | keeps its state |
|
||||
|---|---|---|---|
|
||||
| B1 Trace → Trophy case | yes, deliberately | no — lands on the menu | n/a |
|
||||
| B2 Trophy case → Trace | yes, deliberately | no — lands on the menu | n/a |
|
||||
| B3 Node detail → peers | yes, as a side effect of `run_screen` | **yes** | **no** |
|
||||
|
||||
B3 is the only one that re-enters, and re-entering through a constructor is what costs the
|
||||
tab and the cursor. That makes A3-on-node-detail a *consequence* of this shape, not an
|
||||
independent bug: fixing it means either hoisting the screen out of the loop (keep the object,
|
||||
keep the state) or nesting properly (keep it pushed while the peer runs).
|
||||
|
||||
> **Question for JP:** "flatten before a peer opens" looks like the settled rule — three for
|
||||
> three. What is unsettled is whether a hub screen should *nest* instead, so it never has to
|
||||
> be rebuilt. Node detail is the only hub, so this is really one decision about one screen.
|
||||
|
||||
---
|
||||
|
||||
## 5. Exception C — the backdrop re-push
|
||||
|
||||
`run_screen` pops the screen when it resolves. But a dialog raised straight afterwards needs
|
||||
something behind it, or it draws over a blank frame. So five sites pop, then immediately
|
||||
re-push the same screen as a static backdrop:
|
||||
|
||||
| Site | Screen re-pushed as backdrop |
|
||||
|---|---|
|
||||
| [`records_screen.py:867`](../meshterm/ui/records_screen.py#L867) | trophy case, behind the discipline picker / delete confirms |
|
||||
| [`contacts_screen.py:210`](../meshterm/ui/contacts_screen.py#L210) | contacts, behind the purge picker |
|
||||
| [`repeater_admin.py:108`](../meshterm/ui/repeater_admin.py#L108) | node picker, behind the login prompt |
|
||||
| [`menu.py:910-911`](../meshterm/ui/menu.py#L910) | base screen, behind the disconnect dialog |
|
||||
| [`tx_screen.py:885`](../meshterm/ui/tx_screen.py#L885) | flight screen |
|
||||
|
||||
Records has the fullest explanation ([`records_screen.py:862-866`](../meshterm/ui/records_screen.py#L862)).
|
||||
|
||||
This is a **workaround for an API shape**, repeated five times. The screens that avoid it
|
||||
(channels, config editor, repeater admin's own menu, courier, watchtower, main menu) all
|
||||
drive `push`/`await future`/`pop` by hand instead of calling `run_screen`, precisely so the
|
||||
screen stays up while sub-prompts float.
|
||||
|
||||
> **Question for JP:** would a `session.run_screen(screen, keep_pushed=True)` — or a
|
||||
> `session.backdrop(screen)` context manager — retire both the five re-pushes and the six
|
||||
> hand-rolled push/await/pop loops? They are the same need spelled two ways.
|
||||
|
||||
---
|
||||
|
||||
## 6. Exception D — what Esc peels before it leaves
|
||||
|
||||
Four screens carry a live find-as-you-type filter and draw it through the one
|
||||
`render.query_line`. **Esc means two different things across them:**
|
||||
|
||||
| Screen | Esc with a filter active | ⌫ with a filter active |
|
||||
|---|---|---|
|
||||
| Map | clears the filter, stays ([`map_screen.py:920-925`](../meshterm/ui/map_screen.py#L920)) | — |
|
||||
| Mesh walk | clears the filter, stays ([`walk_screen.py:430-436`](../meshterm/ui/walk_screen.py#L430)) | deletes a char, then pops the trail ([`walk_screen.py:449-456`](../meshterm/ui/walk_screen.py#L449)) |
|
||||
| **Select list** | **leaves the screen, filter and all** ([`select.py:773-774`](../meshterm/ui/tui/select.py#L773)) | deletes a char |
|
||||
| **Path composer** | **leaves the screen, filter and all** ([`path_composer.py:717-718`](../meshterm/ui/path_composer.py#L717)) | deletes a char, then pops a hop |
|
||||
|
||||
So on the map, typing `hub` then Esc shows you the map again. On a contact list, typing
|
||||
`hub` then Esc drops you back to the menu. Same affordance, same glyphs, opposite outcome —
|
||||
and the select list is by far the most-used of the four.
|
||||
|
||||
Chat is a fifth member of the family with a different peelable thing
|
||||
([`chat.py:624-629`](../meshterm/ui/chat.py#L624)):
|
||||
|
||||
```python
|
||||
if self._selected is not None:
|
||||
self._clear_selection() # first Esc unpicks; next leaves the chat
|
||||
```
|
||||
|
||||
so chat peels, and its comment reads as though peeling were the house rule.
|
||||
|
||||
**Score: 3 screens peel, 2 do not.** Worth noting the peel is *not* free — on a screen where
|
||||
Esc peels, leaving a filtered list takes two Escs, which is the cost JP is weighing on exit
|
||||
rows elsewhere.
|
||||
|
||||
> **Question for JP:** peel everywhere, or nowhere? The current split is not defensible as
|
||||
> a distinction between spatial and list screens, because the path composer is a list and
|
||||
> does not peel, while chat is not a filter and does.
|
||||
|
||||
---
|
||||
|
||||
## 7. Exception E — six spellings of "the user pressed Back"
|
||||
|
||||
`back_rows(value)` takes the value its Back row resolves with. Call sites, verbatim:
|
||||
|
||||
| Value passed | Sites |
|
||||
|---|---|
|
||||
| *(nothing — `None`)* | [`admin_picker.py:49`](../meshterm/ui/admin_picker.py#L49), [`:77`](../meshterm/ui/admin_picker.py#L77), [`config_editor.py:617`](../meshterm/ui/config_editor.py#L617), [`:790`](../meshterm/ui/config_editor.py#L790), [`:1025`](../meshterm/ui/config_editor.py#L1025), [`courier_screen.py:242`](../meshterm/ui/courier_screen.py#L242), [`watchtower_screen.py:225`](../meshterm/ui/watchtower_screen.py#L225), [`:362`](../meshterm/ui/watchtower_screen.py#L362) |
|
||||
| explicit `None` | [`tx_optimize.py:439`](../meshterm/tools/tx_optimize.py#L439), [`contacts_screen.py:263`](../meshterm/ui/contacts_screen.py#L263) |
|
||||
| `"__back__"` | [`tools/chat.py:187`](../meshterm/tools/chat.py#L187), [`records_screen.py:790`](../meshterm/ui/records_screen.py#L790) |
|
||||
| `_BACK` | [`channels.py:619`](../meshterm/ui/channels.py#L619), [`:683`](../meshterm/ui/channels.py#L683), [`contacts_screen.py:152`](../meshterm/ui/contacts_screen.py#L152) |
|
||||
| `_CANCEL` | [`config_editor.py:736`](../meshterm/ui/config_editor.py#L736) |
|
||||
| a tuple shaped like the screen's other values | [`records_screen.py:850`](../meshterm/ui/records_screen.py#L850) `("back", None, 0, None)`, [`trace_screen.py:2012`](../meshterm/ui/trace_screen.py#L2012) `("back", None)` |
|
||||
|
||||
The cost lands on the callers, which have to test every way at once:
|
||||
|
||||
```python
|
||||
# contacts_screen.py:204
|
||||
if chosen is CANCEL or chosen is None or chosen == _BACK:
|
||||
# records_screen.py:858
|
||||
if picked is CANCEL or picked is None or picked[0] == "back":
|
||||
```
|
||||
|
||||
Note `config_editor` names its Back sentinel `_CANCEL` — the one word CLAUDE.md reserves for
|
||||
dialogs, used here for a row that says "Back".
|
||||
|
||||
> **Suggestion:** `back_rows()` could default its value to `CANCEL` itself, so a Back row and
|
||||
> Esc resolve *identically* and every caller's three-way test collapses to `is CANCEL`. The
|
||||
> tuple-shaped ones exist only because their screens type their values as tuples; those could
|
||||
> take `CANCEL` too.
|
||||
|
||||
**Resolved 2026-08-29** — by removal rather than by defaulting: with no Back row there is
|
||||
no Back value, and every caller now tests `is CANCEL` / `is None` alone. All six spellings
|
||||
are gone, `channels._BACK` and `contacts_screen._BACK` with them.
|
||||
|
||||
This tied directly into the exit-row question — see §10.
|
||||
|
||||
---
|
||||
|
||||
## 8. Exception F — the stack reset
|
||||
|
||||
One site clears the whole stack: [`menu.py:824`](../meshterm/ui/menu.py#L824), when the device
|
||||
link drops mid-session. The watcher cancels the menu worker, `session.reset()` drops whatever
|
||||
screens it left behind, and the reconnect dialog opens over a clean frame.
|
||||
|
||||
This is the only place the stack is cleared rather than unwound. It relies on every screen
|
||||
being pop-safe in a `finally`, which is documented at
|
||||
[`session.py:388-395`](../meshterm/ui/tui/session.py#L388). Correct as written — noted here
|
||||
because it is the one code path where a screen can vanish without its caller resolving it,
|
||||
and anything holding state across a `run_screen` boundary would silently lose it.
|
||||
|
||||
---
|
||||
|
||||
## 9. Exception G — one tool floats, twenty-two replace
|
||||
|
||||
`Tool.popup` ([`tools/base.py:67`](../meshterm/tools/base.py#L67)) decides whether the menu
|
||||
stays pushed while the tool runs. **Exactly one tool sets it:** `advert`
|
||||
([`tools/advert.py:33`](../meshterm/tools/advert.py#L33)).
|
||||
|
||||
Every other tool pops the menu, runs full-screen, and the menu is rebuilt afterwards. Which
|
||||
is right for a screen — but several tools are *also* dialog-sized in practice, and the result
|
||||
presenter already makes exactly this call at the other end
|
||||
([`surface.py:603-610`](../meshterm/ui/surface.py#L603)): a short text-only result floats as
|
||||
a dialog, anything bigger opens a window.
|
||||
|
||||
So the app decides "float or replace" twice, by two different mechanisms, one hand-declared
|
||||
per tool and one measured from the content.
|
||||
|
||||
> **Question for JP:** should `popup` be inferred the way the result presentation is, or is
|
||||
> one declared flag on one tool the whole of the need?
|
||||
|
||||
---
|
||||
|
||||
## 10. Where the exit-row question actually lands
|
||||
|
||||
> **Resolved 2026-08-29.** Acted on in full, along the middle position this section
|
||||
> proposed — wider, in the end, than the 19 `back_rows()` sites: the four hand-drawn Back
|
||||
> rows went too (`ReorderScreen`'s clean-state row, the live feed's pinned foot row, node
|
||||
> detail's tail action, and the `trace`/`tx` screens' action-list rows). Kept: the
|
||||
> `exit_rows` discard pair, `ReorderScreen`'s dirty-state pair, the two **Quit** rows, and
|
||||
> the new-record dialog's `Close` button — each for the reason named below. The gallery
|
||||
> now asserts the *absence* of an exit row on every screen, on both platforms. What
|
||||
> follows is the survey as written.
|
||||
|
||||
JP's note — *"remove the cancel and close actions at the bottom of pages; Esc is quicker and
|
||||
available on all platforms; keep it where there is a confirm or cancel situation"* — was
|
||||
acted on for the one unambiguous case (the path composer's `Cancel` row, which literally ran
|
||||
`super().handle("escape")`; removed in `4b29248`). The rest of the territory:
|
||||
|
||||
**Already clean.** The three About pages have no exit row at all — prose, Esc, done. Titles
|
||||
carry no emoji. Footer hints are 100% compliant on width, Esc-last, and verb choice (56
|
||||
literal hints audited).
|
||||
|
||||
**Rows that are exactly Esc, still standing:**
|
||||
|
||||
- `back_rows()` — the app-wide **Back** row, 19 sites. Two of them carry the comment
|
||||
`# a visible exit beside Esc` ([`courier_screen.py:242`](../meshterm/ui/courier_screen.py#L242),
|
||||
[`watchtower_screen.py:225`](../meshterm/ui/watchtower_screen.py#L225)) — i.e. the row exists
|
||||
*because* it duplicates Esc, which is precisely the premise now in question.
|
||||
- `ReorderScreen`'s Back row ([`select.py:931`](../meshterm/ui/tui/select.py#L931)) —
|
||||
`super().handle("escape") # Back resolves CANCEL, same as Esc`.
|
||||
- Live feed's Back row, reached by scrolling past the oldest packet
|
||||
([`livefeed_screen.py:41-44`](../meshterm/ui/livefeed_screen.py#L41)).
|
||||
|
||||
Removing **Back** app-wide is a bigger decision than the path composer's Cancel, because:
|
||||
|
||||
- it is written into CLAUDE.md as a binding standard ("the only exit word on rows");
|
||||
- `exit_rows()` builds on it — an editor with staged changes shows `✓ Apply n staged changes`
|
||||
over `✗ Back — discard staged changes`, where Back is *not* redundant: it names a
|
||||
consequence Esc does not;
|
||||
- on PicoCalc, Esc is a real key, so the platform argument does not add anything there.
|
||||
|
||||
**A middle position worth considering:** keep Back exactly where it says something Esc does
|
||||
not (the `exit_rows` discard case), drop it where it says nothing (the plain `back_rows`
|
||||
case). That is roughly 3 sites kept, 16 dropped — and §7's suggestion (Back resolves `CANCEL`)
|
||||
would make the drop mechanical rather than a per-caller edit.
|
||||
|
||||
**Buttons that are exactly Esc:** only one — the `Close` button on the new-record dialog
|
||||
([`trace_screen.py:639`](../meshterm/ui/trace_screen.py#L639)). Left alone deliberately: it
|
||||
is the *default*, so Enter dismisses safely, and this dialog appears unbidden after a trace.
|
||||
Dropping it would leave `Trophy case` as the default and make a stray Enter navigate away.
|
||||
Every other `Cancel` in the app is one half of a real two-way choice.
|
||||
|
||||
---
|
||||
|
||||
## 11. Summary — the shortlist
|
||||
|
||||
| # | Finding | Sites | Weight | Status |
|
||||
|---|---|---|---|---|
|
||||
| H | A picker gathered in `prompt_params` pops before the screen it feeds | 3 | **high** — Esc skipped a whole list | **done** 08-30 |
|
||||
| I | An entry chain of prompts abandons instead of stepping back | 5 | medium — loses typing, never surprises | **done** 08-30 |
|
||||
| A3 | Screen rebuilt per round, losing cursor (and node detail's tab) | 3 | **high** — felt on every visit | **done** 08-30 |
|
||||
| D | Esc peels the filter on 3 screens, leaves on 2 | 5 | **high** — same keys, opposite result | **done** 08-30 |
|
||||
| E | Six spellings of the Back value; callers test three at once | 19 | medium — invisible to users, costly in code | done 08-29 |
|
||||
| C | Pop-then-re-push backdrop workaround | 5 | medium — an API gap, repeated | **done** 08-30 |
|
||||
| B | Peer hand-offs all flatten, but the rule is unwritten and node detail pays for it | 3 | medium | **done** 08-30 |
|
||||
| G | `popup` declared once by hand while the result presenter infers it | 1 | low | open |
|
||||
| A1 | Cursor restore doesn't handle a vanishing row (except watchtower) | 7 | low | **done** 08-30 |
|
||||
| F | `session.reset()` on disconnect — correct, noted for completeness | 1 | none | kept |
|
||||
@@ -5,9 +5,8 @@
|
||||
> that doesn't. Some of what's here has since been fixed and some hasn't; nothing in
|
||||
> it is a to-do list. Read it as "what this looked like then", not "what is wrong now".
|
||||
|
||||
Written 2026-08-22 against `main` @ `4b29248`. Companion to
|
||||
[`survey-navigation.md`](survey-navigation.md), which covers navigation exceptions; this one
|
||||
covers everything else in the CLAUDE.md UX standard. A catalogue, not a change.
|
||||
Written 2026-08-22 against `main` @ `4b29248`. It covers the CLAUDE.md UX standard apart
|
||||
from navigation. A catalogue, not a change.
|
||||
|
||||
**The short version: the standard is very well kept.** Six sweeps found nothing at all;
|
||||
what remains is nine small, specific things. That is the useful result — it means the list
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Terminals, icons, and the Windows console
|
||||
|
||||
Why MeshTerm looks right in some terminals and draws empty boxes in others, what it does
|
||||
about it on Windows, and how to turn all of that off.
|
||||
|
||||
MeshTerm is drawn with emoji icons, braille charts, and powerline path chips. Whether you
|
||||
see them is up to your *terminal*, not really your font: a modern terminal, asked for a
|
||||
character its font doesn't have, quietly borrows it from another font on the machine.
|
||||
That's why the app looks right in Windows Terminal, in VS Code's terminal, and on macOS
|
||||
and Linux — usually with a font that contains almost none of it. The exception is macOS's
|
||||
Terminal app, where the charts may draw misaligned: no Mac monospace font has braille, so
|
||||
it is borrowed from a proportional one at the wrong width.
|
||||
|
||||
The classic Windows console — the black `cmd.exe` window a double-click opens on
|
||||
Windows 10 — doesn't borrow. (Windows 11, 22H2 and later, opens Windows Terminal by
|
||||
default instead.) It draws what its one font holds and empty boxes for everything else, and
|
||||
no font fixes the icons there: the only two fonts on a Windows machine with emoji in them
|
||||
are proportional, and a console won't take a proportional font.
|
||||
|
||||
So when MeshTerm lands in that console it just **moves to Windows Terminal** — it says
|
||||
so, and opens there. Nothing is installed and nothing is changed; it's one window instead
|
||||
of another, and it's the whole app exactly as the screenshots show it. Windows Terminal is
|
||||
already on every Windows 11 machine and is a free install on Windows 10.
|
||||
|
||||
Where there's no Windows Terminal to move to, and the console's font can't already draw
|
||||
the charts, MeshTerm offers the next best thing instead — the charts and marks, without
|
||||
the icons. It ships
|
||||
[Cascadia Mono PL](https://github.com/microsoft/cascadia-code), Microsoft's own console
|
||||
font and one of the very few monospace faces that carries braille at all, and will install
|
||||
it just for you: no administrator rights, nothing downloaded. If you already have a font
|
||||
that can draw the charts (Cascadia, or another such as Iosevka), it offers to switch to
|
||||
that one instead. Either way it asks first (`[y/N]`), and the answer defaults to no.
|
||||
|
||||
Preferences → Display → Console setup turns all of this off if you'd rather stay put.
|
||||
|
||||
Where MeshTerm keeps its state, and how to point a trial run somewhere else, is in
|
||||
[Configuring MeshTerm](configuration.md).
|
||||
@@ -0,0 +1,621 @@
|
||||
# MeshTerm on the uConsole
|
||||
|
||||
A ClockworkPi uConsole with the hackergadgets AIO LoRa board has no companion
|
||||
microcontroller and no firmware to flash — the LoRa chip sits straight on the host's SPI
|
||||
bus. MeshTerm can drive it directly: it starts a mesh node as a child process the moment
|
||||
you connect, talks to it over the standard companion protocol on a loopback port, and ends
|
||||
it when you disconnect or quit. No separate service, no TCP port to remember — just
|
||||
`meshterm --spi`.
|
||||
|
||||
If you'd rather the node stayed on the mesh while MeshTerm is closed, a small bridge can
|
||||
run it as a background service instead, fronting the same chip as a network companion.
|
||||
That's [further down](#always-on-the-bridge) — most people want the section above it.
|
||||
|
||||
> ⚠ **Read this first.** Either route edits your uConsole's boot configuration, adds your
|
||||
> user to hardware groups, and then keys a LoRa transmitter. It assumes you are comfortable
|
||||
> at a Linux shell and know which frequency plan and power limits apply where you live —
|
||||
> what the radio does on air is your responsibility, not MeshTerm's. There is a real risk
|
||||
> of leaving the system, the radio, or the mesh around you in a worse state if a step goes
|
||||
> wrong. You do this at your own risk.
|
||||
>
|
||||
> **Read the whole manual through once before you start.** Which install route you take
|
||||
> depends on how you're running MeshTerm, and that's easier to get right knowing the later
|
||||
> steps.
|
||||
>
|
||||
> This is a guide, not a prescription. Despite the author's best efforts it may contain
|
||||
> errors, and parts of it will go out of date as packages, runtimes, and boards change. It
|
||||
> is up to you to check every fact in it against your own hardware and the current sources
|
||||
> before you act on it, and every decision along the way is yours. The author accepts no
|
||||
> responsibility for any damage, loss, or injury that results from following this guide.
|
||||
|
||||
- [What you need](#what-you-need)
|
||||
- [Step 1 — Prepare the system](#step-1--prepare-the-system)
|
||||
- [Step 2 — Install the radio library](#step-2--install-the-radio-library)
|
||||
- [Step 3 — Install MeshTerm](#step-3--install-meshterm)
|
||||
- [Step 4 — Connect](#step-4--connect)
|
||||
- [Moving over from the bridge (0.9.0 and earlier)](#moving-over-from-the-bridge-090-and-earlier)
|
||||
- [Board wiring](#board-wiring)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Always on: the bridge](#always-on-the-bridge)
|
||||
- [What this has been tested on](#what-this-has-been-tested-on)
|
||||
|
||||
---
|
||||
|
||||
## What you need
|
||||
|
||||
**Hardware:**
|
||||
|
||||
| Part | Notes |
|
||||
| --- | --- |
|
||||
| ClockworkPi **uConsole** (CM4 module) | Any uConsole with a Compute Module 4. |
|
||||
| hackergadgets **AIO** expansion board | The board this guide is written for and the one we recommend: an SX1262 on SPI bus 1, plus GPS, an RTL-SDR and a USB hub you don't need here. MeshTerm's SPI defaults are its wiring (AIO v1; the v2 needs one extra setting — see [Board wiring](#board-wiring)). |
|
||||
| An antenna for your region's LoRa band | Required. |
|
||||
|
||||
Installing the AIO board into the uConsole is out of scope here — follow
|
||||
[hackergadgets' own setup guide](https://hackergadgets.com/pages/hackergadgets-uconsole-rtl-sdr-lora-gps-rtc-usb-hub-all-in-one-extension-board-setup-guide)
|
||||
for that part.
|
||||
|
||||
**Software:**
|
||||
|
||||
- Raspberry Pi OS / Debian **bookworm** or **trixie** on the uConsole, 64-bit. This is
|
||||
Linux-only — driving a radio on the host's own SPI bus is not something Windows or macOS
|
||||
can do.
|
||||
- A `/dev/spidev*` node. This needs `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt`.
|
||||
- `/dev/gpiochip0`, and membership of the `spi` and `gpio` groups so you can read and write
|
||||
both without root.
|
||||
- The radio library, **`openhop-core[hardware]` 1.1.3 or newer** — [Step
|
||||
2](#step-2--install-the-radio-library) gets you there. It doesn't have to be in
|
||||
MeshTerm's own Python; MeshTerm looks in several places for it.
|
||||
- MeshTerm itself, on the uConsole — [Step 3](#step-3--install-meshterm).
|
||||
|
||||
A node's own radio settings — frequency, bandwidth, spreading factor, coding rate, TX
|
||||
power — are not part of this setup. They're the node's, saved by the node, and you change
|
||||
them the same way you would on any companion: MeshTerm's Device config page. A brand-new
|
||||
node starts on MeshCore's US/Canada preset (910.525 MHz, 62.5 kHz, SF 7, CR 4/5, 22 dBm)
|
||||
until you change it.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Prepare the system
|
||||
|
||||
Enable the SPI overlay. Edit `/boot/firmware/config.txt` and add:
|
||||
|
||||
```
|
||||
dtoverlay=spi1-1cs
|
||||
```
|
||||
|
||||
Add yourself to the groups that own the SPI and GPIO device nodes:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG spi,gpio $USER
|
||||
```
|
||||
|
||||
Reboot so both the overlay and the group membership take effect:
|
||||
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
After it comes back, check the device nodes exist and that you can use them:
|
||||
|
||||
```bash
|
||||
ls -l /dev/spidev* /dev/gpiochip0
|
||||
groups
|
||||
```
|
||||
|
||||
You should see `/dev/spidev1.0` (or another `spidev*` node) and `/dev/gpiochip0` listed,
|
||||
and `spi` and `gpio` in your `groups` output.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Install the radio library
|
||||
|
||||
MeshTerm needs `openhop-core[hardware]` 1.1.3 or newer importable by *some* Python on the
|
||||
machine — not necessarily MeshTerm's own. It looks, in order: a `python` named in the
|
||||
profile's `spi` table; MeshTerm's own interpreter; the bridge's venv at
|
||||
`~/.local/share/meshterm-spi-bridge/venv/bin/python`; `/opt/venvs/meshcore-uconsole/bin/python`;
|
||||
then `python3` on your `PATH`. Whichever route you take below, that's the interpreter it
|
||||
lands in.
|
||||
|
||||
- **Installed MeshTerm with `pipx`?** Inject it into MeshTerm's own environment:
|
||||
|
||||
```bash
|
||||
pipx inject mesh-term 'openhop-core[hardware]'
|
||||
```
|
||||
|
||||
- **Installed with `pip`?** Pull in the `spi` extra when you install MeshTerm (see
|
||||
[Step 3](#step-3--install-meshterm)):
|
||||
|
||||
```bash
|
||||
pip install 'mesh-term[spi]'
|
||||
```
|
||||
|
||||
- **Running the one-file Linux ARM64 build?** It's a single frozen binary and can't carry
|
||||
the library, so give it a venv of its own — the same one the bridge guide uses, so it's
|
||||
shared if you ever run both:
|
||||
|
||||
```bash
|
||||
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
|
||||
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
|
||||
```
|
||||
|
||||
MeshTerm finds it there automatically; no configuration needed.
|
||||
|
||||
The older `pymc_core` runtime (the library's name before 2026) is not supported for a
|
||||
direct connection — only the standalone bridge still carries the compatibility shims it
|
||||
needs.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Install MeshTerm
|
||||
|
||||
Same as any other machine — the simplest way is the Linux ARM64 one-file release:
|
||||
|
||||
```bash
|
||||
curl -fL -o meshterm https://github.com/jpmartineau/MeshTerm/releases/latest/download/meshterm-linux-arm64
|
||||
chmod +x meshterm
|
||||
sudo mv meshterm /usr/local/bin/meshterm
|
||||
```
|
||||
|
||||
Or install it from the repository with `pipx`:
|
||||
|
||||
```bash
|
||||
sudo apt install pipx
|
||||
pipx install git+https://github.com/jpmartineau/MeshTerm
|
||||
pipx ensurepath
|
||||
pipx inject mesh-term 'openhop-core[hardware]'
|
||||
```
|
||||
|
||||
Or with `pip`, pulling in the radio library as its own extra in one line:
|
||||
|
||||
```bash
|
||||
pip install 'mesh-term[spi]'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Connect
|
||||
|
||||
```bash
|
||||
meshterm --spi
|
||||
```
|
||||
|
||||
`--spi` finds the AIO's radio at `/dev/spidev1.0` and starts a node on it. The startup
|
||||
device picker also lists it — a row reading **SPI radio (/dev/spidev1.0)** with a 📍 icon —
|
||||
whenever that device node exists, so `meshterm` with no flags gets you there too.
|
||||
|
||||
You're connected when **Device info** or the dashboard shows a node name instead of a
|
||||
connection error. MeshTerm reports the device model as **MeshTerm SPI node**, and the
|
||||
header's connection label reads **SPI**.
|
||||
|
||||
**What happens underneath.** MeshTerm starts the node when it connects and ends it when
|
||||
you disconnect or quit — including a crash: the node exits with MeshTerm, and the kernel
|
||||
frees the GPIO lines and SPI handle the moment its process does. The trade-off is the one
|
||||
this implies: **the node is off the mesh while MeshTerm is closed.** If you want it on all
|
||||
the time, that's what [the bridge](#always-on-the-bridge) is for.
|
||||
|
||||
**Where its state lives.** Each radio keeps its identity, preferences, channels, and
|
||||
contacts under `~/.meshterm/radio/spidev1.0/` — `identity.key`, `prefs.json`,
|
||||
`channels.json`, `contacts.json` — plus a running log, `node.log` (the previous run's is
|
||||
kept as `node.log.1`). All of it survives a restart.
|
||||
|
||||
**A profile, if you want a name for it:**
|
||||
|
||||
```toml
|
||||
[profiles.aio]
|
||||
transport = "spi"
|
||||
```
|
||||
|
||||
```bash
|
||||
meshterm -p aio
|
||||
```
|
||||
|
||||
The AIO's wiring is the default, so this profile needs nothing beyond `transport = "spi"`.
|
||||
[Board wiring](#board-wiring) is for when yours differs.
|
||||
|
||||
---
|
||||
|
||||
## Moving over from the bridge (0.9.0 and earlier)
|
||||
|
||||
> **Only if you set up the bridge with MeshTerm 0.9.0 or earlier.** Before 0.10.0 the bridge
|
||||
> was the only way to use the uConsole's radio, so an older setup is almost certainly
|
||||
> running one. On a fresh install of 0.10.0 or newer there is nothing to move over — skip
|
||||
> ahead to [Board wiring](#board-wiring).
|
||||
|
||||
Upgraded from 0.9.0 or earlier with the bridge still installed? The first time you connect
|
||||
with `--spi` (or a profile, or the picker row), MeshTerm carries the bridge's node over —
|
||||
**copying**, never moving, so the bridge still works afterwards if you go back to it:
|
||||
|
||||
- the identity key — from the `meshcore-console` GUI's copy first
|
||||
(`~/.local/share/meshcore-uconsole/identity.key`), the way the bridge itself looks for
|
||||
it, else the bridge's own (`~/.local/share/meshterm-spi-bridge/identity.key`);
|
||||
- the bridge's `contacts.json`;
|
||||
- the name `uConsole`;
|
||||
- the bridge's radio settings — frequency, bandwidth, spreading factor, coding rate, TX
|
||||
power — read out of the bridge service's `MESHCORE_*` environment.
|
||||
|
||||
This only happens once, while `~/.meshterm/radio/spidev1.0/` has no `identity.key` yet — so
|
||||
your node keeps its public key, and with it its place in everyone else's contact list.
|
||||
|
||||
**Wiring is not carried over.** If your board needed `MESHCORE_EN_PINS` or another wiring
|
||||
variable for the bridge, put the equivalent in the profile's `spi` table (see
|
||||
[Board wiring](#board-wiring)) — the direct connection reads wiring from there, never from
|
||||
the bridge's environment.
|
||||
|
||||
**Stop the bridge before switching over**, since only one program may hold the radio at a
|
||||
time:
|
||||
|
||||
```bash
|
||||
systemctl --user stop meshterm-spi-bridge
|
||||
systemctl --user disable meshterm-spi-bridge # keep it stopped across reboots
|
||||
```
|
||||
|
||||
If MeshTerm sees the bridge service running, it refuses to start the node and tells you the
|
||||
same two commands.
|
||||
|
||||
---
|
||||
|
||||
## Board wiring
|
||||
|
||||
The defaults match the hackergadgets uConsole AIO **v1**, which needs no `spi` table at
|
||||
all. State only what differs, under `[profiles.<name>.spi]`. For a board that isn't an
|
||||
AIO — a LoRa HAT on a Raspberry Pi, say — [Adding a radio on the SPI
|
||||
bus](configuration.md#adding-a-radio-on-the-spi-bus) walks through a whole profile, including
|
||||
how to read the pins off the board's `meshtasticd` configuration:
|
||||
|
||||
```toml
|
||||
[profiles.aio2]
|
||||
transport = "spi"
|
||||
|
||||
[profiles.aio2.spi]
|
||||
en_pins = [27] # the AIO v2 needs this
|
||||
```
|
||||
|
||||
| Setting | Default | What it is |
|
||||
| --- | --- | --- |
|
||||
| `bus_id`, `cs_id` | `1`, `0` | Which SPI bus and chip-select the radio is on (`/dev/spidev<bus_id>.<cs_id>`). |
|
||||
| `cs_pin` | `-1` | A GPIO driven as chip select by hand, instead of the bus's own. |
|
||||
| `gpio_chip` | `0` | Which `/dev/gpiochip<n>` the pins below are on. |
|
||||
| `use_gpiod_backend` | `false` | Drive the pins through `gpiod` instead of `python-periphery`. |
|
||||
| `reset_pin`, `busy_pin`, `irq_pin` | `25`, `24`, `26` | The chip's reset, busy and interrupt (DIO1) lines. |
|
||||
| `txen_pin`, `rxen_pin` | `-1`, `-1` | An external RF switch's transmit/receive-enable lines, if the board has one. |
|
||||
| `en_pins` | `[]` | Power-enable lines to raise before the chip is touched. The **AIO v2 needs `[27]`.** |
|
||||
| `use_dio2_rf`, `use_dio3_tcxo` | `true`, `true` | Whether DIO2 drives the RF switch and DIO3 a TCXO (both on for the AIO). |
|
||||
| `is_waveshare` | `false` | The Waveshare HAT's wiring quirks, which the radio library knows about. |
|
||||
| `python` | `""` (empty) | An interpreter to run the node under, when the one MeshTerm would find on its own isn't the one you want. Empty lets MeshTerm look. |
|
||||
|
||||
An unknown key, or one of the wrong type, stops MeshTerm at startup with the profile
|
||||
named — a misspelt pin is never silently ignored.
|
||||
|
||||
Radio settings — frequency, bandwidth, spreading factor, coding rate, TX power — are
|
||||
**not** in this table. They're the node's own, on Device config, as noted in
|
||||
[What you need](#what-you-need).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
| --- | --- |
|
||||
| The radio library isn't found | Follow [Step 2](#step-2--install-the-radio-library) for however you installed MeshTerm, or set `python` in the profile's `spi` table to an interpreter that has `openhop-core[hardware]`. |
|
||||
| No `/dev/spidev1.0` | Add `dtoverlay=spi1-1cs` to `/boot/firmware/config.txt` and reboot. |
|
||||
| Permission denied opening the SPI or GPIO device | `sudo usermod -aG spi,gpio $USER`, then log out and back in. |
|
||||
| "the meshterm-spi-bridge service has the radio" | Stop it: `systemctl --user stop meshterm-spi-bridge` (and `disable` it to keep it stopped). |
|
||||
| "meshcore-console has the radio open" | Close the GUI and connect again. |
|
||||
| Some other program has the radio | `sudo lsof /dev/gpiochip0` shows which; only one program may hold the radio at a time. |
|
||||
| The node starts but never answers | Check its log: `~/.meshterm/radio/spidev1.0/node.log`. |
|
||||
| An AIO v2 radio never comes up | It needs `en_pins = [27]` in the profile's `spi` table — see [Board wiring](#board-wiring). |
|
||||
|
||||
---
|
||||
|
||||
## Always on: the bridge
|
||||
|
||||
Most people should use the direct connection above. Use the bridge instead if you want the
|
||||
node reachable on the mesh **while MeshTerm is closed** — it runs as a `systemd --user`
|
||||
service, on its own, independent of whether MeshTerm is even installed. The trade-off runs
|
||||
the other way from the direct connection: once the bridge is running, it holds the radio's
|
||||
pins for good, so no other LoRa program — including a direct `meshterm --spi` — can use
|
||||
them until you stop it.
|
||||
|
||||
The bridge puts the standard MeshCore companion protocol in front of the same SPI-attached
|
||||
chip, on a local TCP port. MeshTerm then connects to that port like it would any network
|
||||
companion.
|
||||
|
||||
- [Bridge step 1 — Install the radio runtime](#bridge-step-1--install-the-radio-runtime)
|
||||
- [Bridge step 2 — Get the bridge](#bridge-step-2--get-the-bridge)
|
||||
- [Bridge step 3 — Run the preflight](#bridge-step-3--run-the-preflight)
|
||||
- [Bridge step 4 — Run it once in the foreground](#bridge-step-4--run-it-once-in-the-foreground)
|
||||
- [Bridge step 5 — Install it as a service](#bridge-step-5--install-it-as-a-service)
|
||||
- [Bridge step 6 — Connect MeshTerm to it](#bridge-step-6--connect-meshterm-to-it)
|
||||
- [Updating and uninstalling the bridge](#updating-and-uninstalling-the-bridge)
|
||||
- [Bridge hardware knobs](#bridge-hardware-knobs)
|
||||
- [Bridge troubleshooting](#bridge-troubleshooting)
|
||||
|
||||
The bridge's own wiring and radio defaults are the same AIO v1 numbers as the direct
|
||||
connection's — see the table in [Board wiring](#board-wiring) — just set through
|
||||
`MESHCORE_*` environment variables instead of a profile's `spi` table.
|
||||
|
||||
The radio library that actually drives the chip was renamed in 2026: `pymc_core` became
|
||||
`openhop_core`. PyPI's `pymc-core` stopped at 1.0.12; `openhop-core` carries on from 1.1.x.
|
||||
The bridge supports both and prefers the newer one when it finds both installed.
|
||||
|
||||
### Bridge step 1 — Install the radio runtime
|
||||
|
||||
Which route you take depends on your Debian release:
|
||||
|
||||
- **trixie**, with the `meshcore-uconsole` package (by cwill747) installed: you already
|
||||
have a working runtime (Python 3.13-based). Skip to
|
||||
[step 2](#bridge-step-2--get-the-bridge).
|
||||
- **bookworm**: the trixie `.deb` of `meshcore-uconsole` 1.12.0 is built on Python 3.13
|
||||
and needs glibc 2.38, so it will not install on bookworm, and the APT repository offers
|
||||
nothing for bookworm. Upstream's GitHub Releases also carry a separate bookworm `.deb`
|
||||
built on Python 3.11; it has not been tested with the bridge. The documented route is to
|
||||
give the bridge a runtime of its own:
|
||||
|
||||
```bash
|
||||
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
|
||||
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
|
||||
```
|
||||
|
||||
This is also the route to take if you'd rather not install the `meshcore-uconsole` package
|
||||
at all.
|
||||
|
||||
You'll confirm which runtime the bridge actually found in
|
||||
[step 3](#bridge-step-3--run-the-preflight) — its first check line names the interpreter
|
||||
and the runtime.
|
||||
|
||||
### Bridge step 2 — Get the bridge
|
||||
|
||||
`scripts/uconsole/meshterm-spi-bridge` is a single, self-contained Python file — no install step of
|
||||
its own. Either clone the MeshTerm repo:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jpmartineau/MeshTerm
|
||||
cd MeshTerm/scripts/uconsole
|
||||
```
|
||||
|
||||
or download just the one file:
|
||||
|
||||
```bash
|
||||
curl -L -o meshterm-spi-bridge \
|
||||
https://raw.githubusercontent.com/jpmartineau/MeshTerm/main/scripts/uconsole/meshterm-spi-bridge
|
||||
```
|
||||
|
||||
Make it executable:
|
||||
|
||||
```bash
|
||||
chmod +x meshterm-spi-bridge
|
||||
```
|
||||
|
||||
### Bridge step 3 — Run the preflight
|
||||
|
||||
```bash
|
||||
./meshterm-spi-bridge --check
|
||||
```
|
||||
|
||||
This prints a `Prerequisite check:` block with one line per check. What each line means and
|
||||
what to do if it fails:
|
||||
|
||||
| Check | Meaning if it fails | Fix |
|
||||
| --- | --- | --- |
|
||||
| `node runtime` | Neither `openhop_core` nor `pymc_core` is importable by any interpreter the bridge tried. | Build the venv from [step 1](#bridge-step-1--install-the-radio-runtime), or set `MESHTERM_PYMC_PYTHON` to a python that has one. |
|
||||
| `SPI device` | No `/dev/spidev*` node exists, or it exists but you can't read/write it. | No node at all: add `dtoverlay=spi1-1cs` to `/boot/firmware/config.txt` and reboot. Node present but no permission: `sudo usermod -aG spi $USER`, then log out and back in. |
|
||||
| `GPIO chip` | `/dev/gpiochip0` is missing or not read/write for you. | `sudo usermod -aG gpio $USER`, then log out and back in. |
|
||||
| `bridge service` / `radio in use` / `port 5000 busy` / `radio is free` | Tells you whether something already holds the radio: the bridge's own boot service, a running `meshcore-console`, or something else already listening on the TCP port. | Only one program may use the radio at a time — see [step 6](#bridge-step-6--connect-meshterm-to-it). If `meshcore-console` is running, close it before running the bridge. |
|
||||
|
||||
If the first three checks all pass, you're ready to run it.
|
||||
|
||||
### Bridge step 4 — Run it once in the foreground
|
||||
|
||||
```bash
|
||||
./meshterm-spi-bridge --run
|
||||
```
|
||||
|
||||
This runs the preflight again and prints it. Before it starts the bridge, it asks whether
|
||||
to add a `bridge` profile to your MeshTerm config
|
||||
(`` Add a 'bridge' profile to /home/you/.meshterm/config.toml so you can just run `meshterm`? [y/N] ``).
|
||||
Either answer is fine; step 5 asks again if you say no. Then it starts the bridge in this
|
||||
terminal. A healthy startup logs something like:
|
||||
|
||||
```
|
||||
node runtime openhop_core 1.1.3 (/home/you/.local/share/meshterm-spi-bridge/venv/bin/python)
|
||||
radio config bus_id=1 reset_pin=25 busy_pin=24 irq_pin=26 frequency=910525000 tx_power=22
|
||||
radio.begin() attempt 1/4
|
||||
radio ready
|
||||
node identity ab12cd34ef567890… (hash 0xab, name 'uConsole')
|
||||
READY — connect with: meshterm --tcp 127.0.0.1:5000
|
||||
```
|
||||
|
||||
The `radio config` line lists every setting as `key=value`, so the one above is
|
||||
shortened; yours is longer.
|
||||
|
||||
You can test the bridge this way: leave it running, and in another terminal on the same
|
||||
machine run:
|
||||
|
||||
```bash
|
||||
meshterm --tcp 127.0.0.1:5000
|
||||
```
|
||||
|
||||
Back in the bridge's terminal, press **Ctrl+C** to stop it.
|
||||
|
||||
### Bridge step 5 — Install it as a service
|
||||
|
||||
Run the bridge with no flags to get the interactive menu:
|
||||
|
||||
```bash
|
||||
./meshterm-spi-bridge
|
||||
```
|
||||
|
||||
Choose **2) Install as a service**. This:
|
||||
|
||||
- copies the script to `~/.local/bin/meshterm-spi-bridge`,
|
||||
- writes a `systemd --user` unit at
|
||||
`~/.config/systemd/user/meshterm-spi-bridge.service` that restarts on failure and starts
|
||||
with your session,
|
||||
- enables and starts it now,
|
||||
- tries to enable lingering for your user, so the service comes up at boot even before you
|
||||
log in. If it can't, it prints the command to run yourself:
|
||||
|
||||
```bash
|
||||
sudo loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
It also offers to add a `[profiles.bridge]` block to your MeshTerm `config.toml` — say yes,
|
||||
it's the profile you'll use next.
|
||||
|
||||
Check on it any time:
|
||||
|
||||
```bash
|
||||
systemctl --user status meshterm-spi-bridge.service
|
||||
journalctl --user -u meshterm-spi-bridge.service -f
|
||||
```
|
||||
|
||||
Menu option **4) Show status** prints the same information from inside the script.
|
||||
|
||||
### Bridge step 6 — Connect MeshTerm to it
|
||||
|
||||
Install MeshTerm on the uConsole the same way as [Step 3](#step-3--install-meshterm)
|
||||
above — the bridge doesn't need the `spi` extra or `openhop-core[hardware]` in MeshTerm's
|
||||
own Python, since the bridge is the one running the node.
|
||||
|
||||
If you accepted the profile in step 4 or 5, `~/.meshterm/config.toml` now has this block
|
||||
(the bridge always writes to that path, even if you set `MESHTERM_HOME`):
|
||||
|
||||
```toml
|
||||
[profiles.bridge]
|
||||
host = "127.0.0.1"
|
||||
tcp_port = 5000
|
||||
description = "Local SPI radio via meshterm-spi-bridge"
|
||||
```
|
||||
|
||||
Connect with:
|
||||
|
||||
```bash
|
||||
meshterm -p bridge
|
||||
```
|
||||
|
||||
or, without a profile:
|
||||
|
||||
```bash
|
||||
meshterm --tcp 127.0.0.1:5000
|
||||
```
|
||||
|
||||
You're connected when **Device info** or the dashboard shows a node name (`uConsole` by
|
||||
default) instead of a connection error. MeshTerm reports the device model as
|
||||
`pyMC-spi-bridge` — that's how you tell a bridged node from a real companion or a direct
|
||||
SPI connection.
|
||||
|
||||
**Identity.** If the `meshcore-console` GUI's package is present, the bridge loads *that*
|
||||
package's identity key, so MeshTerm and the GUI share the same public key. Contacts and
|
||||
radio settings are the bridge's own: it keeps contacts in
|
||||
`~/.local/share/meshterm-spi-bridge/contacts.json`, its channels in
|
||||
`~/.local/share/meshterm-spi-bridge/channels.json` (both survive a restart), and builds its
|
||||
radio settings from the `MESHCORE_*` variables and its defaults, never from the GUI's saved
|
||||
settings. Set the `MESHCORE_*` variables to match what the GUI uses. On a library-only
|
||||
install, the bridge mints and keeps its own identity key under
|
||||
`~/.local/share/meshterm-spi-bridge/identity.key`.
|
||||
|
||||
**Only one program may use the radio at a time.** Never run the bridge and the
|
||||
`meshcore-console` GUI at the same time — whichever started second will fail to open the
|
||||
radio. The same goes for a direct `meshterm --spi` connection while the bridge is running.
|
||||
|
||||
### Updating and uninstalling the bridge
|
||||
|
||||
To update, pull or re-download `meshterm-spi-bridge` and reinstall the service (menu option
|
||||
2 again — it overwrites the copy in `~/.local/bin/`). The running service keeps the old
|
||||
code until you restart it:
|
||||
|
||||
```bash
|
||||
systemctl --user restart meshterm-spi-bridge.service
|
||||
```
|
||||
|
||||
Option 2 also rewrites the unit file, so any `Environment=` lines you added to it by hand
|
||||
are lost. Put them in a drop-in instead, as [Bridge hardware knobs](#bridge-hardware-knobs)
|
||||
shows.
|
||||
|
||||
To remove the bridge, run the menu and choose **3) Uninstall the service**. This stops and
|
||||
removes the `systemd --user` unit and the installed script copy. It does **not** touch
|
||||
`meshcore-console`, the radio library, your node identity, or your saved contacts and
|
||||
channels — those stay put. If you'd added `[profiles.bridge]` to your MeshTerm config, it
|
||||
tells you to remove that block by hand if you no longer want it.
|
||||
|
||||
### Bridge hardware knobs
|
||||
|
||||
The pin defaults match the hackergadgets uConsole AIO v1, which needs none of these. The
|
||||
**AIO v2 needs `MESHCORE_EN_PINS=27`**, whether or not the `meshcore-console` package is
|
||||
installed. The radio settings default to the US/Canada preset. For a foreground run, export
|
||||
what differs before launching the bridge. For the service, add a drop-in, which survives a
|
||||
reinstall:
|
||||
|
||||
```bash
|
||||
systemctl --user edit meshterm-spi-bridge.service
|
||||
```
|
||||
|
||||
and put the variables under a `[Service]` heading, one `Environment=` line each:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Environment=MESHCORE_EN_PINS=27
|
||||
```
|
||||
|
||||
Then `systemctl --user restart meshterm-spi-bridge.service`.
|
||||
|
||||
| Variable | What it sets |
|
||||
| --- | --- |
|
||||
| `MESHCORE_BUS_ID`, `MESHCORE_CS_ID`, `MESHCORE_CS_PIN` | which SPI bus, device, and chip-select pin the radio is on |
|
||||
| `MESHCORE_RESET_PIN`, `MESHCORE_BUSY_PIN`, `MESHCORE_IRQ_PIN` | the three control lines |
|
||||
| `MESHCORE_FREQUENCY`, `MESHCORE_TX_POWER` | frequency in Hz, power in dBm |
|
||||
| `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining. Whole numbers only: bandwidth in Hz (`62500`), coding rate as the denominator (`5` for 4/5). A decimal or `4/5` stops the bridge at startup. |
|
||||
| `MESHCORE_TXEN_PIN`, `MESHCORE_RXEN_PIN`, `MESHCORE_EN_PINS` | the RF-switch and power-enable lines a board may need (`-1` for none; `EN_PINS` is a comma list — the AIO v2 wants `27`) |
|
||||
| `MESHCORE_USE_DIO2_RF`, `MESHCORE_USE_DIO3_TCXO`, `MESHCORE_IS_WAVESHARE` | whether DIO2 drives the RF switch and DIO3 the TCXO (both on for the AIO), and a flag for a different vendor's wiring the runtime knows about |
|
||||
| `MESHCORE_GPIO_CHIP`, `MESHCORE_USE_GPIOD_BACKEND`, `MESHCORE_PREAMBLE_LENGTH` | which gpiochip, whether to drive it through `gpiod`, and the LoRa preamble (leave it unset: the bridge follows MeshCore, 32 symbols up to SF8 and 16 above, and a shorter one leaves the radio deaf to most of the mesh) |
|
||||
| `MESHTERM_PYMC_PYTHON` | force a specific interpreter instead of letting the bridge discover one |
|
||||
|
||||
The bridge passes a knob only when the runtime's radio constructor accepts it, so the same
|
||||
environment works under `pymc_core` 1.0.x, which lacks the newer ones. Where the
|
||||
`meshcore-console` package is installed, the bridge builds the radio through that
|
||||
package's environment reader, which reads the same names plus a few more of its own (see
|
||||
that package's documentation) and ignores the GUI's saved presets. On a library-only
|
||||
install the table above is the whole of it. Either way, an AIO v2 without
|
||||
`MESHCORE_EN_PINS=27` will not power its radio.
|
||||
|
||||
### Bridge troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| `node runtime` fails in the preflight | Did you build the venv in [step 1](#bridge-step-1--install-the-radio-runtime)? Is `MESHTERM_PYMC_PYTHON` pointing at the right interpreter, if set? |
|
||||
| `SPI device` shows "no /dev/spidev* found" | Is `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt`? Did you reboot after adding it? |
|
||||
| `SPI device` or `GPIO chip` shows a permission error | Are you in the `spi` and `gpio` groups (`groups`)? Did you log out and back in after `usermod`? |
|
||||
| Preflight warns `radio in use meshcore-console is running:` | Close the GUI before running or installing the bridge. |
|
||||
| Preflight warns `port 5000 busy something is already on 127.0.0.1:5000` | Something other than the bridge service is listening there (an active service is reported as `bridge service running` instead). Usually it is a foreground `--run` in another terminal; stop that one. |
|
||||
| Bridge exits with `bridge failed: GPIO pins still busy after retries` | The bridge retries `radio.begin()` four times only when the GPIO lines are busy. They may still be held by a session that just closed — wait a few seconds and try again. |
|
||||
| MeshTerm connects but direct messages to an old contact say "not found" | Contacts are restored from the bridge's snapshot file at startup. If the contact is not in it, wait until the contact is heard again. |
|
||||
| Reboot, factory reset, or setting a device PIN silently does nothing | Expected — the radio library has no handler for these three, whatever runtime you're on. |
|
||||
| Traces or the live feed don't show up | These rely on compatibility shims the bridge installs (or, under `openhop_core`, on the library's own equivalents). Check the bridge's log for lines starting `compat:` to confirm they're wired. |
|
||||
|
||||
---
|
||||
|
||||
## What this has been tested on
|
||||
|
||||
**The direct connection** has been tested against the real radio library
|
||||
(`openhop-core`) with a simulated radio; it has **not yet been verified on uConsole
|
||||
hardware**.
|
||||
|
||||
**The bridge** has been verified on a bookworm uConsole with `openhop-core` 1.1.3: the
|
||||
radio comes up, contacts are restored across a restart, and `info` and `contacts` answer
|
||||
correctly over the TCP connection. The older `pymc-core` 1.0.x path, with all three of the
|
||||
bridge's compatibility shims active, has been exercised in full, including traces and the
|
||||
live feed. MeshTerm's test suite does not cover the bridge.
|
||||
|
||||
What has not been confirmed:
|
||||
|
||||
- **The direct connection on real hardware** — everything above has only run against a
|
||||
simulated radio under the real library.
|
||||
- **The bridge under `openhop_core` beyond `info` and `contacts`.** Startup, identity,
|
||||
contact restore and those two reads were exercised; a trace, a message and the live feed
|
||||
under the new runtime were not, though the library's own trace push is what the bridge
|
||||
now relies on for the first.
|
||||
- **Everything on-air** — the per-hop trace semantics the bridge reassembles and the
|
||||
acknowledgement bytes newer firmware appends after the CRC. The code is internally
|
||||
consistent with the library's API; the wire behaviour was seen on one mesh, against the
|
||||
radios in range of one bench.
|
||||
@@ -1,11 +1,12 @@
|
||||
"""MeshTerm is a full-featured TUI-based MeshCore client for your terminal.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MeshTerm is a full-featured TUI MeshCore client for your terminal.
|
||||
|
||||
Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Linux.
|
||||
Connects to a companion over USB, Bluetooth, or TCP. For Windows, macOS, and Linux.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
__version__ = "0.2.8"
|
||||
__version__ = "0.10.2"
|
||||
__author__ = "Jean-Pierre Martineau"
|
||||
|
||||
#: The year MeshTerm was first published; the copyright span starts here.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Enable ``python -m meshterm``."""
|
||||
|
||||
from .cli import main
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
## What it is
|
||||
|
||||
**MeshTerm** is a full-featured **MeshCore** client for the terminal. It talks to companion devices running the stock firmware over serial, Bluetooth, or TCP.
|
||||
**MeshTerm** is a full-featured TUI **MeshCore** client for your terminal. Plug in a companion device over USB, pair one over Bluetooth, or reach one over TCP. Windows, macOS, and Linux. Free and open-source.
|
||||
|
||||
Everything MeshTerm can do thru its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks!
|
||||
Nearly everything MeshTerm can do through its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The live pictures stay in the TUI, where they belong — the map, the dashboard, the live feed, the watchtower, the mesh walk — and everything else has a command. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks! Every command prints for a person by default, and speaks JSON with `--json` when a program is reading!
|
||||
|
||||
Everything the radio overhears is recorded to a local database from the moment it's turned on — adverts, telemetry, signal quality, positions, etc. Features like maps, dashboard, and route graphs read back from it, so the longer it runs, the more it know about the mesh!
|
||||
While MeshTerm is running and connected to your radio, everything the radio overhears is recorded to a local database — adverts, telemetry, signal quality, positions, etc. Features like maps, dashboard, and route graphs read back from it, so the longer it runs, the more it knows about the mesh!
|
||||
|
||||
## Where it came from
|
||||
|
||||
@@ -18,29 +18,29 @@ I wanted something I could run from my computer. I wanted it to run on Windows,
|
||||
|
||||
## Platform-specific developments
|
||||
|
||||
In addition to generic Terminal setup, I wanted it to run on two devices I had lying around on my nerdcave.
|
||||
In addition to a generic terminal setup, I wanted it to run on two devices I had lying around in my nerdcave.
|
||||
|
||||
### Clockwork uConsole
|
||||
MeshTerm is compatible with the **HackerGadgets AIO board v1**. The **AIO** board has just a raw LoRa radio, meaning it can't run MeshCore firmware. MeshTerm includes a script to run the mesh node in software, and expose it using a TCP port which MeshTerm can connect to.
|
||||
### ClockworkPi uConsole
|
||||
MeshTerm is compatible with the **HackerGadgets AIO board (v1 or v2)**. The **AIO** board has just a raw LoRa radio, meaning it can't run MeshCore firmware. MeshTerm can run the mesh node in software itself, straight over the board's SPI bus, for as long as MeshTerm is running — no separate service needed. If you'd rather the node stayed on the mesh while MeshTerm is closed, an optional bridge service can run it all the time instead.
|
||||
|
||||
MeshTerm feels right at home on the **uConsole**. Running MeshTerm on it makes it feel like a piece of gear straight out of a William Gibson novel.
|
||||
|
||||
### Clockwork PicoCalc
|
||||
### ClockworkPi PicoCalc
|
||||
Getting MeshTerm to run on the **PicoCalc** is a bit more involved, but totally worth it:
|
||||
- Swap out the **Raspberry Pi Pico** for a **Luckfox Lyra** to run **Calculinux**.
|
||||
- Add a compatible 3.3v USB wifi dongle (e.g.: **TP-Link TL-WN725N N150 Nano**) to the **Lyra**'s internal USB port.
|
||||
- Add a LoRa device. Here's a few options:
|
||||
- Add a compatible 3.3 V USB Wi-Fi dongle (e.g. **TP-Link TL-WN725N N150 Nano**) to the **Lyra**'s internal USB port.
|
||||
- Add a LoRa device. Here are two options:
|
||||
- Connect a companion via USB-C;
|
||||
- add **Seeed Studio XIAO nRF52840 + Wio-SX1262** internally, soldered to GPIO connectors' legs.
|
||||
- In order to use the XIAO nRF52840 via GPIO, we do need to use a modified firmware. A **PR** *https://github.com/meshcore-dev/MeshCore/pull/3191* has been submitted to MeshCore, but it might take a while for it to go through.
|
||||
- In order to use the XIAO nRF52840 via GPIO, a modified firmware is needed. A **PR** *https://github.com/meshcore-dev/MeshCore/pull/3191* has been submitted to MeshCore, but it might take a while for it to go through.
|
||||
|
||||
The UX is a bit different on the the PicoCalc's 16-color 53-column screen layout, but it's been tailored to make good use of the available space and the PicoCalc's F-keys. The result is, in my humble opinion, the best MeshCore experience you can get on a portable device. With two 18650 batteries, it'll run all day.
|
||||
The UX is a bit different on the PicoCalc's 16-colour 53-column screen layout, but it's been tailored to make good use of the available space and the PicoCalc's F-keys. The result is, in my humble opinion, the best MeshCore experience you can get on a portable device. It runs on one 18650 battery, and a second one roughly doubles the runtime.
|
||||
|
||||
## What the future holds
|
||||
|
||||
MeshTerm will evolve over time, it is a work in progress.
|
||||
MeshTerm will evolve over time. It is a work in progress.
|
||||
|
||||
I've build MeshTerm for myself — I find that's when I produce my best work — but I sincerely hope it will be appreciated by others.
|
||||
I've built MeshTerm for myself — I find that's when I produce my best work — but I sincerely hope it will be appreciated by others.
|
||||
|
||||
If you'd like a specific feature, pitch your idea on the Discord!
|
||||
|
||||
@@ -49,21 +49,24 @@ If you'd like a specific feature, pitch your idea on the Discord!
|
||||
MeshTerm ships under the **Apache License, Version 2.0** — free to use,
|
||||
change, and redistribute, commercially or otherwise, as long as the
|
||||
LICENSE and NOTICE files travel with it. The full terms:
|
||||
**apache.org/licenses/LICENSE-2.0** *https://www.apache.org/licenses/LICENSE-2.0*.
|
||||
*https://www.apache.org/licenses/LICENSE-2.0*.
|
||||
|
||||
The licence covers the code, not the name: **MeshTerm and its logo are
|
||||
The licence does not cover the name: **MeshTerm and its logo are
|
||||
trademarks the author keeps**, so a fork goes out under a name of its own.
|
||||
The donate link in this app supports this project and its original
|
||||
author — a fork that keeps soliciting through it, without pointing it at
|
||||
itself, isn't affiliated with MeshTerm.
|
||||
|
||||
Built on **MeshCore** *https://meshcore.io/* — its firmware and companion
|
||||
library — with Rich, prompt_toolkit, Typer, bleak, pyserial, pycryptodome
|
||||
and Segno, each under its own terms.
|
||||
library — with Rich, prompt_toolkit, Typer, bleak, pyserial, pycryptodome,
|
||||
markdown-it-py, tomli/tomli-w, and Segno, each under its own terms.
|
||||
|
||||
Map data **© OpenStreetMap contributors**, served as vector tiles by
|
||||
**OpenFreeMap** *https://openfreemap.org/*, used under the
|
||||
**ODbL** *https://www.openstreetmap.org/copyright*.
|
||||
**OpenFreeMap** *https://openfreemap.org/* on the **© OpenMapTiles** schema
|
||||
*https://openmaptiles.org/*, used under the **ODbL**
|
||||
*https://www.openstreetmap.org/copyright*. That last address is where the map's
|
||||
own corner credit points — nothing on a console is clickable, so the link is
|
||||
spelled out here instead.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
## Who am I IRL
|
||||
I'm Jean-Pierre Martineau. I live in Montréal, Québec, Canada.
|
||||
I'm Jean-Pierre Martineau. I live in Montréal, Québec, Canada.
|
||||
|
||||
Data expert by day and tinkerer by night, I've spend my career designing solutions and writing code for various industries, from video-games to public transit. I've always had an unquenchable curiosity that drove me to pursue personal projects during my free time. MeshCore has been the focal point of my nights and weekends as of lately.
|
||||
Data expert by day and tinkerer by night, I've spent my career designing solutions and writing code for various industries, from video games to public transit. I've always had an unquenchable curiosity that drove me to pursue personal projects during my free time. MeshCore has been the focal point of my nights and weekends lately.
|
||||
|
||||
## Who am I on the mesh
|
||||
|
||||
@@ -9,11 +9,11 @@ I operate **YUL-Cartierville** and **YUL-Upper-Salaberry** repeaters in Montréa
|
||||
|
||||
## On the subject of AI assisted development
|
||||
|
||||
As a computer scientist, I find AI to be a powerful amplifier, not a substitute—of one's capabilities. For me, it's been a vector of productivity, allowing me to grasp something which would have been out of reach. I can spend my time ideating and let the machine take care of the nitty-gritty. Don't be fooled, sculpting MeshTerm into its current form still took a *lot* of work. Every nook and cranny of the app has been designed, tested, and iterated by me. Hundreds of hours of my own time, augmented by what I learned throughout my sofware developer career.
|
||||
As a computer scientist, I find AI to be a powerful amplifier of one's capabilities. For me, it's been a vector of productivity, allowing me to grasp something which would have been out of reach. I can spend my time ideating and let the machine take care of the nitty-gritty. Don't be fooled: sculpting MeshTerm into its current form still took a *lot* of work. Every nook and cranny of the app has been designed, tested, and iterated by me. It took hundreds of hours of my own time, augmented by what I learned throughout my software developer career.
|
||||
|
||||
MeshTerm would *not* exist without the use of AI tools.
|
||||
|
||||
One detail: the about pages were hand-written by me, and the ANSI art splash screen logo was done by hand — no AI — using **Moebius**.
|
||||
One detail: these pages were hand-written by me, and the ANSI art splash screen logo was done by hand — no AI — using **Moebius**.
|
||||
|
||||
## How to reach me
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
## Why it needs support
|
||||
|
||||
**MeshTerm** is free.
|
||||
**MeshTerm** is free and open source, and it will stay that way.
|
||||
|
||||
The project is a labour of love. I've poured hundreds of hours of my free time into it. Evenings, late nights, weekends, even some of my vacation time!
|
||||
|
||||
As I mentionned in the about pages, this project relies on AI tools to accelerate development. Without them, MeshTerm would absolutely not exist. Subscriptions to these AI tools are a recurring expense!
|
||||
As I mentioned on the Author page, this project relies on AI tools to accelerate development. Without them, MeshTerm would absolutely not exist. Subscriptions to these AI tools are a recurring expense!
|
||||
|
||||
Routers, companion devices, handhelds, antennas, and batteries aren't super expensive individually, but it all adds up quickly!
|
||||
Repeaters, companion devices, handhelds, antennas, and batteries aren't super expensive individually, but it all adds up quickly!
|
||||
|
||||
If you want pitch in and contribute financially, you're more that welcome to do so. Any help will be greatly appreciated and put to good use.
|
||||
If you want to pitch in and contribute financially, you're more than welcome to do so. Any help will be greatly appreciated and put to good use.
|
||||
|
||||
## Chip in
|
||||
|
||||
@@ -20,27 +20,27 @@ If you want pitch in and contribute financially, you're more that welcome to do
|
||||
https://buymeacoffee.com/johnputer
|
||||
```
|
||||
|
||||
### Donate via Paypal
|
||||
### Donate via PayPal
|
||||
|
||||
- *https://tinyurl.com/SupportMeshTerm*
|
||||
- *https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6*
|
||||
|
||||
```qr
|
||||
https://tinyurl.com/SupportMeshTerm
|
||||
https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6
|
||||
```
|
||||
|
||||
### Become a patron
|
||||
|
||||
- *https://www.patreon.com/c/MeshTerm*
|
||||
- *https://patreon.com/MeshTerm*
|
||||
|
||||
```qr
|
||||
https://www.patreon.com/c/MeshTerm
|
||||
https://patreon.com/MeshTerm
|
||||
```
|
||||
|
||||
## Other ways to help
|
||||
|
||||
- Spread the word and let others know how awesome **MeshTerm** is!
|
||||
- Join the Discord community!
|
||||
- Give this project a Star **⭐** on **GitHub**!
|
||||
- Give this project a Star **⭐** on **GitHub**!
|
||||
- Open an issue on **GitHub** when you encounter a bug or problem!
|
||||
|
||||
*https://github.com/jpmartineau/MeshTerm*
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+379
-88
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Typer entry point.
|
||||
|
||||
Global options are parsed once in the callback, which builds the shared
|
||||
@@ -15,73 +16,138 @@ from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from typer.core import TyperGroup
|
||||
|
||||
from . import __version__
|
||||
from .context import AppContext
|
||||
from .core import win32dll
|
||||
from .core import exitcodes, win32dll
|
||||
from .core.admin_store import AdminStore
|
||||
from .core.config import Settings
|
||||
from .core.config import MOCK_DB_FILENAME, Settings
|
||||
from .core.connection import DeviceCommandError, is_connection_lost
|
||||
from .core.device_config import DeviceConfigError
|
||||
from .core.device_store import DeviceStore
|
||||
from .core.instancelock import InstanceBusy, hold_instance_lock
|
||||
from .core.preferences import PreferenceError, Preferences
|
||||
from .core.preferences import (
|
||||
PREFERENCES_FILENAME,
|
||||
PreferenceError,
|
||||
Preferences,
|
||||
report_dropped,
|
||||
)
|
||||
from .core.selection import DeviceSelectionError
|
||||
from .persistence.logging import configure_logging, get_logger, level_from_name, log_path
|
||||
from .persistence.repository import Repository
|
||||
from .platforms import Resolution, resolve, set_platform, without_emoji
|
||||
from .tools import all_tools
|
||||
from .tools.base import Tool, ToolResult
|
||||
from .ui import renderers, script
|
||||
from .ui.renderers import OutputFormat
|
||||
from .ui.termfont import emoji_support
|
||||
from .ui.theme import make_console
|
||||
|
||||
#: The exit-status table, printed under every ``--help``. A documented return value is
|
||||
#: half of what makes the CLI scriptable (the other half is :mod:`meshterm.ui.script`),
|
||||
#: and a caller should not have to find it in a README.
|
||||
EXIT_STATUS_EPILOG = "Exit status: " + " · ".join(
|
||||
f"{code} {meaning}" for code, meaning in exitcodes.MEANINGS.items()
|
||||
)
|
||||
|
||||
def _unframe_help_panels() -> None:
|
||||
"""Render Typer's help/error sections as coloured headings instead of boxed panels.
|
||||
|
||||
Typer's rich help gives us the colour we want — yellow usage, cyan options, green
|
||||
switches — but wraps each Options/Commands/Error section in a rounded :class:`Panel`,
|
||||
the boxed framing scripted output is meant to be free of. There's no constant to drop
|
||||
that box, so we swap the ``Panel`` the help formatter calls for a thin stand-in that
|
||||
keeps the colour and the section title (as a bold heading, indigo like the app accent,
|
||||
red for errors) but no border — the same heading-over-content shape tool results use
|
||||
(see :func:`~meshterm.ui.surface._deframe`). The inner tables are already box-less.
|
||||
class _GlobalOptionsAnywhere(TyperGroup):
|
||||
"""A group whose own options may be typed after the subcommand as well as before.
|
||||
|
||||
Click binds an option to whatever command declares it, so a global declared on the
|
||||
callback is a parse error one word later: ``meshterm contacts --json`` failed with a
|
||||
bare usage error while ``meshterm --json contacts`` worked. That is the wrong way
|
||||
round for an output-format flag — ``-o json`` goes *after* the verb in every tool that
|
||||
has one, and ``--json`` is the first thing anyone will reach for here.
|
||||
|
||||
So the group's own options are lifted to the front before Click sees them, and both
|
||||
orders mean the same run. ``--`` stops the lifting, which is the standard way to say
|
||||
the rest is data; ``--help`` is deliberately never lifted, since ``contacts --help``
|
||||
must stay the *contacts* help rather than silently becoming the program's.
|
||||
"""
|
||||
import typer.rich_utils as rich_utils
|
||||
from rich.console import Group
|
||||
from rich.text import Text
|
||||
|
||||
def _bare_panel(
|
||||
renderable: object,
|
||||
*,
|
||||
title: object = None,
|
||||
border_style: str = "",
|
||||
**_: object,
|
||||
) -> Group:
|
||||
style = "bold red" if border_style == "red" else "bold #818cf8"
|
||||
heading = Text(str(title), style=style) if title else Text("")
|
||||
return Group(Text(""), heading, renderable) # type: ignore[list-item]
|
||||
|
||||
rich_utils.Panel = _bare_panel # type: ignore[assignment,misc]
|
||||
def parse_args(self, ctx: typer.Context, args: list[str]) -> list[str]:
|
||||
"""Lift this group's options out of ``args``, then parse as Click normally would."""
|
||||
return super().parse_args(ctx, _globals_first(self, args))
|
||||
|
||||
|
||||
_unframe_help_panels()
|
||||
def _globals_first(group: TyperGroup, args: list[str]) -> list[str]:
|
||||
"""``args`` with ``group``'s own options moved ahead of the subcommand.
|
||||
|
||||
An option is told from an argument by carrying ``is_flag`` — Typer vendors Click, so
|
||||
there is no importable ``click.Option`` to test against, and the attribute is the same
|
||||
question asked without reaching into a private package.
|
||||
|
||||
Args:
|
||||
group: The command whose options count as global.
|
||||
args: The argument list as typed.
|
||||
|
||||
Returns:
|
||||
A reordered list. The relative order of the options, and of everything else, is
|
||||
preserved, so a repeated flag resolves exactly as Click would resolve it.
|
||||
"""
|
||||
takes_value: dict[str, bool] = {}
|
||||
for param in group.params:
|
||||
if getattr(param, "is_flag", None) is None or param.name == "help":
|
||||
continue
|
||||
for opt in (*param.opts, *param.secondary_opts):
|
||||
takes_value[opt] = not param.is_flag
|
||||
|
||||
lifted: list[str] = []
|
||||
kept: list[str] = []
|
||||
index = 0
|
||||
while index < len(args):
|
||||
token = args[index]
|
||||
if token == "--":
|
||||
kept.extend(args[index:])
|
||||
break
|
||||
name = token.split("=", 1)[0]
|
||||
if name not in takes_value:
|
||||
kept.append(token)
|
||||
index += 1
|
||||
elif "=" in token or not takes_value[name] or index + 1 >= len(args):
|
||||
lifted.append(token)
|
||||
index += 1
|
||||
else:
|
||||
lifted.extend(args[index : index + 2])
|
||||
index += 2
|
||||
return lifted + kept
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
cls=_GlobalOptionsAnywhere,
|
||||
add_completion=False,
|
||||
no_args_is_help=False,
|
||||
# Rich help for its colour (usage, option flags, metavars), but with the boxed
|
||||
# section panels flattened to plain headings by _unframe_help_panels() above — a
|
||||
# splash of colour, none of the framing.
|
||||
rich_markup_mode="rich",
|
||||
# Click's own plain help, not Typer's rich one: no boxed Options/Commands panels, no
|
||||
# colour, no markup to strip out of a piped `--help`. The CLI is read by scripts, and
|
||||
# its help is read the way every other utility's is (see meshterm.ui.script).
|
||||
rich_markup_mode=None,
|
||||
# The one description, same as the package summary and the PyPI page. See
|
||||
# `meshterm.__doc__` — if this drifts from that, one of them is lying.
|
||||
help=(
|
||||
"MeshTerm is a full-featured TUI-based MeshCore client for your terminal. "
|
||||
"Supports USB, Bluetooth and TCP companion connection. "
|
||||
"For Windows, macOS and Linux."
|
||||
"MeshTerm is a full-featured TUI MeshCore client for your terminal. "
|
||||
"Connects to a companion over USB, Bluetooth, or TCP. "
|
||||
"For Windows, macOS, and Linux."
|
||||
),
|
||||
epilog=EXIT_STATUS_EPILOG,
|
||||
)
|
||||
|
||||
|
||||
def _print_version(value: bool) -> None:
|
||||
"""Print the version and stop, the moment ``--version`` is seen.
|
||||
|
||||
Eager, so it answers before the callback opens a database, resolves a platform or looks
|
||||
for a radio — the one question that is true of the program rather than of a run. Bare
|
||||
``meshterm <version>``: the caller asked for a version, and a script reading it back has
|
||||
one field to take. Exits ``0``, because knowing the version is a success.
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
script.console().print(f"meshterm {__version__}", highlight=False)
|
||||
raise typer.Exit(exitcodes.OK)
|
||||
|
||||
|
||||
# The context built by the callback and consumed by subcommands within one process.
|
||||
_state: AppContext | None = None
|
||||
|
||||
@@ -93,6 +159,13 @@ _platform_resolution: Resolution | None = None
|
||||
@app.callback(invoke_without_command=True)
|
||||
def main_callback(
|
||||
ctx: typer.Context,
|
||||
version: bool = typer.Option(
|
||||
False,
|
||||
"--version",
|
||||
help="Print the version and exit",
|
||||
callback=_print_version,
|
||||
is_eager=True,
|
||||
),
|
||||
profile: str | None = typer.Option(None, "--profile", "-p", help="Device profile"),
|
||||
port: str | None = typer.Option(None, "--port", help="Serial port override"),
|
||||
ble: str | None = typer.Option(
|
||||
@@ -106,9 +179,19 @@ def main_callback(
|
||||
"--tcp",
|
||||
help="Network address host[:port] of a TCP companion (selects the TCP transport)",
|
||||
),
|
||||
spi: bool = typer.Option(
|
||||
False,
|
||||
"--spi",
|
||||
help="Run a node on the LoRa radio on this machine's SPI bus (selects the SPI transport)",
|
||||
),
|
||||
mock: bool = typer.Option(False, "--mock", help="Use the built-in simulator"),
|
||||
db_path: Path | None = typer.Option(None, "--db", help="SQLite database path"),
|
||||
db_path: Path | None = typer.Option(
|
||||
None, "--db", help="SQLite database path (--mock records to meshterm-mock.db)"
|
||||
),
|
||||
json_output: bool = typer.Option(False, "--json", help="Machine-readable output"),
|
||||
absolute: bool = typer.Option(
|
||||
False, "--absolute", help="Print absolute timestamps instead of relative ages"
|
||||
),
|
||||
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress console logging"),
|
||||
platform: str | None = typer.Option(
|
||||
None,
|
||||
@@ -120,14 +203,18 @@ def main_callback(
|
||||
|
||||
Args:
|
||||
ctx: The Click/Typer context.
|
||||
version: Print the version and exit (handled eagerly by :func:`_print_version`).
|
||||
profile: Named device profile to use.
|
||||
port: Explicit serial port, overriding the profile.
|
||||
ble: Explicit Bluetooth address, selecting the BLE transport.
|
||||
ble_pin: Optional BLE pairing PIN for the Bluetooth companion.
|
||||
tcp: Explicit network address ``host[:port]``, selecting the TCP transport.
|
||||
spi: Select the radio on this machine's SPI bus, whose node MeshTerm runs itself.
|
||||
mock: Whether to use the simulator instead of real hardware.
|
||||
db_path: Override the database location.
|
||||
json_output: Request machine-readable output from tools.
|
||||
db_path: Override the database location. Without it, a ``--mock`` run records to
|
||||
the simulator's own database rather than the real history.
|
||||
json_output: Print the answer as JSON instead of aligned text.
|
||||
absolute: Print times as ISO-8601 instants rather than relative ages, for this run.
|
||||
quiet: Suppress console logging (file logging continues).
|
||||
platform: Explicit ``--platform`` override; see :func:`meshterm.platforms.resolve`.
|
||||
"""
|
||||
@@ -148,21 +235,75 @@ def main_callback(
|
||||
active = without_emoji(active)
|
||||
set_platform(active)
|
||||
|
||||
named_radios = [
|
||||
flag
|
||||
for flag, given in (("--port", port), ("--ble", ble), ("--tcp", tcp), ("--spi", spi))
|
||||
if given
|
||||
]
|
||||
if spi and len(named_radios) > 1:
|
||||
# The SPI radio is on this machine and every other flag names a radio that isn't.
|
||||
raise typer.BadParameter(f"--spi and {named_radios[0]} name different devices; pass one")
|
||||
if mock and named_radios:
|
||||
# One flag says "pretend", the other names one exact radio, and `--mock` used to
|
||||
# win silently — so a scheduled `--port COM7 --mock info` reported on a simulator
|
||||
# while reading as though it had reached the radio. Two contradictory claims about
|
||||
# which device to talk to is a bad command line, not a precedence question.
|
||||
raise typer.BadParameter(f"--mock and {named_radios[0]} name different devices; pass one")
|
||||
|
||||
settings = Settings.load()
|
||||
if db_path is not None:
|
||||
settings.db_path = db_path
|
||||
elif mock:
|
||||
# The simulator is a fake radio, not a fake MeshTerm: everything it adverts was
|
||||
# written to the real history like anything a companion said, and its four
|
||||
# invented contacts then turned up in the mesh walk, the dashboard and the map of
|
||||
# the mesh you actually run. A pretend radio gets a pretend history —
|
||||
# ``<config_dir>/meshterm-mock.db``, created on first use — while ``--db`` still
|
||||
# wins, because naming a database is a deliberate claim about where a run records.
|
||||
# ``$MESHTERM_HOME`` remains the only thing that isolates a run whole: the contact
|
||||
# and channel caches, the outbox and the remembered devices live beside the
|
||||
# database either way (see docs/cli.md).
|
||||
settings.db_path = settings.config_dir / MOCK_DB_FILENAME
|
||||
|
||||
console = make_console()
|
||||
# Two front ends, two consoles, and which one this process gets is settled here — a
|
||||
# named subcommand is a scripted run, so it prints on the plain, colourless,
|
||||
# never-wrapping console the CLI's output language is written for (see
|
||||
# meshterm.ui.script); no subcommand means the menu, which needs the themed one.
|
||||
scripted = ctx.invoked_subcommand is not None
|
||||
console = script.console() if scripted else make_console()
|
||||
# Loaded before logging is configured, because how much goes in the file is one of
|
||||
# them — and handed to the context afterwards so the file is not read twice.
|
||||
prefs = Preferences.load(settings.config_dir / "preferences.yaml")
|
||||
prefs = Preferences.load(settings.config_dir / PREFERENCES_FILENAME)
|
||||
if absolute:
|
||||
# An override of the preference rather than a second switch beside it: *how
|
||||
# MeshTerm behaves* is a preference by this project's own rule, and a flag that
|
||||
# bypassed the registry would be a behaviour a reader could not find. Set, never
|
||||
# saved — it is a claim about this run.
|
||||
prefs.set("cli_time_format", "absolute")
|
||||
configure_logging(
|
||||
console,
|
||||
# A scripted run's stdout carries its answer and nothing else, so log records go
|
||||
# to stderr instead of interleaving with the data a caller is parsing.
|
||||
script.stderr_console() if scripted else console,
|
||||
settings.config_dir,
|
||||
level=logging.INFO,
|
||||
# And it only hears about faults. Every *expected* failure is already reported on
|
||||
# stderr in one line and in the exit status (see `_reported`), so echoing the same
|
||||
# thing again as a WARNING record would say it twice; the file keeps both either
|
||||
# way. The menu stays at INFO, where the log pane is the only place these show.
|
||||
level=logging.ERROR if scripted else logging.INFO,
|
||||
file_level=level_from_name(prefs.log_level),
|
||||
quiet=quiet or json_output,
|
||||
)
|
||||
# What the file held that no preference now takes — reported only now, since the file
|
||||
# was read before there was a log to say so in.
|
||||
report_dropped(prefs, get_logger())
|
||||
|
||||
if profile is not None and settings.resolve_profile(profile) is None:
|
||||
# Falling through to ordinary discovery would pick whichever radio is attached, so
|
||||
# a typo in a scheduled `--profile yagi config advert` transmits from the wrong
|
||||
# node rather than failing. Naming a profile is a claim about *which* device, and
|
||||
# an unkeepable claim is a bad argument.
|
||||
known = ", ".join(sorted(settings.profiles)) or "none are defined"
|
||||
raise typer.BadParameter(f"no device profile named {profile!r} (known: {known})")
|
||||
|
||||
app_ctx = AppContext(
|
||||
preferences=prefs,
|
||||
@@ -176,16 +317,26 @@ def main_callback(
|
||||
port_override=port,
|
||||
ble_override=ble,
|
||||
tcp_override=tcp,
|
||||
spi_override=spi,
|
||||
ble_pin=ble_pin,
|
||||
json_output=json_output,
|
||||
output=OutputFormat.JSON if json_output else OutputFormat.PLAIN,
|
||||
# Whether a tool may stop and ask, which is a different question from what its
|
||||
# output looks like. A pipe on stdin means nobody is there to answer.
|
||||
interactive=_stdin_is_a_person(),
|
||||
explicit_selection=(
|
||||
profile is not None or port is not None or ble is not None or tcp is not None
|
||||
profile is not None or port is not None or ble is not None or tcp is not None or spi
|
||||
),
|
||||
)
|
||||
_state = app_ctx
|
||||
ctx.call_on_close(app_ctx.repo.close)
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
if json_output:
|
||||
# There is no document an interactive session can emit, and launching the
|
||||
# full-screen menu for a caller that asked for JSON would hang whatever was
|
||||
# waiting to parse it.
|
||||
raise typer.BadParameter("--json needs a subcommand; the menu has no document")
|
||||
|
||||
from .ui.menu import run_menu
|
||||
|
||||
# Before prompt_toolkit takes the screen: this can change the console's own font,
|
||||
@@ -194,6 +345,17 @@ def main_callback(
|
||||
if _offer_a_console_that_can_draw_meshterm(console, prefs):
|
||||
return
|
||||
|
||||
from .services import consolefont as vt_console_font
|
||||
|
||||
# The PicoCalc's whole console shares one font, so asking for the 6x8 build changes
|
||||
# what the *shell* is drawn in too. Save the reader's font before the first paint
|
||||
# and put it back on the way out — from the `finally` here, and from an `atexit`
|
||||
# remember() arms, since ^Q and an unhandled error leave by other doors. This is
|
||||
# the menu's boundary only: a scripted run prints into a console it was invited
|
||||
# into and never touches its font.
|
||||
saved_font = vt_console_font.remember()
|
||||
vt_console_font.apply(prefs.get("console_font"))
|
||||
|
||||
# The interactive session is the one that holds the stores in memory and rewrites
|
||||
# them whole, so it is the one that claims the directory. One-shot subcommands are
|
||||
# brief and mostly read; blocking `meshterm contacts` because a menu is open in
|
||||
@@ -219,6 +381,8 @@ def main_callback(
|
||||
get_logger().exception("the interactive session raised an unhandled error")
|
||||
_report_log_location(console, settings.config_dir)
|
||||
raise
|
||||
finally:
|
||||
vt_console_font.restore(saved_font)
|
||||
|
||||
|
||||
def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences) -> bool:
|
||||
@@ -429,9 +593,9 @@ def _report_no_windows_console(console: Console) -> None:
|
||||
console.print()
|
||||
console.print("That usually means one of two things:")
|
||||
console.print()
|
||||
console.print(" • You are in Git Bash, MSYS or Cygwin. Those are not Windows consoles.")
|
||||
console.print(" Open [accent]Windows Terminal[/accent] or [accent]PowerShell[/accent]")
|
||||
console.print(" and run it from there.")
|
||||
console.print(" • You are in Git Bash, MSYS, or Cygwin, which are not Windows")
|
||||
console.print(" consoles. Open [accent]Windows Terminal[/accent] or")
|
||||
console.print(" [accent]PowerShell[/accent] and run it from there.")
|
||||
console.print(" • The output is piped or redirected somewhere. The full-screen menu")
|
||||
console.print(" needs the terminal itself.")
|
||||
console.print()
|
||||
@@ -439,7 +603,10 @@ def _report_no_windows_console(console: Console) -> None:
|
||||
console.print("try [accent]meshterm --help[/accent].")
|
||||
|
||||
|
||||
@app.command(name="platform")
|
||||
@app.command(
|
||||
name="platform",
|
||||
help="Show which platform MeshTerm runs as, and why",
|
||||
)
|
||||
def platform_command() -> None:
|
||||
"""Print the resolved platform and every input the resolution looked at.
|
||||
|
||||
@@ -452,26 +619,44 @@ def platform_command() -> None:
|
||||
the flavour and the usual reason a Windows session looks plainer than the screenshots
|
||||
(see :func:`meshterm.ui.termfont.emoji_support`).
|
||||
"""
|
||||
from .ui import fields
|
||||
from .ui.report import Facts
|
||||
|
||||
assert _platform_resolution is not None # set by the callback that always runs first
|
||||
assert _state is not None # ditto
|
||||
r = _platform_resolution
|
||||
console = make_console()
|
||||
console.print(f"platform: [accent]{r.platform.name}[/accent] (source: {r.source})")
|
||||
console.print(f" --platform: {r.flag or '(not passed)'}")
|
||||
console.print(f" MESHTERM_PLATFORM: {r.env or '(not set)'}")
|
||||
console.print(f" /proc/device-tree/model: {r.detected_model or '(unavailable)'}")
|
||||
|
||||
emoji = emoji_support()
|
||||
verdict = "yes" if emoji.supported else "no — icons use the compact glyphs"
|
||||
console.print(f"icons: [accent]{verdict}[/accent] (source: {emoji.source})")
|
||||
if not emoji.supported and emoji.source == "console":
|
||||
console.print(
|
||||
" this is the classic Windows console, which draws no emoji whatever font\n"
|
||||
" it is set to. Windows Terminal and VS Code's terminal both do — running\n"
|
||||
" MeshTerm in one gets the icons back. MESHTERM_EMOJI=1 overrides this."
|
||||
renderers.for_format(_state.output, _state.console).render(
|
||||
(
|
||||
Facts(
|
||||
key="platform",
|
||||
fields=(
|
||||
fields.word("platform", "platform"),
|
||||
fields.word("platform_source", "platform_source"),
|
||||
fields.word("flag", "flag"),
|
||||
fields.word("env", "env"),
|
||||
fields.word("device_tree_model", "device_tree_model"),
|
||||
fields.flag("icons", "icons"),
|
||||
fields.word("icons_source", "icons_source"),
|
||||
),
|
||||
values={
|
||||
"platform": r.platform.name,
|
||||
"platform_source": r.source,
|
||||
"flag": r.flag or None,
|
||||
"env": r.env or None,
|
||||
"device_tree_model": r.detected_model or None,
|
||||
"icons": emoji.supported,
|
||||
"icons_source": emoji.source,
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.command(name="specimen")
|
||||
@app.command(
|
||||
name="specimen",
|
||||
help="Print every mark, icon, and colour MeshTerm draws",
|
||||
)
|
||||
def specimen_command() -> None:
|
||||
"""Print the visual-language specimen for the active platform.
|
||||
|
||||
@@ -479,9 +664,26 @@ def specimen_command() -> None:
|
||||
theme and glyph machinery the TUI uses — so on the PicoCalc console it is the
|
||||
font/palette acceptance screen, and with ``--platform picocalc`` on a desktop it
|
||||
previews that flavour. See :mod:`meshterm.ui.specimen`.
|
||||
|
||||
The one command that keeps its colour, and it builds its own themed console to do it
|
||||
(rather than the plain one every other subcommand prints on — see
|
||||
:mod:`meshterm.ui.script`). This is not scripted output that happens to be pretty: the
|
||||
colour *is* the output. A monochrome specimen would test nothing.
|
||||
|
||||
It is also the one command that *refuses* ``--json``, loudly and as a usage error.
|
||||
There is no data behind a colour card, so a document of it would be either a lie or an
|
||||
empty gesture — and a loud refusal is the same answer this project already gives for
|
||||
the map, the dashboard and the live feed, which simply have no subcommand to refuse
|
||||
from.
|
||||
"""
|
||||
from .ui.specimen import specimen_lines
|
||||
|
||||
assert _state is not None # set by the callback that always runs first
|
||||
if _state.output is not OutputFormat.PLAIN:
|
||||
raise typer.BadParameter(
|
||||
"specimen has no machine-readable output — its output is the colour"
|
||||
)
|
||||
|
||||
console = make_console()
|
||||
for line in specimen_lines():
|
||||
console.print(line)
|
||||
@@ -499,44 +701,119 @@ def run_tool_command(tool: Tool, params: dict) -> None:
|
||||
"""
|
||||
assert _state is not None # set by the callback before any subcommand runs
|
||||
try:
|
||||
asyncio.run(_drive(_execute_and_render(tool, params, _state), _state))
|
||||
except (DeviceSelectionError, DeviceConfigError, DeviceCommandError, PreferenceError) as exc:
|
||||
# Expected user-facing error (ambiguous/absent device, a bad config or preference
|
||||
# value, or a transient command failure): show the message, not a traceback. Logged
|
||||
# at warning because it is a thing that went wrong, even though it is an ordinary
|
||||
# one — a log that only holds crashes cannot answer "what happened before it".
|
||||
get_logger().warning("%s failed: %s", tool.name, exc)
|
||||
_state.console.print(f"[err]✗[/err] {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
result = asyncio.run(_drive(_execute_and_render(tool, params, _state), _state))
|
||||
except DeviceSelectionError as exc:
|
||||
# No companion could be picked at all: nothing was transmitted, and no retry is
|
||||
# going to help until the caller attaches a device or names one.
|
||||
raise _reported(tool, exc, exitcodes.NO_DEVICE) from exc
|
||||
except DeviceCommandError as exc:
|
||||
# A device was reached and the operation failed. Worth retrying.
|
||||
raise _reported(tool, exc, exitcodes.DEVICE) from exc
|
||||
except (DeviceConfigError, PreferenceError) as exc:
|
||||
# A value we were given is wrong — a bad setting key, an out-of-range preference.
|
||||
# Retrying changes nothing; the caller has to change what it asked for.
|
||||
raise _reported(tool, exc, exitcodes.FAILURE) from exc
|
||||
except (typer.BadParameter, typer.Abort, typer.Exit):
|
||||
# A tool that rejected one of its own arguments (an unresolvable `--to`, a
|
||||
# malformed path) raised the same thing the parser would have. It is a usage
|
||||
# error, Click already knows how to print it and what to exit with, and it is not
|
||||
# a fault — so it passes through without a traceback or a pointer to the log.
|
||||
raise
|
||||
except Exception as exc:
|
||||
# A dropped serial link (device unplugged/powered off mid-command) can't be recovered
|
||||
# from in a one-shot scripted run the way the interactive menu offers — but it should
|
||||
# still read as a clean message, not a traceback.
|
||||
# A dropped serial link (device unplugged/powered off mid-command) can't be
|
||||
# recovered from in a one-shot scripted run the way the interactive menu offers —
|
||||
# but it reads as a clean message, not a traceback, and as a device failure.
|
||||
if is_connection_lost(exc):
|
||||
get_logger().warning("%s lost the device connection: %s", tool.name, exc)
|
||||
_state.console.print(
|
||||
"[err]✗[/err] the connection to your device was lost "
|
||||
"(it may have been unplugged or powered off)."
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
raise _reported(
|
||||
tool,
|
||||
"the connection to the device was lost (it may have been unplugged or powered off)",
|
||||
exitcodes.DEVICE,
|
||||
) from exc
|
||||
# Anything else is a real fault. It still reaches the terminal as a traceback,
|
||||
# because a person looking at one wants to see it — but it also lands in the log,
|
||||
# which is the copy that survives the terminal being closed and is the one thing
|
||||
# worth attaching to a bug report.
|
||||
get_logger().exception("%s raised an unhandled error", tool.name)
|
||||
_report_log_location(_state.console, _state.settings.config_dir)
|
||||
_report_log_location(script.stderr_console(), _state.settings.config_dir)
|
||||
raise
|
||||
if result.exit_code != exitcodes.OK:
|
||||
raise typer.Exit(result.exit_code)
|
||||
|
||||
|
||||
async def _drive(coro, ctx: AppContext) -> None:
|
||||
def _stdin_is_a_person() -> bool:
|
||||
"""Whether somebody is there to answer a prompt.
|
||||
|
||||
The test a tool actually wants before it stops and asks: a redirected or piped stdin
|
||||
means nobody is watching, and a command that blocks there has hung rather than failed.
|
||||
``tx-optimize`` used to ask ``--json`` instead — a flag about output format, which
|
||||
answers a different question and answered this one wrong in both directions.
|
||||
|
||||
``isatty`` alone is not enough on Windows, and the gap is exactly the case this exists
|
||||
for. ``NUL`` is a *character device*, so a scheduled task's empty stdin answers yes —
|
||||
and :func:`getpass.getpass` on Windows then reads the console directly through
|
||||
``msvcrt`` rather than through stdin, and waits forever for a keypress nobody is there
|
||||
to make. ``GetConsoleMode`` succeeds only on a real console handle, which is the
|
||||
question being asked.
|
||||
|
||||
Returns:
|
||||
``True`` only where stdin is a terminal somebody could type into.
|
||||
"""
|
||||
try:
|
||||
if not sys.stdin.isatty():
|
||||
return False
|
||||
except (AttributeError, ValueError): # pragma: no cover - stdin closed or replaced
|
||||
return False
|
||||
if sys.platform != "win32":
|
||||
return True
|
||||
try: # pragma: no cover - the branch is Windows-only and needs a real handle
|
||||
import ctypes
|
||||
import msvcrt
|
||||
|
||||
mode = ctypes.c_uint()
|
||||
handle = msvcrt.get_osfhandle(sys.stdin.fileno())
|
||||
return bool(win32dll.kernel32().GetConsoleMode(handle, ctypes.byref(mode)))
|
||||
except Exception: # pragma: no cover - no console, or a stubbed kernel32
|
||||
return False
|
||||
|
||||
|
||||
def _reported(tool: Tool, problem: object, code: int) -> typer.Exit:
|
||||
"""Log a failure, say so on stderr, and build the exit it should end on.
|
||||
|
||||
The message goes to stderr in the ``program: what went wrong`` shape every utility
|
||||
uses, so a caller redirecting stdout still sees it and a caller parsing stdout never
|
||||
has to filter it out. It carries no mark and no colour — the exit status is what says
|
||||
this failed, which is why it is classified (see :mod:`meshterm.core.exitcodes`).
|
||||
|
||||
It is logged at warning rather than error because these are the *ordinary* failures —
|
||||
an absent device, a wrong password — and a log holding only crashes cannot answer
|
||||
"what happened just before it".
|
||||
|
||||
Args:
|
||||
tool: The tool that failed, for the log line.
|
||||
problem: The exception or message to report.
|
||||
code: The status to exit with.
|
||||
|
||||
Returns:
|
||||
The :class:`typer.Exit` for the caller to raise.
|
||||
"""
|
||||
get_logger().warning("%s failed: %s", tool.name, problem)
|
||||
script.stderr_console().print(f"meshterm: {problem}", style="err", highlight=False)
|
||||
return typer.Exit(code)
|
||||
|
||||
|
||||
async def _drive(coro, ctx: AppContext): # noqa: ANN201 - passes the awaited value through
|
||||
"""Await a coroutine then disconnect the device (but keep the repo open).
|
||||
|
||||
Args:
|
||||
coro: The coroutine to run (a tool execution or the menu loop).
|
||||
ctx: The application context whose device should be closed afterward.
|
||||
|
||||
Returns:
|
||||
Whatever ``coro`` returned — a :class:`~meshterm.tools.base.ToolResult` on the
|
||||
scripted path, whose ``exit_code`` the caller turns into the process status.
|
||||
"""
|
||||
try:
|
||||
await coro
|
||||
return await coro
|
||||
finally:
|
||||
if ctx._device is not None:
|
||||
try:
|
||||
@@ -547,7 +824,18 @@ async def _drive(coro, ctx: AppContext) -> None:
|
||||
|
||||
|
||||
async def _execute_and_render(tool: Tool, params: dict, ctx: AppContext) -> ToolResult:
|
||||
"""Run a tool and print its closing message and artifact list.
|
||||
"""Run a tool, render the answer it stated, and close with what it has to say.
|
||||
|
||||
The one place a report becomes output. The tool says what happened as data and this
|
||||
hands it to whichever renderer ``--json`` selected, exactly as ``exit_code`` is stated
|
||||
here and turned into a process status by the caller — which is what lets a third format
|
||||
be a new renderer rather than an edit to twenty tools.
|
||||
|
||||
A tool's ``message`` — "✓ applied 3 changes", "✓ traced Alice" — goes to **stderr**,
|
||||
like every other acknowledgement (see :meth:`~meshterm.ui.surface.PlainUi.ack`). It is
|
||||
the closing line rather than the answer, so it must stay out of a redirect; it is also
|
||||
the count a person at a prompt actually wanted after a command that scrolled, so
|
||||
dropping it was throwing it away to protect a stream it was never going to reach.
|
||||
|
||||
Args:
|
||||
tool: The tool to execute.
|
||||
@@ -558,10 +846,9 @@ async def _execute_and_render(tool: Tool, params: dict, ctx: AppContext) -> Tool
|
||||
The tool's :class:`ToolResult`.
|
||||
"""
|
||||
result = await tool.execute(ctx, params)
|
||||
renderers.for_format(ctx.output, ctx.console).render(result.report)
|
||||
if result.message:
|
||||
ctx.console.print(result.message)
|
||||
for artifact in result.artifacts:
|
||||
ctx.console.print(f"[ok]●[/ok] wrote [accent]{artifact}[/accent]")
|
||||
ctx.ui.ack(result.message)
|
||||
return result
|
||||
|
||||
|
||||
@@ -608,7 +895,11 @@ def main() -> None:
|
||||
run from a shell leaves its output on the screen anyway.
|
||||
"""
|
||||
try:
|
||||
app()
|
||||
# No wildcard expansion on Windows. Click expands an argument holding `*` into
|
||||
# the files it matches — even one the shell passed through quoted, which it can't
|
||||
# tell apart — so `chat send --scope '*'` arrived as the working directory's file
|
||||
# names. No MeshTerm argument is a file glob, and `*` is the wildcard region.
|
||||
app(windows_expand_args=False)
|
||||
except SystemExit as exit_request:
|
||||
if exit_request.code and _owns_its_console():
|
||||
_wait_before_the_window_closes()
|
||||
|
||||
+231
-25
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The application context: a small dependency container passed to every tool.
|
||||
|
||||
``AppContext`` owns the shared, expensive singletons (console, settings, repository,
|
||||
@@ -7,6 +8,7 @@ never open a serial port.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -15,27 +17,30 @@ from rich.console import Console
|
||||
from .core.admin_store import AdminStore
|
||||
from .core.advert_store import AdvertStore
|
||||
from .core.channel_store import ChannelStore
|
||||
from .core.config import DeviceProfile, Settings
|
||||
from .core.config import DeviceProfile, Settings, SpiWiring
|
||||
from .core.connection import Device, make_device
|
||||
from .core.contact_store import ContactStore
|
||||
from .core.courier_store import CourierStore
|
||||
from .core.device_store import DeviceStore
|
||||
from .core.discovery import DiscoveredDevice, discover_devices
|
||||
from .core.mute_store import MuteStore
|
||||
from .core.preferences import Preferences
|
||||
from .core.preferences import PREFERENCES_FILENAME, Preferences, report_dropped
|
||||
from .core.preferences import install as install_preferences
|
||||
from .core.region_store import RegionStore
|
||||
from .core.remote_store import RemoteStore
|
||||
from .core.selection import resolve_device
|
||||
from .core.settings_store import SettingsStore
|
||||
from .core.watch_store import WatchStore
|
||||
from .persistence.logging import get_logger
|
||||
from .persistence.repository import Repository
|
||||
from .ui.renderers import OutputFormat
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .services.advert_scheduler import AdvertScheduler
|
||||
from .services.basemap import BasemapSource
|
||||
from .services.battery_service import BatteryService
|
||||
from .services.chat_service import ChatService
|
||||
from .services.clock_sync import ClockSync
|
||||
from .services.courier import CourierService
|
||||
from .services.device_state import DeviceState
|
||||
from .services.event_hub import EventHub
|
||||
@@ -54,7 +59,7 @@ class AppContext:
|
||||
talk to (config directory, database, named profiles). *How MeshTerm behaves*
|
||||
is ``preferences``, not this.
|
||||
preferences: MeshTerm's own preferences, backed by
|
||||
``<config_dir>/preferences.yaml`` and edited on the Preferences page. Defaults
|
||||
``<config_dir>/preferences.toml`` and edited on the Preferences page. Defaults
|
||||
to a set loaded from that file when not injected, and is installed as the
|
||||
process-wide :func:`~meshterm.core.preferences.current` set so the render
|
||||
layer — which has no context to reach through — reads the same values.
|
||||
@@ -73,6 +78,9 @@ class AppContext:
|
||||
mute_store: Store for muted channel notifications — the channels whose new
|
||||
messages don't raise the unread badge (defaults to
|
||||
``<config_dir>/mutes.json`` when not injected).
|
||||
region_store: Store for the regions known by name and each channel's send scope —
|
||||
what a scoped flood is resolved against (defaults to
|
||||
``<config_dir>/regions.json`` when not injected).
|
||||
channel_store: Store for channels created through MeshTerm, replayed into a device
|
||||
that forgot them (a firmware-less radio bridge). Keyed by device public key;
|
||||
defaults to ``<config_dir>/channels.json`` when not injected.
|
||||
@@ -91,7 +99,16 @@ class AppContext:
|
||||
interactive picker), selecting the TCP transport. Takes precedence over
|
||||
``ble_override`` and ``port_override``.
|
||||
ble_pin: Optional BLE pairing PIN for the chosen Bluetooth device.
|
||||
json_output: Whether tools should emit machine-readable output.
|
||||
output: Which format a scripted run prints its answer in (see
|
||||
:class:`~meshterm.ui.renderers.OutputFormat`). A tool never reads this to
|
||||
decide *what* to say — it states its answer as a
|
||||
:class:`~meshterm.ui.report.Report` and the CLI boundary picks the renderer —
|
||||
and the two streaming commands read it only to open the right kind of stream.
|
||||
interactive: Whether this run may stop and ask a person something. False for every
|
||||
scripted run whose stdin is not a terminal, which is the question a tool
|
||||
actually wants answered before it prompts. ``tx-optimize`` used to ask
|
||||
``--json`` instead, and a flag about *output format* cannot answer it: without
|
||||
the flag, a scheduled run on a terminal would sit waiting for a password.
|
||||
selected_device: The discovered device chosen for this session, when known, so it
|
||||
can be remembered after a successful connection.
|
||||
explicit_selection: Whether ``--port``/``--ble``/``--profile`` was passed explicitly
|
||||
@@ -109,6 +126,7 @@ class AppContext:
|
||||
watch_store: WatchStore | None = None
|
||||
courier_store: CourierStore | None = None
|
||||
mute_store: MuteStore | None = None
|
||||
region_store: RegionStore | None = None
|
||||
channel_store: ChannelStore | None = None
|
||||
settings_store: SettingsStore | None = None
|
||||
contact_store: ContactStore | None = None
|
||||
@@ -117,8 +135,10 @@ class AppContext:
|
||||
port_override: str | None = None
|
||||
ble_override: str | None = None
|
||||
tcp_override: str | None = None
|
||||
spi_override: bool = False
|
||||
ble_pin: str | None = None
|
||||
json_output: bool = False
|
||||
output: OutputFormat = OutputFormat.PLAIN
|
||||
interactive: bool = True
|
||||
selected_device: DiscoveredDevice | None = None
|
||||
explicit_selection: bool = False
|
||||
_device: Device | None = field(default=None, init=False, repr=False)
|
||||
@@ -132,17 +152,22 @@ class AppContext:
|
||||
#: the older handle no longer names it. Typed ``object`` so ``bleak`` stays optional.
|
||||
_ble_handle: object | None = field(default=None, init=False, repr=False)
|
||||
unpair_on_exit: bool = field(default=False, init=False, repr=False)
|
||||
#: Set by the config editor just before it sends a reboot command, so the session's
|
||||
#: disconnect watcher can label the ensuing (expected) link drop as a reboot in
|
||||
#: progress rather than a surprise unplug. Cleared by the reconnect dialog that
|
||||
#: consumes it (see :func:`meshterm.ui.menu._handle_disconnect`).
|
||||
#: Set by :meth:`announce_reboot` once a reboot command has gone out, so the reconnect
|
||||
#: dialog presents the drop as a reboot in progress rather than a surprise unplug.
|
||||
#: Cleared by :meth:`take_reboot` in the dialog that consumes it (see
|
||||
#: :func:`meshterm.ui.menu._handle_disconnect`).
|
||||
reboot_in_progress: bool = field(default=False, init=False, repr=False)
|
||||
#: Set alongside :attr:`reboot_in_progress`: wakes the session's disconnect watcher at
|
||||
#: once, without waiting for a liveness poll to *observe* the drop — which it may never
|
||||
#: do, since a board behind a USB-UART bridge keeps its port through a reboot.
|
||||
_link_going_down: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False)
|
||||
_resume_intent: tuple[bool, bool, bool] | None = field(default=None, init=False, repr=False)
|
||||
_events: EventHub | None = field(default=None, init=False, repr=False)
|
||||
_monitor: MonitorService | None = field(default=None, init=False, repr=False)
|
||||
_chat: ChatService | None = field(default=None, init=False, repr=False)
|
||||
_adverts: AdvertScheduler | None = field(default=None, init=False, repr=False)
|
||||
_watchtower: WatchtowerService | None = field(default=None, init=False, repr=False)
|
||||
_clock_sync: ClockSync | None = field(default=None, init=False, repr=False)
|
||||
_courier: CourierService | None = field(default=None, init=False, repr=False)
|
||||
_battery: BatteryService | None = field(default=None, init=False, repr=False)
|
||||
_devstate: DeviceState | None = field(default=None, init=False, repr=False)
|
||||
@@ -157,7 +182,8 @@ class AppContext:
|
||||
stores built without a context still have to read them.
|
||||
"""
|
||||
if self.preferences is None:
|
||||
self.preferences = Preferences.load(self.settings.config_dir / "preferences.yaml")
|
||||
self.preferences = Preferences.load(self.settings.config_dir / PREFERENCES_FILENAME)
|
||||
report_dropped(self.preferences, self.log)
|
||||
install_preferences(self.preferences)
|
||||
if self.advert_store is None:
|
||||
self.advert_store = AdvertStore(self.settings.config_dir / "adverts.json")
|
||||
@@ -169,6 +195,8 @@ class AppContext:
|
||||
self.courier_store = CourierStore(self.settings.config_dir / "courier.json")
|
||||
if self.mute_store is None:
|
||||
self.mute_store = MuteStore(self.settings.config_dir / "mutes.json")
|
||||
if self.region_store is None:
|
||||
self.region_store = RegionStore(self.settings.config_dir / "regions.json")
|
||||
if self.channel_store is None:
|
||||
self.channel_store = ChannelStore(self.settings.config_dir / "channels.json")
|
||||
if self.settings_store is None:
|
||||
@@ -195,7 +223,7 @@ class AppContext:
|
||||
serial port). Falls back to an explicit ``--port`` override when a connection hasn't
|
||||
recorded one yet.
|
||||
"""
|
||||
if self.mock or self.active_transport in ("ble", "tcp"):
|
||||
if self.mock or self.active_transport in ("ble", "tcp", "spi"):
|
||||
return None
|
||||
return self._active_port or self.port_override
|
||||
|
||||
@@ -227,15 +255,17 @@ class AppContext:
|
||||
def active_transport(self) -> str | None:
|
||||
"""Which transport the current or selected connection uses.
|
||||
|
||||
One of ``"serial"``, ``"ble"``, ``"tcp"``, or ``None`` for the simulator. For a
|
||||
real device it reflects the open connection when one exists, otherwise the
|
||||
One of ``"serial"``, ``"ble"``, ``"tcp"``, ``"spi"``, or ``None`` for the simulator.
|
||||
For a real device it reflects the open connection when one exists, otherwise the
|
||||
transport implied by the pending selection (an explicit ``--tcp`` selects TCP,
|
||||
``--ble`` selects BLE), defaulting to serial.
|
||||
``--ble`` selects BLE, ``--spi`` the SPI radio), defaulting to serial.
|
||||
"""
|
||||
if self.mock:
|
||||
return None
|
||||
if self._active_transport is not None:
|
||||
return self._active_transport
|
||||
if self.spi_override or (self.profile is not None and self.profile.is_spi):
|
||||
return "spi"
|
||||
if self.tcp_override:
|
||||
return "tcp"
|
||||
return "ble" if self.ble_override else "serial"
|
||||
@@ -257,6 +287,34 @@ class AppContext:
|
||||
except Exception: # noqa: BLE001 - a liveness-check failure must not fake a disconnect
|
||||
return True
|
||||
|
||||
def announce_reboot(self) -> None:
|
||||
"""Declare the link down because we just told the companion to reboot.
|
||||
|
||||
Called *after* the reboot command was sent (never before: the watcher it wakes
|
||||
cancels the screen that sent it, and a cancel mid-write could leave the command half
|
||||
out). From then on the device is not ours to talk to until it comes back, so the
|
||||
session's disconnect watcher (:meth:`link_going_down`) fires at once and the
|
||||
reconnect dialog takes over — rather than hoping a liveness poll catches the drop,
|
||||
which misses a quick reboot entirely where the serial port never goes away.
|
||||
"""
|
||||
self.reboot_in_progress = True
|
||||
self._link_going_down.set()
|
||||
|
||||
async def link_going_down(self) -> None:
|
||||
"""Resolve once :meth:`announce_reboot` has declared the link down."""
|
||||
await self._link_going_down.wait()
|
||||
|
||||
def take_reboot(self) -> bool:
|
||||
"""Consume a pending reboot announcement, returning whether there was one.
|
||||
|
||||
Clears both halves, so a later, genuine disconnect goes back to the generic
|
||||
wording and the next watcher waits for a real drop again.
|
||||
"""
|
||||
pending = self.reboot_in_progress
|
||||
self.reboot_in_progress = False
|
||||
self._link_going_down.clear()
|
||||
return pending
|
||||
|
||||
@property
|
||||
def ui(self) -> Ui:
|
||||
"""The active UI surface, defaulting to the plain console surface for the CLI.
|
||||
@@ -346,6 +404,21 @@ class AppContext:
|
||||
self._watchtower = WatchtowerService(self)
|
||||
return self._watchtower
|
||||
|
||||
@property
|
||||
def clock_sync(self) -> ClockSync:
|
||||
"""Return the session's on-connect clock setter, creating it on first use.
|
||||
|
||||
Created idle here; the interactive session starts it alongside the other
|
||||
always-on services, and :meth:`device` tells it about each connection as it
|
||||
settles. Scripted CLI runs never start it, so a one-shot command that only reads
|
||||
the radio never writes its clock.
|
||||
"""
|
||||
if self._clock_sync is None:
|
||||
from .services.clock_sync import ClockSync
|
||||
|
||||
self._clock_sync = ClockSync(self)
|
||||
return self._clock_sync
|
||||
|
||||
@property
|
||||
def courier(self) -> CourierService:
|
||||
"""Return the session's store-and-forward courier, creating it on first use.
|
||||
@@ -439,6 +512,31 @@ class AppContext:
|
||||
getattr(device, "endpoint", None) if (self._active_transport == "tcp") else None
|
||||
)
|
||||
|
||||
async def _open(self, what: str) -> None:
|
||||
"""Open :attr:`_device`, reporting a failure to open as a *selection* failure.
|
||||
|
||||
The two device exit statuses answer different questions — ``NO_DEVICE`` says
|
||||
nothing was transmitted and the caller should look at what is plugged in;
|
||||
``DEVICE`` says the radio was reached and the operation failed, so retrying is
|
||||
reasonable. Every failure on this path is the first kind: the connection does not
|
||||
exist yet, so nothing has been sent over it. It used to come back as ``DEVICE``
|
||||
with the message "the connection to the device was lost", which describes a
|
||||
connection there had never been — and ``--port NOSUCHPORT`` is the most common way
|
||||
to reach this line.
|
||||
|
||||
Args:
|
||||
what: The thing being opened, for the message (a port, an address, a host).
|
||||
|
||||
Raises:
|
||||
DeviceSelectionError: If the connection cannot be established.
|
||||
"""
|
||||
from .core.selection import DeviceSelectionError
|
||||
|
||||
try:
|
||||
await self._device.connect()
|
||||
except Exception as exc: # noqa: BLE001 - reclassified, then re-raised
|
||||
raise DeviceSelectionError(f"could not open {what}: {exc}") from exc
|
||||
|
||||
async def device(self) -> Device:
|
||||
"""Return a connected :class:`Device`, opening the connection on first use.
|
||||
|
||||
@@ -462,7 +560,28 @@ class AppContext:
|
||||
self._active_port = None
|
||||
self._active_address = None
|
||||
self._active_endpoint = None
|
||||
await self._device.connect()
|
||||
await self._open("the simulator")
|
||||
return self._device
|
||||
|
||||
# A radio on the host's own SPI bus (``--spi``, an SPI profile, or the remembered
|
||||
# default) has nothing to find: MeshTerm starts the node that answers on it.
|
||||
spi = self.resolve_spi()
|
||||
if spi is not None:
|
||||
from .core.spiradio import state_dir
|
||||
|
||||
self._device = make_device(
|
||||
mock=False,
|
||||
port=None,
|
||||
transport="spi",
|
||||
spi=spi,
|
||||
state=state_dir(self.settings.config_dir, spi),
|
||||
)
|
||||
self._active_transport = "spi"
|
||||
self._active_endpoint = spi.spidev
|
||||
self._active_port = None
|
||||
self._active_address = None
|
||||
await self._open(f"SPI radio {spi.spidev}")
|
||||
await self._settle_connection()
|
||||
return self._device
|
||||
|
||||
# A network endpoint (an explicit ``--tcp``, a TCP profile, a device picked at startup,
|
||||
@@ -477,9 +596,8 @@ class AppContext:
|
||||
self._active_endpoint = f"{tcp_host}:{tcp_port}"
|
||||
self._active_port = None
|
||||
self._active_address = None
|
||||
await self._device.connect()
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
await self._open(f"TCP companion {tcp_host}:{tcp_port}")
|
||||
await self._settle_connection()
|
||||
return self._device
|
||||
|
||||
# A Bluetooth endpoint (an explicit ``--ble``, a BLE profile, a device picked at
|
||||
@@ -511,9 +629,8 @@ class AppContext:
|
||||
self._active_address = ble_address
|
||||
self._active_port = None
|
||||
self._active_endpoint = None
|
||||
await self._device.connect()
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
await self._open(f"BLE companion {ble_address}")
|
||||
await self._settle_connection()
|
||||
return self._device
|
||||
|
||||
resolution = resolve_device(
|
||||
@@ -530,9 +647,8 @@ class AppContext:
|
||||
self._active_port = resolution.port
|
||||
self._active_address = None
|
||||
self._active_endpoint = None
|
||||
await self._device.connect()
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
await self._open(f"serial port {resolution.port}")
|
||||
await self._settle_connection()
|
||||
return self._device
|
||||
|
||||
def _resolve_tcp_endpoint(self) -> tuple[str | None, int | None]:
|
||||
@@ -563,8 +679,10 @@ class AppContext:
|
||||
explicit_other = (
|
||||
bool(self.port_override)
|
||||
or bool(self.ble_override)
|
||||
or self.spi_override
|
||||
or (
|
||||
self.profile is not None and (bool(self.profile.port) or bool(self.profile.address))
|
||||
self.profile is not None
|
||||
and (bool(self.profile.port) or bool(self.profile.address) or self.profile.is_spi)
|
||||
)
|
||||
)
|
||||
if explicit_other:
|
||||
@@ -579,6 +697,79 @@ class AppContext:
|
||||
return host, port
|
||||
return None, None
|
||||
|
||||
def resolve_spi(self) -> SpiWiring | None:
|
||||
"""The wiring of the SPI radio to open, or ``None`` when another transport is meant.
|
||||
|
||||
In priority order: an SPI profile, the radio picked on the startup splash, ``--spi``
|
||||
(the one radio attached — see :meth:`_spi_for_flag`), then the remembered default when it
|
||||
was an SPI radio and nothing else was named. Anything else named explicitly — a port,
|
||||
an address, a host, another kind of profile — means this session is not about the SPI
|
||||
radio.
|
||||
"""
|
||||
if self.profile is not None and self.profile.is_spi:
|
||||
return self.profile.spi or SpiWiring()
|
||||
chosen = self.selected_device
|
||||
if chosen is not None and chosen.is_spi:
|
||||
return chosen.spi or self.spi_wiring_for(chosen.port) # type: ignore[return-value]
|
||||
if self.spi_override:
|
||||
return self._spi_for_flag()
|
||||
if self.port_override or self.ble_override or self.tcp_override or self.profile:
|
||||
return None
|
||||
remembered = self.device_store.load()
|
||||
if remembered is None or remembered.transport != "spi" or not remembered.target:
|
||||
return None
|
||||
self.selected_device = None # remembered, not freshly discovered this session
|
||||
return self.spi_wiring_for(remembered.target)
|
||||
|
||||
def _spi_for_flag(self) -> SpiWiring:
|
||||
"""The radio a bare ``--spi`` means: the one there is, and never a guess between two.
|
||||
|
||||
Nearly everyone with a radio on the SPI bus has exactly one, and for them ``--spi``
|
||||
needs nothing more — attached radios are counted first, so a single one is used
|
||||
whether or not a profile names it. Only when two are attached, or (with none
|
||||
attached to count) two SPI profiles are configured, is the flag ambiguous; it used
|
||||
to take whichever profile ``config.toml`` listed first, which is a coin toss that
|
||||
transmits. With none attached and one profile or none, the choice is made from the
|
||||
configuration, so the connect attempt can say what is missing.
|
||||
|
||||
Raises:
|
||||
DeviceSelectionError: When more than one radio could be meant.
|
||||
"""
|
||||
from .core.selection import DeviceSelectionError
|
||||
from .core.spiradio import spi_radios
|
||||
|
||||
profiles = self.settings.profiles
|
||||
attached = spi_radios(profiles)
|
||||
if len(attached) == 1:
|
||||
return attached[0].spi # type: ignore[return-value]
|
||||
configured = [p for p in profiles.values() if p.is_spi]
|
||||
if not attached and len(configured) <= 1:
|
||||
return (configured[0].spi or SpiWiring()) if configured else SpiWiring()
|
||||
if attached:
|
||||
rows = [f" {d.name or '(no profile)':<16} {d.port}" for d in attached]
|
||||
what = "Several radios are attached to the SPI bus"
|
||||
else:
|
||||
rows = [f" {p.name:<16} {(p.spi or SpiWiring()).spidev}" for p in configured]
|
||||
what = "Several SPI profiles are configured"
|
||||
raise DeviceSelectionError(
|
||||
f"{what}, and --spi doesn't say which.\n"
|
||||
+ "\n".join(rows)
|
||||
+ "\nChoose one with -p <PROFILE>. A radio with no profile needs one first — see "
|
||||
"'Adding a radio on the SPI bus' in docs/configuration.md."
|
||||
)
|
||||
|
||||
def spi_wiring_for(self, spidev: str) -> SpiWiring:
|
||||
"""The wiring for the radio on ``spidev``: a profile's that names it, else the AIO's.
|
||||
|
||||
A remembered or listed SPI radio is known by its device node alone, so its pins come
|
||||
from whichever SPI profile is on that node; with none, the defaults are the only
|
||||
wiring there is (and the right one for the board they describe).
|
||||
"""
|
||||
for profile in self.settings.profiles.values():
|
||||
if profile.is_spi and (profile.spi or SpiWiring()).spidev == spidev:
|
||||
return profile.spi or SpiWiring()
|
||||
return SpiWiring()
|
||||
|
||||
def _resolve_ble_endpoint(self) -> tuple[str | None, str | None]:
|
||||
"""Return the ``(address, pin)`` to open over Bluetooth, or ``(None, None)`` for serial.
|
||||
|
||||
@@ -619,6 +810,19 @@ class AppContext:
|
||||
hardware_model=await self._hardware_model(),
|
||||
)
|
||||
|
||||
async def _settle_connection(self) -> None:
|
||||
"""The steps every fresh connection gets once the link is open, in order.
|
||||
|
||||
Remember the device as the default, replay its remembered channels, and hand it to
|
||||
the on-connect clock setter — which returns at once and does its work in the
|
||||
background, so the connection is usable the moment this returns. Each step is
|
||||
best-effort on its own; none can fail the connection.
|
||||
"""
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
if self._device is not None:
|
||||
self.clock_sync.on_connected(self._device)
|
||||
|
||||
async def _reconcile_channels(self) -> None:
|
||||
"""Replay channels remembered for this device that it isn't reporting (best-effort).
|
||||
|
||||
@@ -755,6 +959,8 @@ class AppContext:
|
||||
await self._battery.aclose()
|
||||
if self._watchtower is not None:
|
||||
await self._watchtower.aclose()
|
||||
if self._clock_sync is not None:
|
||||
await self._clock_sync.aclose()
|
||||
if self._adverts is not None:
|
||||
await self._adverts.aclose()
|
||||
if self._monitor is not None:
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Core domain layer: models, configuration, and the device connection abstraction."""
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for remembered remote-node admin passwords.
|
||||
|
||||
The remote-admin optimizer logs in to a repeater to tune its TX power; storing the
|
||||
@@ -6,6 +7,10 @@ password means the user isn't prompted on every run. Like the remembered device
|
||||
directory (``<config_dir>/admin.json``) rather than the per-invocation SQLite database,
|
||||
because admin credentials are global machine state.
|
||||
|
||||
The file is read into :class:`AdminCredential` records and written back out of them,
|
||||
never round-tripped as the raw JSON it was read as, so a field no record knows is gone
|
||||
after the next write (see :data:`AdminCredential.RETIRED`).
|
||||
|
||||
Passwords are stored in plaintext, so the file is written with owner-only permissions
|
||||
where the platform supports it. This is a local operator tool tuning their own mesh, not
|
||||
a multi-user secret store; treat the file accordingly.
|
||||
@@ -16,6 +21,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .models import Contact, LoginResult, utcnow
|
||||
@@ -42,7 +48,8 @@ class AdminCredential:
|
||||
"""A remembered admin password for one remote node.
|
||||
|
||||
Attributes:
|
||||
key: The :func:`admin_key` the password is stored under.
|
||||
key: The :func:`admin_key` the password is stored under (the record's key in the
|
||||
file, not one of its fields).
|
||||
password: The node's admin password (plaintext).
|
||||
label: A friendly node name for display.
|
||||
last_used: ISO-8601 timestamp of the last successful login.
|
||||
@@ -53,6 +60,26 @@ class AdminCredential:
|
||||
label: str
|
||||
last_used: str
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@classmethod
|
||||
def read(cls, key: str, raw: object) -> AdminCredential | None:
|
||||
"""Build a record from one file entry, or ``None`` when it holds no password."""
|
||||
if not isinstance(raw, dict) or raw.get("password") is None:
|
||||
return None
|
||||
return cls(
|
||||
key=key,
|
||||
password=str(raw["password"]),
|
||||
label=str(raw.get("label") or ""),
|
||||
last_used=str(raw.get("last_used") or ""),
|
||||
)
|
||||
|
||||
def to_json(self) -> dict:
|
||||
"""The record as written: its own fields and nothing it happened to be read with."""
|
||||
return {"password": self.password, "label": self.label, "last_used": self.last_used}
|
||||
|
||||
|
||||
class AdminStore:
|
||||
"""Reads and writes remembered remote-node admin passwords."""
|
||||
@@ -65,13 +92,20 @@ class AdminStore:
|
||||
"""
|
||||
self._path = path
|
||||
|
||||
def _load_all(self) -> dict[str, dict]:
|
||||
"""Return the raw key -> record mapping, or empty on missing/corrupt file."""
|
||||
def _load(self) -> dict[str, AdminCredential]:
|
||||
"""Read every credential, keyed by :func:`admin_key`; empty on a missing/corrupt file."""
|
||||
try:
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
records = {}
|
||||
for key, raw in data.items():
|
||||
record = AdminCredential.read(str(key), raw)
|
||||
if record is not None:
|
||||
records[record.key] = record
|
||||
return records
|
||||
|
||||
def get(self, node: Contact) -> str | None:
|
||||
"""Return the remembered password for ``node``, or ``None`` if not stored.
|
||||
@@ -82,11 +116,8 @@ class AdminStore:
|
||||
Returns:
|
||||
The stored password, or ``None``.
|
||||
"""
|
||||
record = self._load_all().get(admin_key(node))
|
||||
if isinstance(record, dict):
|
||||
password = record.get("password")
|
||||
return str(password) if password is not None else None
|
||||
return None
|
||||
record = self._load().get(admin_key(node))
|
||||
return record.password if record is not None else None
|
||||
|
||||
def remember(self, node: Contact, password: str) -> None:
|
||||
"""Store (or update) the admin password for ``node``.
|
||||
@@ -95,12 +126,11 @@ class AdminStore:
|
||||
node: The contact the password belongs to.
|
||||
password: The admin password to remember.
|
||||
"""
|
||||
records = self._load_all()
|
||||
records[admin_key(node)] = {
|
||||
"password": password,
|
||||
"label": node.name,
|
||||
"last_used": utcnow().isoformat(),
|
||||
}
|
||||
records = self._load()
|
||||
key = admin_key(node)
|
||||
records[key] = AdminCredential(
|
||||
key=key, password=password, label=node.name, last_used=utcnow().isoformat()
|
||||
)
|
||||
self._write(records)
|
||||
|
||||
def record(self, node: Contact, password: str, outcome: LoginResult) -> None:
|
||||
@@ -132,10 +162,11 @@ class AdminStore:
|
||||
Args:
|
||||
node: The contact to forget.
|
||||
"""
|
||||
records = self._load_all()
|
||||
records = self._load()
|
||||
if records.pop(admin_key(node), None) is not None:
|
||||
self._write(records)
|
||||
|
||||
def _write(self, records: dict[str, dict]) -> None:
|
||||
def _write(self, records: dict[str, AdminCredential]) -> None:
|
||||
"""Persist ``records`` atomically with owner-only permissions where supported."""
|
||||
write_atomically(self._path, json.dumps(records, indent=2), owner_only=True)
|
||||
data = {key: record.to_json() for key, record in records.items()}
|
||||
write_atomically(self._path, json.dumps(data, indent=2), owner_only=True)
|
||||
+178
-130
@@ -1,104 +1,133 @@
|
||||
"""Persistence for the background-advert schedule.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for the weekly flood advert: when each device's week began.
|
||||
|
||||
MeshTerm announces the connected node on a cadence — a zero-hop (direct) advert for the
|
||||
immediate neighbourhood and a flood advert for the wider mesh — so the node stays fresh in
|
||||
other nodes' contact lists without anyone thinking about it. This store remembers, per
|
||||
device (keyed by its public key), the chosen cadence for each advert type and when one was
|
||||
last sent, so the countdown survives restarts and *every* advert counts: a manual send from
|
||||
the advert menu resets the same clock the scheduler reads (see
|
||||
:class:`~meshterm.services.advert_scheduler.AdvertScheduler`).
|
||||
A MeshCore companion never advertises by itself — no timer, not even at boot; only an app
|
||||
asking (``CMD_SEND_SELF_ADVERT``) or a press on the device's own menu puts one on the air.
|
||||
So a node that nobody remembers to advertise slowly drops out of everyone else's contact
|
||||
list. The ``weekly_flood_advert`` preference is MeshTerm's answer, and deliberately a
|
||||
frugal one: at most one flood advert a week from any one device, and only once that device
|
||||
has gone a full week without one (see
|
||||
:class:`~meshterm.services.advert_scheduler.AdvertScheduler`, which sends it).
|
||||
|
||||
This store remembers what that rule needs, and nothing else:
|
||||
|
||||
* **when the preference was turned on** — turning it on starts the week rather than
|
||||
sending, so a week has to pass before the first automatic advert from any device;
|
||||
* **per device** (keyed by its public key), **when its week began** — the last flood
|
||||
advert that went out from it, whoever asked for it, or failing that the first time
|
||||
MeshTerm connected to it.
|
||||
|
||||
A device's advert is due one week after the *latest* of those instants. The clock is real
|
||||
time rather than run time, so a week with the app closed counts, and a due advert that the
|
||||
app did not live to send is still due the next time that device connects.
|
||||
|
||||
Like the remembered devices (:mod:`meshterm.core.device_store`), this is global machine
|
||||
state in a small JSON file (``<config_dir>/adverts.json``) rather than the per-invocation
|
||||
SQLite database.
|
||||
SQLite database, read into typed records and written back out of them (see
|
||||
:class:`AdvertStore`). A file in an older shape (the per-device cadences this replaced)
|
||||
reads as empty: every device simply starts its week again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .models import utcnow
|
||||
|
||||
#: The direct (zero-hop) cadence choices offered in the editor, in hours.
|
||||
DIRECT_CADENCE_HOURS = (1, 2, 3, 4)
|
||||
#: How long a device must go without a flood advert before the weekly one is due.
|
||||
WEEK = timedelta(days=7)
|
||||
|
||||
#: The flood cadence choices offered in the editor, in hours (3 h .. 1 week).
|
||||
FLOOD_CADENCE_HOURS = (3, 6, 12, 24, 48, 168)
|
||||
#: The quiet spells the ``advert_quiet_s`` preference offers, in seconds: how long the air
|
||||
#: must go without a packet, heard or sent, before the due advert goes out.
|
||||
QUIET_CHOICES_S = (5, 15, 30, 60)
|
||||
|
||||
#: Defaults applied when a device has no stored policy: announce to neighbours hourly,
|
||||
#: flood the wider mesh daily.
|
||||
DEFAULT_DIRECT_HOURS = 1
|
||||
DEFAULT_FLOOD_HOURS = 24
|
||||
|
||||
#: The stored cadence meaning "this advert type is off" (0 hours).
|
||||
OFF = 0
|
||||
#: The quiet spell waited for by default, in seconds.
|
||||
DEFAULT_QUIET_S = 30
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdvertPolicy:
|
||||
"""One device's background-advert schedule and its last-sent marks.
|
||||
class _Device:
|
||||
"""One device's record: when its week began.
|
||||
|
||||
Attributes:
|
||||
direct_hours: Hours between zero-hop adverts (:data:`OFF` disables them).
|
||||
flood_hours: Hours between flood adverts (:data:`OFF` disables them).
|
||||
last_direct: When a zero-hop advert (scheduled *or* manual) last went out.
|
||||
last_flood: When a flood advert last went out.
|
||||
since: The last flood advert from it, or its first connection, whichever is
|
||||
later — the instant its week counts from.
|
||||
"""
|
||||
|
||||
direct_hours: int = DEFAULT_DIRECT_HOURS
|
||||
flood_hours: int = DEFAULT_FLOOD_HOURS
|
||||
last_direct: datetime | None = None
|
||||
last_flood: datetime | None = None
|
||||
since: datetime | None = None
|
||||
|
||||
def cadence(self, flood: bool) -> int:
|
||||
"""The cadence in hours for one advert type (:data:`OFF` when disabled)."""
|
||||
return self.flood_hours if flood else self.direct_hours
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset(
|
||||
{
|
||||
# The per-device cadences the weekly flood advert replaced.
|
||||
"direct_hours",
|
||||
"flood_hours",
|
||||
"last_direct",
|
||||
"last_flood",
|
||||
}
|
||||
)
|
||||
|
||||
def last_sent(self, flood: bool) -> datetime | None:
|
||||
"""When an advert of one type last went out, or ``None`` if never recorded."""
|
||||
return self.last_flood if flood else self.last_direct
|
||||
@classmethod
|
||||
def read(cls, raw: object) -> _Device:
|
||||
"""Build a record from the file's JSON, taking only the fields it knows."""
|
||||
record = raw if isinstance(raw, dict) else {}
|
||||
return cls(since=_as_time(record.get("since")))
|
||||
|
||||
def due(self, flood: bool, now: datetime | None = None) -> bool:
|
||||
"""Whether an advert of one type is due at ``now``.
|
||||
def to_json(self) -> dict:
|
||||
"""The record as written: its own fields and nothing it happened to be read with."""
|
||||
return {"since": _stamp(self.since)}
|
||||
|
||||
Never-sent is *not* due: the scheduler arms the clock by recording "now" on its
|
||||
first look (see :meth:`AdvertStore.arm`), so a fresh install waits a full cadence
|
||||
instead of transmitting the moment it starts — adverts are routine, not urgent,
|
||||
and the quiet default is the polite one on a shared mesh.
|
||||
|
||||
Args:
|
||||
flood: ``True`` for the flood clock, ``False`` for the zero-hop one.
|
||||
now: The current time (defaults to :func:`~meshterm.core.models.utcnow`).
|
||||
@dataclass(slots=True)
|
||||
class _Document:
|
||||
"""The whole file: the switch-on instant, and every device's record by public key.
|
||||
|
||||
Returns:
|
||||
``True`` when the type is enabled, armed, and its cadence has elapsed.
|
||||
Attributes:
|
||||
enabled_since: When the weekly advert was last switched on; ``None`` while off.
|
||||
devices: Each device's record, keyed by its normalized public key.
|
||||
"""
|
||||
|
||||
enabled_since: datetime | None = None
|
||||
devices: dict[str, _Device] = field(default_factory=dict)
|
||||
|
||||
#: Top-level fields this document once held; none yet (see :attr:`_Device.RETIRED`).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@classmethod
|
||||
def read(cls, raw: object) -> _Document:
|
||||
"""Build the document from the file's JSON, taking only the fields it knows.
|
||||
|
||||
A file without a ``devices`` map is from before this shape — the per-device
|
||||
cadences — and reads as empty rather than being mined for look-alike fields.
|
||||
"""
|
||||
hours = self.cadence(flood)
|
||||
last = self.last_sent(flood)
|
||||
if hours == OFF or last is None:
|
||||
return False
|
||||
return ((now or utcnow()) - last).total_seconds() >= hours * 3600
|
||||
if not isinstance(raw, dict) or not isinstance(raw.get("devices"), dict):
|
||||
return cls()
|
||||
return cls(
|
||||
enabled_since=_as_time(raw.get("enabled_since")),
|
||||
devices={str(k): _Device.read(v) for k, v in raw["devices"].items()},
|
||||
)
|
||||
|
||||
|
||||
def cadence_label(hours: int) -> str:
|
||||
"""Render a cadence for display: ``"off"``, ``"every hour"``, ``"every 2 h"``, ``"weekly"``."""
|
||||
if hours == OFF:
|
||||
return "off"
|
||||
if hours == 1:
|
||||
return "every hour"
|
||||
if hours == 24:
|
||||
return "daily"
|
||||
if hours == 168:
|
||||
return "weekly"
|
||||
return f"every {hours} h"
|
||||
def to_json(self) -> dict:
|
||||
"""The document as written: known fields only, so a stale one dies at the next save."""
|
||||
return {
|
||||
"enabled_since": _stamp(self.enabled_since),
|
||||
"devices": {key: device.to_json() for key, device in self.devices.items()},
|
||||
}
|
||||
|
||||
|
||||
class AdvertStore:
|
||||
"""Reads and writes per-device background-advert schedules."""
|
||||
"""Reads and writes the weekly-advert clock: the switch-on instant, and each device's.
|
||||
|
||||
The file is read into typed records and written back out of them — never round-tripped
|
||||
as the raw JSON it was read as — so a field no record knows (a retired one, one a hand
|
||||
edit invented) is gone after the next write.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
"""Open the store against a JSON file location.
|
||||
@@ -113,94 +142,113 @@ class AdvertStore:
|
||||
"""Normalize a device public key into the storage key."""
|
||||
return public_key.lower().removeprefix("0x")
|
||||
|
||||
def _load_all(self) -> dict[str, dict]:
|
||||
"""Return the raw key -> record mapping, or empty on missing/corrupt file."""
|
||||
def _load(self) -> _Document:
|
||||
"""Read the file's document; an empty one when it is missing, corrupt or outdated."""
|
||||
try:
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
return _Document()
|
||||
return _Document.read(raw)
|
||||
|
||||
def load(self, public_key: str) -> AdvertPolicy:
|
||||
"""Return the stored policy for a device, or the on-by-default policy.
|
||||
def _write(self, document: _Document) -> None:
|
||||
"""Persist ``document`` atomically (a crash mid-write keeps the previous file)."""
|
||||
write_atomically(self._path, json.dumps(document.to_json(), indent=2))
|
||||
|
||||
# -- the switch ---------------------------------------------------------------------
|
||||
|
||||
def enabled_since(self) -> datetime | None:
|
||||
"""When the weekly advert was last turned on, or ``None`` while it is off."""
|
||||
return self._load().enabled_since
|
||||
|
||||
def set_enabled(self, on: bool, when: datetime | None = None) -> None:
|
||||
"""Record the preference being turned on (starting the week) or off.
|
||||
|
||||
Turning it on always restarts the clock, even when it was already recorded as on:
|
||||
the call means "it was switched on just now", and switching on never sends.
|
||||
|
||||
Args:
|
||||
public_key: The device's public key (hex).
|
||||
|
||||
Returns:
|
||||
The device's :class:`AdvertPolicy`; defaults apply for anything unset.
|
||||
on: Whether the weekly advert is now on.
|
||||
when: The switch-on instant (defaults to now); ignored when turning off.
|
||||
"""
|
||||
record = self._load_all().get(self._key(public_key))
|
||||
if not isinstance(record, dict):
|
||||
return AdvertPolicy()
|
||||
return AdvertPolicy(
|
||||
direct_hours=_as_hours(record.get("direct_hours"), DEFAULT_DIRECT_HOURS),
|
||||
flood_hours=_as_hours(record.get("flood_hours"), DEFAULT_FLOOD_HOURS),
|
||||
last_direct=_as_time(record.get("last_direct")),
|
||||
last_flood=_as_time(record.get("last_flood")),
|
||||
)
|
||||
document = self._load()
|
||||
if on:
|
||||
document.enabled_since = when or utcnow()
|
||||
elif document.enabled_since is not None:
|
||||
document.enabled_since = None
|
||||
else:
|
||||
return # already off; nothing to write
|
||||
self._write(document)
|
||||
|
||||
def set_cadence(self, public_key: str, *, flood: bool, hours: int) -> None:
|
||||
"""Store one advert type's cadence for a device.
|
||||
def sync_enabled(self, on: bool) -> None:
|
||||
"""Bring the recorded switch into line with the preference, where they disagree.
|
||||
|
||||
Args:
|
||||
public_key: The device's public key (hex).
|
||||
flood: ``True`` to set the flood cadence, ``False`` for the zero-hop one.
|
||||
hours: The new cadence in hours (:data:`OFF` disables the type).
|
||||
The Preferences page and ``preferences set`` record the switch as it happens (see
|
||||
:meth:`set_enabled`); this catches the one path neither sees, a hand edit of
|
||||
``preferences.toml``, so a preference found on with no switch-on instant starts its
|
||||
week now instead of never.
|
||||
"""
|
||||
self._update(public_key, {"flood_hours" if flood else "direct_hours": int(hours)})
|
||||
recorded = self.enabled_since() is not None
|
||||
if on != recorded:
|
||||
self.set_enabled(on)
|
||||
|
||||
def mark_sent(self, public_key: str, *, flood: bool, when: datetime | None = None) -> None:
|
||||
"""Record that an advert went out, resetting that type's countdown.
|
||||
# -- the devices ----------------------------------------------------------------------
|
||||
|
||||
Called for scheduled *and* manual sends alike, so the next background advert
|
||||
counts from the most recent announcement of that type, whoever triggered it.
|
||||
def week_began(self, public_key: str) -> datetime | None:
|
||||
"""When a device's own week began, or ``None`` if MeshTerm has never marked it."""
|
||||
device = self._load().devices.get(self._key(public_key))
|
||||
return device.since if device is not None else None
|
||||
|
||||
def arm(self, public_key: str, when: datetime | None = None) -> None:
|
||||
"""Start a device's week on its first connection, without sending anything.
|
||||
|
||||
Only writes for a device with no mark yet, so it is safe to call on every connect.
|
||||
|
||||
Args:
|
||||
public_key: The device's public key (hex).
|
||||
when: The week's start (defaults to now).
|
||||
"""
|
||||
if self.week_began(public_key) is None:
|
||||
self.mark_flood(public_key, when=when)
|
||||
|
||||
def mark_flood(self, public_key: str, when: datetime | None = None) -> None:
|
||||
"""Record a flood advert from a device, restarting its week.
|
||||
|
||||
Called for the weekly advert and for a flood advert sent by hand alike, so the
|
||||
next automatic one is never less than a week after the last of either.
|
||||
|
||||
Args:
|
||||
public_key: The device's public key (hex).
|
||||
flood: Which advert type went out.
|
||||
when: The send time (defaults to now).
|
||||
"""
|
||||
stamp = (when or utcnow()).isoformat()
|
||||
self._update(public_key, {"last_flood" if flood else "last_direct": stamp})
|
||||
document = self._load()
|
||||
document.devices[self._key(public_key)] = _Device(since=when or utcnow())
|
||||
self._write(document)
|
||||
|
||||
def arm(self, public_key: str, *, flood: bool, when: datetime | None = None) -> None:
|
||||
"""Start a never-sent advert type's countdown from ``when`` without sending.
|
||||
# -- the verdict ----------------------------------------------------------------------
|
||||
|
||||
Only writes when no last-sent mark exists, so it is safe to call on every
|
||||
scheduler pass.
|
||||
def due_at(self, public_key: str) -> datetime | None:
|
||||
"""When a device's weekly advert falls due, or ``None`` while it cannot.
|
||||
|
||||
``None`` while the weekly advert is off, and for a device not yet armed.
|
||||
|
||||
Args:
|
||||
public_key: The device's public key (hex).
|
||||
flood: Which advert type to arm.
|
||||
when: The countdown start (defaults to now).
|
||||
"""
|
||||
if self.load(public_key).last_sent(flood) is None:
|
||||
self.mark_sent(public_key, flood=flood, when=when)
|
||||
document = self._load()
|
||||
device = document.devices.get(self._key(public_key))
|
||||
if document.enabled_since is None or device is None or device.since is None:
|
||||
return None
|
||||
return max(document.enabled_since, device.since) + WEEK
|
||||
|
||||
def _update(self, public_key: str, fields: dict) -> None:
|
||||
"""Merge ``fields`` into a device's record and persist the store."""
|
||||
records = self._load_all()
|
||||
key = self._key(public_key)
|
||||
record = records.get(key)
|
||||
if not isinstance(record, dict):
|
||||
record = {}
|
||||
record.update(fields)
|
||||
records[key] = record
|
||||
self._write(records)
|
||||
|
||||
def _write(self, records: dict[str, dict]) -> None:
|
||||
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
|
||||
write_atomically(self._path, json.dumps(records, indent=2))
|
||||
def due(self, public_key: str, now: datetime | None = None) -> bool:
|
||||
"""Whether a device's weekly advert is due at ``now`` (defaults to now)."""
|
||||
at = self.due_at(public_key)
|
||||
return at is not None and (now or utcnow()) >= at
|
||||
|
||||
|
||||
def _as_hours(value: object, default: int) -> int:
|
||||
"""Coerce a stored cadence to a non-negative int, falling back to ``default``."""
|
||||
try:
|
||||
hours = int(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return hours if hours >= 0 else default
|
||||
def _stamp(when: datetime | None) -> str | None:
|
||||
"""A timestamp as the file stores it: ISO-8601, or ``null`` for absent."""
|
||||
return when.isoformat() if when is not None else None
|
||||
|
||||
|
||||
def _as_time(value: object) -> datetime | None:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""THE way MeshTerm replaces a file it owns: all of the new contents, or none of them.
|
||||
|
||||
Every store under ``core/`` keeps its state in one file that it rewrites whole. Writing
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Pair a Bluetooth companion with its PIN through BlueZ, the Linux Bluetooth stack.
|
||||
|
||||
A MeshCore companion puts its UART characteristic behind an *authenticated* bond (ENC+MITM),
|
||||
which on a device with a fixed PIN means Passkey Entry: the computer has to type the six
|
||||
digits in. On Linux, bleak's ``pair()`` cannot do that. It calls BlueZ's ``Device1.Pair`` and
|
||||
nothing else, and BlueZ gets a passkey only by asking a **pairing agent** — an object some
|
||||
program registers on the system bus to answer ``RequestPasskey``. On a desktop that agent is
|
||||
the Settings panel, in a terminal it is ``bluetoothctl``, over SSH it is usually nobody. With
|
||||
no agent to ask, BlueZ falls back to "Just Works", the firmware refuses a bond without MITM,
|
||||
and the PIN MeshTerm was handed never reached the radio at all. That is the Linux twin of the
|
||||
Windows gap :meth:`~meshterm.core.connection.MeshCoreDevice._pair_ble_windows` closes.
|
||||
|
||||
So MeshTerm registers its own agent for the length of one pairing, answering with the PIN it
|
||||
was given, and calls ``Pair`` from the *same* bus connection. BlueZ routes a pairing's agent
|
||||
requests to the agent of whoever called ``Pair`` (the system default agent is only the
|
||||
fallback), so this never takes over the desktop's agent and never answers for any other
|
||||
device's pairing.
|
||||
|
||||
The agent answers raw D-Bus messages (:class:`PinAgent`) rather than going through
|
||||
``dbus_fast``'s annotated ``ServiceInterface``: that one reads D-Bus signatures out of
|
||||
annotations like ``device: "o"``, which a linter takes for an undefined name and postponed
|
||||
annotations turn into a quoted string. A message handler has neither problem and can be
|
||||
exercised without a bus.
|
||||
|
||||
``dbus_fast`` is bleak's own dependency on Linux, so nothing new is installed; it is imported
|
||||
lazily, and every entry point here is best-effort — a missing system bus or BlueZ reports an
|
||||
``"error"`` outcome instead of raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_BLUEZ = "org.bluez"
|
||||
_AGENT_IFACE = "org.bluez.Agent1"
|
||||
_AGENT_MANAGER_IFACE = "org.bluez.AgentManager1"
|
||||
_ADAPTER_IFACE = "org.bluez.Adapter1"
|
||||
_DEVICE_IFACE = "org.bluez.Device1"
|
||||
_PROPERTIES_IFACE = "org.freedesktop.DBus.Properties"
|
||||
_OBJECT_MANAGER_IFACE = "org.freedesktop.DBus.ObjectManager"
|
||||
_REJECTED = "org.bluez.Error.Rejected"
|
||||
_ALREADY_EXISTS = "org.bluez.Error.AlreadyExists"
|
||||
_AGENTS = "/org/bluez" # where AgentManager1 lives
|
||||
|
||||
#: The agent's declared input/output capability. The companion is the side with the PIN
|
||||
#: (``DisplayOnly``), so we are the keyboard: ``KeyboardOnly`` makes the pairing Passkey
|
||||
#: Entry with us typing, which is the one method a fixed PIN can satisfy.
|
||||
AGENT_CAPABILITY = "KeyboardOnly"
|
||||
|
||||
#: How long to run discovery for a device BlueZ has forgotten (seconds). BlueZ drops an
|
||||
#: unpaired device it has stopped hearing after about half a minute, so one the picker
|
||||
#: listed a while ago may need hearing again before it can be paired.
|
||||
DISCOVER_S = 6.0
|
||||
|
||||
#: Bound on the ``Pair`` call itself (seconds). A healthy passkey pairing takes one to three
|
||||
#: seconds; this only has to outlast a slow radio, and stays well inside the probe's window.
|
||||
PAIR_TIMEOUT_S = 15.0
|
||||
|
||||
#: Attempts at ``Pair`` when the link under it fails to come up, and the pause between them.
|
||||
#: A Raspberry Pi class radio drops three to seven link attempts per connect
|
||||
#: (``le-connection-abort-by-local``, HCI 0x3e). bleak retries those inside its own
|
||||
#: ``connect()``; BlueZ's ``Pair`` does not, and answers ConnectionAttemptFailed at once.
|
||||
#: Measured on a uConsole: the first Pair failed that way, the connect then went ahead
|
||||
#: unpaired, and the protected subscribe cost 32 s before the repair paired it after all.
|
||||
PAIR_ATTEMPTS = 4
|
||||
PAIR_RETRY_DELAY_S = 0.5
|
||||
|
||||
_LINK_FLAKES = ("org.bluez.Error.ConnectionAttemptFailed",)
|
||||
|
||||
|
||||
def _link_flake(reply: Any) -> bool:
|
||||
"""Whether a ``Pair`` error is the link failing to come up — worth another try."""
|
||||
detail = reply.body[0] if reply.body and isinstance(reply.body[0], str) else ""
|
||||
return reply.error_name in _LINK_FLAKES or "le-connection-abort" in detail
|
||||
|
||||
|
||||
_MAC = re.compile(r"^[0-9A-Fa-f]{2}([:-][0-9A-Fa-f]{2}){5}$")
|
||||
|
||||
|
||||
def is_mac(address: str) -> bool:
|
||||
"""Whether ``address`` is a colon- or dash-separated 48-bit Bluetooth address."""
|
||||
return bool(_MAC.match(address or ""))
|
||||
|
||||
|
||||
def _normal(address: str) -> str:
|
||||
"""An address as BlueZ spells it: upper case, colon-separated."""
|
||||
return address.replace("-", ":").upper()
|
||||
|
||||
|
||||
def status_name(error_name: str, body: list[Any] | None = None) -> str:
|
||||
"""Turn a BlueZ error into the lowercase words a message can quote.
|
||||
|
||||
``org.bluez.Error.AuthenticationFailed`` → ``"authentication failed"``. The bare
|
||||
``org.bluez.Error.Failed`` says nothing on its own, so its text rides along
|
||||
(``"failed: le-connection-abort-by-local"``).
|
||||
|
||||
Args:
|
||||
error_name: The D-Bus error name from the reply.
|
||||
body: The error reply's body, whose first item is BlueZ's detail string.
|
||||
|
||||
Returns:
|
||||
The status, as :data:`~meshterm.core.connection._PAIRING_REFUSED` and its sibling
|
||||
sets spell them.
|
||||
"""
|
||||
leaf = error_name.rsplit(".", 1)[-1]
|
||||
words = re.sub(r"(?<!^)(?=[A-Z])", " ", leaf).lower()
|
||||
detail = body[0] if body and isinstance(body[0], str) else ""
|
||||
if words == "failed" and detail:
|
||||
return f"failed: {detail}"
|
||||
return words
|
||||
|
||||
|
||||
class PinAgent:
|
||||
"""A BlueZ ``Agent1`` that answers one device's pairing with one PIN — or refuses it.
|
||||
|
||||
Installed as a message handler on the bus that calls ``Pair`` (see the module docstring
|
||||
for why that bus), or made BlueZ's default agent for the length of a connect (see
|
||||
:func:`answering`). It answers only calls addressed to its own object path, and only for
|
||||
the device it was built for; anything else is ``Rejected``, so a pairing we did not start
|
||||
can never be approved by us. Without a PIN it refuses every request at once, which is
|
||||
the point of it there: the alternative is BlueZ waiting on an agent that doesn't exist.
|
||||
|
||||
It never approves a pairing *without* the passkey (``RequestAuthorization``, the "Just
|
||||
Works" yes/no). That is how a companion ends up with an unauthenticated bond — encryption
|
||||
works, the UART characteristic still refuses — which is the stale-bond failure itself.
|
||||
|
||||
Attributes:
|
||||
path: The object path the agent is registered at.
|
||||
asked: Whether BlueZ actually asked for the PIN — a pairing that failed without
|
||||
asking was refused before the passkey stage, which is a different story.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str, pin: str | None, device_path: str) -> None:
|
||||
"""Build the agent.
|
||||
|
||||
Args:
|
||||
path: The object path to answer at.
|
||||
pin: The pairing PIN (digits), or ``None`` to refuse every request.
|
||||
device_path: The one device it may answer for: its BlueZ object path, or the
|
||||
path's ``/dev_…`` tail when the adapter isn't known yet (see
|
||||
:func:`device_tail`).
|
||||
"""
|
||||
self.path = path
|
||||
self._pin = pin
|
||||
self._device = device_path
|
||||
self.asked = False
|
||||
|
||||
def handle(self, msg: Any) -> Any:
|
||||
"""Answer one incoming message, or return ``None`` to leave it to other handlers.
|
||||
|
||||
Args:
|
||||
msg: A ``dbus_fast.Message``.
|
||||
|
||||
Returns:
|
||||
A reply ``Message``, or ``None`` when the message is not ours.
|
||||
"""
|
||||
from dbus_fast import Message, MessageType
|
||||
|
||||
if (
|
||||
msg.message_type != MessageType.METHOD_CALL
|
||||
or msg.path != self.path
|
||||
or msg.interface != _AGENT_IFACE
|
||||
):
|
||||
return None
|
||||
member = msg.member
|
||||
if member in ("Release", "Cancel"):
|
||||
return Message.new_method_return(msg)
|
||||
device = msg.body[0] if msg.body else ""
|
||||
if not str(device).endswith(self._device):
|
||||
return Message.new_error(msg, _REJECTED, "not the device MeshTerm is pairing")
|
||||
if member in ("RequestPasskey", "RequestPinCode", "RequestConfirmation") and not self._pin:
|
||||
self.asked = True
|
||||
return Message.new_error(msg, _REJECTED, "no PIN was given for this device")
|
||||
if member == "RequestPasskey":
|
||||
self.asked = True
|
||||
return Message.new_method_return(msg, "u", [int(self._pin)])
|
||||
if member == "RequestPinCode":
|
||||
self.asked = True
|
||||
return Message.new_method_return(msg, "s", [self._pin])
|
||||
if member == "RequestConfirmation":
|
||||
# Numeric Comparison: approve only the number our PIN names. A companion with a
|
||||
# fixed PIN never asks for this; one that does and shows another number is not
|
||||
# the pairing we were told to make.
|
||||
self.asked = True
|
||||
if int(msg.body[1]) == int(self._pin):
|
||||
return Message.new_method_return(msg)
|
||||
return Message.new_error(msg, _REJECTED, "passkey does not match the PIN")
|
||||
if member in ("DisplayPasskey", "DisplayPinCode"):
|
||||
return Message.new_method_return(msg)
|
||||
return Message.new_error(msg, _REJECTED, f"{member} is not something MeshTerm answers")
|
||||
|
||||
|
||||
def device_tail(address: str) -> str:
|
||||
"""The ``/dev_AA_BB_…`` tail of the object path BlueZ gives the device at ``address``."""
|
||||
return "/dev_" + _normal(address).replace(":", "_")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def answering(address: str, pin: str | None) -> AsyncIterator[None]:
|
||||
"""Be BlueZ's default pairing agent for one device while a connect runs.
|
||||
|
||||
BlueZ does not wait to be asked to pair. When the companion refuses the UART subscribe
|
||||
with *Insufficient Authentication*, BlueZ raises the link's security by itself and starts
|
||||
SMP pairing, which needs an agent — and the one it asks is the **default** agent, not
|
||||
ours (that routing is only for a ``Pair`` we call). With none registered, as on any
|
||||
headless machine, it offers the companion ``DisplayYesNo``, lands on "Just Works", asks a
|
||||
yes/no nobody is there to answer, and the companion hangs up on its 30 s SMP timeout.
|
||||
Measured on a uConsole with btmon: 3.3 s to the question, 33.5 s to the hang-up, and only
|
||||
then "requires a PIN". On a desktop the question goes to the desktop's dialog instead,
|
||||
and answering it makes the unauthenticated bond that later refuses the subscribe.
|
||||
|
||||
So for the length of the connect MeshTerm is the default agent, for this device only,
|
||||
declared ``KeyboardOnly`` so the pairing BlueZ starts is Passkey Entry: with a PIN it types
|
||||
it in and the connect pairs on the spot; without one it refuses at once and the refusal
|
||||
arrives in seconds. Another device's pairing in that window is refused, not left hanging.
|
||||
BlueZ keeps default agents as a stack, so unregistering hands the role back to whoever
|
||||
held it. Best-effort: with no system bus it simply does nothing.
|
||||
|
||||
Args:
|
||||
address: The companion's Bluetooth address.
|
||||
pin: Its PIN, or ``None`` to refuse.
|
||||
|
||||
Yields:
|
||||
Nothing; the agent is live for the body of the ``async with``.
|
||||
"""
|
||||
bus = None
|
||||
agent = PinAgent(f"/net/meshterm/connect{os.getpid()}", pin, device_tail(address))
|
||||
registered = False
|
||||
try:
|
||||
bus = await _system_bus()
|
||||
bus.add_message_handler(agent.handle)
|
||||
reply = await _call(
|
||||
bus, _AGENTS, _AGENT_MANAGER_IFACE, "RegisterAgent", "os",
|
||||
[agent.path, AGENT_CAPABILITY],
|
||||
) # fmt: skip
|
||||
registered = not _is_error(reply)
|
||||
if registered:
|
||||
await _call(
|
||||
bus, _AGENTS, _AGENT_MANAGER_IFACE, "RequestDefaultAgent", "o", [agent.path]
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - no bus, no BlueZ: connect without an agent
|
||||
_log.debug("couldn't stand in as the BlueZ agent: %s", exc)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if bus is not None:
|
||||
try:
|
||||
if registered:
|
||||
await _call(
|
||||
bus, _AGENTS, _AGENT_MANAGER_IFACE, "UnregisterAgent", "o", [agent.path]
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - the bus closing drops it anyway
|
||||
_log.debug("unregistering the BlueZ agent failed: %s", exc)
|
||||
bus.remove_message_handler(agent.handle)
|
||||
bus.disconnect()
|
||||
|
||||
|
||||
async def _call(bus: Any, path: str, interface: str, member: str, signature: str = "", body=None):
|
||||
"""Call a BlueZ method and return the raw reply (errors are left for the caller)."""
|
||||
from dbus_fast import Message
|
||||
|
||||
return await bus.call(
|
||||
Message(
|
||||
destination=_BLUEZ,
|
||||
path=path,
|
||||
interface=interface,
|
||||
member=member,
|
||||
signature=signature,
|
||||
body=body or [],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _is_error(reply: Any) -> bool:
|
||||
from dbus_fast import MessageType
|
||||
|
||||
return reply.message_type == MessageType.ERROR
|
||||
|
||||
|
||||
async def _objects(bus: Any) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
"""Every BlueZ object, from ``GetManagedObjects`` (empty when BlueZ didn't answer)."""
|
||||
reply = await _call(bus, "/", _OBJECT_MANAGER_IFACE, "GetManagedObjects")
|
||||
if _is_error(reply) or not reply.body:
|
||||
return {}
|
||||
return reply.body[0]
|
||||
|
||||
|
||||
def _value(props: dict[str, Any], name: str) -> Any:
|
||||
"""A property's plain value, unwrapping the ``Variant`` BlueZ sends."""
|
||||
raw = props.get(name)
|
||||
return getattr(raw, "value", raw)
|
||||
|
||||
|
||||
def _device_in(objects: dict[str, dict[str, dict[str, Any]]], address: str) -> str | None:
|
||||
"""The object path of the device at ``address``, if BlueZ knows it."""
|
||||
want = _normal(address)
|
||||
for path, interfaces in objects.items():
|
||||
props = interfaces.get(_DEVICE_IFACE)
|
||||
if props is not None and str(_value(props, "Address") or "").upper() == want:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _adapter_in(objects: dict[str, dict[str, dict[str, Any]]]) -> str | None:
|
||||
"""The first Bluetooth adapter's object path."""
|
||||
return next((path for path, ifaces in objects.items() if _ADAPTER_IFACE in ifaces), None)
|
||||
|
||||
|
||||
async def _find_device(bus: Any, address: str, discover_s: float) -> tuple[str | None, bool]:
|
||||
"""Find the device's object path, listening for it briefly if BlueZ has forgotten it.
|
||||
|
||||
Returns:
|
||||
``(path, paired)`` — ``path`` is ``None`` when it could not be heard.
|
||||
"""
|
||||
objects = await _objects(bus)
|
||||
path = _device_in(objects, address)
|
||||
if path is None:
|
||||
adapter = _adapter_in(objects)
|
||||
if adapter is None:
|
||||
return None, False
|
||||
# A refusal here is fine: bleak or the desktop may already be discovering, which
|
||||
# is all we need.
|
||||
await _call(bus, adapter, _ADAPTER_IFACE, "StartDiscovery")
|
||||
try:
|
||||
deadline = asyncio.get_running_loop().time() + discover_s
|
||||
while path is None and asyncio.get_running_loop().time() < deadline:
|
||||
await asyncio.sleep(0.5)
|
||||
objects = await _objects(bus)
|
||||
path = _device_in(objects, address)
|
||||
finally:
|
||||
await _call(bus, adapter, _ADAPTER_IFACE, "StopDiscovery")
|
||||
if path is None:
|
||||
return None, False
|
||||
return path, bool(_value(objects[path][_DEVICE_IFACE], "Paired"))
|
||||
|
||||
|
||||
def _adapter_of(objects: dict[str, dict[str, dict[str, Any]]], path: str) -> str | None:
|
||||
"""The adapter a device hangs off — its own ``Adapter`` property, else the first one."""
|
||||
own = _value(objects.get(path, {}).get(_DEVICE_IFACE, {}), "Adapter")
|
||||
return str(own) if own else _adapter_in(objects)
|
||||
|
||||
|
||||
async def _remove(bus: Any, path: str) -> Any:
|
||||
"""Forget a device's bond (``Adapter1.RemoveDevice``), which also forgets the device.
|
||||
|
||||
Returns:
|
||||
The raw reply, or ``None`` when there was no adapter to ask.
|
||||
"""
|
||||
adapter = _adapter_of(await _objects(bus), path)
|
||||
if adapter is None:
|
||||
return None
|
||||
return await _call(bus, adapter, _ADAPTER_IFACE, "RemoveDevice", "o", [path])
|
||||
|
||||
|
||||
async def _system_bus() -> Any:
|
||||
from dbus_fast import BusType
|
||||
from dbus_fast.aio import MessageBus
|
||||
|
||||
return await MessageBus(bus_type=BusType.SYSTEM).connect()
|
||||
|
||||
|
||||
async def pair(
|
||||
address: str,
|
||||
pin: str,
|
||||
*,
|
||||
force: bool,
|
||||
discover_s: float = DISCOVER_S,
|
||||
pair_timeout_s: float = PAIR_TIMEOUT_S,
|
||||
) -> tuple[str, str]:
|
||||
"""Pair the companion at ``address`` using ``pin``, through our own BlueZ agent.
|
||||
|
||||
Mirrors the Windows ceremony's contract: an existing bond is trusted unless ``force``,
|
||||
which tears it down and pairs afresh — the heal for a bond the device has since lost.
|
||||
|
||||
Args:
|
||||
address: The companion's Bluetooth address.
|
||||
pin: Its pairing PIN (six digits).
|
||||
force: Whether to remove an existing bond first.
|
||||
discover_s: How long to listen for a device BlueZ has forgotten.
|
||||
pair_timeout_s: Bound on the ``Pair`` call.
|
||||
|
||||
Returns:
|
||||
``(outcome, status)`` in :class:`~meshterm.core.connection._BlePairing`'s vocabulary:
|
||||
``"reused"``, ``"paired"``, ``"absent"``, ``"failed"`` with the BlueZ status, or
|
||||
``"error"`` with what went wrong.
|
||||
"""
|
||||
if not pin.isdigit():
|
||||
return "failed", "authentication failed" # a passkey is a number; this can't be it
|
||||
try:
|
||||
bus = await _system_bus()
|
||||
except Exception as exc: # noqa: BLE001 - no system bus / no dbus_fast: nothing to pair with
|
||||
return "error", f"couldn't reach BlueZ: {exc}"
|
||||
try:
|
||||
path, paired = await _find_device(bus, address, discover_s)
|
||||
if path is None:
|
||||
return "absent", ""
|
||||
if paired:
|
||||
if not force:
|
||||
return "reused", ""
|
||||
await _remove(bus, path)
|
||||
path, _ = await _find_device(bus, address, discover_s)
|
||||
if path is None:
|
||||
return "absent", ""
|
||||
agent = PinAgent(f"/net/meshterm/agent{os.getpid()}", pin, path)
|
||||
paired_link: str | None = None # set once Pair has been asked to open a link
|
||||
bus.add_message_handler(agent.handle)
|
||||
try:
|
||||
reply = await _call(
|
||||
bus, _AGENTS, _AGENT_MANAGER_IFACE, "RegisterAgent", "os",
|
||||
[agent.path, AGENT_CAPABILITY],
|
||||
) # fmt: skip
|
||||
if _is_error(reply):
|
||||
return "error", status_name(reply.error_name, reply.body)
|
||||
paired_link = path
|
||||
for attempt in range(PAIR_ATTEMPTS):
|
||||
if attempt:
|
||||
await asyncio.sleep(PAIR_RETRY_DELAY_S)
|
||||
try:
|
||||
reply = await asyncio.wait_for(
|
||||
_call(bus, path, _DEVICE_IFACE, "Pair"), pair_timeout_s
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await _call(bus, path, _DEVICE_IFACE, "CancelPairing")
|
||||
return "failed", "authentication timeout"
|
||||
if not (_is_error(reply) and _link_flake(reply)):
|
||||
break
|
||||
_log.debug("BlueZ Pair lost its link (attempt %d/%d)", attempt + 1, PAIR_ATTEMPTS)
|
||||
if _is_error(reply) and reply.error_name != _ALREADY_EXISTS:
|
||||
status = status_name(reply.error_name, reply.body)
|
||||
if not agent.asked and status == "authentication failed":
|
||||
# Refused before the passkey stage: the PIN was never in question.
|
||||
status = "authentication rejected"
|
||||
return "failed", status
|
||||
# Trusted lets later connections through without an agent to authorize them.
|
||||
from dbus_fast import Variant
|
||||
|
||||
await _call(
|
||||
bus, path, _PROPERTIES_IFACE, "Set", "ssv",
|
||||
[_DEVICE_IFACE, "Trusted", Variant("b", True)],
|
||||
) # fmt: skip
|
||||
return "paired", ""
|
||||
finally:
|
||||
if paired_link is not None:
|
||||
# ``Pair`` opens a link to pair over and BlueZ leaves it up. A companion that
|
||||
# is connected stops advertising, and the connect that follows looks the
|
||||
# device up by scanning for it, so it never finds it — measured on a
|
||||
# uConsole: paired, then "couldn't open a link" after two 30 s tries. Put
|
||||
# the link down so the connect meets an advertising device, as it would
|
||||
# have without us.
|
||||
await _call(bus, paired_link, _DEVICE_IFACE, "Disconnect")
|
||||
await _call(bus, _AGENTS, _AGENT_MANAGER_IFACE, "UnregisterAgent", "o", [agent.path])
|
||||
bus.remove_message_handler(agent.handle)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort: the connect reports the refusal
|
||||
return "error", str(exc)
|
||||
finally:
|
||||
bus.disconnect()
|
||||
|
||||
|
||||
async def is_paired(address: str) -> bool:
|
||||
"""Whether BlueZ holds a bond for ``address``. ``False`` on any failure to ask."""
|
||||
try:
|
||||
bus = await _system_bus()
|
||||
except Exception as exc: # noqa: BLE001 - no bus: nothing is known to be paired
|
||||
_log.debug("BlueZ pairing query unavailable: %s", exc)
|
||||
return False
|
||||
try:
|
||||
objects = await _objects(bus)
|
||||
path = _device_in(objects, address)
|
||||
return path is not None and bool(_value(objects[path][_DEVICE_IFACE], "Paired"))
|
||||
except Exception as exc: # noqa: BLE001 - a status hiccup is not a bond
|
||||
_log.debug("BlueZ pairing query failed for %s: %s", address, exc)
|
||||
return False
|
||||
finally:
|
||||
bus.disconnect()
|
||||
|
||||
|
||||
async def unpair(address: str) -> bool:
|
||||
"""Forget BlueZ's bond for ``address``. ``True`` only if a bond was removed."""
|
||||
try:
|
||||
bus = await _system_bus()
|
||||
except Exception as exc: # noqa: BLE001 - no bus: nothing to remove
|
||||
_log.debug("BlueZ unpair unavailable: %s", exc)
|
||||
return False
|
||||
try:
|
||||
objects = await _objects(bus)
|
||||
path = _device_in(objects, address)
|
||||
if path is None or not _value(objects[path][_DEVICE_IFACE], "Paired"):
|
||||
return False
|
||||
reply = await _remove(bus, path)
|
||||
return reply is not None and not _is_error(reply)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort, like the Windows twin
|
||||
_log.debug("BlueZ unpair failed for %s: %s", address, exc)
|
||||
return False
|
||||
finally:
|
||||
bus.disconnect()
|
||||
@@ -0,0 +1,166 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Reading the device's channel slots back off the radio.
|
||||
|
||||
One configured channel slot (:class:`ChannelSlot`) and the two reads that discover them.
|
||||
Every derived property on the slot delegates to :mod:`meshterm.core.channels`, which owns the
|
||||
key/identity maths; this module is only the device-facing half — the scan, and what it means
|
||||
when the scan stops.
|
||||
|
||||
It lives in :mod:`meshterm.core` rather than beside the channel manager screen because the
|
||||
screen is not the only reader: the device-state session cache
|
||||
(:class:`~meshterm.services.device_state.DeviceState`) warms the probe, the ``channels`` and
|
||||
``chat`` tools read slots for their CLI faces, and a service must never have to reach up into
|
||||
``ui/`` for a device read. It is a separate module from :mod:`meshterm.core.channels` because
|
||||
it needs :class:`~meshterm.core.connection.Device`, and ``connection`` already imports
|
||||
``channels`` — keeping the maths free of the transport is what keeps that direction one-way.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .channels import (
|
||||
CHANNEL_SLOT_EMPTY_RUN,
|
||||
CHANNEL_SLOT_PROBE_CAP,
|
||||
channel_hash,
|
||||
channel_identity,
|
||||
full_channel_hash,
|
||||
is_name_derived,
|
||||
is_public_channel,
|
||||
)
|
||||
from .connection import Device, DeviceCommandError, is_connection_lost
|
||||
from .models import Conversation
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChannelSlot:
|
||||
"""One configured channel slot read back from the device.
|
||||
|
||||
Attributes:
|
||||
idx: The 0-based slot index.
|
||||
name: The channel's name.
|
||||
secret: The channel's 16-byte shared secret.
|
||||
"""
|
||||
|
||||
idx: int
|
||||
name: str
|
||||
secret: bytes
|
||||
|
||||
@property
|
||||
def is_name_derived(self) -> bool:
|
||||
"""Whether this channel's key is reproducible from its name (so it needn't be stored)."""
|
||||
return is_name_derived(self.name, self.secret)
|
||||
|
||||
@property
|
||||
def is_public(self) -> bool:
|
||||
"""Whether this channel is public (shared meshwide) rather than a private one.
|
||||
|
||||
Covers both name-derived channels and the firmware's fixed-key default ``Public``.
|
||||
"""
|
||||
return is_public_channel(self.name, self.secret)
|
||||
|
||||
@property
|
||||
def hash(self) -> str:
|
||||
"""The channel's two-character hash fingerprint (the leading byte MeshCore shows)."""
|
||||
return channel_hash(self.secret)
|
||||
|
||||
@property
|
||||
def full_hash(self) -> str:
|
||||
"""The channel's complete ``sha256(secret)`` digest; its first byte is :attr:`hash`."""
|
||||
return full_channel_hash(self.secret)
|
||||
|
||||
@property
|
||||
def identity(self) -> str:
|
||||
"""The channel's slot-independent identity, used to key its chat history."""
|
||||
return channel_identity(self.name, self.secret)
|
||||
|
||||
@property
|
||||
def conversation(self) -> Conversation:
|
||||
"""A :class:`~meshterm.core.models.Conversation` for opening this channel in chat."""
|
||||
return Conversation(
|
||||
label=self.name,
|
||||
is_channel=True,
|
||||
channel_idx=self.idx,
|
||||
channel_id=self.identity,
|
||||
secret=self.secret,
|
||||
)
|
||||
|
||||
|
||||
async def read_channel_slots(device: Device) -> list[ChannelSlot]:
|
||||
"""Probe the channel slots and return the configured ones, in index order.
|
||||
|
||||
The scan stops as soon as the firmware rejects a slot index, so it reads exactly the
|
||||
slots a well-behaved device has regardless of its capacity. Firmware that never rejects
|
||||
an out-of-range index (it answers every slot with an empty payload instead of raising)
|
||||
would otherwise walk all :data:`CHANNEL_SLOT_PROBE_CAP` slots on every read; a run of
|
||||
:data:`CHANNEL_SLOT_EMPTY_RUN` consecutive empty slots ends the scan on such firmware.
|
||||
That is safe because the manager packs channels from slot 0 up, so an unbroken empty run
|
||||
that long means every configured channel has already been seen (see the constant's note).
|
||||
|
||||
Args:
|
||||
device: The connected device to query.
|
||||
|
||||
Returns:
|
||||
One :class:`ChannelSlot` per configured slot (an empty slot is skipped).
|
||||
"""
|
||||
slots, _complete = await probe_channel_slots(device)
|
||||
return slots
|
||||
|
||||
|
||||
async def probe_channel_slots(device: Device) -> tuple[list[ChannelSlot], bool]:
|
||||
"""The probe behind :func:`read_channel_slots`, reporting whether it *finished*.
|
||||
|
||||
The scan stops for two very different reasons and the plain list cannot tell them
|
||||
apart. Off the end of the configured slots (a rejected index, or a long enough run of
|
||||
empty ones) the answer is complete and worth keeping. A read that simply *failed* —
|
||||
a timeout, a link hiccup, and then every subsequent read failing too because the
|
||||
firmware's reply no longer matches the slot asked for — ends the scan early with
|
||||
whatever it happened to have, which is not the device's layout and must not be cached
|
||||
or acted on as if it were: an empty one reads as "no channels configured", and a
|
||||
truncated one makes the next free slot look free when a channel is sitting in it.
|
||||
|
||||
The two endings raise different things, and that is what separates them: the firmware
|
||||
*answering* "no such slot" surfaces as a plain rejection, while a link that stopped
|
||||
answering surfaces as a timeout, a :class:`~meshterm.core.connection.DeviceCommandError`
|
||||
(whose whole subject is a companion that did not reply in time), or one of the dropped-
|
||||
link signatures :func:`~meshterm.core.connection.is_connection_lost` knows. Only the
|
||||
first ending is a layout. A read that fails for some fourth reason is treated as a
|
||||
rejection, which is the safe way round: the list is used but the cache re-probes.
|
||||
|
||||
Args:
|
||||
device: The connected device to query.
|
||||
|
||||
Returns:
|
||||
The slots read, and whether the scan ran to a clean end.
|
||||
"""
|
||||
slots: list[ChannelSlot] = []
|
||||
empty_run = 0
|
||||
for idx in range(CHANNEL_SLOT_PROBE_CAP):
|
||||
try:
|
||||
payload = await device.get_channel(idx)
|
||||
except Exception as exc: # noqa: BLE001 - a rejected index, or a read that failed
|
||||
return slots, not _read_failed(exc)
|
||||
if payload and payload.get("channel_name"):
|
||||
empty_run = 0
|
||||
slots.append(
|
||||
ChannelSlot(
|
||||
idx=idx,
|
||||
name=str(payload["channel_name"]),
|
||||
secret=bytes(payload.get("channel_secret") or b"\x00" * 16),
|
||||
)
|
||||
)
|
||||
else:
|
||||
empty_run += 1
|
||||
if empty_run >= CHANNEL_SLOT_EMPTY_RUN:
|
||||
break # off the end of a never-rejecting firmware; nothing more to find
|
||||
return slots, True
|
||||
|
||||
|
||||
def _read_failed(exc: BaseException) -> bool:
|
||||
"""Whether ``exc`` means the *read* failed, rather than the firmware refusing a slot.
|
||||
|
||||
A refusal is the probe's ordinary ending and leaves a complete list behind it. A
|
||||
failure leaves a short one that looks exactly the same — which is the whole reason
|
||||
this distinction has to be drawn somewhere (see :func:`probe_channel_slots`).
|
||||
"""
|
||||
return isinstance(exc, (TimeoutError, DeviceCommandError)) or is_connection_lost(exc)
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for channels created through MeshTerm, so they survive a device that forgets.
|
||||
|
||||
Most companions keep their own channel table in firmware, so MeshTerm reads channels live from
|
||||
@@ -28,7 +29,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .channels import (
|
||||
@@ -51,6 +52,10 @@ class RememberedChannel:
|
||||
name: str
|
||||
secret: bytes
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@property
|
||||
def identity(self) -> str:
|
||||
"""The channel's slot-independent intrinsic identity (see :func:`channel_identity`)."""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Pure channel logic: secret derivation, MeshCore share-URL round-tripping, and decrypt.
|
||||
|
||||
A MeshCore channel is a name plus a 16-byte shared secret. *Public* channels (whose name
|
||||
@@ -35,6 +36,13 @@ from urllib.parse import parse_qs, quote, urlsplit
|
||||
#: be followed by ``": "`` — conservative enough to leave ``http://…`` and ``note:x`` alone.
|
||||
SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,19}):[ \t]+(.*)$", re.DOTALL)
|
||||
|
||||
#: Matches an ``@[Name]`` mention token, as the reply flow primes into the compose line. A
|
||||
#: transcript renders each as a bare ``@Name`` coloured in that sender's hue instead of
|
||||
#: showing the literal brackets, and the CLI's history face lifts the same names out — so the
|
||||
#: token lives here with the rest of the message-body parsing rather than in either renderer.
|
||||
#: Name is 1–20 non-``]`` characters.
|
||||
MENTION = re.compile(r"@\[([^\]]{1,20})\]")
|
||||
|
||||
|
||||
def split_channel_sender(text: str) -> tuple[str | None, str]:
|
||||
"""Split a channel message into ``(sender_name, body)`` when it carries a name prefix.
|
||||
@@ -74,7 +82,7 @@ MAX_CHANNELS = 8
|
||||
CHANNEL_SLOT_PROBE_CAP = 64
|
||||
|
||||
#: A run of this many consecutive *empty* slots ends a *configured-channel* scan (see
|
||||
#: :func:`meshterm.ui.channels.read_channel_slots`) on firmware that never rejects an
|
||||
#: :func:`meshterm.core.channel_probe.read_channel_slots`) on firmware that never rejects an
|
||||
#: out-of-range index — some builds answer every index with an empty payload instead of
|
||||
#: raising, so the scan would otherwise walk all :data:`CHANNEL_SLOT_PROBE_CAP` slots on
|
||||
#: every read. The channel manager packs channels from slot 0 up within the stock
|
||||
|
||||
+134
-3
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Machine setup and named device profiles.
|
||||
|
||||
Settings are read from a TOML file (default ``~/.meshterm/config.toml``) and may
|
||||
@@ -19,7 +20,7 @@ import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
@@ -33,6 +34,17 @@ else: # pragma: no cover - exercised only on 3.10
|
||||
#: can move it between cases.
|
||||
CONFIG_DIR_ENV = "MESHTERM_HOME"
|
||||
|
||||
#: The history's filename inside the config directory, when ``config.toml`` and ``--db``
|
||||
#: both leave it alone.
|
||||
DB_FILENAME = "meshterm.db"
|
||||
|
||||
#: The simulator's history, kept beside the real one and used by ``--mock`` runs that did
|
||||
#: not name a database themselves. ``--mock`` is a fake radio, not a fake MeshTerm: what
|
||||
#: the simulator adverts is recorded like anything a real companion said, so without a
|
||||
#: database of its own its four invented contacts turn up in the mesh walk, the dashboard
|
||||
#: and the map of the mesh you actually run.
|
||||
MOCK_DB_FILENAME = "meshterm-mock.db"
|
||||
|
||||
|
||||
def default_config_dir() -> Path:
|
||||
"""Return the directory MeshTerm uses for config and data.
|
||||
@@ -55,6 +67,104 @@ def default_config_dir() -> Path:
|
||||
return Path.home() / ".meshterm"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SpiWiring:
|
||||
"""How a LoRa chip on the host's SPI bus is wired, for a ``transport = "spi"`` profile.
|
||||
|
||||
The defaults are the hackergadgets uConsole AIO v1's wiring, so that board needs no
|
||||
``[profiles.<name>.spi]`` table at all; any other board states only what differs. These
|
||||
are facts about the *board*, which is why they live in ``config.toml`` with the profile
|
||||
and not among the radio's settings: the frequency, bandwidth and power are the node's
|
||||
own, saved by the node and edited on Device config like any companion's.
|
||||
|
||||
Attributes:
|
||||
bus_id: SPI bus (``/dev/spidev<bus_id>.<cs_id>``).
|
||||
cs_id: SPI chip-select device on that bus.
|
||||
cs_pin: A GPIO driven as chip select by hand, or ``-1`` for the bus's own.
|
||||
gpio_chip: Which ``/dev/gpiochip<n>`` the pins below are on.
|
||||
use_gpiod_backend: Drive the pins through ``gpiod`` instead of ``python-periphery``.
|
||||
reset_pin: The chip's reset line.
|
||||
busy_pin: The chip's busy line.
|
||||
irq_pin: The chip's interrupt line (DIO1).
|
||||
txen_pin: A transmit-enable line for an external RF switch, or ``-1``.
|
||||
rxen_pin: A receive-enable line for an external RF switch, or ``-1``.
|
||||
en_pins: Power-enable lines raised before the chip is touched (the AIO v2 wants
|
||||
``[27]``); empty for none.
|
||||
use_dio2_rf: Whether DIO2 drives the RF switch.
|
||||
use_dio3_tcxo: Whether DIO3 powers a TCXO.
|
||||
is_waveshare: The Waveshare HAT's wiring quirks, which the radio library knows.
|
||||
python: An interpreter to run the node under, when the one MeshTerm would find
|
||||
is not the one you want. Empty to let MeshTerm look.
|
||||
"""
|
||||
|
||||
bus_id: int = 1
|
||||
cs_id: int = 0
|
||||
cs_pin: int = -1
|
||||
gpio_chip: int = 0
|
||||
use_gpiod_backend: bool = False
|
||||
reset_pin: int = 25
|
||||
busy_pin: int = 24
|
||||
irq_pin: int = 26
|
||||
txen_pin: int = -1
|
||||
rxen_pin: int = -1
|
||||
en_pins: tuple[int, ...] = ()
|
||||
use_dio2_rf: bool = True
|
||||
use_dio3_tcxo: bool = True
|
||||
is_waveshare: bool = False
|
||||
python: str = ""
|
||||
|
||||
#: Keys this table once took and must never take again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED`). ``preamble_length`` was never wiring: it is
|
||||
#: protocol, and a fixed 12 against a mesh sending 32 is what left the node deaf to
|
||||
#: most traffic; the node now derives it from the spreading factor as MeshCore does.
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset({"preamble_length"})
|
||||
|
||||
@classmethod
|
||||
def from_toml(cls, table: dict[str, Any]) -> SpiWiring:
|
||||
"""Build the wiring from a ``[profiles.<name>.spi]`` table, defaults for the rest.
|
||||
|
||||
A key this dataclass doesn't know is refused rather than ignored: a misspelt
|
||||
``irq_pn = 22`` would otherwise leave the default in force and a deaf radio with
|
||||
nothing to say why.
|
||||
|
||||
Raises:
|
||||
ValueError: On an unknown key or a value of the wrong type.
|
||||
"""
|
||||
known = {f.name: f for f in cls.__dataclass_fields__.values()}
|
||||
retired = sorted(set(table) & cls.RETIRED)
|
||||
if retired:
|
||||
raise ValueError(
|
||||
f"{', '.join(retired)} is no longer a wiring key — the node sets the "
|
||||
"preamble from the spreading factor, as MeshCore does; delete the line"
|
||||
)
|
||||
unknown = sorted(set(table) - set(known))
|
||||
if unknown:
|
||||
raise ValueError(f"unknown SPI wiring key(s): {', '.join(unknown)}")
|
||||
values: dict[str, Any] = {}
|
||||
for key, raw in table.items():
|
||||
default = getattr(cls(), key)
|
||||
if isinstance(default, bool):
|
||||
ok = isinstance(raw, bool)
|
||||
elif isinstance(default, int):
|
||||
ok = isinstance(raw, int) and not isinstance(raw, bool)
|
||||
elif isinstance(default, tuple):
|
||||
ok = isinstance(raw, list) and all(
|
||||
isinstance(p, int) and not isinstance(p, bool) for p in raw
|
||||
)
|
||||
raw = tuple(raw) if ok else raw
|
||||
else:
|
||||
ok = isinstance(raw, str)
|
||||
if not ok:
|
||||
raise ValueError(f"SPI wiring {key} = {raw!r} is not a {type(default).__name__}")
|
||||
values[key] = raw
|
||||
return cls(**values)
|
||||
|
||||
@property
|
||||
def spidev(self) -> str:
|
||||
"""The SPI device node this wiring opens."""
|
||||
return f"/dev/spidev{self.bus_id}.{self.cs_id}"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeviceProfile:
|
||||
"""Connection defaults for one physical companion device.
|
||||
@@ -76,6 +186,8 @@ class DeviceProfile:
|
||||
host: Hostname or IP of a network companion (e.g. ``"192.168.1.50"``); TCP profiles.
|
||||
tcp_port: TCP port the network companion listens on; TCP profiles (defaults to
|
||||
:data:`~meshterm.core.discovery.DEFAULT_TCP_PORT` when a host is given without one).
|
||||
spi: How the radio is wired, for ``transport = "spi"`` — a LoRa chip on the host's
|
||||
own SPI bus, run by a node MeshTerm starts itself (:mod:`meshterm.core.spiradio`).
|
||||
"""
|
||||
|
||||
name: str
|
||||
@@ -88,6 +200,12 @@ class DeviceProfile:
|
||||
ble_pin: str | None = None
|
||||
host: str | None = None
|
||||
tcp_port: int | None = None
|
||||
spi: SpiWiring | None = None
|
||||
|
||||
@property
|
||||
def is_spi(self) -> bool:
|
||||
"""Whether this profile addresses a radio on the host's own SPI bus."""
|
||||
return self.transport == "spi"
|
||||
|
||||
@property
|
||||
def is_ble(self) -> bool:
|
||||
@@ -137,7 +255,7 @@ class Settings:
|
||||
def __post_init__(self) -> None:
|
||||
"""Derive dependent paths that were not explicitly provided."""
|
||||
if self.db_path is None:
|
||||
self.db_path = self.config_dir / "meshterm.db"
|
||||
self.db_path = self.config_dir / DB_FILENAME
|
||||
|
||||
def resolve_profile(self, name: str | None) -> DeviceProfile | None:
|
||||
"""Look up a profile by name, falling back to the default profile.
|
||||
@@ -179,9 +297,21 @@ class Settings:
|
||||
# keeps port-only profiles working untouched while a bare ``host``/``address`` is
|
||||
# enough to declare a network/Bluetooth one.
|
||||
transport = pdata.get("transport") or (
|
||||
"tcp" if pdata.get("host") else "ble" if pdata.get("address") else "serial"
|
||||
"spi"
|
||||
if "spi" in pdata
|
||||
else "tcp"
|
||||
if pdata.get("host")
|
||||
else "ble"
|
||||
if pdata.get("address")
|
||||
else "serial"
|
||||
)
|
||||
tcp_port = pdata.get("tcp_port")
|
||||
spi = None
|
||||
if transport == "spi":
|
||||
try:
|
||||
spi = SpiWiring.from_toml(pdata.get("spi") or {})
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"[profiles.{pname}.spi]: {exc}") from exc
|
||||
profiles[pname] = DeviceProfile(
|
||||
name=pname,
|
||||
port=pdata.get("port"),
|
||||
@@ -193,6 +323,7 @@ class Settings:
|
||||
ble_pin=pdata.get("ble_pin"),
|
||||
host=pdata.get("host"),
|
||||
tcp_port=int(tcp_port) if tcp_port is not None else None,
|
||||
spi=spi,
|
||||
)
|
||||
|
||||
return cls(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""TOML backup and restore for device configuration.
|
||||
|
||||
A backup captures every registry setting's current value plus experimental custom
|
||||
|
||||
+1554
-110
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
r"""Installing and selecting a console font on Windows, for one user, without admin.
|
||||
|
||||
The classic Windows console does no font fallback: a character its configured font lacks
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Rating contacts by how much they are worth a slot in the device's contact table.
|
||||
|
||||
A companion's contact table is finite, and a mesh fills it with whatever adverts happen to
|
||||
arrive — so a radio left running long enough ends up holding mostly nodes heard once, in
|
||||
passing, from four hops away, while there is no room left to *discover* anyone new. The
|
||||
Contacts screen's bulk sweep (see :func:`~meshterm.ui.purge_screen.purge_contacts`) is how
|
||||
Contacts screen's bulk sweep (see :func:`~meshterm.ui.sweep_screen.archive_contacts`) is how
|
||||
that table gets its headroom back, and this module is the judgement it runs on: one score
|
||||
per contact, so the sweep can take the weakest and leave the ones you would actually miss.
|
||||
|
||||
@@ -17,7 +18,7 @@ must not have.
|
||||
contacts on a real mesh advertise no location, plenty are never overheard as a relayed
|
||||
packet so have no hop count, and a channel poster whose name matches no contact cannot be
|
||||
attributed at all. If "we don't know" resolved to ``0.0``, every one of those would sink to
|
||||
the bottom *together*, and the sweep would purge by how much metadata a node happens to
|
||||
the bottom *together*, and the sweep would archive by how much metadata a node happens to
|
||||
broadcast rather than by how much it is worth. So a term that cannot be computed returns
|
||||
``None`` and is filled with the **population median** of the contacts that could compute it
|
||||
(:func:`_fill_unknowns`) — the contact lands exactly where an average peer would on that
|
||||
@@ -34,17 +35,19 @@ before this history began) has no arrival time to decay from and is protected ou
|
||||
rather than being punished for MeshTerm's own ignorance — see :data:`PROTECT_UNOBSERVED`.
|
||||
|
||||
**Some contacts are never candidates.** A score is a heuristic and a heuristic must not
|
||||
overrule an explicit choice, so :func:`protection_for` short-circuits four cases before any
|
||||
arithmetic: a watched node, anyone you have sent a direct message to, a repeater whose
|
||||
admin credentials are stored, and the unobserved contact above. They are still scored and
|
||||
still ranked — they are real contacts and the percentile scale is the whole population —
|
||||
but they are never swept.
|
||||
overrule an explicit choice, so :func:`protection_for` short-circuits five cases before any
|
||||
arithmetic: a contact you locked, a watched node, anyone you have sent a direct message to,
|
||||
a repeater whose admin credentials are stored, and the unobserved contact above. They are
|
||||
still scored and still ranked — they are real contacts and the percentile scale is the whole
|
||||
population — but they are never swept.
|
||||
|
||||
**What is displayed is the percentile, never the score.** A raw ``47.3`` means nothing
|
||||
without the distribution it came from; a contact's *rank against your other contacts* is
|
||||
self-calibrating, needs no legend, and stays comparable when the weights change. So
|
||||
:class:`ScoredContact` carries :attr:`~ScoredContact.percentile` and the UI shows only that
|
||||
(see :func:`percentile_rank`).
|
||||
**The score is never displayed.** A raw ``47.3`` means nothing without the distribution it
|
||||
came from. What this module hands a screen instead is a *rank* — the list comes back ordered,
|
||||
and :attr:`~ScoredContact.percentile` places one contact in the field without a legend and
|
||||
without going stale when the weights change (see :func:`percentile_rank`). What a screen draws
|
||||
beside a contact is neither: the archive preview shows the :class:`ContactSignals` themselves, a
|
||||
lane per measured kind, because the thing a reader can check against what they know is the
|
||||
evidence, not this module's reading of it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -57,6 +60,10 @@ from statistics import median
|
||||
from .geo import haversine_km
|
||||
from .models import Contact
|
||||
|
||||
#: Protection reason: the reader locked this contact from its page. The most explicit claim
|
||||
#: there is — a lock says nothing but "never archive this one" — so it is checked first.
|
||||
PROTECT_LOCKED = "locked"
|
||||
|
||||
#: Protection reason: the node is starred in the Watchtower. An explicit pin outranks every
|
||||
#: heuristic in this module — the user already said this one matters.
|
||||
PROTECT_WATCHED = "watched"
|
||||
@@ -66,22 +73,29 @@ PROTECT_WATCHED = "watched"
|
||||
#: undone by a score. (Inbound-only traffic does *not* protect — anyone can message you.)
|
||||
PROTECT_MESSAGED = "messaged"
|
||||
|
||||
#: Protection reason: a repeater or room server whose admin password is stored. Purging it
|
||||
#: Protection reason: a repeater or room server whose admin password is stored. Archiving it
|
||||
#: costs the login, which is a far larger loss than a contact slot is a gain.
|
||||
PROTECT_ADMIN = "admin"
|
||||
|
||||
#: Protection reason: MeshTerm has never heard this contact, so every evidence term is
|
||||
#: empty for a reason that says nothing about the node. It came from the device's own table
|
||||
#: (or was added by hand) before this history began; sweeping it would be purging by how
|
||||
#: (or was added by hand) before this history began; sweeping it would be archiving by how
|
||||
#: long MeshTerm has been running rather than by anything the contact did.
|
||||
PROTECT_UNOBSERVED = "unobserved"
|
||||
|
||||
#: The order protections are reported in when a contact qualifies for several — most
|
||||
#: deliberate first, so the preview explains a row by the strongest claim on it.
|
||||
PROTECTION_ORDER = (PROTECT_WATCHED, PROTECT_MESSAGED, PROTECT_ADMIN, PROTECT_UNOBSERVED)
|
||||
PROTECTION_ORDER = (
|
||||
PROTECT_LOCKED,
|
||||
PROTECT_WATCHED,
|
||||
PROTECT_MESSAGED,
|
||||
PROTECT_ADMIN,
|
||||
PROTECT_UNOBSERVED,
|
||||
)
|
||||
|
||||
#: How a protection reads in the UI, keyed by the constants above.
|
||||
PROTECTION_LABELS = {
|
||||
PROTECT_LOCKED: "locked",
|
||||
PROTECT_WATCHED: "watched",
|
||||
PROTECT_MESSAGED: "you messaged",
|
||||
PROTECT_ADMIN: "admin login",
|
||||
@@ -178,6 +192,8 @@ class ContactSignals:
|
||||
which is what :data:`PROTECT_UNOBSERVED` keys on.
|
||||
watched: Whether the node is starred in the Watchtower.
|
||||
has_admin: Whether admin credentials are stored for it.
|
||||
locked: Whether the reader locked the contact against archiving (see
|
||||
:meth:`~meshterm.core.contact_store.ContactStore.set_locked`).
|
||||
"""
|
||||
|
||||
node: str
|
||||
@@ -193,26 +209,29 @@ class ContactSignals:
|
||||
known_days: float | None = None
|
||||
watched: bool = False
|
||||
has_admin: bool = False
|
||||
locked: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoredContact:
|
||||
"""One contact's standing among the others: its score, its percentile, and why.
|
||||
"""One contact's standing among the others: its score, its rank, and the evidence for both.
|
||||
|
||||
Attributes:
|
||||
contact: The contact this describes.
|
||||
signals: The evidence the score was computed from.
|
||||
score: The weighted sum plus any newcomer grace. Never displayed — see
|
||||
:attr:`percentile`, and the module docstring for why.
|
||||
percentile: Percentile rank within the scored population, ``0``–``100``. **This is
|
||||
the only number the UI shows**: it is self-calibrating (a mesh of 30 contacts
|
||||
and one of 300 both read the same way), needs no legend, and stays meaningful
|
||||
if the weights are ever retuned.
|
||||
percentile: Percentile rank within the scored population, ``0``–``100``. The
|
||||
score's one publishable reading: self-calibrating (a mesh of 30 contacts and
|
||||
one of 300 both read the same way), needing no legend, and still meaningful if
|
||||
the weights are ever retuned.
|
||||
protection: Why this contact can never be swept, or ``None`` if it can. One of the
|
||||
``PROTECT_*`` constants.
|
||||
reasons: Short lowercase phrases naming what this contact's standing rests on —
|
||||
its weakest lanes when it is a purge candidate, so the preview row explains
|
||||
itself without the reader having to know the formula.
|
||||
|
||||
The **signals** are what a screen shows, not the score or its terms: the archive preview
|
||||
draws one lane per measured kind straight off :attr:`signals` (see
|
||||
:mod:`~meshterm.ui.sweep_screen`), so a reader auditing a sweep reads the evidence in
|
||||
the same units it was gathered in rather than a phrase this module chose for them.
|
||||
"""
|
||||
|
||||
contact: Contact
|
||||
@@ -220,7 +239,6 @@ class ScoredContact:
|
||||
score: float
|
||||
percentile: int
|
||||
protection: str | None = None
|
||||
reasons: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def protected(self) -> bool:
|
||||
@@ -374,6 +392,7 @@ def protection_for(signals: ContactSignals) -> str | None:
|
||||
One of the ``PROTECT_*`` constants, or ``None``.
|
||||
"""
|
||||
claims = {
|
||||
PROTECT_LOCKED: signals.locked,
|
||||
PROTECT_WATCHED: signals.watched,
|
||||
PROTECT_MESSAGED: signals.dm_outbound > 0,
|
||||
PROTECT_ADMIN: signals.has_admin,
|
||||
@@ -394,7 +413,7 @@ def protection_for(signals: ContactSignals) -> str | None:
|
||||
#: unknown.
|
||||
_MIN_LOCATED = 4
|
||||
|
||||
#: The term names, in the order a :class:`ScoredContact` reports its reasons.
|
||||
#: The term names, in the order the weighted sum walks them.
|
||||
_TERM_NAMES = ("dm", "recency", "volume", "channel", "hops", "distance")
|
||||
|
||||
|
||||
@@ -448,54 +467,6 @@ def percentile_rank(score: float, population: Sequence[float]) -> int:
|
||||
return int(round(100.0 * (below + 0.5 * equal) / len(population)))
|
||||
|
||||
|
||||
def _reasons(
|
||||
signals: ContactSignals, terms: dict[str, float], weights: ScoreWeights
|
||||
) -> tuple[str, ...]:
|
||||
"""Name what this contact's standing rests on, weakest lane first.
|
||||
|
||||
The preview has one line per contact and the reader should not have to know the formula
|
||||
to audit a sweep — so each row says, in plain words, the two things that put it where it
|
||||
is. Phrases are drawn from the *measured* signals rather than from the term values, so
|
||||
"3 packets" is the actual tally and not a normalised fraction.
|
||||
"""
|
||||
phrases: list[tuple[float, str]] = []
|
||||
contribution = {
|
||||
"dm": terms["dm"] * weights.dm,
|
||||
"recency": terms["recency"] * weights.recency,
|
||||
"volume": terms["volume"] * weights.volume,
|
||||
"hops": terms["hops"] * weights.hops,
|
||||
}
|
||||
if signals.dm_total:
|
||||
phrases.append((contribution["dm"], f"{signals.dm_total} messages"))
|
||||
else:
|
||||
phrases.append((0.0, "never messaged"))
|
||||
if signals.heard_age_days is None:
|
||||
phrases.append((0.0, "never heard"))
|
||||
else:
|
||||
phrases.append((contribution["recency"], _age_phrase(signals.heard_age_days)))
|
||||
packets = signals.packets
|
||||
phrases.append((contribution["volume"], f"{packets} pkt{'' if packets == 1 else 's'}"))
|
||||
if signals.hops is not None:
|
||||
hops = signals.hops
|
||||
label = "direct" if hops < 0.5 else f"{hops:g} hops"
|
||||
phrases.append((contribution["hops"], label))
|
||||
phrases.sort(key=lambda pair: pair[0])
|
||||
return tuple(phrase for _, phrase in phrases[:3])
|
||||
|
||||
|
||||
def _age_phrase(days: float) -> str:
|
||||
"""A last-heard age in the fewest words: ``today`` / ``4d`` / ``6w`` / ``9mo`` / ``2y``."""
|
||||
if days < 1:
|
||||
return "today"
|
||||
if days < 14:
|
||||
return f"{int(days)}d"
|
||||
if days < 60:
|
||||
return f"{int(days / 7)}w"
|
||||
if days < 365:
|
||||
return f"{int(days / 30)}mo"
|
||||
return f"{days / 365:.0f}y"
|
||||
|
||||
|
||||
def rank_contacts(
|
||||
contacts: Sequence[Contact],
|
||||
signals: dict[str, ContactSignals],
|
||||
@@ -529,7 +500,7 @@ def rank_contacts(
|
||||
|
||||
gathered: list[ContactSignals] = []
|
||||
for contact in contacts:
|
||||
node = _node_id(contact)
|
||||
node = node_id(contact)
|
||||
found = signals.get(node) or ContactSignals(node=node)
|
||||
gathered.append(replace(found, distance_km=_self_distance(contact, self_lat, self_lon)))
|
||||
|
||||
@@ -549,7 +520,6 @@ def rank_contacts(
|
||||
filled = {name: _fill_unknowns(column) for name, column in columns.items()}
|
||||
|
||||
scores: list[float] = []
|
||||
per_contact_terms: list[dict[str, float]] = []
|
||||
for index, sig in enumerate(gathered):
|
||||
terms = {name: filled[name][index] for name in _TERM_NAMES}
|
||||
total = (
|
||||
@@ -561,7 +531,6 @@ def rank_contacts(
|
||||
+ terms["distance"] * weights.distance
|
||||
+ term_grace(sig, weights)
|
||||
)
|
||||
per_contact_terms.append(terms)
|
||||
scores.append(total)
|
||||
|
||||
ranked = [
|
||||
@@ -571,11 +540,8 @@ def rank_contacts(
|
||||
score=score,
|
||||
percentile=percentile_rank(score, scores),
|
||||
protection=protection_for(sig),
|
||||
reasons=_reasons(sig, terms, weights),
|
||||
)
|
||||
for contact, sig, score, terms in zip(
|
||||
contacts, gathered, scores, per_contact_terms, strict=True
|
||||
)
|
||||
for contact, sig, score in zip(contacts, gathered, scores, strict=True)
|
||||
]
|
||||
ranked.sort(key=lambda scored: (-scored.score, scored.contact.name.casefold()))
|
||||
return ranked
|
||||
@@ -590,7 +556,7 @@ def sweep_candidates(ranked: Sequence[ScoredContact], keep: int) -> list[ScoredC
|
||||
protections against the target instead would mean starring a node quietly deepened the
|
||||
next sweep, which is the opposite of what a star is for. The picker's rungs therefore
|
||||
report their own real removal counts rather than arithmetic on the table size (see
|
||||
:func:`~meshterm.ui.purge_screen.purge_contacts`).
|
||||
:func:`~meshterm.ui.sweep_screen.archive_contacts`).
|
||||
|
||||
Args:
|
||||
ranked: The full ranking from :func:`rank_contacts`, strongest first.
|
||||
@@ -607,7 +573,7 @@ def sweep_candidates(ranked: Sequence[ScoredContact], keep: int) -> list[ScoredC
|
||||
return list(reversed(victims))
|
||||
|
||||
|
||||
def _node_id(contact: Contact) -> str:
|
||||
def node_id(contact: Contact) -> str:
|
||||
"""The 12-hex canonical id a contact's history is keyed by (see ``observations.node``)."""
|
||||
ident = contact.public_key or contact.key_prefix or ""
|
||||
return ident.lower().removeprefix("0x")[:12]
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for contacts heard through MeshTerm, so they survive a device that forgets them.
|
||||
|
||||
Most companions keep their contact table in firmware, so MeshTerm reads contacts live from the
|
||||
@@ -33,6 +34,13 @@ it back. So a restore is one write, and the chat screen already performs it on d
|
||||
send is rejected for an unknown recipient (see
|
||||
:class:`~meshterm.core.connection.ContactNotOnDeviceError`).
|
||||
|
||||
A live contact can also be **locked**, which is the reader's own word that it stays: a
|
||||
locked contact is never a sweep candidate (see
|
||||
:data:`~meshterm.core.contact_score.PROTECT_LOCKED`) and its page offers no Archive. The flag
|
||||
lives here rather than on the device because the firmware has no such field — it is
|
||||
MeshTerm's claim about the contact, kept beside the other one it makes (the archive stamp),
|
||||
and a device read never clears it.
|
||||
|
||||
The one place to be careful is a firmware-less bridge, whose contact table is RAM-only and
|
||||
for which this store *is* the memory: archiving there removes the contact from the lists for
|
||||
real, and only a restore brings it back.
|
||||
@@ -46,8 +54,9 @@ contact set actually changes, and flushes atomically.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .models import Contact, advert_time
|
||||
@@ -96,6 +105,9 @@ class RememberedContact:
|
||||
while it is a live contact. An archived contact is remembered in full but kept
|
||||
*out* of :func:`merge_contacts`, so it stops occupying a device slot without
|
||||
being forgotten — and the stamp is what lets a list say how long ago it went.
|
||||
locked: Whether the reader locked this contact against archiving. MeshTerm's own
|
||||
state, not the device's, so :meth:`ContactStore.remember_all` carries it across
|
||||
every fresh read of the contact rather than resetting it.
|
||||
"""
|
||||
|
||||
public_key: str
|
||||
@@ -105,6 +117,11 @@ class RememberedContact:
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
archived_at: int | None = None
|
||||
locked: bool = False
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@property
|
||||
def archived(self) -> bool:
|
||||
@@ -218,7 +235,11 @@ class ContactStore:
|
||||
if not contact.public_key:
|
||||
continue # unaddressable — nothing to remember it by
|
||||
remembered = RememberedContact.from_contact(contact)
|
||||
if current.get(remembered.public_key) != remembered:
|
||||
known = current.get(remembered.public_key)
|
||||
if known is not None and known.locked:
|
||||
# The lock is ours, not the device's: a fresh read knows nothing about it.
|
||||
remembered = replace(remembered, locked=True)
|
||||
if known != remembered:
|
||||
current[remembered.public_key] = remembered
|
||||
changed = True
|
||||
if changed:
|
||||
@@ -255,16 +276,7 @@ class ContactStore:
|
||||
dev = _norm(device_pubkey)
|
||||
if not dev or not contact.public_key:
|
||||
return # unaddressable — there would be nothing to restore it by
|
||||
remembered = RememberedContact.from_contact(contact)
|
||||
entry = RememberedContact(
|
||||
public_key=remembered.public_key,
|
||||
name=remembered.name,
|
||||
node_type=remembered.node_type,
|
||||
last_advert=remembered.last_advert,
|
||||
lat=remembered.lat,
|
||||
lon=remembered.lon,
|
||||
archived_at=int(when),
|
||||
)
|
||||
entry = replace(RememberedContact.from_contact(contact), archived_at=int(when))
|
||||
current = dict(self._state.get(dev, {}))
|
||||
if current.get(entry.public_key) == entry:
|
||||
return
|
||||
@@ -294,19 +306,45 @@ class ContactStore:
|
||||
if entry is None or not entry.archived:
|
||||
return None
|
||||
current = dict(contacts)
|
||||
current[key] = RememberedContact(
|
||||
public_key=entry.public_key,
|
||||
name=entry.name,
|
||||
node_type=entry.node_type,
|
||||
last_advert=entry.last_advert,
|
||||
lat=entry.lat,
|
||||
lon=entry.lon,
|
||||
archived_at=None,
|
||||
)
|
||||
current[key] = replace(entry, archived_at=None)
|
||||
self._state[dev] = current
|
||||
self._save()
|
||||
return entry
|
||||
|
||||
def locked_keys(self, device_pubkey: str) -> frozenset[str]:
|
||||
"""The full keys (lowercase hex) of every contact locked on this device."""
|
||||
remembered = self._state.get(_norm(device_pubkey), {}).values()
|
||||
return frozenset(c.public_key for c in remembered if c.locked)
|
||||
|
||||
def is_locked(self, device_pubkey: str, contact_pubkey: str) -> bool:
|
||||
"""Whether one contact is locked against archiving on this device."""
|
||||
entry = self._state.get(_norm(device_pubkey), {}).get(_norm(contact_pubkey))
|
||||
return entry is not None and entry.locked
|
||||
|
||||
def set_locked(self, device_pubkey: str, contact: Contact, locked: bool) -> None:
|
||||
"""Lock or unlock one contact, upserting it so a contact the store never saw can be.
|
||||
|
||||
Only a live contact is locked — the lock exists to keep a contact *off* the archive
|
||||
path, so locking an archived one would claim something about a contact that has
|
||||
already gone. Persists only a real change.
|
||||
|
||||
Args:
|
||||
device_pubkey: The device's own public key.
|
||||
contact: The contact to lock or unlock, addressed by its full key.
|
||||
locked: The state to leave it in.
|
||||
"""
|
||||
dev = _norm(device_pubkey)
|
||||
if not dev or not contact.public_key:
|
||||
return # unaddressable — nothing to hang the flag on
|
||||
current = dict(self._state.get(dev, {}))
|
||||
key = _norm(contact.public_key)
|
||||
known = current.get(key) or RememberedContact.from_contact(contact)
|
||||
if known.archived or known.locked == locked:
|
||||
return
|
||||
current[key] = replace(known, locked=locked)
|
||||
self._state[dev] = current
|
||||
self._save()
|
||||
|
||||
def forget(self, device_pubkey: str, contact_pubkey: str) -> None:
|
||||
"""Drop one remembered contact; persist only a real change."""
|
||||
dev = _norm(device_pubkey)
|
||||
@@ -349,6 +387,8 @@ def _contact_to_json(contact: RememberedContact) -> dict:
|
||||
entry["lon"] = contact.lon
|
||||
if contact.archived_at is not None:
|
||||
entry["archived_at"] = contact.archived_at
|
||||
if contact.locked:
|
||||
entry["locked"] = True
|
||||
return entry
|
||||
|
||||
|
||||
@@ -368,6 +408,7 @@ def _contact_from_json(entry: object) -> RememberedContact | None:
|
||||
lat=_opt_float(entry.get("lat")),
|
||||
lon=_opt_float(entry.get("lon")),
|
||||
archived_at=_opt_int(entry.get("archived_at")),
|
||||
locked=entry.get("locked") is True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for the Courier: the store-and-forward outbox.
|
||||
|
||||
The Courier (see :mod:`meshterm.services.courier`) queues direct messages for contacts
|
||||
@@ -18,6 +19,7 @@ import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .models import utcnow
|
||||
@@ -28,7 +30,8 @@ DELIVERED = "delivered"
|
||||
GAVE_UP = "gave-up"
|
||||
|
||||
#: How many finished (delivered / given-up) entries are kept for the screen's history —
|
||||
#: the code's default behind the ``courier_history_kept`` preference.
|
||||
#: *the* default behind the ``courier_history_kept`` preference, which the registry names
|
||||
#: rather than re-types (:data:`meshterm.core.preferences.PREFERENCES`).
|
||||
DONE_CAP = 100
|
||||
|
||||
|
||||
@@ -64,6 +67,10 @@ class QueuedMessage:
|
||||
status: str = QUEUED
|
||||
finished: datetime | None = None
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
|
||||
class CourierStore:
|
||||
"""Reads and writes the outbox, memory-first (loaded once, persisted on change)."""
|
||||
|
||||
+175
-15
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Declarative registry of every settable device configuration value.
|
||||
|
||||
Each :class:`SettingSpec` describes one tunable setting: how to read its current value
|
||||
@@ -17,7 +18,8 @@ from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .connection import Device
|
||||
from . import regions
|
||||
from .connection import Device, repeat_freq_allowed
|
||||
|
||||
# Display categories, in the order the editor and `config show` present them.
|
||||
CATEGORIES = ("Identity", "Radio", "Tuning", "Behavior", "Experimental")
|
||||
@@ -53,6 +55,12 @@ class SettingSpec:
|
||||
the connected hardware actually supports.
|
||||
max_length: For ``str`` values, the longest accepted string (protocol field
|
||||
widths, e.g. the flood scope's 31-byte name slot).
|
||||
decimals: For ``float`` values, the fixed decimal places a value is shown with and
|
||||
rounded to on parse, so what is applied is what the row showed; ``None`` keeps
|
||||
the value as given.
|
||||
validate: A last rule for a typed, in-range value, given the snapshot when there
|
||||
is one: it returns the complaint, or ``None``. For what a bound can't state —
|
||||
the PIN's "zero or six digits", relaying only on an allowed frequency.
|
||||
getter: Extracts the current value from a snapshot dict.
|
||||
apply: Coroutine applying a parsed value to a device, given the snapshot.
|
||||
"""
|
||||
@@ -70,6 +78,8 @@ class SettingSpec:
|
||||
strict_choices: bool = True
|
||||
max_key: str | None = None
|
||||
max_length: int | None = None
|
||||
decimals: int | None = None
|
||||
validate: Callable[[Any, dict | None], str | None] | None = None
|
||||
|
||||
|
||||
# --- value parsing / formatting ----------------------------------------------
|
||||
@@ -92,10 +102,29 @@ def parse_value(spec: SettingSpec, raw: Any, snapshot: dict | None = None) -> An
|
||||
The typed, range-checked value ready for :meth:`SettingSpec.apply`.
|
||||
|
||||
Raises:
|
||||
DeviceConfigError: If the value is the wrong type or out of range.
|
||||
DeviceConfigError: If the value is the wrong type, out of range, or breaks the
|
||||
setting's own :attr:`~SettingSpec.validate` rule.
|
||||
"""
|
||||
value = _parse_typed(spec, raw, snapshot)
|
||||
if spec.validate is not None:
|
||||
complaint = spec.validate(value, snapshot)
|
||||
if complaint:
|
||||
raise DeviceConfigError(f"{spec.key}: {complaint}")
|
||||
return value
|
||||
|
||||
|
||||
def _parse_typed(spec: SettingSpec, raw: Any, snapshot: dict | None) -> Any:
|
||||
""":func:`parse_value` short of the setting's own rule: the type, bounds and choices."""
|
||||
text = str(raw).strip()
|
||||
if spec.value_type == "str":
|
||||
# `config show` prints an empty string as the two characters `""`, because a key
|
||||
# with nothing after it reads as a truncated line rather than as an empty value.
|
||||
# A value the dump prints has to be one `set` takes back (CLAUDE.md, "A value must
|
||||
# round-trip"), so the quotes come off here — otherwise feeding a dump back in
|
||||
# silently replaces every empty setting with a pair of quote marks, and the next
|
||||
# dump looks identical, so nothing ever tells you.
|
||||
if text == '""':
|
||||
text = ""
|
||||
if spec.max_length is not None and len(text) > spec.max_length:
|
||||
raise DeviceConfigError(
|
||||
f"{spec.key}: must be at most {spec.max_length} characters, got {len(text)}"
|
||||
@@ -114,6 +143,8 @@ def parse_value(spec: SettingSpec, raw: Any, snapshot: dict | None = None) -> An
|
||||
try:
|
||||
if spec.value_type == "float":
|
||||
value: Any = float(text)
|
||||
if spec.decimals is not None:
|
||||
value = round(value, spec.decimals)
|
||||
else: # int or enum
|
||||
value = int(text, 0) if isinstance(raw, str) else int(raw)
|
||||
except (ValueError, TypeError) as exc:
|
||||
@@ -176,6 +207,8 @@ def format_value(spec: SettingSpec, value: Any) -> str:
|
||||
return "true" if value else "false"
|
||||
if spec.value_type == "enum" and spec.choices is not None and value in spec.choices:
|
||||
return f"{value} ({spec.choices[value]})"
|
||||
if spec.value_type == "float" and spec.decimals is not None:
|
||||
return f"{float(value):.{spec.decimals}f}"
|
||||
return str(value)
|
||||
|
||||
|
||||
@@ -204,7 +237,9 @@ async def build_snapshot(
|
||||
|
||||
Returns:
|
||||
A dict keyed like ``SELF_INFO`` plus ``rx_delay``, ``airtime_factor``,
|
||||
``path_hash_mode``, ``autoadd_config`` and ``flood_scope``.
|
||||
``path_hash_mode``, ``autoadd_config``, ``autoadd_max_hops`` and ``flood_scope``,
|
||||
with the device-query frame's facts (``ble_pin``, ``model``, client ``repeat``)
|
||||
underneath and, where the firmware relays at all, its ``repeat_freqs``.
|
||||
"""
|
||||
snapshot = dict(self_info if self_info is not None else await device.get_self_info())
|
||||
try:
|
||||
@@ -222,6 +257,12 @@ async def build_snapshot(
|
||||
snapshot["autoadd_config"] = await device.get_autoadd_config()
|
||||
except Exception: # noqa: BLE001 - optional read; absence is acceptable
|
||||
pass
|
||||
try:
|
||||
hops = await device.get_autoadd_max_hops()
|
||||
except Exception: # noqa: BLE001 - optional read; absence is acceptable
|
||||
hops = None
|
||||
if hops is not None:
|
||||
snapshot["autoadd_max_hops"] = hops
|
||||
try:
|
||||
snapshot["flood_scope"] = await device.get_default_flood_scope()
|
||||
except Exception: # noqa: BLE001 - optional read; absence is acceptable
|
||||
@@ -236,6 +277,15 @@ async def build_snapshot(
|
||||
snapshot = {**await device.get_device_info(), **snapshot}
|
||||
except Exception: # noqa: BLE001 - optional read; absence is acceptable
|
||||
pass
|
||||
# Client repeat (firmware v9+) may only be switched on where the firmware allows. The
|
||||
# ranges are one more round trip, so they are asked only of firmware that can relay.
|
||||
if "repeat" in snapshot:
|
||||
try:
|
||||
freqs = await device.get_allowed_repeat_freqs()
|
||||
except Exception: # noqa: BLE001 - optional read; absence is acceptable
|
||||
freqs = []
|
||||
if freqs:
|
||||
snapshot["repeat_freqs"] = [tuple(pair) for pair in freqs]
|
||||
return snapshot
|
||||
|
||||
|
||||
@@ -265,7 +315,15 @@ def _radio_apply(field_name: str) -> Callable[[Device, Any, dict], Awaitable[Non
|
||||
"cr": snapshot.get("radio_cr"),
|
||||
}
|
||||
params[field_name] = value
|
||||
await device.set_radio(params["freq"], params["bw"], params["sf"], params["cr"])
|
||||
# Client repeat rides the same command, and the firmware reads its absence as off:
|
||||
# restate it, or retuning the radio quietly stops the node relaying.
|
||||
await device.set_radio(
|
||||
params["freq"],
|
||||
params["bw"],
|
||||
params["sf"],
|
||||
params["cr"],
|
||||
repeat=snapshot.get("repeat"),
|
||||
)
|
||||
snapshot[f"radio_{field_name}"] = value
|
||||
|
||||
return apply
|
||||
@@ -309,23 +367,81 @@ def _telemetry_apply(field_name: str) -> Callable[[Device, Any, dict], Awaitable
|
||||
return apply
|
||||
|
||||
|
||||
async def _client_repeat_apply(device: Device, value: Any, snapshot: dict) -> None:
|
||||
"""Switch client repeat, restating the four radio fields it travels with."""
|
||||
radio = [snapshot.get(key) for key in ("radio_freq", "radio_bw", "radio_sf", "radio_cr")]
|
||||
if any(field is None for field in radio):
|
||||
raise DeviceConfigError(
|
||||
"client_repeat: the radio settings were never read, so they can't be restated"
|
||||
)
|
||||
await device.set_radio(*radio, repeat=bool(value))
|
||||
snapshot["repeat"] = bool(value)
|
||||
|
||||
|
||||
def _flood_scope_valid(value: Any, snapshot: dict | None) -> str | None:
|
||||
"""Refuse a default scope name the firmware would refuse (see :func:`regions.validate`).
|
||||
|
||||
``max_length`` counts characters; the firmware counts UTF-8 bytes, and a repeater could
|
||||
never list back a name with a space or comma in it. Empty is valid — it clears.
|
||||
"""
|
||||
if not str(value or "").strip():
|
||||
return None
|
||||
try:
|
||||
regions.validate(str(value))
|
||||
except regions.RegionNameError as exc:
|
||||
return str(exc)
|
||||
return None
|
||||
|
||||
|
||||
async def _autoadd_hops_apply(device: Device, value: Any, snapshot: dict) -> None:
|
||||
"""Set the auto-add hop limit, restating the bitmask it travels behind."""
|
||||
flags = snapshot.get("autoadd_config")
|
||||
if flags is None:
|
||||
raise DeviceConfigError(
|
||||
"autoadd_max_hops: the auto-add bitmask was never read, so it can't be restated"
|
||||
)
|
||||
await device.set_autoadd_config(int(flags), max_hops=int(value))
|
||||
snapshot["autoadd_max_hops"] = value
|
||||
|
||||
|
||||
def _pin_rule(value: Any, snapshot: dict | None) -> str | None:
|
||||
"""The firmware takes a pairing PIN of zero (none) or exactly six digits, nothing else."""
|
||||
if value == 0 or 100000 <= value <= 999999:
|
||||
return None
|
||||
return f"must be 0 (no PIN) or six digits, got {value}"
|
||||
|
||||
|
||||
def _repeat_rule(value: Any, snapshot: dict | None) -> str | None:
|
||||
"""Relaying goes on only at a frequency the firmware allows it (when both are known)."""
|
||||
if not value or not snapshot:
|
||||
return None
|
||||
ranges, freq = snapshot.get("repeat_freqs"), snapshot.get("radio_freq")
|
||||
if not ranges or freq is None or repeat_freq_allowed(freq, ranges):
|
||||
return None
|
||||
allowed = ", ".join(f"{low:g}" if low == high else f"{low:g}–{high:g}" for low, high in ranges)
|
||||
return f"this firmware relays only on {allowed} MHz, not {float(freq):g}"
|
||||
|
||||
|
||||
def _get(key: str) -> Callable[[dict], Any]:
|
||||
"""Build a getter that reads ``key`` from a snapshot."""
|
||||
return lambda snapshot: snapshot.get(key)
|
||||
|
||||
|
||||
_TELEMETRY_CHOICES = {0: "off", 1: "always", 2: "device-only", 3: "all"}
|
||||
# The firmware's TELEM_MODE_DENY / TELEM_MODE_ALLOW_FLAGS / TELEM_MODE_ALLOW_ALL: nobody, the
|
||||
# contacts whose telemetry permission flag is set, or anyone who asks. There is no mode 3.
|
||||
# The labels stay short because the widest value sizes the editor's value lane for every row.
|
||||
_TELEMETRY_CHOICES = {0: "deny", 1: "by contact", 2: "allow all"}
|
||||
|
||||
# adv_loc_policy / multi_acks are single firmware bytes; we list the values seen in the
|
||||
# wild but keep them non-strict so an unfamiliar value is still accepted.
|
||||
_ADV_LOC_CHOICES = {0: "off", 1: "on"}
|
||||
_MULTI_ACKS_CHOICES = {0: "off", 1: "on"}
|
||||
# path_hash_mode is a 2-bit field; the hash size carried per hop is mode + 1 bytes.
|
||||
# path_hash_mode is a 2-bit field; the hash size carried per hop is mode + 1 bytes. The field
|
||||
# has room for a mode 3, but CMD_SET_PATH_HASH_MODE refuses it (``cmd_frame[2] >= 3``).
|
||||
_PATH_HASH_CHOICES = {
|
||||
0: "1-byte hashes (default)",
|
||||
1: "2-byte hashes",
|
||||
2: "3-byte hashes",
|
||||
3: "4-byte hashes",
|
||||
}
|
||||
|
||||
|
||||
@@ -339,7 +455,7 @@ def _telemetry_spec(key: str, label: str) -> SettingSpec:
|
||||
value_type="enum",
|
||||
choices=_TELEMETRY_CHOICES,
|
||||
minimum=0,
|
||||
maximum=3,
|
||||
maximum=2,
|
||||
getter=_get(key),
|
||||
apply=_telemetry_apply(key),
|
||||
)
|
||||
@@ -465,6 +581,7 @@ DEVICE_SETTINGS: list[SettingSpec] = [
|
||||
"The name other nodes see",
|
||||
"Identity",
|
||||
"str",
|
||||
max_length=31, # the firmware's 32-byte name field, its terminator included
|
||||
getter=_get("name"),
|
||||
apply=lambda d, v, s: d.set_name(v),
|
||||
),
|
||||
@@ -504,6 +621,7 @@ DEVICE_SETTINGS: list[SettingSpec] = [
|
||||
"int",
|
||||
minimum=0,
|
||||
maximum=999999,
|
||||
validate=_pin_rule,
|
||||
getter=lambda snapshot: snapshot.get("ble_pin", snapshot.get("device_pin")),
|
||||
apply=lambda d, v, s: d.set_device_pin(v),
|
||||
),
|
||||
@@ -514,8 +632,9 @@ DEVICE_SETTINGS: list[SettingSpec] = [
|
||||
"Must match every other node on your mesh",
|
||||
"Radio",
|
||||
"float",
|
||||
minimum=100.0,
|
||||
maximum=1000.0,
|
||||
# The bounds CMD_SET_RADIO_PARAMS itself enforces (150–2500 MHz, 7–500 kHz below).
|
||||
minimum=150.0,
|
||||
maximum=2500.0,
|
||||
getter=_get("radio_freq"),
|
||||
apply=_radio_apply("freq"),
|
||||
),
|
||||
@@ -525,8 +644,8 @@ DEVICE_SETTINGS: list[SettingSpec] = [
|
||||
"Wider is faster, narrower reaches further",
|
||||
"Radio",
|
||||
"float",
|
||||
minimum=1.0,
|
||||
maximum=1000.0,
|
||||
minimum=7.0,
|
||||
maximum=500.0,
|
||||
getter=_get("radio_bw"),
|
||||
apply=_radio_apply("bw"),
|
||||
),
|
||||
@@ -558,12 +677,26 @@ DEVICE_SETTINGS: list[SettingSpec] = [
|
||||
"How loud this radio transmits",
|
||||
"Radio",
|
||||
"int",
|
||||
minimum=1,
|
||||
minimum=-9, # CMD_SET_RADIO_TX_POWER's floor; the ceiling is the board's own
|
||||
maximum=30,
|
||||
max_key="max_tx_power",
|
||||
getter=_get("tx_power"),
|
||||
apply=lambda d, v, s: d.set_tx_power(v),
|
||||
),
|
||||
SettingSpec(
|
||||
# Client repeat (firmware v9+): the companion relays mesh traffic like a repeater.
|
||||
# Reported in the device-query frame as ``repeat`` and written as the optional last
|
||||
# byte of the radio command, so it restates the radio and every radio change
|
||||
# restates it (see _radio_apply). The firmware allows it only on a few frequencies.
|
||||
"client_repeat",
|
||||
"Repeat",
|
||||
"Relay mesh traffic; allowed frequencies only",
|
||||
"Radio",
|
||||
"bool",
|
||||
getter=_get("repeat"),
|
||||
apply=_client_repeat_apply,
|
||||
validate=_repeat_rule,
|
||||
),
|
||||
# Tuning. Both are firmware floats moved over the wire ×1000; the ranges are the
|
||||
# firmware's own constrain() bounds. (The repeater-side TX delay factors are *not*
|
||||
# here: companion firmware ignores them — they are remote-CLI settings on repeaters.)
|
||||
@@ -586,6 +719,7 @@ DEVICE_SETTINGS: list[SettingSpec] = [
|
||||
"float",
|
||||
minimum=0.0,
|
||||
maximum=20.0,
|
||||
decimals=1,
|
||||
getter=_get("rx_delay"),
|
||||
apply=_tuning_apply("rx_delay"),
|
||||
),
|
||||
@@ -610,13 +744,27 @@ DEVICE_SETTINGS: list[SettingSpec] = [
|
||||
getter=_get("autoadd_config"),
|
||||
apply=lambda d, v, s: d.set_autoadd_config(v),
|
||||
),
|
||||
SettingSpec(
|
||||
# The firmware compares it with an advert's path hash count: 0 is no limit, 1 is
|
||||
# direct neighbours only, N admits up to N-1 hops. Capped at 64.
|
||||
"autoadd_max_hops",
|
||||
"Auto-add max hops",
|
||||
"Only auto-add nodes this near; 1 = direct, 0 = any",
|
||||
"Behavior",
|
||||
"int",
|
||||
minimum=0,
|
||||
maximum=64,
|
||||
getter=_get("autoadd_max_hops"),
|
||||
apply=_autoadd_hops_apply,
|
||||
),
|
||||
SettingSpec(
|
||||
"flood_scope",
|
||||
"Flood scope",
|
||||
"Keep traffic to one group; empty reaches everyone",
|
||||
"Keep floods in one region; empty reaches everyone",
|
||||
"Behavior",
|
||||
"str",
|
||||
max_length=30,
|
||||
validate=_flood_scope_valid,
|
||||
getter=_get("flood_scope"),
|
||||
apply=lambda d, v, s: d.set_default_flood_scope(v),
|
||||
),
|
||||
@@ -656,7 +804,7 @@ DEVICE_SETTINGS: list[SettingSpec] = [
|
||||
"enum",
|
||||
choices=_PATH_HASH_CHOICES,
|
||||
minimum=0,
|
||||
maximum=3,
|
||||
maximum=2,
|
||||
getter=_get("path_hash_mode"),
|
||||
apply=lambda d, v, s: d.set_path_hash_mode(v),
|
||||
),
|
||||
@@ -664,6 +812,18 @@ DEVICE_SETTINGS: list[SettingSpec] = [
|
||||
|
||||
_BY_KEY: dict[str, SettingSpec] = {s.key: s for s in DEVICE_SETTINGS}
|
||||
|
||||
#: Every key that was once a device setting and no longer is — none yet. A retired key is
|
||||
#: never reused, for the reason :data:`meshterm.core.preferences.RETIRED` gives, and with
|
||||
#: more at stake here: the settings store remembers values to *restore onto the radio*, so a
|
||||
#: key that came back meaning something else would offer to write the old value's number
|
||||
#: into the new setting. ``tests/test_retired.py`` fails a registry that takes one back.
|
||||
RETIRED: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def is_setting_key(key: str) -> bool:
|
||||
"""Whether ``key`` names a device setting in the registry (and not a retired one)."""
|
||||
return key in _BY_KEY
|
||||
|
||||
|
||||
def get_spec(key: str) -> SettingSpec:
|
||||
"""Return the spec for ``key``.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for confirmed companion devices.
|
||||
|
||||
Every device that has ever spoken the MeshCore protocol to us successfully is remembered
|
||||
@@ -11,6 +12,15 @@ The registry also tracks which device was most recently connected (``last``), us
|
||||
preselect and star a default on the startup splash. Membership in the registry is what marks
|
||||
a device as a confirmed MeshCore companion — the splash reserves its "MeshCore device" tag
|
||||
for these, rather than guessing from the USB vendor ID.
|
||||
|
||||
Alongside those it keeps ``hidden``: the stable ids the splash has been told to stop
|
||||
listing. It lives here rather than in the preferences because it is a fact about *this
|
||||
machine's* hardware — the USB adapters and dev boards permanently plugged into it that are
|
||||
not companions — and because the ids it holds are the registry's own. Hiding is a **splash**
|
||||
concern only: nothing else consults it, so ``--port`` and every scripted path still reach a
|
||||
hidden device by name. A hidden id need not be a remembered device (usually it is the
|
||||
opposite: a serial adapter nobody wants to see), so the set stands on its own rather than
|
||||
being a flag on a record.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,6 +28,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .discovery import (
|
||||
@@ -65,6 +76,10 @@ class RememberedDevice:
|
||||
host: str = ""
|
||||
tcp_port: int = 0
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@property
|
||||
def is_ble(self) -> bool:
|
||||
"""Whether this remembered device was reached over Bluetooth LE."""
|
||||
@@ -101,8 +116,8 @@ class DeviceStore:
|
||||
"""
|
||||
self._path = path
|
||||
|
||||
def _read(self) -> tuple[dict[str, RememberedDevice], str | None]:
|
||||
"""Return the parsed ``(registry, last_id)``; empty on a missing/corrupt file.
|
||||
def _read(self) -> tuple[dict[str, RememberedDevice], str | None, set[str]]:
|
||||
"""Return the parsed ``(registry, last_id, hidden_ids)``; empty on a missing file.
|
||||
|
||||
A missing or corrupt file is treated as "nothing remembered" rather than an error,
|
||||
so a stray edit never blocks startup. An old flat-format file (a single record at
|
||||
@@ -112,16 +127,16 @@ class DeviceStore:
|
||||
try:
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}, None
|
||||
return {}, None, set()
|
||||
if not isinstance(data, dict):
|
||||
return {}, None
|
||||
return {}, None, set()
|
||||
|
||||
# Old flat shape: a single record with ``stable_id`` at the top level.
|
||||
if "devices" not in data and "stable_id" in data:
|
||||
record = self._record_from(data)
|
||||
if record is None:
|
||||
return {}, None
|
||||
return {record.stable_id: record}, record.stable_id
|
||||
return {}, None, set()
|
||||
return {record.stable_id: record}, record.stable_id, set()
|
||||
|
||||
registry: dict[str, RememberedDevice] = {}
|
||||
for entry in (data.get("devices") or {}).values():
|
||||
@@ -131,7 +146,9 @@ class DeviceStore:
|
||||
last = data.get("last")
|
||||
if last not in registry:
|
||||
last = None
|
||||
return registry, last
|
||||
raw_hidden = data.get("hidden")
|
||||
hidden = {str(x) for x in raw_hidden} if isinstance(raw_hidden, list) else set()
|
||||
return registry, last, hidden
|
||||
|
||||
@staticmethod
|
||||
def _record_from(entry: object) -> RememberedDevice | None:
|
||||
@@ -163,17 +180,17 @@ class DeviceStore:
|
||||
Returns:
|
||||
The last-connected :class:`RememberedDevice`, or ``None``.
|
||||
"""
|
||||
registry, last = self._read()
|
||||
registry, last, _hidden = self._read()
|
||||
return registry.get(last) if last is not None else None
|
||||
|
||||
def load_all(self) -> dict[str, RememberedDevice]:
|
||||
"""Return the full registry of confirmed devices, keyed by ``stable_id``."""
|
||||
registry, _ = self._read()
|
||||
registry, _last, _hidden = self._read()
|
||||
return registry
|
||||
|
||||
def is_known(self, device: DiscoveredDevice) -> bool:
|
||||
"""Return whether ``device`` has ever been confirmed as a MeshCore companion."""
|
||||
registry, _ = self._read()
|
||||
registry, _last, _hidden = self._read()
|
||||
return device.stable_id in registry
|
||||
|
||||
def remember(
|
||||
@@ -193,7 +210,7 @@ class DeviceStore:
|
||||
a reconnect on firmware that couldn't answer the query doesn't erase a model
|
||||
learned earlier.
|
||||
"""
|
||||
registry, _ = self._read()
|
||||
registry, _last, hidden = self._read()
|
||||
existing = registry.get(device.stable_id)
|
||||
if not node_name and existing is not None:
|
||||
node_name = existing.node_name
|
||||
@@ -211,7 +228,11 @@ class DeviceStore:
|
||||
host=device.host or "",
|
||||
tcp_port=device.tcp_port or 0,
|
||||
)
|
||||
self._write(registry, device.stable_id)
|
||||
# Connecting to a device is the plainest statement that it should be listed, so a
|
||||
# confirmed one stops being hidden — otherwise it would vanish from the splash the
|
||||
# moment it proved itself, which is the opposite of what hiding is for.
|
||||
hidden.discard(device.stable_id)
|
||||
self._write(registry, device.stable_id, hidden)
|
||||
|
||||
def forget(self, stable_id: str) -> bool:
|
||||
"""Drop a remembered device from the registry.
|
||||
@@ -230,17 +251,58 @@ class DeviceStore:
|
||||
Returns:
|
||||
``True`` if a record was removed, ``False`` if none matched.
|
||||
"""
|
||||
registry, last = self._read()
|
||||
registry, last, hidden = self._read()
|
||||
if stable_id not in registry:
|
||||
return False
|
||||
del registry[stable_id]
|
||||
if last == stable_id:
|
||||
last = max(registry, key=lambda k: registry[k].last_connected, default=None)
|
||||
self._write(registry, last)
|
||||
hidden.discard(stable_id) # nothing left to hide it from
|
||||
self._write(registry, last, hidden)
|
||||
return True
|
||||
|
||||
def _write(self, registry: dict[str, RememberedDevice], last: str | None) -> None:
|
||||
"""Persist the registry, marking ``last`` as the most recently connected device."""
|
||||
def hidden_ids(self) -> set[str]:
|
||||
"""Return the stable ids the startup splash has been told not to list."""
|
||||
_registry, _last, hidden = self._read()
|
||||
return hidden
|
||||
|
||||
def hide(self, stable_id: str) -> None:
|
||||
"""Stop listing ``stable_id`` on the startup splash, from now until it is shown again.
|
||||
|
||||
Kept forever, like the registry itself — the splash is a list of what is plugged into
|
||||
this machine, and the adapters that are always plugged in and never companions are
|
||||
the same ones every time. Hiding an id that is not remembered is normal and fine:
|
||||
those are exactly the rows worth hiding.
|
||||
|
||||
Args:
|
||||
stable_id: The device's :attr:`DiscoveredDevice.stable_id`.
|
||||
"""
|
||||
registry, last, hidden = self._read()
|
||||
if stable_id in hidden:
|
||||
return
|
||||
hidden.add(stable_id)
|
||||
self._write(registry, last, hidden)
|
||||
|
||||
def show_all(self) -> int:
|
||||
"""Un-hide every hidden device; return how many were brought back.
|
||||
|
||||
The single way back, deliberately: hiding is per-row and un-hiding is not, because a
|
||||
hidden row is not on screen to press a key on. Returning the count lets the caller
|
||||
say what it just did.
|
||||
"""
|
||||
registry, last, hidden = self._read()
|
||||
if not hidden:
|
||||
return 0
|
||||
self._write(registry, last, set())
|
||||
return len(hidden)
|
||||
|
||||
def _write(
|
||||
self,
|
||||
registry: dict[str, RememberedDevice],
|
||||
last: str | None,
|
||||
hidden: set[str],
|
||||
) -> None:
|
||||
"""Persist the registry, its ``last`` pointer, and the splash's hidden ids."""
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"devices": {
|
||||
@@ -259,5 +321,7 @@ class DeviceStore:
|
||||
for stable_id, record in registry.items()
|
||||
},
|
||||
"last": last,
|
||||
# Sorted so the file does not churn between writes over set ordering alone.
|
||||
"hidden": sorted(hidden),
|
||||
}
|
||||
write_atomically(self._path, json.dumps(payload, indent=2))
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Companion-device discovery across transports (serial + Bluetooth LE + TCP).
|
||||
|
||||
Discovery enumerates the companion kinds that can be *found* automatically:
|
||||
@@ -35,6 +36,13 @@ _log = logging.getLogger(__name__)
|
||||
TRANSPORT_SERIAL = "serial"
|
||||
TRANSPORT_BLE = "ble"
|
||||
TRANSPORT_TCP = "tcp"
|
||||
#: A LoRa chip on the host's own SPI bus, run by a node MeshTerm starts itself
|
||||
#: (:mod:`meshterm.core.spiradio`). Found by its ``/dev/spidev*`` node, like a serial port.
|
||||
TRANSPORT_SPI = "spi"
|
||||
#: The built-in simulator, as a device inventory reports it under ``--mock``. Never
|
||||
#: discovered — there is nothing to find — but a session that promised no real hardware
|
||||
#: still has exactly one device, and this is what it is called.
|
||||
TRANSPORT_MOCK = "mock"
|
||||
|
||||
#: Default TCP port assumed when a network companion is named as a bare host with no port.
|
||||
#: 5000 is the conventional MeshCore companion-over-TCP / ``meshcored`` listen port.
|
||||
@@ -48,6 +56,32 @@ BLE_NAME_PREFIX = "MeshCore"
|
||||
#: advertisement, short enough not to stall the startup splash noticeably.
|
||||
BLE_SCAN_TIMEOUT_S = 5.0
|
||||
|
||||
try: # bleak >= 3 only; the floor in pyproject is 0.22, so this must not be assumed
|
||||
from bleak.exc import BleakBluetoothNotAvailableError as _BleakNotAvailable
|
||||
|
||||
_BLE_NOT_AVAILABLE: tuple[type[BaseException], ...] = (_BleakNotAvailable,)
|
||||
except Exception: # noqa: BLE001 - older bleak, or bleak absent entirely
|
||||
# An empty tuple in ``except`` matches nothing, so older installs fall straight through
|
||||
# to the broad handler and behave exactly as they did before.
|
||||
_BLE_NOT_AVAILABLE = ()
|
||||
|
||||
#: Why the last BLE scan could not run, when the cause is something the reader can act on
|
||||
#: (Bluetooth off; on macOS, permission not granted to the terminal running us). ``None``
|
||||
#: when the scan worked — including when it simply heard nothing, which is not a fault.
|
||||
_ble_unavailable: str | None = None
|
||||
|
||||
|
||||
def ble_unavailable_reason() -> str | None:
|
||||
"""Why the last BLE scan was refused, if it was, as a sentence fit to show the reader.
|
||||
|
||||
Returns:
|
||||
The reason the most recent :func:`discover_ble_devices` could not scan, or ``None``
|
||||
if it ran — whether or not it found anything. Reset at the start of every scan, so
|
||||
it always describes the latest attempt rather than a stale one.
|
||||
"""
|
||||
return _ble_unavailable
|
||||
|
||||
|
||||
#: Default seconds to watch for one *known* companion's advertisement while waiting for it to
|
||||
#: come back (see :func:`find_ble_device`). Deliberately longer than the startup scan: nothing
|
||||
#: is waiting on it, the watch ends the instant the device is heard, and a companion that just
|
||||
@@ -96,7 +130,7 @@ class DiscoveredDevice:
|
||||
serial_number: USB serial number, if exposed by the device.
|
||||
manufacturer: USB manufacturer string, if available.
|
||||
product: USB product string, if available.
|
||||
transport: ``"serial"``, ``"ble"``, or ``"tcp"`` — the connection layer for this device.
|
||||
transport: ``"serial"``, ``"ble"``, ``"tcp"``, or ``"spi"`` — the connection layer.
|
||||
address: Bluetooth address for a BLE device (e.g. ``AA:BB:CC:DD:EE:FF``).
|
||||
name: Advertised BLE local name (e.g. ``"MeshCore-Basestation"``); BLE only.
|
||||
ble_device: The live ``bleak.BLEDevice`` the scan produced; BLE only, and only for
|
||||
@@ -108,6 +142,10 @@ class DiscoveredDevice:
|
||||
persisted.
|
||||
host: Hostname or IP of a TCP companion (e.g. ``"192.168.1.50"``); TCP only.
|
||||
tcp_port: TCP port the companion listens on (e.g. ``5000``); TCP only.
|
||||
spi: The :class:`~meshterm.core.config.SpiWiring` of a radio on the host's SPI bus;
|
||||
SPI only. Its ``port`` is the SPI device node (``/dev/spidev1.0``), which is what
|
||||
such a radio is named and remembered by. Typed ``object`` to keep this module
|
||||
free of the config import.
|
||||
"""
|
||||
|
||||
port: str = ""
|
||||
@@ -124,6 +162,12 @@ class DiscoveredDevice:
|
||||
ble_device: object | None = None
|
||||
host: str | None = None
|
||||
tcp_port: int | None = None
|
||||
spi: object | None = None
|
||||
|
||||
@property
|
||||
def is_spi(self) -> bool:
|
||||
"""Whether this device is a radio on the host's own SPI bus, run by MeshTerm's node."""
|
||||
return self.transport == TRANSPORT_SPI
|
||||
|
||||
@property
|
||||
def is_ble(self) -> bool:
|
||||
@@ -144,6 +188,8 @@ class DiscoveredDevice:
|
||||
"""
|
||||
if self.is_tcp:
|
||||
return f"{self.host}:{self.tcp_port}"
|
||||
if self.transport == TRANSPORT_MOCK:
|
||||
return "--mock" # the flag is the target: it is how this device is selected
|
||||
return self.address or self.port if self.is_ble else self.port
|
||||
|
||||
@property
|
||||
@@ -159,6 +205,10 @@ class DiscoveredDevice:
|
||||
"""
|
||||
if self.is_tcp:
|
||||
return f"tcp:{self.host}:{self.tcp_port}"
|
||||
if self.transport == TRANSPORT_MOCK:
|
||||
return "mock:simulator"
|
||||
if self.is_spi:
|
||||
return f"spi:{self.port}"
|
||||
if self.is_ble:
|
||||
return f"ble:{(self.address or self.name or '').lower()}"
|
||||
if self.serial_number:
|
||||
@@ -177,8 +227,8 @@ class DiscoveredDevice:
|
||||
``"bridge"`` for a generic USB-UART chip (a weak hint — could be anything), or
|
||||
``"unknown"`` for an unrecognized adapter.
|
||||
"""
|
||||
if self.is_ble or self.is_tcp:
|
||||
return "board" # a MeshCore BLE advert / a user-named TCP endpoint is confident
|
||||
if self.is_ble or self.is_tcp or self.is_spi:
|
||||
return "board" # a MeshCore advert / a named endpoint / a wired radio is confident
|
||||
if self.vid in NATIVE_LORA_VIDS:
|
||||
return "board"
|
||||
if self.vid in UART_BRIDGE_VIDS:
|
||||
@@ -201,7 +251,7 @@ class DiscoveredDevice:
|
||||
its USB vendor ID to a friendly name, falling back to the driver's manufacturer
|
||||
string.
|
||||
"""
|
||||
if self.is_ble or self.is_tcp:
|
||||
if self.is_ble or self.is_tcp or self.is_spi:
|
||||
return self.manufacturer or ""
|
||||
if self.vid in KNOWN_LORA_VIDS:
|
||||
return KNOWN_LORA_VIDS[self.vid]
|
||||
@@ -220,7 +270,11 @@ class DiscoveredDevice:
|
||||
if self.is_ble:
|
||||
name = self.name or self.product or self.description or "Bluetooth device"
|
||||
return f"{name} (BLE)"
|
||||
if self.is_spi:
|
||||
return f"{self.name or 'SPI radio'} ({self.port})"
|
||||
name = self.product or self.description or self.vendor_label or "Serial device"
|
||||
if not self.port: # the simulator has nothing to point at, so no "()"
|
||||
return name
|
||||
suffix = f"({self.port})"
|
||||
if name.endswith(suffix): # avoid "… (COM11) (COM11)"
|
||||
name = name[: -len(suffix)].rstrip()
|
||||
@@ -384,6 +438,9 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc
|
||||
Returns:
|
||||
Discovered BLE companions (deduplicated by address), sorted by name.
|
||||
"""
|
||||
global _ble_unavailable
|
||||
_ble_unavailable = None
|
||||
|
||||
try:
|
||||
from bleak import BleakScanner
|
||||
except ImportError: # bleak not installed → BLE simply unavailable
|
||||
@@ -391,6 +448,16 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc
|
||||
|
||||
try:
|
||||
found = await BleakScanner.discover(timeout=timeout, return_adv=True)
|
||||
except _BLE_NOT_AVAILABLE as exc:
|
||||
# A *state the reader can fix* — Bluetooth switched off, or (on macOS, routinely)
|
||||
# the terminal running us has not been granted Bluetooth. That is not the "no
|
||||
# adapter / driver hiccup" the broad catch below is for, and returning [] silently
|
||||
# made it indistinguishable from "nothing is nearby": the one message that would
|
||||
# have told the user what to do was thrown away at debug level, under a log that
|
||||
# defaults to WARNING. Keep the sentence bleak wrote — it already names the remedy.
|
||||
_ble_unavailable = str(getattr(exc, "args", [None])[0] or exc)
|
||||
_log.warning("BLE unavailable: %s", _ble_unavailable)
|
||||
return []
|
||||
except Exception as exc: # noqa: BLE001 - no adapter / OS Bluetooth off / driver hiccup
|
||||
_log.debug("BLE scan unavailable: %s", exc)
|
||||
return []
|
||||
@@ -481,3 +548,27 @@ async def discover_all(
|
||||
# (USB serial number vs BLE address), so no cross-transport dedup is needed here.
|
||||
ble_devices = await discover_ble_devices(ble_timeout)
|
||||
return serial + ble_devices
|
||||
|
||||
|
||||
def spi_device(wiring: object, *, name: str = "") -> DiscoveredDevice:
|
||||
"""Build the :class:`DiscoveredDevice` for a radio on the host's own SPI bus.
|
||||
|
||||
Such a radio is "found" the way a serial port is — its ``/dev/spidev*`` node exists —
|
||||
but nothing answers on it until MeshTerm starts a node, so it is listed from its wiring
|
||||
(a profile's, or the AIO's defaults) rather than probed.
|
||||
|
||||
Args:
|
||||
wiring: The radio's :class:`~meshterm.core.config.SpiWiring`.
|
||||
name: Optional friendly name for display (a profile's description, a node name).
|
||||
|
||||
Returns:
|
||||
An SPI :class:`DiscoveredDevice` ready to select or connect.
|
||||
"""
|
||||
return DiscoveredDevice(
|
||||
transport=TRANSPORT_SPI,
|
||||
port=wiring.spidev, # type: ignore[attr-defined]
|
||||
spi=wiring,
|
||||
name=name or None,
|
||||
description=name or "SPI radio",
|
||||
product=name or None,
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Typed events carried by the always-on mesh event hub.
|
||||
|
||||
These are the domain-level events that flow from the connected companion device out to
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The exit statuses ``meshterm`` returns, and what each one means.
|
||||
|
||||
A scripted run's *return value is its report*: the CLI says what happened in plain
|
||||
text on stdout and says whether it worked in ``$?``. The codes are classified rather
|
||||
than a bare 0/1 so a caller can tell the failures apart without reading prose — a
|
||||
timeout is worth retrying, a bad key never is.
|
||||
|
||||
The set is small and closed. Anything that does not map onto one of the specific
|
||||
causes below is :data:`FAILURE`; nothing invents a code of its own.
|
||||
|
||||
+------+--------------+--------------------------------------------------------+
|
||||
| Code | Name | Meaning |
|
||||
+======+==============+========================================================+
|
||||
| 0 | ``OK`` | The command did what it was asked. |
|
||||
+------+--------------+--------------------------------------------------------+
|
||||
| 1 | ``FAILURE`` | It failed for a reason with no more specific code — |
|
||||
| | | a bad configuration or preference value, an |
|
||||
| | | unreadable file, an unhandled fault. |
|
||||
+------+--------------+--------------------------------------------------------+
|
||||
| 2 | ``USAGE`` | The command line itself was wrong: an unknown flag, a |
|
||||
| | | missing argument, a value the parser rejected. Click |
|
||||
| | | returns this on its own; it is named here so the |
|
||||
| | | table is complete. |
|
||||
+------+--------------+--------------------------------------------------------+
|
||||
| 3 | ``NO_DEVICE``| No companion could be selected: none attached, none |
|
||||
| | | matching ``--port``/``--ble``/``--tcp``, or the |
|
||||
| | | choice was ambiguous. Nothing was transmitted. |
|
||||
+------+--------------+--------------------------------------------------------+
|
||||
| 4 | ``DEVICE`` | A device was reached but the operation failed: a |
|
||||
| | | command error, a timeout, a link lost mid-run. |
|
||||
| | | Retrying is reasonable. |
|
||||
+------+--------------+--------------------------------------------------------+
|
||||
| 5 | ``NO_RESULT``| The command ran and completed, and there was nothing |
|
||||
| | | to report: an empty list, a target that never |
|
||||
| | | answered, a conversation with no messages. stdout is |
|
||||
| | | empty; this is not an error. |
|
||||
+------+--------------+--------------------------------------------------------+
|
||||
|
||||
:data:`NO_RESULT` is the one that needs saying out loud, because it is the one a
|
||||
script most wants and no other tool offers: ``grep`` conflates "found nothing" with
|
||||
"worked", and a caller that has to count output lines to tell an empty mesh from a
|
||||
full one is parsing when it could be branching.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: The command did what it was asked.
|
||||
OK = 0
|
||||
|
||||
#: A failure with no more specific code — a bad value, an unreadable file, a fault.
|
||||
FAILURE = 1
|
||||
|
||||
#: The command line was wrong. Click returns this itself; named here for completeness.
|
||||
USAGE = 2
|
||||
|
||||
#: No companion device could be selected. Nothing was transmitted.
|
||||
NO_DEVICE = 3
|
||||
|
||||
#: A device was reached but the operation failed. Retrying is reasonable.
|
||||
DEVICE = 4
|
||||
|
||||
#: The command completed with nothing to report. Not an error; stdout is empty.
|
||||
NO_RESULT = 5
|
||||
|
||||
#: Every code, by name, for the ``--help`` epilog and the documentation.
|
||||
MEANINGS: dict[int, str] = {
|
||||
OK: "success",
|
||||
FAILURE: "failure",
|
||||
USAGE: "usage error",
|
||||
NO_DEVICE: "no device found, or the selection was ambiguous",
|
||||
DEVICE: "the device was reached but the operation failed",
|
||||
NO_RESULT: "nothing to report (empty result)",
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""What an overheard frame is *addressed to* — the packet body nobody else parses.
|
||||
|
||||
The companion's RX packet log hands every overheard frame to the meshcore library, which
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Pure geographic helpers for the terminal map: Web Mercator, tiles, and viewports.
|
||||
|
||||
This module is deliberately I/O-free and rendering-free so it can be unit-tested without a
|
||||
@@ -33,7 +34,9 @@ TILE_PX = 256
|
||||
#: :mod:`meshterm.ui.map_screen` for it dragged the whole map stack (and, through the
|
||||
#: basemap's tile fetcher, ``urllib.request`` → ``http.client`` → ``ssl``) into every run,
|
||||
#: including runs that never open a map. This module is pure arithmetic and already on the
|
||||
#: boot path, so the constant is free here.
|
||||
#: boot path, so the constant is free here. It is also what the ``map_view_fraction``
|
||||
#: preference defaults to — the registry names this constant rather than re-typing the
|
||||
#: number, so the code default and the preference default are one value.
|
||||
DEFAULT_VIEW_FRACTION = 0.5
|
||||
|
||||
|
||||
@@ -87,7 +90,9 @@ def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
|
||||
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a))
|
||||
# Clamped because rounding can push ``a`` a hair past 1 for near-antipodal points, and
|
||||
# ``asin`` of that raises a domain error rather than returning half the planet.
|
||||
return 2 * EARTH_RADIUS_KM * math.asin(min(1.0, math.sqrt(a)))
|
||||
|
||||
|
||||
def _central_points(
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""What MeshTerm is running on, read once so a bug report does not have to be interviewed.
|
||||
|
||||
Every value here answers a question that otherwise costs a round trip with whoever
|
||||
reported the problem: which build of MeshTerm, on which OS, in which terminal, how wide,
|
||||
with which colour depth. None of it is about the mesh and none of it is private — this
|
||||
module deliberately knows nothing about contacts, keys, positions or messages, so the
|
||||
block it feeds can be pasted in public without anyone having to read it first.
|
||||
|
||||
Two probes are **ladders over environment variables** rather than protocol queries, which
|
||||
is a concession worth naming: a terminal has no portable way to be asked who it is. The
|
||||
one sequence that comes close (``CSI > q``) needs a live tty, a reply that may never
|
||||
arrive, and a timeout budget on a path that must never hang. So each terminal is
|
||||
identified by the variable it sets about *itself* — which is the same ladder
|
||||
:func:`~meshterm.ui.termfont.detect_terminal_font` already climbs for the font — and
|
||||
anything unrecognised falls back to ``TERM`` rather than to a guess.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
#: The first Windows 11 build. Windows 11 still reports its release as ``"10"`` through
|
||||
#: every documented API, so the build number is the only thing that separates the two.
|
||||
_WINDOWS_11_BUILD = 22000
|
||||
|
||||
#: Terminals that announce themselves, by the variable each one sets. Ordered most to
|
||||
#: least specific: an emulator hosting another (VS Code's terminal, a multiplexer) sets
|
||||
#: its own marker *and* inherits the outer one, so the inner identity has to win.
|
||||
#:
|
||||
#: ``TERM_PROGRAM`` is checked separately, between these and the ``TERM`` fallback,
|
||||
#: because its *value* is the name — it identifies terminals this table never has to list.
|
||||
_TERMINAL_VARS: tuple[tuple[str, str], ...] = (
|
||||
("WT_SESSION", "Windows Terminal"),
|
||||
("ConEmuPID", "ConEmu"),
|
||||
("TERMINAL_EMULATOR", ""), # JetBrains IDEs put the name in the value
|
||||
("KITTY_WINDOW_ID", "kitty"),
|
||||
("ALACRITTY_WINDOW_ID", "Alacritty"),
|
||||
("KONSOLE_VERSION", "Konsole"),
|
||||
("GNOME_TERMINAL_SCREEN", "GNOME Terminal"),
|
||||
("VTE_VERSION", "VTE-based"),
|
||||
)
|
||||
|
||||
#: What ``TERM_PROGRAM`` calls terminals whose own name is not what a person calls them.
|
||||
#: Only the ones that genuinely differ; every other value passes through as it is, which
|
||||
#: is what keeps this from growing into a catalogue of every terminal there is.
|
||||
_TERM_PROGRAM_NAMES: dict[str, str] = {
|
||||
"Apple_Terminal": "Terminal.app",
|
||||
"vscode": "VS Code",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Host:
|
||||
"""The machine and the Python running MeshTerm.
|
||||
|
||||
Attributes:
|
||||
install: How this copy was installed — ``"frozen"`` (a one-file build, which has
|
||||
its own bootloader, its own relaunch rules and no source tree to read),
|
||||
``"source"`` (a checkout, so a commit can be asked for), or ``"package"``
|
||||
(a wheel from an index). The three fail in different ways and the reporter
|
||||
usually does not know which one they have.
|
||||
os: The flavour and its version as a person writes it — ``Windows 11``,
|
||||
``macOS 15.6``, ``Debian GNU/Linux 12 (bookworm)``.
|
||||
os_build: The exact build under that name: a Windows build number, or the kernel
|
||||
release everywhere else. What separates two hosts that call themselves the
|
||||
same thing.
|
||||
arch: The processor architecture, which is what tells an arm64 build from an
|
||||
x86_64 one when a binary misbehaves.
|
||||
python: The interpreter version, three parts.
|
||||
"""
|
||||
|
||||
install: str
|
||||
os: str
|
||||
os_build: str
|
||||
arch: str
|
||||
python: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Terminal:
|
||||
"""The terminal MeshTerm is drawing into, and what it can draw.
|
||||
|
||||
Attributes:
|
||||
program: The emulator's name, or ``TERM``'s value where nothing identified
|
||||
itself, or ``None`` when even that is unset.
|
||||
term: The ``TERM`` value, verbatim. Kept beside ``program`` rather than folded
|
||||
into it: a terminal lying about ``TERM`` is itself a common cause of a
|
||||
rendering report.
|
||||
colorterm: The ``COLORTERM`` value, which is how a terminal claims truecolor and
|
||||
the first thing to check when a screen arrives in the wrong colours.
|
||||
cols: The terminal's width in cells.
|
||||
rows: The terminal's height in cells.
|
||||
over_ssh: Whether this is an ssh session. It changes what is knowable — the font
|
||||
lives on the far client — and it is why several other verdicts here read
|
||||
``unknown`` rather than wrong.
|
||||
"""
|
||||
|
||||
program: str | None
|
||||
term: str | None
|
||||
colorterm: str | None
|
||||
cols: int
|
||||
rows: int
|
||||
over_ssh: bool
|
||||
|
||||
|
||||
def install_kind() -> str:
|
||||
"""How this copy of MeshTerm got here: ``frozen``, ``source`` or ``package``.
|
||||
|
||||
A frozen build is unambiguous — the bootloader sets the flag. Past that, the question
|
||||
is whether there is a repository above the package: a checkout can be asked for its
|
||||
commit and can carry uncommitted work, and a wheel can do neither.
|
||||
|
||||
Returns:
|
||||
One of ``"frozen"``, ``"source"`` or ``"package"``.
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return "frozen"
|
||||
# meshterm/core/hostinfo.py -> meshterm/core -> meshterm -> the tree that holds it
|
||||
if (Path(__file__).resolve().parents[2] / ".git").exists():
|
||||
return "source"
|
||||
return "package"
|
||||
|
||||
|
||||
def host() -> Host:
|
||||
"""Read the machine's own facts.
|
||||
|
||||
Returns:
|
||||
The :class:`Host` block.
|
||||
"""
|
||||
name, build = _os_name_and_build()
|
||||
return Host(
|
||||
install=install_kind(),
|
||||
os=name,
|
||||
os_build=build,
|
||||
arch=platform.machine() or "unknown",
|
||||
python=platform.python_version(),
|
||||
)
|
||||
|
||||
|
||||
def terminal(environ: Mapping[str, str] | None = None) -> Terminal:
|
||||
"""Read the terminal's own facts.
|
||||
|
||||
Args:
|
||||
environ: The environment to inspect (defaults to ``os.environ``), injectable so
|
||||
the ladder can be tested without a terminal of the right kind to hand.
|
||||
|
||||
Returns:
|
||||
The :class:`Terminal` block.
|
||||
"""
|
||||
env = os.environ if environ is None else environ
|
||||
size = shutil.get_terminal_size()
|
||||
return Terminal(
|
||||
program=terminal_program(env),
|
||||
term=env.get("TERM") or None,
|
||||
colorterm=env.get("COLORTERM") or None,
|
||||
cols=size.columns,
|
||||
rows=size.lines,
|
||||
over_ssh=bool(env.get("SSH_TTY") or env.get("SSH_CONNECTION")),
|
||||
)
|
||||
|
||||
|
||||
def terminal_program(environ: Mapping[str, str] | None = None) -> str | None:
|
||||
"""Name the terminal emulator from what it says about itself.
|
||||
|
||||
The ladder: the terminals in :data:`_TERMINAL_VARS` that set a marker variable, then
|
||||
``TERM_PROGRAM`` (whose *value* is the name, so it covers terminals this module has
|
||||
never heard of), then ``TERM`` as the last honest thing there is to say.
|
||||
|
||||
Args:
|
||||
environ: The environment to inspect (defaults to ``os.environ``).
|
||||
|
||||
Returns:
|
||||
The terminal's name, or ``None`` when nothing in the environment names one.
|
||||
"""
|
||||
env = os.environ if environ is None else environ
|
||||
for var, name in _TERMINAL_VARS:
|
||||
value = env.get(var)
|
||||
if value:
|
||||
return name or value
|
||||
program = env.get("TERM_PROGRAM")
|
||||
if program:
|
||||
return _TERM_PROGRAM_NAMES.get(program, program)
|
||||
return env.get("TERM") or None
|
||||
|
||||
|
||||
def _os_name_and_build() -> tuple[str, str]:
|
||||
"""The OS as a person writes it, and the exact build under that name."""
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
return _windows()
|
||||
if system == "Darwin":
|
||||
version = platform.mac_ver()[0]
|
||||
return (f"macOS {version}" if version else "macOS"), platform.release()
|
||||
if system == "Linux":
|
||||
return (_pretty_name() or "Linux"), platform.release()
|
||||
return (system or "unknown"), platform.release()
|
||||
|
||||
|
||||
def _windows() -> tuple[str, str]:
|
||||
"""Windows by the name it is sold under, and its build number.
|
||||
|
||||
``platform.win32_ver`` reports release ``"10"`` on Windows 11 as well — the rename
|
||||
never reached the version APIs — so the build number is what decides which one this
|
||||
is. The build is also the useful half of the pair: it is what a fix is shipped
|
||||
against.
|
||||
"""
|
||||
release, version, _service_pack, _kind = platform.win32_ver()
|
||||
try:
|
||||
build = int(version.rsplit(".", 1)[-1])
|
||||
except ValueError:
|
||||
build = 0
|
||||
name = "Windows 11" if build >= _WINDOWS_11_BUILD else f"Windows {release or '?'}"
|
||||
return name, version or platform.version()
|
||||
|
||||
|
||||
def _pretty_name() -> str | None:
|
||||
"""The distribution's own name for itself, from ``/etc/os-release``.
|
||||
|
||||
The one place a Linux flavour is stated by the system rather than inferred, and the
|
||||
reason a report can say *Calculinux* or *Raspberry Pi OS* instead of ``Linux``.
|
||||
A host without the file (or without permission to read it) simply has no answer.
|
||||
"""
|
||||
try:
|
||||
text = Path("/etc/os-release").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
for line in text.splitlines():
|
||||
key, _, value = line.partition("=")
|
||||
if key == "PRETTY_NAME":
|
||||
return value.strip().strip('"') or None
|
||||
return None
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""One interactive MeshTerm per data directory, and a clear answer when you want two.
|
||||
|
||||
Every copy of MeshTerm on a machine reads and writes the same ``~/.meshterm`` — a
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Reopening the session in a Terminal window that looks like MeshTerm.
|
||||
|
||||
macOS Terminal is not the classic Windows console's problem over again. It falls back
|
||||
generously — ask it for a glyph its font lacks and it finds one somewhere — so nothing here
|
||||
is ever a box. The trouble is *what* it finds: no Mac monospace face carries the Braille
|
||||
Patterns block at all (SF Mono, Menlo, Monaco and Courier all measure zero), so the block
|
||||
the charts and the map's terrain are drawn from is borrowed from a **proportional** face
|
||||
and arrives at the wrong width. The screen is not empty, it is crooked, which is worse —
|
||||
nothing announces itself as broken and the reader has nothing to compare it against.
|
||||
|
||||
The fix is a font, and a font means a Terminal profile that names it. Which raises the
|
||||
question this module exists to answer carefully: **whose terminal is it?**
|
||||
|
||||
MeshTerm's palette is the 16-colour VT set (:data:`~meshterm.ui.theme._VT_SLOTS`) — a
|
||||
deliberately crude, deliberately CGA-ish thing that the app wears on purpose. Nobody should
|
||||
have to live in it to read their mail. So this module never touches the reader's default
|
||||
profile and never edits their preferences: it *adds* a profile named MeshTerm, and opens
|
||||
MeshTerm's own window in it. Their Terminal keeps whatever they chose, down to its
|
||||
translucency; ours is opaque and crude in a window of its own, and quitting leaves nothing
|
||||
behind.
|
||||
|
||||
That also sidesteps a trap worth recording, because it costs an evening to find. Terminal
|
||||
rewrites ``~/Library/Preferences/com.apple.Terminal.plist`` **from memory when it quits**,
|
||||
so anything written into that file while Terminal is running is silently discarded later —
|
||||
and MeshTerm is always running inside it. Nothing here writes that file. A ``.terminal``
|
||||
file is handed to Terminal and *Terminal* does the importing, which is the one path that
|
||||
works from inside a live session.
|
||||
|
||||
The profile is generated rather than shipped, from the same theme constant the app draws
|
||||
itself with, so the window can never disagree with the screen it is holding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import plistlib
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .relaunch import REOPENED_ENV, own_command, wanted_size
|
||||
|
||||
#: The name the profile is installed under, and the name the reader sees in Terminal's
|
||||
#: settings list. Stable: renaming it would strand the copy already on their machine.
|
||||
PROFILE_NAME = "MeshTerm"
|
||||
|
||||
#: The bundled face, by its PostScript name — which is what an ``NSFont`` archive holds,
|
||||
#: and not the family name Font Book shows. Installed by :func:`install_font` from the copy
|
||||
#: in the package; see :mod:`meshterm.core.consolefont` for the Windows half of the same
|
||||
#: idea.
|
||||
FONT_FACE = "CascadiaMonoPL-Regular"
|
||||
|
||||
#: Points. Terminal's own default is 11; 13 is a size the braille charts read cleanly at on
|
||||
#: a Retina panel without the map losing rows.
|
||||
FONT_SIZE = 13.0
|
||||
|
||||
#: Where a user-installed font goes on macOS. No administrator rights, no installer, and
|
||||
#: the reader can undo it by dragging the file out of Font Book.
|
||||
USER_FONT_DIR = Path("~/Library/Fonts").expanduser()
|
||||
|
||||
#: Terminal's key for each palette slot, in :data:`~meshterm.ui.theme._VT_SLOTS` order —
|
||||
#: the standard ANSI 16, dim bank then bright bank.
|
||||
_ANSI_KEYS: tuple[str, ...] = (
|
||||
"ANSIBlackColor",
|
||||
"ANSIRedColor",
|
||||
"ANSIGreenColor",
|
||||
"ANSIYellowColor",
|
||||
"ANSIBlueColor",
|
||||
"ANSIMagentaColor",
|
||||
"ANSICyanColor",
|
||||
"ANSIWhiteColor",
|
||||
"ANSIBrightBlackColor",
|
||||
"ANSIBrightRedColor",
|
||||
"ANSIBrightGreenColor",
|
||||
"ANSIBrightYellowColor",
|
||||
"ANSIBrightBlueColor",
|
||||
"ANSIBrightMagentaColor",
|
||||
"ANSIBrightCyanColor",
|
||||
"ANSIBrightWhiteColor",
|
||||
)
|
||||
|
||||
|
||||
def _archive(objects: list[object]) -> bytes:
|
||||
"""One ``NSKeyedArchiver`` document, as Terminal stores every colour and font.
|
||||
|
||||
Terminal keeps these as nested binary plists inside the profile — an archived
|
||||
``NSColor`` or ``NSFont`` rather than a number and a string, because the profile is
|
||||
written by AppKit rather than for us. The shape is small and fixed, so it is built
|
||||
here rather than reached for through a dependency.
|
||||
|
||||
Args:
|
||||
objects: The ``$objects`` array, with ``$null`` already at index 0.
|
||||
|
||||
Returns:
|
||||
The encoded archive. Binary, because :class:`plistlib.UID` has no XML spelling.
|
||||
"""
|
||||
return plistlib.dumps(
|
||||
{
|
||||
"$archiver": "NSKeyedArchiver",
|
||||
"$objects": objects,
|
||||
"$top": {"root": plistlib.UID(1)},
|
||||
"$version": 100000,
|
||||
},
|
||||
fmt=plistlib.FMT_BINARY,
|
||||
)
|
||||
|
||||
|
||||
def ns_color(rgb: str) -> bytes:
|
||||
"""An archived ``NSColor`` for a ``#rrggbb`` string.
|
||||
|
||||
Args:
|
||||
rgb: The colour, as the theme spells it.
|
||||
|
||||
Returns:
|
||||
The archive Terminal expects in a colour key.
|
||||
"""
|
||||
channels = (int(rgb[index : index + 2], 16) / 255 for index in (1, 3, 5))
|
||||
# Calibrated RGB, space-separated to six places and NUL-terminated: AppKit's own
|
||||
# spelling, matched exactly because Terminal parses it rather than tolerating it.
|
||||
packed = " ".join(f"{value:.6f}" for value in channels).encode("ascii") + b"\x00"
|
||||
return _archive(
|
||||
[
|
||||
"$null",
|
||||
{"$class": plistlib.UID(2), "NSColorSpace": 1, "NSRGB": packed},
|
||||
{"$classes": ["NSColor", "NSObject"], "$classname": "NSColor"},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def ns_font(face: str = FONT_FACE, size: float = FONT_SIZE) -> bytes:
|
||||
"""An archived ``NSFont``.
|
||||
|
||||
Args:
|
||||
face: The PostScript name of the face.
|
||||
size: The size in points.
|
||||
|
||||
Returns:
|
||||
The archive Terminal expects in the ``Font`` key.
|
||||
"""
|
||||
return _archive(
|
||||
[
|
||||
"$null",
|
||||
{
|
||||
"$class": plistlib.UID(3),
|
||||
"NSName": plistlib.UID(2),
|
||||
"NSSize": float(size),
|
||||
"NSfFlags": 16,
|
||||
},
|
||||
face,
|
||||
{"$classes": ["NSFont", "NSObject"], "$classname": "NSFont"},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def launch_command() -> str:
|
||||
"""The shell line that starts this session again, for the profile to run.
|
||||
|
||||
Terminal starts the command from a **fresh login shell**, which is why this is a
|
||||
string rather than an argv and why it carries what it needs explicitly. Nothing of the
|
||||
current process's environment survives the hop — good news for the ``_PYI*`` variables
|
||||
a frozen build must not pass on (see
|
||||
:func:`~meshterm.core.relaunch.child_environment`, which exists for the Windows side
|
||||
where they *do* survive), and bad news for a ``MESHTERM_HOME`` the reader set for this
|
||||
invocation alone, which the README itself suggests doing. So every ``MESHTERM_*``
|
||||
variable currently set is carried across, and the reopened-session marker with them.
|
||||
|
||||
The variables ride on ``/usr/bin/env`` rather than on a ``VAR=value`` prefix, and that
|
||||
is not a stylistic choice. Terminal's own handling of the string is not something to
|
||||
lean on: measured on macOS 26, the same command line runs under ``RunCommandAsShell``
|
||||
*false* and fails under *true*, where Terminal takes the whole thing as the name of a
|
||||
program and puts this on the screen::
|
||||
|
||||
Command not found: MESHTERM_REOPENED=1
|
||||
Could not create a new process and open a pseudo-tty
|
||||
|
||||
``env`` is a real executable, so it is a valid argv *and* a valid shell line, and the
|
||||
window cannot land on that message however Terminal decides to read it. ``env`` execs
|
||||
the program in its own place, so nothing lingers behind it either — the window holds
|
||||
MeshTerm and nothing else, and closing MeshTerm does not drop to a prompt.
|
||||
|
||||
Returns:
|
||||
A command line Terminal can run with or without a shell.
|
||||
"""
|
||||
carried = {name: value for name, value in os.environ.items() if name.startswith("MESHTERM_")}
|
||||
carried[REOPENED_ENV] = "1"
|
||||
assignments = " ".join(
|
||||
f"{name}={shlex.quote(value)}" for name, value in sorted(carried.items())
|
||||
)
|
||||
program = " ".join(shlex.quote(part) for part in own_command())
|
||||
return f"/usr/bin/env {assignments} {program}"
|
||||
|
||||
|
||||
def build_profile(command: str | None = None) -> dict[str, object]:
|
||||
"""The MeshTerm profile, as Terminal's preferences hold it.
|
||||
|
||||
Every colour comes from :data:`~meshterm.ui.theme._VT_SLOTS`, so the window and the
|
||||
app it is holding cannot disagree — change the theme and this follows on the next
|
||||
write. The window size is
|
||||
:func:`~meshterm.core.relaunch.wanted_size`'s, the same one Windows Terminal is asked
|
||||
for.
|
||||
|
||||
Args:
|
||||
command: A shell line for the window to run on open, or ``None`` for a profile
|
||||
that just sits in the list waiting to be chosen.
|
||||
|
||||
Returns:
|
||||
The profile dictionary, ready to write as a ``.terminal`` file.
|
||||
"""
|
||||
from ..ui.theme import _VT_SLOTS
|
||||
|
||||
cols, rows = wanted_size()
|
||||
profile: dict[str, object] = {
|
||||
"name": PROFILE_NAME,
|
||||
"type": "Window Settings",
|
||||
"ProfileCurrentVersion": 2.07,
|
||||
"Font": ns_font(),
|
||||
"FontAntialias": True,
|
||||
"FontWidthSpacing": 1.0,
|
||||
# Opaque on purpose. A braille raster over a translucent ground is unreadable, and
|
||||
# a translucent profile is a perfectly reasonable thing for the reader to have --
|
||||
# which is the whole argument for MeshTerm having a window of its own.
|
||||
"BackgroundColor": ns_color("#000000"),
|
||||
"TextColor": ns_color("#aaaaaa"),
|
||||
"TextBoldColor": ns_color("#ffffff"),
|
||||
"SelectionColor": ns_color("#555555"),
|
||||
"CursorColor": ns_color("#55ffff"),
|
||||
"CursorType": 0,
|
||||
"BlinkText": False,
|
||||
# Bold is bold, not a jump to the bright bank. The theme states brightness as a
|
||||
# colour (see the wordmark's own note); letting Terminal promote it as well would
|
||||
# land a bank too high.
|
||||
"UseBrightBold": False,
|
||||
"columnCount": cols,
|
||||
"rowCount": rows,
|
||||
"ScrollbackLines": 10000,
|
||||
"ShouldLimitScrollback": 0,
|
||||
"ShowActiveProcessInTitle": True,
|
||||
"ShowDimensionsInTitle": False,
|
||||
"ShowWindowSettingsNameInTitle": False,
|
||||
# Close the window when MeshTerm exits cleanly, and keep it when it does not --
|
||||
# a crash the reader never sees is a bug report nobody can write.
|
||||
"shellExitAction": 1,
|
||||
}
|
||||
for key, (_slot, _label, rgb) in zip(_ANSI_KEYS, _VT_SLOTS, strict=True):
|
||||
profile[key] = ns_color(rgb)
|
||||
if command is not None:
|
||||
profile["CommandString"] = command
|
||||
# False, measured. Under *true* Terminal takes the whole string as a program name
|
||||
# and the window says "Command not found: ..."; under false it runs. The opposite
|
||||
# of what the key's name suggests, which is why it is written down.
|
||||
profile["RunCommandAsShell"] = False
|
||||
return profile
|
||||
|
||||
|
||||
def launcher_path() -> Path:
|
||||
"""The little script the profile runs, beside the profile itself."""
|
||||
from .config import default_config_dir
|
||||
|
||||
return default_config_dir() / "launch.sh"
|
||||
|
||||
|
||||
def write_launcher() -> Path:
|
||||
"""Write the launcher, and return where it landed.
|
||||
|
||||
This exists for one reason, and it is the finding that shaped this module. Terminal
|
||||
names an imported settings set **after the file** — ``MeshTerm.terminal`` becomes the
|
||||
profile "MeshTerm" — and re-importing the *same bytes* reuses that profile, while
|
||||
importing **different** bytes under the same filename creates "MeshTerm 1", then
|
||||
"MeshTerm 2". Measured, by opening one file four times: three identical imports left
|
||||
one profile, and the fourth, one word different, left two.
|
||||
|
||||
So the command cannot live in the profile. It carries ``sys.argv`` and the reader's
|
||||
``MESHTERM_*`` variables, which differ between ``meshterm`` and ``meshterm --mock``,
|
||||
and a profile per command line would fill the reader's settings list with junk that
|
||||
only they can remove.
|
||||
|
||||
Moving the variable part behind a fixed path fixes it exactly: the profile says
|
||||
``~/.meshterm/launch.sh`` and never changes, and this file underneath it says whatever
|
||||
this launch happens to need.
|
||||
|
||||
Returns:
|
||||
The path written, executable.
|
||||
"""
|
||||
path = launcher_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"# Written by MeshTerm each time it reopens itself. Safe to delete.\n"
|
||||
f"exec {launch_command()}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
path.chmod(0o755)
|
||||
return path
|
||||
|
||||
|
||||
def profile_path() -> Path:
|
||||
"""Where the generated ``.terminal`` file is kept.
|
||||
|
||||
In MeshTerm's own directory rather than the Desktop: it is ours, it is the one place
|
||||
that already survives an upgrade, and it moves with ``MESHTERM_HOME``.
|
||||
"""
|
||||
from .config import default_config_dir
|
||||
|
||||
return default_config_dir() / f"{PROFILE_NAME}.terminal"
|
||||
|
||||
|
||||
def write_profile(*, command: str | None = None) -> Path:
|
||||
"""Write the profile out, and return where it landed.
|
||||
|
||||
Args:
|
||||
command: A shell line for the window to run, or ``None`` for a bare profile.
|
||||
|
||||
Returns:
|
||||
The path written.
|
||||
"""
|
||||
path = profile_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(plistlib.dumps(build_profile(command), fmt=plistlib.FMT_XML))
|
||||
return path
|
||||
|
||||
|
||||
def font_installed() -> bool:
|
||||
"""Whether the bundled face is already in the reader's font directory."""
|
||||
from .consolefont import BUNDLED_FONT
|
||||
|
||||
return (USER_FONT_DIR / BUNDLED_FONT.name).is_file()
|
||||
|
||||
|
||||
def install_font() -> bool:
|
||||
"""Copy the bundled face into the reader's own font directory.
|
||||
|
||||
The one thing here that writes outside MeshTerm's own folder, which is why it is the
|
||||
one thing worth asking about: a single file, in the reader's own Library, no
|
||||
administrator rights, no installer, removable by dragging it out of Font Book. It
|
||||
changes nothing about how their Terminal looks — it only makes the face available for
|
||||
a profile to name.
|
||||
|
||||
Safe to repeat: an existing copy is left alone.
|
||||
|
||||
Returns:
|
||||
Whether the font is installed and usable, including when it already was.
|
||||
"""
|
||||
from .consolefont import BUNDLED_FONT
|
||||
|
||||
if sys.platform != "darwin" or not BUNDLED_FONT.is_file():
|
||||
return False
|
||||
target = USER_FONT_DIR / BUNDLED_FONT.name
|
||||
if target.is_file():
|
||||
return True
|
||||
try:
|
||||
USER_FONT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(BUNDLED_FONT, target)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def in_apple_terminal() -> bool:
|
||||
"""Whether this session is drawing into macOS Terminal.
|
||||
|
||||
iTerm2, Ghostty, kitty, WezTerm and VS Code all set ``TERM_PROGRAM`` to their own
|
||||
name, and several of them have both truecolor and a font the reader chose deliberately
|
||||
— none of them wants anything this module offers.
|
||||
"""
|
||||
return sys.platform == "darwin" and os.environ.get("TERM_PROGRAM") == "Apple_Terminal"
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
"""Whether reopening in MeshTerm's own window is a thing worth doing here.
|
||||
|
||||
``False`` in a session that was itself reopened, which is what keeps a relaunch from
|
||||
chaining into another one: the profile's own command carries the marker, because
|
||||
Terminal starts it from a fresh shell that inherits nothing from us.
|
||||
"""
|
||||
return in_apple_terminal() and not os.environ.get(REOPENED_ENV)
|
||||
|
||||
|
||||
def reopen() -> bool:
|
||||
"""Open MeshTerm in a window using its own profile, and report whether it started.
|
||||
|
||||
``open`` hands the file to Terminal, and **Terminal** imports the profile and opens the
|
||||
window — the only route that works from inside a live session, since Terminal discards
|
||||
external edits to its preferences when it quits.
|
||||
|
||||
The new window is not a child in any meaningful sense: the caller exits immediately
|
||||
afterwards and the two never talk.
|
||||
|
||||
Two files, not one: the launcher carries what varies and the profile points at it, so
|
||||
the profile's bytes are the same on every launch and Terminal reuses the one settings
|
||||
set instead of adding a numbered copy. :func:`write_launcher` has the measurement.
|
||||
|
||||
Returns:
|
||||
Whether ``open`` was launched. ``False`` leaves the caller exactly where it was,
|
||||
so a failure here is a detour and never a dead end.
|
||||
"""
|
||||
if not available():
|
||||
return False
|
||||
try:
|
||||
launcher = write_launcher()
|
||||
path = write_profile(command=str(launcher))
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
subprocess.Popen( # noqa: S603 - the argv is ours, not the reader's
|
||||
["/usr/bin/open", str(path)],
|
||||
close_fds=True,
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
+66
-2
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Domain models shared across services, tools, persistence, and visualizations.
|
||||
|
||||
These are plain dataclasses with no I/O dependencies so they can be constructed by the
|
||||
@@ -8,10 +9,25 @@ from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
|
||||
#: Maps a hop's raw key-prefix hash to a display label (a contact name when known, or the
|
||||
#: hash itself when not). ``None`` passes through, being our own device. THE alias for the
|
||||
#: name side of a hop, shared by everything that builds one
|
||||
#: (:func:`~meshterm.services.trace_runner.make_node_resolver`) and everything that takes
|
||||
#: one (every screen that draws a route).
|
||||
NodeResolver = Callable[[str | None], str | None]
|
||||
|
||||
#: Maps a display name back to the node's key — as full as we hold one: a public key, a key
|
||||
#: prefix, or a stored node id — or ``None`` for a name no known node carries. The colour
|
||||
#: side of :data:`NodeResolver`: where that turns hex into names, this turns a bare name back
|
||||
#: into the key its palette hue derives from
|
||||
#: (:func:`~meshterm.services.trace_runner.make_name_key_resolver`).
|
||||
NameKeyResolver = Callable[[str], str | None]
|
||||
|
||||
#: Label used for our own (local) device when framing a trace path's endpoints.
|
||||
LOCAL_DEVICE_LABEL = "us"
|
||||
|
||||
@@ -32,14 +48,24 @@ NODE_TYPE_ROOM = 3
|
||||
NODE_TYPE_SENSOR = 4
|
||||
|
||||
#: Human-readable label for each advert type, for legends and summaries.
|
||||
#: The one name for each advert type, everywhere MeshTerm prints one and in every
|
||||
#: `--json` answer that carries a role or a type. A type-1 node is a companion: the radio
|
||||
#: someone talks through, the same kind as the one in your hand.
|
||||
NODE_TYPE_LABELS = {
|
||||
NODE_TYPE_CHAT: "node",
|
||||
NODE_TYPE_CHAT: "companion",
|
||||
NODE_TYPE_REPEATER: "repeater",
|
||||
NODE_TYPE_ROOM: "room",
|
||||
NODE_TYPE_ROOM: "room server",
|
||||
NODE_TYPE_SENSOR: "sensor",
|
||||
}
|
||||
|
||||
|
||||
def node_type_label(node_type: int | None) -> str | None:
|
||||
"""The name for an advert type, ``type N`` for one MeshTerm does not know, None for none."""
|
||||
if node_type is None:
|
||||
return None
|
||||
return NODE_TYPE_LABELS.get(int(node_type), f"type {node_type}")
|
||||
|
||||
|
||||
class LoginResult(Enum):
|
||||
"""How an admin login ended — and, crucially, whether the node was there to end it.
|
||||
|
||||
@@ -177,6 +203,39 @@ class Contact:
|
||||
return self.node_type == NODE_TYPE_REPEATER
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MapMarker:
|
||||
"""One mesh node to overlay on the map.
|
||||
|
||||
Data, not drawing: a located node with the little the map needs to place and label it.
|
||||
It lives here rather than beside the renderer because it is assembled well below the UI
|
||||
— :func:`~meshterm.services.markers.gather_markers` builds the list from contacts and
|
||||
observations, and three separate surfaces draw it.
|
||||
|
||||
Attributes:
|
||||
label: Node name shown beside the marker.
|
||||
lat: Latitude in decimal degrees.
|
||||
lon: Longitude in decimal degrees.
|
||||
is_repeater: Whether the node is a repeater (prioritised marker).
|
||||
is_self: Whether this is our own node (highlighted).
|
||||
detail: Extra text for the CLI legend (e.g. ``"18 pkts · +6.0 dB"``).
|
||||
key: The node's key hex (as full as the caller holds), seeding the label's
|
||||
key-derived hue; ``None`` leaves the label the muted no-key grey.
|
||||
"""
|
||||
|
||||
label: str
|
||||
lat: float
|
||||
lon: float
|
||||
is_repeater: bool = False
|
||||
is_self: bool = False
|
||||
detail: str = ""
|
||||
key: str | None = None
|
||||
|
||||
def _rank(self) -> int:
|
||||
"""Draw order: self on top of repeaters on top of leaf nodes."""
|
||||
return 2 if self.is_self else (1 if self.is_repeater else 0)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NeighbourInfo:
|
||||
"""One entry of a remote repeater's neighbour table, fetched over the mesh.
|
||||
@@ -346,6 +405,10 @@ class ChatMessage:
|
||||
created_at: When the message was sent or received.
|
||||
row_id: The ``messages`` table primary key once persisted, used to update an
|
||||
outbound message's delivery state in place on retry; ``None`` until stored.
|
||||
scope: What an outbound channel message was flooded under: the bare region name,
|
||||
:data:`~meshterm.core.regions.WILDCARD` (``*``) for unscoped, or ``None`` where
|
||||
it isn't known — every inbound or direct message, and anything sent before the
|
||||
scope was recorded.
|
||||
"""
|
||||
|
||||
text: str
|
||||
@@ -359,6 +422,7 @@ class ChatMessage:
|
||||
acked: bool | None = None
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
row_id: int | None = None
|
||||
scope: str | None = None
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for muted channel notifications.
|
||||
|
||||
Some channels carry traffic you want *recorded* but not *notified* about — an automated
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""A tiny, dependency-free Mapbox Vector Tile (MVT) decoder.
|
||||
|
||||
Vector tiles are Protocol-Buffer messages (spec: github.com/mapbox/vector-tile-spec). Rather
|
||||
|
||||
+296
-43
@@ -1,4 +1,5 @@
|
||||
"""Declarative registry of MeshTerm's own preferences, and the YAML file they live in.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Declarative registry of MeshTerm's own preferences, and the TOML file they live in.
|
||||
|
||||
The distinction this module draws is between the three kinds of "setting" the app already
|
||||
had and never named apart:
|
||||
@@ -22,9 +23,11 @@ away from that default, so the file is a short list of your disagreements with t
|
||||
built-in behaviour rather than a snapshot that silently pins every value forever. Delete a
|
||||
key (or use *Reset to defaults*) and the code's default takes over again.
|
||||
|
||||
The file is ``<config_dir>/preferences.yaml``, written grouped and commented so it reads
|
||||
the way the page does. Reads are tolerant: an unknown key, a malformed value, or an
|
||||
unparseable file costs the override, never the session.
|
||||
The file is ``<config_dir>/preferences.toml``, written flat, grouped and commented so it
|
||||
reads the way the page does — TOML, like ``config.toml`` and the device-config backups,
|
||||
so every file a person edits by hand speaks one syntax. Reads are tolerant: an unknown
|
||||
key, a malformed value, or a line TOML refuses costs that one override, never the rest of
|
||||
the file and never the session.
|
||||
|
||||
Two access paths, deliberately:
|
||||
|
||||
@@ -37,20 +40,31 @@ Two access paths, deliberately:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .advert_store import DEFAULT_QUIET_S, QUIET_CHOICES_S
|
||||
from .atomicwrite import write_atomically
|
||||
from .watch_store import DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
|
||||
from .courier_store import DONE_CAP
|
||||
from .geo import DEFAULT_VIEW_FRACTION
|
||||
from .watch_store import ALERT_CAP, DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else: # pragma: no cover - exercised only on 3.10
|
||||
import tomli as tomllib
|
||||
|
||||
#: Display groups, in the order the page and the file present them. The order is the
|
||||
#: order a session happens in — what MeshTerm puts on the air, how loud, what it watches
|
||||
#: for, how it draws the world, what it keeps, and how it paints. Nothing sorts
|
||||
#: alphabetically: a reader looking for "how long before I give up on a message" should
|
||||
#: not have to know it starts with a D.
|
||||
#: order a session happens in — what MeshTerm does to the radio as the link opens, what
|
||||
#: it puts on the air, how loud, what it watches for, how it draws the world, what it
|
||||
#: keeps, and how it paints. Nothing sorts alphabetically: a reader looking for "how long
|
||||
#: before I give up on a message" should not have to know it starts with a D.
|
||||
GROUPS: tuple[str, ...] = (
|
||||
"Device",
|
||||
"Sending",
|
||||
"TX optimize",
|
||||
"Watchtower",
|
||||
@@ -75,7 +89,7 @@ class PrefSpec:
|
||||
|
||||
Attributes:
|
||||
key: Canonical key — the attribute name on :class:`Preferences`, and the key in
|
||||
the YAML file.
|
||||
the TOML file.
|
||||
label: Human-friendly name, as the page's SETTING lane shows it.
|
||||
help: One-line description, as the page's DESCRIPTION lane shows it.
|
||||
group: One of :data:`GROUPS`.
|
||||
@@ -89,6 +103,14 @@ class PrefSpec:
|
||||
relaunch: Whether the new value only takes hold next launch. Woven into the
|
||||
description rather than shown as its own mark — it is a caveat about one
|
||||
preference, not a status the reader scans a column for.
|
||||
platforms: Which :class:`~meshterm.platforms.Platform` names the preference is
|
||||
*offered* on, or ``None`` (the default) for all of them. A preference about
|
||||
hardware only one flavour has — the PicoCalc's console font — would be a row
|
||||
answering a question the desktop cannot ask, so the page leaves it out there.
|
||||
Only the page: the key stays in the registry on every platform, so a file
|
||||
written on the handheld still parses, keeps its value and saves back out on a
|
||||
desktop rather than being silently dropped by a machine that merely cannot
|
||||
act on it.
|
||||
"""
|
||||
|
||||
key: str
|
||||
@@ -102,12 +124,28 @@ class PrefSpec:
|
||||
maximum: float | None = None
|
||||
unit: str = ""
|
||||
relaunch: bool = False
|
||||
platforms: frozenset[str] | None = None
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""The DESCRIPTION lane's text: the help, plus the relaunch caveat where it applies."""
|
||||
return f"{self.help} (next launch)" if self.relaunch else self.help
|
||||
|
||||
def offered_on(self, platform: str) -> bool:
|
||||
"""Whether the editor draws a row for this preference on ``platform``.
|
||||
|
||||
Args:
|
||||
platform: A :attr:`~meshterm.platforms.Platform.name`.
|
||||
|
||||
Returns:
|
||||
``True`` unless :attr:`platforms` names a set this platform is not in.
|
||||
"""
|
||||
return self.platforms is None or platform in self.platforms
|
||||
|
||||
|
||||
#: The two languages the plain CLI can print a time in. Named for what the reader sees
|
||||
#: rather than for the mechanism: an *age* is how long ago, a *timestamp* is when.
|
||||
_CLI_TIME_CHOICES: dict[str, str] = {"relative": "ages", "absolute": "timestamps"}
|
||||
|
||||
#: The silence-rule choices, drawn from the Watchtower's own ring so the two never drift.
|
||||
_SILENCE_CHOICES: dict[int, str] = {
|
||||
@@ -115,6 +153,10 @@ _SILENCE_CHOICES: dict[int, str] = {
|
||||
OFF: "off",
|
||||
}
|
||||
|
||||
#: How long the air must stay quiet before the weekly flood advert goes out, drawn from the
|
||||
#: scheduler's own choices so the two never drift.
|
||||
_QUIET_CHOICES: dict[int, str] = {s: f"{s} s" for s in QUIET_CHOICES_S}
|
||||
|
||||
#: How the terminal-width reclaim can be asked for: follow the platform's own verdict, or
|
||||
#: overrule it in either direction. The row's description asks a yes/no question ("use the
|
||||
#: column ... ?"), so the values answer it in those words rather than naming the mechanism
|
||||
@@ -122,12 +164,20 @@ _SILENCE_CHOICES: dict[int, str] = {
|
||||
#: need to disagree with its platform.
|
||||
_WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
|
||||
|
||||
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
|
||||
#: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a
|
||||
#: default — nothing else has to follow.
|
||||
#: What the log file keeps. The plain level names, which are what every other tool calls
|
||||
#: these — someone being talked through a problem is being told "set it to debug", not
|
||||
#: "set it to everything".
|
||||
#: How many colours to send the terminal. ``auto`` is the honest default and the only
|
||||
#: value that is not itself a depth: it means "take prompt_toolkit's verdict unless we can
|
||||
#: positively establish better", which is the difference between fixing a host and breaking
|
||||
#: one. The rest are named for the count a reader can see rather than for the bit depth,
|
||||
#: because "24-bit" is a fact about the wire and "16 million" is a fact about the screen.
|
||||
#: See :func:`meshterm.ui.tui.session._color_depth` for why auto is not simply "the most
|
||||
#: this terminal can do".
|
||||
_COLOR_DEPTH_CHOICES: dict[str, str] = {
|
||||
"auto": "auto",
|
||||
"truecolor": "16 million",
|
||||
"256": "256",
|
||||
"16": "16",
|
||||
}
|
||||
|
||||
#: What MeshTerm may offer when it lands in a console that cannot draw it. Only ever
|
||||
#: consulted on the classic Windows console, the one host with no font fallback of its own.
|
||||
_CONSOLE_SETUP_CHOICES: dict[str, str] = {
|
||||
@@ -135,6 +185,19 @@ _CONSOLE_SETUP_CHOICES: dict[str, str] = {
|
||||
"off": "Leave it alone",
|
||||
}
|
||||
|
||||
#: The two console fonts ``scripts/picocalc/calculinux-console-font-6x12.sh`` (and its 6x8
|
||||
#: companion) build for the PicoCalc's framebuffer panel. Each is named by its cell and by the
|
||||
#: screen that cell buys, because choosing a font here is choosing how much fits — not a bitmap.
|
||||
#: Plain ASCII ``x`` and not ``×``: the multiplication sign is not one of the 512 glyphs
|
||||
#: the console font carries, so it would draw as a tofu box on the one platform this
|
||||
#: preference is offered on. The value maps to a file in
|
||||
#: :data:`meshterm.services.consolefont.FONT_FILES`, which is where a filename belongs —
|
||||
#: a path on one device is not something a reader picks.
|
||||
_CONSOLE_FONT_CHOICES: dict[str, str] = {"6x12": "6x12 (53x26)", "6x8": "6x8 (53x40)"}
|
||||
|
||||
#: What the log file keeps. The plain level names, which are what every other tool calls
|
||||
#: these — someone being talked through a problem is being told "set it to debug", not
|
||||
#: "set it to everything".
|
||||
_LOG_LEVEL_CHOICES: dict[str, str] = {
|
||||
"ERROR": "Error",
|
||||
"WARNING": "Warning",
|
||||
@@ -143,7 +206,24 @@ _LOG_LEVEL_CHOICES: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
|
||||
#: Preferences page, a key in the TOML file, a ``preferences get``/``set`` CLI face, and a
|
||||
#: default — nothing else has to follow. A default that a module already states as its
|
||||
#: code-level behaviour is named, never re-typed: one source, so changing it cannot leave
|
||||
#: the constant and the registry disagreeing about what MeshTerm does.
|
||||
PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
# --- Device ------------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="set_clock_on_connect",
|
||||
label="Set clock on connect",
|
||||
help="Set the device clock from this computer when it connects",
|
||||
group="Device",
|
||||
value_type="bool",
|
||||
# Off: writing to a radio nobody asked to be written to is the owner's call. The
|
||||
# set is silent (the log records it) and happens once per connection — see
|
||||
# :mod:`meshterm.services.clock_sync`.
|
||||
default=False,
|
||||
),
|
||||
# --- Sending -----------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="trace_cooldown_s",
|
||||
@@ -167,6 +247,26 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
maximum=3600.0,
|
||||
unit="s",
|
||||
),
|
||||
PrefSpec(
|
||||
key="weekly_flood_advert",
|
||||
label="Weekly advert",
|
||||
help="Flood an advert after a week without one",
|
||||
group="Sending",
|
||||
value_type="bool",
|
||||
# Off: a companion never advertises on its own, so this is the only advert MeshTerm
|
||||
# would ever send unasked, and every repeater in range rebroadcasts it. Turning it
|
||||
# on starts the week rather than sending — see meshterm.services.advert_scheduler.
|
||||
default=False,
|
||||
),
|
||||
PrefSpec(
|
||||
key="advert_quiet_s",
|
||||
label="Advert quiet",
|
||||
help="Silence to wait for before the weekly advert",
|
||||
group="Sending",
|
||||
value_type="enum",
|
||||
default=DEFAULT_QUIET_S,
|
||||
choices=_QUIET_CHOICES,
|
||||
),
|
||||
PrefSpec(
|
||||
key="direct_message_soft_retries",
|
||||
label="Message retries",
|
||||
@@ -227,7 +327,7 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
help="How many past alerts to keep",
|
||||
group="Watchtower",
|
||||
value_type="int",
|
||||
default=200,
|
||||
default=ALERT_CAP,
|
||||
minimum=10,
|
||||
maximum=5000,
|
||||
),
|
||||
@@ -238,7 +338,7 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
help="Share of nodes to fit on screen; 1 shows them all",
|
||||
group="Map",
|
||||
value_type="float",
|
||||
default=0.5,
|
||||
default=DEFAULT_VIEW_FRACTION,
|
||||
minimum=0.1,
|
||||
maximum=1.0,
|
||||
),
|
||||
@@ -279,11 +379,24 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
help="Finished outbox messages to keep",
|
||||
group="History",
|
||||
value_type="int",
|
||||
default=100,
|
||||
default=DONE_CAP,
|
||||
minimum=10,
|
||||
maximum=5000,
|
||||
),
|
||||
# --- Display -----------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="cli_time_format",
|
||||
label="Command-line times",
|
||||
help="Whether the command line prints ages or timestamps",
|
||||
group="Display",
|
||||
value_type="enum",
|
||||
# Relative, because a person at a prompt asking "heard recently?" should not have
|
||||
# to subtract an ISO instant from `date` to find out. `--absolute` overrides this
|
||||
# for one run, and the JSON face is UTC either way — it is read somewhere else and
|
||||
# often later, where a relative age has nothing to be relative to.
|
||||
default="relative",
|
||||
choices=_CLI_TIME_CHOICES,
|
||||
),
|
||||
PrefSpec(
|
||||
key="fast_render",
|
||||
label="Fast redraw",
|
||||
@@ -302,6 +415,20 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
default="auto",
|
||||
choices=_WIDTH_CHOICES,
|
||||
),
|
||||
PrefSpec(
|
||||
key="color_depth",
|
||||
label="Colours",
|
||||
help="How many colours to send this terminal",
|
||||
group="Display",
|
||||
value_type="enum",
|
||||
# Auto, because the count a terminal accepts is a fact about the terminal and the
|
||||
# reader should not have to know it. It is only ever consulted to *raise* the
|
||||
# verdict prompt_toolkit already reached, never to lower it — a terminal that
|
||||
# cannot be shown to do better keeps exactly what it had.
|
||||
default="auto",
|
||||
choices=_COLOR_DEPTH_CHOICES,
|
||||
relaunch=True,
|
||||
),
|
||||
PrefSpec(
|
||||
key="console_setup",
|
||||
label="Console setup",
|
||||
@@ -316,6 +443,20 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
default="auto",
|
||||
choices=_CONSOLE_SETUP_CHOICES,
|
||||
),
|
||||
PrefSpec(
|
||||
key="console_font",
|
||||
label="Console font",
|
||||
help="Bigger text, or more rows on the handheld's panel",
|
||||
group="Display",
|
||||
value_type="enum",
|
||||
# The 6x12 Terminus derivative, because it is the font the device boots with and
|
||||
# the one every screen's row budget is designed to (Platform.readable_rows is 26).
|
||||
# The 6x8 build is the same glyph inventory in a shorter cell — fourteen more rows
|
||||
# for anyone who would rather see more of a list than read it comfortably.
|
||||
default="6x12",
|
||||
choices=_CONSOLE_FONT_CHOICES,
|
||||
platforms=frozenset({"picocalc"}),
|
||||
),
|
||||
# --- Diagnostics ---------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="log_level",
|
||||
@@ -334,6 +475,28 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
|
||||
_BY_KEY: dict[str, PrefSpec] = {spec.key: spec for spec in PREFERENCES}
|
||||
|
||||
#: Every key that was once a preference and no longer is. A retired key is never reused:
|
||||
#: a value can outlive its key in a file nobody saved since, and if the name came back
|
||||
#: with a new meaning — seconds become minutes, a range moves — an old ``30`` that still
|
||||
#: passes the new spec would load cleanly and mean something else, which no validation
|
||||
#: can catch. So a preference that returns changed returns under a new name (a unit in the
|
||||
#: key makes that the natural move: ``_s`` to ``_min``), and ``tests/test_retired.py``
|
||||
#: fails any registry that takes one of these back. Loading refuses them outright.
|
||||
RETIRED: frozenset[str] = frozenset(
|
||||
{
|
||||
# Per-device background advert cadences, moved to Device config and since replaced
|
||||
# by the weekly flood advert (``weekly_flood_advert``).
|
||||
"advert_direct_hours",
|
||||
"advert_flood_hours",
|
||||
}
|
||||
)
|
||||
|
||||
#: The reason :attr:`Preferences.dropped` gives for a key found in :data:`RETIRED`.
|
||||
DROPPED_RETIRED = "retired"
|
||||
|
||||
#: The reason :attr:`Preferences.dropped` gives for a key the registry has never held.
|
||||
DROPPED_UNKNOWN = "unknown"
|
||||
|
||||
|
||||
def get_spec(key: str) -> PrefSpec:
|
||||
"""Look up one preference's spec.
|
||||
@@ -350,16 +513,26 @@ def get_spec(key: str) -> PrefSpec:
|
||||
try:
|
||||
return _BY_KEY[key]
|
||||
except KeyError:
|
||||
if key in RETIRED:
|
||||
raise PreferenceError(f"{key!r} is no longer a preference") from None
|
||||
raise PreferenceError(f"unknown preference: {key!r}") from None
|
||||
|
||||
|
||||
def by_group() -> list[tuple[str, list[PrefSpec]]]:
|
||||
def by_group(platform: str | None = None) -> list[tuple[str, list[PrefSpec]]]:
|
||||
"""Every preference grouped for display, in :data:`GROUPS` order.
|
||||
|
||||
Args:
|
||||
platform: A :attr:`~meshterm.platforms.Platform.name` to draw the page for, which
|
||||
drops the preferences that platform is not offered (see
|
||||
:meth:`PrefSpec.offered_on`). ``None`` — the default, and what the file writer
|
||||
uses — keeps every preference, so an override made on one flavour survives
|
||||
being loaded and saved on another.
|
||||
|
||||
Returns:
|
||||
``(group, specs)`` pairs; a group holding no preferences is omitted.
|
||||
"""
|
||||
grouped = [(g, [s for s in PREFERENCES if s.group == g]) for g in GROUPS]
|
||||
kept = [s for s in PREFERENCES if platform is None or s.offered_on(platform)]
|
||||
grouped = [(g, [s for s in kept if s.group == g]) for g in GROUPS]
|
||||
return [(g, specs) for g, specs in grouped if specs]
|
||||
|
||||
|
||||
@@ -399,15 +572,15 @@ def parse_value(spec: PrefSpec, raw: Any) -> Any:
|
||||
if raw in choices and not isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, bool):
|
||||
# YAML 1.1 reads a bare ``yes``/``no`` (and ``on``/``off``) as a *boolean*, so a
|
||||
# hand-edited file hands us ``True`` where the choice is spelled "yes". We quote
|
||||
# ours on the way out, but someone typing the obvious thing should still be
|
||||
# understood: map the boolean back to whichever spelling this spec offers.
|
||||
# A hand edit that answers a yes/no choice with a TOML boolean
|
||||
# (``full_width = true``) hands us ``True`` where the choice is spelled "yes".
|
||||
# We quote ours on the way out, but someone typing the obvious thing should
|
||||
# still be understood: map the boolean back to whichever spelling this offers.
|
||||
for choice in choices:
|
||||
if str(choice).lower() in (_TRUE if raw else _FALSE):
|
||||
return choice
|
||||
# Text arriving from the CLI or a hand-edited file: match a choice by its own
|
||||
# spelling, so `preferences set watch_silence_hours 6` and a YAML `6` land alike.
|
||||
# spelling, so `preferences set watch_silence_hours 6` and a TOML `6` land alike.
|
||||
for choice in choices:
|
||||
if text.lower() == str(choice).lower():
|
||||
return choice
|
||||
@@ -463,6 +636,9 @@ def range_hint(spec: PrefSpec) -> str:
|
||||
|
||||
# --- the store ----------------------------------------------------------------
|
||||
|
||||
#: The file's name inside the config directory — one spelling for every place that opens it.
|
||||
PREFERENCES_FILENAME = "preferences.toml"
|
||||
|
||||
#: The file's opening comment. It says the one thing a reader hand-editing the file needs
|
||||
#: to know — that absence means "use the default", so deleting a line is how a preference
|
||||
#: is undone — and points at the two neighbouring kinds of setting so nobody looks for a
|
||||
@@ -475,11 +651,36 @@ _HEADER = """\
|
||||
# Preferences page) hands the value back to the code. `meshterm preferences show` prints
|
||||
# every preference, what it is set to, and what it defaults to.
|
||||
#
|
||||
# One `key = value` per line: text in quotes ("DEBUG"), numbers and true/false bare.
|
||||
#
|
||||
# Device profiles, the database location, and the rest of the machine setup stay in
|
||||
# config.toml; the radio's own settings live on the radio, under Device config.
|
||||
"""
|
||||
|
||||
|
||||
def _salvage(text: str) -> dict[str, Any]:
|
||||
"""Read a file TOML refused as a document, one ``key = value`` line at a time.
|
||||
|
||||
MeshTerm writes the file flat — no tables, one entry to a line — so a line is a whole
|
||||
entry and can be judged on its own. A line TOML accepts keeps its typed value. One it
|
||||
refuses keeps its right-hand side as text, which is exactly what :func:`parse_value`
|
||||
takes from the command line, so the likeliest hand edit of all — a word left unquoted,
|
||||
``log_level = DEBUG`` — is still understood. Whatever the spec then refuses is dropped
|
||||
by :meth:`Preferences.update`, the same as any other bad value.
|
||||
"""
|
||||
data: dict[str, Any] = {}
|
||||
for line in text.splitlines():
|
||||
entry = line.strip()
|
||||
if not entry or entry.startswith(("#", "[")) or "=" not in entry:
|
||||
continue
|
||||
try:
|
||||
data.update(tomllib.loads(entry))
|
||||
except tomllib.TOMLDecodeError:
|
||||
key, _, value = entry.partition("=")
|
||||
data[key.strip()] = value.split("#", 1)[0].strip()
|
||||
return data
|
||||
|
||||
|
||||
class Preferences:
|
||||
"""The effective preferences: the built-in defaults, with the file's overrides on top.
|
||||
|
||||
@@ -504,26 +705,60 @@ class Preferences:
|
||||
"""
|
||||
self._path = path
|
||||
self._values: dict[str, Any] = {}
|
||||
self._dropped: dict[str, str] = {}
|
||||
if values:
|
||||
self.update(values)
|
||||
self._absorb(values)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> Preferences:
|
||||
"""Read ``path``, returning all-defaults for a missing, empty, or corrupt file.
|
||||
"""Read ``path``, returning all-defaults for a missing, empty, or unreadable file.
|
||||
|
||||
TOML refuses a whole document over one bad line, and a hand edit should cost the
|
||||
line it is on, not the file: a document TOML refuses is read again a line at a
|
||||
time (:func:`_salvage`), keeping every entry that still makes sense.
|
||||
|
||||
Args:
|
||||
path: The YAML file to read.
|
||||
path: The TOML file to read.
|
||||
|
||||
Nothing the file holds that the registry cannot take survives the next
|
||||
:meth:`save`, which writes only the overrides in force: a retired key, a key
|
||||
never registered (a typo, most likely), and a value its spec refuses are all
|
||||
left out, and :attr:`dropped` says which and why, for the caller to log once
|
||||
logging is configured (see :func:`report_dropped`).
|
||||
|
||||
Returns:
|
||||
The loaded preferences, bound to ``path`` for a later :meth:`save`.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, yaml.YAMLError):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, ValueError):
|
||||
return cls(path)
|
||||
return cls(path, data if isinstance(data, Mapping) else None)
|
||||
try:
|
||||
data: dict[str, Any] = tomllib.loads(text)
|
||||
except tomllib.TOMLDecodeError:
|
||||
data = _salvage(text)
|
||||
return cls(path, data)
|
||||
|
||||
def _absorb(self, values: Mapping[str, Any]) -> None:
|
||||
"""Take in a file's entries, recording in :attr:`dropped` each one refused."""
|
||||
for raw_key, value in values.items():
|
||||
key = str(raw_key)
|
||||
if key in RETIRED:
|
||||
self._dropped[key] = DROPPED_RETIRED
|
||||
continue
|
||||
try:
|
||||
self.set(key, value)
|
||||
except PreferenceError as exc:
|
||||
self._dropped[key] = str(exc) if key in _BY_KEY else DROPPED_UNKNOWN
|
||||
|
||||
@property
|
||||
def dropped(self) -> dict[str, str]:
|
||||
"""What loading refused, key by key (a copy).
|
||||
|
||||
The reason is :data:`DROPPED_RETIRED`, :data:`DROPPED_UNKNOWN`, or — for a key
|
||||
that is registered but whose value its spec refused — the refusal itself.
|
||||
"""
|
||||
return dict(self._dropped)
|
||||
|
||||
@property
|
||||
def path(self) -> Path | None:
|
||||
@@ -611,14 +846,14 @@ class Preferences:
|
||||
self._values.clear()
|
||||
return count
|
||||
|
||||
def as_yaml(self) -> str:
|
||||
def as_toml(self) -> str:
|
||||
"""Render the current overrides as the file's text: header, then grouped entries.
|
||||
|
||||
Each group holding an override gets its own comment heading, and each entry its
|
||||
help line and its default above it, so the file reads the way the page does
|
||||
rather than as a bare map someone has to look up elsewhere.
|
||||
"""
|
||||
import yaml
|
||||
import tomli_w
|
||||
|
||||
lines = [_HEADER]
|
||||
for group, specs in by_group():
|
||||
@@ -627,12 +862,7 @@ class Preferences:
|
||||
continue
|
||||
lines.append(f"# --- {group} ---")
|
||||
for spec in present:
|
||||
entry = yaml.safe_dump(
|
||||
{spec.key: self._values[spec.key]},
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
).rstrip("\n")
|
||||
entry = tomli_w.dumps({spec.key: self._values[spec.key]}).rstrip("\n")
|
||||
lines.append(f"# {spec.help}")
|
||||
lines.append(f"# default: {format_value(spec, spec.default)}")
|
||||
lines.append(entry)
|
||||
@@ -650,7 +880,7 @@ class Preferences:
|
||||
"""
|
||||
if self._path is None:
|
||||
raise RuntimeError("these preferences have no file to save to")
|
||||
write_atomically(self._path, self.as_yaml())
|
||||
write_atomically(self._path, self.as_toml())
|
||||
|
||||
|
||||
#: The set installed for this process. Starts as a defaults-only, file-less instance so
|
||||
@@ -659,6 +889,27 @@ class Preferences:
|
||||
_current: Preferences = Preferences()
|
||||
|
||||
|
||||
def report_dropped(preferences: Preferences, log: logging.Logger) -> None:
|
||||
"""Log what loading the file refused: the entries its next save will leave out.
|
||||
|
||||
A retired key is expected — it was written by an older MeshTerm — so it is noted at
|
||||
INFO. Anything else was probably typed by hand and is not doing what its author
|
||||
meant, so it is a WARNING, and the line names it so the typo can be found.
|
||||
|
||||
Args:
|
||||
preferences: A set built by :meth:`Preferences.load`.
|
||||
log: Where to report.
|
||||
"""
|
||||
name = preferences.path.name if preferences.path else PREFERENCES_FILENAME
|
||||
for key, why in preferences.dropped.items():
|
||||
if why == DROPPED_RETIRED:
|
||||
log.info("%s: ignoring retired preference %r; the next save drops it", name, key)
|
||||
elif why == DROPPED_UNKNOWN:
|
||||
log.warning("%s: ignoring unknown preference %r; the next save drops it", name, key)
|
||||
else:
|
||||
log.warning("%s: ignoring %s; the next save drops it", name, why)
|
||||
|
||||
|
||||
def current() -> Preferences:
|
||||
"""The preferences in force for this process.
|
||||
|
||||
@@ -680,6 +931,7 @@ def install(preferences: Preferences) -> None:
|
||||
__all__ = [
|
||||
"GROUPS",
|
||||
"PREFERENCES",
|
||||
"RETIRED",
|
||||
"PrefSpec",
|
||||
"PreferenceError",
|
||||
"Preferences",
|
||||
@@ -690,4 +942,5 @@ __all__ = [
|
||||
"install",
|
||||
"parse_value",
|
||||
"range_hint",
|
||||
"report_dropped",
|
||||
]
|
||||
@@ -0,0 +1,552 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The software MeshCore node MeshTerm runs on a radio wired straight to the host.
|
||||
|
||||
A board like the uConsole's AIO puts an SX1262 on the host's SPI bus with no
|
||||
microcontroller and no firmware in front of it, so there is no companion to talk to until
|
||||
something runs one. This file is that something: MeshTerm starts it as a **child process**
|
||||
when it connects (:mod:`meshterm.core.spiradio` is the parent's half), talks to it over the
|
||||
standard companion protocol on a loopback port like any network companion, and ends it when
|
||||
it disconnects — so the radio's pins are held exactly as long as MeshTerm is using them.
|
||||
|
||||
**Self-contained on purpose.** Nothing here imports MeshTerm. The radio library
|
||||
(``openhop_core``) is an optional install that often lives in a different interpreter than
|
||||
MeshTerm's — the one-file build cannot import it at all, and on a uConsole it usually sits
|
||||
in its own venv — so the parent runs *this file* by path under whichever Python has the
|
||||
library. Standard library plus ``openhop_core``, and nothing else, is the contract that
|
||||
makes that work; ``tests/test_radionode.py`` holds it.
|
||||
|
||||
**What firmware would remember, this remembers.** The library keeps preferences, channels
|
||||
and contacts in memory only, which is why a node run this way used to come up with no
|
||||
channels after every restart. Everything a firmware companion keeps in flash is kept in the
|
||||
state directory the parent names instead:
|
||||
|
||||
================= =============================================================
|
||||
``identity.key`` the node's private seed — its identity on the mesh
|
||||
``prefs.json`` name, radio settings, TX power, position, the other prefs
|
||||
``channels.json`` the channel table, slot by slot
|
||||
``contacts.json`` the contact list
|
||||
================= =============================================================
|
||||
|
||||
**Talking to the parent.** The parent reads exactly one JSON line from this process's
|
||||
stdout: ``{"event": "ready", "port": …, "public_key": …}`` once the frame server is
|
||||
listening, or ``{"event": "error", "kind": …, "message": …}`` if the radio could not be
|
||||
opened. The library prints its own diagnostics to stdout, so the real stdout is set aside
|
||||
for that one line and everything else is sent to stderr, which the parent keeps as the
|
||||
node's log.
|
||||
|
||||
**Letting go of the radio.** GPIO lines requested through the character device and an open
|
||||
``spidev`` are the kernel's to release, and it releases them when the process ends however
|
||||
it ends. The work is making sure the process *does* end with its parent: it exits when its
|
||||
stdin reaches end-of-file (the parent closing it, or the parent dying and the kernel closing
|
||||
it), and on Linux it also asks for ``SIGTERM`` the moment its parent goes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
#: The radio library this node runs on. ``pymc_core`` (its name before 2026) is not
|
||||
#: supported here: it needs three shims that ``openhop_core`` made unnecessary, and the
|
||||
#: standalone bridge still carries them for anyone on it.
|
||||
RUNTIME = "openhop_core"
|
||||
|
||||
#: How many times ``radio.begin()`` is tried when a GPIO line is busy, and the pause before
|
||||
#: each retry. A node that just exited released its lines as it went, so the retries are for
|
||||
#: a line some *other* program is letting go of — not for waiting out a program that holds it.
|
||||
BEGIN_ATTEMPTS = 3
|
||||
BEGIN_BACKOFF_S = 1.5
|
||||
|
||||
#: The model string the frame server reports, which MeshTerm shows as the device model.
|
||||
DEVICE_MODEL = "MeshTerm SPI node"
|
||||
|
||||
log = logging.getLogger("radionode")
|
||||
|
||||
|
||||
# --- errors the parent is told about -------------------------------------------------------
|
||||
|
||||
|
||||
class NodeError(Exception):
|
||||
"""A failure to bring the node up, with the ``kind`` the parent words its message by.
|
||||
|
||||
Kinds: ``runtime`` (the radio library is missing or too old), ``no-spi`` / ``no-gpio``
|
||||
(the device node doesn't exist), ``permission`` (it exists but this user can't open it),
|
||||
``busy`` (another program holds the radio's pins), and ``failed`` (anything else).
|
||||
"""
|
||||
|
||||
def __init__(self, kind: str, message: str) -> None:
|
||||
"""Carry ``message`` as the error text and ``kind`` as its classification."""
|
||||
super().__init__(message)
|
||||
self.kind = kind
|
||||
|
||||
|
||||
class _Recent(logging.Handler):
|
||||
"""Keeps the library's recent error lines, to tell *why* ``radio.begin()`` gave up.
|
||||
|
||||
The GPIO manager answers a busy or forbidden pin by logging the reason and calling
|
||||
``sys.exit``, so the reason exists only as a log line; this is where it is read back.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(level=logging.WARNING)
|
||||
self.lines: list[str] = []
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
self.lines.append(record.getMessage())
|
||||
del self.lines[:-20]
|
||||
|
||||
|
||||
def classify_begin_failure(lines: list[str]) -> str:
|
||||
"""The error kind a failed ``radio.begin()`` amounts to, from the library's log lines."""
|
||||
text = " ".join(lines).lower()
|
||||
if "already in use" in text or "resource busy" in text:
|
||||
return "busy"
|
||||
if "permission denied" in text:
|
||||
return "permission"
|
||||
return "failed"
|
||||
|
||||
|
||||
# --- the state directory -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_json(path: Path, value: Any) -> None:
|
||||
"""Replace ``path`` with ``value`` as JSON in one rename (a crash keeps the old file)."""
|
||||
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
||||
tmp.write_text(json.dumps(value, indent=2), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any:
|
||||
"""``path`` parsed as JSON, or ``None`` when it is missing or unreadable."""
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def load_identity_seed(state: Path, mint) -> bytes: # noqa: ANN001 - () -> bytes
|
||||
"""The node's private seed from ``identity.key``, minting and saving one if there is none.
|
||||
|
||||
Args:
|
||||
state: The node's state directory.
|
||||
mint: Makes a fresh seed (the library's key generator) when none is saved yet.
|
||||
"""
|
||||
path = state / "identity.key"
|
||||
try:
|
||||
seed = path.read_bytes()
|
||||
except OSError:
|
||||
seed = b""
|
||||
if seed:
|
||||
return seed
|
||||
seed = bytes(mint())
|
||||
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
||||
tmp.write_bytes(seed)
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
tmp.replace(path)
|
||||
log.info("minted a new node identity at %s", path)
|
||||
return seed
|
||||
|
||||
|
||||
def prefs_to_json(prefs: Any) -> dict:
|
||||
"""A ``NodePrefs`` as JSON-safe fields (``bytes`` as hex), for ``prefs.json``."""
|
||||
out = {}
|
||||
for field in dataclasses.fields(prefs):
|
||||
value = getattr(prefs, field.name)
|
||||
out[field.name] = value.hex() if isinstance(value, (bytes, bytearray)) else value
|
||||
return out
|
||||
|
||||
|
||||
def apply_saved_prefs(prefs: Any, saved: Any) -> None:
|
||||
"""Load ``prefs.json`` into a ``NodePrefs`` in place, field by field.
|
||||
|
||||
Only fields the library's ``NodePrefs`` still declares are taken, each coerced to the
|
||||
type its default has, so a field the library dropped is ignored and a hand-edited value
|
||||
of the wrong type is skipped rather than carried into the radio.
|
||||
"""
|
||||
if not isinstance(saved, dict):
|
||||
return
|
||||
for field in dataclasses.fields(prefs):
|
||||
if field.name not in saved:
|
||||
continue
|
||||
current = getattr(prefs, field.name)
|
||||
raw = saved[field.name]
|
||||
try:
|
||||
if isinstance(current, (bytes, bytearray)):
|
||||
value: Any = bytes.fromhex(str(raw))
|
||||
elif isinstance(current, bool):
|
||||
value = bool(raw)
|
||||
elif isinstance(current, int):
|
||||
value = int(raw)
|
||||
elif isinstance(current, float):
|
||||
value = float(raw)
|
||||
else:
|
||||
value = str(raw)
|
||||
except (TypeError, ValueError):
|
||||
log.warning("prefs.json: ignoring %s=%r", field.name, raw)
|
||||
continue
|
||||
setattr(prefs, field.name, value)
|
||||
|
||||
|
||||
def channels_to_json(store: Any) -> list[dict]:
|
||||
"""The channel table as ``[{"idx", "name", "secret"}]`` in slot order."""
|
||||
out = []
|
||||
for idx in range(store.max_channels):
|
||||
channel = store.get(idx)
|
||||
if channel is not None and channel.name:
|
||||
out.append({"idx": idx, "name": channel.name, "secret": bytes(channel.secret).hex()})
|
||||
return out
|
||||
|
||||
|
||||
def saved_channels(saved: Any) -> list[tuple[int, str, bytes]]:
|
||||
"""``channels.json`` as ``(idx, name, secret)`` triples, skipping malformed entries."""
|
||||
out = []
|
||||
for entry in saved if isinstance(saved, list) else []:
|
||||
try:
|
||||
out.append((int(entry["idx"]), str(entry["name"]), bytes.fromhex(entry["secret"])))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
return [c for c in out if c[1]]
|
||||
|
||||
|
||||
# --- the node ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def preamble_for_sf(spreading_factor: int) -> int:
|
||||
"""The LoRa preamble, in symbols, MeshCore uses at a spreading factor: 32 up to SF8, else 16.
|
||||
|
||||
This is MeshCore's own rule (``RadioLibWrapper::preambleLengthForSF``), and the receiver
|
||||
has to follow it, not only the transmitter. The SX1262 waits for the sync word only about
|
||||
as long as the preamble it was told to expect, so a node listening for 12 symbols against
|
||||
a mesh sending 32 locks on, gives up, and locks on again further along the same preamble
|
||||
— and decodes a packet only when it happens to lock on near the end. That was the whole
|
||||
of "the uConsole misses replies other radios hear": the preamble was a fixed 12, the
|
||||
library's default, and at SF7 the mesh sends 32.
|
||||
"""
|
||||
return 32 if spreading_factor <= 8 else 16
|
||||
|
||||
|
||||
def radio_kwargs(signature_params, wiring: dict, prefs: Any) -> dict: # noqa: ANN001
|
||||
"""The ``SX1262Radio`` constructor arguments: the board's wiring plus the saved radio.
|
||||
|
||||
Passes only what this library version's constructor accepts, so a knob it lacks is
|
||||
dropped instead of refusing the call. ``None`` means "not set" and is never passed.
|
||||
"""
|
||||
wanted = dict(wiring)
|
||||
wanted.update(
|
||||
preamble_length=preamble_for_sf(prefs.spreading_factor),
|
||||
frequency=prefs.frequency_hz,
|
||||
bandwidth=prefs.bandwidth_hz,
|
||||
spreading_factor=prefs.spreading_factor,
|
||||
coding_rate=prefs.coding_rate,
|
||||
tx_power=prefs.tx_power_dbm,
|
||||
)
|
||||
return {k: v for k, v in wanted.items() if k in signature_params and v is not None}
|
||||
|
||||
|
||||
def _check_device(path: str, missing_kind: str, what: str) -> None:
|
||||
"""Refuse early, with the right kind, when a device node is absent or unopenable."""
|
||||
if not os.path.exists(path):
|
||||
raise NodeError(missing_kind, f"{path} does not exist — is the {what} enabled?")
|
||||
if not os.access(path, os.R_OK | os.W_OK):
|
||||
raise NodeError("permission", f"no read/write permission on {path}")
|
||||
|
||||
|
||||
def _persist_contacts(store: Any, path: Path) -> None:
|
||||
"""Snapshot the contact list after every change, the way the firmware writes flash."""
|
||||
|
||||
def save() -> None:
|
||||
try:
|
||||
_write_json(path, store.to_dicts())
|
||||
except Exception as exc: # noqa: BLE001 - a failed snapshot must not stop the node
|
||||
log.warning("could not save contacts: %s", exc)
|
||||
|
||||
for name in ("add", "add_or_overwrite", "update", "remove", "clear"):
|
||||
original = getattr(store, name, None)
|
||||
if original is None:
|
||||
continue
|
||||
|
||||
def wrapped(*args, _original=original, **kwargs): # noqa: ANN002, ANN003, ANN202
|
||||
result = _original(*args, **kwargs)
|
||||
save()
|
||||
return result
|
||||
|
||||
setattr(store, name, wrapped)
|
||||
store.save_snapshot = save
|
||||
|
||||
|
||||
async def run_node(config: dict, report) -> int: # noqa: ANN001 - (dict) -> None
|
||||
"""Bring the node up, report ready, and serve until told to stop.
|
||||
|
||||
Args:
|
||||
config: ``state_dir``, ``wiring`` (the board's pins and switches) and ``seed``
|
||||
(name and radio settings for a node with no ``prefs.json`` yet).
|
||||
report: Sends the one status line to the parent.
|
||||
|
||||
Returns:
|
||||
The process exit status.
|
||||
"""
|
||||
import importlib
|
||||
import inspect
|
||||
|
||||
try:
|
||||
rt = importlib.import_module(RUNTIME)
|
||||
companion_mod = importlib.import_module(f"{RUNTIME}.companion")
|
||||
models = importlib.import_module(f"{RUNTIME}.companion.models")
|
||||
identity_mod = importlib.import_module(f"{RUNTIME}.protocol.identity")
|
||||
sx1262 = importlib.import_module(f"{RUNTIME}.hardware.sx1262_wrapper")
|
||||
except ImportError as exc:
|
||||
raise NodeError("runtime", f"{RUNTIME} is not importable here ({exc})") from exc
|
||||
log.info("node runtime %s %s (%s)", RUNTIME, getattr(rt, "__version__", "?"), sys.executable)
|
||||
|
||||
state = Path(config["state_dir"])
|
||||
state.mkdir(parents=True, exist_ok=True)
|
||||
wiring = dict(config.get("wiring") or {})
|
||||
seed = dict(config.get("seed") or {})
|
||||
|
||||
bus, cs = int(wiring.get("bus_id", 0)), int(wiring.get("cs_id", 0))
|
||||
_check_device(f"/dev/spidev{bus}.{cs}", "no-spi", "SPI overlay")
|
||||
_check_device(f"/dev/gpiochip{int(wiring.get('gpio_chip', 0))}", "no-gpio", "GPIO chip")
|
||||
|
||||
# The saved preferences decide the radio the chip is brought up on; the seed only fills
|
||||
# in a node that has never saved any.
|
||||
prefs = models.NodePrefs(
|
||||
node_name=seed.get("node_name", "MeshTerm"),
|
||||
tx_power_dbm=seed.get("tx_power_dbm", 22),
|
||||
frequency_hz=seed.get("frequency_hz", 910_525_000),
|
||||
bandwidth_hz=seed.get("bandwidth_hz", 62_500),
|
||||
spreading_factor=seed.get("spreading_factor", 7),
|
||||
coding_rate=seed.get("coding_rate", 5),
|
||||
)
|
||||
apply_saved_prefs(prefs, _read_json(state / "prefs.json"))
|
||||
|
||||
SX1262Radio = sx1262.SX1262Radio
|
||||
kwargs = radio_kwargs(inspect.signature(SX1262Radio.__init__).parameters, wiring, prefs)
|
||||
log.info("radio config %s", " ".join(f"{k}={v}" for k, v in kwargs.items()))
|
||||
radio = SX1262Radio(**kwargs)
|
||||
|
||||
recent = _Recent()
|
||||
logging.getLogger().addHandler(recent)
|
||||
for attempt in range(BEGIN_ATTEMPTS):
|
||||
recent.lines.clear()
|
||||
try:
|
||||
if radio.begin():
|
||||
break
|
||||
kind = classify_begin_failure(recent.lines)
|
||||
except SystemExit: # the GPIO manager's answer to a busy or forbidden pin
|
||||
kind = classify_begin_failure(recent.lines)
|
||||
except Exception as exc: # noqa: BLE001 - reported to the parent, not raised
|
||||
recent.lines.append(str(exc))
|
||||
kind = classify_begin_failure(recent.lines)
|
||||
try:
|
||||
radio.cleanup()
|
||||
except Exception: # noqa: BLE001, S110 - best-effort release before retrying
|
||||
pass
|
||||
if kind != "busy" or attempt == BEGIN_ATTEMPTS - 1:
|
||||
detail = recent.lines[-1] if recent.lines else "radio.begin() failed"
|
||||
raise NodeError(kind, detail)
|
||||
await asyncio.sleep(BEGIN_BACKOFF_S * (attempt + 1))
|
||||
logging.getLogger().removeHandler(recent)
|
||||
log.info("radio ready")
|
||||
|
||||
try:
|
||||
seed_bytes = load_identity_seed(
|
||||
state, lambda: identity_mod.LocalIdentity().get_signing_key_bytes()
|
||||
)
|
||||
identity = identity_mod.LocalIdentity(seed_bytes)
|
||||
node = _persistent_companion(companion_mod.CompanionRadio, state)(
|
||||
radio,
|
||||
identity,
|
||||
node_name=prefs.node_name,
|
||||
radio_config={
|
||||
"frequency": prefs.frequency_hz,
|
||||
"bandwidth": prefs.bandwidth_hz,
|
||||
"spreading_factor": prefs.spreading_factor,
|
||||
"coding_rate": prefs.coding_rate,
|
||||
"tx_power": prefs.tx_power_dbm,
|
||||
},
|
||||
)
|
||||
apply_saved_prefs(node.prefs, prefs_to_json(prefs))
|
||||
|
||||
for idx, name, secret in saved_channels(_read_json(state / "channels.json")):
|
||||
node.set_channel(idx, name, secret)
|
||||
contacts = _read_json(state / "contacts.json")
|
||||
if isinstance(contacts, list):
|
||||
node.contacts.load_from_dicts(contacts)
|
||||
log.info("restored %d contact(s)", node.contacts.get_count())
|
||||
_persist_contacts(node.contacts, state / "contacts.json")
|
||||
node.add_push_callback(
|
||||
"channel_updated",
|
||||
lambda _idx, _channel: _write_json(
|
||||
state / "channels.json", channels_to_json(node.channels)
|
||||
),
|
||||
)
|
||||
|
||||
await node.start()
|
||||
public_key = node.get_public_key()
|
||||
server = companion_mod.CompanionFrameServer(
|
||||
bridge=node,
|
||||
companion_hash=f"{public_key[0]:02x}",
|
||||
port=0,
|
||||
bind_address="127.0.0.1",
|
||||
local_hash=public_key[0],
|
||||
device_model=DEVICE_MODEL,
|
||||
client_idle_timeout_sec=None,
|
||||
)
|
||||
await server.start()
|
||||
# The one push the library's frame server doesn't subscribe to: every overheard frame
|
||||
# with its SNR/RSSI, which MeshTerm's live feed is built from.
|
||||
node.add_push_callback("rx_log_data", server.push_rx_raw)
|
||||
port = server._server.sockets[0].getsockname()[1]
|
||||
except Exception:
|
||||
radio.cleanup()
|
||||
raise
|
||||
|
||||
log.info("node %s… listening on 127.0.0.1:%d", public_key.hex()[:16], port)
|
||||
report({"event": "ready", "port": port, "public_key": public_key.hex()})
|
||||
|
||||
try:
|
||||
await _until_told_to_stop()
|
||||
finally:
|
||||
log.info("shutting down")
|
||||
for step in (server.stop, node.stop):
|
||||
try:
|
||||
await step()
|
||||
except Exception as exc: # noqa: BLE001 - keep going: the radio still needs freeing
|
||||
log.warning("shutdown step failed: %s", exc)
|
||||
node.contacts.save_snapshot()
|
||||
radio.cleanup()
|
||||
return 0
|
||||
|
||||
|
||||
def apply_preamble(radio: Any, symbols: int) -> None:
|
||||
"""Give a running radio a new preamble length, for both what it sends and what it hears.
|
||||
|
||||
The driver reads ``preamble_length`` afresh for every transmission, but reception keeps
|
||||
the packet parameters it was last given, so those are re-sent from standby and the chip
|
||||
put back to listening.
|
||||
"""
|
||||
if getattr(radio, "preamble_length", symbols) == symbols:
|
||||
return
|
||||
radio.preamble_length = symbols
|
||||
lora = getattr(radio, "lora", None)
|
||||
if lora is None:
|
||||
return
|
||||
try:
|
||||
lora.setStandby(lora.STANDBY_RC)
|
||||
lora.setPacketParamsLoRa(symbols, lora.HEADER_EXPLICIT, 64, lora.CRC_ON, lora.IQ_STANDARD)
|
||||
lora.request(lora.RX_CONTINUOUS)
|
||||
log.info("preamble set to %d symbols", symbols)
|
||||
except Exception as exc: # noqa: BLE001 - the next transmission sets it regardless
|
||||
log.warning("could not apply the new preamble while listening: %s", exc)
|
||||
|
||||
|
||||
def _persistent_companion(base: type, state: Path) -> type:
|
||||
"""``CompanionRadio`` with its preferences written to ``prefs.json`` on every change.
|
||||
|
||||
``_save_prefs`` is the library's own hook for exactly this ("subclasses that need
|
||||
persistence … should override this method"), called after every preference setter.
|
||||
"""
|
||||
|
||||
class PersistentCompanion(base): # type: ignore[misc, valid-type]
|
||||
def set_radio_params(self, freq_hz: int, bw_hz: int, sf: int, cr: int) -> bool:
|
||||
"""Retune, and bring the preamble along when the spreading factor moves it.
|
||||
|
||||
The library retunes the modulation but leaves the packet parameters alone, and
|
||||
the preamble is one of them (see :func:`preamble_for_sf`).
|
||||
"""
|
||||
ok = super().set_radio_params(freq_hz, bw_hz, sf, cr)
|
||||
if ok:
|
||||
apply_preamble(self._radio, preamble_for_sf(sf))
|
||||
return ok
|
||||
|
||||
def _save_prefs(self) -> None:
|
||||
try:
|
||||
_write_json(state / "prefs.json", prefs_to_json(self.prefs))
|
||||
except Exception as exc: # noqa: BLE001 - a failed save must not fail the setter
|
||||
log.warning("could not save prefs: %s", exc)
|
||||
|
||||
return PersistentCompanion
|
||||
|
||||
|
||||
async def _until_told_to_stop() -> None:
|
||||
"""Wait for stdin to close or a SIGTERM/SIGINT — the parent's two ways of saying stop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
stop = asyncio.Event()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
except (NotImplementedError, RuntimeError):
|
||||
pass
|
||||
|
||||
def watch_stdin() -> None:
|
||||
try:
|
||||
while sys.stdin.buffer.read(4096):
|
||||
pass
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
loop.call_soon_threadsafe(stop.set)
|
||||
|
||||
threading.Thread(target=watch_stdin, name="parent-watch", daemon=True).start()
|
||||
await stop.wait()
|
||||
|
||||
|
||||
def _die_with_parent() -> None:
|
||||
"""On Linux, have the kernel send SIGTERM when the parent exits (best-effort)."""
|
||||
if not sys.platform.startswith("linux"):
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
PR_SET_PDEATHSIG = 1
|
||||
ctypes.CDLL("libc.so.6", use_errno=True).prctl(PR_SET_PDEATHSIG, signal.SIGTERM)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Entry point: ``python radionode.py --config '<json>'``."""
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--config", required=True, help="the node's configuration, as JSON")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# One line on the real stdout is the parent's; everything else goes to stderr.
|
||||
status = os.fdopen(os.dup(1), "w", encoding="utf-8")
|
||||
os.dup2(2, 1)
|
||||
sys.stdout = sys.stderr
|
||||
|
||||
def report(message: dict) -> None:
|
||||
status.write(json.dumps(message) + "\n")
|
||||
status.flush()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
stream=sys.stderr,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
_die_with_parent()
|
||||
try:
|
||||
return asyncio.run(run_node(json.loads(args.config), report))
|
||||
except NodeError as exc:
|
||||
log.error("%s: %s", exc.kind, exc)
|
||||
report({"event": "error", "kind": exc.kind, "message": str(exc)})
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001 - the parent gets a sentence, the log the trace
|
||||
log.exception("node failed")
|
||||
report({"event": "error", "kind": "failed", "message": str(exc)})
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,520 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""A repeater's region table as its CLI shows it: the parser, the commands, the edits.
|
||||
|
||||
A MeshCore repeater keeps a small table of regions (at most 32) in a tree under the
|
||||
wildcard ``*``, and relays a *scoped* flood only when the flood's region is one it lists as
|
||||
flood-allowed (see :mod:`~meshterm.core.regions` for the maths that ties a flood to its
|
||||
region). The tree is **organisational only**: a repeater relays exactly the regions it
|
||||
lists as allowed, so allowing ``lakeside`` says nothing about ``lakeside-north`` beneath it.
|
||||
The wildcard's own flag is the unscoped case — whether it relays plain floods at all.
|
||||
|
||||
The admin reads and edits that table over the remote CLI (firmware ``CommonCLI::
|
||||
handleRegionCmd``, ``RegionMap``), and this module is everything about it that is not a
|
||||
screen:
|
||||
|
||||
* **The dump** (:func:`parse_region_dump`). ``region`` answers with the tree, one region per
|
||||
line, each line indented one space per level below the wildcard, the name followed by
|
||||
``^`` on the home region and `` F`` where floods are allowed::
|
||||
|
||||
*^ F
|
||||
lakeside F
|
||||
lakeside-north F
|
||||
lakeside-south
|
||||
harbour F
|
||||
|
||||
* **The cut.** Every CLI reply is written into a 160-byte buffer (``exportTo(reply, 160)``),
|
||||
and the dump simply stops where the buffer ends — mid-line, mid-name, with nothing to say
|
||||
it did. A table of 32 regions with real names does not fit, so a dump near the cap is read
|
||||
as *possibly cut* (:data:`REPLY_CAP_BYTES`, :func:`parse_region_dump`), its unfinished last
|
||||
line is dropped rather than shown as a region with half a name, and the reader is told.
|
||||
The flat lists (``region list allowed`` / ``region list denied``) are how the rest is
|
||||
recovered (:func:`parse_name_list`, :func:`RegionTable.with_lists`) — they carry no parent,
|
||||
so those regions are shown *beyond the cut*, placed nowhere. The lists have a cap of their
|
||||
own and skip a name that would not fit rather than cutting it, so a list close enough to
|
||||
the cap to have skipped one is flagged too (:func:`list_may_be_incomplete`). Nothing here
|
||||
ever claims to hold the whole table when it cannot know.
|
||||
|
||||
* **The commands** (:func:`allow_command` and siblings). Every edit is one remote command,
|
||||
and each is spelled here exactly once. ``allowf``/``denyf``/``home`` match a name by
|
||||
*prefix* on the firmware, preferring an exact match — MeshTerm only ever sends names the
|
||||
repeater itself listed, so the exact match always wins.
|
||||
|
||||
* **The edits** (:meth:`RegionTable.after`). What a command the repeater accepted does to the
|
||||
table, so the editor redraws from the reply rather than spending another round trip on a
|
||||
re-read. ``region put`` of a name that already exists *moves* it on the firmware, which is
|
||||
why the editor refuses a name it can see is taken.
|
||||
|
||||
* **Persistence.** Every edit lives in the repeater's RAM until ``region save``; a reboot
|
||||
undoes whatever was not saved. The one exception is ``region default`` (firmware 1.15+),
|
||||
which saves the whole table itself — its reply (``default scope is now …``) is how an edit
|
||||
says it took the unsaved ones with it (:func:`saves_table`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from .regions import MAX_NAME_BYTES, WILDCARD, RegionNameError, validate
|
||||
|
||||
#: The firmware's CLI reply buffer, in bytes. ``BufStream`` stops one short of it (the NUL),
|
||||
#: so a dump that ran out of room arrives 159 bytes long.
|
||||
REPLY_CAP_BYTES = 160
|
||||
|
||||
#: How close to the cap a dump must come to be read as possibly cut. A cut can land inside
|
||||
#: a multi-byte UTF-8 name, which the decode on the way here may shorten by a few bytes, and
|
||||
#: the transport may strip a trailing newline — so the test leaves room for both rather
|
||||
#: than asking for exactly 159.
|
||||
_CUT_SLACK = 4
|
||||
|
||||
#: The command that dumps the tree.
|
||||
DUMP_COMMAND = "region"
|
||||
#: The command that asks which region is the default scope (firmware 1.15+).
|
||||
DEFAULT_QUERY = "region default"
|
||||
#: The two flat lists — every flood-allowed and every flood-denied name, ``*`` included.
|
||||
LIST_ALLOWED = "region list allowed"
|
||||
LIST_DENIED = "region list denied"
|
||||
#: The command that writes the table to flash. Nothing else survives a reboot.
|
||||
SAVE_COMMAND = "region save"
|
||||
|
||||
#: The token ``region default`` takes to clear the default scope.
|
||||
NULL_DEFAULT = "<null>"
|
||||
|
||||
#: What an empty flat list reads as on the repeater.
|
||||
_EMPTY_LIST = "-none-"
|
||||
|
||||
|
||||
# -- the table -----------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegionRow:
|
||||
"""One region in a repeater's table.
|
||||
|
||||
Attributes:
|
||||
name: The bare region name (``*`` for the wildcard).
|
||||
parent: The parent's name — ``*`` for a top-level region, ``None`` for the wildcard
|
||||
itself and for a region recovered from a flat list, whose place is unknown.
|
||||
depth: Levels below the wildcard (0 for the wildcard, 1 for a top-level region).
|
||||
flood: Whether the repeater relays floods scoped to it (``F``) — for the wildcard,
|
||||
whether it relays unscoped floods.
|
||||
home: Whether it is the repeater's home region (``^``).
|
||||
placed: ``False`` for a region known only from a flat list: it is in the table,
|
||||
but the dump was cut before it, so its parent and depth are not known.
|
||||
"""
|
||||
|
||||
name: str
|
||||
parent: str | None
|
||||
depth: int
|
||||
flood: bool
|
||||
home: bool = False
|
||||
placed: bool = True
|
||||
|
||||
@property
|
||||
def wildcard(self) -> bool:
|
||||
"""Whether this row is the wildcard — the unscoped case, not a region."""
|
||||
return self.name == WILDCARD
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegionTable:
|
||||
"""What MeshTerm knows of one repeater's region table.
|
||||
|
||||
Attributes:
|
||||
rows: The wildcard first (when the dump reached it), then every region in dump order
|
||||
(depth-first, as the firmware prints the tree), then any recovered from the flat
|
||||
lists after a cut (``placed=False``).
|
||||
cut: Whether the dump may have been cut at the reply cap — rows after ``cut_after``
|
||||
may exist that the tree does not show.
|
||||
cut_after: The last region the dump showed whole (``None`` when it showed none).
|
||||
lists_read: Whether the flat lists were read to recover what the cut hid.
|
||||
lists_partial: Whether either flat list came close enough to its own cap that a
|
||||
name may have been skipped — the recovered set may still be short.
|
||||
default: The default-scope region, ``None`` where there is none, and absent
|
||||
knowledge is :attr:`default_known` ``False`` (older firmware has no such query).
|
||||
default_known: Whether ``default`` was actually read.
|
||||
"""
|
||||
|
||||
rows: tuple[RegionRow, ...] = ()
|
||||
cut: bool = False
|
||||
cut_after: str | None = None
|
||||
lists_read: bool = False
|
||||
lists_partial: bool = False
|
||||
default: str | None = None
|
||||
default_known: bool = False
|
||||
|
||||
# -- reading it ---------------------------------------------------------------
|
||||
|
||||
def get(self, name: str) -> RegionRow | None:
|
||||
"""The row for ``name``, or ``None`` when the table (as known) has no such region."""
|
||||
return next((row for row in self.rows if row.name == name), None)
|
||||
|
||||
@property
|
||||
def wildcard(self) -> RegionRow | None:
|
||||
"""The wildcard's row, or ``None`` when the dump never reached it."""
|
||||
return self.get(WILDCARD)
|
||||
|
||||
def regions(self) -> list[RegionRow]:
|
||||
"""Every region row, the wildcard left out."""
|
||||
return [row for row in self.rows if not row.wildcard]
|
||||
|
||||
def children(self, name: str) -> list[RegionRow]:
|
||||
"""The regions directly under ``name`` (placed rows only — nothing else has a parent)."""
|
||||
return [row for row in self.rows if row.parent == name and not row.wildcard]
|
||||
|
||||
@property
|
||||
def home(self) -> str | None:
|
||||
"""The home region's name (``*`` when none was set), or ``None`` when not seen."""
|
||||
return next((row.name for row in self.rows if row.home), None)
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
"""Whether every region the repeater holds is (to the best of our reading) here.
|
||||
|
||||
A clean dump is complete. A cut one is complete again once both flat lists were read
|
||||
and neither came near its own cap — the tree is still partly unplaced, but no name
|
||||
is missing.
|
||||
"""
|
||||
return not self.cut or (self.lists_read and not self.lists_partial)
|
||||
|
||||
def carried(self) -> list[str]:
|
||||
"""What the repeater relays floods for, as the regions request would list it.
|
||||
|
||||
``*`` first when it relays unscoped floods, then every flood-allowed region — the
|
||||
exact shape :meth:`~meshterm.core.region_store.RegionStore.learn_carried` takes.
|
||||
"""
|
||||
wild = self.wildcard
|
||||
head = [WILDCARD] if wild is not None and wild.flood else []
|
||||
return head + [row.name for row in self.regions() if row.flood]
|
||||
|
||||
# -- editing it ---------------------------------------------------------------
|
||||
|
||||
def after(self, command: str, reply: str) -> RegionTable:
|
||||
"""The table as it stands once the repeater accepted ``command``.
|
||||
|
||||
Only the verbs this module spells are understood; anything else (or a refused
|
||||
reply — see :func:`region_refused`) returns the table unchanged, so a caller may
|
||||
fold every accepted reply through here without first asking what it was.
|
||||
|
||||
Args:
|
||||
command: The command sent, as built by this module.
|
||||
reply: The repeater's reply to it.
|
||||
|
||||
Returns:
|
||||
The updated table (a new object; tables are immutable).
|
||||
"""
|
||||
if region_refused(reply):
|
||||
return self
|
||||
parts = command.split()
|
||||
if len(parts) < 2 or parts[0] != "region":
|
||||
return self
|
||||
verb, args = parts[1], parts[2:]
|
||||
if verb in ("allowf", "denyf") and args:
|
||||
return self._with(args[0], flood=verb == "allowf")
|
||||
if verb == "home" and args:
|
||||
if self.get(args[0]) is None:
|
||||
return self
|
||||
rows = tuple(replace(row, home=row.name == args[0]) for row in self.rows)
|
||||
return replace(self, rows=rows)
|
||||
if verb == "default" and args:
|
||||
if args[0] == NULL_DEFAULT:
|
||||
return replace(self, default=None, default_known=True)
|
||||
# The firmware forces flood on for the default region, and creates it at the top
|
||||
# level if it did not exist.
|
||||
table = self if self.get(args[0]) else self._put(args[0], WILDCARD, flood=True)
|
||||
return replace(table._with(args[0], flood=True), default=args[0], default_known=True)
|
||||
if verb == "put" and args:
|
||||
parent = args[1] if len(args) > 1 else WILDCARD
|
||||
# 1.15+ answers "OK - (flood allowed)"; older firmware created it denied.
|
||||
return self._put(args[0], parent, flood="flood allowed" in reply.lower())
|
||||
if verb == "remove" and args:
|
||||
if self.children(args[0]):
|
||||
return self # the firmware refuses this; a reply claiming otherwise is noise
|
||||
rows = tuple(row for row in self.rows if row.name != args[0])
|
||||
default = None if self.default == args[0] else self.default
|
||||
return replace(self, rows=rows, default=default)
|
||||
return self
|
||||
|
||||
def _with(self, name: str, **changes: object) -> RegionTable:
|
||||
"""The table with one row's fields changed (unchanged when the row is not here)."""
|
||||
rows = tuple(replace(row, **changes) if row.name == name else row for row in self.rows)
|
||||
return replace(self, rows=rows)
|
||||
|
||||
def _put(self, name: str, parent: str, *, flood: bool) -> RegionTable:
|
||||
"""The table with a new region placed as the last child of ``parent``.
|
||||
|
||||
Dump order is depth-first, so the new row goes after the parent's last descendant —
|
||||
exactly where the next ``region`` dump would print it.
|
||||
"""
|
||||
if self.get(name) is not None:
|
||||
return self
|
||||
above = self.get(parent)
|
||||
depth = (above.depth + 1) if above is not None and above.placed else 1
|
||||
row = RegionRow(name=name, parent=parent, depth=depth, flood=flood)
|
||||
rows = list(self.rows)
|
||||
at = len(rows)
|
||||
if above is not None and above.placed:
|
||||
at = rows.index(above) + 1
|
||||
while at < len(rows) and rows[at].placed and rows[at].depth > above.depth:
|
||||
at += 1
|
||||
else:
|
||||
# An unplaced parent has no subtree to append to: keep placed rows together and
|
||||
# put the new one ahead of the unplaced tail.
|
||||
at = next((i for i, r in enumerate(rows) if not r.placed), len(rows))
|
||||
rows.insert(at, row)
|
||||
return replace(self, rows=tuple(rows))
|
||||
|
||||
def with_lists(self, allowed: str | None, denied: str | None) -> RegionTable:
|
||||
"""Fold the two flat lists into a cut table, recovering the regions the cut hid.
|
||||
|
||||
A name in a list that the tree did not show joins it unplaced, flooded as its list
|
||||
says. Names the tree already has keep their tree row — the tree has the parent the
|
||||
list lacks — and the wildcard's flag is taken from the lists when the tree never
|
||||
reached it (it always does, being the first line, unless the reply was empty).
|
||||
|
||||
Args:
|
||||
allowed: The reply to :data:`LIST_ALLOWED` (``None`` when it never came).
|
||||
denied: The reply to :data:`LIST_DENIED` (``None`` when it never came).
|
||||
|
||||
Returns:
|
||||
The table with the recovered rows appended; ``lists_read`` is set only when both
|
||||
lists answered, and ``lists_partial`` when either may have skipped a name.
|
||||
"""
|
||||
rows = list(self.rows)
|
||||
partial = False
|
||||
for reply, flood in ((allowed, True), (denied, False)):
|
||||
if reply is None or region_refused(reply):
|
||||
continue
|
||||
partial = partial or list_may_be_incomplete(reply)
|
||||
for name in parse_name_list(reply):
|
||||
if any(row.name == name for row in rows):
|
||||
continue
|
||||
if name == WILDCARD:
|
||||
rows.insert(0, RegionRow(name=WILDCARD, parent=None, depth=0, flood=flood))
|
||||
else:
|
||||
rows.append(
|
||||
RegionRow(name=name, parent=None, depth=1, flood=flood, placed=False)
|
||||
)
|
||||
both = (
|
||||
allowed is not None
|
||||
and denied is not None
|
||||
and not region_refused(allowed)
|
||||
and not region_refused(denied)
|
||||
)
|
||||
return replace(self, rows=tuple(rows), lists_read=both, lists_partial=partial)
|
||||
|
||||
def with_default(self, reply: str | None) -> RegionTable:
|
||||
"""The table with the default scope read from a :data:`DEFAULT_QUERY` reply.
|
||||
|
||||
Unchanged (``default_known`` left ``False``) when there was no reply or the firmware
|
||||
does not know the query — pre-1.15 answers it with its catch-all ``Err - ??``.
|
||||
"""
|
||||
parsed = parse_default_reply(reply)
|
||||
if parsed is None:
|
||||
return self
|
||||
return replace(self, default=parsed or None, default_known=True)
|
||||
|
||||
|
||||
# -- parsing ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def dump_may_be_cut(text: str) -> bool:
|
||||
"""Whether a reply came close enough to the 160-byte cap to have been cut short."""
|
||||
return len(text.encode("utf-8")) >= REPLY_CAP_BYTES - 1 - _CUT_SLACK
|
||||
|
||||
|
||||
def parse_region_dump(text: str | None) -> RegionTable:
|
||||
"""Parse a ``region`` dump into a table, dropping a line the reply cap cut in half.
|
||||
|
||||
Each non-blank line is one region: its leading spaces are its depth, then the name, an
|
||||
optional ``^`` (home), and an optional ``F`` (flood allowed) after a space. A line's
|
||||
parent is the nearest line above it one level up; depth 0 is the wildcard. A line that
|
||||
does not fit the grammar, or whose indentation skips a level, is not guessed at — it
|
||||
ends the tree there and the table is marked cut, since a dump that stops making sense
|
||||
has stopped being the dump.
|
||||
|
||||
Args:
|
||||
text: The reply text (``None`` or empty reads as an empty, uncut table).
|
||||
|
||||
Returns:
|
||||
The table. When the reply came within reach of the cap its last line is dropped
|
||||
unless the reply ended on a newline (so the line is known whole), ``cut`` is set,
|
||||
and ``cut_after`` names the last region shown.
|
||||
"""
|
||||
if not text:
|
||||
return RegionTable()
|
||||
raw = text.replace("\r", "").replace("\x00", "")
|
||||
cut = dump_may_be_cut(raw)
|
||||
lines = raw.split("\n")
|
||||
if cut and not raw.endswith("\n") and lines:
|
||||
lines = lines[:-1] # the line the buffer ran out inside — a name may be half a name
|
||||
rows: list[RegionRow] = []
|
||||
stack: list[str] = [] # the open ancestor at each depth
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
continue
|
||||
depth = len(line) - len(line.lstrip(" "))
|
||||
row = _parse_line(line.strip(), depth, stack)
|
||||
if row is None:
|
||||
cut = True
|
||||
break
|
||||
del stack[depth:]
|
||||
stack.append(row.name)
|
||||
rows.append(row)
|
||||
cut_after = next((row.name for row in reversed(rows)), None) if cut else None
|
||||
return RegionTable(rows=tuple(rows), cut=cut, cut_after=cut_after)
|
||||
|
||||
|
||||
def _parse_line(body: str, depth: int, stack: list[str]) -> RegionRow | None:
|
||||
"""One dump line, or ``None`` when it breaks the grammar (see :func:`parse_region_dump`)."""
|
||||
tokens = body.split()
|
||||
if not tokens or len(tokens) > 2 or (len(tokens) == 2 and tokens[1] != "F"):
|
||||
return None
|
||||
name = tokens[0]
|
||||
home = name.endswith("^")
|
||||
name = name.removesuffix("^")
|
||||
if not name or depth > len(stack):
|
||||
return None
|
||||
if depth == 0 and name != WILDCARD:
|
||||
return None
|
||||
if depth > 0 and name == WILDCARD:
|
||||
return None
|
||||
parent = stack[depth - 1] if depth > 0 else None
|
||||
return RegionRow(name=name, parent=parent, depth=depth, flood=len(tokens) == 2, home=home)
|
||||
|
||||
|
||||
def parse_name_list(text: str | None) -> list[str]:
|
||||
"""The names in a ``region list allowed|denied`` reply (``-none-`` reads as empty)."""
|
||||
if not text or text.strip() == _EMPTY_LIST:
|
||||
return []
|
||||
names: list[str] = []
|
||||
for part in text.replace("\x00", "").split(","):
|
||||
name = part.strip().removeprefix("#")
|
||||
if name and name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def list_may_be_incomplete(text: str) -> bool:
|
||||
"""Whether a flat list came near enough its cap that a name may have been skipped.
|
||||
|
||||
The firmware appends a name only while ``length + len(name) + 2 < 160`` and skips one
|
||||
that would not fit — then carries on with the next, so a later short name can still get
|
||||
in. A skip therefore needs the list to have reached at least ``158 - len(name)`` bytes
|
||||
for some name of at most :data:`~meshterm.core.regions.MAX_NAME_BYTES`, and a list
|
||||
shorter than that skipped nothing.
|
||||
"""
|
||||
return len(text.encode("utf-8")) >= REPLY_CAP_BYTES - 2 - MAX_NAME_BYTES
|
||||
|
||||
|
||||
def parse_default_reply(text: str | None) -> str | None:
|
||||
"""The default scope named by a ``region default`` reply.
|
||||
|
||||
Returns:
|
||||
The region name, ``""`` for none (``<null>``), or ``None`` when the reply is
|
||||
missing, refused, or not a default-scope answer at all.
|
||||
"""
|
||||
if not text or region_refused(text):
|
||||
return None
|
||||
marker = "default scope is"
|
||||
lowered = text.lower()
|
||||
if marker not in lowered:
|
||||
return None
|
||||
rest = text[lowered.index(marker) + len(marker) :].strip()
|
||||
rest = rest.removeprefix("now").strip()
|
||||
name = rest.split()[0] if rest.split() else ""
|
||||
return "" if name in ("", NULL_DEFAULT) else name.removeprefix("#")
|
||||
|
||||
|
||||
def region_refused(reply: str | None) -> bool:
|
||||
"""Whether a reply to a region command is the repeater refusing it.
|
||||
|
||||
The region verbs answer ``Err - …`` (``unknown region``, ``not empty``, ``unable to
|
||||
put``, ``save failed``, and ``??`` for a verb the firmware lacks), which the settings
|
||||
catalog's own error test does not recognise. Older firmware with no ``region`` command
|
||||
at all answers its generic unknown-command text.
|
||||
"""
|
||||
if reply is None:
|
||||
return True
|
||||
lowered = reply.strip().lower()
|
||||
return lowered.startswith(("err", "??", "unknown command", "error"))
|
||||
|
||||
|
||||
def saves_table(command: str, reply: str | None) -> bool:
|
||||
"""Whether an accepted command wrote the whole table to flash on its own.
|
||||
|
||||
``region save`` does, by definition; ``region default`` does too on firmware 1.15+,
|
||||
which is the firmware that answers it with ``default scope is now …``.
|
||||
"""
|
||||
if reply is None or region_refused(reply):
|
||||
return False
|
||||
if command == SAVE_COMMAND:
|
||||
return True
|
||||
return command.startswith("region default ") and "default scope is now" in reply.lower()
|
||||
|
||||
|
||||
# -- the commands ------------------------------------------------------------------
|
||||
|
||||
|
||||
def allow_command(name: str) -> str:
|
||||
"""Relay floods scoped to ``name`` (``*``: relay unscoped floods)."""
|
||||
return f"region allowf {name}"
|
||||
|
||||
|
||||
def deny_command(name: str) -> str:
|
||||
"""Stop relaying floods scoped to ``name`` (``*``: stop relaying unscoped floods)."""
|
||||
return f"region denyf {name}"
|
||||
|
||||
|
||||
def home_command(name: str) -> str:
|
||||
"""Make ``name`` the repeater's home region (``*`` clears it back to none)."""
|
||||
return f"region home {name}"
|
||||
|
||||
|
||||
def default_command(name: str | None) -> str:
|
||||
"""Make ``name`` the default scope for the repeater's own floods, or clear it (``None``)."""
|
||||
return f"region default {name or NULL_DEFAULT}"
|
||||
|
||||
|
||||
def put_command(name: str, parent: str = WILDCARD) -> str:
|
||||
"""Add ``name`` under ``parent`` (top level for ``*``). New regions are flood-allowed."""
|
||||
return f"region put {name}" if parent == WILDCARD else f"region put {name} {parent}"
|
||||
|
||||
|
||||
def remove_command(name: str) -> str:
|
||||
"""Remove ``name`` — refused by the firmware while it still has sub-regions."""
|
||||
return f"region remove {name}"
|
||||
|
||||
|
||||
def _is_name_char(ch: str) -> bool:
|
||||
"""Whether the firmware's ``RegionMap::is_name_char`` accepts ``ch`` in a region name."""
|
||||
return ch in "-$#" or ch.isdigit() or ord(ch) >= ord("A")
|
||||
|
||||
|
||||
def validate_new_name(name: str, taken: Iterable[str] = ()) -> str:
|
||||
"""Check a name for ``region put``, the firmware's own rules and one of MeshTerm's.
|
||||
|
||||
On top of :func:`~meshterm.core.regions.validate` (length, no wildcard, no private
|
||||
``$`` names), the firmware refuses a name with any punctuation but ``-`` — its
|
||||
``is_name_char`` — and ``region put`` of a name that exists *moves* that region rather
|
||||
than adding one, so a name already in the table is refused here instead.
|
||||
|
||||
Args:
|
||||
name: The name as typed.
|
||||
taken: Names already in the table.
|
||||
|
||||
Returns:
|
||||
The bare, valid name.
|
||||
|
||||
Raises:
|
||||
RegionNameError: With the reason, in words a reader can act on.
|
||||
"""
|
||||
bare = validate(name)
|
||||
bad = sorted({ch for ch in bare if not _is_name_char(ch)})
|
||||
if bad:
|
||||
raise RegionNameError(
|
||||
f"a repeater refuses {' '.join(bad)} in a region name — letters, digits, and - only"
|
||||
)
|
||||
if bare in set(taken):
|
||||
raise RegionNameError(f"{bare} is already a region here")
|
||||
return bare
|
||||
@@ -0,0 +1,244 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""A repeater's region table and region CLI, as the simulator speaks them.
|
||||
|
||||
The :class:`~meshterm.core.connection.MockDevice` answers ``region …`` commands and the
|
||||
anonymous regions request from one of these per simulated repeater, so the node page, the
|
||||
region editor and the ``regions`` command are all drivable under ``--mock``.
|
||||
|
||||
It is a transcription of the firmware rather than an idea of it — ``RegionMap`` and
|
||||
``CommonCLI::handleRegionCmd`` as of MeshCore 1.15/1.16 — because the parser it feeds
|
||||
(:mod:`~meshterm.core.region_admin`) is meant for the real thing, and a simulator that
|
||||
answered more kindly than a repeater would let a parser bug through. So it keeps the
|
||||
firmware's awkward edges on purpose: the 160-byte reply buffer that cuts a long dump
|
||||
mid-name, the flat lists that *skip* a name that would not fit, prefix matching on
|
||||
``allowf``/``denyf``/``home``/``get``, ``put`` of an existing name moving it, ``remove``
|
||||
refusing a region with children, new regions flood-allowed, ``default`` creating a missing
|
||||
region and saving the table, and every other edit living in RAM until ``save``
|
||||
(:meth:`SimulatedRegionMap.reboot` puts the saved table back).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .region_admin import REPLY_CAP_BYTES
|
||||
from .regions import WILDCARD
|
||||
|
||||
#: The firmware's table size (``MAX_REGION_ENTRIES``).
|
||||
MAX_REGIONS = 32
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Entry:
|
||||
"""One ``RegionEntry``: an id, its parent's id, the deny-flood flag, and the name."""
|
||||
|
||||
id: int
|
||||
parent: int
|
||||
deny_flood: bool
|
||||
name: str
|
||||
|
||||
|
||||
class SimulatedRegionMap:
|
||||
"""One simulated repeater's region table and the CLI verbs that edit it."""
|
||||
|
||||
def __init__(self, tree: list[tuple[str, str, bool]] | None = None) -> None:
|
||||
"""Build a table, already saved, from ``(name, parent, flood)`` triples in order.
|
||||
|
||||
Args:
|
||||
tree: The regions, each after its parent (``*`` for top level). ``None`` is an
|
||||
empty table: a stock repeater relaying unscoped floods and nothing else.
|
||||
"""
|
||||
self._wild_deny = False
|
||||
self._entries: list[_Entry] = []
|
||||
self._next_id = 1
|
||||
self._home = 0
|
||||
self._default = 0
|
||||
for name, parent, flood in tree or ():
|
||||
parent_id = 0 if parent == WILDCARD else self._by_name(parent).id # type: ignore[union-attr]
|
||||
entry = self._put(name, parent_id)
|
||||
assert entry is not None
|
||||
entry.deny_flood = not flood
|
||||
self._saved = self._snapshot()
|
||||
|
||||
# -- the RegionMap ------------------------------------------------------------
|
||||
|
||||
def _snapshot(self) -> tuple:
|
||||
return copy.deepcopy(
|
||||
(self._wild_deny, self._entries, self._next_id, self._home, self._default)
|
||||
)
|
||||
|
||||
def reboot(self) -> None:
|
||||
"""Drop every unsaved edit, as a power cycle does."""
|
||||
(self._wild_deny, self._entries, self._next_id, self._home, self._default) = copy.deepcopy(
|
||||
self._saved
|
||||
)
|
||||
|
||||
def _by_name(self, name: str) -> _Entry | None:
|
||||
"""``findByName``: an exact match (``*`` is the wildcard, reported as ``None`` here)."""
|
||||
name = name.removeprefix("#")
|
||||
return next((e for e in self._entries if e.name.removeprefix("#") == name), None)
|
||||
|
||||
def _by_prefix(self, prefix: str) -> _Entry | str | None:
|
||||
"""``findByNamePrefix``: exact wins, else the *last* prefix match; ``*`` → ``"*"``."""
|
||||
if prefix == WILDCARD:
|
||||
return WILDCARD
|
||||
prefix = prefix.removeprefix("#")
|
||||
partial = None
|
||||
for entry in self._entries:
|
||||
bare = entry.name.removeprefix("#")
|
||||
if bare == prefix:
|
||||
return entry
|
||||
if bare.startswith(prefix):
|
||||
partial = entry
|
||||
return partial
|
||||
|
||||
def _put(self, name: str, parent_id: int) -> _Entry | None:
|
||||
"""``putRegion``: refuse bad characters, move an existing name, else append."""
|
||||
if not all(ch in "-$#" or ch.isdigit() or ord(ch) >= ord("A") for ch in name):
|
||||
return None
|
||||
entry = self._by_name(name)
|
||||
if entry is not None:
|
||||
if entry.id == parent_id:
|
||||
return None
|
||||
entry.parent = parent_id
|
||||
return entry
|
||||
if len(self._entries) >= MAX_REGIONS:
|
||||
return None
|
||||
entry = _Entry(id=self._next_id, parent=parent_id, deny_flood=True, name=name)
|
||||
self._next_id += 1
|
||||
self._entries.append(entry)
|
||||
return entry
|
||||
|
||||
def _dump(self) -> str:
|
||||
"""``exportTo(reply, 160)``: the tree, cut where the buffer runs out."""
|
||||
out: list[str] = []
|
||||
|
||||
def visit(indent: int, entry_id: int, name: str, deny: bool) -> None:
|
||||
home = "^" if entry_id == self._home else ""
|
||||
out.append(
|
||||
" " * indent + f"{name.removeprefix('#')}{home}" + ("" if deny else " F") + "\n"
|
||||
)
|
||||
for child in self._entries:
|
||||
if child.parent == entry_id:
|
||||
visit(indent + 1, child.id, child.name, child.deny_flood)
|
||||
|
||||
visit(0, 0, WILDCARD, self._wild_deny)
|
||||
return _capped("".join(out))
|
||||
|
||||
def _names(self, *, allowed: bool) -> str:
|
||||
"""``exportNamesTo(reply, 160, …)``: comma-joined, skipping what will not fit."""
|
||||
parts: list[str] = []
|
||||
length = 0
|
||||
if self._wild_deny != allowed:
|
||||
parts.append(WILDCARD)
|
||||
length = 2
|
||||
for entry in self._entries:
|
||||
if entry.deny_flood == allowed:
|
||||
continue
|
||||
bare = entry.name.removeprefix("#")
|
||||
size = len(bare.encode("utf-8"))
|
||||
if length + size + 2 < REPLY_CAP_BYTES:
|
||||
parts.append(bare)
|
||||
length += size + 1
|
||||
return ",".join(parts)
|
||||
|
||||
def allowed_names(self) -> str:
|
||||
"""What the anonymous regions request answers with (``*`` first when unscoped)."""
|
||||
return self._names(allowed=True)
|
||||
|
||||
# -- the CLI ------------------------------------------------------------------
|
||||
|
||||
def command(self, text: str) -> str:
|
||||
"""Answer one ``region …`` command as ``handleRegionCmd`` does."""
|
||||
parts = text.split()
|
||||
n = len(parts)
|
||||
if n == 1:
|
||||
return self._dump()
|
||||
verb = parts[1]
|
||||
if verb == "save":
|
||||
self._saved = self._snapshot()
|
||||
return "OK"
|
||||
if verb in ("allowf", "denyf") and n >= 3:
|
||||
found = self._by_prefix(parts[2])
|
||||
if found is None:
|
||||
return "Err - unknown region"
|
||||
if found == WILDCARD:
|
||||
self._wild_deny = verb == "denyf"
|
||||
else:
|
||||
found.deny_flood = verb == "denyf" # type: ignore[union-attr]
|
||||
return "OK"
|
||||
if verb == "get" and n >= 3:
|
||||
found = self._by_prefix(parts[2])
|
||||
if found is None:
|
||||
return "Err - unknown region"
|
||||
if found == WILDCARD:
|
||||
return f" * {'' if self._wild_deny else 'F'}"
|
||||
parent = next((e for e in self._entries if e.id == found.parent), None) # type: ignore[union-attr]
|
||||
flag = "" if found.deny_flood else "F" # type: ignore[union-attr]
|
||||
if parent is not None:
|
||||
return f" {found.name} ({parent.name}) {flag}" # type: ignore[union-attr]
|
||||
return f" {found.name} {flag}" # type: ignore[union-attr]
|
||||
if verb == "home":
|
||||
if n == 2:
|
||||
home = next((e.name for e in self._entries if e.id == self._home), WILDCARD)
|
||||
return f" home is {home}"
|
||||
found = self._by_prefix(parts[2])
|
||||
if found is None:
|
||||
return "Err - unknown region"
|
||||
self._home = 0 if found == WILDCARD else found.id # type: ignore[union-attr]
|
||||
return f" home is now {WILDCARD if found == WILDCARD else found.name}" # type: ignore[union-attr]
|
||||
if verb == "default":
|
||||
if n == 2:
|
||||
current = next((e.name for e in self._entries if e.id == self._default), None)
|
||||
return f" default scope is {current or '<null>'}"
|
||||
if parts[2] == "<null>":
|
||||
self._default = 0
|
||||
self._saved = self._snapshot()
|
||||
return " default scope is now <null>"
|
||||
found = self._by_prefix(parts[2])
|
||||
if found == WILDCARD:
|
||||
self._wild_deny = False
|
||||
self._default = 0
|
||||
self._saved = self._snapshot()
|
||||
return " default scope is now *"
|
||||
if found is None:
|
||||
found = self._put(parts[2], 0)
|
||||
if found is None:
|
||||
return "Err - region table full"
|
||||
found.deny_flood = False # type: ignore[union-attr]
|
||||
self._default = found.id # type: ignore[union-attr]
|
||||
self._saved = self._snapshot()
|
||||
return f" default scope is now {found.name}" # type: ignore[union-attr]
|
||||
if verb == "put" and n >= 3:
|
||||
if n >= 4:
|
||||
parent = self._by_prefix(parts[3])
|
||||
if parent is None:
|
||||
return "Err - unknown parent"
|
||||
parent_id = 0 if parent == WILDCARD else parent.id # type: ignore[union-attr]
|
||||
else:
|
||||
parent_id = 0
|
||||
entry = self._put(parts[2], parent_id)
|
||||
if entry is None:
|
||||
return "Err - unable to put"
|
||||
entry.deny_flood = False
|
||||
return "OK - (flood allowed)"
|
||||
if verb == "remove" and n >= 3:
|
||||
entry = self._by_name(parts[2])
|
||||
if entry is None:
|
||||
return "Err - not found"
|
||||
if any(e.parent == entry.id for e in self._entries):
|
||||
return "Err - not empty"
|
||||
self._entries.remove(entry)
|
||||
return "OK"
|
||||
if verb == "list" and n >= 3:
|
||||
if parts[2] not in ("allowed", "denied"):
|
||||
return "Err - use 'allowed' or 'denied'"
|
||||
return self._names(allowed=parts[2] == "allowed") or "-none-"
|
||||
return "Err - ??"
|
||||
|
||||
|
||||
def _capped(text: str) -> str:
|
||||
"""``BufStream``: bytes past the 159th are dropped, whatever they were in the middle of."""
|
||||
raw = text.encode("utf-8")[: REPLY_CAP_BYTES - 1]
|
||||
return raw.decode("utf-8", errors="ignore")
|
||||
@@ -0,0 +1,417 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The regions MeshTerm knows by name, and the scope each channel is sent under.
|
||||
|
||||
A scoped flood carries no region name — only a transport code keyed on one (see
|
||||
:mod:`~meshterm.core.regions`) — so *which* region a packet was flooded into can only be
|
||||
answered by trying the names already known. This store is that list. A name arrives from
|
||||
wherever MeshTerm meets one:
|
||||
|
||||
* ``default`` — the companion's own default flood scope, read or set on Device config;
|
||||
* ``channel`` — a scope given to a channel, so its messages stay in its region;
|
||||
* ``repeater`` — a repeater's answer to the regions request, or its ``region`` listing,
|
||||
which also records *which* repeaters carry it (the node page lists them, and a scoped
|
||||
send that nothing relayed can say whether anything in earshot was ever heard to carry
|
||||
its region) — and, beside the regions, each repeater's last whole answer: whether it
|
||||
relays unscoped floods too, and when it said so (:class:`CarriedAnswer`);
|
||||
* ``typed`` — a name the reader entered themselves.
|
||||
|
||||
The store also holds **channel scopes**: the region a channel's messages are sent under.
|
||||
The firmware has no per-channel scope (``// TODO: have per-channel send_scope``), so a
|
||||
client that wants one sets the companion's session scope just before each channel send,
|
||||
which is what every official app does. The scope is keyed by the channel's intrinsic
|
||||
identity (:func:`~meshterm.core.channels.channel_identity`), exactly like a mute, so it
|
||||
follows the channel across slots and two devices sharing the channel share its scope.
|
||||
|
||||
Resolution is memoized here too (:meth:`RegionStore.scope_of`): a packet list repaints
|
||||
often and resolving a scoped frame costs an HMAC per known name, so an answer is kept per
|
||||
``(body, code)`` until the set of names changes.
|
||||
|
||||
Like the other operator state (mutes, watched nodes, admin passwords) this is global
|
||||
machine state in a small JSON file (``<config_dir>/regions.json``), read once and written
|
||||
atomically on each real change. It writes from its typed records, never from the document
|
||||
it read, so a field no record knows is gone at the next save.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .regions import (
|
||||
WILDCARD,
|
||||
RegionNameError,
|
||||
Scope,
|
||||
frame_scope,
|
||||
normalize,
|
||||
raw_scope_body,
|
||||
validate,
|
||||
)
|
||||
|
||||
#: Where a region name was learned — the closed set :attr:`KnownRegion.sources` draws from.
|
||||
SOURCES = ("default", "channel", "repeater", "typed")
|
||||
|
||||
#: Resolutions kept before the memo is cleared wholesale (a long capture meets many bodies;
|
||||
#: a repaint meets the same few hundred again and again).
|
||||
_MEMO_CAP = 4096
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnownRegion:
|
||||
"""One region MeshTerm knows by name.
|
||||
|
||||
Attributes:
|
||||
name: The bare region name (no ``#``).
|
||||
sources: Where it was learned, members of :data:`SOURCES`, in first-learned order.
|
||||
repeaters: The 12-hex node ids of repeaters that were heard to carry it.
|
||||
learned_at: When it was first learned (UTC).
|
||||
"""
|
||||
|
||||
name: str
|
||||
sources: tuple[str, ...] = ()
|
||||
repeaters: tuple[str, ...] = ()
|
||||
learned_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CarriedAnswer:
|
||||
"""The last whole answer one repeater gave about what it carries.
|
||||
|
||||
The regions themselves live on :class:`KnownRegion` (``repeaters``), where resolution
|
||||
wants them; this keeps what does not belong to any one region — whether the repeater
|
||||
also relays *unscoped* floods (the ``*`` a region list leads with, which names no
|
||||
region), and when it said so. A repeater with no record here was never asked, which is
|
||||
a different thing from a repeater that answered with nothing.
|
||||
|
||||
Attributes:
|
||||
node: The repeater's 12-hex node id.
|
||||
unscoped: Whether its answer included the wildcard.
|
||||
answered_at: When it answered (UTC).
|
||||
"""
|
||||
|
||||
node: str
|
||||
unscoped: bool
|
||||
answered_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning.
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
|
||||
class RegionStore:
|
||||
"""Reads and writes the known regions and the channel scopes, memory-first.
|
||||
|
||||
Interact through :meth:`names`/:meth:`regions` (what is known), :meth:`learn` and
|
||||
:meth:`forget` (changing it), :meth:`channel_scope`/:meth:`set_channel_scope` (a
|
||||
channel's send scope), :meth:`carriers` (which repeaters carry a region), and
|
||||
:meth:`scope_of` (what a received frame's scope is). :attr:`revision` bumps on every
|
||||
change, for a screen that memoizes on it.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
"""Open the store against a JSON file location.
|
||||
|
||||
Args:
|
||||
path: Path to the JSON state file (created lazily on the first change).
|
||||
"""
|
||||
self._path = path
|
||||
self._regions: dict[str, KnownRegion] | None = None
|
||||
self._channels: dict[str, str] = {}
|
||||
self._answers: dict[str, CarriedAnswer] = {}
|
||||
self._memo: dict[tuple[bytes, int], str | None] = {}
|
||||
self.revision = 0
|
||||
|
||||
# -- loading ---------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def _state(self) -> dict[str, KnownRegion]:
|
||||
"""The name -> record map, loaded from disk on first access."""
|
||||
if self._regions is None:
|
||||
self._regions, self._channels, self._answers = self._load()
|
||||
return self._regions
|
||||
|
||||
def _load(
|
||||
self,
|
||||
) -> tuple[dict[str, KnownRegion], dict[str, str], dict[str, CarriedAnswer]]:
|
||||
"""Parse the file, or start empty on a missing or corrupt one."""
|
||||
try:
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}, {}, {}
|
||||
if not isinstance(data, dict):
|
||||
return {}, {}, {}
|
||||
regions: dict[str, KnownRegion] = {}
|
||||
for entry in data.get("regions") or []:
|
||||
region = _region_from_json(entry)
|
||||
if region is not None:
|
||||
regions[region.name] = region
|
||||
channels: dict[str, str] = {}
|
||||
for identity, name in (data.get("channels") or {}).items():
|
||||
try:
|
||||
channels[str(identity)] = validate(str(name))
|
||||
except RegionNameError:
|
||||
continue
|
||||
answers: dict[str, CarriedAnswer] = {}
|
||||
for entry in data.get("answers") or []:
|
||||
answer = _answer_from_json(entry)
|
||||
if answer is not None:
|
||||
answers[answer.node] = answer
|
||||
return regions, channels, answers
|
||||
|
||||
# -- what is known ---------------------------------------------------------------
|
||||
|
||||
def regions(self) -> list[KnownRegion]:
|
||||
"""Every known region, in the order names are tried when resolving a frame.
|
||||
|
||||
Names the reader chose (a default scope, a channel scope, a typed name) lead, then
|
||||
names only a repeater has mentioned — the ones this station most likely sends
|
||||
under are the ones its own frames will carry. Ties keep the order learned.
|
||||
"""
|
||||
chosen = [r for r in self._state.values() if set(r.sources) - {"repeater"}]
|
||||
heard = [r for r in self._state.values() if not set(r.sources) - {"repeater"}]
|
||||
return chosen + heard
|
||||
|
||||
def names(self) -> list[str]:
|
||||
"""The known region names, in resolution order (see :meth:`regions`)."""
|
||||
return [r.name for r in self.regions()]
|
||||
|
||||
def get(self, name: str) -> KnownRegion | None:
|
||||
"""The record for one region name, or ``None`` if it is not known."""
|
||||
return self._state.get(normalize(name))
|
||||
|
||||
def carriers(self, name: str) -> tuple[str, ...]:
|
||||
"""The node ids of the repeaters heard to carry a region (empty when none)."""
|
||||
region = self.get(name)
|
||||
return region.repeaters if region else ()
|
||||
|
||||
def carried_by(self, node: str) -> list[str]:
|
||||
"""The regions a repeater was heard to carry, by its node id (12-hex prefix)."""
|
||||
node = node.lower()[:12]
|
||||
return [r.name for r in self.regions() if node in r.repeaters]
|
||||
|
||||
def answer_of(self, node: str) -> CarriedAnswer | None:
|
||||
"""The last whole answer a repeater gave (unscoped too, and when), or ``None``.
|
||||
|
||||
``None`` means it was never asked — or never answered — which the node page states
|
||||
as such, rather than reading an empty :meth:`carried_by` as "carries nothing".
|
||||
"""
|
||||
self._state # noqa: B018 - load on first access
|
||||
return self._answers.get(node.lower()[:12])
|
||||
|
||||
# -- changing it -----------------------------------------------------------------
|
||||
|
||||
def learn(self, name: str, source: str, *, repeater: str | None = None) -> str | None:
|
||||
"""Record a region name and where it came from; persist only a real change.
|
||||
|
||||
Args:
|
||||
name: The name, bare or ``#``-prefixed. The wildcard and names the firmware
|
||||
would refuse are ignored (a repeater listing ``*`` teaches no region).
|
||||
source: One of :data:`SOURCES`.
|
||||
repeater: For ``source="repeater"``, the node id of the repeater that carries it.
|
||||
|
||||
Returns:
|
||||
The bare name recorded, or ``None`` when nothing was learnable.
|
||||
"""
|
||||
if source not in SOURCES:
|
||||
raise ValueError(f"unknown region source {source!r}")
|
||||
try:
|
||||
bare = validate(name)
|
||||
except RegionNameError:
|
||||
return None
|
||||
current = self._state.get(bare) or KnownRegion(name=bare)
|
||||
sources = current.sources if source in current.sources else current.sources + (source,)
|
||||
repeaters = current.repeaters
|
||||
if repeater:
|
||||
node = repeater.lower()[:12]
|
||||
if node not in repeaters:
|
||||
repeaters = repeaters + (node,)
|
||||
updated = replace(current, sources=sources, repeaters=repeaters)
|
||||
if self._state.get(bare) != updated:
|
||||
self._state[bare] = updated
|
||||
self._changed()
|
||||
return bare
|
||||
|
||||
def learn_carried(self, repeater: str, names: Iterable[str]) -> list[str]:
|
||||
"""Record what one repeater says it carries, replacing what it said before.
|
||||
|
||||
A repeater's list is its whole answer, so a region it no longer names loses that
|
||||
repeater (and, if nothing else taught it, the region itself). Whether it listed the
|
||||
wildcard — relays unscoped floods — and when it answered are kept beside the
|
||||
regions (:meth:`answer_of`).
|
||||
|
||||
Args:
|
||||
repeater: The repeater's node id (12-hex prefix or full key).
|
||||
names: The regions it listed (the wildcard is recorded as the unscoped flag).
|
||||
|
||||
Returns:
|
||||
The bare region names learned, in the order given.
|
||||
"""
|
||||
node = repeater.lower()[:12]
|
||||
given = [normalize(x) for x in names]
|
||||
self._state # noqa: B018 - load on first access
|
||||
self._answers[node] = CarriedAnswer(node=node, unscoped=WILDCARD in given)
|
||||
self._changed()
|
||||
wanted = [n for n in given if n and n != WILDCARD]
|
||||
learned: list[str] = []
|
||||
for name in wanted:
|
||||
bare = self.learn(name, "repeater", repeater=node)
|
||||
if bare:
|
||||
learned.append(bare)
|
||||
for region in list(self._state.values()):
|
||||
if node in region.repeaters and region.name not in learned:
|
||||
repeaters = tuple(r for r in region.repeaters if r != node)
|
||||
sources = region.sources
|
||||
if not repeaters:
|
||||
sources = tuple(s for s in sources if s != "repeater")
|
||||
if not sources:
|
||||
del self._state[region.name]
|
||||
else:
|
||||
self._state[region.name] = replace(region, sources=sources, repeaters=repeaters)
|
||||
self._changed()
|
||||
return learned
|
||||
|
||||
def forget(self, name: str) -> None:
|
||||
"""Drop a region name entirely, and any channel scope that used it."""
|
||||
bare = normalize(name)
|
||||
if bare not in self._state and bare not in self._channels.values():
|
||||
return
|
||||
self._state.pop(bare, None)
|
||||
self._channels = {k: v for k, v in self._channels.items() if v != bare}
|
||||
self._changed()
|
||||
|
||||
# -- channel scopes ----------------------------------------------------------------
|
||||
|
||||
def channel_scope(self, channel_id: str | None) -> str | None:
|
||||
"""The region a channel's messages are sent under, or ``None`` for the default."""
|
||||
self._state # noqa: B018 - load on first access
|
||||
return self._channels.get(channel_id) if channel_id else None
|
||||
|
||||
def channel_scopes(self) -> dict[str, str]:
|
||||
"""Every channel scope, channel identity -> region (a copy)."""
|
||||
self._state # noqa: B018 - load on first access
|
||||
return dict(self._channels)
|
||||
|
||||
def set_channel_scope(self, channel_id: str, name: str | None) -> None:
|
||||
"""Give a channel a send scope, or clear it back to the device's default.
|
||||
|
||||
Args:
|
||||
channel_id: The channel's intrinsic identity.
|
||||
name: The region, or ``None``/empty to clear.
|
||||
|
||||
Raises:
|
||||
RegionNameError: If ``name`` is a name the firmware would refuse.
|
||||
"""
|
||||
self._state # noqa: B018 - load on first access
|
||||
if not name or not normalize(name):
|
||||
if self._channels.pop(channel_id, None) is not None:
|
||||
self._changed()
|
||||
return
|
||||
bare = validate(name)
|
||||
self.learn(bare, "channel")
|
||||
if self._channels.get(channel_id) != bare:
|
||||
self._channels[channel_id] = bare
|
||||
self._changed()
|
||||
|
||||
# -- resolution ------------------------------------------------------------------
|
||||
|
||||
def scope_of(self, raw: dict | None) -> Scope | None:
|
||||
"""The scope of a received frame against every known name (memoized).
|
||||
|
||||
Args:
|
||||
raw: The frame's raw payload (live, or restored from history).
|
||||
|
||||
Returns:
|
||||
As :func:`~meshterm.core.regions.frame_scope`: ``None`` where the frame has no
|
||||
scope to state (direct, or a route type never kept).
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
route = str(raw.get("route_typename") or "").upper()
|
||||
if route != "TC_FLOOD":
|
||||
return frame_scope(raw, ())
|
||||
body = raw_scope_body(raw)
|
||||
scope = frame_scope(raw, ()) # parses the code; resolution happens below
|
||||
if scope is None or scope.code is None or body is None:
|
||||
return scope
|
||||
memo_key = (body, int(scope.code, 16))
|
||||
if memo_key not in self._memo:
|
||||
if len(self._memo) >= _MEMO_CAP:
|
||||
self._memo.clear()
|
||||
self._memo[memo_key] = (frame_scope(raw, self.names()) or scope).region
|
||||
region = self._memo[memo_key]
|
||||
return Scope("scoped", region, scope.code) if region else scope
|
||||
|
||||
# -- persistence -----------------------------------------------------------------
|
||||
|
||||
def _changed(self) -> None:
|
||||
"""Bump the revision, drop the memo, and persist."""
|
||||
self.revision += 1
|
||||
self._memo.clear()
|
||||
self._save()
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Persist the whole store atomically (a crash mid-write keeps the old file)."""
|
||||
data = {
|
||||
"regions": [
|
||||
{
|
||||
"name": r.name,
|
||||
"sources": list(r.sources),
|
||||
"repeaters": list(r.repeaters),
|
||||
"learned_at": r.learned_at.astimezone(timezone.utc).isoformat(),
|
||||
}
|
||||
for r in self._state.values()
|
||||
],
|
||||
"channels": dict(sorted(self._channels.items())),
|
||||
"answers": [
|
||||
{
|
||||
"node": a.node,
|
||||
"unscoped": a.unscoped,
|
||||
"answered_at": a.answered_at.astimezone(timezone.utc).isoformat(),
|
||||
}
|
||||
for a in self._answers.values()
|
||||
],
|
||||
}
|
||||
write_atomically(self._path, json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def _answer_from_json(entry: object) -> CarriedAnswer | None:
|
||||
"""Parse one stored repeater answer, or ``None`` if it is malformed."""
|
||||
if not isinstance(entry, dict) or not entry.get("node"):
|
||||
return None
|
||||
try:
|
||||
answered_at = datetime.fromisoformat(str(entry["answered_at"]))
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
if answered_at.tzinfo is None:
|
||||
answered_at = answered_at.replace(tzinfo=timezone.utc)
|
||||
return CarriedAnswer(
|
||||
node=str(entry["node"]).lower()[:12],
|
||||
unscoped=bool(entry.get("unscoped")),
|
||||
answered_at=answered_at,
|
||||
)
|
||||
|
||||
|
||||
def _region_from_json(entry: object) -> KnownRegion | None:
|
||||
"""Parse one stored region, or ``None`` if it is malformed."""
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
try:
|
||||
name = validate(str(entry["name"]))
|
||||
except (KeyError, RegionNameError):
|
||||
return None
|
||||
sources = tuple(s for s in entry.get("sources") or () if s in SOURCES)
|
||||
repeaters = tuple(str(r).lower()[:12] for r in entry.get("repeaters") or () if r)
|
||||
try:
|
||||
learned_at = datetime.fromisoformat(str(entry["learned_at"]))
|
||||
except (KeyError, ValueError):
|
||||
learned_at = datetime.now(timezone.utc)
|
||||
if learned_at.tzinfo is None:
|
||||
learned_at = learned_at.replace(tzinfo=timezone.utc)
|
||||
return KnownRegion(name=name, sources=sources, repeaters=repeaters, learned_at=learned_at)
|
||||
@@ -0,0 +1,305 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Region scopes: the maths that ties a flood to the region it was sent into.
|
||||
|
||||
MeshCore repeaters can be told which *regions* they carry (``region put yul``,
|
||||
``region allowf yul``), and a flood sent *scoped* to a region is relayed only by the
|
||||
repeaters that carry it. The scope travels in the packet as a **transport code**: a
|
||||
``TRANSPORT_FLOOD`` frame (route type 0 — the library's ``TC_FLOOD``) carries two
|
||||
little-endian ``uint16`` codes between its header and its path, and the first is
|
||||
|
||||
HMAC-SHA256(region key, payload_type_byte || payload)[0:2]
|
||||
|
||||
with ``0x0000`` and ``0xFFFF`` reserved (they become ``0x0001`` and ``0xFFFE``). The
|
||||
second code is reserved and always zero today. A region's key is the first 16 bytes of
|
||||
``SHA-256("#" + name)``, the name taken as its UTF-8 bytes — the firmware stores names
|
||||
bare and puts the ``#`` in front when it derives the key (``RegionMap::getTransportKeysFor``,
|
||||
``TransportKeyStore``), and so does every companion app.
|
||||
|
||||
Two consequences shape everything built on this module:
|
||||
|
||||
* **A code names no region.** It is a keyed hash of the packet's own payload, so the one
|
||||
way to say which region a scoped packet belongs to is to try the region names already
|
||||
known and see whose key reproduces it (:func:`resolve`). A packet scoped to a region
|
||||
nobody here has named reads as *scoped, region unknown* — honestly, and for good.
|
||||
* **The code survives the trip.** Neither the header nor the path is in the hash, so every
|
||||
relay's copy carries the sender's code unchanged: one message has one scope however many
|
||||
ways it arrived.
|
||||
|
||||
Direct-routed packets are never region-filtered by a repeater, so a scope matters to a
|
||||
flood alone; a ``TC_DIRECT`` frame's codes are the ``{0, 0}`` "to nowhere" pair a contact
|
||||
share uses, not a region.
|
||||
|
||||
The vocabulary, fixed here because the UI speaks it everywhere: a **region** is a named
|
||||
area a repeater carries; a flood's **scope** is the region it is limited to; a plain flood
|
||||
is **unscoped**, which a repeater's region list spells as the wildcard ``*``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
#: The wildcard — "every region", i.e. unscoped — as repeaters list it and as the send
|
||||
#: scope's explicit-unscoped override takes it.
|
||||
WILDCARD = "*"
|
||||
|
||||
#: The longest region name, in UTF-8 bytes. The firmware's name slot is 31 bytes with the
|
||||
#: terminating NUL (``RegionEntry.name[31]``, the default-scope frame's 31-byte field), and
|
||||
#: ``CMD_SET_DEFAULT_FLOOD_SCOPE`` refuses anything outside 1–30.
|
||||
MAX_NAME_BYTES = 30
|
||||
|
||||
#: Width of a region's transport key, in bytes.
|
||||
KEY_BYTES = 16
|
||||
|
||||
#: The route types that carry transport codes, as the meshcore library names them
|
||||
#: (``ROUTE_TYPENAMES``): a scoped flood and its direct sibling.
|
||||
SCOPED_ROUTES = frozenset({"TC_FLOOD", "TC_DIRECT"})
|
||||
|
||||
#: The route types a repeater filters by region at all — floods, scoped or not. A direct
|
||||
#: packet is never region-filtered, so it has no scope to show.
|
||||
FLOOD_ROUTES = frozenset({"FLOOD", "TC_FLOOD"})
|
||||
|
||||
|
||||
class RegionNameError(ValueError):
|
||||
"""A region name the firmware would refuse, with the reason in words a reader can act on."""
|
||||
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
"""A region name as the firmware stores and shows it: trimmed, with no leading ``#``.
|
||||
|
||||
Apps and older firmware wrote names as ``#yul``; since firmware 1.12 they are stored
|
||||
bare and the ``#`` belongs only to key derivation. Case is kept — the hash is over the
|
||||
exact bytes, so ``YUL`` and ``yul`` are two different regions.
|
||||
|
||||
Args:
|
||||
name: The name as typed, listed by a repeater, or read back from a device.
|
||||
|
||||
Returns:
|
||||
The bare name (possibly empty).
|
||||
"""
|
||||
return name.strip().removeprefix("#").strip()
|
||||
|
||||
|
||||
def validate(name: str) -> str:
|
||||
"""Normalize a region name and refuse one the firmware would refuse.
|
||||
|
||||
Args:
|
||||
name: The name as typed.
|
||||
|
||||
Returns:
|
||||
The bare, valid name.
|
||||
|
||||
Raises:
|
||||
RegionNameError: If the name is empty, too long, private, or the wildcard.
|
||||
"""
|
||||
bare = normalize(name)
|
||||
if not bare:
|
||||
raise RegionNameError("a region needs a name")
|
||||
if bare == WILDCARD:
|
||||
raise RegionNameError("* is every region — leave the scope empty for unscoped")
|
||||
if bare.startswith("$"):
|
||||
# Private regions derive their keys from a hardware keystore the firmware has not
|
||||
# built yet (``RegionMap::loadKeysFor`` is a TODO), so no repeater can match one.
|
||||
raise RegionNameError("$ regions are private and no firmware can match one yet")
|
||||
if len(bare.encode("utf-8")) > MAX_NAME_BYTES:
|
||||
raise RegionNameError(f"a region name is at most {MAX_NAME_BYTES} bytes")
|
||||
if any(ch.isspace() or ch == "," for ch in bare):
|
||||
# A repeater lists its regions comma-separated and parses its CLI on whitespace, so
|
||||
# a name holding either could be stored but never listed or addressed again.
|
||||
raise RegionNameError("a region name has no spaces or commas")
|
||||
return bare
|
||||
|
||||
|
||||
def region_key(name: str) -> bytes:
|
||||
"""The 16-byte transport key of a region, as the firmware derives it.
|
||||
|
||||
Args:
|
||||
name: The region name, with or without its leading ``#``.
|
||||
|
||||
Returns:
|
||||
``SHA-256("#" + bare name)[:16]``, the name taken as UTF-8.
|
||||
"""
|
||||
return hashlib.sha256(("#" + normalize(name)).encode("utf-8")).digest()[:KEY_BYTES]
|
||||
|
||||
|
||||
def transport_code(key: bytes, body: bytes) -> int:
|
||||
"""The transport code a packet carries when scoped under ``key``.
|
||||
|
||||
Args:
|
||||
key: The region's 16-byte transport key (:func:`region_key`).
|
||||
body: The HMAC input: the payload type byte followed by the payload
|
||||
(:func:`scope_body`).
|
||||
|
||||
Returns:
|
||||
The 16-bit code, with the two reserved values stepped off.
|
||||
"""
|
||||
code = int.from_bytes(hmac.new(key, body, hashlib.sha256).digest()[:2], "little")
|
||||
if code == 0x0000:
|
||||
return 0x0001
|
||||
if code == 0xFFFF:
|
||||
return 0xFFFE
|
||||
return code
|
||||
|
||||
|
||||
def scope_body(payload_type: int, payload: bytes) -> bytes:
|
||||
"""The bytes a transport code is computed over: the payload type, then the payload.
|
||||
|
||||
Args:
|
||||
payload_type: The header's 4-bit payload type (``GRP_TXT`` is 5, ``ADVERT`` 4, …).
|
||||
payload: The packet's payload — everything after the path.
|
||||
|
||||
Returns:
|
||||
``bytes([payload_type]) + payload``.
|
||||
"""
|
||||
return bytes([payload_type & 0x0F]) + bytes(payload)
|
||||
|
||||
|
||||
def parse_codes(hex4: str | None) -> tuple[int, int] | None:
|
||||
"""Split a frame's 4-byte transport-codes field into its two little-endian codes.
|
||||
|
||||
Args:
|
||||
hex4: The field as the meshcore library reports it (``transport_code``, 8 hex
|
||||
digits), or ``None`` where the frame carried none.
|
||||
|
||||
Returns:
|
||||
``(code0, code1)``, or ``None`` for an absent or malformed field.
|
||||
"""
|
||||
if not hex4:
|
||||
return None
|
||||
try:
|
||||
raw = bytes.fromhex(str(hex4))
|
||||
except ValueError:
|
||||
return None
|
||||
if len(raw) != 4:
|
||||
return None
|
||||
return int.from_bytes(raw[0:2], "little"), int.from_bytes(raw[2:4], "little")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Scope:
|
||||
"""What a received frame says about the region it was flooded into.
|
||||
|
||||
Attributes:
|
||||
state: ``"unscoped"`` — a plain flood, every repeater may relay it;
|
||||
``"scoped"`` — a scoped flood whose region is :attr:`region`; ``"unknown"`` — a
|
||||
scoped flood whose code matches no region known here.
|
||||
region: The bare region name for a resolved scope, else ``None``.
|
||||
code: The frame's first transport code (lowercase 4-hex), for a scoped frame.
|
||||
"""
|
||||
|
||||
state: str
|
||||
region: str | None = None
|
||||
code: str | None = None
|
||||
|
||||
@property
|
||||
def scoped(self) -> bool:
|
||||
"""Whether the flood was scoped at all (resolved or not)."""
|
||||
return self.state != "unscoped"
|
||||
|
||||
|
||||
#: The one unscoped answer, shared (a Scope is immutable).
|
||||
UNSCOPED = Scope("unscoped")
|
||||
|
||||
|
||||
def resolve(names: Iterable[str], body: bytes, code: int) -> str | None:
|
||||
"""The first known region whose key reproduces ``code`` over ``body``.
|
||||
|
||||
Two distinct regions can collide on a 16-bit code for one payload (one chance in
|
||||
65 534 per pair), and the firmware has the same ambiguity — a repeater that carries
|
||||
both relays the packet under whichever it lists first. The first match in ``names``
|
||||
order is the answer here too; callers order names by how likely they are.
|
||||
|
||||
Args:
|
||||
names: Candidate region names (bare or ``#``-prefixed).
|
||||
body: The HMAC input (:func:`scope_body`).
|
||||
code: The frame's first transport code.
|
||||
|
||||
Returns:
|
||||
The matching bare name, or ``None``.
|
||||
"""
|
||||
for name in names:
|
||||
bare = normalize(name)
|
||||
if bare and bare != WILDCARD and transport_code(region_key(bare), body) == code:
|
||||
return bare
|
||||
return None
|
||||
|
||||
|
||||
def frame_scope(raw: dict, names: Iterable[str]) -> Scope | None:
|
||||
"""The scope of a received frame's raw payload, or ``None`` where it has none.
|
||||
|
||||
Reads the route type and transport codes the meshcore library parses out of an
|
||||
RX-log frame (or that the repository restores for a replayed one), and the HMAC input
|
||||
— built live from ``payload_type``/``pkt_payload``, or restored as ``scope_body``.
|
||||
|
||||
Args:
|
||||
raw: The frame's raw payload mapping.
|
||||
names: Region names to resolve a scoped frame against.
|
||||
|
||||
Returns:
|
||||
:data:`UNSCOPED` for a plain flood, a scoped :class:`Scope` for a transport flood,
|
||||
and ``None`` for a direct-routed frame or one whose route type was never kept —
|
||||
a repeater never region-filters those, so there is no scope to state.
|
||||
"""
|
||||
route = str(raw.get("route_typename") or "").upper()
|
||||
if route == "FLOOD":
|
||||
return UNSCOPED
|
||||
if route != "TC_FLOOD":
|
||||
return None
|
||||
codes = parse_codes(raw.get("transport_code"))
|
||||
if codes is None:
|
||||
return Scope("unknown")
|
||||
code0 = codes[0]
|
||||
body = raw_scope_body(raw)
|
||||
region = resolve(names, body, code0) if body is not None else None
|
||||
return Scope("scoped" if region else "unknown", region, f"{code0:04x}")
|
||||
|
||||
|
||||
def raw_scope_body(raw: dict) -> bytes | None:
|
||||
"""The HMAC input for a frame's raw payload, live or restored from history.
|
||||
|
||||
Args:
|
||||
raw: The frame's raw payload mapping.
|
||||
|
||||
Returns:
|
||||
The body bytes, or ``None`` where neither the live fields nor a stored body exist.
|
||||
"""
|
||||
stored = raw.get("scope_body")
|
||||
if stored:
|
||||
try:
|
||||
return bytes.fromhex(str(stored))
|
||||
except ValueError:
|
||||
return None
|
||||
payload_type = raw.get("payload_type")
|
||||
payload = raw.get("pkt_payload")
|
||||
if payload_type is None or payload is None or int(payload_type) < 0:
|
||||
return None
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = bytes.fromhex(payload)
|
||||
except ValueError:
|
||||
return None
|
||||
return scope_body(int(payload_type), bytes(payload))
|
||||
|
||||
|
||||
def parse_region_list(text: str | None) -> list[str]:
|
||||
"""The regions a repeater's ``REGIONS`` anonymous-request reply names.
|
||||
|
||||
The reply is a comma-separated list of the regions the repeater relays floods for,
|
||||
``*`` first when it relays unscoped floods too (firmware ``RegionMap::exportNamesTo``).
|
||||
|
||||
Args:
|
||||
text: The reply's text (the meshcore library strips the clock ahead of it).
|
||||
|
||||
Returns:
|
||||
The bare names in the order given, the wildcard kept as ``*``, blanks dropped.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
names: list[str] = []
|
||||
for part in str(text).replace("\x00", "").split(","):
|
||||
bare = normalize(part)
|
||||
if bare and bare not in names:
|
||||
names.append(bare)
|
||||
return names
|
||||
@@ -0,0 +1,104 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""What every relaunch of MeshTerm needs, whichever terminal it is relaunching into.
|
||||
|
||||
MeshTerm reopens itself when the terminal it was started from cannot draw it, and there
|
||||
are two such terminals for two unrelated reasons. The classic Windows console has no font
|
||||
fallback at all, so an icon it lacks is a box and no font choice fixes it
|
||||
(:mod:`meshterm.core.winterminal`). macOS Terminal falls back generously — too generously:
|
||||
no Mac monospace face carries the Braille Patterns block, so the charts are borrowed from a
|
||||
*proportional* face and land at the wrong width (:mod:`meshterm.core.macterminal`). One
|
||||
host shows nothing, the other shows something crooked.
|
||||
|
||||
The destinations could hardly be less alike. The act is the same one: work out the command
|
||||
that would start this session again, hand it an environment a fresh launch can survive, and
|
||||
let the caller exit. So it is written once, here, and each platform module supplies only
|
||||
the part that is genuinely its own — which terminal, and how to ask it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
#: Set in the reopened session, so a relaunch can never chain into another one. Each
|
||||
#: platform's own gate would usually refuse a second pass anyway — Windows Terminal is not
|
||||
#: a classic console; a window opened from MeshTerm's profile is already in it — but this
|
||||
#: is the belt to those braces, and it costs one environment variable.
|
||||
REOPENED_ENV = "MESHTERM_REOPENED"
|
||||
|
||||
|
||||
def own_command() -> list[str]:
|
||||
"""The command that would start this same session again.
|
||||
|
||||
A frozen build is its own executable and takes the arguments as they were given. A
|
||||
development or ``pip`` install is reached through its interpreter, because the console
|
||||
script's own path is not something the receiving terminal can be relied on to resolve.
|
||||
|
||||
Returns:
|
||||
The program and its arguments, ready to hand to a terminal.
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return [sys.executable, *sys.argv[1:]]
|
||||
return [sys.executable, "-m", "meshterm", *sys.argv[1:]]
|
||||
|
||||
|
||||
def child_environment() -> dict[str, str]:
|
||||
"""The environment for a fresh launch of MeshTerm, minus this bundle's bookkeeping.
|
||||
|
||||
A PyInstaller one-file build runs in two stages: the executable unpacks itself into a
|
||||
temporary directory and then runs itself again as a child, the two halves coordinating
|
||||
through ``_PYI*`` environment variables. Those must not reach a *new* launch of the
|
||||
executable. The bootloader would find them, conclude it is the second stage of a launch
|
||||
it never made, check that its parent is the same program — find the terminal instead —
|
||||
and abort::
|
||||
|
||||
[PYI-33368:ERROR] Security validation failure: parent process has different
|
||||
executable!
|
||||
|
||||
Which is the whole session gone, in a brand new window holding a message nobody can act
|
||||
on, while the window that offered the move has already closed. Removing them makes the
|
||||
new process a first stage, which is what it actually is.
|
||||
|
||||
Returns:
|
||||
The environment to start the new process with.
|
||||
"""
|
||||
environment = dict(os.environ)
|
||||
environment[REOPENED_ENV] = "1"
|
||||
if not getattr(sys, "frozen", False):
|
||||
return environment
|
||||
|
||||
for key in [key for key in environment if key.startswith("_PYI")]:
|
||||
del environment[key]
|
||||
environment.pop("_MEIPASS2", None) # the name older bootloaders used
|
||||
# The bootloader points the loader at the unpacked bundle and parks the caller's own
|
||||
# value beside it. The new process unpacks its own copy, so it wants the original.
|
||||
for name in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"):
|
||||
original = environment.pop(f"{name}_ORIG", None)
|
||||
if original is not None:
|
||||
environment[name] = original
|
||||
else:
|
||||
environment.pop(name, None)
|
||||
return environment
|
||||
|
||||
|
||||
def wanted_size() -> tuple[int, int]:
|
||||
"""The window to ask for, as ``(cols, rows)``.
|
||||
|
||||
Asked for, because a window MeshTerm opens should be one MeshTerm fits in. A session
|
||||
that lands in whatever size the reader's shell happens to launch at can arrive too
|
||||
small for the app, and the failure is quiet and cosmetic: the startup wordmark is 71
|
||||
columns, so at 70 it silently swaps to the narrow one drawn for the PicoCalc, and a
|
||||
short window truncates every description onto the pager.
|
||||
|
||||
The numbers come from the platform's own stated minimum plus a margin, so they follow
|
||||
it rather than repeating it. A terminal is free to clamp to what the display can show,
|
||||
so this is a request and not a demand — on a small screen the app degrades exactly as
|
||||
it would have anyway.
|
||||
|
||||
Returns:
|
||||
The columns and rows to ask the terminal for.
|
||||
"""
|
||||
from ..platforms import get_platform
|
||||
|
||||
platform = get_platform()
|
||||
return platform.readable_cols + 8, platform.readable_rows + 6
|
||||
+811
-96
File diff suppressed because it is too large.
Load diff
+153
-37
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for remote-node admin state: last-read settings and CLI history.
|
||||
|
||||
Reading a repeater's configuration costs one paced mesh round trip per value, so the
|
||||
@@ -7,14 +8,19 @@ here, per node (keyed like the admin passwords, by public key), in a small JSON
|
||||
the config directory (``<config_dir>/remote.json``) alongside each node's remote
|
||||
command-line history — global machine state, like the other JSON stores, deliberately
|
||||
outside the per-invocation SQLite database.
|
||||
|
||||
The file is read into typed records (:class:`_RemoteNode`, each holding its
|
||||
:class:`CachedValue` settings) and written back out of them, never round-tripped as the
|
||||
raw JSON it was read as, so a field no record knows is gone after the next write.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .admin_store import admin_key
|
||||
from .atomicwrite import write_atomically
|
||||
@@ -29,12 +35,98 @@ class CachedValue:
|
||||
"""One remembered remote setting: what the node last said, and when.
|
||||
|
||||
Attributes:
|
||||
value: The value as display text.
|
||||
value: The value as stored text (empty when unsupported).
|
||||
read_at: When it was read from (or written to) the node.
|
||||
supported: ``False`` when the node answered the read with an error — its firmware
|
||||
has no such setting (a board without a front-end module, a build without a
|
||||
bridge). Kept apart from *never read*, which is no entry at all.
|
||||
discovered: ``True`` for a setting the catalog hasn't got, learned from a command
|
||||
the reader ran on *this node's* command line. Such a row reads ``n/a`` like any
|
||||
other when the node stops answering it, and is removed only by hand: its key is
|
||||
recorded nowhere else, so a misread reply must not be able to delete it.
|
||||
"""
|
||||
|
||||
value: str
|
||||
read_at: datetime | None
|
||||
supported: bool = True
|
||||
discovered: bool = False
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@classmethod
|
||||
def read(cls, raw: object) -> CachedValue | None:
|
||||
"""Build a value from one file entry, or ``None`` when it is malformed.
|
||||
|
||||
A supported entry must carry its value; an unsupported one carries none.
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
supported = not raw.get("unsupported", False)
|
||||
if supported and "value" not in raw:
|
||||
return None
|
||||
return cls(
|
||||
value=str(raw.get("value", "")),
|
||||
read_at=_as_time(raw.get("read_at")),
|
||||
supported=supported,
|
||||
discovered=bool(raw.get("discovered", False)),
|
||||
)
|
||||
|
||||
def to_json(self) -> dict:
|
||||
"""The entry as written, in the file's long-standing spelling.
|
||||
|
||||
A supported value carries ``value``; an unsupported one says ``unsupported`` and
|
||||
carries no value; ``discovered`` appears only when true.
|
||||
"""
|
||||
entry: dict = {"value": self.value} if self.supported else {"unsupported": True}
|
||||
entry["read_at"] = self.read_at.isoformat() if self.read_at is not None else None
|
||||
if self.discovered:
|
||||
entry["discovered"] = True
|
||||
return entry
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _RemoteNode:
|
||||
"""Everything remembered about one remote node.
|
||||
|
||||
Attributes:
|
||||
settings: Its cached settings, keyed by CLI parameter name.
|
||||
history: Its remote command-line history, oldest first.
|
||||
"""
|
||||
|
||||
settings: dict[str, CachedValue] = field(default_factory=dict)
|
||||
history: list[str] = field(default_factory=list)
|
||||
|
||||
#: Fields this record once held; none yet (see :attr:`CachedValue.RETIRED`).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@classmethod
|
||||
def read(cls, raw: object) -> _RemoteNode:
|
||||
"""Build a node's record from its file entry, taking only the fields it knows."""
|
||||
record = raw if isinstance(raw, dict) else {}
|
||||
settings: dict[str, CachedValue] = {}
|
||||
raw_settings = record.get("settings")
|
||||
if isinstance(raw_settings, dict):
|
||||
for key, entry in raw_settings.items():
|
||||
value = CachedValue.read(entry)
|
||||
if value is not None:
|
||||
settings[str(key)] = value
|
||||
raw_history = record.get("history")
|
||||
history = [str(c) for c in raw_history] if isinstance(raw_history, list) else []
|
||||
return cls(settings=settings, history=history)
|
||||
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
"""Whether there is nothing left to remember about this node."""
|
||||
return not self.settings and not self.history
|
||||
|
||||
def to_json(self) -> dict:
|
||||
"""The record as written: its own fields and nothing it happened to be read with."""
|
||||
return {
|
||||
"settings": {key: value.to_json() for key, value in self.settings.items()},
|
||||
"history": list(self.history),
|
||||
}
|
||||
|
||||
|
||||
class RemoteStore:
|
||||
@@ -48,66 +140,90 @@ class RemoteStore:
|
||||
"""
|
||||
self._path = path
|
||||
|
||||
def _load_all(self) -> dict[str, dict]:
|
||||
"""Return the raw key -> record mapping, or empty on missing/corrupt file."""
|
||||
def _load(self) -> dict[str, _RemoteNode]:
|
||||
"""Read every node's record, keyed by :func:`admin_key`; empty on trouble."""
|
||||
try:
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {str(key): _RemoteNode.read(raw) for key, raw in data.items()}
|
||||
|
||||
def _node(self, node: Contact) -> _RemoteNode:
|
||||
"""One node's record, or an empty one when nothing is remembered."""
|
||||
return self._load().get(admin_key(node)) or _RemoteNode()
|
||||
|
||||
# -- the settings cache -------------------------------------------------------
|
||||
|
||||
def settings(self, node: Contact) -> dict[str, CachedValue]:
|
||||
"""Every remembered setting for ``node``, keyed by CLI parameter name."""
|
||||
record = self._load_all().get(admin_key(node))
|
||||
raw = record.get("settings") if isinstance(record, dict) else None
|
||||
out: dict[str, CachedValue] = {}
|
||||
if not isinstance(raw, dict):
|
||||
return out
|
||||
for key, entry in raw.items():
|
||||
if not isinstance(entry, dict) or "value" not in entry:
|
||||
continue
|
||||
read_at: datetime | None = None
|
||||
stamp = entry.get("read_at")
|
||||
if isinstance(stamp, str):
|
||||
try:
|
||||
read_at = datetime.fromisoformat(stamp)
|
||||
except ValueError:
|
||||
read_at = None
|
||||
out[key] = CachedValue(value=str(entry["value"]), read_at=read_at)
|
||||
return out
|
||||
return dict(self._node(node).settings)
|
||||
|
||||
def remember_setting(self, node: Contact, key: str, value: str) -> None:
|
||||
"""Cache one setting's value for ``node``, stamped now."""
|
||||
records = self._load_all()
|
||||
record = records.setdefault(admin_key(node), {})
|
||||
settings = record.setdefault("settings", {})
|
||||
settings[key] = {"value": value, "read_at": utcnow().isoformat()}
|
||||
self._put(node, key, CachedValue(value=value, read_at=utcnow()))
|
||||
|
||||
def remember_discovered(self, node: Contact, key: str, value: str) -> None:
|
||||
"""Cache a setting the catalog hasn't got, which ``node`` has just proved it has."""
|
||||
self._put(node, key, CachedValue(value=value, read_at=utcnow(), discovered=True))
|
||||
|
||||
def remember_unsupported(self, node: Contact, key: str) -> None:
|
||||
"""Record that ``node`` answered a read of ``key`` with an error, stamped now."""
|
||||
self._put(node, key, CachedValue(value="", read_at=utcnow(), supported=False))
|
||||
|
||||
def forget_setting(self, node: Contact, key: str) -> None:
|
||||
"""Drop one cached setting, so the row reads as never read."""
|
||||
records = self._load()
|
||||
record = records.get(admin_key(node))
|
||||
if record is not None and record.settings.pop(key, None) is not None:
|
||||
self._write(records)
|
||||
|
||||
def _put(self, node: Contact, key: str, value: CachedValue) -> None:
|
||||
"""Store one setting's cache entry for ``node``, keeping it discovered if it was.
|
||||
|
||||
A discovered row is refreshed by the same reads and writes as any other — through
|
||||
:meth:`remember_setting` — and that must not quietly demote it to a catalog row it
|
||||
has no entry for, which would leave a row nothing draws.
|
||||
"""
|
||||
records = self._load()
|
||||
record = records.setdefault(admin_key(node), _RemoteNode())
|
||||
previous = record.settings.get(key)
|
||||
if previous is not None and previous.discovered:
|
||||
value = replace(value, discovered=True)
|
||||
record.settings[key] = value
|
||||
self._write(records)
|
||||
|
||||
# -- the command-line history ---------------------------------------------------
|
||||
|
||||
def history(self, node: Contact) -> list[str]:
|
||||
"""The node's remote CLI history, oldest first."""
|
||||
record = self._load_all().get(admin_key(node))
|
||||
raw = record.get("history") if isinstance(record, dict) else None
|
||||
return [str(c) for c in raw] if isinstance(raw, list) else []
|
||||
return list(self._node(node).history)
|
||||
|
||||
def append_history(self, node: Contact, command: str) -> None:
|
||||
"""Append one sent command to the node's history (dropping an adjacent dupe)."""
|
||||
command = command.strip()
|
||||
if not command:
|
||||
return
|
||||
records = self._load_all()
|
||||
record = records.setdefault(admin_key(node), {})
|
||||
history = record.setdefault("history", [])
|
||||
if history and history[-1] == command:
|
||||
records = self._load()
|
||||
record = records.setdefault(admin_key(node), _RemoteNode())
|
||||
if record.history and record.history[-1] == command:
|
||||
return # re-running the last command shouldn't stutter the recall
|
||||
history.append(command)
|
||||
del history[:-HISTORY_CAP]
|
||||
record.history.append(command)
|
||||
del record.history[:-HISTORY_CAP]
|
||||
self._write(records)
|
||||
|
||||
def _write(self, records: dict[str, dict]) -> None:
|
||||
def _write(self, records: dict[str, _RemoteNode]) -> None:
|
||||
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
|
||||
write_atomically(self._path, json.dumps(records, indent=2))
|
||||
data = {key: record.to_json() for key, record in records.items() if not record.empty}
|
||||
write_atomically(self._path, json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def _as_time(value: object) -> datetime | None:
|
||||
"""Parse a stored ISO-8601 timestamp, or ``None`` if absent or corrupt."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Non-interactive device selection: turn discovery + memory into a chosen port.
|
||||
|
||||
This is the logic used on the scripted CLI path (and as the fallback when the interactive
|
||||
@@ -111,9 +112,17 @@ def resolve_device(
|
||||
3. ``explicit_port`` (an explicit ``--port``).
|
||||
4. A TCP profile's ``host:port``, or a serial profile's ``port``.
|
||||
5. The remembered "last known good" device, if it is currently attached/in range.
|
||||
6. The single attached device, if exactly one is present.
|
||||
6. The single *likely-LoRa* device, if exactly one is present — a port whose USB
|
||||
vendor marks it a board or bridge, or a BLE/TCP endpoint. Ports that look like
|
||||
nothing in particular are ignored at this step, because a platform may present
|
||||
some unconditionally (every Mac carries two) and they would otherwise make the
|
||||
count ambiguous forever.
|
||||
7. The single attached device, if exactly one is present and *none* looked likely —
|
||||
so an unrecognized-but-real adapter still resolves.
|
||||
|
||||
Otherwise a :class:`DeviceSelectionError` is raised listing the candidates.
|
||||
Otherwise a :class:`DeviceSelectionError` is raised listing the candidates: the
|
||||
likely ones where there are any, else everything attached under a message that does
|
||||
not claim they are companions.
|
||||
|
||||
Args:
|
||||
devices: Currently discovered devices (serial and/or BLE).
|
||||
@@ -154,7 +163,23 @@ def resolve_device(
|
||||
if match is not None:
|
||||
return Resolution(match.target, match, "remembered", match.transport)
|
||||
|
||||
if len(devices) == 1:
|
||||
# Prefer the devices that actually look like companions. macOS is why this matters:
|
||||
# every Mac permanently presents /dev/cu.Bluetooth-Incoming-Port and
|
||||
# /dev/cu.debug-console, which carry no USB VID/PID and so score "unknown". Counting
|
||||
# them put `len(devices) == 1` out of reach there — a Mac with exactly one real board
|
||||
# attached saw three devices and refused to choose, so auto-detection could never fire
|
||||
# on that platform and every Mac user met "Multiple companion devices detected" on a
|
||||
# first run. Which device is plausible is already known, and already printed in the
|
||||
# listing below as "[likely LoRa]"; this decides with it instead of only saying it.
|
||||
likely = [d for d in devices if d.is_likely_lora]
|
||||
|
||||
if len(likely) == 1:
|
||||
return Resolution(likely[0].target, likely[0], "only", likely[0].transport)
|
||||
|
||||
# Nothing recognizable: fall back to the whole list rather than narrowing to nothing,
|
||||
# so a lone *unrecognized* adapter — a UART bridge carrying a VID we don't list, which
|
||||
# is a real board more often than not — still connects exactly as it always has.
|
||||
if not likely and len(devices) == 1:
|
||||
return Resolution(devices[0].target, devices[0], "only", devices[0].transport)
|
||||
|
||||
if not devices:
|
||||
@@ -163,9 +188,20 @@ def resolve_device(
|
||||
"or pass --port / --ble explicitly, or use --mock for the simulator."
|
||||
)
|
||||
|
||||
if not likely:
|
||||
# Several ports attached, none of them plausible. "Multiple companion devices" is
|
||||
# simply false here: on a bare Mac it names two virtual ports that are not
|
||||
# companions at all, and then tells the reader to choose one of them.
|
||||
raise DeviceSelectionError(
|
||||
"No companion devices detected among the attached ports.\n"
|
||||
f"{_format_device_list(devices)}\n"
|
||||
"Connect a companion device (USB or Bluetooth), or pass --port / --ble "
|
||||
"explicitly if one of these is your companion, or use --mock for the simulator."
|
||||
)
|
||||
|
||||
raise DeviceSelectionError(
|
||||
"Multiple companion devices detected and no default to fall back on.\n"
|
||||
f"{_format_device_list(devices)}\n"
|
||||
f"{_format_device_list(likely)}\n"
|
||||
"Choose one with --port <PORT> or --ble <ADDRESS> (or run 'meshterm devices' to "
|
||||
"inspect them). The chosen device is remembered as the default after it connects."
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for device settings changed through MeshTerm, so they survive a forgetful device.
|
||||
|
||||
Most companions keep their configuration in firmware, so MeshTerm reads settings live from the
|
||||
@@ -33,7 +34,13 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .device_config import DeviceConfigError, format_value, get_spec, parse_value
|
||||
from .device_config import (
|
||||
DeviceConfigError,
|
||||
format_value,
|
||||
get_spec,
|
||||
is_setting_key,
|
||||
parse_value,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .connection import Device
|
||||
@@ -85,9 +92,14 @@ class SettingsStore:
|
||||
for pubkey, entries in (data.get("devices") or {}).items():
|
||||
if not isinstance(entries, dict):
|
||||
continue
|
||||
# Keep only plain scalar values; a container or null is a malformed entry.
|
||||
# Keep only plain scalar values of settings the registry knows: a container or
|
||||
# null is a malformed entry, and a key that is no longer a setting (or a typo)
|
||||
# is not something to offer to write back onto the radio. Whatever is left out
|
||||
# here is gone from the file at its next save.
|
||||
values = {
|
||||
str(key): value for key, value in entries.items() if isinstance(value, _SCALARS)
|
||||
str(key): value
|
||||
for key, value in entries.items()
|
||||
if isinstance(value, _SCALARS) and is_setting_key(str(key))
|
||||
}
|
||||
if values:
|
||||
devices[_norm(str(pubkey))] = values
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""A LoRa radio on the host's own SPI bus, driven by a node MeshTerm runs only while it runs.
|
||||
|
||||
Some boards — the uConsole's AIO is the one this was built for — put the radio chip straight
|
||||
on the host's SPI bus, with no microcontroller and so no companion firmware to talk to. A
|
||||
software node has to run somewhere. The standalone ``meshterm-spi-bridge`` service runs one
|
||||
all the time, which keeps the node on the mesh while MeshTerm is closed but holds the radio's
|
||||
pins for good, so no other LoRa program can use them until the service is stopped.
|
||||
|
||||
This is the other shape: **MeshTerm runs the node itself, for exactly as long as it is
|
||||
connected.** :class:`SpiRadioDevice` starts the node (:mod:`meshterm.core.radionode`) as a
|
||||
child process when it connects, talks to it over the ordinary companion protocol on a
|
||||
loopback port — so every feature works exactly as it does against a network companion — and
|
||||
ends it when it disconnects. The pins go back when MeshTerm quits, and also when it crashes:
|
||||
the node exits with its parent (see :mod:`~meshterm.core.radionode`), and the kernel frees a
|
||||
process's GPIO lines and SPI handle when it exits.
|
||||
|
||||
**Why a child process and not a library call.** The radio library (``openhop-core``) is an
|
||||
optional install, deliberately not something every desktop MeshTerm carries, and the
|
||||
one-file build can't import it at all. So the node runs under whichever Python *has* the
|
||||
library (:func:`find_runtime`): MeshTerm's own when it was installed with the ``spi``
|
||||
extra, the bridge's venv, or the ``meshcore-uconsole`` package's. A separate process also
|
||||
keeps the node's radio timing off the event loop that draws the screen.
|
||||
|
||||
**One radio, one identity.** A node's state — its identity key, preferences, channels and
|
||||
contacts — lives under ``<config_dir>/radio/<spidev>/``, keyed by the SPI device rather than
|
||||
by a profile name, so reaching the same radio from the picker one day and from a profile the
|
||||
next is still the same node. The first time a radio is used, the bridge's identity and
|
||||
contacts are copied in (:func:`import_bridge_state`) so an existing node keeps its key, and
|
||||
with it its place in everyone else's contact list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import DeviceProfile, SpiWiring
|
||||
from .connection import DeviceCommandError, MeshCoreDevice
|
||||
from .discovery import DiscoveredDevice, spi_device
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
#: The radio library the node needs, as ``pip`` spells it — what every "install it" hint says.
|
||||
RUNTIME_PACKAGE = "openhop-core[hardware]"
|
||||
|
||||
#: How long a starting node gets to report ready. It imports the radio library, brings the
|
||||
#: chip up (retrying a busy pin a few times) and starts listening; on a PicoCalc-class board
|
||||
#: the import alone is several seconds.
|
||||
READY_TIMEOUT_S = 60.0
|
||||
|
||||
#: How long a node gets to shut down on its own after its stdin closes, before it is sent
|
||||
#: SIGTERM, and then again before SIGKILL. Its own shutdown saves contacts and frees the
|
||||
#: radio; the signals are for a node that has stopped answering.
|
||||
STOP_GRACE_S = 8.0
|
||||
TERM_GRACE_S = 3.0
|
||||
|
||||
#: What the node is seeded with the first time a radio is used and it has no preferences of
|
||||
#: its own: MeshCore's US/Canada preset at the AIO's 22 dBm. Afterwards the node's saved
|
||||
#: settings rule, edited on Device config like any companion's.
|
||||
DEFAULT_SEED: dict[str, object] = {
|
||||
"frequency_hz": 910_525_000,
|
||||
"bandwidth_hz": 62_500,
|
||||
"spreading_factor": 7,
|
||||
"coding_rate": 5,
|
||||
"tx_power_dbm": 22,
|
||||
}
|
||||
|
||||
#: The standalone bridge's service name and the directory it keeps its state in.
|
||||
BRIDGE_SERVICE = "meshterm-spi-bridge.service"
|
||||
BRIDGE_NAME = "meshterm-spi-bridge"
|
||||
|
||||
#: ``MESHCORE_*`` variables the bridge reads, mapped to the node preference each seeds.
|
||||
_BRIDGE_RADIO_ENV = {
|
||||
"MESHCORE_FREQUENCY": "frequency_hz",
|
||||
"MESHCORE_BANDWIDTH": "bandwidth_hz",
|
||||
"MESHCORE_SPREADING_FACTOR": "spreading_factor",
|
||||
"MESHCORE_CODING_RATE": "coding_rate",
|
||||
"MESHCORE_TX_POWER": "tx_power_dbm",
|
||||
}
|
||||
|
||||
|
||||
class SpiRadioError(DeviceCommandError):
|
||||
"""The node could not be started, worded for the person who has to fix it.
|
||||
|
||||
A :class:`~meshterm.core.connection.DeviceCommandError`, because that is what the
|
||||
startup picker and the connect path pass through as an *actionable* failure — shown
|
||||
with its remedy — rather than folding it into "didn't answer".
|
||||
|
||||
Attributes:
|
||||
kind: What went wrong — ``unsupported``, ``runtime``, ``no-spi``, ``no-gpio``,
|
||||
``permission``, ``busy``, or ``failed`` (see :func:`explain`).
|
||||
"""
|
||||
|
||||
def __init__(self, kind: str, message: str) -> None:
|
||||
"""Carry ``message`` as the error text and ``kind`` as its classification."""
|
||||
super().__init__(message)
|
||||
self.kind = kind
|
||||
|
||||
|
||||
# --- where things are ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def node_script() -> Path:
|
||||
"""The node's source file, which is run by path under another interpreter.
|
||||
|
||||
It sits beside this module in a checkout and in an install, and the one-file build
|
||||
ships it as a data file at the same relative place (``packaging/meshterm.spec``).
|
||||
"""
|
||||
return Path(__file__).with_name("radionode.py")
|
||||
|
||||
|
||||
def state_dir(config_dir: Path, wiring: SpiWiring) -> Path:
|
||||
"""Where the node on ``wiring``'s SPI device keeps its identity, settings and lists."""
|
||||
return config_dir / "radio" / Path(wiring.spidev).name
|
||||
|
||||
|
||||
def _on_linux() -> bool:
|
||||
"""Whether this is Linux, the one place a radio on the SPI bus can be driven from."""
|
||||
return sys.platform.startswith("linux")
|
||||
|
||||
|
||||
def _xdg_data_home() -> Path:
|
||||
"""``$XDG_DATA_HOME``, or ``~/.local/share`` where it is unset."""
|
||||
raw = os.environ.get("XDG_DATA_HOME", "").strip()
|
||||
return Path(raw) if raw else Path.home() / ".local" / "share"
|
||||
|
||||
|
||||
def spi_present(wiring: SpiWiring) -> bool:
|
||||
"""Whether ``wiring``'s SPI device node exists on this machine (Linux only)."""
|
||||
return _on_linux() and os.path.exists(wiring.spidev)
|
||||
|
||||
|
||||
def spi_radios(
|
||||
profiles: Mapping[str, DeviceProfile] | None, listed: Iterable[DiscoveredDevice] = ()
|
||||
) -> list[DiscoveredDevice]:
|
||||
"""The radios on this machine's own SPI bus, as devices — THE one answer to "which are here".
|
||||
|
||||
One per ``transport = "spi"`` profile whose ``/dev/spidev*`` node exists, named by the
|
||||
profile; and, where no profile covers it, one for the uConsole AIO's device node when it
|
||||
exists, wired with the defaults. Nothing is probed — a radio on the bus has no node
|
||||
running until one is chosen — so a radio is listed exactly when there is a device node
|
||||
to open. The device screen and ``meshterm devices`` both list from here, so the two can
|
||||
never disagree about what is attached.
|
||||
|
||||
Args:
|
||||
profiles: The configured profiles, or ``None`` when none are loaded.
|
||||
listed: Devices already gathered; a radio among them is not listed twice.
|
||||
|
||||
Returns:
|
||||
One SPI :class:`~meshterm.core.discovery.DiscoveredDevice` per radio not in ``listed``.
|
||||
"""
|
||||
seen = {d.stable_id for d in listed}
|
||||
wirings = [(p.spi or SpiWiring(), p.name) for p in (profiles or {}).values() if p.is_spi]
|
||||
covered = {wiring.spidev for wiring, _name in wirings}
|
||||
if SpiWiring().spidev not in covered:
|
||||
wirings.append((SpiWiring(), ""))
|
||||
radios: list[DiscoveredDevice] = []
|
||||
for wiring, name in wirings:
|
||||
if not spi_present(wiring):
|
||||
continue
|
||||
device = spi_device(wiring, name=name)
|
||||
if device.stable_id in seen:
|
||||
continue
|
||||
seen.add(device.stable_id)
|
||||
radios.append(device)
|
||||
return radios
|
||||
|
||||
|
||||
# --- finding a Python with the radio library -----------------------------------------------
|
||||
|
||||
#: Each interpreter probed this session: path -> whether it can import the radio library.
|
||||
_probed: dict[str, bool] = {}
|
||||
|
||||
|
||||
def _runtime_candidates(wiring: SpiWiring) -> list[str]:
|
||||
"""Interpreters worth probing for the radio library, most specific first."""
|
||||
candidates: list[str] = []
|
||||
if wiring.python:
|
||||
candidates.append(os.path.expanduser(wiring.python))
|
||||
if not getattr(sys, "frozen", False):
|
||||
candidates.append(sys.executable) # MeshTerm's own, installed with the ``spi`` extra
|
||||
data = _xdg_data_home()
|
||||
candidates += [
|
||||
str(data / BRIDGE_NAME / "venv" / "bin" / "python"),
|
||||
"/opt/venvs/meshcore-uconsole/bin/python",
|
||||
]
|
||||
system = shutil.which("python3")
|
||||
if system:
|
||||
candidates.append(system)
|
||||
seen: set[str] = set()
|
||||
return [c for c in candidates if c and not (c in seen or seen.add(c))]
|
||||
|
||||
|
||||
def _can_import_runtime(python: str) -> bool:
|
||||
"""Whether ``python`` can import ``openhop_core`` (one short subprocess, then cached)."""
|
||||
if python in _probed:
|
||||
return _probed[python]
|
||||
ok = False
|
||||
if os.path.exists(python):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[python, "-c", "import openhop_core"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=child_env(),
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
ok = result.returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
ok = False
|
||||
_probed[python] = ok
|
||||
return ok
|
||||
|
||||
|
||||
def find_runtime(wiring: SpiWiring) -> str:
|
||||
"""An interpreter that can run the node, or raise :class:`SpiRadioError` (``runtime``).
|
||||
|
||||
A ``python`` named in the wiring is the only candidate when it is set — naming one is a
|
||||
decision, and quietly falling back past it would hide that it doesn't work.
|
||||
"""
|
||||
candidates = _runtime_candidates(wiring)
|
||||
if wiring.python:
|
||||
candidates = candidates[:1]
|
||||
for python in candidates:
|
||||
if _can_import_runtime(python):
|
||||
return python
|
||||
if wiring.python:
|
||||
raise SpiRadioError("runtime", f"{wiring.python} cannot import openhop_core")
|
||||
raise SpiRadioError("runtime", "no Python on this machine has the radio library")
|
||||
|
||||
|
||||
def child_env() -> dict[str, str]:
|
||||
"""The environment for a program MeshTerm starts, minus what a frozen build injected.
|
||||
|
||||
The one-file build's bootloader leaves its own variables behind (``_PYI_*``,
|
||||
``_MEIPASS2``) and points ``LD_LIBRARY_PATH`` at its unpacked libraries; another Python
|
||||
started with those loads MeshTerm's bundled copies of libssl and friends instead of its
|
||||
own. PyInstaller keeps the original under ``LD_LIBRARY_PATH_ORIG``, which is put back.
|
||||
"""
|
||||
env = {k: v for k, v in os.environ.items() if not (k.startswith("_PYI") or k == "_MEIPASS2")}
|
||||
if getattr(sys, "frozen", False):
|
||||
original = env.pop("LD_LIBRARY_PATH_ORIG", None)
|
||||
if original is not None:
|
||||
env["LD_LIBRARY_PATH"] = original
|
||||
else:
|
||||
env.pop("LD_LIBRARY_PATH", None)
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
# --- who else has the radio ----------------------------------------------------------------
|
||||
|
||||
|
||||
def radio_holder() -> str | None:
|
||||
"""Name the program already holding the radio, when it is one we know how to stop.
|
||||
|
||||
Asked before starting the node, because the node's own answer to a held pin is a bare
|
||||
"busy" after several seconds of retries, and the two usual holders each have a one-line
|
||||
fix worth saying instead.
|
||||
"""
|
||||
if not _on_linux():
|
||||
return None
|
||||
systemctl = shutil.which("systemctl")
|
||||
if systemctl:
|
||||
try:
|
||||
active = subprocess.run(
|
||||
[systemctl, "--user", "is-active", "--quiet", BRIDGE_SERVICE],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
if active.returncode == 0:
|
||||
return "bridge"
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
for cmdline in Path("/proc").glob("[0-9]*/cmdline"):
|
||||
try:
|
||||
argv = cmdline.read_bytes().split(b"\0")
|
||||
except OSError:
|
||||
continue
|
||||
if any(os.path.basename(a) == b"meshcore-console" for a in argv[:3]):
|
||||
return "console"
|
||||
return None
|
||||
|
||||
|
||||
def explain(kind: str, detail: str, wiring: SpiWiring, holder: str | None = None) -> str:
|
||||
"""The sentence a failed start is reported with: what is wrong and what to do about it."""
|
||||
if kind == "busy" or holder:
|
||||
if holder == "bridge":
|
||||
return (
|
||||
f"the {BRIDGE_NAME} service has the radio — stop it with "
|
||||
f"`systemctl --user stop {BRIDGE_NAME}` (and `disable` it to keep it stopped)"
|
||||
)
|
||||
if holder == "console":
|
||||
return "meshcore-console has the radio open — close it and connect again"
|
||||
return (
|
||||
"another program has the radio's pins "
|
||||
f"(`sudo lsof /dev/gpiochip{wiring.gpio_chip}` shows which)"
|
||||
)
|
||||
if kind == "runtime":
|
||||
return (
|
||||
f"{detail}. Install it with `pipx inject mesh-term '{RUNTIME_PACKAGE}'`, or into a "
|
||||
"venv of its own and name that venv's python as `python` in the profile's spi table"
|
||||
)
|
||||
if kind == "no-spi":
|
||||
return (
|
||||
f"{wiring.spidev} does not exist — enable SPI (on a uConsole, add "
|
||||
"`dtoverlay=spi1-1cs` to /boot/firmware/config.txt) and reboot"
|
||||
)
|
||||
if kind == "no-gpio":
|
||||
return f"/dev/gpiochip{wiring.gpio_chip} does not exist — check `gpio_chip` in the profile"
|
||||
if kind == "permission":
|
||||
return (
|
||||
f"{detail} — add yourself to the spi and gpio groups "
|
||||
"(`sudo usermod -aG spi,gpio $USER`) and log in again"
|
||||
)
|
||||
if kind == "unsupported":
|
||||
return detail
|
||||
return f"the radio node failed: {detail}"
|
||||
|
||||
|
||||
# --- first use: carrying over the bridge's node --------------------------------------------
|
||||
|
||||
|
||||
def _bridge_radio_seed() -> dict[str, int]:
|
||||
"""The radio settings the bridge service runs with, from its unit's environment.
|
||||
|
||||
The bridge takes its frequency, preset and power from ``MESHCORE_*`` variables, usually
|
||||
set in a drop-in; a node that inherits the bridge's identity should come up on the same
|
||||
channel of air, not on the default preset. Empty when there is no such service.
|
||||
"""
|
||||
systemctl = shutil.which("systemctl")
|
||||
if not systemctl:
|
||||
return {}
|
||||
try:
|
||||
shown = subprocess.run(
|
||||
[systemctl, "--user", "show", "--property=Environment", BRIDGE_SERVICE],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return {}
|
||||
seed: dict[str, int] = {}
|
||||
for word in shown.stdout.removeprefix("Environment=").split():
|
||||
name, _, value = word.partition("=")
|
||||
if name in _BRIDGE_RADIO_ENV:
|
||||
try:
|
||||
seed[_BRIDGE_RADIO_ENV[name]] = int(value)
|
||||
except ValueError:
|
||||
continue
|
||||
return seed
|
||||
|
||||
|
||||
def import_bridge_state(state: Path) -> list[str]:
|
||||
"""Copy the bridge's node into a radio's state directory the first time it is used.
|
||||
|
||||
Runs only while the state directory has no identity yet, and copies rather than moves, so
|
||||
the bridge still works for anyone who goes back to it. The identity is looked for where
|
||||
the bridge itself looks, in its order: the ``meshcore-uconsole`` GUI's key first (the
|
||||
bridge shares it), then the bridge's own. Contacts come from the bridge's snapshot, whose
|
||||
records are the radio library's own contact dicts, so they load unchanged. Radio settings
|
||||
are seeded from the service's environment, and the node keeps the bridge's name.
|
||||
|
||||
Args:
|
||||
state: The radio's state directory.
|
||||
|
||||
Returns:
|
||||
What was carried over, for the log — empty when nothing was.
|
||||
"""
|
||||
if (state / "identity.key").exists():
|
||||
return []
|
||||
data = _xdg_data_home()
|
||||
bridge = data / BRIDGE_NAME
|
||||
carried: list[str] = []
|
||||
for key in (data / "meshcore-uconsole" / "identity.key", bridge / "identity.key"):
|
||||
if key.is_file():
|
||||
state.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(key, state / "identity.key")
|
||||
os.chmod(state / "identity.key", 0o600)
|
||||
carried.append(f"identity from {key}")
|
||||
break
|
||||
if not carried:
|
||||
return []
|
||||
contacts = bridge / "contacts.json"
|
||||
if contacts.is_file() and not (state / "contacts.json").exists():
|
||||
shutil.copyfile(contacts, state / "contacts.json")
|
||||
carried.append(f"contacts from {contacts}")
|
||||
if not (state / "prefs.json").exists():
|
||||
prefs: dict[str, object] = {"node_name": "uConsole", **_bridge_radio_seed()}
|
||||
(state / "prefs.json").write_text(json.dumps(prefs, indent=2), encoding="utf-8")
|
||||
carried.append("the bridge's name and radio settings")
|
||||
return carried
|
||||
|
||||
|
||||
# --- the node process ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeProcess:
|
||||
"""One running node: the child process and the loopback port it serves on.
|
||||
|
||||
Attributes:
|
||||
process: The child.
|
||||
port: The loopback TCP port its frame server listens on.
|
||||
public_key: The node's public key, hex, as it reported it.
|
||||
log_path: Where its stderr goes.
|
||||
"""
|
||||
|
||||
process: asyncio.subprocess.Process
|
||||
port: int
|
||||
public_key: str
|
||||
log_path: Path
|
||||
|
||||
@property
|
||||
def alive(self) -> bool:
|
||||
"""Whether the child is still running."""
|
||||
return self.process.returncode is None
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""End the node and wait for it: close its stdin, then SIGTERM, then SIGKILL.
|
||||
|
||||
Closing stdin is the polite request — the node saves its contacts and frees the radio
|
||||
on the way out. The signals are for a node that has stopped listening, and each wait
|
||||
is bounded so a wedged child can never hold up a quit.
|
||||
"""
|
||||
proc = self.process
|
||||
if proc.returncode is not None:
|
||||
return
|
||||
if proc.stdin is not None:
|
||||
proc.stdin.close()
|
||||
for grace, escalate in ((STOP_GRACE_S, proc.terminate), (TERM_GRACE_S, proc.kill)):
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=grace)
|
||||
return
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
escalate()
|
||||
except ProcessLookupError:
|
||||
return
|
||||
await proc.wait()
|
||||
|
||||
|
||||
def _rotate(log_path: Path) -> None:
|
||||
"""Keep the previous run's node log beside this one's, and no more."""
|
||||
if log_path.exists():
|
||||
try:
|
||||
log_path.replace(log_path.with_suffix(".log.1"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
async def start_node(wiring: SpiWiring, state: Path, *, node_name: str) -> NodeProcess:
|
||||
"""Start the node on ``wiring``'s radio and wait until it is listening.
|
||||
|
||||
Args:
|
||||
wiring: How the radio is wired.
|
||||
state: The radio's state directory.
|
||||
node_name: The name a brand-new node advertises until it is renamed.
|
||||
|
||||
Returns:
|
||||
The running node.
|
||||
|
||||
Raises:
|
||||
SpiRadioError: If it can't be started, with the reason already worded.
|
||||
"""
|
||||
if not _on_linux():
|
||||
raise SpiRadioError("unsupported", "a radio on the SPI bus needs Linux")
|
||||
# Each of these runs a short subprocess or two; none of them belongs on the event loop
|
||||
# that is drawing the "connecting" card.
|
||||
holder = await asyncio.to_thread(radio_holder)
|
||||
if holder:
|
||||
raise SpiRadioError("busy", explain("busy", "", wiring, holder))
|
||||
try:
|
||||
python = await asyncio.to_thread(find_runtime, wiring)
|
||||
except SpiRadioError as exc:
|
||||
raise SpiRadioError(exc.kind, explain(exc.kind, str(exc), wiring)) from None
|
||||
|
||||
state.mkdir(parents=True, exist_ok=True)
|
||||
for line in await asyncio.to_thread(import_bridge_state, state):
|
||||
_log.info("spi radio: carried over %s", line)
|
||||
config = {
|
||||
"state_dir": str(state),
|
||||
"wiring": {
|
||||
"bus_id": wiring.bus_id,
|
||||
"cs_id": wiring.cs_id,
|
||||
"cs_pin": wiring.cs_pin,
|
||||
"gpio_chip": wiring.gpio_chip,
|
||||
"use_gpiod_backend": wiring.use_gpiod_backend,
|
||||
"reset_pin": wiring.reset_pin,
|
||||
"busy_pin": wiring.busy_pin,
|
||||
"irq_pin": wiring.irq_pin,
|
||||
"txen_pin": wiring.txen_pin,
|
||||
"rxen_pin": wiring.rxen_pin,
|
||||
"en_pins": list(wiring.en_pins) or None,
|
||||
"use_dio2_rf": wiring.use_dio2_rf,
|
||||
"use_dio3_tcxo": wiring.use_dio3_tcxo,
|
||||
"is_waveshare": wiring.is_waveshare,
|
||||
},
|
||||
"seed": {"node_name": node_name, **DEFAULT_SEED},
|
||||
}
|
||||
log_path = state / "node.log"
|
||||
_rotate(log_path)
|
||||
_log.info("spi radio: starting the node under %s (log %s)", python, log_path)
|
||||
with log_path.open("wb") as log_file:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
python,
|
||||
str(node_script()),
|
||||
"--config",
|
||||
json.dumps(config),
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=log_file,
|
||||
env=child_env(),
|
||||
start_new_session=True, # ^C in the terminal is MeshTerm's to handle, not the node's
|
||||
)
|
||||
node = NodeProcess(process=proc, port=0, public_key="", log_path=log_path)
|
||||
try:
|
||||
line = await asyncio.wait_for(proc.stdout.readline(), timeout=READY_TIMEOUT_S)
|
||||
except asyncio.CancelledError:
|
||||
proc.kill() # abandoned mid-start (a probe's own deadline): nothing may keep the radio
|
||||
raise
|
||||
except asyncio.TimeoutError:
|
||||
await node.stop()
|
||||
detail = f"it did not start within {READY_TIMEOUT_S:.0f}s (see {log_path})"
|
||||
raise SpiRadioError("failed", explain("failed", detail, wiring)) from None
|
||||
try:
|
||||
status = json.loads(line) if line else {}
|
||||
except ValueError:
|
||||
status = {}
|
||||
if status.get("event") != "ready":
|
||||
await node.stop()
|
||||
kind = str(status.get("kind") or "failed")
|
||||
detail = str(status.get("message") or f"it exited without a word (see {log_path})")
|
||||
holder = await asyncio.to_thread(radio_holder) if kind == "busy" else None
|
||||
raise SpiRadioError(kind, explain(kind, detail, wiring, holder))
|
||||
node.port = int(status["port"])
|
||||
node.public_key = str(status.get("public_key") or "")
|
||||
_log.info("spi radio: node %s… ready on 127.0.0.1:%d", node.public_key[:16], node.port)
|
||||
return node
|
||||
|
||||
|
||||
# --- the device ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SpiRadioDevice(MeshCoreDevice):
|
||||
"""A companion connection to a node MeshTerm starts on the host's own radio.
|
||||
|
||||
Underneath it is exactly a network companion on ``127.0.0.1`` — every command, event
|
||||
and trace goes through :class:`MeshCoreDevice`'s TCP path unchanged — with the node's
|
||||
lifetime tied to the connection's: :meth:`connect` starts it, :meth:`disconnect` ends
|
||||
it, and a reconnect (which disconnects and builds a fresh device) starts a fresh one.
|
||||
"""
|
||||
|
||||
def __init__(self, wiring: SpiWiring, state: Path, *, node_name: str = "MeshTerm") -> None:
|
||||
"""Prepare the device; nothing is started until :meth:`connect`.
|
||||
|
||||
Args:
|
||||
wiring: How the radio is wired.
|
||||
state: The radio's state directory (see :func:`state_dir`).
|
||||
node_name: The name a brand-new node advertises until it is renamed.
|
||||
"""
|
||||
super().__init__(transport="tcp", host="127.0.0.1", tcp_port=0)
|
||||
self._wiring = wiring
|
||||
self._state = state
|
||||
self._node_name = node_name
|
||||
self._node: NodeProcess | None = None
|
||||
|
||||
@property
|
||||
def transport(self) -> str:
|
||||
"""``"spi"`` — the loopback socket underneath is an implementation detail."""
|
||||
return "spi"
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
"""The SPI device the radio is on, which is how the radio is named and remembered."""
|
||||
return self._wiring.spidev
|
||||
|
||||
@property
|
||||
def wiring(self) -> SpiWiring:
|
||||
"""How the radio this device drives is wired."""
|
||||
return self._wiring
|
||||
|
||||
async def connect(self) -> None: # noqa: D102 - inherited docstring
|
||||
if self._mc is not None:
|
||||
return
|
||||
if self._node is None or not self._node.alive:
|
||||
self._node = await start_node(self._wiring, self._state, node_name=self._node_name)
|
||||
self._tcp_port = self._node.port
|
||||
try:
|
||||
await super().connect()
|
||||
except BaseException:
|
||||
await self._stop_node()
|
||||
raise
|
||||
|
||||
async def link_present(self) -> bool: # noqa: D102 - inherited docstring
|
||||
if self._node is not None and not self._node.alive:
|
||||
return False # the node died, so the radio is gone whatever the socket says
|
||||
return await super().link_present()
|
||||
|
||||
async def disconnect(self) -> None: # noqa: D102 - inherited docstring
|
||||
try:
|
||||
await super().disconnect()
|
||||
finally:
|
||||
await self._stop_node()
|
||||
|
||||
async def _stop_node(self) -> None:
|
||||
"""End the node, if one is running, and forget it."""
|
||||
node, self._node = self._node, None
|
||||
if node is not None:
|
||||
await node.stop()
|
||||
|
||||
def _no_response_message(self) -> str:
|
||||
"""The node started but never answered as a companion: point at its log."""
|
||||
where = self._node.log_path if self._node is not None else self._state / "node.log"
|
||||
return f"the radio node on {self._wiring.spidev} did not answer; its log is {where}"
|
||||
@@ -0,0 +1,82 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The arithmetic a trace is sized and flagged by.
|
||||
|
||||
Two pure functions and the four constants behind them: how a per-hop path-hash width is
|
||||
encoded into the trace ``flags`` byte, and how long to wait for a trace's reply given the
|
||||
route it has to walk. Both are protocol arithmetic with no device, no persistence and no
|
||||
rendering, and :class:`~meshterm.core.connection.MeshCoreDevice` needs them on the send path —
|
||||
they live here so ``core`` never has to reach up into ``services`` for them. The trace
|
||||
*runner* (:mod:`meshterm.services.trace_runner`) re-exports both under their own names, which
|
||||
is where every other caller still reads them from.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def path_hash_flags(width_bytes: int) -> int | None:
|
||||
"""Return the trace ``flags`` value that encodes a per-hop path-hash width.
|
||||
|
||||
The trace subsystem encodes the hash size as ``1 << (flags & 3)`` on both the
|
||||
send and receive sides (``send_trace`` and the ``TRACE_DATA`` reader), so only
|
||||
widths of 1, 2, 4, or 8 bytes are representable. This is independent of, and
|
||||
differs from, the ``mode = size - 1`` encoding used for *contact routing*
|
||||
(``out_path_hash_mode``).
|
||||
|
||||
Args:
|
||||
width_bytes: Path-hash width in bytes.
|
||||
|
||||
Returns:
|
||||
The flags value (the exponent ``s``), or ``None`` if the width is not a
|
||||
representable power of two.
|
||||
"""
|
||||
for s in range(4):
|
||||
if (1 << s) == width_bytes:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
#: Fixed overhead in a trace's reply-wait budget, in seconds: our own transmit, the far
|
||||
#: endpoint's turnaround, and our receive-side decode — the cost that doesn't grow with
|
||||
#: the route.
|
||||
TRACE_TIMEOUT_BASE_S = 4.0
|
||||
#: Per-hop allowance added to the budget, in seconds. Each entry in the walked path is one
|
||||
#: relay transmission, and this covers its packet airtime, the repeater's processing, and
|
||||
#: the randomised transmit backoff MeshCore adds so relays don't collide.
|
||||
TRACE_TIMEOUT_PER_HOP_S = 1.6
|
||||
#: Upper bound on the reply-wait, in seconds, so a route that never comes home still
|
||||
#: surrenders the trace (and the session) in bounded time rather than scaling without end.
|
||||
TRACE_TIMEOUT_CEILING_S = 30.0
|
||||
#: Fallback budget, in seconds, when the hop count can't be known ahead of the send — a
|
||||
#: path-less flood to a contact we hold no route for. This is the historical flat value,
|
||||
#: kept for exactly the case where we can't size the walk.
|
||||
TRACE_TIMEOUT_FLOOD_S = 10.0
|
||||
|
||||
|
||||
def trace_timeout(hop_count: int) -> float:
|
||||
"""Return the reply-wait budget, in seconds, for a trace walking ``hop_count`` hops.
|
||||
|
||||
A trace is a *single* packet that has to travel the whole path — out to the far hop
|
||||
and back over the mirrored return leg — before its reply reaches us, so the wait has
|
||||
to grow with the route. A flat budget sized for a neighbour cuts a long walk off
|
||||
mid-flight: the packet completes the circuit on the mesh, but our wait has already
|
||||
expired, so we log a phantom "no reply" for a route that actually worked. The budget
|
||||
is a fixed base (:data:`TRACE_TIMEOUT_BASE_S`) plus a per-hop allowance
|
||||
(:data:`TRACE_TIMEOUT_PER_HOP_S`) for each relay transmission, capped at
|
||||
:data:`TRACE_TIMEOUT_CEILING_S`.
|
||||
|
||||
``hop_count`` is the number of hops in the *transmitted* path, which already includes
|
||||
the mirrored return leg (see
|
||||
:meth:`~meshterm.core.connection.MeshCoreDevice._trace_path_to_contact`), so it maps
|
||||
one-to-one onto relay transmissions and must not be doubled here.
|
||||
|
||||
Args:
|
||||
hop_count: Hops in the walked path (return leg included). ``0`` (or less) means the
|
||||
count is unknown — a path-less flood — and yields :data:`TRACE_TIMEOUT_FLOOD_S`.
|
||||
|
||||
Returns:
|
||||
The number of seconds to wait for the trace reply.
|
||||
"""
|
||||
if hop_count <= 0:
|
||||
return TRACE_TIMEOUT_FLOOD_S
|
||||
budget = TRACE_TIMEOUT_BASE_S + TRACE_TIMEOUT_PER_HOP_S * hop_count
|
||||
return min(budget, TRACE_TIMEOUT_CEILING_S)
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The shared transmit clock: how long ago we last put something on the air.
|
||||
|
||||
MeshTerm has always paced its own bursts — a trace waits between samples, a TX sweep
|
||||
@@ -62,6 +63,15 @@ class TransmitGate:
|
||||
if flood_advert:
|
||||
self._last_flood_advert = now
|
||||
|
||||
@property
|
||||
def last_sent(self) -> float | None:
|
||||
"""When anything last went out, on :func:`time.monotonic`'s clock; ``None`` if never.
|
||||
|
||||
For a listener that treats our own transmissions as breaking the air's silence
|
||||
(the weekly advert's wait for a quiet spell) rather than as a cooldown to wait out.
|
||||
"""
|
||||
return self._last_sent
|
||||
|
||||
def remaining(self, *, flood_advert: bool = False) -> float:
|
||||
"""Seconds a caller should wait before transmitting; ``0.0`` when it may go now.
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The transmit lock: what keeps a channel's scope from leaking onto someone else's flood.
|
||||
|
||||
The firmware has no per-channel scope. What it has is a *session* scope — one setting on
|
||||
the companion that every flood it sends goes out under, channel messages, flood DMs,
|
||||
adverts and requests alike (see :meth:`~meshterm.core.connection.Device.set_flood_scope`).
|
||||
A channel scope is therefore three commands, not one: set the session scope, send the
|
||||
channel message, set it back. Anything else the app transmits *between* the first and the
|
||||
third goes out under that channel's region too — a courier retry, a DM typed in another
|
||||
screen, the weekly advert — and would be relayed only by the repeaters carrying a region
|
||||
its sender never chose.
|
||||
|
||||
So every transmission MeshTerm asks of the companion holds this lock for the moment it
|
||||
takes to hand the frame over, and a scoped channel send holds it across all three commands.
|
||||
Nothing can be interleaved into the window, and the window is as short as three round
|
||||
trips; an acknowledgement *wait* is not a transmission and is done outside the lock, so a
|
||||
DM waiting eight seconds for its ack never holds a channel message up behind it.
|
||||
|
||||
The lock is **re-entrant per task**: the scoped send takes it, then calls the ordinary
|
||||
channel send, which takes it again on its own account — and must not wait for itself.
|
||||
|
||||
One leak is out of any client's reach: a DM *received* inside the window is acknowledged
|
||||
by the firmware on its own, and that ack is a flood under whatever the session scope is
|
||||
at that instant. The window being three round trips long is the whole of the mitigation,
|
||||
and it is what the official apps live with too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
class TransmitLock:
|
||||
"""A task-re-entrant :class:`asyncio.Lock` for "something is going on the air now".
|
||||
|
||||
Interact through :meth:`held`, an async context manager. A task already holding the
|
||||
lock enters again at once; any other task waits for the holder to leave.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Start unheld."""
|
||||
self._lock = asyncio.Lock()
|
||||
self._owner: asyncio.Task | None = None
|
||||
self._depth = 0
|
||||
|
||||
@property
|
||||
def locked(self) -> bool:
|
||||
"""Whether any task holds the lock right now."""
|
||||
return self._lock.locked()
|
||||
|
||||
def held_by_me(self) -> bool:
|
||||
"""Whether the calling task is the one holding the lock."""
|
||||
try:
|
||||
task = asyncio.current_task()
|
||||
except RuntimeError: # no running loop: nobody here holds anything
|
||||
return False
|
||||
return task is not None and self._owner is task
|
||||
|
||||
@asynccontextmanager
|
||||
async def held(self) -> AsyncIterator[None]:
|
||||
"""Hold the lock for the body; re-enter at once if this task already holds it."""
|
||||
if self.held_by_me():
|
||||
self._depth += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._depth -= 1
|
||||
return
|
||||
async with self._lock:
|
||||
self._owner = asyncio.current_task()
|
||||
self._depth = 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._owner = None
|
||||
self._depth = 0
|
||||
|
||||
|
||||
__all__ = ["TransmitLock"]
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for the Watchtower: watched nodes, their rules, and the alert log.
|
||||
|
||||
The Watchtower (see :mod:`meshterm.services.watchtower`) is a passive sentinel: the user
|
||||
@@ -22,6 +23,7 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from .models import utcnow
|
||||
|
||||
@@ -37,8 +39,9 @@ DEFAULT_SILENCE_HOURS = 12
|
||||
#: The stored value meaning "this rule is off".
|
||||
OFF = 0
|
||||
|
||||
#: How many alerts the log retains (oldest dropped first) — the code's default behind
|
||||
#: the ``watch_alerts_kept`` preference.
|
||||
#: How many alerts the log retains (oldest dropped first) — *the* default behind the
|
||||
#: ``watch_alerts_kept`` preference, which the registry names rather than re-types
|
||||
#: (:data:`meshterm.core.preferences.PREFERENCES`), so the two can never disagree.
|
||||
ALERT_CAP = 200
|
||||
|
||||
#: Minimum seconds between disk flushes for the packet-driven last-heard updates.
|
||||
@@ -71,6 +74,10 @@ class WatchedNode:
|
||||
silent_since: datetime | None = None
|
||||
node_type: int | None = None
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Alert:
|
||||
@@ -92,6 +99,10 @@ class Alert:
|
||||
message: str
|
||||
acked: bool = False
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _State:
|
||||
@@ -103,6 +114,10 @@ class _State:
|
||||
new_node_alerts: bool = True
|
||||
next_id: int = 1
|
||||
|
||||
#: Fields this record once held and must never hold again under another meaning (see
|
||||
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
||||
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
|
||||
class WatchStore:
|
||||
"""Reads and writes the Watchtower's state, memory-first."""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Private handles on the Windows DLLs, because ``ctypes.windll`` is shared with everyone.
|
||||
|
||||
``ctypes.windll.kernel32`` is a cache. Every caller in the process gets the *same*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Reopening the session in Windows Terminal, which is where MeshTerm looks like itself.
|
||||
|
||||
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether any
|
||||
@@ -29,10 +30,15 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
#: Set in the reopened session, so a relaunch can never chain into another one. Windows
|
||||
#: Terminal is not a classic console and so would not qualify anyway — this is the belt to
|
||||
#: that braces, and it costs one environment variable.
|
||||
REOPENED_ENV = "MESHTERM_REOPENED"
|
||||
from .relaunch import REOPENED_ENV, child_environment, own_command, wanted_size
|
||||
|
||||
__all__ = [
|
||||
"REOPENED_ENV",
|
||||
"available",
|
||||
"child_environment",
|
||||
"own_command",
|
||||
"reopen",
|
||||
]
|
||||
|
||||
|
||||
def available() -> str | None:
|
||||
@@ -46,60 +52,6 @@ def available() -> str | None:
|
||||
return shutil.which("wt.exe")
|
||||
|
||||
|
||||
def own_command() -> list[str]:
|
||||
"""The command that would start this same session again.
|
||||
|
||||
A frozen build is its own executable and takes the arguments as they were given. A
|
||||
development or ``pip`` install is reached through its interpreter, because the console
|
||||
script's own path is not something ``wt`` can be relied on to resolve.
|
||||
|
||||
Returns:
|
||||
The program and its arguments, ready to hand to Windows Terminal.
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return [sys.executable, *sys.argv[1:]]
|
||||
return [sys.executable, "-m", "meshterm", *sys.argv[1:]]
|
||||
|
||||
|
||||
def child_environment() -> dict[str, str]:
|
||||
"""The environment for a fresh launch of MeshTerm, minus this bundle's bookkeeping.
|
||||
|
||||
A PyInstaller one-file build runs in two stages: the executable unpacks itself into a
|
||||
temporary directory and then runs itself again as a child, the two halves coordinating
|
||||
through ``_PYI*`` environment variables. Those must not reach a *new* launch of the
|
||||
executable. The bootloader would find them, conclude it is the second stage of a launch
|
||||
it never made, check that its parent is the same program — find Windows Terminal
|
||||
instead — and abort::
|
||||
|
||||
[PYI-33368:ERROR] Security validation failure: parent process has different
|
||||
executable!
|
||||
|
||||
Which is the whole session gone, in a brand new window holding a message nobody can act
|
||||
on, while the window that offered the move has already closed. Removing them makes the
|
||||
new process a first stage, which is what it actually is.
|
||||
|
||||
Returns:
|
||||
The environment to start the new process with.
|
||||
"""
|
||||
environment = dict(os.environ)
|
||||
environment[REOPENED_ENV] = "1"
|
||||
if not getattr(sys, "frozen", False):
|
||||
return environment
|
||||
|
||||
for key in [key for key in environment if key.startswith("_PYI")]:
|
||||
del environment[key]
|
||||
environment.pop("_MEIPASS2", None) # the name older bootloaders used
|
||||
# The bootloader points the loader at the unpacked bundle and parks the caller's own
|
||||
# value beside it. The new process unpacks its own copy, so it wants the original.
|
||||
for name in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"):
|
||||
original = environment.pop(f"{name}_ORIG", None)
|
||||
if original is not None:
|
||||
environment[name] = original
|
||||
else:
|
||||
environment.pop(name, None)
|
||||
return environment
|
||||
|
||||
|
||||
def reopen() -> bool:
|
||||
"""Start this session again in Windows Terminal, in the same directory.
|
||||
|
||||
@@ -147,20 +99,12 @@ def reopen() -> bool:
|
||||
def _wanted_size() -> str:
|
||||
"""The window to ask Windows Terminal for, as ``cols,rows``.
|
||||
|
||||
Asked for, because a window MeshTerm opens should be one MeshTerm fits in. Without
|
||||
``-w new`` the session lands as a *tab* in whatever window happens to be open and
|
||||
inherits its size; without ``--size`` a new window takes the reader's global launch
|
||||
size, which is a setting about their shell and not about this app. Either way the app
|
||||
can arrive in a window too small for it, and the failure is quiet and cosmetic: the
|
||||
startup wordmark is 71 columns, so at 70 it silently swaps to the narrow one drawn for
|
||||
the PicoCalc, and a short window truncates every description onto the pager.
|
||||
|
||||
The numbers come from the platform's own stated minimum plus a margin, so they follow
|
||||
it rather than repeating it. Windows Terminal clamps to what the display can show, so
|
||||
this is a request and not a demand — on a small screen the app degrades exactly as it
|
||||
would have anyway.
|
||||
Asked for, because without ``-w new`` the session lands as a *tab* in whatever window
|
||||
happens to be open and inherits its size, and without ``--size`` a new window takes the
|
||||
reader's global launch size, which is a setting about their shell and not about this
|
||||
app. The dimensions themselves are
|
||||
:func:`~meshterm.core.relaunch.wanted_size`'s, shared with every other terminal
|
||||
MeshTerm reopens itself in; only the spelling is Windows Terminal's.
|
||||
"""
|
||||
from ..platforms import get_platform
|
||||
|
||||
platform = get_platform()
|
||||
return f"{platform.readable_cols + 8},{platform.readable_rows + 6}"
|
||||
cols, rows = wanted_size()
|
||||
return f"{cols},{rows}"
|
||||
@@ -1 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence layer: SQLite schema, repository, and structured logging."""
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""SQLite database bootstrap and schema.
|
||||
|
||||
The schema is intentionally generic: a central ``runs`` table records every tool
|
||||
@@ -10,7 +11,7 @@ from __future__ import annotations
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_VERSION = 15
|
||||
SCHEMA_VERSION = 17
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS schema_meta (
|
||||
@@ -139,7 +140,10 @@ CREATE TABLE IF NOT EXISTS observations (
|
||||
src TEXT, -- …the sender's key hash, or its whole key (anon request)
|
||||
tag TEXT, -- …the frame's own token: an ack's checksum, a trace's tag
|
||||
trace_snrs TEXT, -- TRACE rows: comma-separated per-hop SNR readings (dB)
|
||||
route TEXT -- 'packet' rows: FLOOD/DIRECT/… — what `path` MEANS here
|
||||
route TEXT, -- 'packet' rows: FLOOD/DIRECT/… — what `path` MEANS here
|
||||
transport_code TEXT, -- scoped (TC_*) rows: the 4-byte transport-codes field (hex)
|
||||
scope_body TEXT -- TC_FLOOD rows: payload type byte + payload (hex), the
|
||||
-- HMAC input a region name is resolved against
|
||||
);
|
||||
|
||||
-- One chat message, sent or received, on a channel or with a contact. Unlike the other
|
||||
@@ -157,7 +161,9 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
text TEXT NOT NULL,
|
||||
snr REAL,
|
||||
acked INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
scope TEXT -- outbound floods: the region it was sent under, '*' for
|
||||
-- unscoped, NULL where it was never known
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_traces_run ON traces(run_id);
|
||||
@@ -331,3 +337,29 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
# than assumed flooded; the raw headers they arrived in were never persisted, so this
|
||||
# cannot be filled in from history.
|
||||
conn.execute("ALTER TABLE observations ADD COLUMN route TEXT")
|
||||
if "transport_code" not in observation_cols:
|
||||
# v15 -> v16: a scoped flood's region. A TC_FLOOD frame carries a transport code —
|
||||
# an HMAC of its own payload keyed on the region it was sent into — and nothing
|
||||
# else names the region, so which one it was can only be answered by trying the
|
||||
# region names known (see meshterm.core.regions). Names arrive late: a repeater
|
||||
# tells us what it carries long after we overheard traffic scoped to it. So the
|
||||
# code is kept with the exact bytes it was computed over, and a row heard today
|
||||
# resolves against a name learned next week. The body is kept for TC_FLOOD rows
|
||||
# alone — a plain flood has no code, a direct frame is never region-filtered — so
|
||||
# the price is paid only by the traffic it explains. Older rows carry NULL: the
|
||||
# raw frames they arrived in were never persisted, so their scope is lost.
|
||||
conn.execute("ALTER TABLE observations ADD COLUMN transport_code TEXT")
|
||||
conn.execute("ALTER TABLE observations ADD COLUMN scope_body TEXT")
|
||||
if "scope" not in message_cols:
|
||||
# v16 -> v17: the scope a message we sent went out under. A channel can be given a
|
||||
# region its messages are flooded into (a scope is a *setting on the companion*,
|
||||
# made just before the send — see Device.send_channel_in_scope), and the scope can
|
||||
# change between one message and the next, or be dropped for one resend. So the
|
||||
# transcript cannot work out afterwards what a message went out under from what the
|
||||
# channel's scope is *now*; it is kept with the message, the moment it is sent.
|
||||
# Its first reader is the message paths dialog: a scoped message nothing relayed is
|
||||
# most often one no repeater in earshot carries the region of, and saying so needs
|
||||
# the region. Stored as the bare name, '*' for a flood sent unscoped, and NULL for
|
||||
# anything else — inbound messages, direct messages, and every row sent before this
|
||||
# column existed, whose scope was never known and cannot be recovered.
|
||||
conn.execute("ALTER TABLE messages ADD COLUMN scope TEXT")
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Logging setup: a plain text file you can open, and a console that stays out of the way.
|
||||
|
||||
Two sinks. The console handler is Rich-formatted and shows what a person watching the
|
||||
@@ -61,7 +62,7 @@ def level_from_name(name: str, *, fallback: int = logging.WARNING) -> int:
|
||||
|
||||
Args:
|
||||
name: A level name, in any case.
|
||||
fallback: Used when the name is unrecognised — a hand-edited ``preferences.yaml``
|
||||
fallback: Used when the name is unrecognised — a hand-edited ``preferences.toml``
|
||||
with a typo in it should lose some log detail, not stop the app from starting.
|
||||
|
||||
Returns:
|
||||
@@ -97,12 +98,18 @@ def configure_logging(
|
||||
"""
|
||||
logger = logging.getLogger(_LOGGER_NAME)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.handlers.clear()
|
||||
_drop_handlers(logger)
|
||||
logger.propagate = False
|
||||
|
||||
if not quiet:
|
||||
# markup=False: a log line carries whatever the device said — a node name, a
|
||||
# firmware error — and Rich would read `[...]` in it as a style tag. That made the
|
||||
# *error path* the one most likely to fail: reporting a fault on a node called
|
||||
# `[/]Bob` raised MarkupError from inside the handler, replacing the diagnostic
|
||||
# with a traceback about the diagnostic. No log call in the package marks up its
|
||||
# message, so parsing them buys nothing.
|
||||
rich_handler = RichHandler(
|
||||
console=console, rich_tracebacks=True, show_path=False, markup=True
|
||||
console=console, rich_tracebacks=True, show_path=False, markup=False
|
||||
)
|
||||
rich_handler.setLevel(level)
|
||||
logger.addHandler(rich_handler)
|
||||
@@ -155,12 +162,29 @@ def _quiet_library_console(file_handler: logging.Handler) -> None:
|
||||
|
||||
for name in _LIBRARY_LOGGERS:
|
||||
lib = logging.getLogger(name)
|
||||
lib.handlers.clear()
|
||||
_drop_handlers(lib)
|
||||
lib.setLevel(logging.INFO)
|
||||
lib.propagate = False
|
||||
lib.addHandler(file_handler)
|
||||
|
||||
|
||||
def _drop_handlers(logger: logging.Logger) -> None:
|
||||
"""Detach ``logger``'s handlers, closing each on the way out.
|
||||
|
||||
Clearing the list alone drops the *reference* and leaves the file open: every
|
||||
reconfigure — a reconnect in the app, each in-process CLI invocation under test — leaked
|
||||
another descriptor on ``meshterm.log``, which on Windows is also a lock on a file the
|
||||
rotation then wants to rename. A handler that refuses to close is dropped anyway: the
|
||||
point of this call is that the old handlers stop receiving records.
|
||||
"""
|
||||
for handler in list(logger.handlers):
|
||||
try:
|
||||
handler.close()
|
||||
except Exception: # noqa: BLE001 - a handler we are discarding cannot fail the app
|
||||
pass
|
||||
logger.handlers.clear()
|
||||
|
||||
|
||||
def get_logger(name: str | None = None) -> logging.Logger:
|
||||
"""Return a child of the application logger.
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Repository: the single gateway between the app and the database.
|
||||
|
||||
Tools and services never issue SQL directly; they call typed methods here. This keeps
|
||||
@@ -28,6 +29,7 @@ from ..core.models import (
|
||||
TxLevelResult,
|
||||
utcnow,
|
||||
)
|
||||
from ..core.regions import SCOPED_ROUTES, raw_scope_body
|
||||
from . import db
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -95,6 +97,8 @@ def _packet_raw(row: sqlite3.Row) -> dict | None:
|
||||
tag = _row_value(row, "tag")
|
||||
trace_snrs = _row_value(row, "trace_snrs")
|
||||
route = _row_value(row, "route")
|
||||
transport_code = _row_value(row, "transport_code")
|
||||
scope_body = _row_value(row, "scope_body")
|
||||
if not any((typename, chan_hash, cipher_mac, dest, src, tag, trace_snrs, route)):
|
||||
return None
|
||||
raw: dict = {}
|
||||
@@ -120,6 +124,12 @@ def _packet_raw(row: sqlite3.Row) -> dict | None:
|
||||
raw["trace_snrs"] = [float(v) for v in str(trace_snrs).split(",") if v]
|
||||
if route:
|
||||
raw["route_typename"] = route
|
||||
if transport_code:
|
||||
# A scoped frame's code and the bytes it was computed over, so its region resolves
|
||||
# against names learned after it was heard (see meshterm.core.regions).
|
||||
raw["transport_code"] = transport_code
|
||||
if scope_body:
|
||||
raw["scope_body"] = scope_body
|
||||
return raw
|
||||
|
||||
|
||||
@@ -1023,12 +1033,18 @@ class Repository:
|
||||
trace_snrs = ",".join(f"{v:g}" for v in readings) if readings else None
|
||||
# What the frame's `path` means — see the v15 migration. Only a packet row has one.
|
||||
route = raw.get("route_typename") if obs.kind == "packet" else None
|
||||
# A scoped frame's transport code, and — for a scoped flood — the bytes it was
|
||||
# computed over, so its region can be resolved against a name learned later (see
|
||||
# the v16 migration). Nothing is kept for a frame that carries no code.
|
||||
transport_code = raw.get("transport_code") if route in SCOPED_ROUTES else None
|
||||
body = raw_scope_body(raw) if route == "TC_FLOOD" and transport_code else None
|
||||
scope_body = body.hex() if body is not None else None
|
||||
self._conn.execute(
|
||||
"INSERT INTO observations "
|
||||
"(run_id, node, public_key, name, kind, node_type, snr, rssi, lat, lon, path, "
|
||||
"observed_at, chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, "
|
||||
"trace_snrs, route) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"trace_snrs, route, transport_code, scope_body) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
run_id,
|
||||
obs.node,
|
||||
@@ -1051,6 +1067,8 @@ class Repository:
|
||||
tag,
|
||||
trace_snrs,
|
||||
route,
|
||||
transport_code,
|
||||
scope_body,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
@@ -1067,6 +1085,69 @@ class Repository:
|
||||
row = self._conn.execute("SELECT COUNT(*) AS n FROM observations").fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
def table_counts(self) -> dict[str, int]:
|
||||
"""Return a row count for every table in the database, by name.
|
||||
|
||||
Read off ``sqlite_master`` rather than a list written out here, which is the whole
|
||||
point: a table added to the schema starts being reported the day it lands, and a
|
||||
table removed stops, with nothing to keep in step. The one thing this must never
|
||||
become is a curated set of the counts somebody thought were interesting — the
|
||||
shape of a database is the aggregate, and the interesting one is always the table
|
||||
nobody expected to be full.
|
||||
|
||||
Every value is a *count*. Nothing here reads a row, so the result says how much
|
||||
history a report's author has without saying a word about who they talk to.
|
||||
|
||||
Returns:
|
||||
Table name to row count, alphabetically, SQLite's own internal tables
|
||||
excluded (they describe the file format, not this app's data).
|
||||
"""
|
||||
names = [
|
||||
str(row["name"])
|
||||
for row in self._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||||
"AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
).fetchall()
|
||||
]
|
||||
counts: dict[str, int] = {}
|
||||
for name in names:
|
||||
# The names come from sqlite_master, so they are this database's own tables
|
||||
# and not caller input; quoted anyway, because an identifier cannot be bound.
|
||||
row = self._conn.execute(f'SELECT COUNT(*) AS n FROM "{name}"').fetchone()
|
||||
counts[name] = int(row["n"]) if row else 0
|
||||
return counts
|
||||
|
||||
def failed_run_count(self) -> int:
|
||||
"""Return how many recorded tool runs ended in an error.
|
||||
|
||||
The single most useful number a reporter can hand over: it says whether this
|
||||
install has been failing quietly for weeks or broke for the first time today,
|
||||
and it costs one query rather than an interview.
|
||||
|
||||
Returns:
|
||||
The number of ``runs`` rows whose status is ``error``.
|
||||
"""
|
||||
row = self._conn.execute("SELECT COUNT(*) AS n FROM runs WHERE status = 'error'").fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
def observation_span(self) -> tuple[datetime | None, datetime | None]:
|
||||
"""Return the first and last times anything was overheard.
|
||||
|
||||
How much mesh this database has actually seen. A report whose span is an hour and
|
||||
a report whose span is a year describe different installs, and the difference
|
||||
explains a class of "it works here" before anyone else has to ask for it.
|
||||
|
||||
Returns:
|
||||
``(first, last)`` as aware UTC times, or ``(None, None)`` when nothing has
|
||||
ever been heard.
|
||||
"""
|
||||
row = self._conn.execute(
|
||||
"SELECT MIN(observed_at) AS first, MAX(observed_at) AS last FROM observations"
|
||||
).fetchone()
|
||||
if row is None or row["first"] is None:
|
||||
return None, None
|
||||
return _as_when(row["first"]), _as_when(row["last"])
|
||||
|
||||
def recent_observations(self, *, since: datetime, limit: int = 4000) -> list[Observation]:
|
||||
"""Return raw stored observations in a recent window, oldest first.
|
||||
|
||||
@@ -1086,7 +1167,8 @@ class Repository:
|
||||
"""
|
||||
rows = self._conn.execute(
|
||||
"SELECT node, name, kind, node_type, snr, rssi, lat, lon, path, observed_at, "
|
||||
"chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, trace_snrs, route "
|
||||
"chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, trace_snrs, route, "
|
||||
"transport_code, scope_body "
|
||||
"FROM observations WHERE observed_at >= ? ORDER BY observed_at DESC LIMIT ?",
|
||||
(since.isoformat(), limit),
|
||||
).fetchall()
|
||||
@@ -1112,7 +1194,8 @@ class Repository:
|
||||
"""
|
||||
rows = self._conn.execute(
|
||||
"SELECT node, name, kind, node_type, snr, rssi, lat, lon, path, observed_at, "
|
||||
"chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, trace_snrs, route "
|
||||
"chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, trace_snrs, route, "
|
||||
"transport_code, scope_body "
|
||||
"FROM observations WHERE kind = 'packet' AND observed_at >= ? "
|
||||
"AND observed_at <= ? ORDER BY observed_at ASC LIMIT ?",
|
||||
(start.isoformat(), end.isoformat(), limit),
|
||||
@@ -1318,6 +1401,44 @@ class Repository:
|
||||
continue # a malformed stray timestamp simply isn't counted
|
||||
return counts
|
||||
|
||||
def flood_frames(self, *, since: datetime | None = None) -> list[tuple[datetime, dict]]:
|
||||
"""Every stored flood frame, oldest first, with what its scope is read from.
|
||||
|
||||
The Time Machine's scope views: a scoped frame's region is named by recomputing its
|
||||
transport code under each known region's key, which no SQL can do, so the frames
|
||||
come back as the raw mapping :meth:`~meshterm.core.region_store.RegionStore.scope_of`
|
||||
reads (route, transport code, the bytes the code was computed over) for the caller
|
||||
to resolve. Only floods: a direct frame has no scope, and a frame stored before its
|
||||
route was kept cannot say which it was.
|
||||
|
||||
Args:
|
||||
since: Only frames at or after this time, if given.
|
||||
|
||||
Returns:
|
||||
``(observed_at, raw)`` per flood frame, oldest first.
|
||||
"""
|
||||
sql = (
|
||||
"SELECT observed_at, route, transport_code, scope_body FROM observations "
|
||||
"WHERE kind = 'packet' AND upper(route) IN ('FLOOD', 'TC_FLOOD')"
|
||||
)
|
||||
params: list[Any] = []
|
||||
if since is not None:
|
||||
sql += " AND observed_at >= ?"
|
||||
params.append(since.isoformat())
|
||||
sql += " ORDER BY observed_at"
|
||||
frames: list[tuple[datetime, dict]] = []
|
||||
for row in self._conn.execute(sql, params):
|
||||
when = _as_when(row["observed_at"])
|
||||
if when is None:
|
||||
continue # a malformed stray simply isn't counted
|
||||
raw = {"route_typename": row["route"]}
|
||||
if row["transport_code"]:
|
||||
raw["transport_code"] = row["transport_code"]
|
||||
if row["scope_body"]:
|
||||
raw["scope_body"] = row["scope_body"]
|
||||
frames.append((when, raw))
|
||||
return frames
|
||||
|
||||
def self_transmissions(self, *, since: datetime | None = None) -> list[datetime]:
|
||||
"""Timestamps of everything this station put on the air, oldest first.
|
||||
|
||||
@@ -1838,7 +1959,7 @@ class Repository:
|
||||
cur = self._conn.execute(
|
||||
"INSERT INTO messages "
|
||||
"(run_id, outbound, is_channel, channel_id, channel_idx, peer, peer_name, text, "
|
||||
"snr, acked, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"snr, acked, created_at, scope) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
run_id,
|
||||
int(msg.outbound),
|
||||
@@ -1851,6 +1972,7 @@ class Repository:
|
||||
msg.snr,
|
||||
None if msg.acked is None else int(msg.acked),
|
||||
msg.created_at.isoformat(),
|
||||
msg.scope,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
@@ -2144,4 +2266,5 @@ class Repository:
|
||||
acked=None if row["acked"] is None else bool(row["acked"]),
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
row_id=row["id"],
|
||||
scope=row["scope"] if "scope" in row.keys() else None,
|
||||
)
|
||||
Loaded 100 of 337 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user