Compare commits
No files matched your search
@@ -0,0 +1,136 @@
|
||||
---
|
||||
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.
|
||||
|
||||
## 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. Report the release
|
||||
URL and whether the notes came out of the changelog or fell back to "No changelog entry".
|
||||
If a build fails, the tag is already public: fix forward with a new patch version rather
|
||||
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())
|
||||
+290
-1
@@ -17,6 +17,289 @@ allowed to change behaviour, not just add to it.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
@@ -235,7 +518,13 @@ 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.1...HEAD
|
||||
[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
|
||||
|
||||
@@ -187,8 +187,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 +264,12 @@ 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 a path that has
|
||||
finished never wears one. It ends **square** — nothing appended, the last chip's own pad
|
||||
is the edge — and opens square the same way; a full Nerd Font rounds both ends into a
|
||||
lozenge instead (`POWERLINE_ROUND_*`, drawn only where `termfont.powerline_full`). A
|
||||
*wrapped* line the path outruns still closes on the point: there the route really does
|
||||
go on, which is the cue arrow mode spells with a trailing `→`.
|
||||
- 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 +336,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
|
||||
|
||||
@@ -68,14 +68,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.6-windows-x64.exe
|
||||
.\meshterm-0.2.8-windows-x64.exe
|
||||
```
|
||||
|
||||
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
|
||||
|
||||
```bash
|
||||
chmod +x meshterm-0.2.6-*
|
||||
./meshterm-0.2.6-*
|
||||
chmod +x meshterm-0.2.8-*
|
||||
./meshterm-0.2.8-*
|
||||
```
|
||||
|
||||
### A word about terminals, on Windows
|
||||
@@ -91,20 +91,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 +113,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.6-*` and run it again.
|
||||
> `xattr -d com.apple.quarantine meshterm-0.2.8-*` and run it again.
|
||||
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
|
||||
>
|
||||
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
|
||||
@@ -124,10 +123,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.6-* # macOS, Linux
|
||||
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.2.8-* # macOS, Linux
|
||||
> ```
|
||||
> ```powershell
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.6-windows-x64.exe
|
||||
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.8-windows-x64.exe
|
||||
> ```
|
||||
>
|
||||
> Same variable if you run two radios and want them kept apart.
|
||||
@@ -199,7 +198,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 +214,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
|
||||
@@ -240,7 +239,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 +254,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 +275,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 +287,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
|
||||
|
||||
@@ -332,6 +386,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.
|
||||
|
||||
|
||||
+1635
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
__version__ = "0.2.6"
|
||||
__version__ = "0.3.1"
|
||||
__author__ = "Jean-Pierre Martineau"
|
||||
|
||||
#: The year MeshTerm was first published; the copyright span starts here.
|
||||
|
||||
@@ -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!
|
||||
|
||||
|
||||
+311
-98
@@ -15,9 +15,10 @@ from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from typer.core import TyperGroup
|
||||
|
||||
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
|
||||
@@ -31,48 +32,90 @@ 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,6 +123,7 @@ app = typer.Typer(
|
||||
"Supports USB, Bluetooth and TCP companion connection. "
|
||||
"For Windows, macOS and Linux."
|
||||
),
|
||||
epilog=EXIT_STATUS_EPILOG,
|
||||
)
|
||||
|
||||
# The context built by the callback and consumed by subcommands within one process.
|
||||
@@ -109,6 +153,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,
|
||||
@@ -127,7 +174,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 +196,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")
|
||||
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 +258,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 +270,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,
|
||||
@@ -222,16 +312,21 @@ def main_callback(
|
||||
|
||||
|
||||
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 +336,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 +367,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 +542,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 +584,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 +621,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 +744,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 +766,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
|
||||
|
||||
|
||||
|
||||
+42
-6
@@ -30,6 +30,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
|
||||
@@ -91,7 +92,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 +128,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)
|
||||
@@ -439,6 +450,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 +498,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 +513,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 +547,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 +566,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
|
||||
|
||||
@@ -96,6 +96,14 @@ def parse_value(spec: SettingSpec, raw: Any, snapshot: dict | None = None) -> An
|
||||
"""
|
||||
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)}"
|
||||
|
||||
@@ -11,6 +11,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 +110,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 +121,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 +140,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 +174,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 +204,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 +222,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 +245,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 +315,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))
|
||||
@@ -0,0 +1,73 @@
|
||||
"""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)",
|
||||
}
|
||||
@@ -109,6 +109,10 @@ class PrefSpec:
|
||||
return f"{self.help} (next launch)" if self.relaunch else self.help
|
||||
|
||||
|
||||
#: 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] = {
|
||||
**{h: f"{h} h" for h in SILENCE_CHOICES_H},
|
||||
@@ -122,6 +126,20 @@ _SILENCE_CHOICES: dict[int, str] = {
|
||||
#: need to disagree with its platform.
|
||||
_WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
|
||||
|
||||
#: 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",
|
||||
}
|
||||
|
||||
#: 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.
|
||||
@@ -131,7 +149,7 @@ _WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
|
||||
#: 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",
|
||||
}
|
||||
|
||||
@@ -284,6 +302,19 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
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,15 +334,31 @@ 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,
|
||||
),
|
||||
# --- Diagnostics ---------------------------------------------------------------
|
||||
|
||||
@@ -119,10 +119,48 @@ def reopen() -> bool:
|
||||
# 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()],
|
||||
[
|
||||
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}"
|
||||
@@ -101,8 +101,14 @@ def configure_logging(
|
||||
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)
|
||||
|
||||
@@ -19,7 +19,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 +128,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.
|
||||
|
||||
@@ -94,6 +94,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.
|
||||
@@ -276,7 +277,9 @@ class DeviceState:
|
||||
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
|
||||
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.ui.channels.
|
||||
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.
|
||||
@@ -284,10 +287,16 @@ class DeviceState:
|
||||
if self._channels is None:
|
||||
async with self._channels_lock:
|
||||
if self._channels is None:
|
||||
from ..ui.channels import read_channel_slots
|
||||
from ..ui.channels import probe_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 +382,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).
|
||||
|
||||
+91
-3
@@ -26,6 +26,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 +68,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 +81,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 +157,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
|
||||
+45
-5
@@ -12,10 +12,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 +27,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):
|
||||
@@ -123,7 +150,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 +166,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)
|
||||
|
||||
+159
-55
@@ -12,16 +12,18 @@ 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 +31,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 +54,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 +62,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 --------------------------------------------------------------------
|
||||
|
||||
@@ -88,23 +90,11 @@ class ChannelsTool(Tool):
|
||||
|
||||
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 +109,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})
|
||||
self._show_qr(ctx, 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 +124,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 +140,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."""
|
||||
"""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 ..ui.channels 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,
|
||||
)
|
||||
self._show_qr(ctx, 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.
|
||||
@@ -176,19 +183,30 @@ class ChannelsTool(Tool):
|
||||
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
|
||||
def _show_qr(ctx: AppContext, 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. Piped into a file it is a block of
|
||||
block characters wrapped around the one thing that is actually the answer, so a
|
||||
scripted run states the link and nothing else.
|
||||
"""
|
||||
from ..ui.qr import qr_text
|
||||
from ..ui.surface import TuiUi
|
||||
|
||||
if isinstance(ctx.ui, TuiUi):
|
||||
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register the ``channels`` subcommand group.
|
||||
@@ -198,7 +216,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 +265,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,
|
||||
)
|
||||
+412
-99
@@ -15,29 +15,36 @@ 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,
|
||||
channel_hash,
|
||||
channel_identity,
|
||||
is_public_channel,
|
||||
)
|
||||
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 import renderers
|
||||
from ..ui.chat import _MENTION, _split_channel_sender
|
||||
from ..ui.fields import ChannelRef, NodeRef
|
||||
from ..ui.menus import Lane, column_header, fit_cells, section_heading
|
||||
from ..ui.theme import name_style
|
||||
from ..ui.tui import Choice, DeleteRequest, Separator
|
||||
@@ -46,6 +53,7 @@ from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..ui.channels import ChannelSlot
|
||||
from ..ui.report import Facts, Listing
|
||||
|
||||
#: How many recent messages ``chat history`` prints by default.
|
||||
_HISTORY_LIMIT = 50
|
||||
@@ -121,7 +129,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 +190,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 +277,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 +323,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 +344,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 +365,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 +407,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 +424,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(
|
||||
@@ -605,25 +649,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 +738,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 +753,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 +766,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 +779,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 +797,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 +805,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.
|
||||
@@ -726,21 +836,28 @@ def _title(
|
||||
_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 +866,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()
|
||||
@@ -767,7 +888,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
|
||||
|
||||
|
||||
@@ -800,25 +920,218 @@ def _ago(when: Any) -> str:
|
||||
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,
|
||||
)
|
||||
+435
-70
@@ -12,23 +12,27 @@ management, factory reset) through the same 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):
|
||||
@@ -70,11 +74,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 +95,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,7 +210,7 @@ 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
|
||||
@@ -217,32 +226,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 +280,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 +290,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(_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 +353,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 +379,268 @@ 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."""
|
||||
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)
|
||||
|
||||
|
||||
def _share_contact(ctx: AppContext, snapshot: dict) -> Facts:
|
||||
"""State this node's contact card — with the QR code drawn in the menu.
|
||||
|
||||
The QR is for a phone pointed at the screen. Redirected into a file it is a block of
|
||||
block characters around the one thing that is the answer, so a scripted run states the
|
||||
link by itself. There is no machine face for the code either, and never will be: it is
|
||||
a second rendering of ``url``.
|
||||
"""
|
||||
from rich.console import Group
|
||||
from rich.text import Text
|
||||
|
||||
from ..ui import fields
|
||||
from ..ui.config_editor import contact_share_url
|
||||
from ..ui.qr import qr_text
|
||||
from ..ui.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):
|
||||
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
|
||||
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 +659,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,25 @@
|
||||
"""The ``contacts`` tool: list this node and the contacts it knows about."""
|
||||
"""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 +43,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 +74,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 +98,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 +106,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_pkts
|
||||
|
||||
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_pkts(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"),
|
||||
)
|
||||
+175
-60
@@ -17,14 +17,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 +101,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 +124,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 +154,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 +169,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 +197,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 +214,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 +256,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,
|
||||
)
|
||||
+103
-78
@@ -11,27 +11,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 import exitcodes
|
||||
from ..core.discovery import 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 +42,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.
|
||||
@@ -62,74 +58,25 @@ class DevicesTool(Tool):
|
||||
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 +86,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,
|
||||
)
|
||||
+178
-23
@@ -3,7 +3,9 @@
|
||||
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 +15,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 +52,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 +75,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 +98,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 +135,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 +191,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:
|
||||
|
||||
+24
-134
@@ -5,11 +5,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,9 +18,6 @@ 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
|
||||
@@ -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,90 +99,22 @@ 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]:
|
||||
@@ -285,35 +204,6 @@ async def _self_marker(ctx: AppContext) -> MapMarker | 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:
|
||||
|
||||
+166
-55
@@ -18,17 +18,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 +78,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 +98,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 +165,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,
|
||||
)
|
||||
+168
-10
@@ -17,14 +17,24 @@ 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):
|
||||
@@ -73,31 +83,50 @@ class PreferencesTool(Tool):
|
||||
cleanly by the CLI rather than as a traceback.
|
||||
"""
|
||||
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 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 +135,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 +149,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 +186,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,
|
||||
)
|
||||
+148
-37
@@ -13,16 +13,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 +79,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 +89,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 +173,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
|
||||
@@ -14,15 +14,19 @@ per invocation, the trace tool's rule.
|
||||
|
||||
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.connection import DeviceCommandError
|
||||
from ..core.models import LoginResult
|
||||
from ..core.models import NODE_TYPE_LABELS, Contact, LoginResult
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Facts
|
||||
|
||||
|
||||
@register
|
||||
class RepeaterAdminTool(Tool):
|
||||
@@ -67,12 +71,16 @@ class RepeaterAdminTool(Tool):
|
||||
contacts = await device.get_contacts()
|
||||
name = str(params["node"])
|
||||
node = next((c for c in contacts if c.name == name), None)
|
||||
# Both of these are the *argument* being wrong, and nothing has gone out over
|
||||
# the air when either fires — so they are usage errors (2), not device failures
|
||||
# (4), whose promise to a caller is that a retry is worth trying. `tx-optimize`
|
||||
# already answered the identical question this way for the identical condition.
|
||||
if node is None:
|
||||
raise DeviceCommandError(f"unknown contact: {name!r}")
|
||||
raise typer.BadParameter(f"unknown contact: {name!r}")
|
||||
|
||||
password = params.get("password") or ctx.admin_store.get(node)
|
||||
if not password:
|
||||
raise DeviceCommandError(
|
||||
raise typer.BadParameter(
|
||||
f"no admin password for {name!r}; pass --password or run the "
|
||||
"interactive flow once to store it."
|
||||
)
|
||||
@@ -91,11 +99,13 @@ class RepeaterAdminTool(Tool):
|
||||
command = str(params["command"])
|
||||
ctx.remote_store.append_history(node, command)
|
||||
reply = await device.send_remote_command(node, command, timeout=10.0)
|
||||
if reply is None:
|
||||
ctx.ui.note("[warn]no reply — the command may still have landed[/warn]")
|
||||
else:
|
||||
ctx.ui.note(reply)
|
||||
return ToolResult(summary={"node": name, "command": command, "replied": reply is not None})
|
||||
return ToolResult(
|
||||
report=(_answered(node, command, reply),),
|
||||
summary={"node": name, "command": command, "replied": reply is not None},
|
||||
# Silence is not failure — the command may well have landed — but there is
|
||||
# nothing to report, and a script waiting on output should know which it got.
|
||||
exit_code=exitcodes.OK if reply is not None else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
"""Register the ``repeater-admin`` subcommand.
|
||||
@@ -117,3 +127,41 @@ class RepeaterAdminTool(Tool):
|
||||
if password is not None:
|
||||
tool_params["password"] = password
|
||||
run_tool_command(self, tool_params)
|
||||
|
||||
|
||||
def _answered(node: Contact, command: str, reply: str | None) -> Facts:
|
||||
"""What a remote node said back.
|
||||
|
||||
``reply`` is the node's own text, whole and verbatim — the plain face prints it bare
|
||||
(as text and never as markup, so a reply holding a square bracket is a reply holding a
|
||||
square bracket), and the document carries it raw, multi-line where the node sent
|
||||
several lines.
|
||||
|
||||
It is deliberately **not parsed**. MeshTerm does not know the remote node's CLI
|
||||
grammar, and a document that pretended to would be inventing structure a consumer
|
||||
would then depend on.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.fields import NodeRef
|
||||
from ..ui.report import BARE, Facts
|
||||
|
||||
return Facts(
|
||||
key="remote",
|
||||
fields=(
|
||||
fields.node("node", lanes=()),
|
||||
fields.hidden("command"),
|
||||
fields.word("reply", "reply"),
|
||||
),
|
||||
values={
|
||||
"node": NodeRef(
|
||||
name=node.name,
|
||||
key=(node.public_key or "").lower() or None,
|
||||
hash=(node.key_prefix or "").lower() or None,
|
||||
type=NODE_TYPE_LABELS.get(node.node_type),
|
||||
),
|
||||
"command": command,
|
||||
"reply": reply,
|
||||
},
|
||||
shape=BARE,
|
||||
bare="reply",
|
||||
)
|
||||
+145
-19
@@ -32,7 +32,14 @@ from typing import TYPE_CHECKING, Any
|
||||
import typer
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core.models import LOCAL_DEVICE_LABEL, PATH_TRACE_TARGET, Contact, TraceStats
|
||||
from ..core import exitcodes
|
||||
from ..core.models import (
|
||||
LOCAL_DEVICE_LABEL,
|
||||
PATH_TRACE_TARGET,
|
||||
Contact,
|
||||
TraceResult,
|
||||
TraceStats,
|
||||
)
|
||||
from ..services import trace_runner
|
||||
from ..ui.widgets import stats_panel
|
||||
from .base import Tool, ToolResult, register
|
||||
@@ -397,6 +404,8 @@ async def _trace_once_cli(
|
||||
Returns:
|
||||
A :class:`ToolResult` with the trace's outcome.
|
||||
"""
|
||||
from ..ui.surface import TuiUi
|
||||
|
||||
is_walk = target == PATH_TRACE_TARGET
|
||||
device = await ctx.device()
|
||||
|
||||
@@ -419,9 +428,6 @@ async def _trace_once_cli(
|
||||
path: str | None = None
|
||||
if path_spec:
|
||||
path = trace_runner.parse_trace_path(path_spec, contacts)
|
||||
ctx.ui.note(f"[muted]forcing path:[/muted] [brand]{path}[/brand]")
|
||||
else:
|
||||
ctx.ui.note("[muted]path: auto (device-routed)[/muted]")
|
||||
|
||||
with ctx.ui.progress("trace") as progress:
|
||||
task = progress.add_task("walking the path" if is_walk else f"tracing {target}", total=1)
|
||||
@@ -429,18 +435,22 @@ async def _trace_once_cli(
|
||||
ctx.repo.record_trace(run_id, result)
|
||||
progress.advance(task)
|
||||
|
||||
# The stats panel renders route + per-hop readings for the single trace (its
|
||||
# medians collapse to the readings themselves).
|
||||
stats = TraceStats.from_traces(target, [result])
|
||||
ctx.ui.show(
|
||||
stats_panel(
|
||||
stats,
|
||||
device_label,
|
||||
resolve,
|
||||
route=result if result.success else None,
|
||||
device_hash=device_hash,
|
||||
if isinstance(ctx.ui, TuiUi):
|
||||
# The stats panel renders route + per-hop readings for the single trace (its
|
||||
# medians collapse to the readings themselves).
|
||||
ctx.ui.show(
|
||||
stats_panel(
|
||||
stats,
|
||||
device_label,
|
||||
resolve,
|
||||
route=result if result.success else None,
|
||||
device_hash=device_hash,
|
||||
)
|
||||
)
|
||||
)
|
||||
report = None
|
||||
if not isinstance(ctx.ui, TuiUi):
|
||||
report = _trace_report(result, target, path, device_label, resolve, device_hash)
|
||||
|
||||
summary: dict[str, Any] = {
|
||||
"target": target,
|
||||
@@ -453,15 +463,131 @@ async def _trace_once_cli(
|
||||
summary["path"] = path
|
||||
via = f" via [brand]{path}[/brand]" if path else ""
|
||||
if is_walk:
|
||||
if result.success:
|
||||
message = f"[ok]✓[/ok] the path came home{via}"
|
||||
else:
|
||||
message = f"[err]✗[/err] no reply{via}"
|
||||
message = (
|
||||
f"[ok]✓[/ok] the path came home{via}"
|
||||
if result.success
|
||||
else f"[err]✗[/err] no reply{via}"
|
||||
)
|
||||
elif result.success:
|
||||
message = f"[ok]✓[/ok] traced [brand]{target}[/brand]{via}"
|
||||
else:
|
||||
message = f"[err]✗[/err] no reply from [brand]{target}[/brand]{via}"
|
||||
return ToolResult(summary=summary, message=message)
|
||||
# A trace that never came home is not a failure of the command — the radio did
|
||||
# transmit and the walk did run. It is a walk with nothing to report, which is what
|
||||
# NO_RESULT says, and it is the answer a script most often branches on.
|
||||
return ToolResult(
|
||||
summary=summary,
|
||||
message=message,
|
||||
report=report,
|
||||
exit_code=exitcodes.OK if result.success else exitcodes.NO_RESULT,
|
||||
)
|
||||
|
||||
|
||||
def _trace_report(
|
||||
result: TraceResult,
|
||||
target: str,
|
||||
path: str | None,
|
||||
device_label: str,
|
||||
resolve: Any,
|
||||
device_hash: str | None,
|
||||
) -> tuple:
|
||||
"""State one trace: the walk's outcome, then its per-hop readings.
|
||||
|
||||
Two blocks. The first is what the walk *did* — one fact per line, ``route`` among them
|
||||
as a drawn line, every node named and carrying the hash it was addressed by. The
|
||||
second is a record per hop, and it names its two ends by that same **hash** rather than
|
||||
repeating the names: the route line above is where the names are, and the hash is what
|
||||
joins the two blocks (it is what carries a node's identity here, the way its colour
|
||||
does on a screen). The document embeds the whole node at both ends instead, because a
|
||||
structural join needs no key and each edge should be readable on its own.
|
||||
|
||||
Our own node is a hop like any other, named and hashed. The menu draws it as ``★``
|
||||
because a reader never has to be told which node is theirs; this line is as often read
|
||||
out of a file by somebody who was not at the prompt when it ran.
|
||||
|
||||
Args:
|
||||
result: The trace that ran.
|
||||
target: The label it was addressed to.
|
||||
path: The forced route as hex, or ``None`` when the device routed it.
|
||||
device_label: Our own node's name, at both ends of the walk.
|
||||
resolve: Maps a hop hash to a friendly name when known.
|
||||
device_hash: Our own public key, so our ends carry a hash like every other hop.
|
||||
|
||||
Returns:
|
||||
The report's blocks.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.fields import NodeRef
|
||||
from ..ui.report import Facts, Listing
|
||||
|
||||
hash_bytes = result.path_hash_bytes
|
||||
|
||||
def short(value: str | None) -> str | None:
|
||||
"""A hash at the width this trace addressed nodes by."""
|
||||
if not value:
|
||||
return None
|
||||
raw = value.lower().removeprefix("0x")
|
||||
return raw[: hash_bytes * 2] if hash_bytes else raw
|
||||
|
||||
def node(label: str) -> NodeRef:
|
||||
"""One end of a hop as the shared node shape."""
|
||||
if not label or label == device_label:
|
||||
return NodeRef(name=device_label, hash=short(device_hash), is_self=True)
|
||||
return NodeRef(name=resolve(label) or None, hash=short(label))
|
||||
|
||||
edges = result.edges(device_label)
|
||||
route = None
|
||||
if edges:
|
||||
route = [node(edges[0].origin)] + [node(edge.destination) for edge in edges]
|
||||
|
||||
facts = Facts(
|
||||
key="trace",
|
||||
fields=(
|
||||
fields.word("target", "target"),
|
||||
fields.spec(),
|
||||
fields.flag("success", "success"),
|
||||
fields.integer("hops", "hops"),
|
||||
fields.snr("min_snr_db", "min_snr_db"),
|
||||
fields.decimal("rtt_ms", "rtt_ms", ".0f"),
|
||||
# New surface on the machine face: the model has always carried the TX power
|
||||
# the walk ran at, and the plain facts block has no room for it.
|
||||
fields.hidden("tx_dbm"),
|
||||
# What `hash` means on this walk's nodes, so a consumer can join two traces
|
||||
# that addressed the same node at different widths.
|
||||
fields.hidden("hash_bytes"),
|
||||
fields.route("route", "route"),
|
||||
),
|
||||
values={
|
||||
"target": target if target != PATH_TRACE_TARGET else None,
|
||||
"path": path,
|
||||
"success": result.success,
|
||||
"hops": result.hop_count if result.success else None,
|
||||
"min_snr_db": result.min_snr,
|
||||
"rtt_ms": result.round_trip_ms,
|
||||
"tx_dbm": result.tx_power,
|
||||
"hash_bytes": hash_bytes,
|
||||
"route": route,
|
||||
},
|
||||
)
|
||||
hops = Listing(
|
||||
key="edges",
|
||||
columns=(
|
||||
fields.integer("index", "HOP"),
|
||||
fields.node("from", lanes=(("hash", "FROM"),)),
|
||||
fields.node("to", lanes=(("hash", "TO"),)),
|
||||
fields.snr("snr_db", "SNR_DB"),
|
||||
),
|
||||
rows=[
|
||||
{
|
||||
"index": edge.index,
|
||||
"from": node(edge.origin),
|
||||
"to": node(edge.destination),
|
||||
"snr_db": edge.snr,
|
||||
}
|
||||
for edge in edges
|
||||
],
|
||||
)
|
||||
return (facts, hops)
|
||||
|
||||
|
||||
def _last_traced_by_name(
|
||||
|
||||
+116
-10
@@ -22,8 +22,9 @@ from typing import Any
|
||||
import typer
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core import exitcodes
|
||||
from ..core.connection import DeviceCommandError
|
||||
from ..core.models import Contact, LoginResult
|
||||
from ..core.models import NODE_TYPE_LABELS, Contact, LoginResult, TxOptResult
|
||||
from ..services import trace_runner, tx_optimizer
|
||||
from ..ui.widgets import tx_opt_summary, tx_opt_table
|
||||
from .base import Tool, ToolResult, register
|
||||
@@ -257,6 +258,8 @@ class TxOptimizeTool(Tool):
|
||||
Returns:
|
||||
A :class:`ToolResult` with the optimum and any chart path.
|
||||
"""
|
||||
from ..ui.surface import TuiUi
|
||||
|
||||
run_id = params["_run_id"]
|
||||
device = await ctx.device()
|
||||
contacts = await device.get_contacts()
|
||||
@@ -268,14 +271,14 @@ class TxOptimizeTool(Tool):
|
||||
|
||||
samples = max(1, min(MAX_SAMPLES, int(params.get("samples", 3))))
|
||||
if int(params.get("samples", 3)) > MAX_SAMPLES:
|
||||
ctx.ui.note(f"[warn]capping at {MAX_SAMPLES} traces per level[/warn]")
|
||||
ctx.ui.ack(f"[warn]capping at {MAX_SAMPLES} traces per level[/warn]")
|
||||
|
||||
ctx.ui.note(
|
||||
ctx.ui.ack(
|
||||
f"[muted]logging in to[/muted] [brand]{admin_node.name}[/brand] [muted]…[/muted]"
|
||||
)
|
||||
await self._login(ctx, admin_node, params)
|
||||
|
||||
ctx.ui.note(
|
||||
ctx.ui.ack(
|
||||
f"[muted]tuning[/muted] [brand]{admin_node.name}[/brand] "
|
||||
f"[muted]→ target[/muted] [brand]{target_label}[/brand] "
|
||||
f"[muted]via {path}[/muted]"
|
||||
@@ -316,8 +319,12 @@ class TxOptimizeTool(Tool):
|
||||
if result.applied:
|
||||
ctx.log.info("set TX power %s on %s", result.best_tx, admin_node.name)
|
||||
|
||||
ctx.ui.show(tx_opt_table(result))
|
||||
ctx.ui.show(tx_opt_summary(result))
|
||||
report = None
|
||||
if isinstance(ctx.ui, TuiUi):
|
||||
ctx.ui.show(tx_opt_table(result))
|
||||
ctx.ui.show(tx_opt_summary(result))
|
||||
else:
|
||||
report = _sweep_report(result, path, admin_node, contacts)
|
||||
|
||||
if no_result:
|
||||
restored = (
|
||||
@@ -335,6 +342,10 @@ class TxOptimizeTool(Tool):
|
||||
)
|
||||
|
||||
return ToolResult(
|
||||
report=report,
|
||||
# No level got a trace through, so nothing was measured and nothing was tuned:
|
||||
# the sweep ran and has nothing to report.
|
||||
exit_code=exitcodes.NO_RESULT if no_result else exitcodes.OK,
|
||||
summary={
|
||||
"target": result.target,
|
||||
"admin_node": result.admin_node,
|
||||
@@ -354,6 +365,13 @@ class TxOptimizeTool(Tool):
|
||||
) -> str:
|
||||
"""Find the admin password: explicit flag, remembered, or an interactive prompt.
|
||||
|
||||
The question this asks is **"is anyone there to answer?"**, and it used to ask
|
||||
``--json`` instead — a flag about what the output looks like, which cannot answer
|
||||
it and answered it wrong in both directions: with the flag a piped run failed
|
||||
cleanly, and without it a scheduled run on a terminal sat waiting for a password
|
||||
nobody was going to type. ``ctx.interactive`` reads stdin, which is where the
|
||||
answer actually is.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
admin_node: The node we're about to log in to.
|
||||
@@ -363,13 +381,15 @@ class TxOptimizeTool(Tool):
|
||||
The password to log in with.
|
||||
|
||||
Raises:
|
||||
DeviceCommandError: If no password is available and we can't prompt (JSON mode).
|
||||
typer.BadParameter: If no password is available and nobody can be asked for
|
||||
one. A usage error rather than a device failure: nothing was transmitted,
|
||||
and no retry helps until the command line carries the password.
|
||||
"""
|
||||
password = params.get("password") or ctx.admin_store.get(admin_node)
|
||||
if password:
|
||||
return str(password)
|
||||
if ctx.json_output:
|
||||
raise DeviceCommandError(
|
||||
if not ctx.interactive:
|
||||
raise typer.BadParameter(
|
||||
f"no admin password for {admin_node.name!r}; pass --password or run once "
|
||||
"interactively to store it."
|
||||
)
|
||||
@@ -377,7 +397,7 @@ class TxOptimizeTool(Tool):
|
||||
# sweep screen), so the surface here is PlainUi — no screen stack, no floating.
|
||||
entered = await ctx.ui.text(f"Admin password for {admin_node.name}:", password=True)
|
||||
if not entered:
|
||||
raise DeviceCommandError("an admin password is required to tune a remote node.")
|
||||
raise typer.BadParameter("an admin password is required to tune a remote node.")
|
||||
return entered
|
||||
|
||||
def register_cli(self, app: typer.Typer) -> None:
|
||||
@@ -524,3 +544,89 @@ def _fmt_snr(snr: float | None) -> str:
|
||||
A short fixed-width string like ``+5.1`` or `` n/a``.
|
||||
"""
|
||||
return f"{snr:+.1f}" if snr is not None else " n/a"
|
||||
|
||||
|
||||
def _sweep_report(
|
||||
result: TxOptResult, path: str, admin_node: Contact, contacts: list[Contact]
|
||||
) -> tuple:
|
||||
"""State a TX sweep: the outcome, then every level measured.
|
||||
|
||||
The winner comes first, because it is what the command was asked for and what a caller
|
||||
acts on; the per-level records follow, so the choice can be checked against the
|
||||
measurements it was made from. The menu's ``★`` on the winning row has no column here
|
||||
— ``optimal_tx_dbm`` above the table already names it, and a mark is something to look
|
||||
at rather than something to test.
|
||||
|
||||
The two nodes become the shared node shape, and here that matters more than anywhere
|
||||
else: on the plain line they are told apart by nothing but their names, and a caller
|
||||
correlating a sweep with a trace has no key to join on.
|
||||
|
||||
Args:
|
||||
result: The completed sweep.
|
||||
path: The forced route the traces walked, as hex hops.
|
||||
admin_node: The node whose TX power was tuned.
|
||||
contacts: The contact list, for placing the target by name.
|
||||
|
||||
Returns:
|
||||
The report's blocks.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.fields import NodeRef
|
||||
from ..ui.report import Facts, Listing
|
||||
|
||||
def node(name: str) -> NodeRef | None:
|
||||
"""One end of the tuned link as the shared node shape."""
|
||||
contact = next((c for c in contacts if c.name == name), None)
|
||||
if contact is None:
|
||||
return NodeRef(name=name) if name else None
|
||||
return NodeRef(
|
||||
name=contact.name,
|
||||
key=(contact.public_key or "").lower() or None,
|
||||
hash=(contact.key_prefix or "").lower() or None,
|
||||
type=NODE_TYPE_LABELS.get(contact.node_type),
|
||||
)
|
||||
|
||||
facts = Facts(
|
||||
key="tx_optimize",
|
||||
fields=(
|
||||
fields.node("tuning_node", lanes=(("name", "tuning_node"),)),
|
||||
fields.node("target", lanes=(("name", "target"),)),
|
||||
fields.spec(),
|
||||
fields.integer("optimal_tx_dbm", "optimal_tx_dbm"),
|
||||
fields.snr("target_snr_db", "target_snr_db"),
|
||||
fields.decimal("reliability", "reliability", ".2f"),
|
||||
fields.integer("previous_tx_dbm", "previous_tx_dbm"),
|
||||
fields.flag("applied", "applied"),
|
||||
),
|
||||
values={
|
||||
"tuning_node": node(result.admin_node) or NodeRef(name=admin_node.name),
|
||||
"target": node(result.target),
|
||||
"path": path,
|
||||
"optimal_tx_dbm": result.best_tx,
|
||||
"target_snr_db": result.best_snr,
|
||||
# A fraction in [0, 1], not a formatted "1.00": the plain column rounds for
|
||||
# the eye and the document keeps the number a number.
|
||||
"reliability": result.best_success_rate,
|
||||
"previous_tx_dbm": result.original_tx,
|
||||
"applied": result.applied,
|
||||
},
|
||||
)
|
||||
levels = Listing(
|
||||
key="levels",
|
||||
columns=(
|
||||
fields.integer("tx_dbm", "TX_DBM"),
|
||||
fields.snr("target_snr_db", "TARGET_SNR_DB"),
|
||||
fields.integer("successes", "SUCCESSES"),
|
||||
fields.integer("samples", "SAMPLES"),
|
||||
),
|
||||
rows=[
|
||||
{
|
||||
"tx_dbm": level.tx_power,
|
||||
"target_snr_db": level.target_snr,
|
||||
"successes": level.successes,
|
||||
"samples": level.samples,
|
||||
}
|
||||
for level in result.sorted_by_tx()
|
||||
],
|
||||
)
|
||||
return (facts, levels)
|
||||
+398
-114
@@ -40,6 +40,7 @@ from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Group
|
||||
from rich.text import Text
|
||||
|
||||
@@ -62,9 +63,20 @@ from ..core.channels import (
|
||||
from ..core.connection import Device
|
||||
from ..core.models import Conversation
|
||||
from ..persistence.repository import ACTIVITY_DRAWN_BUCKETS
|
||||
from ..platforms import get_platform
|
||||
from .braillechart import activity_peak, activity_sparkline
|
||||
from .menus import command_label, fit_cells, menu_rows, run_steps, section_heading
|
||||
from .menus import (
|
||||
Lane,
|
||||
column_header,
|
||||
fit_cells,
|
||||
icon_lane,
|
||||
marked_label,
|
||||
menu_rows,
|
||||
run_steps,
|
||||
section_heading,
|
||||
)
|
||||
from .qr import share_popup
|
||||
from .theme import glyph
|
||||
from .tui import CANCEL, Choice, SelectScreen, Separator
|
||||
from .widgets import _age_seconds, _format_age, channel_glyph, format_ago
|
||||
|
||||
@@ -72,6 +84,9 @@ if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
from ..persistence.repository import ChannelStats
|
||||
|
||||
#: The manager's footer sentence: navigation, then the filter, then the action, Esc last.
|
||||
_MANAGER_HINT = "↑↓ move · type to filter · Enter open · Esc back"
|
||||
|
||||
# Top-menu action sentinels (distinct from a plain slot index, which selects that channel).
|
||||
_CREATE = "__create__"
|
||||
_PUBLIC = "__public__"
|
||||
@@ -146,11 +161,24 @@ class ChannelSlot:
|
||||
async def manage_channels(ctx: AppContext) -> int:
|
||||
"""Run the interactive channel manager until the user backs out.
|
||||
|
||||
The visit says **nothing** on the way out. It used to bank a note per action and then an
|
||||
"✓ applied 3 channel changes" line on top of those, shown once the manager had already
|
||||
closed — an acknowledgement for changes the reader had just watched land in the list in
|
||||
front of them, arriving as a tidy popup or as a full-frame window depending on how many
|
||||
lines had piled up. An error is the exception, and is shown when it happens (see
|
||||
:func:`_import_link`).
|
||||
|
||||
What the reader gets instead is the wait itself: every device action reports *while it
|
||||
runs*, under the modal busy card (:meth:`~meshterm.ui.surface.Ui.busy_dialog`) — see
|
||||
:func:`write_channel` and ``settle`` below for where those are drawn, and why the card
|
||||
has to be modal.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context (provides the connected device and UI surface).
|
||||
|
||||
Returns:
|
||||
The number of channels created, changed, or cleared during the session.
|
||||
How many channels were created, changed, or cleared — the run log's record of what
|
||||
the visit did (``ToolResult.summary``), not anything the reader is shown.
|
||||
"""
|
||||
device = await ctx.device()
|
||||
# The firmware's slot count is fixed for the session, so discover it once (a read-only
|
||||
@@ -164,7 +192,6 @@ async def manage_channels(ctx: AppContext) -> int:
|
||||
stats = _LiveStats(ctx)
|
||||
changes = 0
|
||||
highlight: object | None = None
|
||||
|
||||
slots: list = []
|
||||
|
||||
async def reload() -> tuple[str, list]:
|
||||
@@ -180,13 +207,38 @@ async def manage_channels(ctx: AppContext) -> int:
|
||||
slots = list(await ctx.devstate.channel_slots())
|
||||
return _menu_items(ctx, slots, capacity, stats)
|
||||
|
||||
async def settle(*, moved: bool = False) -> tuple[str, list]:
|
||||
"""Re-probe the layout — and, when a slot actually moved, re-file what points at it.
|
||||
|
||||
Two device round-trips back to back when something changed (the chat service's slot
|
||||
map and the slot probe ``reload`` calls one of the slowest reads the app makes), so
|
||||
they report as a single wait rather than boxes flashing in sequence — and while the
|
||||
card is up the reader cannot type into a list whose rows are being replaced
|
||||
underneath them.
|
||||
|
||||
Args:
|
||||
moved: Whether a slot's occupant changed during the round just finished. The
|
||||
caller decides that by comparing ``devstate.channels_epoch``, not by trusting
|
||||
a count (see :func:`write_channel`, which drops the cache as it writes).
|
||||
"""
|
||||
async with ctx.ui.busy_dialog("reading channels…", title="Channels"):
|
||||
title_and_items = await reload()
|
||||
if moved:
|
||||
# Inbound messages carry only a slot index, which the chat service maps to a
|
||||
# channel identity through a cache keyed by slot; refresh it now so a message
|
||||
# on a reused/re-keyed slot is filed under the channel that's actually there
|
||||
# and not the one that used to be — otherwise its transcript surfaces in the
|
||||
# wrong chat. After ``reload``, deliberately: it reads the same cached probe,
|
||||
# so this way round the pair costs one walk of the device instead of two.
|
||||
await _refresh_chat_channels(ctx)
|
||||
return title_and_items
|
||||
|
||||
async def handle(choice: object) -> bool:
|
||||
"""Dispatch one menu choice (over the still-pushed list); ``False`` exits."""
|
||||
nonlocal changes, highlight
|
||||
if choice is None: # Esc
|
||||
return False
|
||||
highlight = choice
|
||||
before = changes
|
||||
if choice == _CREATE:
|
||||
changes += await _create_private(ctx, device, slots, capacity)
|
||||
elif choice == _DEFAULT_PUBLIC:
|
||||
@@ -203,17 +255,6 @@ async def manage_channels(ctx: AppContext) -> int:
|
||||
slot = next((s for s in slots if s.idx == choice), None)
|
||||
if slot is not None:
|
||||
changes += await _channel_detail(ctx, device, slot, stats)
|
||||
if changes > before:
|
||||
# A slot's occupant changed (created, re-keyed, cleared, or moved). Drop the
|
||||
# session cache's channel list so the dashboard (and anything else reading it)
|
||||
# re-reads the new layout instead of a stale copy.
|
||||
ctx.devstate.invalidate_channels()
|
||||
# Inbound messages carry only a slot index, which the chat service maps to a
|
||||
# channel identity through a cache keyed by slot; refresh it now so a message
|
||||
# on a reused/re-keyed slot is filed under the channel that's actually there
|
||||
# and not the one that used to be — otherwise its transcript surfaces in the
|
||||
# wrong chat.
|
||||
await _refresh_chat_channels(ctx)
|
||||
return True
|
||||
|
||||
# The list stays pushed for the whole visit: every sub-flow — creating a channel,
|
||||
@@ -221,20 +262,41 @@ async def manage_channels(ctx: AppContext) -> int:
|
||||
# swapped in place afterwards, so the highlight (and any typed filter) survives a layout
|
||||
# that changed under it. A surface with no session — the plain CLI, scripted tests — has
|
||||
# no stack to stay on and runs the same dispatcher a round at a time.
|
||||
title, items = await reload()
|
||||
title, items = await settle()
|
||||
session = getattr(ctx.ui, "session", None)
|
||||
if session is None:
|
||||
while True:
|
||||
if not await _menu_round(ctx, title, items, default=highlight, handle=handle):
|
||||
epoch = ctx.devstate.channels_epoch
|
||||
try:
|
||||
keep = await _menu_round(ctx, title, items, default=highlight, handle=handle)
|
||||
except BaseException:
|
||||
# A write that landed and then raised — or a ^W unwinding out through the QR
|
||||
# view opened after it — moved the device just as much as one that returned
|
||||
# cleanly. The epoch says so where the action's return value never got the
|
||||
# chance to, so the chat map is re-filed on the way past.
|
||||
if ctx.devstate.channels_epoch != epoch:
|
||||
await _refresh_chat_channels(ctx)
|
||||
raise
|
||||
if not keep:
|
||||
return changes
|
||||
title, items = await reload()
|
||||
menu = SelectScreen(title, items)
|
||||
title, items = await settle(moved=ctx.devstate.channels_epoch != epoch)
|
||||
# ``Enter open``, not the list default: every slot row pushes the channel's detail
|
||||
# screen and Reorder pushes the reorder screen, which is what the chat picker — the same
|
||||
# channels, one screen over — has always said.
|
||||
menu = SelectScreen(title, items, footer_hint=_MANAGER_HINT)
|
||||
async with session.stay(menu) as visit:
|
||||
while True:
|
||||
choice = await visit.result()
|
||||
if not await handle(None if choice is CANCEL else choice):
|
||||
epoch = ctx.devstate.channels_epoch
|
||||
try:
|
||||
keep = await handle(None if choice is CANCEL else choice)
|
||||
except BaseException:
|
||||
if ctx.devstate.channels_epoch != epoch: # see the note on the other loop
|
||||
await _refresh_chat_channels(ctx)
|
||||
raise
|
||||
if not keep:
|
||||
return changes
|
||||
title, items = await reload()
|
||||
title, items = await settle(moved=ctx.devstate.channels_epoch != epoch)
|
||||
menu.replace_items(items, title=title)
|
||||
|
||||
|
||||
@@ -300,13 +362,43 @@ async def read_channel_slots(device: Device) -> list[ChannelSlot]:
|
||||
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: # noqa: BLE001 - firmware may not support channel reads
|
||||
break
|
||||
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(
|
||||
@@ -320,7 +412,19 @@ async def read_channel_slots(device: Device) -> list[ChannelSlot]:
|
||||
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
|
||||
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`).
|
||||
"""
|
||||
from ..core.connection import DeviceCommandError, is_connection_lost
|
||||
|
||||
return isinstance(exc, (TimeoutError, DeviceCommandError)) or is_connection_lost(exc)
|
||||
|
||||
|
||||
def _next_free_slot(slots: list[ChannelSlot], capacity: int) -> int | None:
|
||||
@@ -344,6 +448,11 @@ async def write_channel(
|
||||
a name-derived channel's derived key so it can later be replayed as-is. Remembering is
|
||||
best-effort: it never blocks or fails the actual device write.
|
||||
|
||||
Being the one boundary, it is also where the *wait* is reported: the write and the
|
||||
identity probe behind it are a device round-trip each, and until the card went up the
|
||||
list sat there looking idle and fully interactive while they ran. A batch (a reorder)
|
||||
nests inside its caller's card rather than flashing one box per slot.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context (for the channel store and cached self-info).
|
||||
device: The connected device to write to.
|
||||
@@ -351,6 +460,21 @@ async def write_channel(
|
||||
name: The channel name (empty clears the slot).
|
||||
secret: The 16-byte secret, or ``None`` to let the firmware derive it from the name.
|
||||
"""
|
||||
caption = f"clearing slot {idx}…" if not name.strip() else f"saving {name.strip()}…"
|
||||
async with ctx.ui.busy_dialog(caption, title="Channels"):
|
||||
await _write_and_remember(ctx, device, idx, name, secret)
|
||||
# The layout on the device is no longer what anything cached, so say so here rather
|
||||
# than leaving each caller to remember: the menu's manager did (and lost it whenever an
|
||||
# action raised on its way back), and the four CLI subcommands never did at all. The
|
||||
# bump also *is* the signal a caller compares across a round — see
|
||||
# :attr:`~meshterm.services.device_state.DeviceState.channels_epoch`.
|
||||
ctx.devstate.invalidate_channels()
|
||||
|
||||
|
||||
async def _write_and_remember(
|
||||
ctx: AppContext, device: Device, idx: int, name: str, secret: bytes | None
|
||||
) -> None:
|
||||
"""Do the write itself and the channel-store bookkeeping (see :func:`write_channel`)."""
|
||||
await device.set_channel(idx, name, secret)
|
||||
store = ctx.channel_store
|
||||
if store is None:
|
||||
@@ -501,24 +625,36 @@ def _activity_sparkline(histogram: tuple[int, ...], peak: float) -> Text:
|
||||
# --- menus -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _lanes_header(name_w: int) -> str:
|
||||
def _lanes_header(name_w: int, width: int) -> str:
|
||||
"""Column headers over the channel list's fixed lanes (see :func:`_slot_text`).
|
||||
|
||||
The leading spaces cover the select screen's pointer column (2 cells) plus the glyph
|
||||
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 message count. (No TYPE or HASH lane: the glyph already
|
||||
carries the openness and the hash lives in Show key, which buys the activity sparkline
|
||||
its room on a 72-column terminal.)
|
||||
The indent covers the select screen's pointer column (2 cells, drawn on choice rows but
|
||||
not separators) plus the glyph lane, *measured* rather than assumed: a channel's glyph
|
||||
is two cells on the desktop and one on the console, so a hard-coded indent put every
|
||||
label a column off there. ``UNREAD`` borrows its lane's trailing gap — the badge lane
|
||||
itself is one cell too narrow for the word — which still leaves a space before the
|
||||
message count. (No TYPE or HASH lane: the glyph already carries the openness and the
|
||||
hash lives in Show key, which buys the activity sparkline its room on a 72-column
|
||||
terminal.)
|
||||
|
||||
Resolved against the render width, because the header row is pinned and must stay one
|
||||
row: at 53 columns the full line ran to 54 and wrapped, costing a content row out of
|
||||
twenty-six and leaving the landmark drawn twice over. ``ACTIVITY`` gives its cells back
|
||||
first (see :func:`~meshterm.ui.menus.column_header`).
|
||||
"""
|
||||
return (
|
||||
" "
|
||||
+ "CHANNEL".ljust(name_w + 2)
|
||||
+ "UNREAD".ljust(_BADGE_WIDTH + 2)
|
||||
+ f"{'MSGS':>{_COUNT_WIDTH}}"
|
||||
+ " "
|
||||
+ f"{'LAST':>{_AGE_WIDTH}}"
|
||||
+ " ACTIVITY"
|
||||
return column_header(
|
||||
[
|
||||
Lane("CHANNEL", name_w + 2),
|
||||
Lane("UNREAD", _BADGE_WIDTH + 2),
|
||||
# Right-aligned, because the values under them are: a count and an age are
|
||||
# padded to the right edge of their lane, so a left-aligned label would sit
|
||||
# off the digits it names.
|
||||
Lane(f"{'MSGS':>{_COUNT_WIDTH}}", _COUNT_WIDTH + 2),
|
||||
Lane(f"{'LAST':>{_AGE_WIDTH}}", _AGE_WIDTH + 2),
|
||||
Lane(("ACTIVITY", "ACT")),
|
||||
],
|
||||
width,
|
||||
indent=2 + cell_len(channel_glyph("Public", None)) + 1,
|
||||
)
|
||||
|
||||
|
||||
@@ -555,8 +691,9 @@ def _slot_text(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: in
|
||||
text.append(fit_cells(slot.name, name_w))
|
||||
text.append(" ")
|
||||
if muted:
|
||||
text.append("🔕", style="muted") # 2 cells; pad the rest of the lane
|
||||
text.append(" " * (_BADGE_WIDTH - 2))
|
||||
mark = glyph("🔕") # two cells on the desktop, one on the console
|
||||
text.append(mark, style="muted")
|
||||
text.append(" " * (_BADGE_WIDTH - cell_len(mark)))
|
||||
elif unread:
|
||||
text.append("●", style="err")
|
||||
text.append(f" {unread}".ljust(_BADGE_WIDTH - 1), style="warn")
|
||||
@@ -568,7 +705,11 @@ def _slot_text(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: in
|
||||
# Clamped so a pathological backlog can't push the row out of its lanes.
|
||||
text.append(f"{min(total, 99999):>{_COUNT_WIDTH}}")
|
||||
else:
|
||||
text.append(f"{'·':>{_COUNT_WIDTH}}", style="muted")
|
||||
# ``○`` is the app's empty mark. ``·`` here was three things at once: the separator
|
||||
# that chains status atoms, the picker's unknown-sender stand-in, and — two cells
|
||||
# away in this very row — what the console folds ``🔕`` to, so a muted channel with
|
||||
# no messages drew the same glyph twice meaning different things.
|
||||
text.append(f"{'○':>{_COUNT_WIDTH}}", style="muted")
|
||||
text.append(" ")
|
||||
age = _format_age(_age_seconds(st.last_at)) if st is not None and st.last_at else ""
|
||||
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
|
||||
@@ -593,56 +734,99 @@ def _menu_items(
|
||||
name_w = min(_NAME_WIDTH_MAX, max(len("CHANNEL"), *(len(s.name) for s in slots)))
|
||||
# The lane names are this block's only landmark (its section carries no ── heading ──),
|
||||
# so they pin overhead while the slots scroll and give way to Organize/Add a channel.
|
||||
items.append(Separator(_lanes_header(name_w), heading=True))
|
||||
items.append(Separator(lambda w: _lanes_header(name_w, w), heading=True))
|
||||
for slot in slots:
|
||||
items.append(Choice(title=_slot_row(ctx, slot, stats, name_w), value=slot.idx))
|
||||
else:
|
||||
items.append(Separator(" no channels configured yet"))
|
||||
items.append(Separator(" no channels yet — add one below"))
|
||||
|
||||
# One measured icon column for every command row on this screen, so the one-cell marks
|
||||
# (↕) start their labels in the same column as the two-cell ones (+ # 🌐 🔑 🔗) instead
|
||||
# of a column early — which the Organize row used to fix by hand, with two spaces and a
|
||||
# four-line comment. Empty where the platform draws no icons, and the labels take the
|
||||
# cells back.
|
||||
lane = icon_lane(("↕", "🌐", "+", "#", "🔑", "🔗"))
|
||||
if len(slots) > 1:
|
||||
items.append(section_heading("Organize"))
|
||||
# Two spaces after the arrow: ↕ (East-Asian-ambiguous width) renders one cell where
|
||||
# the sibling rows' glyphs (+ # 🔑 🔗) render two, so the extra space keeps this
|
||||
# label's text column-aligned with theirs. Both go together where the platform
|
||||
# draws no icon lane at all.
|
||||
items.append(Choice(title=command_label("↕ Reorder channels"), value=_REORDER))
|
||||
reorder = marked_label("↕", "Reorder channels", "", lane=lane)
|
||||
items.append(Choice(title=reorder, value=_REORDER))
|
||||
|
||||
items.append(section_heading("Add a channel"))
|
||||
if _next_free_slot(slots, capacity) is None:
|
||||
# The list already knows there is nowhere to put one, so it says so here rather than
|
||||
# offering four rows that all dead-end in the same refusal.
|
||||
items.append(Separator(" every slot is full — clear one first"))
|
||||
return f"Channels · {len(slots)}/{capacity} slots", items
|
||||
rows = []
|
||||
# The firmware's built-in fixed-key Public channel has one well-known secret, so it's the
|
||||
# same channel on every slot — offer to restore it only while no slot already holds it.
|
||||
if not any(s.secret == DEFAULT_PUBLIC_SECRET for s in slots):
|
||||
rows.append(
|
||||
("🌐 Standard Public channel", "MeshCore's built-in meshwide channel", _DEFAULT_PUBLIC)
|
||||
(
|
||||
marked_label("🌐", "Standard Public channel", "", lane=lane),
|
||||
"MeshCore's built-in meshwide channel",
|
||||
_DEFAULT_PUBLIC,
|
||||
)
|
||||
)
|
||||
rows.extend(
|
||||
[
|
||||
("+ New private channel…", "A fresh random key", _CREATE),
|
||||
("# Public channel…", "Key derived from its name", _PUBLIC),
|
||||
("🔑 Join with a key…", "Paste a channel's 32-hex key", _JOIN),
|
||||
("🔗 Import a link…", "Paste a meshcore:// share link", _IMPORT),
|
||||
(
|
||||
marked_label("+", "New private channel…", "", lane=lane),
|
||||
"A fresh random key",
|
||||
_CREATE,
|
||||
),
|
||||
(
|
||||
marked_label("#", "Public channel…", "", lane=lane),
|
||||
"Key derived from its name",
|
||||
_PUBLIC,
|
||||
),
|
||||
(
|
||||
marked_label("🔑", "Join with a key…", "", lane=lane),
|
||||
"Paste a channel's 32-hex key",
|
||||
_JOIN,
|
||||
),
|
||||
(
|
||||
marked_label("🔗", "Import a link…", "", lane=lane),
|
||||
"Paste a meshcore:// share link",
|
||||
_IMPORT,
|
||||
),
|
||||
]
|
||||
)
|
||||
items.extend(menu_rows(rows))
|
||||
|
||||
return f"Channels — {len(slots)}/{capacity} slots", items
|
||||
# ``·`` chains a status atom; ``—`` would introduce a subject, and the slot count is not
|
||||
# what this screen is about (see Contacts, which reads "Contacts · 12 known").
|
||||
return f"Channels · {len(slots)}/{capacity} slots", items
|
||||
|
||||
|
||||
def _detail_summary(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats) -> str:
|
||||
"""One line of vital signs for the detail screen: slot, totals, unread, mute, last activity."""
|
||||
"""One line of vital signs for the detail screen: what it is, then how it has been used.
|
||||
|
||||
The openness and the hash lead, having moved here out of the screen's title, which was
|
||||
carrying them in a parenthesised blob and running to 42 cells — the whole of the
|
||||
PicoCalc's borderless title bar, leaving no room for the one place that says Esc leaves.
|
||||
|
||||
The line is one line. Atoms are shed from the right until it fits the platform's
|
||||
readable width, because the ones on the left identify the channel and the ones on the
|
||||
right describe traffic the reader can also see in the row they came from.
|
||||
"""
|
||||
st = stats.get(slot.identity)
|
||||
unread = ctx.chat.unread(slot.conversation.key)
|
||||
muted = _is_muted(ctx, slot)
|
||||
kind = "public" if slot.is_public else "private"
|
||||
parts = [kind, f"hash {slot.hash}", f"slot {slot.idx}"]
|
||||
if st is None or not st.total:
|
||||
base = f"Slot {slot.idx} · no messages recorded yet"
|
||||
return f"{base} · muted" if muted else base
|
||||
parts = [f"Slot {slot.idx}", f"{st.total} message{'' if st.total == 1 else 's'}"]
|
||||
if unread:
|
||||
parts.append(f"{unread} unread")
|
||||
if muted:
|
||||
parts.append("no messages yet")
|
||||
else:
|
||||
parts.append(f"{st.total} msg{'' if st.total == 1 else 's'}")
|
||||
if unread:
|
||||
parts.append(f"{unread} unread")
|
||||
if _is_muted(ctx, slot):
|
||||
parts.append("muted")
|
||||
if st.last_at is not None:
|
||||
parts.append(f"last message {format_ago(_age_seconds(st.last_at))}")
|
||||
if st is not None and st.last_at is not None:
|
||||
parts.append(f"last {format_ago(_age_seconds(st.last_at))}")
|
||||
width = get_platform().readable_cols
|
||||
while len(parts) > 1 and cell_len(" · ".join(parts)) > width:
|
||||
parts.pop()
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
@@ -657,31 +841,49 @@ def _detail_items(ctx: AppContext, slot: ChannelSlot) -> list:
|
||||
err-tinted label so it reads as such.
|
||||
"""
|
||||
unread = ctx.chat.unread(slot.conversation.key)
|
||||
chat_label = Text("💬 Open in chat")
|
||||
# ✎ and 🗑 are one cell where 📱 🔑 💬 🔔 🔕 are two, so the column is measured once and
|
||||
# every mark padded out to it — otherwise the edit and clear rows start their labels a
|
||||
# column left of the rows above them.
|
||||
lane = icon_lane(("📱", "🔑", "💬", "🔔", "🔕", "✎", "🗑"))
|
||||
chat_label = marked_label("💬", "Open in chat", "", lane=lane)
|
||||
if unread:
|
||||
chat_label.append(" ●", style="err")
|
||||
chat_label.append(f" {unread}", style="warn")
|
||||
if _is_muted(ctx, slot):
|
||||
mute_row = (
|
||||
"🔔 Unmute notifications",
|
||||
marked_label("🔔", "Unmute notifications", "", lane=lane),
|
||||
"Show new messages here in the unread badge",
|
||||
_MUTE,
|
||||
)
|
||||
else:
|
||||
mute_row = (
|
||||
"🔕 Mute notifications",
|
||||
marked_label("🔕", "Mute notifications", "", lane=lane),
|
||||
"Hide new messages here from the unread badge",
|
||||
_MUTE,
|
||||
)
|
||||
items = menu_rows(
|
||||
[
|
||||
("📱 Show QR code", "Share this channel as a scannable code", _QR),
|
||||
("🔑 Show key", "The name, key, hash, and share link", _KEY),
|
||||
(
|
||||
marked_label("📱", "Show QR code", "", lane=lane),
|
||||
"Share this channel as a scannable code",
|
||||
_QR,
|
||||
),
|
||||
(
|
||||
marked_label("🔑", "Show key", "", lane=lane),
|
||||
"The name, key, hash, and share link",
|
||||
_KEY,
|
||||
),
|
||||
(chat_label, "Read and send messages on this channel", _CHAT),
|
||||
mute_row,
|
||||
("✎ Rename / change key…", "Edit the name or paste a different key", _EDIT),
|
||||
(
|
||||
Text("🗑 Clear this slot…", style="err"),
|
||||
marked_label("✎", "Rename / change key…", "", lane=lane),
|
||||
"Edit the name or paste a different key",
|
||||
_EDIT,
|
||||
),
|
||||
(
|
||||
# The tint goes on the mark, not the words — and falls back to the words only
|
||||
# where the platform draws no mark at all (see menus.marked_label).
|
||||
marked_label("🗑", "Clear this slot…", "err", lane=lane),
|
||||
"Remove the channel from this device",
|
||||
_CLEAR,
|
||||
),
|
||||
@@ -700,17 +902,34 @@ async def _channel_detail(
|
||||
pushed while each action's prompts float over it, and the rows and the vital-signs line
|
||||
are swapped in place afterwards rather than the screen being drawn again. Both are live
|
||||
(the mute row is a toggle; the summary counts unread and ages the last message), so both
|
||||
are re-read each round, and the highlight stays on the row that was just used. A
|
||||
rename/re-key or clear closes the detail (the slot's occupant changed, so the caller
|
||||
re-reads the device); the read-only actions loop back here.
|
||||
are re-read each round, and the highlight stays on the row that was just used.
|
||||
|
||||
A **clear** closes the page, because the channel it was about is gone. A **rename or
|
||||
re-key does not**: the channel is still there and this is still its page, so the new name
|
||||
and key are read back into the title, the summary and the rows, and the reader stays
|
||||
where they were. The page used to close on both, for no better reason than ``slot`` being
|
||||
a snapshot that the rename had made stale.
|
||||
"""
|
||||
kind = "public" if slot.is_public else "private"
|
||||
title = f"{slot.name} ({kind}, hash {slot.hash})"
|
||||
changes = 0
|
||||
|
||||
def title_for() -> str:
|
||||
"""``Feature — subject``: the openness and the hash lead the summary line instead."""
|
||||
return f"Channel — {slot.name}"
|
||||
|
||||
async def reread() -> bool:
|
||||
"""Re-point ``slot`` at what now occupies its index; ``False`` if nothing does."""
|
||||
nonlocal slot
|
||||
fresh = next((s for s in await ctx.devstate.channel_slots() if s.idx == slot.idx), None)
|
||||
if fresh is None:
|
||||
return False
|
||||
slot = fresh
|
||||
return True
|
||||
|
||||
async def handle(choice: object) -> int | None:
|
||||
"""Run one action; an int closes the detail with that many changes, ``None`` stays."""
|
||||
nonlocal changes
|
||||
if choice is None: # Esc
|
||||
return 0
|
||||
return changes
|
||||
if choice == _QR:
|
||||
await _show_share(ctx, slot.name, slot.secret)
|
||||
elif choice == _KEY:
|
||||
@@ -722,9 +941,11 @@ async def _channel_detail(
|
||||
# detail (whose rows re-render to the new state) without counting a channel change.
|
||||
_toggle_mute(ctx, slot)
|
||||
elif choice == _EDIT and await _edit(ctx, device, slot):
|
||||
return 1
|
||||
changes += 1
|
||||
if not await reread(): # pragma: no cover - the slot we just wrote is there
|
||||
return changes
|
||||
elif choice == _CLEAR and await _clear(ctx, device, slot):
|
||||
return 1
|
||||
return changes + 1 # the channel this page is about is gone; so is the page
|
||||
return None
|
||||
|
||||
# A surface with no session (the plain CLI, scripted tests) has no stack to stay on and
|
||||
@@ -732,12 +953,12 @@ async def _channel_detail(
|
||||
session = getattr(ctx.ui, "session", None)
|
||||
if session is None:
|
||||
while True:
|
||||
result = await _menu_round(ctx, title, _detail_items(ctx, slot), handle=handle)
|
||||
result = await _menu_round(ctx, title_for(), _detail_items(ctx, slot), handle=handle)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
menu = SelectScreen(
|
||||
title,
|
||||
title_for(),
|
||||
_detail_items(ctx, slot),
|
||||
prompt=_detail_summary(ctx, slot, stats),
|
||||
)
|
||||
@@ -747,7 +968,11 @@ async def _channel_detail(
|
||||
result = await handle(None if choice is CANCEL else choice)
|
||||
if result is not None:
|
||||
return result
|
||||
menu.replace_items(_detail_items(ctx, slot), prompt=_detail_summary(ctx, slot, stats))
|
||||
menu.replace_items(
|
||||
_detail_items(ctx, slot),
|
||||
title=title_for(),
|
||||
prompt=_detail_summary(ctx, slot, stats),
|
||||
)
|
||||
|
||||
|
||||
# --- create / join flows -----------------------------------------------------
|
||||
@@ -757,7 +982,7 @@ async def _create_private(
|
||||
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
|
||||
) -> int:
|
||||
"""Create a private channel with a fresh random key on the next free slot."""
|
||||
idx = await _pick_free_slot(ctx, slots, capacity)
|
||||
idx = await _pick_free_slot(ctx, device, slots, capacity)
|
||||
if idx is None:
|
||||
return 0
|
||||
name = await ctx.ui.text("Channel name:", validate=_nonblank)
|
||||
@@ -765,7 +990,6 @@ async def _create_private(
|
||||
return 0
|
||||
secret = random_secret()
|
||||
await write_channel(ctx, device, idx, name.strip(), secret)
|
||||
ctx.ui.note(f"[ok]✓[/ok] created private channel [brand]{name.strip()}[/brand]")
|
||||
await _show_share(ctx, name.strip(), secret, intro="Share this channel:")
|
||||
return 1
|
||||
|
||||
@@ -773,12 +997,19 @@ async def _create_private(
|
||||
async def _add_default_public(
|
||||
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
|
||||
) -> int:
|
||||
"""Add MeshCore's built-in fixed-key ``Public`` channel on the next free slot."""
|
||||
idx = await _pick_free_slot(ctx, slots, capacity)
|
||||
"""Add MeshCore's built-in fixed-key ``Public`` channel on the next free slot.
|
||||
|
||||
One well-known secret means it is the same channel on every slot, so a second copy is
|
||||
not a channel but a duplicate row. The menu already drops this action once a slot holds
|
||||
it; this is the same check where the action *runs*, for a press that was on its way while
|
||||
the first one was still being written.
|
||||
"""
|
||||
if any(s.secret == DEFAULT_PUBLIC_SECRET for s in slots):
|
||||
return 0
|
||||
idx = await _pick_free_slot(ctx, device, slots, capacity)
|
||||
if idx is None:
|
||||
return 0
|
||||
await write_channel(ctx, device, idx, "Public", DEFAULT_PUBLIC_SECRET)
|
||||
ctx.ui.note("[ok]✓[/ok] added the standard [brand]Public[/brand] channel")
|
||||
return 1
|
||||
|
||||
|
||||
@@ -786,7 +1017,7 @@ async def _add_public(
|
||||
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
|
||||
) -> int:
|
||||
"""Create a public channel whose key is derived from its (``#``-prefixed) name."""
|
||||
idx = await _pick_free_slot(ctx, slots, capacity)
|
||||
idx = await _pick_free_slot(ctx, device, slots, capacity)
|
||||
if idx is None:
|
||||
return 0
|
||||
raw = await ctx.ui.text(
|
||||
@@ -801,7 +1032,6 @@ async def _add_public(
|
||||
name = f"#{name}"
|
||||
secret = derive_secret(name) # what the firmware will compute; kept for the QR/share
|
||||
await write_channel(ctx, device, idx, name, None) # None => firmware derives the key from name
|
||||
ctx.ui.note(f"[ok]✓[/ok] added public channel [brand]{name}[/brand]")
|
||||
await _show_share(ctx, name, secret, intro="Share this channel:")
|
||||
return 1
|
||||
|
||||
@@ -815,7 +1045,7 @@ async def _join_with_key(
|
||||
steps back to the name with what was typed still in the field, rather than throwing
|
||||
both away — a 32-hex key is a long thing to mistype.
|
||||
"""
|
||||
idx = await _pick_free_slot(ctx, slots, capacity)
|
||||
idx = await _pick_free_slot(ctx, device, slots, capacity)
|
||||
if idx is None:
|
||||
return 0
|
||||
answers = await run_steps(
|
||||
@@ -833,7 +1063,6 @@ async def _join_with_key(
|
||||
name, key = answers
|
||||
secret = normalize_secret(key)
|
||||
await write_channel(ctx, device, idx, name.strip(), secret)
|
||||
ctx.ui.note(f"[ok]✓[/ok] joined [brand]{name.strip()}[/brand]")
|
||||
return 1
|
||||
|
||||
|
||||
@@ -841,7 +1070,7 @@ async def _import_link(
|
||||
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
|
||||
) -> int:
|
||||
"""Import a channel from a pasted ``meshcore://channel/add`` link."""
|
||||
idx = await _pick_free_slot(ctx, slots, capacity)
|
||||
idx = await _pick_free_slot(ctx, device, slots, capacity)
|
||||
if idx is None:
|
||||
return 0
|
||||
url = await ctx.ui.text("Paste a meshcore:// channel link:", validate=_valid_link)
|
||||
@@ -849,11 +1078,10 @@ async def _import_link(
|
||||
return 0
|
||||
parsed = parse_share_url(url)
|
||||
if parsed is None: # pragma: no cover - guarded by the validator
|
||||
ctx.ui.note("[err]not a valid channel link[/err]")
|
||||
await _say(ctx, "not a valid channel link", "err")
|
||||
return 0
|
||||
name, secret = parsed
|
||||
await write_channel(ctx, device, idx, name, secret)
|
||||
ctx.ui.note(f"[ok]✓[/ok] imported [brand]{name}[/brand]")
|
||||
return 1
|
||||
|
||||
|
||||
@@ -890,7 +1118,6 @@ async def _edit(ctx: AppContext, device: Device, slot: ChannelSlot) -> bool:
|
||||
name = name.strip()
|
||||
secret = normalize_secret(key) if key.strip() else None
|
||||
await write_channel(ctx, device, slot.idx, name, secret)
|
||||
ctx.ui.note(f"[ok]✓[/ok] updated channel [brand]{name}[/brand]")
|
||||
return True
|
||||
|
||||
|
||||
@@ -913,7 +1140,6 @@ async def _clear(ctx: AppContext, device: Device, slot: ChannelSlot) -> bool:
|
||||
ctx.chat.set_active(slot.conversation.key) # drop its unread before the slot goes away
|
||||
ctx.chat.set_active(None)
|
||||
await write_channel(ctx, device, slot.idx, "", None) # empty name => the slot reads as unused
|
||||
ctx.ui.note(f"[warn]cleared channel {slot.name}[/warn]")
|
||||
return True
|
||||
|
||||
|
||||
@@ -938,14 +1164,22 @@ async def _apply_order(
|
||||
in-memory snapshot, so the interleaved writes never clobber a not-yet-placed channel.
|
||||
"""
|
||||
indices = sorted(s.idx for s in slots) # the physical slots to fill, ascending
|
||||
changes = 0
|
||||
for target_idx, pos in zip(indices, order, strict=True):
|
||||
slot = slots[pos]
|
||||
if slot.idx == target_idx:
|
||||
continue # already in place; no write needed
|
||||
await _write_slot(ctx, device, target_idx, slot)
|
||||
changes += 1
|
||||
return changes
|
||||
moves = [
|
||||
(target_idx, slots[pos])
|
||||
for target_idx, pos in zip(indices, order, strict=True)
|
||||
if slots[pos].idx != target_idx # already in place; no write needed
|
||||
]
|
||||
if not moves:
|
||||
return 0
|
||||
# One card for the whole batch, retitled as it walks: this is the longest thing the
|
||||
# manager does — a write per moved channel, each a device round-trip — and the one place
|
||||
# where a count is worth showing, because the reader can see it is progressing rather
|
||||
# than stuck. The per-write cards inside nest into this one.
|
||||
async with ctx.ui.busy_dialog("reordering channels…", title="Channels") as busy:
|
||||
for position, (target_idx, slot) in enumerate(moves, start=1):
|
||||
busy.message = f"moving {slot.name} · {position}/{len(moves)}"
|
||||
await _write_slot(ctx, device, target_idx, slot)
|
||||
return len(moves)
|
||||
|
||||
|
||||
async def _reorder_channels(ctx: AppContext, device: Device, slots: list[ChannelSlot]) -> int:
|
||||
@@ -960,7 +1194,16 @@ async def _reorder_channels(ctx: AppContext, device: Device, slots: list[Channel
|
||||
# the channels automatically — reordering needs no history migration. The slot→identity
|
||||
# cache the chat service resolves inbound messages through is refreshed centrally by
|
||||
# manage_channels once any change lands (see :func:`_refresh_chat_channels`).
|
||||
return await _apply_order(ctx, device, slots, order)
|
||||
try:
|
||||
return await _apply_order(ctx, device, slots, order)
|
||||
except Exception as exc: # noqa: BLE001 - reported here, where the half-done state is
|
||||
# A relay is a sequence of writes with no transaction under it, so a link that drops
|
||||
# partway leaves the slots between the two orders. That is worth saying plainly and
|
||||
# at once: the manager re-reads on the way out of this round, so what the reader is
|
||||
# about to look at *is* the half-applied layout, and nothing else would tell them so.
|
||||
ctx.log.debug("channels: reorder stopped partway: %s", exc)
|
||||
await _say(ctx, f"reorder stopped partway — {exc}", "err")
|
||||
return 0
|
||||
|
||||
|
||||
# --- shared views ------------------------------------------------------------
|
||||
@@ -1016,19 +1259,60 @@ async def _open_chat(ctx: AppContext, slot: ChannelSlot) -> None:
|
||||
await open_chat(ctx, slot.conversation)
|
||||
|
||||
|
||||
async def _pick_free_slot(ctx: AppContext, slots: list[ChannelSlot], capacity: int) -> int | None:
|
||||
"""Return the next free slot, warning (and returning ``None``) if all are full."""
|
||||
async def _pick_free_slot(
|
||||
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
|
||||
) -> int | None:
|
||||
"""Return a slot that is *confirmed* empty, or ``None`` (having said why) if there is none.
|
||||
|
||||
The list this picks from is a snapshot, and a snapshot can be short of the truth: a slot
|
||||
probe that failed partway returns the slots it managed to read, so a slot holding a
|
||||
channel the probe never reached looks free. Every add flow lands here, and what follows
|
||||
it is an unconditional write — so the slot is read back one more time before it is handed
|
||||
out. A channel is not something to overwrite on the strength of a list that might be
|
||||
missing a row.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context (for the message surfaces).
|
||||
device: The connected device, for the confirming read.
|
||||
slots: The slots as last read.
|
||||
capacity: The device's slot count.
|
||||
|
||||
Returns:
|
||||
A free slot index, or ``None`` when there is none to give.
|
||||
"""
|
||||
idx = _next_free_slot(slots, capacity)
|
||||
if idx is None:
|
||||
await ctx.ui.view(
|
||||
Text.from_markup(
|
||||
"[warn]All channel slots are full.[/warn] Clear one first, then try again."
|
||||
),
|
||||
title="No free slot",
|
||||
)
|
||||
await _say(ctx, "all channel slots are full — clear one first, then try again", "warn")
|
||||
return None
|
||||
try:
|
||||
async with ctx.ui.busy_dialog(f"checking slot {idx}…", title="Channels"):
|
||||
occupant = await device.get_channel(idx)
|
||||
except Exception as exc: # noqa: BLE001 - unreadable is not provably free
|
||||
ctx.log.debug("channels: could not confirm slot %s is free: %s", idx, exc)
|
||||
await _say(ctx, f"could not read slot {idx} — nothing was written", "err")
|
||||
return None
|
||||
if occupant and occupant.get("channel_name"):
|
||||
await _say(ctx, f"slot {idx} is in use — reopen Channels to see what is there", "err")
|
||||
return None
|
||||
return idx
|
||||
|
||||
|
||||
async def _say(ctx: AppContext, text: str, style: str) -> None:
|
||||
"""Tell the reader something *now*, in a popup over the manager it happened in.
|
||||
|
||||
An in-visit outcome is not a tool result: it belongs to the action in front of the
|
||||
reader, so it goes to the dialog surface rather than into the buffer the menu drains
|
||||
once the whole tool has finished (which is where a failed import used to leave it).
|
||||
"""
|
||||
mark = {"err": "✗", "warn": "⚠"}.get(style, "")
|
||||
body = Text(f"{mark} {text}" if mark else text, style=style)
|
||||
session = getattr(ctx.ui, "session", None)
|
||||
if session is None: # the scripted CLI has no dialog surface; print it
|
||||
ctx.ui.note(f"[{style}]{body.plain}[/{style}]")
|
||||
return
|
||||
await session.message_dialog(body, title="Channels")
|
||||
|
||||
|
||||
# --- validators --------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -306,7 +306,7 @@ PIN_KEY = "device_pin"
|
||||
_PIN_WIDTH = len(str(get_spec(PIN_KEY).maximum))
|
||||
|
||||
|
||||
def conceal(value: str) -> str:
|
||||
def conceal(value: str, *, absent: str = "?") -> str:
|
||||
"""``value`` as a row of :data:`_PIN_WIDTH` mask bullets.
|
||||
|
||||
A value the device could not report is not a secret, it is an absence: ``format_value``
|
||||
@@ -314,8 +314,16 @@ def conceal(value: str) -> str:
|
||||
something to reveal when there is nothing. So only a real value is concealed, and
|
||||
:func:`has_pin` asks the same question the other way round — a page with nothing to
|
||||
conceal advertises no key for it.
|
||||
|
||||
Args:
|
||||
value: The already-rendered value to mask.
|
||||
absent: How *this* surface writes "the device never reported one". The menu's
|
||||
token is ``?``; the scripted CLI's is :data:`~meshterm.ui.script.NONE`. The
|
||||
rule is the same on both and the glyph is not, so the caller names it — a
|
||||
``conceal`` that knew only the menu's token masked the CLI's absence too, and
|
||||
six bullets told the reader the radio was PIN-locked when it was not.
|
||||
"""
|
||||
return value if value == "?" else MASK_MARK * _PIN_WIDTH
|
||||
return value if value == absent else MASK_MARK * _PIN_WIDTH
|
||||
|
||||
|
||||
def has_pin(snapshot: dict) -> bool:
|
||||
@@ -995,7 +1003,7 @@ async def _run_now(
|
||||
"""
|
||||
from ..tools.config import apply_ops
|
||||
|
||||
changes, artifacts = await apply_ops(ctx, device, snapshot, ops)
|
||||
changes, artifacts, _report = await apply_ops(ctx, device, snapshot, ops)
|
||||
for artifact in artifacts:
|
||||
ctx.ui.note(f"[ok]●[/ok] wrote [accent]{artifact}[/accent]")
|
||||
await ctx.ui.present(title=title)
|
||||
|
||||
+208
-21
@@ -15,6 +15,14 @@ merely detected). Everything else follows in discovery order.
|
||||
Selecting a device runs an immediate smoke test (via the ``verify`` callback): a genuine
|
||||
MeshCore companion is remembered — forever — as confirmed and becomes the session's active
|
||||
device; anything else sends the user back to the list to choose another.
|
||||
|
||||
A machine with things permanently plugged into it that are not companions — a debug probe, a
|
||||
programmer, a serial adapter — shows them here every single time, in front of the one device
|
||||
the user actually wants. So a row can be **hidden**: ``h`` drops the highlighted device from
|
||||
this list and remembers that (see :meth:`~meshterm.core.device_store.DeviceStore.hide`), and
|
||||
``⇧H`` brings every hidden one back. It is a *listing* choice and nothing more: a hidden
|
||||
device is still remembered, still reachable by ``--port``, and un-hides itself the moment it
|
||||
is connected to again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -35,8 +43,10 @@ from ..core.discovery import (
|
||||
serial_device,
|
||||
tcp_device,
|
||||
)
|
||||
from ..platforms import get_platform
|
||||
from .logo import load_logo
|
||||
from .tui import Choice, DeleteRequest, Separator
|
||||
from .menus import Lane, column_header, fit_cells
|
||||
from .tui import Choice, DeleteRequest, KeyRequest, Separator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .surface import Ui
|
||||
@@ -57,6 +67,13 @@ _QUIT = object()
|
||||
#: companions aren't discoverable, so they're named by hand) and smoke-tests the result.
|
||||
_ADD_TCP = object()
|
||||
|
||||
#: The two shortcut tokens the splash declares, and the keys that raise them. ``h`` acts on
|
||||
#: the highlighted row; ``⇧H`` is screen-wide, because a hidden row is not there to press a
|
||||
#: key on — the way back has to be reachable from anywhere in the list.
|
||||
_HIDE = object()
|
||||
_SHOW_ALL = object()
|
||||
_SHORTCUTS = {"h": _HIDE, "H": _SHOW_ALL}
|
||||
|
||||
#: TYPE-column glyphs marking how a device connects. Kept as module constants so the splash's
|
||||
#: look can be retuned without touching the row-building logic. ``ᛒ`` is the *Bjarkan* rune the
|
||||
#: Bluetooth logo is drawn from — rendered white on the Bluetooth blue (see the ``bluetooth``
|
||||
@@ -280,6 +297,9 @@ async def prompt_device(
|
||||
which the caller treats as a request to exit.
|
||||
"""
|
||||
scanned = list(devices)
|
||||
#: The row the *next* redraw should open on, when the pass just finished moved the list
|
||||
#: under the reader (see :func:`_after_hiding`). Cleared as soon as it is spent.
|
||||
focus: DiscoveredDevice | None = None
|
||||
while True:
|
||||
remembered = store.load()
|
||||
# The full registry (not just the single last device) so *every* confirmed companion
|
||||
@@ -297,23 +317,40 @@ async def prompt_device(
|
||||
# produced by the scan, so fold those in too — after the scanned set, so a port
|
||||
# pyserial *does* enumerate keeps its richer scanned row rather than the bare profile.
|
||||
listed += _profile_serial_devices(listed, profiles)
|
||||
# Preselect the remembered "last known good" device when it is currently attached/in range.
|
||||
default = next((d for d in listed if remembered and remembered.matches(d)), None)
|
||||
# Devices the user has told this splash to stop showing (h). Read each pass, so a
|
||||
# row hidden a moment ago is gone the next time the list is drawn — which is the
|
||||
# only feedback hiding needs.
|
||||
hidden = store.hidden_ids()
|
||||
# In display order, so a shortcut acting on a row can say what the row *after* it is.
|
||||
ordered = _order([d for d in listed if d.stable_id not in hidden], registry)
|
||||
# Preselect the remembered "last known good" device when it is currently attached/in
|
||||
# range — unless the last pass asked for a particular row, which a hide does so the
|
||||
# highlight lands where the vanished row was rather than jumping back to the default.
|
||||
default = next((d for d in ordered if remembered and remembered.matches(d)), None)
|
||||
if focus is not None:
|
||||
default = focus if focus in ordered else default
|
||||
focus = None
|
||||
|
||||
# Build the rows once so the same list can be redrawn as the backdrop behind a
|
||||
# removal confirm (so it floats over the picker rather than replacing it).
|
||||
items = _build_items(listed, remembered, registry)
|
||||
items = _build_items(ordered, remembered, registry, hidden=len(hidden))
|
||||
chosen = await ui.select_startup(
|
||||
"Select a companion device",
|
||||
items,
|
||||
default=default,
|
||||
banner=load_logo(),
|
||||
keys=_SHORTCUTS,
|
||||
key_hint=_shortcut_hint(len(hidden)),
|
||||
)
|
||||
# Esc (``None``) and the Quit row both mean "leave the picker" — surface that to the
|
||||
# caller as ``None`` so it can exit the program instead of continuing device-less.
|
||||
if chosen is None or chosen is _QUIT:
|
||||
return None
|
||||
|
||||
if isinstance(chosen, KeyRequest):
|
||||
focus = _run_shortcut(store, chosen, ordered)
|
||||
continue
|
||||
|
||||
if chosen is _ADD_TCP:
|
||||
# Name a network companion by hand and smoke-test it. On success it's returned like
|
||||
# any picked device; on cancel/failure we loop back to the list.
|
||||
@@ -344,6 +381,93 @@ async def prompt_device(
|
||||
return chosen
|
||||
|
||||
|
||||
def _run_shortcut(
|
||||
store: DeviceStore, request: KeyRequest, listed: list[DiscoveredDevice]
|
||||
) -> DiscoveredDevice | None:
|
||||
"""Act on ``h`` / ``⇧H``, and say which row the redrawn list should open on.
|
||||
|
||||
Nothing is *said* about either: after a hide the row is gone, after a show-all the hidden
|
||||
rows are back, and a dialog acknowledging a change the reader is looking straight at is
|
||||
the acknowledgement this app takes out of screens rather than adding to them. What the
|
||||
redraw does owe them is their place — see :func:`_after_hiding`.
|
||||
|
||||
Args:
|
||||
store: The registry the hidden set lives in.
|
||||
request: The shortcut the splash resolved with.
|
||||
listed: The devices as displayed, in display order.
|
||||
|
||||
Returns:
|
||||
The device to highlight when the list is redrawn, or ``None`` to let the remembered
|
||||
default decide (which is what a show-all wants: the list it restores is a different
|
||||
list, and the reader's place in the old one means nothing in it).
|
||||
"""
|
||||
if request.action is _SHOW_ALL:
|
||||
store.show_all()
|
||||
return None
|
||||
if request.action is _HIDE and isinstance(request.value, DiscoveredDevice):
|
||||
# Only a device row can be hidden — ``h`` on Add a network device or Quit is inert,
|
||||
# the way Delete is on a row that never opted into removal.
|
||||
store.hide(request.value.stable_id)
|
||||
return _after_hiding(listed, request.value)
|
||||
return None
|
||||
|
||||
|
||||
def _after_hiding(
|
||||
listed: list[DiscoveredDevice], hidden: DiscoveredDevice
|
||||
) -> DiscoveredDevice | None:
|
||||
"""The row the highlight should land on once ``hidden`` leaves the list.
|
||||
|
||||
The next device down, so a run of adapters can be cleared with a run of presses without
|
||||
the hand moving; the one above where there is no next, because the highlight has reached
|
||||
the end and there is nowhere further down to go. Neither ever rolls round to the top —
|
||||
nothing in the app does — and an emptied list has nothing to land on at all.
|
||||
"""
|
||||
remaining = [d for d in listed if d.stable_id != hidden.stable_id]
|
||||
if not remaining:
|
||||
return None
|
||||
at = next((i for i, d in enumerate(listed) if d.stable_id == hidden.stable_id), 0)
|
||||
return remaining[min(at, len(remaining) - 1)]
|
||||
|
||||
|
||||
def _shortcut_hint(hidden: int) -> Callable[[object], str]:
|
||||
"""Name the hide shortcuts that would actually do something right here, right now.
|
||||
|
||||
Asked of the highlighted row on every paint (see ``key_hint`` on
|
||||
:class:`~meshterm.ui.tui.select.SelectScreen`), so the footer follows the same rule
|
||||
``Del remove`` does: a key is named where it acts and nowhere else.
|
||||
|
||||
* ``h hide`` only on a device row — on *Add a network device* or *Quit* there is nothing
|
||||
to hide, and the key is inert there.
|
||||
* ``⇧H show all`` only while something is actually hidden. It is screen-wide rather than
|
||||
per-row (a hidden row is not there to press a key on), so it rides whichever row the
|
||||
reader is standing on — but it stays off the line entirely while it would do nothing,
|
||||
which is also what keeps the splash from ever naming a key nobody could act on.
|
||||
"""
|
||||
|
||||
def atoms_for(value: object) -> str:
|
||||
atoms = []
|
||||
if isinstance(value, DiscoveredDevice):
|
||||
atoms.append("h hide")
|
||||
if hidden:
|
||||
atoms.append("⇧H show all")
|
||||
return " · ".join(atoms)
|
||||
|
||||
return atoms_for
|
||||
|
||||
|
||||
def _hidden_note(hidden: int) -> Separator:
|
||||
"""The muted row that stands in for a list hiding has emptied.
|
||||
|
||||
Only then. With devices still listed, the footer already names ``⇧H`` on every row and a
|
||||
standing line saying the same thing is a row of the box spent on chrome. With *nothing*
|
||||
listed there is no footer atom to read it off — and "no companion devices detected" would
|
||||
be a lie about a machine with a radio plugged into it.
|
||||
"""
|
||||
it = "it" if hidden == 1 else "them"
|
||||
device = "device" if hidden == 1 else "devices"
|
||||
return Separator(f" {hidden} {device} hidden — press ⇧H to show {it}")
|
||||
|
||||
|
||||
def _where_phrase(device: DiscoveredDevice) -> str:
|
||||
"""A human phrase for a device's transport, woven into the smoke-test spinner line."""
|
||||
if device.is_tcp:
|
||||
@@ -541,6 +665,40 @@ async def _remove_network_device(
|
||||
store.forget(device.stable_id)
|
||||
|
||||
|
||||
#: The narrowest the HARDWARE column is worth keeping before the name lane has to give way.
|
||||
#: Below this a model string says nothing at all, and the lane is better spent on the name.
|
||||
_HARDWARE_MIN = 10
|
||||
|
||||
|
||||
def _name_width(
|
||||
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice], other_lanes: int
|
||||
) -> int:
|
||||
"""The DEVICE lane's width: the longest name, capped so HARDWARE keeps a readable column.
|
||||
|
||||
A USB adapter's own product string runs to forty-odd characters ("CP2102 USB to UART
|
||||
Bridge Controller"), and the lane used to size itself to whichever of those was longest.
|
||||
On a 53-column console that pushed every other lane off the right-hand edge — the row
|
||||
ended mid-port, and the HARDWARE column that says what the thing *is* was not on screen
|
||||
at all, nor reachable by scrolling, because the pinned head was already wider than the
|
||||
box. So the name is the lane that gives: it ellipsizes, and the columns after it survive.
|
||||
|
||||
Args:
|
||||
devices: The devices being listed.
|
||||
registry: Confirmed companions, for their remembered names.
|
||||
other_lanes: Cells the PORT and TYPE lanes take between them.
|
||||
|
||||
Returns:
|
||||
The name lane's width in cells.
|
||||
"""
|
||||
platform = get_platform()
|
||||
# What the splash's box gives a row: the terminal, less the gutter it floats over, less
|
||||
# its own border and padding, less the pointer and star columns the row leads with.
|
||||
content = platform.readable_cols - platform.dialog_margin - 4 - 4
|
||||
fixed = other_lanes + 6 # the three two-cell gaps between the four lanes
|
||||
longest = max(cell_len(_display_name(d, registry)) for d in devices)
|
||||
return max(len("DEVICE"), min(longest, content - fixed - _HARDWARE_MIN))
|
||||
|
||||
|
||||
def _order(
|
||||
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice]
|
||||
) -> list[DiscoveredDevice]:
|
||||
@@ -560,6 +718,8 @@ def _build_items(
|
||||
devices: list[DiscoveredDevice],
|
||||
remembered: RememberedDevice | None,
|
||||
registry: dict[str, RememberedDevice],
|
||||
*,
|
||||
hidden: int = 0,
|
||||
) -> list:
|
||||
"""Build the aligned splash rows (a muted header + one :class:`Choice` per device).
|
||||
|
||||
@@ -569,17 +729,33 @@ def _build_items(
|
||||
vendor); columns are padded to a shared width so they align. Confirmed companions sort to
|
||||
the top (most-recent first), wear their name in white and a bright tag; the remembered
|
||||
default is starred. Trailing rows let the user name a network device by hand and quit here.
|
||||
|
||||
A device row's fixed lanes are pinned and its HARDWARE tail scrolls: a firmware model
|
||||
string is as long as its vendor felt like making it, and on a narrow terminal the column
|
||||
that says *what the thing actually is* was the one being cut off. ←→ read it to its end,
|
||||
on the highlighted row alone, and only while that row overflows.
|
||||
|
||||
Args:
|
||||
devices: The devices to list — already less anything hidden.
|
||||
remembered: The last-connected device, starred and preselected when present.
|
||||
registry: Every confirmed companion, keyed by stable id.
|
||||
hidden: How many devices were left out for being hidden. Only the empty state uses
|
||||
it, to tell "nothing is plugged in" apart from "you hid all of it".
|
||||
"""
|
||||
if not devices:
|
||||
# Nothing attached or in range — but a TCP companion can still be reached by hand, so
|
||||
# show a muted note over the same action rows rather than a dead-end.
|
||||
note = Separator(" no companion devices detected — add a network device, or quit")
|
||||
# show a muted note over the same action rows rather than a dead-end. When the list is
|
||||
# empty *because everything in it is hidden*, say that instead: "nothing detected"
|
||||
# would be a lie, and the way back is a key the reader has to be told about.
|
||||
note = (
|
||||
_hidden_note(hidden)
|
||||
if hidden
|
||||
else Separator(" no companion devices detected — add a network device, or quit")
|
||||
)
|
||||
return [note, *_action_rows()]
|
||||
devices = _order(devices, registry)
|
||||
known: set[str] = set(registry)
|
||||
|
||||
name_w = max(cell_len(_display_name(d, registry)) for d in devices)
|
||||
name_w = max(name_w, len("DEVICE"))
|
||||
# The middle column holds a serial port, a BLE address, or a TCP host:port; label it for
|
||||
# whichever kinds are present so a non-serial endpoint never sits under a bare "PORT"
|
||||
# heading (a BLE address and a network host:port both read as an "address").
|
||||
@@ -593,21 +769,26 @@ def _build_items(
|
||||
# The TYPE column holds a small transport badge (at most 3 cells); its heading is wider,
|
||||
# so the four-cell "TYPE" label sets the column width and every badge pads out to it.
|
||||
type_w = len("TYPE")
|
||||
name_w = _name_width(devices, registry, port_w + type_w)
|
||||
hardware_w = max(cell_len(_hardware_label(d, registry)) for d in devices)
|
||||
hardware_w = max(hardware_w, len("HARDWARE"))
|
||||
|
||||
# A muted, aligned header. The leading spaces mirror the row pointer (2) and the star
|
||||
# column (2) so the labels sit above their columns. It leads the device rows as their
|
||||
# landmark, so a long detection list keeps the lane names overhead as it scrolls.
|
||||
# A muted, aligned header, resolved against the render width because it is *pinned*: it
|
||||
# leads the device rows as their landmark, so a long detection list keeps the lane names
|
||||
# overhead as it scrolls — and a pinned row that wraps is drawn outside the body slice,
|
||||
# where the second line costs the list a device. The indent mirrors the row pointer (2)
|
||||
# and the star column (2) so each label sits over its own lane.
|
||||
header = Separator(
|
||||
" "
|
||||
+ _pad("DEVICE", name_w)
|
||||
+ " "
|
||||
+ _pad(port_label, port_w)
|
||||
+ " "
|
||||
+ _pad("TYPE", type_w)
|
||||
+ " "
|
||||
+ "HARDWARE",
|
||||
lambda width: column_header(
|
||||
[
|
||||
Lane("DEVICE", name_w + 2),
|
||||
Lane((port_label, "PORT" if has_serial else "ADDRESS"), port_w + 2),
|
||||
Lane("TYPE", type_w + 2),
|
||||
Lane(("HARDWARE", "HW")),
|
||||
],
|
||||
width,
|
||||
indent=4,
|
||||
),
|
||||
heading=True,
|
||||
)
|
||||
|
||||
@@ -620,7 +801,8 @@ def _build_items(
|
||||
row.append(" ")
|
||||
# A confirmed companion wears its name in white so it stands out from mere detections.
|
||||
row.append(
|
||||
_pad(_display_name(device, registry), name_w), style="device.known" if is_known else ""
|
||||
_pad(fit_cells(_display_name(device, registry), name_w), name_w),
|
||||
style="device.known" if is_known else "",
|
||||
)
|
||||
row.append(" ")
|
||||
row.append(_pad(_where(device), port_w), style="muted")
|
||||
@@ -633,6 +815,11 @@ def _build_items(
|
||||
row.append_text(cell)
|
||||
row.append(" " * max(0, type_w - cell.cell_len))
|
||||
row.append(" ")
|
||||
# Everything up to here is the row's fixed lanes — the reader's place in the list —
|
||||
# so it is pinned and only what follows slides under ←→ (see Choice.hscroll_from).
|
||||
# What follows is the hardware model and its tag, which is the part that runs long:
|
||||
# a firmware model string is as long as its vendor felt like making it.
|
||||
head = row.cell_len
|
||||
row.append(_pad(_hardware_label(device, registry), hardware_w), style="muted")
|
||||
# Only devices we've actually confirmed are billed as MeshCore companions; a USB
|
||||
# vendor ID (or a BLE advert) is a sort hint, not a claim. A bare serial bridge earns
|
||||
@@ -652,7 +839,7 @@ def _build_items(
|
||||
# Only a network device opts into Delete-to-remove: it's listed solely from its
|
||||
# remembered endpoint, so forgetting it is the only way it leaves the picker. A scanned
|
||||
# serial/BLE device would just reappear, so Delete stays inert on those rows.
|
||||
items.append(Choice(title=row, value=device, deletable=device.is_tcp))
|
||||
items.append(Choice(title=row, value=device, deletable=device.is_tcp, hscroll_from=head))
|
||||
items.extend(_action_rows())
|
||||
return items
|
||||
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
"""The shared shapes a report is built out of — one constructor per concept.
|
||||
|
||||
A node means the same thing in ``contacts``, ``monitor``, ``courier list``, ``trace`` and
|
||||
``records``, and it should *look* the same in all five and parse the same in all five. The
|
||||
way to guarantee that is not review, it is construction: every listing that mentions a
|
||||
node asks this module for the column, so there is exactly one definition of what a node's
|
||||
plain lanes are called and what its machine object holds.
|
||||
|
||||
Each constructor returns one :class:`~meshterm.ui.report.Column`, which carries both
|
||||
projections of one typed value. The typed value is what a row holds — a :class:`NodeRef`,
|
||||
a :class:`Position`, a :class:`~datetime.datetime`, a bare float — never a formatted cell,
|
||||
so the plain face can print ``+5.1`` and the machine face ``5.1`` without either one
|
||||
parsing the other's output back.
|
||||
|
||||
The interesting one is :func:`node`, and it is what earns the design: **one machine key
|
||||
expands to as many plain columns as the surface has room for.** ``contacts`` spends four
|
||||
(a name, a role, a hash, a key), ``monitor``'s live stream spends two, a trace's per-hop
|
||||
table spends one — and all four documents carry the same five-key object, because the
|
||||
lanes are a rendering choice and the shape is not.
|
||||
|
||||
Two absences, kept apart on purpose. ``None`` is *we do not know*, and it reads as
|
||||
:data:`~meshterm.ui.script.NONE` plain and ``null`` in JSON. A word standing in for an
|
||||
absence is not the same thing: ``unknown`` in a ``TYPE`` column is a word a reader
|
||||
understands, so the plain face keeps it, while the document writes ``null`` — because a
|
||||
consumer already has one spelling for "there is nothing here" and a second one costs it a
|
||||
special case.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from . import script
|
||||
from .report import Column, Lane, normalise
|
||||
|
||||
# -- the shared value shapes -----------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NodeRef:
|
||||
"""A mesh participant's identity, and nothing else.
|
||||
|
||||
The most repeated concept in either face. *Identity only*: when a node was heard, how
|
||||
strong it came in, where it says it is — those belong to the **row**, because they
|
||||
differ per surface and a node object that carried them would mean something different
|
||||
in every one.
|
||||
|
||||
Attributes:
|
||||
name: The name the node advertises, or ``None`` where none was ever heard —
|
||||
including where a surface substitutes a bare hash for one.
|
||||
key: The full public key, lowercase hex, **never truncated**: a key cut short is
|
||||
not something a caller can hand back to ``--to`` or ``--path``. ``None`` where
|
||||
only a hash was ever heard.
|
||||
hash: The short derived id, at the width the surrounding surface addressed the
|
||||
node by — never ``key`` shortened by this module's own choosing.
|
||||
type: The advertised role in the lexicon's words (``node``, ``repeater``,
|
||||
``room``, ``sensor``), or ``None`` where the advert never said.
|
||||
is_self: Whether this is our own node. The plain face draws it like any other hop,
|
||||
which is the point: "you already know who this is" is true of the person at
|
||||
the prompt and false of whoever reads the file afterwards. The machine face
|
||||
carries it as ``self``, because a parser has no way to work it out at all.
|
||||
"""
|
||||
|
||||
name: str | None = None
|
||||
key: str | None = None
|
||||
hash: str | None = None
|
||||
type: str | None = None
|
||||
is_self: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Position:
|
||||
"""A shared location, in decimal degrees.
|
||||
|
||||
Unrounded in the document, as stored: the plain face's five decimals are a column-width
|
||||
concession and a consumer should not inherit one.
|
||||
"""
|
||||
|
||||
lat: float
|
||||
lon: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ChannelRef:
|
||||
"""One channel slot's identity. The secret is deliberately not in it.
|
||||
|
||||
A channel object travels through listings and send receipts, and a key that rode along
|
||||
in all of them would be a key printed by commands nobody asked to print one. It
|
||||
appears only where the caller explicitly asked (``channels share``), under its own
|
||||
field, beside this.
|
||||
|
||||
Attributes:
|
||||
slot: The 0-based index every ``channels`` subcommand's ``INDEX`` takes.
|
||||
name: The channel name.
|
||||
public: Whether the key is derived from the name (a ``#`` channel or a firmware
|
||||
default) rather than secret.
|
||||
hash: The channel hash, lowercase hex.
|
||||
"""
|
||||
|
||||
slot: int
|
||||
name: str
|
||||
public: bool
|
||||
hash: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rendered:
|
||||
"""A typed value beside the one plain rendering only its own registry can produce.
|
||||
|
||||
Almost every cell is derivable from its value alone, which is what lets a
|
||||
:class:`~meshterm.ui.report.Lane` be a function of one argument. A *setting* is the
|
||||
exception: what ``config set`` takes back for a given key is a question only that key's
|
||||
spec can answer — an enum wants its number, an empty string wants ``""``, a preference
|
||||
drops the unit its page shows — and the spec is per row, not per column.
|
||||
|
||||
So the row carries both halves, computed once where the spec is in hand. The plain
|
||||
face prints ``text`` and the document emits ``value``, and neither has to parse the
|
||||
other's output to get at what it needs, which is the same rule the rest of this module
|
||||
keeps by other means.
|
||||
|
||||
Attributes:
|
||||
value: The typed value, or ``None`` where the source never reported one.
|
||||
text: What the plain face prints, in the form the matching ``set`` accepts back.
|
||||
"""
|
||||
|
||||
value: Any
|
||||
text: str
|
||||
|
||||
|
||||
def node_json(ref: NodeRef | None) -> dict[str, Any] | None:
|
||||
"""One node as its machine object, or ``null`` where there is no node at all."""
|
||||
if ref is None:
|
||||
return None
|
||||
return {
|
||||
"name": ref.name or None,
|
||||
"key": (ref.key or None) and ref.key.lower(),
|
||||
"hash": (ref.hash or None) and ref.hash.lower(),
|
||||
"type": ref.type or None,
|
||||
"self": ref.is_self,
|
||||
}
|
||||
|
||||
|
||||
def position_json(where: Position | None) -> dict[str, Any] | None:
|
||||
"""One position as its machine object, or ``null`` where none was ever shared.
|
||||
|
||||
The whole object is ``null`` rather than a pair of ``null``s: a latitude without a
|
||||
longitude is not half a location, it is no location.
|
||||
"""
|
||||
return None if where is None else {"lat": where.lat, "lon": where.lon}
|
||||
|
||||
|
||||
def channel_json(channel: ChannelRef | None) -> dict[str, Any] | None:
|
||||
"""One channel as its machine object, or ``null`` for an empty slot."""
|
||||
if channel is None:
|
||||
return None
|
||||
return {
|
||||
"slot": channel.slot,
|
||||
"name": channel.name,
|
||||
"public": channel.public,
|
||||
"hash": (channel.hash or None) and channel.hash.lower(),
|
||||
}
|
||||
|
||||
|
||||
# -- the constructors ------------------------------------------------------------------
|
||||
|
||||
#: How each of a node's plain lanes reads its cell. A surface picks the ones it has room
|
||||
#: for; the machine object is the same five keys either way.
|
||||
_NODE_LANES = {
|
||||
"name": lambda ref: script.name(ref.name if ref else None),
|
||||
# The word, not a glyph: `▲` without its colour would need a legend and the CLI has no
|
||||
# legends. `unknown` is a word a reader acts on; the document writes `null`.
|
||||
"type": lambda ref: ref.type if ref and ref.type else "unknown",
|
||||
"hash": lambda ref: ((ref.hash or "").lower() if ref else "") or script.NONE,
|
||||
"key": lambda ref: ((ref.key or "").lower() if ref else "") or script.NONE,
|
||||
}
|
||||
|
||||
|
||||
def node(key: str = "node", *, lanes: Sequence[tuple[str, str]] = (("name", "NAME"),)) -> Column:
|
||||
"""One node: the shared five-key object, and whichever plain lanes fit.
|
||||
|
||||
Args:
|
||||
key: The machine key, and the key the row holds its :class:`NodeRef` at.
|
||||
lanes: ``(part, header)`` pairs naming the plain columns to draw, in order. The
|
||||
parts are ``name``, ``type``, ``hash`` and ``key``. A surface with one column
|
||||
to spend draws the name; a per-hop table draws the hash, because that is what
|
||||
joins it to the route line above it.
|
||||
|
||||
Returns:
|
||||
The column.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=tuple(Lane(header=header, render=_NODE_LANES[part]) for part, header in lanes),
|
||||
json=node_json,
|
||||
)
|
||||
|
||||
|
||||
def position(key: str = "position", header: str = "LOCATION") -> Column:
|
||||
"""A shared location: one plain column, one machine object.
|
||||
|
||||
One column and not two. A coordinate pair is one fact to a reader, it is what goes
|
||||
into a map's search box verbatim, and two columns of ``-`` for every node that has
|
||||
never shared a position is worse than one.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=_position_cell),),
|
||||
json=position_json,
|
||||
)
|
||||
|
||||
|
||||
def channel(
|
||||
key: str = "channel",
|
||||
*,
|
||||
lanes: Sequence[tuple[str, str]] = (("name", "NAME"),),
|
||||
) -> Column:
|
||||
"""One channel slot: the shared four-key object, and whichever plain lanes fit.
|
||||
|
||||
Args:
|
||||
key: The machine key.
|
||||
lanes: ``(part, header)`` pairs; the parts are ``slot``, ``name``, ``type`` and
|
||||
``hash``. ``type`` is the plain face's word for the ``public`` boolean —
|
||||
``public``/``private`` reads where ``true``/``false`` would need the heading
|
||||
to say which way round it runs.
|
||||
"""
|
||||
parts = {
|
||||
"slot": lambda c: script.number(c.slot if c else None),
|
||||
"name": lambda c: script.name(c.name if c else None),
|
||||
"type": lambda c: ("public" if c.public else "private") if c else script.NONE,
|
||||
"hash": lambda c: ((c.hash or "").lower() if c else "") or script.NONE,
|
||||
}
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=tuple(Lane(header=header, render=parts[part]) for part, header in lanes),
|
||||
json=channel_json,
|
||||
)
|
||||
|
||||
|
||||
def when(key: str, header: str, *, absent: str = script.NONE) -> Column:
|
||||
"""A time the reader reads as an age: ``5m``, ``3h``, ``never``.
|
||||
|
||||
The default form for every time in a listing, because "recently?" is the question the
|
||||
reader typed the command to ask and an ISO instant makes them do arithmetic to answer
|
||||
it. ``--absolute`` swaps the whole language back for a run; the document is UTC
|
||||
regardless, since it is read somewhere else and often later.
|
||||
|
||||
Args:
|
||||
key: The machine key. It ends in ``_at`` by convention — a JSON key is a type hint
|
||||
for a parser, and the suffix is the hint.
|
||||
header: The plain heading. It must name the *fact* — ``HEARD``, ``RECORDED`` —
|
||||
rather than the form, because ``--absolute`` changes the form underneath it
|
||||
and a column headed ``AGE`` holding an ISO instant is a heading that lies.
|
||||
absent: What an unknown time reads as plain. ``never`` where the row exists and
|
||||
the event has not happened (a node not yet heard, a conversation with nothing
|
||||
said in it); the default token where the row has no such time at all.
|
||||
|
||||
Returns:
|
||||
The column.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=lambda v: _time(v, absent=absent), align="right"),),
|
||||
)
|
||||
|
||||
|
||||
def instant(key: str, header: str) -> Column:
|
||||
"""A time that *is* the fact, printed absolutely whatever ``--absolute`` says.
|
||||
|
||||
Three surfaces want this and each for the same reason — the instant is the answer, not
|
||||
a way of saying how long ago something was. The device's own clock. An appointment set
|
||||
with ``--at``, which is a wall-clock time the caller chose. And a live capture's own
|
||||
``TIME`` column, every row of which would otherwise read ``now``.
|
||||
"""
|
||||
return Column(key=key, lanes=(Lane(header=header, render=script.stamp),))
|
||||
|
||||
|
||||
def snr(key: str, header: str) -> Column:
|
||||
"""A signal-to-noise reading: ``+5.1`` plain, ``5.1`` in the document.
|
||||
|
||||
The plain sign is a column convention — a lane of readings only reads as a ladder when
|
||||
every one of them declares its direction. A number in JSON carries its own sign and a
|
||||
leading ``+`` would make it a string.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=lambda v: script.number(v, "+.1f"), align="right"),),
|
||||
json=lambda v: None if v is None else round(float(v), 1),
|
||||
)
|
||||
|
||||
|
||||
def integer(key: str, header: str) -> Column:
|
||||
"""A whole number, right-aligned so a column of them is comparable by eye."""
|
||||
return Column(key=key, lanes=(Lane(header=header, render=script.number, align="right"),))
|
||||
|
||||
|
||||
def decimal(key: str, header: str, spec: str = ".1f") -> Column:
|
||||
"""A measured number, formatted for the column and left unrounded in the document."""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=lambda v: script.number(v, spec), align="right"),),
|
||||
)
|
||||
|
||||
|
||||
def flag(key: str, header: str) -> Column:
|
||||
"""A boolean: ``yes``/``no`` for a reader, ``true``/``false`` for a parser.
|
||||
|
||||
``None`` is a third answer and stays one — ``acked`` on a channel message is not
|
||||
``false``, it is *there is no acknowledgement on a channel*, and the two are different
|
||||
things to anyone counting delivery failures.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=_flag_cell),),
|
||||
)
|
||||
|
||||
|
||||
def word(key: str, header: str) -> Column:
|
||||
"""A member of a closed set — a state, a role, an outcome — as its own lowercase word.
|
||||
|
||||
Both faces say the same word, so ``courier send``'s ``no ack`` reads the same in a
|
||||
document as it does on the line the help lists it on.
|
||||
"""
|
||||
return Column(key=key, lanes=(Lane(header=header, render=lambda v: v or script.NONE),))
|
||||
|
||||
|
||||
def name(key: str, header: str) -> Column:
|
||||
"""A bare name that is not a node's — a channel label, a conversation, a device."""
|
||||
return Column(key=key, lanes=(Lane(header=header, render=script.name),))
|
||||
|
||||
|
||||
def free(key: str, header: str) -> Column:
|
||||
"""Free text a stranger filled in: escaped so it can never end its own record.
|
||||
|
||||
Always the last lane in a listing, because it is the one field with no width. The
|
||||
document carries it **raw and unshortened** — a JSON string holds a newline natively,
|
||||
so the escaping is a plain-text necessity rather than something to inflict on a
|
||||
consumer.
|
||||
"""
|
||||
return Column(key=key, lanes=(Lane(header=header, render=script.text),))
|
||||
|
||||
|
||||
def route(key: str = "route", header: str = "ROUTE") -> Column:
|
||||
"""A walked hop sequence: drawn with arrows plain, an array of nodes in the document.
|
||||
|
||||
The row holds :class:`NodeRef` objects in propagation order, so the document's hops are
|
||||
the same objects a listing's rows carry and a consumer's node handler is written once.
|
||||
``None`` where nothing came home — never ``[]``, which would claim a walk of no hops.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(
|
||||
Lane(
|
||||
header=header,
|
||||
render=lambda hops: (
|
||||
script.route([(h.name, h.hash) for h in hops]) if hops else script.NONE
|
||||
),
|
||||
),
|
||||
),
|
||||
json=lambda hops: None if hops is None else [node_json(hop) for hop in hops],
|
||||
)
|
||||
|
||||
|
||||
def spec(key: str = "path", header: str = "path") -> Column:
|
||||
"""A forced path exactly as the radio was given it: ``a1,d4,a1``, or absent.
|
||||
|
||||
The one line on either face that round-trips into ``--path``, so it keeps its commas
|
||||
and grows nothing. ``None`` means the device routed the walk itself: the plain face
|
||||
says ``auto`` because a reader needs a word there, and the document says ``null``
|
||||
because ``auto`` is not a path spec and R3 already has a token for "there is no spec
|
||||
here".
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=lambda v: v if v else "auto"),),
|
||||
)
|
||||
|
||||
|
||||
def rendered(key: str, header: str) -> Column:
|
||||
"""A setting's value: what ``set`` takes back plain, and the typed value in the document.
|
||||
|
||||
Takes a :class:`Rendered` (see there for why this one concept needs both halves in the
|
||||
row). The round-trip claim is the plain face's whole design for these commands —
|
||||
``config show > f`` and feeding ``f`` back in must work — and the document's typed
|
||||
value is the machine face's largest single gain over it: ``9`` and not ``"9"``.
|
||||
"""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=_rendered_cell),),
|
||||
json=lambda r: None if r is None else r.value,
|
||||
)
|
||||
|
||||
|
||||
def hexid(key: str, header: str) -> Column:
|
||||
"""A hash or a key on its own, lowercase, never truncated and never elided."""
|
||||
return Column(
|
||||
key=key,
|
||||
lanes=(Lane(header=header, render=lambda v: (v or "").lower() or script.NONE),),
|
||||
json=lambda v: (v or None) and str(v).lower(),
|
||||
)
|
||||
|
||||
|
||||
def note(key: str, header: str) -> Column:
|
||||
"""Prose for a reader, with no machine face at all.
|
||||
|
||||
A preference's DESCRIPTION is the case: it explains a preference to somebody deciding
|
||||
whether to change it, and there is nothing in it a program would branch on. Declaring
|
||||
it as a column rather than smuggling it in keeps the listing's two faces stated in one
|
||||
place.
|
||||
"""
|
||||
return Column(key=key, lanes=(Lane(header=header, render=script.text),), json=None)
|
||||
|
||||
|
||||
def plain_only(column: Column) -> Column:
|
||||
"""The same concept, drawn for a reader and left out of the document.
|
||||
|
||||
For a lane the machine face says *better* somewhere else rather than not at all. A
|
||||
transcript's ``PEER`` is the case: one column holding whichever of a channel or a
|
||||
contact the conversation had, because a reader has the conversation in front of them —
|
||||
where the document keeps ``channel`` and ``node`` apart, since a parser has to know
|
||||
which of the two it is holding and cannot tell from the label.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
return replace(column, json=None)
|
||||
|
||||
|
||||
def hidden(key: str) -> Column:
|
||||
"""A fact the document carries and the plain face has no room for.
|
||||
|
||||
The mirror of :func:`note`. ``config show`` prints a setting's key and its value —
|
||||
that is what a reader came for — while the document also says what type it is, what an
|
||||
enum's number is called, and whether the value was withheld.
|
||||
"""
|
||||
return Column(key=key)
|
||||
|
||||
|
||||
def _rendered_cell(value: Rendered | None) -> str:
|
||||
"""One setting's cell: the text its own spec produced, or the absent token."""
|
||||
return script.NONE if value is None else value.text
|
||||
|
||||
|
||||
def _position_cell(where: Position | None) -> str:
|
||||
"""One position's cell: the pair, or the absent token."""
|
||||
return script.NONE if where is None else script.location(where.lat, where.lon)
|
||||
|
||||
|
||||
def _flag_cell(value: bool | None) -> str:
|
||||
"""One boolean's cell, keeping ``None`` as an absence rather than a ``no``."""
|
||||
return script.NONE if value is None else ("yes" if value else "no")
|
||||
|
||||
|
||||
def _time(value: Any, *, absent: str) -> str:
|
||||
"""Render a time in whichever language this run speaks (see :func:`when`).
|
||||
|
||||
``absent`` survives the switch to timestamps, because it is not a rendering choice:
|
||||
``never`` says the event has not happened, and ``--absolute`` was asked for a different
|
||||
*form* of time, not for one fewer fact.
|
||||
"""
|
||||
if not isinstance(value, datetime) and value is not None:
|
||||
return normalise(value) # pragma: no cover - a row holding something else is a bug
|
||||
if value is None:
|
||||
return absent
|
||||
return script.stamp(value) if _absolute() else script.age(value, absent=absent)
|
||||
|
||||
|
||||
def _absolute() -> bool:
|
||||
"""Whether this run prints absolute times.
|
||||
|
||||
Read from the preference registry rather than a module flag, because *how MeshTerm
|
||||
behaves* is a preference by this project's own rule — and ``--absolute`` is then
|
||||
simply that preference overridden for one run (see
|
||||
:func:`~meshterm.cli.main_callback`), rather than a second switch saying the same
|
||||
thing in a place a reader cannot find.
|
||||
"""
|
||||
from ..core.preferences import current
|
||||
|
||||
return str(current().cli_time_format) == "absolute"
|
||||
+38
-1
@@ -98,11 +98,18 @@ class Block:
|
||||
heading: Whether this block is one of the page's section landmarks (see
|
||||
:attr:`MarkdownDoc.sections`).
|
||||
label: The heading's plain text, for a landmark; empty otherwise.
|
||||
qr: Whether this block is a ``qr`` fence's scannable code. Marked so a surface
|
||||
that cannot use one can drop it (see :meth:`MarkdownDoc.for_script`).
|
||||
rule: Whether this block is a ``---`` horizontal rule. Marked for the same
|
||||
reason: a rule is drawn to the full width of whatever prints it, and the
|
||||
scripted console is 16384 cells wide.
|
||||
"""
|
||||
|
||||
renderable: RenderableType
|
||||
heading: bool = False
|
||||
label: str = ""
|
||||
qr: bool = False
|
||||
rule: bool = False
|
||||
|
||||
|
||||
class MarkdownDoc:
|
||||
@@ -124,6 +131,34 @@ class MarkdownDoc:
|
||||
"""The page's section headings, in order — the landmarks a jump steps by."""
|
||||
return tuple(block.label for block in self.blocks if block.heading)
|
||||
|
||||
def for_script(self) -> MarkdownDoc:
|
||||
"""The same page with what only a screen can use left out, and no gap where it stood.
|
||||
|
||||
Two blocks are drawn for an eye and are worse than useless in a pipe:
|
||||
|
||||
* A **QR** is a second rendering of a URL the page already prints as a link, drawn
|
||||
for a phone pointed at a screen. Redirected into a file it is a block of block
|
||||
characters wrapped around nothing new.
|
||||
* A **rule** is sized to the console it is printed on, and the scripted console is
|
||||
:data:`~meshterm.ui.script.WIDTH` cells wide — so the ``---`` above a colophon
|
||||
arrives as one 16384-character line, tens of kilobytes of ``─`` in a page of
|
||||
prose. It is also a frame, which the scripted CLI does not draw. The blank line
|
||||
on each side already says what the rule said.
|
||||
|
||||
Returns:
|
||||
A new document; this one is untouched.
|
||||
"""
|
||||
kept: list[Block] = []
|
||||
for block in self.blocks:
|
||||
if block.qr or block.rule:
|
||||
# The blank separator that opened the dropped block goes with it, so
|
||||
# taking one out does not leave two blank lines where it stood.
|
||||
if kept and isinstance(kept[-1].renderable, Text) and not kept[-1].renderable.plain:
|
||||
kept.pop()
|
||||
continue
|
||||
kept.append(block)
|
||||
return MarkdownDoc(kept)
|
||||
|
||||
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
||||
"""Render every block in order (what makes the document a plain renderable)."""
|
||||
for block in self.blocks:
|
||||
@@ -190,8 +225,10 @@ def _document(nodes: Sequence[SyntaxTreeNode]) -> list[Block]:
|
||||
continue
|
||||
heading = node.type == "heading" and node.tag == landmark
|
||||
label = _inline(node.children[0], heading=True).plain if heading else ""
|
||||
qr = node.type == "fence" and node.info.strip() == "qr"
|
||||
rule = node.type == "hr"
|
||||
for rendered in _block(node, indent=INDENT, depth=0):
|
||||
blocks.append(Block(rendered, heading=heading, label=label))
|
||||
blocks.append(Block(rendered, heading=heading, label=label, qr=qr, rule=rule))
|
||||
heading = False # a heading is one block; never mark a stray second
|
||||
return blocks
|
||||
|
||||
|
||||
+57
-28
@@ -527,7 +527,7 @@ class PathLine:
|
||||
if seam is not None and len(groups) > 1:
|
||||
# The outbound leg's own last line still continues into the return, so it
|
||||
# holds the cue back where the whole path's final line doesn't.
|
||||
legs = self._flow(self._hops[:seam], budget, plain, len(cue), len(cue))
|
||||
legs = self._flow(self._hops[:seam], budget, plain, len(cue), len(cue), ends=False)
|
||||
legs += self._flow(self._hops[seam:], budget, plain, len(cue), 0, carry_in=True)
|
||||
if len(legs) <= len(groups): # the turn folds for free — take it
|
||||
groups = legs
|
||||
@@ -582,7 +582,7 @@ class PathLine:
|
||||
full = self.text().cell_len
|
||||
if not self._hops:
|
||||
return Measurement(full, full)
|
||||
cells, _join, tail, lead, _head = self._measure(
|
||||
cells, _join, tail, lead, _head, _foot = self._measure(
|
||||
self._hops, self._resolved_mode() != "powerline"
|
||||
)
|
||||
least = max(cells) + tail + lead + WRAP_OFFSET
|
||||
@@ -590,7 +590,9 @@ class PathLine:
|
||||
|
||||
# --- where the breaks fall --------------------------------------------------------
|
||||
|
||||
def _measure(self, hops: list[PathHop], plain: bool) -> tuple[list[int], int, int, int, int]:
|
||||
def _measure(
|
||||
self, hops: list[PathHop], plain: bool
|
||||
) -> tuple[list[int], int, int, int, int, int]:
|
||||
"""Each hop's own width, plus what joining, opening and closing a line costs.
|
||||
|
||||
Fitting hops to a column is arithmetic once every hop has been measured once —
|
||||
@@ -602,24 +604,26 @@ class PathLine:
|
||||
plain: Measure as arrow text rather than as chips.
|
||||
|
||||
Returns:
|
||||
``(cells, join, tail, lead, head)`` — each hop's cells, the cells one join
|
||||
between two hops costs (chip mode's seam is the single interlocked chevron —
|
||||
and so is either side of an elision gap, so the figure holds there too), the
|
||||
cells a line always closes with (chip mode's edge; nothing in arrow mode), the
|
||||
cells every line *after the first* opens with (chip mode's notch, the seam
|
||||
redrawn), and the cells the *first* line opens with (chip mode's rounded cap,
|
||||
nothing where the font has none).
|
||||
``(cells, join, tail, lead, head, foot)`` — each hop's cells, the cells one
|
||||
join between two hops costs (chip mode's seam is the single interlocked
|
||||
chevron — and so is either side of an elision gap, so the figure holds there
|
||||
too), the cells a line the path *runs on past* closes with (chip mode's
|
||||
point; nothing in arrow mode), the cells every line *after the first* opens
|
||||
with (chip mode's notch, the seam redrawn), and the two outer caps: what the
|
||||
*first* line opens with and what the line that *ends* the path closes with —
|
||||
one cell each where the font has the rounded caps, nothing where it hasn't,
|
||||
a squared end being drawn by appending nothing at all.
|
||||
"""
|
||||
if plain:
|
||||
plain_cells = [self._plain_hop(hop).cell_len for hop in hops]
|
||||
return plain_cells, cell_len(self._separator), 0, 0, 0
|
||||
return plain_cells, cell_len(self._separator), 0, 0, 0, 0
|
||||
widths = [
|
||||
cell_len(hop.label) if hop.gap else self._chip(hop, self._chip_fill(hop)).cell_len
|
||||
for hop in hops
|
||||
]
|
||||
sep = cell_len(POWERLINE_SEP)
|
||||
head = cell_len(POWERLINE_ROUND_OPEN) if powerline_full() else 0
|
||||
return widths, sep, sep, sep, head
|
||||
cap = cell_len(POWERLINE_ROUND_OPEN) if powerline_full() else 0
|
||||
return widths, sep, sep, sep, cap, cap
|
||||
|
||||
@staticmethod
|
||||
def _fill(
|
||||
@@ -631,13 +635,15 @@ class PathLine:
|
||||
last: int,
|
||||
lead: int = 0,
|
||||
head: int = 0,
|
||||
foot: int = 0,
|
||||
ends: bool = True,
|
||||
) -> list[int]:
|
||||
"""Pack hop widths into lines of ``budget`` cells, greedily, never splitting one.
|
||||
|
||||
Args:
|
||||
cells: Each hop's width, in order (see :meth:`_measure`).
|
||||
join: Cells one join between two hops costs.
|
||||
tail: Cells every line closes with.
|
||||
tail: Cells a line the path runs on past closes with.
|
||||
budget: The content column's width in cells.
|
||||
reserve: Cells held back on a line for the continuation cue it will carry.
|
||||
last: Cells held back on a line ending at the *final* hop — ``0`` when the
|
||||
@@ -646,6 +652,12 @@ class PathLine:
|
||||
lead: Cells every line but the first opens with — the reopened seam plus
|
||||
the :data:`WRAP_OFFSET` step it hangs past the indent.
|
||||
head: Cells the first line opens with (the rounded cap, when drawn).
|
||||
foot: Cells the line that *ends the path* closes with (the rounded cap, when
|
||||
drawn) — charged in place of ``tail``, a finished path closing on a cap
|
||||
or, where the font has none, on nothing at all.
|
||||
ends: These hops finish the path, so their last line pays ``foot``. ``False``
|
||||
where they are one leg of a route that goes on and every line, the last
|
||||
included, still closes on the point.
|
||||
|
||||
Returns:
|
||||
How many hops each line takes; a hop too wide for ``budget`` gets a line of
|
||||
@@ -655,9 +667,11 @@ class PathLine:
|
||||
count = 0
|
||||
width = head
|
||||
for i, cell in enumerate(cells):
|
||||
final = i == len(cells) - 1
|
||||
grown = width + cell + (join if count else 0)
|
||||
held = last if i == len(cells) - 1 else reserve
|
||||
if count and grown + tail + held > budget:
|
||||
held = last if final else reserve
|
||||
close = foot if final and ends else tail
|
||||
if count and grown + close + held > budget:
|
||||
sizes.append(count)
|
||||
width, count = lead + cell, 1
|
||||
else:
|
||||
@@ -673,6 +687,7 @@ class PathLine:
|
||||
reserve: int,
|
||||
last: int,
|
||||
carry_in: bool = False,
|
||||
ends: bool = True,
|
||||
) -> list[list[PathHop]]:
|
||||
"""Break ``hops`` into the fewest lines, then even those lines out.
|
||||
|
||||
@@ -692,25 +707,31 @@ class PathLine:
|
||||
last: Cells held back on the line the hops end on (see :meth:`_fill`).
|
||||
carry_in: These hops are a *later* leg of a path already under way, so
|
||||
even their first line opens as a continuation, not as a beginning.
|
||||
ends: These hops finish the path, so their last line closes on the outer cap
|
||||
rather than on the continuation point (see :meth:`_fill`).
|
||||
|
||||
Returns:
|
||||
The hops grouped per line.
|
||||
"""
|
||||
cells, join, tail, lead, head = self._measure(hops, plain)
|
||||
cells, join, tail, lead, head, foot = self._measure(hops, plain)
|
||||
lead += WRAP_OFFSET # every continuation steps in past the hanging indent
|
||||
if carry_in:
|
||||
head = lead
|
||||
lines = len(self._fill(cells, join, tail, budget, reserve, last, lead, head))
|
||||
|
||||
def fill(column: int) -> list[int]:
|
||||
return self._fill(cells, join, tail, column, reserve, last, lead, head, foot, ends)
|
||||
|
||||
lines = len(fill(budget))
|
||||
low, high = 1, budget
|
||||
while low < high: # the narrowest column still taking `lines` rows
|
||||
mid = (low + high) // 2
|
||||
if len(self._fill(cells, join, tail, mid, reserve, last, lead, head)) <= lines:
|
||||
if len(fill(mid)) <= lines:
|
||||
high = mid
|
||||
else:
|
||||
low = mid + 1
|
||||
groups: list[list[PathHop]] = []
|
||||
at = 0
|
||||
for size in self._fill(cells, join, tail, low, reserve, last, lead, head):
|
||||
for size in fill(low):
|
||||
groups.append(hops[at : at + size])
|
||||
at += size
|
||||
return groups
|
||||
@@ -825,12 +846,18 @@ class PathLine:
|
||||
"""Powerline chips: each hop filled with its hue, a two-cell gap at every seam.
|
||||
|
||||
The two outer ends say whether this line *is* the path or only part of it. A
|
||||
line that opens the path opens rounded (squared off where the font has no
|
||||
rounded caps); one that continues a wrapped path opens on the break's other
|
||||
half — which is exactly the seam's own second cell, so a fold looks like the
|
||||
seam it interrupted. A line that ends the path closes rounded; one the path
|
||||
outruns closes on the point, the same "goes on" cue arrow mode spells with a
|
||||
trailing ``→``.
|
||||
line that opens the path opens rounded; one that continues a wrapped path opens
|
||||
on the break's other half — which is exactly the seam's own second cell, so a
|
||||
fold looks like the seam it interrupted. A line that ends the path closes
|
||||
rounded; one the path outruns closes on the point, the same "goes on" cue arrow
|
||||
mode spells with a trailing ``→``.
|
||||
|
||||
Where the font has no rounded caps both outer ends are drawn **square** — the
|
||||
chip's own pad is the edge, and nothing is appended. Square is not a degraded
|
||||
cap, it is the one shape left that says *stop*: the point is spoken for as the
|
||||
continuation cue, so closing a finished path on one claimed a hop had been cut
|
||||
off (JP, 2026-09-09). Only the *opening* used to square itself off, which left
|
||||
every route on a core-only terminal ending on the mark for "there is more".
|
||||
|
||||
An elision (:attr:`PathHop.gap`) interrupts the ribbon rather than joining it: the
|
||||
chip before it closes on the page, the mark sits on the page bare, and the chip
|
||||
@@ -863,8 +890,10 @@ class PathLine:
|
||||
text.append_text(self._chip(hop, fills[i]))
|
||||
if hops[-1].gap:
|
||||
return text # the line ended on the page; there is no chip left to close
|
||||
close = POWERLINE_SEP if carry_on or not rounded else POWERLINE_ROUND_CLOSE
|
||||
text.append(close, style=fills[-1]) # the edge into the page: pointed or round
|
||||
if carry_on:
|
||||
text.append(POWERLINE_SEP, style=fills[-1]) # the point: the path goes on
|
||||
elif rounded:
|
||||
text.append(POWERLINE_ROUND_CLOSE, style=fills[-1]) # the lozenge's far end
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -51,7 +51,7 @@ from .mapcanvas import RGB, MapCanvas
|
||||
from .menus import fit_cells, marked_label, section_heading
|
||||
from .pathgraph import PathLayer, render_path_graph
|
||||
from .pathline import path_line
|
||||
from .theme import name_style, snr_style
|
||||
from .theme import glyph, name_style, snr_style
|
||||
from .tui.render import render_hanging, render_to_ansi
|
||||
from .tui.screen import Screen
|
||||
from .widgets import (
|
||||
@@ -102,6 +102,42 @@ _SIDE_BY_SIDE_MIN = 44
|
||||
_LABEL_W = 12
|
||||
|
||||
|
||||
def discipline_label(category: Category, lane: int) -> str:
|
||||
"""One discipline's mark padded to ``lane`` cells, then its title.
|
||||
|
||||
THE way a board's name is written, so the seven headings — and the seven rows of the
|
||||
delete picker — start their titles in one column instead of two. The marks are not all
|
||||
the same width: ``🛣`` and ``🕸`` sit outside Emoji_Presentation and are one cell where
|
||||
the other five are two, which is what left ``🛣 Longest distance`` a column adrift of
|
||||
``🎯 Farthest node``, and its gap looking closed on a terminal that paints the road
|
||||
wider than it advances (JP, 2026-09-09).
|
||||
|
||||
The same rule as a command row's icon column (:func:`~meshterm.ui.menus.icon_lane`) and
|
||||
deliberately not that function: a command row *drops* its icon where the platform draws
|
||||
no icon lane, and a board's mark is part of its name on both platforms. So the lane is
|
||||
measured over what :func:`~meshterm.ui.theme.glyph` will actually draw here, and the
|
||||
marks stay.
|
||||
|
||||
Args:
|
||||
category: The discipline being named.
|
||||
lane: The mark column's width, from :func:`discipline_lane`.
|
||||
|
||||
Returns:
|
||||
``"<mark><pad><title>"``, its trailing separator space included in the pad.
|
||||
"""
|
||||
mark = glyph(category.icon)
|
||||
return f"{mark}{' ' * max(1, lane - cell_len(mark) + 1)}{category.title}"
|
||||
|
||||
|
||||
def discipline_lane() -> int:
|
||||
"""Cells the widest discipline mark claims on this platform (see :func:`discipline_label`).
|
||||
|
||||
Measured, never written down: the marks change with the platform, and a substitution
|
||||
that later narrows simply tightens every heading by a cell without anything else moving.
|
||||
"""
|
||||
return max(cell_len(glyph(category.icon)) for category in CATEGORIES)
|
||||
|
||||
|
||||
def _drawn_rows(lines: list[str]) -> list[str]:
|
||||
"""``lines`` with the rows nothing actually landed on stripped from either end.
|
||||
|
||||
@@ -577,6 +613,9 @@ async def open_records(ctx: AppContext) -> dict:
|
||||
# its far node). Read once here; the far node is matched against these keys per record.
|
||||
target_counts = ctx.repo.target_trace_counts()
|
||||
|
||||
# The mark column every board heading writes its title after (see discipline_label).
|
||||
mark_lane = discipline_lane()
|
||||
|
||||
# Node positions and types for a record's area drawing, gathered live the same way the
|
||||
# trace tools gather them to score a walk: adverts we've heard, contacts over them. A
|
||||
# record stores canonical ids, so match those the resolver's way (a prefix either side).
|
||||
@@ -694,7 +733,7 @@ async def open_records(ctx: AppContext) -> dict:
|
||||
|
||||
def describe(category: Category) -> list:
|
||||
"""The heading and its word-wrapped description as non-selectable rows."""
|
||||
rows: list = [section_heading(f"{category.icon} {category.title}")]
|
||||
rows: list = [section_heading(discipline_label(category, mark_lane))]
|
||||
desc = category.description
|
||||
if category.needs_positions and self_pos is None:
|
||||
desc += " — needs your location (set it in Config) to score"
|
||||
@@ -776,12 +815,13 @@ async def open_records(ctx: AppContext) -> dict:
|
||||
async def delete_category_flow() -> None:
|
||||
"""Pick a discipline, confirm, and delete its records (every width)."""
|
||||
rows: list = []
|
||||
lane = discipline_lane()
|
||||
for category in CATEGORIES:
|
||||
count = len(ctx.repo.discoveries(category.id))
|
||||
rows.append(
|
||||
Choice(
|
||||
title=Text.assemble(
|
||||
(f"{category.icon} {category.title} ", ""),
|
||||
(f"{discipline_label(category, lane)} ", ""),
|
||||
(f"{count} record{'s' if count != 1 else ''}, all widths", "muted"),
|
||||
),
|
||||
value=category.id,
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""The two faces of one answer: a renderer turns a report into bytes.
|
||||
|
||||
A tool states its answer as a :mod:`~meshterm.ui.report`; this module is where that
|
||||
becomes output, and the CLI boundary picks which one runs (see
|
||||
:func:`~meshterm.cli.main_callback`). Nothing above here knows what a gutter or a comma is,
|
||||
and nothing below here knows what a contact is. That separation is the whole point: a
|
||||
third format later — YAML, CSV, whatever somebody needs — is a new class in this file and
|
||||
not an edit to twenty tools.
|
||||
|
||||
**Plain** (:class:`PlainRenderer`) is for a person at a prompt: aligned columns, ages, a
|
||||
route drawn with arrows, and a ``sysctl -a`` block for a set of facts about one thing. It
|
||||
is written in the vocabulary of :mod:`meshterm.ui.script`, which is where the reasons for
|
||||
each of its rules live.
|
||||
|
||||
**JSON** (:class:`JsonRenderer`) is for a program, often on another machine and often
|
||||
later. It prints **the answer itself** — an array for a listing, an object for a set of
|
||||
facts — compact, one line, UTF-8, no ASCII escaping, keys in the order the report declares
|
||||
them. There is no wrapper: ``meshterm contacts --json | jq '.[].node.name'`` reads what it
|
||||
looks like it reads, and the report a caller needs is still ``$?``, exactly as it is
|
||||
without the flag. So a failure prints **nothing at all** on stdout — the ``meshterm: what
|
||||
went wrong`` line stays on stderr where every utility puts it, and stdout holding a valid
|
||||
document is then a reliable signal that the command worked.
|
||||
|
||||
Two rules cover a report of more than one block, and between them they produce the
|
||||
documents ``trace`` and ``tx-optimize`` are specified with. A **single-block** report *is*
|
||||
its block's document. A **multi-block** report is one object: each :class:`Listing` under
|
||||
its own key, and each :class:`Facts` merged in at the top level — a facts block is the set
|
||||
of facts about the one thing the command answered, so it is the document rather than a
|
||||
compartment inside it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
from . import script
|
||||
from .report import BARE, SILENT, Block, Facts, Lane, Listing, Report
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..context import AppContext
|
||||
|
||||
|
||||
class OutputFormat(str, Enum):
|
||||
"""The formats a scripted run can print its answer in.
|
||||
|
||||
An enum rather than the ``--json`` boolean it replaces, because the boolean was
|
||||
already being asked questions it could not answer — ``tx-optimize`` read it as "may I
|
||||
prompt?" — and because a third member here costs one renderer and no tool changes at
|
||||
all. ``--json`` stays the documented spelling and is sugar for this.
|
||||
"""
|
||||
|
||||
PLAIN = "plain"
|
||||
JSON = "json"
|
||||
|
||||
|
||||
class Renderer:
|
||||
"""Turns a report into bytes. One subclass per format."""
|
||||
|
||||
def render(self, report: Report | None) -> None:
|
||||
"""Print a completed report, or nothing at all when there is none."""
|
||||
raise NotImplementedError
|
||||
|
||||
@contextmanager
|
||||
def stream(self, listing: Listing) -> Iterator[Any]:
|
||||
"""Open a live listing whose rows arrive one at a time.
|
||||
|
||||
The narrow second seam, and it exists because a stream has no end to return from:
|
||||
``monitor`` and ``chat listen`` run until a window closes or somebody interrupts
|
||||
them, so their answer cannot be a value handed back at the end. Two commands use
|
||||
it. Nothing else should — a command that can build a whole report should, because
|
||||
a report is the thing every format already knows how to read.
|
||||
|
||||
Args:
|
||||
listing: The shape of the records to come — its columns, its lane order, and
|
||||
(for the plain face) the width each lane is pinned to. Its ``rows`` are
|
||||
ignored; rows arrive through the yielded callable.
|
||||
|
||||
Yields:
|
||||
A callable taking one typed row mapping.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class PlainRenderer(Renderer):
|
||||
"""The face a person reads: aligned text on the scripted console.
|
||||
|
||||
Attributes:
|
||||
console: The plain console (see :func:`meshterm.ui.script.console`).
|
||||
"""
|
||||
|
||||
def __init__(self, console: Console) -> None:
|
||||
"""Bind the renderer to the console its output goes to."""
|
||||
self.console = console
|
||||
|
||||
def render(self, report: Report | None) -> None:
|
||||
"""Draw each block in order, one blank line between them.
|
||||
|
||||
The separator is counted off what was *drawn* rather than off the block index: a
|
||||
block can render to nothing (a listing with no rows, an acknowledgement whose plain
|
||||
answer is its exit status), and a blank line above the first visible block would be
|
||||
a line of output the command did not have.
|
||||
"""
|
||||
drawn_any = False
|
||||
for block in report or ():
|
||||
drawn = self._block(block)
|
||||
if drawn is None:
|
||||
continue
|
||||
if drawn_any:
|
||||
self.console.print(script.blank())
|
||||
self.console.print(drawn)
|
||||
drawn_any = True
|
||||
|
||||
def _block(self, block: Block) -> Any:
|
||||
"""The renderable for one block, or ``None`` where it prints nothing."""
|
||||
if isinstance(block, Listing):
|
||||
return self._listing(block) if block.rows else None
|
||||
if isinstance(block, Facts):
|
||||
return self._facts(block)
|
||||
raise TypeError(f"unrenderable block: {block!r}") # pragma: no cover - closed set
|
||||
|
||||
def _listing(self, block: Listing) -> Any:
|
||||
"""A listing as its header line and its padded records."""
|
||||
lanes = block.lanes()
|
||||
table = script.columns(
|
||||
*(lane.header for _, lane in lanes),
|
||||
right=[lane.header for _, lane in lanes if lane.align == "right"],
|
||||
)
|
||||
# `config show` is an array to a parser and a `sysctl -a` block to a reader, so it
|
||||
# keeps the columns and drops the heading row. Same table either way — the padding,
|
||||
# the no-wrap and the crop are what make a record splittable, and none of them is
|
||||
# the header's doing.
|
||||
table.show_header = block.headed
|
||||
for row in block.rows:
|
||||
table.add_row(*(lane.render(row.get(column.key)) for column, lane in lanes))
|
||||
return table
|
||||
|
||||
def _facts(self, block: Facts) -> Any:
|
||||
"""A facts block as a key/value listing, one bare value, or nothing."""
|
||||
if block.shape == SILENT:
|
||||
return None
|
||||
if block.shape == BARE:
|
||||
column = next(c for c in block.fields if c.key == block.bare)
|
||||
value = block.values.get(column.key)
|
||||
if value is None:
|
||||
# The bare form prints the value, and there is no value: a node that never
|
||||
# answered, a slot with nothing in it. A lone `-` there would be a line of
|
||||
# output where the exit status is the whole report.
|
||||
return None
|
||||
# As a Text, not a markup string: this is a private key, a share URL or a
|
||||
# remote node's own reply, and Rich would read a bracket in one as a style tag.
|
||||
return Text(column.lanes[0].render(value))
|
||||
rows: list[tuple[str, str]] = []
|
||||
for column in block.fields:
|
||||
value = block.values.get(column.key)
|
||||
if block.omit_absent and (value is None or value == ""):
|
||||
continue
|
||||
for lane in column.lanes:
|
||||
rows.append((lane.header, lane.render(value)))
|
||||
return script.pairs(rows)
|
||||
|
||||
@contextmanager
|
||||
def stream(self, listing: Listing) -> Iterator[Any]:
|
||||
"""Print the header line, then one pinned-lane record per row as it arrives."""
|
||||
lanes = listing.lanes()
|
||||
pinned = script.stream(
|
||||
*((lane.header, _lane_width(lane)) for _, lane in lanes),
|
||||
right=[lane.header for _, lane in lanes if lane.align == "right"],
|
||||
)
|
||||
self.console.print(pinned.header, highlight=False)
|
||||
|
||||
def row(record: Mapping[str, Any]) -> None:
|
||||
cells = [lane.render(record.get(column.key)) for column, lane in lanes]
|
||||
self.console.print(pinned.record(*cells), highlight=False)
|
||||
|
||||
yield row
|
||||
|
||||
|
||||
class JsonRenderer(Renderer):
|
||||
"""The face a program reads: one compact document, or one per streamed record.
|
||||
|
||||
Attributes:
|
||||
console: The plain console, used only for its stream — the document is written
|
||||
through :func:`json.dumps` rather than by Rich, so nothing can pad, wrap,
|
||||
highlight or otherwise adorn it.
|
||||
"""
|
||||
|
||||
def __init__(self, console: Console) -> None:
|
||||
"""Bind the renderer to the console its output goes to."""
|
||||
self.console = console
|
||||
|
||||
def render(self, report: Report | None) -> None:
|
||||
"""Print the report as one document, or nothing when there is no report at all."""
|
||||
if report is None:
|
||||
return
|
||||
blocks = [block for block in report if not block.plain_only]
|
||||
if not blocks:
|
||||
return
|
||||
self._write(self._document(blocks))
|
||||
|
||||
def _document(self, blocks: Sequence[Block]) -> Any:
|
||||
"""Fold the blocks into the one value this command answers with."""
|
||||
if len(blocks) == 1:
|
||||
return self._value(blocks[0])
|
||||
document: dict[str, Any] = {}
|
||||
for block in blocks:
|
||||
if isinstance(block, Facts):
|
||||
document.update(self._facts(block))
|
||||
else:
|
||||
document[block.key] = self._value(block)
|
||||
return document
|
||||
|
||||
def _value(self, block: Block) -> Any:
|
||||
"""One block's document: an array for a listing, an object for a set of facts."""
|
||||
if isinstance(block, Listing):
|
||||
return [self._row(block, row) for row in block.rows]
|
||||
if isinstance(block, Facts):
|
||||
return self._facts(block)
|
||||
raise TypeError(f"unrenderable block: {block!r}") # pragma: no cover - closed set
|
||||
|
||||
@staticmethod
|
||||
def _row(block: Listing, row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""One record, with every declared key present (``null`` where it is absent)."""
|
||||
return {
|
||||
column.key: column.json(row.get(column.key))
|
||||
for column in block.columns
|
||||
if column.json is not None
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _facts(block: Facts) -> dict[str, Any]:
|
||||
"""One facts block as an object, keeping every declared key.
|
||||
|
||||
``omit_absent`` is a *plain* concession — ``info`` leaves out a reading the
|
||||
firmware does not answer for, so a dash never claims the radio reported nothing.
|
||||
A consumer cannot act on that distinction and pays a membership test on every
|
||||
field for it, so the document keeps the key and writes ``null``.
|
||||
"""
|
||||
return {
|
||||
column.key: column.json(block.values.get(column.key))
|
||||
for column in block.fields
|
||||
if column.json is not None
|
||||
}
|
||||
|
||||
def _write(self, document: Any) -> None:
|
||||
"""Write one compact document and terminate it with a newline."""
|
||||
line = json.dumps(
|
||||
document,
|
||||
# A node name in Cyrillic is a node name, not a run of `\uXXXX`. The scripted
|
||||
# console already reconfigures stdout to UTF-8 for exactly this class of
|
||||
# character.
|
||||
ensure_ascii=False,
|
||||
# Compact and one line, so a stream and a one-shot answer are read the same
|
||||
# way and `jq .` can re-indent for a person at no cost. Nothing can un-stream
|
||||
# a document that was pretty-printed across four hundred lines.
|
||||
separators=(",", ":"),
|
||||
)
|
||||
self.console.file.write(line + "\n")
|
||||
self.console.file.flush()
|
||||
|
||||
@contextmanager
|
||||
def stream(self, listing: Listing) -> Iterator[Any]:
|
||||
"""Print one document per record, flushed, as each arrives.
|
||||
|
||||
Every line is the same shape, which is what makes the stream readable without a
|
||||
discriminator: a consumer reads line by line and never has to ask which kind of
|
||||
record it is holding. The closing per-node summary the plain face draws is marked
|
||||
``plain_only`` for that reason — it is derivable from the records above it, and a
|
||||
second shape mid-stream would cost every reader a branch it otherwise never needs.
|
||||
"""
|
||||
|
||||
def row(record: Mapping[str, Any]) -> None:
|
||||
self._write(self._row(listing, record))
|
||||
|
||||
yield row
|
||||
|
||||
|
||||
def for_format(output: OutputFormat, console: Console) -> Renderer:
|
||||
"""The renderer for one output format.
|
||||
|
||||
Args:
|
||||
output: What the caller asked for.
|
||||
console: The console to print on.
|
||||
|
||||
Returns:
|
||||
The renderer.
|
||||
"""
|
||||
return JsonRenderer(console) if output is OutputFormat.JSON else PlainRenderer(console)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def stream(ctx: AppContext, listing: Listing) -> Iterator[Any]:
|
||||
"""Open a live listing on whichever renderer this run is using.
|
||||
|
||||
Args:
|
||||
ctx: The application context, for its output format and console.
|
||||
listing: The stream's shape (see :meth:`Renderer.stream`).
|
||||
|
||||
Yields:
|
||||
A callable taking one typed row mapping.
|
||||
"""
|
||||
with for_format(ctx.output, ctx.console).stream(listing) as row:
|
||||
yield row
|
||||
|
||||
|
||||
def _lane_width(lane: Lane) -> int:
|
||||
"""How wide a streamed lane is pinned.
|
||||
|
||||
A live capture cannot size its columns the way :func:`~meshterm.ui.script.columns`
|
||||
does, because it has not seen the records yet. So each lane declares the width it is
|
||||
pinned to, and a value wider than that overruns and pushes the rest of its row right
|
||||
rather than being cut — which is why the one unbounded field goes last (see
|
||||
:class:`meshterm.ui.script.Lanes`).
|
||||
"""
|
||||
return max(len(lane.header), lane.width)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""What a tool answers with, stated as data before anything decides how it looks.
|
||||
|
||||
A scripted run used to say its answer by *printing* it: ``Tool.run`` called
|
||||
``ctx.ui.show(<a table built by ui/script.py>)`` and returned a :class:`ToolResult`
|
||||
carrying only bookkeeping. By the time the CLI boundary saw that result the contacts had
|
||||
already gone down the pipe as a space-aligned table, and there was nothing left to hand a
|
||||
second format. That is exactly why ``--json`` reached two commands out of twenty and
|
||||
stopped: each one that wanted it grew an ``if ctx.json_output:`` branch *above* the
|
||||
rendering, restating the whole answer in a second dialect nobody else could reuse.
|
||||
|
||||
``exit_code`` already showed the shape that works — the tool states it, the boundary turns
|
||||
it into a process status, and no tool has ever called :func:`sys.exit`. This module lets
|
||||
the answer travel the same way:
|
||||
|
||||
**A tool states its answer as data. A renderer turns data into bytes. The CLI
|
||||
boundary picks the renderer** (see :mod:`meshterm.ui.renderers`).
|
||||
|
||||
A report is a short sequence of blocks, and there are only two kinds because the plain
|
||||
face proved only two are needed — a :class:`Listing` (records under a header line) and
|
||||
:class:`Facts` (one thing described key by key). A :class:`Facts` block covers the
|
||||
one-scalar answer too, through :data:`BARE`: ``config get`` prints its value alone
|
||||
*because the caller named the key*, while the machine face still gets the object with the
|
||||
key, the type and the label in it. Splitting that into a third block type would have
|
||||
bought a second name for the same data.
|
||||
|
||||
**A row holds the typed value.** ``6.0``, ``None``, a :class:`~datetime.datetime` — never
|
||||
a formatted cell. Each :class:`Column` carries both projections instead: the plain
|
||||
:class:`Lane` that turns the value into text, and the JSON function that normalises it.
|
||||
One typed row, two projections, and a third format later is a third function rather than a
|
||||
third row-builder. :mod:`meshterm.ui.fields` builds the columns for the concepts the whole
|
||||
app shares — a node, a time, an SNR, a route — so every listing speaks the same shapes by
|
||||
construction rather than by review.
|
||||
|
||||
Nothing here renders. A block does not know what a gutter is, and a column does not know
|
||||
whether anyone will ever ask it for JSON.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
#: How a :class:`Facts` block reaches the *plain* face. The machine face is the same
|
||||
#: object either way — this only chooses what a person at a prompt is shown.
|
||||
PAIRS = "pairs"
|
||||
#: One named field's value, alone, with no key in front of it. What ``config get`` prints:
|
||||
#: the caller named the key, so repeating it back is one more thing to strip off.
|
||||
BARE = "bare"
|
||||
#: Nothing at all. An acknowledgement — ``config advert``, ``channels join`` — whose plain
|
||||
#: report is its exit status. The machine face still gets a document, because "prints
|
||||
#: nothing" is an answer a person can act on and a program cannot.
|
||||
SILENT = "silent"
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
"""The last-resort plain projection: the value as text, or the absent token."""
|
||||
from . import script
|
||||
|
||||
return script.NONE if value is None else str(value)
|
||||
|
||||
|
||||
def normalise(value: Any) -> Any:
|
||||
"""A typed value in its machine form: JSON-ready, and the same on every host.
|
||||
|
||||
The one place the contract's cross-cutting value rules are applied, so a command
|
||||
cannot forget one:
|
||||
|
||||
* a **time** is UTC, RFC 3339, ``Z``-suffixed, to the second — always twenty
|
||||
characters, so string comparison is time comparison. A local offset is a fact about
|
||||
the machine that ran the command rather than about the event, and two hosts asking
|
||||
the same radio the same question must produce the same document. A *naive* time is
|
||||
one whose offset we do not actually know, which is not a fact, so it reads as absent
|
||||
exactly as it does on the plain face.
|
||||
* an **enum** is its value, not its Python name.
|
||||
* a **path** is its text.
|
||||
* everything else travels as it is, typed: ``9`` and not ``"9"``, ``false`` and not
|
||||
``"false"``, ``None`` and not the plain face's ``-``.
|
||||
|
||||
Args:
|
||||
value: The typed value from a report row.
|
||||
|
||||
Returns:
|
||||
The value in a form :mod:`json` can write.
|
||||
"""
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
return None
|
||||
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
if isinstance(value, Enum):
|
||||
return normalise(value.value)
|
||||
if isinstance(value, Path):
|
||||
return str(value)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [normalise(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Lane:
|
||||
"""One plain-text column, drawn from its :class:`Column`'s typed value.
|
||||
|
||||
A lane is the *reader's* half of a column. Several may share one column where the
|
||||
plain face spends more room on a concept than the machine face does — a node is one
|
||||
JSON object and up to four plain columns (its name, its role, its hash, its key),
|
||||
which is the whole reason a column owns a list of these rather than one header string.
|
||||
|
||||
Attributes:
|
||||
header: The uppercase heading, and the field a caller greps for. Renaming one
|
||||
silently breaks a script, so it is as much a contract as the exit status.
|
||||
render: Turns the column's typed value into this lane's cell.
|
||||
align: ``"right"`` for a lane of magnitudes — a column of numbers or ages is only
|
||||
comparable by eye once the digits line up.
|
||||
width: The cells this lane is pinned to in a *live* listing, where the widths
|
||||
cannot be measured because the records have not arrived yet. Ignored by an
|
||||
ordinary listing, which sizes every lane to its widest value.
|
||||
"""
|
||||
|
||||
header: str
|
||||
render: Callable[[Any], str] = _text
|
||||
align: str = "left"
|
||||
width: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Column:
|
||||
"""One concept in a report, with a projection for each face.
|
||||
|
||||
Attributes:
|
||||
key: The name this concept goes under: the JSON key, and the key a row's mapping
|
||||
holds its typed value at.
|
||||
lanes: The plain columns this concept draws — usually one. **Empty means the
|
||||
concept has no plain face**, which is how ``config show`` carries a setting's
|
||||
type and enum label into the document while printing the two fields a reader
|
||||
wants.
|
||||
json: The typed value's machine projection, or ``None`` where the concept has no
|
||||
machine face — the mirror of an empty ``lanes``, and what a preference's
|
||||
DESCRIPTION is: prose for whoever is choosing a value, carrying nothing a
|
||||
program would branch on. Defaults to :func:`normalise`, which handles the
|
||||
scalars; a shared shape (a node, a position) passes its own.
|
||||
"""
|
||||
|
||||
key: str
|
||||
lanes: tuple[Lane, ...] = ()
|
||||
json: Callable[[Any], Any] | None = normalise
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Block:
|
||||
"""One part of an answer.
|
||||
|
||||
Attributes:
|
||||
key: The name this block goes under in a multi-block document.
|
||||
plain_only: Whether this block is a rendering for a person that the machine face
|
||||
has no use for. The one case is a stream's closing summary: ``monitor`` prints
|
||||
a per-node aggregate under its live capture, and a consumer holding every
|
||||
record of that capture can compute the same thing — where a second *shape* of
|
||||
line in the middle of an otherwise homogeneous NDJSON stream costs every
|
||||
reader a discriminator it would otherwise never need.
|
||||
"""
|
||||
|
||||
key: str
|
||||
plain_only: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Listing(Block):
|
||||
"""Records under a header line: a listing plain, an array of objects in JSON.
|
||||
|
||||
Attributes:
|
||||
columns: The concepts each record carries, in machine-face order.
|
||||
rows: One mapping per record, keyed by ``Column.key`` and holding **typed**
|
||||
values.
|
||||
order: The lane headers in plain draw order. Empty means "as declared", which is
|
||||
what most listings want; a listing states it where the two orders genuinely
|
||||
differ — ``contacts`` puts HEARD and PKTS between a node's TYPE and its HASH,
|
||||
and ``monitor`` puts the one unbounded field (a node's name) last so it can
|
||||
never push a column.
|
||||
headed: Whether the plain face draws the header line. ``config show`` is the one
|
||||
listing that does not: it is an array of settings to a parser and a
|
||||
``sysctl -a`` block to a reader, and a header line over two columns whose keys
|
||||
are the answer would be furniture.
|
||||
"""
|
||||
|
||||
columns: tuple[Column, ...]
|
||||
rows: Sequence[Mapping[str, Any]] = ()
|
||||
order: tuple[str, ...] = ()
|
||||
headed: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject an ``order`` that does not name this listing's own lanes.
|
||||
|
||||
A typo here draws a listing with a column missing and no complaint, which is the
|
||||
one failure a header line cannot show. Cheaper to raise at construction.
|
||||
"""
|
||||
if not self.order:
|
||||
return
|
||||
known = {lane.header for column in self.columns for lane in column.lanes}
|
||||
if set(self.order) != known or len(self.order) != len(known):
|
||||
raise ValueError(f"{self.key}: order must name every lane exactly once")
|
||||
|
||||
def lanes(self) -> list[tuple[Column, Lane]]:
|
||||
"""The plain columns in draw order, each with the column it reads from."""
|
||||
pairs = [(column, lane) for column in self.columns for lane in column.lanes]
|
||||
if not self.order:
|
||||
return pairs
|
||||
by_header = {lane.header: (column, lane) for column, lane in pairs}
|
||||
return [by_header[header] for header in self.order]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Facts(Block):
|
||||
"""One thing, described key by key: a ``sysctl -a`` block plain, an object in JSON.
|
||||
|
||||
Attributes:
|
||||
fields: The concepts, in the order both faces present them.
|
||||
values: The typed value per ``Column.key``.
|
||||
shape: :data:`PAIRS`, :data:`BARE` or :data:`SILENT` — what the *plain* face
|
||||
prints. The machine face is the object either way.
|
||||
bare: With :data:`BARE`, the one field whose value is printed alone.
|
||||
omit_absent: Whether a field with no value is left out of the plain block
|
||||
entirely. Only ``info`` wants this, and deliberately: its readings are
|
||||
optional device queries, so a missing key says "this radio does not report
|
||||
it" where a dash would claim it reported nothing. The machine face keeps every
|
||||
declared key and writes ``null``, because "does not report" and "reported
|
||||
nothing" are the same fact to a caller and a variable key set costs every
|
||||
consumer a membership test on every field.
|
||||
"""
|
||||
|
||||
fields: tuple[Column, ...]
|
||||
values: Mapping[str, Any] = field(default_factory=dict)
|
||||
shape: str = PAIRS
|
||||
bare: str = ""
|
||||
omit_absent: bool = False
|
||||
|
||||
|
||||
#: A tool's whole answer. Deliberately a bare sequence rather than a class of its own:
|
||||
#: there is nothing a report *does*, and the two rules about how several blocks combine
|
||||
#: belong to the renderer that combines them (see :mod:`meshterm.ui.renderers`).
|
||||
Report = tuple[Block, ...]
|
||||
@@ -0,0 +1,640 @@
|
||||
"""The plain CLI's output language — text for a person at a prompt, aligned and unadorned.
|
||||
|
||||
MeshTerm has two front ends and a third face. The menu is read by a person sitting in
|
||||
front of it, and everything else in ``meshterm/ui/`` is built for that reader: colour that
|
||||
carries meaning, glyphs that stand for concepts, frames that group. ``--json`` is read by
|
||||
a program, often somewhere else and often later. This module is the third: **someone at a
|
||||
prompt who typed a command to find something out, and wants the answer legible in one
|
||||
glance.**
|
||||
|
||||
That is a change of premise. The plain face used to be a serialisation format a person
|
||||
could squint at, and several of its rules existed only to make *splitting* safe — quoted
|
||||
names, absolute timestamps, a comma between path hops. Splitting is the machine face's job
|
||||
now (see :mod:`meshterm.ui.report` and :mod:`meshterm.ui.renderers`), so those rules were
|
||||
paying rent they could not afford. What survives is everything that makes the output a
|
||||
Unix utility's output, and one new rule in place of the quoting.
|
||||
|
||||
* **Alignment is the delimiter.** A column that lines up is already one field to the eye,
|
||||
so a name is bare (:func:`name`). The *escaping* under the quoting is not gone and never
|
||||
can be (:func:`_escaped`): a name is broadcast by its own node and a message body is
|
||||
filled in by a stranger, and neither may end the record it sits in.
|
||||
* **No colour.** ``stdout`` is a pipe more often than a terminal, and a colour that
|
||||
survives into a file is noise in it. The console this module builds has
|
||||
``color_system=None``, so Rich emits no escape sequence at all — not a dim, not a bold.
|
||||
That is stricter than ``no_color``, which keeps the attributes and drops only the hues.
|
||||
* **No wrapping.** A record is a line. Wrapping turns one record into two and puts the
|
||||
second one's fields under the wrong headings, so the console is made :data:`WIDTH` cells
|
||||
wide — far past any real line — and every column is ``no_wrap``. The one deliberate
|
||||
exception is a *written page* (``about``, ``support``), which is drawn on a console
|
||||
:data:`PAGE_WIDTH` cells wide instead: a paragraph is not a record, it has no headings
|
||||
to land under, and unwrapped it is a single 600-cell line no terminal can read.
|
||||
* **No frames.** No panel borders, no table boxes, no rules, no titles. The command the
|
||||
reader typed is the title; a box around the answer is a second one.
|
||||
* **Aligned columns, one header line.** ``ps`` and ``df``'s shape: an uppercase header
|
||||
row, then records, fields padded apart by :data:`GUTTER` spaces. See :func:`columns`.
|
||||
* **A time is an age.** ``5m``, ``3h``, ``never`` — the same ladder the menu's columns use
|
||||
(:func:`age`), because "how long ago" is the question a person is actually asking. An
|
||||
absolute instant survives where the instant *is* the fact: the device clock, a scheduled
|
||||
appointment, a live capture's own clock (:func:`stamp`). ``--absolute`` swaps the whole
|
||||
language back for anyone who wants it, and the machine face is absolute UTC regardless.
|
||||
* **A route is drawn, a path is typed.** The lexicon already splits these: a *path* is an
|
||||
ordered hop spec you compose or force, a *route* is the concrete node sequence a walk
|
||||
took. So a path keeps its commas (:func:`spec`) because it round-trips into ``--path``,
|
||||
and a route takes arrows (:func:`route`) because it is a picture and visibly is not a
|
||||
spec. Rendering both with commas promised a round trip only one of them has.
|
||||
* **One token for "nothing".** :data:`NONE` — a lone ``-`` — in every column, so an absent
|
||||
value never has to be told apart from an empty one, an ``—``, an ``n/a`` or a blank.
|
||||
``never`` is a *value*, not an absence: a node that has never been heard is a fact.
|
||||
* **No trailing whitespace.** Padding the last column to its width would put invisible
|
||||
spaces at the end of every line; the console trims them on the way out
|
||||
(:class:`_Trimmed`).
|
||||
|
||||
Nothing here is reachable from the menu, and nothing in the menu is reachable from here:
|
||||
:class:`~meshterm.ui.surface.PlainUi` is the seam, chosen in
|
||||
:func:`~meshterm.cli.main_callback` by whether a subcommand was named.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console, Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
#: The scripted console's width. Wide enough that Rich never wraps or truncates anything
|
||||
#: MeshTerm can produce (a 64-hex key, a full route, a preference description), leaving
|
||||
#: the reader's own terminal to deal with a line longer than itself. Not ``sys.maxsize``:
|
||||
#: Rich sizes a few structures against the width, and a merely enormous number costs
|
||||
#: nothing where an astronomical one does.
|
||||
WIDTH = 1 << 14
|
||||
|
||||
#: Spaces between columns. Two, so a padded column and its neighbour never touch and a
|
||||
#: lone space is never mistaken for a separator inside an unquoted field.
|
||||
GUTTER = 2
|
||||
|
||||
#: The one token for a value that is absent, unknown, or does not apply. A reader (and a
|
||||
#: parser) learns it once. Never ``—``, ``n/a``, ``never``, or an empty cell.
|
||||
NONE = "-"
|
||||
|
||||
#: The separator between a route's hops, spaced — the same ``→`` the menu's path lines
|
||||
#: draw with (:data:`meshterm.ui.pathline._ARROW`), and device-verified present in the
|
||||
#: PicoCalc console font. It carries the direction the packet travelled, and it is what
|
||||
#: makes the quoting unnecessary: a comma inside a node's name can no longer be read as a
|
||||
#: hop boundary.
|
||||
ARROW = " → "
|
||||
|
||||
#: How wide a *written page* wraps. Not the terminal's width: the same paragraph must read
|
||||
#: the same when it is piped, redirected, or shipped as the machine face's ``text`` field,
|
||||
#: and 72 is the width every screen in this app is already held to.
|
||||
PAGE_WIDTH = 72
|
||||
|
||||
|
||||
#: The characters with a readable escape of their own. Everything else that cannot travel
|
||||
#: in a record falls through to ``\xNN``/``\uNNNN`` below.
|
||||
_BREAKS = {"\\": "\\\\", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
||||
|
||||
#: Codepoints that must never reach stdout unescaped, as a *category* rather than a list:
|
||||
#: every C0 and C1 control, plus the two Unicode separators a terminal also breaks a line
|
||||
#: on. A four-entry table caught the newline and the tab and let ESC through — and an ESC
|
||||
#: inside a node name is a live colour run written into the caller's file, which is the
|
||||
#: one thing "no colour, not a single escape sequence" exists to prevent. A BEL was worse:
|
||||
#: silently dropped, so the printed name was not the advertised one.
|
||||
_UNPRINTABLE = frozenset([*range(0x00, 0x20), 0x7F, *range(0x80, 0xA0), 0x2028, 0x2029])
|
||||
|
||||
|
||||
def _escaped(text: str) -> str:
|
||||
r"""``text`` with everything that cannot travel inside one record folded into an escape.
|
||||
|
||||
A record is a line and a field is a run of printable cells; this is what makes both
|
||||
true of a value that arrived over the air. Named escapes where one reads (``\n``),
|
||||
numeric escapes everywhere else, and the backslash doubled so the whole thing reads
|
||||
back unambiguously.
|
||||
|
||||
This is what survived the retirement of the quoting. The quotes made a name *one
|
||||
field* for a splitter, and alignment does that now; nothing else can stop a name
|
||||
holding a newline from ending its own record early.
|
||||
"""
|
||||
out: list[str] = []
|
||||
for ch in text:
|
||||
if ch in _BREAKS:
|
||||
out.append(_BREAKS[ch])
|
||||
elif ord(ch) in _UNPRINTABLE:
|
||||
out.append(f"\\x{ord(ch):02x}" if ord(ch) < 0x100 else f"\\u{ord(ch):04x}")
|
||||
else:
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def name(value: str | None) -> str:
|
||||
r"""A node's name as a field: bare and escaped, or :data:`NONE` when it has none.
|
||||
|
||||
The quotes are gone. They existed so a name holding a space or a comma stayed one
|
||||
field for whatever was splitting the line; the splitter reads JSON now, and in plain
|
||||
text the column alignment already says where a field ends. What survives is the
|
||||
escaping — a node broadcasts its own name, so every name here is remote data, and a
|
||||
newline in one must still not end the record it sits in.
|
||||
|
||||
An empty name is still :data:`NONE` rather than an empty cell: ``""`` says *called
|
||||
nothing* where the token says *never said*, and those are different facts. It is also
|
||||
the distinction the machine face needs — an absent name is ``null``, an empty one is
|
||||
``""`` — and it cannot be recovered from a field that flattened both.
|
||||
|
||||
Args:
|
||||
value: The name, or ``None``/empty where the node never supplied one.
|
||||
|
||||
Returns:
|
||||
The escaped name, or :data:`NONE`.
|
||||
"""
|
||||
return _escaped(value) if value else NONE
|
||||
|
||||
|
||||
def text(value: str | None) -> str:
|
||||
r"""Free text as a field that can end a record without breaking it.
|
||||
|
||||
For the field a listing puts last because it *is* the rest of the line — a message
|
||||
body, a preference's description. "The rest of the line" and "anything at all" are not
|
||||
compatible: a body holding a newline ended its record early and left the remainder
|
||||
indented under the other columns, reading as a second record with an empty ``TIME``.
|
||||
A body is also the field a stranger fills in, so that is not a hypothetical shape of
|
||||
message.
|
||||
|
||||
Args:
|
||||
value: The free text. ``None`` gives :data:`NONE`.
|
||||
|
||||
Returns:
|
||||
The text with its line breaks and tabs escaped.
|
||||
"""
|
||||
return NONE if value is None else _escaped(value)
|
||||
|
||||
|
||||
def stamp(when: datetime | None) -> str:
|
||||
"""Format a timestamp as local ISO-8601 to the second, or :data:`NONE`.
|
||||
|
||||
Kept for the times where the instant *is* the fact rather than a way of saying how
|
||||
long ago something was: the device's own clock, an appointment set with ``--at``, a
|
||||
live capture's per-row time (every row of which would otherwise read ``now``), and
|
||||
every column under ``--absolute``.
|
||||
|
||||
Stored times are timezone-aware UTC; a naive one is a time whose offset we do not
|
||||
actually know, which is not a fact and so reads as absent — the same rule
|
||||
:func:`~meshterm.ui.widgets._age_seconds` applies to ages.
|
||||
|
||||
Args:
|
||||
when: The instant to format.
|
||||
|
||||
Returns:
|
||||
e.g. ``2026-09-07T18:22:41-04:00``, or :data:`NONE`.
|
||||
"""
|
||||
if when is None or when.tzinfo is None:
|
||||
return NONE
|
||||
return when.astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def age(when: datetime | None, *, absent: str = NONE) -> str:
|
||||
"""How long ago ``when`` was: ``now``, ``5m``, ``3h``, ``2d``, ``4w``.
|
||||
|
||||
The default time form, and the reason is the premise change: a person at a prompt
|
||||
reading ``2026-09-07T19:58:53-04:00`` is doing arithmetic to answer "recently?", which
|
||||
is the question they typed the command to ask. Delegates to
|
||||
:func:`~meshterm.ui.widgets._format_age` so the two faces cannot drift apart.
|
||||
|
||||
Args:
|
||||
when: The instant to age. A naive datetime has no offset we know, so it reads as
|
||||
absent, exactly as :func:`stamp` treats it.
|
||||
absent: What an unknown time reads as. A heard-age passes ``"never"`` — a node
|
||||
that has never been heard is a fact, not a missing field — and everything else
|
||||
takes the default token, which says this row has no such time at all. The same
|
||||
distinction :func:`name` draws between ``-`` and an empty name, one axis over.
|
||||
|
||||
Returns:
|
||||
The age, or ``absent``.
|
||||
"""
|
||||
from .widgets import _age_seconds, _format_age
|
||||
|
||||
seconds = _age_seconds(when)
|
||||
return absent if seconds is None else _format_age(seconds)
|
||||
|
||||
|
||||
def number(value: Any, spec: str = "") -> str:
|
||||
"""Format a number, or :data:`NONE` when it is absent.
|
||||
|
||||
Args:
|
||||
value: The number to format; ``None`` reads as absent.
|
||||
spec: An optional :func:`format` spec, e.g. ``"+.1f"``.
|
||||
|
||||
Returns:
|
||||
The formatted number, or :data:`NONE`.
|
||||
"""
|
||||
if value is None:
|
||||
return NONE
|
||||
return format(value, spec) if spec else str(value)
|
||||
|
||||
|
||||
#: The units :func:`duration` counts in, largest first.
|
||||
_UNITS: tuple[tuple[int, str], ...] = ((86400, "d"), (3600, "h"), (60, "m"), (1, "s"))
|
||||
|
||||
|
||||
def duration(seconds: float | None) -> str:
|
||||
"""A span of seconds as something readable: ``93784`` → ``1d 2h``, ``360`` → ``6m``.
|
||||
|
||||
Two units at most and always adjacent, so the magnitude arrives at a glance and the
|
||||
precision never outruns what anyone would act on. Used only as a *gloss* beside the
|
||||
raw figure (``uptime_s 93784 (1d 2h)``), never in place of it — the key says what
|
||||
the number counts, and a caller reading the key must still find a number under it.
|
||||
|
||||
Args:
|
||||
seconds: The span. ``None`` reads as absent; the sign is dropped, since a caller
|
||||
glossing a signed reading says which way in its own words.
|
||||
|
||||
Returns:
|
||||
The span in one or two units, or :data:`NONE`.
|
||||
"""
|
||||
if seconds is None:
|
||||
return NONE
|
||||
whole = int(abs(seconds))
|
||||
amounts: list[tuple[int, str]] = []
|
||||
for size, unit in _UNITS:
|
||||
amounts.append((whole // size, unit))
|
||||
whole %= size
|
||||
lead = next((i for i, (amount, _) in enumerate(amounts) if amount), len(amounts) - 1)
|
||||
parts = [f"{amounts[lead][0]}{amounts[lead][1]}"]
|
||||
if lead + 1 < len(amounts) and amounts[lead + 1][0]:
|
||||
parts.append(f"{amounts[lead + 1][0]}{amounts[lead + 1][1]}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def location(lat: float | None, lon: float | None) -> str:
|
||||
"""A shared position as one field: ``45.50190,-73.56740``, or :data:`NONE`.
|
||||
|
||||
One column rather than two. A coordinate pair is one fact to a reader, it is what goes
|
||||
into a map's search box verbatim, and two columns of ``-`` for every contact that has
|
||||
never shared a position is worse than one.
|
||||
|
||||
Args:
|
||||
lat: Latitude in decimal degrees.
|
||||
lon: Longitude in decimal degrees.
|
||||
|
||||
Returns:
|
||||
The pair to five decimals, or :data:`NONE` when either half is missing.
|
||||
"""
|
||||
if lat is None or lon is None:
|
||||
return NONE
|
||||
return f"{lat:.5f},{lon:.5f}"
|
||||
|
||||
|
||||
def route(hops: Iterable[tuple[str | None, str | None]]) -> str:
|
||||
"""A walked hop sequence, drawn: ``Yagi-Repeater (a1) → Alice (d4) → Yagi (a1)``.
|
||||
|
||||
A *route* is what a walk actually did, so it is a picture and not a spec — which is
|
||||
why it takes :data:`ARROW` and not the comma ``--path`` accepts. Rendering it with
|
||||
commas made it look pasteable, which it is not: the names are in it, and it is a
|
||||
round trip nothing has.
|
||||
|
||||
The hash rides on every hop and always. It is the join key to the per-hop table below
|
||||
a trace, the token ``--path`` takes, and the disambiguator for two contacts sharing a
|
||||
name. Dropping it "where the name is unique" would make one line's grammar depend on
|
||||
another line's content.
|
||||
|
||||
Our own node is a hop like any other — named and hashed. The menu draws it as ``★``
|
||||
because a reader never has to be told which node is theirs; here the line is as often
|
||||
read out of a file by someone who was not at the prompt when it ran.
|
||||
|
||||
Args:
|
||||
hops: ``(label, hash)`` pairs in propagation order. A hop with both halves reads
|
||||
``Name (hash)``; a hop with one half is that half alone, never an empty
|
||||
``()`` — which is how the menu's :class:`~meshterm.ui.pathline.PathLine`
|
||||
labels an unresolved hop too.
|
||||
|
||||
Returns:
|
||||
The joined route, or :data:`NONE` when there are no hops.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for label, value in hops:
|
||||
if label and value:
|
||||
parts.append(f"{_escaped(label)} ({value})")
|
||||
else:
|
||||
parts.append(_escaped(label) if label else (value or NONE))
|
||||
return ARROW.join(parts) if parts else NONE
|
||||
|
||||
|
||||
def spec(hops: Iterable[str]) -> str:
|
||||
"""A forced path as the radio was given it: ``a1,d4,a1``.
|
||||
|
||||
The other half of the split :func:`route` names. This is the *spec* — comma-separated
|
||||
hashes, exactly what ``--path`` takes back — so it is the one line on either face that
|
||||
round-trips, and it must never grow a name, an arrow or a space.
|
||||
|
||||
Args:
|
||||
hops: The hop hashes in propagation order.
|
||||
|
||||
Returns:
|
||||
The comma-joined spec, or :data:`NONE` when there are no hops.
|
||||
"""
|
||||
parts = [hop for hop in hops if hop]
|
||||
return ",".join(parts) if parts else NONE
|
||||
|
||||
|
||||
def columns(*headers: str, right: Sequence[str] = ()) -> Table:
|
||||
"""Build the scripted table: an uppercase header line, then padded records.
|
||||
|
||||
The shape ``ps`` and ``df`` print — no box, no title, no edge padding, every column
|
||||
sized to its widest value and separated by :data:`GUTTER` spaces, nothing wrapped and
|
||||
nothing elided. Numeric lanes right-align, which is what makes a column of magnitudes
|
||||
comparable by eye without changing what splitting it yields; an *age* lane aligns the
|
||||
same way, so the ladder from ``now`` to ``4w`` reads down the column.
|
||||
|
||||
Args:
|
||||
*headers: The column headings, already uppercase.
|
||||
right: The headings (drawn from ``headers``) whose values right-align.
|
||||
|
||||
Returns:
|
||||
A Rich :class:`Table` ready for ``ctx.ui.show``.
|
||||
"""
|
||||
table = Table(
|
||||
box=None,
|
||||
show_edge=False,
|
||||
pad_edge=False,
|
||||
expand=False,
|
||||
# (top, right, bottom, left): the whole gutter hangs to the right of each cell, so
|
||||
# no line opens with an indent and the last column ends where its value does.
|
||||
padding=(0, GUTTER, 0, 0),
|
||||
)
|
||||
wanted = set(right)
|
||||
for header in headers:
|
||||
table.add_column(
|
||||
header,
|
||||
justify="right" if header in wanted else "left",
|
||||
no_wrap=True,
|
||||
# "crop", not "ignore". Both refuse to elide — neither ever writes the "…"
|
||||
# that would make a key or a route unusable to the caller — but Rich's
|
||||
# ``Text.wrap`` returns early on "ignore" (text.py: ``if overflow ==
|
||||
# "ignore": lines.append(line); continue``) and that early return is *above*
|
||||
# the justify step, so "ignore" silently discards ``justify="right"``. The
|
||||
# two settings differ only past the console's 16384 cells, where "crop" cuts
|
||||
# and "ignore" also cuts; they differ on every line before that, where only
|
||||
# "crop" aligns.
|
||||
overflow="crop",
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
def pairs(rows: Iterable[tuple[str, str]]) -> Table:
|
||||
"""Build the scripted key/value listing: key then value, aligned, no header line.
|
||||
|
||||
What a set of facts about one thing prints as (``meshterm info``, ``config show``,
|
||||
``trace``) — the ``sysctl -a`` shape. Keys are the ones the matching ``get``/``set``
|
||||
subcommand takes, so a line read out of ``show`` can be typed back in.
|
||||
|
||||
Args:
|
||||
rows: ``(key, value)`` pairs in display order.
|
||||
|
||||
Returns:
|
||||
A Rich :class:`Table` with no header line.
|
||||
"""
|
||||
table = Table(
|
||||
box=None,
|
||||
show_edge=False,
|
||||
show_header=False,
|
||||
pad_edge=False,
|
||||
expand=False,
|
||||
padding=(0, GUTTER, 0, 0),
|
||||
)
|
||||
table.add_column(no_wrap=True, overflow="crop")
|
||||
table.add_column(no_wrap=True, overflow="crop")
|
||||
for key, value in rows:
|
||||
table.add_row(key, value)
|
||||
return table
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Lanes:
|
||||
"""A fixed-width column layout for output whose widths arrive with the data.
|
||||
|
||||
:func:`columns` sizes each lane to its widest value, which it can only do once it has
|
||||
seen every record. A live capture has no "once": ``monitor`` prints a row the instant
|
||||
a packet lands, and the row after it may be twice as wide. Those two streams used to
|
||||
be gutter-joined with no alignment at all, which is why a name in one still carried
|
||||
its quotes — nothing else said where the field ended.
|
||||
|
||||
Pinning the lanes up front fixes it properly. A value wider than its lane **overruns
|
||||
and pushes the rest of that row right** rather than being elided: one row out of many
|
||||
is simply wider, and nothing lies about what it holds. Put the one unbounded field
|
||||
(a node's name, a message body) last and it cannot push anything at all.
|
||||
|
||||
Attributes:
|
||||
widths: ``(header, width)`` per lane, in order.
|
||||
right: The headers whose cells right-align.
|
||||
"""
|
||||
|
||||
widths: tuple[tuple[str, int], ...]
|
||||
right: frozenset[str] = frozenset()
|
||||
|
||||
@property
|
||||
def header(self) -> str:
|
||||
"""The one header line, printed before the first record."""
|
||||
return self.record(*(header for header, _ in self.widths))
|
||||
|
||||
def record(self, *cells: str) -> str:
|
||||
"""One record, padded into the lanes and joined by the gutter."""
|
||||
out: list[str] = []
|
||||
for (header, width), cell in zip(self.widths, cells, strict=True):
|
||||
out.append(cell.rjust(width) if header in self.right else cell.ljust(width))
|
||||
return (" " * GUTTER).join(out).rstrip()
|
||||
|
||||
|
||||
def stream(*widths: tuple[str, int], right: Sequence[str] = ()) -> Lanes:
|
||||
"""Build a fixed-lane layout for a live stream (see :class:`Lanes`).
|
||||
|
||||
Args:
|
||||
*widths: ``(header, width)`` per lane, in order.
|
||||
right: The headings whose values right-align.
|
||||
|
||||
Returns:
|
||||
The layout, which prints its own header and formats each record.
|
||||
"""
|
||||
return Lanes(widths=widths, right=frozenset(right))
|
||||
|
||||
|
||||
# -- the console ---------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Trimmed:
|
||||
"""A text stream that drops trailing whitespace from every line passing through it.
|
||||
|
||||
Rich pads a table's cells to their column width, the last column's included, which
|
||||
would leave invisible spaces at the end of every record. Trimming at the stream is the
|
||||
one place that catches all of it — table padding, a justified :class:`Text`, a blank
|
||||
line Rich decided to pad — without any renderable having to know.
|
||||
|
||||
What it holds back is only the *trailing whitespace run*, never a whole line. Rich
|
||||
writes a rendered row segment by segment and, on Windows, flushes after each one, so a
|
||||
wrapper that waited for the newline before deciding would be asked to decide dozens of
|
||||
times a row — and one that trimmed at flush would throw away the very padding that
|
||||
separates two columns. Everything up to the last non-space character goes straight
|
||||
through; the spaces after it wait to find out whether a newline or another column
|
||||
follows. At end of stream nothing follows, and trailing whitespace is exactly what it
|
||||
is then, so the held run is simply dropped.
|
||||
"""
|
||||
|
||||
def __init__(self, stream: Any) -> None:
|
||||
"""Wrap ``stream``, holding back whatever trailing whitespace has arrived."""
|
||||
self._stream = stream
|
||||
self._held = ""
|
||||
|
||||
def write(self, text: str) -> int:
|
||||
"""Write ``text``, emitting whitespace only once something follows it on the line."""
|
||||
pending = self._held + text
|
||||
if "\n" in pending:
|
||||
*lines, pending = pending.split("\n")
|
||||
self._stream.write("".join(line.rstrip() + "\n" for line in lines))
|
||||
body = pending.rstrip()
|
||||
if body:
|
||||
self._stream.write(body)
|
||||
self._held = pending[len(body) :]
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Flush the wrapped stream, keeping the held whitespace held.
|
||||
|
||||
Deliberately *not* a place that emits: a flush lands between two columns as often
|
||||
as at the end of a line, and it carries no information about which.
|
||||
"""
|
||||
self._stream.flush()
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""Delegate everything else (``isatty``, ``encoding``, ...) to the real stream."""
|
||||
return getattr(self._stream, name)
|
||||
|
||||
|
||||
def console(stream: Any = None) -> Console:
|
||||
"""Build the scripted console: no colour, no wrapping, no trailing whitespace.
|
||||
|
||||
Args:
|
||||
stream: The file to write to; defaults to ``sys.stdout``.
|
||||
|
||||
Returns:
|
||||
A :class:`~rich.console.Console` that emits plain text and nothing else.
|
||||
"""
|
||||
import sys
|
||||
|
||||
target = sys.stdout if stream is None else stream
|
||||
reconfigure = getattr(target, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
try: # a name or mark outside cp1252 must not raise on a legacy Windows console
|
||||
reconfigure(encoding="utf-8")
|
||||
except (ValueError, OSError): # pragma: no cover - stream not reconfigurable
|
||||
pass
|
||||
return Console(
|
||||
file=_Trimmed(target),
|
||||
# Not `no_color`, which keeps bold, dim, reverse and the rest and drops only the
|
||||
# hues. `color_system=None` is the one setting under which Rich emits no escape
|
||||
# sequence at all.
|
||||
color_system=None,
|
||||
width=WIDTH,
|
||||
# Rich would otherwise notice a real terminal and soft-wrap to its width.
|
||||
soft_wrap=True,
|
||||
highlight=False,
|
||||
emoji=False,
|
||||
# A node broadcasts its own name, so every name on this console is remote data.
|
||||
# Rich reads `[...]` as a style tag: `[bold]Loud` printed as `Loud` — silently
|
||||
# corrupted, and no longer the string that identifies the node — while `[/]Bob`
|
||||
# raised MarkupError and took the whole command down with it. Nothing the scripted
|
||||
# CLI prints is ever marked up, so the parser has nothing to do here but misfire.
|
||||
markup=False,
|
||||
)
|
||||
|
||||
|
||||
def stderr_console() -> Console:
|
||||
"""The console for everything that is *about* the run rather than part of its answer.
|
||||
|
||||
stdout carries what the command was asked for and nothing else, so a progress bar, a
|
||||
log line, an acknowledgement and an error message all go here instead — visible in a
|
||||
terminal, absent from ``meshterm contacts > contacts.txt``. This one keeps its colour,
|
||||
because a terminal is usually the only thing that reads it.
|
||||
|
||||
**Off a terminal it stops wrapping**, and that is the point of the width below. Rich
|
||||
falls back to 80 cells when it cannot measure the destination, so ``2> errors.log``
|
||||
used to hard-wrap every message at 80 — and ``grep 'not already connected'`` then
|
||||
found nothing, because the sentence it was looking for had been broken across three
|
||||
lines by the logger. In a real terminal the width is left alone: a progress bar sized
|
||||
to 16384 cells is not a progress bar.
|
||||
|
||||
Returns:
|
||||
A themed :class:`~rich.console.Console` writing to ``sys.stderr``.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from .theme import active_theme
|
||||
|
||||
reconfigure = getattr(sys.stderr, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
try: # the ✓ on an acknowledgement must not depend on the machine's code page
|
||||
reconfigure(encoding="utf-8")
|
||||
except (ValueError, OSError): # pragma: no cover - stream not reconfigurable
|
||||
pass
|
||||
try:
|
||||
wraps = bool(sys.stderr.isatty())
|
||||
except (AttributeError, ValueError): # pragma: no cover - a closed or exotic stream
|
||||
wraps = False
|
||||
return Console(
|
||||
file=sys.stderr,
|
||||
theme=active_theme(),
|
||||
markup=False,
|
||||
emoji=False,
|
||||
width=None if wraps else WIDTH,
|
||||
)
|
||||
|
||||
|
||||
# -- the safety net ------------------------------------------------------------------
|
||||
|
||||
|
||||
def flatten(renderable: RenderableType) -> list[RenderableType]:
|
||||
"""Strip framing from anything reaching the scripted console still wearing it.
|
||||
|
||||
Every CLI surface is *written* plain — a tool states its answer as a
|
||||
:mod:`~meshterm.ui.report` and the plain renderer builds it through :func:`columns`
|
||||
and :func:`pairs`. This is the net under that, for a renderable shared with the menu
|
||||
that still arrives boxed: a :class:`Panel` gives up its border and title and yields
|
||||
its body, a :class:`Table` gives up its box, title and expansion, and a :class:`Group`
|
||||
is flattened member by member.
|
||||
|
||||
It is deliberately not a *design*: a screen's table has the menu's columns, not the
|
||||
CLI's, so passing it through here makes it printable, not right.
|
||||
|
||||
Args:
|
||||
renderable: What a tool handed to :meth:`~meshterm.ui.surface.PlainUi.show`.
|
||||
|
||||
Returns:
|
||||
The unframed renderables to print, in order.
|
||||
"""
|
||||
if isinstance(renderable, Panel):
|
||||
return flatten(renderable.renderable)
|
||||
if isinstance(renderable, Table):
|
||||
renderable.title = None
|
||||
renderable.caption = None
|
||||
renderable.box = None
|
||||
renderable.show_edge = False
|
||||
renderable.pad_edge = False
|
||||
renderable.expand = False
|
||||
renderable.padding = (0, GUTTER, 0, 0)
|
||||
for column in renderable.columns:
|
||||
column.no_wrap = True
|
||||
column.overflow = "crop"
|
||||
column.ratio = None
|
||||
return [renderable]
|
||||
if isinstance(renderable, Group):
|
||||
out: list[RenderableType] = []
|
||||
for child in renderable.renderables:
|
||||
out.extend(flatten(child))
|
||||
return out
|
||||
return [renderable]
|
||||
|
||||
|
||||
def blank() -> Text:
|
||||
"""One empty line, for separating a listing from the block after it."""
|
||||
return Text("")
|
||||
+149
-75
@@ -17,14 +17,11 @@ only), so :class:`PlainUi` leaves them unsupported.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from collections.abc import AsyncIterator, Callable, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from rich import box
|
||||
from rich.console import Console, Group, RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from .tui.session import TuiSession
|
||||
@@ -55,6 +52,14 @@ def _dialog_max_cells() -> int:
|
||||
return _DIALOG_MAX_CELLS if get_platform().frame_border else 43
|
||||
|
||||
|
||||
class _NullBusy:
|
||||
"""The scripted surface's stand-in for a busy card: a caption that goes nowhere."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Start with an empty caption; assigning to it is the whole of the contract."""
|
||||
self.message = ""
|
||||
|
||||
|
||||
def _collapse_to_message(buffered: list[RenderableType]) -> Text | None:
|
||||
"""Collapse small, text-only buffered output into one dialog message.
|
||||
|
||||
@@ -94,6 +99,23 @@ class Ui:
|
||||
"""Display a short line of Rich-markup text."""
|
||||
raise NotImplementedError
|
||||
|
||||
def ack(self, markup: str) -> None:
|
||||
"""Acknowledge that an action was carried out — in the menu only.
|
||||
|
||||
The distinction from :meth:`note` is who the line is for. A note is *output*: the
|
||||
answer the caller asked for. An acknowledgement is reassurance that a thing
|
||||
happened — "✓ device clock set", "✓ channel 2 = #general" — which a person watching
|
||||
a screen needs and a script does not, because the exit status already said it and
|
||||
the line would only be something to filter out of the real output.
|
||||
|
||||
Both surfaces show them; what differs is *where*. The menu prints them in its
|
||||
result window. The CLI prints them on **stderr** (see :meth:`PlainUi.ack`), which
|
||||
keeps the promise the dropping was made to keep — ``meshterm contacts > f`` still
|
||||
catches only the answer — while giving the person at the prompt back the ✓ and the
|
||||
count they were losing to a rule written for a redirect they were not doing.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def view(
|
||||
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
|
||||
) -> None:
|
||||
@@ -120,6 +142,18 @@ class Ui:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def busy_dialog(self, message: str = "", *, title: str = ""): # noqa: ANN201 - async CM, varies by backend
|
||||
"""Return an async context manager that floats a **modal** busy card over a screen.
|
||||
|
||||
The counterpart to :meth:`busy_overlay`, for slow work a *screen* starts rather than
|
||||
work that happens in the gap between screens. The overlay is not a screen at all: it
|
||||
draws only on an empty stack and it takes no keys, so a hub that stays pushed while
|
||||
it works gets neither the card nor the protection. This one is pushed and modal — see
|
||||
:meth:`~meshterm.ui.tui.session.TuiSession.busy_dialog` for what that buys. In
|
||||
scripted CLI mode it does nothing, as the overlay does.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def select(
|
||||
self,
|
||||
title: str,
|
||||
@@ -147,8 +181,17 @@ class Ui:
|
||||
default: Any = None,
|
||||
banner: Any = None,
|
||||
footnote: str | None = None,
|
||||
footer_hint: str | None = None,
|
||||
keys: Mapping[str, Any] | None = None,
|
||||
key_hint: Callable[[Any], str] | None = None,
|
||||
) -> Any:
|
||||
"""Choose one item on a chromeless startup splash; ``None`` if skipped."""
|
||||
"""Choose one item on a chromeless startup splash; ``None`` if skipped.
|
||||
|
||||
``keys`` declares bare-key shortcuts the splash answers, resolving with a
|
||||
:class:`~meshterm.ui.tui.select.KeyRequest` (the picker's hide/show-all pair), and
|
||||
``key_hint`` names them per highlighted row so the footer only advertises a key
|
||||
where it would act. ``footer_hint`` replaces the base sentence.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def confirm_startup(
|
||||
@@ -291,69 +334,14 @@ class Ui:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _heading(title: str | Text) -> Text:
|
||||
"""Render a Panel's or Table's hoisted title as an accent heading line.
|
||||
|
||||
The ``accent`` theme style (bold indigo) is the one bit of emphasis that earns its
|
||||
keep here: it marks where each block starts so a scripted run stays scannable, without
|
||||
reaching for the interactive menu's framing. It must stay a bare theme name — Rich
|
||||
can't combine the ``bold`` keyword with a named style in one string — but ``accent``
|
||||
already carries bold, so nothing is lost.
|
||||
"""
|
||||
text = title if isinstance(title, Text) else Text.from_markup(str(title))
|
||||
text.style = "accent"
|
||||
return text
|
||||
|
||||
|
||||
def _deframe(renderable: RenderableType) -> list[RenderableType]:
|
||||
"""Strip a renderable's chrome for direct terminal output.
|
||||
|
||||
The scripted CLI prints straight to the terminal, with no result window to hold the
|
||||
output, so a tool's heavy Rich framing — a :class:`Panel`'s border, a :class:`Table`'s
|
||||
full box and centered title — is chrome between the reader and the data. This lightens
|
||||
it to the middle ground: enough styling to stay legible, none of the interactive menu's
|
||||
weight. A Panel becomes its title as a plain heading above its (also de-framed) body; a
|
||||
Table keeps just a single rule under its header (:data:`~rich.box.SIMPLE_HEAD`) so the
|
||||
columns still read, and hoists its title to the same plain heading; a :class:`Group` is
|
||||
de-framed member by member. Everything else passes through untouched.
|
||||
|
||||
The tables and panels are built fresh for each scripted run and discarded after
|
||||
printing, so mutating them in place (clearing ``box``/``title``) is safe here.
|
||||
|
||||
Args:
|
||||
renderable: A renderable a tool handed to :meth:`PlainUi.show` or :meth:`view`.
|
||||
|
||||
Returns:
|
||||
The frame-light renderables to print, in order.
|
||||
"""
|
||||
if isinstance(renderable, Panel):
|
||||
out: list[RenderableType] = []
|
||||
if renderable.title:
|
||||
out.append(_heading(renderable.title))
|
||||
out.extend(_deframe(renderable.renderable))
|
||||
return out
|
||||
if isinstance(renderable, Table):
|
||||
out = []
|
||||
if renderable.title:
|
||||
out.append(_heading(renderable.title))
|
||||
renderable.title = None
|
||||
renderable.box = box.SIMPLE_HEAD
|
||||
out.append(renderable)
|
||||
return out
|
||||
if isinstance(renderable, Group):
|
||||
out = []
|
||||
for child in renderable.renderables:
|
||||
out.extend(_deframe(child))
|
||||
return out
|
||||
return [renderable]
|
||||
|
||||
|
||||
class PlainUi(Ui):
|
||||
"""CLI surface: print directly to the console; interactive prompts are unsupported.
|
||||
"""CLI surface: print straight to the scripted console; interactive prompts are unsupported.
|
||||
|
||||
Output is de-framed on the way out (see :func:`_deframe`): a scripted run prints
|
||||
to the bare terminal, so a tool's Panels and boxed Tables are lightened to plain
|
||||
headings and header-ruled columns — legible and easy to scan, without the framing.
|
||||
The console it prints on emits no colour, wraps nothing, and trims trailing whitespace
|
||||
(:func:`meshterm.ui.script.console`), and everything a tool hands over passes through
|
||||
:func:`~meshterm.ui.script.flatten` on the way out, so no border, box or hoisted title
|
||||
reaches stdout. That fold is the net, not the design: a surface a script is meant to
|
||||
read is *written* in the CLI's own vocabulary — see :mod:`meshterm.ui.script`.
|
||||
"""
|
||||
|
||||
def __init__(self, console: Console) -> None:
|
||||
@@ -365,33 +353,98 @@ class PlainUi(Ui):
|
||||
self.console = console
|
||||
|
||||
def show(self, *renderables: RenderableType) -> None:
|
||||
"""Print each renderable to the console immediately, de-framed (see :func:`_deframe`)."""
|
||||
"""Print each renderable immediately, unframed (see :func:`~meshterm.ui.script.flatten`)."""
|
||||
from . import script
|
||||
|
||||
for renderable in renderables:
|
||||
for item in _deframe(renderable):
|
||||
for item in script.flatten(renderable):
|
||||
self.console.print(item)
|
||||
|
||||
def note(self, markup: str) -> None:
|
||||
"""Print a markup line to the console immediately."""
|
||||
self.console.print(markup)
|
||||
"""Print a markup line to the console immediately, as plain text.
|
||||
|
||||
The parameter is markup — that is the shared surface's contract, and forty-odd
|
||||
callers write it — but the scripted console does not interpret markup: a node
|
||||
broadcasts its own name, and Rich would read ``[...]`` in one as a style tag. So
|
||||
the tags are resolved *here* rather than by the console, and what reaches stdout
|
||||
is the text they were wrapping. Printing them raw put ``[muted]`` and ``[/muted]``
|
||||
around the private key that ``config export-key > key.hex`` is supposed to be the
|
||||
whole content of.
|
||||
|
||||
Malformed markup — which is what a *name* holding a bracket looks like — is not an
|
||||
error to report but a string to print, so it falls through verbatim.
|
||||
"""
|
||||
from rich.errors import MarkupError
|
||||
from rich.markup import render
|
||||
|
||||
try:
|
||||
self.console.print(render(markup).plain)
|
||||
except MarkupError:
|
||||
self.console.print(markup)
|
||||
|
||||
def ack(self, markup: str) -> None:
|
||||
"""Print it on stderr, where everything *about* the run goes.
|
||||
|
||||
These used to be dropped, and the reason was sound as far as it went: an
|
||||
acknowledgement is not the answer, and a caller redirecting stdout must catch only
|
||||
the answer. But stdout is not the only stream — putting them on stderr honours that
|
||||
rule exactly, and a person watching a five-minute ``config restore`` gets the
|
||||
setting-by-setting ✓ they had no way to see.
|
||||
|
||||
The markup is resolved here rather than by the console, as :meth:`note` explains,
|
||||
and a *name* holding a square bracket is not an error to report but a string to
|
||||
print — so malformed markup falls through verbatim.
|
||||
"""
|
||||
from rich.errors import MarkupError
|
||||
from rich.markup import render
|
||||
|
||||
from . import script
|
||||
|
||||
try:
|
||||
line: Text | str = render(markup)
|
||||
except MarkupError:
|
||||
line = markup
|
||||
script.stderr_console().print(line, highlight=False)
|
||||
|
||||
async def view(
|
||||
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
|
||||
) -> None:
|
||||
"""Print the renderable immediately, de-framed (there is no windowing in CLI mode)."""
|
||||
for item in _deframe(renderable):
|
||||
"""Print the renderable immediately, unframed (there is no windowing in CLI mode)."""
|
||||
from . import script
|
||||
|
||||
for item in script.flatten(renderable):
|
||||
self.console.print(item)
|
||||
|
||||
def progress(self, title: str = "Working"): # noqa: ANN201
|
||||
"""Return the Rich progress bar used for scripted runs."""
|
||||
"""Return a progress bar drawn on stderr, so it never lands in piped output.
|
||||
|
||||
A progress bar is a courtesy to someone watching a long command run, and noise in
|
||||
anything reading the command's output. stderr is where it belongs: visible in a
|
||||
terminal, absent from ``meshterm contacts > contacts.txt``. Rich also draws
|
||||
nothing at all when stderr is not a terminal, so a fully redirected run is silent.
|
||||
"""
|
||||
from . import script
|
||||
from .widgets import make_progress
|
||||
|
||||
return make_progress(self.console)
|
||||
console = script.stderr_console()
|
||||
progress = make_progress(console)
|
||||
progress.disable = not console.is_terminal
|
||||
return progress
|
||||
|
||||
@asynccontextmanager
|
||||
async def busy_overlay(self, message: str = "", *, title: str = "") -> AsyncIterator[None]:
|
||||
"""Do nothing: the scripted CLI has no full-screen surface to float a skeleton over."""
|
||||
yield
|
||||
|
||||
@asynccontextmanager
|
||||
async def busy_dialog(self, message: str = "", *, title: str = "") -> AsyncIterator[_NullBusy]:
|
||||
"""Do nothing: there is no screen to interrupt and no keyboard to take.
|
||||
|
||||
Still yields something with a settable caption, because a caller that retitles the
|
||||
card mid-batch must not have to ask which surface it is running on.
|
||||
"""
|
||||
yield _NullBusy()
|
||||
|
||||
def _no_prompt(self) -> RuntimeError:
|
||||
"""Build the error raised if a rich prompt is reached on the non-interactive path."""
|
||||
return RuntimeError("interactive prompts are only available in the menu")
|
||||
@@ -417,6 +470,9 @@ class PlainUi(Ui):
|
||||
default: Any = None,
|
||||
banner: Any = None,
|
||||
footnote: str | None = None,
|
||||
footer_hint: str | None = None,
|
||||
keys: Mapping[str, Any] | None = None,
|
||||
key_hint: Callable[[Any], str] | None = None,
|
||||
) -> Any:
|
||||
"""Unsupported in scripted CLI mode."""
|
||||
raise self._no_prompt()
|
||||
@@ -594,6 +650,10 @@ class TuiUi(Ui):
|
||||
"""Collect a markup line for the next result window."""
|
||||
self._buffer.append(Text.from_markup(markup))
|
||||
|
||||
def ack(self, markup: str) -> None:
|
||||
"""Collect an acknowledgement — on this surface, exactly a note."""
|
||||
self.note(markup)
|
||||
|
||||
async def present(self, *, title: str = "") -> None:
|
||||
"""Show everything buffered since the last present; window or popup to fit.
|
||||
|
||||
@@ -637,6 +697,10 @@ class TuiUi(Ui):
|
||||
"""Float the session's top-most skeleton card while the wrapped block runs."""
|
||||
return self.session.busy_overlay(message, title=title)
|
||||
|
||||
def busy_dialog(self, message: str = "", *, title: str = ""): # noqa: ANN201
|
||||
"""Push the session's modal busy card over the current screen while the block runs."""
|
||||
return self.session.busy_dialog(message, title=title)
|
||||
|
||||
# --- input ---------------------------------------------------------------
|
||||
|
||||
async def select(
|
||||
@@ -667,10 +731,20 @@ class TuiUi(Ui):
|
||||
default: Any = None,
|
||||
banner: Any = None,
|
||||
footnote: str | None = None,
|
||||
footer_hint: str | None = None,
|
||||
keys: Mapping[str, Any] | None = None,
|
||||
key_hint: Callable[[Any], str] | None = None,
|
||||
) -> Any:
|
||||
"""Delegate to the session's chromeless startup select splash."""
|
||||
return await self.session.select_startup(
|
||||
title, items, default=default, banner=banner, footnote=footnote
|
||||
title,
|
||||
items,
|
||||
default=default,
|
||||
banner=banner,
|
||||
footnote=footnote,
|
||||
keys=keys,
|
||||
key_hint=key_hint,
|
||||
**({} if footer_hint is None else {"footer_hint": footer_hint}),
|
||||
)
|
||||
|
||||
async def confirm_startup(
|
||||
|
||||
@@ -32,8 +32,8 @@ from .prompt import (
|
||||
TypedConfirmDialog,
|
||||
byte_counter,
|
||||
)
|
||||
from .screen import CANCEL, BusyScreen, PopToMenu, Screen, ScrollScreen
|
||||
from .select import Choice, DeleteRequest, SelectScreen, Separator
|
||||
from .screen import CANCEL, BusyDialog, BusyScreen, PopToMenu, Screen, ScrollScreen
|
||||
from .select import Choice, DeleteRequest, KeyRequest, SelectScreen, Separator
|
||||
from .session import TuiSession, Visit
|
||||
from .spinner import Spinner
|
||||
|
||||
@@ -41,10 +41,12 @@ __all__ = [
|
||||
"TuiSession",
|
||||
"Screen",
|
||||
"ScrollScreen",
|
||||
"BusyDialog",
|
||||
"BusyScreen",
|
||||
"SelectScreen",
|
||||
"Choice",
|
||||
"DeleteRequest",
|
||||
"KeyRequest",
|
||||
"Separator",
|
||||
"TextScreen",
|
||||
"ConfirmScreen",
|
||||
|
||||
@@ -376,6 +376,48 @@ def _banner_lines(banner: Sequence[str], cols: int) -> list[str]:
|
||||
return _center(padded, cols)
|
||||
|
||||
|
||||
def fit_hint(hint: str, width: int, *, shed_first: Sequence[str] = ()) -> str:
|
||||
"""Drop ``·`` atoms from a footer hint, right to left, until it fits ``width``.
|
||||
|
||||
The chromeless splash has one row of border for its hint and a box no wider than the
|
||||
terminal less its gutter — 47 cells on the PicoCalc — while the hint itself grows as the
|
||||
highlight moves onto a row with more keys to offer. Cutting the line at the edge takes
|
||||
the *end* of it, and the end is ``Esc``: the one atom that must survive, being the only
|
||||
way out. So atoms are dropped instead, from the right and never the last one, which
|
||||
spends the cells on the keys nearest the front of the sentence — the ones that move and
|
||||
commit — and gives up the optional ones a screen advertises elsewhere.
|
||||
|
||||
``shed_first`` overrides that order for atoms whose key the reader would find anyway. A
|
||||
scroll atom is the example: ←→ are the keys already named by the leading move atom, so
|
||||
trying them costs nothing and teaches the rest, while a bare-letter shortcut is
|
||||
unguessable and has nowhere else to be advertised on a chromeless splash. Given the
|
||||
choice between those two, the sentence keeps the letter.
|
||||
|
||||
Args:
|
||||
hint: The composed hint sentence.
|
||||
width: Cells available.
|
||||
shed_first: Atoms to drop before any others, in the order given.
|
||||
|
||||
Returns:
|
||||
The hint, or as much of its head plus its final atom as fits (ellipsized only if
|
||||
even those cannot).
|
||||
"""
|
||||
if cell_len(hint) <= width:
|
||||
return hint
|
||||
atoms = hint.split(" · ")
|
||||
for spare in shed_first:
|
||||
if cell_len(" · ".join(atoms)) <= width:
|
||||
break
|
||||
if spare in atoms:
|
||||
atoms.remove(spare)
|
||||
while len(atoms) > 2 and cell_len(" · ".join(atoms)) > width:
|
||||
del atoms[-2] # the last atom is Esc's; drop what sits in front of it
|
||||
fitted = " · ".join(atoms)
|
||||
if cell_len(fitted) <= width:
|
||||
return fitted
|
||||
return crop_cells(Text(fitted), 0, max(1, width - 1)).plain + "…"
|
||||
|
||||
|
||||
def compose_startup(screen: Screen, cols: int, rows: int) -> str:
|
||||
"""Compose a chromeless splash: a centered wordmark above a content-sized panel.
|
||||
|
||||
@@ -416,6 +458,10 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
|
||||
sizing_footer = getattr(screen, "sizing_footer_hint", screen.footer_hint)
|
||||
inner_w = max(measured, cell_len(screen.title), cell_len(sizing_footer))
|
||||
inner_w = max(10, min(inner_w, cols - 6))
|
||||
# The hint is fitted to the box rather than cut off by it (see :func:`fit_hint`).
|
||||
hint_text = fit_hint(
|
||||
screen.footer_hint, inner_w, shed_first=getattr(screen, "spare_hint_atoms", ())
|
||||
)
|
||||
|
||||
# The probe render is the answer whenever it was already made at the width we settled
|
||||
# on — the common case, since a body narrower than the probe *is* what set inner_w.
|
||||
@@ -455,10 +501,10 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
|
||||
|
||||
body = Text.from_ansi("\n".join(visible))
|
||||
hint = hint_style("accent")
|
||||
subtitle = f"[{hint}]{screen.footer_hint}[/]"
|
||||
subtitle = f"[{hint}]{hint_text}[/]"
|
||||
if more_above or more_below:
|
||||
arrow = ("↑" if more_above else " ") + ("↓" if more_below else " ")
|
||||
subtitle = f"[{hint}]{arrow} · {screen.footer_hint}[/]"
|
||||
subtitle = f"[{hint}]{arrow} · {fit_hint(hint_text, inner_w - 5)}[/]"
|
||||
panel = Panel(
|
||||
body,
|
||||
title=f"[{title_style('accent')}]{screen.title}[/]" if screen.title else None,
|
||||
|
||||
@@ -17,6 +17,7 @@ from collections.abc import Callable, Sequence
|
||||
from collections.abc import Sequence as _SequenceABC
|
||||
from typing import Any
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
@@ -721,3 +722,47 @@ class BusyScreen(Screen):
|
||||
def handle(self, action: str, data: str = "") -> None:
|
||||
"""Swallow all keys: the splash dismisses itself when the task finishes."""
|
||||
return
|
||||
|
||||
|
||||
class BusyDialog(BusyScreen):
|
||||
"""A busy splash drawn as a box over the screen it interrupts.
|
||||
|
||||
:class:`BusyScreen`'s twin, for work started *from* a screen rather than instead of one:
|
||||
same spinner, same caption, same swallow-everything :meth:`~BusyScreen.handle` — but
|
||||
``floating``, so the hub that started the work stays visible beneath it as a backdrop
|
||||
instead of being replaced.
|
||||
|
||||
Modal by inheritance, and that is the half that matters. A hub screen kept on the stack
|
||||
with ``session.stay`` is *armed the whole time* (see
|
||||
:meth:`~meshterm.ui.tui.session.Visit._arm`), so while it sits there waiting for a slow
|
||||
device write every key a reader presses still reaches it: letters land in its live filter
|
||||
and Esc resolves it, to be handed back the instant the work finishes. A modal screen on
|
||||
top is what stops that — the key reaches this dialog and dies here.
|
||||
|
||||
:meth:`~meshterm.ui.tui.session.TuiSession.busy_overlay` cannot do the job: it is not a
|
||||
screen, so it is never consulted for a keypress, and it paints only when the stack is
|
||||
empty — over a pushed hub it draws nothing at all.
|
||||
"""
|
||||
|
||||
floating = True
|
||||
|
||||
@property
|
||||
def dialog_width(self) -> int:
|
||||
"""Hug the caption rather than stretching to the dialog cap.
|
||||
|
||||
A spinner and four words have no business in a 94-cell box. The compositor still
|
||||
caps this to the terminal (see :func:`~meshterm.ui.tui.frame._dialog_layout`), and
|
||||
the width ratchets, so a caption that grows mid-batch never makes the box shrink
|
||||
back and jitter.
|
||||
"""
|
||||
return cell_len(self._message) + 8 # spinner, its two spaces, and the padded border
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
"""The caption beside the spinner."""
|
||||
return self._message
|
||||
|
||||
@message.setter
|
||||
def message(self, text: str) -> None:
|
||||
"""Retitle the card mid-batch (a sequence of writes reporting which one it is on)."""
|
||||
self._message = text
|
||||
@@ -9,7 +9,7 @@ arbitrary objects, so the same screen drives the main menu (tool names), the dev
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
@@ -119,6 +119,25 @@ class DeleteRequest:
|
||||
value: Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyRequest:
|
||||
"""A shortcut key pressed on a select list, with the row it was pressed on.
|
||||
|
||||
The sibling of :class:`DeleteRequest`, for a list that declares its own bare-key
|
||||
shortcuts (see :class:`SelectScreen`'s ``keys``): the list resolves with this rather
|
||||
than with a row's value, so the caller can tell "the user pressed *this key*" apart
|
||||
from "the user chose this row" and act accordingly before re-opening the list.
|
||||
|
||||
Attributes:
|
||||
action: The token the pressed key was declared for.
|
||||
value: The :attr:`Choice.value` of the highlighted row, or ``None`` where the list
|
||||
had no rows to highlight. A screen-wide shortcut simply ignores it.
|
||||
"""
|
||||
|
||||
action: Any
|
||||
value: Any = None
|
||||
|
||||
|
||||
def _plain(label: str | Text) -> str:
|
||||
"""The plain-text form of a row label, for filtering (a :class:`Text` keeps its ``plain``)."""
|
||||
return label.plain if isinstance(label, Text) else label
|
||||
@@ -300,6 +319,8 @@ class SelectScreen(Screen):
|
||||
filterable: bool = True,
|
||||
hscroll: bool = False,
|
||||
hscroll_hint: str = "←→ scroll",
|
||||
keys: Mapping[str, Any] | None = None,
|
||||
key_hint: Callable[[Any], str] | None = None,
|
||||
) -> None:
|
||||
"""Build a select screen.
|
||||
|
||||
@@ -331,6 +352,22 @@ class SelectScreen(Screen):
|
||||
the move atom) while ``hscroll`` is on and the highlighted row overflows —
|
||||
so ←→ advertises itself exactly when it would do something. Ignored when
|
||||
``hscroll`` is off.
|
||||
keys: Bare-key shortcuts this list answers, mapping the character pressed to a
|
||||
token naming what it means — the idiom
|
||||
:meth:`~meshterm.ui.tui.session.TuiSession.button_dialog` already uses for a
|
||||
dialog's y/n accelerators. The list resolves with a :class:`KeyRequest`
|
||||
carrying the token and the highlighted row's value, so the caller can act
|
||||
and re-open. **Only honoured on a non-filterable list**, where a bare letter
|
||||
has nothing else to do: on a filtering list every letter belongs to the
|
||||
query, and a shortcut stealing one would be a key that silently stops the
|
||||
reader typing a name.
|
||||
key_hint: What those shortcuts are called in the footer, asked of the *highlighted
|
||||
row's value* on every paint and spliced in just before the trailing ``Esc``
|
||||
clause. Return one ` · `-joined run of atoms, or ``""`` for a row none of the
|
||||
shortcuts would touch — the same rule ``delete_hint`` follows, and for the
|
||||
same reason: the footer must never name a key that would do nothing where the
|
||||
reader is standing. The policy lives with the caller because only it knows
|
||||
which rows its keys mean anything on.
|
||||
"""
|
||||
super().__init__()
|
||||
self.title = title
|
||||
@@ -342,6 +379,10 @@ class SelectScreen(Screen):
|
||||
# wherever it is opened, ``ctx.ui.select`` included.
|
||||
self._hscroll = hscroll or any(getattr(item, "hscroll_from", 0) > 0 for item in items)
|
||||
self._hscroll_hint = hscroll_hint
|
||||
# Shortcuts are the non-filterable list's compensation for having no filter: the
|
||||
# letters are free, so a list may spend them (see the ``keys`` argument).
|
||||
self._keys: dict[str, Any] = {} if filterable else dict(keys or {})
|
||||
self._key_hint = key_hint
|
||||
self._hshift = 0
|
||||
self._last_width = 0 # the last render width, for the footer's overflow probe
|
||||
if footer_hint is None:
|
||||
@@ -502,6 +543,8 @@ class SelectScreen(Screen):
|
||||
|
||||
* the :attr:`_delete_hint` atom, spliced just before the trailing ``Esc`` clause
|
||||
(see :func:`_splice_hint`) while the highlighted row is :attr:`Choice.deletable`;
|
||||
* whatever ``key_hint`` names for the highlighted row's value — the shortcut keys
|
||||
(``keys``) that would act on *it*, spliced the same way;
|
||||
* the :attr:`_hscroll_hint` atom (``hscroll`` lists only), inserted right after the
|
||||
move atom (see :func:`_insert_atom`) while the highlighted row overflows the width
|
||||
— a short row scrolls nowhere, so ←→ stays hidden on it.
|
||||
@@ -513,6 +556,10 @@ class SelectScreen(Screen):
|
||||
current = self._current_choice()
|
||||
if self._delete_hint and current is not None and current.deletable:
|
||||
base = _splice_hint(base, self._delete_hint)
|
||||
if self._key_hint is not None:
|
||||
atoms = self._key_hint(current.value if current is not None else None)
|
||||
if atoms:
|
||||
base = _splice_hint(base, atoms)
|
||||
if self._hscroll and self._hscroll_hint and self._selected_overflows():
|
||||
base = _insert_atom(base, self._hscroll_hint)
|
||||
if self._filter:
|
||||
@@ -858,6 +905,12 @@ class SelectScreen(Screen):
|
||||
self._filter = self._filter[:-1]
|
||||
self._index = 0
|
||||
self._hshift = 0
|
||||
elif action == "text" and data in self._keys:
|
||||
# A declared shortcut resolves the list with what was pressed and where the
|
||||
# highlight was standing; only a non-filterable list can declare any.
|
||||
self.resolve(
|
||||
KeyRequest(self._keys[data], choices[self._index].value if choices else None)
|
||||
)
|
||||
elif action == "text" and self._filterable and data.isprintable():
|
||||
# A leading space is ignored (the filter never begins with whitespace); a
|
||||
# trailing one is dropped when matching (see _rows), so spaces count only
|
||||
|
||||
+180
-5
@@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import AsyncIterator, Callable, Sequence
|
||||
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
@@ -25,6 +25,7 @@ from prompt_toolkit.formatted_text import ANSI
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.keys import Keys
|
||||
from prompt_toolkit.layout import ConditionalContainer, Float, FloatContainer, Layout, Window
|
||||
from prompt_toolkit.output.color_depth import ColorDepth
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
@@ -45,7 +46,7 @@ from .prompt import (
|
||||
TypedConfirmDialog,
|
||||
Validator,
|
||||
)
|
||||
from .screen import CANCEL, POP_ALL, BusyScreen, PopToMenu, Screen, ScrollScreen
|
||||
from .screen import CANCEL, POP_ALL, BusyDialog, BusyScreen, PopToMenu, Screen, ScrollScreen
|
||||
from .select import Choice, ReorderScreen, SelectScreen, Separator
|
||||
from .spinner import spinner_interval
|
||||
|
||||
@@ -252,6 +253,70 @@ def _reclaim_last_column() -> bool:
|
||||
return get_platform().width_reclaim
|
||||
|
||||
|
||||
#: What each ``color_depth`` preference value asks for. ``auto`` is deliberately absent: it
|
||||
#: is the one answer that is not a depth, and :func:`_color_depth` resolves it separately.
|
||||
_DEPTH_BY_NAME = {
|
||||
"truecolor": ColorDepth.DEPTH_24_BIT,
|
||||
"256": ColorDepth.DEPTH_8_BIT,
|
||||
"16": ColorDepth.DEPTH_4_BIT,
|
||||
}
|
||||
|
||||
#: What ``COLORTERM`` says on a terminal that means it about 24-bit colour. There is no
|
||||
#: in-band way to ask — a terminal that cannot do truecolor answers a truecolor SGR by
|
||||
#: quietly approximating it, which looks from here exactly like one that can — so this
|
||||
#: convention, which every truecolor terminal follows, is the whole of the evidence.
|
||||
_TRUECOLOR_COLORTERM = frozenset({"truecolor", "24bit"})
|
||||
|
||||
|
||||
def _color_depth() -> ColorDepth | None:
|
||||
"""How many colours to send this terminal, or ``None`` to keep prompt_toolkit's verdict.
|
||||
|
||||
prompt_toolkit picks a depth per *output class*, and its two classes disagree:
|
||||
``Windows10_Output`` returns ``TRUE_COLOR`` outright, while ``Vt100_Output`` returns
|
||||
``DEPTH_8_BIT`` for every ``TERM`` but ``linux`` and a dumb terminal. So the same build
|
||||
of MeshTerm, drawing the same theme, is 24-bit on Windows and 256 colours on macOS and
|
||||
Linux — and it is quantized *silently*, which is why this went unnoticed: the app looks
|
||||
fine, just flatter. It costs exactly what this palette is made of. The seven ``heat.*``
|
||||
steps and the per-node hue wheel (:func:`~meshterm.ui.theme.node_style`) are close
|
||||
pastels chosen to be told apart; snapped to the 216-colour cube, neighbours collide and
|
||||
identities stop being distinguishable by hue. ``COLORTERM`` is not consulted by
|
||||
prompt_toolkit at all — only ``PROMPT_TOOLKIT_COLOR_DEPTH`` is — so a terminal
|
||||
announcing truecolor in the conventional way is still handed 256.
|
||||
|
||||
Three sources, in confidence order, the same shape as :func:`_reclaim_last_column`:
|
||||
``MESHTERM_COLOR_DEPTH`` for a one-off, then the ``color_depth`` preference, then a
|
||||
reading of the environment.
|
||||
|
||||
The reading only ever *raises* the verdict, never lowers it. A terminal we cannot get a
|
||||
positive claim from returns ``None`` and keeps precisely the depth it had, so a host
|
||||
this function has never heard of cannot be made worse by it — which matters more than
|
||||
being right everywhere, because the failure mode of guessing 24-bit at a terminal
|
||||
without it is not a duller palette but no colour at all.
|
||||
|
||||
Returns:
|
||||
The depth to force, or ``None`` to leave prompt_toolkit's own default in place.
|
||||
"""
|
||||
from ...core.preferences import current as current_preferences
|
||||
|
||||
override = os.environ.get("MESHTERM_COLOR_DEPTH")
|
||||
preferred = override if override is not None else current_preferences().color_depth
|
||||
if preferred in _DEPTH_BY_NAME:
|
||||
return _DEPTH_BY_NAME[preferred]
|
||||
if preferred != "auto":
|
||||
return None
|
||||
# A 16-slot console is already right and has no truecolor to claim: the theme addresses
|
||||
# its palette by index (see theme._VT_SLOTS), and promoting the depth would send RGB to
|
||||
# a screen with sixteen colours to put it in.
|
||||
if not get_platform().truecolor:
|
||||
return None
|
||||
if os.environ.get("COLORTERM", "").strip().lower() in _TRUECOLOR_COLORTERM:
|
||||
return ColorDepth.DEPTH_24_BIT
|
||||
# terminfo's own spelling for a direct-colour entry (``xterm-direct``, ``*-direct16m``).
|
||||
if os.environ.get("TERM", "").endswith(("-direct", "-direct16m")):
|
||||
return ColorDepth.DEPTH_24_BIT
|
||||
return None
|
||||
|
||||
|
||||
#: How many stacked dialog layers the layout can float over the background at once. A fixed
|
||||
#: pool of centered-box floats (see :meth:`TuiSession._build_app`), sized well past the deepest
|
||||
#: real nesting — a tool's list, an item's detail popup, and a confirm over that is only three.
|
||||
@@ -431,6 +496,10 @@ class TuiSession:
|
||||
# deliberately *not* on the screen stack: it hovers above every layer and is shown/
|
||||
# hidden by busy_overlay, independent of whatever screens are pushed.
|
||||
self._overlay: BusyOverlay | None = None
|
||||
# The busy card currently pushed by :meth:`busy_dialog`, or None. Unlike the overlay
|
||||
# this one *is* a screen — it has to be, to take the keys the hub underneath would
|
||||
# otherwise bank — and this holds it so a nested wait rides it instead of stacking.
|
||||
self._busy: BusyDialog | None = None
|
||||
# What each drawn layer last composed — ``layer -> (text, carries a wide glyph)`` — so
|
||||
# :meth:`_emit` can tell a frame that actually changed from one the 1 Hz refresh just
|
||||
# re-rendered identically, and can ask whether anything currently on screen needs the
|
||||
@@ -826,18 +895,27 @@ class TuiSession:
|
||||
default: Any = None,
|
||||
banner: Any | None = None,
|
||||
footnote: str | None = None,
|
||||
footer_hint: str = "↑↓ move · Enter select · Esc quit",
|
||||
footer_hint: str = "↑↓ move · Enter select · Esc bye",
|
||||
keys: Mapping[str, Any] | None = None,
|
||||
key_hint: Callable[[Any], str] | None = None,
|
||||
) -> Any:
|
||||
"""Show a chromeless select splash (banner above a content-sized box).
|
||||
|
||||
Like :meth:`select`, but drawn without the header/footer status bars and centered
|
||||
under ``banner`` — the startup device picker's presentation. Type-to-filter is off:
|
||||
under ``banner`` — the startup device picker's presentation. Esc's verb here is
|
||||
``bye``, the app's one send-off: this splash is the door, and the reader leaving it
|
||||
has not started anything to quit out of. Type-to-filter is off:
|
||||
the device list is short and fixed, so stray keys never narrow it. An optional
|
||||
``footnote`` (e.g. a copyright notice) sits muted below the box. When any row opts
|
||||
into removal (a :attr:`~meshterm.ui.tui.select.Choice.deletable` row), a "Del remove"
|
||||
atom joins the footer — but only while the highlight is actually on such a row, so the
|
||||
removal key advertises itself exactly where it acts (see
|
||||
:attr:`~meshterm.ui.tui.select.SelectScreen.footer_hint`).
|
||||
|
||||
``keys`` hands the list bare-key shortcuts — which a splash can afford precisely
|
||||
because it does not filter, so every letter is free (see :class:`SelectScreen`) —
|
||||
and ``key_hint`` says what they are called on the row the highlight is standing on,
|
||||
so they advertise themselves exactly where they act, as ``Del remove`` does.
|
||||
"""
|
||||
delete_hint = (
|
||||
"Del remove" if any(isinstance(it, Choice) and it.deletable for it in items) else ""
|
||||
@@ -849,10 +927,19 @@ class TuiSession:
|
||||
footer_hint=footer_hint,
|
||||
delete_hint=delete_hint,
|
||||
filterable=False,
|
||||
keys=keys,
|
||||
key_hint=key_hint,
|
||||
)
|
||||
screen.chrome = False
|
||||
screen.banner = banner
|
||||
screen.footnote = footnote
|
||||
# The splash's border is its only hint line and it is narrow (47 cells on the
|
||||
# PicoCalc), so when the sentence outgrows the box it gives up the scroll atom
|
||||
# first — ←→ are already named by the move atom in front of it — and then the
|
||||
# *hide* key. Between the two shortcuts the one that survives is the way back:
|
||||
# ⇧H only appears at all once something is hidden, which is exactly the state where
|
||||
# a reader needs to be told how to undo it, and by then they have already found h.
|
||||
screen.spare_hint_atoms = ("←→ scroll", "h hide")
|
||||
result = await self.run_screen(screen)
|
||||
return None if result is CANCEL else result
|
||||
|
||||
@@ -922,7 +1009,7 @@ class TuiSession:
|
||||
backdrop_title,
|
||||
backdrop_items,
|
||||
default=backdrop_default,
|
||||
footer_hint="↑↓ move · Enter select · Esc quit",
|
||||
footer_hint="↑↓ move · Enter select · Esc bye",
|
||||
delete_hint="Del remove",
|
||||
filterable=False,
|
||||
)
|
||||
@@ -1323,6 +1410,91 @@ class TuiSession:
|
||||
self._overlay = None
|
||||
self.invalidate()
|
||||
|
||||
@asynccontextmanager
|
||||
async def busy_dialog(
|
||||
self,
|
||||
message: str = "",
|
||||
*,
|
||||
title: str = "",
|
||||
interval: float | None = None,
|
||||
) -> AsyncIterator[BusyDialog]:
|
||||
"""Float a modal busy card over the current screen for the duration of a block.
|
||||
|
||||
The in-stack counterpart to :meth:`busy_overlay`, and the one a screen wants when
|
||||
*it* starts the slow work::
|
||||
|
||||
async with session.busy_dialog("saving Lakeside…", title="Channels"):
|
||||
await device.set_channel(...)
|
||||
|
||||
Two things separate it from the overlay, and both are why the overlay could not do
|
||||
this job. It is a real :class:`~meshterm.ui.tui.screen.BusyDialog` *pushed on the
|
||||
stack*, so it draws over a hub instead of only in the gaps between screens — the
|
||||
overlay paints on an empty stack alone (see :meth:`_overlay_visible`), which is
|
||||
exactly never while a hub is visited. And being modal, it **owns the keyboard**: a
|
||||
hub kept up with :meth:`stay` stays armed while the work runs, so without a modal
|
||||
layer every key pressed during the wait still reaches it — letters landing in its
|
||||
live filter (a list that comes back showing nothing), Esc resolving it to be handed
|
||||
back the instant the work finishes (a screen that appears to close on its own a
|
||||
beat later). Those presses now reach this card and stop there.
|
||||
|
||||
Nesting keeps the outermost card, so a batch of writes reports as one wait rather
|
||||
than flashing a box per write; the inner block still runs. The caller may retitle
|
||||
the card as it goes — ``busy.message = …`` — which is how a sequence says which
|
||||
step it is on.
|
||||
|
||||
Unlike :meth:`busy_overlay` there is no fade-in. The fade is there so a fast
|
||||
operation shows nothing at all, and that is a fine trade for a cosmetic card; this
|
||||
one is also the key guard, and a guard that arrives late is a guard with a hole in
|
||||
it.
|
||||
|
||||
Args:
|
||||
message: The caption drawn beside the spinner.
|
||||
title: An optional heading naming the feature doing the work.
|
||||
interval: Seconds between spinner frames; defaults to the platform's cadence
|
||||
(see :meth:`busy_startup`, which shares the hazard: the card animates *over*
|
||||
a device read, so a rate the console can't sustain steals the loop from the
|
||||
work it is reporting on).
|
||||
|
||||
Yields:
|
||||
The live :class:`~meshterm.ui.tui.screen.BusyDialog`, so its caption can change.
|
||||
"""
|
||||
tick = spinner_interval() if interval is None else interval
|
||||
if self._busy is not None: # a nested wait rides the card its caller already put up
|
||||
yield self._busy
|
||||
return
|
||||
screen = BusyDialog(message, title=title)
|
||||
# A lone floating screen on an empty stack is drawn *as* the background, framed and
|
||||
# full-frame rather than as a box, so it gets the same blank backdrop every other
|
||||
# dialog uses (see :meth:`_run_dialog_screen`).
|
||||
backdrop = None
|
||||
if not self._stack:
|
||||
backdrop = ScrollScreen("", floating=False, footer_hint="")
|
||||
self.push(backdrop)
|
||||
self._busy = screen
|
||||
self.push(screen)
|
||||
|
||||
async def animate() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(tick)
|
||||
screen.tick()
|
||||
self.invalidate()
|
||||
|
||||
ticker = asyncio.ensure_future(animate())
|
||||
try:
|
||||
yield screen
|
||||
finally:
|
||||
ticker.cancel()
|
||||
try:
|
||||
await ticker
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001 - a spinner hiccup must never break a flow
|
||||
pass
|
||||
self._busy = None
|
||||
self.pop(screen)
|
||||
if backdrop is not None:
|
||||
self.pop(backdrop)
|
||||
|
||||
# --- application lifecycle ------------------------------------------------
|
||||
|
||||
async def run(self, main: Any) -> None:
|
||||
@@ -1445,6 +1617,9 @@ class TuiSession:
|
||||
max_render_postpone_time=None,
|
||||
input=self._input,
|
||||
output=self._resolve_output(),
|
||||
# None leaves the output's own default alone; see _color_depth for why the
|
||||
# two prompt_toolkit outputs disagree and why only a raise is ever applied.
|
||||
color_depth=_color_depth(),
|
||||
)
|
||||
if fastrender.enabled():
|
||||
# Swap in the row-diff renderer for plain full-screen frames. Built with the
|
||||
|
||||
+35
-5
@@ -1037,6 +1037,16 @@ class ContactsSort:
|
||||
columns: tuple[str, ...] = _SORT_COLUMNS
|
||||
opens_ascending: dict[str, bool] = field(default_factory=lambda: _SORT_OPENS_ASCENDING)
|
||||
|
||||
@classmethod
|
||||
def names(cls, columns: tuple[str, ...] = _SORT_COLUMNS) -> tuple[str, ...]:
|
||||
"""The orders :meth:`from_name` accepts — what a caller may legitimately ask for.
|
||||
|
||||
:meth:`from_name` answers "which sort is this?" and forgives anything it does not
|
||||
recognise. A surface that wants to *refuse* an unknown order needs the set itself,
|
||||
which is this.
|
||||
"""
|
||||
return columns
|
||||
|
||||
@classmethod
|
||||
def from_name(
|
||||
cls,
|
||||
@@ -1061,7 +1071,7 @@ class ContactsSort:
|
||||
self.ascending = self.opens_ascending[self.column]
|
||||
|
||||
|
||||
def _ordered_contacts(
|
||||
def ordered_contacts(
|
||||
contacts: list[Contact], counts: dict[str, int], sort: ContactsSort
|
||||
) -> list[Contact]:
|
||||
"""Contacts sorted per ``sort`` (our own node is pinned separately, above these).
|
||||
@@ -1294,7 +1304,7 @@ def contacts_table(
|
||||
highlighted_hash(self_key, prefix_bytes) if self_key else unknown,
|
||||
)
|
||||
table.add_section()
|
||||
for c in _ordered_contacts(contacts, counts, sort):
|
||||
for c in ordered_contacts(contacts, counts, sort):
|
||||
secs = _age_seconds(c.last_seen)
|
||||
glyph, glyph_style = _NODE_GLYPHS.get(c.node_type, _DEFAULT_GLYPH)
|
||||
pkts = _contact_pkts(c, counts)
|
||||
@@ -1422,6 +1432,23 @@ _BATTERY_FLASH_PCT = 100 / _BATTERY_STEPS / 2
|
||||
#: Frames in the charging sweep: empty → four fills → round again (bottom-to-full loop).
|
||||
_CHARGE_FRAMES = 5
|
||||
|
||||
#: A full pack reads ``100``, with no ``%`` — the one charge whose sign is dropped, so the
|
||||
#: reading is three cells wide exactly as ``99%`` is.
|
||||
#:
|
||||
#: Which matters because 99 and 100 are the two numbers a topped-off pack sits *between*:
|
||||
#: the charger cuts out at full, the pack settles back to 99, the charger restarts, and the
|
||||
#: companion reports the flip every poll for as long as it is plugged in (JP, 2026-09-09).
|
||||
#: The gauge is pinned to the header's right edge and the pulse takes what is left, so a
|
||||
#: number that grew a cell at the top of the range took that cell off the sparkline and
|
||||
#: redrew the whole activity history at a new scale, twice a minute, on a device sitting
|
||||
#: still. Every other width change in the range — 9 to 10 on the way down — is a boundary a
|
||||
#: discharging pack crosses once and never comes back over, so it costs one repaint and
|
||||
#: needs no answer.
|
||||
#:
|
||||
#: A bare ``100`` beside a full block reads as a percentage without being told; ``%`` is
|
||||
#: doing its work at the readings that could be mistaken for something else.
|
||||
_BATTERY_FULL = "100"
|
||||
|
||||
|
||||
def _braille_fill(steps: int) -> str:
|
||||
"""A single braille cell filled from the bottom by ``steps`` (0–8) half-rows.
|
||||
@@ -1494,7 +1521,9 @@ def battery_cell(percent: int, *, charging: bool = False, frame: int = 0) -> Tex
|
||||
|
||||
A pack at 100% is drawn as **not charging** whichever way the flag reads: a full cell
|
||||
resting full is the honest picture, and a topped-off charger left plugged in shouldn't
|
||||
leave the gauge sweeping forever.
|
||||
leave the gauge sweeping forever. It also drops its ``%`` (:data:`_BATTERY_FULL`), so
|
||||
that full reading is the same three cells as the 99% it keeps flipping back to and the
|
||||
header stops resizing under a pack on a charger.
|
||||
|
||||
Args:
|
||||
percent: State of charge, 0–100 (clamped).
|
||||
@@ -1503,7 +1532,8 @@ def battery_cell(percent: int, *, charging: bool = False, frame: int = 0) -> Tex
|
||||
steadily-incrementing integer animates the two live states.
|
||||
|
||||
Returns:
|
||||
A Rich :class:`Text`: the coloured block, a space, and ``NN%`` in muted text — the
|
||||
A Rich :class:`Text`: the coloured block, a space, and ``NN%`` in muted text — a
|
||||
full pack's bare ``100`` the one exception (:data:`_BATTERY_FULL`) — with the
|
||||
block's colours on the glyph's span alone, so the ground stops at the cell.
|
||||
"""
|
||||
pct = max(0, min(100, int(percent)))
|
||||
@@ -1520,5 +1550,5 @@ def battery_cell(percent: int, *, charging: bool = False, frame: int = 0) -> Tex
|
||||
# muted percent onto the coloured ground alongside the cell.
|
||||
out = Text()
|
||||
out.append(_braille_fill(steps), style=color)
|
||||
out.append(f" {pct}%", style="muted")
|
||||
out.append(f" {_BATTERY_FULL if pct == 100 else f'{pct}%'}", style="muted")
|
||||
return out
|
||||
@@ -18,6 +18,7 @@ always see the colours the app really emits.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Callable, Iterable, Iterator
|
||||
|
||||
@@ -87,6 +88,33 @@ def _reset_transmit_gate() -> Iterator[None]:
|
||||
transmit_clock().reset()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_config_dir(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]:
|
||||
"""No test may reach the developer's own ``~/.meshterm``.
|
||||
|
||||
``$MESHTERM_HOME`` moves the whole directory — the history, the contact and channel
|
||||
caches, the outbox, the stored admin passwords, the device profiles, the preferences
|
||||
and the log. ``--db`` moves only the first of those, which is why isolating a CLI test
|
||||
with ``--db`` alone was not isolation at all: a synthetic contact written by a probe
|
||||
sat in the real cache and made ``test_cli_contract`` fail on one machine and pass on
|
||||
every other, and every run appended to the developer's real ``meshterm.log`` and read
|
||||
their real ``log_level``.
|
||||
|
||||
Autouse and unconditional, because the exposure is not the CLI's: anything that
|
||||
touches ``Settings.load()``, a preference, a store or the logger has it. A test that
|
||||
wants a *populated* directory sets the variable itself; ``monkeypatch.setenv`` inside
|
||||
a test still wins over this.
|
||||
"""
|
||||
home = tmp_path_factory.mktemp("meshterm-home")
|
||||
previous = os.environ.get("MESHTERM_HOME")
|
||||
os.environ["MESHTERM_HOME"] = str(home)
|
||||
yield
|
||||
if previous is None:
|
||||
os.environ.pop("MESHTERM_HOME", None)
|
||||
else:
|
||||
os.environ["MESHTERM_HOME"] = previous
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_platform() -> Iterator[None]:
|
||||
"""Every test starts and ends on :data:`~meshterm.platforms.REGULAR`.
|
||||
|
||||
@@ -131,6 +131,10 @@ class _NoteUi:
|
||||
def note(self, markup: str) -> None:
|
||||
self.notes.append(markup)
|
||||
|
||||
def ack(self, markup: str) -> None:
|
||||
# The menu's answer: an acknowledgement is a note. (The scripted CLI drops it.)
|
||||
self.note(markup)
|
||||
|
||||
|
||||
async def test_scheduler_first_pass_arms_without_sending(ctx: AppContext) -> None:
|
||||
"""The first look at a fresh device starts the countdown; nothing is transmitted."""
|
||||
@@ -190,7 +194,7 @@ async def test_advert_cadence_op_writes_the_store(ctx: AppContext) -> None:
|
||||
device = await ctx.device()
|
||||
ctx._ui = _NoteUi() # type: ignore[assignment]
|
||||
snapshot = dict(await device.get_self_info())
|
||||
changes, _ = await apply_ops(
|
||||
changes, _artifacts, _report = await apply_ops(
|
||||
ctx, device, snapshot, [("advert_cadence", True, 168), ("advert_cadence", False, OFF)]
|
||||
)
|
||||
assert changes == 2
|
||||
|
||||
+23
-2
@@ -77,6 +77,27 @@ def test_battery_cell_shows_the_true_percent() -> None:
|
||||
assert battery_cell(42).plain == chr(0x2800 | 0xE4) + " 42%"
|
||||
|
||||
|
||||
def test_battery_cell_drops_the_sign_at_full_so_the_header_stops_moving() -> None:
|
||||
"""A full pack reads ``100`` — three cells, exactly as ``99%`` is.
|
||||
|
||||
Those two readings are the ones a topped-off pack sits between: the charger cuts out
|
||||
at full, the pack settles to 99, the charger restarts, and the flip repeats every poll
|
||||
for as long as it is plugged in. The gauge is pinned to the header's right edge and the
|
||||
pulse takes what is left, so a fourth cell at the top of the range came off the
|
||||
sparkline and rescaled the whole activity history, twice a minute, on a device sitting
|
||||
still. Dropping the one sign that can be inferred costs nothing and settles it.
|
||||
"""
|
||||
assert battery_cell(100).plain.endswith(" 100") # no sign — a full block says it
|
||||
assert battery_cell(99).cell_len == battery_cell(100).cell_len
|
||||
# Every other reading keeps its sign; the charging sweep and the alarm never touch it.
|
||||
assert battery_cell(99).plain.endswith(" 99%") and battery_cell(9).plain.endswith(" 9%")
|
||||
assert {
|
||||
battery_cell(pct, charging=True, frame=f).plain.endswith("%")
|
||||
for pct in (5, 99)
|
||||
for f in range(6)
|
||||
} == {True}
|
||||
|
||||
|
||||
def test_battery_cell_colours_by_band_while_the_dots_carry_the_detail() -> None:
|
||||
"""Three broad bands over half / over a quarter / below, each a block of its own hue."""
|
||||
assert _glyph_style(battery_cell(100)) == "batt.full" # light green on green
|
||||
@@ -143,8 +164,8 @@ def test_battery_cell_animates_off_the_frame_counter_alone() -> None:
|
||||
|
||||
def test_battery_cell_at_full_rests_full_however_the_charging_flag_reads() -> None:
|
||||
"""A topped-off pack draws as not charging, so a plugged-in charger stops the sweep."""
|
||||
assert battery_cell(100, charging=True, frame=0).plain == "⣿ 100%"
|
||||
assert {battery_cell(100, charging=True, frame=f).plain for f in range(6)} == {"⣿ 100%"}
|
||||
assert battery_cell(100, charging=True, frame=0).plain == "⣿ 100"
|
||||
assert {battery_cell(100, charging=True, frame=f).plain for f in range(6)} == {"⣿ 100"}
|
||||
# One percent short is still filling, and still sweeps.
|
||||
assert battery_cell(99, charging=True, frame=0).plain[0] == chr(0x2800)
|
||||
|
||||
|
||||
+288
-3
@@ -7,10 +7,13 @@ run against the :class:`MockDevice` simulator and a temporary database.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Console
|
||||
|
||||
from meshterm.context import AppContext
|
||||
@@ -35,6 +38,7 @@ from meshterm.persistence.repository import Repository
|
||||
from meshterm.services.chat_service import ChatService
|
||||
from meshterm.tools.channels import ChannelsTool
|
||||
from meshterm.ui.channels import (
|
||||
_CREATE,
|
||||
ChannelSlot,
|
||||
_apply_order,
|
||||
_next_free_slot,
|
||||
@@ -369,9 +373,17 @@ class _ScriptedUi:
|
||||
def show(self, *renderables) -> None: # noqa: ANN002
|
||||
pass
|
||||
|
||||
@asynccontextmanager
|
||||
async def busy_dialog(self, message: str = "", *, title: str = ""): # noqa: ANN201
|
||||
"""No card without a screen stack — every mutation path goes through this."""
|
||||
yield SimpleNamespace(message=message)
|
||||
|
||||
def note(self, markup: str) -> None:
|
||||
pass
|
||||
|
||||
def ack(self, markup: str) -> None:
|
||||
pass
|
||||
|
||||
async def view(self, renderable, *, title: str = "", footer_hint: str = "") -> None: # noqa: ANN001
|
||||
pass
|
||||
|
||||
@@ -569,7 +581,7 @@ async def test_channel_rows_carry_stats_unread_and_lanes(ctx: AppContext) -> Non
|
||||
ctx.chat._unread[slot.conversation.key] = 2 # as the service would after two arrivals
|
||||
|
||||
title, items = _menu_items(ctx, slots, 8, _LiveStats(ctx))
|
||||
assert title == "Channels — 1/8 slots"
|
||||
assert title == "Channels · 1/8 slots" # a status atom chains with ·, not an em dash
|
||||
row = next(it for it in items if isinstance(it, Choice) and it.value == 0)
|
||||
plain = row.label.plain # the title is a live callable; .label resolves it
|
||||
assert "Ops" in plain
|
||||
@@ -667,13 +679,47 @@ async def test_detail_summary_reads_slot_totals_and_unread(ctx: AppContext) -> N
|
||||
await device.set_channel(3, "Ops", bytes(range(16)))
|
||||
slot = next(s for s in await read_channel_slots(device) if s.idx == 3)
|
||||
|
||||
assert _detail_summary(ctx, slot, _LiveStats(ctx)) == "Slot 3 · no messages recorded yet"
|
||||
# What the channel *is* leads — the openness and hash moved here out of a title that
|
||||
# was 42 cells wide and left no room on the console for the bar that says Esc leaves.
|
||||
assert _detail_summary(ctx, slot, _LiveStats(ctx)) == (
|
||||
"private · hash be · slot 3 · no messages yet"
|
||||
)
|
||||
|
||||
ctx.repo.record_chat_message(ChatMessage(text="hi", is_channel=True, channel_id=slot.identity))
|
||||
ctx.chat._unread[slot.conversation.key] = 1
|
||||
summary = _detail_summary(ctx, slot, _LiveStats(ctx))
|
||||
# A fresh age reads as bare "now" (the app-wide format_ago grammar — never "now ago").
|
||||
assert summary == "Slot 3 · 1 message · 1 unread · last message now"
|
||||
assert summary == "private · hash be · slot 3 · 1 msg · 1 unread · last now"
|
||||
|
||||
|
||||
async def test_the_detail_summary_sheds_atoms_rather_than_wrapping(ctx: AppContext) -> None:
|
||||
"""One line, on both platforms: the traffic atoms go before the line does.
|
||||
|
||||
The identifying atoms are on the left and the describing ones on the right, so a line
|
||||
too long for the console loses what the reader could already see in the row they opened
|
||||
this screen from — never the channel's own identity.
|
||||
"""
|
||||
from meshterm.core.models import ChatMessage
|
||||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||||
from meshterm.ui.channels import _detail_summary, _LiveStats
|
||||
|
||||
device = await ctx.device()
|
||||
await device.set_channel(0, "Lakeside emergency net", bytes(range(16)))
|
||||
slot = next(s for s in await read_channel_slots(device) if s.idx == 0)
|
||||
for _ in range(3):
|
||||
ctx.repo.record_chat_message(
|
||||
ChatMessage(text="hi", is_channel=True, channel_id=slot.identity)
|
||||
)
|
||||
ctx.chat._unread[slot.conversation.key] = 2
|
||||
|
||||
try:
|
||||
for platform in (REGULAR, PICOCALC):
|
||||
set_platform(platform)
|
||||
line = _detail_summary(ctx, slot, _LiveStats(ctx))
|
||||
assert cell_len(line) <= platform.readable_cols, (platform.name, line)
|
||||
assert line.startswith("private · hash be · slot 0") # identity always survives
|
||||
finally:
|
||||
set_platform(REGULAR)
|
||||
|
||||
|
||||
# -- muting channel notifications ---------------------------------------------
|
||||
@@ -875,3 +921,242 @@ async def test_cli_clear_of_an_empty_slot_is_a_no_op(ctx: AppContext) -> None:
|
||||
tool = ChannelsTool()
|
||||
result = await tool.run(ctx, {"cli_action": "clear", "index": 7})
|
||||
assert result.summary == {"index": 7, "cleared": False}
|
||||
|
||||
|
||||
class _RecordingSession:
|
||||
"""Records which surface :meth:`TuiUi.present` chose — the popup or the result window."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.shown: list[tuple[str, str]] = []
|
||||
|
||||
async def message_dialog(self, message, title: str = "") -> None: # noqa: ANN001
|
||||
self.shown.append(("popup", title))
|
||||
|
||||
async def scroll(self, renderable, *, title: str = "", footer_hint: str = "") -> None: # noqa: ANN001
|
||||
self.shown.append(("window", title))
|
||||
|
||||
|
||||
class _PresentingUi(_ScriptedUi):
|
||||
"""Scripted answers, but real note buffering — so what the visit *shows* can be asserted."""
|
||||
|
||||
def __init__(self, selects: list, texts: list, dialogs: list) -> None:
|
||||
from meshterm.ui.surface import TuiUi
|
||||
|
||||
super().__init__(selects, texts, dialogs)
|
||||
self.session_spy = _RecordingSession()
|
||||
self.surface = TuiUi(self.session_spy) # no ``.session`` attribute: stays headless
|
||||
|
||||
def note(self, markup: str) -> None:
|
||||
self.surface.note(markup)
|
||||
|
||||
def ack(self, markup: str) -> None:
|
||||
self.surface.ack(markup)
|
||||
|
||||
|
||||
async def test_a_busy_visit_says_nothing_on_the_way_out(ctx: AppContext) -> None:
|
||||
"""A visit that changed three channels closes silently — no popup, no window.
|
||||
|
||||
The history this pins, in the order it happened: every action banked its own "✓ created
|
||||
…" note, so the outcome grew a line per change and the third pushed it past the
|
||||
acknowledgement popup's budget into the full-frame result window — the same visit
|
||||
reporting itself two different ways depending on how much had been done in it. Folding
|
||||
those into one summary line fixed the shape and left the real problem: an
|
||||
acknowledgement for changes the reader had just watched land in the list in front of
|
||||
them. So the line went too. The count survives in ``summary`` for the run log, which
|
||||
nobody reads off the screen.
|
||||
"""
|
||||
ui = _PresentingUi(
|
||||
selects=[_CREATE, _CREATE, _CREATE, None], # create three channels, then Esc
|
||||
texts=["One", "Two", "Three"],
|
||||
dialogs=[],
|
||||
)
|
||||
ctx.ui = ui
|
||||
result = await ChannelsTool().run(ctx, {})
|
||||
|
||||
assert result.summary == {"changes": 3} # the run log still records what was done
|
||||
assert result.message is None
|
||||
# The menu's own tail: note the tool's message, then present what the run buffered.
|
||||
if result.message:
|
||||
ui.surface.note(result.message)
|
||||
await ui.surface.present(title="Channels")
|
||||
assert ui.session_spy.shown == [] # nothing shown, in either shape
|
||||
|
||||
|
||||
class _FlakyDevice:
|
||||
"""A device whose channel reads stop working partway through the probe.
|
||||
|
||||
``fail_from`` is the first slot index that raises, and ``error`` is what it raises —
|
||||
the two endings the probe has to tell apart: a firmware refusing a slot it does not
|
||||
have (a plain rejection) and a link that stopped answering (a timeout).
|
||||
"""
|
||||
|
||||
def __init__(self, names: list[str], *, fail_from: int, error: BaseException) -> None:
|
||||
self._names = names
|
||||
self._fail_from = fail_from
|
||||
self._error = error
|
||||
self.written: list[tuple[int, str]] = []
|
||||
|
||||
async def get_channel(self, idx: int): # noqa: ANN201
|
||||
if idx >= self._fail_from:
|
||||
raise self._error
|
||||
if idx < len(self._names):
|
||||
return {"channel_name": self._names[idx], "channel_secret": bytes(range(16))}
|
||||
return None
|
||||
|
||||
async def set_channel(self, idx: int, name: str, secret) -> None: # noqa: ANN001
|
||||
self.written.append((idx, name))
|
||||
|
||||
|
||||
async def test_a_probe_cut_short_by_a_failed_read_is_not_a_layout() -> None:
|
||||
"""A timeout mid-probe reports incomplete; a refused slot reports a finished list.
|
||||
|
||||
They look identical in the returned list — a short one either way — which is what let
|
||||
one timed-out read stand in for "this device has no channels".
|
||||
"""
|
||||
from meshterm.core.connection import DeviceCommandError
|
||||
from meshterm.ui.channels import probe_channel_slots
|
||||
|
||||
dropped = _FlakyDevice(["Alpha", "Beta"], fail_from=1, error=DeviceCommandError("no reply"))
|
||||
slots, complete = await probe_channel_slots(dropped)
|
||||
assert [s.name for s in slots] == ["Alpha"] and complete is False
|
||||
|
||||
at_the_end = _FlakyDevice(["Alpha", "Beta"], fail_from=2, error=RuntimeError("out of range"))
|
||||
slots, complete = await probe_channel_slots(at_the_end)
|
||||
assert [s.name for s in slots] == ["Alpha", "Beta"] and complete is True
|
||||
|
||||
|
||||
async def test_an_incomplete_probe_is_answered_but_never_cached(ctx: AppContext) -> None:
|
||||
"""One bad read must not read as "no channels" for the rest of the session."""
|
||||
from meshterm.core.connection import DeviceCommandError
|
||||
|
||||
device = await ctx.device()
|
||||
await device.set_channel(0, "Alpha", bytes(range(16)))
|
||||
|
||||
calls = {"n": 0}
|
||||
real = device.get_channel
|
||||
|
||||
async def flaky(idx: int): # noqa: ANN202
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise DeviceCommandError("timed out")
|
||||
return await real(idx)
|
||||
|
||||
device.get_channel = flaky # type: ignore[method-assign]
|
||||
assert await ctx.devstate.channel_slots() == [] # the failed read, answered honestly
|
||||
# ...and not kept: the next ask re-probes and finds the channel that was there all along.
|
||||
assert [s.name for s in await ctx.devstate.channel_slots()] == ["Alpha"]
|
||||
|
||||
|
||||
async def test_a_slot_is_confirmed_empty_before_a_channel_is_written_over_it(
|
||||
ctx: AppContext,
|
||||
) -> None:
|
||||
"""A slot missing from a short list must not be handed out as free.
|
||||
|
||||
The dangerous version of the bug above: a probe that stopped at slot 1 leaves slot 1
|
||||
looking free, and every add flow writes to the slot it is given without asking.
|
||||
"""
|
||||
from meshterm.ui.channels import _pick_free_slot
|
||||
|
||||
device = await ctx.device()
|
||||
await device.set_channel(0, "Alpha", bytes(range(16)))
|
||||
await device.set_channel(1, "Beta", bytes(range(16, 32)))
|
||||
|
||||
ui = _ScriptedUi(selects=[], texts=[], dialogs=[])
|
||||
ctx.ui = ui
|
||||
truncated = [ChannelSlot(idx=0, name="Alpha", secret=bytes(range(16)))] # slot 1 unseen
|
||||
assert await _pick_free_slot(ctx, device, truncated, 8) is None # refused, not slot 1
|
||||
|
||||
# With the list telling the truth, the next genuinely free slot is handed out.
|
||||
full_list = list(await read_channel_slots(device))
|
||||
assert await _pick_free_slot(ctx, device, full_list, 8) == 2
|
||||
|
||||
|
||||
async def test_a_rename_keeps_the_page_it_renamed(ctx: AppContext) -> None:
|
||||
"""Renaming a channel re-reads it in place; only a clear closes the page.
|
||||
|
||||
The channel is still there and this is still its page. It used to close on both, which
|
||||
left the reader back in the list to find the row again — for no better reason than the
|
||||
snapshot the page was holding having gone stale under the rename.
|
||||
"""
|
||||
from meshterm.ui.channels import _CHAT, _EDIT, _channel_detail, _LiveStats
|
||||
|
||||
device = await ctx.device()
|
||||
await device.set_channel(0, "Ops", bytes(range(16)))
|
||||
slot = next(s for s in await read_channel_slots(device) if s.idx == 0)
|
||||
|
||||
opened: list[str] = []
|
||||
ui = _ScriptedUi(
|
||||
# Rename, then open chat (proving the page is still up and knows the new name), Esc.
|
||||
selects=[_EDIT, _CHAT, None],
|
||||
texts=["Lakeside", ""], # the new name, and a blank key (derive it from the name)
|
||||
dialogs=[],
|
||||
)
|
||||
ctx.ui = ui
|
||||
|
||||
import meshterm.ui.channels as channels_mod
|
||||
|
||||
async def fake_chat(_ctx, opened_slot): # noqa: ANN001, ANN202
|
||||
opened.append(opened_slot.name)
|
||||
|
||||
original = channels_mod._open_chat
|
||||
channels_mod._open_chat = fake_chat
|
||||
try:
|
||||
changes = await _channel_detail(ctx, device, slot, _LiveStats(ctx))
|
||||
finally:
|
||||
channels_mod._open_chat = original
|
||||
|
||||
assert opened == ["Lakeside"] # the page stayed, pointing at the renamed channel
|
||||
assert changes == 1 # ...and still reports the rename when it finally closes
|
||||
|
||||
|
||||
async def test_the_standard_public_channel_is_not_added_twice(ctx: AppContext) -> None:
|
||||
"""A re-fired add is refused where it runs, not only where it is drawn.
|
||||
|
||||
The menu drops the row once a slot holds the channel, but the row is dispatched by a
|
||||
sentinel: a press already on its way while the first write ran would add a second copy
|
||||
of a channel that has exactly one well-known key.
|
||||
"""
|
||||
from meshterm.ui.channels import _add_default_public
|
||||
|
||||
device = await ctx.device()
|
||||
ctx.ui = _ScriptedUi(selects=[], texts=[], dialogs=[])
|
||||
slots: list[ChannelSlot] = []
|
||||
|
||||
assert await _add_default_public(ctx, device, slots, 8) == 1
|
||||
slots = list(await read_channel_slots(device))
|
||||
assert await _add_default_public(ctx, device, slots, 8) == 0 # refused, not duplicated
|
||||
assert [s.name for s in await read_channel_slots(device)] == ["Public"]
|
||||
|
||||
|
||||
async def test_a_reorder_that_breaks_partway_says_so(ctx: AppContext) -> None:
|
||||
"""A relay has no transaction under it, so a link that drops mid-way is reported."""
|
||||
from meshterm.ui.channels import _reorder_channels
|
||||
|
||||
device = await ctx.device()
|
||||
await device.set_channel(0, "Alpha", bytes(range(16)))
|
||||
await device.set_channel(1, "Beta", bytes(range(16, 32)))
|
||||
slots = list(await read_channel_slots(device))
|
||||
|
||||
said: list[str] = []
|
||||
|
||||
class _Ui(_ScriptedUi):
|
||||
def note(self, markup: str) -> None:
|
||||
said.append(markup)
|
||||
|
||||
async def reorder(self, title: str, labels: list): # noqa: ANN201
|
||||
return [1, 0] # swap them
|
||||
|
||||
ctx.ui = _Ui(selects=[], texts=[], dialogs=[])
|
||||
|
||||
real = device.set_channel
|
||||
calls = {"n": 0}
|
||||
|
||||
async def flaky(idx: int, name: str, secret) -> None: # noqa: ANN001
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 2:
|
||||
raise RuntimeError("link dropped")
|
||||
await real(idx, name, secret)
|
||||
|
||||
device.set_channel = flaky # type: ignore[method-assign]
|
||||
assert await _reorder_channels(ctx, device, slots) == 0 # not counted as a clean reorder
|
||||
assert said and "reorder stopped partway" in said[0]
|
||||
+144
-17
@@ -11,6 +11,7 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from rich.cells import cell_len
|
||||
|
||||
from meshterm.core.channels import DEFAULT_PUBLIC_SECRET, derive_secret
|
||||
from meshterm.core.config import Settings
|
||||
@@ -27,7 +28,7 @@ from meshterm.core.preferences import Preferences
|
||||
from meshterm.persistence.repository import Repository
|
||||
from meshterm.services.chat_service import ChatService
|
||||
from meshterm.services.event_hub import EventHub
|
||||
from meshterm.tools.chat import _PREVIEW_WIDTH, _LiveLasts, _preview_text, _title
|
||||
from meshterm.tools.chat import _lanes, _LiveLasts, _preview_text, _title
|
||||
from meshterm.ui.chat import ChatScreen
|
||||
from meshterm.ui.tui import fkeys
|
||||
from meshterm.ui.tui.screen import CANCEL
|
||||
@@ -441,11 +442,10 @@ def test_preview_colours_channel_sender_and_mentions() -> None:
|
||||
assert any(s.style == "node.unknown" and s.start <= zed < s.end for s in preview.spans)
|
||||
|
||||
|
||||
def test_preview_ellipsizes_long_text() -> None:
|
||||
"""An over-long preview is clipped to the width budget with a trailing ellipsis."""
|
||||
long = ChatMessage(text="x" * 100, is_channel=True, channel_idx=0)
|
||||
out = _preview_text(long, _NO_KEYS).plain
|
||||
assert len(out) == _PREVIEW_WIDTH and out.endswith("…")
|
||||
def test_preview_keeps_the_whole_message() -> None:
|
||||
"""The preview is built whole — the row's own cut is what the ←→ scroll walks past."""
|
||||
long = ChatMessage(text="x" * 300, is_channel=True, channel_idx=0)
|
||||
assert _preview_text(long, _NO_KEYS).plain == "x" * 300
|
||||
|
||||
|
||||
def test_title_shows_badge_and_author_preview(repo: Repository) -> None:
|
||||
@@ -453,7 +453,7 @@ def test_title_shows_badge_and_author_preview(repo: Repository) -> None:
|
||||
conv = Conversation(label="General", is_channel=True, channel_idx=0, channel_id="c0")
|
||||
ctx = _RowCtx(repo, unread={"chan:c0": 3})
|
||||
last = ChatMessage(text="Bob: hi there", is_channel=True, channel_id="c0") # sender inline
|
||||
title = _title(ctx, conv, {"chan:c0": last}, _NO_KEYS)
|
||||
title = _title(ctx, conv, {"chan:c0": last}, _NO_KEYS, _lanes([conv]))
|
||||
line = title.plain # a Text, since there is unread
|
||||
assert line.startswith("🔒 General") # a private channel leads with its openness glyph
|
||||
assert "● 3" in line
|
||||
@@ -465,7 +465,7 @@ def test_title_reddens_only_the_unread_dot(repo: Repository) -> None:
|
||||
from rich.text import Text
|
||||
|
||||
conv = Conversation(label="General", is_channel=True, channel_idx=0, channel_id="c0")
|
||||
unread = _title(_RowCtx(repo, unread={"chan:c0": 2}), conv, {}, _NO_KEYS)
|
||||
unread = _title(_RowCtx(repo, unread={"chan:c0": 2}), conv, {}, _NO_KEYS, _lanes([conv]))
|
||||
assert isinstance(unread, Text)
|
||||
dot = unread.plain.index("●")
|
||||
reddened = [
|
||||
@@ -473,7 +473,7 @@ def test_title_reddens_only_the_unread_dot(repo: Repository) -> None:
|
||||
]
|
||||
assert reddened and all(span.end - span.start == 1 for span in reddened) # just the glyph
|
||||
|
||||
read = _title(_RowCtx(repo, unread={}), conv, {}, _NO_KEYS)
|
||||
read = _title(_RowCtx(repo, unread={}), conv, {}, _NO_KEYS, _lanes([conv]))
|
||||
assert isinstance(read, Text) # always a Text now, so its spans can carry the row's colour
|
||||
assert "●" not in read.plain # nothing unread -> no badge dot
|
||||
assert not any(span.style == "err" for span in read.spans)
|
||||
@@ -488,8 +488,9 @@ def test_title_preview_column_aligns_regardless_of_label_length(repo: Repository
|
||||
)
|
||||
m0 = ChatMessage(text="X: hello", is_channel=True, channel_id="c0")
|
||||
m1 = ChatMessage(text="Y: hello", is_channel=True, channel_id="c1")
|
||||
l0 = _title(ctx, short, {"chan:c0": m0}, _NO_KEYS).plain
|
||||
l1 = _title(ctx, long, {"chan:c1": m1}, _NO_KEYS).plain
|
||||
lanes = _lanes([short, long]) # one measurement for the list, as the picker does
|
||||
l0 = _title(ctx, short, {"chan:c0": m0}, _NO_KEYS, lanes).plain
|
||||
l1 = _title(ctx, long, {"chan:c1": m1}, _NO_KEYS, lanes).plain
|
||||
assert l0.index("X: hello") == l1.index("Y: hello")
|
||||
|
||||
|
||||
@@ -498,7 +499,7 @@ def test_title_leads_with_openness_glyph(repo: Repository) -> None:
|
||||
ctx = _RowCtx(repo)
|
||||
|
||||
def head(conv):
|
||||
return _title(ctx, conv, {}, _NO_KEYS).plain.split(" ", 1)[0]
|
||||
return _title(ctx, conv, {}, _NO_KEYS, _lanes([conv])).plain.split(" ", 1)[0]
|
||||
|
||||
named = Conversation(
|
||||
label="#general", is_channel=True, channel_id="c0", secret=derive_secret("#general")
|
||||
@@ -533,7 +534,7 @@ def test_title_contact_dot_reflects_conversation_history(repo: Repository) -> No
|
||||
)
|
||||
|
||||
# No history yet — a hollow ring in the standard companion pink.
|
||||
fresh = _title(ctx, contact, {}, _NO_KEYS)
|
||||
fresh = _title(ctx, contact, {}, _NO_KEYS, _lanes([contact]))
|
||||
assert fresh.plain.startswith("○") and dot_is_pink(fresh)
|
||||
# The name lane carries Alice's key-derived hue.
|
||||
name_at = fresh.plain.index("Alice")
|
||||
@@ -541,7 +542,7 @@ def test_title_contact_dot_reflects_conversation_history(repo: Repository) -> No
|
||||
|
||||
# Once we've exchanged messages the same pink dot fills in.
|
||||
last = ChatMessage(text="hi", peer=contact.peer)
|
||||
talked = _title(ctx, contact, {contact.key: last}, _NO_KEYS)
|
||||
talked = _title(ctx, contact, {contact.key: last}, _NO_KEYS, _lanes([contact]))
|
||||
assert talked.plain.startswith("●") and dot_is_pink(talked)
|
||||
|
||||
|
||||
@@ -1791,9 +1792,135 @@ async def test_picker_pins_its_column_header_over_the_group_heading(
|
||||
assert top[0].startswith("CONVERSATION") and top[0].endswith("LAST MESSAGE")
|
||||
assert top[1] == "── 👤 Direct ──"
|
||||
assert above is True
|
||||
# Too narrow for the whole line, the trailing label shortens — never wraps.
|
||||
assert header.text(50).endswith("LAST MSG")
|
||||
assert "\n" not in header.text(40)
|
||||
# Too narrow for the whole line, the trailing label shortens — never wraps. The
|
||||
# lane is sized to these six-cell names now, so the squeeze starts well below any
|
||||
# real terminal: the fallback is what is under test, not the width it happens at.
|
||||
assert header.text(40).endswith("LAST MSG")
|
||||
assert "\n" not in header.text(30)
|
||||
|
||||
|
||||
async def test_picker_lane_is_sized_to_the_names_it_holds(repo: Repository) -> None:
|
||||
"""The conversation lane fits the longest name, between its floor and its ceiling.
|
||||
|
||||
Every cell the lane holds past that longest name is padding taken out of the message
|
||||
preview, which is the row's only unbounded content — so it is measured, not written down.
|
||||
"""
|
||||
from meshterm.tools.chat import _LABEL_MAX, _LABEL_MIN, _lanes
|
||||
|
||||
def lane(*names: str) -> int:
|
||||
return _lanes([Conversation(label=n, is_channel=True, channel_id=n) for n in names]).label
|
||||
|
||||
assert lane("Bob", "Ann") == _LABEL_MIN # short names can't shrink it under the header word
|
||||
assert lane("Bob", "YUL-Cartierville") == len("YUL-Cartierville")
|
||||
assert lane("A Rather Long Contact Name Indeed") == _LABEL_MAX # nor grow it without end
|
||||
|
||||
|
||||
async def test_picker_rows_scroll_the_message_under_pinned_lanes(repo: Repository) -> None:
|
||||
"""A row hands ←→ the preview alone: the lanes in front of it are where it starts to slide.
|
||||
|
||||
Those lanes are the reader's place in the list, so the scroll begins exactly where the
|
||||
message does — which is one column for every row, long name or short.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from meshterm.tools.chat import ChatTool
|
||||
from meshterm.ui.tui import Choice
|
||||
|
||||
contacts = [
|
||||
Contact(name="Alice", public_key="d4" + "0" * 62, node_type=1),
|
||||
Contact(name="A Rather Long Contact Name", public_key="60" + "0" * 62, node_type=1),
|
||||
]
|
||||
alice = Conversation(label="Alice", is_channel=False, contact=contacts[0])
|
||||
repo.record_chat_message(ChatMessage(text="x" * 200, peer=alice.peer))
|
||||
ctx = SimpleNamespace(devstate=_PickerDevstate(contacts), repo=repo, chat=_PickerChat())
|
||||
rows = [it for it in await ChatTool()._picker_items(ctx) if isinstance(it, Choice)]
|
||||
|
||||
heads = {row.hscroll_from for row in rows}
|
||||
assert len(heads) == 1 and heads.pop() > 0 # one head block for the whole list
|
||||
talked = next(row for row in rows if row.value.label == "Alice")
|
||||
line = talked.label.plain
|
||||
# Measured in cells, not characters: a channel glyph is one character and two cells.
|
||||
assert cell_len(line[: line.index("x")]) == talked.hscroll_from
|
||||
# And the preview keeps the whole message: the cut is the row's, and ←→ walk past it.
|
||||
assert talked.label.plain.endswith("x" * 200)
|
||||
|
||||
|
||||
def test_picker_marker_lane_lines_the_two_groups_up(repo: Repository) -> None:
|
||||
"""A channel glyph and a contact dot claim the same cells — on either platform.
|
||||
|
||||
That glyph is double-cell on regular and single-cell in the console font, so the dot is
|
||||
padded to whatever it actually measures rather than to a written-down width: a
|
||||
hard-coded pad left every channel row a cell adrift of every direct row on the PicoCalc.
|
||||
"""
|
||||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||||
from meshterm.tools.chat import _lanes
|
||||
|
||||
channel = Conversation(label="Public", is_channel=True, channel_idx=0, channel_id="c0")
|
||||
contact = Conversation(
|
||||
label="Alice", is_channel=False, contact=Contact(name="Alice", public_key="d4" * 32)
|
||||
)
|
||||
ctx = _RowCtx(repo)
|
||||
try:
|
||||
for platform in (REGULAR, PICOCALC):
|
||||
set_platform(platform)
|
||||
lanes = _lanes([channel, contact])
|
||||
rows = [_title(ctx, c, {}, _NO_KEYS, lanes).plain for c in (channel, contact)]
|
||||
names = ("Public", "Alice")
|
||||
at = [cell_len(r[: r.index(n)]) for r, n in zip(rows, names, strict=True)]
|
||||
assert at[0] == at[1] == lanes.marker
|
||||
finally:
|
||||
set_platform(REGULAR)
|
||||
|
||||
|
||||
async def test_picker_footer_fits_with_every_atom_showing(repo: Repository, monkeypatch) -> None:
|
||||
"""Scroll, erase and the base atoms together stay inside the 72-cell footer budget.
|
||||
|
||||
The three of them coincide on an ordinary row — a thread with history long enough to
|
||||
scroll — so the budget is the wording's constraint, not a corner case (it is what
|
||||
``Del erase`` is: the row already names the conversation it would take the history of).
|
||||
"""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from prompt_toolkit.input.defaults import create_pipe_input
|
||||
from prompt_toolkit.output import DummyOutput
|
||||
|
||||
from meshterm.tools.chat import ChatTool
|
||||
from meshterm.ui.surface import TuiUi
|
||||
from meshterm.ui.tui.screen import CANCEL
|
||||
from meshterm.ui.tui.session import TuiSession
|
||||
|
||||
ally = Contact(name="Ally", public_key="d4" + "0" * 62, node_type=1)
|
||||
conv = Conversation(label="Ally", is_channel=False, contact=ally)
|
||||
repo.record_chat_message(ChatMessage(text="y" * 200, peer=conv.peer))
|
||||
ctx = SimpleNamespace(devstate=_PickerDevstate([ally]), repo=repo, chat=_PickerChat())
|
||||
hints: list[str] = []
|
||||
|
||||
with create_pipe_input() as inp:
|
||||
session = TuiSession(input=inp, output=DummyOutput())
|
||||
ctx.ui = TuiUi(session)
|
||||
|
||||
async def main() -> None:
|
||||
run = asyncio.ensure_future(ChatTool()._run_live(ctx))
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + 2
|
||||
while not session._stack:
|
||||
assert loop.time() < deadline, "the picker never opened"
|
||||
await asyncio.sleep(0)
|
||||
picker = session._stack[-1]
|
||||
picker.render_body(72) # the overflow probe reads the last render width
|
||||
picker.handle("down") # onto the one Direct row: deletable, and overflowing
|
||||
picker.render_body(72)
|
||||
hints.append(picker.footer_hint)
|
||||
picker.resolve(CANCEL)
|
||||
await asyncio.wait_for(run, timeout=2)
|
||||
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
|
||||
hint = hints[0]
|
||||
assert cell_len(hint) <= 72, f"{cell_len(hint)} cells: {hint!r}"
|
||||
for atom in ("←→ scroll", "Enter open", "Del erase", "Esc back"):
|
||||
assert atom in hint
|
||||
|
||||
|
||||
async def test_picker_del_deletes_history_after_a_red_confirm(repo: Repository) -> None:
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
"""End-to-end tests for what the CLI actually prints and returns.
|
||||
|
||||
:mod:`tests.test_script_output` covers the vocabulary and :mod:`tests.test_report` the
|
||||
seam; this covers the commands built out of both — the real Typer app, driven against the
|
||||
simulator, asserting the things a caller depends on: no escape sequences, no wrapped
|
||||
records, a header its records line up under, a documented exit status — and, under
|
||||
``--json``, a document that parses and a status that did not change because of a rendering
|
||||
flag.
|
||||
|
||||
Every command here is read-only or simulator-backed, so nothing transmits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from meshterm.core import exitcodes
|
||||
|
||||
#: Any ANSI escape sequence. Nothing on the CLI's stdout may match this.
|
||||
_ANSI = re.compile(r"\x1b\[")
|
||||
|
||||
#: A Rich console-markup tag. The scripted console no longer *interprets* markup — a node
|
||||
#: name is remote data and `[bold]` in one is a name, not a style — which means a tag
|
||||
#: MeshTerm writes itself is no longer consumed either, and lands in the caller's pipe.
|
||||
_MARKUP = re.compile(r"\[/?(?:ok|err|warn|muted|accent|brand|bold|dim|reverse)\b[^\]]*\]")
|
||||
|
||||
#: The shape of a drawn route: one or more hops, each a bare name with its hash in
|
||||
#: parentheses (or one of the two halves alone, where history knows only one), joined by
|
||||
#: the spaced arrow. This is the whole grammar, and the arrow is what makes it one — a
|
||||
#: name holding a comma no longer needs quoting to stay one hop.
|
||||
_HOP = r"[^\n→]+?"
|
||||
_ROUTE_LINE = re.compile(f"{_HOP}(?: → {_HOP})*")
|
||||
|
||||
#: The shape of a path *spec*: comma-joined lowercase hex, exactly what ``--path`` takes
|
||||
#: back. The one line on either face that round-trips, so nothing may creep into it.
|
||||
_PATH_SPEC = re.compile(r"[0-9a-f]+(?:,[0-9a-f]+)*")
|
||||
|
||||
#: The commands whose output is a listing or a key/value block, and the fields a caller
|
||||
#: should find in each. Every one runs against the mock companion or the database alone.
|
||||
_LISTINGS: list[tuple[str, list[str], list[str]]] = [
|
||||
# (command, arguments, headings or keys the output must carry)
|
||||
("contacts", [], ["NAME", "TYPE", "HEARD", "PKTS", "HASH", "LOCATION", "KEY"]),
|
||||
("info", [], ["name", "public_key", "role"]),
|
||||
("config", ["show"], ["name", "radio_freq", "tx_power"]),
|
||||
("preferences", ["show"], ["PREFERENCE", "VALUE", "DEFAULT", "DESCRIPTION"]),
|
||||
("chat", ["list"], ["CONVERSATION", "KIND", "UNREAD", "LAST", "LAST_TEXT"]),
|
||||
("platform", [], ["platform", "icons"]),
|
||||
]
|
||||
|
||||
|
||||
def _leaf_commands() -> list[tuple[str, ...]]:
|
||||
"""Every registered subcommand path, walked out of the real Typer app.
|
||||
|
||||
Derived rather than listed: a hand-kept roster covers whatever was reworked the day it
|
||||
was written, and a command added afterwards inherits none of it. Two rule violations
|
||||
shipped inside commands the roster did not name — a 16384-cell rule in ``about`` and
|
||||
markup tags in ``config export-key``.
|
||||
"""
|
||||
from typer.main import get_command
|
||||
|
||||
from meshterm.cli import app
|
||||
|
||||
def walk(command, prefix: tuple[str, ...] = ()) -> list[tuple[str, ...]]: # noqa: ANN001
|
||||
found: list[tuple[str, ...]] = []
|
||||
for name, sub in sorted(getattr(command, "commands", {}).items()):
|
||||
here = (*prefix, name)
|
||||
found.extend(walk(sub, here) if getattr(sub, "commands", None) else [here])
|
||||
return found
|
||||
|
||||
return walk(get_command(app))
|
||||
|
||||
|
||||
#: Commands that own the terminal or run until interrupted: they have no one-shot output.
|
||||
_NOT_ONE_SHOT = {("monitor",), ("chat", "listen"), ("tx-optimize",)}
|
||||
|
||||
#: The one command whose output *is* its colour, and says so (:mod:`meshterm.ui.specimen`).
|
||||
_KEEPS_ITS_COLOUR = {("specimen",)}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # noqa: ANN201 - a closure
|
||||
"""Invoke the real CLI against the simulator, in a config directory of its own.
|
||||
|
||||
``--db`` moves the history and *nothing else*: the contact cache, the outbox, the
|
||||
channel cache, the stored admin passwords and any device profiles all live in the
|
||||
config directory regardless. So a suite that set only ``--db`` read whatever was in
|
||||
the developer's own ``~/.meshterm`` — which is how a contact named ``[/]Bob``, planted
|
||||
by hand on one machine, could crash this suite on that machine and pass everywhere
|
||||
else. ``$MESHTERM_HOME`` is the override that moves the whole directory, and it is
|
||||
the one a test wants.
|
||||
"""
|
||||
from meshterm.cli import app
|
||||
|
||||
monkeypatch.setenv("MESHTERM_HOME", str(tmp_path / "home"))
|
||||
runner = CliRunner()
|
||||
|
||||
def invoke(*args: str): # noqa: ANN202
|
||||
return runner.invoke(app, ["--mock", "--db", str(tmp_path / "test.db"), *args])
|
||||
|
||||
return invoke
|
||||
|
||||
|
||||
#: Commands that refuse a machine-readable face outright, and say so as a usage error.
|
||||
_REFUSES_JSON = {("specimen",)}
|
||||
|
||||
|
||||
# -- the four rules -------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS])
|
||||
def test_a_command_prints_no_escape_sequence(run, command, args, fields) -> None: # noqa: ANN001
|
||||
"""Nothing is coloured: stdout is a pipe more often than a terminal."""
|
||||
result = run(command, *args)
|
||||
assert result.exit_code == exitcodes.OK, result.output
|
||||
assert not _ANSI.search(result.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS])
|
||||
def test_a_command_carries_the_fields_a_caller_looks_for(run, command, args, fields) -> None: # noqa: ANN001
|
||||
"""The headings and keys are the contract; renaming one silently breaks a script."""
|
||||
result = run(command, *args)
|
||||
for field in fields:
|
||||
assert field in result.stdout, f"{command} lost {field}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS])
|
||||
def test_a_command_draws_no_frame_and_no_rule(run, command, args, fields) -> None: # noqa: ANN001
|
||||
"""No borders, no boxes, no header rules — the command typed is the title."""
|
||||
result = run(command, *args)
|
||||
assert not set(result.stdout) & set("─│┌┐└┘├┤━┃")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS])
|
||||
def test_a_command_leaves_no_trailing_whitespace(run, command, args, fields) -> None: # noqa: ANN001
|
||||
"""Column padding must not become invisible spaces at the end of every record."""
|
||||
for line in result_lines(run(command, *args)):
|
||||
assert line == line.rstrip(), repr(line)
|
||||
|
||||
|
||||
def result_lines(result) -> list[str]: # noqa: ANN001
|
||||
"""The command's stdout as lines, with the trailing blank dropped.
|
||||
|
||||
``result.output`` is a third buffer that *both* streams copy into, so a rule about
|
||||
stdout asserted against it passes when the field it wants only ever appeared on
|
||||
stderr — and fails for the wrong reason the moment anything logs.
|
||||
"""
|
||||
return result.stdout.splitlines()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"leaf",
|
||||
[c for c in _leaf_commands() if c not in _NOT_ONE_SHOT | _KEEPS_ITS_COLOUR],
|
||||
ids=lambda leaf: "-".join(leaf),
|
||||
)
|
||||
def test_every_registered_command_obeys_the_rules(run, leaf) -> None: # noqa: ANN001
|
||||
"""The rules belong to the CLI, not to the six commands somebody listed by hand.
|
||||
|
||||
Run bare, a command that needs arguments fails harmlessly as a usage error and still
|
||||
proves it wrote nothing awkward on the way out; a command that can answer without
|
||||
arguments is exercised for real. That is where both shipped violations were — the
|
||||
``---`` in ``about`` drawn as a rule the width of a 16384-cell console, and the
|
||||
``[muted]`` tags around what ``config export-key`` exists to emit.
|
||||
"""
|
||||
out = run(*leaf).stdout
|
||||
assert not _ANSI.search(out), f"{' '.join(leaf)} put an escape sequence on stdout"
|
||||
assert not set(out) & set("─│┌┐└┘├┤━┃╭╮╰╯"), f"{' '.join(leaf)} drew a frame or a rule"
|
||||
assert not _MARKUP.search(out), f"{' '.join(leaf)} printed a console markup tag"
|
||||
for line in out.splitlines():
|
||||
assert line == line.rstrip(), f"{' '.join(leaf)} padded a line: {line!r}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"leaf",
|
||||
[c for c in _leaf_commands() if c not in _NOT_ONE_SHOT | _KEEPS_ITS_COLOUR],
|
||||
ids=lambda leaf: "-".join(leaf),
|
||||
)
|
||||
def test_every_registered_command_answers_json_that_parses(run, leaf) -> None: # noqa: ANN001
|
||||
"""``--json`` is a rendering, so it belongs to the CLI rather than to a list of tools.
|
||||
|
||||
It reached two commands out of twenty and stopped, because each one that wanted it had
|
||||
to restate its whole answer above the rendering. The same sweep that keeps the plain
|
||||
rules honest now runs every command a second time and reads what comes back: **every
|
||||
line on stdout is one complete document**, on success and on empty alike, and a
|
||||
command that fails writes nothing there at all.
|
||||
"""
|
||||
result = run("--json", *leaf)
|
||||
for line in result.stdout.splitlines():
|
||||
json.loads(line) # raises, with the offending line, if anything else got out
|
||||
if result.exit_code in (exitcodes.OK, exitcodes.NO_RESULT):
|
||||
assert result.stdout.strip(), f"{' '.join(leaf)} exited {result.exit_code} silently"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"leaf",
|
||||
[c for c in _leaf_commands() if c not in _NOT_ONE_SHOT | _KEEPS_ITS_COLOUR],
|
||||
ids=lambda leaf: "-".join(leaf),
|
||||
)
|
||||
def test_the_two_faces_never_disagree_about_the_exit_status(run, leaf) -> None: # noqa: ANN001
|
||||
"""``--json`` changes the rendering, never the report.
|
||||
|
||||
The status is the report — it is what a caller branches on — and a flag about *how the
|
||||
answer looks* has no business moving it. This is the cross-check that stops the two
|
||||
faces drifting the way they did before: ``devices --json`` used to exit ``0`` where the
|
||||
plain path said ``5``, so a script could not tell an empty scan from a full one by
|
||||
asking the same question twice.
|
||||
"""
|
||||
assert run("--json", *leaf).exit_code == run(*leaf).exit_code, " ".join(leaf)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("leaf", sorted(_REFUSES_JSON), ids=lambda leaf: "-".join(leaf))
|
||||
def test_a_command_whose_output_is_the_colour_refuses_json_loudly(run, leaf) -> None: # noqa: ANN001
|
||||
"""``specimen``'s output *is* the colour; a document of it would be an empty gesture.
|
||||
|
||||
A loud refusal is the honest answer, and it is the same one this project already gives
|
||||
for the map, the dashboard and the live feed — which simply have no subcommand to
|
||||
refuse from. It is a usage error, so stdout stays empty and the sentence goes where
|
||||
every other failure's does.
|
||||
"""
|
||||
result = run("--json", *leaf)
|
||||
assert result.exit_code == exitcodes.USAGE
|
||||
assert result.stdout == ""
|
||||
assert "machine-readable" in result.stderr
|
||||
|
||||
|
||||
def test_the_menu_has_no_document_and_says_so(run) -> None: # noqa: ANN001
|
||||
"""``meshterm --json`` with no subcommand would launch a full-screen session.
|
||||
|
||||
There is no document an interactive session can emit, and starting one would hang
|
||||
whatever was waiting to parse it.
|
||||
"""
|
||||
result = run("--json")
|
||||
assert result.exit_code == exitcodes.USAGE
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
def test_the_sweep_actually_covers_the_whole_command_surface() -> None:
|
||||
"""A derived list is only a guarantee while it is still finding the commands.
|
||||
|
||||
If the walk breaks — a Typer upgrade moving ``commands``, say — every parametrised
|
||||
case silently becomes zero cases and the sweep passes by covering nothing.
|
||||
"""
|
||||
leaves = _leaf_commands()
|
||||
assert len(leaves) > 40, f"only found {len(leaves)} commands: {leaves}"
|
||||
for expected in [("about",), ("config", "export-key"), ("contacts",), ("trace",)]:
|
||||
assert expected in leaves, expected
|
||||
|
||||
|
||||
def test_a_global_option_may_be_typed_after_the_subcommand(run) -> None: # noqa: ANN001
|
||||
"""``meshterm contacts --json`` is what a person types, so it has to be what works.
|
||||
|
||||
Click binds an option to whatever command declares it, so a global declared on the
|
||||
callback is a parse error one word later. For an output format that is the wrong way
|
||||
round — ``-o json`` goes after the verb in every tool that has one — and the failure
|
||||
was a bare usage error with no hint that the flag simply needed moving.
|
||||
"""
|
||||
after = run("contacts", "--json")
|
||||
before = run("--json", "contacts")
|
||||
assert after.exit_code == exitcodes.OK, after.output
|
||||
assert after.stdout == before.stdout
|
||||
|
||||
|
||||
def test_a_global_option_taking_a_value_moves_with_its_value(run) -> None: # noqa: ANN001
|
||||
"""Lifting ``--db`` to the front must take the path with it, not orphan it."""
|
||||
assert run("contacts", "--sort", "name", "--json").exit_code == exitcodes.OK
|
||||
|
||||
|
||||
def test_lifting_a_global_never_steals_a_subcommands_own_help(run) -> None: # noqa: ANN001
|
||||
"""``contacts --help`` must stay the *contacts* help, not silently become the app's.
|
||||
|
||||
``--help`` is on every command, so a rule that lifts "the group's options" would take
|
||||
it too and answer a different question than the one asked.
|
||||
"""
|
||||
contacts_help = run("contacts", "--help").stdout
|
||||
assert "contacts [OPTIONS]" in contacts_help
|
||||
assert "--sort" in contacts_help # a contacts option, absent from the app's own help
|
||||
app_help = run("--help").stdout
|
||||
assert "COMMAND [ARGS]" in app_help
|
||||
assert "--sort" not in app_help
|
||||
|
||||
|
||||
def test_a_time_column_is_headed_by_its_fact_not_by_its_format(run) -> None: # noqa: ANN001
|
||||
"""``--absolute`` changes what is under the heading, so the heading must survive it.
|
||||
|
||||
A column headed ``AGE`` holding ``2026-09-08T04:18:25-04:00`` is a heading that lies —
|
||||
and every other time column already names the fact (``HEARD``, ``RECORDED``), which
|
||||
reads correctly whichever form the run asked for.
|
||||
"""
|
||||
run("chat", "send", "--to", "Alice", "hello")
|
||||
relative = result_lines(run("chat", "history", "--to", "Alice"))[0].split()
|
||||
absolute = result_lines(run("chat", "history", "--to", "Alice", "--absolute"))[0].split()
|
||||
assert relative == absolute
|
||||
assert "AGE" not in relative
|
||||
|
||||
|
||||
# -- listings -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_contacts_names_every_node_bare_and_ages_every_time(run) -> None: # noqa: ANN001
|
||||
"""The two rules that reversed, checked on the listing they were reversed for.
|
||||
|
||||
A name is bare — the alignment is what makes it one field now — and ``HEARD`` is an
|
||||
age, because "heard recently?" is the question this listing is opened to ask and an ISO
|
||||
instant makes the reader do arithmetic to answer it. ``--absolute`` puts the instants
|
||||
back, for anyone who wants them.
|
||||
"""
|
||||
header, *records = result_lines(run("contacts"))
|
||||
assert header.split() == ["NAME", "TYPE", "HEARD", "PKTS", "HASH", "LOCATION", "KEY"]
|
||||
assert records, "the simulator always has contacts"
|
||||
for line in records:
|
||||
assert not line.startswith('"')
|
||||
age = line.split()[2]
|
||||
assert age in ("now", "never") or age[-1] in "mhdw"
|
||||
|
||||
# `--absolute` asks for a different *form* of time, not for one fewer fact, so
|
||||
# `never` survives it: a node that has never been heard has no instant to print.
|
||||
absolute = result_lines(run("--absolute", "contacts"))[1:]
|
||||
for line in absolute:
|
||||
stamp = line.split()[2]
|
||||
assert stamp in ("-", "never") or stamp[:4].isdigit()
|
||||
|
||||
|
||||
def test_contacts_never_elides_a_key(run) -> None: # noqa: ANN001
|
||||
"""A truncated key is not something a caller can hand back to ``--to`` or ``--path``."""
|
||||
for line in result_lines(run("contacts"))[1:]:
|
||||
assert "…" not in line and not line.endswith("...")
|
||||
|
||||
|
||||
def test_a_config_line_can_be_typed_back_into_config_set(run) -> None: # noqa: ANN001
|
||||
"""``show`` names each setting by the key ``get``/``set`` take, and prints its value."""
|
||||
values = dict(line.split(None, 1) for line in result_lines(run("config", "show")) if line)
|
||||
assert values["name"] == "MockCompanion"
|
||||
# An enum prints its number, not the reader's label: `parse_value` takes the number.
|
||||
assert values["adv_loc_policy"].isdigit()
|
||||
assert run("config", "get", "name").output.strip() == "MockCompanion"
|
||||
|
||||
|
||||
def test_every_setting_show_prints_is_one_set_takes_back(run) -> None: # noqa: ANN001
|
||||
"""The round-trip claim, checked on every setting rather than on the two easy ones.
|
||||
|
||||
``config show > f`` and feeding ``f`` back in is the obvious thing to do with a dump,
|
||||
and the failure mode is silent: an empty string printed as ``""`` used to parse back
|
||||
as *the two quote characters*, and the next dump looked identical, so nothing ever
|
||||
said the setting had been replaced. A refusal is fine here — you cannot set a value
|
||||
the firmware never reported — but a value that parses to something else is not.
|
||||
"""
|
||||
from meshterm.core import device_config as dc
|
||||
from meshterm.tools.config import _script_value
|
||||
|
||||
probes = {
|
||||
"str": ["", "Yagi-Repeater", "a name with spaces"],
|
||||
"bool": [True, False],
|
||||
"int": [0, 1, 12],
|
||||
"float": [0.0, 3.5],
|
||||
}
|
||||
checked = 0
|
||||
for _category, specs in dc.settings_by_category():
|
||||
for spec in specs:
|
||||
if spec.value_type == "enum" and spec.choices:
|
||||
values: list = list(spec.choices)
|
||||
else:
|
||||
values = probes.get(spec.value_type, [])
|
||||
for value in values:
|
||||
printed = _script_value(spec, value)
|
||||
try:
|
||||
parsed = dc.parse_value(spec, printed, {})
|
||||
except dc.DeviceConfigError:
|
||||
continue # a loud refusal is honest; a wrong value is not
|
||||
checked += 1
|
||||
assert parsed == value, f"{spec.key}: show printed {printed!r} -> {parsed!r}"
|
||||
assert checked > 40, f"only {checked} round-trips exercised"
|
||||
|
||||
|
||||
def test_an_unreported_pin_is_not_masked_as_though_one_were_set(run) -> None: # noqa: ANN001
|
||||
"""Bullets say "there is a secret here". An absence must not borrow that claim.
|
||||
|
||||
``conceal`` already declines to mask the *menu's* absence token for exactly this
|
||||
reason, but the scripted token is ``-``, which it had never been shown — so a radio
|
||||
that had never reported a PIN dumped six bullets, and the reader (or the bug report
|
||||
they pasted it into) read that as PIN-locked.
|
||||
"""
|
||||
from meshterm.ui import script
|
||||
from meshterm.ui.config_editor import MASK_MARK, conceal
|
||||
|
||||
# The rule at the boundary where it broke: a real value is masked, an absence is not,
|
||||
# and each surface says which glyph it writes an absence with.
|
||||
assert conceal("1234", absent=script.NONE) == MASK_MARK * 6
|
||||
assert conceal(script.NONE, absent=script.NONE) == script.NONE
|
||||
assert conceal("?") == "?"
|
||||
|
||||
# And the two surfaces must never disagree about the same radio: `show` wears bullets
|
||||
# only where `get` has something to conceal.
|
||||
values = dict(
|
||||
line.split(None, 1) for line in result_lines(run("config", "show")) if " " in line
|
||||
)
|
||||
got = run("config", "get", "device_pin").output.strip()
|
||||
assert (MASK_MARK in values["device_pin"]) == (got != script.NONE), (
|
||||
f"show says {values['device_pin']!r} while get says {got!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_config_get_prints_the_bare_value(run) -> None: # noqa: ANN001
|
||||
"""The caller named the key; repeating it back is one more thing to strip off."""
|
||||
assert run("config", "get", "tx_power").output.strip() == "20"
|
||||
|
||||
|
||||
def test_preferences_get_prints_a_value_its_own_set_would_take(run) -> None: # noqa: ANN001
|
||||
"""The page's ``5 s`` carries a unit that ``preferences set`` will not accept back."""
|
||||
assert run("preferences", "get", "trace_cooldown_s").output.strip() == "5"
|
||||
|
||||
|
||||
# -- path lines -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_trace_draws_its_route_with_arrows_and_keeps_its_path_in_commas(run) -> None: # noqa: ANN001
|
||||
"""The sharpest of the reversals, and the reason is the project's own lexicon.
|
||||
|
||||
A *path* is a spec you compose and can paste back into ``--path``, so it stays
|
||||
comma-separated and round-trippable. A *route* is what a walk actually did; it is not a
|
||||
spec, and rendering it with commas promised a round trip it does not have. Our own node
|
||||
is still named like any other hop — the menu's ``★`` says "you already know who this
|
||||
is", which is true of the reader and false of whoever opens the file later.
|
||||
"""
|
||||
result = run("trace-path", "--path", "a1,d4,a1")
|
||||
assert result.exit_code == exitcodes.OK, result.output
|
||||
facts = dict(line.split(None, 1) for line in result_lines(result) if line and " " in line)
|
||||
assert facts["success"] == "yes"
|
||||
|
||||
route = facts["route"]
|
||||
assert "★" not in route
|
||||
assert "→" in route
|
||||
assert _ROUTE_LINE.fullmatch(route), route
|
||||
|
||||
assert _PATH_SPEC.fullmatch(facts["path"]), facts["path"]
|
||||
assert "→" not in facts["path"]
|
||||
|
||||
|
||||
def test_a_trace_reports_its_per_hop_readings_under_a_header(run) -> None: # noqa: ANN001
|
||||
"""The second block is a record per hop, joined to the route line by hash."""
|
||||
lines = result_lines(run("trace-path", "--path", "a1,d4,a1"))
|
||||
header = next(line for line in lines if line.startswith("HOP"))
|
||||
assert header.split() == ["HOP", "FROM", "TO", "SNR_DB"]
|
||||
|
||||
|
||||
# -- exit statuses --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_empty_result_is_its_own_status_and_prints_nothing(run) -> None: # noqa: ANN001
|
||||
"""The simulator configures no channel slots: the command worked and found nothing.
|
||||
|
||||
``grep`` conflates "found nothing" with "worked"; this is the whole reason
|
||||
:data:`~meshterm.core.exitcodes.NO_RESULT` exists.
|
||||
"""
|
||||
result = run("channels", "list")
|
||||
assert result.exit_code == exitcodes.NO_RESULT
|
||||
assert result.output.strip() == ""
|
||||
|
||||
|
||||
def test_a_bad_flag_is_a_usage_error(run) -> None: # noqa: ANN001
|
||||
"""Click's own status, named in the table so it is complete."""
|
||||
assert run("contacts", "--no-such-flag").exit_code == exitcodes.USAGE
|
||||
|
||||
|
||||
def test_a_bad_setting_key_is_a_plain_failure(run) -> None: # noqa: ANN001
|
||||
"""Retrying will not help: the caller has to change what it asked for."""
|
||||
result = run("config", "get", "nosuchkey")
|
||||
assert result.exit_code == exitcodes.FAILURE
|
||||
|
||||
|
||||
def test_a_failure_says_so_on_stderr_and_never_on_stdout(run) -> None: # noqa: ANN001
|
||||
"""``program: what went wrong`` — so a caller parsing stdout never has to filter it."""
|
||||
result = run("config", "get", "nosuchkey")
|
||||
assert result.stdout == ""
|
||||
assert "meshterm: unknown setting" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
("config", "reboot"),
|
||||
("config", "factory-reset"),
|
||||
("config", "import-key", "00" * 32),
|
||||
("channels", "clear", "1"),
|
||||
("preferences", "reset"),
|
||||
],
|
||||
ids=["reboot", "factory-reset", "import-key", "channels-clear", "preferences-reset"],
|
||||
)
|
||||
def test_a_destructive_command_without_yes_is_a_silent_usage_error(run, args) -> None: # noqa: ANN001
|
||||
"""A missing confirmation *is* a bad argument, and it says so where errors go.
|
||||
|
||||
These used to print a red refusal on stdout and exit 1 — a colour and a sentence that
|
||||
is not the command's answer, in whatever was reading the command's answer.
|
||||
"""
|
||||
result = run(*args)
|
||||
assert result.exit_code == exitcodes.USAGE
|
||||
assert result.stdout == ""
|
||||
assert "--yes" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args,flag",
|
||||
[
|
||||
(("contacts", "--sort", "bogus"), "--sort"),
|
||||
(("records", "--category", "long_hual"), "--category"),
|
||||
],
|
||||
ids=["contacts-sort", "records-category"],
|
||||
)
|
||||
def test_a_value_outside_a_closed_set_is_refused_rather_than_ignored(run, args, flag) -> None: # noqa: ANN001
|
||||
"""A typo in a closed-set option used to be answered with a different question.
|
||||
|
||||
``--sort bogus`` quietly sorted by the default, and ``--category long_hual`` quietly
|
||||
selected *every* discipline — which then came back as exit ``5``, the code that means
|
||||
a real empty result. A caller branching on ``5`` could not tell "there are no records"
|
||||
from "you misspelled the discipline", which is the worst answer a closed set can give.
|
||||
"""
|
||||
result = run(*args)
|
||||
assert result.exit_code == exitcodes.USAGE
|
||||
assert result.stdout == ""
|
||||
assert flag in result.stderr
|
||||
|
||||
|
||||
def test_a_profile_that_names_nothing_is_refused_before_anything_transmits(run) -> None: # noqa: ANN001
|
||||
"""``--profile`` is a claim about *which* radio, and an unkeepable one is not a default.
|
||||
|
||||
An unknown name used to fall through to ordinary discovery, so a scheduled
|
||||
``--profile yagi config advert`` with a typo transmitted from whichever companion
|
||||
happened to be attached — the one failure mode where being quietly wrong puts a packet
|
||||
on the air.
|
||||
"""
|
||||
result = run("--profile", "nosuchprofile", "info")
|
||||
assert result.exit_code == exitcodes.USAGE
|
||||
assert result.stdout == ""
|
||||
assert "nosuchprofile" in result.stderr
|
||||
|
||||
|
||||
def test_a_port_that_will_not_open_is_no_device_not_a_device_failure(run) -> None: # noqa: ANN001
|
||||
"""The two device statuses answer different questions, and this one answered wrong.
|
||||
|
||||
``3`` says nothing was transmitted — look at what is plugged in. ``4`` says the radio
|
||||
was reached and the operation failed, so a retry is reasonable. An unopenable
|
||||
``--port`` reported ``4``, with the message "the connection to the device was lost"
|
||||
describing a connection there had never been. The manual's own ``case $?`` recipe
|
||||
branches on exactly this split.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from meshterm.cli import app
|
||||
|
||||
result = CliRunner().invoke(app, ["--port", "NOSUCHPORT99", "info"])
|
||||
assert result.exit_code == exitcodes.NO_DEVICE
|
||||
assert "could not open" in result.stderr
|
||||
assert "lost" not in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
("repeater-admin", "Nobody", "get", "name"),
|
||||
("courier", "queue", "Nobody", "hi"),
|
||||
("courier", "queue", "Alice", "hi", "--at", "25:99"),
|
||||
],
|
||||
ids=["unknown-admin-node", "unknown-courier-contact", "bad-at-time"],
|
||||
)
|
||||
def test_a_bad_argument_is_never_reported_as_a_device_failure(run, args) -> None: # noqa: ANN001
|
||||
"""``4`` promises a caller that the radio answered and a retry is worth trying.
|
||||
|
||||
Each of these is the *argument* being wrong, decided before a word goes over the air —
|
||||
so a caller that retried on ``4`` would retransmit nothing, forever. ``tx-optimize``
|
||||
already answered the identical question the identical way for a missing password;
|
||||
these three were the ones that had not caught up.
|
||||
"""
|
||||
result = run(*args)
|
||||
assert result.exit_code == exitcodes.USAGE, result.output
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
def test_a_node_that_answers_no_is_still_a_device_failure(run) -> None: # noqa: ANN001
|
||||
"""The other side of the line: the radio was reached, so a retry is a real option.
|
||||
|
||||
A refused admin login is not a bad argument — the password may simply have changed —
|
||||
and reclassifying the arguments above must not drag this one along with them.
|
||||
"""
|
||||
result = run("repeater-admin", "Yagi-Repeater", "get", "name", "--password", "wrongpw")
|
||||
assert result.exit_code == exitcodes.DEVICE, result.output
|
||||
|
||||
|
||||
def test_an_empty_result_writes_nothing_to_stdout_at_all(run) -> None: # noqa: ANN001
|
||||
"""Not a "no channels configured" line: the status carries it, so the pipe stays clean."""
|
||||
result = run("channels", "list")
|
||||
assert result.exit_code == exitcodes.NO_RESULT
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
def test_mock_and_an_explicit_radio_are_a_bad_command_line(run) -> None: # noqa: ANN001
|
||||
"""One flag says "pretend", the other names one exact radio, and both cannot be true.
|
||||
|
||||
``--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.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from meshterm.cli import app
|
||||
|
||||
for flag, value in (("--port", "COM7"), ("--ble", "AA:BB"), ("--tcp", "host:1")):
|
||||
result = CliRunner().invoke(app, ["--mock", flag, value, "info"])
|
||||
assert result.exit_code == exitcodes.USAGE, flag
|
||||
assert result.stdout == ""
|
||||
assert flag in result.stderr
|
||||
|
||||
|
||||
def test_an_acknowledgement_reaches_the_person_without_reaching_the_pipe(run) -> None: # noqa: ANN001
|
||||
"""``ack`` used to be dropped on the CLI, and the reason only covered half the case.
|
||||
|
||||
An acknowledgement is not the answer, and a caller redirecting stdout must catch only
|
||||
the answer — but stdout is not the only stream. stderr honours that rule exactly while
|
||||
giving a person watching a five-minute ``config restore`` the setting-by-setting ✓ they
|
||||
had no way to see.
|
||||
"""
|
||||
result = run("config", "set", "tx_power", "20")
|
||||
assert result.exit_code == exitcodes.OK
|
||||
assert result.stdout == ""
|
||||
assert "tx_power" in result.stderr
|
||||
|
||||
|
||||
def test_a_closing_message_goes_to_stderr_too(run) -> None: # noqa: ANN001
|
||||
"""A closing count is for a person, not a record for a parser.
|
||||
|
||||
It restates what the listing above already showed and what ``$?`` already says, so it
|
||||
has no business in a redirect — and it is exactly what someone wants after a command
|
||||
that scrolled past them.
|
||||
"""
|
||||
result = run("records")
|
||||
assert result.exit_code == exitcodes.NO_RESULT
|
||||
assert result.stdout == ""
|
||||
assert "records stored" in result.stderr
|
||||
|
||||
|
||||
# -- the map --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_map_has_no_cli_command_at_all(run) -> None: # noqa: ANN001
|
||||
"""A picture has no scripted face; the located nodes stay in ``contacts``."""
|
||||
assert run("map").exit_code == exitcodes.USAGE
|
||||
|
||||
|
||||
# -- help -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_help_is_plain_and_names_every_exit_status(run) -> None: # noqa: ANN001
|
||||
"""Click's own help: no boxed panels, no colour, and the status table under it."""
|
||||
result = run("--help")
|
||||
assert not _ANSI.search(result.output)
|
||||
assert not set(result.output) & set("─│╭╮╰╯")
|
||||
for code in exitcodes.MEANINGS:
|
||||
assert f"{code} " in result.output
|
||||
@@ -687,6 +687,10 @@ class _ScriptedUi:
|
||||
def note(self, markup: str) -> None:
|
||||
self.notes.append(markup)
|
||||
|
||||
def ack(self, markup: str) -> None:
|
||||
# The menu's answer: an acknowledgement is a note. (The scripted CLI drops it.)
|
||||
self.note(markup)
|
||||
|
||||
def show(self, *renderables: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
+25
-6
@@ -13,6 +13,7 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from meshterm.core import exitcodes
|
||||
from meshterm.core.courier_store import DONE_CAP, QUEUED, CourierStore
|
||||
from meshterm.core.models import ChatMessage, Contact, utcnow
|
||||
from meshterm.core.watch_store import WatchStore
|
||||
@@ -229,6 +230,10 @@ class _NoteUi:
|
||||
def note(self, markup: str) -> None:
|
||||
self.notes.append(markup)
|
||||
|
||||
def ack(self, markup: str) -> None:
|
||||
# The menu's answer: an acknowledgement is a note. (The scripted CLI drops it.)
|
||||
self.note(markup)
|
||||
|
||||
def show(self, *renderables: object) -> None:
|
||||
self.shown.extend(renderables)
|
||||
|
||||
@@ -269,7 +274,11 @@ async def test_cli_list_renders_waiting_and_finished(tmp_path: Path) -> None:
|
||||
|
||||
result = await CourierTool().run(ctx, {"cli_action": "list"})
|
||||
assert result.summary == {"entries": 2}
|
||||
assert ctx.ui.shown # a table was rendered
|
||||
# The outbox is *stated*, not printed: the tool hands back the rows and the CLI
|
||||
# boundary picks a renderer for them (see meshterm.ui.report).
|
||||
listing = result.report[0]
|
||||
assert sorted(row["state"] for row in listing.rows) == ["delivered", "waiting"]
|
||||
assert sorted(row["text"] for row in listing.rows) == ["landed", "still waiting"]
|
||||
|
||||
|
||||
async def test_cli_list_empty_notes_and_counts_zero(tmp_path: Path) -> None:
|
||||
@@ -279,7 +288,10 @@ async def test_cli_list_empty_notes_and_counts_zero(tmp_path: Path) -> None:
|
||||
ctx = _ToolCtx(tmp_path)
|
||||
result = await CourierTool().run(ctx, {"cli_action": "list"})
|
||||
assert result.summary == {"entries": 0}
|
||||
assert ctx.ui.shown == []
|
||||
assert result.exit_code == exitcodes.NO_RESULT
|
||||
# An empty listing, not an absent one: the plain face draws nothing from it and the
|
||||
# machine face gets `[]`, which is a document a consumer can read.
|
||||
assert result.report[0].rows == []
|
||||
|
||||
|
||||
async def test_cli_cancel_removes_a_waiting_entry(tmp_path: Path) -> None:
|
||||
@@ -308,13 +320,20 @@ async def test_cli_send_forces_one_attempt(tmp_path: Path) -> None:
|
||||
assert result.summary == {"id": entry.ident, "outcome": "delivered"}
|
||||
|
||||
|
||||
async def test_cli_send_unknown_entry_errors(tmp_path: Path) -> None:
|
||||
"""Sending a non-existent entry is a clean device-command error, not a crash."""
|
||||
from meshterm.core.connection import DeviceCommandError
|
||||
async def test_cli_send_unknown_entry_is_a_bad_argument(tmp_path: Path) -> None:
|
||||
"""An id nobody has is a *usage* error, and it used to claim the device had failed.
|
||||
|
||||
The outbox is a local file: nothing was transmitted, and no retry will make an id
|
||||
exist. Exit 4 tells a caller "the radio was reached and the operation failed, try
|
||||
again" — which sent a scheduled sender into a retry loop over a typo. Exit 2 says fix
|
||||
the command, which is the truth.
|
||||
"""
|
||||
import typer
|
||||
|
||||
from meshterm.tools.courier import CourierTool
|
||||
|
||||
ctx = _ToolCtx(tmp_path)
|
||||
with pytest.raises(DeviceCommandError):
|
||||
with pytest.raises(typer.BadParameter):
|
||||
await CourierTool().run(ctx, {"cli_action": "send", "id": 999})
|
||||
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ def test_contacts_table_breaks_metric_ties_by_name_ascending() -> None:
|
||||
from datetime import timedelta
|
||||
|
||||
from meshterm.core.models import Contact, utcnow
|
||||
from meshterm.ui.widgets import ContactsSort, _ordered_contacts
|
||||
from meshterm.ui.widgets import ContactsSort, ordered_contacts
|
||||
|
||||
same = utcnow() - timedelta(minutes=5)
|
||||
contacts = [
|
||||
@@ -209,7 +209,7 @@ def test_contacts_table_breaks_metric_ties_by_name_ascending() -> None:
|
||||
|
||||
def order(ascending: bool) -> list[str]:
|
||||
sort = ContactsSort(column="heard", ascending=ascending)
|
||||
return [c.name for c in _ordered_contacts(contacts, {}, sort)]
|
||||
return [c.name for c in ordered_contacts(contacts, {}, sort)]
|
||||
|
||||
# Freshest first: the two 5-min contacts lead, ordered Alice→Charlie, then Bob (3h).
|
||||
assert order(ascending=True) == ["Alice", "Charlie", "Bob"]
|
||||
@@ -219,23 +219,25 @@ def test_contacts_table_breaks_metric_ties_by_name_ascending() -> None:
|
||||
|
||||
async def test_contacts_tool_opens_sorted_by_heard(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The Contacts list opens most-recently-heard first — who's out there now, not a roll call."""
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.tools.contacts import ContactsTool
|
||||
from meshterm.ui import widgets
|
||||
|
||||
captured: dict = {}
|
||||
real_order = widgets.ordered_contacts
|
||||
|
||||
def fake_table(self_name, self_key, contacts, prefix_bytes, counts, sort):
|
||||
def fake_order(contacts, counts, sort): # noqa: ANN001, ANN202
|
||||
captured["sort"] = sort
|
||||
return ""
|
||||
return real_order(contacts, counts, sort)
|
||||
|
||||
monkeypatch.setattr(widgets, "contacts_table", fake_table)
|
||||
monkeypatch.setattr(widgets, "ordered_contacts", fake_order)
|
||||
|
||||
class _Devstate:
|
||||
async def self_info(self) -> dict:
|
||||
return {"name": "Us", "public_key": "aa" * 32}
|
||||
|
||||
async def contacts(self) -> list:
|
||||
return []
|
||||
return [Contact(name="Alice", public_key="d4" * 32)]
|
||||
|
||||
async def path_hash_mode(self) -> int:
|
||||
return 0
|
||||
@@ -1325,8 +1327,13 @@ def test_uptime_renders_compact_units() -> None:
|
||||
|
||||
|
||||
def test_require_yes_blocks_without_confirmation() -> None:
|
||||
"""Destructive CLI commands abort unless --yes is passed."""
|
||||
with pytest.raises(typer.Exit):
|
||||
"""Destructive CLI commands abort unless --yes is passed.
|
||||
|
||||
As a *usage* error: a missing confirmation is a bad invocation, so it exits under the
|
||||
parser's own status and prints in the parser's own words on stderr, rather than a red
|
||||
line of its own on stdout (see :mod:`meshterm.core.exitcodes`).
|
||||
"""
|
||||
with pytest.raises(typer.BadParameter, match="--yes"):
|
||||
_require_yes(False, "factory reset erases all data")
|
||||
_require_yes(True, "factory reset erases all data") # does not raise
|
||||
|
||||
|
||||
@@ -132,6 +132,10 @@ def test_bare_text_default_emoji_are_left_exactly_as_measured() -> None:
|
||||
# A heading built around one costs its own cells and no more, so the row frames flush.
|
||||
caption = cell_len(f"── {_ROAD} Longest distance ──")
|
||||
assert caption == len("── ") + 1 + len(" Longest distance ──")
|
||||
# The Trophy case's *visible* gap after the road is not this module's business and must
|
||||
# not become it: the heading pads the mark out to the widest of the seven disciplines
|
||||
# (records_screen.discipline_label), which costs a real cell the terminal advances over
|
||||
# — where widening the glyph here would claim one it does not.
|
||||
|
||||
|
||||
def test_flags_measure_two_cells_in_both_authorities() -> None:
|
||||
|
||||
@@ -22,18 +22,21 @@ starts reporting XPASS.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from rich.cells import cell_len
|
||||
from rich.text import Text
|
||||
|
||||
from meshterm.core.advert_store import AdvertPolicy
|
||||
from meshterm.core.channels import DEFAULT_PUBLIC_SECRET, derive_secret
|
||||
from meshterm.core.courier_store import CourierStore
|
||||
from meshterm.core.models import (
|
||||
NODE_TYPE_REPEATER,
|
||||
@@ -339,6 +342,163 @@ def _chat(cols: int, rows: int) -> Screen:
|
||||
)
|
||||
|
||||
|
||||
class _PickerChat:
|
||||
"""The unread counter the picker rows read live."""
|
||||
|
||||
def __init__(self, unread: dict[str, int]) -> None:
|
||||
self._unread = unread
|
||||
|
||||
def unread(self, key: str) -> int:
|
||||
return self._unread.get(key, 0)
|
||||
|
||||
|
||||
class _PickerDevstate:
|
||||
"""The session-cached device reads the picker builds itself from."""
|
||||
|
||||
def __init__(self, contacts: list[Contact], slots: list) -> None:
|
||||
self._contacts = contacts
|
||||
self._slots = slots
|
||||
|
||||
async def channel_slots(self) -> list:
|
||||
return list(self._slots)
|
||||
|
||||
async def contacts(self) -> list[Contact]:
|
||||
return list(self._contacts)
|
||||
|
||||
|
||||
class _PickerRepo:
|
||||
"""The stored history behind each row's age, badge and last-message preview."""
|
||||
|
||||
def __init__(self, lasts: dict) -> None:
|
||||
self._lasts = lasts
|
||||
|
||||
def last_chat_messages(self) -> dict:
|
||||
return dict(self._lasts)
|
||||
|
||||
def node_names(self) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _chat_picker(cols: int, rows: int) -> Screen:
|
||||
"""The conversation picker, built through the tool's own row funnel.
|
||||
|
||||
Its widest case: a name at the lane's ceiling, a three-digit-capable unread badge, and
|
||||
a last message far longer than any terminal — which is the row ←→ scroll, so it has to
|
||||
be cut rather than fitted (see :meth:`meshterm.tools.chat.ChatTool._picker_items`).
|
||||
"""
|
||||
from meshterm.tools.chat import ChatTool
|
||||
|
||||
contacts = [
|
||||
Contact(name="Alice", public_key="aa" * 32, key_prefix="aa" * 6, node_type=1),
|
||||
Contact(name="A Rather Long Contact Name", public_key="d4" * 32, key_prefix="d4" * 6),
|
||||
Contact(name="Homestead", public_key="60" * 32, key_prefix="60" * 6, node_type=1),
|
||||
]
|
||||
now = datetime(2026, 7, 12, 14, 30, tzinfo=timezone.utc)
|
||||
lasts = {
|
||||
# The default public channel, as _channels_from_slots names it with no slots read.
|
||||
"chan:slot:0": ChatMessage(
|
||||
text="Alice: anyone up around the Plateau tonight? testing a new antenna",
|
||||
is_channel=True,
|
||||
channel_idx=0,
|
||||
channel_id="slot:0",
|
||||
created_at=now,
|
||||
),
|
||||
f"dm:{'aa' * 6}": ChatMessage(
|
||||
text="on my way, should be there soon - the bridge is backed up again",
|
||||
peer="aa" * 6,
|
||||
created_at=now,
|
||||
),
|
||||
}
|
||||
ctx = _PickerCtx(contacts, lasts)
|
||||
items = asyncio.run(ChatTool()._picker_items(ctx))
|
||||
return SelectScreen(
|
||||
"Chat - pick a conversation",
|
||||
items,
|
||||
footer_hint="↑↓ move · type to filter · Enter open · Esc back",
|
||||
delete_hint="Del erase",
|
||||
)
|
||||
|
||||
|
||||
def _channels_manager(cols: int, rows: int) -> Screen:
|
||||
"""The channel manager at its widest: every lane populated, every action row drawn.
|
||||
|
||||
Built through the feature's own row funnel, so the header line, the glyph lane, the
|
||||
unread badge, the counts, the ages and the sparkline are laid out by the code the app
|
||||
runs. The cases that matter are here: a name at the lane's ceiling, a muted channel (its
|
||||
mark folds to a different glyph on the console), a channel with no messages at all, and
|
||||
a three-digit unread badge.
|
||||
"""
|
||||
from meshterm.ui.channels import _MANAGER_HINT, ChannelSlot, _LiveStats, _menu_items
|
||||
|
||||
slots = [
|
||||
ChannelSlot(idx=0, name="Public", secret=DEFAULT_PUBLIC_SECRET),
|
||||
ChannelSlot(idx=1, name="Lakeside emergency", secret=bytes(range(16))),
|
||||
ChannelSlot(idx=2, name="#montreal", secret=derive_secret("#montreal")),
|
||||
ChannelSlot(idx=3, name="Ops", secret=bytes(range(16, 32))),
|
||||
]
|
||||
ctx = _ChannelsCtx(muted={slots[3].identity})
|
||||
title, items = _menu_items(ctx, slots, 8, _LiveStats(ctx))
|
||||
return SelectScreen(title, items, footer_hint=_MANAGER_HINT)
|
||||
|
||||
|
||||
def _channel_detail(cols: int, rows: int) -> Screen:
|
||||
"""One channel's action page, its vital-signs line above the rows."""
|
||||
from meshterm.ui.channels import ChannelSlot, _detail_items, _detail_summary, _LiveStats
|
||||
|
||||
slot = ChannelSlot(idx=2, name="Lakeside emergency", secret=bytes(range(16)))
|
||||
ctx = _ChannelsCtx(muted=set())
|
||||
stats = _LiveStats(ctx)
|
||||
return SelectScreen(
|
||||
f"Channel — {slot.name}",
|
||||
_detail_items(ctx, slot),
|
||||
prompt=_detail_summary(ctx, slot, stats),
|
||||
footer_hint="↑↓ move · Enter select · Esc back",
|
||||
)
|
||||
|
||||
|
||||
class _ChannelsChat:
|
||||
"""The chat service surface the channel rows read: unread counts and mute state."""
|
||||
|
||||
def __init__(self, muted: set[str]) -> None:
|
||||
self._muted = muted
|
||||
|
||||
def unread(self, key: str) -> int:
|
||||
"""A three-digit badge on one channel, so the lane is drawn at its widest."""
|
||||
return 128 if key.endswith("slot:1") or "Lakeside" in key else 0
|
||||
|
||||
|
||||
class _ChannelsCtx:
|
||||
"""The minimal AppContext surface the channel rows and detail page read."""
|
||||
|
||||
def __init__(self, muted: set[str]) -> None:
|
||||
self.chat = _ChannelsChat(muted)
|
||||
self.repo = _ChannelsRepo()
|
||||
self.preferences = Preferences()
|
||||
self._muted = muted
|
||||
|
||||
@property
|
||||
def mute_store(self): # noqa: ANN201 - a stand-in for the real store
|
||||
"""A store answering only the one question a row asks it."""
|
||||
return SimpleNamespace(is_muted=lambda identity: identity in self._muted)
|
||||
|
||||
|
||||
class _ChannelsRepo:
|
||||
"""Channel statistics with one busy channel, one quiet one, and one never used."""
|
||||
|
||||
def channel_stats(self, *a, **k): # noqa: ANN002, ANN003, ANN201
|
||||
"""Return nothing: the rows then draw their empty marks, which is the tighter case."""
|
||||
return {}
|
||||
|
||||
|
||||
class _PickerCtx:
|
||||
"""The minimal AppContext surface :meth:`ChatTool._picker_items` reads."""
|
||||
|
||||
def __init__(self, contacts: list[Contact], lasts: dict) -> None:
|
||||
self.devstate = _PickerDevstate(contacts, [])
|
||||
self.repo = _PickerRepo(lasts)
|
||||
self.chat = _PickerChat({"chan:slot:0": 3, f"dm:{'aa' * 6}": 12})
|
||||
|
||||
|
||||
def _livefeed(cols: int, rows: int) -> Screen:
|
||||
return LiveFeedScreen(
|
||||
session=_GallerySession(cols, rows),
|
||||
@@ -701,6 +861,9 @@ _ENTRIES: list[_Entry] = [
|
||||
_Entry("node_detail", _node_detail),
|
||||
_Entry("map", _map),
|
||||
_Entry("chat", _chat),
|
||||
_Entry("chat_picker", _chat_picker),
|
||||
_Entry("channels_manager", _channels_manager),
|
||||
_Entry("channel_detail", _channel_detail),
|
||||
_Entry("livefeed", _livefeed),
|
||||
_Entry("walk", _walk),
|
||||
_Entry("timemachine", _timemachine),
|
||||
|
||||
+7
-62
@@ -1178,36 +1178,6 @@ def ctx(tmp_path: Path):
|
||||
context.repo.close()
|
||||
|
||||
|
||||
async def test_map_tool_static_render_plots_contacts(ctx) -> None:
|
||||
"""The CLI path plots the device's located contacts and reports the breakdown.
|
||||
|
||||
The mock companion's contacts carry two located repeaters and one located leaf node
|
||||
(a fourth, Alice, has no fix), so the contact list — not the observation history — is
|
||||
what fills the map.
|
||||
"""
|
||||
# Seed an observation whose key matches a located contact, so its reception detail is
|
||||
# merged onto that contact's marker.
|
||||
run_id = ctx.repo.start_run("monitor", {})
|
||||
ctx.repo.record_observation(
|
||||
run_id,
|
||||
Observation(
|
||||
node="a1b2c3d4",
|
||||
name="Yagi-Repeater",
|
||||
node_type=NODE_TYPE_REPEATER,
|
||||
snr=6.0,
|
||||
observed_at=utcnow(),
|
||||
),
|
||||
)
|
||||
result = await MapTool().run(ctx, {"static": True, "basemap": False})
|
||||
assert result.summary["located"] == 3
|
||||
assert result.summary["repeaters"] == 2
|
||||
assert result.summary["nodes"] == 1
|
||||
assert result.summary["self_located"] is False
|
||||
out = ctx.console.file.getvalue()
|
||||
assert out != ""
|
||||
assert "1 pkts" in out # the seeded observation's detail merged onto the contact
|
||||
|
||||
|
||||
def test_usable_fix_rejects_out_of_range_and_null_island() -> None:
|
||||
"""A fix is usable only in valid lat/lon range and away from the 0/0 no-GPS sentinel."""
|
||||
from meshterm.core.geo import usable_fix
|
||||
@@ -2565,41 +2535,16 @@ def test_each_road_class_keeps_its_own_colour_through_the_per_tile_style_memo()
|
||||
assert mark_rgb(expected[0]) in painted, f"{cls} lost its own colour"
|
||||
|
||||
|
||||
def _run_map_cli(monkeypatch, *args: str):
|
||||
"""Invoke the ``map`` subcommand with the tool itself stubbed out.
|
||||
def test_the_map_registers_no_cli_command() -> None:
|
||||
"""The map is menu-only: a picture has no scripted face.
|
||||
|
||||
Returns the params dict the command would have handed :func:`run_tool_command`,
|
||||
or ``None`` if the invocation failed before getting there.
|
||||
Its answer is a drawing whose nodes are told apart by colour, which the scripted CLI
|
||||
does not have (see :mod:`meshterm.ui.script`) — so the tool registers nothing rather
|
||||
than printing an unparseable block of braille. The located nodes stay scriptable
|
||||
through ``meshterm contacts``.
|
||||
"""
|
||||
import typer
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from meshterm import cli
|
||||
|
||||
captured: dict = {}
|
||||
monkeypatch.setattr(cli, "run_tool_command", lambda tool, params: captured.update(params))
|
||||
app = typer.Typer()
|
||||
MapTool().register_cli(app)
|
||||
result = CliRunner().invoke(app, list(args))
|
||||
return result, (captured or None)
|
||||
|
||||
|
||||
def test_map_cli_fraction_defaults_to_the_preference(monkeypatch) -> None:
|
||||
"""No ``--fraction`` means *ask the preferences*, which is a ``None`` to pass on.
|
||||
|
||||
The bounds check used to compare that ``None`` against 0.0, so the plain
|
||||
``meshterm map`` — the one invocation nobody passes a flag to — was the one that
|
||||
raised.
|
||||
"""
|
||||
result, params = _run_map_cli(monkeypatch)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert params is not None and params["fraction"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,ok", [("0", False), ("0.5", True), ("1.0", True)])
|
||||
def test_map_cli_fraction_bounds(monkeypatch, value: str, ok: bool) -> None:
|
||||
"""A fraction of zero frames nothing, so it is refused; anything up to 1.0 stands."""
|
||||
result, params = _run_map_cli(monkeypatch, "--fraction", value)
|
||||
assert (result.exit_code == 0) is ok, result.output
|
||||
if ok:
|
||||
assert params is not None and params["fraction"] == float(value)
|
||||
assert app.registered_commands == []
|
||||
@@ -20,7 +20,15 @@ from prompt_toolkit.output import DummyOutput
|
||||
|
||||
from meshterm.ui.tui.progress import ProgressScreen
|
||||
from meshterm.ui.tui.prompt import ButtonDialog
|
||||
from meshterm.ui.tui.screen import CANCEL, POP_ALL, BusyScreen, PopToMenu, Screen, ScrollScreen
|
||||
from meshterm.ui.tui.screen import (
|
||||
CANCEL,
|
||||
POP_ALL,
|
||||
BusyDialog,
|
||||
BusyScreen,
|
||||
PopToMenu,
|
||||
Screen,
|
||||
ScrollScreen,
|
||||
)
|
||||
from meshterm.ui.tui.select import Choice, SelectScreen
|
||||
from meshterm.ui.tui.session import _CTRL_LETTER_CHORDS, _KEY_ACTIONS, TuiSession
|
||||
|
||||
@@ -181,8 +189,14 @@ async def test_the_trace_target_picker_stays_pushed_under_the_live_screen(monkey
|
||||
monkeypatch.setattr("meshterm.ui.trace_screen.open_trace", fake_open_trace)
|
||||
|
||||
async def main() -> None:
|
||||
# The picker is pushed by `_run_live` itself, so the keystroke cannot be
|
||||
# queued before the call: sending it blind raced the screen that reads it,
|
||||
# and lost about one full-suite run in six. `_screen_at` exists for exactly
|
||||
# this — wait until the picker is on the stack, *then* press Enter on it.
|
||||
running = asyncio.create_task(TraceTool()._run_live(ctx))
|
||||
await _screen_at(session, 1)
|
||||
inp.send_text(ENTER) # Enter on the leading row commits it as the target
|
||||
result = await TraceTool()._run_live(ctx)
|
||||
result = await running
|
||||
assert result.summary == {"sessions": 1, "traces": 2}
|
||||
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
@@ -579,6 +593,7 @@ def test_pop_all_is_refused_over_anything_modal() -> None:
|
||||
ButtonDialog("Delete it?", [("Cancel", False), ("Delete", True)]),
|
||||
ProgressScreen("Working"),
|
||||
BusyScreen("checking…"),
|
||||
BusyDialog("saving Lakeside…"),
|
||||
):
|
||||
session.push(layer)
|
||||
assert session.request_pop_all() is False, f"{type(layer).__name__} must block ^W"
|
||||
@@ -727,6 +742,10 @@ def test_only_navigational_layers_are_non_modal() -> None:
|
||||
assert SelectScreen("list", [Choice("a", 1)]).modal is False
|
||||
assert BusyScreen("checking…").floating is False
|
||||
assert BusyScreen("checking…").modal is True
|
||||
# ...and its twin is the same claim on the keyboard drawn as a box over what it
|
||||
# interrupts, which is the pair's whole point.
|
||||
assert BusyDialog("saving…").floating is True
|
||||
assert BusyDialog("saving…").modal is True
|
||||
assert Screen().modal is False
|
||||
assert CANCEL is not POP_ALL
|
||||
|
||||
@@ -803,3 +822,81 @@ async def test_a_detached_flow_still_reports_a_real_failure() -> None:
|
||||
task = session.run_detached(work())
|
||||
with pytest.raises(RuntimeError):
|
||||
await task
|
||||
|
||||
|
||||
# --- work in flight ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_key_pressed_during_a_slow_action_reaches_the_hub_that_is_still_armed() -> None:
|
||||
"""The defect ``busy_dialog`` exists to close, stated as a test.
|
||||
|
||||
A hub kept up with ``stay`` is armed for the whole visit, so a key pressed while the
|
||||
caller is off doing slow device work still lands on it. Esc resolves the round nobody
|
||||
is waiting for yet, and it is handed back the instant the work finishes — the screen
|
||||
closing on its own a beat after a press that appeared to do nothing.
|
||||
"""
|
||||
with create_pipe_input() as inp:
|
||||
session = _session(inp)
|
||||
screen = SelectScreen("hub", [Choice("alpha", 1)])
|
||||
banked: list = []
|
||||
|
||||
async def main() -> None:
|
||||
async with session.stay(screen) as visit:
|
||||
inp.send_text(ESC) # pressed "while the write is running"
|
||||
await asyncio.sleep(0.05) # let the input loop dispatch it
|
||||
banked.append(await asyncio.wait_for(visit.result(), timeout=1))
|
||||
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
|
||||
assert banked == [CANCEL] # the press was banked and comes straight back
|
||||
|
||||
|
||||
async def test_the_busy_dialog_takes_the_keys_the_hub_would_have_banked() -> None:
|
||||
"""With the card up, the same press dies on it: nothing is banked, nothing resolves.
|
||||
|
||||
This is what makes a slow channel write safe to sit through — the list underneath
|
||||
cannot be filtered down to nothing, cannot be re-fired by an impatient second Enter,
|
||||
and cannot be closed by an Esc that looks ignored until the work lands.
|
||||
"""
|
||||
with create_pipe_input() as inp:
|
||||
session = _session(inp)
|
||||
screen = SelectScreen("hub", [Choice("alpha", 1)])
|
||||
timed_out = []
|
||||
|
||||
async def main() -> None:
|
||||
async with session.stay(screen) as visit:
|
||||
async with session.busy_dialog("saving…", title="Channels"):
|
||||
inp.send_text(ESC + "zzz" + ENTER) # Esc, filter letters, and Enter
|
||||
await asyncio.sleep(0.05)
|
||||
assert session.top.__class__ is BusyDialog # the card owns the keyboard
|
||||
try:
|
||||
await asyncio.wait_for(visit.result(), timeout=0.2)
|
||||
except asyncio.TimeoutError:
|
||||
timed_out.append(True)
|
||||
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
|
||||
assert timed_out == [True] # nothing was banked while the card was up
|
||||
assert screen._filter == "" # and the letters never reached the list's filter
|
||||
|
||||
|
||||
async def test_the_busy_dialog_is_one_card_however_deeply_it_nests() -> None:
|
||||
"""A batch of writes reports as one wait, not a box flashing per write."""
|
||||
with create_pipe_input() as inp:
|
||||
session = _session(inp)
|
||||
depths: list[int] = []
|
||||
|
||||
async def main() -> None:
|
||||
session.push(ScrollScreen("hub", floating=False))
|
||||
async with session.busy_dialog("reordering channels…") as outer:
|
||||
depths.append(len(session._stack))
|
||||
async with session.busy_dialog("moving Lakeside · 1/2") as inner:
|
||||
assert inner is outer # the nested wait rides the card already up
|
||||
depths.append(len(session._stack))
|
||||
outer.message = "moving Dorval · 2/2" # the owner retitles it as it goes
|
||||
depths.append(len(session._stack))
|
||||
depths.append(len(session._stack))
|
||||
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
|
||||
assert depths == [2, 2, 2, 1] # pushed once, popped once, hub still underneath
|
||||
+24
-16
@@ -112,18 +112,19 @@ def test_empty_path_reads_as_the_callers_word() -> None:
|
||||
|
||||
|
||||
def test_chips_are_joined_by_one_interlocked_chevron() -> None:
|
||||
"""A seam is one cell, not two.
|
||||
"""A seam is one cell, not two — and the path's own end is not a seam at all.
|
||||
|
||||
The previous chip's point is laid *on* the next chip's fill, so the route reads as
|
||||
a ribbon whose segments meet on a chevron. The closing edge keeps its lone taper —
|
||||
nothing follows it to lay the point on.
|
||||
a ribbon whose segments meet on a chevron. The point is spoken for: it is what says
|
||||
the route continues, so a finished path never wears one. It ends square on the last
|
||||
chip's own pad, and the only chevrons in the line are the joins.
|
||||
"""
|
||||
alice_fill = node_style("aa").split()[-1]
|
||||
line = PathLine([PathHop("Alice", key="aa"), PathHop("you", you=True)], mode="powerline")
|
||||
text = line.text()
|
||||
assert text.plain == f" Alice {POWERLINE_SEP} you {POWERLINE_SEP}"
|
||||
assert text.plain == f" Alice {POWERLINE_SEP} you "
|
||||
seam_styles = [str(s.style) for s in text.spans if text.plain[s.start : s.end] == POWERLINE_SEP]
|
||||
assert seam_styles == [f"{alice_fill} on {_YOU_BG}", _YOU_BG]
|
||||
assert seam_styles == [f"{alice_fill} on {_YOU_BG}"]
|
||||
|
||||
|
||||
def test_chips_keep_the_same_words_and_honour_style_overrides() -> None:
|
||||
@@ -136,9 +137,9 @@ def test_chips_keep_the_same_words_and_honour_style_overrides() -> None:
|
||||
chips = PathLine(hops, mode="powerline").text().plain
|
||||
assert plain.replace(" → ", " ") == chips.replace(POWERLINE_SEP, "").replace(" ", " ").strip()
|
||||
themed = PathLine([PathHop("X", style="brand")], mode="powerline").text()
|
||||
assert str(themed.spans[-1].style) == "#5eead4" # the closing edge, brand-filled
|
||||
assert pathline._fills(themed)[-1] == "#5eead4" # the chip runs to the very last cell
|
||||
hexed = PathLine([PathHop("X", style="bold #123456")], mode="powerline").text()
|
||||
assert str(hexed.spans[-1].style) == "#123456"
|
||||
assert pathline._fills(hexed)[-1] == "#123456"
|
||||
|
||||
|
||||
def test_auto_mode_follows_the_terminal_verdict(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -161,7 +162,7 @@ def test_the_cursor_is_a_hop_of_its_own_in_either_mode() -> None:
|
||||
assert _styles(plain)[CURSOR_GLYPH] == "selected" # the reverse-video block
|
||||
|
||||
chips = PathLine(hops, mode="powerline").text()
|
||||
assert chips.plain == f" a {POWERLINE_SEP} {CURSOR_GLYPH} {POWERLINE_SEP} b {POWERLINE_SEP}"
|
||||
assert chips.plain == f" a {POWERLINE_SEP} {CURSOR_GLYPH} {POWERLINE_SEP} b "
|
||||
slot = next(s for s in chips.spans if chips.plain[s.start : s.end] == CURSOR_GLYPH)
|
||||
assert str(slot.style).endswith(f"on {_style_hex('cursor')}") # the cursor-white fill
|
||||
|
||||
@@ -500,10 +501,11 @@ def test_wrapped_carries_the_cursor_like_any_other_hop() -> None:
|
||||
def test_wrapped_chip_lines_open_on_the_break_they_continue() -> None:
|
||||
"""Chip wrapping never splits a chip; each line opens on the break it continues.
|
||||
|
||||
Every line ends on the pointed edge, and every line *but the first* opens on the
|
||||
break's other half — the point notched out of its own fill in reverse video, so the
|
||||
page shows through and not a trace of the line above bleeds down. The point always
|
||||
faces the way the path flows.
|
||||
Every line the path *outruns* ends on the pointed edge — the last one ends square,
|
||||
the route having finished — and every line *but the first* opens on the break's
|
||||
other half: the point notched out of its own fill in reverse video, so the page
|
||||
shows through and not a trace of the line above bleeds down. The point always faces
|
||||
the way the path flows.
|
||||
"""
|
||||
hops = [
|
||||
PathHop(label, key=key)
|
||||
@@ -518,7 +520,8 @@ def test_wrapped_chip_lines_open_on_the_break_they_continue() -> None:
|
||||
# (it marks the run as a path line for the cursor fold and draws nothing).
|
||||
carried = next(s for s in lines[1].spans if s.start == step and str(s.style) != PATH_INK)
|
||||
assert str(carried.style) == f"{_style_hex(node_style('3d'))} reverse"
|
||||
assert all(line.plain.endswith(POWERLINE_SEP) for line in lines)
|
||||
assert lines[0].plain.endswith(POWERLINE_SEP) # …the path runs on past this line
|
||||
assert lines[-1].plain.endswith(" ") # …and stops square on the last chip's pad
|
||||
assert all(line.cell_len <= 20 for line in lines)
|
||||
|
||||
|
||||
@@ -526,8 +529,10 @@ def test_rounded_caps_finish_a_path_only_where_the_font_has_them(monkeypatch) ->
|
||||
"""Rounded caps finish a path only where the font actually has them.
|
||||
|
||||
A full Nerd Font rounds the path's two *outer* ends into a lozenge; a core-only
|
||||
terminal squares the opening and points the close, never drawing tofu. Interior
|
||||
breaks stay angled either way — a rounded end would read as the path stopping.
|
||||
terminal squares *both* of them off, appending nothing and letting the outer chips'
|
||||
own pads be the edges, never drawing tofu. Interior breaks stay angled either way —
|
||||
a rounded end would read as the path stopping, and a pointed one at the finish would
|
||||
read as the opposite, a route cut off mid-walk.
|
||||
"""
|
||||
hops = [
|
||||
PathHop(label, key=key)
|
||||
@@ -537,7 +542,10 @@ def test_rounded_caps_finish_a_path_only_where_the_font_has_them(monkeypatch) ->
|
||||
|
||||
monkeypatch.setattr(pathline, "powerline_full", lambda: False)
|
||||
assert line.text().plain.startswith(" AAAA")
|
||||
assert line.text().plain.endswith(POWERLINE_SEP)
|
||||
assert line.text().plain.endswith("DDDD ")
|
||||
# The point is still what a *wrapped* line ends on: there the route really does go on.
|
||||
squared = line.wrapped(20, indent=2)
|
||||
assert squared[0].plain.endswith(POWERLINE_SEP) and squared[-1].plain.endswith("DDDD ")
|
||||
|
||||
monkeypatch.setattr(pathline, "powerline_full", lambda: True)
|
||||
assert line.text().plain.startswith(POWERLINE_ROUND_OPEN + " AAAA")
|
||||
|
||||
@@ -414,6 +414,9 @@ class _ScriptedUi:
|
||||
def note(self, markup: str) -> None:
|
||||
pass
|
||||
|
||||
def ack(self, markup: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ctx(tmp_path: Path) -> AppContext:
|
||||
|
||||
@@ -14,6 +14,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Console
|
||||
|
||||
from meshterm.context import AppContext
|
||||
@@ -22,7 +23,13 @@ from meshterm.core.config import Settings
|
||||
from meshterm.core.device_store import DeviceStore
|
||||
from meshterm.persistence.repository import DiscoveredPath, Repository
|
||||
from meshterm.services.records import CATEGORIES, CATEGORY_BY_ID
|
||||
from meshterm.ui.records_screen import RecordDialog, WalkVertex, open_records
|
||||
from meshterm.ui.records_screen import (
|
||||
RecordDialog,
|
||||
WalkVertex,
|
||||
discipline_label,
|
||||
discipline_lane,
|
||||
open_records,
|
||||
)
|
||||
from meshterm.ui.surface import TuiUi
|
||||
from meshterm.ui.tui import frame
|
||||
from meshterm.ui.tui.prompt import TypedConfirmDialog
|
||||
@@ -513,7 +520,14 @@ async def test_the_board_pins_its_discipline_heading_and_description(tui_ctx) ->
|
||||
blocks = [
|
||||
[_plain([line]).strip() for line in rows] for _idx, rows in browser._sticky_headers
|
||||
]
|
||||
assert [rows[0] for rows in blocks] == [f"── {c.icon} {c.title} ──" for c in CATEGORIES]
|
||||
lane = discipline_lane()
|
||||
assert [rows[0] for rows in blocks] == [
|
||||
f"── {discipline_label(c, lane)} ──" for c in CATEGORIES
|
||||
]
|
||||
# And every one of them starts its title in the same column: the marks are not all
|
||||
# the same width (🛣 and 🕸 are one cell where the rest are two), so the lane pads.
|
||||
starts = {cell_len(discipline_label(c, lane)) - cell_len(c.title) for c in CATEGORIES}
|
||||
assert len(starts) == 1
|
||||
assert all(len(rows) >= 2 for rows in blocks) # each carries its description too
|
||||
# Highlighting deep in the one populated board scrolls its heading off the top of a
|
||||
# short viewport; the heading leads what pins, its description under it.
|
||||
@@ -521,7 +535,7 @@ async def test_the_board_pins_its_discipline_heading_and_description(tui_ctx) ->
|
||||
browser.handle("down")
|
||||
visible, above, _below = frame._visible_slice(browser, browser.render_body(72), 10)
|
||||
board = CATEGORY_BY_ID["grand_tour"]
|
||||
assert _plain([visible[0]]).strip() == f"── {board.icon} {board.title} ──"
|
||||
assert _plain([visible[0]]).strip() == f"── {discipline_label(board, lane)} ──"
|
||||
assert _plain([visible[1]]).strip().startswith(board.description[:20])
|
||||
assert above is True
|
||||
browser.resolve(None) # Esc
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
"""Tests for the seam between a tool's answer and its rendering.
|
||||
|
||||
:mod:`meshterm.ui.report` is where a tool states what it found, :mod:`meshterm.ui.fields`
|
||||
is where the shapes it states it in are defined once, and :mod:`meshterm.ui.renderers` is
|
||||
where that becomes bytes. The claim the whole design rests on is that **one typed row
|
||||
projects two ways** — so almost every test here builds one report and asserts both faces
|
||||
of it, because a rule that holds in only one of them is exactly the drift this replaced.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from meshterm.ui import fields, script
|
||||
from meshterm.ui.renderers import JsonRenderer, OutputFormat, PlainRenderer, for_format
|
||||
from meshterm.ui.report import BARE, SILENT, Column, Facts, Lane, Listing
|
||||
|
||||
_WHEN = datetime(2026, 9, 7, 22, 22, 41, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _plain(*blocks) -> str: # noqa: ANN002 - blocks vary per test
|
||||
"""Render a report through the plain face and return what came out."""
|
||||
buffer = io.StringIO()
|
||||
PlainRenderer(script.console(buffer)).render(blocks)
|
||||
buffer.flush()
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _json(*blocks): # noqa: ANN002, ANN202 - blocks vary per test
|
||||
"""Render a report through the machine face and return the parsed document."""
|
||||
buffer = io.StringIO()
|
||||
JsonRenderer(script.console(buffer)).render(blocks)
|
||||
lines = [line for line in buffer.getvalue().splitlines() if line]
|
||||
assert len(lines) == 1, lines
|
||||
return json.loads(lines[0])
|
||||
|
||||
|
||||
def _contacts(rows) -> Listing: # noqa: ANN001 - rows vary per test
|
||||
"""A listing in the shape ``contacts`` uses: a node, an age and a count."""
|
||||
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"),
|
||||
),
|
||||
rows=rows,
|
||||
order=("NAME", "TYPE", "HEARD", "PKTS", "HASH", "KEY"),
|
||||
)
|
||||
|
||||
|
||||
_ALICE = {
|
||||
"node": fields.NodeRef(name="Alice", key="d4" * 32, hash="d4", type="node"),
|
||||
"heard_at": _WHEN,
|
||||
"packets": 12,
|
||||
}
|
||||
|
||||
|
||||
# -- one row, two faces ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_one_typed_row_reaches_both_faces_without_either_parsing_the_other() -> None:
|
||||
"""The claim the seam exists for: a row holds the value, not somebody's rendering.
|
||||
|
||||
The plain face writes ``+5.1`` because a column of readings only compares by eye once
|
||||
every one declares its sign; the document writes ``5.1`` because a number carries its
|
||||
own. Neither could be derived from the other without a parser, which is what the two
|
||||
hand-written ``if ctx.json_output:`` branches were.
|
||||
"""
|
||||
block = Listing(key="hops", columns=(fields.snr("snr_db", "SNR_DB"),), rows=[{"snr_db": 5.14}])
|
||||
assert _plain(block).splitlines()[1].strip() == "+5.1"
|
||||
assert _json(block) == [{"snr_db": 5.1}]
|
||||
|
||||
|
||||
def test_a_node_is_one_object_however_many_columns_it_is_worth() -> None:
|
||||
"""One machine key, four plain lanes — the thing :func:`fields.node` earns.
|
||||
|
||||
Every listing that mentions a node asks for the same column, so ``jq '.[].node.key'``
|
||||
reads the same on contacts, courier and monitor and a consumer's node handler is
|
||||
written once. What differs is only how much room the surface has for it.
|
||||
"""
|
||||
header, record = _plain(_contacts([_ALICE])).splitlines()
|
||||
assert header.split() == ["NAME", "TYPE", "HEARD", "PKTS", "HASH", "KEY"]
|
||||
assert len(record.split()) == len(header.split())
|
||||
assert "Alice" in record and "d4" * 32 in record
|
||||
assert _json(_contacts([_ALICE]))[0]["node"] == {
|
||||
"name": "Alice",
|
||||
"key": "d4" * 32,
|
||||
"hash": "d4",
|
||||
"type": "node",
|
||||
"self": False,
|
||||
}
|
||||
|
||||
|
||||
def test_a_listing_may_draw_its_lanes_in_an_order_the_document_does_not_use() -> None:
|
||||
"""``contacts`` puts HEARD and PKTS between a node's TYPE and its HASH.
|
||||
|
||||
The lane order is a reading decision — the fields a person scans first go left — and
|
||||
the key order is the document's. Tying them together would have meant choosing which
|
||||
face to make worse.
|
||||
"""
|
||||
header = _plain(_contacts([_ALICE])).splitlines()[0].split()
|
||||
assert header.index("HEARD") < header.index("HASH")
|
||||
assert list(_json(_contacts([_ALICE]))[0]) == ["node", "heard_at", "packets"]
|
||||
|
||||
|
||||
def test_a_listing_refuses_an_order_that_does_not_name_its_own_lanes() -> None:
|
||||
"""A typo here draws a listing with a column missing and no complaint.
|
||||
|
||||
That is the one failure a header line cannot show — the records still line up, under
|
||||
the wrong headings — so it is caught where it is written instead.
|
||||
"""
|
||||
with pytest.raises(ValueError, match="every lane"):
|
||||
Listing(
|
||||
key="x",
|
||||
columns=(fields.integer("a", "A"), fields.integer("b", "B")),
|
||||
order=("A", "NOPE"),
|
||||
)
|
||||
|
||||
|
||||
# -- absences --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_two_faces_spell_absence_in_their_own_alphabets() -> None:
|
||||
"""``-`` is learned once by a reader; ``null`` is what a parser already has.
|
||||
|
||||
Same fact, and neither face has to know the other's token — which is what stopped the
|
||||
plain ``-`` from ever reaching a document as a string.
|
||||
"""
|
||||
empty = {"node": None, "heard_at": None, "packets": None}
|
||||
record = _plain(_contacts([empty])).splitlines()[1]
|
||||
assert record.split() == ["-", "unknown", "never", "-", "-", "-"]
|
||||
assert _json(_contacts([empty]))[0] == {"node": None, "heard_at": None, "packets": None}
|
||||
|
||||
|
||||
def test_a_word_standing_in_for_an_absence_stays_a_word_only_on_the_plain_face() -> None:
|
||||
"""``unknown`` is a word a reader acts on; a consumer already has ``null`` for it.
|
||||
|
||||
Carrying both would give a program two spellings for one fact and a special case to
|
||||
remember on every field.
|
||||
"""
|
||||
row = {"node": fields.NodeRef(hash="a1")}
|
||||
block = Listing(key="n", columns=(fields.node("node", lanes=(("type", "TYPE"),)),), rows=[row])
|
||||
assert _plain(block).splitlines()[1].strip() == "unknown"
|
||||
assert _json(block)[0]["node"]["type"] is None
|
||||
|
||||
|
||||
def test_a_never_heard_node_is_a_fact_and_an_undated_row_is_an_absence() -> None:
|
||||
"""Two different things, and the age lane says which without a second column.
|
||||
|
||||
A node that has never been heard *has* a heard-age; a row with no such time at all
|
||||
does not. Flattening both onto ``-`` would have made "we have not heard from it" and
|
||||
"this kind of row has no heard time" the same reading.
|
||||
"""
|
||||
assert script.age(None, absent="never") == "never"
|
||||
assert script.age(None) == script.NONE
|
||||
|
||||
|
||||
def test_info_leaves_out_a_reading_the_radio_does_not_answer_for_and_json_keeps_the_key() -> None:
|
||||
"""The one deliberate disagreement between the faces, and why it is deliberate.
|
||||
|
||||
A missing plain key says "this radio does not report it", where a dash would claim it
|
||||
reported nothing — a distinction a person can act on. A consumer cannot, and a
|
||||
variable key set costs it a membership test on every field, so the document keeps the
|
||||
key and writes ``null``.
|
||||
"""
|
||||
block = Facts(
|
||||
key="info",
|
||||
fields=(fields.word("model", "model"), fields.integer("uptime_s", "uptime_s")),
|
||||
values={"model": None, "uptime_s": 42},
|
||||
omit_absent=True,
|
||||
)
|
||||
assert _plain(block).split() == ["uptime_s", "42"]
|
||||
assert _json(block) == {"model": None, "uptime_s": 42}
|
||||
|
||||
|
||||
# -- facts, bare and silent ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_get_prints_its_value_alone_and_still_documents_the_key() -> None:
|
||||
"""The caller named the key, so repeating it back is one more thing to strip off.
|
||||
|
||||
``$(meshterm config get name)`` needs no stripping — an argument entirely about shell
|
||||
substitution, which a JSON consumer is not doing, so the document keeps the key, the
|
||||
type and the label the bare form threw away.
|
||||
"""
|
||||
block = Facts(
|
||||
key="setting",
|
||||
fields=(
|
||||
fields.word("key", "key"),
|
||||
fields.word("value", "value"),
|
||||
fields.hidden("type"),
|
||||
),
|
||||
values={"key": "tx_power", "value": "20", "type": "int"},
|
||||
shape=BARE,
|
||||
bare="value",
|
||||
)
|
||||
assert _plain(block).strip() == "20"
|
||||
assert _json(block) == {"key": "tx_power", "value": "20", "type": "int"}
|
||||
|
||||
|
||||
def test_a_bare_answer_with_nothing_in_it_prints_nothing_at_all() -> None:
|
||||
"""A node that never answered, a slot with nothing in it — and no lone dash.
|
||||
|
||||
The bare form prints *the value*, so where there is none the exit status is the whole
|
||||
report and stdout stays empty. The document still carries the shape, with ``null``
|
||||
where the answer would have been, because a consumer branching on it needs the key.
|
||||
"""
|
||||
block = Facts(
|
||||
key="remote",
|
||||
fields=(fields.word("command", "command"), fields.word("reply", "reply")),
|
||||
values={"command": "get name", "reply": None},
|
||||
shape=BARE,
|
||||
bare="reply",
|
||||
)
|
||||
assert _plain(block) == ""
|
||||
assert _json(block) == {"command": "get name", "reply": None}
|
||||
|
||||
|
||||
def test_an_acknowledgement_prints_nothing_and_is_still_a_document() -> None:
|
||||
"""Printing nothing is an answer a person can act on and a program cannot.
|
||||
|
||||
``config advert`` says everything it has to say in its exit status, and that is the
|
||||
right plain answer. A caller that needs to log *what was sent* has nowhere to read it,
|
||||
which is what the document is for.
|
||||
"""
|
||||
block = Facts(
|
||||
key="advert",
|
||||
fields=(fields.flag("sent", "sent"), fields.flag("flood", "flood")),
|
||||
values={"sent": True, "flood": False},
|
||||
shape=SILENT,
|
||||
)
|
||||
assert _plain(block) == ""
|
||||
assert _json(block) == {"sent": True, "flood": False}
|
||||
|
||||
|
||||
# -- several blocks --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_single_block_report_is_its_block_and_nothing_around_it() -> None:
|
||||
"""``meshterm contacts --json | jq '.[].node.name'`` reads what it looks like.
|
||||
|
||||
There is no envelope: the report a caller needs is ``$?``, exactly as it is without
|
||||
the flag, so nothing has to be reached through to get at the answer.
|
||||
"""
|
||||
assert isinstance(_json(_contacts([_ALICE])), list)
|
||||
|
||||
|
||||
def test_several_blocks_make_one_object_with_the_facts_at_its_top_level() -> None:
|
||||
"""A trace is a set of facts *and* a per-hop table, and the document says so.
|
||||
|
||||
The facts block is the set of facts about the one thing the command answered, so it is
|
||||
the document rather than a compartment inside it; a listing needs a name and takes its
|
||||
own key.
|
||||
"""
|
||||
facts = Facts(
|
||||
key="trace", fields=(fields.flag("success", "success"),), values={"success": True}
|
||||
)
|
||||
edges = Listing(key="edges", columns=(fields.integer("index", "HOP"),), rows=[{"index": 0}])
|
||||
assert _json(facts, edges) == {"success": True, "edges": [{"index": 0}]}
|
||||
assert _plain(facts, edges).splitlines() == ["success yes", "", "HOP", " 0"]
|
||||
|
||||
|
||||
def test_a_block_drawn_only_for_a_person_never_reaches_the_document() -> None:
|
||||
"""``monitor``'s closing per-node summary is derivable from the records above it.
|
||||
|
||||
Emitting it as a second *shape* of line mid-stream would cost every reader a
|
||||
discriminator it otherwise never needs, so the plain face keeps the aggregate and the
|
||||
stream stays homogeneous.
|
||||
"""
|
||||
facts = Facts(
|
||||
key="window", fields=(fields.integer("packets", "packets"),), values={"packets": 2}
|
||||
)
|
||||
summary = Listing(
|
||||
key="heard",
|
||||
columns=(fields.integer("packets", "PKTS"),),
|
||||
rows=[{"packets": 2}],
|
||||
plain_only=True,
|
||||
)
|
||||
assert "PKTS" in _plain(facts, summary)
|
||||
assert _json(facts, summary) == {"packets": 2}
|
||||
|
||||
|
||||
def test_a_block_that_prints_nothing_leaves_no_blank_line_where_it_stood() -> None:
|
||||
"""The separator is counted off what was drawn, not off the block index.
|
||||
|
||||
``config restore --dry-run`` states its outcome silently and its plan as a listing, and
|
||||
counting positions instead opened the command with an empty line — output it did not
|
||||
have, in whatever was reading it.
|
||||
"""
|
||||
silent = Facts(
|
||||
key="restore", fields=(fields.flag("dry_run", "dry_run"),), values={}, shape=SILENT
|
||||
)
|
||||
plan = Listing(
|
||||
key="operations",
|
||||
columns=(fields.word("operation", "OPERATION"),),
|
||||
rows=[{"operation": "set"}],
|
||||
)
|
||||
assert _plain(silent, plan).splitlines()[0] == "OPERATION"
|
||||
|
||||
|
||||
# -- the document's own rules ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_document_is_one_compact_line_that_never_escapes_a_name() -> None:
|
||||
r"""A node name in Cyrillic is a node name, not a run of ``\\uXXXX``.
|
||||
|
||||
One line, so a stream and a one-shot answer are read the same way and ``jq .`` can
|
||||
re-indent for a person at no cost. Nothing can un-stream a document that was
|
||||
pretty-printed across four hundred lines.
|
||||
"""
|
||||
buffer = io.StringIO()
|
||||
row = {"node": fields.NodeRef(name="Наблюдатель", hash="c3")}
|
||||
block = Listing(key="n", columns=(fields.node("node"),), rows=[row])
|
||||
JsonRenderer(script.console(buffer)).render((block,))
|
||||
out = buffer.getvalue()
|
||||
assert out.endswith("\n") and out.count("\n") == 1
|
||||
assert "Наблюдатель" in out
|
||||
assert ", " not in out and ": " not in out
|
||||
|
||||
|
||||
def test_every_time_in_a_document_is_utc_to_the_second() -> None:
|
||||
"""Twenty characters, always, so string comparison is time comparison.
|
||||
|
||||
A local offset is a fact about the machine that ran the command rather than about the
|
||||
event, and two hosts asking the same radio the same question must produce the same
|
||||
document.
|
||||
"""
|
||||
stamped = _json(_contacts([_ALICE]))[0]["heard_at"]
|
||||
assert stamped == "2026-09-07T22:22:41Z"
|
||||
assert len(stamped) == 20
|
||||
|
||||
|
||||
def test_a_time_whose_offset_we_do_not_know_is_absent_on_both_faces() -> None:
|
||||
"""A naive datetime is not a fact, and neither face may invent an offset for it."""
|
||||
naive = {"node": None, "heard_at": datetime(2026, 9, 7, 22, 22, 41), "packets": None}
|
||||
assert "2026" not in _plain(_contacts([naive]))
|
||||
assert _json(_contacts([naive]))[0]["heard_at"] is None
|
||||
|
||||
|
||||
def test_a_concept_may_have_a_face_on_only_one_side() -> None:
|
||||
"""Both directions, and each has a real call site.
|
||||
|
||||
A preference's DESCRIPTION is prose for whoever is choosing a value and carries
|
||||
nothing a program would branch on; a setting's declared type is the reverse — a fact
|
||||
the document wants and a reader dumping ``config show`` does not.
|
||||
"""
|
||||
block = Listing(
|
||||
key="prefs",
|
||||
columns=(
|
||||
fields.word("key", "PREFERENCE"),
|
||||
fields.note("help", "DESCRIPTION"),
|
||||
fields.hidden("type"),
|
||||
),
|
||||
rows=[{"key": "trace_cooldown_s", "help": "Wait this long", "type": "int"}],
|
||||
)
|
||||
assert "Wait this long" in _plain(block)
|
||||
assert _json(block) == [{"key": "trace_cooldown_s", "type": "int"}]
|
||||
|
||||
|
||||
# -- streams ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stream_listing() -> Listing:
|
||||
"""A live listing in the shape ``monitor`` uses: pinned lanes, the name last."""
|
||||
return Listing(
|
||||
key="observations",
|
||||
columns=(
|
||||
fields.instant("observed_at", "TIME"),
|
||||
fields.node("node", lanes=(("hash", "NODE"), ("name", "NAME"))),
|
||||
),
|
||||
order=("TIME", "NODE", "NAME"),
|
||||
)
|
||||
|
||||
|
||||
def test_a_stream_pins_its_lanes_because_the_widths_arrive_with_the_data() -> None:
|
||||
"""A live capture has no "once every record is in", which is when columns are sized.
|
||||
|
||||
Both streams used to be gutter-joined with no alignment at all, which is why a name in
|
||||
one still carried its quotes — nothing else said where the field ended.
|
||||
"""
|
||||
buffer = io.StringIO()
|
||||
listing = _stream_listing()
|
||||
with PlainRenderer(script.console(buffer)).stream(listing) as row:
|
||||
row({"observed_at": _WHEN, "node": fields.NodeRef(name="Alice", hash="d4e5f6a7")})
|
||||
row({"observed_at": _WHEN, "node": fields.NodeRef(name="A Very Long Node Name", hash="c3")})
|
||||
header, first, second = buffer.getvalue().splitlines()
|
||||
assert header.split() == ["TIME", "NODE", "NAME"]
|
||||
assert first.index("d4e5f6a7") == second.index("c3")
|
||||
|
||||
|
||||
def test_a_streamed_document_is_one_record_per_line_all_the_same_shape() -> None:
|
||||
"""A consumer reads line by line and never has to ask which kind of record it holds."""
|
||||
buffer = io.StringIO()
|
||||
listing = _stream_listing()
|
||||
with JsonRenderer(script.console(buffer)).stream(listing) as row:
|
||||
row({"observed_at": _WHEN, "node": fields.NodeRef(hash="d4")})
|
||||
row({"observed_at": _WHEN, "node": fields.NodeRef(hash="a1")})
|
||||
documents = [json.loads(line) for line in buffer.getvalue().splitlines()]
|
||||
assert [set(d) for d in documents] == [{"observed_at", "node"}] * 2
|
||||
assert [d["node"]["hash"] for d in documents] == ["d4", "a1"]
|
||||
|
||||
|
||||
# -- the boundary ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_format_picks_the_renderer_and_nothing_else_does() -> None:
|
||||
"""One choice, made once, at the one place that knows what the caller asked for."""
|
||||
console = script.console(io.StringIO())
|
||||
assert isinstance(for_format(OutputFormat.PLAIN, console), PlainRenderer)
|
||||
assert isinstance(for_format(OutputFormat.JSON, console), JsonRenderer)
|
||||
|
||||
|
||||
def test_a_tool_with_no_scripted_face_renders_nothing_at_all() -> None:
|
||||
"""The map and the dashboard state no report, and both renderers must be fine with it."""
|
||||
assert _plain() == ""
|
||||
buffer = io.StringIO()
|
||||
JsonRenderer(script.console(buffer)).render(None)
|
||||
assert buffer.getvalue() == ""
|
||||
|
||||
|
||||
def test_a_lane_and_a_column_are_separable_so_a_third_format_needs_neither() -> None:
|
||||
"""The point of the split, stated as a test: a format is a function of the typed row.
|
||||
|
||||
A renderer that wanted CSV would read the same columns and the same rows and write its
|
||||
own cells. Nothing about the report knows a gutter, a comma or a brace.
|
||||
"""
|
||||
column = Column(key="n", lanes=(Lane(header="N", render=lambda v: f"<{v}>"),))
|
||||
assert column.lanes[0].render(3) == "<3>"
|
||||
assert column.json(3) == 3
|
||||
@@ -0,0 +1,568 @@
|
||||
"""Tests for the plain CLI's output language (:mod:`meshterm.ui.script`).
|
||||
|
||||
The plain face is read by a person at a prompt — ``--json`` carries the machine now — and
|
||||
every rule that makes it legible is testable: no colour, no wrapping, no framing, bare
|
||||
names that still cannot break a record, relative ages, arrows for a route and commas for a
|
||||
path, one token for absent, one documented exit status per outcome. These are the
|
||||
enforcement point for :mod:`meshterm.ui.script` the way ``test_gallery`` is for the
|
||||
screens, and :mod:`tests.test_report` is for the seam above them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from rich.console import Group
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from meshterm.core import exitcodes
|
||||
from meshterm.ui import script
|
||||
|
||||
#: Any ANSI escape sequence. Nothing the scripted console emits may match this.
|
||||
_ANSI = re.compile(r"\x1b\[")
|
||||
|
||||
|
||||
def _rendered(*renderables: object) -> str:
|
||||
"""Print ``renderables`` through the scripted console and return what came out."""
|
||||
buffer = io.StringIO()
|
||||
console = script.console(buffer)
|
||||
for renderable in renderables:
|
||||
console.print(renderable)
|
||||
console.file.flush()
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
# -- names ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
("Yagi-Repeater", "Yagi-Repeater"),
|
||||
("YUL Cartierville", "YUL Cartierville"),
|
||||
("Node, Inc", "Node, Inc"),
|
||||
('He said "hi"', 'He said "hi"'),
|
||||
("back\\slash", "back\\\\slash"),
|
||||
],
|
||||
)
|
||||
def test_a_name_is_bare_because_alignment_is_the_delimiter(value: str, expected: str) -> None:
|
||||
"""The quotes are retired; a column that lines up is already one field to the eye.
|
||||
|
||||
They existed so a name holding a space or a comma stayed one field for whatever was
|
||||
splitting the line. The splitter reads JSON now, and in plain text the padding says
|
||||
where a field ends — so a reader gets back the six cells the quotes were spending on
|
||||
every row, and a name reads as the name the node broadcast.
|
||||
"""
|
||||
assert script.name(value) == expected
|
||||
|
||||
|
||||
def test_a_name_still_cannot_end_the_record_it_sits_in() -> None:
|
||||
"""The escaping under the quoting stays, and it is the half that was load-bearing.
|
||||
|
||||
A node broadcasts its own name, so every name here is remote data. Nothing about
|
||||
alignment stops a newline, a tab or an ESC in one from ending the record early — and
|
||||
an ESC inside a name is a live colour run written into the caller's file, which is the
|
||||
one thing "not a single escape sequence" exists to prevent.
|
||||
"""
|
||||
assert script.name("two\nlines") == "two\\nlines"
|
||||
assert script.name("tabbed\there") == "tabbed\\there"
|
||||
assert script.name("bell\a") == "bell\\x07"
|
||||
assert "\x1b" not in script.name("esc\x1b[31m")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body,expected",
|
||||
[
|
||||
("line one\nline two", "line one\\nline two"),
|
||||
("tabbed\there", "tabbed\\there"),
|
||||
("carriage\rreturn", "carriage\\rreturn"),
|
||||
("back\\slash", "back\\\\slash"),
|
||||
('a "quoted" body', 'a "quoted" body'),
|
||||
("plain", "plain"),
|
||||
],
|
||||
)
|
||||
def test_a_message_body_can_never_end_its_own_record(body: str, expected: str) -> None:
|
||||
"""``TEXT`` is the rest of the line, and "the rest of the line" has to stay one line.
|
||||
|
||||
A body holding a newline used to end its record early and leave the remainder indented
|
||||
under the other columns, reading as a second record with an empty ``TIME``. The body is
|
||||
also the one field a stranger fills in, so this is not a hypothetical message. Quotes
|
||||
inside it are left alone: nothing wraps it, so nothing needs escaping from.
|
||||
"""
|
||||
assert script.text(body) == expected
|
||||
assert "\n" not in script.text(body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("absent", [None, ""])
|
||||
def test_a_node_that_never_gave_a_name_reads_as_absent_not_as_empty(absent: str | None) -> None:
|
||||
"""``""`` says "called nothing"; ``-`` says "never said". They are different facts.
|
||||
|
||||
Seven of the ten places that printed a name made the first claim by accident, so the
|
||||
same unnamed node read as ``""`` in one listing and ``-`` in the next. One helper now
|
||||
answers it, and the distinction is the one the machine face needs too — an absent name
|
||||
is ``null``, an empty one is ``""``, and a field that flattened both cannot say which
|
||||
it meant.
|
||||
"""
|
||||
assert script.name(absent) == script.NONE
|
||||
assert script.name("Alice") == "Alice"
|
||||
|
||||
|
||||
# -- timestamps ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stamp_is_absolute_local_iso_to_the_second() -> None:
|
||||
"""A time is printed as something a script can sort and subtract."""
|
||||
when = datetime(2026, 9, 7, 22, 22, 41, tzinfo=timezone.utc)
|
||||
text = script.stamp(when)
|
||||
assert datetime.fromisoformat(text) == when
|
||||
assert text.endswith(when.astimezone().strftime("%z")[:3] + ":00")
|
||||
|
||||
|
||||
def test_stamp_reads_an_unknown_offset_as_absent() -> None:
|
||||
"""A naive datetime is a time whose offset we don't know, which is not a fact.
|
||||
|
||||
The same rule :func:`~meshterm.ui.widgets._age_seconds` applies to relative ages.
|
||||
"""
|
||||
assert script.stamp(None) == script.NONE
|
||||
assert script.stamp(datetime(2026, 9, 7, 22, 22, 41)) == script.NONE
|
||||
|
||||
|
||||
def test_a_time_is_an_age_unless_the_instant_is_itself_the_fact() -> None:
|
||||
"""The rule that reversed, and the reason it reversed.
|
||||
|
||||
A person at a prompt reading ``2026-09-07T19:58:53-04:00`` is doing arithmetic to
|
||||
answer "recently?", which is the question they typed the command to ask. So an age is
|
||||
the default now. :func:`~meshterm.ui.script.stamp` survives for the three places the
|
||||
instant *is* the answer — the device clock, an appointment set with ``--at``, and a
|
||||
live capture's own ``TIME`` column, every row of which would otherwise read ``now`` —
|
||||
and for every column under ``--absolute``.
|
||||
"""
|
||||
fresh = datetime.now(timezone.utc) - timedelta(seconds=5)
|
||||
assert script.age(fresh) == "now"
|
||||
assert script.stamp(fresh)[:4].isdigit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"seconds,expected",
|
||||
[
|
||||
(0, "now"),
|
||||
(59, "now"),
|
||||
(60, "1m"),
|
||||
(3599, "59m"),
|
||||
(3600, "1h"),
|
||||
(86400, "1d"),
|
||||
(604800, "1w"),
|
||||
],
|
||||
)
|
||||
def test_the_age_ladder_steps_where_a_person_would_step(seconds: int, expected: str) -> None:
|
||||
"""``now``, ``5m``, ``3h``, ``2d``, ``4w`` — the menu's own column ladder.
|
||||
|
||||
Delegated to :func:`~meshterm.ui.widgets._format_age` rather than re-derived, so the
|
||||
two faces of the same age cannot drift apart.
|
||||
"""
|
||||
assert script.age(datetime.now(timezone.utc) - timedelta(seconds=seconds)) == expected
|
||||
|
||||
|
||||
def test_an_age_reads_an_unknown_offset_as_absent_exactly_as_a_stamp_does() -> None:
|
||||
"""A naive datetime is a time whose offset we don't know, which is not a fact."""
|
||||
assert script.age(None) == script.NONE
|
||||
assert script.age(datetime(2026, 9, 7, 22, 22, 41)) == script.NONE
|
||||
|
||||
|
||||
def test_never_is_a_value_and_the_absent_token_is_not() -> None:
|
||||
"""A node that has never been heard is a *fact*; a row with no such time is not.
|
||||
|
||||
Flattening both onto ``-`` made "we have not heard from it" and "this kind of row has
|
||||
no heard time" read the same, which is the distinction the column exists to draw.
|
||||
"""
|
||||
assert script.age(None, absent="never") == "never"
|
||||
assert script.age(None) == script.NONE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"seconds,expected",
|
||||
[(42, "42s"), (360, "6m"), (125, "2m 5s"), (93784, "1d 2h"), (3600, "1h"), (-125, "2m 5s")],
|
||||
)
|
||||
def test_a_duration_reads_in_two_units_at_most(seconds: int, expected: str) -> None:
|
||||
"""``93784`` is a number nobody can hold in their head; ``1d 2h`` is the same fact.
|
||||
|
||||
Two adjacent units, largest first, so the magnitude arrives at a glance and the
|
||||
precision never outruns what anyone would act on. Only ever a gloss beside the raw
|
||||
figure — the key says what the number counts, and a caller reading the key must still
|
||||
find a number under it.
|
||||
"""
|
||||
assert script.duration(seconds) == expected
|
||||
|
||||
|
||||
def test_a_location_is_one_field_because_it_is_one_fact() -> None:
|
||||
"""A coordinate pair is what goes into a map's search box, verbatim.
|
||||
|
||||
Two columns would also mean two ``-`` for every node that has never shared a position,
|
||||
which is worse than one.
|
||||
"""
|
||||
assert script.location(45.50190, -73.56740) == "45.50190,-73.56740"
|
||||
assert script.location(45.5, None) == script.NONE
|
||||
assert script.location(None, None) == script.NONE
|
||||
|
||||
|
||||
# -- the absent token -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_one_token_stands_for_every_kind_of_absence() -> None:
|
||||
"""A reader learns ``-`` once, and never meets an em dash, an ``n/a`` or a blank.
|
||||
|
||||
``never`` is the one word that is *not* an absence: it is a value, and the age lane is
|
||||
where it is spelled (see the ladder tests above).
|
||||
"""
|
||||
assert script.NONE == "-"
|
||||
assert script.number(None) == script.NONE
|
||||
assert script.route([]) == script.NONE
|
||||
assert script.spec([]) == script.NONE
|
||||
|
||||
|
||||
def test_number_formats_with_its_spec_when_there_is_one() -> None:
|
||||
"""A present number is formatted; only an absent one becomes the token."""
|
||||
assert script.number(2.64, "+.1f") == "+2.6"
|
||||
assert script.number(-1.5, "+.1f") == "-1.5"
|
||||
assert script.number(7) == "7"
|
||||
|
||||
|
||||
# -- routes and paths ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_route_is_drawn_with_arrows_and_a_path_keeps_its_commas() -> None:
|
||||
"""The lexicon's own split, finally drawn: a *route* is walked, a *path* is typed.
|
||||
|
||||
Both used to be comma-separated, which made a route look like something you could
|
||||
paste back into ``--path``. You cannot — the names are in it — so that comma was
|
||||
promising a round trip only the spec has. The arrow says "this is a picture", and
|
||||
carries the direction the packet travelled while it is there.
|
||||
"""
|
||||
assert (
|
||||
script.route([("Alice", "3d"), ("Yagi-Repeater", "f2")])
|
||||
== "Alice (3d) → Yagi-Repeater (f2)"
|
||||
)
|
||||
assert script.spec(["a1", "d4", "a1"]) == "a1,d4,a1"
|
||||
|
||||
|
||||
def test_a_route_names_our_own_node_like_any_other_hop() -> None:
|
||||
"""No star, and the reason is who reads the line.
|
||||
|
||||
The menu draws our own node as ``★`` because a reader never has to be told which node
|
||||
is theirs. A route line is as often read out of a file, by somebody who was not at the
|
||||
prompt when it ran — and by then the star names nothing.
|
||||
"""
|
||||
line = script.route([("MockCompanion", "00"), ("Alice", "3d"), ("MockCompanion", "00")])
|
||||
assert "★" not in line
|
||||
assert line.startswith("MockCompanion (00)")
|
||||
assert line.endswith("MockCompanion (00)")
|
||||
|
||||
|
||||
def test_a_route_hop_never_shows_an_empty_pair_of_parentheses() -> None:
|
||||
"""Half an identity prints as the half there is — a name alone, or a hash alone.
|
||||
|
||||
Which is how the menu's own :class:`~meshterm.ui.pathline.PathLine` labels an
|
||||
unresolved hop, and the reason both halves are optional: history often knows one.
|
||||
"""
|
||||
assert script.route([("Alice", None)]) == "Alice"
|
||||
assert script.route([(None, "3d")]) == "3d"
|
||||
assert script.route([(None, None)]) == script.NONE
|
||||
|
||||
|
||||
def test_the_arrow_is_what_makes_the_quoting_unnecessary() -> None:
|
||||
"""The claim moved: the quotes made a comma safe, and now nothing has to.
|
||||
|
||||
A comma inside a node's name can no longer be read as a hop boundary, because the hop
|
||||
boundary is not a comma any more.
|
||||
"""
|
||||
line = script.route([("Node, Inc", "3d"), ("Bob", "f2")])
|
||||
assert line == "Node, Inc (3d) → Bob (f2)"
|
||||
assert line.count("→") == 1
|
||||
|
||||
|
||||
def test_a_spec_is_the_one_line_on_either_face_that_round_trips() -> None:
|
||||
"""It is what ``--path`` takes back, so it grows no name, no arrow and no space."""
|
||||
assert script.spec(["a1b2", "d4e5", "a1b2"]) == "a1b2,d4e5,a1b2"
|
||||
assert " " not in script.spec(["a1", "d4"])
|
||||
|
||||
|
||||
# -- live streams ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_stream_pins_its_lanes_because_it_cannot_measure_them() -> None:
|
||||
"""A live capture cannot size its columns, because it has not seen the records yet.
|
||||
|
||||
:func:`~meshterm.ui.script.columns` sizes each lane to its widest value once every
|
||||
record is in. ``monitor`` prints a row the instant a packet lands, and the row after it
|
||||
may be twice as wide — so those two streams were gutter-joined with no alignment at
|
||||
all, which is the last thing the quoting was holding up.
|
||||
"""
|
||||
lanes = script.stream(("TIME", 19), ("NODE", 8), ("SNR_DB", 6), right=("SNR_DB",))
|
||||
assert lanes.header == "TIME NODE SNR_DB"
|
||||
assert lanes.record("2026-09-08T02:25:45", "a1b2c3d4", "+7.0") == (
|
||||
"2026-09-08T02:25:45 a1b2c3d4 +7.0"
|
||||
)
|
||||
|
||||
|
||||
def test_a_streamed_value_wider_than_its_lane_overruns_rather_than_lying() -> None:
|
||||
"""One row out of many is simply wider, and nothing is elided.
|
||||
|
||||
Which is why the one unbounded field — a node's name, a message body — goes last: it
|
||||
can then push nothing at all.
|
||||
"""
|
||||
lanes = script.stream(("NODE", 8), ("NAME", 6))
|
||||
assert lanes.record("a1", "A Very Long Node Name") == "a1 A Very Long Node Name"
|
||||
|
||||
|
||||
# -- the console ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_scripted_console_emits_no_escape_sequence_at_all() -> None:
|
||||
"""Not a colour, not a bold, not a dim — ``stdout`` is a pipe more often than a screen."""
|
||||
loud = Text.assemble(
|
||||
("brand", "brand"), ("bold", "bold"), ("hex", "#ff8800"), ("reverse", "reverse")
|
||||
)
|
||||
assert not _ANSI.search(_rendered(loud))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
["[bold]Loud", "[/]Bob", "[red]a[/red]", ":fire:Hot", "[[weird]]", "100% [done]"],
|
||||
)
|
||||
def test_a_node_name_is_never_read_as_rich_markup(name: str) -> None:
|
||||
"""A node broadcasts its own name, so every name printed here is remote data.
|
||||
|
||||
Rich reads ``[...]`` as a style tag unless told not to. That made a name into two
|
||||
different bugs: ``[bold]Loud`` printed as ``Loud`` — silently corrupted, and no longer
|
||||
the string that identifies the node to whoever reads the output — and ``[/]Bob`` raised
|
||||
``MarkupError`` and took the whole command down. ``:fire:`` is the same hazard one
|
||||
parser over. Neither is hypothetical: a name is the one field a stranger controls.
|
||||
"""
|
||||
table = script.columns("NAME", "TYPE")
|
||||
table.add_row(script.name(name), "node")
|
||||
record = _rendered(table).splitlines()[1]
|
||||
assert record.startswith(script.name(name)), record
|
||||
|
||||
|
||||
def test_the_scripted_console_never_wraps_a_record_onto_a_second_line() -> None:
|
||||
"""A record is a line: wrapping would put half its fields under the wrong headings."""
|
||||
table = script.columns("KEY", "VALUE")
|
||||
table.add_row("basemap_tilejson_url", "x" * 400)
|
||||
lines = _rendered(table).splitlines()
|
||||
assert len(lines) == 2 # the header and the one record
|
||||
assert lines[1].endswith("x" * 400)
|
||||
|
||||
|
||||
def test_the_scripted_console_leaves_no_trailing_whitespace() -> None:
|
||||
"""Padding the last column to its width would put invisible spaces on every line."""
|
||||
table = script.columns("NAME", "TYPE")
|
||||
table.add_row(script.name("a-long-name"), "node")
|
||||
table.add_row(script.name("b"), "repeater")
|
||||
for line in _rendered(table).splitlines():
|
||||
assert line == line.rstrip(), repr(line)
|
||||
|
||||
|
||||
def test_the_gutter_still_separates_columns_after_the_trimming() -> None:
|
||||
"""The trimmer holds trailing whitespace back; it must not eat the padding *between*.
|
||||
|
||||
Rich writes a rendered row segment by segment and flushes after each one, so a
|
||||
wrapper that trimmed at flush would throw away the very spaces that make the columns
|
||||
columns (see :class:`meshterm.ui.script._Trimmed`).
|
||||
"""
|
||||
table = script.columns("NAME", "TYPE", "PKTS", right=("PKTS",))
|
||||
table.add_row(script.name("Alice"), "node", "12")
|
||||
header, record = _rendered(table).splitlines()
|
||||
assert header.split() == ["NAME", "TYPE", "PKTS"]
|
||||
assert record.split() == ["Alice", "node", "12"]
|
||||
# Spelled out, not written as `" " * script.GUTTER`: an assertion that reads the
|
||||
# constant it is checking narrows to `" " in record` when the gutter narrows, which
|
||||
# is exactly the state the gutter exists to prevent.
|
||||
assert "Alice node" in record
|
||||
|
||||
|
||||
def test_columns_align_so_every_record_splits_the_same_way() -> None:
|
||||
"""Every field lands in its heading's column, whatever the widths."""
|
||||
table = script.columns("NAME", "TYPE")
|
||||
table.add_row(script.name("Yagi-Repeater"), "repeater")
|
||||
table.add_row(script.name("Al"), "node")
|
||||
header, *records = _rendered(table).splitlines()
|
||||
starts = {line.index("re") if "repeater" in line else line.index("node") for line in records}
|
||||
assert starts == {header.index("TYPE")} # every TYPE value opens under its heading
|
||||
|
||||
|
||||
def test_a_numeric_column_really_right_aligns() -> None:
|
||||
"""A column of magnitudes is only comparable by eye if the digits line up.
|
||||
|
||||
Rich's ``Text.wrap`` returns early on ``overflow="ignore"`` — and that early return
|
||||
sits *above* the justify step, so asking for ``overflow="ignore"`` throws
|
||||
``justify="right"`` away without a word. Every ``right=`` in the CLI was a no-op
|
||||
until the columns asked for ``"crop"``, which refuses to elide just as firmly and
|
||||
still aligns. Checking where a field *starts* cannot see this; only its end can.
|
||||
"""
|
||||
table = script.columns("NAME", "PKTS", right=("PKTS",))
|
||||
table.add_row(script.name("Alice"), "1174")
|
||||
table.add_row(script.name("Bob"), "3")
|
||||
header, *records = _rendered(table).splitlines()
|
||||
assert len({len(line) for line in records}) == 1, records
|
||||
assert {len(line) for line in records} == {len(header)}
|
||||
|
||||
|
||||
def test_no_column_elides_however_long_its_value_runs() -> None:
|
||||
"""A key cut short is not something a caller can hand back to ``--to`` or ``--path``.
|
||||
|
||||
The guard against eliding and the guard against mis-alignment are the same setting,
|
||||
so a change to one silently moves the other; both are pinned here.
|
||||
"""
|
||||
table = script.columns("KEY", "PKTS", right=("PKTS",))
|
||||
table.add_row("d4" * 32, "7")
|
||||
out = _rendered(table)
|
||||
assert "…" not in out
|
||||
assert "d4" * 32 in out
|
||||
|
||||
|
||||
def test_the_trimmer_keeps_the_gutter_when_the_stream_flushes_between_columns() -> None:
|
||||
"""Rich's legacy-Windows renderer writes a row one segment at a time, flushing each.
|
||||
|
||||
``LegacyWindowsTerm.write_text`` is ``write(text); flush()``, so on that path a flush
|
||||
lands *between two columns* as often as at the end of a line. A wrapper that emptied
|
||||
its held run at flush destroyed every gutter in real use while the in-process tests —
|
||||
which see one write and one flush per ``print`` — went on passing. That is not a
|
||||
hypothetical: it shipped, and this drives the stream the way that renderer does.
|
||||
"""
|
||||
buffer = io.StringIO()
|
||||
stream = script._Trimmed(buffer)
|
||||
for segment in ("Alice", " ", "node", " ", "12", "\n"):
|
||||
stream.write(segment)
|
||||
stream.flush()
|
||||
assert buffer.getvalue() == "Alice node 12\n"
|
||||
|
||||
|
||||
def test_the_trimmer_still_drops_the_run_that_ends_a_line() -> None:
|
||||
"""The other half of the rule: holding padding back is only right if it is still cut.
|
||||
|
||||
Held whitespace is emitted when something follows it on the line, and dropped at the
|
||||
newline and at the end of the stream — otherwise every record would end in the
|
||||
invisible padding of its last column.
|
||||
"""
|
||||
buffer = io.StringIO()
|
||||
stream = script._Trimmed(buffer)
|
||||
for segment in ("row", " ", "\n", "next", " "):
|
||||
stream.write(segment)
|
||||
stream.flush()
|
||||
assert buffer.getvalue() == "row\nnext"
|
||||
|
||||
|
||||
def test_a_value_wider_than_the_console_is_cut_rather_than_elided() -> None:
|
||||
"""Past :data:`~meshterm.ui.script.WIDTH` cells something gives; it must not be a "…".
|
||||
|
||||
An elision is indistinguishable from a value that really ends there, so a key read out
|
||||
of a listing would go back into ``--to`` subtly wrong. A 64-hex key never reaches the
|
||||
overflow path at all, so only an over-width value tests the guard that stands there.
|
||||
"""
|
||||
table = script.columns("KEY")
|
||||
table.add_row("d" * (script.WIDTH + 500))
|
||||
assert "…" not in _rendered(table)
|
||||
|
||||
|
||||
def test_an_error_stays_greppable_when_stderr_is_not_a_terminal() -> None:
|
||||
"""A sentence broken across three lines is a sentence nothing can find.
|
||||
|
||||
Rich falls back to 80 cells when it cannot measure the destination, so
|
||||
``2> errors.log`` hard-wrapped every message — and ``grep 'not already connected'``
|
||||
then found nothing, because the words it was looking for had a newline in the middle
|
||||
of them. In a real terminal the width is left alone: a progress bar sized to 16384
|
||||
cells is not a progress bar.
|
||||
"""
|
||||
import sys
|
||||
|
||||
buffer = io.StringIO()
|
||||
stderr, sys.stderr = sys.stderr, buffer
|
||||
try:
|
||||
console = script.stderr_console()
|
||||
console.print("meshterm: " + "the connection was not already connected " * 6)
|
||||
finally:
|
||||
sys.stderr = stderr
|
||||
assert len(buffer.getvalue().splitlines()) == 1
|
||||
|
||||
|
||||
# -- framing --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_panel_gives_up_its_border_and_its_title() -> None:
|
||||
"""No frames: the command the reader typed is the title."""
|
||||
out = _rendered(*script.flatten(Panel(Text("body"), title="Trace summary")))
|
||||
assert out.strip() == "body"
|
||||
|
||||
|
||||
def test_a_table_gives_up_its_box_its_title_and_its_expansion() -> None:
|
||||
"""A shared table that still arrives boxed is unframed on the way out."""
|
||||
from rich import box
|
||||
|
||||
table = Table(title="Contacts", box=box.SIMPLE_HEAD, expand=True)
|
||||
table.add_column("NAME")
|
||||
table.add_row("Alice")
|
||||
out = _rendered(*script.flatten(table))
|
||||
assert "Contacts" not in out
|
||||
assert not any(ch in out for ch in "─│┌└")
|
||||
|
||||
|
||||
def test_a_group_is_unframed_member_by_member() -> None:
|
||||
"""Framing is stripped however deep a shared renderable nests it."""
|
||||
nested = Group(Panel(Text("one"), title="First"), Panel(Text("two"), title="Second"))
|
||||
assert _rendered(*script.flatten(nested)).split() == ["one", "two"]
|
||||
|
||||
|
||||
def test_no_rendered_line_is_a_box_drawing_rule() -> None:
|
||||
"""No borders and no header rules — a listing is its header line and its records."""
|
||||
table = script.columns("NAME", "TYPE")
|
||||
table.add_row(script.name("Alice"), "node")
|
||||
assert "─" not in _rendered(table)
|
||||
|
||||
|
||||
# -- key/value listings ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pairs_print_one_fact_per_line_with_no_header() -> None:
|
||||
"""The ``sysctl -a`` shape: a key, the gutter, and the rest of the line as its value."""
|
||||
out = _rendered(script.pairs([("name", "MockCompanion"), ("uptime_s", "93784")]))
|
||||
assert [line.split(None, 1) for line in out.splitlines()] == [
|
||||
["name", "MockCompanion"],
|
||||
["uptime_s", "93784"],
|
||||
]
|
||||
|
||||
|
||||
def test_a_pairs_value_is_unquoted_because_it_is_the_rest_of_the_line() -> None:
|
||||
"""Quoting is for a name sharing a line with other fields; here there are only two."""
|
||||
out = _rendered(script.pairs([("name", "YUL Cartierville")]))
|
||||
assert out.splitlines()[0].split(None, 1) == ["name", "YUL Cartierville"]
|
||||
|
||||
|
||||
# -- exit statuses --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_exit_statuses_are_the_numbers_a_caller_hard_codes() -> None:
|
||||
"""``case $? in 3)`` is written against the digits, so the digits are the contract.
|
||||
|
||||
Stated as literals on purpose. Asserting ``set(MEANINGS) == {OK, FAILURE, …}``, or
|
||||
``len(set(d)) == len(d)``, cannot fail however the constants are renumbered: a dict
|
||||
has no duplicate keys, so two statuses colliding *removes* a row and both sides of
|
||||
the comparison lose the same one together. Renumbering ``NO_DEVICE`` to ``4`` left
|
||||
that whole family of assertions green while status 3 vanished from ``--help``.
|
||||
"""
|
||||
assert (exitcodes.OK, exitcodes.FAILURE, exitcodes.USAGE) == (0, 1, 2)
|
||||
assert (exitcodes.NO_DEVICE, exitcodes.DEVICE, exitcodes.NO_RESULT) == (3, 4, 5)
|
||||
assert sorted(exitcodes.MEANINGS) == [0, 1, 2, 3, 4, 5]
|
||||
assert all(meaning for meaning in exitcodes.MEANINGS.values())
|
||||
|
||||
|
||||
def test_the_help_epilog_names_every_status() -> None:
|
||||
"""A caller should not have to find the table in a README."""
|
||||
from meshterm.cli import EXIT_STATUS_EPILOG
|
||||
|
||||
for code, meaning in exitcodes.MEANINGS.items():
|
||||
assert f"{code} {meaning}" in EXIT_STATUS_EPILOG
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Session colour tests: how many colours the terminal is actually sent.
|
||||
|
||||
prompt_toolkit decides this per *output class*, and its two classes disagree —
|
||||
``Windows10_Output`` returns true colour outright, ``Vt100_Output`` returns 256 for every
|
||||
``TERM`` but ``linux`` — so the same theme was drawn at 24 bits on Windows and quantized to
|
||||
the 216-colour cube on macOS and Linux, silently. The resolver closes that gap, and these
|
||||
tests pin the one property that makes it safe to run everywhere: it only ever *raises* the
|
||||
verdict. A terminal that cannot be shown to do better returns ``None`` and keeps exactly the
|
||||
depth prompt_toolkit gave it, because guessing 24-bit at a terminal without it costs not a
|
||||
duller palette but the colour entirely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from prompt_toolkit.output.color_depth import ColorDepth
|
||||
|
||||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||||
from meshterm.ui.tui.session import _color_depth
|
||||
|
||||
|
||||
def _auto(monkeypatch) -> None:
|
||||
"""Clear both overrides so the environment reading is what is under test."""
|
||||
monkeypatch.delenv("MESHTERM_COLOR_DEPTH", raising=False)
|
||||
monkeypatch.delenv("COLORTERM", raising=False)
|
||||
monkeypatch.delenv("TERM", raising=False)
|
||||
|
||||
|
||||
def test_an_unremarkable_terminal_keeps_prompt_toolkits_own_verdict(monkeypatch) -> None:
|
||||
"""Nothing claimed → ``None``, so a host we have never heard of is never made worse."""
|
||||
_auto(monkeypatch)
|
||||
set_platform(REGULAR)
|
||||
assert _color_depth() is None
|
||||
|
||||
|
||||
def test_colorterm_truecolor_raises_the_depth(monkeypatch) -> None:
|
||||
"""The convention every truecolor terminal follows — and the one prompt_toolkit ignores."""
|
||||
_auto(monkeypatch)
|
||||
monkeypatch.setenv("COLORTERM", "truecolor")
|
||||
set_platform(REGULAR)
|
||||
assert _color_depth() is ColorDepth.DEPTH_24_BIT
|
||||
|
||||
|
||||
def test_colorterm_24bit_is_the_same_claim(monkeypatch) -> None:
|
||||
"""``24bit`` is the other spelling in circulation; case is not part of the claim."""
|
||||
_auto(monkeypatch)
|
||||
monkeypatch.setenv("COLORTERM", "24BIT")
|
||||
set_platform(REGULAR)
|
||||
assert _color_depth() is ColorDepth.DEPTH_24_BIT
|
||||
|
||||
|
||||
def test_a_direct_colour_terminfo_entry_raises_the_depth(monkeypatch) -> None:
|
||||
"""``*-direct`` is terminfo's own spelling for a direct-colour terminal."""
|
||||
_auto(monkeypatch)
|
||||
monkeypatch.setenv("TERM", "xterm-direct")
|
||||
set_platform(REGULAR)
|
||||
assert _color_depth() is ColorDepth.DEPTH_24_BIT
|
||||
|
||||
|
||||
def test_picocalc_is_never_promoted(monkeypatch) -> None:
|
||||
"""A 16-slot console addresses its palette by index; RGB has nowhere to land there."""
|
||||
_auto(monkeypatch)
|
||||
monkeypatch.setenv("COLORTERM", "truecolor")
|
||||
set_platform(PICOCALC)
|
||||
assert _color_depth() is None
|
||||
|
||||
|
||||
def test_env_override_names_a_depth_outright(monkeypatch) -> None:
|
||||
"""``MESHTERM_COLOR_DEPTH`` wins over the reading, in either direction."""
|
||||
_auto(monkeypatch)
|
||||
monkeypatch.setenv("COLORTERM", "truecolor")
|
||||
set_platform(REGULAR)
|
||||
monkeypatch.setenv("MESHTERM_COLOR_DEPTH", "256")
|
||||
assert _color_depth() is ColorDepth.DEPTH_8_BIT
|
||||
monkeypatch.setenv("MESHTERM_COLOR_DEPTH", "16")
|
||||
assert _color_depth() is ColorDepth.DEPTH_4_BIT
|
||||
|
||||
|
||||
def test_the_preference_is_consulted_below_the_env_override(monkeypatch) -> None:
|
||||
"""A stored preference names a depth the same way; ``auto`` falls through to the reading."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
_auto(monkeypatch)
|
||||
set_platform(REGULAR)
|
||||
monkeypatch.setattr(
|
||||
"meshterm.core.preferences.current",
|
||||
lambda: SimpleNamespace(color_depth="truecolor"),
|
||||
)
|
||||
assert _color_depth() is ColorDepth.DEPTH_24_BIT
|
||||
monkeypatch.setattr(
|
||||
"meshterm.core.preferences.current",
|
||||
lambda: SimpleNamespace(color_depth="auto"),
|
||||
)
|
||||
assert _color_depth() is None
|
||||
|
||||
|
||||
def test_an_unrecognised_preference_value_changes_nothing(monkeypatch) -> None:
|
||||
"""A hand-edited YAML value we cannot read is not grounds for guessing."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
_auto(monkeypatch)
|
||||
monkeypatch.setenv("COLORTERM", "truecolor")
|
||||
set_platform(REGULAR)
|
||||
monkeypatch.setattr(
|
||||
"meshterm.core.preferences.current",
|
||||
lambda: SimpleNamespace(color_depth="lots"),
|
||||
)
|
||||
assert _color_depth() is None
|
||||
@@ -224,7 +224,7 @@ async def test_apply_setting_remembers_through_the_config_executor(ctx) -> None:
|
||||
pubkey = (await device.get_self_info())["public_key"]
|
||||
snapshot = await build_snapshot(device)
|
||||
|
||||
changes, _ = await apply_ops(
|
||||
changes, _artifacts, _report = await apply_ops(
|
||||
ctx, device, snapshot, [("set", "name", "Ops-Node"), ("set", "tx_power", "18")]
|
||||
)
|
||||
assert changes == 2
|
||||
|
||||
+298
-15
@@ -1226,7 +1226,9 @@ def test_device_picker_builds_aligned_columns(tmp_path) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class _Ui:
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
captured["items"] = items
|
||||
captured["banner"] = banner
|
||||
captured["footnote"] = footnote
|
||||
@@ -1278,7 +1280,9 @@ def test_device_picker_names_and_sorts_known_devices(tmp_path) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class _Ui:
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
captured["items"] = items
|
||||
return None # skip past the smoke test
|
||||
|
||||
@@ -1315,7 +1319,9 @@ def test_device_picker_reinjects_remembered_tcp_device(tmp_path) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class _Ui:
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
captured["items"] = items
|
||||
return None # skip past the smoke test
|
||||
|
||||
@@ -1346,7 +1352,9 @@ def test_device_picker_lists_configured_tcp_profile(tmp_path) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class _Ui:
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
captured["items"] = items
|
||||
return None # skip past the smoke test
|
||||
|
||||
@@ -1379,7 +1387,9 @@ def test_device_picker_lists_configured_serial_profile(tmp_path) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class _Ui:
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
captured["items"] = items
|
||||
return None # skip past the smoke test
|
||||
|
||||
@@ -1412,7 +1422,9 @@ def test_device_picker_serial_profile_yields_to_scanned_port(tmp_path) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class _Ui:
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
captured["items"] = items
|
||||
return None
|
||||
|
||||
@@ -1442,7 +1454,9 @@ def test_device_picker_profile_yields_to_remembered_endpoint(tmp_path) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class _Ui:
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
captured["items"] = items
|
||||
return None
|
||||
|
||||
@@ -1467,7 +1481,9 @@ def test_device_picker_adds_network_device(tmp_path) -> None:
|
||||
probed: dict = {}
|
||||
|
||||
class _Ui:
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
# Choose the "add a network device" action row.
|
||||
return next(it.value for it in items if isinstance(it, Choice) and it.value is _ADD_TCP)
|
||||
|
||||
@@ -1517,7 +1533,9 @@ def test_device_picker_removes_network_device_on_delete(tmp_path) -> None:
|
||||
def __init__(self) -> None:
|
||||
self._passes = 0
|
||||
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
names = [
|
||||
it.label.plain
|
||||
for it in items
|
||||
@@ -1583,7 +1601,9 @@ def test_device_picker_keeps_network_device_when_removal_cancelled(tmp_path) ->
|
||||
def __init__(self) -> None:
|
||||
self._passes = 0
|
||||
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
self._passes += 1
|
||||
if self._passes == 1:
|
||||
row = next(
|
||||
@@ -1662,7 +1682,9 @@ class _PickerUi:
|
||||
def __init__(self) -> None:
|
||||
self.notes: list = []
|
||||
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
return next(it.value for it in items if isinstance(it, Choice))
|
||||
|
||||
async def notify_startup(self, renderable, *, title="", banner=None, footnote=None):
|
||||
@@ -1707,7 +1729,9 @@ def test_device_picker_leaves_the_copyright_to_the_wordmark(tmp_path) -> None:
|
||||
footnotes: list = []
|
||||
|
||||
class _Ui:
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
footnotes.append(("select", footnote))
|
||||
return next(it.value for it in items if isinstance(it, Choice))
|
||||
|
||||
@@ -1748,7 +1772,9 @@ def test_device_picker_prompts_and_retries_ble_pin(tmp_path) -> None:
|
||||
errors: list = []
|
||||
|
||||
class _Ui:
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
return next(it.value for it in items if isinstance(it, Choice))
|
||||
|
||||
async def notify_startup(self, renderable, *, title="", banner=None, footnote=None):
|
||||
@@ -1793,7 +1819,9 @@ def test_device_picker_pin_cancel_returns_to_list(tmp_path) -> None:
|
||||
picks = iter([0, "quit"]) # pick the device once, then quit the re-opened list
|
||||
|
||||
class _Ui:
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
choices = [it for it in items if isinstance(it, Choice)]
|
||||
step = next(picks)
|
||||
if step == "quit":
|
||||
@@ -1825,7 +1853,9 @@ def test_device_picker_quit_row_returns_none(tmp_path) -> None:
|
||||
devices = [DiscoveredDevice(port="COM5", product="Wio SX1262")]
|
||||
|
||||
class _QuitUi:
|
||||
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
|
||||
async def select_startup( # noqa: ANN001, ANN201, ANN003
|
||||
self, title, items, *, default=None, banner=None, footnote=None, **_kw
|
||||
):
|
||||
# The last choice is the Quit row; picking it signals "exit".
|
||||
quit_choice = [it for it in items if isinstance(it, Choice) and it.value is _QUIT]
|
||||
assert quit_choice, "the picker offers a Quit row"
|
||||
@@ -2958,3 +2988,256 @@ def test_a_button_dialogs_hint_never_names_the_verb_of_one_button() -> None:
|
||||
assert "←→" in hint, (
|
||||
f"{where}:{line} hides ←→, the key that moves the highlight Enter commits"
|
||||
)
|
||||
|
||||
|
||||
# --- hiding devices on the splash --------------------------------------------
|
||||
|
||||
|
||||
def _hide_store(tmp_path): # noqa: ANN001, ANN202
|
||||
"""A device registry on a scratch file."""
|
||||
from meshterm.core.device_store import DeviceStore
|
||||
|
||||
return DeviceStore(tmp_path / "devices.json")
|
||||
|
||||
|
||||
def test_hidden_devices_survive_the_session_that_hid_them(tmp_path) -> None: # noqa: ANN001
|
||||
"""Hiding is written to the registry file, so a fresh store still knows about it."""
|
||||
store = _hide_store(tmp_path)
|
||||
assert store.hidden_ids() == set()
|
||||
|
||||
store.hide("usb-0001")
|
||||
store.hide("usb-0002")
|
||||
store.hide("usb-0001") # idempotent
|
||||
assert _hide_store(tmp_path).hidden_ids() == {"usb-0001", "usb-0002"}
|
||||
|
||||
assert _hide_store(tmp_path).show_all() == 2
|
||||
assert _hide_store(tmp_path).hidden_ids() == set()
|
||||
assert _hide_store(tmp_path).show_all() == 0 # nothing left to bring back
|
||||
|
||||
|
||||
def test_hiding_leaves_the_registry_and_its_default_alone(tmp_path) -> None: # noqa: ANN001
|
||||
"""A hidden device is still remembered, still the default, still reachable by name.
|
||||
|
||||
Hiding is a *listing* choice on one screen. Everything that resolves a device without
|
||||
the splash — ``--port``, a profile, the reconnect — must not be able to tell.
|
||||
"""
|
||||
from meshterm.core.discovery import serial_device
|
||||
|
||||
store = _hide_store(tmp_path)
|
||||
device = serial_device("COM7", name="A Radio")
|
||||
store.remember(device, node_name="Wardriver")
|
||||
store.hide(device.stable_id)
|
||||
|
||||
fresh = _hide_store(tmp_path)
|
||||
assert fresh.hidden_ids() == {device.stable_id}
|
||||
assert fresh.is_known(device)
|
||||
remembered = fresh.load()
|
||||
assert remembered is not None and remembered.node_name == "Wardriver"
|
||||
|
||||
|
||||
def test_connecting_to_a_hidden_device_shows_it_again(tmp_path) -> None: # noqa: ANN001
|
||||
"""Confirming a device is the plainest statement that it belongs on the list."""
|
||||
from meshterm.core.discovery import serial_device
|
||||
|
||||
store = _hide_store(tmp_path)
|
||||
device = serial_device("COM7", name="A Radio")
|
||||
store.hide(device.stable_id)
|
||||
store.remember(device, node_name="Wardriver")
|
||||
assert store.hidden_ids() == set()
|
||||
|
||||
|
||||
async def test_the_splash_hides_the_highlighted_device_and_redraws(tmp_path) -> None: # noqa: ANN001
|
||||
"""Pressing h drops the row and remembers it; the redrawn list is the feedback."""
|
||||
from meshterm.core.discovery import serial_device
|
||||
from meshterm.ui.device_picker import prompt_device
|
||||
from meshterm.ui.tui import Choice, KeyRequest
|
||||
|
||||
probe = serial_device("COM3", name="A Debug Probe")
|
||||
radio = serial_device("COM7", name="A Radio")
|
||||
store = _hide_store(tmp_path)
|
||||
drawn: list[list] = []
|
||||
|
||||
class _Ui:
|
||||
"""Presses h on the probe, then picks whatever is left."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.round = 0
|
||||
|
||||
async def select_startup(self, title, items, **kw): # noqa: ANN001, ANN003, ANN201
|
||||
drawn.append([it for it in items if isinstance(it, Choice)])
|
||||
self.round += 1
|
||||
if self.round == 1:
|
||||
return KeyRequest(kw["keys"]["h"], probe)
|
||||
return radio
|
||||
|
||||
async def busy_startup(self, message, coro, **kw): # noqa: ANN001, ANN003, ANN201
|
||||
return await coro
|
||||
|
||||
async def verify(device, pin): # noqa: ANN001, ANN202
|
||||
return {"name": "Wardriver"}
|
||||
|
||||
chosen = await prompt_device(_Ui(), [probe, radio], store, verify)
|
||||
assert chosen is radio
|
||||
assert store.hidden_ids() == {probe.stable_id}
|
||||
# The first pass offered both devices; the second offered only the one left.
|
||||
assert probe in [row.value for row in drawn[0]]
|
||||
assert probe not in [row.value for row in drawn[1]]
|
||||
|
||||
|
||||
def test_the_splash_says_so_when_it_is_empty_only_because_of_hiding() -> None:
|
||||
"""Nothing-detected would be a lie, and with no rows there is no footer atom either."""
|
||||
from meshterm.core.discovery import serial_device
|
||||
from meshterm.ui.device_picker import _build_items
|
||||
|
||||
lines = [str(it.title) for it in _build_items([], None, {}, hidden=2)]
|
||||
assert any("2 devices hidden" in line and "⇧H" in line for line in lines)
|
||||
|
||||
detected = [str(it.title) for it in _build_items([], None, {}, hidden=0)]
|
||||
assert any("no companion devices detected" in line for line in detected)
|
||||
|
||||
# With something still listed the footer names ⇧H on every row, so the line would be a
|
||||
# row of the box spent saying it twice.
|
||||
radio = serial_device("COM7", name="A Radio")
|
||||
rows = [str(it.title) for it in _build_items([radio], None, {}, hidden=2)]
|
||||
assert not any("hidden" in line for line in rows)
|
||||
|
||||
|
||||
def test_hiding_a_device_leaves_the_highlight_where_the_row_was() -> None:
|
||||
"""The next device down, or the one above at the end — so a run of adapters clears in a run."""
|
||||
from meshterm.core.discovery import serial_device
|
||||
from meshterm.ui.device_picker import _after_hiding
|
||||
|
||||
first, middle, last = (serial_device(f"COM{n}", name=f"D{n}") for n in (1, 2, 3))
|
||||
listed = [first, middle, last]
|
||||
|
||||
assert _after_hiding(listed, first) is middle
|
||||
assert _after_hiding(listed, middle) is last
|
||||
# Off the end there is nowhere further down, so the highlight steps up rather than
|
||||
# rolling round to the top — nothing in the app rolls over.
|
||||
assert _after_hiding(listed, last) is middle
|
||||
assert _after_hiding([first], first) is None # an emptied list has nothing to land on
|
||||
|
||||
|
||||
def test_the_splash_names_a_shortcut_only_where_it_would_act() -> None:
|
||||
"""Named on a device row only, and ⇧H only while hidden — the Del remove rule."""
|
||||
from meshterm.core.discovery import serial_device
|
||||
from meshterm.ui.device_picker import _ADD_TCP, _QUIT, _shortcut_hint
|
||||
|
||||
radio = serial_device("COM7", name="A Radio")
|
||||
|
||||
nothing_hidden = _shortcut_hint(0)
|
||||
assert nothing_hidden(radio) == "h hide" # no way back is offered; there is nothing back
|
||||
assert nothing_hidden(_ADD_TCP) == "" # nothing to hide on the action rows
|
||||
assert nothing_hidden(_QUIT) == ""
|
||||
assert nothing_hidden(None) == "" # an empty list highlights nothing at all
|
||||
|
||||
with_hidden = _shortcut_hint(2)
|
||||
assert with_hidden(radio) == "h hide · ⇧H show all"
|
||||
# ⇧H is screen-wide, so it rides whatever row the reader happens to be standing on.
|
||||
assert with_hidden(_QUIT) == "⇧H show all"
|
||||
|
||||
|
||||
def test_a_hint_too_long_for_its_box_drops_atoms_rather_than_its_tail() -> None:
|
||||
"""Cutting the line at the edge would take Esc, which is the one atom that must survive."""
|
||||
from meshterm.ui.tui.frame import fit_hint
|
||||
|
||||
full = "↑↓ move · ←→ scroll · Enter select · h hide · ⇧H show all · Esc quit"
|
||||
assert fit_hint(full, 100) == full # room for everything: untouched
|
||||
|
||||
# Dropped from the right, in front of Esc — never Esc itself.
|
||||
assert fit_hint(full, 56) == "↑↓ move · ←→ scroll · Enter select · h hide · Esc quit"
|
||||
assert fit_hint(full, 45) == "↑↓ move · ←→ scroll · Enter select · Esc quit"
|
||||
assert fit_hint(full, 20).endswith("Esc quit")
|
||||
|
||||
# ...unless the caller names atoms it can spare first, in the order it can spare them:
|
||||
# the scroll keys are already named by the move atom, and between the two hide keys the
|
||||
# one to keep is the way back.
|
||||
spared = fit_hint(full, 56, shed_first=("←→ scroll", "h hide"))
|
||||
assert spared == "↑↓ move · Enter select · h hide · ⇧H show all · Esc quit"
|
||||
assert (
|
||||
fit_hint(full, 50, shed_first=("←→ scroll", "h hide"))
|
||||
== "↑↓ move · Enter select · ⇧H show all · Esc quit"
|
||||
)
|
||||
|
||||
|
||||
def test_the_splash_scrolls_the_hardware_column_of_the_row_it_is_on() -> None:
|
||||
"""←→ read a long firmware model to its end; the lanes in front of it stay put."""
|
||||
from meshterm.core.device_store import RememberedDevice
|
||||
from meshterm.core.discovery import serial_device
|
||||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||||
from meshterm.ui.device_picker import _build_items
|
||||
from meshterm.ui.tui import Choice, SelectScreen
|
||||
|
||||
radio = serial_device("COM7", name="Wardriver")
|
||||
registry = {
|
||||
radio.stable_id: RememberedDevice(
|
||||
stable_id=radio.stable_id,
|
||||
port="COM7",
|
||||
label="COM7",
|
||||
last_connected="2026-09-01T00:00:00+00:00",
|
||||
node_name="Wardriver",
|
||||
hardware_model="Seeed Wio Tracker 1110 Development Kit",
|
||||
)
|
||||
}
|
||||
try:
|
||||
for platform, width in ((REGULAR, 66), (PICOCALC, 47)):
|
||||
set_platform(platform)
|
||||
items = _build_items([radio], registry[radio.stable_id], registry)
|
||||
screen = SelectScreen("Select a companion device", items, filterable=False)
|
||||
row = next(it for it in items if isinstance(it, Choice))
|
||||
assert row.hscroll_from > 0 # the fixed lanes are pinned...
|
||||
assert row.hscroll_from < width # ...and leave something to scroll into
|
||||
|
||||
before = _plain(screen.render_body(width)[1])
|
||||
for _ in range(3):
|
||||
screen.handle("right")
|
||||
after = _plain(screen.render_body(width)[1])
|
||||
assert before != after, platform.name
|
||||
assert after.startswith(before[: row.hscroll_from - 4]), "the lanes moved"
|
||||
finally:
|
||||
set_platform(REGULAR)
|
||||
|
||||
|
||||
def test_the_splash_hint_stays_inside_the_box_it_is_drawn_in() -> None:
|
||||
"""The chromeless splash keeps its hint in its own border, on both platforms.
|
||||
|
||||
The border is the terminal less the gutter the box floats over — not the full readable
|
||||
width — and on the PicoCalc that is 47 cells, which is what makes every atom here
|
||||
conditional rather than merely tidy.
|
||||
"""
|
||||
from rich.cells import cell_len
|
||||
|
||||
from meshterm.core.discovery import serial_device
|
||||
from meshterm.platforms import PICOCALC, REGULAR
|
||||
from meshterm.ui.device_picker import _shortcut_hint
|
||||
from meshterm.ui.tui.select import _splice_hint
|
||||
|
||||
base = "↑↓ move · Enter select · Esc bye" # the splash's own send-off
|
||||
radio = serial_device("COM7", name="A Radio")
|
||||
common = _splice_hint(base, _shortcut_hint(0)(radio))
|
||||
for platform in (REGULAR, PICOCALC):
|
||||
budget = platform.readable_cols - platform.dialog_margin - 2
|
||||
assert cell_len(common) <= budget, (platform.name, common)
|
||||
|
||||
|
||||
def test_a_shortcut_is_only_honoured_where_a_letter_is_free() -> None:
|
||||
"""A filtering list spends its letters on the query, so it declares no shortcuts.
|
||||
|
||||
Otherwise a device named "Homestead" could not be typed on a list that had claimed h.
|
||||
"""
|
||||
from meshterm.ui.tui import Choice, KeyRequest
|
||||
from meshterm.ui.tui.select import SelectScreen
|
||||
|
||||
token = object()
|
||||
fixed = SelectScreen("fixed", [Choice("a row", 1)], filterable=False, keys={"h": token})
|
||||
fixed.future = None
|
||||
resolved: list = []
|
||||
fixed.resolve = lambda value: resolved.append(value) # type: ignore[method-assign]
|
||||
fixed.handle("text", "h")
|
||||
assert resolved == [KeyRequest(token, 1)]
|
||||
|
||||
filtering = SelectScreen("filtering", [Choice("Homestead", 1)], keys={"h": token})
|
||||
filtering.resolve = lambda value: resolved.append(value) # type: ignore[method-assign]
|
||||
filtering.handle("text", "h")
|
||||
assert len(resolved) == 1 # nothing new resolved
|
||||
assert filtering._filter == "h" # the letter went to the query, where it belongs
|
||||
@@ -131,3 +131,46 @@ def test_the_loader_path_the_bootloader_replaced_is_put_back(
|
||||
assert environment["LD_LIBRARY_PATH"] == "/opt/mine/lib"
|
||||
assert "LD_LIBRARY_PATH_ORIG" not in environment
|
||||
assert "DYLD_LIBRARY_PATH" not in environment
|
||||
|
||||
|
||||
def test_the_window_asked_for_fits_the_startup_wordmark() -> None:
|
||||
"""The size request exists because a too-small window fails quietly, not loudly.
|
||||
|
||||
The startup wordmark is 71 columns; below that the splash silently swaps to the narrow
|
||||
one drawn for the PicoCalc, which is how this was found — a desktop session showing the
|
||||
handheld's mark. The request has to clear that, not merely the platform's minimum.
|
||||
"""
|
||||
from meshterm.platforms import REGULAR, set_platform
|
||||
from meshterm.ui.logo import load_logo, logo_width
|
||||
|
||||
set_platform(REGULAR)
|
||||
cols, rows = (int(part) for part in winterminal._wanted_size().split(","))
|
||||
|
||||
assert logo_width(load_logo(cols)) == 71, "the window asked for would get the narrow mark"
|
||||
assert cols >= REGULAR.readable_cols
|
||||
assert rows >= REGULAR.readable_rows
|
||||
|
||||
|
||||
def test_the_session_gets_its_own_window_at_that_size(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Both halves matter, and each was a separate way to arrive somewhere too small.
|
||||
|
||||
Without ``-w new`` the session lands as a tab in whatever window is already open and
|
||||
inherits its size — measured at 64 columns on a real machine, which is why the wrong
|
||||
mark appeared. Without ``--size`` a new window takes the reader's global launch size,
|
||||
a setting about their shell rather than about this app.
|
||||
"""
|
||||
started: list[list[str]] = []
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.delenv(winterminal.REOPENED_ENV, raising=False)
|
||||
monkeypatch.setattr(winterminal.shutil, "which", lambda _: r"C:\wt.exe")
|
||||
monkeypatch.setattr(
|
||||
winterminal.subprocess, "Popen", lambda argv, **_: started.append(argv) or None
|
||||
)
|
||||
|
||||
assert winterminal.reopen() is True
|
||||
argv = started[0]
|
||||
assert argv[:3] == [r"C:\wt.exe", "-w", "new"]
|
||||
assert "--size" in argv
|
||||
assert argv[argv.index("--size") + 1] == winterminal._wanted_size()
|
||||
# `--` must still separate wt's options from MeshTerm's, or `--mock` becomes wt's.
|
||||
assert "--" in argv and argv.index("--") > argv.index("--size")
|
||||
Reference in new issue
Block a user