Compare commits
No files matched your search
@@ -1,107 +0,0 @@
|
||||
---
|
||||
name: add-narrow-emoji
|
||||
description: Fix a MeshTerm row whose layout an emoji breaks — a right border pulled a column short, or a glyph overrunning its lane — because the terminal draws it in a different number of cells than Rich/prompt_toolkit measure. Works in both directions: a glyph drawn narrower than measured joins the narrow allowlist, one drawn wider joins the wide set. Classifies the emoji (lone codepoint, country flag, VS16 sequence, or ZWJ/other cluster), says whether it is already listed, and adds it the correct way. Use whenever an emoji "fucks up the layout", notches a panel border, or smears a row.
|
||||
---
|
||||
|
||||
# Adding an emoji to MeshTerm's terminal-aligned width sets
|
||||
|
||||
MeshTerm aligns Rich's and prompt_toolkit's glyph widths with how the terminal
|
||||
*actually draws* an emoji, so a row frames flush instead of notching its right border a
|
||||
column short or overrunning its lane. The machinery lives in
|
||||
[emoji_width.py](../../..//meshterm/ui/tui/emoji_width.py); the design is in that module's
|
||||
docstring and in the `glyph-width-alignment` memory. Background: the terminal's
|
||||
narrow/wide split is **per-glyph and font-driven** — there is no rule and no way to probe
|
||||
it (a cursor probe reads the PTY, not the renderer) — so the exceptions are two
|
||||
hand-curated sets, extended one confirmed glyph at a time. This skill does that one
|
||||
addition correctly.
|
||||
|
||||
## When to run
|
||||
|
||||
An emoji in a row breaks its layout, in either of two ways:
|
||||
|
||||
- **Border pulled in** (a blank sits between the border and the frame edge), or a smear to
|
||||
the right of the glyph — the terminal draws it in *fewer* cells than the authorities
|
||||
reserve. → the narrow allowlist (`_DEFAULT_NARROW_LONE`).
|
||||
- **The row overruns**, pushing what follows a column right — the terminal draws it in
|
||||
*more* cells than the authorities reserve. → the wide set (`_DEFAULT_WIDE_BASE`). In
|
||||
practice this only happens to a **VS16 sequence** the narrow verdict got wrong; a *bare*
|
||||
codepoint both authorities call one cell is measured correctly and must be left alone.
|
||||
|
||||
Step 1 tells you which; it also tells you when the glyph is **already listed**, which is
|
||||
the commonest answer to "please add this one".
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Classify the emoji.** Run, from the repo root:
|
||||
|
||||
The interpreter below is `$DEV_VENV_PYTHON` from `.dev.env` at the repo root (gitignored;
|
||||
`.dev.env.example` shows the shape) — read it first.
|
||||
|
||||
```
|
||||
$DEV_VENV_PYTHON .claude/skills/add-narrow-emoji/classify.py <emoji>
|
||||
```
|
||||
|
||||
It prints the codepoints, how each authority measures it now (`Rich now`, `pt now`)
|
||||
and after the fix (`Rich fixed`, `pt fixed`), the class, and the action. `pt now` is
|
||||
the number that matters — prompt_toolkit places the panel border.
|
||||
|
||||
2. **Act on the class.** The machinery runs in *both* directions — a glyph drawn narrower
|
||||
than measured joins `_DEFAULT_NARROW_LONE`, one drawn wider joins `_DEFAULT_WIDE_BASE`
|
||||
— and the classifier names which, including "already listed", the commonest answer:
|
||||
- **already listed** (either set): nothing to add. If a row still misaligns, some
|
||||
*other* glyph on it is the culprit — classify that one.
|
||||
- **lone wide codepoint** (e.g. `👋`, `👍`): the narrow-allowlist case. Only add it **if
|
||||
the terminal draws it in one cell** — confirm visually first. Append the character to
|
||||
`_DEFAULT_NARROW_LONE` in [emoji_width.py](../../..//meshterm/ui/tui/emoji_width.py):
|
||||
`_DEFAULT_NARROW_LONE = "👋👍"`. (For a throwaway session test instead of a code
|
||||
change, set `MESHTERM_NARROW_EMOJI="👋👍"` in the environment.)
|
||||
- **lone narrow codepoint, bare** (e.g. `🛣` U+1F6E3, `🕸` U+1F578): **no change**, and
|
||||
resist the pull to "fix" it. Both authorities say one because the codepoint is outside
|
||||
Emoji_Presentation, and with no VS16 asking for emoji presentation the font draws a
|
||||
one-cell *text* glyph — they are right. Forcing it into `_DEFAULT_WIDE_BASE` reserves a
|
||||
cell the terminal never draws and pulls the row's border a column **in**; that is
|
||||
exactly what listing `🛣` did to the Trophy case, while `🕸` — same class, one board
|
||||
down, never listed — framed flush throughout. Both authorities already agreeing is not
|
||||
a bug to correct.
|
||||
- **VS16 emoji-presentation sequence** (e.g. `☀️`): **handled by default** (the variation
|
||||
selector is skipped, so the base is measured alone). But the renderer is not uniform —
|
||||
if *this* one paints two cells wide, add its **base** codepoint to `_DEFAULT_WIDE_BASE`,
|
||||
as `🛩️` (U+1F6E9) needed. Keying on the base makes the bare form ride along.
|
||||
- **flag (Regional Indicator pair)** (e.g. `🇨🇦`): **already handled** as a whole
|
||||
category — no entry. Confirm `pt fixed` is 2 and stop. Never list an individual flag:
|
||||
indicators are shared, so it would half-fix that flag's neighbours and skip the rest.
|
||||
- **ZWJ sequence / keycap / skin-tone / other cluster** (e.g. `👨👩👧`, `1️⃣`, `👋🏽`):
|
||||
**not covered** by the lone/flag mechanism — resizing its parts individually would
|
||||
misalign it. Do not force it into either set; note it for bespoke handling.
|
||||
|
||||
3. **Extend the tests.** In [tests/test_emoji_width.py](../../..//tests/test_emoji_width.py),
|
||||
add the new glyph to `test_rich_cell_len_narrows_only_allowlisted_lone_emoji` and
|
||||
`test_pt_cache_narrows_only_allowlisted_lone_emoji` (both authorities → one cell for a
|
||||
narrow addition, two for a wide one), and to the width-1 calibrate test. Keep a
|
||||
known-wide emoji (`📡`) asserted at two, so a future over-broad change is caught.
|
||||
|
||||
4. **Run the tests:**
|
||||
|
||||
```
|
||||
$DEV_VENV_PYTHON -m pytest tests/test_emoji_width.py -q
|
||||
```
|
||||
|
||||
5. **Confirm visually.** The fix can't be reproduced headlessly — a non-interactive run
|
||||
keeps Rich's defaults, and CI can't see the renderer. Ask the person on the affected
|
||||
terminal to send a chat message with the glyph and confirm the border is now flush and
|
||||
nothing shifted. Only then is the addition trustworthy.
|
||||
|
||||
6. **Commit** with a message naming the glyph and why (see the repo's commit style).
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Never narrow a glyph the terminal draws two wide** (a menu icon like `📡`, `💬`), and
|
||||
never widen one it draws in one cell. Each set only helps glyphs mismeasured in its own
|
||||
direction; a wrong entry *introduces* the misalignment it was meant to remove. When
|
||||
unsure, leave it out.
|
||||
- **One glyph, confirmed, at a time.** An unlisted emoji is no worse than before (the
|
||||
`erase_down` full-repaint still prevents a narrow one's smear); a wrongly-listed one
|
||||
breaks a border. Bias to omission.
|
||||
- **Do not touch Rich/prompt_toolkit width globally.** A blanket emoji remap has been tried
|
||||
and reverted twice — it wrecks the menu icons the terminal genuinely draws two wide. The
|
||||
curated allowlist and the flag category are the only sanctioned levers.
|
||||
@@ -1,141 +0,0 @@
|
||||
r"""Diagnose one emoji for the MeshTerm terminal-aligned width machinery.
|
||||
|
||||
Given an emoji on the command line, report its codepoints and how the three width
|
||||
authorities measure it (wcwidth, Rich, prompt_toolkit), then classify it and print the
|
||||
exact action the `add-narrow-emoji` skill should take. Run from the repo root:
|
||||
|
||||
d:\\vibe\\MeshTerm\\.venv\\Scripts\\python.exe .claude\\skills\\add-narrow-emoji\\classify.py 👋
|
||||
|
||||
The point is to remove the guesswork the skill's judgement call depends on: a lone wide
|
||||
codepoint gets an allowlist entry, a flag is already handled by category, and a ZWJ /
|
||||
keycap / modifier cluster is *not* covered and must not be shoehorned in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
# The chat border fix only ever engages on a terminal that draws emoji narrow; measure
|
||||
# there or trust the maintainer's eyes. This script just classifies — it never patches.
|
||||
sys.stdout.reconfigure(encoding="utf-8") # print emoji safely on a cp1252 console
|
||||
# Run from anywhere: put the repo root (four levels up) on the path so `meshterm` imports.
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3]))
|
||||
|
||||
import rich.cells as rc # noqa: E402
|
||||
from prompt_toolkit.utils import get_cwidth # noqa: E402
|
||||
from wcwidth import wcwidth # noqa: E402
|
||||
|
||||
from meshterm.ui.tui.emoji_width import ( # noqa: E402
|
||||
_DEFAULT_NARROW_LONE,
|
||||
_DEFAULT_WIDE_BASE,
|
||||
_is_regional_indicator,
|
||||
_make_cell_len,
|
||||
_make_pt_cache,
|
||||
)
|
||||
|
||||
_ZWJ = ""
|
||||
_VS16 = "️"
|
||||
|
||||
_NARROW = frozenset(_DEFAULT_NARROW_LONE)
|
||||
_WIDE = frozenset(_DEFAULT_WIDE_BASE) - frozenset((_ZWJ, _VS16))
|
||||
|
||||
|
||||
def _base(emoji: str) -> str:
|
||||
"""The emoji's base codepoint — what the wide set is keyed on (selectors dropped)."""
|
||||
others = [c for c in emoji if c not in (_VS16, _ZWJ)]
|
||||
return others[0] if len(others) == 1 else ""
|
||||
|
||||
|
||||
def _classify(emoji: str) -> tuple[str, str]:
|
||||
"""Return ``(label, action)`` for ``emoji`` — what it is and what the skill should do."""
|
||||
if not emoji:
|
||||
return "empty", "Nothing to classify — pass an emoji as the argument."
|
||||
|
||||
# Already curated? Say so first — it is the commonest answer to "please add this one",
|
||||
# and every branch below would otherwise re-derive advice for a decision already made.
|
||||
base = _base(emoji)
|
||||
if base and base in _WIDE:
|
||||
return (
|
||||
"lone codepoint drawn WIDE — already listed",
|
||||
"ALREADY IN _DEFAULT_WIDE_BASE (both authorities measure it 2, bare and with its "
|
||||
"VS16 selector). Nothing to add. If a row still misaligns, the culprit is another "
|
||||
"glyph on it — classify that one instead.",
|
||||
)
|
||||
if len(emoji) == 1 and emoji in _NARROW:
|
||||
return (
|
||||
"lone codepoint drawn NARROW — already listed",
|
||||
"ALREADY IN _DEFAULT_NARROW_LONE (both authorities measure it 1). Nothing to add.",
|
||||
)
|
||||
|
||||
if len(emoji) == 2 and all(_is_regional_indicator(c) for c in emoji):
|
||||
return (
|
||||
"flag (Regional Indicator pair)",
|
||||
"ALREADY HANDLED as a category — no allowlist entry. Verify pt width is 2 below; "
|
||||
"if so there is nothing to add. Never list a single flag (it half-fixes its "
|
||||
"neighbours that share an indicator).",
|
||||
)
|
||||
if _ZWJ in emoji:
|
||||
return (
|
||||
"ZWJ sequence (multi-glyph cluster)",
|
||||
"NOT covered by the lone/flag mechanism (a family, a profession, a flag-with-tag). "
|
||||
"Do not add it to either set — the parts would be resized individually and "
|
||||
"misalign. Flag this to a maintainer for bespoke handling.",
|
||||
)
|
||||
if len(emoji) == 1:
|
||||
if wcwidth(emoji) == 2:
|
||||
return (
|
||||
"lone wide codepoint",
|
||||
"IF your terminal draws it in ONE cell, add it to _DEFAULT_NARROW_LONE in "
|
||||
"meshterm/ui/tui/emoji_width.py (or MESHTERM_NARROW_EMOJI for a session test). "
|
||||
"If your terminal draws it TWO wide, leave it — narrowing would break its border.",
|
||||
)
|
||||
return (
|
||||
"lone narrow codepoint (bare, text presentation)",
|
||||
"NO CHANGE — and resist the pull to 'fix' it. Both authorities measure it one "
|
||||
"because it sits outside Emoji_Presentation, and with no VS16 asking for emoji "
|
||||
"presentation the font draws a one-cell text glyph: they are right. Forcing it "
|
||||
"to two reserves a cell the terminal never draws and pulls the row's border a "
|
||||
"column IN — the mistake U+1F6E3 🛣 cost the Trophy case. If this row really is "
|
||||
"misaligned, another glyph on it is the culprit; classify that one.",
|
||||
)
|
||||
if base and _VS16 in emoji:
|
||||
return (
|
||||
"VS16 emoji-presentation sequence",
|
||||
"HANDLED BY DEFAULT (the variation selector is skipped, so the base is measured "
|
||||
"alone and the narrow-VS16 verdict applies). If your terminal draws THIS one two "
|
||||
"cells wide anyway — the renderer is not uniform — add its BASE codepoint to "
|
||||
"_DEFAULT_WIDE_BASE (or MESHTERM_WIDE_EMOJI), as U+1F6E9 🛩️ needed.",
|
||||
)
|
||||
return (
|
||||
"multi-codepoint cluster (keycap / skin-tone / other)",
|
||||
"NOT a plain lone codepoint — the lone/flag mechanism does not cover it. Flag this to a "
|
||||
"maintainer rather than adding parts to either set.",
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2:
|
||||
print("usage: classify.py <emoji>")
|
||||
return 2
|
||||
emoji = argv[1]
|
||||
label, action = _classify(emoji)
|
||||
|
||||
print(f"emoji : {emoji}")
|
||||
print("codepoints : " + " ".join(f"U+{ord(c):04X}" for c in emoji))
|
||||
print(f"grapheme : {len(emoji)} codepoint(s)")
|
||||
print(f"wcwidth : {sum(max(0, wcwidth(c)) for c in emoji)} (per char: "
|
||||
+ ", ".join(str(wcwidth(c)) for c in emoji) + ")")
|
||||
print(f"Rich now : {rc.cell_len(emoji)}")
|
||||
print(f"pt now : {get_cwidth(emoji)} <- the authority that places the panel border")
|
||||
# "fixed" = what the width-1 calibration path already produces today, both sets applied.
|
||||
print(f"Rich fixed : {_make_cell_len(_NARROW, _WIDE)(emoji)}")
|
||||
print(f"pt fixed : {_make_pt_cache(_NARROW, _WIDE)[emoji]}")
|
||||
print()
|
||||
print(f"class : {label}")
|
||||
print(f"action : {action}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
@@ -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())
|
||||
@@ -96,6 +96,24 @@ version is written**. `[project] version` in `pyproject.toml` is `dynamic` and h
|
||||
reads the attribute, so the number in a built wheel cannot drift from the number in the
|
||||
source. Do not add a second one.
|
||||
|
||||
### Then re-point the README's download commands
|
||||
|
||||
`installers.yml` names each asset `meshterm-${version}-<label><ext>`, so **the file a reader
|
||||
downloads is named after the release** — and the README tells them what to type at it. Those
|
||||
lines are prose, not code, so nothing rewrites them for you:
|
||||
|
||||
```
|
||||
grep -n '<the previous version>' README.md
|
||||
```
|
||||
|
||||
Every hit is a download or run command (`.\meshterm-X.Y.Z-windows-x64.exe`,
|
||||
`chmod +x meshterm-X.Y.Z-*`, the `xattr` quarantine note, the `MESHTERM_HOME` trial runs).
|
||||
Replace the old number with the one just bumped to, and re-read each line: they must name
|
||||
the file the workflow is about to publish, or the first thing a new user does fails.
|
||||
|
||||
Also check `docs/cli.md` for a sample document carrying a `"version"` field, which is the
|
||||
live `{version}` placeholder and must show the new number too.
|
||||
|
||||
## 5. Commit, tag, push
|
||||
|
||||
```
|
||||
@@ -121,9 +139,23 @@ gh run watch --exit-status
|
||||
gh release view vX.Y.Z
|
||||
```
|
||||
|
||||
Five platform builds plus the release job, so it takes a few minutes. Report the release
|
||||
URL and whether the notes came out of the changelog or fell back to "No changelog entry".
|
||||
If a build fails, the tag is already public: fix forward with a new patch version rather
|
||||
Five platform builds, the release job, and `website` — which rebuilds meshterm.net from the
|
||||
tag so the site shows the new version — so it takes a few minutes. If only `website` fails,
|
||||
the release itself is fine (it runs after publishing); the usual cause is the
|
||||
`SITE_DEPLOY_KEY` secret, and once that is fixed `gh run rerun <run-id> --failed` finishes
|
||||
it. **Check the notes
|
||||
actually came out of the changelog** — a green run is not proof, because the fallback is a
|
||||
successful step:
|
||||
|
||||
```
|
||||
gh release view vX.Y.Z --json body --jq .body | head -3
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## At 0.9.0 the changelog resets
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
buy_me_a_coffee: johnputer
|
||||
custom:
|
||||
- "https://www.patreon.com/c/MeshTerm"
|
||||
- "https://tinyurl.com/SupportMeshTerm"
|
||||
- "https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6"
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
## Before you mark it ready
|
||||
|
||||
- [ ] I've read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) and agree its licensing terms apply to this contribution
|
||||
- [ ] `python -m pytest -q` passes, including the dual-platform gallery gate
|
||||
- [ ] `ruff check .` and `ruff format --check .` are clean
|
||||
- [ ] New screens, rows and hints go through the helpers in `ui/menus.py`, and follow the
|
||||
|
||||
@@ -18,7 +18,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
# A second push while the first is still running makes the first answer obsolete.
|
||||
# Cancel it — on a private repo those are metered minutes spent on a stale commit.
|
||||
# Cancel it — a run on a stale commit is a queue slot and a wait spent on nothing.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -54,13 +54,11 @@ jobs:
|
||||
# and one red leg cancelling its siblings hides the rest until the next push.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Shaped for a *private* repo, where minutes are metered and billed by a
|
||||
# multiplier: Linux 1x, Windows 2x, macOS 10x. So Linux carries the full
|
||||
# Python range and the costly runners cover the ends of it, which is where a
|
||||
# version-dependent break actually shows up.
|
||||
#
|
||||
# Once the repo is public, standard runners are unmetered: widen this to every
|
||||
# OS times every version and stop thinking about it.
|
||||
# Linux carries the full Python range and the slower-to-schedule runners
|
||||
# cover the ends of it, which is where a version-dependent break actually
|
||||
# shows up. It was shaped this way when the repository was private and
|
||||
# minutes were metered; on a public one they are not, so this can widen to
|
||||
# every OS times every version whenever the wait for a full run is worth it.
|
||||
include:
|
||||
- { os: ubuntu-latest, python: "3.10" }
|
||||
- { os: ubuntu-latest, python: "3.11" }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Puts the downloadable builds on the repository's Releases page.
|
||||
#
|
||||
# Nothing here leaves GitHub. While the repository is private the release is private too,
|
||||
# visible and downloadable only by people who can see the repo. This is *not* the PyPI
|
||||
# publish — that is `release.yml`, it is a separate act, and it does not run from here.
|
||||
# Nothing here leaves GitHub: a release is exactly as visible as the repository it sits
|
||||
# on. This is *not* the PyPI publish — that is `release.yml`, it is a separate act, and it
|
||||
# does not run from here.
|
||||
#
|
||||
# Triggered by pushing a version tag, which is the moment a release exists in git anyway:
|
||||
#
|
||||
@@ -11,6 +11,11 @@
|
||||
#
|
||||
# The binaries are built by `installers.yml`, called rather than copied, so the files
|
||||
# people download are made exactly the way the ones CI checks on every push are.
|
||||
#
|
||||
# Once the release exists, `website` puts it on meshterm.net: it rebuilds that site from
|
||||
# the tagged tree — the landing and About pages carry the version — and pushes the result
|
||||
# to jpmartineau/meshterm.net, which GitHub Pages serves. It writes with a deploy key
|
||||
# scoped to that one repository (secret SITE_DEPLOY_KEY); that repo's README makes one.
|
||||
name: GitHub release
|
||||
|
||||
on:
|
||||
@@ -74,17 +79,22 @@ 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 {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
|
||||
@@ -101,3 +111,55 @@ jobs:
|
||||
--verify-tag \
|
||||
$PRERELEASE
|
||||
echo "https://github.com/${{ github.repository }}/releases/tag/$TAG"
|
||||
|
||||
website:
|
||||
name: put the version on meshterm.net
|
||||
needs: publish
|
||||
# A pre-release stays out of "Latest", so it stays off the landing page too.
|
||||
if: ${{ !inputs.prerelease }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check for the deploy key
|
||||
env:
|
||||
SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }}
|
||||
run: |
|
||||
if [ -z "$SITE_DEPLOY_KEY" ]; then
|
||||
echo "::error::SITE_DEPLOY_KEY is not set; the meshterm.net README says how to make it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
path: meshterm
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: jpmartineau/meshterm.net
|
||||
ssh-key: ${{ secrets.SITE_DEPLOY_KEY }}
|
||||
path: site
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Rebuild the site from this release
|
||||
run: |
|
||||
python -m pip install --quiet -r site/requirements.txt
|
||||
python site/build.py --meshterm meshterm
|
||||
|
||||
- name: Publish it
|
||||
working-directory: site
|
||||
env:
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "meshterm.net already shows ${TAG#v}."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898341+github-actions[bot]@users.noreply.github.com"
|
||||
git commit -m "MeshTerm ${TAG#v}"
|
||||
git push
|
||||
@@ -14,12 +14,12 @@ on:
|
||||
# Callable, so the release workflow builds these binaries rather than keeping a second
|
||||
# copy of the matrix that would drift from this one.
|
||||
workflow_call:
|
||||
# Only when the packaging itself changes. Three operating systems is not something to
|
||||
# spend on every commit while the repository is private and minutes are metered.
|
||||
# Only when the packaging itself changes. A five-platform build takes the better part
|
||||
# of half an hour, and a commit that touched nothing it freezes has nothing to learn
|
||||
# from one; the release build is what proves the binaries.
|
||||
push:
|
||||
# `main` only. Without this, every Dependabot branch that touches this file — which
|
||||
# is every action bump — starts a five-platform build, and three of those legs are
|
||||
# billed at 2x and 10x.
|
||||
# is every action bump — starts a five-platform build of its own.
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packaging/**"
|
||||
@@ -50,9 +50,9 @@ jobs:
|
||||
# fail, it queues forever and holds the whole run open behind it.)
|
||||
- { os: macos-15-intel, label: macos-x64, ext: "" }
|
||||
- { os: ubuntu-22.04, label: linux-x64, ext: "" }
|
||||
# For the uConsole and other 64-bit ARM handhelds. ARM runners are free on
|
||||
# public repositories; on a private one this leg may sit unclaimed until the
|
||||
# repo is public. Drop the line if it blocks you.
|
||||
# For the uConsole and other 64-bit ARM handhelds. The ARM runners are the
|
||||
# public-repository ones; a private fork of this file may find the leg sits
|
||||
# unclaimed, and can drop the line.
|
||||
- { os: ubuntu-22.04-arm, label: linux-arm64, ext: "" }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -69,6 +69,37 @@ jobs:
|
||||
working-directory: packaging
|
||||
run: pyinstaller --clean --noconfirm meshterm.spec
|
||||
|
||||
# `packaging/meshterm.spec` regenerates `THIRD-PARTY-NOTICES.txt` fresh on every
|
||||
# leg's own build (see `packaging/notices.py`) and bundles it, along with LICENSE
|
||||
# and NOTICE, inside that leg's own installer -- so each downloaded binary always
|
||||
# carries an accurate copy of what it actually contains. This step is a separate
|
||||
# convenience: the same three files land on the release page itself, next to the
|
||||
# downloads, so nobody has to extract an installer just to read a license. One
|
||||
# copy serves every platform there (LICENSE/NOTICE never vary, and the runtime
|
||||
# dependency closures differ only in a handful of platform-specific packages), so
|
||||
# it rides on this leg alone rather than being uploaded -- and attached to the
|
||||
# release -- five identical-in-substance times over. linux-x64 was picked
|
||||
# arbitrarily among the five; nothing here is Linux-specific.
|
||||
- name: Upload the licences for the release page
|
||||
if: matrix.label == 'linux-x64'
|
||||
shell: bash
|
||||
run: |
|
||||
# `build/<specname>/` is PyInstaller's own workpath layout (see the spec's
|
||||
# comment on `workpath`), not something worth this step knowing by heart
|
||||
# twice -- copy it flat, next to LICENSE and NOTICE, so the artifact (and,
|
||||
# via `find`, the release assets built from it) is three plain files.
|
||||
cp packaging/build/meshterm/THIRD-PARTY-NOTICES.txt .
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: matrix.label == 'linux-x64'
|
||||
with:
|
||||
name: meshterm-licenses
|
||||
path: |
|
||||
LICENSE
|
||||
NOTICE
|
||||
THIRD-PARTY-NOTICES.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Check it actually runs
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
+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/
|
||||
+328
-1
@@ -17,6 +17,328 @@ allowed to change behaviour, not just add to it.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.6] — 2026-09-17
|
||||
|
||||
### Added
|
||||
|
||||
- **A contact can be locked.** Lock contact on a contact's page keeps it from ever being
|
||||
archived: the archive sweep counts it protected, and the page hides Archive until you
|
||||
unlock it. Locked contacts show a padlock ahead of their node type icon in the Contacts
|
||||
list — 🔒, or the console font's own padlock on the PicoCalc. The lock is kept by
|
||||
MeshTerm, not the radio, and reading the device never clears it.
|
||||
|
||||
- **Set clock on connect.** A new preference in the Device group sets the radio's clock from
|
||||
this computer's every time MeshTerm connects to it — at launch, when you connect later,
|
||||
and after a reconnect, since a dropped link is exactly when a board with no real-time
|
||||
clock may have rebooted and lost the time. It is off by default, runs in the background,
|
||||
and records the drift it corrected only in the log. Scripted commands never write the
|
||||
clock.
|
||||
|
||||
- **Two installation manuals, one per handheld.** [`docs/picocalc.md`](docs/picocalc.md)
|
||||
takes a stock ClockworkPi PicoCalc from a shopping list to a Linux handheld running
|
||||
MeshTerm with a LoRa radio soldered inside — the Lyra swap, writing Calculinux, the setup
|
||||
script, building and flashing the XIAO firmware (the patch it still needs, explained
|
||||
hunk by hunk), the wiring, and the Lyra setup. [`docs/uconsole.md`](docs/uconsole.md)
|
||||
installs the SPI bridge on a uConsole with the AIO LoRa board and connects MeshTerm to
|
||||
it. Both are step-by-step in plain language and self-contained, each ending on its own
|
||||
list of what has only been proven on one bench; [`docs/hardware.md`](docs/hardware.md)
|
||||
shrinks to the page that says which manual is yours, and the README's new
|
||||
**Documentation** table points at all of it.
|
||||
|
||||
- **A basemap doctor for a map that stays blank.** Every failure on the map's one network
|
||||
path is logged below the default log level, so tiles that never arrive leave a blank
|
||||
ground and no explanation. [`scripts/basemap-doctor.py`](scripts/README.md), run on the
|
||||
machine with the problem, reports the Python MeshTerm runs under, its certificate store,
|
||||
the tile source, and the terminal's colour depth, then fetches a real tile and names the
|
||||
cause with its fix: a Python with an empty certificate store (a python.org install on
|
||||
macOS, until `Install Certificates.command` is run), the network in between, or a
|
||||
terminal at 256 colours, where the basemap's greens and blues collide.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Purge contacts is Archive contacts**, because archiving is what it does: every contact
|
||||
it takes off the device stays in MeshTerm and can be restored. The row, its screens, its
|
||||
confirm button, and its progress bar all say Archive, and the row wears the 💾 that
|
||||
archiving one contact already wore, not the red 🗑 a deletion gets.
|
||||
|
||||
- **The archive preview shows the evidence, one kind to a column.** Its rows led on a
|
||||
percentile and followed it with the reasons a contact ranked low, three columns under one
|
||||
header, so a message count, an age, and a hop count traded places from row to row. Each
|
||||
column now holds one measurement all the way down: `HEARD` and `PKTS` first, drawn as the
|
||||
Contacts list draws them, then the `MSGS` and `HOPS` the sweep adds. The percentile is
|
||||
gone, since the list's order already is the ranking. Every contact leads with its node
|
||||
type icon, as in the Contacts list, and ←→ scrolls the columns under a name that stays
|
||||
put. Archiving a single contact from its page names it the same way in the confirm.
|
||||
|
||||
- **`scripts/` is sorted by handheld.** The PicoCalc's Calculinux bring-up, its two
|
||||
console-font builds, the GPL-2.0 text that belongs beside the 6×8 one, and the
|
||||
`xiao-radio/` firmware kit all live under `scripts/picocalc/`; the SPI bridge is
|
||||
`scripts/uconsole/meshterm-spi-bridge`. The index of what every file does and which
|
||||
machine runs it is a section of [`docs/hardware.md`](docs/hardware.md) now, beside the
|
||||
table that says which manual is yours, and the folder's own README keeps only a pointer
|
||||
to each handheld's folder and the basemap doctor. Every path in the manuals, the notices,
|
||||
the tests, and the scripts' own headers follows.
|
||||
|
||||
- **The 6×12 console-font script carries its size in its name**,
|
||||
`calculinux-console-font-6x12.sh`, like its 6×8 sibling always did. Same script, every
|
||||
reference updated.
|
||||
|
||||
- A tool that fails says so in an amber ⚠ popup, which wraps a long explanation instead of
|
||||
opening the full-screen result window for it.
|
||||
|
||||
- The About page opens on a new description of what MeshTerm is and what it plugs into.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Reading a big contact table no longer times out.** The library under MeshTerm gave a
|
||||
whole contacts read one five-second deadline, so a companion holding a few hundred
|
||||
contacts failed every read with "no event received", however healthy the radio. The wait
|
||||
is now for the gap between records — each record restarts it, and the read takes as long
|
||||
as the table does — so a timeout means the companion really stopped answering. An error
|
||||
reply meant for some other command no longer ends the read either.
|
||||
|
||||
- **An emoji no longer knocks a row out of line.** Terminals disagree about how wide they
|
||||
draw an emoji, and a contact's name arrives off the air carrying whatever its owner typed,
|
||||
so one emoji in a name could shift every column after it, and the screen's right border,
|
||||
by a cell. Every glyph that may be drawn as an emoji now gets two cells, and each is drawn
|
||||
at the column MeshTerm measured for it — in lists, in dialogs, and on the screens behind
|
||||
them — so one drawn narrow leaves a blank beside it rather than moving anything else, and
|
||||
a repaint no longer leaves a stale letter standing next to it. A name cut to fit its
|
||||
column is cut between glyphs, never through one, and a newline or an escape in a name no
|
||||
longer ends its row early. `MESHTERM_COLUMN_SNAP=0` turns the placement off.
|
||||
|
||||
- **Hops in near-identical colours stay separate chips.** Where a path is drawn as coloured
|
||||
chips, two neighbours of the same colour cut a wedge of bare background out of the route,
|
||||
and two of nearly the same colour were joined by a chevron nobody could see. Both are now
|
||||
divided by a thin chevron in a shade of the first chip's own colour, so the route runs on
|
||||
unbroken and the join still shows. "Nearly the same" is measured the way the eye sees
|
||||
colour, in OKLab, rather than as a hue gap, which judged the greens and the cyans very
|
||||
differently.
|
||||
|
||||
- Walking back up a scrolled list no longer lets the highlight ride off the top of its box.
|
||||
|
||||
## [0.3.5] — 2026-09-14
|
||||
|
||||
### Added
|
||||
|
||||
- A release now rebuilds [meshterm.net](https://meshterm.net) from the tag, so the site
|
||||
carries the version it links to.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The purge preview counts in columns.** The target ladder used to describe each rung in a
|
||||
phrase — "keeps 45 · archives 12" — so comparing two rungs meant finding the numbers inside
|
||||
two sentences. Keeps and archives are right-aligned columns now under a pinned
|
||||
`TARGET / KEEPS / ARCHIVES` header, one lane measured across both sections so the numbers
|
||||
run straight down the ladder; a rung that archives nothing shows a muted 0. The reasons a
|
||||
contact ranks low sit in three lanes of their own under one header, instead of running on
|
||||
from wherever the previous reason ended.
|
||||
|
||||
- The PayPal link on the Support page, and in the repository's funding file, is PayPal's own
|
||||
donate address rather than a shortener redirect, so a reader can see where it goes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A name with a skin tone or a cut-off joiner no longer pulls its row out of line.** Two
|
||||
real contact names showed it: one carries a skin-tone modifier, which one width table
|
||||
counted as two cells and the terminal draws as none, so the row reserved four cells for a
|
||||
two-cell glyph; the other is a flag sequence cut at the name's byte limit just past its
|
||||
joiner, which swallowed the padding space after it and left the name a cell short. A
|
||||
modifier now measures zero, and a joiner joins only a pictograph. The purge preview, the
|
||||
Contacts list and the chat picker all pad names through that one measurement.
|
||||
|
||||
## [0.3.4] — 2026-09-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The macOS installers build again.** 0.3.3's release never published: both macOS legs
|
||||
stopped at the new third-party notices step, which refuses to bundle a dependency whose
|
||||
wheel carries no licence text — and on macOS bleak's Bluetooth backend pulls in two such
|
||||
wheels, `pyobjc-core` and `pyobjc-framework-libdispatch`. Their MIT text is vendored
|
||||
beside pyserial's and pywinrt's now, verified against the wheels themselves, and a test
|
||||
checks every vendored path on every platform so a gap that only one runner can hit is
|
||||
caught here first. The 0.3.3 tag stands with no release behind it; this is the same
|
||||
code with its installers.
|
||||
|
||||
## [0.3.3] — 2026-09-13
|
||||
|
||||
### Added
|
||||
|
||||
- **`meshterm --version` answers**, and under `--mock` the `devices` command lists the
|
||||
simulator as the one device and scans nothing — it used to walk the serial ports and
|
||||
switch the Bluetooth radio on, on the first command a stranger without a radio would try.
|
||||
|
||||
- **[`docs/hardware.md`](docs/hardware.md)** is the one manual for putting MeshTerm on a
|
||||
PicoCalc or a uConsole: the Calculinux bring-up, the console font, the XIAO UART radio from
|
||||
parts to a verified link, the SPI bridge, and a closing list of what has only ever been
|
||||
proven on one bench. The two script READMEs are short indexes pointing at it.
|
||||
|
||||
- **The standalone installers carry their own licences now.** Each one-file build
|
||||
bundles `LICENSE`, `NOTICE` and a generated `THIRD-PARTY-NOTICES.txt` — every
|
||||
MIT/BSD/PSF dependency's own licence text, gathered at build time from what that
|
||||
build actually installed — at the root of the archive, and the same three files ride
|
||||
beside the downloads on the GitHub release page.
|
||||
|
||||
- **The map credits OpenStreetMap in its own bottom-right corner.** The full-screen map is
|
||||
arrived at with `© OpenMapTiles · Data from OpenStreetMap` there — the corner a paper map
|
||||
puts it in — and the first pan, zoom, reframe or find keystroke collapses it to
|
||||
`© OpenStreetMap`, which is a complete credit in its own right, so nothing is ever taken
|
||||
back. It costs no chrome at all: no title atom, no footer character, no body row. On a
|
||||
desktop terminal it is set into the panel's bottom border rule, right-justified, the way
|
||||
a title sits in the top one, so the drawing keeps every cell it had; on the PicoCalc,
|
||||
whose frame has no bottom rule to set anything into, it rides the right end of the map's
|
||||
own last row, where a pan can move the ground out from under it. The node page's location
|
||||
preview carries the short form throughout, being a picture rather than a place. The
|
||||
licence and its URL live where they can be read — the About page, which also now names
|
||||
**OpenMapTiles**, as OpenFreeMap asks and neither the page nor the README did.
|
||||
|
||||
- **Every Python file names its licence with an SPDX identifier** on its first line (second, after a shebang), and `tests/test_spdx.py` keeps a new file from landing without one. Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all.
|
||||
|
||||
- **The XIAO firmware patch carries MeshCore's MIT notice beside it** as `scripts/xiao-radio/LICENSE.MeshCore`, and the console-font scripts state that the shared generator block is offered under GPL-2.0-only as well as Apache-2.0, which is what lets the GPL-2.0-only 6×8 script run it.
|
||||
|
||||
- **The SPI radio bridge runs on `openhop_core`.** The library the uConsole's software
|
||||
node runs on was renamed from `pymc_core` in 2026, and the current `meshcore-uconsole`
|
||||
package depends on the new name. `meshterm-spi-bridge` now drives either runtime,
|
||||
preferring the newer, and two of its three compatibility shims stand down under it —
|
||||
openhop_core accepts long ACK payloads and pushes completed trace replies itself; only
|
||||
the raw RX-log push is still the bridge's. On a bookworm uConsole, where the trixie
|
||||
package cannot run, the bridge takes a venv of its own with `openhop-core[hardware]`
|
||||
and still uses the GUI's identity, so it stays the same node. Verified on the device:
|
||||
radio up, 159 contacts restored, `info` and `contacts` answered over TCP.
|
||||
|
||||
- **The PicoCalc picks its console font, and MeshTerm hands the console back.** A *Console
|
||||
font* row under Display on the Preferences page offers `6x12 (53x26)` — the font the
|
||||
device boots in — and `6x8 (53x40)`, fourteen more rows of the same glyph inventory in a
|
||||
shorter cell. Picking one is the preview: the console loads it there and then, and the
|
||||
page repaints at the new height (the kernel raises SIGWINCH on a font change), so the
|
||||
choice is looked at before it is kept. Apply keeps it; leaving and discarding puts the
|
||||
saved font back. The 6×8 base has no Cyrillic, so a node named in Cyrillic draws as
|
||||
boxes on it.
|
||||
|
||||
A virtual terminal has one font for the whole console, so switching MeshTerm's switches
|
||||
the shell's. It saves whatever font was loaded before the first paint and reloads it on
|
||||
the way out — from the session's teardown and from an `atexit` hook, so quitting, an
|
||||
unwind and a crash all leave the console as they found it. Off the handheld, off a real
|
||||
VT, or with no `setfont`, none of it happens and the log says which. The row is drawn on
|
||||
the PicoCalc only, while the value round-trips through `preferences.toml` everywhere.
|
||||
|
||||
The 6×8 font's build moved into `scripts/calculinux-console-font-6x8.sh`, on its own
|
||||
because its base bitmap is the Linux kernel's `font_6x8` and therefore GPL-2.0 — marked
|
||||
as such, with the licence beside it, and reading the donor, alias and keeper tables out
|
||||
of the 6×12 script rather than keeping a second copy of them. Running the 6×12 script
|
||||
still builds both.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Preferences are kept in `preferences.toml`.** The same file as before — only what you
|
||||
have changed, grouped and commented the way the Preferences page reads — in the format
|
||||
`config.toml` and the device-config backups already use, so every file you edit by hand
|
||||
speaks one syntax and MeshTerm no longer needs PyYAML. A `preferences.yaml` from an
|
||||
earlier build is not read: set those preferences again, or copy the lines across as
|
||||
`key = value` with text in quotes. A line TOML refuses costs only that line, and a word
|
||||
left unquoted (`log_level = DEBUG`) is still understood.
|
||||
|
||||
- **A trophy case record is a page, not a card floating over the list.** It fills the frame
|
||||
like the browser under it and takes the node page's shape: a header line that reads as a
|
||||
sentence — the discipline's mark, its standing and its score — over three tabs. *Info* is
|
||||
the record's stats, identity first, with the page's actions at its foot; *Route* is the walk
|
||||
two ways, the route graph and the route line, where Enter traces the path on show; *Area*
|
||||
is the ground the walk covered, drawn across the whole stage with every pin labelled by its
|
||||
hash byte so a pin and the graph node under it match by eye. Tab and Shift+Tab switch, F3 on
|
||||
the PicoCalc. The trophy case itself fills the frame whichever way it is opened, from the
|
||||
menu or from a trace.
|
||||
|
||||
- **A QR code is white on black, and when it is the answer it is the whole screen.** A camera
|
||||
reads contrast, and light-on-dark is what a scanner expects of a screen, so every code
|
||||
MeshTerm draws is pure white on pure black on both themes. A channel's share link and a
|
||||
contact card are no longer a titled popup with an instruction over the code: they are a
|
||||
bare frame with nothing on it but the code and the URL it encodes, and Esc leaves.
|
||||
|
||||
- **A question is a popup, and it is gone before the page it asked for opens.** The trace
|
||||
target, the repeater admin's node and the TX optimize link used to stay pushed as full
|
||||
screens under the page they led to, so Esc out of a trace or an admin page landed back on
|
||||
the list it was picked from — two places where there is one. Each now floats over the main
|
||||
menu, is popped before its page opens, and Esc from the page lands on the menu. The TX
|
||||
optimize link is one box that turns its page (`… · step 1 of 2`): Esc on the second step
|
||||
turns back with the picked node still highlighted. A refused admin login re-asks with the
|
||||
same node highlighted.
|
||||
|
||||
- The three delay settings — TX delay, direct TX delay and RX delay — show, stage and send at
|
||||
one decimal on both the repeater admin and Device config pages; Device config's RX delay
|
||||
had no rounding at all.
|
||||
|
||||
- The source distribution ships the package, its tests and the manual, not
|
||||
the repository: the Claude skills, the GitHub config, the PyInstaller spec and the device
|
||||
scripts are out of it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A share code's bottom-left finder was one cell skewed**, so a camera could not lock on.
|
||||
Rich's centre justify strips a row's trailing spaces before padding it, so a code row whose
|
||||
right edge was light modules landed a column off its neighbours. A code is now moved across
|
||||
a line one way only, the same indent in front of every row.
|
||||
|
||||
## [0.3.2] — 2026-09-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Device config holds every setting the companion firmware has, and the device actions
|
||||
with them.** Clock sync, backup and restore, the identity key, reboot and factory reset
|
||||
were a menu entry of their own; they are an Actions section on the Device config page
|
||||
now, and the page takes the repeater admin page's shape — full-screen, titled with the
|
||||
node's name and a staged count, with Apply sending in place one value at a time and
|
||||
leaving a value the device refused staged beside its reason.
|
||||
|
||||
The settings were checked against the firmware itself rather than the library that
|
||||
talks to it. New are client repeat, the auto-add hop limit and a row for each custom
|
||||
variable the device reports, and the firmware's own bounds apply: a PIN of 0 or six
|
||||
digits, path-hash modes 0–2, 150–2500 MHz, 7–500 kHz, TX power from −9 dBm.
|
||||
|
||||
- **Both editor pages pick a location on the map.** A "Pick location on map…" row sits
|
||||
above separate latitude and longitude rows, opens the map on the position as staged — or
|
||||
as the device last reported it — and stages both coordinates at once. Device config's
|
||||
single Location row and its Pick / Type / Clear dialog are gone.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Repeater admin speaks the firmware's own CLI, every setting of it.** The catalog had
|
||||
been written from convention, and the firmware disagreed: the delays are floats (0.5
|
||||
showed as 0, and typing it back was refused), bandwidth, spreading factor and coding rate
|
||||
are one `radio` setting rather than three keys, the guest password is readable, and
|
||||
`path.hash.mode` and twenty-odd others were missing. It is transcribed from the firmware
|
||||
source now. Read settings leaves every row answered — a value, `empty`, or `n/a` where
|
||||
the node's firmware lacks the setting — and `^R` (F3 on the PicoCalc) re-reads the
|
||||
highlighted row alone.
|
||||
|
||||
- Repeater admin fills the frame like Device config, instead of floating over the node
|
||||
picker it was opened from.
|
||||
|
||||
- **A packet's relay chain is drawn as the middle of a route.** The `via` field names the
|
||||
repeaters that forwarded a frame and neither the node that sent it nor the one that
|
||||
heard it, yet its ribbon opened and closed square — the mark for *the route began and
|
||||
ended here*. Both ends wear the chevron now, as the TX sweep's composed relays do, and
|
||||
arrow mode says the same with a leading and a trailing `→`. Under the chain, the note
|
||||
about which node the reception described gives way to the hop count.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Retuning a companion's radio switched off its relaying.** The firmware reads the radio
|
||||
command's trailing client-repeat byte as *off* when it is missing, and nothing sent it.
|
||||
Every radio edit restates it now.
|
||||
|
||||
- The location picker opens at once. It waited on the radio for the contact list first,
|
||||
and a busy companion held it shut for twenty-odd seconds only to open with no contacts
|
||||
anyway; it opens on what is cached now and adds the rest when the radio answers.
|
||||
|
||||
- A repeater setting its firmware lacks reads `n/a`, rather than the `?` that says it was
|
||||
never asked.
|
||||
|
||||
- Every icon-led list starts its labels in the same cell. Some icons draw one cell wide and
|
||||
most emoji two, so a row led by `↻`, `⌨` or `🗑` started its words a column early; each
|
||||
list pads its icons out to the widest one now.
|
||||
|
||||
- Neither editor page slides its rows sideways any more — a long row ends at the edge.
|
||||
|
||||
## [0.3.1] — 2026-09-09
|
||||
|
||||
### Added
|
||||
@@ -518,7 +840,12 @@ deliberately not reconstructed here.
|
||||
- Two `TYPE_CHECKING` imports the test suite referenced but never imported, on paths that
|
||||
happened never to run.
|
||||
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.1...HEAD
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.6...HEAD
|
||||
[0.3.6]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.6
|
||||
[0.3.5]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.5
|
||||
[0.3.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.4
|
||||
[0.3.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.3
|
||||
[0.3.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.2
|
||||
[0.3.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.1
|
||||
[0.3.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.0
|
||||
[0.2.8]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.8
|
||||
|
||||
@@ -16,6 +16,26 @@ Commit directly on `main` — do not create topic branches unless explicitly ask
|
||||
is a solo repo; branches just create divergence to merge back later). Commit freely, but
|
||||
never `git push` unless explicitly asked in that moment.
|
||||
|
||||
## Licensing — what every file and every build carries
|
||||
|
||||
- **Every Python file opens with `# SPDX-License-Identifier: Apache-2.0`** (line 2 after a
|
||||
shebang). `tests/test_spdx.py` is the gate. Anything under another licence lives beside
|
||||
its own text and says so in its header — the GPL-2.0-only console-font script, the
|
||||
MeshCore MIT notice beside the XIAO patch — and never enters the distributed package.
|
||||
- **The map credits OpenStreetMap in the map's own bottom-right corner**
|
||||
(`ui/attribution.py`): the full OpenFreeMap line on arrival, `© OpenStreetMap` after the
|
||||
first keystroke, `muted`, on both platforms and in the same words. *Which* corner is the
|
||||
frame's, not the drawing's, wherever the frame has one — **regular** sets the credit into
|
||||
the panel's **bottom border rule**, right-justified, one rule cell before the corner, the
|
||||
way a title sits in the top rule (`Screen.bottom_caption` → `frame._panel_box`), so the
|
||||
map's cells are untouched; **picocalc**, whose frame has no bottom rule at all, stamps it
|
||||
over the right end of the drawing's last row instead, overprinting what it covers. The
|
||||
minimap always stamps. The split is bound once via `platforms.on_platform`, never asked
|
||||
per frame. Never a title atom, never a footer character, never a key. The licence and URL
|
||||
live on the About page.
|
||||
- The name and the wordmark are trademarks reserved in `NOTICE`, which Apache §4(d) makes
|
||||
every fork carry; `NOTICE` holds only what must travel with a redistribution.
|
||||
|
||||
## UX standards
|
||||
|
||||
These are binding. Every new screen, dialog, row, or hint follows them; when you touch an
|
||||
@@ -28,7 +48,7 @@ them instead of hand-rolling:
|
||||
the language below).
|
||||
- `ui/widgets.py` — `highlighted_hash` (THE key widget — shows a key, lights its hash),
|
||||
`format_ago` (prose ages),
|
||||
`_format_age` (column ages), `channel_glyph`, `_NODE_GLYPHS`, heard-age heat colouring.
|
||||
`format_age` (column ages), `channel_glyph`, `NODE_GLYPHS`, heard-age heat colouring.
|
||||
- `ui/theme.py` — `name_style`/`node_style` (per-node hues, hash-derived), `snr_style`,
|
||||
the `you` white.
|
||||
|
||||
@@ -58,14 +78,14 @@ sortable list you pick from is `contactlist.py` (`ContactListScreen`/`ContactRow
|
||||
Setting vs preference — the other boundary, three-way and never blurred: a **setting** is
|
||||
the *radio's* (`core/device_config.py`, read live from the companion, edited on Device
|
||||
config); a **preference** is *MeshTerm's own behaviour* (`core/preferences.py`, defaults in
|
||||
code, overrides in `preferences.yaml`, edited on the Preferences page — `ctx.preferences`,
|
||||
code, overrides in `preferences.toml`, edited on the Preferences page — `ctx.preferences`,
|
||||
or `preferences.current()` where there is no context to reach through); and **config** is
|
||||
machine setup — paths and device profiles (`core/config.py`, `config.toml`, a text editor
|
||||
only). A behaviour value belongs in the preference registry, not as a module constant and
|
||||
not in `config.toml`: a code constant a reader can't reach is a preference nobody has.
|
||||
|
||||
Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now ago"),
|
||||
`_format_age` for aligned columns ("now", "5m").
|
||||
`format_age` for aligned columns ("now", "5m").
|
||||
|
||||
### Navigation — the stack
|
||||
|
||||
@@ -85,11 +105,26 @@ deliberately, one at a time, and say why in the code.
|
||||
refreshes in place with `SelectScreen.replace_items`, which follows the highlighted row
|
||||
by value and keeps the filter. Rebuilding the screen is for when the rows it was holding
|
||||
a place in are genuinely gone (a purge, a delete) — and then say so.
|
||||
- **A popup is not a place.** The stack is for *places* — full-frame screens the reader
|
||||
is *in* (a menu, a map, a list that is the tool's own page, an editor). A popup
|
||||
*informs, confirms, or asks*, and then it is gone: it is never kept pushed as a hub
|
||||
under the screen it was gathering for, so Esc from that screen lands where the tool
|
||||
was launched from, not on a picker. The test is whether the reader is in it or being
|
||||
asked something — a `SelectScreen` can be either (Channels is a place; "node to
|
||||
manage" is a question). A question **draws as a box** even when it is the only frame
|
||||
on the stack (the main menu is popped while a tool runs, and a lone floating screen
|
||||
would otherwise be painted full-frame): `session.select(floating=True)`,
|
||||
`session.text(floating=True)`, `session.stay(screen, dialog=True)` — all through
|
||||
`TuiSession._floated`, which puts a blank base under it.
|
||||
- **An entry chain is a stack too.** A flow that asks two or more things in a row runs
|
||||
through `menus.run_steps`: each step gets the answers so far (to label itself, and to
|
||||
offer its own previous answer as its default) and returns `None` to step back, so Esc
|
||||
undoes one step instead of the whole flow. A trailing Cancel/verb confirm is still a
|
||||
decision, not a step — its Cancel abandons.
|
||||
decision, not a step — its Cancel abandons. Where the steps are *lists to pick from*,
|
||||
the chain is **one popup that turns its page** — `menus.run_wizard` over `WizardPage`s
|
||||
(`SelectScreen.turn_page`; title `Feature — subject · step 1 of 2`), never two popups
|
||||
stacked. Esc on a later page turns back one, with that page's previous answer
|
||||
highlighted; the box is popped before whatever the answers were for opens.
|
||||
- **Esc peels before it leaves.** A screen carrying a find-as-you-type filter treats the
|
||||
typed query as the most recent thing the reader entered: the first Esc clears it and the
|
||||
screen stays, the second leaves. One rule on all four (`SelectScreen` and everything
|
||||
@@ -157,6 +192,21 @@ deliberately, one at a time, and say why in the code.
|
||||
the query in its footer hint draws the body line only where the footer isn't drawn
|
||||
(`Platform.footer_fkeys`), never both: the query must be visible on every platform, and
|
||||
twice on none.
|
||||
- **A QR code is white on black, and when it is the answer it is the whole screen.**
|
||||
Modules are pure white ink on a pure black field whatever theme the terminal runs
|
||||
(`qr._STYLE`, both ends named) — a camera reads contrast, light-on-dark is what a
|
||||
scanner expects of a screen — and never black on white. A share screen
|
||||
(`qr.share_screen` → `qr.QrScreen`: the channel share, the contact card, the CLI's
|
||||
share commands in the menu) is a **bare frame**: `Screen.bare` / `frame.compose_bare`
|
||||
draw the body alone on blank rows — no header, no footer, no title bar, no box, no
|
||||
hint, no F-key lane, no instruction — with nothing on it but the code and the URL it
|
||||
encodes, sat a little above centre; Esc leaves, as everywhere. The code **fits itself
|
||||
to the frame every paint**, width and rows both: a lighter error level, then a
|
||||
narrower quiet zone, before it would ever be cut (a contact card is 57 cells and 29
|
||||
rows at the standard fit; the PicoCalc is 53 across and a regular terminal 24 tall),
|
||||
and when code and URL can't share the frame the code is whole at the top and the URL
|
||||
a page down. The one exception is a ` ```qr ` fence in a written page, where the code
|
||||
is an illustration drawn in the prose where the fence is.
|
||||
|
||||
### Written pages
|
||||
|
||||
@@ -261,17 +311,30 @@ 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 a path that has
|
||||
finished 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 both ends into a
|
||||
lozenge instead (`POWERLINE_ROUND_*`, drawn only where `termfont.powerline_full`). A
|
||||
*wrapped* line the path outruns still closes on the point: there the route really does
|
||||
go on, which is the cue arrow mode spells with a trailing `→`.
|
||||
- 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) ·
|
||||
@@ -393,7 +456,7 @@ splitting safe has gone, because splitting is the machine face's job now.
|
||||
quoting stays and always will (`script._escaped`): a node broadcasts its own name and a
|
||||
stranger fills in a message body, and neither may end the record it sits in.
|
||||
- **A time is an age** — `now`, `5m`, `3h`, `never` (`script.age`, delegating to
|
||||
`widgets._format_age` so the two faces cannot drift). An absolute instant survives where
|
||||
`widgets.format_age` so the two faces cannot drift). An absolute instant survives where
|
||||
the instant *is* the fact (`script.stamp`, `fields.instant`): the device clock, an
|
||||
appointment set with `--at`, a live capture's own `TIME`, every column under `--absolute`.
|
||||
`--absolute` is an override of the `cli_time_format` **preference**, not a switch beside
|
||||
@@ -489,7 +552,7 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
|
||||
`MapCanvas.to_ansi_lines`) — but the fold is the safety net, not the design.
|
||||
- The glyph contract is `ui/fontset.py` — the installed console font's exact codepoint
|
||||
inventory, device-verified. A character outside it is a test failure, not a tofu box
|
||||
found on-device. The font itself is built by `scripts/calculinux-console-font.sh`;
|
||||
found on-device. The font itself is built by `scripts/picocalc/calculinux-console-font-6x12.sh`;
|
||||
the two files move in the same commit.
|
||||
- The 16-slot palette is `theme._VT_SLOTS` (programmed via `/etc/vtrgb`; same script).
|
||||
`MESH_THEME_16` speaks `color(0..15)` only; backgrounds stop at slot 7. Both themes
|
||||
@@ -500,7 +563,7 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
|
||||
base style into every span, so a silent one changes colour inside a selected row. Same
|
||||
trap outside the theme: `MapCanvas` drops emphasis entirely where bold is brightness,
|
||||
since its colours arrive quantized and it can't know which bank they landed in, and the
|
||||
fold's quantizer (`theme._nearest_slot_sgr`) states intent for it — a dim slot leaves as
|
||||
fold's quantizer (`theme._nearest_slot_params`) states intent for it — a dim slot leaves as
|
||||
`22;3N`, never a bare `3N`, because `9N` is *how* the console spells bright and adjacent
|
||||
art spans (the wordmark's bevels, a raster's neighbouring cells) reset nothing between
|
||||
them, so a bare one inherits the intensity bit and lands a bank too high mid-row.
|
||||
|
||||
@@ -25,6 +25,14 @@ project and is not authorized to do so.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Device script: scripts/picocalc/calculinux-console-font-6x8.sh embeds the Linux kernel's
|
||||
font_6x8 bitmap (lib/fonts/font_6x8.c) and is therefore licensed GPL-2.0-only,
|
||||
as is the console font it builds; its terms are included beside it as
|
||||
scripts/picocalc/LICENSE.GPL-2.0 and are its only license -- the Apache License above
|
||||
does not apply to it. Nothing GPL-2.0 reaches the MeshTerm package: that script
|
||||
is excluded from the distribution, runs on the device, and produces a PSF data
|
||||
file the Linux console driver loads, which MeshTerm neither links nor bundles.
|
||||
|
||||
Bundled font: meshterm/assets/fonts/CascadiaMonoPL.ttf is Cascadia Mono PL,
|
||||
Copyright (c) 2019 - Present, Microsoft Corporation, with Reserved Font Name
|
||||
Cascadia Code. It is redistributed here unmodified under the SIL Open Font
|
||||
|
||||
@@ -26,11 +26,18 @@ your mesh's history is worth.
|
||||
> MeshTerm is a side project, not a product. Bug reports are very welcome — but please
|
||||
> open an issue before writing a pull request. [More on how it's run](#how-this-project-is-run).
|
||||
|
||||
<!--
|
||||
Screenshots welcome here. Drop PNGs into docs/ and reference them, e.g.:
|
||||

|
||||

|
||||
-->
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/dashboard.png" alt="The dashboard: a packets-per-minute timeline over the last two hours, and every packet class counted" width="49%">
|
||||
<img src="docs/screenshots/map.png" alt="The map: repeaters named over an OpenStreetMap basemap of the Lachine Canal" width="49%">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/trace.png" alt="A trace: the route it walked drawn as chips, then the SNR of every hop" width="49%">
|
||||
<img src="docs/screenshots/path.png" alt="Message paths: every route a channel message was heard over, drawn on the ground" width="49%">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/picocalc.png" alt="MeshTerm on the PicoCalc, a 53-column console with a radio wired inside" width="38%">
|
||||
</p>
|
||||
<p align="center"><sub>The dashboard, the map, a trace, the paths a message took — and the whole thing on a PicoCalc.</sub></p>
|
||||
|
||||
---
|
||||
|
||||
@@ -68,14 +75,14 @@ if anything goes wrong at startup the window closes before you can read why.
|
||||
**Windows** — open Windows Terminal or PowerShell, `cd` to your downloads, then:
|
||||
|
||||
```powershell
|
||||
.\meshterm-0.2.8-windows-x64.exe
|
||||
.\meshterm-0.3.6-windows-x64.exe
|
||||
```
|
||||
|
||||
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
|
||||
|
||||
```bash
|
||||
chmod +x meshterm-0.2.8-*
|
||||
./meshterm-0.2.8-*
|
||||
chmod +x meshterm-0.3.6-*
|
||||
./meshterm-0.3.6-*
|
||||
```
|
||||
|
||||
### A word about terminals, on Windows
|
||||
@@ -113,7 +120,7 @@ becomes just `meshterm` from anywhere.
|
||||
> 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.
|
||||
> `xattr -d com.apple.quarantine meshterm-0.3.6-*` and run it again.
|
||||
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
|
||||
>
|
||||
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
|
||||
@@ -123,10 +130,10 @@ becomes just `meshterm` from anywhere.
|
||||
> MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation:
|
||||
>
|
||||
> ```bash
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.2.8-* # macOS, Linux
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.6-* # macOS, Linux
|
||||
> ```
|
||||
> ```powershell
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.8-windows-x64.exe
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.6-windows-x64.exe
|
||||
> ```
|
||||
>
|
||||
> Same variable if you run two radios and want them kept apart.
|
||||
@@ -160,6 +167,17 @@ question is still being sorted out ([why](https://github.com/pypi/support/issues
|
||||
|
||||
Setting up for development instead? That's in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
| Read | For |
|
||||
| --- | --- |
|
||||
| **[The command line](docs/cli.md)** | Every subcommand and option, what each prints on the plain and JSON faces, the exit statuses, recipes. |
|
||||
| **[MeshTerm on the PicoCalc](docs/picocalc.md)** | Building the handheld from a stock PicoCalc: the shopping list, swapping in the Lyra, installing Calculinux and MeshTerm, the soldered LoRa radio. |
|
||||
| **[MeshTerm on the uConsole](docs/uconsole.md)** | A uConsole with the AIO LoRa board: installing the bridge that fronts its SPI radio as a companion, and connecting to it. |
|
||||
| [MeshTerm on hardware](docs/hardware.md) | Which of the two manuals is yours, and the two platform flavours MeshTerm draws in. |
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | The development setup and the house rules. |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | What changed, newest first. |
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
@@ -182,8 +200,12 @@ meshterm --mock --platform picocalc
|
||||
meshterm specimen
|
||||
```
|
||||
|
||||
Handheld bring-up (fonts, palette, boot services) lives in
|
||||
`scripts/calculinux-setup.sh` + `scripts/calculinux-console-font.sh`.
|
||||
Putting MeshTerm on a handheld has its own manuals. **[MeshTerm on the
|
||||
PicoCalc](docs/picocalc.md)** takes a stock PicoCalc from the shopping list to a Linux
|
||||
handheld with a LoRa radio soldered inside; **[MeshTerm on the uConsole](docs/uconsole.md)**
|
||||
connects a uConsole's SPI LoRa board through a small bridge. The device-side scripts both
|
||||
manuals run are in [`scripts/`](scripts/); not sure which manual is yours?
|
||||
[`docs/hardware.md`](docs/hardware.md) says.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -225,8 +247,7 @@ here with its scripted equivalent, where one exists.
|
||||
| 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` / … |
|
||||
| **🔧 Device config** | Every setting the companion firmware exposes — name, radio, client repeat, auto-add, telemetry, custom variables — *staged* for review and applied in place, plus the operations on the box itself: clock sync, TOML backup/restore, the identity key, reboot, and factory reset, each acting the moment it's confirmed, with destructive ops gated. Laid out like Repeater admin, so the radio in your hand and one over the mesh are configured the same way. | `meshterm config` / `config set <key> <value>` / `config backup` / `reboot` / … |
|
||||
| **📡 Send advert** | Announce this node to the mesh — a zero-hop or flood advertisement, or share this node's contact card as a QR code. | `meshterm config advert` / `config share` |
|
||||
| **🔌 Device discovery** | Enumerate serial *and* Bluetooth LE companions, pick one interactively (or auto-select the only one present), add a network (TCP) companion by host:port, and remember the last good default. A dropped link is detected live and offers to reconnect. | `meshterm devices` |
|
||||
|
||||
@@ -353,7 +374,7 @@ $ meshterm preferences set history_days 90
|
||||
$ meshterm preferences reset --yes # back to the built-in defaults
|
||||
```
|
||||
|
||||
They are kept in `~/.meshterm/preferences.yaml`, which lists only what you have changed;
|
||||
They are kept in `~/.meshterm/preferences.toml`, which lists only what you have changed;
|
||||
delete a line and the default takes over again.
|
||||
|
||||
**Config** is where things live and which device to talk to. MeshTerm runs with none of it
|
||||
@@ -470,17 +491,25 @@ MeshTerm stands on the [MeshCore](https://meshcore.io/) project — its firmware
|
||||
### Map data
|
||||
|
||||
The street map is rendered from **© OpenStreetMap contributors** data, served as vector
|
||||
tiles by [OpenFreeMap](https://openfreemap.org/). OpenStreetMap data is available under the
|
||||
[Open Database License (ODbL)](https://www.openstreetmap.org/copyright). Tiles are decoded
|
||||
with a small built-in reader for the [Mapbox Vector Tile](https://github.com/mapbox/vector-tile-spec)
|
||||
format.
|
||||
tiles by [OpenFreeMap](https://openfreemap.org/) on the unmodified
|
||||
**© OpenMapTiles** [schema](https://openmaptiles.org/). OpenStreetMap data is available
|
||||
under the [Open Database License (ODbL)](https://www.openstreetmap.org/copyright). Tiles
|
||||
are decoded with a small built-in reader for the
|
||||
[Mapbox Vector Tile](https://github.com/mapbox/vector-tile-spec) format.
|
||||
|
||||
Both map surfaces carry the credit themselves. The full-screen map opens with
|
||||
`© OpenMapTiles · Data from OpenStreetMap` in its bottom-right corner and collapses it to
|
||||
`© OpenStreetMap` once you pan, zoom or type — set into the panel's bottom border rule on
|
||||
a desktop terminal, so the drawing itself keeps every cell, and on the map's own last row
|
||||
on the PicoCalc, whose frame has no bottom rule. The node page's location preview shows the
|
||||
short form throughout. The About page inside the app spells out the licence URL.
|
||||
|
||||
### Bundled font
|
||||
|
||||
MeshTerm ships **Cascadia Mono PL**, © 2019–present Microsoft Corporation, redistributed
|
||||
unmodified under the [SIL Open Font License 1.1](meshterm/assets/fonts/CascadiaMono-OFL.txt).
|
||||
It's offered to Windows users whose console can't draw the charts — see
|
||||
[A word about fonts](#a-word-about-fonts). Microsoft doesn't endorse MeshTerm; the font is
|
||||
[A word about terminals, on Windows](#a-word-about-terminals-on-windows). Microsoft doesn't endorse MeshTerm; the font is
|
||||
simply the right tool, being one of the very few monospace faces that carries the braille
|
||||
block the timelines are drawn from.
|
||||
|
||||
@@ -498,8 +527,9 @@ respective project for the authoritative terms):
|
||||
| [Typer](https://typer.tiangolo.com/) | Scripted CLI (subcommands mirror the menu) | MIT |
|
||||
| [Rich](https://github.com/Textualize/rich) | Console rendering | MIT |
|
||||
| [prompt_toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) | Full-screen interactive TUI | BSD-3-Clause |
|
||||
| [markdown-it-py](https://github.com/executablebooks/markdown-it-py) | CommonMark parser behind the written About pages | MIT |
|
||||
| [Segno](https://github.com/heuer/segno) | Pure-Python QR codes (channel share links) | BSD-3-Clause |
|
||||
| [tomli](https://github.com/hukkin/tomli) / [tomli-w](https://github.com/hukkin/tomli-w) | TOML config read/write | MIT |
|
||||
| [tomli](https://github.com/hukkin/tomli) / [tomli-w](https://github.com/hukkin/tomli-w) | TOML config, preferences and device-config backups | MIT |
|
||||
|
||||
## License
|
||||
|
||||
@@ -518,3 +548,8 @@ is Microsoft's Cascadia Mono PL, redistributed unmodified under the SIL Open Fon
|
||||
1.1, which travels with it as
|
||||
[CascadiaMono-OFL.txt](meshterm/assets/fonts/CascadiaMono-OFL.txt) and stays its only
|
||||
license.
|
||||
|
||||
Each standalone build carries `LICENSE`, `NOTICE` and a generated `THIRD-PARTY-NOTICES.txt`
|
||||
(every dependency's own license text) inside the archive, and the same three files sit
|
||||
beside the downloads on the [releases page](https://github.com/jpmartineau/MeshTerm/releases)
|
||||
so you don't have to extract one just to read them.
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
#
|
||||
# How MeshTerm itself behaves — transmit cooldown, message retries, how much history it
|
||||
# keeps, whether it connects at launch — is *not* here. Those are preferences: they live
|
||||
# in ~/.meshterm/preferences.yaml, and are edited on the Preferences page (or with
|
||||
# in ~/.meshterm/preferences.toml, and are edited on the Preferences page (or with
|
||||
# `meshterm preferences set <key> <value>`). Every one has a built-in default, so that
|
||||
# file only ever lists what you have changed.
|
||||
#
|
||||
|
||||
+18
-13
@@ -51,6 +51,7 @@ With no command, MeshTerm launches the interactive menu instead.
|
||||
|
||||
| Option | What it does |
|
||||
| --- | --- |
|
||||
| `--version` | Print `meshterm <version>` and exit `0`, before anything else is opened. |
|
||||
| `-p`, `--profile NAME` | Use a named device profile from `config.toml`. |
|
||||
| `--port PORT` | Serial port to connect to (`COM5`, `/dev/ttyACM0`), overriding the profile. |
|
||||
| `--ble ADDRESS` | Bluetooth address of a companion; selects the BLE transport. |
|
||||
@@ -70,8 +71,8 @@ for. A bare `--` stops the lifting, the standard way to say the rest is data, an
|
||||
`--help` is never lifted: `meshterm contacts --help` stays the *contacts* help.
|
||||
|
||||
Commands that need a radio open one, do their work, and close it. Commands that only read
|
||||
stored history (`records`) or MeshTerm's own state (`preferences`, the written pages) need
|
||||
no device at all and work with nothing attached.
|
||||
stored history (`records`) or MeshTerm's own state (`preferences`, `platform`, `specimen`,
|
||||
the written pages) need no device at all and work with nothing attached.
|
||||
|
||||
> **One transmission per invocation.** A trace transmits exactly once — repeaters
|
||||
> penalise, and can blacklist, nodes that burst traffic. To sample more, run the command
|
||||
@@ -97,7 +98,7 @@ of two directories from the same shell.
|
||||
| --- | --- |
|
||||
| `meshterm.db` | The history: every packet overheard, every trace walked, every message. **The one file `--db` moves.** |
|
||||
| `config.toml` | Machine setup — device profiles, where the database lives. Yours to write; MeshTerm only reads it. |
|
||||
| `preferences.yaml` | Your overrides of MeshTerm's own behaviour. Lists only what you changed. |
|
||||
| `preferences.toml` | Your overrides of MeshTerm's own behaviour. Lists only what you changed. |
|
||||
| `courier.json` | The outbox — queued messages, waiting and finished. |
|
||||
| `admin.json` | Remembered repeater-admin passwords. |
|
||||
| `devices.json` | The remembered default companion, and every device proven to speak the protocol. |
|
||||
@@ -583,11 +584,14 @@ repeat](#the-shapes-that-repeat), the table names the shape rather than restatin
|
||||
#### `meshterm devices`
|
||||
|
||||
List every attached serial device and in-range Bluetooth companion. Opens no radio — it
|
||||
only enumerates what is attached or advertising.
|
||||
only enumerates what is attached or advertising. Under `--mock` it enumerates nothing at
|
||||
all: the simulator is the one device, listed with `--mock` as its target, and neither the
|
||||
serial ports nor the Bluetooth radio are touched — a session that promised no real
|
||||
hardware keeps the promise here too.
|
||||
|
||||
| Option | What it does |
|
||||
| --- | --- |
|
||||
| `--ble` / `--no-ble` | Include a Bluetooth LE scan. On by default; skipping it saves a few seconds. |
|
||||
| `--ble` / `--no-ble` | Include a Bluetooth LE scan. On by default; skipping it saves a few seconds. Moot under `--mock`. |
|
||||
|
||||
```console
|
||||
$ meshterm devices --no-ble
|
||||
@@ -984,7 +988,7 @@ The live transcript is a menu screen; from the command line, pick a subcommand.
|
||||
| `send TEXT --channel N` | Broadcast on a channel slot. |
|
||||
| `history [--to NAME \| --channel N] [--limit N]` | Print a conversation's stored transcript. Default limit 50. |
|
||||
| `list` | Every channel and contact with its unread count and last message. |
|
||||
| `listen [-s SECONDS] [--debug]` | Tail inbound messages live. |
|
||||
| `listen [-s SECONDS] [--debug]` | Tail inbound messages live. `-s 0` (the default) runs until Ctrl-C. |
|
||||
|
||||
Exactly one of `--to` and `--channel` is required on `send` and `history`.
|
||||
|
||||
@@ -1201,7 +1205,7 @@ Walk the path to a target once and report what came back.
|
||||
| Option | What it does |
|
||||
| --- | --- |
|
||||
| `-t`, `--target NAME` | **Required.** Target node name or key prefix. |
|
||||
| `-p`, `--path SPEC` | Force a route: comma-separated contact names and/or hex hashes, mixed freely (`3d,f2,3d`). Omit it and the device routes. |
|
||||
| `--path SPEC` | Force a route: comma-separated contact names and/or hex hashes, mixed freely (`3d,f2,3d`). Omit it and the device routes. |
|
||||
|
||||
`--target` is required, so `meshterm trace --path …` alone is a usage error — the
|
||||
target-free form is [`trace-path`](#meshterm-trace-path).
|
||||
@@ -1238,7 +1242,7 @@ whichever way you choose. It only has to end within earshot of this node.
|
||||
|
||||
| Option | What it does |
|
||||
| --- | --- |
|
||||
| `-p`, `--path SPEC` | **Required.** The whole walk, comma-separated. |
|
||||
| `--path SPEC` | **Required.** The whole walk, comma-separated. |
|
||||
|
||||
```console
|
||||
$ meshterm trace-path --path "a1,d4,a1"
|
||||
@@ -1288,7 +1292,7 @@ gets through reliably.
|
||||
|
||||
| Option | What it does |
|
||||
| --- | --- |
|
||||
| `-p`, `--path SPEC` | **Required.** Forced path ending at the target (`Repeater,Target`, or `3d,f2`). The hop before the target is the node being tuned. |
|
||||
| `--path SPEC` | **Required.** Forced path ending at the target (`Repeater,Target`, or `3d,f2`). The hop before the target is the node being tuned. |
|
||||
| `-n`, `--samples N` | Traces per TX level. Default 3. |
|
||||
| `--step N` | Coarse sweep step. Default 3. |
|
||||
| `--min N` / `--max N` | Bound the sweep (else the `tx_opt_min` / `tx_opt_max` preferences). |
|
||||
@@ -1388,7 +1392,8 @@ CLI grammar, and a document that pretended to would be inventing structure. `rep
|
||||
#### `meshterm preferences`
|
||||
|
||||
How MeshTerm behaves, as opposed to how the radio is configured. Reads nothing from the
|
||||
companion, transmits nothing, and works with no device attached.
|
||||
companion, transmits nothing, and works with no device attached. With no subcommand it
|
||||
runs `show`.
|
||||
|
||||
| Subcommand | What it does |
|
||||
| --- | --- |
|
||||
@@ -1415,7 +1420,7 @@ opening the menu to find out what one does. Values print in the form `preference
|
||||
accepts back — no unit suffix on a number, `on`/`off` for a boolean. Where `VALUE` differs
|
||||
from `DEFAULT`, this install has an override.
|
||||
|
||||
Overrides live in `preferences.yaml` in [MeshTerm's state
|
||||
Overrides live in `preferences.toml` in [MeshTerm's state
|
||||
directory](#where-meshterm-keeps-its-state), which lists only what you have changed; delete
|
||||
a line and the default takes over again.
|
||||
|
||||
@@ -1433,7 +1438,7 @@ wanted it would be rendering a settings screen. A boolean prints `on`/`off` plai
|
||||
a mapping; every other `.value | tostring` goes straight back in.
|
||||
|
||||
`set` and `reset --yes` answer `{"changes":…,"applied":[…],"path":…}`, `path` being the
|
||||
`preferences.yaml` that was written.
|
||||
`preferences.toml` that was written.
|
||||
|
||||
#### `meshterm about`, `about-author`, `discord`, `support`
|
||||
|
||||
@@ -1455,7 +1460,7 @@ each link; the scripted face prints the link alone.
|
||||
**`--json`:**
|
||||
|
||||
```json
|
||||
{"page":"discord","title":"Join Discord","version":"0.2.8","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
|
||||
{"page":"discord","title":"Join Discord","version":"0.3.6","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
|
||||
```
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# MeshTerm on hardware
|
||||
|
||||
MeshTerm talks to a **node** — a MeshCore radio that speaks the companion protocol over
|
||||
serial, Bluetooth LE or TCP. Plug a companion board into USB, or pair one over Bluetooth,
|
||||
and none of this page applies: MeshTerm finds it, and the [README](../README.md) and the
|
||||
[command line manual](cli.md) are all you need.
|
||||
|
||||
This page is for the hardware that *doesn't* present a node that way. Each such device has
|
||||
its own step-by-step manual, and this page only tells you which one is yours.
|
||||
|
||||
## Which guide is yours
|
||||
|
||||
| I have… | Read |
|
||||
| --- | --- |
|
||||
| a ClockworkPi **PicoCalc**, stock or already running Calculinux on a Luckfox Lyra, and I want MeshTerm on its own screen | **[MeshTerm on the PicoCalc](picocalc.md)** — from the shopping list to a working machine |
|
||||
| …and I want a **radio** inside it | the same manual, [Phase 3](picocalc.md#phase-3--add-the-radio) — a XIAO nRF52840 + Wio-SX1262 soldered to the Lyra's UART |
|
||||
| a ClockworkPi **uConsole** with the hackergadgets AIO board — an SX1262 on the host's own SPI bus | **[MeshTerm on the uConsole](uconsole.md)** — the bridge that fronts that chip as a companion |
|
||||
| a MeshCore companion on USB, BLE or Wi-Fi | nothing here — the [README](../README.md) |
|
||||
|
||||
The two targets have nothing in common but MeshTerm itself. The device-side scripts each
|
||||
manual runs live in [`scripts/`](../scripts/), one folder per handheld; the
|
||||
[index below](#the-scripts) says what every file does and which machine it runs on. Read a
|
||||
script before you run it; most run as root and change the machine they run on.
|
||||
|
||||
## The scripts
|
||||
|
||||
[`scripts/`](../scripts/) holds the device-side helpers, one folder per manual:
|
||||
[`picocalc/`](../scripts/picocalc/) for the PicoCalc — bringing up Calculinux on the Lyra,
|
||||
building its console font, and (under [`xiao-radio/`](../scripts/picocalc/xiao-radio/))
|
||||
giving it a MeshCore radio over UART — and [`uconsole/`](../scripts/uconsole/) for the
|
||||
bridge that fronts the uConsole's SPI-attached LoRa chip as a companion. The manual is the
|
||||
walkthrough; each script's own header says what it assumes about the machine and why each
|
||||
step is the way it is. On the Lyra, only one program may hold `/dev/ttyS1` — MeshTerm
|
||||
directly, or a pump in front of it, never both.
|
||||
|
||||
| File | Runs on | What it does |
|
||||
| --- | --- | --- |
|
||||
| [`picocalc/calculinux-setup.sh`](../scripts/picocalc/calculinux-setup.sh) | the Lyra (root) | One-time bring-up: python packages, the Wi-Fi boot-scan kick, a boot clock sync (there is no RTC), the deploy user, the checkout, the venv and `pip install -e .`, the login PATH, and the console font. Idempotent. |
|
||||
| [`picocalc/calculinux-console-font-6x12.sh`](../scripts/picocalc/calculinux-console-font-6x12.sh) | the Lyra (root) | Just the console font: builds, installs and persists the 512-glyph **meshterm** PSF (braille, node marks, rounded frame corners, the list cursor) and restores the stock 16-slot palette. Calls the 6×8 build below when it sits alongside. Run it alone after a MeshTerm update. |
|
||||
| [`picocalc/calculinux-console-font-6x8.sh`](../scripts/picocalc/calculinux-console-font-6x8.sh) | the Lyra (root) | The shorter-cell companion font, **meshterm8** (6×8 → 53×40), for anyone who wants more rows than the 6×12 default gives. Its base bitmap is the kernel's `font_6x8`, so this one file and the PSF it builds are **GPL-2.0-only** ([`LICENSE.GPL-2.0`](../scripts/picocalc/LICENSE.GPL-2.0) sits beside it); it reads the donor/alias/keeper tables out of the script above rather than keeping a second copy. Pick between the two fonts from MeshTerm's Preferences page. |
|
||||
| [`picocalc/calculinux-wifi-set.sh`](../scripts/picocalc/calculinux-wifi-set.sh) | the Lyra (sudo) | Interactive Wi-Fi join: prompts for an SSID and passphrase, writes iwd's credentials, connects now. This is how a network *becomes* known to the boot kick. |
|
||||
| [`picocalc/calculinux-radio-bridge.sh`](../scripts/picocalc/calculinux-radio-bridge.sh) | the Lyra (root) | **Unfinished, unsupported.** An early UART-to-TCP pump, superseded by `xiao-radio/lyra-setup.sh`; its header describes routing it does not contain. See the manual before reading it. |
|
||||
| [`picocalc/xiao-radio/`](../scripts/picocalc/xiao-radio/) | see below | Everything for the XIAO nRF52840 + Wio-SX1262 radio wired to the Lyra's UART1; its [bench card](../scripts/picocalc/xiao-radio/README.md) has the wiring table. The firmware is MeshCore, MIT-licensed — [`LICENSE.MeshCore`](../scripts/picocalc/xiao-radio/LICENSE.MeshCore) sits beside the patch. |
|
||||
| [`xiao-radio/build-firmware.sh`](../scripts/picocalc/xiao-radio/build-firmware.sh) | dev machine | Clones and patches MeshCore, builds the `Xiao_nrf52_companion_radio_serial` environment, emits the `.uf2`. |
|
||||
| [`xiao-radio/flash.py`](../scripts/picocalc/xiao-radio/flash.py) | dev machine | Flashes the `.uf2` to the XIAO over its UF2 bootloader, refusing the wrong board or SoftDevice; prints the serial-DFU command when the bootloader offers no drive. |
|
||||
| [`xiao-radio/meshcore-uart1.patch`](../scripts/picocalc/xiao-radio/meshcore-uart1.patch) | — | The two firmware hunks: the companion on nRF52 `Serial1`, and I²C moved off the UART pads. Not upstream yet. |
|
||||
| [`xiao-radio/lyra-setup.sh`](../scripts/picocalc/xiao-radio/lyra-setup.sh) | the Lyra (root) | Boot-persistent UART1 → GP4/GP5 mux service, `dialout` membership, and a default MeshTerm serial profile for `/dev/ttyS1`. |
|
||||
| [`xiao-radio/uart1-mux.py`](../scripts/picocalc/xiao-radio/uart1-mux.py) | the Lyra (root) | The matrix-IO register poke that routes UART1 to the header pads, run once per boot by the service above. |
|
||||
| [`uconsole/meshterm-spi-bridge`](../scripts/uconsole/meshterm-spi-bridge) | the radio host (your user) | Runs a mesh node in software over an SPI-attached SX1262 (uConsole AIO, Waveshare HATs) and serves the companion protocol on `127.0.0.1:5000`. Setup menu, preflight, optional `systemd --user` service. Runs on `openhop_core` (preferred) or its predecessor `pymc_core`; see the manual for the venv it wants on a bookworm uConsole. |
|
||||
|
||||
## The regular and picocalc platforms
|
||||
|
||||
MeshTerm resolves one frozen **platform** at boot and draws everything through it. The
|
||||
**regular** platform is a desktop or ssh terminal: 72 readable columns, truecolour, emoji,
|
||||
a footer hint line. The **picocalc** platform is a framebuffer console: 53 columns, no
|
||||
emoji, an F-key lane instead of a hint line, and sixteen colour slots rather than a colour
|
||||
space — nothing loses its colour there, the node hue and the recency heat gradient
|
||||
quantise to those slots instead. It is detected from the device tree on the Lyra, and
|
||||
`--platform picocalc` or `MESHTERM_PLATFORM=picocalc` forces it anywhere, which is how you
|
||||
preview the handheld layout on a desktop:
|
||||
|
||||
```bash
|
||||
meshterm --mock --platform picocalc # the whole app, simulated radio, handheld layout
|
||||
meshterm specimen # the visual language on one card
|
||||
meshterm platform # which platform this terminal resolved to
|
||||
```
|
||||
|
||||
What that platform needs from the console — a font with the glyphs the interface draws,
|
||||
and the stock sixteen-slot palette — is installed by the PicoCalc manual's setup script;
|
||||
the details are in that manual and in the scripts' own headers.
|
||||
@@ -0,0 +1,914 @@
|
||||
# MeshTerm on the PicoCalc
|
||||
|
||||
You start with a stock ClockworkPi PicoCalc — the kit that ships with a Raspberry Pi Pico —
|
||||
and a short shopping list. You end with a Linux handheld: MeshTerm running full-screen on
|
||||
the PicoCalc's own display and keyboard, a LoRa radio soldered inside, and no laptop needed
|
||||
to use it day to day. There are three phases: build the machine (swap the Pico for a Linux
|
||||
board), install the OS and MeshTerm, and add the radio. Budget an afternoon for the first
|
||||
two phases, most of it spent waiting for a toolchain to download; the radio phase needs a
|
||||
soldering iron and can be done later — MeshTerm's `--mock` mode runs perfectly well without
|
||||
a radio in the meantime.
|
||||
|
||||
> ⚠ **Read this first.** This build takes the PicoCalc apart, swaps its core board, and
|
||||
> solders to it. It assumes you can already solder cleanly, use a multimeter, and work with
|
||||
> small electronics without forcing anything. There is a real risk of damaging the
|
||||
> hardware if a step goes wrong — a cracked screen, a snapped shell post, a dead board, or
|
||||
> an overcharged lithium cell — and some of that damage is not repairable. You do this at
|
||||
> your own risk.
|
||||
>
|
||||
> **Read the whole manual through once before you buy anything or open anything.** Several
|
||||
> steps only make sense in light of a later one — where the SD card goes, what must be out
|
||||
> of the case before a board is pressed in, which USB port is never to be powered — and the
|
||||
> shopping list is easier to get right when you know what each part is for.
|
||||
>
|
||||
> This is a guide, not a prescription. Despite the author's best efforts it may contain
|
||||
> errors, and parts of it will go out of date as boards, images and firmware change. It is
|
||||
> up to you to check every fact in it against your own hardware and the current sources
|
||||
> before you act on it, and every decision along the way is yours. The author accepts no
|
||||
> responsibility for any damage, loss or injury that results from following this guide,
|
||||
> and nothing in it is a warranty that your parts, your tools or your hands will behave the
|
||||
> way the author's did. If you are not comfortable with any step, stop and get help from
|
||||
> someone who is.
|
||||
|
||||
- [Shopping list](#shopping-list)
|
||||
- [Before you start](#before-you-start)
|
||||
- [Three ways to break it](#three-ways-to-break-it)
|
||||
- [Phase 1 — Build the machine](#phase-1--build-the-machine)
|
||||
- [Step 1 — Assemble the PicoCalc](#step-1--assemble-the-picocalc)
|
||||
- [Step 2 — Swap the Pico for the Lyra](#step-2--swap-the-pico-for-the-lyra)
|
||||
- [Step 3 — Attach the Wi-Fi dongle](#step-3--attach-the-wi-fi-dongle)
|
||||
- [Phase 2 — Install Calculinux and MeshTerm](#phase-2--install-calculinux-and-meshterm)
|
||||
- [Step 4 — Write the image to the microSD](#step-4--write-the-image-to-the-microsd)
|
||||
- [Step 5 — First boot](#step-5--first-boot)
|
||||
- [Step 6 — Open the serial console](#step-6--open-the-serial-console)
|
||||
- [Step 7 — Join Wi-Fi](#step-7--join-wi-fi)
|
||||
- [Step 8 — Copy the setup scripts to the device](#step-8--copy-the-setup-scripts-to-the-device)
|
||||
- [Step 9 — Run calculinux-setup.sh](#step-9--run-calculinux-setupsh)
|
||||
- [Step 10 — Log in as meshterm](#step-10--log-in-as-meshterm)
|
||||
- [Phase 3 — Add the radio](#phase-3--add-the-radio)
|
||||
- [Step 11 — Install PlatformIO](#step-11--install-platformio)
|
||||
- [Step 12 — Build the firmware](#step-12--build-the-firmware)
|
||||
- [Step 13 — Flash the XIAO](#step-13--flash-the-xiao)
|
||||
- [Step 14 — Wire the radio](#step-14--wire-the-radio)
|
||||
- [Step 15 — Set up the Lyra](#step-15--set-up-the-lyra)
|
||||
- [Step 16 — Run it](#step-16--run-it)
|
||||
- [Day-to-day](#day-to-day)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [What has been tested](#what-has-been-tested)
|
||||
|
||||
---
|
||||
|
||||
## Shopping list
|
||||
|
||||
### The machine
|
||||
|
||||
| Item | Product / what to search for | Approx. price | Link | Note |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| PicoCalc kit | ClockworkPi PicoCalc | $89 | [clockworkpi.com/product-page/picocalc](https://www.clockworkpi.com/product-page/picocalc) | Comes with a Raspberry Pi Pico 1H, screen, keyboard, shell, hex key and a 32 GB SD card. **18650 batteries are not included.** |
|
||||
| Luckfox Lyra | "Luckfox Lyra 128MB, pre-soldered header, no NAND" | $20–25 | [luckfox.com/Luckfox-Lyra](https://www.luckfox.com/Luckfox-Lyra) | Buy the **plain 128 MB RAM, Pico-form-factor** variant, **with the header pre-soldered**. See the note below — this is the one part of the list where the wrong SKU won't work. |
|
||||
| microSD card | 16 GB+, Class 10 or better | $6–10 | — | The PicoCalc's stock 32 GB card works fine — it's more than the 8 GB minimum. You can reuse it instead of buying a new one. |
|
||||
| Two 18650 batteries | e.g. Samsung 30Q, Molicel P26A, Sony VTC6 (examples, not endorsements) | $10–20 | — | **Unprotected**, flat-top or button-top, Ø18 × 65–69 mm. See [the battery note](#a-note-on-batteries) below. |
|
||||
| USB Wi-Fi dongle | TP-Link TL-WN725N (an RTL8188EU nano dongle), or search the **chipset name**: RTL8192CU, R8712U, RTL8188EU | $5–10 | [amazon.ca/dp/B008IFXQFU](https://www.amazon.ca/dp/B008IFXQFU) | The TL-WN725N is the dongle this guide was built with. Must run at **3.3 V** — see [Step 3](#step-3--attach-the-wi-fi-dongle). Only needed for setup and updates; MeshTerm itself runs offline. |
|
||||
| MX1.25 4-pin pigtail | "MX1.25 4P cable with leads" (a pack of pre-crimped pigtails, 10–15 cm) | $2–4 | — | One end of the DIY USB lead in [Step 3](#step-3--attach-the-wi-fi-dongle). Pre-crimped, because MX1.25 contacts need a crimper you don't otherwise own. |
|
||||
| USB-A female socket | "USB 2.0 type A female socket, solder / through-hole" | $2–3 | — | The other end of the DIY lead. A ready-made [MX1.25 4P to USB-A cable](https://spotpear.com/shop/Luckfox-Lyra-MX1.25-4P-To-USB-A-Cable.html) (about $1, 30 cm) works too, but it is a lot of cable to fold into the shell. |
|
||||
|
||||
### The radio
|
||||
|
||||
| Item | Product / what to search for | Approx. price | Link | Note |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| XIAO nRF52840 + Wio-SX1262 kit | "XIAO nRF52840 & Wio-SX1262 Kit for Meshtastic", SKU 102010710 | $13.49 | [seeedstudio.com](https://www.seeedstudio.com/XIAO-nRF52840-Wio-SX1262-Kit-for-Meshtastic-p-6400.html) | Includes the XIAO nRF52840, the Wio-SX1262 LoRa board (stacked and wired together) and an antenna. Covers both 868 and 915 MHz. Plain or Sense XIAO both work. **Do not** buy the "-N" variant — it has no antenna connector. |
|
||||
|
||||
### Tools and consumables
|
||||
|
||||
| Item | Note |
|
||||
| --- | --- |
|
||||
| Soldering iron, fine tip, and solder | For the four radio wires. |
|
||||
| Hookup wire, 26–30 AWG, four short lengths | TX and RX get crossed — see [Step 14](#step-14--wire-the-radio). |
|
||||
| 2.5 mm hex key | Comes with the PicoCalc kit. |
|
||||
| USB-C data cable | For the serial console, and later for flashing the XIAO. A charge-only cable won't do; it has to carry data. |
|
||||
| microSD card reader | For writing the Calculinux image from your PC. |
|
||||
| Small side cutters | For trimming wire ends, and a section of the case grille if the Wi-Fi lead needs the room. |
|
||||
| Small needle file | To open the back panel's USB-C cutout a little if the Lyra's connector doesn't clear it. |
|
||||
| Kapton (polyimide) tape | Wraps the XIAO stack and the DIY USB lead so nothing bare touches the board or the shell. Heat-proof, thin, and it peels off cleanly. |
|
||||
| A PC (Windows/macOS/Linux) with Python 3 and git | Runs the image-writing, firmware-build and flashing steps. |
|
||||
|
||||
### Optional
|
||||
|
||||
| Item | Why |
|
||||
| --- | --- |
|
||||
| A second 18650 battery | The holder takes two; one is enough to run, two roughly double your runtime. |
|
||||
| Powered USB hub | If you want the Wi-Fi dongle and something else on the Lyra's single USB host socket at once, or a 5 V dongle. |
|
||||
| Foam tape | To secure the XIAO board inside the shell once it's wired in. |
|
||||
|
||||
Approximate total: **$130–150** for the machine and radio together, before batteries — most
|
||||
of that is the two boards ($89 + ~$22) and the radio kit ($13.49).
|
||||
|
||||
**What you do NOT need:**
|
||||
|
||||
- A level shifter. Every signal in this build — the Lyra's header, the XIAO, the SX1262 — is
|
||||
3.3 V.
|
||||
- A separate USB-to-serial adapter. The PicoCalc mainboard already has one built in.
|
||||
- Bluetooth anything. Neither the Lyra nor this radio path uses it.
|
||||
- A new SD card, if your stock 32 GB card is handy — it's well over the 8 GB minimum
|
||||
Calculinux asks for.
|
||||
|
||||
### A note on batteries
|
||||
|
||||
ClockworkPi has not published an official battery spec for the PicoCalc — a
|
||||
[still-open GitHub issue](https://github.com/clockworkpi/PicoCalc/issues/6) asking for one
|
||||
has gone unanswered. What's known from the kit itself: the holder takes two 18650 cells, one
|
||||
cell is enough to power the board, and the physical envelope is about Ø18 mm × 65–69 mm,
|
||||
which rules out most *protected* cells — the protection circuit adds length that doesn't
|
||||
fit. Unprotected flat-top or button-top cells both work; the PicoCalc mainboard has its own
|
||||
charge circuit built in. Buy from a reputable brand rather than a cheap unbranded listing —
|
||||
Samsung 30Q, Molicel P26A and Sony VTC6 are commonly recommended examples, not an
|
||||
endorsement of any one of them.
|
||||
|
||||
---
|
||||
|
||||
## Before you start
|
||||
|
||||
Work happens on two machines, and it helps to keep straight which is which:
|
||||
|
||||
- **Your PC** writes the Calculinux image to the microSD card, builds the radio firmware,
|
||||
and flashes the XIAO. Nothing in these three jobs touches the PicoCalc itself.
|
||||
- **The PicoCalc** (really, the Luckfox Lyra inside it) does everything else — setup,
|
||||
Wi-Fi, running MeshTerm — either over the serial console from your PC or on its own
|
||||
screen and keyboard. Both work identically; the serial console is just easier for pasting
|
||||
long commands.
|
||||
|
||||
The device has two logins you'll use for different things: **`root`**, which you use once
|
||||
to run the setup script and fix Wi-Fi, and **`meshterm`**, the unprivileged account
|
||||
MeshTerm actually runs under day to day.
|
||||
|
||||
Calculinux is a console-only Linux — no desktop, no window manager. That's deliberate: the
|
||||
PicoCalc's screen and keyboard are a terminal, and MeshTerm is a full-screen terminal
|
||||
program built for exactly that.
|
||||
|
||||
---
|
||||
|
||||
## Three ways to break it
|
||||
|
||||
The PicoCalc is a kit, and its forum has a well-worn list of the ways people have damaged
|
||||
theirs. Three of them apply directly to this build, and the first two are not theoretical:
|
||||
the author of this guide **cracked a screen and snapped screw posts** building the very
|
||||
machine it describes. Read this section before you open the shell.
|
||||
|
||||
> ⚠ **The screen is extremely fragile.** It is a bare glass panel with no frame of its own,
|
||||
> and when the case is closed it sits directly against the mainboard. Anything that flexes
|
||||
> the mainboard flexes the glass: pushing a board into the Pico socket, prying one out,
|
||||
> closing the shell with the screen not sitting square in its opening, or pressing a key
|
||||
> while the halves are being screwed together. Forum members have cracked screens doing
|
||||
> each of those, and the swap in [Step 2](#step-2--swap-the-pico-for-the-lyra) is the
|
||||
> classic one — pressing a new board into the socket with the mainboard still in the case
|
||||
> is enough on its own. That is exactly how the screen in this guide's own PicoCalc broke.
|
||||
> ([Before replacing the Pico](https://forum.clockworkpi.com/t/before-replacing-the-pico-read-this-to-avoid-cracked-screen/16666),
|
||||
> [Avoid breaking your screen](https://forum.clockworkpi.com/t/avoid-breaking-your-screen/18805))
|
||||
>
|
||||
> What to do instead:
|
||||
>
|
||||
> - **Never push a board into the socket, or pull one out, with the mainboard in the case.**
|
||||
> Unplug the display's ribbon cable (the FPC) from the mainboard, lift the mainboard out,
|
||||
> and do the swap on a flat, padded surface — a folded microfibre cloth is ideal. Ease a
|
||||
> board out a little at each end in turn, never by levering one end.
|
||||
> - **Secure the screen with Kapton tape** along its edges so it cannot shift in the
|
||||
> opening while you work and while the case is closed. Kapton is the right tape: thin,
|
||||
> it stays put, and it peels off cleanly. Don't use anything that sets hard.
|
||||
> - **Before the back goes on, check the screen sits flush and square** in the front half's
|
||||
> opening with no pressure on any corner. If the mainboard doesn't sit perfectly flush on
|
||||
> the front half, stop and find out why before you add the back.
|
||||
> - Handle the panel as little as possible, and keep your fingers off the keyboard while
|
||||
> closing the shell.
|
||||
|
||||
> ⚠ **Don't overtighten the screws.** The shell's screw posts are brass inserts moulded
|
||||
> into plastic, and forcing a screw snaps the post out of the shell — a repair that needs
|
||||
> epoxy and never quite comes back
|
||||
> ([Broken screw thread](https://forum.clockworkpi.com/t/broken-screw-thread/20726)).
|
||||
> This guide's author snapped several on the first assembly; it takes less force than you
|
||||
> would think.
|
||||
> Turn each screw until it just seats, then stop. If a screw won't go in easily, the halves
|
||||
> aren't aligned, or something inside is in the way; more force is never the answer.
|
||||
|
||||
> ⚠ **Never power the Lyra through its own USB-C while the batteries are in.** The
|
||||
> PicoCalc charges its cells only through the **mainboard's** USB-C port, under the control
|
||||
> of its power-management chip (an AXP2101). Power fed in through the core board's USB port
|
||||
> — the Pico's originally, the Lyra's now — arrives on the header's system-power pin and
|
||||
> bypasses that charge control. Forum members who powered the Pico's port directly measured
|
||||
> their cells at **4.46 V and 4.74 V** afterwards, well over the 4.2 V a lithium cell is
|
||||
> allowed to reach
|
||||
> ([New PicoCalc — battery charging](https://forum.clockworkpi.com/t/new-picocalc-battery-charging/22330)).
|
||||
> An overcharged cell is a fire risk, not just a worn one.
|
||||
>
|
||||
> Nothing in this guide needs the Lyra's USB-C: the serial console, charging and power all
|
||||
> use the mainboard's port on the side of the case, and the Wi-Fi dongle uses the 4-pin
|
||||
> socket on top of the Lyra. If you ever must plug the Lyra's USB-C into a PC — to
|
||||
> reflash it with Luckfox's tools, say — **take both batteries out first.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Build the machine
|
||||
|
||||
### Step 1 — Assemble the PicoCalc
|
||||
|
||||
Follow ClockworkPi's own
|
||||
[assembly guidelines](https://github.com/clockworkpi/PicoCalc/blob/master/Clockwork_PicoCalc_Assembly_Guidelines.pdf)
|
||||
(a PDF in the [PicoCalc repository](https://github.com/clockworkpi/PicoCalc), which also
|
||||
has a wiki) to put the kit together: mainboard, screen, keyboard, speakers and shell. Insert your two
|
||||
18650 batteries, watching the `+`/`-` marks in the battery compartment.
|
||||
|
||||
⚠ Two of the warnings in [Three ways to break it](#three-ways-to-break-it) apply here: tape
|
||||
the screen's edges with Kapton before you close the shell and check it sits square, and
|
||||
turn the screws only until they seat. You will open the shell again in the next step, so
|
||||
there is no reason to make the screws tight now.
|
||||
|
||||
Power it on once with the **stock Pico still in place**. You should see the stock BASIC
|
||||
firmware boot on the screen, and the keyboard should respond. This confirms the screen,
|
||||
keyboard and battery are all good before you start swapping boards.
|
||||
|
||||
Power it back off.
|
||||
|
||||
### Step 2 — Swap the Pico for the Lyra
|
||||
|
||||
Remove the back of the shell with the hex key.
|
||||
|
||||
⚠ **Do the swap with the mainboard out of the case.** This is the step that cracks screens
|
||||
(see [Three ways to break it](#three-ways-to-break-it)). Unplug the display's ribbon cable
|
||||
from the mainboard, lift the mainboard out, and lay it on a padded surface. Only then ease
|
||||
the Raspberry Pi Pico out of its socket, a little at each end in turn — **keep it**, you're
|
||||
just setting it aside.
|
||||
|
||||
If your Luckfox Lyra has SPI NAND on board (a "Lyra B"), erase it first — the boot ROM
|
||||
otherwise ignores the SD card entirely and boots the on-board flash instead. See
|
||||
[Calculinux's hardware requirements page](https://calculinux.org/getting-started/hardware-requirements/)
|
||||
for how.
|
||||
|
||||
Seat the Lyra into the same socket the Pico came out of, in the same orientation — its
|
||||
USB-C port at the end where the Pico's USB port was, so it lines up with the cutout in the
|
||||
back of the shell. Press it down fully with the mainboard still on the padded surface; a
|
||||
Lyra that isn't fully seated won't boot. Then reconnect the ribbon cable and set the
|
||||
mainboard back into the front half, checking the screen sits square before anything is
|
||||
screwed down.
|
||||
|
||||
You should see the Lyra sitting flat on the socket with no gap at either end, and, once
|
||||
the mainboard is back in, its USB-C port centred in the back-panel cutout.
|
||||
|
||||
The cutout was made for the Pico's micro-USB, and the Lyra's USB-C connector is a
|
||||
different shape. Depending on the connector Luckfox fitted to your board, the back panel
|
||||
may not close over it. If it catches, open the hole a little with a small needle file,
|
||||
testing the fit as you go, until the panel sits down without pressing on the board.
|
||||
|
||||
Leave the back off for now. The microSD card goes into the **Lyra's own slot** in
|
||||
[Step 4](#step-4--write-the-image-to-the-microsd), and that slot is easier to reach with the
|
||||
board exposed.
|
||||
|
||||
### Step 3 — Attach the Wi-Fi dongle
|
||||
|
||||
The Lyra has two USB connectors. The USB-C on its edge is for power and flashing and is
|
||||
not used here. The **tiny 4-pin socket on top of the board** (an MX1.25 connector, next to
|
||||
the two buttons by the USB-C end) is the USB host. The Wi-Fi dongle hangs off it through a
|
||||
short lead you make yourself: a ready-made cable is 30 cm long and a struggle to fold into
|
||||
the shell, and you have the soldering iron out for the radio anyway.
|
||||
|
||||
**Make the lead.** Solder the four wires of an MX1.25 pigtail to a USB-A female socket,
|
||||
keeping the run short — 5 to 8 cm is enough to reach a spot where the dongle sits flat.
|
||||
The pigtail's pins are in the standard USB order, which the pre-made cable's wire colours
|
||||
follow:
|
||||
|
||||
| MX1.25 pin | USB-A socket pin | Signal | Pigtail wire colour (usually) |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 1 | VBUS (**3.3 V** on this board) | red |
|
||||
| 2 | 2 | D− | white |
|
||||
| 3 | 3 | D+ | green |
|
||||
| 4 | 4 | GND | black |
|
||||
|
||||
USB-A socket pins are numbered 1 to 4 across the connector's opening: VBUS on one edge,
|
||||
GND on the other, the data pair between them. Colours on a pigtail are a convention, not a
|
||||
promise, so check pin 1 before you plug anything in: with the Lyra powered, the socket's
|
||||
pin 1 reads **3.3 V** against a GND header pin and the pin at the other end reads 0 V.
|
||||
Swapping VBUS and GND is the one mistake that damages the dongle.
|
||||
|
||||
Once soldered, wrap the socket's solder side and the exposed wire in Kapton tape. Plug the
|
||||
MX1.25 end into the socket on the Lyra — it is keyed and only goes in one way — and the
|
||||
Wi-Fi dongle into the USB-A end. Lead and dongle stay inside the shell; tuck them where
|
||||
they don't press on the board.
|
||||
|
||||
The MX1.25 socket sits on top of the Lyra, and the lead leaves it upward, which is where
|
||||
the back panel's grille is. The wire may interfere with the grille when the back goes on.
|
||||
If it does, snip a small section out of the grille with the side cutters where the wire
|
||||
passes and shape the edge until nothing presses on the plug or the wire. Better to remove a
|
||||
sliver of plastic than to close the case against a connector.
|
||||
|
||||
> **The Lyra's USB port is 3.3 V, not 5 V.** A standard 5 V USB Wi-Fi dongle will not work
|
||||
> and may damage the board. Stick to the tested chipsets — RTL8192CU, R8712U, or RTL8188EU
|
||||
> — which is why the shopping list names a known dongle (the TP-Link TL-WN725N) and
|
||||
> otherwise tells you to search by chipset rather than by brand.
|
||||
|
||||
Don't power the device on yet — Wi-Fi is set up later, once Calculinux is installed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Install Calculinux and MeshTerm
|
||||
|
||||
### Step 4 — Write the image to the microSD
|
||||
|
||||
On your PC, download the latest `calculinux-image-luckfox-lyra.rootfs.wic.gz` from the
|
||||
[Calculinux releases page](https://github.com/Calculinux/meta-calculinux/releases).
|
||||
|
||||
Decompress it:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
gunzip calculinux-image-luckfox-lyra.rootfs.wic.gz
|
||||
```
|
||||
|
||||
(The Calculinux installation page mentions `unxz`, but the file is a `.gz`, so use
|
||||
`gunzip`. 7-Zip works on any platform if you'd rather not use the command line.)
|
||||
|
||||
Write the resulting `.wic` file to your microSD card. Three options:
|
||||
|
||||
- **Balena Etcher** (recommended, all platforms) — open it, select the `.wic` file, select
|
||||
your SD card, click Flash.
|
||||
- **`dd`**, on Linux/macOS. Check the device name first — writing to the wrong device
|
||||
destroys its contents:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
lsblk # find your SD card's device, e.g. /dev/sdb
|
||||
sudo dd if=calculinux-image-luckfox-lyra.rootfs.wic of=/dev/sdX bs=4M status=progress conv=fsync
|
||||
```
|
||||
|
||||
- **Rufus**, on Windows — select the device and the image, choose the **MBR** partition
|
||||
scheme, and start.
|
||||
|
||||
This erases everything already on the card. Once it finishes, eject it and put it in the
|
||||
**Luckfox Lyra's own microSD slot** — not the PicoCalc's front-facing slot. The Lyra boots
|
||||
from its own slot; the front slot is only reachable once MeshTerm (or anything else) is
|
||||
already running on Linux.
|
||||
|
||||
### Step 5 — First boot
|
||||
|
||||
With the card in the Lyra, power the PicoCalc on. Give it 30–60 seconds — the first boot is
|
||||
slower than later ones. You should see boot messages scroll on the PicoCalc's own screen,
|
||||
ending in a `Calculinux GNU/Linux` login prompt.
|
||||
|
||||
Log in:
|
||||
|
||||
```
|
||||
login: root
|
||||
Password: root
|
||||
```
|
||||
|
||||
Change the password immediately:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
passwd
|
||||
```
|
||||
|
||||
### Step 6 — Open the serial console
|
||||
|
||||
The PicoCalc's own screen and keyboard work fine, so you can skip this step. The serial
|
||||
console makes pasting the longer commands in this guide much easier, though, so it is
|
||||
recommended.
|
||||
|
||||
The PicoCalc mainboard's USB-C port carries a USB-to-UART bridge (a CH340 chip). Plug a
|
||||
USB-C cable from that port into your PC. It shows up as:
|
||||
|
||||
- `/dev/ttyUSB0` on Linux (you may need to be in the `dialout` group),
|
||||
- a `COM` port on Windows,
|
||||
- `/dev/cu.usbserial-*` on macOS.
|
||||
|
||||
Settings: **1500000 8N1**, no flow control. Using `pyserial`'s `miniterm`:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
pip install pyserial
|
||||
python3 -m serial.tools.miniterm /dev/ttyUSB0 1500000
|
||||
```
|
||||
|
||||
minicom and PuTTY work too, at the same settings. To leave `miniterm`, press **Ctrl+]**.
|
||||
|
||||
You should see the same login prompt you saw on the PicoCalc's screen, now in your PC's
|
||||
terminal.
|
||||
|
||||
### Step 7 — Join Wi-Fi
|
||||
|
||||
Calculinux manages Wi-Fi with `iwd`. The easiest way is its text-mode picker: run `uwific`,
|
||||
highlight your network, press Enter and type the passphrase (`Q` quits). Or do the same
|
||||
from the command line:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
iwctl station wlan0 scan
|
||||
iwctl station wlan0 get-networks
|
||||
iwctl station wlan0 connect "<your SSID>"
|
||||
```
|
||||
|
||||
It'll prompt for the passphrase. Check you're online:
|
||||
|
||||
```bash
|
||||
ip addr show wlan0
|
||||
ping -c 3 1.1.1.1
|
||||
```
|
||||
|
||||
You should see an `inet` address on `wlan0`, and replies from the ping. `iwd` remembers
|
||||
the network, so this is a one-time step; to change networks later, run `uwific` again.
|
||||
|
||||
### Step 8 — Copy the setup scripts to the device
|
||||
|
||||
You need this repository's `scripts/` directory on the Lyra — its `picocalc/` folder is the
|
||||
part the Lyra runs. Three ways to get it there, in order of ease:
|
||||
|
||||
- **`scp` from your PC**, once Wi-Fi is up. Find the Lyra's IP address with `ip addr show
|
||||
wlan0` on the device, then from your PC:
|
||||
|
||||
```bash
|
||||
# on the PC, from your MeshTerm checkout
|
||||
scp -r scripts root@<lyra-ip>:/root/
|
||||
```
|
||||
|
||||
If root is refused over SSH, copy as the `pico` user (password `calc`) instead, then
|
||||
move the directory as root on the device:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
scp -r scripts pico@<lyra-ip>:/home/pico/
|
||||
# on the PicoCalc, as root
|
||||
mv /home/pico/scripts /root/
|
||||
```
|
||||
|
||||
- **The microSD card.** Pull the card, copy `scripts/` onto it from your PC's card reader,
|
||||
put it back.
|
||||
- **`git clone` on the device.** Only once `git` is installed, and the setup script is what
|
||||
installs it — so this is a route for later updates, not the first copy.
|
||||
|
||||
`scp` is the recommended route.
|
||||
|
||||
### Step 9 — Run calculinux-setup.sh
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root, with scripts/ copied over
|
||||
cd scripts/picocalc
|
||||
sh calculinux-setup.sh
|
||||
```
|
||||
|
||||
It's idempotent — safe to re-run — and it checks before it acts. Two things it can't figure
|
||||
out on its own, both covered by environment variables read at the top of the script:
|
||||
|
||||
| Knob | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `DEPLOY_USER` | `meshterm` | the login MeshTerm runs under |
|
||||
| `TIMEZONE` | `America/Toronto` | any IANA zone |
|
||||
| `REPO_URL` | the project's HTTPS URL | cloned when there's no deploy key |
|
||||
| `REPO_SSH` | the project's SSH URL | cloned when there is one |
|
||||
| `KEY_PATH` | `/home/$DEPLOY_USER/.ssh/id_ed25519` | a read-only GitHub deploy key, if you want SSH instead of HTTPS |
|
||||
|
||||
You don't need to set any of these for a normal install — the defaults clone MeshTerm's
|
||||
public repository over plain HTTPS, which needs no credential at all.
|
||||
|
||||
What it does, one line per phase:
|
||||
|
||||
- **opkg packages** — installs `python3-modules`, `python3-pip`, `git`, `kbd`, because
|
||||
Calculinux ships a stripped Python missing `pip`, `venv` and several stdlib modules.
|
||||
- **Wi-Fi kick** — installs a boot-time service that re-scans until the dongle joins the
|
||||
network `iwd` already knows, because on a cold boot the dongle's firmware finishes
|
||||
loading after `iwd`'s first scan and the link would otherwise never come up.
|
||||
- **Time sync** — installs a boot-time service that sets the clock over the network, since
|
||||
this board has no battery-backed real-time clock.
|
||||
- **Timezone** — sets `$TIMEZONE` (Eastern by default).
|
||||
- **Deploy user** — creates the `meshterm` login and puts it in the groups it needs.
|
||||
- **Clone** — checks out MeshTerm to `/home/meshterm/MeshTerm`.
|
||||
- **venv and install** — builds a Python virtual environment and `pip install -e .`s
|
||||
MeshTerm into it.
|
||||
- **Login PATH** — puts that venv's `bin` on the `meshterm` user's PATH.
|
||||
- **Console font** — builds and installs the console font MeshTerm's UI needs (braille
|
||||
charts, node glyphs, rounded corners).
|
||||
|
||||
This takes a few minutes on a good connection — most of it is `pip install` compiling
|
||||
things. It ends with `done` and tells you to log in as `meshterm`. That user has no
|
||||
password yet, so set one first:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
passwd meshterm
|
||||
```
|
||||
|
||||
> **With no Wi-Fi dongle attached, the next boot takes about six minutes** to get past the
|
||||
> Wi-Fi kick, which keeps trying before it gives up. Nothing is broken. If the device is
|
||||
> going to live offline, see [Day-to-day](#day-to-day) for how to switch the kick off.
|
||||
|
||||
### Step 10 — Log in as meshterm
|
||||
|
||||
Log out of `root`, and log in as `meshterm` with the password you just set. Try the app:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as meshterm
|
||||
meshterm --mock
|
||||
```
|
||||
|
||||
You should see MeshTerm's full-screen interface come up at **53 columns**, with a simulated
|
||||
radio behind it, drawing braille charts and node glyphs cleanly — no empty tofu boxes. Along
|
||||
the bottom you should see the **F-key lane**: five labelled chips instead of the footer hint
|
||||
line a desktop terminal shows.
|
||||
|
||||
Have a look around. **Esc** backs out of any screen, and **Quit** on the main menu leaves
|
||||
the app.
|
||||
|
||||
Then look at the whole visual language on one card:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as meshterm
|
||||
meshterm specimen
|
||||
```
|
||||
|
||||
There are two console fonts available — a 6×12 one (53×26 rows) and a 6×8 one (53×40 rows,
|
||||
more rows but no Cyrillic glyphs). Try both from *Preferences → Display → Console font* —
|
||||
picking one previews it immediately, and the screen repaints at the new row count so you can
|
||||
see the choice before you keep it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Add the radio
|
||||
|
||||
This phase can wait. Everything above already gives you a working MeshTerm in `--mock`
|
||||
mode. Come back to this when you have the soldering iron out.
|
||||
|
||||
### Step 11 — Install PlatformIO
|
||||
|
||||
On your PC:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
pip install platformio
|
||||
```
|
||||
|
||||
(or use the
|
||||
[official installer script](https://docs.platformio.org/en/latest/core/installation/methods/installer-script.html)
|
||||
if you'd rather not touch your system Python). Confirm `pio` is on your PATH:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
pio --version
|
||||
```
|
||||
|
||||
The first firmware build downloads the nRF52 toolchain — several hundred megabytes — so do
|
||||
this somewhere with a decent connection.
|
||||
|
||||
### Step 12 — Build the firmware
|
||||
|
||||
```bash
|
||||
# on the PC (Git Bash or WSL on Windows)
|
||||
cd scripts/picocalc/xiao-radio
|
||||
sh build-firmware.sh
|
||||
```
|
||||
|
||||
You should see it clone MeshCore, apply a patch, build, and finish with:
|
||||
|
||||
```
|
||||
DONE. Firmware: /path/to/scripts/picocalc/xiao-radio/meshcore-xiao-radio.uf2
|
||||
Next: put the XIAO in bootloader (double-tap reset) and run: python flash.py
|
||||
```
|
||||
|
||||
Here's what it did, so you could do it by hand if you needed to:
|
||||
|
||||
1. Cloned [MeshCore](https://github.com/meshcore-dev/MeshCore) into
|
||||
`scripts/picocalc/xiao-radio/_meshcore-build`.
|
||||
2. Checked out the pinned commit `e9edfc8e` on the `dev` branch — the commit this patch is
|
||||
known to apply cleanly to and build against.
|
||||
3. Applied `meshcore-uart1.patch`.
|
||||
4. Built the `Xiao_nrf52_companion_radio_serial` PlatformIO environment:
|
||||
`pio run -e Xiao_nrf52_companion_radio_serial`.
|
||||
5. Converted the resulting `.hex` to a `.uf2` with MeshCore's own converter, using the
|
||||
nRF52840 UF2 family id:
|
||||
`python bin/uf2conv/uf2conv.py firmware.hex -c -f 0xADA52840 -o meshcore-xiao-radio.uf2`.
|
||||
|
||||
#### What the patch changes, and why it is still needed
|
||||
|
||||
`meshcore-uart1.patch` touches two files, and both hunks are required:
|
||||
|
||||
- **`examples/companion_radio/main.cpp`.** MeshCore's companion-over-serial code declares
|
||||
`HardwareSerial companion_serial(1)` — a numbered constructor. On the Adafruit nRF52 core,
|
||||
`HardwareSerial` is an abstract base class with no numbered constructor at all, so this
|
||||
line simply doesn't compile for the XIAO nRF52840. The patch adds an `#if
|
||||
defined(NRF52_PLATFORM)` branch that binds `companion_serial` to `Serial1` instead — the
|
||||
concrete UART object the nRF52 core always provides. `Uart::setPins(rx, tx)`, called later
|
||||
in the same file, takes the same arguments either way, so nothing else needs to change.
|
||||
- **`variants/xiao_nrf52/platformio.ini`.** This adds a new
|
||||
`Xiao_nrf52_companion_radio_serial` build environment that puts the companion serial link
|
||||
on pins **D6 (TX)** and **D7 (RX)**. It also **moves I²C off those same two pads**, onto
|
||||
two internal pins, 16 and 17, that nothing in this build uses. This second change is the one that matters: the
|
||||
stock `Xiao_nrf52` environment maps I²C onto D6/D7 by default, and both the board's
|
||||
`begin()` and the sensor code start the I²C bus on them automatically. Without the move,
|
||||
I²C seizes the UART pins at boot — the wiring is correct, the firmware builds and runs,
|
||||
and the serial link is still completely silent, because something else already owns the
|
||||
pins.
|
||||
|
||||
Both changes have been submitted upstream to MeshCore, but as of this writing they are
|
||||
**not merged**, and there's no sign that will happen soon — upstream `dev` still declares
|
||||
the bare `HardwareSerial(1)` with no nRF52 branch, and the XIAO's `platformio.ini` still has
|
||||
no `_serial` companion environment. Building with the patch is the normal route here, not a
|
||||
stopgap; when the patch does land, the `git apply` step (and the patch file itself) can be
|
||||
deleted.
|
||||
|
||||
#### Building by hand
|
||||
|
||||
If you want to track newer MeshCore instead of the pinned commit:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
git clone https://github.com/meshcore-dev/MeshCore.git
|
||||
cd MeshCore
|
||||
git checkout dev # or MESHCORE_COMMIT=dev sh build-firmware.sh
|
||||
git apply /path/to/scripts/picocalc/xiao-radio/meshcore-uart1.patch
|
||||
pio run -e Xiao_nrf52_companion_radio_serial
|
||||
python bin/uf2conv/uf2conv.py .pio/build/Xiao_nrf52_companion_radio_serial/firmware.hex \
|
||||
-c -f 0xADA52840 -o meshcore-xiao-radio.uf2
|
||||
```
|
||||
|
||||
If `git apply` fails — upstream has moved and the patch context no longer lines up — the two
|
||||
hunks are small enough to make by hand:
|
||||
|
||||
1. In `examples/companion_radio/main.cpp`, find the line
|
||||
`HardwareSerial companion_serial(1);` and replace it with:
|
||||
|
||||
```cpp
|
||||
#if defined(NRF52_PLATFORM)
|
||||
Uart& companion_serial = Serial1;
|
||||
#else
|
||||
HardwareSerial companion_serial(1);
|
||||
#endif
|
||||
```
|
||||
|
||||
2. At the end of `variants/xiao_nrf52/platformio.ini`, add a new environment:
|
||||
|
||||
```ini
|
||||
[env:Xiao_nrf52_companion_radio_serial]
|
||||
extends = Xiao_nrf52
|
||||
board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld
|
||||
board_upload.maximum_size = 708608
|
||||
build_flags =
|
||||
${Xiao_nrf52.build_flags}
|
||||
-I examples/companion_radio/ui-orig
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D QSPIFLASH=1
|
||||
-D SERIAL_TX=D6
|
||||
-D SERIAL_RX=D7
|
||||
-D PIN_WIRE_SCL=16
|
||||
-D PIN_WIRE_SDA=17
|
||||
build_unflags =
|
||||
-D PIN_WIRE_SCL=D6
|
||||
-D PIN_WIRE_SDA=D7
|
||||
build_src_filter = ${Xiao_nrf52.build_src_filter}
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-orig/*.cpp>
|
||||
lib_deps =
|
||||
${Xiao_nrf52.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
```
|
||||
|
||||
Only the pinned commit (`e9edfc8e`) is known to build cleanly with this patch — a newer
|
||||
`dev` may need small further adjustments.
|
||||
|
||||
### Step 13 — Flash the XIAO
|
||||
|
||||
Plug the **XIAO's own USB-C port** into your PC — it doesn't matter yet whether it's also
|
||||
wired to the Lyra. Then:
|
||||
|
||||
```bash
|
||||
# on the PC
|
||||
python flash.py
|
||||
```
|
||||
|
||||
It first tries a 1200-baud "touch" over USB serial to reset the XIAO into its bootloader —
|
||||
this only ever targets Seeed's own USB vendor id, so it won't disturb another nRF52840 board
|
||||
on the same bench. If that doesn't take, **double-tap the XIAO's reset button** and run it
|
||||
again.
|
||||
|
||||
Once it finds the bootloader, it checks two things before writing anything:
|
||||
|
||||
- **`INFO_UF2.TXT`'s `Model` field** must say a XIAO — a drive letter isn't an identity, and
|
||||
another board could have inherited the letter your XIAO just gave up.
|
||||
- **`SoftDevice` must not be `6.1.1`.** This firmware links for SoftDevice S140 v7 (app at
|
||||
`0x27000`); a S140 6.1.1 bootloader wants the app at `0x26000` and would flash successfully
|
||||
and then simply never boot.
|
||||
|
||||
`--force` overrides both checks, if you're certain. A clean flash ends with:
|
||||
|
||||
```
|
||||
DONE. XIAO flashed and rebooting into the radio firmware.
|
||||
```
|
||||
|
||||
**If no UF2 drive ever appears**, that's not necessarily wrong — some XIAO bootloaders
|
||||
expose only a serial port, no mass-storage drive at all. `flash.py` recognises this case and
|
||||
prints the fallback command instead of failing silently:
|
||||
|
||||
```bash
|
||||
# on the PC, from your MeshCore checkout
|
||||
pio run -e Xiao_nrf52_companion_radio_serial -t upload --upload-port <bootloader port>
|
||||
```
|
||||
|
||||
That command ends in `Device programmed.` and the XIAO reboots itself into the radio
|
||||
firmware.
|
||||
|
||||
Afterwards, the XIAO's USB serial port goes **silent** to companion frames — the companion
|
||||
protocol now lives on D6/D7, not on USB. That silence is correct; it doesn't mean the flash
|
||||
failed.
|
||||
|
||||
### Step 14 — Wire the radio
|
||||
|
||||
Four wires, TX and RX crossed:
|
||||
|
||||
| XIAO pad | → | Lyra header | Physical pin | Carries |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **D7** (Serial1 RX) | → | **GP4** | pin **6** | Lyra UART1 **TX** → XIAO RX |
|
||||
| **D6** (Serial1 TX) | → | **GP5** | pin **7** | XIAO **TX** → Lyra UART1 RX |
|
||||
| **GND** | → | **GND** | pin **8** | common ground |
|
||||
| **3V3** | → | **3V3 OUT** | pin **36** | power, so it runs without USB |
|
||||
|
||||
> **The Lyra does not use Raspberry Pi Pico GPIO numbering.** The 40-pin header is
|
||||
> physically Pico-shaped, but each pad's function is Luckfox's own RK3506 mapping: GP4 is
|
||||
> `gpio0-0` and GP5 is `gpio0-1`. Don't reach for a Pico pinout for anything else on this
|
||||
> header. Every signal here is 3.3 V, and there is **no 5 V rail** on this board — power the
|
||||
> XIAO from 3V3 only.
|
||||
|
||||
With the PicoCalc powered off and the back of the shell removed, solder the four wires
|
||||
between the XIAO's pads and the Lyra's header pins as above. The Lyra can stay seated in
|
||||
its socket: the tops of its header pins are reachable from inside the case, so solder to
|
||||
those. Keep the iron on each pin only as long as it takes, and don't press down on the
|
||||
board. Attach the LoRa antenna to the Wio-SX1262's u.FL connector before you
|
||||
power anything back on — running the radio without an antenna attached can damage it.
|
||||
Wrap the XIAO + Wio-SX1262 stack in Kapton tape so no pad can touch the Lyra, the
|
||||
mainboard or a battery, then tuck it into the shell wherever it fits without straining the
|
||||
wires; a strip of foam tape holds it in place against the inside of the case.
|
||||
|
||||
### Step 15 — Set up the Lyra
|
||||
|
||||
With the wiring done and the shell closed back up, copy `scripts/` to the device if you
|
||||
haven't already (Step 8), then:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
sh scripts/picocalc/xiao-radio/lyra-setup.sh # MT_USER=meshterm by default
|
||||
```
|
||||
|
||||
It does three idempotent things:
|
||||
|
||||
1. Installs `uart1-mux.py` as `/usr/local/bin/uart1-radio-mux.py`, run by a oneshot
|
||||
`uart1-radio-mux.service` on every boot. This is the part that actually makes
|
||||
`/dev/ttyS1` reach the header pins — Calculinux enables the UART1 controller but never
|
||||
wires it to a physical pad on its own, and the RK3506's flexible pin matrix needs a
|
||||
direct register poke to route it onto GP4/GP5 instead.
|
||||
2. Adds `$MT_USER` to the `dialout` group, so it can open the serial port. **This needs a
|
||||
fresh login before it takes effect.**
|
||||
3. Writes a MeshTerm profile, *only if none exists yet*, at
|
||||
`/home/$MT_USER/.meshterm/config.toml`:
|
||||
|
||||
```toml
|
||||
default_profile = "picocalc"
|
||||
|
||||
[profiles.picocalc]
|
||||
port = "/dev/ttyS1"
|
||||
baudrate = 115200
|
||||
transport = "serial"
|
||||
default_tx_power = 22
|
||||
description = "XIAO nRF52840 + SX1262 via Lyra UART1 on GP4/GP5"
|
||||
```
|
||||
|
||||
If a `config.toml` already exists, it prints those lines instead of touching your file —
|
||||
paste them in yourself.
|
||||
|
||||
### Step 16 — Run it
|
||||
|
||||
Log out and back in as `meshterm` (so the `dialout` group membership takes effect), then:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as meshterm
|
||||
meshterm
|
||||
```
|
||||
|
||||
Because `default_profile` is set, a bare `meshterm` connects straight to the radio. You
|
||||
should see your node come up under whatever name the firmware advertises — both the device
|
||||
page and the dashboard header show it.
|
||||
|
||||
If you want to prove the wiring itself before suspecting anything else, there's a deeper
|
||||
check: open `/dev/ttyS1` at 115200 as root and send a raw `APP_START` frame (`3c 0e 00 01`,
|
||||
seven zero bytes, then a name) — a live radio answers with a framed `05`, `SELF_INFO`. The
|
||||
command byte and the reply code are the companion protocol's; the `<`/`>` and
|
||||
little-endian-length framing around them come from the firmware and are worth confirming
|
||||
against it. It's a lower-level check than running MeshTerm and mostly useful for isolating
|
||||
a wiring problem.
|
||||
|
||||
---
|
||||
|
||||
## Day-to-day
|
||||
|
||||
**Charging.** Only through the mainboard's USB-C port on the side of the case. ⚠ Never
|
||||
through the Lyra's own USB-C at the back — see
|
||||
[Three ways to break it](#three-ways-to-break-it).
|
||||
|
||||
**Powering off.** Shut down cleanly rather than pulling the batteries:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc
|
||||
sudo poweroff
|
||||
```
|
||||
|
||||
**Updating MeshTerm.** Either re-run the setup script from root (it pulls the repository,
|
||||
reinstalls, and rebuilds the console font):
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
sh calculinux-setup.sh
|
||||
```
|
||||
|
||||
or, as the `meshterm` user, do just the update:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as meshterm
|
||||
cd ~/MeshTerm && git pull && .venv/bin/pip install -e .
|
||||
```
|
||||
|
||||
If an update adds new glyphs to the console font, rebuild it on its own afterwards:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
sh calculinux-console-font-6x12.sh
|
||||
```
|
||||
|
||||
**Changing Wi-Fi.** Run `uwific` as root, as in [Step 7](#step-7--join-wi-fi). The boot-time
|
||||
kick picks up the new network on its own; it only needs `iwd` to know it.
|
||||
|
||||
**The six-minute boot without a dongle.** If the device is going to run permanently offline,
|
||||
the Wi-Fi kick is wasted time on every boot. Disable it:
|
||||
|
||||
```bash
|
||||
# on the PicoCalc, as root
|
||||
systemctl disable --now wifi-kick.service
|
||||
```
|
||||
|
||||
**The console font restores itself.** MeshTerm saves whatever font the console was using
|
||||
before it starts, and puts it back when it exits — on a normal quit and on a crash alike.
|
||||
You don't need to do anything to keep the shell's own font intact.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| Nothing on the PicoCalc's screen at all | Is the SD card in the **Lyra's own slot**, not the PicoCalc's front slot? Is a "Lyra B" (SPI NAND) erased? Is it the **128 MB RAM, Pico-form-factor** variant — other RAM sizes have incompatible pinouts? |
|
||||
| Wi-Fi never comes up | Is the dongle one of the tested chipsets (RTL8192CU / R8712U / RTL8188EU)? Remember the Lyra's USB port is **3.3 V** — a 5 V dongle won't work. Give it the full six minutes on a cold boot before assuming it's stuck. |
|
||||
| `pip install` fails with `ENOSPC` | `/tmp` is a small RAM disk. `calculinux-setup.sh` already sets `TMPDIR=$HOME/tmp` for its own install; if you're running `pip` by hand, do the same. |
|
||||
| `meshterm: command not found` after setup | Log out and back in — the PATH line is added to `~/.profile`, which only takes effect on a fresh login shell. |
|
||||
| Tofu boxes instead of braille charts or node glyphs | The console font script hasn't run, or didn't persist. Re-run `sh calculinux-console-font-6x12.sh`, and check `/etc/vconsole.conf` has a `FONT=` line. |
|
||||
| `meshterm` can't open `/dev/ttyS1` | Is the `meshterm` user in `dialout` (`groups`)? Group changes need a fresh login to take effect. |
|
||||
| Port opens but the radio never answers | Re-flash with the **`_serial`** environment, not `_ble` or `_usb` — only it defines `SERIAL_RX`/`SERIAL_TX`. The XIAO's USB serial should be silent once it's running the radio firmware; that's expected, not a fault. |
|
||||
| Still silent with wiring confirmed | Confirm the firmware actually carries the I²C remap (`PIN_WIRE_SCL=16`, `PIN_WIRE_SDA=17`) — a build without the patch's second hunk looks identical until you check this. |
|
||||
| Flashed fine, XIAO never comes back | Wrong board, or the wrong SoftDevice — `INFO_UF2.TXT` must report a XIAO and **S140 v7**, not `6.1.1`. `flash.py` refuses to flash either mismatch without `--force`. |
|
||||
| `flash.py` finds no UF2 drive | Expected on a CDC-only bootloader — use the `pio … -t upload --upload-port` command it prints instead. |
|
||||
| Nothing on `/dev/ttyS1` after a reboot | `systemctl status uart1-radio-mux`, then `journalctl -b -u uart1-radio-mux` — the pin-mux poke has to run every boot, and a failed oneshot says why. |
|
||||
|
||||
---
|
||||
|
||||
## What has been tested
|
||||
|
||||
This whole path — from a stock PicoCalc kit through a running radio — has been exercised on
|
||||
one maintainer's bench: one Luckfox Lyra, one PicoCalc, one XIAO + Wio-SX1262 pair. It
|
||||
works. Some of it is also checked in code: a test parses the font script and pins it to
|
||||
MeshTerm's glyph inventory, the profile keys the scripts write are tested, the pinned
|
||||
MeshCore commit exists, the patch's two hunks are still needed on `dev`, and the nRF52840
|
||||
UF2 family id and both XIAO bootloader ids match Adafruit's board files.
|
||||
|
||||
The rest is one bench's findings, and a second device may disagree:
|
||||
|
||||
- **The RK3506 register map** in `uart1-mux.py` — the two register blocks, the UART1 signal
|
||||
ids, matrix mode, and that `gpio0-0`/`gpio0-1` are header GP4 and GP5 — was probed live
|
||||
on one board, not read from a reference manual.
|
||||
- **That the Calculinux kernel permits those `/dev/mem` writes**, and that the mux oneshot
|
||||
really runs at every boot.
|
||||
- **The header pin numbers** 6, 7, 8 and 36 for GP4, GP5, GND and 3V3. The Pico-side
|
||||
numbering is right; the carrier's wiring is a bench observation.
|
||||
- **The MX1.25 socket's pin order.** Taken from the wire colours of Luckfox's own cable,
|
||||
which is why [Step 3](#step-3--attach-the-wi-fi-dongle) has you check pin 1 with a meter.
|
||||
- **The 5 V path through the Lyra's USB-C.** The mechanism is read off the mainboard
|
||||
schematic and two forum measurements on the Pico; nobody has measured it with a Lyra.
|
||||
Treat the warning as the safe assumption it is.
|
||||
- **The Calculinux specifics the scripts assert**: the stripped Python and the package
|
||||
names that fix it, which overlays are writable, that `/tmp` is a small RAM tmpfs, the
|
||||
dongle's cold-boot race with `iwd`, the opkg feed's occasional 404, that the stock
|
||||
Terminus console font is installed, and that `kbd` brings `setvtrgb`.
|
||||
- **That this board has no RTC.** Only the script says so; the clock-sync phase follows.
|
||||
- **The XIAO bootloader that exposed no mass storage**, and the S140 v7 / `0x27000`
|
||||
requirement — public references agree, but neither is a primary source.
|
||||
- **Whether the firmware patch still applies at MeshCore `dev` HEAD.** Only the pinned
|
||||
commit is known to build.
|
||||
- **MeshCore's serial framing** around the raw `APP_START` check in
|
||||
[Step 16](#step-16--run-it): the command byte and reply code are from the protocol
|
||||
documentation; the framing bytes are not.
|
||||
- **The framebuffer console's 512-glyph cap.** Well known, taken as given, not re-measured.
|
||||
|
||||
If you build this and something disagrees with what's written here, that's worth
|
||||
reporting — a second board is the only way to learn which parts of this were about the
|
||||
hardware and which were about the one unit it was built on.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -74,6 +74,47 @@ and `prompt_params` returns only the marker for the interactive path. Trace's pi
|
||||
re-reads its `TRACED` lane after each walk (`ContactListScreen.update_rows`), because that
|
||||
is the column the list is sorted by and a returning trace changes it.
|
||||
|
||||
*Revised 2026-09-12 — TX optimize.* Keeping both pickers pushed under the sweep was the
|
||||
wrong reading of the rule for a **popup**: a popup asks, confirms or informs, and then it is
|
||||
gone — it is not a place to walk back to, and two of them stacked read as two places. The
|
||||
two picks are now one floating list that turns its page (`menus.run_wizard`, `… · step 1
|
||||
of 2`): Esc on the second step turns back to the first with the picked node still
|
||||
highlighted, Esc on the first leaves, and the box is popped before the sweep opens — so Esc
|
||||
from the sweep lands on the main menu, the sweep being the whole visit. It also *draws* as
|
||||
a popup: the menu is popped while a tool runs, and a floating screen that is the only frame
|
||||
on the stack used to be painted as the full-frame background (`_base_index`), so the first
|
||||
of the two pickers never looked like a question at all. `stay(dialog=True)` now puts the
|
||||
blank base under a popup's visit that `_run_dialog_screen` puts under a one-shot. Trace's
|
||||
picker is a full-screen contact list (a place), and stays as it is.
|
||||
|
||||
*Same day — Repeater admin.* The same shape, one popup instead of two: the node picker was
|
||||
a hub under the password prompt and the admin page. It is now a floating one-shot
|
||||
(`session.select(floating=True)`, `admin_picker.pick_admin_node`), gone once answered; the
|
||||
admin page opens over the main menu and Esc from it lands there. A refused login re-asks
|
||||
the question with the same node highlighted. `admin_node_visit` is deleted — nothing keeps
|
||||
a popup pushed any more.
|
||||
|
||||
*Same day — the base under a popup.* The blank `ScrollScreen` that `_run_dialog_screen`
|
||||
pushed on an empty stack showed nothing behind the box. A dialog is smaller than the frame,
|
||||
so what shows around it is the page it was reached from: `TuiSession._floated` now pushes
|
||||
the **root** (the main menu, popped while a tool runs but still declared for ^W), and the
|
||||
reconnect dialog draws over it too. A blank base survives only for a session with no root.
|
||||
|
||||
*Same day — the trophy case is a place.* A bare `SelectScreen` floats by default, and the
|
||||
browser was full-frame from the menu only by the compositor's fallback; opened from a
|
||||
trace it came up as a box. It declares `floating = False` now, like the contact list.
|
||||
|
||||
*Same day — Trace target.* The third of the three pickers finding H kept pushed, and the
|
||||
one the previous note said would stay: JP's call is that it is a question too. The contact
|
||||
list floats over the menu as a one-shot (`session.run_dialog`, public now), the trace screen
|
||||
opens over the menu, Esc from it lands there; tracing another node is opening the tool
|
||||
again. The typed fallback floats the same way. `ContactListScreen` stays a full-screen place
|
||||
everywhere else.
|
||||
|
||||
*Same day — a record is a page.* `RecordDialog` floated a 62-column card over the trophy
|
||||
case; the reader is *in* a record (reading, scrolling, arming a trace), so it is
|
||||
`RecordScreen`, full-frame, Esc back onto the browser.
|
||||
|
||||
**I. An entry chain of prompts was not a stack.** Five flows asked two or more things in a
|
||||
row, and Esc on any of them abandoned the whole flow rather than stepping back one — a
|
||||
mistyped 32-hex channel key cost the name typed before it. `menus.run_steps` now runs a
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
# MeshTerm on the uConsole
|
||||
|
||||
A ClockworkPi uConsole with the hackergadgets AIO LoRa board has no companion
|
||||
microcontroller and no firmware to flash — the LoRa chip sits straight on the host's SPI
|
||||
bus. So a small bridge runs the mesh node in software on the uConsole itself and puts the
|
||||
standard MeshCore companion protocol in front of it on a local TCP port. MeshTerm then
|
||||
connects to that port like it would any network companion. By the end of this guide you'll
|
||||
have the bridge running as a background service and MeshTerm talking to it.
|
||||
|
||||
> ⚠ **Read this first.** This guide edits your uConsole's boot configuration, adds your
|
||||
> user to hardware groups, installs a service under your account, and then keys a LoRa
|
||||
> transmitter. It assumes you are comfortable at a Linux shell and know which frequency
|
||||
> plan and power limits apply where you live — what the radio does on air is your
|
||||
> responsibility, not the bridge's. There is a real risk of leaving the system, the radio
|
||||
> or the mesh around you in a worse state if a step goes wrong. You do this at your own
|
||||
> risk.
|
||||
>
|
||||
> **Read the whole manual through once before you start.** Which route you take in
|
||||
> Step 2 depends on your Debian release, and whether the GUI is running changes what the
|
||||
> preflight and the service will do — both are easier to get right knowing the later steps.
|
||||
>
|
||||
> This is a guide, not a prescription. Despite the author's best efforts it may contain
|
||||
> errors, and parts of it will go out of date as packages, runtimes and boards change. It
|
||||
> is up to you to check every fact in it against your own hardware and the current sources
|
||||
> before you act on it, and every decision along the way is yours. The author accepts no
|
||||
> responsibility for any damage, loss or injury that results from following this guide.
|
||||
|
||||
- [What you need](#what-you-need)
|
||||
- [Step 1 — Prepare the system](#step-1--prepare-the-system)
|
||||
- [Step 2 — Install the radio runtime](#step-2--install-the-radio-runtime)
|
||||
- [Step 3 — Get the bridge](#step-3--get-the-bridge)
|
||||
- [Step 4 — Run the preflight](#step-4--run-the-preflight)
|
||||
- [Step 5 — Run it once in the foreground](#step-5--run-it-once-in-the-foreground)
|
||||
- [Step 6 — Install it as a service](#step-6--install-it-as-a-service)
|
||||
- [Step 7 — Install MeshTerm and connect](#step-7--install-meshterm-and-connect)
|
||||
- [Updating and uninstalling](#updating-and-uninstalling)
|
||||
- [Hardware knobs](#hardware-knobs)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [What this has been tested on](#what-this-has-been-tested-on)
|
||||
|
||||
---
|
||||
|
||||
## What you need
|
||||
|
||||
**Hardware:**
|
||||
|
||||
| Part | Notes |
|
||||
| --- | --- |
|
||||
| ClockworkPi **uConsole** (CM4 module) | Any uConsole with a Compute Module 4. |
|
||||
| hackergadgets **AIO** expansion board | The board this guide is written for and the one we recommend: an SX1262 on SPI bus 1, plus GPS, an RTL-SDR and a USB hub you don't need here. The bridge's defaults are its wiring. |
|
||||
| An antenna for your region's LoRa band | Required. |
|
||||
|
||||
Installing the AIO board into the uConsole is out of scope here — follow
|
||||
[hackergadgets' own setup guide](https://hackergadgets.com/pages/hackergadgets-uconsole-rtl-sdr-lora-gps-rtc-usb-hub-all-in-one-extension-board-setup-guide)
|
||||
for that part.
|
||||
|
||||
The bridge assumes the AIO's wiring by default:
|
||||
|
||||
| Setting | Default |
|
||||
| --- | --- |
|
||||
| SPI bus | 1 |
|
||||
| Reset pin | 25 |
|
||||
| Busy pin | 24 |
|
||||
| IRQ pin | 26 |
|
||||
| TX-enable pin | none |
|
||||
| DIO2 (RF switch) | on |
|
||||
| DIO3 (TCXO) | on |
|
||||
| Frequency | 910.525 MHz |
|
||||
| TX power | 22 dBm |
|
||||
| Spreading factor | 7 |
|
||||
| Bandwidth | 62.5 kHz |
|
||||
| Coding rate | 4/5 |
|
||||
|
||||
Every one of these is overridable with a `MESHCORE_*` environment variable if your board or
|
||||
region differs — see [Hardware knobs](#hardware-knobs).
|
||||
|
||||
**Software:**
|
||||
|
||||
- Raspberry Pi OS / Debian **bookworm** or **trixie** on the uConsole, 64-bit.
|
||||
- Python 3 (the system one is fine — the bridge finds whichever interpreter has the radio
|
||||
library, and re-execs itself under it).
|
||||
- A `/dev/spidev*` node. This needs `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt`.
|
||||
- `/dev/gpiochip0`, and membership of the `spi` and `gpio` groups so you can read and write
|
||||
both without root.
|
||||
- Optionally, the [`meshcore-uconsole`](https://github.com/cwill747/meshcore-uconsole)
|
||||
package (it brings the `meshcore-console` GUI and a radio runtime). Whether you can use it depends on your Debian release — see
|
||||
[Step 2](#step-2--install-the-radio-runtime).
|
||||
|
||||
The radio library that actually drives the chip was renamed in 2026: `pymc_core` became
|
||||
`openhop_core`. PyPI's `pymc-core` stopped at 1.0.12; `openhop-core` carries on from 1.1.x.
|
||||
The bridge supports both and prefers the newer one when it finds both installed.
|
||||
|
||||
**MeshTerm itself** — either:
|
||||
|
||||
- the Linux ARM64 one-file release from
|
||||
[the latest release page](https://github.com/jpmartineau/MeshTerm/releases/latest), or
|
||||
- an install from the repository with `pipx` (Python 3.10 or newer). Plain `pip install`
|
||||
into the system Python is refused on bookworm and trixie; `pipx` (`sudo apt install pipx`)
|
||||
gives MeshTerm a virtual environment of its own.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Prepare the system
|
||||
|
||||
Enable the SPI overlay. Edit `/boot/firmware/config.txt` and add:
|
||||
|
||||
```
|
||||
dtoverlay=spi1-1cs
|
||||
```
|
||||
|
||||
Add yourself to the groups that own the SPI and GPIO device nodes:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG spi,gpio $USER
|
||||
```
|
||||
|
||||
Reboot so both the overlay and the group membership take effect:
|
||||
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
After it comes back, check the device nodes exist and that you can use them:
|
||||
|
||||
```bash
|
||||
ls -l /dev/spidev* /dev/gpiochip0
|
||||
groups
|
||||
```
|
||||
|
||||
You should see `/dev/spidev1.0` (or another `spidev*` node) and `/dev/gpiochip0` listed,
|
||||
and `spi` and `gpio` in your `groups` output.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Install the radio runtime
|
||||
|
||||
Which route you take depends on your Debian release:
|
||||
|
||||
- **trixie**, with `meshcore-uconsole` installed from the vendor's package: you already
|
||||
have a working runtime (Python 3.13-based). Skip to [Step 3](#step-3--get-the-bridge).
|
||||
- **bookworm**: the `meshcore-uconsole` 1.12.0+ package is built for Python 3.13 and will
|
||||
**not run** — its virtualenv points at an interpreter bookworm doesn't have. Don't take
|
||||
the upgrade if apt offers it. Instead give the bridge a runtime of its own:
|
||||
|
||||
```bash
|
||||
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
|
||||
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
|
||||
```
|
||||
|
||||
This is also the route to take if you'd rather not install the `meshcore-uconsole` package
|
||||
at all.
|
||||
|
||||
You'll confirm which runtime the bridge actually found in [Step 4](#step-4--run-the-preflight) —
|
||||
its first check line names the interpreter and the runtime.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Get the bridge
|
||||
|
||||
`scripts/uconsole/meshterm-spi-bridge` is a single, self-contained Python file — no install step of
|
||||
its own. Either clone the MeshTerm repo:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jpmartineau/MeshTerm
|
||||
cd MeshTerm/scripts/uconsole
|
||||
```
|
||||
|
||||
or download just the one file:
|
||||
|
||||
```bash
|
||||
curl -L -o meshterm-spi-bridge \
|
||||
https://raw.githubusercontent.com/jpmartineau/MeshTerm/main/scripts/uconsole/meshterm-spi-bridge
|
||||
```
|
||||
|
||||
Make it executable:
|
||||
|
||||
```bash
|
||||
chmod +x meshterm-spi-bridge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Run the preflight
|
||||
|
||||
```bash
|
||||
./meshterm-spi-bridge --check
|
||||
```
|
||||
|
||||
This prints a `Prerequisite check:` block with one line per check. What each line means and
|
||||
what to do if it fails:
|
||||
|
||||
| Check | Meaning if it fails | Fix |
|
||||
| --- | --- | --- |
|
||||
| `node runtime` | Neither `openhop_core` nor `pymc_core` is importable by any interpreter the bridge tried. | Build the venv from [Step 2](#step-2--install-the-radio-runtime), or set `MESHTERM_PYMC_PYTHON` to a python that has one. |
|
||||
| `SPI device` | No `/dev/spidev*` node exists, or it exists but you can't read/write it. | No node at all: add `dtoverlay=spi1-1cs` to `/boot/firmware/config.txt` and reboot. Node present but no permission: `sudo usermod -aG spi $USER`, then log out and back in. |
|
||||
| `GPIO chip` | `/dev/gpiochip0` is missing or not read/write for you. | `sudo usermod -aG gpio $USER`, then log out and back in. |
|
||||
| `bridge service` / `radio in use` / `port 5000 busy` / `radio is free` | Tells you whether something already holds the radio: the bridge's own boot service, a running `meshcore-console`, or something else already listening on the TCP port. | Only one program may use the radio at a time — see [Step 7](#step-7--install-meshterm-and-connect). If `meshcore-console` is running, close it before running the bridge. |
|
||||
|
||||
If the first three checks all pass, you're ready to run it.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Run it once in the foreground
|
||||
|
||||
```bash
|
||||
./meshterm-spi-bridge --run
|
||||
```
|
||||
|
||||
This runs the preflight again, prints it, then starts the bridge in this terminal. A
|
||||
healthy startup logs something like:
|
||||
|
||||
```
|
||||
node runtime openhop_core 1.1.3 (/home/you/.local/share/meshterm-spi-bridge/venv/bin/python)
|
||||
radio config bus_id=1 reset_pin=25 busy_pin=24 irq_pin=26 frequency=910525000 tx_power=22
|
||||
radio.begin() attempt 1/4
|
||||
radio ready
|
||||
node identity ab12cd34ef567890… (hash 0xab, name 'uConsole')
|
||||
READY — connect with: meshterm --tcp 127.0.0.1:5000
|
||||
```
|
||||
|
||||
The `radio config` line lists every setting as `key=value`, so the one above is
|
||||
shortened; yours is longer.
|
||||
|
||||
Leave it running, and in another terminal on the same machine try:
|
||||
|
||||
```bash
|
||||
meshterm --tcp 127.0.0.1:5000
|
||||
```
|
||||
|
||||
Back in the bridge's terminal, press **Ctrl+C** to stop it.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Install it as a service
|
||||
|
||||
Run the bridge with no flags to get the interactive menu:
|
||||
|
||||
```bash
|
||||
./meshterm-spi-bridge
|
||||
```
|
||||
|
||||
Choose **2) Install as a service**. This:
|
||||
|
||||
- copies the script to `~/.local/bin/meshterm-spi-bridge`,
|
||||
- writes a `systemd --user` unit at
|
||||
`~/.config/systemd/user/meshterm-spi-bridge.service` that restarts on failure and starts
|
||||
with your session,
|
||||
- enables and starts it now,
|
||||
- tries to enable lingering for your user, so the service comes up at boot even before you
|
||||
log in. If it can't, it prints the command to run yourself:
|
||||
|
||||
```bash
|
||||
sudo loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
It also offers to add a `[profiles.bridge]` block to your MeshTerm `config.toml` — say yes,
|
||||
it's the profile you'll use in [Step 7](#step-7--install-meshterm-and-connect).
|
||||
|
||||
Check on it any time:
|
||||
|
||||
```bash
|
||||
systemctl --user status meshterm-spi-bridge.service
|
||||
journalctl --user -u meshterm-spi-bridge.service -f
|
||||
```
|
||||
|
||||
Menu option **4) Show status** prints the same information from inside the script.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Install MeshTerm and connect
|
||||
|
||||
Install MeshTerm on the uConsole. The simplest way is the Linux ARM64 one-file release:
|
||||
|
||||
```bash
|
||||
chmod +x meshterm-*-linux-arm64
|
||||
sudo mv meshterm-*-linux-arm64 /usr/local/bin/meshterm
|
||||
```
|
||||
|
||||
Or install it from the repository with `pipx`:
|
||||
|
||||
```bash
|
||||
sudo apt install pipx
|
||||
pipx install git+https://github.com/jpmartineau/MeshTerm
|
||||
pipx ensurepath
|
||||
```
|
||||
|
||||
If you accepted the profile in Step 6, your `config.toml` now has:
|
||||
|
||||
```toml
|
||||
[profiles.bridge]
|
||||
host = "127.0.0.1"
|
||||
tcp_port = 5000
|
||||
description = "Local SPI radio via meshterm-spi-bridge"
|
||||
```
|
||||
|
||||
Connect with:
|
||||
|
||||
```bash
|
||||
meshterm -p bridge
|
||||
```
|
||||
|
||||
or, without a profile:
|
||||
|
||||
```bash
|
||||
meshterm --tcp 127.0.0.1:5000
|
||||
```
|
||||
|
||||
You're connected when the device page or dashboard shows a node name (`uConsole` by
|
||||
default) instead of a connection error. MeshTerm reports the device model as
|
||||
`pyMC-spi-bridge` — that's how you tell a bridged node from a real companion.
|
||||
|
||||
**Identity.** If the `meshcore-console` GUI's package is present, the bridge loads *that*
|
||||
package's identity key, so MeshTerm and the GUI are the same node — same contacts, same
|
||||
public key. On a library-only install, the bridge mints and keeps its own identity key
|
||||
under `~/.local/share/meshterm-spi-bridge/identity.key`.
|
||||
|
||||
**Only one program may use the radio at a time.** Never run the bridge and the
|
||||
`meshcore-console` GUI at the same time — whichever started second will fail to open the
|
||||
radio.
|
||||
|
||||
---
|
||||
|
||||
## Updating and uninstalling
|
||||
|
||||
To update, pull or re-download `meshterm-spi-bridge` and reinstall the service (menu option
|
||||
2 again — it overwrites the copy in `~/.local/bin/`).
|
||||
|
||||
To remove the bridge, run the menu and choose **3) Uninstall the service**. This stops and
|
||||
removes the `systemd --user` unit and the installed script copy. It does **not** touch
|
||||
`meshcore-console`, the radio library, your node identity, or your saved contacts — those
|
||||
stay put. If you'd added `[profiles.bridge]` to your MeshTerm config, it tells you to remove
|
||||
that block by hand if you no longer want it.
|
||||
|
||||
---
|
||||
|
||||
## Hardware knobs
|
||||
|
||||
The defaults match the hackergadgets uConsole AIO, so a stock AIO needs none of these. They
|
||||
exist for a revision or a board that differs: export what differs before launching the
|
||||
bridge, or put the lines in the service's environment.
|
||||
|
||||
| Variable | What it sets |
|
||||
| --- | --- |
|
||||
| `MESHCORE_BUS_ID`, `MESHCORE_CS_ID`, `MESHCORE_CS_PIN` | which SPI bus, device and chip-select pin the radio is on |
|
||||
| `MESHCORE_RESET_PIN`, `MESHCORE_BUSY_PIN`, `MESHCORE_IRQ_PIN` | the three control lines |
|
||||
| `MESHCORE_FREQUENCY`, `MESHCORE_TX_POWER` | frequency in Hz, power in dBm |
|
||||
| `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining |
|
||||
| `MESHCORE_TXEN_PIN`, `MESHCORE_RXEN_PIN`, `MESHCORE_EN_PINS` | the RF-switch and power-enable lines a board may need (`-1` for none; `EN_PINS` is a comma list — the AIO v2 wants `27`) |
|
||||
| `MESHCORE_USE_DIO2_RF`, `MESHCORE_USE_DIO3_TCXO`, `MESHCORE_IS_WAVESHARE` | whether DIO2 drives the RF switch and DIO3 the TCXO (both on for the AIO), and a flag for a different vendor's wiring the runtime knows about |
|
||||
| `MESHCORE_GPIO_CHIP`, `MESHCORE_USE_GPIOD_BACKEND`, `MESHCORE_PREAMBLE_LENGTH` | which gpiochip, whether to drive it through `gpiod`, and the LoRa preamble |
|
||||
| `MESHTERM_PYMC_PYTHON` | force a specific interpreter instead of letting the bridge discover one |
|
||||
|
||||
The bridge passes a knob only when the runtime's radio constructor accepts it, so the same
|
||||
environment works under `pymc_core` 1.0.x, which lacks the newer ones. Where the
|
||||
`meshcore-console` package is installed, the bridge builds the radio through *its* hardware
|
||||
configuration, which reads the same names plus a few more of its own — see that package's
|
||||
documentation. On a library-only install the table above is the whole of it, so an AIO v2
|
||||
without that package present may not power its radio.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| `node runtime` fails in the preflight | Did you build the venv in [Step 2](#step-2--install-the-radio-runtime)? Is `MESHTERM_PYMC_PYTHON` pointing at the right interpreter, if set? |
|
||||
| `SPI device` shows "no /dev/spidev* found" | Is `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt`? Did you reboot after adding it? |
|
||||
| `SPI device` or `GPIO chip` shows a permission error | Are you in the `spi` and `gpio` groups (`groups`)? Did you log out and back in after `usermod`? |
|
||||
| Preflight warns "radio in use — meshcore-console is running" | Close the GUI before running or installing the bridge. |
|
||||
| Preflight warns "port 5000 busy" | Something else is already listening there — check `systemctl --user status meshterm-spi-bridge.service` for an already-running instance. |
|
||||
| Bridge starts but `radio.begin()` fails after 4 attempts | GPIO lines may still be held by a session that just closed — wait a few seconds and try again. |
|
||||
| MeshTerm connects but direct messages to an old contact say "not found" | Restart the bridge — contacts are restored from its snapshot file at startup; if the snapshot is missing, the contact needs to be heard again. |
|
||||
| Reboot, factory reset, or setting a device PIN silently does nothing | Expected — the radio library has no handler for these three, whatever runtime you're on. |
|
||||
| Traces or the live feed don't show up | These rely on compatibility shims the bridge installs (or, under `openhop_core`, on the library's own equivalents). Check the bridge's log for lines starting `compat:` to confirm they're wired. |
|
||||
|
||||
---
|
||||
|
||||
## What this has been tested on
|
||||
|
||||
This has been verified on a bookworm uConsole with `openhop-core` 1.1.3: the radio comes up,
|
||||
contacts are restored across a restart, and `info` and `contacts` answer correctly over the
|
||||
TCP connection. The older `pymc-core` 1.0.x path, with all three of the bridge's
|
||||
compatibility shims active, has been exercised in full, including traces and the live feed.
|
||||
Every interface the bridge calls in both runtimes is checked by MeshTerm's test suite.
|
||||
|
||||
What has not been confirmed:
|
||||
|
||||
- **The bridge under `openhop_core` beyond `info` and `contacts`.** Startup, identity,
|
||||
contact restore and those two reads were exercised; a trace, a message and the live feed
|
||||
under the new runtime were not, though the library's own trace push is what the bridge
|
||||
now relies on for the first.
|
||||
- **Everything on-air** — the per-hop trace semantics the bridge reassembles and the
|
||||
acknowledgement bytes newer firmware appends after the CRC. The code is internally
|
||||
consistent with the library's API; the wire behaviour was seen on one mesh, against the
|
||||
radios in range of one bench.
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MeshTerm is a full-featured TUI-based MeshCore client for your terminal.
|
||||
|
||||
Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Linux.
|
||||
@@ -5,7 +6,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
__version__ = "0.3.1"
|
||||
__version__ = "0.3.6"
|
||||
__author__ = "Jean-Pierre Martineau"
|
||||
|
||||
#: The year MeshTerm was first published; the copyright span starts here.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Enable ``python -m meshterm``."""
|
||||
|
||||
from .cli import main
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## What it is
|
||||
|
||||
**MeshTerm** is a full-featured **MeshCore** client for the terminal. It talks to companion devices running the stock firmware over serial, Bluetooth, or TCP.
|
||||
**MeshTerm** is a full-featured **MeshCore** client that lives in your terminal. Plug in a companion device over USB, pair one over Bluetooth, or reach one over TCP. Windows, macOS, and Linux. Free and open-source.
|
||||
|
||||
Nearly everything MeshTerm can do thru its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The live pictures stay in the TUI, where they belong — the map, the dashboard, the live feed, the watchtower, the mesh walk — and everything else has a command. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks! Every command prints for a person by default, and speaks JSON with `--json` when a program is reading!
|
||||
|
||||
@@ -58,12 +58,15 @@ author — a fork that keeps soliciting through it, without pointing it at
|
||||
itself, isn't affiliated with MeshTerm.
|
||||
|
||||
Built on **MeshCore** *https://meshcore.io/* — its firmware and companion
|
||||
library — with Rich, prompt_toolkit, Typer, bleak, pyserial, pycryptodome
|
||||
and Segno, each under its own terms.
|
||||
library — with Rich, prompt_toolkit, Typer, bleak, pyserial, pycryptodome,
|
||||
markdown-it-py, tomli/tomli-w and Segno, each under its own terms.
|
||||
|
||||
Map data **© OpenStreetMap contributors**, served as vector tiles by
|
||||
**OpenFreeMap** *https://openfreemap.org/*, used under the
|
||||
**ODbL** *https://www.openstreetmap.org/copyright*.
|
||||
**OpenFreeMap** *https://openfreemap.org/* on the **© OpenMapTiles** schema
|
||||
*https://openmaptiles.org/*, used under the **ODbL**
|
||||
*https://www.openstreetmap.org/copyright*. That last address is where the map's
|
||||
own corner credit points — nothing on a console is clickable, so the link is
|
||||
spelled out here instead.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ https://buymeacoffee.com/johnputer
|
||||
|
||||
### Donate via Paypal
|
||||
|
||||
- *https://tinyurl.com/SupportMeshTerm*
|
||||
- *https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6*
|
||||
|
||||
```qr
|
||||
https://tinyurl.com/SupportMeshTerm
|
||||
https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6
|
||||
```
|
||||
|
||||
### Become a patron
|
||||
|
||||
+40
-2
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Typer entry point.
|
||||
|
||||
Global options are parsed once in the callback, which builds the shared
|
||||
@@ -17,6 +18,7 @@ import typer
|
||||
from rich.console import Console
|
||||
from typer.core import TyperGroup
|
||||
|
||||
from . import __version__
|
||||
from .context import AppContext
|
||||
from .core import exitcodes, win32dll
|
||||
from .core.admin_store import AdminStore
|
||||
@@ -25,7 +27,7 @@ from .core.connection import DeviceCommandError, is_connection_lost
|
||||
from .core.device_config import DeviceConfigError
|
||||
from .core.device_store import DeviceStore
|
||||
from .core.instancelock import InstanceBusy, hold_instance_lock
|
||||
from .core.preferences import PreferenceError, Preferences
|
||||
from .core.preferences import PREFERENCES_FILENAME, PreferenceError, Preferences
|
||||
from .core.selection import DeviceSelectionError
|
||||
from .persistence.logging import configure_logging, get_logger, level_from_name, log_path
|
||||
from .persistence.repository import Repository
|
||||
@@ -126,6 +128,21 @@ app = typer.Typer(
|
||||
epilog=EXIT_STATUS_EPILOG,
|
||||
)
|
||||
|
||||
|
||||
def _print_version(value: bool) -> None:
|
||||
"""Print the version and stop, the moment ``--version`` is seen.
|
||||
|
||||
Eager, so it answers before the callback opens a database, resolves a platform or looks
|
||||
for a radio — the one question that is true of the program rather than of a run. Bare
|
||||
``meshterm <version>``: the caller asked for a version, and a script reading it back has
|
||||
one field to take. Exits ``0``, because knowing the version is a success.
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
script.console().print(f"meshterm {__version__}", highlight=False)
|
||||
raise typer.Exit(exitcodes.OK)
|
||||
|
||||
|
||||
# The context built by the callback and consumed by subcommands within one process.
|
||||
_state: AppContext | None = None
|
||||
|
||||
@@ -137,6 +154,13 @@ _platform_resolution: Resolution | None = None
|
||||
@app.callback(invoke_without_command=True)
|
||||
def main_callback(
|
||||
ctx: typer.Context,
|
||||
version: bool = typer.Option(
|
||||
False,
|
||||
"--version",
|
||||
help="Print the version and exit",
|
||||
callback=_print_version,
|
||||
is_eager=True,
|
||||
),
|
||||
profile: str | None = typer.Option(None, "--profile", "-p", help="Device profile"),
|
||||
port: str | None = typer.Option(None, "--port", help="Serial port override"),
|
||||
ble: str | None = typer.Option(
|
||||
@@ -167,6 +191,7 @@ def main_callback(
|
||||
|
||||
Args:
|
||||
ctx: The Click/Typer context.
|
||||
version: Print the version and exit (handled eagerly by :func:`_print_version`).
|
||||
profile: Named device profile to use.
|
||||
port: Explicit serial port, overriding the profile.
|
||||
ble: Explicit Bluetooth address, selecting the BLE transport.
|
||||
@@ -216,7 +241,7 @@ def main_callback(
|
||||
console = script.console() if scripted else make_console()
|
||||
# Loaded before logging is configured, because how much goes in the file is one of
|
||||
# them — and handed to the context afterwards so the file is not read twice.
|
||||
prefs = Preferences.load(settings.config_dir / "preferences.yaml")
|
||||
prefs = Preferences.load(settings.config_dir / PREFERENCES_FILENAME)
|
||||
if absolute:
|
||||
# An override of the preference rather than a second switch beside it: *how
|
||||
# MeshTerm behaves* is a preference by this project's own rule, and a flag that
|
||||
@@ -284,6 +309,17 @@ def main_callback(
|
||||
if _offer_a_console_that_can_draw_meshterm(console, prefs):
|
||||
return
|
||||
|
||||
from .services import consolefont as vt_console_font
|
||||
|
||||
# The PicoCalc's whole console shares one font, so asking for the 6x8 build changes
|
||||
# what the *shell* is drawn in too. Save the reader's font before the first paint
|
||||
# and put it back on the way out — from the `finally` here, and from an `atexit`
|
||||
# remember() arms, since ^Q and an unhandled error leave by other doors. This is
|
||||
# the menu's boundary only: a scripted run prints into a console it was invited
|
||||
# into and never touches its font.
|
||||
saved_font = vt_console_font.remember()
|
||||
vt_console_font.apply(prefs.get("console_font"))
|
||||
|
||||
# The interactive session is the one that holds the stores in memory and rewrites
|
||||
# them whole, so it is the one that claims the directory. One-shot subcommands are
|
||||
# brief and mostly read; blocking `meshterm contacts` because a menu is open in
|
||||
@@ -309,6 +345,8 @@ def main_callback(
|
||||
get_logger().exception("the interactive session raised an unhandled error")
|
||||
_report_log_location(console, settings.config_dir)
|
||||
raise
|
||||
finally:
|
||||
vt_console_font.restore(saved_font)
|
||||
|
||||
|
||||
def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences) -> bool:
|
||||
|
||||
+39
-9
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The application context: a small dependency container passed to every tool.
|
||||
|
||||
``AppContext`` owns the shared, expensive singletons (console, settings, repository,
|
||||
@@ -22,7 +23,7 @@ from .core.courier_store import CourierStore
|
||||
from .core.device_store import DeviceStore
|
||||
from .core.discovery import DiscoveredDevice, discover_devices
|
||||
from .core.mute_store import MuteStore
|
||||
from .core.preferences import Preferences
|
||||
from .core.preferences import PREFERENCES_FILENAME, Preferences
|
||||
from .core.preferences import install as install_preferences
|
||||
from .core.remote_store import RemoteStore
|
||||
from .core.selection import resolve_device
|
||||
@@ -37,6 +38,7 @@ if TYPE_CHECKING:
|
||||
from .services.basemap import BasemapSource
|
||||
from .services.battery_service import BatteryService
|
||||
from .services.chat_service import ChatService
|
||||
from .services.clock_sync import ClockSync
|
||||
from .services.courier import CourierService
|
||||
from .services.device_state import DeviceState
|
||||
from .services.event_hub import EventHub
|
||||
@@ -55,7 +57,7 @@ class AppContext:
|
||||
talk to (config directory, database, named profiles). *How MeshTerm behaves*
|
||||
is ``preferences``, not this.
|
||||
preferences: MeshTerm's own preferences, backed by
|
||||
``<config_dir>/preferences.yaml`` and edited on the Preferences page. Defaults
|
||||
``<config_dir>/preferences.toml`` and edited on the Preferences page. Defaults
|
||||
to a set loaded from that file when not injected, and is installed as the
|
||||
process-wide :func:`~meshterm.core.preferences.current` set so the render
|
||||
layer — which has no context to reach through — reads the same values.
|
||||
@@ -154,6 +156,7 @@ class AppContext:
|
||||
_chat: ChatService | None = field(default=None, init=False, repr=False)
|
||||
_adverts: AdvertScheduler | None = field(default=None, init=False, repr=False)
|
||||
_watchtower: WatchtowerService | None = field(default=None, init=False, repr=False)
|
||||
_clock_sync: ClockSync | None = field(default=None, init=False, repr=False)
|
||||
_courier: CourierService | None = field(default=None, init=False, repr=False)
|
||||
_battery: BatteryService | None = field(default=None, init=False, repr=False)
|
||||
_devstate: DeviceState | None = field(default=None, init=False, repr=False)
|
||||
@@ -168,7 +171,7 @@ class AppContext:
|
||||
stores built without a context still have to read them.
|
||||
"""
|
||||
if self.preferences is None:
|
||||
self.preferences = Preferences.load(self.settings.config_dir / "preferences.yaml")
|
||||
self.preferences = Preferences.load(self.settings.config_dir / PREFERENCES_FILENAME)
|
||||
install_preferences(self.preferences)
|
||||
if self.advert_store is None:
|
||||
self.advert_store = AdvertStore(self.settings.config_dir / "adverts.json")
|
||||
@@ -357,6 +360,21 @@ class AppContext:
|
||||
self._watchtower = WatchtowerService(self)
|
||||
return self._watchtower
|
||||
|
||||
@property
|
||||
def clock_sync(self) -> ClockSync:
|
||||
"""Return the session's on-connect clock setter, creating it on first use.
|
||||
|
||||
Created idle here; the interactive session starts it alongside the other
|
||||
always-on services, and :meth:`device` tells it about each connection as it
|
||||
settles. Scripted CLI runs never start it, so a one-shot command that only reads
|
||||
the radio never writes its clock.
|
||||
"""
|
||||
if self._clock_sync is None:
|
||||
from .services.clock_sync import ClockSync
|
||||
|
||||
self._clock_sync = ClockSync(self)
|
||||
return self._clock_sync
|
||||
|
||||
@property
|
||||
def courier(self) -> CourierService:
|
||||
"""Return the session's store-and-forward courier, creating it on first use.
|
||||
@@ -514,8 +532,7 @@ class AppContext:
|
||||
self._active_port = None
|
||||
self._active_address = None
|
||||
await self._open(f"TCP companion {tcp_host}:{tcp_port}")
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
await self._settle_connection()
|
||||
return self._device
|
||||
|
||||
# A Bluetooth endpoint (an explicit ``--ble``, a BLE profile, a device picked at
|
||||
@@ -548,8 +565,7 @@ class AppContext:
|
||||
self._active_port = None
|
||||
self._active_endpoint = None
|
||||
await self._open(f"BLE companion {ble_address}")
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
await self._settle_connection()
|
||||
return self._device
|
||||
|
||||
resolution = resolve_device(
|
||||
@@ -567,8 +583,7 @@ class AppContext:
|
||||
self._active_address = None
|
||||
self._active_endpoint = None
|
||||
await self._open(f"serial port {resolution.port}")
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
await self._settle_connection()
|
||||
return self._device
|
||||
|
||||
def _resolve_tcp_endpoint(self) -> tuple[str | None, int | None]:
|
||||
@@ -655,6 +670,19 @@ class AppContext:
|
||||
hardware_model=await self._hardware_model(),
|
||||
)
|
||||
|
||||
async def _settle_connection(self) -> None:
|
||||
"""The steps every fresh connection gets once the link is open, in order.
|
||||
|
||||
Remember the device as the default, replay its remembered channels, and hand it to
|
||||
the on-connect clock setter — which returns at once and does its work in the
|
||||
background, so the connection is usable the moment this returns. Each step is
|
||||
best-effort on its own; none can fail the connection.
|
||||
"""
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
if self._device is not None:
|
||||
self.clock_sync.on_connected(self._device)
|
||||
|
||||
async def _reconcile_channels(self) -> None:
|
||||
"""Replay channels remembered for this device that it isn't reporting (best-effort).
|
||||
|
||||
@@ -791,6 +819,8 @@ class AppContext:
|
||||
await self._battery.aclose()
|
||||
if self._watchtower is not None:
|
||||
await self._watchtower.aclose()
|
||||
if self._clock_sync is not None:
|
||||
await self._clock_sync.aclose()
|
||||
if self._adverts is not None:
|
||||
await self._adverts.aclose()
|
||||
if self._monitor is not None:
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Core domain layer: models, configuration, and the device connection abstraction."""
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for remembered remote-node admin passwords.
|
||||
|
||||
The remote-admin optimizer logs in to a repeater to tune its TX power; storing the
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for the background-advert schedule.
|
||||
|
||||
MeshTerm announces the connected node on a cadence — a zero-hop (direct) advert for the
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""THE way MeshTerm replaces a file it owns: all of the new contents, or none of them.
|
||||
|
||||
Every store under ``core/`` keeps its state in one file that it rewrites whole. Writing
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Reading the device's channel slots back off the radio.
|
||||
|
||||
One configured channel slot (:class:`ChannelSlot`) and the two reads that discover them.
|
||||
Every derived property on the slot delegates to :mod:`meshterm.core.channels`, which owns the
|
||||
key/identity maths; this module is only the device-facing half — the scan, and what it means
|
||||
when the scan stops.
|
||||
|
||||
It lives in :mod:`meshterm.core` rather than beside the channel manager screen because the
|
||||
screen is not the only reader: the device-state session cache
|
||||
(:class:`~meshterm.services.device_state.DeviceState`) warms the probe, the ``channels`` and
|
||||
``chat`` tools read slots for their CLI faces, and a service must never have to reach up into
|
||||
``ui/`` for a device read. It is a separate module from :mod:`meshterm.core.channels` because
|
||||
it needs :class:`~meshterm.core.connection.Device`, and ``connection`` already imports
|
||||
``channels`` — keeping the maths free of the transport is what keeps that direction one-way.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .channels import (
|
||||
CHANNEL_SLOT_EMPTY_RUN,
|
||||
CHANNEL_SLOT_PROBE_CAP,
|
||||
channel_hash,
|
||||
channel_identity,
|
||||
full_channel_hash,
|
||||
is_name_derived,
|
||||
is_public_channel,
|
||||
)
|
||||
from .connection import Device, DeviceCommandError, is_connection_lost
|
||||
from .models import Conversation
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChannelSlot:
|
||||
"""One configured channel slot read back from the device.
|
||||
|
||||
Attributes:
|
||||
idx: The 0-based slot index.
|
||||
name: The channel's name.
|
||||
secret: The channel's 16-byte shared secret.
|
||||
"""
|
||||
|
||||
idx: int
|
||||
name: str
|
||||
secret: bytes
|
||||
|
||||
@property
|
||||
def is_name_derived(self) -> bool:
|
||||
"""Whether this channel's key is reproducible from its name (so it needn't be stored)."""
|
||||
return is_name_derived(self.name, self.secret)
|
||||
|
||||
@property
|
||||
def is_public(self) -> bool:
|
||||
"""Whether this channel is public (shared meshwide) rather than a private one.
|
||||
|
||||
Covers both name-derived channels and the firmware's fixed-key default ``Public``.
|
||||
"""
|
||||
return is_public_channel(self.name, self.secret)
|
||||
|
||||
@property
|
||||
def hash(self) -> str:
|
||||
"""The channel's two-character hash fingerprint (the leading byte MeshCore shows)."""
|
||||
return channel_hash(self.secret)
|
||||
|
||||
@property
|
||||
def full_hash(self) -> str:
|
||||
"""The channel's complete ``sha256(secret)`` digest; its first byte is :attr:`hash`."""
|
||||
return full_channel_hash(self.secret)
|
||||
|
||||
@property
|
||||
def identity(self) -> str:
|
||||
"""The channel's slot-independent identity, used to key its chat history."""
|
||||
return channel_identity(self.name, self.secret)
|
||||
|
||||
@property
|
||||
def conversation(self) -> Conversation:
|
||||
"""A :class:`~meshterm.core.models.Conversation` for opening this channel in chat."""
|
||||
return Conversation(
|
||||
label=self.name,
|
||||
is_channel=True,
|
||||
channel_idx=self.idx,
|
||||
channel_id=self.identity,
|
||||
secret=self.secret,
|
||||
)
|
||||
|
||||
|
||||
async def read_channel_slots(device: Device) -> list[ChannelSlot]:
|
||||
"""Probe the channel slots and return the configured ones, in index order.
|
||||
|
||||
The scan stops as soon as the firmware rejects a slot index, so it reads exactly the
|
||||
slots a well-behaved device has regardless of its capacity. Firmware that never rejects
|
||||
an out-of-range index (it answers every slot with an empty payload instead of raising)
|
||||
would otherwise walk all :data:`CHANNEL_SLOT_PROBE_CAP` slots on every read; a run of
|
||||
:data:`CHANNEL_SLOT_EMPTY_RUN` consecutive empty slots ends the scan on such firmware.
|
||||
That is safe because the manager packs channels from slot 0 up, so an unbroken empty run
|
||||
that long means every configured channel has already been seen (see the constant's note).
|
||||
|
||||
Args:
|
||||
device: The connected device to query.
|
||||
|
||||
Returns:
|
||||
One :class:`ChannelSlot` per configured slot (an empty slot is skipped).
|
||||
"""
|
||||
slots, _complete = await probe_channel_slots(device)
|
||||
return slots
|
||||
|
||||
|
||||
async def probe_channel_slots(device: Device) -> tuple[list[ChannelSlot], bool]:
|
||||
"""The probe behind :func:`read_channel_slots`, reporting whether it *finished*.
|
||||
|
||||
The scan stops for two very different reasons and the plain list cannot tell them
|
||||
apart. Off the end of the configured slots (a rejected index, or a long enough run of
|
||||
empty ones) the answer is complete and worth keeping. A read that simply *failed* —
|
||||
a timeout, a link hiccup, and then every subsequent read failing too because the
|
||||
firmware's reply no longer matches the slot asked for — ends the scan early with
|
||||
whatever it happened to have, which is not the device's layout and must not be cached
|
||||
or acted on as if it were: an empty one reads as "no channels configured", and a
|
||||
truncated one makes the next free slot look free when a channel is sitting in it.
|
||||
|
||||
The two endings raise different things, and that is what separates them: the firmware
|
||||
*answering* "no such slot" surfaces as a plain rejection, while a link that stopped
|
||||
answering surfaces as a timeout, a :class:`~meshterm.core.connection.DeviceCommandError`
|
||||
(whose whole subject is a companion that did not reply in time), or one of the dropped-
|
||||
link signatures :func:`~meshterm.core.connection.is_connection_lost` knows. Only the
|
||||
first ending is a layout. A read that fails for some fourth reason is treated as a
|
||||
rejection, which is the safe way round: the list is used but the cache re-probes.
|
||||
|
||||
Args:
|
||||
device: The connected device to query.
|
||||
|
||||
Returns:
|
||||
The slots read, and whether the scan ran to a clean end.
|
||||
"""
|
||||
slots: list[ChannelSlot] = []
|
||||
empty_run = 0
|
||||
for idx in range(CHANNEL_SLOT_PROBE_CAP):
|
||||
try:
|
||||
payload = await device.get_channel(idx)
|
||||
except Exception as exc: # noqa: BLE001 - a rejected index, or a read that failed
|
||||
return slots, not _read_failed(exc)
|
||||
if payload and payload.get("channel_name"):
|
||||
empty_run = 0
|
||||
slots.append(
|
||||
ChannelSlot(
|
||||
idx=idx,
|
||||
name=str(payload["channel_name"]),
|
||||
secret=bytes(payload.get("channel_secret") or b"\x00" * 16),
|
||||
)
|
||||
)
|
||||
else:
|
||||
empty_run += 1
|
||||
if empty_run >= CHANNEL_SLOT_EMPTY_RUN:
|
||||
break # off the end of a never-rejecting firmware; nothing more to find
|
||||
return slots, True
|
||||
|
||||
|
||||
def _read_failed(exc: BaseException) -> bool:
|
||||
"""Whether ``exc`` means the *read* failed, rather than the firmware refusing a slot.
|
||||
|
||||
A refusal is the probe's ordinary ending and leaves a complete list behind it. A
|
||||
failure leaves a short one that looks exactly the same — which is the whole reason
|
||||
this distinction has to be drawn somewhere (see :func:`probe_channel_slots`).
|
||||
"""
|
||||
return isinstance(exc, (TimeoutError, DeviceCommandError)) or is_connection_lost(exc)
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for channels created through MeshTerm, so they survive a device that forgets.
|
||||
|
||||
Most companions keep their own channel table in firmware, so MeshTerm reads channels live from
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Pure channel logic: secret derivation, MeshCore share-URL round-tripping, and decrypt.
|
||||
|
||||
A MeshCore channel is a name plus a 16-byte shared secret. *Public* channels (whose name
|
||||
@@ -35,6 +36,13 @@ from urllib.parse import parse_qs, quote, urlsplit
|
||||
#: be followed by ``": "`` — conservative enough to leave ``http://…`` and ``note:x`` alone.
|
||||
SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,19}):[ \t]+(.*)$", re.DOTALL)
|
||||
|
||||
#: Matches an ``@[Name]`` mention token, as the reply flow primes into the compose line. A
|
||||
#: transcript renders each as a bare ``@Name`` coloured in that sender's hue instead of
|
||||
#: showing the literal brackets, and the CLI's history face lifts the same names out — so the
|
||||
#: token lives here with the rest of the message-body parsing rather than in either renderer.
|
||||
#: Name is 1–20 non-``]`` characters.
|
||||
MENTION = re.compile(r"@\[([^\]]{1,20})\]")
|
||||
|
||||
|
||||
def split_channel_sender(text: str) -> tuple[str | None, str]:
|
||||
"""Split a channel message into ``(sender_name, body)`` when it carries a name prefix.
|
||||
@@ -74,7 +82,7 @@ MAX_CHANNELS = 8
|
||||
CHANNEL_SLOT_PROBE_CAP = 64
|
||||
|
||||
#: A run of this many consecutive *empty* slots ends a *configured-channel* scan (see
|
||||
#: :func:`meshterm.ui.channels.read_channel_slots`) on firmware that never rejects an
|
||||
#: :func:`meshterm.core.channel_probe.read_channel_slots`) on firmware that never rejects an
|
||||
#: out-of-range index — some builds answer every index with an empty payload instead of
|
||||
#: raising, so the scan would otherwise walk all :data:`CHANNEL_SLOT_PROBE_CAP` slots on
|
||||
#: every read. The channel manager packs channels from slot 0 up within the stock
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Machine setup and named device profiles.
|
||||
|
||||
Settings are read from a TOML file (default ``~/.meshterm/config.toml``) and may
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""TOML backup and restore for device configuration.
|
||||
|
||||
A backup captures every registry setting's current value plus experimental custom
|
||||
|
||||
+338
-47
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Device connection abstraction.
|
||||
|
||||
Services and tools depend only on the :class:`Device` interface, never on the
|
||||
@@ -18,7 +19,7 @@ import logging
|
||||
import random
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -41,6 +42,7 @@ from .models import (
|
||||
advert_time,
|
||||
utcnow,
|
||||
)
|
||||
from .tracing import path_hash_flags, trace_timeout
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .discovery import DiscoveredDevice
|
||||
@@ -210,6 +212,41 @@ class DeviceAuthenticationError(DeviceCommandError):
|
||||
#: ``ERR_CODE_NOT_FOUND`` — the companion has no entry matching what a command addressed.
|
||||
_ERR_NOT_FOUND = 2
|
||||
|
||||
#: ``CMD_SET_AUTOADD_CONFIG`` — the auto-add bitmask, and (as an optional second byte, which
|
||||
#: the ``meshcore`` library never sends) the hop limit.
|
||||
_CMD_SET_AUTOADD_CONFIG = 58
|
||||
|
||||
#: ``RESP_CODE_AUTOADD_CONFIG`` — the reply to a read of the auto-add configuration. Its
|
||||
#: second payload byte is the hop limit, which the library's parser drops (it keeps only the
|
||||
#: bitmask), so :meth:`MeshCoreDevice.get_autoadd_config` recognises the raw frame by this.
|
||||
_RESP_AUTOADD_CONFIG = 25
|
||||
|
||||
#: Sentinel: no hop limit has been read alongside the last auto-add bitmask.
|
||||
_UNREAD = object()
|
||||
|
||||
#: The client-repeat frequencies (MHz) the companion firmware allows when a board defines
|
||||
#: none of its own (``repeat_freq_ranges`` in MeshCore's ``examples/companion_radio/MyMesh.cpp``).
|
||||
_DEFAULT_REPEAT_FREQS: tuple[tuple[float, float], ...] = (
|
||||
(433.0, 433.0),
|
||||
(869.495, 869.495),
|
||||
(918.0, 918.0),
|
||||
)
|
||||
|
||||
#: How far a radio frequency may sit from an allowed repeat range and still be inside it
|
||||
#: (MHz). Frequencies travel in kHz, so half of one is the whole rounding error.
|
||||
_REPEAT_FREQ_TOLERANCE_MHZ = 0.0005
|
||||
|
||||
|
||||
def repeat_freq_allowed(freq: float, ranges: Iterable[tuple[float, float]]) -> bool:
|
||||
"""Whether ``freq`` (MHz) lies in one of the firmware's client-repeat ``ranges``.
|
||||
|
||||
The firmware's own ``isValidClientRepeatFreq``, in MHz: it refuses to turn relaying on
|
||||
anywhere else.
|
||||
"""
|
||||
tol = _REPEAT_FREQ_TOLERANCE_MHZ
|
||||
return any(low - tol <= float(freq) <= high + tol for low, high in ranges)
|
||||
|
||||
|
||||
#: What each companion error code means, in a sentence that finishes "the device …".
|
||||
#: The wire carries only the number and the library's ``ERR_CODE_*`` spelling (see
|
||||
#: ``meshcore.events.ErrorMessages``), which is diagnostic text, not something to put in
|
||||
@@ -669,7 +706,7 @@ class Device(ABC):
|
||||
timeout: Seconds to wait for the trace reply. ``None`` (the default) sizes the
|
||||
wait to the route: a trace has to travel the whole path out and back, so a
|
||||
long walk is given proportionally longer to come home
|
||||
(:func:`~meshterm.services.trace_runner.trace_timeout`).
|
||||
(:func:`~meshterm.core.tracing.trace_timeout`).
|
||||
|
||||
Returns:
|
||||
A :class:`TraceResult`; ``success`` is ``False`` on timeout.
|
||||
@@ -754,6 +791,24 @@ class Device(ABC):
|
||||
advert types the firmware adds to contacts automatically.
|
||||
"""
|
||||
|
||||
async def get_autoadd_max_hops(self) -> int | None:
|
||||
"""Return the auto-add hop limit, or ``None`` where the firmware doesn't report one.
|
||||
|
||||
The second byte of the firmware's auto-add configuration (``autoadd_max_hops``,
|
||||
capped at 64). Not abstract: a device without the field has nothing to say, and the
|
||||
snapshot leaves its row unread rather than failing.
|
||||
"""
|
||||
return None
|
||||
|
||||
async def get_allowed_repeat_freqs(self) -> list[tuple[float, float]]:
|
||||
"""Return the frequency ranges (MHz, inclusive) client repeat may be enabled on.
|
||||
|
||||
The firmware refuses to relay outside them (``isValidClientRepeatFreq``), so the
|
||||
editor can check before staging instead of after a refusal. Empty where the firmware
|
||||
doesn't say — which means *unknown*, not *nowhere*.
|
||||
"""
|
||||
return []
|
||||
|
||||
@abstractmethod
|
||||
async def get_default_flood_scope(self) -> str | None:
|
||||
"""Return the persisted default flood scope's name (``""`` when unset).
|
||||
@@ -858,7 +913,9 @@ class Device(ABC):
|
||||
"""Set the device's BLE pairing PIN."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_radio(self, freq: float, bw: float, sf: int, cr: int) -> None:
|
||||
async def set_radio(
|
||||
self, freq: float, bw: float, sf: int, cr: int, repeat: bool | None = None
|
||||
) -> None:
|
||||
"""Set the core radio parameters.
|
||||
|
||||
Args:
|
||||
@@ -866,6 +923,11 @@ class Device(ABC):
|
||||
bw: Bandwidth in kHz.
|
||||
sf: Spreading factor.
|
||||
cr: Coding rate denominator (``5``-``8`` for 4/5-4/8).
|
||||
repeat: Whether the companion relays mesh traffic (client repeat, firmware v9+).
|
||||
The firmware takes it as an optional trailing byte of this same command and
|
||||
reads the byte's *absence* as off — so a caller changing any radio field on
|
||||
firmware that reports it must restate it, or the change quietly stops the
|
||||
relaying. ``None`` omits the byte, for firmware that predates it.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@@ -897,8 +959,12 @@ class Device(ABC):
|
||||
"""Set the three telemetry mode fields together (each ``0``-``3``)."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_autoadd_config(self, flags: int) -> None:
|
||||
"""Set the contact auto-add bitmask (see :meth:`get_autoadd_config`)."""
|
||||
async def set_autoadd_config(self, flags: int, max_hops: int | None = None) -> None:
|
||||
"""Set the contact auto-add bitmask (see :meth:`get_autoadd_config`), and its hop limit.
|
||||
|
||||
The firmware reads the hop limit as an optional second byte and leaves it where it
|
||||
was when that byte is absent, so ``None`` changes the bitmask alone.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def set_default_flood_scope(self, scope: str) -> None:
|
||||
@@ -1704,7 +1770,13 @@ class MeshCoreDevice(Device):
|
||||
async def get_self_info(self) -> dict: # noqa: D102 - inherited docstring
|
||||
mc = self._require()
|
||||
result = await mc.commands.send_appstart()
|
||||
return dict(getattr(result, "payload", {}) or {})
|
||||
info = dict(getattr(result, "payload", {}) or {})
|
||||
# The firmware sends TX power as a signed byte (it accepts down to -9 dBm) and the
|
||||
# library reads it unsigned, so a negative power would arrive as 247 and up.
|
||||
power = info.get("tx_power")
|
||||
if isinstance(power, int) and power > 127:
|
||||
info["tx_power"] = power - 256
|
||||
return info
|
||||
|
||||
async def get_device_info(self) -> dict: # noqa: D102 - inherited docstring
|
||||
mc = self._require()
|
||||
@@ -1714,18 +1786,29 @@ class MeshCoreDevice(Device):
|
||||
return {}
|
||||
return dict(getattr(result, "payload", {}) or {})
|
||||
|
||||
async def _contacts_payload(self, mc, *, retries: int = 3, delay: float = 0.5) -> dict: # noqa: ANN001
|
||||
"""Fetch the raw contacts map, retrying the transient "no event" blip.
|
||||
#: How long the contacts stream may go quiet before a read is called failed (seconds).
|
||||
#: The library's own ``get_contacts`` arms one future for the *whole* dump and never
|
||||
#: re-arms it, so a table that takes longer than its five seconds to stream fails with
|
||||
#: "no event received" however healthy the radio is — which is what a node holding four
|
||||
#: hundred contacts does over Bluetooth. What actually means "the companion stopped
|
||||
#: answering" is a gap *between* records, so that is what this times: the dump may take
|
||||
#: as long as it takes, for as long as it keeps arriving.
|
||||
_CONTACTS_IDLE_S = 6.0
|
||||
|
||||
The companion intermittently fails to emit the contacts event in time and
|
||||
returns an error event instead of data (``no event received during contacts
|
||||
retrieval``). That's a recoverable timing hiccup, so retry a few times with a
|
||||
short backoff before surfacing a clean, actionable error.
|
||||
async def _contacts_payload(
|
||||
self, mc, *, retries: int = 3, delay: float = 0.5, idle: float | None = None
|
||||
) -> dict: # noqa: ANN001
|
||||
"""Fetch the raw contacts map, retrying a read that stalls or is refused.
|
||||
|
||||
The companion intermittently refuses or drops a contacts request — a recoverable
|
||||
timing hiccup — so the read is attempted a few times with a short backoff before a
|
||||
clean, actionable error is raised. One attempt is :meth:`_stream_contacts`.
|
||||
|
||||
Args:
|
||||
mc: The connected ``MeshCore`` client.
|
||||
retries: Number of extra attempts after the first.
|
||||
delay: Seconds to wait between attempts.
|
||||
idle: Seconds of silence that end an attempt (default :data:`_CONTACTS_IDLE_S`).
|
||||
|
||||
Returns:
|
||||
The contacts payload mapping (possibly empty).
|
||||
@@ -1733,13 +1816,12 @@ class MeshCoreDevice(Device):
|
||||
Raises:
|
||||
DeviceCommandError: If every attempt fails to retrieve contacts.
|
||||
"""
|
||||
gap = self._CONTACTS_IDLE_S if idle is None else idle
|
||||
reason = ""
|
||||
for attempt in range(retries + 1):
|
||||
event = await mc.commands.get_contacts()
|
||||
if event is None or not getattr(event, "is_error", lambda: False)():
|
||||
return dict(getattr(event, "payload", {}) or {})
|
||||
payload = getattr(event, "payload", {}) or {}
|
||||
reason = str(payload.get("reason", payload))
|
||||
payload, reason = await self._stream_contacts(mc, gap)
|
||||
if payload is not None:
|
||||
return payload
|
||||
if attempt < retries:
|
||||
await asyncio.sleep(delay)
|
||||
raise DeviceCommandError(
|
||||
@@ -1748,6 +1830,85 @@ class MeshCoreDevice(Device):
|
||||
"retry, or power-cycle/reconnect the radio if it persists."
|
||||
)
|
||||
|
||||
async def _stream_contacts(self, mc, idle: float) -> tuple[dict | None, str]: # noqa: ANN001
|
||||
"""Run one contacts read, ending it on *silence* rather than on a deadline.
|
||||
|
||||
The contacts table arrives as one ``NEXT_CONTACT`` frame per record and a closing
|
||||
``CONTACTS`` frame holding the whole map. A dump is therefore not one answer that is
|
||||
either late or on time, it is a stream, and the only thing that distinguishes a slow
|
||||
big table from a radio that has stopped talking is how long it has been since the
|
||||
last record. So each record restarts the clock and only ``idle`` seconds of quiet
|
||||
end the attempt — which is the whole of the fix for a four-hundred-contact node,
|
||||
whose dump simply takes longer than any fixed deadline the library would allow it.
|
||||
|
||||
Args:
|
||||
mc: The connected ``MeshCore`` client.
|
||||
idle: Seconds of silence that end the attempt.
|
||||
|
||||
Returns:
|
||||
``(payload, "")`` on success, or ``(None, reason)`` describing how it ended.
|
||||
"""
|
||||
from meshcore import EventType
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
finished: asyncio.Future = loop.create_future()
|
||||
arrived = asyncio.Event()
|
||||
seen = False
|
||||
refusal = ""
|
||||
|
||||
def on_record(event) -> None: # noqa: ANN001 - meshcore Event
|
||||
nonlocal seen
|
||||
seen = True
|
||||
arrived.set()
|
||||
|
||||
def on_end(event) -> None: # noqa: ANN001 - meshcore Event
|
||||
if not finished.done():
|
||||
finished.set_result(event)
|
||||
|
||||
def on_error(event) -> None: # noqa: ANN001 - meshcore Event
|
||||
# An ERROR frame carries no request id, so it is only ours while nothing else
|
||||
# can have earned it: before the first record it is this request being refused
|
||||
# (a companion too busy to serve a dump answers ERR_CODE_BAD_STATE), and after
|
||||
# one it belongs to whatever else is on the link — a battery poll, a courier
|
||||
# send — where treating it as ours is how a read that was working stopped.
|
||||
nonlocal refusal
|
||||
payload = getattr(event, "payload", {}) or {}
|
||||
refusal = str(payload.get("reason", payload))
|
||||
if not seen and not finished.done():
|
||||
finished.set_result(None)
|
||||
|
||||
subscriptions = [
|
||||
mc.subscribe(EventType.NEXT_CONTACT, on_record),
|
||||
mc.subscribe(EventType.CONTACTS, on_end),
|
||||
mc.subscribe(EventType.ERROR, on_error),
|
||||
]
|
||||
try:
|
||||
await mc.commands.get_contacts_async()
|
||||
while not finished.done():
|
||||
arrived.clear()
|
||||
ticking = asyncio.ensure_future(arrived.wait())
|
||||
try:
|
||||
await asyncio.wait(
|
||||
{finished, ticking},
|
||||
timeout=idle,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
finally:
|
||||
ticking.cancel()
|
||||
if not finished.done() and not arrived.is_set():
|
||||
break
|
||||
finally:
|
||||
for subscription in subscriptions:
|
||||
subscription.unsubscribe()
|
||||
event = finished.result() if finished.done() else None
|
||||
if event is not None:
|
||||
return dict(getattr(event, "payload", {}) or {}), ""
|
||||
if refusal and not seen:
|
||||
return None, refusal
|
||||
if seen:
|
||||
return None, "no event received during contacts retrieval — it stopped partway"
|
||||
return None, "no event received during contacts retrieval"
|
||||
|
||||
async def get_contacts(self) -> list[Contact]: # noqa: D102 - inherited docstring
|
||||
mc = self._require()
|
||||
payload = await self._contacts_payload(mc)
|
||||
@@ -1825,7 +1986,9 @@ class MeshCoreDevice(Device):
|
||||
|
||||
async def set_tx_power(self, value: int) -> None: # noqa: D102 - inherited docstring
|
||||
mc = self._require()
|
||||
await mc.commands.set_tx_power(value)
|
||||
# The firmware reads a signed byte; the library packs an unsigned int, which refuses a
|
||||
# negative one. Two's complement is the same low byte, so -9 dBm survives the trip.
|
||||
await mc.commands.set_tx_power(int(value) & 0xFFFFFFFF)
|
||||
|
||||
@staticmethod
|
||||
def _node_pubkey(node: Contact) -> str:
|
||||
@@ -1852,8 +2015,6 @@ class MeshCoreDevice(Device):
|
||||
async def admin_login(self, node: Contact, password: str) -> LoginResult: # noqa: D102
|
||||
from meshcore import EventType
|
||||
|
||||
from ..services.trace_runner import trace_timeout
|
||||
|
||||
mc = self._require()
|
||||
pub = self._node_pubkey(node)
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -2087,11 +2248,9 @@ class MeshCoreDevice(Device):
|
||||
transmit_gate.mark()
|
||||
from meshcore import EventType # local import keeps mock path dependency-free
|
||||
|
||||
from ..services.trace_runner import path_hash_flags, trace_timeout
|
||||
|
||||
mc = self._require()
|
||||
tag = random.randint(0, 0xFFFFFFFF)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
started = loop.time()
|
||||
|
||||
# A trace packet has no destination field — it walks an explicit path of
|
||||
@@ -2214,8 +2373,6 @@ class MeshCoreDevice(Device):
|
||||
``(path_bytes, flags)`` to walk, or ``None`` only when the contact is
|
||||
unknown or carries no public key to address.
|
||||
"""
|
||||
from ..services.trace_runner import path_hash_flags
|
||||
|
||||
payload = await self._contacts_payload(mc)
|
||||
needle = target.casefold()
|
||||
for name, info in payload.items():
|
||||
@@ -2474,12 +2631,54 @@ class MeshCoreDevice(Device):
|
||||
"airtime_factor": int(payload.get("airtime_factor", 0)) / 1000.0,
|
||||
}
|
||||
|
||||
#: The hop limit that arrived in the same frame as the last bitmask, until it is taken.
|
||||
_autoadd_hops: object = _UNREAD
|
||||
|
||||
async def get_autoadd_config(self) -> int | None: # noqa: D102 - inherited docstring
|
||||
event = self._ok(await self._require().commands.get_autoadd_config())
|
||||
mc = self._require()
|
||||
# One reply carries the bitmask *and* the hop limit, but the library's parser keeps
|
||||
# only the first. So the reader is tapped for the length of this one read — every
|
||||
# transport hands frames to ``reader.handle_rx`` by attribute lookup, so an instance
|
||||
# attribute sees them first — and the raw frame's second byte is kept.
|
||||
reader = getattr(mc, "_reader", None)
|
||||
frames: list[bytes] = []
|
||||
tapped = reader is not None and "handle_rx" not in vars(reader)
|
||||
if tapped:
|
||||
original = reader.handle_rx
|
||||
|
||||
async def tap(data: bytearray) -> None:
|
||||
if data and data[0] == _RESP_AUTOADD_CONFIG:
|
||||
frames.append(bytes(data))
|
||||
await original(data)
|
||||
|
||||
reader.handle_rx = tap
|
||||
try:
|
||||
event = self._ok(await mc.commands.get_autoadd_config())
|
||||
finally:
|
||||
if tapped:
|
||||
del reader.handle_rx
|
||||
payload = getattr(event, "payload", {}) or {}
|
||||
config = payload.get("config")
|
||||
frame = frames[-1] if frames else b""
|
||||
self._autoadd_hops = frame[2] if len(frame) >= 3 else None
|
||||
return None if config is None else int(config)
|
||||
|
||||
async def get_autoadd_max_hops(self) -> int | None: # noqa: D102 - inherited docstring
|
||||
# build_snapshot asks for the bitmask and then for this. The frame that answered the
|
||||
# first question already carried the second, so it is taken rather than asked again.
|
||||
if self._autoadd_hops is _UNREAD:
|
||||
await self.get_autoadd_config()
|
||||
hops, self._autoadd_hops = self._autoadd_hops, _UNREAD
|
||||
return hops # type: ignore[return-value]
|
||||
|
||||
async def get_allowed_repeat_freqs(self) -> list[tuple[float, float]]: # noqa: D102
|
||||
event = self._ok(await self._require().commands.get_allowed_repeat_freq())
|
||||
payload = getattr(event, "payload", {}) or {}
|
||||
# Ranges travel in kHz, like every other frequency on this protocol.
|
||||
return [
|
||||
(int(r["min"]) / 1000.0, int(r["max"]) / 1000.0) for r in payload.get("freqs") or []
|
||||
]
|
||||
|
||||
async def get_default_flood_scope(self) -> str | None: # noqa: D102
|
||||
event = self._ok(await self._require().commands.get_default_flood_scope())
|
||||
payload = getattr(event, "payload", {}) or {}
|
||||
@@ -2575,8 +2774,14 @@ class MeshCoreDevice(Device):
|
||||
async def set_device_pin(self, pin: int) -> None: # noqa: D102 - inherited docstring
|
||||
self._ok(await self._require().commands.set_devicepin(pin))
|
||||
|
||||
async def set_radio(self, freq: float, bw: float, sf: int, cr: int) -> None: # noqa: D102
|
||||
self._ok(await self._require().commands.set_radio(freq, bw, sf, cr))
|
||||
async def set_radio( # noqa: D102 - inherited docstring
|
||||
self, freq: float, bw: float, sf: int, cr: int, repeat: bool | None = None
|
||||
) -> None:
|
||||
self._ok(
|
||||
await self._require().commands.set_radio(
|
||||
freq, bw, sf, cr, None if repeat is None else int(bool(repeat))
|
||||
)
|
||||
)
|
||||
|
||||
async def set_tuning(self, rx_delay: float, airtime_factor: float) -> None: # noqa: D102
|
||||
# CMD_SET_TUNING_PARAMS carries both floats ×1000; the firmware divides them
|
||||
@@ -2587,8 +2792,19 @@ class MeshCoreDevice(Device):
|
||||
)
|
||||
)
|
||||
|
||||
async def set_autoadd_config(self, flags: int) -> None: # noqa: D102
|
||||
self._ok(await self._require().commands.set_autoadd_config(int(flags)))
|
||||
async def set_autoadd_config( # noqa: D102 - inherited docstring
|
||||
self, flags: int, max_hops: int | None = None
|
||||
) -> None:
|
||||
from meshcore import EventType
|
||||
|
||||
mc = self._require()
|
||||
if max_hops is None:
|
||||
self._ok(await mc.commands.set_autoadd_config(int(flags)))
|
||||
else:
|
||||
# The library sends the bitmask alone; the hop limit is the byte after it.
|
||||
frame = bytes([_CMD_SET_AUTOADD_CONFIG, int(flags) & 0xFF, min(int(max_hops), 64)])
|
||||
self._ok(await mc.commands.send(frame, [EventType.OK, EventType.ERROR]))
|
||||
self._autoadd_hops = _UNREAD
|
||||
|
||||
async def set_default_flood_scope(self, scope: str) -> None: # noqa: D102
|
||||
# The library treats "", "0", "None" and "*" as "clear the scope"; normalize to
|
||||
@@ -2777,6 +2993,9 @@ class MockDevice(Device):
|
||||
}
|
||||
self._tuning: dict = {"rx_delay": 0.0, "airtime_factor": 0.0}
|
||||
self._autoadd_config = 0
|
||||
self._autoadd_max_hops = 0
|
||||
# Client repeat (firmware v9+): off, as a freshly flashed companion ships.
|
||||
self._client_repeat = False
|
||||
self._flood_scope = ""
|
||||
# Simulated clock skew (seconds behind the host), so the sync-clock flow has a
|
||||
# visible drift to correct until set_time is called.
|
||||
@@ -2815,6 +3034,7 @@ class MockDevice(Device):
|
||||
"ver": "mock",
|
||||
"fw_build": "mock",
|
||||
"ble_pin": self._device_pin,
|
||||
"repeat": self._client_repeat,
|
||||
}
|
||||
|
||||
async def get_contacts(self) -> list[Contact]: # noqa: D102 - inherited docstring
|
||||
@@ -2873,25 +3093,46 @@ class MockDevice(Device):
|
||||
self._admin_sessions.add(self._mock_key(node))
|
||||
return LoginResult.ACCEPTED
|
||||
|
||||
#: The simulated repeater CLI's configuration defaults (see send_remote_command).
|
||||
#: The simulated repeater CLI's configuration, keyed and answered the way MeshCore's
|
||||
#: ``CommonCLI.cpp`` does (see send_remote_command). What is absent is absent on purpose:
|
||||
#: a board with no front-end module and a build with no bridge, so a read of those
|
||||
#: answers ``??:`` exactly as that hardware would.
|
||||
_REMOTE_CFG_DEFAULTS = {
|
||||
"owner.info": "",
|
||||
"lat": "0",
|
||||
"lon": "0",
|
||||
"freq": "910.525",
|
||||
"bw": "62.5",
|
||||
"sf": "7",
|
||||
"cr": "5",
|
||||
"lat": "0",
|
||||
"lon": "0",
|
||||
"radio.rxgain": "on",
|
||||
"cad": "off",
|
||||
"int.thresh": "0",
|
||||
"agc.reset.interval": "0",
|
||||
"repeat": "on",
|
||||
"txdelay": "0",
|
||||
"direct.txdelay": "0",
|
||||
"txdelay": "0.5",
|
||||
"direct.txdelay": "0.2",
|
||||
"rxdelay": "0",
|
||||
"af": "1",
|
||||
"allow.read.only": "off",
|
||||
"loop.detect": "off",
|
||||
"path.hash.mode": "0",
|
||||
"multi.acks": "0",
|
||||
"flood.max": "64",
|
||||
"flood.max.unscoped": "64",
|
||||
"flood.max.advert": "8",
|
||||
"advert.interval": "240",
|
||||
"flood.advert.interval": "12",
|
||||
"flood.max": "64",
|
||||
"guest.password": "",
|
||||
"allow.read.only": "off",
|
||||
"powersaving": "off",
|
||||
"adc.multiplier": "0.000",
|
||||
"gps advert": "none",
|
||||
"bridge.type": "none",
|
||||
}
|
||||
|
||||
#: The ``get radio`` fields, in the order the reply joins them.
|
||||
_REMOTE_RADIO = ("freq", "bw", "sf", "cr")
|
||||
|
||||
async def send_remote_command( # noqa: D102 - inherited docstring
|
||||
self, node: Contact, command: str, *, timeout: float = 8.0
|
||||
) -> str | None:
|
||||
@@ -2914,25 +3155,57 @@ class MockDevice(Device):
|
||||
if verb == "neighbors":
|
||||
table = self._neighbour_tables.get(node.key_prefix or "", [])
|
||||
return "\n".join(f"{p} {snr:+.1f}dB {ago}s" for p, snr, ago in table) or "none"
|
||||
if verb == "powersaving":
|
||||
if len(parts) == 2 and parts[1] in ("on", "off"):
|
||||
cfg["powersaving"] = parts[1]
|
||||
return cfg["powersaving"] # bare, no "> ": a verb, not a get
|
||||
if verb == "gps":
|
||||
if parts[1:2] == ["advert"]:
|
||||
if len(parts) == 2:
|
||||
return f"> {cfg['gps advert']}"
|
||||
if parts[2] in ("none", "share", "prefs"):
|
||||
cfg["gps advert"] = parts[2]
|
||||
return "OK"
|
||||
return "error: expected none, share or prefs"
|
||||
if len(parts) == 1:
|
||||
return "off" # no receiver on this board
|
||||
return "gps toggle not supported"
|
||||
if verb == "get" and len(parts) == 2:
|
||||
param = parts[1].lower()
|
||||
if param == "tx":
|
||||
return str(self._remote_tx.get(key, self._default_remote_tx))
|
||||
if param == "guest.password":
|
||||
return f"ERR: unknown config: {param}" # write-only, like hardware
|
||||
value = cfg.get(param)
|
||||
return f"> {value}" if value is not None else f"ERR: unknown config: {param}"
|
||||
return f"> {self._remote_tx.get(key, self._default_remote_tx)}"
|
||||
if param == "radio":
|
||||
return "> " + ",".join(cfg[name] for name in self._REMOTE_RADIO)
|
||||
if param == "dutycycle":
|
||||
return f"> {100.0 / (float(cfg['af']) + 1.0):.1f}%"
|
||||
if param in cfg and param not in ("gps advert", "powersaving"):
|
||||
return f"> {cfg[param]}"
|
||||
return f"??: {param}"
|
||||
if verb == "set" and len(parts) >= 3:
|
||||
param = parts[1].lower()
|
||||
value = " ".join(parts[2:])
|
||||
if param == "tx":
|
||||
self._remote_tx[key] = int(float(value))
|
||||
return "OK"
|
||||
if param in cfg or param == "guest.password":
|
||||
if param == "radio":
|
||||
fields = value.split(",")
|
||||
if len(fields) != len(self._REMOTE_RADIO):
|
||||
return "Error, invalid params"
|
||||
cfg.update(zip(self._REMOTE_RADIO, fields, strict=True))
|
||||
return "OK - reboot to apply"
|
||||
if param == "dutycycle":
|
||||
duty = float(value)
|
||||
if not 1 <= duty <= 100:
|
||||
return "ERROR: dutycycle must be 1-100"
|
||||
cfg["af"] = f"{100.0 / duty - 1.0:g}"
|
||||
return f"OK - {duty:.1f}%"
|
||||
# freq alone is serial-only; the rest of the radio goes through "set radio".
|
||||
read_only = (*self._REMOTE_RADIO, "bridge.type", "gps advert", "powersaving")
|
||||
if param in cfg and param not in read_only:
|
||||
cfg[param] = value
|
||||
return "OK"
|
||||
return f"ERR: unknown config: {param}"
|
||||
return f"ERR: unknown command: {command.strip()}"
|
||||
return f"unknown config: {param} {value}"
|
||||
return f"??: {command.strip()}"
|
||||
|
||||
async def get_remote_tx_power(self, node: Contact) -> int | None: # noqa: D102
|
||||
key = self._mock_key(node)
|
||||
@@ -2998,6 +3271,12 @@ class MockDevice(Device):
|
||||
async def get_autoadd_config(self) -> int | None: # noqa: D102
|
||||
return self._autoadd_config
|
||||
|
||||
async def get_autoadd_max_hops(self) -> int | None: # noqa: D102 - inherited docstring
|
||||
return self._autoadd_max_hops
|
||||
|
||||
async def get_allowed_repeat_freqs(self) -> list[tuple[float, float]]: # noqa: D102
|
||||
return list(_DEFAULT_REPEAT_FREQS)
|
||||
|
||||
async def get_default_flood_scope(self) -> str | None: # noqa: D102
|
||||
return self._flood_scope
|
||||
|
||||
@@ -3052,8 +3331,16 @@ class MockDevice(Device):
|
||||
async def set_device_pin(self, pin: int) -> None: # noqa: D102 - inherited docstring
|
||||
self._device_pin = pin
|
||||
|
||||
async def set_radio(self, freq: float, bw: float, sf: int, cr: int) -> None: # noqa: D102
|
||||
async def set_radio( # noqa: D102 - inherited docstring
|
||||
self, freq: float, bw: float, sf: int, cr: int, repeat: bool | None = None
|
||||
) -> None:
|
||||
# Modelled on the firmware: relaying is refused off the allowed frequencies, and a
|
||||
# command that leaves the repeat byte off turns relaying *off* — the trap a radio
|
||||
# change has to restate its way around.
|
||||
if repeat and not repeat_freq_allowed(freq, _DEFAULT_REPEAT_FREQS):
|
||||
raise DeviceCommandError("device rejected the command: illegal argument")
|
||||
self._info.update(radio_freq=freq, radio_bw=bw, radio_sf=sf, radio_cr=cr)
|
||||
self._client_repeat = bool(repeat)
|
||||
|
||||
async def set_tuning(self, rx_delay: float, airtime_factor: float) -> None: # noqa: D102
|
||||
# Round-trip through the wire's ×1000 integer scaling so the simulator loses
|
||||
@@ -3063,8 +3350,12 @@ class MockDevice(Device):
|
||||
"airtime_factor": round(float(airtime_factor) * 1000) / 1000.0,
|
||||
}
|
||||
|
||||
async def set_autoadd_config(self, flags: int) -> None: # noqa: D102
|
||||
async def set_autoadd_config( # noqa: D102 - inherited docstring
|
||||
self, flags: int, max_hops: int | None = None
|
||||
) -> None:
|
||||
self._autoadd_config = int(flags)
|
||||
if max_hops is not None: # the firmware leaves the limit alone when it isn't sent
|
||||
self._autoadd_max_hops = min(int(max_hops), 64)
|
||||
|
||||
async def set_default_flood_scope(self, scope: str) -> None: # noqa: D102
|
||||
# Mirror the transport layer: empty clears, a bare name gains its leading #.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
r"""Installing and selecting a console font on Windows, for one user, without admin.
|
||||
|
||||
The classic Windows console does no font fallback: a character its configured font lacks
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Rating contacts by how much they are worth a slot in the device's contact table.
|
||||
|
||||
A companion's contact table is finite, and a mesh fills it with whatever adverts happen to
|
||||
arrive — so a radio left running long enough ends up holding mostly nodes heard once, in
|
||||
passing, from four hops away, while there is no room left to *discover* anyone new. The
|
||||
Contacts screen's bulk sweep (see :func:`~meshterm.ui.purge_screen.purge_contacts`) is how
|
||||
Contacts screen's bulk sweep (see :func:`~meshterm.ui.sweep_screen.archive_contacts`) is how
|
||||
that table gets its headroom back, and this module is the judgement it runs on: one score
|
||||
per contact, so the sweep can take the weakest and leave the ones you would actually miss.
|
||||
|
||||
@@ -17,7 +18,7 @@ must not have.
|
||||
contacts on a real mesh advertise no location, plenty are never overheard as a relayed
|
||||
packet so have no hop count, and a channel poster whose name matches no contact cannot be
|
||||
attributed at all. If "we don't know" resolved to ``0.0``, every one of those would sink to
|
||||
the bottom *together*, and the sweep would purge by how much metadata a node happens to
|
||||
the bottom *together*, and the sweep would archive by how much metadata a node happens to
|
||||
broadcast rather than by how much it is worth. So a term that cannot be computed returns
|
||||
``None`` and is filled with the **population median** of the contacts that could compute it
|
||||
(:func:`_fill_unknowns`) — the contact lands exactly where an average peer would on that
|
||||
@@ -34,17 +35,19 @@ before this history began) has no arrival time to decay from and is protected ou
|
||||
rather than being punished for MeshTerm's own ignorance — see :data:`PROTECT_UNOBSERVED`.
|
||||
|
||||
**Some contacts are never candidates.** A score is a heuristic and a heuristic must not
|
||||
overrule an explicit choice, so :func:`protection_for` short-circuits four cases before any
|
||||
arithmetic: a watched node, anyone you have sent a direct message to, a repeater whose
|
||||
admin credentials are stored, and the unobserved contact above. They are still scored and
|
||||
still ranked — they are real contacts and the percentile scale is the whole population —
|
||||
but they are never swept.
|
||||
overrule an explicit choice, so :func:`protection_for` short-circuits five cases before any
|
||||
arithmetic: a contact you locked, a watched node, anyone you have sent a direct message to,
|
||||
a repeater whose admin credentials are stored, and the unobserved contact above. They are
|
||||
still scored and still ranked — they are real contacts and the percentile scale is the whole
|
||||
population — but they are never swept.
|
||||
|
||||
**What is displayed is the percentile, never the score.** A raw ``47.3`` means nothing
|
||||
without the distribution it came from; a contact's *rank against your other contacts* is
|
||||
self-calibrating, needs no legend, and stays comparable when the weights change. So
|
||||
:class:`ScoredContact` carries :attr:`~ScoredContact.percentile` and the UI shows only that
|
||||
(see :func:`percentile_rank`).
|
||||
**The score is never displayed.** A raw ``47.3`` means nothing without the distribution it
|
||||
came from. What this module hands a screen instead is a *rank* — the list comes back ordered,
|
||||
and :attr:`~ScoredContact.percentile` places one contact in the field without a legend and
|
||||
without going stale when the weights change (see :func:`percentile_rank`). What a screen draws
|
||||
beside a contact is neither: the archive preview shows the :class:`ContactSignals` themselves, a
|
||||
lane per measured kind, because the thing a reader can check against what they know is the
|
||||
evidence, not this module's reading of it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -57,6 +60,10 @@ from statistics import median
|
||||
from .geo import haversine_km
|
||||
from .models import Contact
|
||||
|
||||
#: Protection reason: the reader locked this contact from its page. The most explicit claim
|
||||
#: there is — a lock says nothing but "never archive this one" — so it is checked first.
|
||||
PROTECT_LOCKED = "locked"
|
||||
|
||||
#: Protection reason: the node is starred in the Watchtower. An explicit pin outranks every
|
||||
#: heuristic in this module — the user already said this one matters.
|
||||
PROTECT_WATCHED = "watched"
|
||||
@@ -66,22 +73,29 @@ PROTECT_WATCHED = "watched"
|
||||
#: undone by a score. (Inbound-only traffic does *not* protect — anyone can message you.)
|
||||
PROTECT_MESSAGED = "messaged"
|
||||
|
||||
#: Protection reason: a repeater or room server whose admin password is stored. Purging it
|
||||
#: Protection reason: a repeater or room server whose admin password is stored. Archiving it
|
||||
#: costs the login, which is a far larger loss than a contact slot is a gain.
|
||||
PROTECT_ADMIN = "admin"
|
||||
|
||||
#: Protection reason: MeshTerm has never heard this contact, so every evidence term is
|
||||
#: empty for a reason that says nothing about the node. It came from the device's own table
|
||||
#: (or was added by hand) before this history began; sweeping it would be purging by how
|
||||
#: (or was added by hand) before this history began; sweeping it would be archiving by how
|
||||
#: long MeshTerm has been running rather than by anything the contact did.
|
||||
PROTECT_UNOBSERVED = "unobserved"
|
||||
|
||||
#: The order protections are reported in when a contact qualifies for several — most
|
||||
#: deliberate first, so the preview explains a row by the strongest claim on it.
|
||||
PROTECTION_ORDER = (PROTECT_WATCHED, PROTECT_MESSAGED, PROTECT_ADMIN, PROTECT_UNOBSERVED)
|
||||
PROTECTION_ORDER = (
|
||||
PROTECT_LOCKED,
|
||||
PROTECT_WATCHED,
|
||||
PROTECT_MESSAGED,
|
||||
PROTECT_ADMIN,
|
||||
PROTECT_UNOBSERVED,
|
||||
)
|
||||
|
||||
#: How a protection reads in the UI, keyed by the constants above.
|
||||
PROTECTION_LABELS = {
|
||||
PROTECT_LOCKED: "locked",
|
||||
PROTECT_WATCHED: "watched",
|
||||
PROTECT_MESSAGED: "you messaged",
|
||||
PROTECT_ADMIN: "admin login",
|
||||
@@ -178,6 +192,8 @@ class ContactSignals:
|
||||
which is what :data:`PROTECT_UNOBSERVED` keys on.
|
||||
watched: Whether the node is starred in the Watchtower.
|
||||
has_admin: Whether admin credentials are stored for it.
|
||||
locked: Whether the reader locked the contact against archiving (see
|
||||
:meth:`~meshterm.core.contact_store.ContactStore.set_locked`).
|
||||
"""
|
||||
|
||||
node: str
|
||||
@@ -193,26 +209,29 @@ class ContactSignals:
|
||||
known_days: float | None = None
|
||||
watched: bool = False
|
||||
has_admin: bool = False
|
||||
locked: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoredContact:
|
||||
"""One contact's standing among the others: its score, its percentile, and why.
|
||||
"""One contact's standing among the others: its score, its rank, and the evidence for both.
|
||||
|
||||
Attributes:
|
||||
contact: The contact this describes.
|
||||
signals: The evidence the score was computed from.
|
||||
score: The weighted sum plus any newcomer grace. Never displayed — see
|
||||
:attr:`percentile`, and the module docstring for why.
|
||||
percentile: Percentile rank within the scored population, ``0``–``100``. **This is
|
||||
the only number the UI shows**: it is self-calibrating (a mesh of 30 contacts
|
||||
and one of 300 both read the same way), needs no legend, and stays meaningful
|
||||
if the weights are ever retuned.
|
||||
percentile: Percentile rank within the scored population, ``0``–``100``. The
|
||||
score's one publishable reading: self-calibrating (a mesh of 30 contacts and
|
||||
one of 300 both read the same way), needing no legend, and still meaningful if
|
||||
the weights are ever retuned.
|
||||
protection: Why this contact can never be swept, or ``None`` if it can. One of the
|
||||
``PROTECT_*`` constants.
|
||||
reasons: Short lowercase phrases naming what this contact's standing rests on —
|
||||
its weakest lanes when it is a purge candidate, so the preview row explains
|
||||
itself without the reader having to know the formula.
|
||||
|
||||
The **signals** are what a screen shows, not the score or its terms: the archive preview
|
||||
draws one lane per measured kind straight off :attr:`signals` (see
|
||||
:mod:`~meshterm.ui.sweep_screen`), so a reader auditing a sweep reads the evidence in
|
||||
the same units it was gathered in rather than a phrase this module chose for them.
|
||||
"""
|
||||
|
||||
contact: Contact
|
||||
@@ -220,7 +239,6 @@ class ScoredContact:
|
||||
score: float
|
||||
percentile: int
|
||||
protection: str | None = None
|
||||
reasons: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def protected(self) -> bool:
|
||||
@@ -374,6 +392,7 @@ def protection_for(signals: ContactSignals) -> str | None:
|
||||
One of the ``PROTECT_*`` constants, or ``None``.
|
||||
"""
|
||||
claims = {
|
||||
PROTECT_LOCKED: signals.locked,
|
||||
PROTECT_WATCHED: signals.watched,
|
||||
PROTECT_MESSAGED: signals.dm_outbound > 0,
|
||||
PROTECT_ADMIN: signals.has_admin,
|
||||
@@ -394,7 +413,7 @@ def protection_for(signals: ContactSignals) -> str | None:
|
||||
#: unknown.
|
||||
_MIN_LOCATED = 4
|
||||
|
||||
#: The term names, in the order a :class:`ScoredContact` reports its reasons.
|
||||
#: The term names, in the order the weighted sum walks them.
|
||||
_TERM_NAMES = ("dm", "recency", "volume", "channel", "hops", "distance")
|
||||
|
||||
|
||||
@@ -448,54 +467,6 @@ def percentile_rank(score: float, population: Sequence[float]) -> int:
|
||||
return int(round(100.0 * (below + 0.5 * equal) / len(population)))
|
||||
|
||||
|
||||
def _reasons(
|
||||
signals: ContactSignals, terms: dict[str, float], weights: ScoreWeights
|
||||
) -> tuple[str, ...]:
|
||||
"""Name what this contact's standing rests on, weakest lane first.
|
||||
|
||||
The preview has one line per contact and the reader should not have to know the formula
|
||||
to audit a sweep — so each row says, in plain words, the two things that put it where it
|
||||
is. Phrases are drawn from the *measured* signals rather than from the term values, so
|
||||
"3 packets" is the actual tally and not a normalised fraction.
|
||||
"""
|
||||
phrases: list[tuple[float, str]] = []
|
||||
contribution = {
|
||||
"dm": terms["dm"] * weights.dm,
|
||||
"recency": terms["recency"] * weights.recency,
|
||||
"volume": terms["volume"] * weights.volume,
|
||||
"hops": terms["hops"] * weights.hops,
|
||||
}
|
||||
if signals.dm_total:
|
||||
phrases.append((contribution["dm"], f"{signals.dm_total} messages"))
|
||||
else:
|
||||
phrases.append((0.0, "never messaged"))
|
||||
if signals.heard_age_days is None:
|
||||
phrases.append((0.0, "never heard"))
|
||||
else:
|
||||
phrases.append((contribution["recency"], _age_phrase(signals.heard_age_days)))
|
||||
packets = signals.packets
|
||||
phrases.append((contribution["volume"], f"{packets} pkt{'' if packets == 1 else 's'}"))
|
||||
if signals.hops is not None:
|
||||
hops = signals.hops
|
||||
label = "direct" if hops < 0.5 else f"{hops:g} hops"
|
||||
phrases.append((contribution["hops"], label))
|
||||
phrases.sort(key=lambda pair: pair[0])
|
||||
return tuple(phrase for _, phrase in phrases[:3])
|
||||
|
||||
|
||||
def _age_phrase(days: float) -> str:
|
||||
"""A last-heard age in the fewest words: ``today`` / ``4d`` / ``6w`` / ``9mo`` / ``2y``."""
|
||||
if days < 1:
|
||||
return "today"
|
||||
if days < 14:
|
||||
return f"{int(days)}d"
|
||||
if days < 60:
|
||||
return f"{int(days / 7)}w"
|
||||
if days < 365:
|
||||
return f"{int(days / 30)}mo"
|
||||
return f"{days / 365:.0f}y"
|
||||
|
||||
|
||||
def rank_contacts(
|
||||
contacts: Sequence[Contact],
|
||||
signals: dict[str, ContactSignals],
|
||||
@@ -529,7 +500,7 @@ def rank_contacts(
|
||||
|
||||
gathered: list[ContactSignals] = []
|
||||
for contact in contacts:
|
||||
node = _node_id(contact)
|
||||
node = node_id(contact)
|
||||
found = signals.get(node) or ContactSignals(node=node)
|
||||
gathered.append(replace(found, distance_km=_self_distance(contact, self_lat, self_lon)))
|
||||
|
||||
@@ -549,7 +520,6 @@ def rank_contacts(
|
||||
filled = {name: _fill_unknowns(column) for name, column in columns.items()}
|
||||
|
||||
scores: list[float] = []
|
||||
per_contact_terms: list[dict[str, float]] = []
|
||||
for index, sig in enumerate(gathered):
|
||||
terms = {name: filled[name][index] for name in _TERM_NAMES}
|
||||
total = (
|
||||
@@ -561,7 +531,6 @@ def rank_contacts(
|
||||
+ terms["distance"] * weights.distance
|
||||
+ term_grace(sig, weights)
|
||||
)
|
||||
per_contact_terms.append(terms)
|
||||
scores.append(total)
|
||||
|
||||
ranked = [
|
||||
@@ -571,11 +540,8 @@ def rank_contacts(
|
||||
score=score,
|
||||
percentile=percentile_rank(score, scores),
|
||||
protection=protection_for(sig),
|
||||
reasons=_reasons(sig, terms, weights),
|
||||
)
|
||||
for contact, sig, score, terms in zip(
|
||||
contacts, gathered, scores, per_contact_terms, strict=True
|
||||
)
|
||||
for contact, sig, score in zip(contacts, gathered, scores, strict=True)
|
||||
]
|
||||
ranked.sort(key=lambda scored: (-scored.score, scored.contact.name.casefold()))
|
||||
return ranked
|
||||
@@ -590,7 +556,7 @@ def sweep_candidates(ranked: Sequence[ScoredContact], keep: int) -> list[ScoredC
|
||||
protections against the target instead would mean starring a node quietly deepened the
|
||||
next sweep, which is the opposite of what a star is for. The picker's rungs therefore
|
||||
report their own real removal counts rather than arithmetic on the table size (see
|
||||
:func:`~meshterm.ui.purge_screen.purge_contacts`).
|
||||
:func:`~meshterm.ui.sweep_screen.archive_contacts`).
|
||||
|
||||
Args:
|
||||
ranked: The full ranking from :func:`rank_contacts`, strongest first.
|
||||
@@ -607,7 +573,7 @@ def sweep_candidates(ranked: Sequence[ScoredContact], keep: int) -> list[ScoredC
|
||||
return list(reversed(victims))
|
||||
|
||||
|
||||
def _node_id(contact: Contact) -> str:
|
||||
def node_id(contact: Contact) -> str:
|
||||
"""The 12-hex canonical id a contact's history is keyed by (see ``observations.node``)."""
|
||||
ident = contact.public_key or contact.key_prefix or ""
|
||||
return ident.lower().removeprefix("0x")[:12]
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for contacts heard through MeshTerm, so they survive a device that forgets them.
|
||||
|
||||
Most companions keep their contact table in firmware, so MeshTerm reads contacts live from the
|
||||
@@ -33,6 +34,13 @@ it back. So a restore is one write, and the chat screen already performs it on d
|
||||
send is rejected for an unknown recipient (see
|
||||
:class:`~meshterm.core.connection.ContactNotOnDeviceError`).
|
||||
|
||||
A live contact can also be **locked**, which is the reader's own word that it stays: a
|
||||
locked contact is never a sweep candidate (see
|
||||
:data:`~meshterm.core.contact_score.PROTECT_LOCKED`) and its page offers no Archive. The flag
|
||||
lives here rather than on the device because the firmware has no such field — it is
|
||||
MeshTerm's claim about the contact, kept beside the other one it makes (the archive stamp),
|
||||
and a device read never clears it.
|
||||
|
||||
The one place to be careful is a firmware-less bridge, whose contact table is RAM-only and
|
||||
for which this store *is* the memory: archiving there removes the contact from the lists for
|
||||
real, and only a restore brings it back.
|
||||
@@ -46,7 +54,7 @@ contact set actually changes, and flushes atomically.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
@@ -96,6 +104,9 @@ class RememberedContact:
|
||||
while it is a live contact. An archived contact is remembered in full but kept
|
||||
*out* of :func:`merge_contacts`, so it stops occupying a device slot without
|
||||
being forgotten — and the stamp is what lets a list say how long ago it went.
|
||||
locked: Whether the reader locked this contact against archiving. MeshTerm's own
|
||||
state, not the device's, so :meth:`ContactStore.remember_all` carries it across
|
||||
every fresh read of the contact rather than resetting it.
|
||||
"""
|
||||
|
||||
public_key: str
|
||||
@@ -105,6 +116,7 @@ class RememberedContact:
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
archived_at: int | None = None
|
||||
locked: bool = False
|
||||
|
||||
@property
|
||||
def archived(self) -> bool:
|
||||
@@ -218,7 +230,11 @@ class ContactStore:
|
||||
if not contact.public_key:
|
||||
continue # unaddressable — nothing to remember it by
|
||||
remembered = RememberedContact.from_contact(contact)
|
||||
if current.get(remembered.public_key) != remembered:
|
||||
known = current.get(remembered.public_key)
|
||||
if known is not None and known.locked:
|
||||
# The lock is ours, not the device's: a fresh read knows nothing about it.
|
||||
remembered = replace(remembered, locked=True)
|
||||
if known != remembered:
|
||||
current[remembered.public_key] = remembered
|
||||
changed = True
|
||||
if changed:
|
||||
@@ -255,16 +271,7 @@ class ContactStore:
|
||||
dev = _norm(device_pubkey)
|
||||
if not dev or not contact.public_key:
|
||||
return # unaddressable — there would be nothing to restore it by
|
||||
remembered = RememberedContact.from_contact(contact)
|
||||
entry = RememberedContact(
|
||||
public_key=remembered.public_key,
|
||||
name=remembered.name,
|
||||
node_type=remembered.node_type,
|
||||
last_advert=remembered.last_advert,
|
||||
lat=remembered.lat,
|
||||
lon=remembered.lon,
|
||||
archived_at=int(when),
|
||||
)
|
||||
entry = replace(RememberedContact.from_contact(contact), archived_at=int(when))
|
||||
current = dict(self._state.get(dev, {}))
|
||||
if current.get(entry.public_key) == entry:
|
||||
return
|
||||
@@ -294,19 +301,45 @@ class ContactStore:
|
||||
if entry is None or not entry.archived:
|
||||
return None
|
||||
current = dict(contacts)
|
||||
current[key] = RememberedContact(
|
||||
public_key=entry.public_key,
|
||||
name=entry.name,
|
||||
node_type=entry.node_type,
|
||||
last_advert=entry.last_advert,
|
||||
lat=entry.lat,
|
||||
lon=entry.lon,
|
||||
archived_at=None,
|
||||
)
|
||||
current[key] = replace(entry, archived_at=None)
|
||||
self._state[dev] = current
|
||||
self._save()
|
||||
return entry
|
||||
|
||||
def locked_keys(self, device_pubkey: str) -> frozenset[str]:
|
||||
"""The full keys (lowercase hex) of every contact locked on this device."""
|
||||
remembered = self._state.get(_norm(device_pubkey), {}).values()
|
||||
return frozenset(c.public_key for c in remembered if c.locked)
|
||||
|
||||
def is_locked(self, device_pubkey: str, contact_pubkey: str) -> bool:
|
||||
"""Whether one contact is locked against archiving on this device."""
|
||||
entry = self._state.get(_norm(device_pubkey), {}).get(_norm(contact_pubkey))
|
||||
return entry is not None and entry.locked
|
||||
|
||||
def set_locked(self, device_pubkey: str, contact: Contact, locked: bool) -> None:
|
||||
"""Lock or unlock one contact, upserting it so a contact the store never saw can be.
|
||||
|
||||
Only a live contact is locked — the lock exists to keep a contact *off* the archive
|
||||
path, so locking an archived one would claim something about a contact that has
|
||||
already gone. Persists only a real change.
|
||||
|
||||
Args:
|
||||
device_pubkey: The device's own public key.
|
||||
contact: The contact to lock or unlock, addressed by its full key.
|
||||
locked: The state to leave it in.
|
||||
"""
|
||||
dev = _norm(device_pubkey)
|
||||
if not dev or not contact.public_key:
|
||||
return # unaddressable — nothing to hang the flag on
|
||||
current = dict(self._state.get(dev, {}))
|
||||
key = _norm(contact.public_key)
|
||||
known = current.get(key) or RememberedContact.from_contact(contact)
|
||||
if known.archived or known.locked == locked:
|
||||
return
|
||||
current[key] = replace(known, locked=locked)
|
||||
self._state[dev] = current
|
||||
self._save()
|
||||
|
||||
def forget(self, device_pubkey: str, contact_pubkey: str) -> None:
|
||||
"""Drop one remembered contact; persist only a real change."""
|
||||
dev = _norm(device_pubkey)
|
||||
@@ -349,6 +382,8 @@ def _contact_to_json(contact: RememberedContact) -> dict:
|
||||
entry["lon"] = contact.lon
|
||||
if contact.archived_at is not None:
|
||||
entry["archived_at"] = contact.archived_at
|
||||
if contact.locked:
|
||||
entry["locked"] = True
|
||||
return entry
|
||||
|
||||
|
||||
@@ -368,6 +403,7 @@ def _contact_from_json(entry: object) -> RememberedContact | None:
|
||||
lat=_opt_float(entry.get("lat")),
|
||||
lon=_opt_float(entry.get("lon")),
|
||||
archived_at=_opt_int(entry.get("archived_at")),
|
||||
locked=entry.get("locked") is True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for the Courier: the store-and-forward outbox.
|
||||
|
||||
The Courier (see :mod:`meshterm.services.courier`) queues direct messages for contacts
|
||||
@@ -28,7 +29,8 @@ DELIVERED = "delivered"
|
||||
GAVE_UP = "gave-up"
|
||||
|
||||
#: How many finished (delivered / given-up) entries are kept for the screen's history —
|
||||
#: the code's default behind the ``courier_history_kept`` preference.
|
||||
#: *the* default behind the ``courier_history_kept`` preference, which the registry names
|
||||
#: rather than re-types (:data:`meshterm.core.preferences.PREFERENCES`).
|
||||
DONE_CAP = 100
|
||||
|
||||
|
||||
|
||||
+137
-14
@@ -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,7 @@ from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .connection import Device
|
||||
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 +54,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 +77,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,8 +101,19 @@ 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
|
||||
@@ -122,6 +142,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:
|
||||
@@ -184,6 +206,8 @@ def format_value(spec: SettingSpec, value: Any) -> str:
|
||||
return "true" if value else "false"
|
||||
if spec.value_type == "enum" and spec.choices is not None and value in spec.choices:
|
||||
return f"{value} ({spec.choices[value]})"
|
||||
if spec.value_type == "float" and spec.decimals is not None:
|
||||
return f"{float(value):.{spec.decimals}f}"
|
||||
return str(value)
|
||||
|
||||
|
||||
@@ -212,7 +236,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:
|
||||
@@ -230,6 +256,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
|
||||
@@ -244,6 +276,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
|
||||
|
||||
|
||||
@@ -273,7 +314,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
|
||||
@@ -317,23 +366,66 @@ 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)
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
@@ -347,7 +439,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),
|
||||
)
|
||||
@@ -473,6 +565,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),
|
||||
),
|
||||
@@ -512,6 +605,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),
|
||||
),
|
||||
@@ -522,8 +616,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"),
|
||||
),
|
||||
@@ -533,8 +628,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"),
|
||||
),
|
||||
@@ -566,12 +661,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.)
|
||||
@@ -594,6 +703,7 @@ DEVICE_SETTINGS: list[SettingSpec] = [
|
||||
"float",
|
||||
minimum=0.0,
|
||||
maximum=20.0,
|
||||
decimals=1,
|
||||
getter=_get("rx_delay"),
|
||||
apply=_tuning_apply("rx_delay"),
|
||||
),
|
||||
@@ -618,6 +728,19 @@ 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",
|
||||
@@ -664,7 +787,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),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for confirmed companion devices.
|
||||
|
||||
Every device that has ever spoken the MeshCore protocol to us successfully is remembered
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Companion-device discovery across transports (serial + Bluetooth LE + TCP).
|
||||
|
||||
Discovery enumerates the companion kinds that can be *found* automatically:
|
||||
@@ -35,6 +36,10 @@ _log = logging.getLogger(__name__)
|
||||
TRANSPORT_SERIAL = "serial"
|
||||
TRANSPORT_BLE = "ble"
|
||||
TRANSPORT_TCP = "tcp"
|
||||
#: The built-in simulator, as a device inventory reports it under ``--mock``. Never
|
||||
#: discovered — there is nothing to find — but a session that promised no real hardware
|
||||
#: still has exactly one device, and this is what it is called.
|
||||
TRANSPORT_MOCK = "mock"
|
||||
|
||||
#: Default TCP port assumed when a network companion is named as a bare host with no port.
|
||||
#: 5000 is the conventional MeshCore companion-over-TCP / ``meshcored`` listen port.
|
||||
@@ -144,6 +149,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 +166,8 @@ class DiscoveredDevice:
|
||||
"""
|
||||
if self.is_tcp:
|
||||
return f"tcp:{self.host}:{self.tcp_port}"
|
||||
if self.transport == TRANSPORT_MOCK:
|
||||
return "mock:simulator"
|
||||
if self.is_ble:
|
||||
return f"ble:{(self.address or self.name or '').lower()}"
|
||||
if self.serial_number:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Typed events carried by the always-on mesh event hub.
|
||||
|
||||
These are the domain-level events that flow from the connected companion device out to
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The exit statuses ``meshterm`` returns, and what each one means.
|
||||
|
||||
A scripted run's *return value is its report*: the CLI says what happened in plain
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""What an overheard frame is *addressed to* — the packet body nobody else parses.
|
||||
|
||||
The companion's RX packet log hands every overheard frame to the meshcore library, which
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Pure geographic helpers for the terminal map: Web Mercator, tiles, and viewports.
|
||||
|
||||
This module is deliberately I/O-free and rendering-free so it can be unit-tested without a
|
||||
@@ -33,7 +34,9 @@ TILE_PX = 256
|
||||
#: :mod:`meshterm.ui.map_screen` for it dragged the whole map stack (and, through the
|
||||
#: basemap's tile fetcher, ``urllib.request`` → ``http.client`` → ``ssl``) into every run,
|
||||
#: including runs that never open a map. This module is pure arithmetic and already on the
|
||||
#: boot path, so the constant is free here.
|
||||
#: boot path, so the constant is free here. It is also what the ``map_view_fraction``
|
||||
#: preference defaults to — the registry names this constant rather than re-typing the
|
||||
#: number, so the code default and the preference default are one value.
|
||||
DEFAULT_VIEW_FRACTION = 0.5
|
||||
|
||||
|
||||
@@ -87,7 +90,9 @@ def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
|
||||
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a))
|
||||
# Clamped because rounding can push ``a`` a hair past 1 for near-antipodal points, and
|
||||
# ``asin`` of that raises a domain error rather than returning half the planet.
|
||||
return 2 * EARTH_RADIUS_KM * math.asin(min(1.0, math.sqrt(a)))
|
||||
|
||||
|
||||
def _central_points(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""One interactive MeshTerm per data directory, and a clear answer when you want two.
|
||||
|
||||
Every copy of MeshTerm on a machine reads and writes the same ``~/.meshterm`` — a
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Domain models shared across services, tools, persistence, and visualizations.
|
||||
|
||||
These are plain dataclasses with no I/O dependencies so they can be constructed by the
|
||||
@@ -8,10 +9,25 @@ from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
|
||||
#: Maps a hop's raw key-prefix hash to a display label (a contact name when known, or the
|
||||
#: hash itself when not). ``None`` passes through, being our own device. THE alias for the
|
||||
#: name side of a hop, shared by everything that builds one
|
||||
#: (:func:`~meshterm.services.trace_runner.make_node_resolver`) and everything that takes
|
||||
#: one (every screen that draws a route).
|
||||
NodeResolver = Callable[[str | None], str | None]
|
||||
|
||||
#: Maps a display name back to the node's key — as full as we hold one: a public key, a key
|
||||
#: prefix, or a stored node id — or ``None`` for a name no known node carries. The colour
|
||||
#: side of :data:`NodeResolver`: where that turns hex into names, this turns a bare name back
|
||||
#: into the key its palette hue derives from
|
||||
#: (:func:`~meshterm.services.trace_runner.make_name_key_resolver`).
|
||||
NameKeyResolver = Callable[[str], str | None]
|
||||
|
||||
#: Label used for our own (local) device when framing a trace path's endpoints.
|
||||
LOCAL_DEVICE_LABEL = "us"
|
||||
|
||||
@@ -177,6 +193,39 @@ class Contact:
|
||||
return self.node_type == NODE_TYPE_REPEATER
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MapMarker:
|
||||
"""One mesh node to overlay on the map.
|
||||
|
||||
Data, not drawing: a located node with the little the map needs to place and label it.
|
||||
It lives here rather than beside the renderer because it is assembled well below the UI
|
||||
— :func:`~meshterm.services.markers.gather_markers` builds the list from contacts and
|
||||
observations, and three separate surfaces draw it.
|
||||
|
||||
Attributes:
|
||||
label: Node name shown beside the marker.
|
||||
lat: Latitude in decimal degrees.
|
||||
lon: Longitude in decimal degrees.
|
||||
is_repeater: Whether the node is a repeater (prioritised marker).
|
||||
is_self: Whether this is our own node (highlighted).
|
||||
detail: Extra text for the CLI legend (e.g. ``"18 pkts · +6.0 dB"``).
|
||||
key: The node's key hex (as full as the caller holds), seeding the label's
|
||||
key-derived hue; ``None`` leaves the label the muted no-key grey.
|
||||
"""
|
||||
|
||||
label: str
|
||||
lat: float
|
||||
lon: float
|
||||
is_repeater: bool = False
|
||||
is_self: bool = False
|
||||
detail: str = ""
|
||||
key: str | None = None
|
||||
|
||||
def _rank(self) -> int:
|
||||
"""Draw order: self on top of repeaters on top of leaf nodes."""
|
||||
return 2 if self.is_self else (1 if self.is_repeater else 0)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NeighbourInfo:
|
||||
"""One entry of a remote repeater's neighbour table, fetched over the mesh.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for muted channel notifications.
|
||||
|
||||
Some channels carry traffic you want *recorded* but not *notified* about — an automated
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""A tiny, dependency-free Mapbox Vector Tile (MVT) decoder.
|
||||
|
||||
Vector tiles are Protocol-Buffer messages (spec: github.com/mapbox/vector-tile-spec). Rather
|
||||
|
||||
+149
-42
@@ -1,4 +1,5 @@
|
||||
"""Declarative registry of MeshTerm's own preferences, and the YAML file they live in.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Declarative registry of MeshTerm's own preferences, and the TOML file they live in.
|
||||
|
||||
The distinction this module draws is between the three kinds of "setting" the app already
|
||||
had and never named apart:
|
||||
@@ -22,9 +23,11 @@ away from that default, so the file is a short list of your disagreements with t
|
||||
built-in behaviour rather than a snapshot that silently pins every value forever. Delete a
|
||||
key (or use *Reset to defaults*) and the code's default takes over again.
|
||||
|
||||
The file is ``<config_dir>/preferences.yaml``, written grouped and commented so it reads
|
||||
the way the page does. Reads are tolerant: an unknown key, a malformed value, or an
|
||||
unparseable file costs the override, never the session.
|
||||
The file is ``<config_dir>/preferences.toml``, written flat, grouped and commented so it
|
||||
reads the way the page does — TOML, like ``config.toml`` and the device-config backups,
|
||||
so every file a person edits by hand speaks one syntax. Reads are tolerant: an unknown
|
||||
key, a malformed value, or a line TOML refuses costs that one override, never the rest of
|
||||
the file and never the session.
|
||||
|
||||
Two access paths, deliberately:
|
||||
|
||||
@@ -37,20 +40,29 @@ Two access paths, deliberately:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .atomicwrite import write_atomically
|
||||
from .watch_store import DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
|
||||
from .courier_store import DONE_CAP
|
||||
from .geo import DEFAULT_VIEW_FRACTION
|
||||
from .watch_store import ALERT_CAP, DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else: # pragma: no cover - exercised only on 3.10
|
||||
import tomli as tomllib
|
||||
|
||||
#: Display groups, in the order the page and the file present them. The order is the
|
||||
#: order a session happens in — what MeshTerm puts on the air, how loud, what it watches
|
||||
#: for, how it draws the world, what it keeps, and how it paints. Nothing sorts
|
||||
#: alphabetically: a reader looking for "how long before I give up on a message" should
|
||||
#: not have to know it starts with a D.
|
||||
#: order a session happens in — what MeshTerm does to the radio as the link opens, what
|
||||
#: it puts on the air, how loud, what it watches for, how it draws the world, what it
|
||||
#: keeps, and how it paints. Nothing sorts alphabetically: a reader looking for "how long
|
||||
#: before I give up on a message" should not have to know it starts with a D.
|
||||
GROUPS: tuple[str, ...] = (
|
||||
"Device",
|
||||
"Sending",
|
||||
"TX optimize",
|
||||
"Watchtower",
|
||||
@@ -75,7 +87,7 @@ class PrefSpec:
|
||||
|
||||
Attributes:
|
||||
key: Canonical key — the attribute name on :class:`Preferences`, and the key in
|
||||
the YAML file.
|
||||
the TOML file.
|
||||
label: Human-friendly name, as the page's SETTING lane shows it.
|
||||
help: One-line description, as the page's DESCRIPTION lane shows it.
|
||||
group: One of :data:`GROUPS`.
|
||||
@@ -89,6 +101,14 @@ class PrefSpec:
|
||||
relaunch: Whether the new value only takes hold next launch. Woven into the
|
||||
description rather than shown as its own mark — it is a caveat about one
|
||||
preference, not a status the reader scans a column for.
|
||||
platforms: Which :class:`~meshterm.platforms.Platform` names the preference is
|
||||
*offered* on, or ``None`` (the default) for all of them. A preference about
|
||||
hardware only one flavour has — the PicoCalc's console font — would be a row
|
||||
answering a question the desktop cannot ask, so the page leaves it out there.
|
||||
Only the page: the key stays in the registry on every platform, so a file
|
||||
written on the handheld still parses, keeps its value and saves back out on a
|
||||
desktop rather than being silently dropped by a machine that merely cannot
|
||||
act on it.
|
||||
"""
|
||||
|
||||
key: str
|
||||
@@ -102,12 +122,24 @@ class PrefSpec:
|
||||
maximum: float | None = None
|
||||
unit: str = ""
|
||||
relaunch: bool = False
|
||||
platforms: frozenset[str] | None = None
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""The DESCRIPTION lane's text: the help, plus the relaunch caveat where it applies."""
|
||||
return f"{self.help} (next launch)" if self.relaunch else self.help
|
||||
|
||||
def offered_on(self, platform: str) -> bool:
|
||||
"""Whether the editor draws a row for this preference on ``platform``.
|
||||
|
||||
Args:
|
||||
platform: A :attr:`~meshterm.platforms.Platform.name`.
|
||||
|
||||
Returns:
|
||||
``True`` unless :attr:`platforms` names a set this platform is not in.
|
||||
"""
|
||||
return self.platforms is None or platform in self.platforms
|
||||
|
||||
|
||||
#: The two languages the plain CLI can print a time in. Named for what the reader sees
|
||||
#: rather than for the mechanism: an *age* is how long ago, a *timestamp* is when.
|
||||
@@ -140,12 +172,6 @@ _COLOR_DEPTH_CHOICES: dict[str, str] = {
|
||||
"16": "16",
|
||||
}
|
||||
|
||||
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
|
||||
#: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a
|
||||
#: default — nothing else has to follow.
|
||||
#: What the log file keeps. The plain level names, which are what every other tool calls
|
||||
#: these — someone being talked through a problem is being told "set it to debug", not
|
||||
#: "set it to everything".
|
||||
#: What MeshTerm may offer when it lands in a console that cannot draw it. Only ever
|
||||
#: consulted on the classic Windows console, the one host with no font fallback of its own.
|
||||
_CONSOLE_SETUP_CHOICES: dict[str, str] = {
|
||||
@@ -153,6 +179,19 @@ _CONSOLE_SETUP_CHOICES: dict[str, str] = {
|
||||
"off": "Leave it alone",
|
||||
}
|
||||
|
||||
#: The two console fonts ``scripts/picocalc/calculinux-console-font-6x12.sh`` (and its 6x8
|
||||
#: companion) build for the PicoCalc's framebuffer panel. Each is named by its cell and by the
|
||||
#: screen that cell buys, because choosing a font here is choosing how much fits — not a bitmap.
|
||||
#: Plain ASCII ``x`` and not ``×``: the multiplication sign is not one of the 512 glyphs
|
||||
#: the console font carries, so it would draw as a tofu box on the one platform this
|
||||
#: preference is offered on. The value maps to a file in
|
||||
#: :data:`meshterm.services.consolefont.FONT_FILES`, which is where a filename belongs —
|
||||
#: a path on one device is not something a reader picks.
|
||||
_CONSOLE_FONT_CHOICES: dict[str, str] = {"6x12": "6x12 (53x26)", "6x8": "6x8 (53x40)"}
|
||||
|
||||
#: What the log file keeps. The plain level names, which are what every other tool calls
|
||||
#: these — someone being talked through a problem is being told "set it to debug", not
|
||||
#: "set it to everything".
|
||||
_LOG_LEVEL_CHOICES: dict[str, str] = {
|
||||
"ERROR": "Error",
|
||||
"WARNING": "Warning",
|
||||
@@ -161,7 +200,24 @@ _LOG_LEVEL_CHOICES: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
|
||||
#: Preferences page, a key in the TOML file, a ``preferences get``/``set`` CLI face, and a
|
||||
#: default — nothing else has to follow. A default that a module already states as its
|
||||
#: code-level behaviour is named, never re-typed: one source, so changing it cannot leave
|
||||
#: the constant and the registry disagreeing about what MeshTerm does.
|
||||
PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
# --- Device ------------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="set_clock_on_connect",
|
||||
label="Set clock on connect",
|
||||
help="Set the device clock from this computer when it connects",
|
||||
group="Device",
|
||||
value_type="bool",
|
||||
# Off: writing to a radio nobody asked to be written to is the owner's call. The
|
||||
# set is silent (the log records it) and happens once per connection — see
|
||||
# :mod:`meshterm.services.clock_sync`.
|
||||
default=False,
|
||||
),
|
||||
# --- Sending -----------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="trace_cooldown_s",
|
||||
@@ -245,7 +301,7 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
help="How many past alerts to keep",
|
||||
group="Watchtower",
|
||||
value_type="int",
|
||||
default=200,
|
||||
default=ALERT_CAP,
|
||||
minimum=10,
|
||||
maximum=5000,
|
||||
),
|
||||
@@ -256,7 +312,7 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
help="Share of nodes to fit on screen; 1 shows them all",
|
||||
group="Map",
|
||||
value_type="float",
|
||||
default=0.5,
|
||||
default=DEFAULT_VIEW_FRACTION,
|
||||
minimum=0.1,
|
||||
maximum=1.0,
|
||||
),
|
||||
@@ -297,7 +353,7 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
help="Finished outbox messages to keep",
|
||||
group="History",
|
||||
value_type="int",
|
||||
default=100,
|
||||
default=DONE_CAP,
|
||||
minimum=10,
|
||||
maximum=5000,
|
||||
),
|
||||
@@ -361,6 +417,20 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
default="auto",
|
||||
choices=_CONSOLE_SETUP_CHOICES,
|
||||
),
|
||||
PrefSpec(
|
||||
key="console_font",
|
||||
label="Console font",
|
||||
help="Bigger text, or more rows on the handheld's panel",
|
||||
group="Display",
|
||||
value_type="enum",
|
||||
# The 6x12 Terminus derivative, because it is the font the device boots with and
|
||||
# the one every screen's row budget is designed to (Platform.readable_rows is 26).
|
||||
# The 6x8 build is the same glyph inventory in a shorter cell — fourteen more rows
|
||||
# for anyone who would rather see more of a list than read it comfortably.
|
||||
default="6x12",
|
||||
choices=_CONSOLE_FONT_CHOICES,
|
||||
platforms=frozenset({"picocalc"}),
|
||||
),
|
||||
# --- Diagnostics ---------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="log_level",
|
||||
@@ -398,13 +468,21 @@ def get_spec(key: str) -> PrefSpec:
|
||||
raise PreferenceError(f"unknown preference: {key!r}") from None
|
||||
|
||||
|
||||
def by_group() -> list[tuple[str, list[PrefSpec]]]:
|
||||
def by_group(platform: str | None = None) -> list[tuple[str, list[PrefSpec]]]:
|
||||
"""Every preference grouped for display, in :data:`GROUPS` order.
|
||||
|
||||
Args:
|
||||
platform: A :attr:`~meshterm.platforms.Platform.name` to draw the page for, which
|
||||
drops the preferences that platform is not offered (see
|
||||
:meth:`PrefSpec.offered_on`). ``None`` — the default, and what the file writer
|
||||
uses — keeps every preference, so an override made on one flavour survives
|
||||
being loaded and saved on another.
|
||||
|
||||
Returns:
|
||||
``(group, specs)`` pairs; a group holding no preferences is omitted.
|
||||
"""
|
||||
grouped = [(g, [s for s in PREFERENCES if s.group == g]) for g in GROUPS]
|
||||
kept = [s for s in PREFERENCES if platform is None or s.offered_on(platform)]
|
||||
grouped = [(g, [s for s in kept if s.group == g]) for g in GROUPS]
|
||||
return [(g, specs) for g, specs in grouped if specs]
|
||||
|
||||
|
||||
@@ -444,15 +522,15 @@ def parse_value(spec: PrefSpec, raw: Any) -> Any:
|
||||
if raw in choices and not isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, bool):
|
||||
# YAML 1.1 reads a bare ``yes``/``no`` (and ``on``/``off``) as a *boolean*, so a
|
||||
# hand-edited file hands us ``True`` where the choice is spelled "yes". We quote
|
||||
# ours on the way out, but someone typing the obvious thing should still be
|
||||
# understood: map the boolean back to whichever spelling this spec offers.
|
||||
# A hand edit that answers a yes/no choice with a TOML boolean
|
||||
# (``full_width = true``) hands us ``True`` where the choice is spelled "yes".
|
||||
# We quote ours on the way out, but someone typing the obvious thing should
|
||||
# still be understood: map the boolean back to whichever spelling this offers.
|
||||
for choice in choices:
|
||||
if str(choice).lower() in (_TRUE if raw else _FALSE):
|
||||
return choice
|
||||
# Text arriving from the CLI or a hand-edited file: match a choice by its own
|
||||
# spelling, so `preferences set watch_silence_hours 6` and a YAML `6` land alike.
|
||||
# spelling, so `preferences set watch_silence_hours 6` and a TOML `6` land alike.
|
||||
for choice in choices:
|
||||
if text.lower() == str(choice).lower():
|
||||
return choice
|
||||
@@ -508,6 +586,9 @@ def range_hint(spec: PrefSpec) -> str:
|
||||
|
||||
# --- the store ----------------------------------------------------------------
|
||||
|
||||
#: The file's name inside the config directory — one spelling for every place that opens it.
|
||||
PREFERENCES_FILENAME = "preferences.toml"
|
||||
|
||||
#: The file's opening comment. It says the one thing a reader hand-editing the file needs
|
||||
#: to know — that absence means "use the default", so deleting a line is how a preference
|
||||
#: is undone — and points at the two neighbouring kinds of setting so nobody looks for a
|
||||
@@ -520,11 +601,36 @@ _HEADER = """\
|
||||
# Preferences page) hands the value back to the code. `meshterm preferences show` prints
|
||||
# every preference, what it is set to, and what it defaults to.
|
||||
#
|
||||
# One `key = value` per line: text in quotes ("DEBUG"), numbers and true/false bare.
|
||||
#
|
||||
# Device profiles, the database location, and the rest of the machine setup stay in
|
||||
# config.toml; the radio's own settings live on the radio, under Device config.
|
||||
"""
|
||||
|
||||
|
||||
def _salvage(text: str) -> dict[str, Any]:
|
||||
"""Read a file TOML refused as a document, one ``key = value`` line at a time.
|
||||
|
||||
MeshTerm writes the file flat — no tables, one entry to a line — so a line is a whole
|
||||
entry and can be judged on its own. A line TOML accepts keeps its typed value. One it
|
||||
refuses keeps its right-hand side as text, which is exactly what :func:`parse_value`
|
||||
takes from the command line, so the likeliest hand edit of all — a word left unquoted,
|
||||
``log_level = DEBUG`` — is still understood. Whatever the spec then refuses is dropped
|
||||
by :meth:`Preferences.update`, the same as any other bad value.
|
||||
"""
|
||||
data: dict[str, Any] = {}
|
||||
for line in text.splitlines():
|
||||
entry = line.strip()
|
||||
if not entry or entry.startswith(("#", "[")) or "=" not in entry:
|
||||
continue
|
||||
try:
|
||||
data.update(tomllib.loads(entry))
|
||||
except tomllib.TOMLDecodeError:
|
||||
key, _, value = entry.partition("=")
|
||||
data[key.strip()] = value.split("#", 1)[0].strip()
|
||||
return data
|
||||
|
||||
|
||||
class Preferences:
|
||||
"""The effective preferences: the built-in defaults, with the file's overrides on top.
|
||||
|
||||
@@ -554,21 +660,27 @@ class Preferences:
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> Preferences:
|
||||
"""Read ``path``, returning all-defaults for a missing, empty, or corrupt file.
|
||||
"""Read ``path``, returning all-defaults for a missing, empty, or unreadable file.
|
||||
|
||||
TOML refuses a whole document over one bad line, and a hand edit should cost the
|
||||
line it is on, not the file: a document TOML refuses is read again a line at a
|
||||
time (:func:`_salvage`), keeping every entry that still makes sense.
|
||||
|
||||
Args:
|
||||
path: The YAML file to read.
|
||||
path: The TOML file to read.
|
||||
|
||||
Returns:
|
||||
The loaded preferences, bound to ``path`` for a later :meth:`save`.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, yaml.YAMLError):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, ValueError):
|
||||
return cls(path)
|
||||
return cls(path, data if isinstance(data, Mapping) else None)
|
||||
try:
|
||||
data: dict[str, Any] = tomllib.loads(text)
|
||||
except tomllib.TOMLDecodeError:
|
||||
data = _salvage(text)
|
||||
return cls(path, data)
|
||||
|
||||
@property
|
||||
def path(self) -> Path | None:
|
||||
@@ -656,14 +768,14 @@ class Preferences:
|
||||
self._values.clear()
|
||||
return count
|
||||
|
||||
def as_yaml(self) -> str:
|
||||
def as_toml(self) -> str:
|
||||
"""Render the current overrides as the file's text: header, then grouped entries.
|
||||
|
||||
Each group holding an override gets its own comment heading, and each entry its
|
||||
help line and its default above it, so the file reads the way the page does
|
||||
rather than as a bare map someone has to look up elsewhere.
|
||||
"""
|
||||
import yaml
|
||||
import tomli_w
|
||||
|
||||
lines = [_HEADER]
|
||||
for group, specs in by_group():
|
||||
@@ -672,12 +784,7 @@ class Preferences:
|
||||
continue
|
||||
lines.append(f"# --- {group} ---")
|
||||
for spec in present:
|
||||
entry = yaml.safe_dump(
|
||||
{spec.key: self._values[spec.key]},
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
).rstrip("\n")
|
||||
entry = tomli_w.dumps({spec.key: self._values[spec.key]}).rstrip("\n")
|
||||
lines.append(f"# {spec.help}")
|
||||
lines.append(f"# default: {format_value(spec, spec.default)}")
|
||||
lines.append(entry)
|
||||
@@ -695,7 +802,7 @@ class Preferences:
|
||||
"""
|
||||
if self._path is None:
|
||||
raise RuntimeError("these preferences have no file to save to")
|
||||
write_atomically(self._path, self.as_yaml())
|
||||
write_atomically(self._path, self.as_toml())
|
||||
|
||||
|
||||
#: The set installed for this process. Starts as a defaults-only, file-less instance so
|
||||
|
||||
+661
-96
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The remote-node settings catalog: what a repeater can be asked and told over the mesh.
|
||||
|
||||
Repeaters and room servers are administered through their text CLI — ``get``/``set``
|
||||
@@ -7,23 +8,80 @@ companion protocol the local device-configuration editor drives. This module is
|
||||
data-driven bridge: one :class:`RemoteSetting` per knob the standard MeshCore repeater
|
||||
firmware exposes, each carrying its CLI spelling, a one-line explanation, and enough
|
||||
typing to prompt and validate sensibly. The repeater-admin screen renders the catalog in
|
||||
the local editor's lanes, stages values, and applies them as ``set`` commands; anything
|
||||
the catalog doesn't cover is one keystroke away in the raw command line.
|
||||
the local editor's lanes, stages values, and applies them; anything the catalog doesn't
|
||||
cover is one keystroke away in the raw command line.
|
||||
|
||||
Replies are parsed *loosely* on purpose: repeater firmware answers tersely and
|
||||
inconsistently across versions (``"tx: 20"``, ``"TX power = 20 dBm"``, ``"> ok"``), so
|
||||
values are extracted rather than pattern-matched, and the raw reply is always kept for
|
||||
display. Firmware without a given key simply answers with an error string, which shows
|
||||
in the value lane instead of breaking the screen.
|
||||
The catalog is transcribed from the firmware itself — ``handleGetCmd``/``handleSetCmd``/
|
||||
``handleCommand`` in MeshCore's ``src/helpers/CommonCLI.cpp`` — not from convention, and
|
||||
three of its shapes only make sense against that source:
|
||||
|
||||
* **Some settings are not ``get``/``set`` keys at all.** ``powersaving``, ``gps`` and
|
||||
``gps advert`` are top-level verbs that answer bare and take their value as an argument
|
||||
(:attr:`RemoteSetting.verb`).
|
||||
* **Some settings travel together.** There is no ``get bw``: frequency, bandwidth,
|
||||
spreading factor and coding rate are read as one ``get radio`` reply and written as one
|
||||
``set radio f,bw,sf,cr`` (``set freq`` alone is refused over the mesh — the firmware
|
||||
honours it only on its serial console). Those four are one
|
||||
:attr:`RemoteSetting.composite`, and :func:`read_plan`/:func:`write_plan` turn a screen's
|
||||
worth of rows into the fewest round trips that carry them.
|
||||
* **One firmware value can have two spellings.** ``dutycycle`` (1.15+) and the older
|
||||
``af`` both read and write ``airtime_factor``, so writing one stales the other
|
||||
(:attr:`RemoteSetting.overlaps`).
|
||||
|
||||
Replies are parsed *loosely* on purpose: repeater firmware answers tersely and has changed
|
||||
its phrasing across versions (``"> 20"``, ``"tx: 20"``, ``"OK - repeat is now ON"``), so
|
||||
values are extracted rather than pattern-matched. Firmware without a given key answers
|
||||
with an error string (``??: key`` to a ``get``, ``unknown config: …`` to a ``set``) — or,
|
||||
because ``get`` matches keys by prefix, with a shorter sibling's value (v1.15 answers
|
||||
``get radio.fem.rxgain`` with the ``get radio`` line). The screen records either as *this
|
||||
node doesn't have it* rather than breaking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
#: Reply text (lowercased) that reads as the firmware refusing/unknowing a command.
|
||||
_ERRORISH = ("unknown", "error", "err:", "invalid", "denied", "bad ")
|
||||
#: Reply text (lowercased) that reads as the firmware refusing/unknowing a command. ``??:``
|
||||
#: is how ``handleGetCmd`` answers a key it doesn't know; ``unknown config:`` is its ``set``
|
||||
#: twin.
|
||||
_ERRORISH = (
|
||||
"??:",
|
||||
"unknown",
|
||||
"error",
|
||||
"err:",
|
||||
"invalid",
|
||||
"denied",
|
||||
"bad ",
|
||||
"not supported",
|
||||
"unsupported",
|
||||
)
|
||||
|
||||
#: A ``get`` reply's value echo. Whatever follows it is a value, even one that happens to
|
||||
#: contain an error-ish word (a node named ``Unknown-Hill`` is not a refusal).
|
||||
_ECHO = re.compile(r"^\s*-?>\s?")
|
||||
|
||||
#: The words a boolean reply may use for each state, beyond the setting's own
|
||||
#: :attr:`RemoteSetting.words`.
|
||||
_ON_WORDS = frozenset({"on", "true", "yes", "1"})
|
||||
_OFF_WORDS = frozenset({"off", "false", "no", "0"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Option:
|
||||
"""One value of an enumerated remote setting.
|
||||
|
||||
Attributes:
|
||||
value: What the firmware takes in a ``set`` and answers to a ``get`` — also what
|
||||
the cache stores.
|
||||
label: What the value lane and the picker show for it.
|
||||
help: An optional one-line explanation for the picker row.
|
||||
"""
|
||||
|
||||
value: str
|
||||
label: str
|
||||
help: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -31,16 +89,35 @@ class RemoteSetting:
|
||||
"""One remote-CLI setting: its spelling, presentation, and typing.
|
||||
|
||||
Attributes:
|
||||
key: The CLI parameter name as the firmware spells it (e.g. ``"txdelay"``).
|
||||
key: The setting's identity: the CLI parameter name as the firmware spells it
|
||||
(``"txdelay"``) for a ``get``/``set`` key, the verb for a verb-shaped one, and
|
||||
the field's own name for a composite member. It is also the cache key.
|
||||
label: The display name shown in the editor lane.
|
||||
help: One-line explanation (no trailing period), shown beside the value.
|
||||
category: Editor section heading the setting sorts under.
|
||||
kind: ``"int"``, ``"float"``, ``"str"``, or ``"bool"`` (on/off).
|
||||
writable: Whether the firmware accepts a ``set`` for this key.
|
||||
readable: Whether the firmware answers a ``get`` (passwords do not).
|
||||
kind: ``"int"``, ``"float"``, ``"str"``, ``"bool"`` (on/off) or ``"enum"``.
|
||||
writable: Whether the firmware accepts a write for this setting.
|
||||
readable: Whether the firmware answers a read for it.
|
||||
minimum: Lower bound for numeric prompts, when known.
|
||||
maximum: Upper bound for numeric prompts, when known.
|
||||
unit: Display unit hint (e.g. ``"dBm"``, ``"min"``), purely cosmetic.
|
||||
decimals: Fixed decimal places a float is shown and sent with; ``None`` shows it
|
||||
as the firmware gave it, trailing zeros trimmed.
|
||||
zero_off: Whether ``0`` is accepted outside ``minimum``–``maximum``, meaning off
|
||||
(the advert intervals: 0, or 60–240 minutes).
|
||||
options: The values an ``enum`` takes, in picker order.
|
||||
words: What a ``bool`` sends for on and off. Most keys take ``on``/``off``;
|
||||
``multi.acks`` is an ``atoi`` and needs ``1``/``0``.
|
||||
aliases: ``(reply word, value)`` pairs consulted before the kind's own parse, for
|
||||
a reply that names a value in words of its own — ``bridge.source`` answers
|
||||
``logRx`` to what it is told as ``rx``.
|
||||
verb: For a verb-shaped setting, the command that reads it bare and writes it
|
||||
with the value appended (``"powersaving"``); empty for a ``get``/``set`` key.
|
||||
composite: The CLI key a group of settings is read and written through as one
|
||||
comma-joined value (``"radio"``); empty for a setting that stands alone.
|
||||
part: This setting's position within its composite's comma-joined value.
|
||||
overlaps: Keys that are another spelling of the same firmware value, whose cached
|
||||
reading a write to this one makes stale.
|
||||
"""
|
||||
|
||||
key: str
|
||||
@@ -53,29 +130,81 @@ class RemoteSetting:
|
||||
minimum: float | None = None
|
||||
maximum: float | None = None
|
||||
unit: str = ""
|
||||
decimals: int | None = None
|
||||
zero_off: bool = False
|
||||
options: tuple[Option, ...] = ()
|
||||
words: tuple[str, str] = ("on", "off")
|
||||
aliases: tuple[tuple[str, str], ...] = ()
|
||||
verb: str = ""
|
||||
composite: str = ""
|
||||
part: int = 0
|
||||
overlaps: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def get_command(self) -> str:
|
||||
"""The CLI command that reads this setting."""
|
||||
return f"get {self.key}"
|
||||
"""The CLI command that reads this setting (shared by a composite's members)."""
|
||||
if self.verb:
|
||||
return self.verb
|
||||
return f"get {self.composite or self.key}"
|
||||
|
||||
def set_command(self, value: str) -> str:
|
||||
"""The CLI command that writes ``value`` to this setting."""
|
||||
"""The CLI command that writes ``value`` to this setting.
|
||||
|
||||
Raises:
|
||||
ValueError: For a composite member, which is only ever written together with
|
||||
its siblings — see :func:`write_plan`.
|
||||
"""
|
||||
if self.composite:
|
||||
raise ValueError(f"{self.key} is written through 'set {self.composite}'")
|
||||
if self.kind == "bool":
|
||||
value = self.words[0] if value == "on" else self.words[1]
|
||||
if self.verb:
|
||||
return f"{self.verb} {value}"
|
||||
return f"set {self.key} {value}"
|
||||
|
||||
def display(self, value: str) -> str:
|
||||
"""The value lane's text for a stored ``value`` (an enum shows its label)."""
|
||||
for option in self.options:
|
||||
if option.value == value:
|
||||
return option.label
|
||||
return value
|
||||
|
||||
#: Every knob the standard MeshCore repeater/room firmware exposes over its CLI, in the
|
||||
#: editor's display order. TX delay and Direct TX delay — absent from the local
|
||||
#: companion editor because companions don't repeat — are first-class here.
|
||||
|
||||
def _radio(key: str, label: str, help_text: str, part: int, **typing) -> RemoteSetting:
|
||||
"""One of the four fields ``get radio`` answers and ``set radio`` takes together."""
|
||||
return RemoteSetting(
|
||||
key=key,
|
||||
label=label,
|
||||
help=help_text,
|
||||
category="Radio",
|
||||
composite="radio",
|
||||
part=part,
|
||||
**typing,
|
||||
)
|
||||
|
||||
|
||||
#: Every setting the standard MeshCore repeater/room firmware exposes over the mesh, in the
|
||||
#: editor's display order. Board- and build-dependent ones (the front-end module's gains,
|
||||
#: GPS, the bridge) are listed like any other: a node built without them answers ``??:``,
|
||||
#: and the row says so instead of the catalog guessing which board it is talking to. Left
|
||||
#: out on purpose: ``prv.key`` and ``freq`` writes (serial console only), ``extra.sf``
|
||||
#: (LR2021 builds only, format board-defined), and the read-only diagnostics
|
||||
#: (``public.key``, ``role``, ``bootloader.ver``, ``pwrmgt.*``) — facts, not settings, and
|
||||
#: each one a paced round trip on every read. The command line reaches them all.
|
||||
REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
|
||||
# -- Identity ---------------------------------------------------------------
|
||||
RemoteSetting(
|
||||
key="name",
|
||||
label="Name",
|
||||
category="Identity",
|
||||
kind="str",
|
||||
help="The node's advertised name",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="owner.info",
|
||||
label="Owner info",
|
||||
category="Identity",
|
||||
help="Owner contact details (| starts a new line)",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="lat",
|
||||
label="Latitude",
|
||||
@@ -95,39 +224,43 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
|
||||
help="Advertised position, decimal degrees",
|
||||
),
|
||||
# -- Radio ------------------------------------------------------------------
|
||||
RemoteSetting(
|
||||
key="freq",
|
||||
label="Frequency",
|
||||
category="Radio",
|
||||
_radio(
|
||||
"freq",
|
||||
"Frequency",
|
||||
"Carrier frequency — every node on the mesh must match; reboot to apply",
|
||||
0,
|
||||
kind="float",
|
||||
unit="MHz",
|
||||
help="Carrier frequency — every node on the mesh must match",
|
||||
minimum=150,
|
||||
maximum=2500,
|
||||
),
|
||||
RemoteSetting(
|
||||
key="bw",
|
||||
label="Bandwidth",
|
||||
category="Radio",
|
||||
_radio(
|
||||
"bw",
|
||||
"Bandwidth",
|
||||
"Channel bandwidth — must match the mesh; reboot to apply",
|
||||
1,
|
||||
kind="float",
|
||||
unit="kHz",
|
||||
help="Channel bandwidth — must match the mesh",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="sf",
|
||||
label="Spreading factor",
|
||||
category="Radio",
|
||||
kind="int",
|
||||
minimum=7,
|
||||
maximum=12,
|
||||
help="LoRa spreading factor — must match the mesh",
|
||||
maximum=500,
|
||||
),
|
||||
RemoteSetting(
|
||||
key="cr",
|
||||
label="Coding rate",
|
||||
category="Radio",
|
||||
_radio(
|
||||
"sf",
|
||||
"Spreading factor",
|
||||
"LoRa spreading factor — must match the mesh; reboot to apply",
|
||||
2,
|
||||
kind="int",
|
||||
minimum=5,
|
||||
maximum=12,
|
||||
),
|
||||
_radio(
|
||||
"cr",
|
||||
"Coding rate",
|
||||
"LoRa coding rate denominator — must match the mesh; reboot to apply",
|
||||
3,
|
||||
kind="int",
|
||||
minimum=5,
|
||||
maximum=8,
|
||||
help="LoRa coding rate denominator — must match the mesh",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="tx",
|
||||
@@ -139,6 +272,50 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
|
||||
maximum=30,
|
||||
help="Transmit power — the TX optimize tool tunes this by measurement",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="radio.rxgain",
|
||||
label="Boosted RX gain",
|
||||
category="Radio",
|
||||
kind="bool",
|
||||
help="The receiver's boosted gain mode (firmware 1.14.1+)",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="radio.fem.rxgain",
|
||||
label="FEM RX gain",
|
||||
category="Radio",
|
||||
kind="bool",
|
||||
help="The front-end module's receive gain (boards with one)",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="radio.fem.txgain",
|
||||
label="FEM TX gain",
|
||||
category="Radio",
|
||||
kind="bool",
|
||||
help="The front-end module's transmit gain (boards with one)",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="cad",
|
||||
label="Activity detection",
|
||||
category="Radio",
|
||||
kind="bool",
|
||||
help="Listen for LoRa activity in hardware before transmitting",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="int.thresh",
|
||||
label="Interference threshold",
|
||||
category="Radio",
|
||||
kind="int",
|
||||
help="Local interference threshold (0 = off)",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="agc.reset.interval",
|
||||
label="AGC reset interval",
|
||||
category="Radio",
|
||||
kind="int",
|
||||
unit="s",
|
||||
minimum=0,
|
||||
help="Seconds between receiver gain resets, in steps of 4 (0 = off)",
|
||||
),
|
||||
# -- Repeating ----------------------------------------------------------------
|
||||
RemoteSetting(
|
||||
key="repeat",
|
||||
@@ -151,40 +328,121 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
|
||||
key="txdelay",
|
||||
label="TX delay",
|
||||
category="Repeating",
|
||||
kind="int",
|
||||
kind="float",
|
||||
decimals=1,
|
||||
minimum=0,
|
||||
maximum=2,
|
||||
help="Delay factor before rebroadcasting a flood packet",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="direct.txdelay",
|
||||
label="Direct TX delay",
|
||||
category="Repeating",
|
||||
kind="int",
|
||||
kind="float",
|
||||
decimals=1,
|
||||
minimum=0,
|
||||
maximum=2,
|
||||
help="Delay factor before forwarding a directed packet",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="rxdelay",
|
||||
label="RX delay",
|
||||
category="Repeating",
|
||||
kind="int",
|
||||
kind="float",
|
||||
decimals=1,
|
||||
minimum=0,
|
||||
maximum=20,
|
||||
help="Base delay before acting on a received packet",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="dutycycle",
|
||||
label="Duty cycle",
|
||||
category="Repeating",
|
||||
kind="float",
|
||||
decimals=1,
|
||||
unit="%",
|
||||
minimum=1,
|
||||
maximum=100,
|
||||
overlaps=("af",),
|
||||
help="Share of the air the node may transmit in (firmware 1.15+)",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="af",
|
||||
label="Airtime factor",
|
||||
category="Repeating",
|
||||
kind="float",
|
||||
decimals=2,
|
||||
minimum=0,
|
||||
help="Duty-cycle multiplier applied to every transmission",
|
||||
maximum=9,
|
||||
overlaps=("dutycycle",),
|
||||
help="Older firmware's duty-cycle knob: duty cycle = 100 / (af + 1) %",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="allow.read.only",
|
||||
label="Allow read-only",
|
||||
key="loop.detect",
|
||||
label="Loop detection",
|
||||
category="Repeating",
|
||||
kind="enum",
|
||||
options=(
|
||||
Option("off", "off"),
|
||||
Option("minimal", "minimal"),
|
||||
Option("moderate", "moderate"),
|
||||
Option("strict", "strict"),
|
||||
),
|
||||
help="How readily the node drops a packet that loops back through it",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="path.hash.mode",
|
||||
label="Path-hash mode",
|
||||
category="Repeating",
|
||||
kind="enum",
|
||||
# The firmware refuses 3 here (``mode < 3``), though the field has room for it. The
|
||||
# labels stay short because the widest value sizes the lane for every row: "hashes"
|
||||
# in each one pushed the descriptions off a 72-column screen once one was staged.
|
||||
options=(
|
||||
Option("0", "1-byte", "The default"),
|
||||
Option("1", "2-byte"),
|
||||
Option("2", "3-byte"),
|
||||
),
|
||||
help="Longer hop ids mix up fewer nodes, but cost space",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="multi.acks",
|
||||
label="Multi-acks",
|
||||
category="Repeating",
|
||||
kind="bool",
|
||||
help="Whether guests may log in with the guest password",
|
||||
words=("1", "0"),
|
||||
help="Send extra receipts so replies get through",
|
||||
),
|
||||
# -- Flooding -----------------------------------------------------------------
|
||||
RemoteSetting(
|
||||
key="flood.max",
|
||||
label="Flood max",
|
||||
category="Flooding",
|
||||
kind="int",
|
||||
unit="hops",
|
||||
minimum=0,
|
||||
maximum=64,
|
||||
help="A flood that already took this many hops isn't rebroadcast",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="flood.max.unscoped",
|
||||
label="Flood max (unscoped)",
|
||||
category="Flooding",
|
||||
kind="int",
|
||||
unit="hops",
|
||||
minimum=0,
|
||||
maximum=64,
|
||||
help="The same cap, for floods sent without a region scope",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="flood.max.advert",
|
||||
label="Flood max (adverts)",
|
||||
category="Flooding",
|
||||
kind="int",
|
||||
unit="hops",
|
||||
minimum=0,
|
||||
maximum=64,
|
||||
help="The same cap, for flooded adverts",
|
||||
),
|
||||
# -- Adverts -----------------------------------------------------------------
|
||||
RemoteSetting(
|
||||
@@ -193,8 +451,10 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
|
||||
category="Adverts",
|
||||
kind="int",
|
||||
unit="min",
|
||||
minimum=0,
|
||||
help="Minutes between the node's own zero-hop adverts (0 = off)",
|
||||
minimum=60,
|
||||
maximum=240,
|
||||
zero_off=True,
|
||||
help="Minutes between the node's own zero-hop adverts, in steps of 2 (0 = off)",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="flood.advert.interval",
|
||||
@@ -202,59 +462,199 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = (
|
||||
category="Adverts",
|
||||
kind="int",
|
||||
unit="h",
|
||||
minimum=0,
|
||||
minimum=3,
|
||||
maximum=168,
|
||||
zero_off=True,
|
||||
help="Hours between the node's own flood adverts (0 = off)",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="flood.max",
|
||||
label="Flood max",
|
||||
category="Adverts",
|
||||
kind="int",
|
||||
minimum=0,
|
||||
help="Hop cap the node applies when rebroadcasting floods (0 = no cap)",
|
||||
),
|
||||
# -- Access ------------------------------------------------------------------
|
||||
RemoteSetting(
|
||||
key="guest.password",
|
||||
label="Guest password",
|
||||
category="Access",
|
||||
kind="str",
|
||||
readable=False,
|
||||
help="Read-only login password (write-only over the CLI)",
|
||||
help="Password for read-only (guest) logins",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="allow.read.only",
|
||||
label="Allow read-only",
|
||||
category="Access",
|
||||
kind="bool",
|
||||
help="Allow read-only access (room servers)",
|
||||
),
|
||||
# -- Power -------------------------------------------------------------------
|
||||
RemoteSetting(
|
||||
key="powersaving",
|
||||
label="Power saving",
|
||||
category="Power",
|
||||
kind="bool",
|
||||
verb="powersaving",
|
||||
help="Sleep between transmissions to save battery",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="adc.multiplier",
|
||||
label="ADC multiplier",
|
||||
category="Power",
|
||||
kind="float",
|
||||
decimals=3,
|
||||
minimum=0,
|
||||
maximum=10,
|
||||
help="Battery voltage calibration (0 = the board's default)",
|
||||
),
|
||||
# -- GPS ---------------------------------------------------------------------
|
||||
RemoteSetting(
|
||||
key="gps",
|
||||
label="GPS",
|
||||
category="GPS",
|
||||
kind="bool",
|
||||
verb="gps",
|
||||
# A board with a receiver answers "on, active|deactivated, fix, N sats" whether or
|
||||
# not it is enabled; the second word is the one that says.
|
||||
aliases=(("active", "on"), ("deactivated", "off")),
|
||||
help="Run the node's GPS receiver (boards with one)",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="gps advert",
|
||||
label="Advertised position",
|
||||
category="GPS",
|
||||
kind="enum",
|
||||
verb="gps advert",
|
||||
options=(
|
||||
Option("none", "none", "Advertise no position"),
|
||||
Option("share", "share", "Advertise the live GPS fix"),
|
||||
Option("prefs", "prefs", "Advertise the latitude and longitude set above"),
|
||||
),
|
||||
help="Which position the node's adverts carry",
|
||||
),
|
||||
# -- Bridge ------------------------------------------------------------------
|
||||
RemoteSetting(
|
||||
key="bridge.type",
|
||||
label="Bridge type",
|
||||
category="Bridge",
|
||||
writable=False,
|
||||
help="The bridge this firmware was built with: none, rs232 or espnow",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="bridge.enabled",
|
||||
label="Bridge enabled",
|
||||
category="Bridge",
|
||||
kind="bool",
|
||||
help="Relay packets over the bridge",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="bridge.source",
|
||||
label="Bridge source",
|
||||
category="Bridge",
|
||||
kind="enum",
|
||||
options=(
|
||||
Option("rx", "received", "Bridge the packets the node receives"),
|
||||
Option("tx", "transmitted", "Bridge the packets the node transmits"),
|
||||
),
|
||||
aliases=(("logrx", "rx"), ("logtx", "tx")),
|
||||
help="Which packets cross the bridge",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="bridge.delay",
|
||||
label="Bridge delay",
|
||||
category="Bridge",
|
||||
kind="int",
|
||||
unit="ms",
|
||||
minimum=0,
|
||||
maximum=10000,
|
||||
help="Delay before a packet crosses the bridge",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="bridge.baud",
|
||||
label="Bridge baud rate",
|
||||
category="Bridge",
|
||||
kind="enum",
|
||||
options=tuple(
|
||||
Option(rate, f"{rate} baud") for rate in ("9600", "19200", "38400", "57600", "115200")
|
||||
),
|
||||
help="Serial speed (RS-232 bridges)",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="bridge.channel",
|
||||
label="Bridge channel",
|
||||
category="Bridge",
|
||||
kind="int",
|
||||
minimum=1,
|
||||
maximum=14,
|
||||
help="Wi-Fi channel (ESP-NOW bridges)",
|
||||
),
|
||||
RemoteSetting(
|
||||
key="bridge.secret",
|
||||
label="Bridge secret",
|
||||
category="Bridge",
|
||||
help="Shared secret, up to 15 characters (ESP-NOW bridges)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
#: Commands the remote CLI screen offers as completions, beyond the catalog's
|
||||
#: ``get``/``set`` spellings: the fixed verbs a MeshCore repeater understands.
|
||||
#: Commands the remote CLI screen offers as completions, beyond the catalog's own read and
|
||||
#: write spellings: the fixed verbs a MeshCore repeater answers over the mesh. The ones its
|
||||
#: firmware keeps to the serial console (``erase``, ``log`` dumps, ``stats-*``) are left out,
|
||||
#: since completing them would only invite a refusal.
|
||||
KNOWN_COMMANDS: tuple[str, ...] = (
|
||||
"advert",
|
||||
"advert.zerohop",
|
||||
"board",
|
||||
"clear stats",
|
||||
"clkreboot",
|
||||
"clock",
|
||||
"clock sync",
|
||||
"discover.neighbors",
|
||||
"get ",
|
||||
"get public.key",
|
||||
"get role",
|
||||
"gps setloc",
|
||||
"gps sync",
|
||||
"log erase",
|
||||
"log start",
|
||||
"log stop",
|
||||
"neighbor.remove ",
|
||||
"neighbors",
|
||||
"password ",
|
||||
"poweroff",
|
||||
"reboot",
|
||||
"region",
|
||||
"region allowf ",
|
||||
"region default",
|
||||
"region denyf ",
|
||||
"region get ",
|
||||
"region home",
|
||||
"region put ",
|
||||
"region remove ",
|
||||
"region save",
|
||||
"sensor get ",
|
||||
"sensor list",
|
||||
"sensor set ",
|
||||
"set ",
|
||||
"setperm ",
|
||||
"start ota",
|
||||
"tempradio ",
|
||||
"time ",
|
||||
"ver",
|
||||
)
|
||||
|
||||
|
||||
def known_commands() -> list[str]:
|
||||
"""Every completion the remote CLI offers: verbs plus catalog get/set spellings."""
|
||||
"""Every completion the remote CLI offers: verbs plus the catalog's read/write spellings."""
|
||||
commands = set(KNOWN_COMMANDS)
|
||||
for spec in REPEATER_SETTINGS:
|
||||
if spec.readable:
|
||||
commands.add(spec.get_command)
|
||||
if spec.writable:
|
||||
commands.add(f"set {spec.key} ")
|
||||
if spec.composite:
|
||||
commands.add(f"set {spec.composite} ")
|
||||
elif spec.verb:
|
||||
commands.add(f"{spec.verb} ")
|
||||
else:
|
||||
commands.add(f"set {spec.key} ")
|
||||
return sorted(commands)
|
||||
|
||||
|
||||
def get_setting(key: str) -> RemoteSetting | None:
|
||||
"""Look up a catalog setting by its CLI key."""
|
||||
"""Look up a catalog setting by its key."""
|
||||
return next((s for s in REPEATER_SETTINGS if s.key == key), None)
|
||||
|
||||
|
||||
@@ -266,50 +666,103 @@ def settings_by_category() -> list[tuple[str, list[RemoteSetting]]]:
|
||||
return list(grouped.items())
|
||||
|
||||
|
||||
def composite_members(composite: str) -> list[RemoteSetting]:
|
||||
"""The settings a composite key carries, in their comma-joined order."""
|
||||
return sorted((s for s in REPEATER_SETTINGS if s.composite == composite), key=lambda s: s.part)
|
||||
|
||||
|
||||
def reply_is_error(reply: str) -> bool:
|
||||
"""Whether a reply text reads as the firmware refusing the command."""
|
||||
"""Whether a reply text reads as the firmware refusing the command.
|
||||
|
||||
A reply that opens with a ``get``'s ``>`` value echo is a value, whatever it says.
|
||||
"""
|
||||
if _ECHO.match(reply):
|
||||
return False
|
||||
lowered = reply.lower()
|
||||
return any(marker in lowered for marker in _ERRORISH)
|
||||
|
||||
|
||||
def parse_reply_value(spec: RemoteSetting, reply: str | None) -> str | None:
|
||||
"""Extract a display value from a ``get`` reply, or ``None`` when unusable.
|
||||
"""Extract a stored value from a read's reply, or ``None`` when unusable.
|
||||
|
||||
Numeric settings pull the first number out of the terse, version-varying reply
|
||||
text (the ``get tx`` convention); strings strip a leading ``key:``/``>`` echo.
|
||||
Error-ish replies parse as ``None`` so a firmware without the key shows unknown
|
||||
rather than adopting the error string as a value.
|
||||
Numbers are pulled out of whatever phrasing the firmware used and formatted the way
|
||||
:func:`normalize_value` formats a typed one, so a value read back compares equal to the
|
||||
same value staged. A composite member takes its own field of the comma-joined reply.
|
||||
Error-ish replies parse as ``None`` so a firmware without the key never adopts the error
|
||||
string as a value.
|
||||
|
||||
Args:
|
||||
spec: The setting the reply answers.
|
||||
reply: The raw reply text, or ``None`` if the node never answered.
|
||||
|
||||
Returns:
|
||||
The value as display text, or ``None``.
|
||||
The value as stored text (an enum's :attr:`Option.value`, a bool's ``on``/``off``),
|
||||
``""`` for a string the node reports as empty, or ``None``.
|
||||
"""
|
||||
if not reply or reply_is_error(reply):
|
||||
if reply is None or reply_is_error(reply):
|
||||
return None
|
||||
text = reply.strip()
|
||||
echoed = bool(_ECHO.match(reply))
|
||||
text = _ECHO.sub("", reply.strip(), count=1).strip()
|
||||
# Some firmware versions echo the key instead of ">": "tx: 20", "name = Yagi".
|
||||
text = re.sub(rf"^{re.escape(spec.key)}\s*[:=]\s*", "", text, flags=re.I)
|
||||
if spec.composite:
|
||||
pieces = text.split(",")
|
||||
if len(pieces) != len(composite_members(spec.composite)):
|
||||
return None
|
||||
text = pieces[spec.part].strip()
|
||||
tokens = re.findall(r"[\w.+-]+", text.lower())
|
||||
for word, value in spec.aliases:
|
||||
if word in tokens:
|
||||
return value
|
||||
if spec.kind in ("int", "float"):
|
||||
match = re.search(r"-?\d+(?:\.\d+)?", text)
|
||||
if match is None:
|
||||
return None
|
||||
raw = match.group()
|
||||
if spec.kind == "int":
|
||||
return str(int(float(raw)))
|
||||
return f"{float(raw):g}"
|
||||
return None if match is None else _format_number(spec, float(match.group()))
|
||||
if spec.kind == "bool":
|
||||
lowered = text.lower()
|
||||
if any(t in lowered for t in ("on", "true", "yes", "1")):
|
||||
return "on"
|
||||
if any(t in lowered for t in ("off", "false", "no", "0")):
|
||||
return "off"
|
||||
on_words = _ON_WORDS | {spec.words[0]}
|
||||
off_words = _OFF_WORDS | {spec.words[1]}
|
||||
for token in tokens:
|
||||
if token in on_words:
|
||||
return "on"
|
||||
if token in off_words:
|
||||
return "off"
|
||||
return None
|
||||
# Strings: drop a "key:" or "-> " echo prefix if the firmware included one.
|
||||
text = re.sub(rf"^\s*(?:>|->|{re.escape(spec.key)}\s*[:=])\s*", "", text, flags=re.I)
|
||||
if spec.kind == "enum":
|
||||
by_value = {option.value.lower(): option.value for option in spec.options}
|
||||
return next((by_value[t] for t in tokens if t in by_value), None)
|
||||
if echoed:
|
||||
return text # "> " with nothing after it is an empty string, which is a value
|
||||
return text or None
|
||||
|
||||
|
||||
def _format_number(spec: RemoteSetting, value: float) -> str:
|
||||
"""One number as the cache stores it: whole, fixed-decimal, or trimmed."""
|
||||
if spec.kind == "int":
|
||||
return str(int(round(value)))
|
||||
if spec.decimals is not None:
|
||||
text = f"{value:.{spec.decimals}f}"
|
||||
else:
|
||||
# The firmware's own ftoa: up to 7 places, trailing zeros trimmed.
|
||||
text = f"{value:.7f}".rstrip("0").rstrip(".")
|
||||
return text.lstrip("-") if float(text) == 0 else text
|
||||
|
||||
|
||||
def normalize_value(spec: RemoteSetting, raw: str) -> str:
|
||||
"""A validated typed value in the form the cache stores and the firmware is sent.
|
||||
|
||||
TX delay entered as ``0.33`` becomes ``0.3``: rounded to the setting's decimals, and
|
||||
spelled exactly as a read of the same value would be, so staging what the node already
|
||||
holds unstages rather than queueing a no-op ``set``.
|
||||
"""
|
||||
text = raw.strip()
|
||||
if spec.kind in ("int", "float"):
|
||||
return _format_number(spec, float(text))
|
||||
if spec.kind == "bool":
|
||||
return text.lower()
|
||||
if spec.kind == "enum":
|
||||
return next((o.value for o in spec.options if o.value.lower() == text.lower()), text)
|
||||
return text
|
||||
|
||||
|
||||
def validate_value(spec: RemoteSetting, raw: str) -> bool | str:
|
||||
"""Validate a prompted value for ``spec``: ``True``, or the error message to show."""
|
||||
text = raw.strip()
|
||||
@@ -319,15 +772,127 @@ def validate_value(spec: RemoteSetting, raw: str) -> bool | str:
|
||||
if text.lower() in ("on", "off"):
|
||||
return True
|
||||
return "Enter on or off."
|
||||
if spec.kind == "enum":
|
||||
if any(o.value.lower() == text.lower() for o in spec.options):
|
||||
return True
|
||||
return "Enter one of " + ", ".join(o.value for o in spec.options) + "."
|
||||
if spec.kind in ("int", "float"):
|
||||
try:
|
||||
value = float(text)
|
||||
except ValueError:
|
||||
return "Enter a number."
|
||||
if spec.kind == "int" and not float(text).is_integer():
|
||||
if spec.kind == "int" and not value.is_integer():
|
||||
return "Enter a whole number."
|
||||
if spec.minimum is not None and value < spec.minimum:
|
||||
return f"Must be ≥ {spec.minimum:g}."
|
||||
if spec.maximum is not None and value > spec.maximum:
|
||||
return f"Must be ≤ {spec.maximum:g}."
|
||||
if spec.zero_off and value == 0:
|
||||
return True
|
||||
low, high = spec.minimum, spec.maximum
|
||||
if spec.zero_off and low is not None and high is not None and not low <= value <= high:
|
||||
return f"Must be 0, or {low:g} – {high:g}."
|
||||
if low is not None and value < low:
|
||||
return f"Must be ≥ {low:g}."
|
||||
if high is not None and value > high:
|
||||
return f"Must be ≤ {high:g}."
|
||||
return True
|
||||
|
||||
|
||||
def range_hint(spec: RemoteSetting) -> str:
|
||||
"""The prompt's help line: what validation will accept, and in what unit."""
|
||||
low, high = spec.minimum, spec.maximum
|
||||
if low is not None and high is not None:
|
||||
hint = f"Allowed: {low:g} – {high:g}"
|
||||
elif low is not None:
|
||||
hint = f"Allowed: ≥ {low:g}"
|
||||
elif high is not None:
|
||||
hint = f"Allowed: ≤ {high:g}"
|
||||
else:
|
||||
hint = ""
|
||||
if hint and spec.zero_off:
|
||||
hint = hint.replace("Allowed: ", "Allowed: 0 (off), or ", 1)
|
||||
if spec.unit:
|
||||
hint = f"{hint} ({spec.unit})" if hint else f"In {spec.unit}."
|
||||
return hint
|
||||
|
||||
|
||||
def read_plan(specs: Iterable[RemoteSetting]) -> list[tuple[str, list[RemoteSetting]]]:
|
||||
"""The read commands that answer ``specs``, each once, with the settings it fills.
|
||||
|
||||
Every command is one paced round trip, so the four radio fields ask ``get radio`` a
|
||||
single time between them — and asking for any one of them fills all four, because the
|
||||
reply carries them anyway and a radio line half-refreshed would disagree with itself.
|
||||
Unreadable settings are skipped.
|
||||
"""
|
||||
plan: dict[str, list[RemoteSetting]] = {}
|
||||
for spec in specs:
|
||||
if not spec.readable:
|
||||
continue
|
||||
fills = plan.setdefault(spec.get_command, [])
|
||||
for member in composite_members(spec.composite) if spec.composite else [spec]:
|
||||
if member not in fills:
|
||||
fills.append(member)
|
||||
return list(plan.items())
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Write:
|
||||
"""One write command, planned from the staged values.
|
||||
|
||||
Attributes:
|
||||
command: The CLI command to send, or ``""`` when it cannot be built yet.
|
||||
values: Setting key -> the value this command leaves the node holding. For a
|
||||
composite that includes the unstaged siblings it restates.
|
||||
missing: The composite siblings neither staged nor known — the reason
|
||||
``command`` is empty. Read them first (:func:`composite_reads`).
|
||||
"""
|
||||
|
||||
command: str
|
||||
values: dict[str, str]
|
||||
missing: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def write_plan(pending: Mapping[str, str], known: Mapping[str, str]) -> list[Write]:
|
||||
"""Group staged values into the commands that send them, in catalog order.
|
||||
|
||||
A standalone setting is its own ``set``. A composite member is sent with all its
|
||||
siblings in one command, the unstaged ones restated from what the node last said —
|
||||
``set radio`` has no way to change one field alone.
|
||||
|
||||
Args:
|
||||
pending: Setting key -> staged value.
|
||||
known: Setting key -> the node's last-read value, for composite siblings.
|
||||
|
||||
Returns:
|
||||
The planned writes.
|
||||
"""
|
||||
writes: list[Write] = []
|
||||
planned: set[str] = set()
|
||||
for spec in REPEATER_SETTINGS:
|
||||
if spec.key not in pending or not spec.writable:
|
||||
continue
|
||||
if not spec.composite:
|
||||
value = pending[spec.key]
|
||||
writes.append(Write(spec.set_command(value), {spec.key: value}))
|
||||
continue
|
||||
if spec.composite in planned:
|
||||
continue
|
||||
planned.add(spec.composite)
|
||||
members = composite_members(spec.composite)
|
||||
values = {m.key: pending.get(m.key, known.get(m.key)) for m in members}
|
||||
missing = tuple(key for key, value in values.items() if value is None)
|
||||
if missing:
|
||||
staged = {m.key: pending[m.key] for m in members if m.key in pending}
|
||||
writes.append(Write("", staged, missing))
|
||||
else:
|
||||
joined = ",".join(str(values[m.key]) for m in members)
|
||||
writes.append(Write(f"set {spec.composite} {joined}", dict(values))) # type: ignore[arg-type]
|
||||
return writes
|
||||
|
||||
|
||||
def composite_reads(pending: Mapping[str, str], known: Mapping[str, str]) -> list[str]:
|
||||
"""The reads a write needs first: composites staged without all their siblings known."""
|
||||
commands: list[str] = []
|
||||
for write in write_plan(pending, known):
|
||||
if write.missing:
|
||||
spec = get_setting(write.missing[0])
|
||||
if spec is not None and spec.get_command not in commands:
|
||||
commands.append(spec.get_command)
|
||||
return commands
|
||||
@@ -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
|
||||
@@ -29,12 +30,16 @@ 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.
|
||||
"""
|
||||
|
||||
value: str
|
||||
read_at: datetime | None
|
||||
supported: bool = True
|
||||
|
||||
|
||||
class RemoteStore:
|
||||
@@ -66,7 +71,10 @@ class RemoteStore:
|
||||
if not isinstance(raw, dict):
|
||||
return out
|
||||
for key, entry in raw.items():
|
||||
if not isinstance(entry, dict) or "value" not in entry:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
supported = not entry.get("unsupported", False)
|
||||
if supported and "value" not in entry:
|
||||
continue
|
||||
read_at: datetime | None = None
|
||||
stamp = entry.get("read_at")
|
||||
@@ -75,15 +83,32 @@ class RemoteStore:
|
||||
read_at = datetime.fromisoformat(stamp)
|
||||
except ValueError:
|
||||
read_at = None
|
||||
out[key] = CachedValue(value=str(entry["value"]), read_at=read_at)
|
||||
out[key] = CachedValue(
|
||||
value=str(entry.get("value", "")), read_at=read_at, supported=supported
|
||||
)
|
||||
return out
|
||||
|
||||
def remember_setting(self, node: Contact, key: str, value: str) -> None:
|
||||
"""Cache one setting's value for ``node``, stamped now."""
|
||||
self._put(node, key, {"value": value, "read_at": utcnow().isoformat()})
|
||||
|
||||
def remember_unsupported(self, node: Contact, key: str) -> None:
|
||||
"""Record that ``node`` answered a read of ``key`` with an error, stamped now."""
|
||||
self._put(node, key, {"unsupported": True, "read_at": utcnow().isoformat()})
|
||||
|
||||
def forget_setting(self, node: Contact, key: str) -> None:
|
||||
"""Drop one cached setting, so the row reads as never read."""
|
||||
records = self._load_all()
|
||||
settings = records.get(admin_key(node), {}).get("settings")
|
||||
if isinstance(settings, dict) and settings.pop(key, None) is not None:
|
||||
self._write(records)
|
||||
|
||||
def _put(self, node: Contact, key: str, entry: dict) -> None:
|
||||
"""Store one setting's cache entry for ``node``."""
|
||||
records = self._load_all()
|
||||
record = records.setdefault(admin_key(node), {})
|
||||
settings = record.setdefault("settings", {})
|
||||
settings[key] = {"value": value, "read_at": utcnow().isoformat()}
|
||||
settings[key] = entry
|
||||
self._write(records)
|
||||
|
||||
# -- the command-line history ---------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for device settings changed through MeshTerm, so they survive a forgetful device.
|
||||
|
||||
Most companions keep their configuration in firmware, so MeshTerm reads settings live from the
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The arithmetic a trace is sized and flagged by.
|
||||
|
||||
Two pure functions and the four constants behind them: how a per-hop path-hash width is
|
||||
encoded into the trace ``flags`` byte, and how long to wait for a trace's reply given the
|
||||
route it has to walk. Both are protocol arithmetic with no device, no persistence and no
|
||||
rendering, and :class:`~meshterm.core.connection.MeshCoreDevice` needs them on the send path —
|
||||
they live here so ``core`` never has to reach up into ``services`` for them. The trace
|
||||
*runner* (:mod:`meshterm.services.trace_runner`) re-exports both under their own names, which
|
||||
is where every other caller still reads them from.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def path_hash_flags(width_bytes: int) -> int | None:
|
||||
"""Return the trace ``flags`` value that encodes a per-hop path-hash width.
|
||||
|
||||
The trace subsystem encodes the hash size as ``1 << (flags & 3)`` on both the
|
||||
send and receive sides (``send_trace`` and the ``TRACE_DATA`` reader), so only
|
||||
widths of 1, 2, 4, or 8 bytes are representable. This is independent of, and
|
||||
differs from, the ``mode = size - 1`` encoding used for *contact routing*
|
||||
(``out_path_hash_mode``).
|
||||
|
||||
Args:
|
||||
width_bytes: Path-hash width in bytes.
|
||||
|
||||
Returns:
|
||||
The flags value (the exponent ``s``), or ``None`` if the width is not a
|
||||
representable power of two.
|
||||
"""
|
||||
for s in range(4):
|
||||
if (1 << s) == width_bytes:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
#: Fixed overhead in a trace's reply-wait budget, in seconds: our own transmit, the far
|
||||
#: endpoint's turnaround, and our receive-side decode — the cost that doesn't grow with
|
||||
#: the route.
|
||||
TRACE_TIMEOUT_BASE_S = 4.0
|
||||
#: Per-hop allowance added to the budget, in seconds. Each entry in the walked path is one
|
||||
#: relay transmission, and this covers its packet airtime, the repeater's processing, and
|
||||
#: the randomised transmit backoff MeshCore adds so relays don't collide.
|
||||
TRACE_TIMEOUT_PER_HOP_S = 1.6
|
||||
#: Upper bound on the reply-wait, in seconds, so a route that never comes home still
|
||||
#: surrenders the trace (and the session) in bounded time rather than scaling without end.
|
||||
TRACE_TIMEOUT_CEILING_S = 30.0
|
||||
#: Fallback budget, in seconds, when the hop count can't be known ahead of the send — a
|
||||
#: path-less flood to a contact we hold no route for. This is the historical flat value,
|
||||
#: kept for exactly the case where we can't size the walk.
|
||||
TRACE_TIMEOUT_FLOOD_S = 10.0
|
||||
|
||||
|
||||
def trace_timeout(hop_count: int) -> float:
|
||||
"""Return the reply-wait budget, in seconds, for a trace walking ``hop_count`` hops.
|
||||
|
||||
A trace is a *single* packet that has to travel the whole path — out to the far hop
|
||||
and back over the mirrored return leg — before its reply reaches us, so the wait has
|
||||
to grow with the route. A flat budget sized for a neighbour cuts a long walk off
|
||||
mid-flight: the packet completes the circuit on the mesh, but our wait has already
|
||||
expired, so we log a phantom "no reply" for a route that actually worked. The budget
|
||||
is a fixed base (:data:`TRACE_TIMEOUT_BASE_S`) plus a per-hop allowance
|
||||
(:data:`TRACE_TIMEOUT_PER_HOP_S`) for each relay transmission, capped at
|
||||
:data:`TRACE_TIMEOUT_CEILING_S`.
|
||||
|
||||
``hop_count`` is the number of hops in the *transmitted* path, which already includes
|
||||
the mirrored return leg (see
|
||||
:meth:`~meshterm.core.connection.MeshCoreDevice._trace_path_to_contact`), so it maps
|
||||
one-to-one onto relay transmissions and must not be doubled here.
|
||||
|
||||
Args:
|
||||
hop_count: Hops in the walked path (return leg included). ``0`` (or less) means the
|
||||
count is unknown — a path-less flood — and yields :data:`TRACE_TIMEOUT_FLOOD_S`.
|
||||
|
||||
Returns:
|
||||
The number of seconds to wait for the trace reply.
|
||||
"""
|
||||
if hop_count <= 0:
|
||||
return TRACE_TIMEOUT_FLOOD_S
|
||||
budget = TRACE_TIMEOUT_BASE_S + TRACE_TIMEOUT_PER_HOP_S * hop_count
|
||||
return min(budget, TRACE_TIMEOUT_CEILING_S)
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The shared transmit clock: how long ago we last put something on the air.
|
||||
|
||||
MeshTerm has always paced its own bursts — a trace waits between samples, a TX sweep
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence for the Watchtower: watched nodes, their rules, and the alert log.
|
||||
|
||||
The Watchtower (see :mod:`meshterm.services.watchtower`) is a passive sentinel: the user
|
||||
@@ -37,8 +38,9 @@ DEFAULT_SILENCE_HOURS = 12
|
||||
#: The stored value meaning "this rule is off".
|
||||
OFF = 0
|
||||
|
||||
#: How many alerts the log retains (oldest dropped first) — the code's default behind
|
||||
#: the ``watch_alerts_kept`` preference.
|
||||
#: How many alerts the log retains (oldest dropped first) — *the* default behind the
|
||||
#: ``watch_alerts_kept`` preference, which the registry names rather than re-types
|
||||
#: (:data:`meshterm.core.preferences.PREFERENCES`), so the two can never disagree.
|
||||
ALERT_CAP = 200
|
||||
|
||||
#: Minimum seconds between disk flushes for the packet-driven last-heard updates.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Private handles on the Windows DLLs, because ``ctypes.windll`` is shared with everyone.
|
||||
|
||||
``ctypes.windll.kernel32`` is a cache. Every caller in the process gets the *same*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Reopening the session in Windows Terminal, which is where MeshTerm looks like itself.
|
||||
|
||||
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether any
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Persistence layer: SQLite schema, repository, and structured logging."""
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""SQLite database bootstrap and schema.
|
||||
|
||||
The schema is intentionally generic: a central ``runs`` table records every tool
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Logging setup: a plain text file you can open, and a console that stays out of the way.
|
||||
|
||||
Two sinks. The console handler is Rich-formatted and shows what a person watching the
|
||||
@@ -61,7 +62,7 @@ def level_from_name(name: str, *, fallback: int = logging.WARNING) -> int:
|
||||
|
||||
Args:
|
||||
name: A level name, in any case.
|
||||
fallback: Used when the name is unrecognised — a hand-edited ``preferences.yaml``
|
||||
fallback: Used when the name is unrecognised — a hand-edited ``preferences.toml``
|
||||
with a typo in it should lose some log detail, not stop the app from starting.
|
||||
|
||||
Returns:
|
||||
@@ -97,7 +98,7 @@ def configure_logging(
|
||||
"""
|
||||
logger = logging.getLogger(_LOGGER_NAME)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.handlers.clear()
|
||||
_drop_handlers(logger)
|
||||
logger.propagate = False
|
||||
|
||||
if not quiet:
|
||||
@@ -161,12 +162,29 @@ def _quiet_library_console(file_handler: logging.Handler) -> None:
|
||||
|
||||
for name in _LIBRARY_LOGGERS:
|
||||
lib = logging.getLogger(name)
|
||||
lib.handlers.clear()
|
||||
_drop_handlers(lib)
|
||||
lib.setLevel(logging.INFO)
|
||||
lib.propagate = False
|
||||
lib.addHandler(file_handler)
|
||||
|
||||
|
||||
def _drop_handlers(logger: logging.Logger) -> None:
|
||||
"""Detach ``logger``'s handlers, closing each on the way out.
|
||||
|
||||
Clearing the list alone drops the *reference* and leaves the file open: every
|
||||
reconfigure — a reconnect in the app, each in-process CLI invocation under test — leaked
|
||||
another descriptor on ``meshterm.log``, which on Windows is also a lock on a file the
|
||||
rotation then wants to rename. A handler that refuses to close is dropped anyway: the
|
||||
point of this call is that the old handlers stop receiving records.
|
||||
"""
|
||||
for handler in list(logger.handlers):
|
||||
try:
|
||||
handler.close()
|
||||
except Exception: # noqa: BLE001 - a handler we are discarding cannot fail the app
|
||||
pass
|
||||
logger.handlers.clear()
|
||||
|
||||
|
||||
def get_logger(name: str | None = None) -> logging.Logger:
|
||||
"""Return a child of the application logger.
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Repository: the single gateway between the app and the database.
|
||||
|
||||
Tools and services never issue SQL directly; they call typed methods here. This keeps
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The platform seam: a frozen spec every consumer binds to once, at boot.
|
||||
|
||||
MeshTerm runs two flavours from one codebase — the regular desktop terminal and the
|
||||
@@ -74,10 +75,11 @@ class Platform:
|
||||
this is ``False`` there. ``MESHTERM_FULL_WIDTH`` remains an explicit override
|
||||
on top of this default.
|
||||
emoji: Whether emoji icons render at all. ``False`` means every icon funnel routes
|
||||
through a compact single-BMP-glyph table instead (P3), and the emoji-width
|
||||
calibration probe (:func:`~meshterm.ui.tui.emoji_width.calibrate`) is skipped
|
||||
outright — there is nothing to calibrate once no emoji are ever drawn, and the
|
||||
probe writes escape sequences the console font can't shape anyway.
|
||||
through a compact single-BMP-glyph table instead (P3), and neither half of the
|
||||
emoji alignment runs — the reserve-two measurement
|
||||
(:func:`~meshterm.ui.tui.emoji_width.install`) or the column pinning
|
||||
(:mod:`~meshterm.ui.tui.colsnap`). Once no emoji are ever drawn, every glyph on
|
||||
screen is one the console font has verified, and the stock widths are exact.
|
||||
ascii_fold: Whether names and message bodies are NFKD-folded (accents stripped) at
|
||||
the render boundary before display. Storage is never touched. Wired in P3.
|
||||
truecolor: Whether the theme may use arbitrary 24-bit SGR colour. ``False`` selects
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Algorithm layer: pure logic for tracing, TX search, and path exploration.
|
||||
|
||||
Services depend on the :class:`~meshterm.core.connection.Device` interface and domain
|
||||
models only. They contain no UI or persistence code, so they are straightforward to unit
|
||||
test against the simulator.
|
||||
Services may depend on :mod:`meshterm.core` (the :class:`~meshterm.core.connection.Device`
|
||||
interface and the domain models), on the persistence layer's *types*, and on the app
|
||||
context; never on ``ui/`` or ``tools/``. Nothing here renders, so every service is
|
||||
straightforward to unit test against the simulator.
|
||||
"""
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The background-advert scheduler: keep this node announced without anyone asking.
|
||||
|
||||
A quiet session-long task that sends a zero-hop (direct) advert and a flood advert on
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Fetch and cache OpenStreetMap vector tiles for the terminal map.
|
||||
|
||||
This is the only networked part of the map. It resolves a tile source's (versioned) tile-URL
|
||||
@@ -69,6 +70,14 @@ DEFAULT_TILEJSON_URL = "https://tiles.openfreemap.org/planet"
|
||||
#: the tile path is no place to discover either.
|
||||
_USER_AGENT = f"MeshTerm/{__version__} (+https://github.com/jpmartineau/MeshTerm; mesh node map)"
|
||||
|
||||
#: How long a tile the source gave no answer about is left alone before it is asked for
|
||||
#: again. Long enough that a genuinely offline map isn't retrying every visible tile on a
|
||||
#: loop, short enough that a Wi-Fi blip costs a few seconds of missing streets rather than
|
||||
#: the rest of the session. It lives here, beside the source that failed to answer, because
|
||||
#: both surfaces that draw tiles wait out the same cooldown (see
|
||||
#: :meth:`meshterm.ui.map_screen.MapScreen._load` and the minimap's).
|
||||
TILE_RETRY_SECONDS = 20.0
|
||||
|
||||
#: Fallback max tile zoom if the TileJSON doesn't declare one (OpenFreeMap serves 14).
|
||||
_DEFAULT_MAX_ZOOM = 14
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The battery poller: keep the status bar's fuel gauge current, quietly.
|
||||
|
||||
A session-long task that reads the connected companion's battery voltage on a slow
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The chat service: message persistence, unread tracking, and the send path.
|
||||
|
||||
Like the passive monitor, this service is not a listener in its own right — the always-on
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Set the companion's clock from this computer — by hand from Device config, or on connect.
|
||||
|
||||
Most MeshCore boards carry no real-time clock: a power cycle puts them back at whatever
|
||||
epoch the firmware boots with, and every message they then send is stamped in a year the
|
||||
mesh does not agree on. :func:`set_clock` is the one device-side step that corrects it —
|
||||
read what the radio thinks the time is, write ours, and hand back the drift that was
|
||||
corrected, the fact worth keeping and the one the set destroys by succeeding. The Device
|
||||
config screen's *Sync clock* action (``config sync-clock`` on the command line) calls it
|
||||
and acknowledges on screen; :class:`ClockSync` calls the same function on its own, once
|
||||
per connection, when the ``set_clock_on_connect`` preference asks for it (off by default —
|
||||
writing to a radio that nobody asked to be written to is a choice the owner makes).
|
||||
|
||||
The automatic set is a background step, not a startup step: the connect path hands the
|
||||
device over and carries on, and the write happens in a task of its own so a slow or silent
|
||||
firmware never holds the menu. It reports in the **log**, never on screen.
|
||||
|
||||
Once per *connection*, keyed by the device object's identity like the advert scheduler's
|
||||
key probe: a reconnect after a link drop is a fresh :class:`~meshterm.core.connection.Device`
|
||||
and gets its own set, because a drop is exactly when the board may have rebooted and lost
|
||||
its clock again. The interactive session starts the service; scripted CLI runs never do,
|
||||
so a one-shot command cannot change a radio it was only asked to read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
from ..core.connection import Device
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ClockSet:
|
||||
"""What one clock set did.
|
||||
|
||||
Attributes:
|
||||
epoch: The UNIX time written to the device.
|
||||
drift_s: How far the device's clock was from ours *before* the set (device minus
|
||||
host, seconds), or ``None`` where the firmware would not report its clock.
|
||||
"""
|
||||
|
||||
epoch: int
|
||||
drift_s: int | None
|
||||
|
||||
@property
|
||||
def set_at(self) -> datetime:
|
||||
"""The written instant, in this computer's local time."""
|
||||
return datetime.fromtimestamp(self.epoch).astimezone()
|
||||
|
||||
@property
|
||||
def stamp(self) -> str:
|
||||
"""The written instant as the acknowledgement and the log print it."""
|
||||
return self.set_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
async def set_clock(device: Device) -> ClockSet:
|
||||
"""Set ``device``'s clock to this computer's, returning what was corrected.
|
||||
|
||||
The read of the device's own clock before the write is optional — the drift is a
|
||||
nicety, and a firmware that will not answer still gets its clock set.
|
||||
|
||||
Args:
|
||||
device: A connected device.
|
||||
|
||||
Returns:
|
||||
The instant written and the drift it corrected.
|
||||
|
||||
Raises:
|
||||
Whatever the device raises when the write itself fails.
|
||||
"""
|
||||
try:
|
||||
before = await device.get_time()
|
||||
except Exception: # noqa: BLE001 - optional read; the drift is a nicety
|
||||
before = None
|
||||
epoch = int(time.time())
|
||||
await device.set_time(epoch)
|
||||
return ClockSet(epoch=epoch, drift_s=None if before is None else before - epoch)
|
||||
|
||||
|
||||
class ClockSync:
|
||||
"""Owns the on-connect clock set for an interactive session.
|
||||
|
||||
Interact through :meth:`start`, :meth:`stop`/:meth:`aclose`, and
|
||||
:meth:`on_connected`, which the context calls as each connection settles.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: AppContext) -> None:
|
||||
"""Initialize an idle service bound to an application context.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context, read for the preference at each
|
||||
connection and for the logger. Nothing is touched until :meth:`start`.
|
||||
"""
|
||||
self._ctx = ctx
|
||||
self._armed = False
|
||||
self._task: asyncio.Task | None = None
|
||||
# Identity of the device object last set, so one connection is set once even if
|
||||
# the connect path reports it more than once.
|
||||
self._synced: int | None = None
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""Whether the service is armed to act on connections."""
|
||||
return self._armed
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Arm the service, setting the clock at once if a device is already connected.
|
||||
|
||||
Idempotent. The startup picker may have adopted a live connection before any
|
||||
service started, so the connection that already exists counts as the first one.
|
||||
"""
|
||||
if self._armed:
|
||||
return
|
||||
self._armed = True
|
||||
device = self._ctx._device
|
||||
if device is not None:
|
||||
self.on_connected(device)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Disarm, cancelling a set still in flight. Idempotent."""
|
||||
self._armed = False
|
||||
task, self._task = self._task, None
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Stop at session end (an alias for :meth:`stop`)."""
|
||||
await self.stop()
|
||||
|
||||
def on_connected(self, device: Device) -> None:
|
||||
"""Note a settled connection and, if the preference asks, set its clock in the background.
|
||||
|
||||
Returns at once; the set runs in its own task. A no-op while the service is not
|
||||
started (scripted runs), while the preference is off, and for a device this
|
||||
session has already set.
|
||||
|
||||
Args:
|
||||
device: The device the context just finished connecting.
|
||||
"""
|
||||
if not self._armed or not self._ctx.preferences.set_clock_on_connect:
|
||||
return
|
||||
if self._synced == id(device):
|
||||
return
|
||||
self._synced = id(device)
|
||||
self._task = asyncio.ensure_future(self._sync(device))
|
||||
|
||||
async def _sync(self, device: Device) -> None:
|
||||
"""Run :func:`set_clock` and put the outcome in the log."""
|
||||
log = self._ctx.log
|
||||
try:
|
||||
done = await set_clock(device)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - best-effort; the link is not ours to break
|
||||
log.warning("clock sync: could not set the device clock: %s", exc)
|
||||
return
|
||||
if done.drift_s is None:
|
||||
log.info("clock sync: device clock set to %s (drift unknown)", done.stamp)
|
||||
else:
|
||||
log.info("clock sync: device clock set to %s (was %+d s off)", done.stamp, done.drift_s)
|
||||
@@ -0,0 +1,221 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The PicoCalc's console font: remember what was there, switch, put it back.
|
||||
|
||||
The handheld draws to a Linux virtual terminal, and a VT loads exactly one font for the
|
||||
whole console — there is no per-application font the way a desktop terminal emulator has
|
||||
one. So MeshTerm asking for its 6x8 build (53x40, fourteen more rows) means changing the
|
||||
font the *shell* is using, and leaving it changed would be MeshTerm walking off with the
|
||||
reader's console. Hence the shape of this module: **remember** the font the shell had,
|
||||
**apply** the one the preference names, and **restore** on every way out.
|
||||
|
||||
``setfont -O`` writes the current font out with its unicode table, and feeding that file
|
||||
straight back to ``setfont`` reproduces it exactly — verified on the device. No ``sudo``
|
||||
is involved: the VT is the process's controlling terminal and is owned by the logged-in
|
||||
user, which is the whole reason this can be a preference rather than a setup step.
|
||||
``-C`` is deliberately never passed, so every call targets that controlling VT and not
|
||||
whichever tty number happens to be first.
|
||||
|
||||
Everything here is cosmetic and everything here fails quietly. A missing ``setfont``, a
|
||||
read-only state directory, an ssh session that is not a VT at all, a non-zero exit — each
|
||||
is one debug line and a no-op return, because a font is never a reason for the app not to
|
||||
start. :func:`applies` is the single gate all three verbs ask first, so "this is not that
|
||||
machine" is answered in one place.
|
||||
|
||||
Distinct from :mod:`meshterm.core.consolefont`, which installs a *TrueType* face for the
|
||||
classic Windows console. Same words, opposite platforms: that one is a Win32 font
|
||||
resource, this one is a PSF bitmap the Linux kernel's console driver reads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.config import default_config_dir
|
||||
from ..persistence.logging import get_logger
|
||||
from ..platforms import get_platform
|
||||
|
||||
#: Where ``scripts/picocalc/calculinux-console-font-6x12.sh`` and its 6x8 companion install
|
||||
#: the fonts.
|
||||
FONT_DIR = Path("/usr/share/consolefonts")
|
||||
|
||||
#: One preference value -> the file the build script writes for it. This map lives beside
|
||||
#: the code that runs ``setfont`` rather than in the preference registry: a reader picks a
|
||||
#: cell size and a row count, not a path on a filesystem only one machine has.
|
||||
FONT_FILES: dict[str, str] = {"6x12": "meshterm.psf.gz", "6x8": "meshterm8.psf.gz"}
|
||||
|
||||
#: The preference this module acts on, named once so the caller and the registry agree.
|
||||
PREFERENCE_KEY = "console_font"
|
||||
|
||||
#: What :func:`remember` writes under the MeshTerm home. A plain ``.psf``: ``setfont -O``
|
||||
#: writes an uncompressed PSF, and the file is transient anyway (:func:`restore` deletes
|
||||
#: it), so it is never gzipped and never versioned.
|
||||
SAVED_NAME = "console-font-before.psf"
|
||||
|
||||
#: A Linux virtual terminal, which is the only kind of console ``setfont`` can talk to.
|
||||
#: ``/dev/pts/3`` (an ssh session or a terminal emulator on the same device) is not one,
|
||||
#: and ``setfont`` against it would either fail or change a console nobody is looking at.
|
||||
_VT_NAME = re.compile(r"^/dev/tty[0-9]+$")
|
||||
|
||||
#: Seconds before a ``setfont`` call is given up on. It is a sub-millisecond ioctl in
|
||||
#: practice; the timeout is here so a wedged binary cannot hold the app's startup.
|
||||
_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
def _controlling_vt() -> str | None:
|
||||
"""The name of stdin's terminal when it is a Linux VT, else ``None``.
|
||||
|
||||
``os.ttyname`` does not exist on Windows and raises on a redirected stdin, which are
|
||||
both simply "not a VT" as far as this module is concerned.
|
||||
"""
|
||||
try:
|
||||
name = os.ttyname(0)
|
||||
except (AttributeError, OSError, ValueError):
|
||||
return None
|
||||
return name if _VT_NAME.match(name) else None
|
||||
|
||||
|
||||
def applies() -> bool:
|
||||
"""Whether switching the console font is a thing this session can do at all.
|
||||
|
||||
Three conditions, each a different reason to leave the console alone: the PicoCalc is
|
||||
the only platform whose fonts this module knows; stdin must be the Linux VT whose font
|
||||
would change (an ssh session's pty has no font of its own); and ``setfont`` must
|
||||
exist, which on the device means the ``kbd`` package the font build already needs.
|
||||
|
||||
Returns:
|
||||
``True`` when all three hold. Otherwise ``False``, with one debug line naming
|
||||
which one did not — the log is where a font that "did nothing" gets explained.
|
||||
"""
|
||||
platform = get_platform()
|
||||
if platform.name != "picocalc":
|
||||
get_logger().debug("console font: leaving it alone — platform is %s", platform.name)
|
||||
return False
|
||||
if _controlling_vt() is None:
|
||||
get_logger().debug("console font: leaving it alone — stdin is not a Linux VT")
|
||||
return False
|
||||
if shutil.which("setfont") is None:
|
||||
get_logger().debug("console font: leaving it alone — no setfont on PATH")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def remember() -> Path | None:
|
||||
"""Save the console's current font, and arm its restore.
|
||||
|
||||
The saved file is the *shell's* font — whatever was loaded before MeshTerm started,
|
||||
which is usually the 6x12 default but is whatever the reader last chose — so the
|
||||
restore puts back what they had rather than what we assume they had.
|
||||
|
||||
The restore is registered with :mod:`atexit` here as well as being run from the
|
||||
session's ``finally``, because the ways out of a full-screen app are many (^Q, an
|
||||
unhandled exception, the menu's own unwind) and a console left in the wrong font is
|
||||
only noticed after the app is gone. Restoring twice is harmless: the first one deletes
|
||||
the file and the second finds nothing to do.
|
||||
|
||||
Returns:
|
||||
The path the font was written to, to hand back to :func:`restore`, or ``None``
|
||||
when there was nothing to remember (see :func:`applies`) or the write failed.
|
||||
"""
|
||||
if not applies():
|
||||
return None
|
||||
path = default_config_dir() / SAVED_NAME
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
get_logger().debug("console font: cannot make %s: %s", path.parent, exc)
|
||||
return None
|
||||
if not _setfont("-O", str(path)):
|
||||
return None
|
||||
atexit.register(restore, path)
|
||||
return path
|
||||
|
||||
|
||||
def apply(choice: str) -> bool:
|
||||
"""Load the font one preference value names.
|
||||
|
||||
Args:
|
||||
choice: A :data:`FONT_FILES` key — the ``console_font`` preference's value.
|
||||
|
||||
Returns:
|
||||
Whether ``setfont`` ran and succeeded. ``False`` is the ordinary answer on every
|
||||
machine that is not the handheld, and never an error the caller has to handle.
|
||||
"""
|
||||
if not applies():
|
||||
return False
|
||||
name = FONT_FILES.get(str(choice))
|
||||
if name is None:
|
||||
get_logger().debug("console font: no file for %r; leaving the console alone", choice)
|
||||
return False
|
||||
# ``as_posix``: this is a path on the handheld's filesystem whatever machine the
|
||||
# string is built on, and a developer's Windows box would otherwise spell it with
|
||||
# backslashes on its way into a test.
|
||||
return _setfont((FONT_DIR / name).as_posix())
|
||||
|
||||
|
||||
def restore(saved: Path | None) -> None:
|
||||
"""Put back the font :func:`remember` saved, then drop the file.
|
||||
|
||||
Idempotent by construction — the file is deleted whether or not loading it worked, so
|
||||
a second call (the ``atexit`` one, after the session's ``finally`` already ran) finds
|
||||
nothing and does nothing.
|
||||
|
||||
Args:
|
||||
saved: What :func:`remember` returned. ``None`` means there was nothing to do.
|
||||
"""
|
||||
if saved is None:
|
||||
return
|
||||
try:
|
||||
present = saved.is_file()
|
||||
except OSError: # pragma: no cover - a stat that fails is a file we can't use
|
||||
present = False
|
||||
if present and shutil.which("setfont") is not None:
|
||||
_setfont(str(saved))
|
||||
try:
|
||||
saved.unlink(missing_ok=True)
|
||||
except OSError as exc: # pragma: no cover - a file we wrote should be ours to remove
|
||||
get_logger().debug("console font: cannot remove %s: %s", saved, exc)
|
||||
|
||||
|
||||
def _setfont(*args: str) -> bool:
|
||||
"""Run ``setfont`` with ``args``, logging anything it has to say about failing.
|
||||
|
||||
Never ``-C``: with no tty named, ``setfont`` acts on its controlling terminal, which
|
||||
is the console the reader is actually looking at.
|
||||
|
||||
Returns:
|
||||
``True`` only on a clean exit.
|
||||
"""
|
||||
argv = ["setfont", *args]
|
||||
try:
|
||||
done = subprocess.run( # noqa: S603 - a fixed binary name with our own arguments
|
||||
argv, capture_output=True, text=True, timeout=_TIMEOUT_S, check=False
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
get_logger().debug("console font: %s failed: %s", " ".join(argv), exc)
|
||||
return False
|
||||
if done.returncode != 0:
|
||||
get_logger().debug(
|
||||
"console font: %s exited %d: %s",
|
||||
" ".join(argv),
|
||||
done.returncode,
|
||||
(done.stderr or "").strip() or "(no output)",
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FONT_DIR",
|
||||
"FONT_FILES",
|
||||
"PREFERENCE_KEY",
|
||||
"SAVED_NAME",
|
||||
"applies",
|
||||
"apply",
|
||||
"remember",
|
||||
"restore",
|
||||
]
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The Courier: store-and-forward delivery for the outbox.
|
||||
|
||||
A session-long background loop over the :class:`~meshterm.core.courier_store.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The session's device-state cache: read the radio once, reuse it everywhere.
|
||||
|
||||
Opening a screen used to re-read the same stable facts from the companion every time —
|
||||
the contacts table, the node's own self-info, the path-hash routing width, the configured
|
||||
channel slots, the channel-slot capacity. Over Bluetooth each of those is a full
|
||||
request→reply round-trip, and the channel-slot reads dominate: ``get_contacts`` on a busy
|
||||
node (hundreds of contacts), the slot probe (:func:`~meshterm.ui.channels.read_channel_slots`)
|
||||
and the capacity probe (:meth:`~meshterm.core.connection.Device.channel_capacity`) each walk
|
||||
node (hundreds of contacts), the slot probe
|
||||
(:func:`~meshterm.core.channel_probe.read_channel_slots`) and the capacity probe
|
||||
(:meth:`~meshterm.core.connection.Device.channel_capacity`) each walk
|
||||
the slot table one index at a time and are measured in *seconds* on firmware that never
|
||||
rejects an out-of-range index. Firing them on every navigation is what made moving between
|
||||
screens feel like it stalled.
|
||||
@@ -43,10 +45,11 @@ from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..core.channel_probe import ChannelSlot, probe_channel_slots
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
from ..core.models import Contact
|
||||
from ..ui.channels import ChannelSlot
|
||||
|
||||
#: How long a cached contacts list is served before a read triggers a background refresh
|
||||
#: (seconds). Contacts only grow as the mesh advertises, and the list is a naming/addressing
|
||||
@@ -132,6 +135,16 @@ class DeviceState:
|
||||
self._spawn(self._refresh_contacts_quietly())
|
||||
return self._contacts
|
||||
|
||||
def peek_contacts(self) -> list[Contact] | None:
|
||||
"""The contacts already in hand — possibly stale — or ``None``, never a radio read.
|
||||
|
||||
For a surface that only wants them as *context* and must not wait for them: the
|
||||
location picker's surrounding nodes. A companion can refuse the contacts read for a
|
||||
stretch (``ERR_CODE_BAD_STATE``), and :meth:`contacts` then blocks through every retry
|
||||
of it — twenty-odd seconds — before giving up.
|
||||
"""
|
||||
return self._contacts
|
||||
|
||||
async def _fetch_contacts(self, *, force: bool = False) -> list[Contact]:
|
||||
"""Read the contacts table from the device and cache it (blocking, deduplicated).
|
||||
|
||||
@@ -194,7 +207,7 @@ class DeviceState:
|
||||
|
||||
So the three are merged by taking the latest, here — once, at the one point every
|
||||
screen fetches contacts through, so heard lanes, recency sorts, silence alerts and
|
||||
the purge ladder all see the same honest value. Taking the *latest* rather than
|
||||
the archive ladder all see the same honest value. Taking the *latest* rather than
|
||||
preferring either side means a device time still stands whenever the firmware caught
|
||||
an advert we did not record (monitoring off, app not running); we only override it
|
||||
with proof of a later reception. A contact we have truly never heard stays ``None``
|
||||
@@ -251,6 +264,13 @@ class DeviceState:
|
||||
self._self_info = dict(await device.get_self_info())
|
||||
return self._self_info
|
||||
|
||||
def peek_self_info(self) -> dict | None:
|
||||
"""The self-info already in hand, or ``None`` — never a radio read.
|
||||
|
||||
The :meth:`peek_contacts` of our own node: context for a surface that must not wait.
|
||||
"""
|
||||
return self._self_info
|
||||
|
||||
async def path_hash_mode(self) -> int:
|
||||
"""Return the device's path-hash routing mode, cached for the session.
|
||||
|
||||
@@ -269,26 +289,45 @@ class DeviceState:
|
||||
self._path_hash_mode = int(await device.get_path_hash_mode())
|
||||
return self._path_hash_mode
|
||||
|
||||
async def routing_prefix_bytes(self) -> int:
|
||||
"""The device's path-hash width in bytes, or 0 when unknowable.
|
||||
|
||||
What a surface that *highlights* hashes actually wants: how many leading bytes of a
|
||||
key the mesh routes on, which is what :func:`~meshterm.ui.widgets.highlighted_hash`
|
||||
lights. Best-effort, because every caller reads stored history and must work with no
|
||||
radio at all — an unreachable device (or firmware that doesn't report the mode) just
|
||||
leaves every hash un-highlighted rather than failing the screen.
|
||||
|
||||
Returns:
|
||||
The hash width in bytes (1–4), or 0 when the mode could not be read.
|
||||
"""
|
||||
try:
|
||||
if not (self._ctx.is_connected or self._ctx.settings.connect_on_start):
|
||||
return 0
|
||||
mode = await self.path_hash_mode()
|
||||
except Exception: # noqa: BLE001 - optional read; absence just skips highlighting
|
||||
return 0
|
||||
return (mode + 1) if isinstance(mode, int) and 0 <= mode <= 3 else 0
|
||||
|
||||
# -- channel slots (held until the channel editor invalidates them) --------
|
||||
|
||||
async def channel_slots(self) -> list[ChannelSlot]:
|
||||
"""Return the configured channel slots, cached until a channel edit invalidates them.
|
||||
|
||||
The underlying probe (:func:`~meshterm.ui.channels.read_channel_slots`) walks every
|
||||
slot index on the firmware, which is one of the slowest reads on a screen open — so it
|
||||
The underlying probe (:func:`~meshterm.core.channel_probe.read_channel_slots`) walks
|
||||
every slot index on the firmware, which is one of the slowest reads on a screen open — so it
|
||||
is well worth reading once. It is best-effort itself (an unsupported firmware yields an
|
||||
empty list rather than raising), but a probe that ended early because a *read failed*
|
||||
is answered and then dropped rather than cached — see :func:`~meshterm.ui.channels.
|
||||
probe_channel_slots` for why a short list is not a layout.
|
||||
is answered and then dropped rather than cached — see
|
||||
:func:`~meshterm.core.channel_probe.probe_channel_slots` for why a short list is not a
|
||||
layout.
|
||||
|
||||
Returns:
|
||||
One :class:`~meshterm.ui.channels.ChannelSlot` per configured slot, in index order.
|
||||
One :class:`~meshterm.core.channel_probe.ChannelSlot` per slot, in index order.
|
||||
"""
|
||||
if self._channels is None:
|
||||
async with self._channels_lock:
|
||||
if self._channels is None:
|
||||
from ..ui.channels import probe_channel_slots
|
||||
|
||||
device = await self._ctx.device()
|
||||
slots, complete = await probe_channel_slots(device)
|
||||
if complete:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The always-on mesh event hub.
|
||||
|
||||
The hub is the single seam between the raw companion-device event stream and everything
|
||||
@@ -205,7 +206,7 @@ class EventHub:
|
||||
Returns:
|
||||
The matching :class:`MeshEvent`, or ``None`` if ``timeout`` elapsed first.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
|
||||
def handler(event: MeshEvent) -> None:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Collecting the mesh's located nodes into map markers.
|
||||
|
||||
One list, three surfaces: the ``map`` tool's full-screen map, the node page's preview minimap,
|
||||
and the editors' pick-a-location map all plot the same mesh, so they all come through
|
||||
:func:`gather_markers`. It sits in ``services/`` because it is an assembly job over the
|
||||
device's contacts and the observation history — the layer below anything that draws — and
|
||||
because two screens and one tool need it, none of which may import from the others.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..core.geo import usable_fix
|
||||
from ..core.models import NODE_TYPE_REPEATER, MapMarker
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..context import AppContext
|
||||
|
||||
|
||||
async def gather_markers(ctx: AppContext, *, wait: bool = True) -> list[MapMarker]:
|
||||
"""Collect every located node to plot: the device's contacts, plus our own node.
|
||||
|
||||
The companion's **contact list** is the authoritative source for a node's name,
|
||||
type (repeater vs. leaf), and advertised position — the passive-monitor
|
||||
observations only carry a location for the rare node that broadcasts one in an
|
||||
advert. So contacts drive the markers, and each contact is enriched with signal
|
||||
detail from the observations when we've overheard it directly.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
wait: Whether to wait on the radio for contacts and our own position the session
|
||||
has not cached yet. ``False`` builds the markers from what is already in hand —
|
||||
the cache and our own history — and never makes a round trip, for a surface
|
||||
whose nodes are only context (the location picker): a companion refusing the
|
||||
contacts read can otherwise hold it closed for twenty-odd seconds.
|
||||
"""
|
||||
observed = {n.node: n for n in ctx.repo.heard_nodes() if n.node}
|
||||
markers: list[MapMarker] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
contacts = await _contacts(ctx) if wait else (ctx.devstate.peek_contacts() or [])
|
||||
for contact in contacts:
|
||||
if not contact.has_location or not usable_fix(contact.lat, contact.lon):
|
||||
continue
|
||||
key = contact.key_prefix or (contact.public_key or "")[:12]
|
||||
if key:
|
||||
seen.add(key)
|
||||
markers.append(
|
||||
MapMarker(
|
||||
label=contact.name or key or "?",
|
||||
lat=float(contact.lat),
|
||||
lon=float(contact.lon),
|
||||
is_repeater=contact.is_repeater,
|
||||
detail=_signal_detail(observed.get(key)),
|
||||
key=contact.public_key or contact.key_prefix or None,
|
||||
)
|
||||
)
|
||||
|
||||
# A node we overheard advertising a location but that isn't in our contacts.
|
||||
for node in observed.values():
|
||||
if not node.has_location or node.node in seen:
|
||||
continue
|
||||
if not usable_fix(node.lat, node.lon):
|
||||
continue
|
||||
markers.append(
|
||||
MapMarker(
|
||||
label=node.name or node.node or "?",
|
||||
lat=float(node.lat),
|
||||
lon=float(node.lon),
|
||||
is_repeater=node.is_repeater,
|
||||
detail=_signal_detail(node),
|
||||
key=node.public_key or node.node or None,
|
||||
)
|
||||
)
|
||||
|
||||
if wait:
|
||||
self_marker = await _self_marker(ctx)
|
||||
else:
|
||||
info = ctx.devstate.peek_self_info()
|
||||
self_marker = _marker_for_self(info) if info is not None else None
|
||||
if self_marker is not None:
|
||||
markers.append(self_marker)
|
||||
return markers
|
||||
|
||||
|
||||
async def _contacts(ctx: AppContext) -> list:
|
||||
"""Fetch the device's contacts, best-effort (an unreachable radio yields none)."""
|
||||
try:
|
||||
return await ctx.devstate.contacts()
|
||||
except Exception: # noqa: BLE001 - the map still works from observations alone
|
||||
return []
|
||||
|
||||
|
||||
async def _self_marker(ctx: AppContext) -> MapMarker | None:
|
||||
"""Build a marker for our own node from the device, if its location is known."""
|
||||
try:
|
||||
info = await ctx.devstate.self_info()
|
||||
except Exception: # noqa: BLE001 - the map is useful without our own position
|
||||
return None
|
||||
return _marker_for_self(info)
|
||||
|
||||
|
||||
def _marker_for_self(info: dict) -> MapMarker | None:
|
||||
"""Our own node's marker from a self-info payload, or ``None`` where it has no fix."""
|
||||
lat, lon = _as_float(info.get("adv_lat")), _as_float(info.get("adv_lon"))
|
||||
if lat is None or lon is None or not usable_fix(lat, lon):
|
||||
return None # a device with no fix reports 0/0 (or nonsense out-of-range)
|
||||
return MapMarker(
|
||||
label=str(info.get("name") or "this node"),
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
is_repeater=info.get("adv_type") == NODE_TYPE_REPEATER,
|
||||
is_self=True,
|
||||
key=str(info.get("public_key") or "") or None,
|
||||
)
|
||||
|
||||
|
||||
def _signal_detail(node: object) -> str:
|
||||
"""A compact 'N pkts · +x.x dB' reception note for a heard node, or '' if unheard."""
|
||||
if node is None:
|
||||
return ""
|
||||
detail = f"{node.count} pkts" # type: ignore[attr-defined]
|
||||
if node.median_snr is not None: # type: ignore[attr-defined]
|
||||
detail += f" · {node.median_snr:+.1f} dB" # type: ignore[attr-defined]
|
||||
return detail
|
||||
|
||||
|
||||
def _as_float(value: object) -> float | None:
|
||||
"""Best-effort float conversion, returning ``None`` on missing/garbage values."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Recover the paths a chat message rode in on, from the stored packet log.
|
||||
|
||||
The radio's RX log records every frame it decodes — including the relay path each one
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The optional Shift-state watcher behind the PicoCalc F-key lane's live flip.
|
||||
|
||||
The PicoCalc keyboard's MCU translates Shift+F1..F5 into plain F6–F10 keycodes, so the
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The passive-monitor history logger.
|
||||
|
||||
Passive monitoring records every advert/telemetry frame the companion overhears to the
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Multi-path probing: measure candidate routes to a target and rank what actually works.
|
||||
|
||||
The topology graph (:mod:`~meshterm.services.topology`) proposes routes from *received*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Walk records: how a completed trace is measured and scored for the trophy case.
|
||||
|
||||
A *walk* is one trace whose route starts and ends at our node — we transmit the first
|
||||
@@ -280,7 +281,7 @@ CATEGORIES: tuple[Category, ...] = (
|
||||
CATEGORY_BY_ID: dict[str, Category] = {c.id: c for c in CATEGORIES}
|
||||
|
||||
|
||||
def _local_xy(origin: tuple[float, float], point: tuple[float, float]) -> tuple[float, float]:
|
||||
def local_xy(origin: tuple[float, float], point: tuple[float, float]) -> tuple[float, float]:
|
||||
"""Project a lat/lon onto a local plane around ``origin``, in km.
|
||||
|
||||
An equirectangular approximation — exact enough for mesh-sized areas (a few tens
|
||||
@@ -353,7 +354,7 @@ def compute_walk_stats(
|
||||
if self_pos is not None:
|
||||
placed = [p for p in points[:-1] if p is not None] # circuit closes itself
|
||||
if len(placed) >= 3:
|
||||
xy = [_local_xy(self_pos, p) for p in placed]
|
||||
xy = [local_xy(self_pos, p) for p in placed]
|
||||
twice = sum(
|
||||
xy[i][0] * xy[(i + 1) % len(xy)][1] - xy[(i + 1) % len(xy)][0] * xy[i][1]
|
||||
for i in range(len(xy))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The observed mesh topology: a link graph distilled from every path we ever received.
|
||||
|
||||
MeshCore never tells us the network's shape outright, but almost everything we *receive*
|
||||
@@ -85,8 +86,13 @@ _HEX_DIGITS = frozenset("0123456789abcdef")
|
||||
_MISS = object()
|
||||
|
||||
|
||||
def _is_hex(value: str) -> bool:
|
||||
"""Whether ``value`` is non-empty, even-length hex (a plausible path hash)."""
|
||||
def is_path_hash(value: str) -> bool:
|
||||
"""Whether ``value`` could be a path hash: non-empty hex, a whole number of bytes.
|
||||
|
||||
THE test, shared by everything that has to tell a hop id from a name or a graph
|
||||
sentinel. Even length because a hash is bytes, never a half one — which is also what
|
||||
keeps a one-character endpoint marker out.
|
||||
"""
|
||||
return bool(value) and len(value) % 2 == 0 and all(c in _HEX_DIGITS for c in value)
|
||||
|
||||
|
||||
@@ -340,7 +346,7 @@ class MeshTopology:
|
||||
if cached is not _MISS:
|
||||
return cached
|
||||
needle = hop.lower().removeprefix("0x")
|
||||
if not _is_hex(needle):
|
||||
if not is_path_hash(needle):
|
||||
result: str | None = None
|
||||
else:
|
||||
matches = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Repeated-trace execution with duty-cycle pacing, plus trace parsing helpers.
|
||||
|
||||
:func:`run_traces` is the measurement primitive the TX optimizer and the path probe
|
||||
@@ -14,15 +15,21 @@ import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from ..core.connection import Device
|
||||
from ..core.models import Contact, TraceResult
|
||||
from ..core.models import Contact, NameKeyResolver, NodeResolver, TraceResult
|
||||
|
||||
# Trace arithmetic lives in ``core`` because the device layer sizes its own sends with it;
|
||||
# re-exported here so the runner's long-standing public names keep working.
|
||||
from ..core.tracing import ( # noqa: F401 - re-exported for this module's callers
|
||||
TRACE_TIMEOUT_BASE_S,
|
||||
TRACE_TIMEOUT_CEILING_S,
|
||||
TRACE_TIMEOUT_FLOOD_S,
|
||||
TRACE_TIMEOUT_PER_HOP_S,
|
||||
path_hash_flags,
|
||||
trace_timeout,
|
||||
)
|
||||
|
||||
ProgressCallback = Callable[[int, int, TraceResult], None]
|
||||
|
||||
#: Maps a trace hop's key-prefix hash to a display label (a contact name, or the
|
||||
#: hash itself when unknown). ``None`` passes through (our own device).
|
||||
NodeResolver = Callable[[str | None], str | None]
|
||||
|
||||
|
||||
_HEX_DIGITS = frozenset("0123456789abcdef")
|
||||
|
||||
|
||||
@@ -163,13 +170,6 @@ def make_key_resolver(contacts: list[Contact] | None) -> NodeResolver:
|
||||
return resolve
|
||||
|
||||
|
||||
#: Maps a display name to the node's key (as full as we hold one — a public key, a key
|
||||
#: prefix, or a stored node id), or ``None`` for a name no known node carries. The
|
||||
#: colour side of :func:`make_node_resolver`: where that resolver turns hex into names,
|
||||
#: this one turns a bare name back into the key its palette hue derives from.
|
||||
NameKeyResolver = Callable[[str], str | None]
|
||||
|
||||
|
||||
def make_name_key_resolver(
|
||||
contacts: list[Contact] | None,
|
||||
stored_names: dict[str, str] | None = None,
|
||||
@@ -269,75 +269,6 @@ def parse_trace_path(spec: str, contacts: list[Contact] | None = None) -> str:
|
||||
return ",".join(hops)
|
||||
|
||||
|
||||
def path_hash_flags(width_bytes: int) -> int | None:
|
||||
"""Return the trace ``flags`` value that encodes a per-hop path-hash width.
|
||||
|
||||
The trace subsystem encodes the hash size as ``1 << (flags & 3)`` on both the
|
||||
send and receive sides (``send_trace`` and the ``TRACE_DATA`` reader), so only
|
||||
widths of 1, 2, 4, or 8 bytes are representable. This is independent of, and
|
||||
differs from, the ``mode = size - 1`` encoding used for *contact routing*
|
||||
(``out_path_hash_mode``).
|
||||
|
||||
Args:
|
||||
width_bytes: Path-hash width in bytes.
|
||||
|
||||
Returns:
|
||||
The flags value (the exponent ``s``), or ``None`` if the width is not a
|
||||
representable power of two.
|
||||
"""
|
||||
for s in range(4):
|
||||
if (1 << s) == width_bytes:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
#: Fixed overhead in a trace's reply-wait budget, in seconds: our own transmit, the far
|
||||
#: endpoint's turnaround, and our receive-side decode — the cost that doesn't grow with
|
||||
#: the route.
|
||||
TRACE_TIMEOUT_BASE_S = 4.0
|
||||
#: Per-hop allowance added to the budget, in seconds. Each entry in the walked path is one
|
||||
#: relay transmission, and this covers its packet airtime, the repeater's processing, and
|
||||
#: the randomised transmit backoff MeshCore adds so relays don't collide.
|
||||
TRACE_TIMEOUT_PER_HOP_S = 1.6
|
||||
#: Upper bound on the reply-wait, in seconds, so a route that never comes home still
|
||||
#: surrenders the trace (and the session) in bounded time rather than scaling without end.
|
||||
TRACE_TIMEOUT_CEILING_S = 30.0
|
||||
#: Fallback budget, in seconds, when the hop count can't be known ahead of the send — a
|
||||
#: path-less flood to a contact we hold no route for. This is the historical flat value,
|
||||
#: kept for exactly the case where we can't size the walk.
|
||||
TRACE_TIMEOUT_FLOOD_S = 10.0
|
||||
|
||||
|
||||
def trace_timeout(hop_count: int) -> float:
|
||||
"""Return the reply-wait budget, in seconds, for a trace walking ``hop_count`` hops.
|
||||
|
||||
A trace is a *single* packet that has to travel the whole path — out to the far hop
|
||||
and back over the mirrored return leg — before its reply reaches us, so the wait has
|
||||
to grow with the route. A flat budget sized for a neighbour cuts a long walk off
|
||||
mid-flight: the packet completes the circuit on the mesh, but our wait has already
|
||||
expired, so we log a phantom "no reply" for a route that actually worked. The budget
|
||||
is a fixed base (:data:`TRACE_TIMEOUT_BASE_S`) plus a per-hop allowance
|
||||
(:data:`TRACE_TIMEOUT_PER_HOP_S`) for each relay transmission, capped at
|
||||
:data:`TRACE_TIMEOUT_CEILING_S`.
|
||||
|
||||
``hop_count`` is the number of hops in the *transmitted* path, which already includes
|
||||
the mirrored return leg (see
|
||||
:meth:`~meshterm.core.connection.MeshCoreDevice._trace_path_to_contact`), so it maps
|
||||
one-to-one onto relay transmissions and must not be doubled here.
|
||||
|
||||
Args:
|
||||
hop_count: Hops in the walked path (return leg included). ``0`` (or less) means the
|
||||
count is unknown — a path-less flood — and yields :data:`TRACE_TIMEOUT_FLOOD_S`.
|
||||
|
||||
Returns:
|
||||
The number of seconds to wait for the trace reply.
|
||||
"""
|
||||
if hop_count <= 0:
|
||||
return TRACE_TIMEOUT_FLOOD_S
|
||||
budget = TRACE_TIMEOUT_BASE_S + TRACE_TIMEOUT_PER_HOP_S * hop_count
|
||||
return min(budget, TRACE_TIMEOUT_CEILING_S)
|
||||
|
||||
|
||||
async def run_traces(
|
||||
device: Device,
|
||||
target: str,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Remote-admin TX-power optimization.
|
||||
|
||||
This tunes the transmit power of a *remote* node we have admin rights on — the node
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The Watchtower: passive rules over what the hub already hears, raising alerts.
|
||||
|
||||
A sentinel, not a prober — it transmits nothing and asks the radio for nothing. It rides
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Pluggable tools (menu options / CLI subcommands).
|
||||
|
||||
Importing this package eagerly imports every tool module so that each one's ``@register``
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The About MeshTerm tools: the four written pages in the menu's last section.
|
||||
|
||||
*About MeshTerm*, *About the author*, *Join Discord*, *Support MeshTerm* — in that
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``advert`` tool: announce this node to the mesh, straight from the main menu.
|
||||
|
||||
Opens the Send advert flow (see :func:`meshterm.ui.config_editor.send_advert`) — a
|
||||
@@ -7,7 +8,7 @@ as modal dialogs (the quit-dialog pattern), so the everyday action stays a quick
|
||||
in-place popup rather than a screen change.
|
||||
|
||||
Menu-only: the scripted equivalents are ``config advert`` and ``config share``, so no
|
||||
duplicate subcommand is registered here (the same pattern as ``device-actions``).
|
||||
duplicate subcommand is registered here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -38,7 +39,7 @@ class SendAdvertTool(Tool):
|
||||
The flow presents its own result (the sent-advert note or the contact-card
|
||||
view) before returning, so by the time the user backs out there is nothing
|
||||
left for :meth:`run` to do — returning ``None`` tells the menu the invocation
|
||||
is complete (the same pattern as the Device actions screen).
|
||||
is complete (the same pattern as the Device config page).
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The tool plugin contract and registry.
|
||||
|
||||
A *tool* is one menu option / CLI subcommand. Subclass :class:`Tool`, decorate it with
|
||||
@@ -109,7 +110,8 @@ class Tool(ABC):
|
||||
"""Interactively gather parameters for the menu.
|
||||
|
||||
The default implementation requires no parameters. Tools override this to ask
|
||||
the user via ``questionary``.
|
||||
the user through :attr:`AppContext.ui` — a dialog or a select list in the menu, and
|
||||
nothing at all on the CLI, where the arguments came off the command line.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
|
||||
+14
-13
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``channels`` tool: create, join, and manage mesh channels.
|
||||
|
||||
Interactively it opens a full-screen channel manager (see :mod:`meshterm.ui.channels`) — a
|
||||
@@ -15,8 +16,6 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
from rich.console import Group
|
||||
from rich.text import Text
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core import exitcodes
|
||||
@@ -86,7 +85,7 @@ class ChannelsTool(Tool):
|
||||
|
||||
async def _cli_list(self, ctx: AppContext) -> ToolResult:
|
||||
"""List the configured channel slots."""
|
||||
from ..ui.channels import read_channel_slots
|
||||
from ..core.channel_probe import read_channel_slots
|
||||
|
||||
device = await ctx.device()
|
||||
slots = await read_channel_slots(device)
|
||||
@@ -109,7 +108,7 @@ class ChannelsTool(Tool):
|
||||
else:
|
||||
secret = normalize_secret(params["secret"]) if params.get("secret") else random_secret()
|
||||
await write_channel(ctx, device, idx, name, secret)
|
||||
self._show_qr(ctx, share_url(name, secret))
|
||||
await self._show_qr(ctx, name, share_url(name, secret))
|
||||
return ToolResult(
|
||||
summary={"index": idx, "name": name},
|
||||
report=(_written(idx, name, secret),),
|
||||
@@ -152,7 +151,7 @@ class ChannelsTool(Tool):
|
||||
the secret and the share URL leave the app at all — every other channel document
|
||||
carries the identity and stops there.
|
||||
"""
|
||||
from ..ui.channels import read_channel_slots
|
||||
from ..core.channel_probe import read_channel_slots
|
||||
|
||||
device = await ctx.device()
|
||||
idx = int(params["index"])
|
||||
@@ -163,7 +162,7 @@ class ChannelsTool(Tool):
|
||||
report=(_written(idx, None, None, shown=False),),
|
||||
exit_code=exitcodes.NO_RESULT,
|
||||
)
|
||||
self._show_qr(ctx, share_url(slot.name, slot.secret))
|
||||
await self._show_qr(ctx, slot.name, share_url(slot.name, slot.secret))
|
||||
return ToolResult(
|
||||
summary={"index": idx, "shared": True},
|
||||
report=(_written(idx, slot.name, slot.secret),),
|
||||
@@ -177,7 +176,8 @@ class ChannelsTool(Tool):
|
||||
command (a private channel's key is lost with the slot unless it's saved
|
||||
elsewhere), mirroring the interactive flow's danger confirmation.
|
||||
"""
|
||||
from ..ui.channels import read_channel_slots, write_channel
|
||||
from ..core.channel_probe import read_channel_slots
|
||||
from ..ui.channels import write_channel
|
||||
|
||||
device = await ctx.device()
|
||||
idx = int(params["index"])
|
||||
@@ -195,18 +195,19 @@ class ChannelsTool(Tool):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _show_qr(ctx: AppContext, url: str) -> None:
|
||||
async def _show_qr(ctx: AppContext, name: str, url: str) -> None:
|
||||
"""Draw a channel's share link as a QR code — in the menu, and only there.
|
||||
|
||||
The QR is for a phone pointed at the screen. Piped into a file it is a block of
|
||||
block characters wrapped around the one thing that is actually the answer, so a
|
||||
scripted run states the link and nothing else.
|
||||
The QR is for a phone pointed at the screen, and there it takes the whole screen
|
||||
(:func:`~meshterm.ui.qr.share_screen`). Piped into a file it is a block of block
|
||||
characters wrapped around the one thing that is actually the answer, so a scripted
|
||||
run states the link and nothing else.
|
||||
"""
|
||||
from ..ui.qr import qr_text
|
||||
from ..ui.qr import share_screen
|
||||
from ..ui.surface import TuiUi
|
||||
|
||||
if isinstance(ctx.ui, TuiUi):
|
||||
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
|
||||
await share_screen(ctx, name=name, url=url)
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register the ``channels`` subcommand group.
|
||||
|
||||
+12
-10
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``chat`` tool: channel and direct messaging over the mesh.
|
||||
|
||||
Interactively it opens a conversation picker and then a live, full-screen chat (see
|
||||
@@ -27,9 +28,11 @@ from ..core import exitcodes
|
||||
from ..core.channels import (
|
||||
CHANNEL_SLOT_PROBE_CAP,
|
||||
DEFAULT_PUBLIC_SECRET,
|
||||
MENTION,
|
||||
channel_hash,
|
||||
channel_identity,
|
||||
is_public_channel,
|
||||
split_channel_sender,
|
||||
)
|
||||
from ..core.connection import Device
|
||||
from ..core.events import EventKind, MeshEvent
|
||||
@@ -43,16 +46,15 @@ from ..core.models import (
|
||||
)
|
||||
from ..services.trace_runner import NameKeyResolver, make_name_key_resolver
|
||||
from ..ui import renderers
|
||||
from ..ui.chat import _MENTION, _split_channel_sender
|
||||
from ..ui.fields import ChannelRef, NodeRef
|
||||
from ..ui.menus import Lane, column_header, fit_cells, section_heading
|
||||
from ..ui.theme import name_style
|
||||
from ..ui.tui import Choice, DeleteRequest, Separator
|
||||
from ..ui.widgets import _NODE_GLYPHS, _age_seconds, _format_age, channel_glyph
|
||||
from ..ui.widgets import NODE_GLYPHS, age_seconds, channel_glyph, format_age
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..ui.channels import ChannelSlot
|
||||
from ..core.channel_probe import ChannelSlot
|
||||
from ..ui.report import Facts, Listing
|
||||
|
||||
#: How many recent messages ``chat history`` prints by default.
|
||||
@@ -133,7 +135,7 @@ class ChatTool(Tool):
|
||||
# scroll nor the erase atom is written into the base: the select list splices
|
||||
# each in exactly when its key would act — ←→ only while the highlighted row
|
||||
# overflows, Del only on a thread that has history to lose — and the three of
|
||||
# them together are what has to stay inside 72 cells (the purge preview's list
|
||||
# them together are what has to stay inside 72 cells (the archive preview's list
|
||||
# is the same arithmetic). `Del erase` is that budget's doing: the row already
|
||||
# names the conversation, so the atom spends its cells on the verb, and `erase`
|
||||
# is the word the app uses elsewhere for taking data away.
|
||||
@@ -498,7 +500,7 @@ def _channels_from_slots(slots: list[ChannelSlot]) -> list[Conversation]:
|
||||
The picker reads its channels through the session cache
|
||||
(:meth:`~meshterm.services.device_state.DeviceState.channel_slots`) so it reuses the one
|
||||
slow slot probe instead of re-walking every slot on each Chat open; this maps that cached
|
||||
:class:`~meshterm.ui.channels.ChannelSlot` list onto the picker's
|
||||
:class:`~meshterm.core.channel_probe.ChannelSlot` list onto the picker's
|
||||
:class:`~meshterm.core.models.Conversation` rows. Channel 0 (the default public channel) is
|
||||
synthesised when the firmware reports no slot for it, so there is always somewhere to chat —
|
||||
the same guarantee :func:`_read_channels` (the CLI path) makes.
|
||||
@@ -833,7 +835,7 @@ def _title(
|
||||
#: The standard companion pink — the shared plain-node ``●`` colour — the contact dot's
|
||||
#: hue: the shape (filled/hollow) marks history, the colour marks "a companion", and the
|
||||
#: name beside it carries the person's own key-derived hue.
|
||||
_COMPANION_DOT_STYLE = _NODE_GLYPHS[NODE_TYPE_CHAT][1]
|
||||
_COMPANION_DOT_STYLE = NODE_GLYPHS[NODE_TYPE_CHAT][1]
|
||||
|
||||
|
||||
def _append_marker(
|
||||
@@ -879,7 +881,7 @@ def _preview_text(last: ChatMessage, key_of: NameKeyResolver) -> Text:
|
||||
text.append("you: ", style="accent")
|
||||
_append_body(text, body_raw, key_of)
|
||||
elif last.is_channel:
|
||||
name, body = _split_channel_sender(body_raw)
|
||||
name, body = split_channel_sender(body_raw)
|
||||
if name is not None:
|
||||
text.append(name, style=name_style(name, key_of(name)))
|
||||
text.append(": ", style="muted")
|
||||
@@ -898,7 +900,7 @@ def _append_body(text: Text, body: str, key_of: NameKeyResolver) -> None:
|
||||
name resolves to no node we know.
|
||||
"""
|
||||
pos = 0
|
||||
for match in _MENTION.finditer(body):
|
||||
for match in MENTION.finditer(body):
|
||||
if match.start() > pos:
|
||||
text.append(body[pos : match.start()], style="muted")
|
||||
name = match.group(1)
|
||||
@@ -909,7 +911,7 @@ def _append_body(text: Text, body: str, key_of: NameKeyResolver) -> None:
|
||||
|
||||
|
||||
def _ago(when: Any) -> str:
|
||||
"""The column age for a message time, through THE grammar (``_format_age``).
|
||||
"""The column age for a message time, through THE grammar (``format_age``).
|
||||
|
||||
One deliberate difference from the widget: a missing or naive timestamp (a stray one
|
||||
from the wire) reads as a *blank* lane rather than ``never`` — the picker wants an
|
||||
@@ -917,7 +919,7 @@ def _ago(when: Any) -> str:
|
||||
"""
|
||||
if getattr(when, "tzinfo", None) is None:
|
||||
return ""
|
||||
return _format_age(_age_seconds(when))
|
||||
return format_age(age_seconds(when))
|
||||
|
||||
|
||||
def _channel_ref(idx: int | None, label: str, secret: bytes | None) -> ChannelRef:
|
||||
|
||||
+36
-46
@@ -1,12 +1,13 @@
|
||||
"""The ``config`` tool: view and change every device configuration value.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``config`` tool: every device setting, and the operations on the box itself.
|
||||
|
||||
Interactively it launches a full editor (see :mod:`meshterm.ui.config_editor`); on the
|
||||
CLI it exposes generic key/value subcommands plus backup/restore, channels, custom vars,
|
||||
and a ``--yes``-gated set of destructive operations. Everything funnels through
|
||||
:func:`apply_ops`: the editor stages value changes for :meth:`ConfigTool.run` to execute
|
||||
and log, and the Device actions screen (the sibling ``device-actions`` tool) and the
|
||||
standalone ``advert`` tool run their immediate operations (adverts, reboot, key
|
||||
management, factory reset) through the same executor.
|
||||
Interactively it opens the Device config page (see :mod:`meshterm.ui.config_editor`) —
|
||||
settings staged and applied in place, with the clock, backup/restore, the identity key,
|
||||
reboot and factory reset as actions below them. On the CLI it exposes generic key/value
|
||||
subcommands plus backup/restore, channels, custom vars, and a ``--yes``-gated set of
|
||||
destructive operations. Everything funnels through :func:`apply_ops`: the page's Apply and
|
||||
its actions, :meth:`ConfigTool.run` for the scripted commands, and the standalone ``advert``
|
||||
tool's sends all run through the one executor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,6 +29,7 @@ from ..core.device_config import (
|
||||
parse_value,
|
||||
settings_by_category,
|
||||
)
|
||||
from ..services.clock_sync import set_clock
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
@@ -41,25 +43,27 @@ class ConfigTool(Tool):
|
||||
name = "config"
|
||||
title = "Device config"
|
||||
icon = "🔧"
|
||||
help = "Device settings — identity, radio, tuning, …"
|
||||
help = "Settings and actions — radio, backup, reboot, …"
|
||||
category = "This node"
|
||||
order = 20
|
||||
|
||||
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
|
||||
"""Launch the interactive editor and collect the operations to perform.
|
||||
"""Run the Device config page; there are never parameters to collect.
|
||||
|
||||
The page applies what is staged and runs its actions itself, logging a ``runs`` row
|
||||
for each Apply (the same pattern as ``repeater-admin``), so by the time the reader
|
||||
backs out there is nothing left for :meth:`run` to do.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
|
||||
Returns:
|
||||
``{"ops": [...]}`` to execute, or ``None`` if the user cancelled.
|
||||
Always ``None``.
|
||||
"""
|
||||
from ..ui.config_editor import edit_config
|
||||
|
||||
ops = await edit_config(ctx)
|
||||
if not ops:
|
||||
return None
|
||||
return {"ops": ops}
|
||||
await edit_config(ctx)
|
||||
return None
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Execute a list of configuration operations against the device.
|
||||
@@ -213,10 +217,10 @@ async def apply_ops(
|
||||
) -> tuple[int, list[str], Report]:
|
||||
"""Execute a list of configuration operation tuples against ``device``.
|
||||
|
||||
This is the single executor for every config operation, used both by
|
||||
:meth:`ConfigTool.run` (for staged, applied changes) and by the interactive editor's
|
||||
device actions (adverts, reboot, backup/restore, key management, factory reset —
|
||||
which run immediately rather than staging).
|
||||
This is the single executor for every config operation, used by :meth:`ConfigTool.run`
|
||||
(the scripted commands) and by the Device config page — its Apply, one staged value at a
|
||||
time, and its actions (reboot, backup/restore, key management, factory reset), which run
|
||||
immediately rather than staging.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context (for console output).
|
||||
@@ -301,25 +305,21 @@ async def apply_ops(
|
||||
"the device reported no public key — the advert cadence was not saved"
|
||||
)
|
||||
elif kind == "share":
|
||||
report.append(_share_contact(ctx, snapshot))
|
||||
report.append(await _share_contact(ctx, snapshot))
|
||||
elif kind == "sync_clock":
|
||||
import time as _time
|
||||
from datetime import datetime
|
||||
|
||||
epoch = int(_time.time())
|
||||
drift = await _try_clock(device)
|
||||
await device.set_time(epoch)
|
||||
stamp = datetime.fromtimestamp(epoch).astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
||||
ctx.ui.ack(f"[ok]✓[/ok] device clock set to [brand]{stamp}[/brand]")
|
||||
# The same step the on-connect service runs in the background
|
||||
# (:mod:`meshterm.services.clock_sync`); here it is acknowledged and reported.
|
||||
done = await set_clock(device)
|
||||
ctx.ui.ack(f"[ok]✓[/ok] device clock set to [brand]{done.stamp}[/brand]")
|
||||
changes += 1
|
||||
report.append(
|
||||
_acted(
|
||||
"clock",
|
||||
changes=1,
|
||||
set_at=datetime.fromtimestamp(epoch).astimezone(),
|
||||
set_at=done.set_at,
|
||||
# What the clock was off by *before* the set: the fact worth logging,
|
||||
# and the one this command destroys by succeeding.
|
||||
drift_s=None if drift is None else drift - epoch,
|
||||
drift_s=done.drift_s,
|
||||
)
|
||||
)
|
||||
elif kind == "reboot":
|
||||
@@ -477,20 +477,18 @@ def _script_value(spec: SettingSpec, value: Any) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _share_contact(ctx: AppContext, snapshot: dict) -> Facts:
|
||||
"""State this node's contact card — with the QR code drawn in the menu.
|
||||
async def _share_contact(ctx: AppContext, snapshot: dict) -> Facts:
|
||||
"""State this node's contact card — with the QR code drawn in the menu, full-frame.
|
||||
|
||||
The QR is for a phone pointed at the screen. Redirected into a file it is a block of
|
||||
The QR is for a phone pointed at the screen, and there it takes the whole screen
|
||||
(:func:`~meshterm.ui.qr.share_screen`). Redirected into a file it is a block of
|
||||
block characters around the one thing that is the answer, so a scripted run states the
|
||||
link by itself. There is no machine face for the code either, and never will be: it is
|
||||
a second rendering of ``url``.
|
||||
"""
|
||||
from rich.console import Group
|
||||
from rich.text import Text
|
||||
|
||||
from ..ui import fields
|
||||
from ..ui.config_editor import contact_share_url
|
||||
from ..ui.qr import qr_text
|
||||
from ..ui.qr import share_screen
|
||||
from ..ui.report import BARE, Facts
|
||||
from ..ui.surface import TuiUi
|
||||
|
||||
@@ -501,7 +499,7 @@ def _share_contact(ctx: AppContext, snapshot: dict) -> Facts:
|
||||
adv_type = int(snapshot.get("adv_type") or 1)
|
||||
url = contact_share_url(name, public_key, adv_type)
|
||||
if isinstance(ctx.ui, TuiUi):
|
||||
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
|
||||
await share_screen(ctx, name=name, url=url)
|
||||
return Facts(
|
||||
key="share",
|
||||
fields=(
|
||||
@@ -801,11 +799,3 @@ def _acted(key: str, **facts: Any) -> Facts:
|
||||
values=dict(facts),
|
||||
shape=SILENT,
|
||||
)
|
||||
|
||||
|
||||
async def _try_clock(device: Device) -> int | None:
|
||||
"""The device's current clock, or ``None`` where the firmware will not answer."""
|
||||
try:
|
||||
return await device.get_time()
|
||||
except Exception: # noqa: BLE001 - optional read; the drift is a nicety
|
||||
return None
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``contacts`` tool: list the contacts this node knows about.
|
||||
|
||||
The menu opens the sortable Contacts screen, which leads with our own node; the CLI
|
||||
@@ -138,7 +139,7 @@ def _listing(contacts: list[Contact], counts: dict[str, int], prefix_bytes: int)
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
from ..ui.widgets import _contact_pkts
|
||||
from ..ui.widgets import contact_packets
|
||||
|
||||
rows = []
|
||||
for contact in contacts:
|
||||
@@ -152,7 +153,7 @@ def _listing(contacts: list[Contact], counts: dict[str, int], prefix_bytes: int)
|
||||
type=NODE_TYPE_LABELS.get(contact.node_type),
|
||||
),
|
||||
"heard_at": contact.last_seen,
|
||||
"packets": _contact_pkts(contact, counts),
|
||||
"packets": contact_packets(contact, counts),
|
||||
"position": (
|
||||
fields.Position(contact.lat, contact.lon) if contact.has_location else None
|
||||
),
|
||||
|
||||
Loaded 100 of 291 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user