Compare commits
No files matched your search
@@ -4,7 +4,10 @@ Given an emoji on the command line, report its codepoints and how the three widt
|
||||
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 👋
|
||||
$DEV_VENV_PYTHON .claude/skills/add-narrow-emoji/classify.py 👋
|
||||
|
||||
(``$DEV_VENV_PYTHON`` is the interpreter named in ``.dev.env``; ``.dev.env.example`` shows
|
||||
the shape.)
|
||||
|
||||
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 /
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: read-logs
|
||||
description: Investigate a MeshTerm problem from its log — find what went wrong, when, and where in the code, and say whether it is still live on HEAD. Use when JP says to check, read or look at the logs, asks why something crashed, froze, disconnected or did something odd, pastes an error or a log excerpt, or reports a bug from the PicoCalc or another machine.
|
||||
---
|
||||
|
||||
# Reading the logs to find an issue
|
||||
|
||||
The log is evidence, not a diagnosis. The job runs **symptom → the records that show it →
|
||||
the code that wrote them → whether HEAD still does it**. Stop at the diagnosis and propose
|
||||
the fix; make it only when JP asks for it.
|
||||
|
||||
## What there is to read
|
||||
|
||||
| Source | Where | Clock |
|
||||
|---|---|---|
|
||||
| `meshterm.log`, rotated at 2 MB into `.1`–`.3` | `$MESHTERM_HOME`, else `~/.meshterm/` | local, of the machine that wrote it |
|
||||
| `meshterm.log.jsonl` — the log before 2026-09-07 15:00, JSON Lines | same directory; nothing reads it but `--legacy` | local |
|
||||
| the `runs` table — a row per tool run: `status` ok / error / running, `summary_json.error` | `meshterm.db`, same directory | **UTC** |
|
||||
|
||||
The **run id** joins the log to the table, never the timestamp.
|
||||
|
||||
How much the file holds is the `log_level` preference (**Log detail** on the Preferences
|
||||
page), default **WARNING**. JP's machine runs at DEBUG; a bug report from anyone else
|
||||
probably doesn't. At WARNING there are no `run N start`/`ok` records and no quiet failures,
|
||||
so a missing record proves nothing. The summary says so when it sees no DEBUG/INFO at all.
|
||||
When the question needs the narration, ask for Log detail at DEBUG (it relaunches) and a
|
||||
reproduction.
|
||||
|
||||
## 1. Pin the question
|
||||
|
||||
Before opening anything, settle four things:
|
||||
|
||||
- what went wrong: the screen or command, and the words on it
|
||||
- roughly when, in local time: "just now" is `--since 30m`
|
||||
- which machine: this one, the PicoCalc, or an attached file
|
||||
- menu or CLI
|
||||
|
||||
A window turns five thousand records into forty.
|
||||
|
||||
## 2. Get the log
|
||||
|
||||
- **This machine:** nothing to fetch.
|
||||
- **The PicoCalc:** read `.dev.env` for the `$DEV_PICOCALC_*` values, then copy the set into
|
||||
the scratchpad and point `--home` at it:
|
||||
```
|
||||
scp -i $DEV_PICOCALC_SSH_KEY "$DEV_PICOCALC_USER@$DEV_PICOCALC_HOST:.meshterm/meshterm.log*" <scratchpad>/picocalc/
|
||||
```
|
||||
Bring `meshterm.db` too only when the runs table matters; it is tens of MB. Those records
|
||||
are in the device's own local time.
|
||||
- **A pasted or attached log:** save it to the scratchpad and pass `--log <file>`.
|
||||
Repeat `--log` for a rotated set, oldest first.
|
||||
|
||||
## 3. Summary first
|
||||
|
||||
```
|
||||
python .claude/skills/read-logs/logscan.py summary --since "2026-09-11 03:00"
|
||||
```
|
||||
|
||||
Stdlib only, so any Python runs it, the PicoCalc's included. It prints, in order:
|
||||
|
||||
- **header**: files, span, window, records kept and how many test records it hid, the
|
||||
level mix
|
||||
- **Activity**: blocks of records split by silences (`--gap`, default 20 min), each with
|
||||
its warning and error count, which is where "when did it go wrong" usually shows first
|
||||
- **Tracebacks**, grouped by the exception they ended on, with first → last and the
|
||||
record they hang under; the CLI's second copy of a failed run is folded in
|
||||
- **Warnings and errors without a traceback**, grouped with first → last
|
||||
- **Quiet failures**: DEBUG/INFO records reporting a failure, which a WARNING-and-up
|
||||
read never sees
|
||||
- **Runs table**: status counts, the latest errors, and runs never finished
|
||||
|
||||
Time forms everywhere: `30m`, `2h`, `3d`, `03:54` (today), `2026-09-11 03:54[:10]`.
|
||||
|
||||
## 4. Then narrow
|
||||
|
||||
```
|
||||
logscan.py around "2026-09-11 03:54:47" --minutes 2 # every level, both sides of a moment
|
||||
logscan.py run 3379 # its runs row, its records, what ran beside it
|
||||
logscan.py show --level WARNING --grep "BLE|link" # verbatim records, filtered
|
||||
```
|
||||
|
||||
`--grep` searches message and traceback, case-insensitive. `--fixtures` brings the hidden
|
||||
test records back. `--no-db` skips the table.
|
||||
|
||||
## Reading it right
|
||||
|
||||
- **The logger name rarely says who spoke.** Nearly everything logs through `ctx.log`, the
|
||||
bare `meshterm` logger, so the component is the message's own prefix: `devstate:`,
|
||||
`courier:`, `event hub:`, `chat:`, `run N`. Only `meshterm.core.connection`,
|
||||
`meshterm.core.discovery`, `meshcore` (the library) and `asyncio` are loggers of their
|
||||
own.
|
||||
- **A count means nothing without its span.** 204× `event hub: subscriber raised: [Errno
|
||||
22]` reads as chronic, and it was three seconds on 09-08. Read first → last before
|
||||
calling anything recurring.
|
||||
- **A run's life** is `run N start: tool=… params=…` followed by exactly one ending
|
||||
(`tools/base.py`):
|
||||
- `run N ok`, at DEBUG.
|
||||
- `run N aborted: …`, at DEBUG. An *expected* refusal: a bad argument, no device, a
|
||||
device error. It has no traceback, and the CLI also logs the one line it printed as
|
||||
`<tool> failed: …` at WARNING.
|
||||
- `run N lost the device connection: …`, at WARNING.
|
||||
- `run N failed: …`, at ERROR with a traceback. The CLI logs the same exception again as
|
||||
`<tool> raised an unhandled error`.
|
||||
|
||||
`the interactive session raised an unhandled error` is the TUI itself dying.
|
||||
- **A run left `running` in the table** never reached an ending. The process was killed,
|
||||
the window closed, it died without a traceback, or it is still running now. A background
|
||||
`chat`/`monitor` row (`{"mode": "background"}`) left open means that *session* didn't
|
||||
tear down. A foreground tool left open died mid-command. `run N` on it shows what
|
||||
followed its start.
|
||||
- **Quiet failures have no stack.** Background loops log `"…: %s", exc` at DEBUG
|
||||
(`devstate: … failed`, `battery poller: read failed`, `courier: pass failed`, `event hub:
|
||||
subscriber raised`). The message survives and where it was raised does not:
|
||||
`[Errno 22] Invalid argument` names no file. Grep the literal prefix to find the call
|
||||
site and read what its `try` covers. Or reproduce with a temporary `exc_info=True` on
|
||||
that call, and never commit it.
|
||||
- **A connection has a lifecycle, a session doesn't.** No record marks the app starting.
|
||||
A link opens as `meshcore: BLE Connection started`, then `event hub started`, then the
|
||||
services (`passive monitor recording`, `advert scheduler started`, `courier started`, …).
|
||||
It closes with `event hub stopped`. A stop/start pair seconds apart is a reconnect, so
|
||||
read the record just before it (`BLE write failed`, `Client is not connected`).
|
||||
- **Radio refusals are usually a symptom.** A storm of `ERR_CODE_BAD_STATE` from contact
|
||||
refreshes and battery polls while an admin login or another exchange is in flight means
|
||||
the companion was busy. Look for what held it, not at the refresh.
|
||||
- **An `asyncio` record is a real bug.** prompt_toolkit's loop handler is disabled, so a
|
||||
stray background-task exception (`Task exception was never retrieved`) lands in the file
|
||||
and nowhere else. No screen ever showed it.
|
||||
- **Test noise before 2026-09-08 02:52.** Until 59f10f9 the suite wrote into the real
|
||||
`~/.meshterm/meshterm.log`. The summary hides it by fingerprint, and hides runs whose ids
|
||||
came from another database, and says how many records went. Anything on this machine
|
||||
from before then that still looks synthetic probably is. A hand-run `--mock` session
|
||||
still writes into the real log *and* the real database, so run `purge-test-data` after
|
||||
one.
|
||||
|
||||
## From record to code
|
||||
|
||||
1. **Find the call site.** Grep the message's literal text, the part before its first
|
||||
formatted value, under `meshterm/`. Read the `try` it sits in.
|
||||
2. **Check it is still live.** A traceback from last week may already be fixed, and
|
||||
finding the name in the source proves nothing: the attribute an `AttributeError`
|
||||
complains of usually still exists, on a different class. Read the frame's own line at
|
||||
HEAD, and use `git log -L` on that function (or `git log -S "<literal>"`) to see whether
|
||||
it changed after the record's date. A fixed one gets named with its commit, not fixed
|
||||
twice.
|
||||
3. **Reproduce a live one.** If it doesn't need hardware, reproduce it (the `verify`
|
||||
skill; `--mock` needs `purge-test-data` after). If it needs the radio, tell JP what to
|
||||
do and at what Log detail.
|
||||
|
||||
## Report
|
||||
|
||||
- **Symptom**, in JP's words.
|
||||
- **Evidence**: the few decisive records, quoted with their timestamps, not the summary
|
||||
dump.
|
||||
- **Cause**: `file:line` and the mechanism.
|
||||
- **Status**: live on HEAD, fixed in `<hash>`, or unknown without a reproduction (and what
|
||||
that reproduction needs).
|
||||
- **Fix**: proposed, not applied, unless JP asked.
|
||||
|
||||
Log excerpts carry node and channel names, and the legacy `.jsonl` carries tool `params`
|
||||
logged verbatim, **admin passwords among them**. Quote only what a finding needs, and never
|
||||
put log text anywhere public (an issue, Discord) without JP reading it first.
|
||||
|
||||
## Keeping the script honest
|
||||
|
||||
- `RECORD` in `logscan.py` parses `_LINE_FORMAT` from `meshterm/persistence/logging.py`.
|
||||
Change one, change the other.
|
||||
- `FIXTURE` carries `MockDevice`'s identities. Keep them in step with
|
||||
`.claude/skills/purge-test-data/purge.py`.
|
||||
@@ -0,0 +1,632 @@
|
||||
"""Read MeshTerm's log the way an investigation needs it: grouped, windowed, joined to runs.
|
||||
|
||||
MeshTerm writes ``<config_dir>/meshterm.log`` (see ``meshterm/persistence/logging.py``):
|
||||
one record per line — ``2026-09-07 14:23:11 WARNING meshterm: message`` — with a
|
||||
traceback indented under the record that raised it, rotated at 2MB into ``.1``..``.3``.
|
||||
Before 2026-09-07 it was JSON Lines, and that file is still beside it as
|
||||
``meshterm.log.jsonl`` (``--legacy``).
|
||||
|
||||
Read raw, the file misleads in predictable ways, and this script takes them off the table
|
||||
before any reasoning starts:
|
||||
|
||||
- **A count without its spread.** Two hundred identical records inside one hour are one
|
||||
incident, not a chronic fault — so every group carries its first and last time.
|
||||
- **The same failure twice.** A tool that raises logs ``run N failed`` with a traceback, and
|
||||
the CLI then logs ``<tool> raised an unhandled error`` with the *same* traceback. The
|
||||
second is folded into the first.
|
||||
- **Test runs.** Until ``tests/conftest.py`` isolated ``MESHTERM_HOME`` the suite wrote into
|
||||
the developer's real log, and ``--mock`` sessions still can. Records carrying their
|
||||
fingerprints, and runs numbered by another database, are hidden unless ``--fixtures``
|
||||
asks for them, and the summary says how many went.
|
||||
- **Two clocks.** Log records are the writing machine's *local* time; the ``runs`` table is
|
||||
UTC. Both carry the run id, which is the join — the table's times are converted to this
|
||||
machine's local time for display only.
|
||||
|
||||
Stdlib only, so it runs anywhere MeshTerm does, the PicoCalc included.
|
||||
|
||||
Usage::
|
||||
|
||||
logscan.py [summary] the whole picture, grouped
|
||||
logscan.py show --level WARNING matching records, verbatim
|
||||
logscan.py around "2026-09-11 03:54" --minutes 3
|
||||
logscan.py run 3379 one run's records, its neighbours, its runs row
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
#: ``_LINE_FORMAT`` / ``_TIME_FORMAT`` in meshterm/persistence/logging.py, as a parser.
|
||||
RECORD = re.compile(
|
||||
r"^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) (DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+): (.*)$"
|
||||
)
|
||||
TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}
|
||||
|
||||
#: Test-suite and simulator fingerprints: the CLI-contract fixtures (``config get
|
||||
#: nosuchkey``/``no_such_key``, ``--port COM99``/``NOPE1``/``xx``, ``chat --to Nobody``, the
|
||||
#: ``[/]Bob`` contact planted to prove markup escaping), the discovery tests' BLE address,
|
||||
#: and ``MockDevice``'s identities — keep the last in step with
|
||||
#: ``.claude/skills/purge-test-data/purge.py``. Every one of them reached the real log only
|
||||
#: before 59f10f9 (2026-09-08 02:52 local) moved the suite into its own ``MESHTERM_HOME``;
|
||||
#: after that, only a ``--mock`` session run by hand can add more.
|
||||
FIXTURE = re.compile(
|
||||
r"nosuchkey|no_such_key|COM99|NOPE\d*|'Nobody'|'nope'|'xx'|\[/\]|AA:BB:CC:DD:EE:FF"
|
||||
r"|\bAlice\b|Yagi-Repeater|Local-Repeater|Observer-Bot"
|
||||
r"|MockCompanion|\bhub-1\b|\brelay-[12]\b|a1b2c3d4|b2c3d4e5|c3d4e5f6|d4e5f6a7"
|
||||
)
|
||||
|
||||
RUN_REF = re.compile(r"\brun (\d+)\b")
|
||||
RUN_START = re.compile(r"^run (\d+) start: tool=([\w-]+)")
|
||||
RUN_FAILED = re.compile(r"^run (\d+) failed: ")
|
||||
RUN_END = re.compile(r"^run (\d+) (?:ok|aborted|failed|lost)\b")
|
||||
#: ``tools/base.py``'s endings that the CLI goes on to report in a line of its own.
|
||||
RUN_REPORTED = re.compile(r"^run (\d+) (?:aborted|failed|lost)\b")
|
||||
#: ``cli.py``'s two reports of a run that ended badly: the one line it printed on stderr
|
||||
#: (``_reported``), and the traceback of one it could not explain.
|
||||
CLI_LINE = re.compile(r"^([\w-]+) failed: ")
|
||||
CLI_COPY = re.compile(r"^([\w-]+) raised an unhandled error$")
|
||||
|
||||
#: A DEBUG/INFO record that reports a failure. Most of the package's background loops log
|
||||
#: ``"...: %s", exc`` at DEBUG — the message survives, the stack does not — so these are
|
||||
#: the failures a WARNING-and-up read never sees.
|
||||
QUIET = re.compile(
|
||||
r"fail|raised|error|could not|couldn't|timed out|timeout|rejected|refused|lost|skipped"
|
||||
r"|invalid|unexpected",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Record:
|
||||
"""One log record and the lines hanging under it (a traceback, a wrapped message)."""
|
||||
|
||||
when: datetime
|
||||
level: str
|
||||
logger: str
|
||||
message: str
|
||||
extra: list[str] = field(default_factory=list)
|
||||
#: The run-ending record this one reports again — the CLI's line or traceback for it.
|
||||
twin_of: Record | None = None
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""The message and everything under it, for searching."""
|
||||
return "\n".join([self.message, *self.extra])
|
||||
|
||||
@property
|
||||
def rank(self) -> int:
|
||||
"""The level as a number, for ``--level``; an unknown level ranks lowest."""
|
||||
return LEVELS.get(self.level, 0)
|
||||
|
||||
def run_id(self) -> int | None:
|
||||
"""The run this record speaks about, if it names one."""
|
||||
m = RUN_REF.search(self.message)
|
||||
return int(m[1]) if m else None
|
||||
|
||||
def exception(self) -> str | None:
|
||||
"""The line a traceback ended on — the exception itself — or ``None``."""
|
||||
if not any(line.startswith("Traceback") for line in self.extra):
|
||||
return None
|
||||
tail = [
|
||||
line
|
||||
for line in self.extra
|
||||
if line.strip()
|
||||
and not line.startswith(
|
||||
(" ", "\t", "Traceback", "During handling", "The above exception")
|
||||
)
|
||||
]
|
||||
return tail[-1] if tail else None
|
||||
|
||||
def render(self, mark: str = "") -> str:
|
||||
"""The record as the file spelt it, behind an optional two-cell ``mark``."""
|
||||
stamp = self.when.strftime(TIME_FORMAT)
|
||||
head = f"{mark}{stamp} {self.level:<8} {self.logger}: {self.message}"
|
||||
return "\n".join([head, *self.extra])
|
||||
|
||||
|
||||
# --- loading --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def default_home() -> Path:
|
||||
"""``meshterm.core.config.default_config_dir``, without importing the package."""
|
||||
env = os.environ.get("MESHTERM_HOME", "").strip()
|
||||
return Path(env).expanduser() if env else Path.home() / ".meshterm"
|
||||
|
||||
|
||||
def log_files(home: Path, *, legacy: bool) -> list[Path]:
|
||||
"""Every log file in ``home``, oldest first: ``.jsonl``, ``.3``, ``.2``, ``.1``, live."""
|
||||
rotated = sorted(
|
||||
(p for p in home.glob("meshterm.log.*") if p.suffix[1:].isdigit()),
|
||||
key=lambda p: int(p.suffix[1:]),
|
||||
reverse=True,
|
||||
)
|
||||
files = [*rotated]
|
||||
if (home / "meshterm.log").exists():
|
||||
files.append(home / "meshterm.log")
|
||||
if legacy and (home / "meshterm.log.jsonl").exists():
|
||||
files.insert(0, home / "meshterm.log.jsonl")
|
||||
return files
|
||||
|
||||
|
||||
def load(files: list[Path]) -> tuple[list[Record], int]:
|
||||
"""Parse ``files`` in order. Returns the records and the count of orphan lines.
|
||||
|
||||
An orphan is a line before a file's first record — rotation cut a traceback away from
|
||||
the record it hangs under. It cannot be placed, so it is counted rather than guessed at.
|
||||
"""
|
||||
records: list[Record] = []
|
||||
orphans = 0
|
||||
for path in files:
|
||||
if path.suffix == ".jsonl":
|
||||
records.extend(_load_jsonl(path))
|
||||
continue
|
||||
with path.open(encoding="utf-8", errors="replace") as fh:
|
||||
here: list[Record] = []
|
||||
for raw in fh:
|
||||
line = raw.rstrip("\r\n")
|
||||
m = RECORD.match(line)
|
||||
if m:
|
||||
when = datetime.strptime(m[1], TIME_FORMAT)
|
||||
here.append(Record(when, m[2], m[3], m[4]))
|
||||
elif here:
|
||||
here[-1].extra.append(line)
|
||||
elif line.strip():
|
||||
orphans += 1
|
||||
records.extend(here)
|
||||
for r in records:
|
||||
while r.extra and not r.extra[-1].strip():
|
||||
r.extra.pop()
|
||||
_pair_cli_reports(records)
|
||||
return records, orphans
|
||||
|
||||
|
||||
def _load_jsonl(path: Path) -> list[Record]:
|
||||
"""The pre-2026-09-07 JSON Lines log: ``ts``/``level``/``logger``/``msg``, and ``exc``.
|
||||
|
||||
A ``msg`` may carry newlines (asyncio's ``Task was destroyed…`` puts the task on a
|
||||
second line), which the text log would have indented under the record; they go to
|
||||
``extra`` here for the same reason, so a group is keyed on the first line alone.
|
||||
"""
|
||||
out: list[Record] = []
|
||||
with path.open(encoding="utf-8", errors="replace") as fh:
|
||||
for raw in fh:
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
when = datetime.fromisoformat(obj["ts"]).replace(tzinfo=None)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
continue
|
||||
first, *more = str(obj.get("msg", "")).splitlines() or [""]
|
||||
extra = [f" {line}" for line in more]
|
||||
for key, value in obj.items():
|
||||
if key in ("ts", "level", "logger", "msg"):
|
||||
continue
|
||||
text = str(value)
|
||||
extra.extend(text.splitlines() if "Traceback" in text else [f" {key}: {text}"])
|
||||
level = str(obj.get("level", "?"))
|
||||
out.append(Record(when, level, str(obj.get("logger", "?")), first, extra))
|
||||
return out
|
||||
|
||||
|
||||
def _pair_cli_reports(records: list[Record]) -> None:
|
||||
"""Point each of the CLI's reports of a run at the run-ending record it repeats.
|
||||
|
||||
``tools/base.py`` ends a bad run with ``run N aborted``/``lost``/``failed``; ``cli.py``
|
||||
then reports the same run once more, naming the tool rather than the run — a
|
||||
``<tool> failed: …`` line, or a ``<tool> raised an unhandled error`` traceback. The
|
||||
report carries no run id, so it is matched to the latest bad ending of a run of that
|
||||
tool within a few seconds. That is what lets a test run's report be hidden with the
|
||||
run, and a traceback be counted once.
|
||||
"""
|
||||
tools: dict[int, str] = {}
|
||||
ended: dict[str, Record] = {}
|
||||
for r in records:
|
||||
if m := RUN_START.match(r.message):
|
||||
tools[int(m[1])] = m[2]
|
||||
elif m := RUN_REPORTED.match(r.message):
|
||||
tool = tools.get(int(m[1]))
|
||||
if tool is not None:
|
||||
ended[tool] = r
|
||||
elif m := CLI_LINE.match(r.message) or CLI_COPY.match(r.message):
|
||||
end = ended.get(m[1])
|
||||
if end is not None and r.when - end.when <= timedelta(seconds=5):
|
||||
r.twin_of = end
|
||||
|
||||
|
||||
def drop_fixtures(records: list[Record]) -> tuple[list[Record], int]:
|
||||
"""Remove test/simulator records: fingerprinted ones, and every record of their runs.
|
||||
|
||||
A run is a fixture when its start record carries a fingerprint, or when its id is lower
|
||||
than one this log has already started. One database hands out increasing ids, so a
|
||||
``run 1`` after ``run 3212`` was written by *another* database — the suite's temporary
|
||||
one, or a ``--db`` run pointed at a scratch file. Ids repeat across databases, so a
|
||||
record belongs to the latest start of its id, never to every run that ever had it.
|
||||
|
||||
A record naming a run whose start this log never saw (a background recorder opens its
|
||||
run without one; ``log_level`` above DEBUG drops them all) is foreign only when it is
|
||||
far below the highest id — a real background run can trail the foreground ids of its
|
||||
own session by a little, never by a thousand.
|
||||
"""
|
||||
highest = 0
|
||||
flagged: dict[int, bool] = {}
|
||||
dropped: set[int] = set()
|
||||
kept: list[Record] = []
|
||||
for r in records:
|
||||
rid = r.run_id()
|
||||
if m := RUN_START.match(r.message):
|
||||
rid = int(m[1])
|
||||
foreign = bool(FIXTURE.search(r.message)) or rid < highest
|
||||
flagged[rid] = foreign
|
||||
if not foreign:
|
||||
highest = max(highest, rid)
|
||||
if rid is not None and rid in flagged:
|
||||
by_run = flagged[rid]
|
||||
else:
|
||||
by_run = rid is not None and highest - rid > 1000
|
||||
if by_run or FIXTURE.search(r.text) or (r.twin_of is not None and id(r.twin_of) in dropped):
|
||||
dropped.add(id(r))
|
||||
else:
|
||||
kept.append(r)
|
||||
return kept, len(records) - len(kept)
|
||||
|
||||
|
||||
# --- time -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_when(text: str, now: datetime) -> datetime:
|
||||
"""``30m`` / ``2h`` / ``3d`` ago, ``03:54`` today, or ``2026-09-11[ 03:54[:10]]``."""
|
||||
text = text.strip()
|
||||
if m := re.fullmatch(r"(\d+(?:\.\d+)?)([mhd])", text):
|
||||
unit = {"m": "minutes", "h": "hours", "d": "days"}[m[2]]
|
||||
return now - timedelta(**{unit: float(m[1])})
|
||||
if m := re.fullmatch(r"(\d\d?):(\d\d)(?::(\d\d))?", text):
|
||||
return datetime.combine(now.date(), time(int(m[1]), int(m[2]), int(m[3] or 0)))
|
||||
return datetime.fromisoformat(text)
|
||||
|
||||
|
||||
def utc_to_local(stamp: str | None) -> datetime | None:
|
||||
"""A ``runs`` timestamp (UTC, RFC 3339) in this machine's local time, naive."""
|
||||
if not stamp:
|
||||
return None
|
||||
when = datetime.fromisoformat(stamp)
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=timezone.utc)
|
||||
return when.astimezone().replace(tzinfo=None)
|
||||
|
||||
|
||||
def short(when: datetime | None) -> str:
|
||||
"""A time narrow enough to sit in a grouped row: month-day and clock, no year."""
|
||||
return when.strftime("%m-%d %H:%M:%S") if when else "-"
|
||||
|
||||
|
||||
# --- grouping -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def shape(message: str) -> str:
|
||||
"""A message's first line with its variable parts blanked, so repeats group together."""
|
||||
s = (message.splitlines() or [""])[0]
|
||||
s = re.sub(r"\b[0-9a-fA-F]{6,}\b", "<hex>", s)
|
||||
s = re.sub(r"\d+(?:\.\d+)?", "#", s)
|
||||
return s if len(s) <= 150 else s[:149] + "…"
|
||||
|
||||
|
||||
class Groups:
|
||||
"""Count, first time and last time per key."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Start empty."""
|
||||
self.items: dict[object, list] = {}
|
||||
|
||||
def add(self, key: object, r: Record) -> None:
|
||||
"""Count ``r`` under ``key`` and stretch the key's span to cover it."""
|
||||
g = self.items.setdefault(key, [0, r.when, r.when])
|
||||
g[0] += 1
|
||||
g[2] = r.when
|
||||
|
||||
def rows(self, top: int) -> list[tuple[object, list]]:
|
||||
"""The ``top`` biggest groups, largest first, earliest first among equals."""
|
||||
ranked = sorted(self.items.items(), key=lambda kv: (-kv[1][0], kv[1][1]))
|
||||
return ranked[:top]
|
||||
|
||||
|
||||
def blocks(records: list[Record], gap: timedelta) -> list[list[Record]]:
|
||||
"""Split into runs of activity separated by at least ``gap`` of silence."""
|
||||
out: list[list[Record]] = []
|
||||
for r in records:
|
||||
if not out or r.when - out[-1][-1].when >= gap:
|
||||
out.append([])
|
||||
out[-1].append(r)
|
||||
return out
|
||||
|
||||
|
||||
# --- the runs table -------------------------------------------------------------------
|
||||
|
||||
|
||||
def open_db(path: Path | None) -> sqlite3.Connection | None:
|
||||
"""Open the database read-only, or ``None`` when there is no usable ``runs`` table."""
|
||||
if path is None or not path.exists():
|
||||
return None
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{path.resolve().as_posix()}?mode=ro", uri=True)
|
||||
con.execute("SELECT 1 FROM runs LIMIT 1")
|
||||
return con
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
|
||||
|
||||
def _error_of(summary_json: str | None) -> str:
|
||||
"""The ``error`` a failed run's summary recorded."""
|
||||
try:
|
||||
return str(json.loads(summary_json or "{}").get("error", ""))
|
||||
except (ValueError, AttributeError):
|
||||
return summary_json or ""
|
||||
|
||||
|
||||
def runs_section(
|
||||
con: sqlite3.Connection,
|
||||
since: datetime | None,
|
||||
until: datetime | None,
|
||||
fixtures: bool,
|
||||
top: int,
|
||||
) -> None:
|
||||
"""Print the window's runs: status counts, the latest errors, and runs never finished."""
|
||||
rows = []
|
||||
for rid, tool, status, args_json, summary_json, started, finished in con.execute(
|
||||
"SELECT id, tool, status, args_json, summary_json, started_at, finished_at FROM runs"
|
||||
):
|
||||
start = utc_to_local(started)
|
||||
if start is None or (since and start < since) or (until and start > until):
|
||||
continue
|
||||
if not fixtures and FIXTURE.search(f"{args_json} {summary_json}"):
|
||||
continue
|
||||
rows.append((rid, tool, status, start, utc_to_local(finished), args_json, summary_json))
|
||||
print("\n== Runs table (meshterm.db; UTC shown as this machine's local time)")
|
||||
if not rows:
|
||||
print(" no runs in the window")
|
||||
return
|
||||
counts = Counter(r[2] for r in rows)
|
||||
print(" " + " · ".join(f"{k} {v}" for k, v in counts.most_common()))
|
||||
errors = [r for r in rows if r[2] == "error"][-top:]
|
||||
if errors:
|
||||
print(" errors, latest last — a raised error the CLI reported cleanly has no traceback:")
|
||||
for rid, tool, _, start, _, _, summary in errors:
|
||||
print(f" #{rid:<6} {short(start)} {tool:<14} {_error_of(summary)[:100]}")
|
||||
open_rows = [r for r in rows if r[2] == "running"][-top:]
|
||||
if open_rows:
|
||||
print(" never finished — the process died, was killed, or is still running now:")
|
||||
for rid, tool, _, start, _, args, _ in open_rows:
|
||||
print(f" #{rid:<6} {short(start)} {tool:<14} {args[:80]}")
|
||||
|
||||
|
||||
# --- modes ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_summary(
|
||||
records: list[Record],
|
||||
args: argparse.Namespace,
|
||||
window: tuple[datetime | None, datetime | None],
|
||||
hidden: int,
|
||||
orphans: int,
|
||||
files: list[Path],
|
||||
con: sqlite3.Connection | None,
|
||||
) -> None:
|
||||
"""Print the grouped picture: activity, tracebacks, warnings, quiet failures, runs."""
|
||||
since, until = window
|
||||
print("files " + ", ".join(f"{p.name} ({p.stat().st_size // 1024} KB)" for p in files))
|
||||
if records:
|
||||
print(f"span {records[0].when} → {records[-1].when}")
|
||||
if since or until:
|
||||
print(f"window {since or '…'} → {until or '…'}")
|
||||
note = (
|
||||
f" · {hidden} test, mock and other-database records hidden (--fixtures)" if hidden else ""
|
||||
)
|
||||
if orphans:
|
||||
note += f" · {orphans} orphan lines (rotation cut)"
|
||||
print(f"records {len(records)}{note}")
|
||||
levels = Counter(r.level for r in records)
|
||||
print("levels " + " · ".join(f"{k} {levels[k]}" for k in LEVELS if levels[k]))
|
||||
if levels and not levels["DEBUG"] and not levels["INFO"]:
|
||||
print(
|
||||
" no DEBUG/INFO at all: log_level is probably WARNING — silence proves nothing"
|
||||
)
|
||||
if not records:
|
||||
return
|
||||
|
||||
print(f"\n== Activity (a new block after {args.gap:g} min of silence; latest {args.top})")
|
||||
for chunk in blocks(records, timedelta(minutes=args.gap))[-args.top :]:
|
||||
lv = Counter(r.level for r in chunk)
|
||||
errors = lv["ERROR"] + lv["CRITICAL"]
|
||||
bad = (f" ⚠ {lv['WARNING']}" if lv["WARNING"] else "") + (
|
||||
f" ✗ {errors}" if errors else ""
|
||||
)
|
||||
print(f" {short(chunk[0].when)} → {short(chunk[-1].when)} {len(chunk):>5} records{bad}")
|
||||
|
||||
problems, tracebacks, quiet = Groups(), Groups(), Groups()
|
||||
copies: Counter = Counter()
|
||||
for r in records:
|
||||
exc = r.exception()
|
||||
if r.twin_of is not None and exc and exc == r.twin_of.exception():
|
||||
copies[(exc, shape(r.twin_of.message))] += 1
|
||||
continue
|
||||
if exc:
|
||||
tracebacks.add((exc, shape(r.message)), r)
|
||||
elif r.rank >= LEVELS["WARNING"]:
|
||||
problems.add((r.level, r.logger, shape(r.message)), r)
|
||||
elif QUIET.search(r.message) and not RUN_START.match(r.message):
|
||||
quiet.add((r.logger, shape(r.message)), r)
|
||||
|
||||
print("\n== Tracebacks (grouped by the exception they ended on)")
|
||||
if not tracebacks.items:
|
||||
print(" none")
|
||||
for (exc, under), (n, first, last) in tracebacks.rows(args.top):
|
||||
folded = copies[(exc, under)]
|
||||
tail = f" (+{folded} CLI copies folded)" if folded else ""
|
||||
print(f" {n:>4}× {short(first)} → {short(last)} {exc[:120]}")
|
||||
print(f" under: {under}{tail}")
|
||||
|
||||
print("\n== Warnings and errors without a traceback")
|
||||
if not problems.items:
|
||||
print(" none")
|
||||
for (level, logger, msg), (n, first, last) in problems.rows(args.top):
|
||||
print(f" {n:>4}× {short(first)} → {short(last)} {level:<7} {logger}: {msg}")
|
||||
|
||||
print("\n== Quiet failures (DEBUG/INFO that report a failure — message kept, stack lost)")
|
||||
if not quiet.items:
|
||||
print(" none (or log_level is above DEBUG)")
|
||||
for (logger, msg), (n, first, last) in quiet.rows(args.top):
|
||||
print(f" {n:>4}× {short(first)} → {short(last)} {logger}: {msg}")
|
||||
|
||||
if con is not None:
|
||||
runs_section(con, since, until, args.fixtures, min(args.top, 10))
|
||||
|
||||
|
||||
def cmd_show(records: list[Record], args: argparse.Namespace) -> None:
|
||||
"""Print the filtered records verbatim — the last ``--limit`` of them."""
|
||||
picked = records[-args.limit :] if args.limit else records
|
||||
if len(picked) < len(records):
|
||||
print(f"… {len(records) - len(picked)} earlier records not shown (--limit)")
|
||||
for r in picked:
|
||||
print(r.render())
|
||||
|
||||
|
||||
def cmd_around(records: list[Record], centre: datetime, args: argparse.Namespace) -> None:
|
||||
"""Print every record within ``--minutes`` of ``centre``; the nearest 30s are marked."""
|
||||
span = timedelta(minutes=args.minutes)
|
||||
print(f"-- {centre - span} → {centre + span}")
|
||||
for r in records:
|
||||
if centre - span <= r.when <= centre + span:
|
||||
print(r.render("» " if abs(r.when - centre) < timedelta(seconds=30) else " "))
|
||||
|
||||
|
||||
def cmd_run(
|
||||
all_records: list[Record],
|
||||
records: list[Record],
|
||||
run_id: int,
|
||||
args: argparse.Namespace,
|
||||
con: sqlite3.Connection | None,
|
||||
) -> None:
|
||||
"""Print one run: its ``runs`` row, then every record from its start to its end.
|
||||
|
||||
The run's own records are marked; the rest are what the process was doing beside it.
|
||||
A run with no end record gets ``--minutes`` after its start instead — the run died or
|
||||
is still going, and what came next is the evidence either way.
|
||||
"""
|
||||
if con is not None:
|
||||
row = con.execute(
|
||||
"SELECT tool, profile, status, args_json, summary_json, started_at, finished_at"
|
||||
" FROM runs WHERE id = ?",
|
||||
(run_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
tool, profile, status, args_json, summary_json, started, finished = row
|
||||
start, end = utc_to_local(started), utc_to_local(finished)
|
||||
took = f" ({(end - start).total_seconds():.1f}s)" if start and end else ""
|
||||
print(f"run #{run_id} {tool} status={status} profile={profile or '-'}")
|
||||
print(f" started {start} finished {end or 'never'}{took} (local)")
|
||||
print(f" args {args_json}")
|
||||
print(f" summary {(summary_json or '-')[:300]}")
|
||||
else:
|
||||
print(f"run #{run_id}: no row in the runs table")
|
||||
own = [r for r in all_records if r.run_id() == run_id]
|
||||
if not own:
|
||||
print(f"no log record names run {run_id} — its tool logs nothing at this log_level")
|
||||
return
|
||||
start = own[0].when
|
||||
ended = any(RUN_END.match(r.message) for r in own)
|
||||
end = own[-1].when if ended else max(own[-1].when, start + timedelta(minutes=args.minutes))
|
||||
if not ended:
|
||||
print(f" no end record: the {args.minutes:g} min after its start follow")
|
||||
slack = timedelta(seconds=2)
|
||||
window = [r for r in records if start - slack <= r.when <= end + slack]
|
||||
if args.limit and len(window) > args.limit:
|
||||
print(f" {len(window)} records in the window; the first {args.limit} (--limit)")
|
||||
window = window[: args.limit]
|
||||
ids = {id(r) for r in own}
|
||||
for r in window:
|
||||
print(r.render("» " if id(r) in ids else " "))
|
||||
|
||||
|
||||
# --- entry ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Parse the arguments, load and filter the log, and run the chosen mode."""
|
||||
ap = argparse.ArgumentParser(description="Read MeshTerm's log: grouped, windowed, joined.")
|
||||
ap.add_argument(
|
||||
"mode", nargs="?", default="summary", choices=("summary", "show", "around", "run")
|
||||
)
|
||||
ap.add_argument("target", nargs="?", help="a time for `around`, a run id for `run`")
|
||||
ap.add_argument("--home", type=Path, help="config dir (default $MESHTERM_HOME or ~/.meshterm)")
|
||||
ap.add_argument(
|
||||
"--log", type=Path, action="append", help="read this file; repeatable, oldest first"
|
||||
)
|
||||
ap.add_argument("--db", type=Path, help="runs table source (default <home>/meshterm.db)")
|
||||
ap.add_argument("--no-db", action="store_true", help="skip the runs table")
|
||||
ap.add_argument(
|
||||
"--legacy", action="store_true", help="include the pre-09-07 meshterm.log.jsonl"
|
||||
)
|
||||
ap.add_argument("--since", help="30m | 2h | 3d | 03:54 | 2026-09-11 03:54 (local)")
|
||||
ap.add_argument("--until", help="same forms as --since")
|
||||
ap.add_argument("--level", help="minimum level: DEBUG INFO WARNING ERROR")
|
||||
ap.add_argument("--grep", help="regex over message and traceback, case-insensitive")
|
||||
ap.add_argument("--fixtures", action="store_true", help="keep test-suite and --mock records")
|
||||
ap.add_argument("--minutes", type=float, default=5, help="`around` half-width; open-run tail")
|
||||
ap.add_argument("--gap", type=float, default=20, help="minutes of silence that start a block")
|
||||
ap.add_argument("--top", type=int, default=25, help="rows per summary section")
|
||||
ap.add_argument("--limit", type=int, default=200, help="most records `show`/`run` print")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
home = (args.home or default_home()).expanduser()
|
||||
files = [f for f in (args.log or log_files(home, legacy=args.legacy)) if f.exists()]
|
||||
if not files:
|
||||
print(f"no log files found (looked in {home})", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
now = datetime.now()
|
||||
since = parse_when(args.since, now) if args.since else None
|
||||
until = parse_when(args.until, now) if args.until else None
|
||||
|
||||
all_records, orphans = load(files)
|
||||
records, hidden = (all_records, 0) if args.fixtures else drop_fixtures(all_records)
|
||||
minimum = LEVELS.get((args.level or "").upper(), 0)
|
||||
grep = re.compile(args.grep, re.IGNORECASE) if args.grep else None
|
||||
records = [
|
||||
r
|
||||
for r in records
|
||||
if (not since or r.when >= since)
|
||||
and (not until or r.when <= until)
|
||||
and r.rank >= minimum
|
||||
and (not grep or grep.search(r.text))
|
||||
]
|
||||
|
||||
con = open_db(None if args.no_db else (args.db or home / "meshterm.db"))
|
||||
|
||||
if args.mode == "summary":
|
||||
cmd_summary(records, args, (since, until), hidden, orphans, files, con)
|
||||
elif args.mode == "show":
|
||||
cmd_show(records, args)
|
||||
elif args.mode == "around":
|
||||
if not args.target:
|
||||
ap.error("around needs a time")
|
||||
cmd_around(records, parse_when(args.target, now), args)
|
||||
else:
|
||||
if not (args.target or "").isdigit():
|
||||
ap.error("run needs a run id")
|
||||
cmd_run(all_records, records, int(args.target), args, con)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
name: release
|
||||
description: Cut a MeshTerm release — pick the version, write the changelog entry from the commits since the last tag, bump `__version__`, commit, tag, and push so the GitHub release builds. Use when JP says to release, cut a release, ship a version, tag a version, or bump the version, and when asked what would go into the next release.
|
||||
---
|
||||
|
||||
# Cutting a MeshTerm release
|
||||
|
||||
A release is a **rename and a tag**, not a week of git archaeology — that is the whole
|
||||
point of keeping an `[Unreleased]` section. The one part that takes real work is writing
|
||||
the entry, because the changelog is prose someone reads, not a list of commit subjects.
|
||||
|
||||
Pushing the tag fires [github-release.yml](../../../.github/workflows/github-release.yml),
|
||||
which calls `installers.yml`, builds all five platforms, attaches them with a combined
|
||||
`SHA256SUMS`, and takes the release notes **from that version's changelog section**. So the
|
||||
changelog is not documentation of the release; it *is* the release notes. An empty section
|
||||
publishes "No changelog entry for X.Y.Z."
|
||||
|
||||
## Two things this skill never does
|
||||
|
||||
- **Never runs `release.yml`.** That is the **PyPI** publish, and it is deliberately hard
|
||||
to fire: no automatic trigger, a typed version that must match the build, and a `pypi`
|
||||
environment gate. PyPI has no rename and no true delete. It runs only when JP says so in
|
||||
that moment, from the Actions tab, by hand. The name `meshterm` is on PyPI's prohibited
|
||||
list anyway — the project publishes as `mesh-term`.
|
||||
- **Never picks the version silently when it isn't a patch.** Default is a **patch** bump
|
||||
(`0.3.0` → `0.3.1`). Anything else — minor, major — is JP's call, and he says which part.
|
||||
Bumping one part zeroes the ones after it. While the major is `0`, a **minor** bump is
|
||||
allowed to change behaviour, not just add to it.
|
||||
|
||||
## 1. Preflight
|
||||
|
||||
```
|
||||
python .claude/skills/release/release_check.py
|
||||
```
|
||||
|
||||
It prints the current version, the last tag, every commit since it, the state of the
|
||||
`[Unreleased]` section, any changelog link refs that are missing, and whether the tree is
|
||||
clean and in sync with `origin/main`. Read it before touching anything — it is also the
|
||||
answer to "what would go into the next release?" on its own.
|
||||
|
||||
Then the gates `CONTRIBUTING.md` asks contributors to pass:
|
||||
|
||||
```
|
||||
python -m pytest -q && ruff check . && ruff format --check .
|
||||
```
|
||||
|
||||
A release does not go out on a red suite. If something fails, stop and say so.
|
||||
|
||||
## 2. Decide the version
|
||||
|
||||
Patch unless JP says otherwise. Say the number you arrived at before you write it anywhere,
|
||||
so a wrong assumption costs a sentence instead of a tag.
|
||||
|
||||
## 3. Write the entry
|
||||
|
||||
This is the work. Read the commits since the last tag (`git log --stat v<last>..HEAD`) and
|
||||
write **what changed for someone using MeshTerm**, in the changelog's existing voice:
|
||||
|
||||
- **Categories are Keep a Changelog's** — `### Added`, `### Changed`, `### Fixed`,
|
||||
`### Removed` — in that order, and only the ones that have entries.
|
||||
- **A bullet opens with a bolded sentence stating the user-visible fact**, then a paragraph
|
||||
saying what was actually wrong and what it now does. Read the `0.2.6` and `0.2.8` entries
|
||||
for the register: the failure is described from the reader's side first, the mechanism
|
||||
second, and the lesson last if there is one.
|
||||
- **Small entries are one line and no bold.** Not every commit earns a paragraph, and
|
||||
several commits often collapse into one bullet — group by what the reader experienced,
|
||||
not by what the diff touched.
|
||||
- **A commit that changes nothing a user can see does not appear.** Refactors that move
|
||||
code behind an unchanged surface, test-only commits, skill and tooling commits: leave
|
||||
them out. The changelog is not a shadow git log.
|
||||
- Wrap at the file's width (~95 columns) and match its em-dash-and-clause rhythm.
|
||||
|
||||
Insert the section directly under the HTML comment at the top of `CHANGELOG.md`:
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
## [X.Y.Z] — YYYY-MM-DD
|
||||
```
|
||||
|
||||
A fresh empty `[Unreleased]` heading goes back in above it — that is what makes the *next*
|
||||
release a rename. Then add the link refs at the bottom, repointing `[Unreleased]` at the
|
||||
new tag:
|
||||
|
||||
```
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/vX.Y.Z...HEAD
|
||||
[X.Y.Z]: https://github.com/jpmartineau/MeshTerm/releases/tag/vX.Y.Z
|
||||
```
|
||||
|
||||
The date is the real date — check it rather than copying the one above.
|
||||
|
||||
## 4. Bump the version
|
||||
|
||||
`__version__` in [meshterm/__init__.py](../../../meshterm/__init__.py) is **the only place a
|
||||
version is written**. `[project] version` in `pyproject.toml` is `dynamic` and hatchling
|
||||
reads the attribute, so the number in a built wheel cannot drift from the number in the
|
||||
source. Do not add a second one.
|
||||
|
||||
### 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
|
||||
|
||||
```
|
||||
git commit -am "MeshTerm X.Y.Z"
|
||||
git tag -a vX.Y.Z -m "MeshTerm X.Y.Z"
|
||||
```
|
||||
|
||||
Commit on `main` — never a topic branch. Then **confirm with JP before pushing**, because
|
||||
the push is the act that builds and publishes the release:
|
||||
|
||||
```
|
||||
git push origin main && git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
This is the one workflow where pushing is expected. It is still asked for, once, in that
|
||||
moment — the standing rule against unasked pushes is not suspended by the skill, it is
|
||||
satisfied by the answer.
|
||||
|
||||
## 6. Watch it land
|
||||
|
||||
```
|
||||
gh run watch --exit-status
|
||||
gh release view vX.Y.Z
|
||||
```
|
||||
|
||||
Five platform builds plus the release job, so it takes a few minutes. **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
|
||||
|
||||
The first public release collapses everything below it into a single entry reading "first
|
||||
public release" with the headline features under it. Every version under 0.9.0 was released
|
||||
nowhere, and its entries are notes to ourselves about getting ready — nobody arriving on
|
||||
launch day wants a changelog of the fortnight before it. **Keep the dates and the tags;
|
||||
replace the prose.** The same note is an HTML comment at the top of `CHANGELOG.md`, where
|
||||
whoever cuts it will be looking.
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Preflight for a MeshTerm release, and the answer to "what's in the next one?".
|
||||
|
||||
Reports the six things worth knowing before a version number is written down, and says
|
||||
nothing else: the current version and the tag it belongs to, the commits since, whether
|
||||
there is an `[Unreleased]` section to rename, which changelog link refs are missing, and
|
||||
whether the working tree is clean and level with `origin/main`.
|
||||
|
||||
It changes nothing. Every finding is a line a person acts on, not a gate that blocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
INIT = ROOT / "meshterm" / "__init__.py"
|
||||
CHANGELOG = ROOT / "CHANGELOG.md"
|
||||
|
||||
OK, WARN, INFO = "ok ", "warn", " "
|
||||
|
||||
|
||||
def git(*args: str) -> str:
|
||||
"""Run a git command in the repo, returning stripped stdout ('' on failure)."""
|
||||
done = subprocess.run(["git", "-C", str(ROOT), *args], capture_output=True, text=True)
|
||||
return done.stdout.strip() if done.returncode == 0 else ""
|
||||
|
||||
|
||||
def say(mark: str, line: str) -> None:
|
||||
"""Print one finding, indented under its heading; a blank mark is a plain note."""
|
||||
print(f" {mark} {line}" if mark.strip() else f" {line}")
|
||||
|
||||
|
||||
def current_version() -> str:
|
||||
"""The version `meshterm/__init__.py` declares - the only place one is written."""
|
||||
found = re.search(r'^__version__\s*=\s*"([^"]+)"', INIT.read_text(encoding="utf-8"), re.M)
|
||||
return found.group(1) if found else "?"
|
||||
|
||||
|
||||
def latest_tag() -> str:
|
||||
"""The highest v-tag by version order, not by date — a re-tag must not reorder this."""
|
||||
tags = [t for t in git("tag", "-l", "v*").splitlines() if t]
|
||||
if not tags:
|
||||
return ""
|
||||
return sorted(tags, key=lambda t: [int(p) for p in re.findall(r"\d+", t)])[-1]
|
||||
|
||||
|
||||
def next_patch(version: str) -> str:
|
||||
"""The default bump. Any other part is JP's call, and he says which."""
|
||||
parts = version.split(".")
|
||||
if len(parts) != 3 or not parts[2].isdigit():
|
||||
return "?"
|
||||
return f"{parts[0]}.{parts[1]}.{int(parts[2]) + 1}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Print the report. Nothing here blocks; every line is for a person to act on."""
|
||||
version, tag = current_version(), latest_tag()
|
||||
text = CHANGELOG.read_text(encoding="utf-8")
|
||||
|
||||
print(f"\nMeshTerm {version} | last tag {tag or '(none)'}\n")
|
||||
|
||||
print("Version")
|
||||
if tag and tag != f"v{version}":
|
||||
say(WARN, f"__version__ is {version} but the last tag is {tag} - already bumped?")
|
||||
else:
|
||||
say(OK, f"__version__ {version} matches the last tag")
|
||||
say(INFO, f"next patch would be {next_patch(version)} (JP names anything else)")
|
||||
|
||||
print("\nCommits since the tag")
|
||||
log = git("log", "--oneline", f"{tag}..HEAD") if tag else git("log", "--oneline")
|
||||
lines = [ln for ln in log.splitlines() if ln]
|
||||
if not lines:
|
||||
say(WARN, "none - there is nothing to release")
|
||||
else:
|
||||
say(INFO, f"{len(lines)} commit(s):")
|
||||
for ln in lines:
|
||||
print(f" {ln}")
|
||||
|
||||
print("\nChangelog")
|
||||
if re.search(r"^## \[Unreleased\]", text, re.M):
|
||||
body = text.split("## [Unreleased]", 1)[1].split("\n## [", 1)[0].strip()
|
||||
if body:
|
||||
say(OK, "an [Unreleased] section with entries - rename it to the new version")
|
||||
else:
|
||||
say(WARN, "an [Unreleased] heading with nothing under it - the entry is unwritten")
|
||||
else:
|
||||
say(WARN, "no [Unreleased] heading - write the section from the commits above")
|
||||
|
||||
versions = set(re.findall(r"^## \[(\d[^\]]*)\]", text, re.M))
|
||||
refs = set(re.findall(r"^\[(\d[^\]]*)\]:", text, re.M))
|
||||
missing = sorted(versions - refs, key=lambda v: [int(p) for p in re.findall(r"\d+", v)])
|
||||
if missing:
|
||||
# Cosmetic only: the heading renders as literal brackets instead of a link. The
|
||||
# release notes come from the section body, so a published release is unaffected.
|
||||
say(WARN, f"link refs missing at the bottom for: {', '.join(missing)}")
|
||||
say(INFO, "(cosmetic - the heading loses its link; release notes are unaffected)")
|
||||
else:
|
||||
say(OK, "every version heading has its link ref")
|
||||
|
||||
print("\nTree")
|
||||
dirty = git("status", "--porcelain")
|
||||
say(
|
||||
OK if not dirty else WARN,
|
||||
"clean" if not dirty else f"{len(dirty.splitlines())} uncommitted change(s)",
|
||||
)
|
||||
branch = git("rev-parse", "--abbrev-ref", "HEAD")
|
||||
say(OK if branch == "main" else WARN, f"on {branch}")
|
||||
git("fetch", "--quiet", "origin")
|
||||
counts = git("rev-list", "--left-right", "--count", "origin/main...HEAD")
|
||||
if counts:
|
||||
behind, ahead = counts.split()
|
||||
say(OK if behind == "0" else WARN, f"{ahead} ahead / {behind} behind origin/main")
|
||||
|
||||
print("\nRun the gates before tagging:")
|
||||
print(" python -m pytest -q && ruff check . && ruff format --check .\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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:
|
||||
#
|
||||
@@ -74,17 +74,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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
+488
-1
@@ -17,6 +17,485 @@ allowed to change behaviour, not just add to it.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
- **Devices you never want to connect to can be hidden from the splash.** A machine with a
|
||||
debug probe, a programmer and a USB adapter soldered into it listed all three on every
|
||||
start, in front of the one radio you came for. `h` drops the highlighted device and
|
||||
remembers it, `⇧H` brings every hidden one back, and both keys are named in the footer
|
||||
only where they would act.
|
||||
|
||||
Hiding is a listing choice and nothing else — a hidden device is still remembered, still
|
||||
resolvable by `--port`, and shows itself again the moment it is connected to. The
|
||||
highlight stays where the row was rather than jumping back to the remembered default, so
|
||||
a run of adapters clears with a run of presses.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The chat picker's conversation lane is measured against the names the list actually
|
||||
holds**, instead of a flat 22 cells. Every cell past the longest name was padding in
|
||||
front of a short one, taken straight out of the last-message preview — the only run on
|
||||
the row with something to say, and on the PicoCalc's 53 columns it left the message 12
|
||||
cells. The preview keeps what it saves: 31 cells to 37 on a 72-column terminal, 12 to 18
|
||||
on the console. What the cap costs is the tail of a long name, so `←→` now slides the
|
||||
message under the pinned columns to read one past its own edge.
|
||||
|
||||
- **Channels shows the wait instead of announcing the result.** Every action banked a
|
||||
"created X" note that surfaced only once the manager closed — by which time the refreshed
|
||||
list had already shown the result — and the notes accumulated: at three changes in one
|
||||
visit the outcome outgrew the acknowledgement popup and came back as the full-frame
|
||||
result window instead, so the same visit reported itself two different ways depending on
|
||||
how much had been done in it. Every device action now reports while it runs, under a
|
||||
modal busy card, and the count stays in the run log.
|
||||
|
||||
- Renaming a channel no longer closes the page it renamed. The channel is still there and
|
||||
that is still its page, so the new name and key are read back into the title and the
|
||||
rows, and the reader stays put. Only clearing it closes it, which is the one case where
|
||||
what the page was about is gone.
|
||||
|
||||
- `Esc` on the device splash says **bye** rather than quit — the splash is the door, and
|
||||
nothing has been started there to quit out of.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **macOS and Linux were drawing the whole app in 256 colours.** prompt_toolkit picks the
|
||||
colour depth per output class, and its two classes disagree: the Windows one returns
|
||||
truecolor outright, the VT100 one returns 8-bit for every `TERM` but `linux`. Nothing
|
||||
ever passed a depth, so the same build drawing the same theme was 24-bit on Windows and
|
||||
quantized to the 216-colour cube everywhere else — silently, which is why it went
|
||||
unnoticed. The app looked fine, just flatter.
|
||||
|
||||
It costs exactly what this palette is made of. The seven heat steps and the per-node hue
|
||||
wheel are close pastels chosen to be told apart; snapped to the cube, neighbours collide
|
||||
and an identity stops being distinguishable by hue. `COLORTERM` is not consulted by
|
||||
prompt_toolkit at all, so a terminal announcing truecolor the conventional way was still
|
||||
handed 256.
|
||||
|
||||
The resolver only ever raises the verdict. A terminal that cannot be shown to do better
|
||||
keeps precisely the depth it had, because guessing 24-bit at a terminal without it costs
|
||||
not a duller palette but the colour entirely. A 16-slot console is never promoted — the
|
||||
theme addresses its palette by index, and RGB has nowhere to land there.
|
||||
|
||||
- **Adding a channel could write straight over one that was already there.** The free-slot
|
||||
probe stops for two very different reasons and returned the same short list either way:
|
||||
off the end of the configured slots it is the truth, but a read that simply failed left
|
||||
whatever it had — and that was cached for the session, an empty one reading exactly like
|
||||
a device with no channels, a truncated one making the next free slot look free with a
|
||||
channel sitting in it. The probe now says whether it finished, an unfinished one is
|
||||
answered but not kept, and every add confirms the slot is empty before anything is
|
||||
written to it.
|
||||
|
||||
- Keys tapped during a slow channel write were landing in the filter of the list
|
||||
underneath, which then came back showing nothing. The busy card is modal, so the press
|
||||
dies on the card instead.
|
||||
|
||||
- **Channels at 53 columns.** The column header was a hand-built string with no idea of the
|
||||
render width: it ran to 54 cells and wrapped, costing a content row out of twenty-six and
|
||||
drawing the pinned landmark twice. The command rows hand-padded their icons, so the
|
||||
one-cell marks started their labels a column left of the two-cell ones. The empty-message
|
||||
count was `·`, which in that same row is also the status separator and what the console
|
||||
folds 🔕 to — a muted channel with no messages drew it twice, two cells apart, meaning
|
||||
different things; it is `○` now, the app's empty mark. And the detail title was a 42-cell
|
||||
parenthesised blob that filled the PicoCalc's whole title bar, leaving nowhere to say Esc
|
||||
leaves.
|
||||
|
||||
- Every channel edit paid up to 64 device round-trips before the screen could redraw. The
|
||||
chat service's slot map was walking the radio itself, unbounded, skipping empty slots all
|
||||
the way to the 64-slot cap on firmware that never rejects an index — on top of the
|
||||
manager's own re-read. It builds from the probe that was being made anyway.
|
||||
|
||||
- A channel write that landed and then raised never invalidated what it had made stale, so
|
||||
a slot list and a chat slot map outlived a layout that had already moved. A reorder that
|
||||
drops partway now says so at once, since the reader is about to be looking straight at
|
||||
the half-applied layout, and the standard Public channel can no longer be added twice by
|
||||
a second press already on its way.
|
||||
|
||||
- Two of the Trophy case's seven discipline marks started their titles a column left of
|
||||
their siblings: 🛣 and 🕸 sit outside Emoji_Presentation, so Rich and wcwidth both measure
|
||||
them at one cell where the other five measure two. The marks now pad out to the widest of
|
||||
the seven, measured against what the platform will actually draw rather than written
|
||||
down.
|
||||
|
||||
- A route that has finished no longer ends on the chevron that means "the path runs on past
|
||||
the edge" — it ends square, and the cell that frees up goes back to the route.
|
||||
|
||||
- **A fully charged pack redrew the activity sparkline twice a minute.** The charger cuts
|
||||
out at full, the pack settles back to 99, the charger restarts, and the companion reports
|
||||
that flip every poll for as long as the thing is plugged in. The gauge is pinned to the
|
||||
header's right edge and the pulse takes whatever is left, so the fourth cell `100%`
|
||||
needed came off the sparkline and redrew the whole activity history at a new scale, on a
|
||||
device sitting still. A full pack reads `100` now, with no sign: three cells, exactly as
|
||||
the `99%` it keeps flipping back to, and the one reading whose `%` can be inferred.
|
||||
|
||||
- On the PicoCalc every channel row in the chat picker sat a cell to the left of every
|
||||
direct row, with the header over neither — the marker lane padded to a two-cell channel
|
||||
glyph that the console font draws in one.
|
||||
|
||||
- The device splash's own header wrapped at 53 columns, and its DEVICE lane sized itself to
|
||||
the longest name — which for a USB adapter is a forty-character product string, pushing
|
||||
HARDWARE off the edge entirely. The name lane yields now, and `←→` reads the rest of a
|
||||
long model string with the lanes in front of it pinned.
|
||||
|
||||
## [0.3.0] — 2026-09-08
|
||||
|
||||
The command line was rebuilt around the fact that it has **two readers**, and that trying
|
||||
to serve both with one stream had been making it worse at each.
|
||||
|
||||
### Added
|
||||
|
||||
- **`--json`, on every command.** It used to be honoured by two of twenty and silently
|
||||
ignored by the rest, so `meshterm --json contacts | jq` failed while MeshTerm reported
|
||||
success. It now covers all 49 subcommands: an array for a listing, an object for a set of
|
||||
facts, one compact line per document, and no envelope — `contacts --json | jq '.[].node.name'`
|
||||
reads the way it looks.
|
||||
|
||||
Values are typed (`20`, not `"20"`), absent is `null` and never an omitted key,
|
||||
timestamps are UTC RFC 3339 to the second so string comparison is time comparison, and a
|
||||
numeric field carries its unit in its key (`snr_db`, `uptime_s`, `rtt_ms`). A key is never
|
||||
truncated — a short id is a `hash`, because a truncated key cannot go back into `--to`.
|
||||
`monitor` and `chat listen` emit one document per record as they arrive, all the same
|
||||
shape.
|
||||
|
||||
`--json` changes the rendering and never the report: the same exit status, the same
|
||||
records. An empty result prints its empty document **and still exits `5`**.
|
||||
|
||||
- **A documented exit status per outcome**, listed under every `--help`: `0` success, `1`
|
||||
failure, `2` usage error, `3` no device, `4` the device was reached but the operation
|
||||
failed, `5` nothing to report.
|
||||
|
||||
`5` is the one worth knowing about. A command that ran fine and found nothing — an empty
|
||||
contact list, a trace that never came home — returns it, so a script can tell "found
|
||||
nothing" from "worked" without counting lines.
|
||||
|
||||
- **`--absolute`**, restoring ISO-8601 timestamps for a run, over a new `cli_time_format`
|
||||
preference. JSON is always absolute UTC regardless: a local offset is a fact about the
|
||||
machine that ran the command, not about the event.
|
||||
|
||||
- **A global option may be typed anywhere.** `meshterm contacts --json` and
|
||||
`meshterm --json contacts` are the same run. The first is what everyone types and it used
|
||||
to fail with a bare usage error.
|
||||
|
||||
- **`contacts` gains `HASH` and `LOCATION`.** The hash is the token `--path` takes; you used
|
||||
to slice it out of `KEY` by hand. `preferences show` gains `DESCRIPTION` back, and `info`
|
||||
glosses its second-valued readings (`uptime_s 93784 (1d 2h)`).
|
||||
|
||||
- **[`docs/cli.md`](docs/cli.md)**, a manual for the whole thing: both faces of every
|
||||
command, where MeshTerm keeps its state, what each failure looks like, and recipes. Every
|
||||
sample in it is captured from a real run rather than written.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The plain face is for a person now, and says so.** It had grown the menu's manners —
|
||||
colour, boxes, section headings, glyph columns, a closing ✓ — and then briefly overcorrected
|
||||
into something only `awk` could love. Machine-readability is `--json`'s job, so the plain
|
||||
face is free to be read: no colour and no frames, still, but **alignment is the delimiter**
|
||||
and names are bare rather than `"quoted"`.
|
||||
|
||||
- **A time is an age** — `now`, `5m`, `3h`, `never` — because "recently?" is the question you
|
||||
typed the command to ask. An absolute instant survives where the instant *is* the fact: the
|
||||
device clock, an appointment, a live capture's own `TIME`.
|
||||
|
||||
- **A route is drawn and a path is typed.** A route reads
|
||||
`Yagi-Repeater (a1) → Alice (d4) → MockCompanion (00)`; a `--path` spec stays the
|
||||
comma-joined hex it has to be, because it is the one line that round-trips. Our own node is
|
||||
a hop like any other — the menu's `★` says "you already know who this is", which is true of
|
||||
the reader and false of whoever opens the file later.
|
||||
|
||||
- **A value read out of `show` can be typed back into `set`.** The settings dump printed an
|
||||
enum as `0 (off)` and an unset string as `(not set)`, neither of which `config set` would
|
||||
take back.
|
||||
|
||||
- **`ui.ack` and a tool's closing message go to stderr** rather than being dropped. stderr
|
||||
keeps the promise the dropping was made to keep — a redirect catches only the answer —
|
||||
while giving the person at the prompt back their ✓ and their count.
|
||||
|
||||
- **`--help` is Click's own plain help**, with no boxed panels and no markup to strip out of
|
||||
a pipe.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A node name could not be printed safely.** A node broadcasts its own name, and Rich read
|
||||
`[...]` in one as console markup: a node called `[bold]Loud` printed as `Loud` — silently
|
||||
no longer the string that identifies it — and one called `[/]Bob` raised `MarkupError` and
|
||||
took the command down, then took the error handler down with it while reporting the crash.
|
||||
|
||||
- **A control character in a name reached stdout.** Escaping covered the newline, the tab and
|
||||
the return and let **ESC** through, which put a live colour run into the caller's file — the
|
||||
one thing "no escape sequences" exists to prevent. BEL was dropped in silence, so the
|
||||
printed name was not the advertised one, and U+2028 ended the record like a newline. It is
|
||||
now every C0 and C1 control plus the Unicode separators.
|
||||
|
||||
- **Numeric columns never right-aligned.** Rich's `Text.wrap` returns early on
|
||||
`overflow="ignore"`, above the justify step, so asking for one threw the other away — while
|
||||
the standard, the code's own docstring and the manual all promised alignment.
|
||||
|
||||
- **`config show` printed an empty string as `""`**, which `config set` read as those two
|
||||
characters. Feeding a dump back replaced every empty setting with a pair of quote marks, and
|
||||
the next dump looked identical, so nothing ever said so. It also masked an **unreported**
|
||||
PIN behind bullets, telling the reader the radio was PIN-locked when it was not.
|
||||
|
||||
- **`meshterm about` drew the rule above its colophon at console width** — one 48KB line in a
|
||||
4KB page.
|
||||
|
||||
- **`config export-key > key.hex`** wrote a file with `[muted]` tags around the key that was
|
||||
supposed to be its whole content.
|
||||
|
||||
- **Failures were classified wrong in six places.** Nothing transmitted is not a device
|
||||
failure: an unopenable `--port` is `3` rather than `4` with a message about a connection
|
||||
there had never been, and a missing `--yes`, an unknown `--sort`/`--category`/`--profile`,
|
||||
a missing admin password, an unknown contact and a bad `--at` are all usage errors. A
|
||||
`--category` typo used to come back as `5` — indistinguishable from a real empty result —
|
||||
and an unknown `--profile` fell through to whichever radio was attached, which is the one
|
||||
wrong answer that puts a packet on the air.
|
||||
|
||||
- **Errors go to stderr**, in the `meshterm: what went wrong` shape, and stop folding at 80
|
||||
columns — a sentence broken across three lines is a sentence `grep` cannot find. Progress
|
||||
bars moved there too and draw nothing off a terminal.
|
||||
|
||||
- **An expected failure no longer dumps a traceback.** A lost serial link, or an argument a
|
||||
command rejected on its own, reads as one line and the right exit status.
|
||||
|
||||
### Removed
|
||||
|
||||
- **`meshterm map`.** A map is a picture — braille cells whose meaning is their position, and
|
||||
whose nodes are told apart by colour. Stripped of colour it would be unreadable; left
|
||||
coloured it was the one command whose output could not be piped anywhere useful. The map
|
||||
stays in the menu, and the located nodes are scriptable through `contacts`, which now
|
||||
carries their coordinates.
|
||||
|
||||
- **The QR codes** from `channels share`, `config share` and the About pages' scripted face.
|
||||
A QR is a second rendering of a link the output already prints, drawn for a phone pointed at
|
||||
a screen; redirected into a file it was a block of block characters wrapped around the one
|
||||
thing that was actually the answer. The menu still draws them.
|
||||
|
||||
## [0.2.8] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The window MeshTerm opened for itself was too small, and it showed.** The startup
|
||||
wordmark is 71 columns wide; below that the splash quietly swaps to the narrow one drawn
|
||||
for the PicoCalc — so a desktop session came up wearing the handheld's mark, with every
|
||||
menu description truncated onto the pager underneath it.
|
||||
|
||||
MeshTerm was opening a *tab* in whatever Windows Terminal window happened to be around,
|
||||
inheriting its size (64 columns on the machine this was found on). It now asks for a
|
||||
window of its own, sized from the platform's own minimum plus a margin. Windows Terminal
|
||||
clamps that to what the display can show, so a small screen degrades exactly as before.
|
||||
|
||||
- The tab it opens is titled **MeshTerm**, not the executable's full path — which on a
|
||||
downloaded build was a line of Downloads folder.
|
||||
|
||||
## [0.2.7] — 2026-09-07
|
||||
|
||||
### Changed
|
||||
|
||||
- **MeshTerm moves to Windows Terminal rather than asking to.** It still says so, and
|
||||
still tells you how to stop it, but it doesn't put the question. The classic console
|
||||
can't draw a single icon whatever font it's given, so there was only one sensible
|
||||
answer — and it was being asked of someone who hadn't seen the app yet and had nothing
|
||||
to judge it by.
|
||||
|
||||
Installing a **font** still asks, because that writes a file to your machine. Moving
|
||||
window doesn't. Preferences → Display → Console setup stops both.
|
||||
|
||||
## [0.2.6] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Accepting "reopen in Windows Terminal" killed the session** in the downloadable
|
||||
builds. A new window opened, printed `Security validation failure: parent process has
|
||||
different executable!`, and exited — while the window that made the offer had already
|
||||
closed. Worse than never offering.
|
||||
|
||||
A one-file build unpacks itself and re-runs itself, the two halves coordinating through
|
||||
private environment variables. Those were being handed to the new copy, which concluded
|
||||
it was the second half of a launch it never made, checked that its parent was the same
|
||||
program, found Windows Terminal instead, and stopped. They are stripped now.
|
||||
|
||||
It worked perfectly when run from source, which is the whole lesson: the packaged build
|
||||
is its own platform.
|
||||
|
||||
## [0.2.5] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
@@ -218,7 +697,15 @@ 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.2.4...HEAD
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.3...HEAD
|
||||
[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
|
||||
[0.2.7]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.7
|
||||
[0.2.6]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.6
|
||||
[0.2.5]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.5
|
||||
[0.2.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.4
|
||||
[0.2.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.3
|
||||
[0.2.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.2
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -187,8 +237,15 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
- ≤72 cells. Sentence shape: navigation keys, then action keys, **Esc last**.
|
||||
- Esc verb by surface: `Esc back` leaves a screen · `Esc close` dismisses a read-only
|
||||
floating view · `Esc cancel` abandons a prompt/dialog · `Esc keep` leaves a value
|
||||
picker unchanged · `Esc quit` only at the main menu. While a find-as-you-type filter
|
||||
is standing the verb becomes `Esc clear`, because that is what the press does then.
|
||||
picker unchanged · `Esc quit` only at the main menu · `Esc bye` only on the device
|
||||
splash, which is the door rather than a screen — nothing has been started there to
|
||||
quit out of. While a find-as-you-type filter is standing the verb becomes `Esc clear`,
|
||||
because that is what the press does then.
|
||||
- A hint that outgrows the surface drawing it **sheds atoms rather than being cut off**,
|
||||
because the cut takes the end and the end is Esc (`frame.fit_hint`, the chromeless
|
||||
splash). They go from the right, in front of Esc; a screen may name the ones it can
|
||||
spare first (`spare_hint_atoms`) — a key the reader would find anyway, like the ←→ its
|
||||
move atom already named, before one that is unguessable and advertised nowhere else.
|
||||
- Enter verb by action: `Enter open` when the row pushes a screen or dialog ·
|
||||
`Enter select` when it picks a value or action row · `Enter set` in a value picker.
|
||||
A more specific committing verb (`Enter adopt path`, `Enter add`) is fine; a synonym
|
||||
@@ -257,6 +314,18 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
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 `⋯`
|
||||
on the page between a closing point and the next chip's notch, no fill, no padding.
|
||||
- The ribbon's **outer ends**: the chevron means *the route continues*, so an end that is
|
||||
the route's own never wears one. It ends **square** — nothing appended, the last chip's
|
||||
own pad is the edge — and opens square the same way; a full Nerd Font rounds such ends
|
||||
into a lozenge instead (`POWERLINE_ROUND_*`, drawn only where `termfont.powerline_full`).
|
||||
Two things put the chevron back, and either is enough. A *wrapped* line the path outruns
|
||||
closes on the point: there the route really does go on. And a line drawing only the
|
||||
**middle** of a route — a packet's `via` chain, the TX sweep's composed relays: hops that
|
||||
are neither the node the frame came from nor the one it reached — opens on the notch and
|
||||
closes on the point, because a square end is the promise that the chip beside it is
|
||||
where the route began or ended (`PathLine(from_origin=…, to_destination=…)`, defaulting
|
||||
to a whole route). Arrow mode spells the same claim with the separator alone, a leading
|
||||
or trailing `→` with no hop on the far side of it.
|
||||
- Concept icons: 📡 advert · 🕒 clock/sync · 🔄 reboot · 💾 backup · 📂 restore ·
|
||||
🔑 channel/credential key · 🔐 identity/auth secret · 🗑 clear/delete · ✎ compose/edit ·
|
||||
⚙ parameter · `#` count · ▶ run · ⚡ explore/probe · ★ best/winner · ⭐ watch ·
|
||||
@@ -323,6 +392,138 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
- Radio traffic: single transmissions or a user-chosen sample count with cooldown pacing
|
||||
— never bursts.
|
||||
|
||||
### The command line — one answer, two faces
|
||||
|
||||
Everything above describes the **menu**, which is read by a person sitting in front of it.
|
||||
The command line has two readers and it no longer pretends they are the same one: the
|
||||
**plain face** is for somebody at a prompt who typed a command to find something out, and
|
||||
the **JSON face** (`--json`) is for a program, often on another machine and often later.
|
||||
|
||||
The seam that makes two faces possible is the rule everything else here hangs off:
|
||||
|
||||
> **A tool states its answer as data. A renderer turns data into bytes. The CLI boundary
|
||||
> picks the renderer.**
|
||||
|
||||
`exit_code` already worked this way — the tool states it, the boundary turns it into a
|
||||
process status, no tool calls `sys.exit`. The answer travels the same way now. A tool that
|
||||
*prints* its answer has given it away: it is rendered and gone by the time anything could
|
||||
offer it in another format, which is why `--json` reached two commands out of twenty and
|
||||
stopped, each one growing an `if ctx.json_output:` branch above the rendering that restated
|
||||
the whole answer in a dialect nobody else could reuse.
|
||||
|
||||
- `ui/report.py` — `Listing` (records) and `Facts` (one thing, key by key). **A row holds
|
||||
the typed value** (`6.0`, `None`, a `datetime`), never a formatted cell; each `Column`
|
||||
carries both projections, a plain `Lane` and a JSON function. A `Facts` block also covers
|
||||
the one-scalar answer, through `shape=BARE` (`config get` prints its value alone because
|
||||
the caller named the key) and the acknowledgement, through `shape=SILENT`.
|
||||
- `ui/fields.py` — **the shared shapes, one constructor per concept**: `node`, `channel`,
|
||||
`position`, `route`, `spec`, `when`, `instant`, `snr`, `flag`, `free`. A listing that
|
||||
mentions a node asks for `fields.node`, so every document speaks the same five-key object
|
||||
and every listing names its lanes the same way — by construction, not by review.
|
||||
`fields.node` is the one that earns the design: **one machine key expands to as many
|
||||
plain columns as the surface has room for**, four in `contacts` and one in a hop table.
|
||||
- `ui/renderers.py` — `PlainRenderer` and `JsonRenderer`, chosen by `ctx.output`.
|
||||
**A third format is a new renderer and nothing else**: no tool is touched, no report
|
||||
changes. `ctx.output` is an `OutputFormat`, never a boolean, because the boolean was
|
||||
already being asked questions it could not answer.
|
||||
- `ToolResult.report` is `None` for the menu path and for a feature with no scripted face
|
||||
(the map, the dashboard). It is **not** `summary`: that is the run log's record of what an
|
||||
invocation did, written to the `runs` table on every execution, and folding a five-hour
|
||||
`monitor` capture into it would be the price of one field fewer.
|
||||
- **`ui/surface.py` is untouched by all of this.** `PlainUi`/`TuiUi` is the *menu-vs-terminal*
|
||||
split and stays exactly what it is; a report is **returned**, not shown, so `ctx.ui` never
|
||||
grows a `report()` method and `TuiUi` never sees one.
|
||||
- **Streaming is a second, narrower seam** and deliberately not the general path:
|
||||
`monitor` and `chat listen` run until a window closes, so their answer cannot be a value
|
||||
handed back at the end. They open `renderers.stream(...)` and emit one typed row per
|
||||
record. Two commands use it; anything that can build a whole report builds one.
|
||||
|
||||
#### The plain face — for a person at a prompt
|
||||
|
||||
It is still a Unix utility's output — one record per line, no colour, no frames, stdout is
|
||||
the answer, the exit status is half the report — but every rule that existed *only* to make
|
||||
splitting safe has gone, because splitting is the machine face's job now.
|
||||
`meshterm/ui/script.py` is the whole vocabulary and carries the reasoning; the rules:
|
||||
|
||||
- **Alignment is the delimiter.** A name is bare (`script.name`). The *escaping* under the
|
||||
quoting stays and always will (`script._escaped`): a node broadcasts its own name and a
|
||||
stranger fills in a message body, and neither may end the record it sits in.
|
||||
- **A time is an age** — `now`, `5m`, `3h`, `never` (`script.age`, delegating to
|
||||
`widgets.format_age` so the two faces cannot drift). An absolute instant survives where
|
||||
the instant *is* the fact (`script.stamp`, `fields.instant`): the device clock, an
|
||||
appointment set with `--at`, a live capture's own `TIME`, every column under `--absolute`.
|
||||
`--absolute` is an override of the `cli_time_format` **preference**, not a switch beside
|
||||
it — behaviour belongs in the registry.
|
||||
- **A route is drawn and a path is typed.** `script.route` joins hops with ` → `; a hop is
|
||||
`Name (hash)`, or whichever half is known, never an empty `()`. `script.spec` is the
|
||||
comma-joined hex `--path` takes back, and it is the one line on either face that
|
||||
round-trips, so nothing creeps into it. **Our own node is a hop like any other** — the
|
||||
menu's `★` says "you already know who this is", which is true of the reader and false of
|
||||
whoever opens the file afterwards.
|
||||
- **`unknown` is a word and `-` is an absence.** `script.NONE` is the one token for absent;
|
||||
`never` is a *value* (a node not yet heard is a fact). A node type stays the word
|
||||
`repeater` — a monochrome `▲` would need a legend and the CLI has no legends.
|
||||
- **A live stream pins its lanes** (`script.stream`), because a capture cannot measure
|
||||
columns it has not seen. A value wider than its lane overruns and pushes the row right;
|
||||
nothing is elided, and the one unbounded field goes **last** so it can push nothing.
|
||||
- **A value that round-trips is untouchable.** `config show` prints what `config set` takes
|
||||
— an enum's number, `""` for an empty string — and gains nothing cosmetic. `fields.rendered`
|
||||
carries the typed value beside the text its own spec produced, so neither face parses the
|
||||
other's output.
|
||||
- **No wrapping, with one exception**: the four written pages (`about`, `about-author`,
|
||||
`discord`, `support`) draw on a `script.PAGE_WIDTH` console. The no-wrap rule exists so a
|
||||
*record* is never split with its fields under the wrong headings; a paragraph has no
|
||||
fields, and unwrapped it is a 600-cell line no terminal can read. **Never wrap a listing.**
|
||||
- **stdout is the answer; everything else is stderr** — errors (`meshterm: what went
|
||||
wrong`), progress bars, log records, `ui.ack`, and a `ToolResult.message`. The last two
|
||||
used to be dropped; stderr keeps the promise the dropping was made to keep (a redirect
|
||||
catches only the answer) while giving the person at the prompt back their ✓ and their
|
||||
count. `script.stderr_console()` stops wrapping off a terminal, because a sentence folded
|
||||
at 80 columns is a sentence `grep` cannot find.
|
||||
|
||||
#### The JSON face — the machine contract
|
||||
|
||||
`--json` prints **the answer itself**, and the report a caller needs is still `$?`.
|
||||
|
||||
- **No envelope.** An array for a listing, an object for a set of facts, so
|
||||
`contacts --json | jq '.[].node.name'` reads what it looks like. A multi-block report is
|
||||
one object: each `Listing` under its own key, each `Facts` merged at the top level.
|
||||
- **One compact line, `\n`-terminated**, UTF-8 with no ASCII escaping, keys in the order the
|
||||
report declares them. A stream is one document per record, **all the same shape** — no
|
||||
discriminator, because every reader would pay for it and only a stream would use it.
|
||||
- **Absent is `null`, never an omitted key**, and typed values throughout: `9` not `"9"`,
|
||||
`false` not `"false"`, a body raw and unescaped. `unknown` becomes `null`; a consumer
|
||||
already has one spelling for "nothing here".
|
||||
- **Timestamps are UTC, RFC 3339, `Z`, to the second** — twenty characters, so string
|
||||
comparison is time comparison. `--absolute` does not touch this: a local offset is a fact
|
||||
about the machine that ran the command, not about the event.
|
||||
- **A numeric field carries its unit in its key** (`snr_db`, `uptime_s`, `rtt_ms`), never in
|
||||
its value, and a key/hash is lowercase hex. **A key is never truncated** — a truncated key
|
||||
cannot go back into `--to` or `--path`, so a short id is a `hash`.
|
||||
- **`--json` changes the rendering, never the report.** Same exit status, same records. An
|
||||
empty result prints its empty document (`[]`, or the object with its `null`s) **and still
|
||||
exits 5**; a failure prints **nothing on stdout** and keeps its sentence on stderr.
|
||||
- **"Prints nothing" is never a legitimate JSON answer.** A command whose plain face is
|
||||
silent (`config advert`, `channels join`, `courier cancel`) still emits what it did.
|
||||
- **A command with no data face refuses, loudly.** `--json specimen` is a usage error (exit
|
||||
2): its output *is* the colour, and a document of it would be a lie or an empty gesture.
|
||||
So is `--json` with no subcommand. The map, the dashboard, the live feed, the watchtower
|
||||
and the mesh walk have no subcommand to refuse from, and that stays the honest answer
|
||||
rather than a degraded one.
|
||||
|
||||
#### The exit status is the report
|
||||
|
||||
`core/exitcodes.py`, and the `--help` epilog: 0 ok · 1 failure · 2 usage · 3 no device ·
|
||||
4 device failed · 5 nothing to report. A tool that ran fine and found nothing returns
|
||||
`NO_RESULT`; a failure is *raised*, never returned. Two classifications worth restating
|
||||
because both were wrong somewhere: **nothing transmitted is not a device failure** (a bad
|
||||
argument is exit 2), and **a rendering flag never moves the status**.
|
||||
|
||||
`tests/test_report.py` is the enforcement point for the seam, `tests/test_script_output.py`
|
||||
for the plain vocabulary, and `tests/test_cli_contract.py` for the commands — including a
|
||||
derived sweep that runs **every** registered command twice, once plain and once under
|
||||
`--json`, and pins that the two agree about the exit status.
|
||||
|
||||
### Platforms
|
||||
|
||||
One codebase, two flavours: **regular** (desktop/ssh, 72 cols, truecolor, emoji) and
|
||||
@@ -355,7 +556,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/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/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
|
||||
|
||||
@@ -27,10 +27,21 @@ your mesh's history is worth.
|
||||
> 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.:
|
||||

|
||||

|
||||
Screenshots to take — PNG, a 72-column window, the dark theme — saved under
|
||||
docs/screenshots/ with these names, and the four links below come alive:
|
||||
dashboard.png the dashboard on a live mesh: heard-age heat, the braille timelines
|
||||
map.png the map zoomed on a handful of nodes, basemap under them
|
||||
trace.png a trace result with its route drawn as chips
|
||||
picocalc.png a photo of the app on the PicoCalc's 53x26 console
|
||||
-->
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/dashboard.png" alt="The dashboard" width="49%">
|
||||
<img src="docs/screenshots/map.png" alt="The mesh map" width="49%">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/trace.png" alt="A trace and the route it walked" width="49%">
|
||||
<img src="docs/screenshots/picocalc.png" alt="MeshTerm on the PicoCalc" width="49%">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
@@ -68,14 +79,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.5-windows-x64.exe
|
||||
.\meshterm-0.3.3-windows-x64.exe
|
||||
```
|
||||
|
||||
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
|
||||
|
||||
```bash
|
||||
chmod +x meshterm-0.2.5-*
|
||||
./meshterm-0.2.5-*
|
||||
chmod +x meshterm-0.3.3-*
|
||||
./meshterm-0.3.3-*
|
||||
```
|
||||
|
||||
### A word about terminals, on Windows
|
||||
@@ -91,20 +102,19 @@ doesn't borrow. It draws what its one font holds and empty boxes for everything
|
||||
no font fixes the icons there: the only two fonts on a Windows machine with emoji in them
|
||||
are proportional, and a console won't take a proportional font.
|
||||
|
||||
So when MeshTerm lands in that console it offers to move: **reopen in Windows Terminal**,
|
||||
one keypress, nothing installed and nothing changed. That's the whole app, exactly as the
|
||||
screenshots show it. Windows Terminal is already on every Windows 11 machine and is a free
|
||||
install on Windows 10.
|
||||
So when MeshTerm lands in that console it just **moves to Windows Terminal** — it says
|
||||
so, and opens there. Nothing is installed and nothing is changed; it's one window instead
|
||||
of another, and it's the whole app exactly as the screenshots show it. Windows Terminal is
|
||||
already on every Windows 11 machine and is a free install on Windows 10.
|
||||
|
||||
Stay in the classic console and MeshTerm offers the next best thing instead — the charts
|
||||
and marks, without the icons. It ships
|
||||
Where there's no Windows Terminal to move to, MeshTerm offers the next best thing instead
|
||||
— the charts and marks, without the icons. It ships
|
||||
[Cascadia Mono PL](https://github.com/microsoft/cascadia-code), Microsoft's own console
|
||||
font and one of the very few monospace faces that carries braille at all, and will install
|
||||
it just for you: no administrator rights, nothing downloaded. If you already have Cascadia
|
||||
it simply switches to it.
|
||||
|
||||
You can decline both and MeshTerm still runs. Preferences → Display → Console setup is
|
||||
where you change your mind.
|
||||
Preferences → Display → Console setup turns all of this off if you'd rather stay put.
|
||||
|
||||
Rename it to something you don't mind typing and put it somewhere on your `PATH`, and it
|
||||
becomes just `meshterm` from anywhere.
|
||||
@@ -114,7 +124,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.5-*` and run it again.
|
||||
> `xattr -d com.apple.quarantine meshterm-0.3.3-*` 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.
|
||||
@@ -124,10 +134,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.5-* # macOS, Linux
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.3.3-* # macOS, Linux
|
||||
> ```
|
||||
> ```powershell
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.5-windows-x64.exe
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.3.3-windows-x64.exe
|
||||
> ```
|
||||
>
|
||||
> Same variable if you run two radios and want them kept apart.
|
||||
@@ -183,8 +193,9 @@ 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 that handheld — the console font, the boot services, a soldered radio for
|
||||
it, and the bridge that fronts a uConsole's SPI LoRa chip as a companion — is
|
||||
[`scripts/`](scripts/), and **[`docs/hardware.md`](docs/hardware.md) is its manual**.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -199,7 +210,7 @@ here with its scripted equivalent, where one exists.
|
||||
| **💬 Chat** | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. Every message is logged; unread counts show in the menu header. | `meshterm chat send / history / list` |
|
||||
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels — with QR codes and `meshcore://` share links. | `meshterm channels list / add / join / import / share / clear` |
|
||||
| **📨 Courier** | Store-and-forward outbox for contacts that aren't reachable yet. Queue a message; it goes out (with ack tracking and polite exponential backoff) the moment the contact is next heard, or at a scheduled time. | `meshterm courier queue / list / send / cancel / clear` |
|
||||
| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. | `meshterm contacts` |
|
||||
| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. The scripted listing is the contacts alone (this node is `meshterm info`'s answer, in far more detail). | `meshterm contacts` |
|
||||
|
||||
### 📊 Watch — what the mesh is doing, and what it did
|
||||
|
||||
@@ -215,10 +226,10 @@ here with its scripted equivalent, where one exists.
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | `meshterm map` (one-shot render) |
|
||||
| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | — (a map is a picture; `meshterm contacts` lists the same nodes) |
|
||||
| **🌐 Mesh walk** | The mesh's *observed shape*, walked one node at a time: an evidence graph built from trace walks, firmware routes, overheard relay chains, and repeater neighbour tables. SNR-coloured braille edges, quality bars, Enter to walk, ⌫ to backtrack, type to find any node. The map answers *where*; the walk answers *how it hangs together*. | — |
|
||||
| **🎯 Trace target** | A live trace screen: pick a target and watch each trace stream in hop by hop, with running per-hop medians and reliability. Compose or force a route through specific repeaters. A trace transmits **exactly once** (repeaters can blacklist nodes that burst) — sample more by running it again. | `meshterm trace --target …` |
|
||||
| **👣 Trace path** | The other half of tracing: compose the whole circuit by hand — out and back whichever way you choose — and walk it. The path composer suggests each next hop from the links actually observed, strongest first, and can fetch a repeater's neighbour table over the mesh when you hold its admin password. | `meshterm trace --path …` |
|
||||
| **👣 Trace path** | The other half of tracing: compose the whole circuit by hand — out and back whichever way you choose — and walk it. The path composer suggests each next hop from the links actually observed, strongest first, and can fetch a repeater's neighbour table over the mesh when you hold its admin password. | `meshterm trace-path --path …` |
|
||||
| **🏆 Trophy case** | Every trace that comes home is scored, on seven boards: longest distance, farthest node, longest single leg, most nodes (with and without revisits), weakest surviving link, and biggest enclosed loop. Records are kept per hash width, and a record walk must be a *trail* — no link crossed twice the same way. | `meshterm records` |
|
||||
|
||||
### 🔧 This node — the radio in your hand
|
||||
@@ -226,8 +237,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` |
|
||||
|
||||
@@ -240,7 +250,11 @@ here with its scripted equivalent, where one exists.
|
||||
|
||||
## CLI cookbook
|
||||
|
||||
Every menu option is also a Typer subcommand — ideal for scripting, cron, and bots.
|
||||
Nearly every menu option is also a subcommand — ideal for scripting, cron, and bots. The
|
||||
live pictures stay in the menu, where their meaning is (the map, dashboard, live feed,
|
||||
watchtower and mesh walk); everything else has a command, and every command speaks
|
||||
`--json`. **[`docs/cli.md`](docs/cli.md) is the full manual**: every command and option,
|
||||
what each one prints on both faces, and the exit statuses. A taste:
|
||||
|
||||
```bash
|
||||
# Trace — a trace transmits exactly once; run it again to sample more.
|
||||
@@ -251,11 +265,13 @@ meshterm trace --target Alice --profile yagi
|
||||
meshterm trace --target Alice --path "3d,f2,3d"
|
||||
meshterm trace --target Alice --path "3d,Bravo-Repeater,f2"
|
||||
|
||||
# Walk a composed circuit with no target at all — out and back your own way
|
||||
meshterm trace-path --path "3d,f2,3d"
|
||||
|
||||
# Sweep and apply a remote node's TX power
|
||||
meshterm tx-optimize --path "Bravo-Repeater,Alice" --samples 6 --step 3 --apply
|
||||
|
||||
# Messaging: live chat in the menu, or scripted from the CLI
|
||||
meshterm chat # interactive: pick a conversation, chat live
|
||||
# Messaging: the live transcript is a menu screen; the CLI takes a subcommand
|
||||
meshterm chat send --to Alice "on my way" # direct message
|
||||
meshterm chat send --channel 0 "net in 5" # channel broadcast
|
||||
meshterm chat history --to Alice
|
||||
@@ -270,7 +286,8 @@ meshterm courier send 3 # force one delivery attempt now
|
||||
meshterm repeater-admin Bravo-Repeater "get name"
|
||||
|
||||
# Device configuration: view, set, back up, restore
|
||||
meshterm config # show all current settings
|
||||
meshterm config # show all current settings, one `key value` per line
|
||||
meshterm config get name # just the value, ready for $(...)
|
||||
meshterm config set radio_sf 9 # change one setting
|
||||
meshterm config backup node.toml # archive every setting to TOML
|
||||
meshterm config restore node.toml --dry-run
|
||||
@@ -281,8 +298,56 @@ meshterm config advert-cadence 24 --flood # flood the wider mesh daily
|
||||
meshterm monitor --seconds 60
|
||||
```
|
||||
|
||||
Global options (before the subcommand): `--profile/-p`, `--port`, `--ble`, `--ble-pin`,
|
||||
`--tcp`, `--mock`, `--db`, `--json`, `--quiet/-q`.
|
||||
Global options — `--profile/-p`, `--port`, `--ble`, `--ble-pin`, `--tcp`, `--mock`,
|
||||
`--db`, `--json`, `--absolute`, `--quiet/-q` — may be typed **before or after** the
|
||||
subcommand: `meshterm contacts --json` and `meshterm --json contacts` are the same run.
|
||||
|
||||
### Two output faces
|
||||
|
||||
*(The short version — [`docs/cli.md`](docs/cli.md) has the whole of it.)*
|
||||
|
||||
The **plain face** is for a person at a prompt. It prints like a standard Unix utility —
|
||||
no colour, no borders, one record per line, nothing wrapped — and it is allowed to be
|
||||
comfortable about it: listings are `ps`-style aligned records with **bare names**
|
||||
(alignment is the delimiter), times are **relative ages** (`now`, `5m`, `never`), and a
|
||||
route is drawn with arrows — `MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4)`.
|
||||
`--absolute` swaps every age back for an ISO-8601 instant. A *path*, the spec `--path`
|
||||
takes back, stays comma-separated hex. `-` is the one token for absent. Errors,
|
||||
acknowledgements and progress all go to stderr, so a redirect catches only the answer.
|
||||
|
||||
The **JSON face** is the machine contract, and `--json` works on **every** command:
|
||||
|
||||
```console
|
||||
$ meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
|
||||
b2c3d4e500000000000000000000000000000000000000000000000000000000
|
||||
a1b2c3d400000000000000000000000000000000000000000000000000000000
|
||||
```
|
||||
|
||||
No envelope — an array for a listing, an object for a set of facts. One compact line;
|
||||
`monitor` and `chat listen` stream one document per record. Values are typed, absent is
|
||||
`null` and never an omitted key, and timestamps are always UTC to the second regardless of
|
||||
`--absolute`. `--json` changes the rendering, never the report: same records, same exit
|
||||
status.
|
||||
|
||||
Anything structural should go through `--json` and `jq`. The plain face is for looking at.
|
||||
|
||||
### Exit status
|
||||
|
||||
`0` success · `1` failure · `2` usage error · `3` no device found (nothing was
|
||||
transmitted) · `4` the device was reached but the operation failed · `5` nothing to
|
||||
report. The table with its full wording is printed under `meshterm --help` and explained
|
||||
in [`docs/cli.md`](docs/cli.md#exit-status).
|
||||
|
||||
`5` is the one worth knowing about: it lets a script tell "found nothing" from "worked"
|
||||
without counting output lines.
|
||||
|
||||
```bash
|
||||
if meshterm contacts > contacts.txt; then
|
||||
echo "$(wc -l < contacts.txt) contacts"
|
||||
elif [ $? -eq 5 ]; then
|
||||
echo "no contacts yet"
|
||||
fi
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -299,7 +364,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
|
||||
@@ -332,6 +397,13 @@ host = "192.168.1.50"
|
||||
description = "Basestation over WiFi"
|
||||
```
|
||||
|
||||
Both live in `~/.meshterm`, along with everything else MeshTerm remembers: the SQLite
|
||||
history, the outbox, the contact and channel caches, stored admin passwords, and the log.
|
||||
**`$MESHTERM_HOME` moves the whole directory**, which is the way to run a second radio —
|
||||
or a `--mock` session — without touching the one you use every day. `--db` moves the
|
||||
database alone; the rest stays where it was. [`docs/cli.md`](docs/cli.md#where-meshterm-keeps-its-state)
|
||||
lists every file.
|
||||
|
||||
Timestamps are stored as UTC and rendered in your local time. History older than the
|
||||
`history_days` preference is pruned once at session start.
|
||||
|
||||
@@ -409,17 +481,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.
|
||||
|
||||
@@ -437,8 +517,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
|
||||
|
||||
@@ -457,3 +538,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.
|
||||
#
|
||||
|
||||
+1640
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,679 @@
|
||||
# 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 and none of this document
|
||||
applies: MeshTerm finds it, and the [command line manual](cli.md) is all you need.
|
||||
|
||||
This is the manual for the hardware that *doesn't* present a node that way, and for the
|
||||
handheld whose console can't draw the interface until you build it a font. Everything under
|
||||
[`scripts/`](../scripts/) is here: what each script does, what it assumes about the machine
|
||||
it runs on, why it does it the way it does, and what has only ever been proven on one bench.
|
||||
|
||||
The scripts are small, POSIX `sh` or stdlib Python, and meant to be read before they are
|
||||
run. They are also *device-side* — they change the machine they run on, most of them as
|
||||
root — so read the section for your target first and run the script second.
|
||||
|
||||
- [Three targets, and which one is yours](#three-targets-and-which-one-is-yours)
|
||||
- [The regular and picocalc platforms](#the-regular-and-picocalc-platforms)
|
||||
- [Calculinux bring-up](#calculinux-bring-up)
|
||||
- [Before you run it](#before-you-run-it)
|
||||
- [What the nine phases do](#what-the-nine-phases-do)
|
||||
- [The console font on its own](#the-console-font-on-its-own)
|
||||
- [Joining a network by hand](#joining-a-network-by-hand)
|
||||
- [Re-running, and undoing](#re-running-and-undoing)
|
||||
- [A real radio for the PicoCalc: XIAO nRF52840 over UART](#a-real-radio-for-the-picocalc-xiao-nrf52840-over-uart)
|
||||
- [Parts and wiring](#parts-and-wiring)
|
||||
- [Build the firmware](#build-the-firmware)
|
||||
- [Flash the XIAO](#flash-the-xiao)
|
||||
- [Set up the Lyra](#set-up-the-lyra)
|
||||
- [Run it, and check the link](#run-it-and-check-the-link)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [The SPI radio bridge](#the-spi-radio-bridge)
|
||||
- [Before you start: the runtime rename](#before-you-start-the-runtime-rename)
|
||||
- [Setting it up](#setting-it-up)
|
||||
- [Identity, and one program at a time](#identity-and-one-program-at-a-time)
|
||||
- [Hardware knobs](#hardware-knobs)
|
||||
- [What works through the bridge](#what-works-through-the-bridge)
|
||||
- [The unfinished UART-to-TCP pump](#the-unfinished-uart-to-tcp-pump)
|
||||
- [What is verified and what is not](#what-is-verified-and-what-is-not)
|
||||
|
||||
---
|
||||
|
||||
## Three targets, and which one is yours
|
||||
|
||||
There are three independent jobs in `scripts/`, and they belong to different machines.
|
||||
|
||||
| I have… | Read |
|
||||
| --- | --- |
|
||||
| a ClockworkPi **PicoCalc** with a Luckfox Lyra running Calculinux, and I want MeshTerm on its own screen | [Calculinux bring-up](#calculinux-bring-up) |
|
||||
| …and I want a **radio** in it | [XIAO nRF52840 over UART](#a-real-radio-for-the-picocalc-xiao-nrf52840-over-uart) |
|
||||
| a **uConsole** with the hackergadgets AIO, or a Pi with a Waveshare LoRa HAT — an SX1262 on the host's own SPI bus | [The SPI radio bridge](#the-spi-radio-bridge) |
|
||||
| a MeshCore companion on USB, BLE or WiFi | nothing here — the [main README](../README.md) |
|
||||
|
||||
The PicoCalc target and the SPI target have nothing in common but MeshTerm itself. The
|
||||
console font applies only to the PicoCalc's framebuffer console; the SPI bridge runs a mesh
|
||||
node in software on a Debian host and never touches a console font. Do not run one target's
|
||||
scripts on the other's machine.
|
||||
|
||||
Within the PicoCalc target the two radio routes are mutually exclusive, and the reason is
|
||||
the kernel's, not MeshTerm's: **only one program may hold `/dev/ttyS1`.** Either MeshTerm
|
||||
opens the UART directly, which is what [`lyra-setup.sh`](../scripts/xiao-radio/lyra-setup.sh)
|
||||
configures and what you want on the device itself, or a pump holds the UART and fronts it on
|
||||
TCP for other machines — never both at once.
|
||||
|
||||
---
|
||||
|
||||
## The regular and picocalc platforms
|
||||
|
||||
MeshTerm resolves one frozen platform spec 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 the PicoCalc's framebuffer console, and
|
||||
every number in it comes from the panel: 53 columns, 26 rows on the 6×12 console font (40
|
||||
on the 6×8 candidate), no panel border, no emoji anywhere, an F-key lane instead of a hint
|
||||
line, and sixteen colour slots rather than a colour space — the RGB565 panel is not the
|
||||
limit, the Linux VT layer is. Nothing loses its colour for being on the console; the node
|
||||
hue and the recency heat gradient **quantise** to those slots rather than switching rules.
|
||||
The platform is detected when `/proc/device-tree/model` reads exactly `Luckfox Lyra`, and
|
||||
`--platform picocalc` or `MESHTERM_PLATFORM=picocalc` forces it anywhere — which is how you
|
||||
preview the handheld layout on a desktop, alongside `meshterm specimen` for the whole
|
||||
visual language on one card.
|
||||
|
||||
The console font is the part that needs building, and it is built **on the device** by
|
||||
[`calculinux-console-font.sh`](../scripts/calculinux-console-font.sh) rather than shipped as
|
||||
a binary. Its only inputs are the stock Terminus console font the Calculinux image already
|
||||
carries and `python3`; every added glyph is generated geometry in the script itself. So
|
||||
nothing binary lives in the repo, and the base font is whatever your image installed rather
|
||||
than whatever a maintainer's was. What it adds is what the bare console cannot draw and
|
||||
MeshTerm cannot do without: the 256 braille cells every chart and map is made of, eighteen
|
||||
marks (`●` `◉` `★` `✓` `✗` `▶` `❯` `▸` `⚠` `⌫` `⚙` `↻` among them), and rounded frame
|
||||
corners aliased onto the square ones.
|
||||
|
||||
The budget is the interesting constraint. The framebuffer console caps a font at **512
|
||||
glyphs**, and braille alone is 256 of them on top of the base font's 256 — so the budget is
|
||||
full before a single mark is drawn. Each mark is therefore drawn *into a donor slot*: a
|
||||
pictograph MeshTerm never emits, whose Unicode entry is repointed at the new bitmap. PSF
|
||||
Unicode tables alias several codepoints onto one glyph, so a careless donation takes a
|
||||
bystander with it — donating `π` once erased a Cyrillic letter that shared its slot. The
|
||||
script aborts if a donor is missing, and after the build it verifies a list of keepers with
|
||||
`π` and `пе` as canaries. A repo test parses the script's own mark, alias and donor tables
|
||||
and pins them to `meshterm/ui/fontset.py`, the device-verified inventory of the installed
|
||||
font: the script and the inventory move in the same commit or the test fails.
|
||||
|
||||
> **Licensing.** The installed 6×12 font is a **derivative of Terminus**, which is under the
|
||||
> SIL Open Font Licence 1.1 with "Terminus" as a Reserved Font Name — so the output is named
|
||||
> `meshterm`, which is exactly what the OFL requires of a renamed derivative, and it must not
|
||||
> be called Terminus. Nothing Terminus itself is in this repo. The 6×8 companion is
|
||||
> different: its base bitmap is the Linux kernel's `font_6x8`, which is **GPL-2.0**, embedded
|
||||
> base64 in the script. So that material lives in one file of its own,
|
||||
> [`calculinux-console-font-6x8.sh`](../scripts/calculinux-console-font-6x8.sh), marked
|
||||
> `SPDX-License-Identifier: GPL-2.0-only` with the licence beside it as
|
||||
> [`scripts/LICENSE.GPL-2.0`](../scripts/LICENSE.GPL-2.0) — and that is the end of the
|
||||
> awkwardness. Nothing GPL-2.0 enters the Apache-2.0 program: the script is excluded from
|
||||
> the Python distribution, it runs **on the device**, and what it produces is a PSF **data
|
||||
> file the kernel's console driver loads** with `setfont`. MeshTerm never links it, never
|
||||
> bundles it and never redistributes it; it names a path on the console's own filesystem.
|
||||
> The two scripts share one copy of the donor, alias and keeper tables — the 6×8 one reads
|
||||
> them out of the 6×12 one, between its `shared generator` marker lines — so a glyph added
|
||||
> in one place lands in both fonts. To permit this sharing, the shared generator block in
|
||||
> the 6×12 script (Apache-2.0) is additionally offered under GPL-2.0-only by its source
|
||||
> file. The XIAO firmware patch and builder reference MeshCore, which is under the MIT
|
||||
> License — see [`scripts/xiao-radio/LICENSE.MeshCore`](../scripts/xiao-radio/LICENSE.MeshCore).
|
||||
|
||||
---
|
||||
|
||||
## Calculinux bring-up
|
||||
|
||||
[`calculinux-setup.sh`](../scripts/calculinux-setup.sh) turns a freshly flashed Calculinux
|
||||
SD card into a machine that runs MeshTerm as a dedicated user, with Wi-Fi that survives a
|
||||
cold boot, a clock that is correct, and a console font. It runs **on the Lyra, as root**,
|
||||
and the serial console is a fine place to run it from. It is idempotent: every phase checks
|
||||
before it acts.
|
||||
|
||||
```bash
|
||||
# on the Lyra, as root, with scripts/ copied over
|
||||
sh calculinux-setup.sh
|
||||
passwd meshterm # the script deliberately sets no password
|
||||
```
|
||||
|
||||
Then log in as that user and look at it:
|
||||
|
||||
```bash
|
||||
meshterm --mock # the interface, with a simulated radio
|
||||
meshterm specimen # the visual language on one card
|
||||
```
|
||||
|
||||
### Before you run it
|
||||
|
||||
The script assumes Calculinux as shipped — Yocto, systemd, a read-only root with writable
|
||||
overlays, `opkg` reachable over the network — and that `sh`, `su`, `useradd` or busybox
|
||||
`adduser`, `systemctl`, `timedatectl`, `sed`, `grep`, `ssh-keyscan`, `git` and `kbd` are
|
||||
present. It installs `git` and `kbd` anyway rather than trusting the base image.
|
||||
|
||||
One thing it cannot embed, and one choice it makes by looking:
|
||||
|
||||
- **Wi-Fi credentials.** The boot-time kick only nudges a network `iwd` already knows.
|
||||
Either export `WIFI_SSID` and `WIFI_PSK` before running, and the script writes iwd's PSK
|
||||
file (mode 600, secrets staying in your environment and never in the repo), or join once
|
||||
by hand with [`calculinux-wifi-set.sh`](#joining-a-network-by-hand).
|
||||
- **How the clone gets in.** MeshTerm is a public repository, so this needs no credential at
|
||||
all: with no key file at `$KEY_PATH` the phase clones `$REPO_URL` over public HTTPS and
|
||||
says so. Put a read-only GitHub deploy key there (mode 600) and it clones `$REPO_SSH` over
|
||||
SSH instead — pinning github.com's host key with `ssh-keyscan` first so the clone never
|
||||
blocks on a prompt, and baking the key into the checkout's `core.sshCommand` so later
|
||||
pulls need no environment. Either way an existing checkout at
|
||||
`/home/$DEPLOY_USER/MeshTerm` is pulled `--ff-only` rather than replaced.
|
||||
|
||||
| Knob | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `DEPLOY_USER` | `meshterm` | the login MeshTerm runs under; its home holds the checkout |
|
||||
| `TIMEZONE` | `America/Toronto` | any IANA zone; see phase 4 for what happens without tzdata |
|
||||
| `REPO_URL` | the project's HTTPS URL | cloned when there is no deploy key |
|
||||
| `REPO_SSH` | the project's SSH URL | cloned when there is one |
|
||||
| `KEY_PATH` | `/home/$DEPLOY_USER/.ssh/id_ed25519` | the read-only deploy key, if you want the SSH route |
|
||||
| `WIFI_SSID`, `WIFI_PSK` | empty | set **both** to have iwd's credentials written for you |
|
||||
|
||||
### What the nine phases do
|
||||
|
||||
Each phase exists because of something that went wrong on the real device, and the *why* is
|
||||
the part worth reading.
|
||||
|
||||
| Phase | What it does, and why |
|
||||
| --- | --- |
|
||||
| **1. opkg packages** | Installs `python3-modules`, `python3-pip`, `git`, `kbd`. Calculinux ships a stripped Python 3.13 — no `pip`, no `venv`, no `ensurepip`, and missing `sqlite3`, `ctypes`, `curses` and more — so MeshTerm cannot even import until `python3-modules` restores the interpreter. `/usr` is a writable overlay, so the install persists. |
|
||||
| **2. Wi-Fi kick** | Writes `/etc/wifi-kick.sh` and a oneshot `wifi-kick.service`. On cold boot the USB RTL8188EU's firmware finishes loading *after* iwd's first scans, so iwd's own scans come back empty and `wlan0` sits UP/NO-CARRIER forever. The kick waits for the interface, then runs **raw `iw dev wlan0 scan`** in rounds until a lease appears. Raw `iw` and not `iwctl scan`: a completed raw scan fills the mac80211 scan results and iwd then associates by itself, whereas `iwctl scan` makes iwd hold the device and the raw scan fails busy. |
|
||||
| **3. Clock sync** | Writes `/etc/time-sync.sh` and a oneshot ordered after the kick. This board has no battery-backed RTC, so every cold boot starts at the kernel's build epoch — fatal for "heard" ordering and message timestamps. It waits for a real route, then tries `chronyd`, busybox `ntpd`, `sntp`, and finally an HTTPS `Date:` header, and writes the hardware clock best-effort. It always exits 0. |
|
||||
| **4. Timezone** | Sets `$TIMEZONE` — `America/Toronto` unless you say otherwise — through `timedatectl` once tzdata is installed, and falls back to a POSIX `TZ` rule in `/etc/environment` and the deploy user's `~/.profile` when it is not, because glibc honours a POSIX rule with no zoneinfo database at all and the opkg feed index has been seen to 404. The fallback rule is only known for the default zone; another zone on a device with no tzdata is set through `timedatectl` alone, with a warning. |
|
||||
| **5. Deploy user** | Creates `$DEPLOY_USER` and puts it in `wheel` for sudo and in `input`, `dialout` and `video`. `input` is not optional: the picocalc platform watches modifier keys directly, and `dialout` is what lets MeshTerm open the radio. Group changes need a fresh login. |
|
||||
| **6. Clone** | The checkout at `/home/$DEPLOY_USER/MeshTerm`, pulled `--ff-only` if it is already there. See [Before you run it](#before-you-run-it). |
|
||||
| **7. venv and install** | `python3 -m venv ~/MeshTerm/.venv`, then `pip install -e .` with **`TMPDIR=$HOME/tmp`** — `/tmp` is a small RAM tmpfs and pip's C builds hit ENOSPC in it. |
|
||||
| **8. Login PATH** | Appends the venv's `bin` to the deploy user's `~/.profile` once, so `meshterm` is just a word. |
|
||||
| **9. Console font** | Hands off to `calculinux-console-font.sh` if it sits alongside, and otherwise tells you to run it yourself. |
|
||||
|
||||
> **A Lyra with no Wi-Fi dongle attached takes about six minutes to boot past the kick**,
|
||||
> which waits a minute for the interface and then burns thirty rounds before exiting 0 — and
|
||||
> the clock sync is ordered after it. Nothing is broken; it is a oneshot giving a slow radio
|
||||
> every chance. Disable `wifi-kick.service` if the device is permanently wired or offline.
|
||||
|
||||
### The console font on its own
|
||||
|
||||
The font script stands alone, which is what you want after a MeshTerm update that adds a
|
||||
glyph:
|
||||
|
||||
```bash
|
||||
sh calculinux-console-font.sh
|
||||
```
|
||||
|
||||
It builds both fonts — the 6×8 one by handing off to
|
||||
[`calculinux-console-font-6x8.sh`](../scripts/calculinux-console-font-6x8.sh) beside it, or
|
||||
telling you it is missing; `SKIP_6X8=1` builds only the default — applies the 6×12 one live
|
||||
with `setfont` on `/dev/tty1`, and persists it as `FONT=meshterm` in `/etc/vconsole.conf` so
|
||||
systemd loads it at boot. If `setfont` fails it aborts *before* persisting, so you are never
|
||||
left with a font that only exists in `vconsole.conf`. A 6×8 build that fails is a warning
|
||||
and nothing more: the default is installed either way.
|
||||
|
||||
**Choosing between them is a preference, not a command.** *Display → Console font* on the
|
||||
Preferences page offers `6x12 (53x26)` and `6x8 (53x40)`, and it is drawn on the PicoCalc
|
||||
only — it is a row about hardware no desktop has, though the value round-trips through
|
||||
`preferences.toml` on any machine. Picking one is the preview: the console loads it
|
||||
immediately and the page repaints at the new row count (the kernel raises SIGWINCH on a
|
||||
font change), so you look at the choice before you keep it. *Apply* keeps it; leaving the
|
||||
page and discarding puts the saved font back.
|
||||
|
||||
MeshTerm puts the font back when it exits. Before the first paint it saves the console's
|
||||
current font with `setfont -O ~/.meshterm/console-font-before.psf` — whatever the *shell*
|
||||
was in, not whatever we assume it was — and reloads that file on the way out, from the
|
||||
session's own teardown and from an `atexit` hook, so ^Q and a crash both leave the console
|
||||
as they found it. Then the file is deleted. No `sudo` anywhere: the VT is the process's
|
||||
controlling terminal and belongs to the logged-in user. Every part of this is optional and
|
||||
silent — off the PicoCalc, off a real VT (an ssh session is a pty, not a console), or with
|
||||
no `setfont` on `PATH`, it does nothing and says so once in the log. A permanent choice
|
||||
still goes in `vconsole.conf` by hand: `FONT=meshterm8` for 53×40, `FONT=meshterm` for 53×26.
|
||||
|
||||
> **The 6×8 font has no Cyrillic.** Its base is CP437, where the 6×12 Terminus base is not,
|
||||
> so a node named in Cyrillic draws as tofu boxes on it. That is a known and accepted cost
|
||||
> of the fourteen extra rows, not a bug to report.
|
||||
|
||||
The same script owns the sixteen colour slots. A **default run restores the stock kernel VT
|
||||
palette** and removes any previous remap: it disables and deletes the `meshterm-vtrgb`
|
||||
boot unit, `/etc/vtrgb` and the applier, programmes the stock table with `setvtrgb`, and
|
||||
then writes the sixteen `OSC P` sequences to the tty as well. Both, and not just the
|
||||
escape-sequence reset, because a previous `setvtrgb` overwrote the kernel's *default*
|
||||
colormap — so a reset alone would faithfully restore the custom colours you were trying to
|
||||
get rid of. `MESHTERM_CUSTOM_PALETTE=1` installs the archived custom remap instead, as
|
||||
`/etc/vtrgb` plus a oneshot that re-applies it after `systemd-vconsole-setup`; a repo test
|
||||
pins that remap to the palette table in `meshterm/ui/theme.py`. There is no uninstall of
|
||||
the fonts themselves — delete the two `.psf.gz` files and the `FONT=` line.
|
||||
|
||||
### Joining a network by hand
|
||||
|
||||
[`calculinux-wifi-set.sh`](../scripts/calculinux-wifi-set.sh) is how a network *becomes*
|
||||
known, which is the half the boot kick cannot do. It lists known networks and a fresh scan,
|
||||
prompts for an SSID and a passphrase with the echo off, writes iwd's PSK file for it, and
|
||||
connects now. It re-execs itself under `sudo` if you are not root.
|
||||
|
||||
```bash
|
||||
sh calculinux-wifi-set.sh
|
||||
```
|
||||
|
||||
It uses `iwctl` where the boot kick deliberately uses raw `iw`, and that is not a
|
||||
contradiction: the kick's problem is a cold-boot firmware race with iwd's scanner, and this
|
||||
is an interactive join on a machine that is already up.
|
||||
|
||||
### Re-running, and undoing
|
||||
|
||||
Re-running is safe and is the normal way to update: the packages, the user, the checkout,
|
||||
the venv and the PATH line are all checked first, the two helper scripts and their units are
|
||||
rewritten with identical content, `pip install -e .` runs again cheaply, and the fonts are
|
||||
rebuilt. Two things to know. Re-running under a **different `DEPLOY_USER` creates a second
|
||||
user and a second checkout** rather than moving anything, so a device provisioned under one
|
||||
name must keep being given that name. And there is **no uninstall** of any of it: to undo,
|
||||
disable `wifi-kick.service` and `time-sync.service`, delete the two helper scripts, drop the
|
||||
`FONT=` line from `/etc/vconsole.conf`, and remove the user.
|
||||
|
||||
---
|
||||
|
||||
## A real radio for the PicoCalc: XIAO nRF52840 over UART
|
||||
|
||||
The PicoCalc has no radio and no USB host to hang a companion off, so the radio is soldered
|
||||
to the header: a Seeed XIAO nRF52840 with a Wio-SX1262, running MeshCore's companion
|
||||
firmware on its own second serial port, wired to the Lyra's UART1. MeshTerm then opens
|
||||
`/dev/ttyS1` like any other serial companion — no bridge process, no BLE, no SD slot
|
||||
sacrificed.
|
||||
|
||||
```
|
||||
MeshTerm ──serial──> /dev/ttyS1 ──UART1 (GP4/GP5)──> XIAO Serial1 (D6/D7) ──> SX1262 ──))) mesh
|
||||
(on the Lyra) 115200 8N1 matrix-IO mux companion firmware
|
||||
```
|
||||
|
||||
Three steps on two machines: **build** the firmware and **flash** the XIAO from your dev
|
||||
box, then **set up the Lyra** as root.
|
||||
|
||||
### Parts and wiring
|
||||
|
||||
- A **Seeed XIAO nRF52840**, plain or Sense, stacked with the **Wio-SX1262** LoRa module.
|
||||
The SX1262 uses XIAO pins D1–D5 and D8–D10 and is already wired inside the kit.
|
||||
- A **Luckfox Lyra** in Pico form factor (RK3506, SD boot) running Calculinux in a
|
||||
ClockworkPi PicoCalc.
|
||||
- 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-compatible and each pad's function is Luckfox's own RK3506 mapping: GP4 is
|
||||
> `gpio0-0` and GP5 is `gpio0-1`. Don't read a Pico pinout for anything else here. Every
|
||||
> signal is 3.3 V, so no level shifter, and there is **no 5 V rail** on this board — power
|
||||
> the XIAO from 3V3 only.
|
||||
|
||||
### Build the firmware
|
||||
|
||||
On your dev machine, with `git` and PlatformIO (`pip install platformio`):
|
||||
|
||||
```bash
|
||||
cd scripts/xiao-radio
|
||||
sh build-firmware.sh
|
||||
```
|
||||
|
||||
It clones MeshCore into `./_meshcore-build` (override with `MESHCORE_DIR`), checks out the
|
||||
pinned commit **`e9edfc8e`** on the `dev` branch (override with `MESHCORE_COMMIT`), applies
|
||||
[`meshcore-uart1.patch`](../scripts/xiao-radio/meshcore-uart1.patch), builds the
|
||||
`Xiao_nrf52_companion_radio_serial` environment, and converts the result into
|
||||
`meshcore-xiao-radio.uf2` beside the script. The `.uf2` is a build product and is not
|
||||
committed.
|
||||
|
||||
The patch is two hunks, and both are required:
|
||||
|
||||
- **Teach the existing companion-over-UART path to build on nRF52.** MeshCore has had the
|
||||
`SERIAL_RX`/`SERIAL_TX` companion interface and per-board `*_companion_radio_serial`
|
||||
environments for a while — the ESP32-S3 twin of this board uses the *same* D6/D7 pads —
|
||||
but the shared code declares `HardwareSerial companion_serial(1)`, and on the Adafruit
|
||||
nRF52 core `HardwareSerial` is abstract with no numbered constructor. `Serial1` is the
|
||||
concrete `Uart` that core always defines and `Uart::setPins(rx, tx)` takes the same
|
||||
arguments in the same order, so one reference fixes the build and the call site is
|
||||
untouched.
|
||||
- **Move I²C off D6/D7.** The base `Xiao_nrf52` environment maps the I²C bus onto exactly
|
||||
those two pads, and both `XiaoNrf52Board::begin()` and `sensors.begin()` start `Wire` on
|
||||
them — so the I²C peripheral seizes the UART pins and the link stays dead with perfect
|
||||
wiring and a correct build. The new environment moves I²C to the internal IMU pins 16 and
|
||||
17. This was the single reason the link stayed silent through every other fix.
|
||||
|
||||
Both changes are upstream-shaped and have been submitted to MeshCore. **They are not merged
|
||||
yet**: as of 2026-09-12, upstream `dev` still carries the bare `HardwareSerial` declaration
|
||||
with no nRF52 branch, and `variants/xiao_nrf52/platformio.ini` still has no
|
||||
`companion_radio_serial` environment. So the patch step stays for now, and when it lands the
|
||||
patch and the `git apply` line can both be deleted. To track upstream instead of the pin,
|
||||
run with `MESHCORE_COMMIT=dev`; if the patch no longer applies, its two hunks are small
|
||||
enough to re-apply by hand. Only the pinned commit is known to build.
|
||||
|
||||
### Flash the XIAO
|
||||
|
||||
Plug the **XIAO's own USB-C** into your computer — it can stay wired to the Lyra — and:
|
||||
|
||||
```bash
|
||||
python flash.py
|
||||
```
|
||||
|
||||
It pulses the application port at 1200 baud, the Arduino-style touch that drops the board
|
||||
into its bootloader, then looks for a UF2 drive and copies the firmware onto it. It only
|
||||
ever touches Seeed's USB vendor id, never Adafruit's, so another nRF52840 board on the same
|
||||
bench is left alone. If the touch doesn't take, **double-tap the XIAO's reset button** and
|
||||
run it again.
|
||||
|
||||
Two things it refuses to guess. A drive letter is not an identity — when one board leaves
|
||||
the USB bus another can inherit its letter — so it reads `INFO_UF2.TXT` and declines a
|
||||
volume whose model is not a XIAO, and declines **SoftDevice S140 6.1.1**, which wants the
|
||||
application at `0x26000` while this firmware links for S140 v7 at `0x27000` (it would flash
|
||||
and then not boot). `--force` overrides both, if you know better than it does.
|
||||
|
||||
**If no UF2 drive appears at all, that is not necessarily a fault.** On the bench where this
|
||||
was developed the bootloader came up as a serial port with no mass-storage interface
|
||||
whatsoever, so there was nothing to copy a file onto. `flash.py` recognises the bootloader's
|
||||
serial port — on the plain XIAO and on the Sense — and prints the serial-DFU command to run
|
||||
from your MeshCore checkout instead:
|
||||
|
||||
```bash
|
||||
pio run -e Xiao_nrf52_companion_radio_serial -t upload --upload-port <bootloader port>
|
||||
```
|
||||
|
||||
That is the route that worked: it 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 now lives on D6/D7, not on USB. That silence is correct.
|
||||
|
||||
### Set up the Lyra
|
||||
|
||||
Copy `scripts/` to the Lyra and, as root:
|
||||
|
||||
```bash
|
||||
sh scripts/xiao-radio/lyra-setup.sh # MT_USER=meshterm by default
|
||||
```
|
||||
|
||||
It does three things. It installs [`uart1-mux.py`](../scripts/xiao-radio/uart1-mux.py) as
|
||||
`/usr/local/bin/uart1-radio-mux.py` behind a oneshot `uart1-radio-mux.service` that runs on
|
||||
every boot; it adds `$MT_USER` to `dialout` (a fresh login is needed before that counts);
|
||||
and, **only if the file is absent**, it writes a MeshTerm profile:
|
||||
|
||||
```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` is already there it prints those lines for you to paste rather than
|
||||
touching your configuration. Because `default_profile` is set, a bare `meshterm` connects
|
||||
straight to the radio.
|
||||
|
||||
The mux service is the interesting half. **Calculinux enables the UART1 controller but wires
|
||||
it to no pad**, which is why `/dev/ttyS1` exists and yet nothing ever comes out of it. The
|
||||
RK3506's matrix IO can carry UART1 on almost any pad, so `uart1-mux.py` makes three 32-bit
|
||||
writes through a one-page `/dev/mem` mapping: two RMIO signal-select words that put UART1-TX
|
||||
on `gpio0-0` and UART1-RX on `gpio0-1`, and one PMU IOC word that switches those two pins
|
||||
into matrix mode. The writes are idempotent, which is why a oneshot at boot is enough, and
|
||||
they need root and a kernel that permits `/dev/mem` writes to that range. Choosing those two
|
||||
pads is what saves the front SD-card slot: UART1's *default* pads belong to SPI1, which
|
||||
drives the slot, so the mux avoids them entirely.
|
||||
|
||||
> The register addresses, the signal ids and the claim that `gpio0-0`/`gpio0-1` are header
|
||||
> GP4/GP5 come from the maintainer's live probing of one board. They are **not verified
|
||||
> against an RK3506 reference manual**, and neither is the assumption that this kernel allows
|
||||
> those `/dev/mem` writes. See [What is verified and what is
|
||||
> not](#what-is-verified-and-what-is-not).
|
||||
|
||||
### Run it, and check the link
|
||||
|
||||
```bash
|
||||
meshterm
|
||||
```
|
||||
|
||||
Your node should come up with whatever name the firmware adverts — the device page and the
|
||||
dashboard header both show it. If you want to prove the wire itself before blaming anything
|
||||
else, route the mux, open `/dev/ttyS1` at 115200 as root and send an `APP_START` frame
|
||||
(`3c 0e 00 01`, seven zero bytes, then a name); a live radio answers with a framed `05`,
|
||||
`SELF_INFO`. The frame's command byte and reply code are the companion protocol's; the
|
||||
`<`/`>` plus little-endian length framing around them is from the firmware and is worth
|
||||
confirming against it rather than taking from here.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| `meshterm` can't open the port | Is `MT_USER` in `dialout`? (`groups`) — it needs a fresh login after setup. |
|
||||
| Port opens but nothing is there | Re-flash with the **`_serial`** environment, not `_ble` or `_usb`; only that one defines `SERIAL_RX`/`SERIAL_TX`. The XIAO's USB serial should be *silent* to companion frames. |
|
||||
| Still silent, wiring confirmed | Confirm the firmware carries the **I²C remap** (`PIN_WIRE_SCL=16`, `PIN_WIRE_SDA=17`). This is the failure that looks like bad wiring and isn't. |
|
||||
| Flashed fine, board never comes back | Wrong board, or the wrong SoftDevice — `INFO_UF2.TXT` must say a XIAO and **S140 v7**, not 6.1.1. `flash.py` refuses both without `--force`. |
|
||||
| `flash.py` finds no UF2 drive | Expected on a CDC-only bootloader. Use the `pio … -t upload --upload-port` line it prints. |
|
||||
| Nothing on `/dev/ttyS1` after a reboot | `systemctl status uart1-radio-mux` first, then `journalctl -b -u uart1-radio-mux` — the poke must run every boot, and a oneshot that failed says why. |
|
||||
| MeshTerm says the radio is unplugged | Use a MeshTerm build with the soldered-UART liveness fix: pyserial's port enumeration doesn't list platform-bus ports like `/dev/ttyS1`, so liveness stats the device node instead. It is in this repo. |
|
||||
|
||||
---
|
||||
|
||||
## The SPI radio bridge
|
||||
|
||||
On some boards the LoRa chip hangs straight off the host's SPI bus — the ClockworkPi
|
||||
uConsole with the hackergadgets AIO, Waveshare LoRa HATs, and similar. There is **no
|
||||
companion microcontroller and no firmware to flash**, so there is no node for MeshTerm to
|
||||
talk to: the mesh node itself has to run in software on the host, through the `openhop_core`
|
||||
library — `pymc_core` until its 2026 rename, and on the uConsole it arrives with the
|
||||
`meshcore-uconsole` package, alongside its own `meshcore-console` GUI.
|
||||
|
||||
[`scripts/meshterm-spi-bridge`](../scripts/meshterm-spi-bridge) fills that gap. It runs the
|
||||
software node and puts the **standard companion protocol in front of it on a local TCP
|
||||
port**, so
|
||||
|
||||
```bash
|
||||
meshterm --tcp 127.0.0.1:5000
|
||||
```
|
||||
|
||||
connects exactly as if a companion were plugged in. One file is both halves: a setup menu
|
||||
that runs under any Python 3, and the bridge process itself, which the menu re-execs under
|
||||
whichever interpreter has the radio library. That split is deliberate — the library lives in
|
||||
a packaged virtual environment, and you should never need to know that.
|
||||
|
||||
It runs on the radio host as **your normal user, never root**: the node identity and the
|
||||
service are per-user.
|
||||
|
||||
### Before you start: which runtime you have
|
||||
|
||||
The library was renamed in 2026: the `pyMC_core` project became `openhop_core`, PyPI's
|
||||
`pymc-core` stopped at 1.0.12, and `openhop-core` carries on from 1.1.x. The bridge drives
|
||||
**either**, and prefers the newer one when both are installed. Its preflight names the
|
||||
runtime it found beside the interpreter it will run under:
|
||||
|
||||
```
|
||||
[ ok ] node runtime /home/you/.local/share/meshterm-spi-bridge/venv/bin/python (openhop_core)
|
||||
```
|
||||
|
||||
Under `openhop_core` two of the bridge's three compatibility shims stand down, because the
|
||||
library now does their work itself — it accepts the longer ACK payloads modern firmware
|
||||
sends, and it pushes a completed trace reply to the client. The bridge logs each as
|
||||
`not needed` at startup. The third, the raw RX-log push that feeds MeshTerm's live feed, is
|
||||
still the bridge's, and still logs `compat:` when it is wired.
|
||||
|
||||
**Which runtime you get depends on your Debian release.** The `meshcore-uconsole` package
|
||||
from 1.12.0 is built for *trixie* against Python 3.13; on a *bookworm* uConsole (Python
|
||||
3.11) that package will not run — its virtual environment points at an interpreter the
|
||||
system does not have — and apt will offer it anyway. Do not take the upgrade. Give the
|
||||
bridge a runtime of its own instead, in the directory the preflight looks in first:
|
||||
|
||||
```bash
|
||||
python3 -m venv ~/.local/share/meshterm-spi-bridge/venv
|
||||
~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]"
|
||||
```
|
||||
|
||||
The GUI keeps its own stack, and the bridge still loads the GUI's identity key from
|
||||
`~/.local/share/meshcore-uconsole/identity.key`, so it is the same node either way. Without
|
||||
the `meshcore_console` helpers in that venv the bridge configures the radio itself, with
|
||||
the uConsole AIO preset (SPI bus 1, reset 25, busy 24, IRQ 26, no TX-enable pin, the DIO2
|
||||
RF switch and the DIO3 TCXO on, 910.525 MHz) and every `MESHCORE_*` override the GUI
|
||||
honours — see [Hardware knobs](#hardware-knobs). Verified on a bookworm uConsole with
|
||||
`openhop-core` 1.1.3: the radio initialises, the contacts come back, and MeshTerm reads
|
||||
`info` and `contacts` over the TCP port. On a pre-rename install — `pymc-core` 1.0.x —
|
||||
everything below applies as written, all three shims included.
|
||||
|
||||
### Setting it up
|
||||
|
||||
```bash
|
||||
./meshterm-spi-bridge # the interactive setup menu
|
||||
./meshterm-spi-bridge --check # run the prerequisite checks and exit
|
||||
./meshterm-spi-bridge --run # run the bridge now, in this terminal
|
||||
```
|
||||
|
||||
The preflight is worth running first. It finds the interpreter that has the radio library,
|
||||
checks that a `/dev/spidev*` node and `/dev/gpiochip0` are readable and writable by you —
|
||||
the uConsole needs `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt` for the first, and
|
||||
membership of the `spi` and `gpio` groups for both — and tells you if the GUI is holding the
|
||||
radio or if something is already listening on the port.
|
||||
|
||||
The menu then offers to **run the bridge in the foreground** (Ctrl+C returns to the menu),
|
||||
to **install it as a service**, to **uninstall** it, or to show its status. Installing copies
|
||||
the script to `~/.local/bin/` and writes a `systemd --user` unit that restarts on failure and
|
||||
starts with your session; it also asks systemd to enable lingering for your user, which is
|
||||
what makes the service come up at boot rather than at your first login, and prints the `sudo`
|
||||
command if it cannot. Either way it offers to add a MeshTerm profile for you:
|
||||
|
||||
```toml
|
||||
[profiles.bridge]
|
||||
host = "127.0.0.1"
|
||||
tcp_port = 5000
|
||||
description = "Local SPI radio via meshterm-spi-bridge"
|
||||
```
|
||||
|
||||
It sets no `default_profile`, so a bare `meshterm` offers the bridge in the picker rather
|
||||
than connecting to it behind your back. Uninstalling removes the service and the copied
|
||||
script and nothing else: it never touches the `meshcore-console` or radio-library packages,
|
||||
and it deliberately leaves your identity and contact files, and the lingering setting, where
|
||||
they are.
|
||||
|
||||
### Identity, and one program at a time
|
||||
|
||||
**Only one program may use the radio at a time.** Run *either* the `meshcore-console` GUI or
|
||||
this bridge, never both — the preflight warns you when the GUI is up, and refuses to start a
|
||||
second bridge over a running service.
|
||||
|
||||
Which node you are depends on what is installed. Where the `meshcore-console` stack is
|
||||
present the bridge loads that stack's identity key, so **MeshTerm is the same node as the
|
||||
GUI** and your contacts and your public key follow you between them. On a library-only
|
||||
install it mints its own key the first time and persists it under
|
||||
`~/.local/share/meshterm-spi-bridge/`, so the node is stable across restarts. Contacts are
|
||||
snapshotted to a file in that same directory on every change, because the library's contact
|
||||
store is memory-only — without that, direct messages to a contact you added before a restart
|
||||
came back "not found".
|
||||
|
||||
### Hardware knobs
|
||||
|
||||
The defaults match the hackergadgets uConsole AIO: SPI bus 1, chip-select 0, reset on 25,
|
||||
busy on 24, IRQ on 26, 910.525 MHz, 22 dBm, SF7, 62.5 kHz, coding rate 4/5. For another SPI
|
||||
board, export what differs before launching.
|
||||
|
||||
| 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 uConsole), and the Waveshare HAT's own wiring |
|
||||
| `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 direct path 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. Under the
|
||||
`meshcore_console` helpers the GUI's own configuration reads the same names.
|
||||
|
||||
Where the `meshcore-console` stack is installed, the bridge builds the radio through *its*
|
||||
hardware configuration, which reads a longer list of variables — transmit/receive enable
|
||||
pins, the GPIO chip, the Waveshare and DIO options, and the enable pins an AIO v2 needs to
|
||||
power its radio at all. Those come from 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.
|
||||
|
||||
### What works through the bridge
|
||||
|
||||
The bridge exposes the companion protocol as the radio library implements it, plus two
|
||||
pushes of its own. In MeshTerm's terms: messaging, contacts, channels, adverts, device
|
||||
configuration, traces and the live packet feed all work. Three things do not, because the
|
||||
library has no handler for them — **reboot and factory reset have no effect, and a device
|
||||
PIN cannot be set**. MeshTerm reports the device model as `pyMC-spi-bridge`, which is a
|
||||
useful way to tell a bridged node from a real companion in the logs.
|
||||
|
||||
Traces and the live feed reach MeshTerm through compatibility shims the bridge installs on
|
||||
the library: it subscribes to raw packets to feed the live feed, reassembles a completed
|
||||
trace reply and pushes it, and relaxes an acknowledgement length check that newer firmware
|
||||
trips by appending bytes after the CRC. None of that is MeshTerm's protocol being bent —
|
||||
`pymc_core` simply never registered those two pushes. `openhop_core` has since taken the
|
||||
ACK relaxation and the trace push upstream, and under it the bridge installs only the raw
|
||||
RX-log shim, logging the other two as `not needed`. Their **on-air behaviour was validated
|
||||
on one bench**, against the radios that were in range of it.
|
||||
|
||||
---
|
||||
|
||||
## The unfinished UART-to-TCP pump
|
||||
|
||||
[`calculinux-radio-bridge.sh`](../scripts/calculinux-radio-bridge.sh) is a work in progress
|
||||
and is not a supported route. It was written to put the UART-attached XIAO radio on a TCP
|
||||
port so other machines could reach it, before the direct-serial path above existed, and it
|
||||
is superseded by `lyra-setup.sh` for on-device use. As it stands its own header describes a
|
||||
pin-routing phase that the body does not contain, on a theory of which pads carry UART1 that
|
||||
`uart1-mux.py` later replaced; it binds to every interface by default, unauthenticated; and
|
||||
it writes a competing default profile into the same `config.toml` that `lyra-setup.sh`
|
||||
writes. Read it if you want the shape of a UART-to-TCP pump — a single-client pump is the
|
||||
right shape, since companion frames from two clients would interleave — but don't run it
|
||||
expecting a finished tool.
|
||||
|
||||
---
|
||||
|
||||
## What is verified and what is not
|
||||
|
||||
Most of what this manual explains is checked in code: the platform spec, the font and
|
||||
palette contracts (a test parses the font script itself and pins it to MeshTerm's glyph
|
||||
inventory), the profile keys the scripts write, the soldered-UART liveness fix, and every
|
||||
interface the SPI bridge calls in `pymc_core` 1.0.12 and in `openhop_core` 1.1.3 (the
|
||||
latter on a live uConsole: radio up, contacts restored, `info` and `contacts` answered over
|
||||
TCP). The upstream firmware state is checked
|
||||
too: the pinned MeshCore commit exists, the patch's two hunks are still needed on `dev`
|
||||
today, and the nRF52840 UF2 family id and both XIAO bootloader ids match Adafruit's board
|
||||
files.
|
||||
|
||||
The rest is one maintainer's bench, reported honestly rather than dressed up. None of the
|
||||
following is confirmable from code in this repository, 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. 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 does run at every boot.
|
||||
- **The PicoCalc header pin numbers** 6, 7, 8 and 36 for GP4, GP5, GND and 3V3 on the Lyra
|
||||
carrier. The Pico-side numbering is right; the carrier's wiring is a bench observation.
|
||||
- **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 rtl8xxxu
|
||||
cold-boot race with iwd, the opkg feed's occasional 404, the absolute `ip` and `iw` paths,
|
||||
that the stock Terminus console font is installed, and that `kbd` brings `setvtrgb`.
|
||||
- **That this board has no RTC.** Only the script says so, and everything about the clock
|
||||
phase follows from it.
|
||||
- **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.** It is unmerged; only
|
||||
the pinned commit is known to build.
|
||||
- **MeshCore's serial framing** around the raw `APP_START` check above. The command byte and
|
||||
the `SELF_INFO` reply code are verified against the protocol documentation; the framing
|
||||
bytes are not.
|
||||
- **Everything on-air about the SPI bridge** — the per-hop trace semantics it reassembles and
|
||||
the acknowledgement bytes "newer firmware" appends. The code is internally consistent with
|
||||
the library's API; the wire behaviour was seen on one mesh.
|
||||
- **The bridge under `openhop_core` beyond `info` and `contacts`.** Startup, identity,
|
||||
contact restore and those two reads were exercised on a uConsole; 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.
|
||||
- **The framebuffer console's 512-glyph cap.** Well known, taken as given, not re-measured
|
||||
here.
|
||||
|
||||
If you have the hardware and any of it is wrong on your bench, that is worth an issue — the
|
||||
scripts encode a set of discoveries, and a second device is the only way to learn which of
|
||||
them were about the hardware and which were about one unit.
|
||||
@@ -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
|
||||
|
||||
@@ -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.2.5"
|
||||
__version__ = "0.3.3"
|
||||
__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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
**MeshTerm** is a full-featured **MeshCore** client for the terminal. It talks to companion devices running the stock firmware over serial, Bluetooth, or TCP.
|
||||
|
||||
Everything MeshTerm can do thru its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks!
|
||||
Nearly everything MeshTerm can do 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!
|
||||
|
||||
Everything the radio overhears is recorded to a local database from the moment it's turned on — adverts, telemetry, signal quality, positions, etc. Features like maps, dashboard, and route graphs read back from it, so the longer it runs, the more it know about the mesh!
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+351
-100
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Typer entry point.
|
||||
|
||||
Global options are parsed once in the callback, which builds the shared
|
||||
@@ -15,64 +16,108 @@ from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from typer.core import TyperGroup
|
||||
|
||||
from . import __version__
|
||||
from .context import AppContext
|
||||
from .core import win32dll
|
||||
from .core import exitcodes, win32dll
|
||||
from .core.admin_store import AdminStore
|
||||
from .core.config import Settings
|
||||
from .core.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
|
||||
from .platforms import Resolution, resolve, set_platform, without_emoji
|
||||
from .tools import all_tools
|
||||
from .tools.base import Tool, ToolResult
|
||||
from .ui import renderers, script
|
||||
from .ui.renderers import OutputFormat
|
||||
from .ui.termfont import emoji_support
|
||||
from .ui.theme import make_console
|
||||
|
||||
#: The exit-status table, printed under every ``--help``. A documented return value is
|
||||
#: half of what makes the CLI scriptable (the other half is :mod:`meshterm.ui.script`),
|
||||
#: and a caller should not have to find it in a README.
|
||||
EXIT_STATUS_EPILOG = "Exit status: " + " · ".join(
|
||||
f"{code} {meaning}" for code, meaning in exitcodes.MEANINGS.items()
|
||||
)
|
||||
|
||||
def _unframe_help_panels() -> None:
|
||||
"""Render Typer's help/error sections as coloured headings instead of boxed panels.
|
||||
|
||||
Typer's rich help gives us the colour we want — yellow usage, cyan options, green
|
||||
switches — but wraps each Options/Commands/Error section in a rounded :class:`Panel`,
|
||||
the boxed framing scripted output is meant to be free of. There's no constant to drop
|
||||
that box, so we swap the ``Panel`` the help formatter calls for a thin stand-in that
|
||||
keeps the colour and the section title (as a bold heading, indigo like the app accent,
|
||||
red for errors) but no border — the same heading-over-content shape tool results use
|
||||
(see :func:`~meshterm.ui.surface._deframe`). The inner tables are already box-less.
|
||||
class _GlobalOptionsAnywhere(TyperGroup):
|
||||
"""A group whose own options may be typed after the subcommand as well as before.
|
||||
|
||||
Click binds an option to whatever command declares it, so a global declared on the
|
||||
callback is a parse error one word later: ``meshterm contacts --json`` failed with a
|
||||
bare usage error while ``meshterm --json contacts`` worked. That is the wrong way
|
||||
round for an output-format flag — ``-o json`` goes *after* the verb in every tool that
|
||||
has one, and ``--json`` is the first thing anyone will reach for here.
|
||||
|
||||
So the group's own options are lifted to the front before Click sees them, and both
|
||||
orders mean the same run. ``--`` stops the lifting, which is the standard way to say
|
||||
the rest is data; ``--help`` is deliberately never lifted, since ``contacts --help``
|
||||
must stay the *contacts* help rather than silently becoming the program's.
|
||||
"""
|
||||
import typer.rich_utils as rich_utils
|
||||
from rich.console import Group
|
||||
from rich.text import Text
|
||||
|
||||
def _bare_panel(
|
||||
renderable: object,
|
||||
*,
|
||||
title: object = None,
|
||||
border_style: str = "",
|
||||
**_: object,
|
||||
) -> Group:
|
||||
style = "bold red" if border_style == "red" else "bold #818cf8"
|
||||
heading = Text(str(title), style=style) if title else Text("")
|
||||
return Group(Text(""), heading, renderable) # type: ignore[list-item]
|
||||
|
||||
rich_utils.Panel = _bare_panel # type: ignore[assignment,misc]
|
||||
def parse_args(self, ctx: typer.Context, args: list[str]) -> list[str]:
|
||||
"""Lift this group's options out of ``args``, then parse as Click normally would."""
|
||||
return super().parse_args(ctx, _globals_first(self, args))
|
||||
|
||||
|
||||
_unframe_help_panels()
|
||||
def _globals_first(group: TyperGroup, args: list[str]) -> list[str]:
|
||||
"""``args`` with ``group``'s own options moved ahead of the subcommand.
|
||||
|
||||
An option is told from an argument by carrying ``is_flag`` — Typer vendors Click, so
|
||||
there is no importable ``click.Option`` to test against, and the attribute is the same
|
||||
question asked without reaching into a private package.
|
||||
|
||||
Args:
|
||||
group: The command whose options count as global.
|
||||
args: The argument list as typed.
|
||||
|
||||
Returns:
|
||||
A reordered list. The relative order of the options, and of everything else, is
|
||||
preserved, so a repeated flag resolves exactly as Click would resolve it.
|
||||
"""
|
||||
takes_value: dict[str, bool] = {}
|
||||
for param in group.params:
|
||||
if getattr(param, "is_flag", None) is None or param.name == "help":
|
||||
continue
|
||||
for opt in (*param.opts, *param.secondary_opts):
|
||||
takes_value[opt] = not param.is_flag
|
||||
|
||||
lifted: list[str] = []
|
||||
kept: list[str] = []
|
||||
index = 0
|
||||
while index < len(args):
|
||||
token = args[index]
|
||||
if token == "--":
|
||||
kept.extend(args[index:])
|
||||
break
|
||||
name = token.split("=", 1)[0]
|
||||
if name not in takes_value:
|
||||
kept.append(token)
|
||||
index += 1
|
||||
elif "=" in token or not takes_value[name] or index + 1 >= len(args):
|
||||
lifted.append(token)
|
||||
index += 1
|
||||
else:
|
||||
lifted.extend(args[index : index + 2])
|
||||
index += 2
|
||||
return lifted + kept
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
cls=_GlobalOptionsAnywhere,
|
||||
add_completion=False,
|
||||
no_args_is_help=False,
|
||||
# Rich help for its colour (usage, option flags, metavars), but with the boxed
|
||||
# section panels flattened to plain headings by _unframe_help_panels() above — a
|
||||
# splash of colour, none of the framing.
|
||||
rich_markup_mode="rich",
|
||||
# Click's own plain help, not Typer's rich one: no boxed Options/Commands panels, no
|
||||
# colour, no markup to strip out of a piped `--help`. The CLI is read by scripts, and
|
||||
# its help is read the way every other utility's is (see meshterm.ui.script).
|
||||
rich_markup_mode=None,
|
||||
# The one description, same as the package summary and the PyPI page. See
|
||||
# `meshterm.__doc__` — if this drifts from that, one of them is lying.
|
||||
help=(
|
||||
@@ -80,8 +125,24 @@ app = typer.Typer(
|
||||
"Supports USB, Bluetooth and TCP companion connection. "
|
||||
"For Windows, macOS and Linux."
|
||||
),
|
||||
epilog=EXIT_STATUS_EPILOG,
|
||||
)
|
||||
|
||||
|
||||
def _print_version(value: bool) -> None:
|
||||
"""Print the version and stop, the moment ``--version`` is seen.
|
||||
|
||||
Eager, so it answers before the callback opens a database, resolves a platform or looks
|
||||
for a radio — the one question that is true of the program rather than of a run. Bare
|
||||
``meshterm <version>``: the caller asked for a version, and a script reading it back has
|
||||
one field to take. Exits ``0``, because knowing the version is a success.
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
script.console().print(f"meshterm {__version__}", highlight=False)
|
||||
raise typer.Exit(exitcodes.OK)
|
||||
|
||||
|
||||
# The context built by the callback and consumed by subcommands within one process.
|
||||
_state: AppContext | None = None
|
||||
|
||||
@@ -93,6 +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(
|
||||
@@ -109,6 +177,9 @@ def main_callback(
|
||||
mock: bool = typer.Option(False, "--mock", help="Use the built-in simulator"),
|
||||
db_path: Path | None = typer.Option(None, "--db", help="SQLite database path"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Machine-readable output"),
|
||||
absolute: bool = typer.Option(
|
||||
False, "--absolute", help="Print absolute timestamps instead of relative ages"
|
||||
),
|
||||
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress console logging"),
|
||||
platform: str | None = typer.Option(
|
||||
None,
|
||||
@@ -120,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.
|
||||
@@ -127,7 +199,8 @@ def main_callback(
|
||||
tcp: Explicit network address ``host[:port]``, selecting the TCP transport.
|
||||
mock: Whether to use the simulator instead of real hardware.
|
||||
db_path: Override the database location.
|
||||
json_output: Request machine-readable output from tools.
|
||||
json_output: Print the answer as JSON instead of aligned text.
|
||||
absolute: Print times as ISO-8601 instants rather than relative ages, for this run.
|
||||
quiet: Suppress console logging (file logging continues).
|
||||
platform: Explicit ``--platform`` override; see :func:`meshterm.platforms.resolve`.
|
||||
"""
|
||||
@@ -148,22 +221,55 @@ def main_callback(
|
||||
active = without_emoji(active)
|
||||
set_platform(active)
|
||||
|
||||
if mock and (port or ble or tcp):
|
||||
# One flag says "pretend", the other names one exact radio, and `--mock` used to
|
||||
# win silently — so a scheduled `--port COM7 --mock info` reported on a simulator
|
||||
# while reading as though it had reached the radio. Two contradictory claims about
|
||||
# which device to talk to is a bad command line, not a precedence question.
|
||||
named = "--port" if port else ("--ble" if ble else "--tcp")
|
||||
raise typer.BadParameter(f"--mock and {named} name different devices; pass one")
|
||||
|
||||
settings = Settings.load()
|
||||
if db_path is not None:
|
||||
settings.db_path = db_path
|
||||
|
||||
console = make_console()
|
||||
# Two front ends, two consoles, and which one this process gets is settled here — a
|
||||
# named subcommand is a scripted run, so it prints on the plain, colourless,
|
||||
# never-wrapping console the CLI's output language is written for (see
|
||||
# meshterm.ui.script); no subcommand means the menu, which needs the themed one.
|
||||
scripted = ctx.invoked_subcommand is not None
|
||||
console = script.console() if scripted else make_console()
|
||||
# Loaded before logging is configured, because how much goes in the file is one of
|
||||
# them — and handed to the context afterwards so the file is not read twice.
|
||||
prefs = Preferences.load(settings.config_dir / "preferences.yaml")
|
||||
prefs = Preferences.load(settings.config_dir / PREFERENCES_FILENAME)
|
||||
if absolute:
|
||||
# An override of the preference rather than a second switch beside it: *how
|
||||
# MeshTerm behaves* is a preference by this project's own rule, and a flag that
|
||||
# bypassed the registry would be a behaviour a reader could not find. Set, never
|
||||
# saved — it is a claim about this run.
|
||||
prefs.set("cli_time_format", "absolute")
|
||||
configure_logging(
|
||||
console,
|
||||
# A scripted run's stdout carries its answer and nothing else, so log records go
|
||||
# to stderr instead of interleaving with the data a caller is parsing.
|
||||
script.stderr_console() if scripted else console,
|
||||
settings.config_dir,
|
||||
level=logging.INFO,
|
||||
# And it only hears about faults. Every *expected* failure is already reported on
|
||||
# stderr in one line and in the exit status (see `_reported`), so echoing the same
|
||||
# thing again as a WARNING record would say it twice; the file keeps both either
|
||||
# way. The menu stays at INFO, where the log pane is the only place these show.
|
||||
level=logging.ERROR if scripted else logging.INFO,
|
||||
file_level=level_from_name(prefs.log_level),
|
||||
quiet=quiet or json_output,
|
||||
)
|
||||
|
||||
if profile is not None and settings.resolve_profile(profile) is None:
|
||||
# Falling through to ordinary discovery would pick whichever radio is attached, so
|
||||
# a typo in a scheduled `--profile yagi config advert` transmits from the wrong
|
||||
# node rather than failing. Naming a profile is a claim about *which* device, and
|
||||
# an unkeepable claim is a bad argument.
|
||||
known = ", ".join(sorted(settings.profiles)) or "none are defined"
|
||||
raise typer.BadParameter(f"no device profile named {profile!r} (known: {known})")
|
||||
|
||||
app_ctx = AppContext(
|
||||
preferences=prefs,
|
||||
console=console,
|
||||
@@ -177,7 +283,10 @@ def main_callback(
|
||||
ble_override=ble,
|
||||
tcp_override=tcp,
|
||||
ble_pin=ble_pin,
|
||||
json_output=json_output,
|
||||
output=OutputFormat.JSON if json_output else OutputFormat.PLAIN,
|
||||
# Whether a tool may stop and ask, which is a different question from what its
|
||||
# output looks like. A pipe on stdin means nobody is there to answer.
|
||||
interactive=_stdin_is_a_person(),
|
||||
explicit_selection=(
|
||||
profile is not None or port is not None or ble is not None or tcp is not None
|
||||
),
|
||||
@@ -186,6 +295,12 @@ def main_callback(
|
||||
ctx.call_on_close(app_ctx.repo.close)
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
if json_output:
|
||||
# There is no document an interactive session can emit, and launching the
|
||||
# full-screen menu for a caller that asked for JSON would hang whatever was
|
||||
# waiting to parse it.
|
||||
raise typer.BadParameter("--json needs a subcommand; the menu has no document")
|
||||
|
||||
from .ui.menu import run_menu
|
||||
|
||||
# Before prompt_toolkit takes the screen: this can change the console's own font,
|
||||
@@ -194,6 +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
|
||||
@@ -219,19 +345,26 @@ 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:
|
||||
"""On the classic Windows console, offer the reader something better than it.
|
||||
"""On the classic Windows console, get the reader somewhere better than it.
|
||||
|
||||
Two offers, in the order of how much they buy. **Windows Terminal** first, because it
|
||||
is the only one that gets the whole app: emoji icons included, which no font can put
|
||||
on the classic console — the only Windows fonts carrying emoji are proportional, and
|
||||
a console will not take one. Then, for a reader who stays, **a font** that can at
|
||||
least draw the charts and the marks.
|
||||
Two remedies, and they are not asked the same way.
|
||||
|
||||
**Windows Terminal** first, and simply done. It is the only thing that gets the whole
|
||||
app — emoji icons included, which no font can put on that console, the only Windows
|
||||
fonts carrying any being proportional and a console taking none of them. Moving there
|
||||
installs nothing and changes nothing, so there is one sensible answer to a question
|
||||
that would be put to someone who has not seen the app yet and cannot judge it.
|
||||
|
||||
**A font** second, only where the move was not possible, and that one *is* asked —
|
||||
it writes a file onto someone's machine, which is theirs to agree to.
|
||||
|
||||
Args:
|
||||
console: The console to ask on.
|
||||
console: The console to report and ask on.
|
||||
prefs: The preference set, read for a previous refusal and written on a new one.
|
||||
|
||||
Returns:
|
||||
@@ -241,24 +374,30 @@ def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences
|
||||
|
||||
if not classic_console() or prefs.get("console_setup") == "off":
|
||||
return False
|
||||
if _offer_windows_terminal(console):
|
||||
if _move_to_windows_terminal(console):
|
||||
return True
|
||||
_offer_a_font_this_console_can_draw(console, prefs)
|
||||
return False
|
||||
|
||||
|
||||
def _offer_windows_terminal(console: Console) -> bool:
|
||||
"""Offer to reopen the session in Windows Terminal, where the app looks like itself.
|
||||
def _move_to_windows_terminal(console: Console) -> bool:
|
||||
"""Reopen the session in Windows Terminal, where the app can draw all of itself.
|
||||
|
||||
Asked with yes as the default, which is not how the font offer is asked: this one
|
||||
changes nothing on the machine — no install, no setting, one new window — and it is
|
||||
the only answer that gets the reader the app as it was drawn.
|
||||
Done rather than offered. The classic console cannot show a single icon whatever it is
|
||||
given, so "reopen there?" is a question with one sensible answer, asked of someone who
|
||||
has not seen the app yet and so cannot judge it — and asked at the worst moment, before
|
||||
anything has been drawn. Moving costs nothing and changes nothing: no install, no
|
||||
setting, one window instead of another.
|
||||
|
||||
It is announced rather than silent, and the announcement names the way to stop it,
|
||||
because a program that opens a window you did not ask for should at least say so.
|
||||
``console_setup`` set to ``off`` keeps the session here for good.
|
||||
|
||||
Args:
|
||||
console: The console to ask on.
|
||||
console: The console to report on.
|
||||
|
||||
Returns:
|
||||
Whether Windows Terminal was started and this session should stand down.
|
||||
Whether Windows Terminal took over and this session should stand down.
|
||||
"""
|
||||
from .core import winterminal
|
||||
|
||||
@@ -266,17 +405,12 @@ def _offer_windows_terminal(console: Console) -> bool:
|
||||
return False
|
||||
|
||||
console.print()
|
||||
console.print("[accent]•[/accent] This is the classic Windows console, and it can only")
|
||||
console.print(" draw part of MeshTerm — no icons, and no charts.")
|
||||
console.print("[accent]•[/accent] Opening in [accent]Windows Terminal[/accent] — this console")
|
||||
console.print(" can't draw MeshTerm's icons or charts.")
|
||||
console.print("[muted] Preferences → Display → Console setup keeps it here instead.[/muted]")
|
||||
console.print()
|
||||
console.print(" [accent]Windows Terminal[/accent] draws all of it, and you have it.")
|
||||
console.print(" MeshTerm can reopen itself there now. Nothing is installed")
|
||||
console.print(" and nothing is changed; it is just a better window.")
|
||||
console.print()
|
||||
if not _asks_yes(console, " Reopen in Windows Terminal? [Y/n] ", default=True):
|
||||
return False
|
||||
|
||||
if winterminal.reopen():
|
||||
console.print("[ok]✓[/ok] Reopening in Windows Terminal.")
|
||||
get_logger().info("reopened the session in Windows Terminal")
|
||||
return True
|
||||
console.print("[warn]⚠[/warn] Windows Terminal would not start. Staying here.")
|
||||
@@ -446,23 +580,38 @@ def platform_command() -> None:
|
||||
the flavour and the usual reason a Windows session looks plainer than the screenshots
|
||||
(see :func:`meshterm.ui.termfont.emoji_support`).
|
||||
"""
|
||||
assert _platform_resolution is not None # set by the callback that always runs first
|
||||
r = _platform_resolution
|
||||
console = make_console()
|
||||
console.print(f"platform: [accent]{r.platform.name}[/accent] (source: {r.source})")
|
||||
console.print(f" --platform: {r.flag or '(not passed)'}")
|
||||
console.print(f" MESHTERM_PLATFORM: {r.env or '(not set)'}")
|
||||
console.print(f" /proc/device-tree/model: {r.detected_model or '(unavailable)'}")
|
||||
from .ui import fields
|
||||
from .ui.report import Facts
|
||||
|
||||
assert _platform_resolution is not None # set by the callback that always runs first
|
||||
assert _state is not None # ditto
|
||||
r = _platform_resolution
|
||||
emoji = emoji_support()
|
||||
verdict = "yes" if emoji.supported else "no — icons use the compact glyphs"
|
||||
console.print(f"icons: [accent]{verdict}[/accent] (source: {emoji.source})")
|
||||
if not emoji.supported and emoji.source == "console":
|
||||
console.print(
|
||||
" this is the classic Windows console, which draws no emoji whatever font\n"
|
||||
" it is set to. Windows Terminal and VS Code's terminal both do — running\n"
|
||||
" MeshTerm in one gets the icons back. MESHTERM_EMOJI=1 overrides this."
|
||||
renderers.for_format(_state.output, _state.console).render(
|
||||
(
|
||||
Facts(
|
||||
key="platform",
|
||||
fields=(
|
||||
fields.word("platform", "platform"),
|
||||
fields.word("platform_source", "platform_source"),
|
||||
fields.word("flag", "flag"),
|
||||
fields.word("env", "env"),
|
||||
fields.word("device_tree_model", "device_tree_model"),
|
||||
fields.flag("icons", "icons"),
|
||||
fields.word("icons_source", "icons_source"),
|
||||
),
|
||||
values={
|
||||
"platform": r.platform.name,
|
||||
"platform_source": r.source,
|
||||
"flag": r.flag or None,
|
||||
"env": r.env or None,
|
||||
"device_tree_model": r.detected_model or None,
|
||||
"icons": emoji.supported,
|
||||
"icons_source": emoji.source,
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.command(name="specimen")
|
||||
@@ -473,9 +622,26 @@ def specimen_command() -> None:
|
||||
theme and glyph machinery the TUI uses — so on the PicoCalc console it is the
|
||||
font/palette acceptance screen, and with ``--platform picocalc`` on a desktop it
|
||||
previews that flavour. See :mod:`meshterm.ui.specimen`.
|
||||
|
||||
The one command that keeps its colour, and it builds its own themed console to do it
|
||||
(rather than the plain one every other subcommand prints on — see
|
||||
:mod:`meshterm.ui.script`). This is not scripted output that happens to be pretty: the
|
||||
colour *is* the output. A monochrome specimen would test nothing.
|
||||
|
||||
It is also the one command that *refuses* ``--json``, loudly and as a usage error.
|
||||
There is no data behind a colour card, so a document of it would be either a lie or an
|
||||
empty gesture — and a loud refusal is the same answer this project already gives for
|
||||
the map, the dashboard and the live feed, which simply have no subcommand to refuse
|
||||
from.
|
||||
"""
|
||||
from .ui.specimen import specimen_lines
|
||||
|
||||
assert _state is not None # set by the callback that always runs first
|
||||
if _state.output is not OutputFormat.PLAIN:
|
||||
raise typer.BadParameter(
|
||||
"specimen has no machine-readable output — its output is the colour"
|
||||
)
|
||||
|
||||
console = make_console()
|
||||
for line in specimen_lines():
|
||||
console.print(line)
|
||||
@@ -493,44 +659,119 @@ def run_tool_command(tool: Tool, params: dict) -> None:
|
||||
"""
|
||||
assert _state is not None # set by the callback before any subcommand runs
|
||||
try:
|
||||
asyncio.run(_drive(_execute_and_render(tool, params, _state), _state))
|
||||
except (DeviceSelectionError, DeviceConfigError, DeviceCommandError, PreferenceError) as exc:
|
||||
# Expected user-facing error (ambiguous/absent device, a bad config or preference
|
||||
# value, or a transient command failure): show the message, not a traceback. Logged
|
||||
# at warning because it is a thing that went wrong, even though it is an ordinary
|
||||
# one — a log that only holds crashes cannot answer "what happened before it".
|
||||
get_logger().warning("%s failed: %s", tool.name, exc)
|
||||
_state.console.print(f"[err]✗[/err] {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
result = asyncio.run(_drive(_execute_and_render(tool, params, _state), _state))
|
||||
except DeviceSelectionError as exc:
|
||||
# No companion could be picked at all: nothing was transmitted, and no retry is
|
||||
# going to help until the caller attaches a device or names one.
|
||||
raise _reported(tool, exc, exitcodes.NO_DEVICE) from exc
|
||||
except DeviceCommandError as exc:
|
||||
# A device was reached and the operation failed. Worth retrying.
|
||||
raise _reported(tool, exc, exitcodes.DEVICE) from exc
|
||||
except (DeviceConfigError, PreferenceError) as exc:
|
||||
# A value we were given is wrong — a bad setting key, an out-of-range preference.
|
||||
# Retrying changes nothing; the caller has to change what it asked for.
|
||||
raise _reported(tool, exc, exitcodes.FAILURE) from exc
|
||||
except (typer.BadParameter, typer.Abort, typer.Exit):
|
||||
# A tool that rejected one of its own arguments (an unresolvable `--to`, a
|
||||
# malformed path) raised the same thing the parser would have. It is a usage
|
||||
# error, Click already knows how to print it and what to exit with, and it is not
|
||||
# a fault — so it passes through without a traceback or a pointer to the log.
|
||||
raise
|
||||
except Exception as exc:
|
||||
# A dropped serial link (device unplugged/powered off mid-command) can't be recovered
|
||||
# from in a one-shot scripted run the way the interactive menu offers — but it should
|
||||
# still read as a clean message, not a traceback.
|
||||
# A dropped serial link (device unplugged/powered off mid-command) can't be
|
||||
# recovered from in a one-shot scripted run the way the interactive menu offers —
|
||||
# but it reads as a clean message, not a traceback, and as a device failure.
|
||||
if is_connection_lost(exc):
|
||||
get_logger().warning("%s lost the device connection: %s", tool.name, exc)
|
||||
_state.console.print(
|
||||
"[err]✗[/err] the connection to your device was lost "
|
||||
"(it may have been unplugged or powered off)."
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
raise _reported(
|
||||
tool,
|
||||
"the connection to the device was lost (it may have been unplugged or powered off)",
|
||||
exitcodes.DEVICE,
|
||||
) from exc
|
||||
# Anything else is a real fault. It still reaches the terminal as a traceback,
|
||||
# because a person looking at one wants to see it — but it also lands in the log,
|
||||
# which is the copy that survives the terminal being closed and is the one thing
|
||||
# worth attaching to a bug report.
|
||||
get_logger().exception("%s raised an unhandled error", tool.name)
|
||||
_report_log_location(_state.console, _state.settings.config_dir)
|
||||
_report_log_location(script.stderr_console(), _state.settings.config_dir)
|
||||
raise
|
||||
if result.exit_code != exitcodes.OK:
|
||||
raise typer.Exit(result.exit_code)
|
||||
|
||||
|
||||
async def _drive(coro, ctx: AppContext) -> None:
|
||||
def _stdin_is_a_person() -> bool:
|
||||
"""Whether somebody is there to answer a prompt.
|
||||
|
||||
The test a tool actually wants before it stops and asks: a redirected or piped stdin
|
||||
means nobody is watching, and a command that blocks there has hung rather than failed.
|
||||
``tx-optimize`` used to ask ``--json`` instead — a flag about output format, which
|
||||
answers a different question and answered this one wrong in both directions.
|
||||
|
||||
``isatty`` alone is not enough on Windows, and the gap is exactly the case this exists
|
||||
for. ``NUL`` is a *character device*, so a scheduled task's empty stdin answers yes —
|
||||
and :func:`getpass.getpass` on Windows then reads the console directly through
|
||||
``msvcrt`` rather than through stdin, and waits forever for a keypress nobody is there
|
||||
to make. ``GetConsoleMode`` succeeds only on a real console handle, which is the
|
||||
question being asked.
|
||||
|
||||
Returns:
|
||||
``True`` only where stdin is a terminal somebody could type into.
|
||||
"""
|
||||
try:
|
||||
if not sys.stdin.isatty():
|
||||
return False
|
||||
except (AttributeError, ValueError): # pragma: no cover - stdin closed or replaced
|
||||
return False
|
||||
if sys.platform != "win32":
|
||||
return True
|
||||
try: # pragma: no cover - the branch is Windows-only and needs a real handle
|
||||
import ctypes
|
||||
import msvcrt
|
||||
|
||||
mode = ctypes.c_uint()
|
||||
handle = msvcrt.get_osfhandle(sys.stdin.fileno())
|
||||
return bool(win32dll.kernel32().GetConsoleMode(handle, ctypes.byref(mode)))
|
||||
except Exception: # pragma: no cover - no console, or a stubbed kernel32
|
||||
return False
|
||||
|
||||
|
||||
def _reported(tool: Tool, problem: object, code: int) -> typer.Exit:
|
||||
"""Log a failure, say so on stderr, and build the exit it should end on.
|
||||
|
||||
The message goes to stderr in the ``program: what went wrong`` shape every utility
|
||||
uses, so a caller redirecting stdout still sees it and a caller parsing stdout never
|
||||
has to filter it out. It carries no mark and no colour — the exit status is what says
|
||||
this failed, which is why it is classified (see :mod:`meshterm.core.exitcodes`).
|
||||
|
||||
It is logged at warning rather than error because these are the *ordinary* failures —
|
||||
an absent device, a wrong password — and a log holding only crashes cannot answer
|
||||
"what happened just before it".
|
||||
|
||||
Args:
|
||||
tool: The tool that failed, for the log line.
|
||||
problem: The exception or message to report.
|
||||
code: The status to exit with.
|
||||
|
||||
Returns:
|
||||
The :class:`typer.Exit` for the caller to raise.
|
||||
"""
|
||||
get_logger().warning("%s failed: %s", tool.name, problem)
|
||||
script.stderr_console().print(f"meshterm: {problem}", style="err", highlight=False)
|
||||
return typer.Exit(code)
|
||||
|
||||
|
||||
async def _drive(coro, ctx: AppContext): # noqa: ANN201 - passes the awaited value through
|
||||
"""Await a coroutine then disconnect the device (but keep the repo open).
|
||||
|
||||
Args:
|
||||
coro: The coroutine to run (a tool execution or the menu loop).
|
||||
ctx: The application context whose device should be closed afterward.
|
||||
|
||||
Returns:
|
||||
Whatever ``coro`` returned — a :class:`~meshterm.tools.base.ToolResult` on the
|
||||
scripted path, whose ``exit_code`` the caller turns into the process status.
|
||||
"""
|
||||
try:
|
||||
await coro
|
||||
return await coro
|
||||
finally:
|
||||
if ctx._device is not None:
|
||||
try:
|
||||
@@ -541,7 +782,18 @@ async def _drive(coro, ctx: AppContext) -> None:
|
||||
|
||||
|
||||
async def _execute_and_render(tool: Tool, params: dict, ctx: AppContext) -> ToolResult:
|
||||
"""Run a tool and print its closing message and artifact list.
|
||||
"""Run a tool, render the answer it stated, and close with what it has to say.
|
||||
|
||||
The one place a report becomes output. The tool says what happened as data and this
|
||||
hands it to whichever renderer ``--json`` selected, exactly as ``exit_code`` is stated
|
||||
here and turned into a process status by the caller — which is what lets a third format
|
||||
be a new renderer rather than an edit to twenty tools.
|
||||
|
||||
A tool's ``message`` — "✓ applied 3 changes", "✓ traced Alice" — goes to **stderr**,
|
||||
like every other acknowledgement (see :meth:`~meshterm.ui.surface.PlainUi.ack`). It is
|
||||
the closing line rather than the answer, so it must stay out of a redirect; it is also
|
||||
the count a person at a prompt actually wanted after a command that scrolled, so
|
||||
dropping it was throwing it away to protect a stream it was never going to reach.
|
||||
|
||||
Args:
|
||||
tool: The tool to execute.
|
||||
@@ -552,10 +804,9 @@ async def _execute_and_render(tool: Tool, params: dict, ctx: AppContext) -> Tool
|
||||
The tool's :class:`ToolResult`.
|
||||
"""
|
||||
result = await tool.execute(ctx, params)
|
||||
renderers.for_format(ctx.output, ctx.console).render(result.report)
|
||||
if result.message:
|
||||
ctx.console.print(result.message)
|
||||
for artifact in result.artifacts:
|
||||
ctx.console.print(f"[ok]●[/ok] wrote [accent]{artifact}[/accent]")
|
||||
ctx.ui.ack(result.message)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
+46
-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
|
||||
@@ -30,6 +31,7 @@ from .core.settings_store import SettingsStore
|
||||
from .core.watch_store import WatchStore
|
||||
from .persistence.logging import get_logger
|
||||
from .persistence.repository import Repository
|
||||
from .ui.renderers import OutputFormat
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .services.advert_scheduler import AdvertScheduler
|
||||
@@ -54,7 +56,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.
|
||||
@@ -91,7 +93,16 @@ class AppContext:
|
||||
interactive picker), selecting the TCP transport. Takes precedence over
|
||||
``ble_override`` and ``port_override``.
|
||||
ble_pin: Optional BLE pairing PIN for the chosen Bluetooth device.
|
||||
json_output: Whether tools should emit machine-readable output.
|
||||
output: Which format a scripted run prints its answer in (see
|
||||
:class:`~meshterm.ui.renderers.OutputFormat`). A tool never reads this to
|
||||
decide *what* to say — it states its answer as a
|
||||
:class:`~meshterm.ui.report.Report` and the CLI boundary picks the renderer —
|
||||
and the two streaming commands read it only to open the right kind of stream.
|
||||
interactive: Whether this run may stop and ask a person something. False for every
|
||||
scripted run whose stdin is not a terminal, which is the question a tool
|
||||
actually wants answered before it prompts. ``tx-optimize`` used to ask
|
||||
``--json`` instead, and a flag about *output format* cannot answer it: without
|
||||
the flag, a scheduled run on a terminal would sit waiting for a password.
|
||||
selected_device: The discovered device chosen for this session, when known, so it
|
||||
can be remembered after a successful connection.
|
||||
explicit_selection: Whether ``--port``/``--ble``/``--profile`` was passed explicitly
|
||||
@@ -118,7 +129,8 @@ class AppContext:
|
||||
ble_override: str | None = None
|
||||
tcp_override: str | None = None
|
||||
ble_pin: str | None = None
|
||||
json_output: bool = False
|
||||
output: OutputFormat = OutputFormat.PLAIN
|
||||
interactive: bool = True
|
||||
selected_device: DiscoveredDevice | None = None
|
||||
explicit_selection: bool = False
|
||||
_device: Device | None = field(default=None, init=False, repr=False)
|
||||
@@ -157,7 +169,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")
|
||||
@@ -439,6 +451,31 @@ class AppContext:
|
||||
getattr(device, "endpoint", None) if (self._active_transport == "tcp") else None
|
||||
)
|
||||
|
||||
async def _open(self, what: str) -> None:
|
||||
"""Open :attr:`_device`, reporting a failure to open as a *selection* failure.
|
||||
|
||||
The two device exit statuses answer different questions — ``NO_DEVICE`` says
|
||||
nothing was transmitted and the caller should look at what is plugged in;
|
||||
``DEVICE`` says the radio was reached and the operation failed, so retrying is
|
||||
reasonable. Every failure on this path is the first kind: the connection does not
|
||||
exist yet, so nothing has been sent over it. It used to come back as ``DEVICE``
|
||||
with the message "the connection to the device was lost", which describes a
|
||||
connection there had never been — and ``--port NOSUCHPORT`` is the most common way
|
||||
to reach this line.
|
||||
|
||||
Args:
|
||||
what: The thing being opened, for the message (a port, an address, a host).
|
||||
|
||||
Raises:
|
||||
DeviceSelectionError: If the connection cannot be established.
|
||||
"""
|
||||
from .core.selection import DeviceSelectionError
|
||||
|
||||
try:
|
||||
await self._device.connect()
|
||||
except Exception as exc: # noqa: BLE001 - reclassified, then re-raised
|
||||
raise DeviceSelectionError(f"could not open {what}: {exc}") from exc
|
||||
|
||||
async def device(self) -> Device:
|
||||
"""Return a connected :class:`Device`, opening the connection on first use.
|
||||
|
||||
@@ -462,7 +499,7 @@ class AppContext:
|
||||
self._active_port = None
|
||||
self._active_address = None
|
||||
self._active_endpoint = None
|
||||
await self._device.connect()
|
||||
await self._open("the simulator")
|
||||
return self._device
|
||||
|
||||
# A network endpoint (an explicit ``--tcp``, a TCP profile, a device picked at startup,
|
||||
@@ -477,7 +514,7 @@ class AppContext:
|
||||
self._active_endpoint = f"{tcp_host}:{tcp_port}"
|
||||
self._active_port = None
|
||||
self._active_address = None
|
||||
await self._device.connect()
|
||||
await self._open(f"TCP companion {tcp_host}:{tcp_port}")
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
return self._device
|
||||
@@ -511,7 +548,7 @@ class AppContext:
|
||||
self._active_address = ble_address
|
||||
self._active_port = None
|
||||
self._active_endpoint = None
|
||||
await self._device.connect()
|
||||
await self._open(f"BLE companion {ble_address}")
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
return self._device
|
||||
@@ -530,7 +567,7 @@ class AppContext:
|
||||
self._active_port = resolution.port
|
||||
self._active_address = None
|
||||
self._active_endpoint = None
|
||||
await self._device.connect()
|
||||
await self._open(f"serial port {resolution.port}")
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
return self._device
|
||||
|
||||
@@ -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
|
||||
|
||||
+238
-36
@@ -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()
|
||||
@@ -1825,7 +1897,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 +1926,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 +2159,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 +2284,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 +2542,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 +2685,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 +2703,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 +2904,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 +2945,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 +3004,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 +3066,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 +3182,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 +3242,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 +3261,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,3 +1,4 @@
|
||||
# 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
|
||||
@@ -529,7 +530,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)))
|
||||
|
||||
@@ -607,7 +608,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+145
-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,10 +101,29 @@ def parse_value(spec: SettingSpec, raw: Any, snapshot: dict | None = None) -> An
|
||||
The typed, range-checked value ready for :meth:`SettingSpec.apply`.
|
||||
|
||||
Raises:
|
||||
DeviceConfigError: If the value is the wrong type or out of range.
|
||||
DeviceConfigError: If the value is the wrong type, out of range, or breaks the
|
||||
setting's own :attr:`~SettingSpec.validate` rule.
|
||||
"""
|
||||
value = _parse_typed(spec, raw, snapshot)
|
||||
if spec.validate is not None:
|
||||
complaint = spec.validate(value, snapshot)
|
||||
if complaint:
|
||||
raise DeviceConfigError(f"{spec.key}: {complaint}")
|
||||
return value
|
||||
|
||||
|
||||
def _parse_typed(spec: SettingSpec, raw: Any, snapshot: dict | None) -> Any:
|
||||
""":func:`parse_value` short of the setting's own rule: the type, bounds and choices."""
|
||||
text = str(raw).strip()
|
||||
if spec.value_type == "str":
|
||||
# `config show` prints an empty string as the two characters `""`, because a key
|
||||
# with nothing after it reads as a truncated line rather than as an empty value.
|
||||
# A value the dump prints has to be one `set` takes back (CLAUDE.md, "A value must
|
||||
# round-trip"), so the quotes come off here — otherwise feeding a dump back in
|
||||
# silently replaces every empty setting with a pair of quote marks, and the next
|
||||
# dump looks identical, so nothing ever tells you.
|
||||
if text == '""':
|
||||
text = ""
|
||||
if spec.max_length is not None and len(text) > spec.max_length:
|
||||
raise DeviceConfigError(
|
||||
f"{spec.key}: must be at most {spec.max_length} characters, got {len(text)}"
|
||||
@@ -114,6 +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:
|
||||
@@ -176,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)
|
||||
|
||||
|
||||
@@ -204,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:
|
||||
@@ -222,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
|
||||
@@ -236,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
|
||||
|
||||
|
||||
@@ -265,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
|
||||
@@ -309,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",
|
||||
}
|
||||
|
||||
|
||||
@@ -339,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),
|
||||
)
|
||||
@@ -465,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),
|
||||
),
|
||||
@@ -504,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),
|
||||
),
|
||||
@@ -514,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"),
|
||||
),
|
||||
@@ -525,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"),
|
||||
),
|
||||
@@ -558,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.)
|
||||
@@ -586,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"),
|
||||
),
|
||||
@@ -610,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",
|
||||
@@ -656,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
|
||||
@@ -11,6 +12,15 @@ The registry also tracks which device was most recently connected (``last``), us
|
||||
preselect and star a default on the startup splash. Membership in the registry is what marks
|
||||
a device as a confirmed MeshCore companion — the splash reserves its "MeshCore device" tag
|
||||
for these, rather than guessing from the USB vendor ID.
|
||||
|
||||
Alongside those it keeps ``hidden``: the stable ids the splash has been told to stop
|
||||
listing. It lives here rather than in the preferences because it is a fact about *this
|
||||
machine's* hardware — the USB adapters and dev boards permanently plugged into it that are
|
||||
not companions — and because the ids it holds are the registry's own. Hiding is a **splash**
|
||||
concern only: nothing else consults it, so ``--port`` and every scripted path still reach a
|
||||
hidden device by name. A hidden id need not be a remembered device (usually it is the
|
||||
opposite: a serial adapter nobody wants to see), so the set stands on its own rather than
|
||||
being a flag on a record.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -101,8 +111,8 @@ class DeviceStore:
|
||||
"""
|
||||
self._path = path
|
||||
|
||||
def _read(self) -> tuple[dict[str, RememberedDevice], str | None]:
|
||||
"""Return the parsed ``(registry, last_id)``; empty on a missing/corrupt file.
|
||||
def _read(self) -> tuple[dict[str, RememberedDevice], str | None, set[str]]:
|
||||
"""Return the parsed ``(registry, last_id, hidden_ids)``; empty on a missing file.
|
||||
|
||||
A missing or corrupt file is treated as "nothing remembered" rather than an error,
|
||||
so a stray edit never blocks startup. An old flat-format file (a single record at
|
||||
@@ -112,16 +122,16 @@ class DeviceStore:
|
||||
try:
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}, None
|
||||
return {}, None, set()
|
||||
if not isinstance(data, dict):
|
||||
return {}, None
|
||||
return {}, None, set()
|
||||
|
||||
# Old flat shape: a single record with ``stable_id`` at the top level.
|
||||
if "devices" not in data and "stable_id" in data:
|
||||
record = self._record_from(data)
|
||||
if record is None:
|
||||
return {}, None
|
||||
return {record.stable_id: record}, record.stable_id
|
||||
return {}, None, set()
|
||||
return {record.stable_id: record}, record.stable_id, set()
|
||||
|
||||
registry: dict[str, RememberedDevice] = {}
|
||||
for entry in (data.get("devices") or {}).values():
|
||||
@@ -131,7 +141,9 @@ class DeviceStore:
|
||||
last = data.get("last")
|
||||
if last not in registry:
|
||||
last = None
|
||||
return registry, last
|
||||
raw_hidden = data.get("hidden")
|
||||
hidden = {str(x) for x in raw_hidden} if isinstance(raw_hidden, list) else set()
|
||||
return registry, last, hidden
|
||||
|
||||
@staticmethod
|
||||
def _record_from(entry: object) -> RememberedDevice | None:
|
||||
@@ -163,17 +175,17 @@ class DeviceStore:
|
||||
Returns:
|
||||
The last-connected :class:`RememberedDevice`, or ``None``.
|
||||
"""
|
||||
registry, last = self._read()
|
||||
registry, last, _hidden = self._read()
|
||||
return registry.get(last) if last is not None else None
|
||||
|
||||
def load_all(self) -> dict[str, RememberedDevice]:
|
||||
"""Return the full registry of confirmed devices, keyed by ``stable_id``."""
|
||||
registry, _ = self._read()
|
||||
registry, _last, _hidden = self._read()
|
||||
return registry
|
||||
|
||||
def is_known(self, device: DiscoveredDevice) -> bool:
|
||||
"""Return whether ``device`` has ever been confirmed as a MeshCore companion."""
|
||||
registry, _ = self._read()
|
||||
registry, _last, _hidden = self._read()
|
||||
return device.stable_id in registry
|
||||
|
||||
def remember(
|
||||
@@ -193,7 +205,7 @@ class DeviceStore:
|
||||
a reconnect on firmware that couldn't answer the query doesn't erase a model
|
||||
learned earlier.
|
||||
"""
|
||||
registry, _ = self._read()
|
||||
registry, _last, hidden = self._read()
|
||||
existing = registry.get(device.stable_id)
|
||||
if not node_name and existing is not None:
|
||||
node_name = existing.node_name
|
||||
@@ -211,7 +223,11 @@ class DeviceStore:
|
||||
host=device.host or "",
|
||||
tcp_port=device.tcp_port or 0,
|
||||
)
|
||||
self._write(registry, device.stable_id)
|
||||
# Connecting to a device is the plainest statement that it should be listed, so a
|
||||
# confirmed one stops being hidden — otherwise it would vanish from the splash the
|
||||
# moment it proved itself, which is the opposite of what hiding is for.
|
||||
hidden.discard(device.stable_id)
|
||||
self._write(registry, device.stable_id, hidden)
|
||||
|
||||
def forget(self, stable_id: str) -> bool:
|
||||
"""Drop a remembered device from the registry.
|
||||
@@ -230,17 +246,58 @@ class DeviceStore:
|
||||
Returns:
|
||||
``True`` if a record was removed, ``False`` if none matched.
|
||||
"""
|
||||
registry, last = self._read()
|
||||
registry, last, hidden = self._read()
|
||||
if stable_id not in registry:
|
||||
return False
|
||||
del registry[stable_id]
|
||||
if last == stable_id:
|
||||
last = max(registry, key=lambda k: registry[k].last_connected, default=None)
|
||||
self._write(registry, last)
|
||||
hidden.discard(stable_id) # nothing left to hide it from
|
||||
self._write(registry, last, hidden)
|
||||
return True
|
||||
|
||||
def _write(self, registry: dict[str, RememberedDevice], last: str | None) -> None:
|
||||
"""Persist the registry, marking ``last`` as the most recently connected device."""
|
||||
def hidden_ids(self) -> set[str]:
|
||||
"""Return the stable ids the startup splash has been told not to list."""
|
||||
_registry, _last, hidden = self._read()
|
||||
return hidden
|
||||
|
||||
def hide(self, stable_id: str) -> None:
|
||||
"""Stop listing ``stable_id`` on the startup splash, from now until it is shown again.
|
||||
|
||||
Kept forever, like the registry itself — the splash is a list of what is plugged into
|
||||
this machine, and the adapters that are always plugged in and never companions are
|
||||
the same ones every time. Hiding an id that is not remembered is normal and fine:
|
||||
those are exactly the rows worth hiding.
|
||||
|
||||
Args:
|
||||
stable_id: The device's :attr:`DiscoveredDevice.stable_id`.
|
||||
"""
|
||||
registry, last, hidden = self._read()
|
||||
if stable_id in hidden:
|
||||
return
|
||||
hidden.add(stable_id)
|
||||
self._write(registry, last, hidden)
|
||||
|
||||
def show_all(self) -> int:
|
||||
"""Un-hide every hidden device; return how many were brought back.
|
||||
|
||||
The single way back, deliberately: hiding is per-row and un-hiding is not, because a
|
||||
hidden row is not on screen to press a key on. Returning the count lets the caller
|
||||
say what it just did.
|
||||
"""
|
||||
registry, last, hidden = self._read()
|
||||
if not hidden:
|
||||
return 0
|
||||
self._write(registry, last, set())
|
||||
return len(hidden)
|
||||
|
||||
def _write(
|
||||
self,
|
||||
registry: dict[str, RememberedDevice],
|
||||
last: str | None,
|
||||
hidden: set[str],
|
||||
) -> None:
|
||||
"""Persist the registry, its ``last`` pointer, and the splash's hidden ids."""
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"devices": {
|
||||
@@ -259,5 +316,7 @@ class DeviceStore:
|
||||
for stable_id, record in registry.items()
|
||||
},
|
||||
"last": last,
|
||||
# Sorted so the file does not churn between writes over set ordering alone.
|
||||
"hidden": sorted(hidden),
|
||||
}
|
||||
write_atomically(self._path, json.dumps(payload, indent=2))
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Companion-device discovery across transports (serial + Bluetooth LE + TCP).
|
||||
|
||||
Discovery enumerates the companion kinds that can be *found* automatically:
|
||||
@@ -35,6 +36,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
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The exit statuses ``meshterm`` returns, and what each one means.
|
||||
|
||||
A scripted run's *return value is its report*: the CLI says what happened in plain
|
||||
text on stdout and says whether it worked in ``$?``. The codes are classified rather
|
||||
than a bare 0/1 so a caller can tell the failures apart without reading prose — a
|
||||
timeout is worth retrying, a bad key never is.
|
||||
|
||||
The set is small and closed. Anything that does not map onto one of the specific
|
||||
causes below is :data:`FAILURE`; nothing invents a code of its own.
|
||||
|
||||
+------+--------------+--------------------------------------------------------+
|
||||
| Code | Name | Meaning |
|
||||
+======+==============+========================================================+
|
||||
| 0 | ``OK`` | The command did what it was asked. |
|
||||
+------+--------------+--------------------------------------------------------+
|
||||
| 1 | ``FAILURE`` | It failed for a reason with no more specific code — |
|
||||
| | | a bad configuration or preference value, an |
|
||||
| | | unreadable file, an unhandled fault. |
|
||||
+------+--------------+--------------------------------------------------------+
|
||||
| 2 | ``USAGE`` | The command line itself was wrong: an unknown flag, a |
|
||||
| | | missing argument, a value the parser rejected. Click |
|
||||
| | | returns this on its own; it is named here so the |
|
||||
| | | table is complete. |
|
||||
+------+--------------+--------------------------------------------------------+
|
||||
| 3 | ``NO_DEVICE``| No companion could be selected: none attached, none |
|
||||
| | | matching ``--port``/``--ble``/``--tcp``, or the |
|
||||
| | | choice was ambiguous. Nothing was transmitted. |
|
||||
+------+--------------+--------------------------------------------------------+
|
||||
| 4 | ``DEVICE`` | A device was reached but the operation failed: a |
|
||||
| | | command error, a timeout, a link lost mid-run. |
|
||||
| | | Retrying is reasonable. |
|
||||
+------+--------------+--------------------------------------------------------+
|
||||
| 5 | ``NO_RESULT``| The command ran and completed, and there was nothing |
|
||||
| | | to report: an empty list, a target that never |
|
||||
| | | answered, a conversation with no messages. stdout is |
|
||||
| | | empty; this is not an error. |
|
||||
+------+--------------+--------------------------------------------------------+
|
||||
|
||||
:data:`NO_RESULT` is the one that needs saying out loud, because it is the one a
|
||||
script most wants and no other tool offers: ``grep`` conflates "found nothing" with
|
||||
"worked", and a caller that has to count output lines to tell an empty mesh from a
|
||||
full one is parsing when it could be branching.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: The command did what it was asked.
|
||||
OK = 0
|
||||
|
||||
#: A failure with no more specific code — a bad value, an unreadable file, a fault.
|
||||
FAILURE = 1
|
||||
|
||||
#: The command line was wrong. Click returns this itself; named here for completeness.
|
||||
USAGE = 2
|
||||
|
||||
#: No companion device could be selected. Nothing was transmitted.
|
||||
NO_DEVICE = 3
|
||||
|
||||
#: A device was reached but the operation failed. Retrying is reasonable.
|
||||
DEVICE = 4
|
||||
|
||||
#: The command completed with nothing to report. Not an error; stdout is empty.
|
||||
NO_RESULT = 5
|
||||
|
||||
#: Every code, by name, for the ``--help`` epilog and the documentation.
|
||||
MEANINGS: dict[int, str] = {
|
||||
OK: "success",
|
||||
FAILURE: "failure",
|
||||
USAGE: "usage error",
|
||||
NO_DEVICE: "no device found, or the selection was ambiguous",
|
||||
DEVICE: "the device was reached but the operation failed",
|
||||
NO_RESULT: "nothing to report (empty result)",
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""What an overheard frame is *addressed to* — the packet body nobody else parses.
|
||||
|
||||
The companion's RX packet log hands every overheard frame to the meshcore library, which
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Pure geographic helpers for the terminal map: Web Mercator, tiles, and viewports.
|
||||
|
||||
This module is deliberately I/O-free and rendering-free so it can be unit-tested without a
|
||||
@@ -33,7 +34,9 @@ TILE_PX = 256
|
||||
#: :mod:`meshterm.ui.map_screen` for it dragged the whole map stack (and, through the
|
||||
#: basemap's tile fetcher, ``urllib.request`` → ``http.client`` → ``ssl``) into every run,
|
||||
#: including runs that never open a map. This module is pure arithmetic and already on the
|
||||
#: boot path, so the constant is free here.
|
||||
#: boot path, so the constant is free here. It is also what the ``map_view_fraction``
|
||||
#: preference defaults to — the registry names this constant rather than re-typing the
|
||||
#: number, so the code default and the preference default are one value.
|
||||
DEFAULT_VIEW_FRACTION = 0.5
|
||||
|
||||
|
||||
@@ -87,7 +90,9 @@ def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
|
||||
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a))
|
||||
# Clamped because rounding can push ``a`` a hair past 1 for near-antipodal points, and
|
||||
# ``asin`` of that raises a domain error rather than returning half the planet.
|
||||
return 2 * EARTH_RADIUS_KM * math.asin(min(1.0, math.sqrt(a)))
|
||||
|
||||
|
||||
def _central_points(
|
||||
|
||||
@@ -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
|
||||
|
||||
+187
-46
@@ -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,13 +40,21 @@ 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
|
||||
@@ -75,7 +86,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 +100,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 +121,28 @@ class PrefSpec:
|
||||
maximum: float | None = None
|
||||
unit: str = ""
|
||||
relaunch: bool = False
|
||||
platforms: frozenset[str] | None = None
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""The DESCRIPTION lane's text: the help, plus the relaunch caveat where it applies."""
|
||||
return f"{self.help} (next launch)" if self.relaunch else self.help
|
||||
|
||||
def offered_on(self, platform: str) -> bool:
|
||||
"""Whether the editor draws a row for this preference on ``platform``.
|
||||
|
||||
Args:
|
||||
platform: A :attr:`~meshterm.platforms.Platform.name`.
|
||||
|
||||
Returns:
|
||||
``True`` unless :attr:`platforms` names a set this platform is not in.
|
||||
"""
|
||||
return self.platforms is None or platform in self.platforms
|
||||
|
||||
|
||||
#: The two languages the plain CLI can print a time in. Named for what the reader sees
|
||||
#: rather than for the mechanism: an *age* is how long ago, a *timestamp* is when.
|
||||
_CLI_TIME_CHOICES: dict[str, str] = {"relative": "ages", "absolute": "timestamps"}
|
||||
|
||||
#: The silence-rule choices, drawn from the Watchtower's own ring so the two never drift.
|
||||
_SILENCE_CHOICES: dict[int, str] = {
|
||||
@@ -122,19 +157,40 @@ _SILENCE_CHOICES: dict[int, str] = {
|
||||
#: need to disagree with its platform.
|
||||
_WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
|
||||
|
||||
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
|
||||
#: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a
|
||||
#: default — nothing else has to follow.
|
||||
#: What the log file keeps. The plain level names, which are what every other tool calls
|
||||
#: these — someone being talked through a problem is being told "set it to debug", not
|
||||
#: "set it to everything".
|
||||
#: How many colours to send the terminal. ``auto`` is the honest default and the only
|
||||
#: value that is not itself a depth: it means "take prompt_toolkit's verdict unless we can
|
||||
#: positively establish better", which is the difference between fixing a host and breaking
|
||||
#: one. The rest are named for the count a reader can see rather than for the bit depth,
|
||||
#: because "24-bit" is a fact about the wire and "16 million" is a fact about the screen.
|
||||
#: See :func:`meshterm.ui.tui.session._color_depth` for why auto is not simply "the most
|
||||
#: this terminal can do".
|
||||
_COLOR_DEPTH_CHOICES: dict[str, str] = {
|
||||
"auto": "auto",
|
||||
"truecolor": "16 million",
|
||||
"256": "256",
|
||||
"16": "16",
|
||||
}
|
||||
|
||||
#: What MeshTerm may offer when it lands in a console that cannot draw it. Only ever
|
||||
#: consulted on the classic Windows console, the one host with no font fallback of its own.
|
||||
_CONSOLE_SETUP_CHOICES: dict[str, str] = {
|
||||
"ask": "Ask",
|
||||
"auto": "Automatic",
|
||||
"off": "Leave it alone",
|
||||
}
|
||||
|
||||
#: The two console fonts ``scripts/calculinux-console-font.sh`` (and its 6x8 companion)
|
||||
#: build for the PicoCalc's framebuffer panel. Each is named by its cell and by the screen
|
||||
#: that cell buys, because choosing a font here is choosing how much fits — not a bitmap.
|
||||
#: Plain ASCII ``x`` and not ``×``: the multiplication sign is not one of the 512 glyphs
|
||||
#: the console font carries, so it would draw as a tofu box on the one platform this
|
||||
#: preference is offered on. The value maps to a file in
|
||||
#: :data:`meshterm.services.consolefont.FONT_FILES`, which is where a filename belongs —
|
||||
#: a path on one device is not something a reader picks.
|
||||
_CONSOLE_FONT_CHOICES: dict[str, str] = {"6x12": "6x12 (53x26)", "6x8": "6x8 (53x40)"}
|
||||
|
||||
#: What the log file keeps. The plain level names, which are what every other tool calls
|
||||
#: these — someone being talked through a problem is being told "set it to debug", not
|
||||
#: "set it to everything".
|
||||
_LOG_LEVEL_CHOICES: dict[str, str] = {
|
||||
"ERROR": "Error",
|
||||
"WARNING": "Warning",
|
||||
@@ -143,6 +199,11 @@ _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, ...] = (
|
||||
# --- Sending -----------------------------------------------------------------
|
||||
PrefSpec(
|
||||
@@ -227,7 +288,7 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
help="How many past alerts to keep",
|
||||
group="Watchtower",
|
||||
value_type="int",
|
||||
default=200,
|
||||
default=ALERT_CAP,
|
||||
minimum=10,
|
||||
maximum=5000,
|
||||
),
|
||||
@@ -238,7 +299,7 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
help="Share of nodes to fit on screen; 1 shows them all",
|
||||
group="Map",
|
||||
value_type="float",
|
||||
default=0.5,
|
||||
default=DEFAULT_VIEW_FRACTION,
|
||||
minimum=0.1,
|
||||
maximum=1.0,
|
||||
),
|
||||
@@ -279,11 +340,24 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
help="Finished outbox messages to keep",
|
||||
group="History",
|
||||
value_type="int",
|
||||
default=100,
|
||||
default=DONE_CAP,
|
||||
minimum=10,
|
||||
maximum=5000,
|
||||
),
|
||||
# --- Display -----------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="cli_time_format",
|
||||
label="Command-line times",
|
||||
help="Whether the command line prints ages or timestamps",
|
||||
group="Display",
|
||||
value_type="enum",
|
||||
# Relative, because a person at a prompt asking "heard recently?" should not have
|
||||
# to subtract an ISO instant from `date` to find out. `--absolute` overrides this
|
||||
# for one run, and the JSON face is UTC either way — it is read somewhere else and
|
||||
# often later, where a relative age has nothing to be relative to.
|
||||
default="relative",
|
||||
choices=_CLI_TIME_CHOICES,
|
||||
),
|
||||
PrefSpec(
|
||||
key="fast_render",
|
||||
label="Fast redraw",
|
||||
@@ -303,17 +377,47 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
choices=_WIDTH_CHOICES,
|
||||
),
|
||||
PrefSpec(
|
||||
key="console_setup",
|
||||
label="Console setup",
|
||||
help="Offer a better terminal or font when this console can't draw MeshTerm",
|
||||
key="color_depth",
|
||||
label="Colours",
|
||||
help="How many colours to send this terminal",
|
||||
group="Display",
|
||||
value_type="enum",
|
||||
# "ask", not "always": changing the font of a window someone else opened is not
|
||||
# ours to do unasked. Declining twice writes "off" here, which is how the offer
|
||||
# stops being made rather than being made every launch.
|
||||
default="ask",
|
||||
# Auto, because the count a terminal accepts is a fact about the terminal and the
|
||||
# reader should not have to know it. It is only ever consulted to *raise* the
|
||||
# verdict prompt_toolkit already reached, never to lower it — a terminal that
|
||||
# cannot be shown to do better keeps exactly what it had.
|
||||
default="auto",
|
||||
choices=_COLOR_DEPTH_CHOICES,
|
||||
relaunch=True,
|
||||
),
|
||||
PrefSpec(
|
||||
key="console_setup",
|
||||
label="Console setup",
|
||||
help="Move to a better terminal when this console can't draw MeshTerm",
|
||||
group="Display",
|
||||
value_type="enum",
|
||||
# Reopening in Windows Terminal is done, not asked: the classic console cannot
|
||||
# draw a single icon whatever font it is given, so there is only one sensible
|
||||
# answer and the reader has not seen the app yet to judge it. Installing a *font*
|
||||
# still asks, because that writes to their machine. Declining that twice writes
|
||||
# "off" here, which turns both off for good.
|
||||
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",
|
||||
@@ -351,13 +455,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]
|
||||
|
||||
|
||||
@@ -397,15 +509,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
|
||||
@@ -461,6 +573,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
|
||||
@@ -473,11 +588,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.
|
||||
|
||||
@@ -507,21 +647,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:
|
||||
@@ -609,14 +755,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():
|
||||
@@ -625,12 +771,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)
|
||||
@@ -648,7 +789,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
|
||||
@@ -61,6 +62,45 @@ def own_command() -> list[str]:
|
||||
return [sys.executable, "-m", "meshterm", *sys.argv[1:]]
|
||||
|
||||
|
||||
def child_environment() -> dict[str, str]:
|
||||
"""The environment for a fresh launch of MeshTerm, minus this bundle's bookkeeping.
|
||||
|
||||
A PyInstaller one-file build runs in two stages: the executable unpacks itself into a
|
||||
temporary directory and then runs itself again as a child, the two halves coordinating
|
||||
through ``_PYI*`` environment variables. Those must not reach a *new* launch of the
|
||||
executable. The bootloader would find them, conclude it is the second stage of a launch
|
||||
it never made, check that its parent is the same program — find Windows Terminal
|
||||
instead — and abort::
|
||||
|
||||
[PYI-33368:ERROR] Security validation failure: parent process has different
|
||||
executable!
|
||||
|
||||
Which is the whole session gone, in a brand new window holding a message nobody can act
|
||||
on, while the window that offered the move has already closed. Removing them makes the
|
||||
new process a first stage, which is what it actually is.
|
||||
|
||||
Returns:
|
||||
The environment to start the new process with.
|
||||
"""
|
||||
environment = dict(os.environ)
|
||||
environment[REOPENED_ENV] = "1"
|
||||
if not getattr(sys, "frozen", False):
|
||||
return environment
|
||||
|
||||
for key in [key for key in environment if key.startswith("_PYI")]:
|
||||
del environment[key]
|
||||
environment.pop("_MEIPASS2", None) # the name older bootloaders used
|
||||
# The bootloader points the loader at the unpacked bundle and parks the caller's own
|
||||
# value beside it. The new process unpacks its own copy, so it wants the original.
|
||||
for name in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"):
|
||||
original = environment.pop(f"{name}_ORIG", None)
|
||||
if original is not None:
|
||||
environment[name] = original
|
||||
else:
|
||||
environment.pop(name, None)
|
||||
return environment
|
||||
|
||||
|
||||
def reopen() -> bool:
|
||||
"""Start this session again in Windows Terminal, in the same directory.
|
||||
|
||||
@@ -74,17 +114,54 @@ def reopen() -> bool:
|
||||
terminal = available()
|
||||
if terminal is None:
|
||||
return False
|
||||
environment = dict(os.environ, **{REOPENED_ENV: "1"})
|
||||
try:
|
||||
# `--` ends wt's own option parsing, so MeshTerm's flags reach MeshTerm rather
|
||||
# than being read as Windows Terminal's (verified: `--mock` and `--profile x`
|
||||
# arrive intact). `-d` keeps the working directory, which relative paths given
|
||||
# on the command line depend on.
|
||||
subprocess.Popen( # noqa: S603 - the argv is ours, not the reader's
|
||||
[terminal, "-d", os.getcwd(), "--", *own_command()],
|
||||
env=environment,
|
||||
[
|
||||
terminal,
|
||||
"-w",
|
||||
"new",
|
||||
"--size",
|
||||
_wanted_size(),
|
||||
# Otherwise the tab is titled with the executable's full path, which on a
|
||||
# downloaded build is a line of Downloads folder. The icon is not ours to
|
||||
# set: Windows Terminal takes that from a profile, and a bare command line
|
||||
# is not one, so it gets the generic console glyph.
|
||||
"--title",
|
||||
"MeshTerm",
|
||||
"-d",
|
||||
os.getcwd(),
|
||||
"--",
|
||||
*own_command(),
|
||||
],
|
||||
env=child_environment(),
|
||||
close_fds=True,
|
||||
)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _wanted_size() -> str:
|
||||
"""The window to ask Windows Terminal for, as ``cols,rows``.
|
||||
|
||||
Asked for, because a window MeshTerm opens should be one MeshTerm fits in. Without
|
||||
``-w new`` the session lands as a *tab* in whatever window happens to be open and
|
||||
inherits its size; without ``--size`` a new window takes the reader's global launch
|
||||
size, which is a setting about their shell and not about this app. Either way the app
|
||||
can arrive in a window too small for it, and the failure is quiet and cosmetic: the
|
||||
startup wordmark is 71 columns, so at 70 it silently swaps to the narrow one drawn for
|
||||
the PicoCalc, and a short window truncates every description onto the pager.
|
||||
|
||||
The numbers come from the platform's own stated minimum plus a margin, so they follow
|
||||
it rather than repeating it. Windows Terminal clamps to what the display can show, so
|
||||
this is a request and not a demand — on a small screen the app degrades exactly as it
|
||||
would have anyway.
|
||||
"""
|
||||
from ..platforms import get_platform
|
||||
|
||||
platform = get_platform()
|
||||
return f"{platform.readable_cols + 8},{platform.readable_rows + 6}"
|
||||
@@ -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,12 +98,18 @@ def configure_logging(
|
||||
"""
|
||||
logger = logging.getLogger(_LOGGER_NAME)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.handlers.clear()
|
||||
_drop_handlers(logger)
|
||||
logger.propagate = False
|
||||
|
||||
if not quiet:
|
||||
# markup=False: a log line carries whatever the device said — a node name, a
|
||||
# firmware error — and Rich would read `[...]` in it as a style tag. That made the
|
||||
# *error path* the one most likely to fail: reporting a fault on a node called
|
||||
# `[/]Bob` raised MarkupError from inside the handler, replacing the diagnostic
|
||||
# with a traceback about the diagnostic. No log call in the package marks up its
|
||||
# message, so parsing them buys nothing.
|
||||
rich_handler = RichHandler(
|
||||
console=console, rich_tracebacks=True, show_path=False, markup=True
|
||||
console=console, rich_tracebacks=True, show_path=False, markup=False
|
||||
)
|
||||
rich_handler.setLevel(level)
|
||||
logger.addHandler(rich_handler)
|
||||
@@ -155,12 +162,29 @@ def _quiet_library_console(file_handler: logging.Handler) -> None:
|
||||
|
||||
for name in _LIBRARY_LOGGERS:
|
||||
lib = logging.getLogger(name)
|
||||
lib.handlers.clear()
|
||||
_drop_handlers(lib)
|
||||
lib.setLevel(logging.INFO)
|
||||
lib.propagate = False
|
||||
lib.addHandler(file_handler)
|
||||
|
||||
|
||||
def _drop_handlers(logger: logging.Logger) -> None:
|
||||
"""Detach ``logger``'s handlers, closing each on the way out.
|
||||
|
||||
Clearing the list alone drops the *reference* and leaves the file open: every
|
||||
reconfigure — a reconnect in the app, each in-process CLI invocation under test — leaked
|
||||
another descriptor on ``meshterm.log``, which on Windows is also a lock on a file the
|
||||
rotation then wants to rename. A handler that refuses to close is dropped anyway: the
|
||||
point of this call is that the old handlers stop receiving records.
|
||||
"""
|
||||
for handler in list(logger.handlers):
|
||||
try:
|
||||
handler.close()
|
||||
except Exception: # noqa: BLE001 - a handler we are discarding cannot fail the app
|
||||
pass
|
||||
logger.handlers.clear()
|
||||
|
||||
|
||||
def get_logger(name: str | None = None) -> logging.Logger:
|
||||
"""Return a child of the application logger.
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Repository: the single gateway between the app and the database.
|
||||
|
||||
Tools and services never issue SQL directly; they call typed methods here. This keeps
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -19,7 +20,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..core.channels import CHANNEL_SLOT_PROBE_CAP, channel_identity
|
||||
from ..core.channels import channel_identity
|
||||
from ..core.connection import Unsubscribe
|
||||
from ..core.events import EventKind, MeshEvent
|
||||
from ..core.models import (
|
||||
@@ -128,20 +129,16 @@ class ChatService:
|
||||
The map is what lets an inbound message (which carries only a slot index) be recorded
|
||||
against its channel's intrinsic identity. It is rebuilt wholesale so a reordered,
|
||||
renamed, re-keyed, or cleared slot is reflected accurately.
|
||||
|
||||
Read through the session cache, which is the same slot probe the channel manager is
|
||||
about to make anyway — so a channel edit costs *one* walk of the device rather than
|
||||
this one plus the manager's. Its own walk was also unbounded where the shared probe
|
||||
is not: it skipped empty slots and kept going to the 64-slot cap, so on firmware that
|
||||
never rejects an index every single edit paid 64 round-trips before the screen could
|
||||
redraw. That is most of what "applying a change takes a while" was.
|
||||
"""
|
||||
device = await self._ctx.device()
|
||||
ids: dict[int, str] = {}
|
||||
for idx in range(CHANNEL_SLOT_PROBE_CAP):
|
||||
try:
|
||||
payload = await device.get_channel(idx)
|
||||
except Exception: # noqa: BLE001 - firmware may not support channel reads
|
||||
break
|
||||
if not payload:
|
||||
continue # an empty slot; keep scanning (slots can be non-contiguous)
|
||||
name = str(payload.get("channel_name") or "")
|
||||
secret = bytes(payload.get("channel_secret") or b"\x00" * 16)
|
||||
ids[idx] = channel_identity(name, secret)
|
||||
self._channel_ids = ids
|
||||
slots = await self._ctx.devstate.channel_slots()
|
||||
self._channel_ids = {slot.idx: channel_identity(slot.name, slot.secret) for slot in slots}
|
||||
|
||||
async def channel_id_for(self, idx: int) -> str:
|
||||
"""Resolve a channel slot index to its intrinsic identity, reading the slot fresh.
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# 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/calculinux-console-font.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
|
||||
@@ -94,6 +97,7 @@ class DeviceState:
|
||||
self._self_info: dict | None = None
|
||||
self._path_hash_mode: int | None = None
|
||||
self._channels: list[ChannelSlot] | None = None
|
||||
self._channels_epoch = 0
|
||||
self._channel_capacity: int | None = None
|
||||
# One lock per slow fetch so overlapping first-access callers (two screens opened in
|
||||
# quick succession) collapse onto a single round-trip instead of each firing their own.
|
||||
@@ -131,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).
|
||||
|
||||
@@ -250,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.
|
||||
|
||||
@@ -268,26 +289,53 @@ 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), and that result is cached as-is.
|
||||
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.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 read_channel_slots
|
||||
|
||||
device = await self._ctx.device()
|
||||
self._channels = await read_channel_slots(device)
|
||||
slots, complete = await probe_channel_slots(device)
|
||||
if complete:
|
||||
self._channels = slots
|
||||
# An incomplete probe is answered but not kept: one timed-out read used
|
||||
# to poison the whole session, and the shortfall does not announce
|
||||
# itself — an empty result reads exactly like a device with no channels.
|
||||
return slots
|
||||
return self._channels
|
||||
|
||||
async def channel_capacity(self) -> int:
|
||||
@@ -373,9 +421,21 @@ class DeviceState:
|
||||
"""Drop the cached path-hash mode — call after writing it in the config editor."""
|
||||
self._path_hash_mode = None
|
||||
|
||||
@property
|
||||
def channels_epoch(self) -> int:
|
||||
"""How many times the channel layout has been dropped this session.
|
||||
|
||||
A caller that wants to know whether *anything* rewrote a slot while it was busy
|
||||
compares this across the gap rather than trusting a return value: a write that
|
||||
landed and then raised on its way back moved the device just as much as one that
|
||||
returned cleanly, and the count it never got to increment cannot say so.
|
||||
"""
|
||||
return self._channels_epoch
|
||||
|
||||
def invalidate_channels(self) -> None:
|
||||
"""Drop the cached channel slots — call after the channel editor saves or clears one."""
|
||||
self._channels = None
|
||||
self._channels_epoch += 1
|
||||
|
||||
def invalidate_config(self) -> None:
|
||||
"""Drop everything the config editor can change in one call (self-info + routing mode).
|
||||
|
||||
@@ -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``
|
||||
|
||||
+92
-3
@@ -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
|
||||
@@ -26,6 +27,7 @@ from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..ui.markdown import MarkdownDoc
|
||||
from ..ui.report import Facts
|
||||
|
||||
|
||||
class _AboutTool(Tool):
|
||||
@@ -67,7 +69,11 @@ class _AboutTool(Tool):
|
||||
return None
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Print the page — only reachable from the CLI (the menu opens the screen).
|
||||
"""State the page — only reachable from the CLI (the menu opens the screen).
|
||||
|
||||
Without its scannable codes: a QR is a second rendering of a link the page already
|
||||
prints, drawn for a phone pointed at a screen, and redirected into a file it is a
|
||||
block of block characters around nothing new.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
@@ -76,8 +82,10 @@ class _AboutTool(Tool):
|
||||
Returns:
|
||||
A :class:`ToolResult` naming the page that was shown.
|
||||
"""
|
||||
ctx.ui.show(self.page())
|
||||
return ToolResult(summary={"page": self.name})
|
||||
return ToolResult(
|
||||
summary={"page": self.name},
|
||||
report=(_written_page(self.name, self.title, self.page().for_script()),),
|
||||
)
|
||||
|
||||
|
||||
@register
|
||||
@@ -150,3 +158,84 @@ class SupportProjectTool(_AboutTool):
|
||||
from ..ui.about import support_project
|
||||
|
||||
return support_project()
|
||||
|
||||
|
||||
def _written_page(name: str, title: str, doc: MarkdownDoc) -> Facts:
|
||||
"""One prose page, stated once for both faces.
|
||||
|
||||
**Wrapped**, and that is the one deliberate exception to "no line wrapping". The rule
|
||||
exists so a *record* is never split across two lines with its fields landing under the
|
||||
wrong headings; a paragraph has no records and no headings, and left unwrapped these
|
||||
four pages print as single six-hundred-cell lines no terminal can read. So the page is
|
||||
drawn on a console :data:`~meshterm.ui.script.PAGE_WIDTH` cells wide — a fixed width,
|
||||
not the terminal's, so the same paragraph reads the same piped, redirected, or shipped
|
||||
as the document's ``text``.
|
||||
|
||||
``links`` is the one machine-actionable thing on a page of prose, and the one thing the
|
||||
menu bothers to draw a QR of. ``text`` is the page's plain rendering rather than a fold
|
||||
of the markdown into nested JSON: a structured markdown tree would be a second
|
||||
rendering, not data, and nothing would consume it.
|
||||
"""
|
||||
from .. import __version__
|
||||
from ..ui import fields
|
||||
from ..ui.report import BARE, Column, Facts
|
||||
|
||||
text = _rendered_page(doc)
|
||||
return Facts(
|
||||
key="page",
|
||||
fields=(
|
||||
fields.word("page", "page"),
|
||||
fields.word("title", "title"),
|
||||
fields.word("version", "version"),
|
||||
fields.word("text", "text"),
|
||||
Column(key="links"),
|
||||
),
|
||||
values={
|
||||
"page": name,
|
||||
"title": title,
|
||||
# Duplicated from the package on purpose: a page's own facts are part of its
|
||||
# content, and this is the version its `{version}` placeholder resolved to.
|
||||
"version": __version__,
|
||||
"text": text,
|
||||
"links": _links(text),
|
||||
},
|
||||
shape=BARE,
|
||||
bare="text",
|
||||
)
|
||||
|
||||
|
||||
def _rendered_page(doc: MarkdownDoc) -> str:
|
||||
"""The page as plain text, folded to the fixed page width."""
|
||||
import io
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from ..ui import script
|
||||
|
||||
buffer = io.StringIO()
|
||||
Console(
|
||||
file=buffer,
|
||||
width=script.PAGE_WIDTH,
|
||||
color_system=None,
|
||||
highlight=False,
|
||||
emoji=False,
|
||||
markup=False,
|
||||
).print(doc)
|
||||
return "\n".join(line.rstrip() for line in buffer.getvalue().splitlines()).strip("\n")
|
||||
|
||||
|
||||
def _links(text: str) -> list[str]:
|
||||
"""Every URL the page carries, in document order, de-duplicated.
|
||||
|
||||
Read off the rendered page rather than the markdown source, so a link written as a
|
||||
reference and a link written inline both count once and both read as what the reader
|
||||
sees. Trailing punctuation is not part of a URL and is trimmed.
|
||||
"""
|
||||
import re
|
||||
|
||||
seen: list[str] = []
|
||||
for match in re.findall(r"https?://[^\s<>()\[\]]+", text):
|
||||
url = match.rstrip(".,;:*_")
|
||||
if url not in seen:
|
||||
seen.append(url)
|
||||
return seen
|
||||
@@ -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.
|
||||
|
||||
+48
-6
@@ -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
|
||||
@@ -12,10 +13,13 @@ from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..core import exitcodes
|
||||
|
||||
if TYPE_CHECKING: # avoid importing typer/context at module load for fast startup
|
||||
import typer
|
||||
|
||||
from ..context import AppContext
|
||||
from ..ui.report import Report
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -24,13 +28,37 @@ class ToolResult:
|
||||
|
||||
Attributes:
|
||||
summary: JSON-serializable summary persisted to the run record.
|
||||
message: Optional human-readable closing message.
|
||||
message: Optional human-readable closing message. The *menu's* closing line — a
|
||||
scripted run does not print it (see
|
||||
:func:`~meshterm.cli._execute_and_render`), because on the command line it
|
||||
restates the output above it and the exit status below it.
|
||||
artifacts: Paths to any files produced (e.g. generated visualizations).
|
||||
exit_code: The status a scripted run should exit with — see
|
||||
:mod:`meshterm.core.exitcodes`. Left at ``OK`` by everything that worked;
|
||||
set to ``NO_RESULT`` by a tool that ran fine and found nothing to report, so
|
||||
a caller can tell an empty mesh from a full one without counting lines. A
|
||||
*failure* is raised, not returned, so this never carries one.
|
||||
report: **The answer**, as data (see :mod:`meshterm.ui.report`). A scripted run
|
||||
states it here and the CLI boundary hands it to a renderer, exactly as it
|
||||
already does with ``exit_code`` — the tool says what happened, and something
|
||||
else turns that into bytes or into a process status. It used to be printed
|
||||
instead, which meant the answer was gone by the time anything could ask for it
|
||||
in another format, which is why ``--json`` reached two commands and stopped.
|
||||
|
||||
``None`` for the menu path and for a feature with no scripted face at all (the
|
||||
map, the dashboard), which keep working unchanged.
|
||||
|
||||
Not folded into ``summary``: that is the *run log's* record of what this
|
||||
invocation did, written to the ``runs`` table on every execution including a
|
||||
menu run. Putting a five-hour ``monitor`` capture in SQLite would be the price
|
||||
of one field fewer.
|
||||
"""
|
||||
|
||||
summary: dict[str, Any] = field(default_factory=dict)
|
||||
message: str | None = None
|
||||
artifacts: list[str] = field(default_factory=list)
|
||||
exit_code: int = exitcodes.OK
|
||||
report: Report | None = None
|
||||
|
||||
|
||||
class Tool(ABC):
|
||||
@@ -82,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.
|
||||
@@ -123,7 +152,9 @@ class Tool(ABC):
|
||||
Raises:
|
||||
Exception: Re-raises any error from :meth:`run` after recording it.
|
||||
"""
|
||||
from ..core.connection import DeviceCommandError
|
||||
import typer
|
||||
|
||||
from ..core.connection import DeviceCommandError, is_connection_lost
|
||||
from ..core.device_config import DeviceConfigError
|
||||
from ..core.preferences import PreferenceError
|
||||
from ..core.selection import DeviceSelectionError
|
||||
@@ -137,16 +168,27 @@ class Tool(ABC):
|
||||
DeviceConfigError,
|
||||
DeviceCommandError,
|
||||
PreferenceError,
|
||||
# A tool rejecting one of its own arguments (an unresolvable `--to`) raises
|
||||
# what the parser would have. It is a usage error, not a fault.
|
||||
typer.BadParameter,
|
||||
typer.Abort,
|
||||
typer.Exit,
|
||||
) as exc:
|
||||
# Expected user-facing condition (no/ambiguous device, a bad config or
|
||||
# preference value, or a transient command failure): record it but don't dump
|
||||
# a traceback; callers print the message cleanly.
|
||||
# preference value, a bad argument, or a transient command failure): record it
|
||||
# but don't dump a traceback; callers print the message cleanly.
|
||||
ctx.repo.finish_run(run_id, "error", {"error": str(exc)})
|
||||
ctx.log.debug("run %s aborted: %s", run_id, exc)
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - we record then re-raise
|
||||
ctx.repo.finish_run(run_id, "error", {"error": str(exc)})
|
||||
ctx.log.exception("run %s failed: %s", run_id, exc)
|
||||
if is_connection_lost(exc):
|
||||
# The radio was unplugged or powered off mid-command. Every layer above
|
||||
# already knows how to say that in one line; a stack of serial internals
|
||||
# describes how the driver found out, which is not the same question.
|
||||
ctx.log.warning("run %s lost the device connection: %s", run_id, exc)
|
||||
else:
|
||||
ctx.log.exception("run %s failed: %s", run_id, exc)
|
||||
raise
|
||||
ctx.repo.finish_run(run_id, "ok", result.summary)
|
||||
ctx.log.debug("run %s ok", run_id)
|
||||
|
||||
+165
-60
@@ -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
|
||||
@@ -12,16 +13,16 @@ everything to do with configuring the slots themselves.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
from rich.console import Group
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core import exitcodes
|
||||
from ..core.channels import (
|
||||
channel_hash,
|
||||
derive_secret,
|
||||
is_public_channel,
|
||||
normalize_secret,
|
||||
parse_share_url,
|
||||
random_secret,
|
||||
@@ -29,6 +30,9 @@ from ..core.channels import (
|
||||
)
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Facts, Listing
|
||||
|
||||
|
||||
@register
|
||||
class ChannelsTool(Tool):
|
||||
@@ -49,7 +53,7 @@ class ChannelsTool(Tool):
|
||||
params: A ``cli_action`` with its arguments (CLI), or empty for the menu.
|
||||
|
||||
Returns:
|
||||
A :class:`ToolResult` summarizing what happened.
|
||||
A :class:`ToolResult` recording what happened (the menu path shows nothing).
|
||||
"""
|
||||
action = params.get("cli_action")
|
||||
if action is not None:
|
||||
@@ -57,14 +61,11 @@ class ChannelsTool(Tool):
|
||||
|
||||
from ..ui.channels import manage_channels
|
||||
|
||||
changes = await manage_channels(ctx)
|
||||
message = (
|
||||
f"[ok]✓[/ok] applied [brand]{changes}[/brand] "
|
||||
f"channel change{'' if changes == 1 else 's'}"
|
||||
if changes
|
||||
else None
|
||||
)
|
||||
return ToolResult(summary={"changes": changes}, message=message)
|
||||
# No message: the manager acknowledged its work on the way out, which meant telling
|
||||
# the reader that changes they had just watched land were applied. The count still
|
||||
# goes to the run log through ``summary``, which is the record of what an invocation
|
||||
# did rather than anything shown.
|
||||
return ToolResult(summary={"changes": await manage_channels(ctx)})
|
||||
|
||||
# -- CLI --------------------------------------------------------------------
|
||||
|
||||
@@ -84,27 +85,15 @@ 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)
|
||||
if not slots:
|
||||
ctx.ui.note("[muted]no channels configured[/muted]")
|
||||
return ToolResult(summary={"channels": 0})
|
||||
table = Table(title="Channels", border_style="muted", expand=False)
|
||||
table.add_column("SLOT", justify="right")
|
||||
table.add_column("NAME")
|
||||
table.add_column("TYPE")
|
||||
table.add_column("HASH")
|
||||
for slot in slots:
|
||||
table.add_row(
|
||||
str(slot.idx),
|
||||
Text(slot.name, style="brand"),
|
||||
"public" if slot.is_public else "private",
|
||||
slot.hash,
|
||||
)
|
||||
ctx.ui.show(table)
|
||||
return ToolResult(summary={"channels": len(slots)})
|
||||
return ToolResult(
|
||||
summary={"channels": len(slots)},
|
||||
report=(_channel_listing(slots),),
|
||||
exit_code=exitcodes.OK if slots else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
async def _cli_add(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Add a public, private-random, or explicitly-keyed channel on a slot."""
|
||||
@@ -119,9 +108,11 @@ class ChannelsTool(Tool):
|
||||
else:
|
||||
secret = normalize_secret(params["secret"]) if params.get("secret") else random_secret()
|
||||
await write_channel(ctx, device, idx, name, secret)
|
||||
ctx.ui.note(f"[ok]✓[/ok] channel [brand]{name}[/brand] set on slot {idx}")
|
||||
self._print_share(ctx, name, secret)
|
||||
return ToolResult(summary={"index": idx, "name": name})
|
||||
await self._show_qr(ctx, name, share_url(name, secret))
|
||||
return ToolResult(
|
||||
summary={"index": idx, "name": name},
|
||||
report=(_written(idx, name, secret),),
|
||||
)
|
||||
|
||||
async def _cli_join(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Join a channel from a name and an explicit key."""
|
||||
@@ -132,8 +123,10 @@ class ChannelsTool(Tool):
|
||||
name = str(params["name"])
|
||||
secret = normalize_secret(str(params["secret"]))
|
||||
await write_channel(ctx, device, idx, name, secret)
|
||||
ctx.ui.note(f"[ok]✓[/ok] joined [brand]{name}[/brand] on slot {idx}")
|
||||
return ToolResult(summary={"index": idx, "name": name})
|
||||
return ToolResult(
|
||||
summary={"index": idx, "name": name},
|
||||
report=(_written(idx, name, secret, shown=False),),
|
||||
)
|
||||
|
||||
async def _cli_import(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Import a channel from a ``meshcore://channel/add`` link."""
|
||||
@@ -146,21 +139,34 @@ class ChannelsTool(Tool):
|
||||
device = await ctx.device()
|
||||
idx = int(params["index"])
|
||||
await write_channel(ctx, device, idx, name, secret)
|
||||
ctx.ui.note(f"[ok]✓[/ok] imported [brand]{name}[/brand] on slot {idx}")
|
||||
return ToolResult(summary={"index": idx, "name": name})
|
||||
return ToolResult(
|
||||
summary={"index": idx, "name": name},
|
||||
report=(_written(idx, name, secret, shown=False),),
|
||||
)
|
||||
|
||||
async def _cli_share(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Print a channel's share link and QR code."""
|
||||
from ..ui.channels import read_channel_slots
|
||||
"""Print a channel's share link, with its QR code in the menu.
|
||||
|
||||
The one command that names a channel's key deliberately, so it is the one place
|
||||
the secret and the share URL leave the app at all — every other channel document
|
||||
carries the identity and stops there.
|
||||
"""
|
||||
from ..core.channel_probe import read_channel_slots
|
||||
|
||||
device = await ctx.device()
|
||||
idx = int(params["index"])
|
||||
slot = next((s for s in await read_channel_slots(device) if s.idx == idx), None)
|
||||
if slot is None:
|
||||
ctx.ui.note(f"[warn]slot {idx} is empty[/warn]")
|
||||
return ToolResult(summary={"index": idx, "shared": False})
|
||||
self._print_share(ctx, slot.name, slot.secret)
|
||||
return ToolResult(summary={"index": idx, "shared": True})
|
||||
return ToolResult(
|
||||
summary={"index": idx, "shared": False},
|
||||
report=(_written(idx, None, None, shown=False),),
|
||||
exit_code=exitcodes.NO_RESULT,
|
||||
)
|
||||
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),),
|
||||
)
|
||||
|
||||
async def _cli_clear(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Clear a channel slot, removing the channel from the device.
|
||||
@@ -170,25 +176,38 @@ 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"])
|
||||
slot = next((s for s in await read_channel_slots(device) if s.idx == idx), None)
|
||||
if slot is None:
|
||||
ctx.ui.note(f"[muted]slot {idx} is already empty[/muted]")
|
||||
return ToolResult(summary={"index": idx, "cleared": False})
|
||||
return ToolResult(
|
||||
summary={"index": idx, "cleared": False},
|
||||
report=(_cleared(idx, False),),
|
||||
exit_code=exitcodes.NO_RESULT,
|
||||
)
|
||||
await write_channel(ctx, device, idx, "", None) # empty name => the slot reads as unused
|
||||
ctx.ui.note(f"[warn]cleared channel {slot.name} on slot {idx}[/warn]")
|
||||
return ToolResult(summary={"index": idx, "cleared": True})
|
||||
return ToolResult(
|
||||
summary={"index": idx, "cleared": True},
|
||||
report=(_cleared(idx, True),),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _print_share(ctx: AppContext, name: str, secret: bytes) -> None:
|
||||
"""Render a channel's QR code and share URL into the output surface."""
|
||||
from ..ui.qr import qr_text
|
||||
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.
|
||||
|
||||
url = share_url(name, secret)
|
||||
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
|
||||
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 share_screen
|
||||
from ..ui.surface import TuiUi
|
||||
|
||||
if isinstance(ctx.ui, TuiUi):
|
||||
await share_screen(ctx, name=name, url=url)
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register the ``channels`` subcommand group.
|
||||
@@ -198,7 +217,7 @@ class ChannelsTool(Tool):
|
||||
"""
|
||||
from ..cli import run_tool_command
|
||||
|
||||
channels_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode="rich")
|
||||
channels_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode=None)
|
||||
|
||||
@channels_app.command("list", help="List the configured channel slots")
|
||||
def _list_cmd() -> None:
|
||||
@@ -247,12 +266,98 @@ class ChannelsTool(Tool):
|
||||
),
|
||||
) -> None:
|
||||
if not yes:
|
||||
typer.secho(
|
||||
"Refusing: clearing a slot removes the channel (a private channel's "
|
||||
"key is lost unless you have it saved). Re-run with --yes to confirm.",
|
||||
fg="red",
|
||||
# A usage error, in the parser's own words and under its own status — not a
|
||||
# red line printed onto whatever is reading this command's output.
|
||||
raise typer.BadParameter(
|
||||
"clearing a slot removes the channel (a private channel's key is lost "
|
||||
"unless you have it saved). Re-run with --yes to confirm."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
run_tool_command(self, {"cli_action": "clear", "index": index})
|
||||
|
||||
app.add_typer(channels_app, name=self.name)
|
||||
|
||||
|
||||
def _channel_listing(slots: list) -> Listing:
|
||||
"""The configured slots, one record each.
|
||||
|
||||
The one listing whose record *is* a shared shape rather than carrying one: a row that
|
||||
nested its only field under a ``channel`` key would make ``jq '.[].channel.name'``
|
||||
out of a listing whose every column is already the channel.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
return Listing(
|
||||
key="channels",
|
||||
columns=(
|
||||
fields.integer("slot", "SLOT"),
|
||||
fields.name("name", "NAME"),
|
||||
fields.word("type", "TYPE"),
|
||||
fields.hexid("hash", "HASH"),
|
||||
),
|
||||
rows=[
|
||||
{
|
||||
"slot": slot.idx,
|
||||
"name": slot.name,
|
||||
"type": "public" if slot.is_public else "private",
|
||||
"hash": slot.hash,
|
||||
}
|
||||
for slot in slots
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _written(idx: int, name: str | None, secret: bytes | None, *, shown: bool = True) -> Facts:
|
||||
"""What a slot now holds, after ``add``, ``join``, ``import`` or ``share``.
|
||||
|
||||
All four write or read the same thing and all four know the key they wrote, so all
|
||||
four state the same shape. The plain face prints the share URL alone where the caller
|
||||
asked to be given something to pass on (``add``, ``share``) and nothing at all where
|
||||
it did not (``join``, ``import``): those two were handed the key, and reading it back
|
||||
to them is not an answer.
|
||||
|
||||
Args:
|
||||
idx: The slot index.
|
||||
name: The channel name, or ``None`` for a slot that turned out to be empty.
|
||||
secret: The 16-byte key, or ``None`` for an empty slot.
|
||||
shown: Whether the plain face prints the URL.
|
||||
|
||||
Returns:
|
||||
The facts block.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.fields import ChannelRef
|
||||
from ..ui.report import BARE, SILENT, Facts
|
||||
|
||||
channel = None
|
||||
if name is not None and secret is not None:
|
||||
public = is_public_channel(name, secret)
|
||||
channel = ChannelRef(slot=idx, name=name, public=public, hash=channel_hash(secret))
|
||||
return Facts(
|
||||
key="channel",
|
||||
fields=(
|
||||
fields.channel("channel"),
|
||||
fields.hexid("secret", "secret"),
|
||||
fields.word("url", "url"),
|
||||
),
|
||||
values={
|
||||
"channel": channel,
|
||||
"secret": secret.hex() if secret else None,
|
||||
"url": share_url(name, secret) if name is not None and secret is not None else None,
|
||||
},
|
||||
shape=BARE if shown else SILENT,
|
||||
bare="url",
|
||||
)
|
||||
|
||||
|
||||
def _cleared(idx: int, cleared: bool) -> Facts:
|
||||
"""What ``channels clear`` did — nothing plain, since the status already said it."""
|
||||
from ..ui import fields
|
||||
from ..ui.report import SILENT, Facts
|
||||
|
||||
return Facts(
|
||||
key="cleared",
|
||||
fields=(fields.integer("slot", "slot"), fields.flag("cleared", "cleared")),
|
||||
values={"slot": idx, "cleared": cleared},
|
||||
shape=SILENT,
|
||||
)
|
||||
+423
-108
@@ -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
|
||||
@@ -15,37 +16,46 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
from rich.table import Table
|
||||
from rich.cells import cell_len
|
||||
from rich.text import Text
|
||||
|
||||
from ..context import AppContext
|
||||
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
|
||||
from ..core.models import (
|
||||
NODE_TYPE_CHAT,
|
||||
NODE_TYPE_LABELS,
|
||||
ChatMessage,
|
||||
Contact,
|
||||
Conversation,
|
||||
is_direct_messageable,
|
||||
)
|
||||
from ..services.trace_runner import NameKeyResolver, make_name_key_resolver
|
||||
from ..ui.chat import _MENTION, _split_channel_sender
|
||||
from ..ui import renderers
|
||||
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.
|
||||
_HISTORY_LIMIT = 50
|
||||
@@ -121,7 +131,16 @@ class ChatTool(Tool):
|
||||
picker = SelectScreen(
|
||||
"Chat — pick a conversation",
|
||||
await self._picker_items(ctx),
|
||||
delete_hint="Del delete history",
|
||||
# Enter pushes the chat screen, so the committing verb is `open`. Neither the
|
||||
# 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
|
||||
# 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.
|
||||
footer_hint="↑↓ move · type to filter · Enter open · Esc back",
|
||||
delete_hint="Del erase",
|
||||
)
|
||||
opened = 0
|
||||
shown = 0
|
||||
@@ -173,35 +192,43 @@ class ChatTool(Tool):
|
||||
# colour rule); a name no contact or stored advert carries stays muted.
|
||||
key_of = make_name_key_resolver(contacts, ctx.repo.node_names())
|
||||
|
||||
# List contacts by recency — those with messages first, newest exchange at the top —
|
||||
# then the never-contacted ones alphabetically (see _recency_key).
|
||||
direct = [Conversation(label=c.name, is_channel=False, contact=c) for c in companions]
|
||||
direct.sort(key=lambda conv: _recency_key(conv, lasts))
|
||||
# One measurement for the whole list, headings and rows alike, taken before either is
|
||||
# built: the name lane is only as wide as the names actually in it, and every cell it
|
||||
# gives back goes to the message preview (see _lanes).
|
||||
lanes = _lanes(channels + direct)
|
||||
|
||||
# The lane names pin for the whole picker (they mean the same in both groups),
|
||||
# so scrolling into Direct keeps them overhead with that group's heading under
|
||||
# them, instead of the header vanishing one row in — see Screen.sticky_rows.
|
||||
items: list = [
|
||||
Separator(_picker_header, pinned=True),
|
||||
Separator(lambda w: _picker_header(lanes, w), pinned=True),
|
||||
section_heading("📡 Channels"),
|
||||
]
|
||||
for conversation in channels:
|
||||
items.append(
|
||||
Choice(
|
||||
title=_row_title(ctx, conversation, live, key_of),
|
||||
title=_row_title(ctx, conversation, live, key_of, lanes),
|
||||
value=conversation,
|
||||
# ←→ slide the message alone; the lanes in front of it hold (_Lanes.head).
|
||||
hscroll_from=lanes.head,
|
||||
)
|
||||
)
|
||||
|
||||
items.append(section_heading("👤 Direct"))
|
||||
if companions:
|
||||
# List contacts by recency — those with messages first, newest exchange at
|
||||
# the top — then the never-contacted ones alphabetically (see _recency_key).
|
||||
direct = [Conversation(label=c.name, is_channel=False, contact=c) for c in companions]
|
||||
direct.sort(key=lambda conv: _recency_key(conv, lasts))
|
||||
for conversation in direct:
|
||||
items.append(
|
||||
Choice(
|
||||
title=_row_title(ctx, conversation, live, key_of),
|
||||
title=_row_title(ctx, conversation, live, key_of, lanes),
|
||||
value=conversation,
|
||||
# Del offers to delete this thread's stored history —
|
||||
# only where there is history to delete.
|
||||
deletable=lasts.get(conversation.key) is not None,
|
||||
hscroll_from=lanes.head,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -252,20 +279,30 @@ class ChatTool(Tool):
|
||||
return await self._cli_list(ctx)
|
||||
|
||||
async def _cli_send(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Send a channel or direct message and report the outcome."""
|
||||
"""Send a channel or direct message and report whether it was acknowledged.
|
||||
|
||||
A channel broadcast has nothing to report — there is no acknowledgement on a
|
||||
channel — so it prints nothing and lets the exit status say it went out. A direct
|
||||
message prints its ``acked`` state, which is the one thing the send does not
|
||||
already tell the caller: the radio accepted it either way, and whether the peer
|
||||
answered is a separate fact.
|
||||
"""
|
||||
device = await ctx.device()
|
||||
text = str(params["text"])
|
||||
channel = params.get("channel")
|
||||
if channel is not None:
|
||||
await ctx.chat.send_channel(int(channel), text, label=f"#{channel}")
|
||||
ctx.ui.note(f"[ok]✓[/ok] sent to channel [brand]{channel}[/brand]")
|
||||
return ToolResult(summary={"channel": channel, "sent": True})
|
||||
return ToolResult(
|
||||
summary={"channel": channel, "sent": True},
|
||||
report=(_sent(channel=int(channel)),),
|
||||
)
|
||||
|
||||
contact = _resolve_contact(await device.get_contacts(), str(params["to"]))
|
||||
message = await ctx.chat.send_direct(contact, text)
|
||||
state = "[ok]✓ delivered[/ok]" if message.acked else "[warn]? no ack[/warn]"
|
||||
ctx.ui.note(f"[ok]✓[/ok] sent to [brand]{contact.name}[/brand] — {state}")
|
||||
return ToolResult(summary={"to": contact.name, "acked": bool(message.acked)})
|
||||
return ToolResult(
|
||||
summary={"to": contact.name, "acked": bool(message.acked)},
|
||||
report=(_sent(contact=contact, acked=bool(message.acked)),),
|
||||
)
|
||||
|
||||
async def _cli_history(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Print a conversation's stored transcript."""
|
||||
@@ -288,11 +325,11 @@ class ChatTool(Tool):
|
||||
)
|
||||
label = contact.name
|
||||
|
||||
if not messages:
|
||||
ctx.ui.note(f"[muted]no messages with {label} yet[/muted]")
|
||||
else:
|
||||
ctx.ui.show(_history_table(label, messages))
|
||||
return ToolResult(summary={"conversation": label, "messages": len(messages)})
|
||||
return ToolResult(
|
||||
summary={"conversation": label, "messages": len(messages)},
|
||||
report=(_transcript(messages),),
|
||||
exit_code=exitcodes.OK if messages else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
async def _cli_listen(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Tail inbound messages live in the console (and record them to history).
|
||||
@@ -309,12 +346,18 @@ class ChatTool(Tool):
|
||||
Returns:
|
||||
A :class:`ToolResult` with how many messages were seen.
|
||||
"""
|
||||
from ..ui import script
|
||||
|
||||
seconds = params.get("seconds") or 0
|
||||
await ctx.device()
|
||||
await ctx.chat.start() # begins recording inbound to history too
|
||||
ctx.console.print(
|
||||
"[muted]listening for messages"
|
||||
f"{f' for {seconds}s' if seconds else ' — press Ctrl-C to stop'}…[/muted]"
|
||||
# About the run, not part of its answer: stderr, so a redirected stdout holds
|
||||
# nothing but messages.
|
||||
script.stderr_console().print(
|
||||
"listening for messages"
|
||||
+ (f" for {seconds}s" if seconds else " — press Ctrl-C to stop"),
|
||||
style="muted",
|
||||
highlight=False,
|
||||
)
|
||||
seen = 0
|
||||
|
||||
@@ -324,27 +367,39 @@ class ChatTool(Tool):
|
||||
if message is None:
|
||||
return
|
||||
seen += 1
|
||||
stamp = message.received_at.astimezone().strftime("%H:%M:%S")
|
||||
if message.is_channel:
|
||||
who = f"#{message.channel}"
|
||||
else:
|
||||
who = message.sender or "?"
|
||||
snr = f" [muted]({message.snr:+.0f} dB)[/muted]" if message.snr is not None else ""
|
||||
ctx.console.print(f"[muted]{stamp}[/muted] [accent]{who}[/accent]: {message.text}{snr}")
|
||||
channel = (
|
||||
_channel_ref(message.channel, f"#{message.channel}", None)
|
||||
if message.is_channel
|
||||
else None
|
||||
)
|
||||
emit(
|
||||
{
|
||||
"received_at": message.received_at,
|
||||
"conversation": channel.name if channel else (message.sender or None),
|
||||
"direction": "in",
|
||||
"node": None if message.is_channel else NodeRef(hash=message.sender),
|
||||
"channel": channel,
|
||||
"snr_db": message.snr,
|
||||
"text": message.text,
|
||||
"acked": None,
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribe = ctx.events.subscribe(on_message, EventKind.MESSAGE)
|
||||
try:
|
||||
if seconds:
|
||||
await asyncio.sleep(seconds)
|
||||
else:
|
||||
await asyncio.Event().wait() # until Ctrl-C / cancellation
|
||||
except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive
|
||||
pass
|
||||
finally:
|
||||
unsubscribe()
|
||||
with renderers.stream(ctx, _live_messages()) as emit:
|
||||
unsubscribe = ctx.events.subscribe(on_message, EventKind.MESSAGE)
|
||||
try:
|
||||
if seconds:
|
||||
await asyncio.sleep(seconds)
|
||||
else:
|
||||
await asyncio.Event().wait() # until Ctrl-C / cancellation
|
||||
except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive
|
||||
pass
|
||||
finally:
|
||||
unsubscribe()
|
||||
return ToolResult(
|
||||
summary={"messages": seen},
|
||||
message=f"[muted]stopped — heard {seen} message{'' if seen == 1 else 's'}[/muted]",
|
||||
exit_code=exitcodes.OK if seen else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
async def _cli_list(self, ctx: AppContext) -> ToolResult:
|
||||
@@ -354,23 +409,14 @@ class ChatTool(Tool):
|
||||
contacts = await device.get_contacts()
|
||||
lasts = ctx.repo.last_chat_messages()
|
||||
|
||||
table = Table(title="Conversations", border_style="muted", expand=False)
|
||||
table.add_column("CONVERSATION")
|
||||
table.add_column("UNREAD", justify="right")
|
||||
table.add_column("LAST MESSAGE")
|
||||
rows = [*channels] + [
|
||||
Conversation(label=c.name, is_channel=False, contact=c) for c in contacts
|
||||
]
|
||||
for conversation in rows:
|
||||
last = lasts.get(conversation.key)
|
||||
snippet = ""
|
||||
if last is not None:
|
||||
who = "you: " if last.outbound else ""
|
||||
snippet = f"{who}{last.text[:40]}"
|
||||
unread = ctx.chat.unread(conversation.key)
|
||||
table.add_row(conversation.label, str(unread) if unread else "·", snippet)
|
||||
ctx.ui.show(table)
|
||||
return ToolResult(summary={"conversations": len(rows)})
|
||||
return ToolResult(
|
||||
summary={"conversations": len(rows)},
|
||||
report=(_conversations(ctx, rows, lasts),),
|
||||
exit_code=exitcodes.OK if rows else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register the ``chat`` subcommand group.
|
||||
@@ -380,7 +426,7 @@ class ChatTool(Tool):
|
||||
"""
|
||||
from ..cli import run_tool_command
|
||||
|
||||
chat_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode="rich")
|
||||
chat_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode=None)
|
||||
|
||||
@chat_app.command("send", help="Send a message to a contact or channel")
|
||||
def _send_cmd(
|
||||
@@ -454,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.
|
||||
@@ -605,25 +651,87 @@ class _LiveLasts:
|
||||
return self._cache.get(key)
|
||||
|
||||
|
||||
#: Column width (display cells) the conversation label is padded/ellipsized to, so the unread
|
||||
#: badge and message preview line up in fixed lanes down the picker.
|
||||
_LABEL_WIDTH = 22
|
||||
#: Width of the unread-badge lane between the label and the age (fits ``● 999``).
|
||||
_BADGE_WIDTH = 5
|
||||
#: Width of the relative-age lane between the badge and the preview (right-aligned; fits ``now``
|
||||
#: and two-digit spans like ``59m`` / ``23h``), so every row's message text starts in one column.
|
||||
_AGE_WIDTH = 3
|
||||
#: Longest message preview shown before it is ellipsized.
|
||||
_PREVIEW_WIDTH = 40
|
||||
#: Floor for the conversation lane. The header word is ``CONVERSATION`` (12 cells) and the
|
||||
#: lane carries a two-cell gap after it, so anything narrower than this runs the heading
|
||||
#: straight into ``UNREAD`` on a mesh whose every name is ``Bob``.
|
||||
_LABEL_MIN = 12
|
||||
#: Ceiling for it. The lane is sized to the longest name it actually holds (see
|
||||
#: :func:`_lanes`), and this is where that stops paying: one very long name would otherwise
|
||||
#: buy padding in front of every *short* one. It holds the long-but-ordinary shape of a
|
||||
#: repeater-style name (``YUL-Cartierville``), a name past it ellipsizes — the row is a way
|
||||
#: in to the conversation, whose own screen is titled with the whole name — and every cell
|
||||
#: the ceiling saves goes to the preview.
|
||||
_LABEL_MAX = 16
|
||||
|
||||
|
||||
def _picker_header(width: int) -> str:
|
||||
@dataclass(frozen=True)
|
||||
class _Lanes:
|
||||
"""The picker's fixed lane widths, measured once per list build.
|
||||
|
||||
Both the column header and every row are laid out from one of these, so the headings
|
||||
cannot drift off the columns they name. Only the first two vary: the marker is the
|
||||
platform's own glyph width (a channel glyph is double-cell on regular and single on the
|
||||
PicoCalc console) and the label lane is sized to the longest name the list actually
|
||||
holds, within :data:`_LABEL_MIN` / :data:`_LABEL_MAX`.
|
||||
|
||||
Attributes:
|
||||
marker: The leading glyph lane — a channel glyph or contact dot plus its space.
|
||||
label: Cells the conversation name is padded/ellipsized to.
|
||||
"""
|
||||
|
||||
marker: int
|
||||
label: int
|
||||
|
||||
@property
|
||||
def head(self) -> int:
|
||||
"""Cells before the preview: everything ←→ leave pinned (:attr:`Choice.hscroll_from`).
|
||||
|
||||
Marker, name, unread badge and age, with the two-cell gap that follows each — the
|
||||
columns that say *which conversation this row is*. The preview is the only run that
|
||||
slides, which is the point: the lanes are the reader's place in the list and they
|
||||
already fit.
|
||||
"""
|
||||
return self.marker + self.label + 2 + _BADGE_WIDTH + 2 + _AGE_WIDTH + 2
|
||||
|
||||
|
||||
def _lanes(conversations: list[Conversation]) -> _Lanes:
|
||||
"""Measure the picker's lanes against the conversations it is about to draw.
|
||||
|
||||
The name lane used to be a flat 22 cells, which is a wide claim on a 72-column terminal
|
||||
and a very wide one on the PicoCalc's 53: every cell it holds past the longest name in
|
||||
the list is padding taken straight out of the last-message preview. Sized to the content
|
||||
instead, a mesh of ``Alice`` and ``Bob`` keeps its names inside the header word's own
|
||||
lane and gives everything else to the messages.
|
||||
|
||||
Args:
|
||||
conversations: Every row the list will hold, channels and direct alike.
|
||||
|
||||
Returns:
|
||||
The lane widths for both the header and the rows.
|
||||
"""
|
||||
longest = max((cell_len(c.label) for c in conversations), default=0)
|
||||
return _Lanes(
|
||||
# The marker is "glyph + space", and the glyph is the platform's: double-cell on
|
||||
# regular, single on the console font. Read from the widget rather than assumed, so
|
||||
# the two groups' lanes line up on both platforms — a contact's dot is padded to
|
||||
# whatever a channel's glyph measures here (see _append_marker).
|
||||
marker=cell_len(channel_glyph("Public", None)) + 1,
|
||||
label=max(_LABEL_MIN, min(_LABEL_MAX, longest)),
|
||||
)
|
||||
|
||||
|
||||
def _picker_header(lanes: _Lanes, width: int) -> str:
|
||||
"""Column headers over the picker's fixed lanes (see :func:`_title` for the layout).
|
||||
|
||||
The five-cell indent covers the select screen's pointer column (2 cells, drawn on choice
|
||||
rows but not separators) plus the marker lane (3 cells), so each header lands exactly
|
||||
over its column. UNREAD borrows its lane's trailing gap — the badge lane itself is one
|
||||
cell too narrow for the word — which still leaves a space before the age column.
|
||||
The indent covers the select screen's pointer column (2 cells, drawn on choice rows but
|
||||
not separators) plus the marker lane, so each header lands exactly over its column.
|
||||
UNREAD borrows its lane's trailing gap — the badge lane itself is one cell too narrow
|
||||
for the word — which still leaves a space before the age column.
|
||||
|
||||
Resolved against the render width (the header row is pinned, so it must stay one row):
|
||||
on a terminal too narrow for the whole line, ``LAST MESSAGE`` gives its cells back a
|
||||
@@ -632,13 +740,13 @@ def _picker_header(width: int) -> str:
|
||||
"""
|
||||
return column_header(
|
||||
[
|
||||
Lane("CONVERSATION", _LABEL_WIDTH + 2),
|
||||
Lane("CONVERSATION", lanes.label + 2),
|
||||
Lane("UNREAD", _BADGE_WIDTH + 2),
|
||||
Lane("AGE", _AGE_WIDTH + 2),
|
||||
Lane("TIME", _AGE_WIDTH + 2),
|
||||
Lane(("LAST MESSAGE", "LAST MSG", "LAST")),
|
||||
],
|
||||
width,
|
||||
indent=5,
|
||||
indent=2 + lanes.marker,
|
||||
)
|
||||
|
||||
|
||||
@@ -647,6 +755,7 @@ def _row_title(
|
||||
conversation: Conversation,
|
||||
lasts: _LiveLasts,
|
||||
key_of: NameKeyResolver,
|
||||
lanes: _Lanes,
|
||||
) -> Callable[[], str | Text]:
|
||||
"""Return a picker-row title *callable* the select screen re-renders on each repaint.
|
||||
|
||||
@@ -659,11 +768,12 @@ def _row_title(
|
||||
conversation: The conversation the row represents.
|
||||
lasts: The self-refreshing latest-message view feeding the preview.
|
||||
key_of: Maps a sender name back to its node's key, for the preview hues.
|
||||
lanes: The list's measured lane widths.
|
||||
|
||||
Returns:
|
||||
A zero-argument callable producing the current row title.
|
||||
"""
|
||||
return lambda: _title(ctx, conversation, lasts, key_of)
|
||||
return lambda: _title(ctx, conversation, lasts, key_of, lanes)
|
||||
|
||||
|
||||
def _title(
|
||||
@@ -671,6 +781,7 @@ def _title(
|
||||
conversation: Conversation,
|
||||
lasts: _LiveLasts,
|
||||
key_of: NameKeyResolver,
|
||||
lanes: _Lanes,
|
||||
) -> str | Text:
|
||||
"""Build a picker row as fixed-width, colour-coded lanes.
|
||||
|
||||
@@ -688,6 +799,7 @@ def _title(
|
||||
conversation: The conversation the row represents.
|
||||
lasts: The self-refreshing latest-message view.
|
||||
key_of: Maps a preview sender/mention name back to its node's key.
|
||||
lanes: The list's measured lane widths.
|
||||
|
||||
Returns:
|
||||
The row title as a styled :class:`~rich.text.Text`.
|
||||
@@ -695,12 +807,12 @@ def _title(
|
||||
unread = ctx.chat.unread(conversation.key)
|
||||
last = lasts.get(conversation.key)
|
||||
text = Text(no_wrap=True, overflow="ellipsis")
|
||||
_append_marker(text, conversation, last)
|
||||
_append_marker(text, conversation, last, lanes)
|
||||
label_style = ""
|
||||
if not conversation.is_channel and conversation.contact is not None:
|
||||
contact = conversation.contact
|
||||
label_style = name_style(conversation.label, contact.public_key or contact.key_prefix)
|
||||
text.append(fit_cells(conversation.label, _LABEL_WIDTH), style=label_style or None)
|
||||
text.append(fit_cells(conversation.label, lanes.label), style=label_style or None)
|
||||
text.append(" ")
|
||||
# Unread badge lane (_BADGE_WIDTH cells): a red ● with the count in warn, or blank filler so
|
||||
# the following lanes still line up on rows with nothing unread.
|
||||
@@ -723,24 +835,31 @@ 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(text: Text, conversation: Conversation, last: ChatMessage | None) -> None:
|
||||
"""Prepend the row's leading marker (3 display cells) — a channel glyph or a contact dot.
|
||||
def _append_marker(
|
||||
text: Text, conversation: Conversation, last: ChatMessage | None, lanes: _Lanes
|
||||
) -> None:
|
||||
"""Prepend the row's leading marker — a channel glyph or a contact dot — in its own lane.
|
||||
|
||||
A channel keeps its openness marker (# / 🌐 / 🔒). A contact gets a small circle in the
|
||||
standard companion pink — filled (``●``) once we've exchanged messages, a hollow ring
|
||||
(``○``) before any — so the hollow-vs-filled shape marks whether there's history while the
|
||||
name itself carries the person's key-derived hue. The contact dot is padded to the same
|
||||
width as a channel's double-cell glyph so the labels line up across both sections.
|
||||
name itself carries the person's key-derived hue.
|
||||
|
||||
The dot is padded out to :attr:`_Lanes.marker`, which is measured from the channel glyph
|
||||
itself rather than assumed: that glyph is double-cell on regular and single-cell on the
|
||||
PicoCalc console font, so a hard-coded pad lined the two sections up on one platform and
|
||||
left every channel row a cell to the left of every direct row on the other.
|
||||
"""
|
||||
if conversation.is_channel:
|
||||
text.append(f"{channel_glyph(conversation.label, conversation.secret)} ")
|
||||
glyph = channel_glyph(conversation.label, conversation.secret)
|
||||
text.append(glyph + " " * max(1, lanes.marker - cell_len(glyph)))
|
||||
else:
|
||||
dot = "●" if last is not None else "○"
|
||||
text.append(dot, style=_COMPANION_DOT_STYLE)
|
||||
text.append(" ")
|
||||
text.append(" " * max(1, lanes.marker - cell_len(dot)))
|
||||
|
||||
|
||||
def _preview_text(last: ChatMessage, key_of: NameKeyResolver) -> Text:
|
||||
@@ -749,8 +868,12 @@ def _preview_text(last: ChatMessage, key_of: NameKeyResolver) -> Text:
|
||||
Mirrors the live transcript: our own messages get a ``you:`` prefix, an inbound channel
|
||||
message's inline ``Name:`` sender is coloured in its key-derived hue (muted when no known
|
||||
node carries the name), and every ``@[Name]`` mention reads as a bare ``@Name`` the same
|
||||
way — so the list and the chat speak the same colour language. The result is clipped to
|
||||
:data:`_PREVIEW_WIDTH` cells.
|
||||
way — so the list and the chat speak the same colour language.
|
||||
|
||||
The preview is built *whole*, however long the message ran. It used to be clipped to a
|
||||
fixed 40 cells, which is a cut nothing could undo: the row is what ←→ scroll now, and a
|
||||
preview pre-truncated to roughly the visible lane would have had nothing left to reveal.
|
||||
The list cuts it at the right edge like any other row; the scroll walks past that.
|
||||
"""
|
||||
body_raw = last.text.replace("\n", " ")
|
||||
text = Text()
|
||||
@@ -758,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")
|
||||
@@ -767,7 +890,6 @@ def _preview_text(last: ChatMessage, key_of: NameKeyResolver) -> Text:
|
||||
_append_body(text, body_raw, key_of)
|
||||
else:
|
||||
_append_body(text, body_raw, key_of)
|
||||
text.truncate(_PREVIEW_WIDTH, overflow="ellipsis")
|
||||
return text
|
||||
|
||||
|
||||
@@ -778,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)
|
||||
@@ -789,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
|
||||
@@ -797,28 +919,221 @@ 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 _history_table(label: str, messages: list[ChatMessage]) -> Table:
|
||||
"""Render a conversation's stored messages as a table.
|
||||
def _channel_ref(idx: int | None, label: str, secret: bytes | None) -> ChannelRef:
|
||||
"""One channel as the shared shape, from whatever the surface knows about it.
|
||||
|
||||
Args:
|
||||
label: The conversation's display name.
|
||||
messages: The messages to show, oldest-first.
|
||||
|
||||
Returns:
|
||||
A Rich :class:`Table` of time, sender, and text.
|
||||
A transcript row and a live message know a slot and a label but not the key, so
|
||||
``public`` is read off the name where the secret is not in hand — which is what the
|
||||
leading ``#`` means and how a public channel is written everywhere else in the app.
|
||||
"""
|
||||
table = Table(title=f"History — {label}", border_style="muted", expand=False)
|
||||
table.add_column("TIME")
|
||||
table.add_column("FROM")
|
||||
table.add_column("MESSAGE")
|
||||
return ChannelRef(
|
||||
slot=int(idx or 0),
|
||||
name=label,
|
||||
public=is_public_channel(label, secret) if secret else label.startswith("#"),
|
||||
hash=channel_hash(secret) if secret else None,
|
||||
)
|
||||
|
||||
|
||||
def _message_columns() -> tuple:
|
||||
"""One transcript row's columns, shared by ``history`` and the ``listen`` stream.
|
||||
|
||||
``DIR`` carries what the menu's transcript writes into its sender lane as the word
|
||||
"you": which way a message went is a fact of its own, and spending the name lane on it
|
||||
made our own name a value that lane could not otherwise hold. ``PEER`` is therefore
|
||||
always the *other* party.
|
||||
|
||||
``TEXT`` is last and escaped: it is the rest of the line, and "the rest of the line"
|
||||
stops being true the moment a body holds a newline — after which the remainder reads
|
||||
as a second record with an empty time. The document carries the body raw, a JSON string
|
||||
holding a newline natively.
|
||||
"""
|
||||
from ..ui import fields
|
||||
|
||||
return (
|
||||
fields.when("created_at", "TIME"),
|
||||
fields.word("direction", "DIR"),
|
||||
# One column for whichever end the conversation had; the document keeps the two
|
||||
# apart, because a parser cannot tell a channel from a contact by its label.
|
||||
fields.plain_only(fields.name("conversation", "PEER")),
|
||||
fields.node("node", lanes=()),
|
||||
fields.channel("channel", lanes=()),
|
||||
fields.snr("snr_db", "SNR_DB"),
|
||||
fields.free("text", "TEXT"),
|
||||
fields.hidden("acked"),
|
||||
)
|
||||
|
||||
|
||||
def _transcript(messages: list[ChatMessage]) -> Listing:
|
||||
"""A conversation's stored messages, oldest first, one record each."""
|
||||
from ..ui.report import Listing
|
||||
|
||||
rows = []
|
||||
for message in messages:
|
||||
stamp = message.created_at.astimezone().strftime("%m-%d %H:%M")
|
||||
if message.outbound:
|
||||
who = Text("you", style="accent")
|
||||
else:
|
||||
who = Text(message.peer_name or message.peer or "?", style="brand")
|
||||
table.add_row(stamp, who, message.text)
|
||||
return table
|
||||
channel = (
|
||||
_channel_ref(message.channel_idx, message.peer_name or f"#{message.channel_idx}", None)
|
||||
if message.is_channel
|
||||
else None
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"created_at": message.created_at,
|
||||
"direction": "out" if message.outbound else "in",
|
||||
"conversation": message.peer_name or message.peer or None,
|
||||
"node": None
|
||||
if message.is_channel
|
||||
else NodeRef(name=message.peer_name, hash=message.peer),
|
||||
"channel": channel,
|
||||
"snr_db": message.snr,
|
||||
"text": message.text,
|
||||
"acked": message.acked,
|
||||
}
|
||||
)
|
||||
return Listing(
|
||||
key="messages",
|
||||
columns=_message_columns(),
|
||||
rows=rows,
|
||||
order=("TIME", "DIR", "PEER", "SNR_DB", "TEXT"),
|
||||
)
|
||||
|
||||
|
||||
def _live_messages() -> Listing:
|
||||
"""The shape ``chat listen`` streams: what arrived, as it arrived.
|
||||
|
||||
``TIME`` is absolute here where the transcript's is an age, and for the reason the
|
||||
whole time rule turns on: every row of a live tail would read ``now``, which is no
|
||||
information at all. There is no ``DIR`` lane — everything a tail hears came in — and
|
||||
the body goes last, so the one field with no width can never push a lane.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
def pin(column, width: int): # noqa: ANN001, ANN202 - one column in, one column out
|
||||
return replace(column, lanes=tuple(replace(lane, width=width) for lane in column.lanes))
|
||||
|
||||
return Listing(
|
||||
key="messages",
|
||||
columns=(
|
||||
pin(fields.instant("received_at", "TIME"), 25),
|
||||
pin(fields.plain_only(fields.name("conversation", "PEER")), 16),
|
||||
fields.node("node", lanes=()),
|
||||
fields.channel("channel", lanes=()),
|
||||
pin(fields.snr("snr_db", "SNR_DB"), 6),
|
||||
fields.free("text", "TEXT"),
|
||||
fields.hidden("direction"),
|
||||
fields.hidden("acked"),
|
||||
),
|
||||
order=("TIME", "PEER", "SNR_DB", "TEXT"),
|
||||
)
|
||||
|
||||
|
||||
def _conversations(ctx: AppContext, rows: list, lasts: dict) -> Listing:
|
||||
"""Every channel and contact, with the last thing said in each.
|
||||
|
||||
``LAST`` is an age rather than an instant: a conversation list is scanned for what is
|
||||
warm, and ``never`` is a real answer — the conversation exists and nothing has been
|
||||
said in it.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
records = []
|
||||
for conversation in rows:
|
||||
last = lasts.get(conversation.key)
|
||||
channel = (
|
||||
_channel_ref(conversation.channel_idx, conversation.label, conversation.secret)
|
||||
if conversation.is_channel
|
||||
else None
|
||||
)
|
||||
contact = getattr(conversation, "contact", None)
|
||||
records.append(
|
||||
{
|
||||
"kind": "channel" if conversation.is_channel else "direct",
|
||||
"conversation": conversation.label,
|
||||
"channel": channel,
|
||||
"node": None
|
||||
if conversation.is_channel
|
||||
else NodeRef(
|
||||
name=conversation.label,
|
||||
key=(getattr(contact, "public_key", "") or "").lower() or None,
|
||||
hash=(getattr(contact, "key_prefix", "") or "").lower() or None,
|
||||
type=NODE_TYPE_LABELS.get(getattr(contact, "node_type", None)),
|
||||
),
|
||||
"unread": ctx.chat.unread(conversation.key),
|
||||
"last_message_at": last.created_at if last is not None else None,
|
||||
# Not truncated to 40 cells the way the picker's preview is: nothing here
|
||||
# wraps, so there is no reason to cut a message short — but a body that
|
||||
# holds a newline would still end the record, so it is folded flat.
|
||||
"last_text": last.text if last is not None else None,
|
||||
}
|
||||
)
|
||||
return Listing(
|
||||
key="conversations",
|
||||
columns=(
|
||||
fields.word("kind", "KIND"),
|
||||
fields.plain_only(fields.name("conversation", "CONVERSATION")),
|
||||
fields.channel("channel", lanes=()),
|
||||
fields.node("node", lanes=()),
|
||||
fields.integer("unread", "UNREAD"),
|
||||
fields.when("last_message_at", "LAST", absent="never"),
|
||||
fields.free("last_text", "LAST_TEXT"),
|
||||
),
|
||||
rows=records,
|
||||
order=("CONVERSATION", "KIND", "UNREAD", "LAST", "LAST_TEXT"),
|
||||
)
|
||||
|
||||
|
||||
def _sent(
|
||||
*,
|
||||
contact: object | None = None,
|
||||
channel: int | None = None,
|
||||
acked: bool | None = None,
|
||||
) -> Facts:
|
||||
"""What one ``chat send`` did.
|
||||
|
||||
A direct message prints its ``acked`` state, which is the one thing the send does not
|
||||
already tell the caller: the radio accepted it either way, and whether the peer
|
||||
answered is a separate fact. A channel broadcast prints nothing, because there *is* no
|
||||
acknowledgement on a channel — and the document says that in the one way the plain
|
||||
face never could, with ``null`` rather than ``false``.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import PAIRS, SILENT, Facts
|
||||
|
||||
node = (
|
||||
NodeRef(
|
||||
name=contact.name,
|
||||
key=(getattr(contact, "public_key", "") or "").lower() or None,
|
||||
hash=(getattr(contact, "key_prefix", "") or "").lower() or None,
|
||||
type=NODE_TYPE_LABELS.get(getattr(contact, "node_type", None)),
|
||||
)
|
||||
if contact is not None
|
||||
else None
|
||||
)
|
||||
return Facts(
|
||||
key="sent",
|
||||
fields=(
|
||||
# `acked` is the only lane: the caller named the recipient and the radio
|
||||
# accepting the message is what a zero exit already says, so the one fact
|
||||
# left to report is whether the peer answered.
|
||||
fields.hidden("kind"),
|
||||
fields.node("node", lanes=()),
|
||||
fields.channel("channel", lanes=()),
|
||||
fields.hidden("sent"),
|
||||
fields.flag("acked", "acked"),
|
||||
),
|
||||
values={
|
||||
"kind": "channel" if channel is not None else "direct",
|
||||
"node": node,
|
||||
"channel": (
|
||||
_channel_ref(channel, f"#{channel}", None) if channel is not None else None
|
||||
),
|
||||
"sent": True,
|
||||
"acked": acked,
|
||||
},
|
||||
shape=SILENT if channel is not None else PAIRS,
|
||||
)
|
||||
+458
-92
@@ -1,34 +1,39 @@
|
||||
"""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
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
from rich.table import Table
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core.channels import CHANNEL_SLOT_PROBE_CAP
|
||||
from ..core.config_io import backup_config, plan_restore, read_backup
|
||||
from ..core.connection import Device
|
||||
from ..core.connection import Device, DeviceCommandError
|
||||
from ..core.device_config import (
|
||||
SettingSpec,
|
||||
build_snapshot,
|
||||
format_value,
|
||||
get_spec,
|
||||
parse_value,
|
||||
settings_by_category,
|
||||
)
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Block, Facts, Listing, Report
|
||||
|
||||
|
||||
@register
|
||||
class ConfigTool(Tool):
|
||||
@@ -37,25 +42,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.
|
||||
@@ -70,11 +77,16 @@ class ConfigTool(Tool):
|
||||
device = await ctx.device()
|
||||
ops: list[tuple] = list(params.get("ops") or [])
|
||||
snapshot = await build_snapshot(device)
|
||||
changes, artifacts = await apply_ops(ctx, device, snapshot, ops)
|
||||
changes, artifacts, report = await apply_ops(ctx, device, snapshot, ops)
|
||||
|
||||
plural = "" if changes == 1 else "s"
|
||||
message = f"[ok]✓[/ok] applied [brand]{changes}[/brand] change{plural}" if changes else None
|
||||
return ToolResult(summary={"changes": changes}, message=message, artifacts=artifacts)
|
||||
return ToolResult(
|
||||
summary={"changes": changes},
|
||||
message=message,
|
||||
artifacts=artifacts,
|
||||
report=report or None,
|
||||
)
|
||||
|
||||
# -- CLI --------------------------------------------------------------------
|
||||
|
||||
@@ -86,7 +98,7 @@ class ConfigTool(Tool):
|
||||
"""
|
||||
from ..cli import run_tool_command
|
||||
|
||||
config_app = typer.Typer(help=self.help, no_args_is_help=False, rich_markup_mode="rich")
|
||||
config_app = typer.Typer(help=self.help, no_args_is_help=False, rich_markup_mode=None)
|
||||
|
||||
@config_app.callback(invoke_without_command=True)
|
||||
def _root(ctx: typer.Context) -> None:
|
||||
@@ -201,13 +213,13 @@ class ConfigTool(Tool):
|
||||
|
||||
async def apply_ops(
|
||||
ctx: AppContext, device: Device, snapshot: dict, ops: list[tuple]
|
||||
) -> tuple[int, list[str]]:
|
||||
) -> 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).
|
||||
@@ -217,32 +229,51 @@ async def apply_ops(
|
||||
ops: Operation tuples (see the module docstring).
|
||||
|
||||
Returns:
|
||||
A ``(changes, artifacts)`` pair: the number of value changes applied and any
|
||||
file paths produced.
|
||||
A ``(changes, artifacts, report)`` triple: the number of value changes applied,
|
||||
any file paths produced, and the blocks stating what happened (empty for the
|
||||
menu, which shows its own acknowledgements).
|
||||
"""
|
||||
changes = 0
|
||||
artifacts: list[str] = []
|
||||
report: list[Any] = []
|
||||
applied: list[dict[str, Any]] = []
|
||||
for op in ops:
|
||||
kind = op[0]
|
||||
if kind == "show":
|
||||
await _show(ctx, device, snapshot)
|
||||
block = await _show(ctx, device, snapshot)
|
||||
if block is not None:
|
||||
report.append(block)
|
||||
elif kind == "get":
|
||||
# The bare value, nothing else: the caller named the key, so repeating it back
|
||||
# is one more thing for `$(meshterm config get name)` to strip off. The same
|
||||
# shape `sysctl -n` and `git config --get` print. The document keeps the key,
|
||||
# the type and an enum's label, none of which the bare form has room for.
|
||||
spec = get_spec(op[1])
|
||||
ctx.ui.note(f"[muted]{spec.key}[/muted] = {format_value(spec, spec.getter(snapshot))}")
|
||||
report.append(_one_setting(spec, spec.getter(snapshot)))
|
||||
elif kind == "set":
|
||||
changes += await _apply_setting(ctx, device, op[1], op[2], snapshot)
|
||||
change = await _apply_setting(ctx, device, op[1], op[2], snapshot)
|
||||
if change is not None:
|
||||
applied.append(change)
|
||||
changes += 1
|
||||
elif kind == "set_custom":
|
||||
previous = (await device.get_custom_vars()).get(op[1])
|
||||
await device.set_custom_var(op[1], op[2])
|
||||
ctx.ui.note(f"[ok]✓[/ok] custom [brand]{op[1]}[/brand] = {op[2]}")
|
||||
ctx.ui.ack(f"[ok]✓[/ok] custom [brand]{op[1]}[/brand] = {op[2]}")
|
||||
applied.append({"key": f"custom.{op[1]}", "previous": previous, "value": op[2]})
|
||||
changes += 1
|
||||
elif kind == "set_channel":
|
||||
await device.set_channel(op[1], op[2], op[3])
|
||||
ctx.ui.note(f"[ok]✓[/ok] channel {op[1]} = [brand]{op[2]}[/brand]")
|
||||
ctx.ui.ack(f"[ok]✓[/ok] channel {op[1]} = [brand]{op[2]}[/brand]")
|
||||
changes += 1
|
||||
report.append(_channel_written(op[1], op[2], op[3]))
|
||||
elif kind == "backup":
|
||||
artifacts.append(str(await _backup(device, snapshot, op[1])))
|
||||
written, counts = await _backup(device, snapshot, op[1])
|
||||
artifacts.append(str(written))
|
||||
report.append(_backed_up(written, counts))
|
||||
elif kind == "restore":
|
||||
changes += await _restore(ctx, device, snapshot, op[1], op[2])
|
||||
done, blocks = await _restore(ctx, device, snapshot, op[1], op[2])
|
||||
changes += done
|
||||
report.extend(blocks)
|
||||
elif kind == "advert":
|
||||
flood = len(op) > 1 and bool(op[1])
|
||||
await device.send_advert(flood)
|
||||
@@ -252,7 +283,8 @@ async def apply_ops(
|
||||
if public_key:
|
||||
ctx.advert_store.mark_sent(public_key, flood=flood)
|
||||
kind_label = "flood" if flood else "zero-hop"
|
||||
ctx.ui.note(f"[ok]✓[/ok] {kind_label} advertisement sent")
|
||||
ctx.ui.ack(f"[ok]✓[/ok] {kind_label} advertisement sent")
|
||||
report.append(_acted("advert", sent=True, flood=flood))
|
||||
elif kind == "advert_cadence":
|
||||
from ..core.advert_store import cadence_label
|
||||
|
||||
@@ -261,38 +293,57 @@ async def apply_ops(
|
||||
if public_key:
|
||||
ctx.advert_store.set_cadence(public_key, flood=flood, hours=hours)
|
||||
kind_label = "flood" if flood else "zero-hop"
|
||||
ctx.ui.note(
|
||||
ctx.ui.ack(
|
||||
f"[ok]✓[/ok] background {kind_label} advert: "
|
||||
f"[brand]{cadence_label(hours)}[/brand]"
|
||||
)
|
||||
changes += 1
|
||||
report.append(_acted("cadence", changes=1, flood=flood, hours=hours))
|
||||
else: # pragma: no cover - SELF_INFO always carries the key on real firmware
|
||||
ctx.ui.note("[err]device reported no public key — cadence not saved[/err]")
|
||||
raise DeviceCommandError(
|
||||
"the device reported no public key — the advert cadence was not saved"
|
||||
)
|
||||
elif kind == "share":
|
||||
_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.note(f"[ok]✓[/ok] device clock set to [brand]{stamp}[/brand]")
|
||||
ctx.ui.ack(f"[ok]✓[/ok] device clock set to [brand]{stamp}[/brand]")
|
||||
changes += 1
|
||||
report.append(
|
||||
_acted(
|
||||
"clock",
|
||||
changes=1,
|
||||
set_at=datetime.fromtimestamp(epoch).astimezone(),
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
elif kind == "reboot":
|
||||
await device.reboot()
|
||||
ctx.ui.note("[warn]device rebooting[/warn]")
|
||||
ctx.ui.ack("[warn]device rebooting[/warn]")
|
||||
report.append(_acted("reboot", rebooted=True))
|
||||
elif kind == "export_key":
|
||||
await _export_key(ctx, device, op[1] if len(op) > 1 else None, artifacts)
|
||||
report.append(await _export_key(ctx, device, op[1] if len(op) > 1 else None, artifacts))
|
||||
elif kind == "import_key":
|
||||
await device.import_private_key(op[1])
|
||||
ctx.ui.note("[ok]✓[/ok] private key imported")
|
||||
ctx.ui.ack("[ok]✓[/ok] private key imported")
|
||||
changes += 1
|
||||
report.append(_acted("imported", changes=1, imported=True))
|
||||
elif kind == "factory_reset":
|
||||
await device.factory_reset()
|
||||
ctx.ui.note("[err]device factory-reset[/err]")
|
||||
ctx.ui.ack("[err]device factory-reset[/err]")
|
||||
report.append(_acted("factory_reset", factory_reset=True))
|
||||
else: # pragma: no cover - guarded by the call sites that build ops
|
||||
ctx.ui.note(f"[err]unknown config op: {kind}[/err]")
|
||||
raise DeviceCommandError(f"unknown config operation: {kind}")
|
||||
if applied:
|
||||
report.append(_applied(applied))
|
||||
# Drop any session-cached facts these ops may have changed, so the next screen re-reads
|
||||
# the truth rather than a stale copy (see meshterm.services.device_state.DeviceState). A
|
||||
# reboot/reconnect resets the whole cache on its own; these cover the in-place edits.
|
||||
@@ -305,22 +356,25 @@ async def apply_ops(
|
||||
ctx.devstate.invalidate_config() # self-info fields + path-hash mode
|
||||
if "set_channel" in kinds:
|
||||
ctx.devstate.invalidate_channels()
|
||||
return changes, artifacts
|
||||
return changes, artifacts, tuple(report)
|
||||
|
||||
|
||||
async def _apply_setting(
|
||||
ctx: AppContext, device: Device, key: str, raw: Any, snapshot: dict
|
||||
) -> int:
|
||||
) -> dict[str, Any]:
|
||||
"""Parse, apply and record one setting; keep ``snapshot`` consistent.
|
||||
|
||||
The local snapshot is updated with the new value so a later coupled change in the
|
||||
same batch (e.g. another radio field) is rebuilt from current values.
|
||||
|
||||
Returns:
|
||||
``1`` (a change was applied).
|
||||
The change, as ``{"key", "previous", "value"}``. The *previous* value is read off
|
||||
the snapshot before it is overwritten: a caller managing a machine's configuration
|
||||
wants to know what moved, and it is gone the moment this returns.
|
||||
"""
|
||||
spec = get_spec(key)
|
||||
value = parse_value(spec, raw, snapshot)
|
||||
previous = spec.getter(snapshot)
|
||||
await spec.apply(device, value, snapshot)
|
||||
snapshot[key] = value
|
||||
# Remember what we set, keyed by the device's own public key, so a forgetful device (a
|
||||
@@ -328,94 +382,266 @@ async def _apply_setting(
|
||||
# meshterm.core.settings_store). Provenance-gated: only values changed through MeshTerm.
|
||||
if ctx.settings_store is not None:
|
||||
ctx.settings_store.remember(str(snapshot.get("public_key") or ""), key, value)
|
||||
ctx.ui.note(f"[ok]✓[/ok] [brand]{key}[/brand] = {format_value(spec, value)}")
|
||||
return 1
|
||||
ctx.ui.ack(f"[ok]✓[/ok] [brand]{key}[/brand] = {format_value(spec, value)}")
|
||||
return {"key": key, "previous": previous, "value": value}
|
||||
|
||||
|
||||
async def _show(ctx: AppContext, device: Device, snapshot: dict) -> None:
|
||||
"""Render the full configuration table plus custom variables."""
|
||||
from ..ui.config_editor import config_table
|
||||
async def _show(ctx: AppContext, device: Device, snapshot: dict) -> Listing | None:
|
||||
"""Print every current setting, plus any custom variables.
|
||||
|
||||
The menu shows the annotated table; a scripted run gets one ``key value`` line per
|
||||
setting, keyed exactly as ``config get`` and ``config set`` name them, so a line read
|
||||
out of ``show`` can be typed straight back in. The labels and descriptions the table
|
||||
carries are for a reader choosing a setting, and this reader has already chosen.
|
||||
|
||||
The pairing PIN stays concealed here, as it is in the table: this is the whole-device
|
||||
dump, the thing that gets redirected into a file and pasted into a bug report, and the
|
||||
PIN is the one value on it that lets someone else's phone onto the radio.
|
||||
``config get device_pin`` names it deliberately.
|
||||
"""
|
||||
from ..ui import fields, script
|
||||
from ..ui.config_editor import PIN_KEY, conceal, config_table
|
||||
from ..ui.report import Listing
|
||||
from ..ui.surface import TuiUi
|
||||
|
||||
custom = await device.get_custom_vars()
|
||||
ctx.ui.show(config_table(snapshot, custom))
|
||||
if isinstance(ctx.ui, TuiUi):
|
||||
ctx.ui.show(config_table(snapshot, custom))
|
||||
return None
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for _category, specs in settings_by_category():
|
||||
for spec in specs:
|
||||
value = spec.getter(snapshot)
|
||||
printed = _script_value(spec, value)
|
||||
redacted = spec.key == PIN_KEY and printed != script.NONE
|
||||
masked = conceal(printed, absent=script.NONE) if spec.key == PIN_KEY else printed
|
||||
rows.append(
|
||||
{
|
||||
"key": spec.key,
|
||||
# A masked PIN is *withheld*, which is not a value: the document says
|
||||
# so with `redacted` and writes `null`, rather than shipping six
|
||||
# bullets a consumer would have to recognise as a mask.
|
||||
"value": fields.Rendered(None if redacted else value, masked),
|
||||
"type": spec.value_type,
|
||||
"label": (spec.choices or {}).get(value) if spec.value_type == "enum" else None,
|
||||
"redacted": redacted,
|
||||
}
|
||||
)
|
||||
# Custom variables are experimental firmware fields with no spec, so they are namespaced
|
||||
# rather than mixed in — a reader can tell which lines `config set` will take.
|
||||
rows.extend(
|
||||
{
|
||||
"key": f"custom.{key}",
|
||||
"value": fields.Rendered(value, value),
|
||||
"type": "str",
|
||||
"label": None,
|
||||
"redacted": False,
|
||||
}
|
||||
for key, value in sorted(custom.items())
|
||||
)
|
||||
return Listing(
|
||||
key="settings",
|
||||
columns=_setting_columns(),
|
||||
rows=rows,
|
||||
# An array to a parser and a `sysctl -a` block to a reader: a header line over two
|
||||
# columns whose keys *are* the answer would be furniture.
|
||||
headed=False,
|
||||
)
|
||||
|
||||
|
||||
def _share_contact(ctx: AppContext, snapshot: dict) -> None:
|
||||
"""Render this node's contact card — a scannable QR code plus the raw URI."""
|
||||
from rich.console import Group
|
||||
from rich.text import Text
|
||||
def _script_value(spec: SettingSpec, value: Any) -> str:
|
||||
"""A setting's value in the form ``config set`` accepts back.
|
||||
|
||||
:func:`~meshterm.core.device_config.format_value` writes for a reader choosing a
|
||||
setting: an enum reads ``0 (off)``, an unset string reads ``(not set)``, an unreported
|
||||
one reads ``?``. None of those three survive a round trip —
|
||||
:func:`~meshterm.core.device_config.parse_value` wants a bare integer for an enum, and
|
||||
would take the words ``(not set)`` as the literal value of a string. So the scripted
|
||||
dump prints what can be typed back: the enum's number, the empty string as ``""``, and
|
||||
:data:`~meshterm.ui.script.NONE` for a value the firmware never reported (which is an
|
||||
absence, not a value, and is the one line ``config set`` should not be handed back).
|
||||
|
||||
Args:
|
||||
spec: The setting the value belongs to.
|
||||
value: Its current value, or ``None`` when the device did not report it.
|
||||
|
||||
Returns:
|
||||
The scripted rendering.
|
||||
"""
|
||||
from ..ui import script
|
||||
|
||||
if value is None:
|
||||
return script.NONE
|
||||
if spec.value_type == "bool":
|
||||
return "true" if value else "false"
|
||||
if spec.value_type == "str" and value == "":
|
||||
return '""'
|
||||
return str(value)
|
||||
|
||||
|
||||
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, 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 ..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
|
||||
|
||||
public_key = str(snapshot.get("public_key") or "")
|
||||
if not public_key:
|
||||
ctx.ui.note("[err]the device did not report a public key — nothing to share[/err]")
|
||||
return
|
||||
raise DeviceCommandError("the device did not report a public key — nothing to share")
|
||||
name = str(snapshot.get("name") or "this node")
|
||||
url = contact_share_url(name, public_key, int(snapshot.get("adv_type") or 1))
|
||||
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
|
||||
adv_type = int(snapshot.get("adv_type") or 1)
|
||||
url = contact_share_url(name, public_key, adv_type)
|
||||
if isinstance(ctx.ui, TuiUi):
|
||||
await share_screen(ctx, name=name, url=url)
|
||||
return Facts(
|
||||
key="share",
|
||||
fields=(
|
||||
fields.word("url", "url"),
|
||||
fields.word("name", "name"),
|
||||
fields.hexid("public_key", "public_key"),
|
||||
fields.integer("type", "type"),
|
||||
),
|
||||
values={"url": url, "name": name, "public_key": public_key.lower(), "type": adv_type},
|
||||
shape=BARE,
|
||||
bare="url",
|
||||
)
|
||||
|
||||
|
||||
async def _backup(device: Device, snapshot: dict, path: Path) -> Path:
|
||||
"""Write a TOML backup of the current configuration."""
|
||||
async def _backup(device: Device, snapshot: dict, path: Path) -> tuple[Path, dict[str, int]]:
|
||||
"""Write a TOML backup of the current configuration.
|
||||
|
||||
Returns:
|
||||
The path written and how much went into it, counted the way the file counts —
|
||||
a setting the firmware never reported is not in the backup and is not in the tally.
|
||||
"""
|
||||
from ..core.device_config import DEVICE_SETTINGS
|
||||
|
||||
custom = await device.get_custom_vars()
|
||||
channels = await _read_channels(device)
|
||||
return backup_config(Path(path), snapshot, custom, channels)
|
||||
written = backup_config(Path(path), snapshot, custom, channels)
|
||||
counts = {
|
||||
"settings": sum(1 for spec in DEVICE_SETTINGS if spec.getter(snapshot) is not None),
|
||||
"channels": len(channels),
|
||||
"custom": len(custom),
|
||||
}
|
||||
return written, counts
|
||||
|
||||
|
||||
async def _restore(
|
||||
ctx: AppContext, device: Device, snapshot: dict, path: Path, dry_run: bool
|
||||
) -> int:
|
||||
) -> tuple[int, list[Block]]:
|
||||
"""Apply (or preview) a backup file against the current configuration.
|
||||
|
||||
The plan is the answer on a dry run and a record on a real one, so the same listing
|
||||
serves both — drawn for a reader only when nothing was changed, since a run that *did*
|
||||
change things already said so, setting by setting, on stderr.
|
||||
|
||||
Returns:
|
||||
The number of changes applied (always ``0`` for a dry run).
|
||||
The number of changes applied (always ``0`` for a dry run), and the blocks stating
|
||||
what was planned or done.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import SILENT, Facts, Listing
|
||||
|
||||
backup = read_backup(Path(path))
|
||||
custom = await device.get_custom_vars()
|
||||
ops = plan_restore(backup, snapshot, custom)
|
||||
|
||||
def plan(applied: list[tuple], *, drawn: bool) -> list[Block]:
|
||||
listing = Listing(
|
||||
key="operations",
|
||||
columns=(
|
||||
fields.word("operation", "OPERATION"),
|
||||
fields.word("target", "TARGET"),
|
||||
fields.word("value", "VALUE"),
|
||||
),
|
||||
rows=[
|
||||
{
|
||||
"operation": op[0],
|
||||
"target": str(op[1]),
|
||||
"value": str(op[2]) if len(op) > 2 else None,
|
||||
}
|
||||
for op in applied
|
||||
],
|
||||
plain_only=not drawn,
|
||||
)
|
||||
facts = Facts(
|
||||
key="restore",
|
||||
fields=(fields.flag("dry_run", "dry_run"), fields.integer("changes", "changes")),
|
||||
values={"dry_run": dry_run, "changes": 0 if dry_run else len(applied)},
|
||||
shape=SILENT,
|
||||
)
|
||||
return [facts, listing]
|
||||
|
||||
if not ops:
|
||||
ctx.ui.note("[muted]restore: device already matches the backup.[/muted]")
|
||||
return 0
|
||||
ctx.ui.ack("[muted]restore: device already matches the backup.[/muted]")
|
||||
return 0, plan([], drawn=False)
|
||||
|
||||
if dry_run:
|
||||
table = Table(title="Restore preview", border_style="warn", expand=False)
|
||||
table.add_column("OPERATION")
|
||||
table.add_column("TARGET")
|
||||
table.add_column("VALUE")
|
||||
for op in ops:
|
||||
table.add_row(op[0], str(op[1]), str(op[2] if len(op) > 2 else ""))
|
||||
ctx.ui.show(table)
|
||||
ctx.ui.note("[muted]dry run — nothing was changed.[/muted]")
|
||||
return 0
|
||||
ctx.ui.ack("[muted]dry run — nothing was changed.[/muted]")
|
||||
return 0, plan(ops, drawn=True)
|
||||
|
||||
changes = 0
|
||||
for op in ops:
|
||||
if op[0] == "set":
|
||||
changes += await _apply_setting(ctx, device, op[1], op[2], snapshot)
|
||||
await _apply_setting(ctx, device, op[1], op[2], snapshot)
|
||||
changes += 1
|
||||
elif op[0] == "set_custom":
|
||||
await device.set_custom_var(op[1], op[2])
|
||||
changes += 1
|
||||
elif op[0] == "set_channel":
|
||||
await device.set_channel(op[1], op[2], op[3])
|
||||
changes += 1
|
||||
return changes
|
||||
return changes, plan(ops, drawn=False)
|
||||
|
||||
|
||||
async def _export_key(
|
||||
ctx: AppContext, device: Device, out: Path | None, artifacts: list[str]
|
||||
) -> None:
|
||||
"""Export the private key, to a file if ``out`` is given, else to the console."""
|
||||
) -> Facts:
|
||||
"""Export the private key, to a file if ``out`` is given, else as the answer itself.
|
||||
|
||||
Either way the plain face prints **one bare line** — the key, or the path it was
|
||||
written to — because ``config export-key > key.hex`` is expected to hold the key and
|
||||
nothing else, and a caller that asked for a file wants the name of it. The warning
|
||||
that comes with either belongs beside it on screen, not in the file, so it goes to
|
||||
stderr with every other acknowledgement.
|
||||
|
||||
This document carries a private key when one was asked for. That is what the command
|
||||
is, exactly as on the plain face, and no extra gate is introduced here.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import BARE, Facts
|
||||
|
||||
key_hex = await device.export_private_key()
|
||||
written: Path | None = None
|
||||
if out is not None:
|
||||
path = Path(out)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(key_hex, encoding="utf-8")
|
||||
artifacts.append(str(path))
|
||||
ctx.ui.note("[warn]private key written — keep this file secret.[/warn]")
|
||||
written = Path(out)
|
||||
written.parent.mkdir(parents=True, exist_ok=True)
|
||||
written.write_text(key_hex, encoding="utf-8")
|
||||
artifacts.append(str(written))
|
||||
ctx.ui.ack("[warn]private key written — keep this file secret.[/warn]")
|
||||
else:
|
||||
ctx.ui.note("[warn]private key (keep secret):[/warn] [muted]" + key_hex + "[/muted]")
|
||||
ctx.ui.ack("[warn]private key (keep secret):[/warn]")
|
||||
return Facts(
|
||||
key="key",
|
||||
fields=(fields.hexid("private_key", "private_key"), fields.word("path", "path")),
|
||||
values={
|
||||
"private_key": None if written else key_hex,
|
||||
"path": str(written) if written else None,
|
||||
},
|
||||
shape=BARE,
|
||||
bare="path" if written else "private_key",
|
||||
)
|
||||
|
||||
|
||||
async def _read_channels(device: Device) -> list[dict]:
|
||||
@@ -434,13 +660,153 @@ async def _read_channels(device: Device) -> list[dict]:
|
||||
def _require_yes(yes: bool, what: str) -> None:
|
||||
"""Abort a destructive CLI command unless ``--yes`` was passed.
|
||||
|
||||
Raised as a :class:`typer.BadParameter` rather than printed and exited: a missing
|
||||
confirmation *is* a usage error, so it belongs on stderr in the parser's own words and
|
||||
under the parser's own status (see :mod:`meshterm.core.exitcodes`). Printing it in red
|
||||
on stdout put a colour, and a sentence that is not the command's answer, into whatever
|
||||
was reading the command's answer.
|
||||
|
||||
Args:
|
||||
yes: Whether the user passed ``--yes``.
|
||||
what: Human-readable description of the consequence.
|
||||
|
||||
Raises:
|
||||
typer.Exit: With code 1 if confirmation was not given.
|
||||
typer.BadParameter: If confirmation was not given.
|
||||
"""
|
||||
if not yes:
|
||||
typer.secho(f"Refusing: {what}. Re-run with --yes to confirm.", fg="red")
|
||||
raise typer.Exit(1)
|
||||
raise typer.BadParameter(f"{what}. Re-run with --yes to confirm.")
|
||||
|
||||
|
||||
def _setting_columns() -> tuple:
|
||||
"""One setting row's columns, shared by ``config show`` and ``config get``."""
|
||||
from ..ui import fields
|
||||
|
||||
return (
|
||||
fields.word("key", "key"),
|
||||
fields.rendered("value", "value"),
|
||||
fields.hidden("type"),
|
||||
fields.hidden("label"),
|
||||
fields.hidden("redacted"),
|
||||
)
|
||||
|
||||
|
||||
def _one_setting(spec: SettingSpec, value: Any) -> Facts:
|
||||
"""One setting: the bare value plain, the whole object in the document.
|
||||
|
||||
``config get device_pin`` is the one place the PIN is named deliberately, so it is
|
||||
**not** redacted here — the caller asked for that key by name, which is a different
|
||||
act from dumping every setting into a file.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import BARE, Facts
|
||||
|
||||
return Facts(
|
||||
key="setting",
|
||||
fields=_setting_columns(),
|
||||
values={
|
||||
"key": spec.key,
|
||||
"value": fields.Rendered(value, _script_value(spec, value)),
|
||||
"type": spec.value_type,
|
||||
"label": (spec.choices or {}).get(value) if spec.value_type == "enum" else None,
|
||||
"redacted": False,
|
||||
},
|
||||
shape=BARE,
|
||||
bare="value",
|
||||
)
|
||||
|
||||
|
||||
def _applied(applied: list[dict[str, Any]]) -> Facts:
|
||||
"""What a ``set`` changed — nothing plain, a record for whoever is logging it.
|
||||
|
||||
The exit status is the plain answer and the acknowledgement on stderr is the reassurance;
|
||||
neither is something a script can read back later to find out *what moved*.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import SILENT, Column, Facts
|
||||
|
||||
return Facts(
|
||||
key="applied",
|
||||
fields=(fields.integer("changes", "changes"), Column(key="applied")),
|
||||
values={"changes": len(applied), "applied": applied},
|
||||
shape=SILENT,
|
||||
)
|
||||
|
||||
|
||||
def _channel_written(idx: int, name: str, secret: bytes | None) -> Facts:
|
||||
"""What ``config channel`` wrote into a slot."""
|
||||
from ..core.channels import channel_hash, derive_secret, is_public_channel
|
||||
from ..ui import fields
|
||||
from ..ui.fields import ChannelRef
|
||||
from ..ui.report import SILENT, Facts
|
||||
|
||||
key = secret or derive_secret(name)
|
||||
return Facts(
|
||||
key="channel",
|
||||
fields=(fields.integer("changes", "changes"), fields.channel("channel", lanes=())),
|
||||
values={
|
||||
"changes": 1,
|
||||
"channel": ChannelRef(
|
||||
slot=idx,
|
||||
name=name,
|
||||
public=is_public_channel(name, key),
|
||||
hash=channel_hash(key),
|
||||
),
|
||||
},
|
||||
shape=SILENT,
|
||||
)
|
||||
|
||||
|
||||
def _backed_up(written: Path, counts: dict[str, int]) -> Facts:
|
||||
"""What ``config backup`` wrote, and how much of it.
|
||||
|
||||
The plain face prints the path bare — it is what the caller keeps, and what the thing
|
||||
that ran the command reads next.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import BARE, Facts
|
||||
|
||||
return Facts(
|
||||
key="backup",
|
||||
fields=(
|
||||
fields.word("path", "path"),
|
||||
fields.integer("settings", "settings"),
|
||||
fields.integer("channels", "channels"),
|
||||
fields.integer("custom", "custom"),
|
||||
),
|
||||
values={"path": str(written), **counts},
|
||||
shape=BARE,
|
||||
bare="path",
|
||||
)
|
||||
|
||||
|
||||
def _acted(key: str, **facts: Any) -> Facts:
|
||||
"""An action, stated as data: nothing plain, a document for whoever wanted a record.
|
||||
|
||||
Every one of these prints nothing on the plain face today and should keep doing so —
|
||||
``config advert`` says all it has to say in its exit status. But "prints nothing" is an
|
||||
answer a person can act on and a program cannot, so the machine face gets the fact.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import SILENT, Column, Facts
|
||||
|
||||
def column(name: str, value: Any) -> Column:
|
||||
if isinstance(value, bool):
|
||||
return fields.flag(name, name)
|
||||
if isinstance(value, int) or value is None:
|
||||
return fields.integer(name, name)
|
||||
return Column(key=name)
|
||||
|
||||
return Facts(
|
||||
key=key,
|
||||
fields=tuple(column(name, value) for name, value in facts.items()),
|
||||
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,14 +1,26 @@
|
||||
"""The ``contacts`` tool: list this node and the contacts it knows about."""
|
||||
# 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
|
||||
prints the contact list alone, because our own node is not a contact — ``meshterm info``
|
||||
reports it, in far more detail than a row could hold.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core import exitcodes
|
||||
from ..core.models import NODE_TYPE_LABELS
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..core.models import Contact
|
||||
from ..ui.report import Listing
|
||||
|
||||
|
||||
@register
|
||||
class ContactsTool(Tool):
|
||||
@@ -32,7 +44,7 @@ class ContactsTool(Tool):
|
||||
A :class:`ToolResult` summarizing the number of known contacts.
|
||||
"""
|
||||
from ..ui.surface import TuiUi
|
||||
from ..ui.widgets import ContactsSort, contacts_table
|
||||
from ..ui.widgets import ContactsSort, ordered_contacts
|
||||
|
||||
# Read through the session cache: on a busy node the contacts table is a slow
|
||||
# round-trip, and re-fetching it (plus self-info) on every menu visit is a chief cause
|
||||
@@ -63,18 +75,22 @@ class ContactsTool(Tool):
|
||||
|
||||
# In the menu, hand the list to the interactive screen so the Ctrl+arrows re-sort it
|
||||
# live — its ring spans the shared contact list's four columns (hash included); on the
|
||||
# scripted CLI, render the static table once in the requested order.
|
||||
# scripted CLI, print the plain listing once in the requested order.
|
||||
if isinstance(ctx.ui, TuiUi):
|
||||
from ..ui.contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING
|
||||
from ..ui.contacts_screen import open_contacts
|
||||
|
||||
sort = ContactsSort.from_name(sort_name, SORT_COLUMNS, SORT_OPENS_ASCENDING)
|
||||
await open_contacts(ctx, self_name, self_key, contacts, prefix_bytes, counts, sort)
|
||||
else:
|
||||
sort = ContactsSort.from_name(sort_name)
|
||||
ctx.ui.show(contacts_table(self_name, self_key, contacts, prefix_bytes, counts, sort))
|
||||
return ToolResult(summary={"contacts": len(contacts)})
|
||||
|
||||
return ToolResult(summary={"contacts": len(contacts)})
|
||||
sort = ContactsSort.from_name(sort_name)
|
||||
listing = _listing(ordered_contacts(contacts, counts, sort), counts, prefix_bytes)
|
||||
return ToolResult(
|
||||
summary={"contacts": len(contacts)},
|
||||
report=(listing,),
|
||||
exit_code=exitcodes.OK if contacts else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register the ``contacts`` subcommand.
|
||||
@@ -83,6 +99,7 @@ class ContactsTool(Tool):
|
||||
app: The Typer application.
|
||||
"""
|
||||
from ..cli import run_tool_command
|
||||
from ..ui.widgets import ContactsSort
|
||||
|
||||
@app.command(name=self.name, help=self.help)
|
||||
def _contacts(
|
||||
@@ -90,4 +107,71 @@ class ContactsTool(Tool):
|
||||
"heard", "--sort", "-s", help="Order contacts by: heard, name, packets"
|
||||
),
|
||||
) -> None:
|
||||
# `ContactsSort.from_name` falls back to the first column for an unknown name,
|
||||
# deliberately, so a saved preference written by an older build cannot wedge
|
||||
# the menu's list. On the command line the same silence hides a typo: the
|
||||
# caller asked for one order and got another, with nothing said.
|
||||
if sort not in ContactsSort.names():
|
||||
choices = ", ".join(ContactsSort.names())
|
||||
raise typer.BadParameter(f"--sort must be one of: {choices} (got {sort!r})")
|
||||
run_tool_command(self, {"sort": sort})
|
||||
|
||||
|
||||
def _listing(contacts: list[Contact], counts: dict[str, int], prefix_bytes: int) -> Listing:
|
||||
"""The contact list, stated once for both faces.
|
||||
|
||||
``TYPE`` is the node's advertised role in words, where the menu draws a coloured glyph:
|
||||
a monochrome ``▲`` would need a legend and the CLI has no legends. ``HEARD`` is a
|
||||
relative age, because "recently?" is the question this listing is opened to answer.
|
||||
``HASH`` is the token ``--path`` and ``--to`` take — it used to have to be sliced out
|
||||
of ``KEY`` by hand — and ``LOCATION`` is a fact the model has always carried and no
|
||||
column had room for. ``KEY`` stays full and last, so it runs off the right harmlessly
|
||||
and is never elided: a truncated key is not something a caller can hand back.
|
||||
|
||||
Args:
|
||||
contacts: The contacts, already in the requested order.
|
||||
counts: Overheard-packet tallies keyed by node id (from passive monitoring).
|
||||
prefix_bytes: The device's path-hash width, which is what makes ``HASH`` the
|
||||
token a forced path addresses this node by rather than an arbitrary slice.
|
||||
|
||||
Returns:
|
||||
The listing.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
from ..ui.widgets import contact_packets
|
||||
|
||||
rows = []
|
||||
for contact in contacts:
|
||||
key = (contact.public_key or "").lower()
|
||||
rows.append(
|
||||
{
|
||||
"node": fields.NodeRef(
|
||||
name=contact.name,
|
||||
key=key or None,
|
||||
hash=(key or contact.key_prefix.lower())[: prefix_bytes * 2] or None,
|
||||
type=NODE_TYPE_LABELS.get(contact.node_type),
|
||||
),
|
||||
"heard_at": contact.last_seen,
|
||||
"packets": contact_packets(contact, counts),
|
||||
"position": (
|
||||
fields.Position(contact.lat, contact.lon) if contact.has_location else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return Listing(
|
||||
key="contacts",
|
||||
columns=(
|
||||
fields.node(
|
||||
"node",
|
||||
lanes=(("name", "NAME"), ("type", "TYPE"), ("hash", "HASH"), ("key", "KEY")),
|
||||
),
|
||||
fields.when("heard_at", "HEARD", absent="never"),
|
||||
fields.integer("packets", "PKTS"),
|
||||
fields.position(),
|
||||
),
|
||||
rows=rows,
|
||||
# The node's four lanes are not contiguous: what a reader scans for — who, what,
|
||||
# how recently, how much — comes first, and the two long hex fields go right.
|
||||
order=("NAME", "TYPE", "HEARD", "PKTS", "HASH", "LOCATION", "KEY"),
|
||||
)
|
||||
+176
-60
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``courier`` tool: store-and-forward messaging for contacts that aren't there yet.
|
||||
|
||||
Interactively it opens the outbox screen (see :mod:`meshterm.ui.courier_screen`):
|
||||
@@ -17,14 +18,18 @@ of the outbox screen's own Send-now, Cancel, and Clear-finished actions.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core.connection import DeviceCommandError
|
||||
from ..core import exitcodes
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..core.courier_store import QueuedMessage
|
||||
from ..ui.report import Facts, Listing
|
||||
|
||||
|
||||
@register
|
||||
class CourierTool(Tool):
|
||||
@@ -97,17 +102,19 @@ class CourierTool(Tool):
|
||||
name = str(params["contact"])
|
||||
needle = name.casefold()
|
||||
contact = next((c for c in contacts if c.name.casefold() == needle), None)
|
||||
# The named contact, not the radio: nothing is queued and nothing is sent, so
|
||||
# these are bad arguments (2) rather than device failures (4).
|
||||
if contact is None:
|
||||
raise DeviceCommandError(f"unknown contact: {name!r}")
|
||||
raise typer.BadParameter(f"unknown contact: {name!r}")
|
||||
key = contact_watch_key(contact)
|
||||
if key is None:
|
||||
raise DeviceCommandError(f"{name!r} has no usable key to address")
|
||||
raise typer.BadParameter(f"{name!r} has no usable key to address")
|
||||
|
||||
not_before = None
|
||||
if params.get("at"):
|
||||
not_before = parse_clock(str(params["at"]))
|
||||
if not_before is None:
|
||||
raise DeviceCommandError(
|
||||
raise typer.BadParameter(
|
||||
f"--at wants a clock time like 07:00, not {params['at']!r}"
|
||||
)
|
||||
message = ctx.courier_store.queue(
|
||||
@@ -118,62 +125,27 @@ class CourierTool(Tool):
|
||||
if not_before is not None
|
||||
else "when the contact is next heard"
|
||||
)
|
||||
ctx.ui.note(
|
||||
ctx.ui.ack(
|
||||
f"[ok]queued[/ok] #{message.ident} for [brand]{contact.name}[/brand] — "
|
||||
f"delivers {when} (an interactive session's courier does the sending)"
|
||||
)
|
||||
return ToolResult(
|
||||
summary={"queued": message.ident, "contact": contact.name, "at": params.get("at")}
|
||||
summary={"queued": message.ident, "contact": contact.name, "at": params.get("at")},
|
||||
report=(_queued(message, contact.name, not_before),),
|
||||
)
|
||||
|
||||
async def _cli_list(self, ctx: AppContext) -> ToolResult:
|
||||
"""Print the outbox — waiting entries then finished ones — as a table.
|
||||
"""State the outbox — waiting entries then finished ones.
|
||||
|
||||
A read-only view over the stored outbox: it needs no device, so it works the same
|
||||
whether or not a radio is attached (the scriptable face of the outbox screen).
|
||||
"""
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from ..core.courier_store import DELIVERED, QUEUED
|
||||
from ..core.models import utcnow
|
||||
from ..ui.courier_screen import _local_stamp, _shorten
|
||||
from ..ui.widgets import _age_seconds, format_ago
|
||||
|
||||
entries = ctx.courier_store.entries()
|
||||
if not entries:
|
||||
ctx.ui.note("[muted]the outbox is empty[/muted]")
|
||||
return ToolResult(summary={"entries": 0})
|
||||
|
||||
table = Table(title="Courier outbox", border_style="muted", expand=False)
|
||||
table.add_column("ID", justify="right")
|
||||
table.add_column("STATE")
|
||||
table.add_column("NODE")
|
||||
table.add_column("MESSAGE")
|
||||
table.add_column("WHEN")
|
||||
now = utcnow()
|
||||
for m in entries:
|
||||
if m.status == QUEUED:
|
||||
state = Text("⏳ waiting", style="warn")
|
||||
if m.not_before is not None and now < m.not_before:
|
||||
when = f"scheduled {_local_stamp(m.not_before)}"
|
||||
else:
|
||||
when = "when next heard"
|
||||
elif m.status == DELIVERED:
|
||||
state = Text("✓ delivered", style="ok")
|
||||
when = f"delivered {format_ago(_age_seconds(m.finished or m.created))}"
|
||||
else:
|
||||
state = Text("✗ gave up", style="err")
|
||||
when = f"gave up {format_ago(_age_seconds(m.finished or m.created))}"
|
||||
table.add_row(
|
||||
str(m.ident),
|
||||
state,
|
||||
Text(m.node_name, style="brand"),
|
||||
_shorten(m.text),
|
||||
Text(when, style="muted"),
|
||||
)
|
||||
ctx.ui.show(table)
|
||||
return ToolResult(summary={"entries": len(entries)})
|
||||
return ToolResult(
|
||||
summary={"entries": len(entries)},
|
||||
report=(_outbox(entries),),
|
||||
exit_code=exitcodes.OK if entries else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
async def _cli_send(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Force one delivery attempt for a waiting entry, right now.
|
||||
@@ -183,7 +155,10 @@ class CourierTool(Tool):
|
||||
"""
|
||||
ident = int(params["id"])
|
||||
if ctx.courier_store.get(ident) is None:
|
||||
raise DeviceCommandError(f"no outbox entry #{ident}")
|
||||
# A bad argument, not a device failure: the outbox is a local file, nothing was
|
||||
# transmitted, and no retry will make an id exist. Exit 4 told a caller to try
|
||||
# again; exit 2 tells it to fix the command, which is the truth.
|
||||
raise typer.BadParameter(f"no outbox entry #{ident} (see `courier list`)")
|
||||
outcome = await ctx.courier.attempt_now(ident)
|
||||
notes = {
|
||||
"delivered": "[ok]✓ delivered[/ok] — acknowledged by the contact",
|
||||
@@ -195,17 +170,27 @@ class CourierTool(Tool):
|
||||
"busy": "[muted]another delivery is in flight — try again in a moment[/muted]",
|
||||
"gone": "[muted]that entry is no longer waiting[/muted]",
|
||||
}
|
||||
ctx.ui.note(notes.get(outcome, outcome))
|
||||
return ToolResult(summary={"id": ident, "outcome": outcome})
|
||||
ctx.ui.ack(notes.get(outcome, outcome))
|
||||
return ToolResult(
|
||||
summary={"id": ident, "outcome": outcome},
|
||||
report=(_attempted(ident, outcome),),
|
||||
)
|
||||
|
||||
async def _cli_cancel(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Remove a waiting entry from the outbox (finished ones use ``clear``)."""
|
||||
ident = int(params["id"])
|
||||
if ctx.courier_store.cancel(ident):
|
||||
ctx.ui.note(f"[warn]cancelled outbox entry #{ident}[/warn]")
|
||||
return ToolResult(summary={"id": ident, "cancelled": True})
|
||||
ctx.ui.note(f"[muted]no waiting entry #{ident} to cancel[/muted]")
|
||||
return ToolResult(summary={"id": ident, "cancelled": False})
|
||||
ctx.ui.ack(f"[warn]cancelled outbox entry #{ident}[/warn]")
|
||||
return ToolResult(
|
||||
summary={"id": ident, "cancelled": True},
|
||||
report=(_did(("id", ident), ("cancelled", True)),),
|
||||
)
|
||||
ctx.ui.ack(f"[muted]no waiting entry #{ident} to cancel[/muted]")
|
||||
return ToolResult(
|
||||
summary={"id": ident, "cancelled": False},
|
||||
report=(_did(("id", ident), ("cancelled", False)),),
|
||||
exit_code=exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
async def _cli_clear(self, ctx: AppContext) -> ToolResult:
|
||||
"""Drop every finished (delivered / given-up) entry, leaving the queue untouched."""
|
||||
@@ -213,10 +198,14 @@ class CourierTool(Tool):
|
||||
ctx.courier_store.clear_done()
|
||||
cleared = before - len(ctx.courier_store.entries())
|
||||
if cleared:
|
||||
ctx.ui.note(f"[ok]cleared {cleared} finished entr{'y' if cleared == 1 else 'ies'}[/ok]")
|
||||
ctx.ui.ack(f"[ok]cleared {cleared} finished entr{'y' if cleared == 1 else 'ies'}[/ok]")
|
||||
else:
|
||||
ctx.ui.note("[muted]no finished entries to clear[/muted]")
|
||||
return ToolResult(summary={"cleared": cleared})
|
||||
ctx.ui.ack("[muted]no finished entries to clear[/muted]")
|
||||
return ToolResult(
|
||||
summary={"cleared": cleared},
|
||||
report=(_did(("cleared", cleared)),),
|
||||
exit_code=exitcodes.OK if cleared else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register the ``courier`` subcommand group.
|
||||
@@ -226,7 +215,7 @@ class CourierTool(Tool):
|
||||
"""
|
||||
from ..cli import run_tool_command
|
||||
|
||||
courier_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode="rich")
|
||||
courier_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode=None)
|
||||
|
||||
@courier_app.command(
|
||||
"queue", help="Queue a message for delivery when the contact is next heard"
|
||||
@@ -268,3 +257,130 @@ class CourierTool(Tool):
|
||||
run_tool_command(self, {"cli_action": "clear"})
|
||||
|
||||
app.add_typer(courier_app, name=self.name)
|
||||
|
||||
|
||||
def _recipient(name: str | None, node_key: str | None) -> object:
|
||||
"""The outbox's addressee as the shared node shape.
|
||||
|
||||
The outbox stores a name and the 12-hex id it addresses, so three of the five fields
|
||||
are honestly ``null`` — and the id goes in ``hash`` rather than ``key``, because that
|
||||
is what it is. A key is the full value and is never truncated by this app's choosing;
|
||||
calling a six-byte prefix one would hand a caller something ``--to`` cannot take.
|
||||
"""
|
||||
from ..ui.fields import NodeRef
|
||||
|
||||
return NodeRef(name=name, hash=(node_key or "").lower() or None)
|
||||
|
||||
|
||||
def _queued(message: QueuedMessage, name: str, not_before: object) -> Facts:
|
||||
"""What ``courier queue`` put in the outbox.
|
||||
|
||||
The plain face prints the **id and nothing else**, because the id is the one fact a
|
||||
caller has to keep — it is what ``send`` and ``cancel`` take — and everything else on
|
||||
the line is what they just typed. The document carries the rest, because a caller
|
||||
logging the queue has no other record of it.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
|
||||
return Facts(
|
||||
key="queued",
|
||||
fields=(
|
||||
fields.integer("id", "id"),
|
||||
fields.node("node", lanes=()),
|
||||
fields.hidden("scheduled_at"),
|
||||
fields.hidden("text"),
|
||||
),
|
||||
values={
|
||||
"id": message.ident,
|
||||
"node": _recipient(name, message.node_key),
|
||||
"scheduled_at": not_before,
|
||||
"text": message.text,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _outbox(entries: list) -> Listing:
|
||||
"""The outbox, waiting entries then finished ones.
|
||||
|
||||
The deliberate asymmetry between the two time columns: ``SCHEDULED`` is an
|
||||
*appointment* the caller set with ``--at``, so it stays a wall-clock instant, and
|
||||
``FINISHED`` is an age, because "how long ago did it go" is what anyone reading a
|
||||
delivered row wants. The mixed widths are the price of both columns being right, and
|
||||
``--absolute`` makes them uniform for anyone who dislikes it.
|
||||
"""
|
||||
from ..core.courier_store import DELIVERED, QUEUED
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
rows = []
|
||||
for m in entries:
|
||||
if m.status == QUEUED:
|
||||
state = "waiting"
|
||||
elif m.status == DELIVERED:
|
||||
state = "delivered"
|
||||
else:
|
||||
state = "gave up"
|
||||
rows.append(
|
||||
{
|
||||
"id": m.ident,
|
||||
"state": state,
|
||||
"node": _recipient(m.node_name, m.node_key),
|
||||
# An entry with no scheduled time goes as soon as the contact is heard,
|
||||
# which is not a time and so is not one here either.
|
||||
"scheduled_at": m.not_before,
|
||||
"finished_at": m.finished,
|
||||
# Not shortened the way the outbox screen shortens it: nothing wraps here.
|
||||
"text": m.text,
|
||||
}
|
||||
)
|
||||
return Listing(
|
||||
key="outbox",
|
||||
columns=(
|
||||
fields.integer("id", "ID"),
|
||||
fields.word("state", "STATE"),
|
||||
fields.node("node", lanes=(("name", "NODE"),)),
|
||||
fields.instant("scheduled_at", "SCHEDULED"),
|
||||
fields.when("finished_at", "FINISHED"),
|
||||
fields.free("text", "TEXT"),
|
||||
),
|
||||
rows=rows,
|
||||
)
|
||||
|
||||
|
||||
def _attempted(ident: int, outcome: str) -> Facts:
|
||||
"""What one forced delivery attempt came to.
|
||||
|
||||
``outcome`` is one of the closed set the help lists, kept verbatim — spaces and all —
|
||||
so both faces say the same word and a script matching ``no ack`` matches what a person
|
||||
reads.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
|
||||
return Facts(
|
||||
key="attempt",
|
||||
fields=(fields.hidden("id"), fields.word("outcome", "outcome")),
|
||||
values={"id": ident, "outcome": outcome},
|
||||
)
|
||||
|
||||
|
||||
def _did(*facts: tuple[str, object]) -> Facts:
|
||||
"""An acknowledgement as data: nothing plain, a document for whoever is logging.
|
||||
|
||||
``cancel`` and ``clear`` say everything they have to say in their exit status, which
|
||||
is the right plain answer — but a caller that wants to record *what* was cancelled has
|
||||
nowhere to read it, and that is what the document is for.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import SILENT, Facts
|
||||
|
||||
def column(key: str, value: object): # noqa: ANN202 - one of two shapes
|
||||
return fields.flag(key, key) if isinstance(value, bool) else fields.integer(key, key)
|
||||
|
||||
return Facts(
|
||||
key="outcome",
|
||||
fields=tuple(column(key, value) for key, value in facts),
|
||||
values=dict(facts),
|
||||
shape=SILENT,
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``dashboard`` tool: the live mesh overview, at the top of the Watch section.
|
||||
|
||||
Opens the full-screen dashboard (see :mod:`meshterm.ui.dashboard_screen`): the two-hour
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
"""The ``device-actions`` tool: immediate operations on the companion device itself.
|
||||
|
||||
Opens the Device actions screen (see :func:`meshterm.ui.config_editor.device_actions`) —
|
||||
clock sync, backup/restore, the identity key, reboot, and factory reset. Where the
|
||||
sibling ``config`` tool *stages* value changes for review, everything here acts on the
|
||||
box the moment it is confirmed, so the two concerns get their own menu entries and
|
||||
screens. Sending an advert is not here: that everyday operation lives in its own
|
||||
main-menu entry (the sibling ``advert`` tool — see :mod:`meshterm.tools.advert`).
|
||||
|
||||
Menu-only: every action already has a first-class scripted equivalent under the ``config``
|
||||
CLI group (``config backup``, ``config restore``, ``config reboot``, ...), so no duplicate
|
||||
subcommand is registered here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
|
||||
from ..context import AppContext
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
|
||||
@register
|
||||
class DeviceActionsTool(Tool):
|
||||
"""Run immediate device operations: clock, backup, identity key, reboot, reset."""
|
||||
|
||||
name = "device-actions"
|
||||
title = "Device actions"
|
||||
icon = "🔨"
|
||||
help = "Clock, backup, identity key, reboot, reset"
|
||||
category = "This node"
|
||||
order = 30
|
||||
|
||||
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
|
||||
"""Run the interactive actions screen; there are never parameters to collect.
|
||||
|
||||
Each action executes and presents its result inside the screen itself, 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 backing
|
||||
out of the config editor with nothing staged).
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
|
||||
Returns:
|
||||
Always ``None``.
|
||||
"""
|
||||
from ..ui.config_editor import device_actions
|
||||
|
||||
await device_actions(ctx)
|
||||
return None
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Nothing runs here: the actions execute inside :meth:`prompt_params`.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
params: Unused.
|
||||
|
||||
Returns:
|
||||
An empty :class:`ToolResult` (only reachable from a scripted call).
|
||||
"""
|
||||
return ToolResult(summary={})
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register no CLI command — the scripted equivalents live under ``config``.
|
||||
|
||||
Args:
|
||||
app: The Typer application (untouched).
|
||||
"""
|
||||
+121
-81
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``devices`` tool: enumerate attached serial and in-range Bluetooth companions.
|
||||
|
||||
Discovery never opens the radio — it only lists what is attached or advertising. This is a
|
||||
@@ -11,27 +12,23 @@ the prompt shown when the menu launches (which smoke-tests the choice before con
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
from rich.table import Table
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core.discovery import discover_all, discover_devices
|
||||
from ..core import exitcodes
|
||||
from ..core.discovery import TRANSPORT_MOCK, DiscoveredDevice, discover_all, discover_devices
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
#: "MeshCore?" table cell per discovery confidence tier, for devices we have *not* yet
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Listing
|
||||
|
||||
#: The ``MESHCORE`` verdict per discovery confidence tier, for devices we have *not* yet
|
||||
#: confirmed. The USB vendor ID is only a hint — a native-USB board or a bare bridge chip
|
||||
#: is a "maybe", never a "yes" — so nothing is billed as MeshCore until a connection proves
|
||||
#: it (see ``_confirmed_cell``).
|
||||
_MAYBE_CELL: dict[str, str] = {
|
||||
"board": "[warn]maybe[/warn]",
|
||||
"bridge": "[warn]maybe[/warn]",
|
||||
"unknown": "[muted]—[/muted]",
|
||||
}
|
||||
|
||||
#: "MeshCore?" cell for a device already confirmed to speak the protocol.
|
||||
_CONFIRMED_CELL = "[ok]yes[/ok]"
|
||||
#: it.
|
||||
_MAYBE: dict[str, str] = {"board": "maybe", "bridge": "maybe", "unknown": "no"}
|
||||
|
||||
|
||||
@register
|
||||
@@ -46,7 +43,7 @@ class DevicesTool(Tool):
|
||||
menu_visible = False # CLI-only: a startup diagnostic with no place in a connected session
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Enumerate serial devices and render them as a table.
|
||||
"""Enumerate attached and advertising devices, and state them as a listing.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
@@ -55,81 +52,46 @@ class DevicesTool(Tool):
|
||||
Returns:
|
||||
A :class:`ToolResult` summarizing how many devices were found.
|
||||
"""
|
||||
scan_ble = params.get("ble", True)
|
||||
devices = await discover_all(ble=scan_ble) if scan_ble else discover_devices()
|
||||
if ctx.mock:
|
||||
# `--mock` promises a session with no real hardware, and a scan is the one
|
||||
# thing this tool does — it walked the serial ports and switched the Bluetooth
|
||||
# radio on regardless, which is a promise broken on the first command a
|
||||
# stranger without a radio would try. So under --mock the simulator *is* the
|
||||
# inventory: one row, and nothing on the machine touched.
|
||||
devices = [
|
||||
DiscoveredDevice(
|
||||
transport=TRANSPORT_MOCK,
|
||||
description="the built-in simulator (nothing transmits)",
|
||||
name="simulator",
|
||||
)
|
||||
]
|
||||
else:
|
||||
scan_ble = params.get("ble", True)
|
||||
devices = await discover_all(ble=scan_ble) if scan_ble else discover_devices()
|
||||
known = ctx.device_store.load_all()
|
||||
remembered = ctx.device_store.load()
|
||||
active = ctx.selected_device
|
||||
active_target = ctx.ble_override or ctx.port_override or (active.target if active else None)
|
||||
|
||||
if ctx.json_output:
|
||||
import json
|
||||
|
||||
payload = [
|
||||
{
|
||||
"transport": d.transport,
|
||||
"port": d.port,
|
||||
"address": d.address,
|
||||
"target": d.target,
|
||||
"label": d.label,
|
||||
"vendor": d.vendor_label,
|
||||
"model": (known[d.stable_id].hardware_model if d.stable_id in known else ""),
|
||||
"likely_lora": d.is_likely_lora,
|
||||
"confidence": d.confidence,
|
||||
"confirmed": d.stable_id in known,
|
||||
"serial_number": d.serial_number,
|
||||
"stable_id": d.stable_id,
|
||||
"remembered": remembered is not None and remembered.matches(d),
|
||||
"active": d.target == active_target,
|
||||
}
|
||||
for d in devices
|
||||
]
|
||||
ctx.console.print_json(json.dumps(payload))
|
||||
return ToolResult(summary={"count": len(devices)})
|
||||
|
||||
if not devices:
|
||||
ctx.ui.note(
|
||||
"[warn]No companion devices detected.[/warn] "
|
||||
"Connect one over USB or power on a Bluetooth companion nearby, or use "
|
||||
"[accent]--mock[/accent] for the simulator."
|
||||
)
|
||||
return ToolResult(summary={"count": 0})
|
||||
# Not an error: the scan ran and found nothing. Said on stderr so a caller
|
||||
# redirecting stdout still hears it, and reported as NO_RESULT so a script can
|
||||
# branch on it without matching prose. The empty listing still reaches the
|
||||
# machine face as `[]`, which is a document a consumer can read — where an
|
||||
# empty stdout is a parse error it would have to tell apart from a real one.
|
||||
from ..ui import script
|
||||
|
||||
table = Table(title="Companion devices", border_style="muted", expand=False)
|
||||
table.add_column("", style="ok", no_wrap=True) # active/confirmed markers
|
||||
table.add_column("PORT / ADDRESS", style="brand")
|
||||
table.add_column("DEVICE")
|
||||
# "Hardware" (not "Vendor"): for a confirmed device this holds the firmware's own model
|
||||
# string ("Seeed Tracker T1000-E") — the only reliable source of what the box is — and
|
||||
# for a merely-attached serial port it falls back to the USB vendor name ("Espressif").
|
||||
# One column spans both because a maker name and a model name are the same question:
|
||||
# "what hardware is this?". A BLE device that's never connected has neither, so it's "?".
|
||||
table.add_column("HARDWARE", style="muted")
|
||||
table.add_column("MESHCORE?", justify="center")
|
||||
table.add_column("SERIAL", style="muted")
|
||||
for d in devices:
|
||||
confirmed = known.get(d.stable_id) # the remembered record, if ever confirmed
|
||||
is_active = d.target == active_target
|
||||
marker = ("●" if is_active else "") + ("★" if confirmed else "")
|
||||
device_name = d.name or d.product or d.description or "[muted]?[/muted]"
|
||||
if confirmed and confirmed.node_name: # the mesh name learned on connect
|
||||
device_name = f"{confirmed.node_name} [muted]({device_name})[/muted]"
|
||||
# Prefer the connected-time model; fall back to the USB vendor for unconnected ports.
|
||||
hardware = (confirmed.hardware_model if confirmed else "") or d.vendor_label
|
||||
table.add_row(
|
||||
marker,
|
||||
d.target,
|
||||
device_name,
|
||||
hardware or "[muted]?[/muted]",
|
||||
_CONFIRMED_CELL if confirmed else _MAYBE_CELL[d.confidence],
|
||||
d.serial_number or "[muted]—[/muted]",
|
||||
script.stderr_console().print(
|
||||
"meshterm: no companion devices detected", style="warn", highlight=False
|
||||
)
|
||||
ctx.ui.show(table)
|
||||
ctx.ui.note(
|
||||
"[muted]● active ★ confirmed MeshCore device. "
|
||||
"Pass --port <PORT> or --ble <ADDRESS> to select on the CLI.[/muted]"
|
||||
|
||||
return ToolResult(
|
||||
summary={"count": len(devices)},
|
||||
report=(_listing(devices, known, remembered, active_target),),
|
||||
# Changed from the 0 this used to return under --json: a rendering flag has no
|
||||
# business changing the report, and the plain path has always said 5 here.
|
||||
exit_code=exitcodes.OK if devices else exitcodes.NO_RESULT,
|
||||
)
|
||||
return ToolResult(summary={"count": len(devices)})
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register the ``devices`` subcommand (inventory only; no connection).
|
||||
@@ -139,10 +101,88 @@ class DevicesTool(Tool):
|
||||
"""
|
||||
from ..cli import run_tool_command
|
||||
|
||||
@app.command(name=self.name, help=self.help)
|
||||
@app.command(
|
||||
name=self.name,
|
||||
help=self.help,
|
||||
# The listing used to close with a line explaining its own markers and how to
|
||||
# act on a row. That is help, and this is where help goes.
|
||||
epilog="Select a device with --port TARGET or --ble TARGET, using the "
|
||||
"TARGET column verbatim.",
|
||||
)
|
||||
def _devices(
|
||||
ble: bool = typer.Option(
|
||||
True, "--ble/--no-ble", help="Include a Bluetooth LE scan (adds a few seconds)"
|
||||
),
|
||||
) -> None:
|
||||
run_tool_command(self, {"ble": ble})
|
||||
|
||||
|
||||
def _listing(devices: list, known: dict, remembered: object, active_target: str | None) -> Listing:
|
||||
"""The device inventory, stated once for both faces.
|
||||
|
||||
``TARGET`` leads because it is the field a caller acts on — it is what ``--port`` and
|
||||
``--ble`` take, verbatim. The menu's two markers become columns of their own
|
||||
(``ACTIVE``, and ``MESHCORE`` for the confirmed star), because a glyph in a margin is
|
||||
something to look at rather than something to test.
|
||||
|
||||
``MESHCORE`` is three-valued and stays that way: ``yes`` only once a connection has
|
||||
proved the device speaks the protocol, ``maybe`` for a USB vendor ID that suggests a
|
||||
LoRa board or a bridge chip, ``no`` for anything else. A vendor ID is a hint, and the
|
||||
column would be lying if it rounded one up — which is why the machine face carries the
|
||||
same three words rather than the boolean it used to.
|
||||
|
||||
Args:
|
||||
devices: The discovered devices.
|
||||
known: Remembered device records, keyed by stable id.
|
||||
remembered: The remembered default device, if there is one.
|
||||
active_target: The target this invocation is (or would be) using.
|
||||
|
||||
Returns:
|
||||
The listing.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
rows = []
|
||||
for device in devices:
|
||||
confirmed = known.get(device.stable_id)
|
||||
rows.append(
|
||||
{
|
||||
"target": device.target,
|
||||
"transport": device.transport,
|
||||
"port": device.port,
|
||||
"address": device.address,
|
||||
"label": (confirmed.node_name if confirmed else "") or device.label,
|
||||
"hardware": (confirmed.hardware_model if confirmed else "") or device.vendor_label,
|
||||
"meshcore": "yes" if confirmed else _MAYBE.get(device.confidence, "no"),
|
||||
"confidence": device.confidence,
|
||||
"serial_number": device.serial_number,
|
||||
"stable_id": device.stable_id,
|
||||
"confirmed": confirmed is not None,
|
||||
"remembered": remembered is not None and remembered.matches(device),
|
||||
"active": device.target == active_target,
|
||||
}
|
||||
)
|
||||
return Listing(
|
||||
key="devices",
|
||||
columns=(
|
||||
fields.word("target", "TARGET"),
|
||||
fields.word("transport", "TRANSPORT"),
|
||||
# The two halves `target` is one of, kept apart for a caller that has to know
|
||||
# which transport it is holding without parsing the target for a colon.
|
||||
fields.hidden("port"),
|
||||
fields.hidden("address"),
|
||||
fields.name("label", "NAME"),
|
||||
fields.name("hardware", "HARDWARE"),
|
||||
fields.word("meshcore", "MESHCORE"),
|
||||
# What the "maybe" was derived from. A person reading the column has the
|
||||
# vendor label beside it and can see for themselves; a program cannot.
|
||||
fields.hidden("confidence"),
|
||||
fields.word("serial_number", "SERIAL"),
|
||||
fields.hidden("stable_id"),
|
||||
fields.hidden("confirmed"),
|
||||
fields.hidden("remembered"),
|
||||
fields.flag("active", "ACTIVE"),
|
||||
),
|
||||
rows=rows,
|
||||
)
|
||||
+179
-23
@@ -1,9 +1,12 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``info`` tool: show the connected companion device's identity and radio config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
from rich.panel import Panel
|
||||
@@ -13,6 +16,9 @@ from ..context import AppContext
|
||||
from ..core.connection import Device
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Column, Facts
|
||||
|
||||
#: MeshCore advert-type byte -> human-readable node role.
|
||||
_ROLES = {1: "companion", 2: "repeater", 3: "room server", 4: "sensor"}
|
||||
|
||||
@@ -47,8 +53,21 @@ class InfoTool(Tool):
|
||||
|
||||
device = await ctx.device()
|
||||
snapshot = await cached_snapshot(ctx, device)
|
||||
custom = await device.get_custom_vars()
|
||||
|
||||
# On the CLI this is the *status* report and nothing else: how the radio is doing
|
||||
# right now, one fact per line. What it is set to is `config show`'s answer, in
|
||||
# the very keys `config get`/`config set` take, so printing the settings here as
|
||||
# well would be the same table twice under two different spellings.
|
||||
session = getattr(ctx.ui, "session", None)
|
||||
if session is None:
|
||||
return ToolResult(
|
||||
summary={"name": snapshot.get("name")},
|
||||
report=(await status_facts(device, snapshot),),
|
||||
)
|
||||
|
||||
from ..ui.device_info_screen import DeviceInfoScreen
|
||||
|
||||
custom = await device.get_custom_vars()
|
||||
# A live status panel (role, firmware, battery, clock, radio/packet statistics)
|
||||
# above every current setting with a short explanation of each. The panel is read
|
||||
# once; only the table differs between the two states, and it is cheap to rebuild.
|
||||
@@ -57,20 +76,11 @@ class InfoTool(Tool):
|
||||
def page(reveal_pin: bool) -> Group:
|
||||
return Group(panel, Text(""), config_table(snapshot, custom, reveal_pin=reveal_pin))
|
||||
|
||||
# In the menu the page is its own screen, because it has a key of its own: the
|
||||
# pairing PIN is masked until ``p`` uncovers it. On the CLI there is no keyboard to
|
||||
# press, so the dump prints as it always did — with the PIN masked there too, and
|
||||
# ``meshterm config get device_pin`` as the way to name it deliberately.
|
||||
session = getattr(ctx.ui, "session", None)
|
||||
if session is None:
|
||||
ctx.ui.show(page(False))
|
||||
else:
|
||||
from ..ui.device_info_screen import DeviceInfoScreen
|
||||
|
||||
await session.run_screen(
|
||||
DeviceInfoScreen(session, page, title=self.title, conceals=has_pin(snapshot))
|
||||
)
|
||||
|
||||
# The page is its own screen because it has a key of its own: the pairing PIN is
|
||||
# masked until ``p`` uncovers it.
|
||||
await session.run_screen(
|
||||
DeviceInfoScreen(session, page, title=self.title, conceals=has_pin(snapshot))
|
||||
)
|
||||
return ToolResult(summary={"name": snapshot.get("name")})
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
@@ -89,11 +99,34 @@ class InfoTool(Tool):
|
||||
# -- the status panel -----------------------------------------------------------
|
||||
|
||||
|
||||
async def _read_status(device: Device) -> dict[str, Any]:
|
||||
"""Read every live status value the firmware will answer for, best-effort.
|
||||
|
||||
The one place the device is asked. Both faces build from what it returns: the menu's
|
||||
panel composes prose rows out of these values, the CLI prints them one fact per line
|
||||
(see :func:`_status_panel` and :func:`status_facts`). Every read is optional —
|
||||
firmware predating a query simply contributes nothing — so this works on any device.
|
||||
|
||||
Args:
|
||||
device: The connected device to query.
|
||||
|
||||
Returns:
|
||||
The merged raw readings: ``info``, ``battery``, ``stats`` and ``clock``.
|
||||
"""
|
||||
return {
|
||||
"info": await _try(device.get_device_info) or {},
|
||||
"battery": await _try(device.get_battery) or {},
|
||||
"stats": await _try(device.get_stats) or {},
|
||||
"clock": await _try(device.get_time),
|
||||
}
|
||||
|
||||
|
||||
async def _status_panel(device: Device, snapshot: dict) -> Panel:
|
||||
"""Build the live status panel: role, firmware, battery, clock, and statistics.
|
||||
|
||||
Every read beyond the snapshot is best-effort — firmware predating a query (battery,
|
||||
clock, stats) simply contributes no row — so the panel renders on any device.
|
||||
The menu's face of :func:`_read_status` — each row a short phrase rather than an
|
||||
atomic value, because a person reads "-110 dBm noise floor · last RSSI -62 dBm" in one
|
||||
go where a script wants the three numbers apart.
|
||||
|
||||
Args:
|
||||
device: The connected device to query.
|
||||
@@ -103,32 +136,31 @@ async def _status_panel(device: Device, snapshot: dict) -> Panel:
|
||||
A Rich :class:`Panel` of label/value status rows.
|
||||
"""
|
||||
rows: list[tuple[str, Text]] = []
|
||||
readings = await _read_status(device)
|
||||
info, battery, stats = readings["info"], readings["battery"], readings["stats"]
|
||||
|
||||
adv_type = snapshot.get("adv_type")
|
||||
if adv_type is not None:
|
||||
role = _ROLES.get(int(adv_type), f"type {adv_type}")
|
||||
rows.append(("role", Text(role, style="brand")))
|
||||
|
||||
info = await _try(device.get_device_info) or {}
|
||||
if info.get("model"):
|
||||
rows.append(("model", Text(str(info["model"]))))
|
||||
firmware = " ".join(str(info[k]) for k in ("ver", "fw_build") if info.get(k))
|
||||
firmware = _firmware(info)
|
||||
if firmware:
|
||||
rows.append(("firmware", Text(firmware)))
|
||||
|
||||
battery = await _try(device.get_battery) or {}
|
||||
level = battery.get("level")
|
||||
if level:
|
||||
rows.append(("battery", Text(f"{int(level) / 1000:.2f} V")))
|
||||
if battery.get("total_kb"):
|
||||
rows.append(("storage", Text(f"{battery.get('used_kb', 0)} / {battery['total_kb']} kB")))
|
||||
|
||||
clock = await _try(device.get_time)
|
||||
clock = readings["clock"]
|
||||
if clock:
|
||||
stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(clock))
|
||||
rows.append(("clock", Text.assemble(stamp, (f" {_drift(clock)}", "muted"))))
|
||||
|
||||
stats = await _try(device.get_stats) or {}
|
||||
if stats.get("uptime_secs") is not None:
|
||||
rows.append(("uptime", Text(_uptime(int(stats["uptime_secs"])))))
|
||||
if stats.get("noise_floor") is not None:
|
||||
@@ -160,6 +192,130 @@ async def _status_panel(device: Device, snapshot: dict) -> Panel:
|
||||
return Panel(body, title="[accent]Status[/accent]", border_style="accent", expand=False)
|
||||
|
||||
|
||||
def _firmware(info: dict) -> str:
|
||||
"""The firmware version and build joined, or ``""`` when neither was reported."""
|
||||
return " ".join(str(info[k]) for k in ("ver", "fw_build") if info.get(k))
|
||||
|
||||
|
||||
async def status_facts(device: Device, snapshot: dict) -> Facts:
|
||||
"""The device's live status as one set of facts.
|
||||
|
||||
The CLI's face of :func:`_read_status`. Where the panel writes a phrase per row, this
|
||||
writes one value per key and puts the unit *in the key* — ``battery_v``, ``uptime_s``,
|
||||
``noise_floor_dbm`` — so nothing has to be pulled back out of prose. A number never
|
||||
carries a unit in its value, on either face.
|
||||
|
||||
The three second-valued readings and the clock drift carry a **gloss** beside the
|
||||
figure: ``uptime_s 93784 (1d 2h)``. This is the one key/value block that may, and
|
||||
the reason is that it is the one with no ``get`` and no ``set`` behind it — there is no
|
||||
round trip to protect, the key still says what the number counts, and ``93784`` is a
|
||||
reading nobody can hold in their head. The document carries the number alone, having
|
||||
nowhere useful to put a phrase.
|
||||
|
||||
A reading the firmware did not answer for is **left out** of the plain block rather
|
||||
than dashed: these are optional queries, and a missing key says "this device does not
|
||||
report it" where a dash would claim it reported nothing. The document keeps the key and
|
||||
writes ``null`` — see :attr:`~meshterm.ui.report.Facts.omit_absent` for why the two
|
||||
faces disagree here on purpose.
|
||||
|
||||
Args:
|
||||
device: The connected device to query.
|
||||
snapshot: The already-built settings snapshot (for role and identity).
|
||||
|
||||
Returns:
|
||||
The facts block.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
|
||||
readings = await _read_status(device)
|
||||
info, battery, stats = readings["info"], readings["battery"], readings["stats"]
|
||||
|
||||
level = battery.get("level")
|
||||
adv_type = snapshot.get("adv_type")
|
||||
clock = readings["clock"]
|
||||
values: dict[str, Any] = {
|
||||
# Identity first: which radio this is. Everything it *can be set to* is
|
||||
# `config show`'s answer, not this one.
|
||||
"name": snapshot.get("name"),
|
||||
"public_key": str(snapshot.get("public_key") or "").lower() or None,
|
||||
"role": _ROLES.get(int(adv_type), f"type {adv_type}") if adv_type is not None else None,
|
||||
"model": info.get("model"),
|
||||
"firmware": _firmware(info) or None,
|
||||
"battery_v": round(int(level) / 1000, 2) if level else None,
|
||||
"storage_used_kb": battery.get("used_kb", 0) if battery.get("total_kb") else None,
|
||||
"storage_total_kb": battery.get("total_kb"),
|
||||
"clock_at": (
|
||||
datetime.fromtimestamp(clock, tz=timezone.utc).astimezone() if clock else None
|
||||
),
|
||||
"clock_drift_s": (clock - int(time.time())) if clock else None,
|
||||
"uptime_s": stats.get("uptime_secs"),
|
||||
"noise_floor_dbm": stats.get("noise_floor"),
|
||||
"last_rssi_dbm": stats.get("last_rssi"),
|
||||
"last_snr_db": stats.get("last_snr"),
|
||||
"tx_air_s": stats.get("tx_air_secs"),
|
||||
"rx_air_s": stats.get("rx_air_secs"),
|
||||
"packets_sent": stats.get("sent"),
|
||||
"packets_received": stats.get("recv"),
|
||||
"receive_errors": stats.get("recv_errors"),
|
||||
}
|
||||
return Facts(
|
||||
key="info",
|
||||
fields=(
|
||||
fields.word("name", "name"),
|
||||
fields.hexid("public_key", "public_key"),
|
||||
fields.word("role", "role"),
|
||||
fields.word("model", "model"),
|
||||
fields.word("firmware", "firmware"),
|
||||
fields.decimal("battery_v", "battery_v", ".2f"),
|
||||
fields.integer("storage_used_kb", "storage_used_kb"),
|
||||
fields.integer("storage_total_kb", "storage_total_kb"),
|
||||
fields.instant("clock_at", "clock_at"),
|
||||
_glossed("clock_drift_s", _drift_gloss),
|
||||
_glossed("uptime_s", _span_gloss),
|
||||
fields.integer("noise_floor_dbm", "noise_floor_dbm"),
|
||||
fields.integer("last_rssi_dbm", "last_rssi_dbm"),
|
||||
fields.snr("last_snr_db", "last_snr_db"),
|
||||
_glossed("tx_air_s", _span_gloss),
|
||||
_glossed("rx_air_s", _span_gloss),
|
||||
fields.integer("packets_sent", "packets_sent"),
|
||||
fields.integer("packets_received", "packets_received"),
|
||||
fields.integer("receive_errors", "receive_errors"),
|
||||
),
|
||||
values=values,
|
||||
omit_absent=True,
|
||||
)
|
||||
|
||||
|
||||
def _glossed(key: str, gloss: Callable[[int], str]) -> Column:
|
||||
"""A seconds reading printed as its own number with a readable span beside it."""
|
||||
from ..ui import script
|
||||
from ..ui.report import Column, Lane
|
||||
|
||||
def cell(value: Any) -> str:
|
||||
if value is None:
|
||||
return script.NONE
|
||||
return f"{value} ({gloss(int(value))})"
|
||||
|
||||
return Column(key=key, lanes=(Lane(header=key, render=cell),))
|
||||
|
||||
|
||||
def _span_gloss(seconds: int) -> str:
|
||||
"""``93784`` → ``1d 2h``: the figure a person can hold in their head."""
|
||||
from ..ui import script
|
||||
|
||||
return script.duration(seconds)
|
||||
|
||||
|
||||
def _drift_gloss(seconds: int) -> str:
|
||||
"""The device clock's error, in the direction a reader would say it."""
|
||||
from ..ui import script
|
||||
|
||||
if abs(seconds) < 2:
|
||||
return "in sync"
|
||||
return f"{script.duration(seconds)} {'fast' if seconds > 0 else 'slow'}"
|
||||
|
||||
|
||||
async def _try(read) -> Any | None:
|
||||
"""Await a device read, returning ``None`` when the firmware doesn't support it."""
|
||||
try:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``livefeed`` tool: every packet as it arrives, right under the dashboard.
|
||||
|
||||
Opens the full-screen live feed (see :mod:`meshterm.ui.livefeed_screen`): the
|
||||
|
||||
+27
-244
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``map`` tool: plot the mesh's located nodes on an OpenStreetMap terminal map.
|
||||
|
||||
Nodes that share their location in adverts are collected from the passive-monitor history
|
||||
@@ -5,11 +6,12 @@ Nodes that share their location in adverts are collected from the passive-monito
|
||||
its position is known, and everything is drawn over a real street basemap rendered as Unicode
|
||||
braille (streets, rivers, place names — the same terminal-map idea as ``mapscii``).
|
||||
|
||||
In the menu it opens a full-screen, pannable/zoomable map (see
|
||||
:mod:`meshterm.ui.map_screen`). On the CLI it prints a one-shot render fitted to the nodes.
|
||||
Repeaters are prioritised over ordinary nodes: a distinct marker, drawn on top, listed first.
|
||||
The basemap is best-effort — with no network (and no cached tiles) the nodes are plotted on a
|
||||
blank grid instead, and the tool still works offline.
|
||||
Menu-only: it opens a full-screen, pannable/zoomable map (see
|
||||
:mod:`meshterm.ui.map_screen`) and registers no CLI subcommand, because a map is a picture
|
||||
and the scripted CLI deals in facts (see :meth:`MapTool.register_cli`). Repeaters are
|
||||
prioritised over ordinary nodes: a distinct marker, drawn on top. The basemap is
|
||||
best-effort — with no network (and no cached tiles) the nodes are plotted on a blank grid
|
||||
instead, and the tool still works offline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,13 +19,9 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
from rich.console import Group
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core.geo import usable_fix
|
||||
from ..core.models import NODE_TYPE_REPEATER
|
||||
from ..services.markers import gather_markers
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
|
||||
@@ -46,7 +44,7 @@ def _fraction(ctx: AppContext, params: dict[str, Any]) -> float:
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..ui.map_render import MapMarker
|
||||
from ..core.models import MapMarker
|
||||
|
||||
|
||||
@register
|
||||
@@ -61,17 +59,18 @@ class MapTool(Tool):
|
||||
order = 10
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Gather located nodes and open the interactive map, or render a static one.
|
||||
"""Gather the located nodes and open the interactive map.
|
||||
|
||||
Menu-only (see :meth:`register_cli`), so there is one path through here.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
params: Optional ``static`` (force one-shot render), ``width``, ``zoom``,
|
||||
``basemap`` (set ``False`` to skip fetching tiles).
|
||||
params: Optional ``fraction`` — how much of the mesh to open framed on.
|
||||
|
||||
Returns:
|
||||
A :class:`ToolResult` summarizing how many nodes were plotted.
|
||||
"""
|
||||
from ..ui.surface import TuiUi
|
||||
from ..ui.map_screen import open_map
|
||||
|
||||
markers = await self._gather(ctx)
|
||||
if not markers:
|
||||
@@ -81,13 +80,7 @@ class MapTool(Tool):
|
||||
)
|
||||
return ToolResult(summary={"located": 0})
|
||||
|
||||
interactive = isinstance(ctx.ui, TuiUi) and not params.get("static")
|
||||
if interactive:
|
||||
from ..ui.map_screen import open_map
|
||||
|
||||
await open_map(ctx, markers, fraction=_fraction(ctx, params))
|
||||
else:
|
||||
await self._render_static(ctx, markers, params)
|
||||
await open_map(ctx, markers, fraction=_fraction(ctx, params))
|
||||
|
||||
repeaters = sum(1 for m in markers if m.is_repeater and not m.is_self)
|
||||
self_located = any(m.is_self for m in markers)
|
||||
@@ -98,12 +91,6 @@ class MapTool(Tool):
|
||||
"nodes": len(markers) - repeaters - (1 if self_located else 0),
|
||||
"self_located": self_located,
|
||||
},
|
||||
message=None
|
||||
if interactive
|
||||
else (
|
||||
f"[ok]✓[/ok] mapped [brand]{len(markers)}[/brand] located nodes "
|
||||
f"([accent]{repeaters}[/accent] repeaters)"
|
||||
),
|
||||
)
|
||||
|
||||
# -- marker gathering -------------------------------------------------------
|
||||
@@ -112,223 +99,19 @@ class MapTool(Tool):
|
||||
"""Collect every located node to plot (see :func:`gather_markers`)."""
|
||||
return await gather_markers(ctx)
|
||||
|
||||
# -- static (CLI) render ----------------------------------------------------
|
||||
|
||||
async def _render_static(
|
||||
self, ctx: AppContext, markers: list[MapMarker], params: dict[str, Any]
|
||||
) -> None:
|
||||
"""Fetch tiles synchronously and print a one-shot map fitted to the nodes."""
|
||||
import asyncio
|
||||
|
||||
from ..core.geo import Viewport
|
||||
from ..ui.map_render import render_map
|
||||
from ..ui.map_screen import basemap_source
|
||||
|
||||
cell_w = int(params.get("width") or min(ctx.console.size.width - 2, 160))
|
||||
cell_h = max(12, cell_w * 4 // 13) # keep roughly the terminal's aspect
|
||||
dot_w, dot_h = cell_w * 2, cell_h * 4
|
||||
|
||||
source = basemap_source(ctx)
|
||||
max_zoom = await asyncio.to_thread(lambda: source.max_zoom)
|
||||
coords = [(m.lat, m.lon) for m in markers]
|
||||
if params.get("zoom") is not None:
|
||||
from ..core.geo import BBox
|
||||
|
||||
center = BBox.around(coords).center
|
||||
vp = Viewport(center[0], center[1], int(params["zoom"]), dot_w, dot_h)
|
||||
else:
|
||||
vp = Viewport.fit(
|
||||
coords,
|
||||
dot_w,
|
||||
dot_h,
|
||||
max_zoom=max_zoom,
|
||||
fraction=_fraction(ctx, params),
|
||||
)
|
||||
|
||||
tiles = {}
|
||||
if params.get("basemap", True):
|
||||
for tile in vp.tiles(max_zoom):
|
||||
tiles[tile] = await asyncio.to_thread(source.load_tile, *tile)
|
||||
|
||||
lines = render_map(vp, tiles, markers)
|
||||
body = Text.from_ansi("\n".join(lines))
|
||||
ctx.ui.show(Group(body, Text(""), _legend(markers)))
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register the ``map`` subcommand.
|
||||
"""Register no CLI command — a map is a picture, and pictures are menu-only.
|
||||
|
||||
Every other feature has a scripted face because its answer is a set of facts a
|
||||
script can act on. A map's answer is a *drawing*: braille cells whose meaning is
|
||||
their position on a grid and whose nodes are told apart by colour, which is
|
||||
exactly what the scripted CLI does not have (see :mod:`meshterm.ui.script`). A
|
||||
one-shot render there would be an unparseable block of glyphs, and stripping its
|
||||
colour to match the rest of the CLI would make it unreadable as well.
|
||||
|
||||
The located nodes themselves are still scriptable — ``meshterm contacts`` lists
|
||||
every one of them, coordinates included.
|
||||
|
||||
Args:
|
||||
app: The Typer application.
|
||||
app: The Typer application (untouched).
|
||||
"""
|
||||
from ..cli import run_tool_command
|
||||
|
||||
@app.command(name=self.name, help=self.help)
|
||||
def _map(
|
||||
width: int | None = typer.Option(
|
||||
None, "--width", "-w", help="Map width in character cells"
|
||||
),
|
||||
zoom: int | None = typer.Option(
|
||||
None, "--zoom", "-z", help="Fixed zoom level (omit to fit the nodes)"
|
||||
),
|
||||
fraction: float | None = typer.Option(
|
||||
None,
|
||||
"--fraction",
|
||||
"-f",
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
help="Fraction of nodes to frame: the densest that many, so distant "
|
||||
"outliers don't zoom the view out. 1.0 fits every node. Ignored with "
|
||||
"--zoom. Defaults to the 'Opening frame' preference",
|
||||
),
|
||||
basemap: bool = typer.Option(
|
||||
True, "--basemap/--no-basemap", help="Draw the OpenStreetMap street basemap"
|
||||
),
|
||||
) -> None:
|
||||
if fraction is not None and not 0.0 < fraction <= 1.0:
|
||||
raise typer.BadParameter("--fraction must be greater than 0 and at most 1")
|
||||
params: dict[str, Any] = {
|
||||
"static": True,
|
||||
"basemap": basemap,
|
||||
"fraction": fraction,
|
||||
}
|
||||
if width is not None:
|
||||
params["width"] = width
|
||||
if zoom is not None:
|
||||
params["zoom"] = zoom
|
||||
run_tool_command(self, params)
|
||||
|
||||
|
||||
async def gather_markers(ctx: AppContext) -> 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.
|
||||
|
||||
Shared by the ``map`` tool and the config editor's pick-a-location map, so both
|
||||
show the same mesh.
|
||||
"""
|
||||
from ..ui.map_render import MapMarker
|
||||
|
||||
observed = {n.node: n for n in ctx.repo.heard_nodes() if n.node}
|
||||
markers: list[MapMarker] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for contact in await _contacts(ctx):
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
self_marker = await _self_marker(ctx)
|
||||
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."""
|
||||
from ..ui.map_render import MapMarker
|
||||
|
||||
try:
|
||||
info = await ctx.devstate.self_info()
|
||||
except Exception: # noqa: BLE001 - the map is useful without our own position
|
||||
return None
|
||||
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 _legend(markers: list[MapMarker]) -> Table:
|
||||
"""A compact legend: self → repeaters → leaf nodes, with coordinates and detail.
|
||||
|
||||
Names take their key-derived hue (our own the pure-white ``you``), matching the
|
||||
interactive map's labels; the glyph keeps the marker's type colour.
|
||||
"""
|
||||
from ..ui.map_render import _NODE, _REPEATER, _SELF
|
||||
from ..ui.theme import name_style
|
||||
|
||||
table = Table(box=None, padding=(0, 2, 0, 0), expand=False)
|
||||
table.add_column("")
|
||||
table.add_column("NODE")
|
||||
table.add_column("TYPE")
|
||||
table.add_column("COORDS", justify="right")
|
||||
table.add_column("HEARD")
|
||||
ordered = sorted(markers, key=lambda m: -m._rank())
|
||||
for m in ordered:
|
||||
glyph, color = _SELF if m.is_self else (_REPEATER if m.is_repeater else _NODE)
|
||||
kind = "you" if m.is_self else ("repeater" if m.is_repeater else "node")
|
||||
table.add_row(
|
||||
Text(glyph, style=color),
|
||||
Text(m.label, style="you" if m.is_self else name_style(m.label, m.key)),
|
||||
Text(kind, style="muted"),
|
||||
Text(f"{m.lat:.4f}, {m.lon:.4f}", style="muted"),
|
||||
Text(m.detail, style="muted"),
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
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
|
||||
+167
-55
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``monitor`` tool: a bounded foreground capture of what the mesh is saying.
|
||||
|
||||
Passive monitoring records every advert and telemetry frame the companion overhears —
|
||||
@@ -18,17 +19,21 @@ summarizes the window when it ends.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core import exitcodes
|
||||
from ..core.events import EventKind, MeshEvent
|
||||
from ..core.models import HeardNode, Observation
|
||||
from ..core.models import NODE_TYPE_LABELS, HeardNode, Observation
|
||||
from ..ui import renderers
|
||||
from ..ui.fields import NodeRef, Position
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Listing
|
||||
|
||||
|
||||
@register
|
||||
class MonitorTool(Tool):
|
||||
@@ -74,13 +79,18 @@ class MonitorTool(Tool):
|
||||
Returns:
|
||||
A :class:`ToolResult` with the window's packet and node counts.
|
||||
"""
|
||||
from ..ui import script
|
||||
|
||||
seconds = int(params.get("seconds") or 0)
|
||||
await ctx.device() # surface connection problems before announcing the capture
|
||||
await ctx.monitor.start() # register history recording before the hub pumps
|
||||
await ctx.events.start()
|
||||
ctx.console.print(
|
||||
"[muted]monitoring"
|
||||
f"{f' for {seconds}s' if seconds else ' — press Ctrl-C to stop'}…[/muted]"
|
||||
# The announcement is about the run, not part of its answer, so it goes to stderr
|
||||
# and stays out of `meshterm monitor -s 60 > packets.txt`.
|
||||
script.stderr_console().print(
|
||||
"monitoring" + (f" for {seconds}s" if seconds else " — press Ctrl-C to stop"),
|
||||
style="muted",
|
||||
highlight=False,
|
||||
)
|
||||
seen: list[Observation] = []
|
||||
|
||||
@@ -89,39 +99,54 @@ class MonitorTool(Tool):
|
||||
if obs is None:
|
||||
return
|
||||
seen.append(obs)
|
||||
stamp = obs.observed_at.astimezone().strftime("%H:%M:%S")
|
||||
who = obs.name or obs.node or "?"
|
||||
snr = f" [muted]{obs.snr:+.1f} dB[/muted]" if obs.snr is not None else ""
|
||||
rssi = f" [muted]{obs.rssi:.0f} dBm[/muted]" if obs.rssi is not None else ""
|
||||
loc = " [ok]●[/ok]" if obs.lat is not None else ""
|
||||
ctx.console.print(f"[muted]{stamp}[/muted] [accent]{who}[/accent]{snr}{rssi}{loc}")
|
||||
emit(
|
||||
{
|
||||
"observed_at": obs.observed_at,
|
||||
"node": NodeRef(
|
||||
name=obs.name,
|
||||
key=(obs.public_key or "").lower() or None,
|
||||
hash=obs.node,
|
||||
type=NODE_TYPE_LABELS.get(obs.node_type),
|
||||
),
|
||||
"kind": obs.kind,
|
||||
"snr_db": obs.snr,
|
||||
"rssi_dbm": obs.rssi,
|
||||
"position": _position(obs.lat, obs.lon),
|
||||
"path": _relays(obs.path),
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribe = ctx.events.subscribe(on_observation, EventKind.OBSERVATION)
|
||||
try:
|
||||
if seconds:
|
||||
await asyncio.sleep(seconds)
|
||||
else:
|
||||
await asyncio.Event().wait() # until Ctrl-C / cancellation
|
||||
except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive
|
||||
pass
|
||||
finally:
|
||||
unsubscribe()
|
||||
await ctx.monitor.stop() # close the run row so the capture is a full record
|
||||
with renderers.stream(ctx, _live_packets()) as emit:
|
||||
unsubscribe = ctx.events.subscribe(on_observation, EventKind.OBSERVATION)
|
||||
try:
|
||||
if seconds:
|
||||
await asyncio.sleep(seconds)
|
||||
else:
|
||||
await asyncio.Event().wait() # until Ctrl-C / cancellation
|
||||
except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive
|
||||
pass
|
||||
finally:
|
||||
unsubscribe()
|
||||
await ctx.monitor.stop() # close the run row so the capture is a full record
|
||||
|
||||
by_node: dict[str, list[Observation]] = {}
|
||||
for obs in seen:
|
||||
by_node.setdefault(obs.node, []).append(obs)
|
||||
nodes = [HeardNode.from_observations(node, group) for node, group in by_node.items()]
|
||||
if nodes:
|
||||
nodes.sort(key=lambda n: n.last_seen, reverse=True)
|
||||
ctx.ui.show(_heard_table(nodes))
|
||||
nodes.sort(key=lambda n: n.last_seen, reverse=True)
|
||||
# The window is over, so its facts are worth saying — on stderr, where everything
|
||||
# about a run goes, keeping the packets a caller redirected the run for on their own.
|
||||
script.stderr_console().print(
|
||||
f"captured {len(seen)} packet{'' if len(seen) == 1 else 's'} "
|
||||
f"from {len(nodes)} node{'' if len(nodes) == 1 else 's'}"
|
||||
+ (f" in {seconds}s" if seconds else ""),
|
||||
style="muted",
|
||||
highlight=False,
|
||||
)
|
||||
return ToolResult(
|
||||
summary={"seconds": seconds, "packets": len(seen), "nodes": len(nodes)},
|
||||
message=(
|
||||
f"[ok]✓[/ok] heard [brand]{len(seen)}[/brand] "
|
||||
f"packet{'' if len(seen) == 1 else 's'} from "
|
||||
f"[brand]{len(nodes)}[/brand] node{'' if len(nodes) == 1 else 's'}"
|
||||
),
|
||||
report=(_heard(nodes),),
|
||||
exit_code=exitcodes.OK if seen else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
@@ -141,33 +166,120 @@ class MonitorTool(Tool):
|
||||
run_tool_command(self, {"seconds": seconds})
|
||||
|
||||
|
||||
def _heard_table(heard: list[HeardNode]) -> Table:
|
||||
"""Render the capture window's heard-node summary table.
|
||||
def _position(lat: float | None, lon: float | None) -> Position | None:
|
||||
"""A reception's shared position, or ``None`` where the packet carried none."""
|
||||
return Position(lat, lon) if lat is not None and lon is not None else None
|
||||
|
||||
|
||||
def _relays(path: str | None) -> list[str] | None:
|
||||
"""A packet's relay chain as hashes, in propagation order.
|
||||
|
||||
Hashes rather than the shared node shape, and that is the honest answer: nothing
|
||||
resolves a relay to a node at reception time, so a node object per hop would be four
|
||||
``null``s wrapped round a hash. ``[]`` is a direct reception (zero hops); ``None``
|
||||
means the packet class carries no path at all, which is a different fact.
|
||||
"""
|
||||
if path is None:
|
||||
return None
|
||||
return [hop for hop in path.split(",") if hop]
|
||||
|
||||
|
||||
def _live_packets() -> Listing:
|
||||
"""The shape the live capture streams: one record per packet, as it lands.
|
||||
|
||||
``TIME`` is absolute where a listing's times are ages, and for the reason the whole
|
||||
rule turns on: every row of a live tail would read ``now``. The **name goes last**, so
|
||||
the one field with no width cannot push a lane — which is what finally let the two
|
||||
streams have columns at all, and with them the quoting could go.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
def pin(column, width: int): # noqa: ANN001, ANN202 - one column in, one column out
|
||||
return replace(column, lanes=tuple(replace(lane, width=width) for lane in column.lanes))
|
||||
|
||||
node = fields.node("node", lanes=(("hash", "NODE"), ("name", "NAME")))
|
||||
node = replace(
|
||||
node,
|
||||
lanes=(replace(node.lanes[0], width=8), node.lanes[1]),
|
||||
)
|
||||
return Listing(
|
||||
key="observations",
|
||||
columns=(
|
||||
pin(fields.instant("observed_at", "TIME"), 25),
|
||||
node,
|
||||
# The packet class, which the plain stream has never had room for. New
|
||||
# surface: the simulator does not exercise every class, so a consumer should
|
||||
# treat an unfamiliar word as a word rather than an error.
|
||||
fields.hidden("kind"),
|
||||
pin(fields.snr("snr_db", "SNR_DB"), 6),
|
||||
pin(fields.decimal("rssi_dbm", "RSSI_DBM", ".0f"), 8),
|
||||
pin(fields.position(), 19),
|
||||
fields.hidden("path"),
|
||||
),
|
||||
order=("TIME", "NODE", "SNR_DB", "RSSI_DBM", "LOCATION", "NAME"),
|
||||
)
|
||||
|
||||
|
||||
def _heard(heard: list[HeardNode]) -> Listing:
|
||||
"""The capture window's per-node summary: what each node did over the whole window.
|
||||
|
||||
The aggregate the live stream cannot give — a count, a median, a best — one record per
|
||||
node heard, most recently heard first.
|
||||
|
||||
Drawn **for a person only**. Every figure in it is computable from the records that
|
||||
scrolled past above it, and emitting it as a second *shape* of line in the middle of an
|
||||
otherwise homogeneous NDJSON stream would cost every consumer a discriminator it would
|
||||
never otherwise need.
|
||||
|
||||
Args:
|
||||
heard: Aggregated per-node statistics for the window.
|
||||
|
||||
Returns:
|
||||
A Rich :class:`Table` of node, packet count, SNR, RSSI, and location.
|
||||
The listing.
|
||||
"""
|
||||
from ..ui.theme import snr_style
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
table = Table(title=f"Heard nodes ({len(heard)})", border_style="muted", expand=False)
|
||||
table.add_column("NODE")
|
||||
table.add_column("PKTS", justify="right")
|
||||
table.add_column("MEDIAN SNR", justify="right")
|
||||
table.add_column("BEST SNR", justify="right")
|
||||
table.add_column("RSSI", justify="right")
|
||||
table.add_column("LOC", justify="center")
|
||||
for node in heard:
|
||||
label = node.name or node.node or "?"
|
||||
median = node.median_snr
|
||||
best = node.best_snr
|
||||
median_cell = (
|
||||
Text(f"{median:+.1f}", style=snr_style(median)) if median is not None else Text("—")
|
||||
)
|
||||
best_cell = Text(f"{best:+.1f}", style=snr_style(best)) if best is not None else Text("—")
|
||||
rssi_cell = f"{node.last_rssi:.0f}" if node.last_rssi is not None else "—"
|
||||
loc_cell = Text("●", style="ok") if node.has_location else Text("·", style="muted")
|
||||
table.add_row(str(label), str(node.count), median_cell, best_cell, rssi_cell, loc_cell)
|
||||
return table
|
||||
return Listing(
|
||||
key="heard",
|
||||
columns=(
|
||||
fields.node("node", lanes=(("hash", "NODE"), ("name", "NAME"))),
|
||||
fields.integer("packets", "PKTS"),
|
||||
fields.snr("median_snr_db", "MEDIAN_SNR_DB"),
|
||||
fields.snr("best_snr_db", "BEST_SNR_DB"),
|
||||
fields.decimal("last_rssi_dbm", "RSSI_DBM", ".0f"),
|
||||
fields.when("last_heard_at", "HEARD"),
|
||||
fields.position(),
|
||||
),
|
||||
rows=[
|
||||
{
|
||||
"node": NodeRef(
|
||||
name=node.name,
|
||||
key=(node.public_key or "").lower() or None,
|
||||
hash=node.node,
|
||||
type=NODE_TYPE_LABELS.get(node.node_type),
|
||||
),
|
||||
"packets": node.count,
|
||||
"median_snr_db": node.median_snr,
|
||||
"best_snr_db": node.best_snr,
|
||||
"last_rssi_dbm": node.last_rssi,
|
||||
"last_heard_at": node.last_seen,
|
||||
"position": _position(node.lat, node.lon),
|
||||
}
|
||||
for node in heard
|
||||
],
|
||||
order=(
|
||||
"NODE",
|
||||
"NAME",
|
||||
"PKTS",
|
||||
"MEDIAN_SNR_DB",
|
||||
"BEST_SNR_DB",
|
||||
"RSSI_DBM",
|
||||
"HEARD",
|
||||
"LOCATION",
|
||||
),
|
||||
plain_only=True,
|
||||
)
|
||||
+178
-11
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``preferences`` tool: how MeshTerm itself behaves.
|
||||
|
||||
Interactively it opens the Preferences page (see :mod:`meshterm.ui.preferences`), which
|
||||
@@ -17,18 +18,28 @@ than sitting beside Device config.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core.preferences import PreferenceError, format_value, get_spec
|
||||
from ..core.preferences import (
|
||||
PREFERENCES,
|
||||
PreferenceError,
|
||||
Preferences,
|
||||
PrefSpec,
|
||||
format_value,
|
||||
get_spec,
|
||||
)
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Block, Facts, Listing
|
||||
|
||||
|
||||
@register
|
||||
class PreferencesTool(Tool):
|
||||
"""View and change MeshTerm's own preferences, saved to ``preferences.yaml``."""
|
||||
"""View and change MeshTerm's own preferences, saved to ``preferences.toml``."""
|
||||
|
||||
name = "preferences"
|
||||
title = "Preferences"
|
||||
@@ -72,32 +83,59 @@ class PreferencesTool(Tool):
|
||||
PreferenceError: If a key is unknown or a value fails its spec — reported
|
||||
cleanly by the CLI rather than as a traceback.
|
||||
"""
|
||||
from ..services import consolefont
|
||||
from ..ui.preferences import preferences_table
|
||||
from ..ui.surface import TuiUi
|
||||
|
||||
prefs = ctx.preferences
|
||||
scripted = not isinstance(ctx.ui, TuiUi)
|
||||
ops: list[tuple] = list(params.get("ops") or [])
|
||||
blocks: list[Block] = []
|
||||
applied: list[dict[str, Any]] = []
|
||||
changes = 0
|
||||
for op in ops:
|
||||
action = op[0]
|
||||
if action == "show":
|
||||
ctx.ui.show(preferences_table(prefs, ctx.console.width))
|
||||
if scripted:
|
||||
blocks.append(_listing(prefs))
|
||||
else:
|
||||
ctx.ui.show(preferences_table(prefs, ctx.console.width))
|
||||
elif action == "get":
|
||||
# The bare value and nothing else — the caller named the key, and
|
||||
# whether it is overridden is what `show`'s DEFAULT lane answers. The
|
||||
# document keeps both, since a parser has no lane to read them off.
|
||||
spec = get_spec(op[1])
|
||||
where = "changed" if prefs.is_overridden(spec.key) else "default"
|
||||
ctx.ui.show(
|
||||
f"[accent]{spec.key}[/accent] = "
|
||||
f"[brand]{format_value(spec, prefs.get(spec.key))}[/brand] ({where})"
|
||||
)
|
||||
if scripted:
|
||||
blocks.append(_one(prefs, spec))
|
||||
else:
|
||||
where = "changed" if prefs.is_overridden(spec.key) else "default"
|
||||
shown = format_value(spec, prefs.get(spec.key))
|
||||
ctx.ui.note(f"[accent]{spec.key}[/accent] = [brand]{shown}[/brand] ({where})")
|
||||
elif action == "set":
|
||||
before = prefs.get(op[1])
|
||||
after = prefs.set(op[1], op[2])
|
||||
if after != before:
|
||||
changes += 1
|
||||
applied.append({"key": op[1], "previous": before, "value": after})
|
||||
elif action == "reset":
|
||||
was = {s.key: prefs.get(s.key) for s in PREFERENCES if prefs.is_overridden(s.key)}
|
||||
changes += prefs.reset()
|
||||
applied.extend(
|
||||
{"key": key, "previous": before, "value": prefs.get(key)}
|
||||
for key, before in was.items()
|
||||
)
|
||||
|
||||
if changes:
|
||||
prefs.save()
|
||||
if not scripted and any(c["key"] == consolefont.PREFERENCE_KEY for c in applied):
|
||||
# The one preference whose effect is outside the app: the console's font is
|
||||
# the console's, so it takes hold now rather than next launch. The kernel
|
||||
# sends SIGWINCH on a VT font change, so prompt_toolkit repaints the whole
|
||||
# frame at the new row count by itself. Menu-only — a scripted run was typed
|
||||
# into a console it does not own (see meshterm.services.consolefont).
|
||||
consolefont.apply(prefs.get(consolefont.PREFERENCE_KEY))
|
||||
if scripted and any(op[0] in ("set", "reset") for op in ops):
|
||||
blocks.append(_written(applied, prefs))
|
||||
|
||||
plural = "" if changes == 1 else "s"
|
||||
message = (
|
||||
@@ -106,7 +144,9 @@ class PreferencesTool(Tool):
|
||||
if changes
|
||||
else None
|
||||
)
|
||||
return ToolResult(summary={"changes": changes}, message=message)
|
||||
return ToolResult(
|
||||
summary={"changes": changes}, message=message, report=tuple(blocks) or None
|
||||
)
|
||||
|
||||
# -- CLI --------------------------------------------------------------------
|
||||
|
||||
@@ -118,7 +158,7 @@ class PreferencesTool(Tool):
|
||||
"""
|
||||
from ..cli import run_tool_command
|
||||
|
||||
prefs_app = typer.Typer(help=self.help, no_args_is_help=False, rich_markup_mode="rich")
|
||||
prefs_app = typer.Typer(help=self.help, no_args_is_help=False, rich_markup_mode=None)
|
||||
|
||||
@prefs_app.callback(invoke_without_command=True)
|
||||
def _root(ctx: typer.Context) -> None:
|
||||
@@ -155,3 +195,130 @@ class PreferencesTool(Tool):
|
||||
|
||||
|
||||
__all__ = ["PreferencesTool", "PreferenceError"]
|
||||
|
||||
|
||||
def _script_value(spec: PrefSpec, value: Any) -> str:
|
||||
"""A preference's value in the form ``preferences set`` accepts back.
|
||||
|
||||
:func:`~meshterm.core.preferences.format_value` writes for the page: a number carries
|
||||
its unit (``5 s``), so a bare figure in the VALUE lane still says what it counts. That
|
||||
unit is exactly what :func:`~meshterm.core.preferences.parse_value` will not take
|
||||
back, so the scripted dump drops it. Booleans keep ``on``/``off`` and an enum keeps
|
||||
its own key, both of which do round-trip.
|
||||
|
||||
Args:
|
||||
spec: The preference the value belongs to.
|
||||
value: Its current value.
|
||||
|
||||
Returns:
|
||||
The scripted rendering.
|
||||
"""
|
||||
if spec.value_type == "bool":
|
||||
return "on" if value else "off"
|
||||
if spec.value_type in ("int", "float"):
|
||||
return f"{value:g}"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _row(prefs: Preferences, spec: PrefSpec) -> dict[str, Any]:
|
||||
"""One preference as a report row, in the shared setting shape.
|
||||
|
||||
``overridden`` is the fact the plain face makes a reader derive by comparing two
|
||||
columns; a document that carried the same two numbers and left the comparison to the
|
||||
consumer would be handing over homework it has the answer to.
|
||||
"""
|
||||
from ..ui.fields import Rendered
|
||||
|
||||
value = prefs.get(spec.key)
|
||||
return {
|
||||
"key": spec.key,
|
||||
"value": Rendered(value, _script_value(spec, value)),
|
||||
"type": spec.value_type,
|
||||
# The reader's word for an enum's own key — never what `set` takes, which is the
|
||||
# key itself and rides in `value`.
|
||||
"label": (spec.choices or {}).get(value) if spec.value_type == "enum" else None,
|
||||
"redacted": False,
|
||||
"default": Rendered(spec.default, _script_value(spec, spec.default)),
|
||||
"overridden": prefs.is_overridden(spec.key),
|
||||
"help": spec.description,
|
||||
}
|
||||
|
||||
|
||||
def _columns() -> tuple:
|
||||
"""The preference row's columns, shared by ``show`` and ``get``."""
|
||||
from ..ui import fields
|
||||
|
||||
return (
|
||||
fields.word("key", "PREFERENCE"),
|
||||
fields.rendered("value", "VALUE"),
|
||||
fields.hidden("type"),
|
||||
fields.hidden("label"),
|
||||
fields.hidden("redacted"),
|
||||
fields.rendered("default", "DEFAULT"),
|
||||
fields.hidden("overridden"),
|
||||
fields.note("help", "DESCRIPTION"),
|
||||
)
|
||||
|
||||
|
||||
def _listing(prefs: Preferences) -> Listing:
|
||||
"""Every preference, its value, its built-in default, and what it is for.
|
||||
|
||||
``DESCRIPTION`` comes straight from :attr:`PrefSpec.help` — the text is already
|
||||
written, one short line each, and as the *last* column it can never disturb an
|
||||
alignment or wrap. It used to be cut for parser hygiene, which is a bargain the plain
|
||||
face no longer has to make: a listing is read by a person now, and a person choosing a
|
||||
preference is exactly who that sentence was written for.
|
||||
|
||||
Args:
|
||||
prefs: The preference set to report.
|
||||
|
||||
Returns:
|
||||
The listing.
|
||||
"""
|
||||
from ..ui.report import Listing
|
||||
|
||||
return Listing(
|
||||
key="preferences",
|
||||
columns=_columns(),
|
||||
rows=[_row(prefs, spec) for spec in PREFERENCES],
|
||||
order=("PREFERENCE", "VALUE", "DEFAULT", "DESCRIPTION"),
|
||||
)
|
||||
|
||||
|
||||
def _one(prefs: Preferences, spec: PrefSpec) -> Facts:
|
||||
"""One preference: the bare value plain, the whole setting object in the document."""
|
||||
from ..ui.report import BARE, Facts
|
||||
|
||||
return Facts(
|
||||
key="preference",
|
||||
fields=_columns(),
|
||||
values=_row(prefs, spec),
|
||||
shape=BARE,
|
||||
bare="value",
|
||||
)
|
||||
|
||||
|
||||
def _written(applied: list[dict[str, Any]], prefs: Preferences) -> Facts:
|
||||
"""What ``set`` or ``reset`` changed — nothing plain, a record for whoever wants one.
|
||||
|
||||
The plain face says it in the exit status and in the acknowledgement on stderr, which
|
||||
is the whole answer for a person. A caller managing a machine's configuration wants to
|
||||
know *what moved*, and had no way to ask.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import SILENT, Column, Facts
|
||||
|
||||
return Facts(
|
||||
key="applied",
|
||||
fields=(
|
||||
fields.integer("changes", "changes"),
|
||||
Column(key="applied"),
|
||||
fields.word("path", "path"),
|
||||
),
|
||||
values={
|
||||
"changes": len(applied),
|
||||
"applied": applied,
|
||||
"path": str(prefs.path) if prefs.path else None,
|
||||
},
|
||||
shape=SILENT,
|
||||
)
|
||||
+149
-37
@@ -1,3 +1,4 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The Trophy case tool: the record-setting walks the trace tools have turned up.
|
||||
|
||||
The menu face opens the browser (:mod:`meshterm.ui.records_screen`): every discipline's
|
||||
@@ -13,16 +14,23 @@ The CLI face prints the stored boards: ``meshterm records`` reads the tables so
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from ..context import AppContext
|
||||
from ..services.records import CATEGORIES, CATEGORY_BY_ID
|
||||
from ..core import exitcodes
|
||||
from ..services.records import CATEGORIES, CATEGORY_BY_ID, Category
|
||||
from ..ui.script import NONE
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..persistence.repository import DiscoveredPath
|
||||
from ..ui.fields import NodeRef
|
||||
from ..ui.report import Column
|
||||
|
||||
|
||||
@register
|
||||
class TrophyCaseTool(Tool):
|
||||
@@ -72,7 +80,7 @@ class TrophyCaseTool(Tool):
|
||||
def _show_records(
|
||||
self, ctx: AppContext, *, category: str | None, width: int | None
|
||||
) -> ToolResult:
|
||||
"""Print the stored record boards (no device, no transmissions).
|
||||
"""State the stored record boards (no device, no transmissions).
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
@@ -82,42 +90,62 @@ class TrophyCaseTool(Tool):
|
||||
Returns:
|
||||
A :class:`ToolResult` with the record count.
|
||||
"""
|
||||
from ..services import trace_runner
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
wanted = [CATEGORY_BY_ID[category]] if category in CATEGORY_BY_ID else CATEGORIES
|
||||
total = 0
|
||||
# Names come from stored history alone — no contacts, because this command reads
|
||||
# the database and must work with no radio attached at all.
|
||||
resolve = trace_runner.make_node_resolver(None, ctx.repo.node_names())
|
||||
|
||||
rows: list[dict] = []
|
||||
for cat in wanted:
|
||||
rows = ctx.repo.discoveries(cat.id, width_bytes=width)
|
||||
if not rows:
|
||||
continue
|
||||
rows.sort(key=lambda r: (r.width_bytes, r.score if cat.ascending else -r.score))
|
||||
table = Table(
|
||||
title=f"{cat.icon} {cat.title} — {cat.description}",
|
||||
title_justify="left",
|
||||
)
|
||||
table.add_column("width", justify="right")
|
||||
table.add_column("score", justify="right")
|
||||
table.add_column("route")
|
||||
table.add_column("recorded")
|
||||
table.add_column("version")
|
||||
for record in rows:
|
||||
score = cat.format_score(record.score)
|
||||
if cat.id == "long_haul" and not record.stats.get("km_complete", True):
|
||||
score = "≥ " + score
|
||||
table.add_row(
|
||||
f"{record.width_bytes} B",
|
||||
score,
|
||||
record.spec,
|
||||
record.discovered_at.astimezone().strftime("%b %d %Y %H:%M"),
|
||||
record.app_version,
|
||||
found = ctx.repo.discoveries(cat.id, width_bytes=width)
|
||||
found.sort(key=lambda r: (r.width_bytes, r.score if cat.ascending else -r.score))
|
||||
for record in found:
|
||||
floor = cat.id == "long_haul" and not record.stats.get("km_complete", True)
|
||||
rows.append(
|
||||
{
|
||||
"category": cat.id,
|
||||
"hash_bytes": record.width_bytes,
|
||||
"score": _Score(record.score, cat, floor),
|
||||
"unit": cat.unit,
|
||||
"lower_bound": floor,
|
||||
"recorded_at": record.discovered_at,
|
||||
"version": record.app_version,
|
||||
"path": record.spec or None,
|
||||
"route": _route(record, resolve),
|
||||
}
|
||||
)
|
||||
total += 1
|
||||
ctx.ui.show(table)
|
||||
if total == 0:
|
||||
ctx.ui.show(
|
||||
Text("no records yet — every trace that comes home is scored here", style="muted")
|
||||
)
|
||||
listing = Listing(
|
||||
key="records",
|
||||
columns=(
|
||||
fields.word("category", "CATEGORY"),
|
||||
# The contract's word for the concept. The plain heading stays WIDTH,
|
||||
# which is what a column of 1/2/4 reads as.
|
||||
fields.integer("hash_bytes", "WIDTH"),
|
||||
_score_column(),
|
||||
fields.word("unit", "UNIT"),
|
||||
# `>=273.0` is not a number, and a consumer comparing scores must be able
|
||||
# to see the qualifier without string-matching a prefix off its own data.
|
||||
fields.hidden("lower_bound"),
|
||||
fields.when("recorded_at", "RECORDED"),
|
||||
fields.word("version", "VERSION"),
|
||||
# The transmitted spec, hop-aligned with the route beside it. It has no
|
||||
# column — a route line already runs off the right — but it is what a
|
||||
# caller re-walks with, so the document carries it.
|
||||
fields.hidden("path"),
|
||||
fields.route(),
|
||||
),
|
||||
rows=rows,
|
||||
order=("CATEGORY", "WIDTH", "SCORE", "UNIT", "RECORDED", "VERSION", "ROUTE"),
|
||||
)
|
||||
return ToolResult(
|
||||
summary={"records": total},
|
||||
message=f"{total} record{'s' if total != 1 else ''} stored",
|
||||
summary={"records": len(rows)},
|
||||
message=f"{len(rows)} record{'s' if len(rows) != 1 else ''} stored",
|
||||
report=(listing,),
|
||||
exit_code=exitcodes.OK if rows else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
@@ -146,7 +174,91 @@ class TrophyCaseTool(Tool):
|
||||
) -> None:
|
||||
tool_params: dict[str, Any] = {}
|
||||
if category:
|
||||
# An unknown id used to select *every* discipline, so a misspelled
|
||||
# `--category long_hual` came back as "no records" — exit 5, which is
|
||||
# exactly what a real empty result looks like. A closed set refuses.
|
||||
if category not in CATEGORY_BY_ID:
|
||||
choices = ", ".join(CATEGORY_BY_ID)
|
||||
raise typer.BadParameter(
|
||||
f"--category must be one of: {choices} (got {category!r})"
|
||||
)
|
||||
tool_params["category"] = category
|
||||
if width:
|
||||
tool_params["width"] = width
|
||||
run_tool_command(self, tool_params)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Score:
|
||||
"""A record's score, and the one thing about it a bare number cannot carry.
|
||||
|
||||
``format_score`` writes ``273.0 km`` for a reader; the unit is its own column here so
|
||||
the ``SCORE`` column is one comparable number per row. What has to travel *with* the
|
||||
number is the ``>=`` on a Longest-distance walk whose kilometre total is incomplete —
|
||||
some hop on it has no known position, so the distance is a floor rather than a
|
||||
measurement, and dropping the qualifier would turn a lower bound into a claim.
|
||||
|
||||
The plain face wears the prefix; the document keeps the number a number and says
|
||||
``lower_bound`` beside it, because a consumer comparing scores cannot be asked to
|
||||
string-match a prefix off the front of its own data.
|
||||
"""
|
||||
|
||||
value: float
|
||||
category: Category
|
||||
lower_bound: bool
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""The score as the SCORE column prints it, qualifier and all."""
|
||||
if self.category.unit == "nodes":
|
||||
drawn = str(int(self.value))
|
||||
elif self.category.unit == "dB":
|
||||
drawn = f"{self.value:+.1f}"
|
||||
else:
|
||||
drawn = f"{self.value:.1f}"
|
||||
return f">={drawn}" if self.lower_bound else drawn
|
||||
|
||||
|
||||
def _score_column() -> Column:
|
||||
"""The SCORE column: the qualified figure plain, the bare number in the document."""
|
||||
from ..ui.report import Column, Lane
|
||||
|
||||
return Column(
|
||||
key="score",
|
||||
lanes=(
|
||||
Lane(
|
||||
header="SCORE",
|
||||
render=lambda s: s.text if s is not None else NONE,
|
||||
align="right",
|
||||
),
|
||||
),
|
||||
json=lambda s: None if s is None else s.value,
|
||||
)
|
||||
|
||||
|
||||
def _route(record: DiscoveredPath, resolve: Callable[[str], str | None]) -> list[NodeRef]:
|
||||
"""A record's walk as the shared route shape.
|
||||
|
||||
Each hop is named where history knows the node and carries the hash it was actually
|
||||
transmitted at — the spec's own hop, at this record's width — so the line can be read
|
||||
against the ``path`` a re-walk would take. Where the two lists disagree in length the
|
||||
node id stands in, which is the only identity that hop has.
|
||||
|
||||
Our own node never appears in a record's walk: a discovery is scored on what it
|
||||
reached, and the two ends of it are somebody else's.
|
||||
|
||||
Args:
|
||||
record: The record whose walk to render.
|
||||
resolve: Maps a node id to a friendly name, or ``None`` when unknown.
|
||||
|
||||
Returns:
|
||||
The hops in propagation order.
|
||||
"""
|
||||
from ..ui.fields import NodeRef
|
||||
|
||||
spec = record.spec.split(",") if record.spec else []
|
||||
hops: list[NodeRef] = []
|
||||
for index, node in enumerate(record.route):
|
||||
addressed = spec[index] if index < len(spec) else node
|
||||
hops.append(NodeRef(name=resolve(node), hash=addressed))
|
||||
return hops
|
||||
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user