From 6046b4ff0e150d691d6570fc18f48d678b9601da Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sat, 19 Sep 2026 15:58:12 -0400 Subject: [PATCH] A bug report should be one page, and a page nobody has to read first A report that opens with "it crashed" costs three round trips before anyone knows which MeshTerm, on which OS, in which terminal, talking to which radio. The person who hit the bug is the one least able to answer: half of it is resolved at boot and shown on no screen -- whether icons draw, whether powerline separators do, which platform flavour resolved and why. Diagnostics states all of it at once, as a page under This app and as meshterm diagnostics. The page is a bare frame, and that is mechanical rather than aesthetic. The one thing it is for is getting its text out of the terminal and into an issue, and a terminal is selected by dragging: a border is characters on the same lines as the content, so every line of a line-wise selection arrives with a box-drawing glyph welded to each end. A header and a footer hint arrive as text that looks like part of the report to whoever reads the paste. So the page draws its rows and nothing besides, the frame the share screen already uses for the same kind of reason. Nothing in the block is private, and that has to be a property rather than a habit, because the whole premise is that it gets pasted in public by somebody who did not read it. No pairing PIN, no admin password, no channel secret, no private key, no position, no contact's name or key. The mesh arrives as aggregate -- a row count per table and the span the observations cover -- which is the half that explains a bug without naming anyone the reporter talks to. Two tests hold the line from opposite sides: one plants real secrets in the stores that hold them and asserts none came out, and one refuses the key *names* such a field would take, so a value the suite cannot plant is still caught by what it would arrive as. The radio is asked and never required. A report about a companion that will not connect is exactly the report most worth filing, so the failure becomes connected=no with the radio's own words in error, and every other fact still arrives. Table counts come off sqlite_master rather than a list written down here, so a table added later starts being reported the day it lands; the interesting count is always the table nobody expected to be full. Only overridden preferences are listed -- the defaults are in the source and identical for everyone. Both faces share one projection of a facts block, so the page cannot drift from what the command prints: a field added to the report reaches both without anyone remembering to. Only the layout differs, in one respect -- the scripted face crops a value too wide for its lane, which is right for a record somebody will split on whitespace and wrong for a page whose whole job is to be complete, so a long value hangs under its own block instead. A path also gains the lane constructor it never had; the machine face had always spelled one correctly and the plain face could not render one at all. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1041 +++------------------------- docs/cli.md | 91 ++- meshterm/core/hostinfo.py | 238 +++++++ meshterm/persistence/repository.py | 63 ++ meshterm/tools/diagnostics.py | 375 ++++++++++ meshterm/ui/diagnostics.py | 153 ++++ meshterm/ui/fields.py | 20 + meshterm/ui/renderers.py | 37 +- meshterm/ui/theme.py | 1 + tests/test_diagnostics.py | 362 ++++++++++ tests/test_gallery.py | 72 ++ tests/test_menus.py | 21 +- 12 files changed, 1521 insertions(+), 953 deletions(-) create mode 100644 meshterm/core/hostinfo.py create mode 100644 meshterm/tools/diagnostics.py create mode 100644 meshterm/ui/diagnostics.py create mode 100644 tests/test_diagnostics.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 07d022c..ed7c762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,951 +2,114 @@ Notable changes to MeshTerm, newest first. -The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the version -numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with the caveat -SemVer itself makes for a leading zero: while the major version is `0`, a **minor** bump is -allowed to change behaviour, not just add to it. - - +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the +version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with +one caveat SemVer makes for a leading zero: while the major version is still `0`, a +**minor** bump is allowed to change how things behave, not just add to them. ## [Unreleased] ### Added -- **Every release page now opens with the commands to install that release.** They are - written from the tag, so the URLs in them are the files attached to the page they are - printed on — there is no example version to mistype and nothing to go stale. The - changelog follows underneath. +- **Diagnostics — everything a bug report opens with, in one block.** A new page under + *This app*, and a new `meshterm diagnostics` command. It states which MeshTerm this is + and how it was installed, the OS and its build, the terminal and its size, every verdict + resolved once at boot and shown on no screen (icons, powerline, the platform flavour and + why), the radio and what its link is set to, and how much stored history there is. Those + are the questions an issue thread otherwise asks one at a time, and the person who hit the + bug is the one least able to answer them. -- **A version-less copy of every binary rides along with the stamped one.** - `meshterm-macos-arm64` and its four siblings are the same builds under a name that never - moves, which is what makes `releases/latest/download/…` a link that always fetches the - newest release. The version-stamped files are still there for anyone keeping more than - one build around. + - **The page is a bare frame** — no border, no header, no footer, no key hints. Not a + style choice: a terminal is selected by dragging, so every one of those is a character + the clipboard carries into the issue. A long value hangs under its own block rather than + being cropped, which is the one place the page and the command line lay the same facts + out differently. + + - **Nothing in it is private.** No pairing PIN, no admin password, no channel secret, no + private key, no position, and no contact's name or key. The mesh is described in + **aggregate** — a row count per table and the span the observations cover — which is the + half that explains a bug without naming anyone the reporter talks to. It is a property + of the feature rather than a habit: `tests/test_diagnostics.py` plants real secrets in + the stores that hold them and fails if any of them, or a field merely *named* like one, + reaches either face. + + - **The radio is asked but never required.** A report about a companion that will not + connect is exactly the report most worth filing, so a failure to reach it becomes + `connected no` with the radio's own words in `error`, and every other fact still + arrives. + + - **The table counts are discovered from the schema**, not from a list written down + somewhere, so a table added later starts being reported the day it lands — and the + interesting count is always the table nobody expected to be full. ### Changed -- **The install instructions lead with `curl` on macOS.** Downloading MeshTerm in a browser - is the one route that doesn't work on a Mac — the browser flags the file and macOS then - refuses to open it, and since Sequoia right-click → Open won't get you past it either. - That was true before and said so in a note below the commands, which is no use to - somebody who followed the commands. Now it is the macOS instruction, and it is three - lines that name no version. - -## [0.3.7] — 2026-09-19 - -### Added - -- **A setting you use on a node's command line becomes a row on that node's page.** - Third-party builds carry knobs the catalog has never heard of, and MeshTerm cannot know - which firmware a repeater runs without asking — and every question is a paced round trip, - so probing for a fork's keys would spend a sweep on every node to benefit a few. Nothing - is probed speculatively and the catalog does not grow. Instead a `get` or `set` you ran, - which that node answered, earns a row under **Extra** on that node's page: the round trip - was one you were spending anyway, and no other node's page changes. Only the reader - removes a discovered row — a later read that comes back empty takes it to `n/a` rather - than deleting the evidence that it exists. - -- **The Windows binary is signed** by SignPath Foundation, who issue certificates free to - open-source projects, so it no longer arrives as an unknown publisher. - -### Fixed - -- **MeshTerm finds your companion on a Mac.** Every Mac permanently presents two virtual - serial ports — `/dev/cu.Bluetooth-Incoming-Port` and `/dev/cu.debug-console` — that carry - no USB vendor id, and they were counted as candidates. A Mac with exactly one real board - attached therefore saw three devices and refused to choose, so automatic detection could - never fire there and every Mac user met *"Multiple companion devices detected"* on a first - run, naming two ports that are not companions as the ones to pick between. Which device is - plausible was already known, and already printed in that listing; it now decides with it. A - lone unrecognised adapter still connects, and several implausible ports no longer claim to - be companions. - -- **A PIN-protected Bluetooth companion connects on the first attempt on macOS**, instead of - reporting an error and working when you selected it again. CoreBluetooth has no pairing - API: a peripheral pairs *implicitly* when something touches a characteristic that requires - encryption, which is exactly what the companion's firmware demands — so the refusal - MeshTerm was treating as a verdict is in fact macOS *starting* to pair, and its Passkey - dialog goes up as the attempt unwinds. The connection now waits for that dialog to be - answered, bounded so a dismissed one still fails. Elsewhere a rejection keeps meaning what - it always did. - -- **The map draws its ground again on the macOS and Linux downloads.** A standalone build can - have no certificate store to consult — the default one names a path baked into the - interpreter's own build, which need not exist on a machine that never installed that - Python — so every tile fetch failed TLS verification. Because a refused fetch is - indistinguishable from empty terrain, the map drew nodes over blank ground and said nothing - about why; Windows hid it for so long because it reads the operating system's certificate - store. The fetch now falls back to the CA bundle the OS ships. Verification is never - weakened: with no bundle to be found it still verifies, and still fails. - -- **The splash wordmark draws in its own colours on macOS.** The art spells brightness the - way DOS did, with bold lifting a colour into the bright bank — true on a DOS console and on - the PicoCalc, false on macOS Terminal, where bold asks for a heavier typeface and leaves - the colour alone. The mark came out muted and its dithered gradients faded toward the wrong - end. Brightness is now emitted as the colour it means, which cannot be mistaken for a font - weight. Nothing about the art changed, and no cell moved. - -- **The device list shows the device.** The startup splash sized its PORT / ADDRESS column to - the longest target and gave the name whatever was left, which held while a Bluetooth - address was a 17-character MAC. macOS reports no MAC at all but a 36-character per-machine - UUID, and that squeezed the DEVICE column down to the width of its own heading. The name is - now sized first and shown in full; the address takes what is left and ellipsizes from the - front, keeping the end that tells two targets apart. - -### Changed - -- **The macOS download note leads with `curl`.** The flag that makes Gatekeeper refuse an - unsigned binary is set by the *browser* that downloaded it, not by macOS, so fetching it - with `curl` sidesteps the dialog entirely — and for a program whose users are already at a - terminal, that is the normal way to install it. The old advice was also going stale: - Sequoia removed the right-click → Open bypass, and since Catalina a quarantined binary - started from Terminal gets the same block rather than escaping it. - -## [0.3.6] — 2026-09-17 - -### Added - -- **A contact can be locked.** Lock contact on a contact's page keeps it from ever being - archived: the archive sweep counts it protected, and the page hides Archive until you - unlock it. Locked contacts show a padlock ahead of their node type icon in the Contacts - list — 🔒, or the console font's own padlock on the PicoCalc. The lock is kept by - MeshTerm, not the radio, and reading the device never clears it. - -- **Set clock on connect.** A new preference in the Device group sets the radio's clock from - this computer's every time MeshTerm connects to it — at launch, when you connect later, - and after a reconnect, since a dropped link is exactly when a board with no real-time - clock may have rebooted and lost the time. It is off by default, runs in the background, - and records the drift it corrected only in the log. Scripted commands never write the - clock. - -- **Two installation manuals, one per handheld.** [`docs/picocalc.md`](docs/picocalc.md) - takes a stock ClockworkPi PicoCalc from a shopping list to a Linux handheld running - MeshTerm with a LoRa radio soldered inside — the Lyra swap, writing Calculinux, the setup - script, building and flashing the XIAO firmware (the patch it still needs, explained - hunk by hunk), the wiring, and the Lyra setup. [`docs/uconsole.md`](docs/uconsole.md) - installs the SPI bridge on a uConsole with the AIO LoRa board and connects MeshTerm to - it. Both are step-by-step in plain language and self-contained, each ending on its own - list of what has only been proven on one bench; [`docs/hardware.md`](docs/hardware.md) - shrinks to the page that says which manual is yours, and the README's new - **Documentation** table points at all of it. - -- **A basemap doctor for a map that stays blank.** Every failure on the map's one network - path is logged below the default log level, so tiles that never arrive leave a blank - ground and no explanation. [`scripts/basemap-doctor.py`](scripts/README.md), run on the - machine with the problem, reports the Python MeshTerm runs under, its certificate store, - the tile source, and the terminal's colour depth, then fetches a real tile and names the - cause with its fix: a Python with an empty certificate store (a python.org install on - macOS, until `Install Certificates.command` is run), the network in between, or a - terminal at 256 colours, where the basemap's greens and blues collide. - -### Changed - -- **Purge contacts is Archive contacts**, because archiving is what it does: every contact - it takes off the device stays in MeshTerm and can be restored. The row, its screens, its - confirm button, and its progress bar all say Archive, and the row wears the 💾 that - archiving one contact already wore, not the red 🗑 a deletion gets. - -- **The archive preview shows the evidence, one kind to a column.** Its rows led on a - percentile and followed it with the reasons a contact ranked low, three columns under one - header, so a message count, an age, and a hop count traded places from row to row. Each - column now holds one measurement all the way down: `HEARD` and `PKTS` first, drawn as the - Contacts list draws them, then the `MSGS` and `HOPS` the sweep adds. The percentile is - gone, since the list's order already is the ranking. Every contact leads with its node - type icon, as in the Contacts list, and ←→ scrolls the columns under a name that stays - put. Archiving a single contact from its page names it the same way in the confirm. - -- **`scripts/` is sorted by handheld.** The PicoCalc's Calculinux bring-up, its two - console-font builds, the GPL-2.0 text that belongs beside the 6×8 one, and the - `xiao-radio/` firmware kit all live under `scripts/picocalc/`; the SPI bridge is - `scripts/uconsole/meshterm-spi-bridge`. The index of what every file does and which - machine runs it is a section of [`docs/hardware.md`](docs/hardware.md) now, beside the - table that says which manual is yours, and the folder's own README keeps only a pointer - to each handheld's folder and the basemap doctor. Every path in the manuals, the notices, - the tests, and the scripts' own headers follows. - -- **The 6×12 console-font script carries its size in its name**, - `calculinux-console-font-6x12.sh`, like its 6×8 sibling always did. Same script, every - reference updated. - -- A tool that fails says so in an amber ⚠ popup, which wraps a long explanation instead of - opening the full-screen result window for it. - -- The About page opens on a new description of what MeshTerm is and what it plugs into. - -### Fixed - -- **Reading a big contact table no longer times out.** The library under MeshTerm gave a - whole contacts read one five-second deadline, so a companion holding a few hundred - contacts failed every read with "no event received", however healthy the radio. The wait - is now for the gap between records — each record restarts it, and the read takes as long - as the table does — so a timeout means the companion really stopped answering. An error - reply meant for some other command no longer ends the read either. - -- **An emoji no longer knocks a row out of line.** Terminals disagree about how wide they - draw an emoji, and a contact's name arrives off the air carrying whatever its owner typed, - so one emoji in a name could shift every column after it, and the screen's right border, - by a cell. Every glyph that may be drawn as an emoji now gets two cells, and each is drawn - at the column MeshTerm measured for it — in lists, in dialogs, and on the screens behind - them — so one drawn narrow leaves a blank beside it rather than moving anything else, and - a repaint no longer leaves a stale letter standing next to it. A name cut to fit its - column is cut between glyphs, never through one, and a newline or an escape in a name no - longer ends its row early. `MESHTERM_COLUMN_SNAP=0` turns the placement off. - -- **Hops in near-identical colours stay separate chips.** Where a path is drawn as coloured - chips, two neighbours of the same colour cut a wedge of bare background out of the route, - and two of nearly the same colour were joined by a chevron nobody could see. Both are now - divided by a thin chevron in a shade of the first chip's own colour, so the route runs on - unbroken and the join still shows. "Nearly the same" is measured the way the eye sees - colour, in OKLab, rather than as a hue gap, which judged the greens and the cyans very - differently. - -- Walking back up a scrolled list no longer lets the highlight ride off the top of its box. - -## [0.3.5] — 2026-09-14 - -### Added - -- A release now rebuilds [meshterm.net](https://meshterm.net) from the tag, so the site - carries the version it links to. - -### Changed - -- **The purge preview counts in columns.** The target ladder used to describe each rung in a - phrase — "keeps 45 · archives 12" — so comparing two rungs meant finding the numbers inside - two sentences. Keeps and archives are right-aligned columns now under a pinned - `TARGET / KEEPS / ARCHIVES` header, one lane measured across both sections so the numbers - run straight down the ladder; a rung that archives nothing shows a muted 0. The reasons a - contact ranks low sit in three lanes of their own under one header, instead of running on - from wherever the previous reason ended. - -- The PayPal link on the Support page, and in the repository's funding file, is PayPal's own - donate address rather than a shortener redirect, so a reader can see where it goes. - -### Fixed - -- **A name with a skin tone or a cut-off joiner no longer pulls its row out of line.** Two - real contact names showed it: one carries a skin-tone modifier, which one width table - counted as two cells and the terminal draws as none, so the row reserved four cells for a - two-cell glyph; the other is a flag sequence cut at the name's byte limit just past its - joiner, which swallowed the padding space after it and left the name a cell short. A - modifier now measures zero, and a joiner joins only a pictograph. The purge preview, the - Contacts list and the chat picker all pad names through that one measurement. - -## [0.3.4] — 2026-09-13 - -### Fixed - -- **The macOS installers build again.** 0.3.3's release never published: both macOS legs - stopped at the new third-party notices step, which refuses to bundle a dependency whose - wheel carries no licence text — and on macOS bleak's Bluetooth backend pulls in two such - wheels, `pyobjc-core` and `pyobjc-framework-libdispatch`. Their MIT text is vendored - beside pyserial's and pywinrt's now, verified against the wheels themselves, and a test - checks every vendored path on every platform so a gap that only one runner can hit is - caught here first. The 0.3.3 tag stands with no release behind it; this is the same - code with its installers. - -## [0.3.3] — 2026-09-13 - -### Added - -- **`meshterm --version` answers**, and under `--mock` the `devices` command lists the - simulator as the one device and scans nothing — it used to walk the serial ports and - switch the Bluetooth radio on, on the first command a stranger without a radio would try. - -- **[`docs/hardware.md`](docs/hardware.md)** is the one manual for putting MeshTerm on a - PicoCalc or a uConsole: the Calculinux bring-up, the console font, the XIAO UART radio from - parts to a verified link, the SPI bridge, and a closing list of what has only ever been - proven on one bench. The two script READMEs are short indexes pointing at it. - -- **The standalone installers carry their own licences now.** Each one-file build - bundles `LICENSE`, `NOTICE` and a generated `THIRD-PARTY-NOTICES.txt` — every - MIT/BSD/PSF dependency's own licence text, gathered at build time from what that - build actually installed — at the root of the archive, and the same three files ride - beside the downloads on the GitHub release page. - -- **The map credits OpenStreetMap in its own bottom-right corner.** The full-screen map is - arrived at with `© OpenMapTiles · Data from OpenStreetMap` there — the corner a paper map - puts it in — and the first pan, zoom, reframe or find keystroke collapses it to - `© OpenStreetMap`, which is a complete credit in its own right, so nothing is ever taken - back. It costs no chrome at all: no title atom, no footer character, no body row. On a - desktop terminal it is set into the panel's bottom border rule, right-justified, the way - a title sits in the top one, so the drawing keeps every cell it had; on the PicoCalc, - whose frame has no bottom rule to set anything into, it rides the right end of the map's - own last row, where a pan can move the ground out from under it. The node page's location - preview carries the short form throughout, being a picture rather than a place. The - licence and its URL live where they can be read — the About page, which also now names - **OpenMapTiles**, as OpenFreeMap asks and neither the page nor the README did. - -- **Every Python file names its licence with an SPDX identifier** on its first line (second, after a shebang), and `tests/test_spdx.py` keeps a new file from landing without one. Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. - -- **The XIAO firmware patch carries MeshCore's MIT notice beside it** as `scripts/xiao-radio/LICENSE.MeshCore`, and the console-font scripts state that the shared generator block is offered under GPL-2.0-only as well as Apache-2.0, which is what lets the GPL-2.0-only 6×8 script run it. - -- **The SPI radio bridge runs on `openhop_core`.** The library the uConsole's software - node runs on was renamed from `pymc_core` in 2026, and the current `meshcore-uconsole` - package depends on the new name. `meshterm-spi-bridge` now drives either runtime, - preferring the newer, and two of its three compatibility shims stand down under it — - openhop_core accepts long ACK payloads and pushes completed trace replies itself; only - the raw RX-log push is still the bridge's. On a bookworm uConsole, where the trixie - package cannot run, the bridge takes a venv of its own with `openhop-core[hardware]` - and still uses the GUI's identity, so it stays the same node. Verified on the device: - radio up, 159 contacts restored, `info` and `contacts` answered over TCP. - -- **The PicoCalc picks its console font, and MeshTerm hands the console back.** A *Console - font* row under Display on the Preferences page offers `6x12 (53x26)` — the font the - device boots in — and `6x8 (53x40)`, fourteen more rows of the same glyph inventory in a - shorter cell. Picking one is the preview: the console loads it there and then, and the - page repaints at the new height (the kernel raises SIGWINCH on a font change), so the - choice is looked at before it is kept. Apply keeps it; leaving and discarding puts the - saved font back. The 6×8 base has no Cyrillic, so a node named in Cyrillic draws as - boxes on it. - - A virtual terminal has one font for the whole console, so switching MeshTerm's switches - the shell's. It saves whatever font was loaded before the first paint and reloads it on - the way out — from the session's teardown and from an `atexit` hook, so quitting, an - unwind and a crash all leave the console as they found it. Off the handheld, off a real - VT, or with no `setfont`, none of it happens and the log says which. The row is drawn on - the PicoCalc only, while the value round-trips through `preferences.toml` everywhere. - - The 6×8 font's build moved into `scripts/calculinux-console-font-6x8.sh`, on its own - because its base bitmap is the Linux kernel's `font_6x8` and therefore GPL-2.0 — marked - as such, with the licence beside it, and reading the donor, alias and keeper tables out - of the 6×12 script rather than keeping a second copy of them. Running the 6×12 script - still builds both. - -### Changed - -- **Preferences are kept in `preferences.toml`.** The same file as before — only what you - have changed, grouped and commented the way the Preferences page reads — in the format - `config.toml` and the device-config backups already use, so every file you edit by hand - speaks one syntax and MeshTerm no longer needs PyYAML. A `preferences.yaml` from an - earlier build is not read: set those preferences again, or copy the lines across as - `key = value` with text in quotes. A line TOML refuses costs only that line, and a word - left unquoted (`log_level = DEBUG`) is still understood. - -- **A trophy case record is a page, not a card floating over the list.** It fills the frame - like the browser under it and takes the node page's shape: a header line that reads as a - sentence — the discipline's mark, its standing and its score — over three tabs. *Info* is - the record's stats, identity first, with the page's actions at its foot; *Route* is the walk - two ways, the route graph and the route line, where Enter traces the path on show; *Area* - is the ground the walk covered, drawn across the whole stage with every pin labelled by its - hash byte so a pin and the graph node under it match by eye. Tab and Shift+Tab switch, F3 on - the PicoCalc. The trophy case itself fills the frame whichever way it is opened, from the - menu or from a trace. - -- **A QR code is white on black, and when it is the answer it is the whole screen.** A camera - reads contrast, and light-on-dark is what a scanner expects of a screen, so every code - MeshTerm draws is pure white on pure black on both themes. A channel's share link and a - contact card are no longer a titled popup with an instruction over the code: they are a - bare frame with nothing on it but the code and the URL it encodes, and Esc leaves. - -- **A question is a popup, and it is gone before the page it asked for opens.** The trace - target, the repeater admin's node and the TX optimize link used to stay pushed as full - screens under the page they led to, so Esc out of a trace or an admin page landed back on - the list it was picked from — two places where there is one. Each now floats over the main - menu, is popped before its page opens, and Esc from the page lands on the menu. The TX - optimize link is one box that turns its page (`… · step 1 of 2`): Esc on the second step - turns back with the picked node still highlighted. A refused admin login re-asks with the - same node highlighted. - -- The three delay settings — TX delay, direct TX delay and RX delay — show, stage and send at - one decimal on both the repeater admin and Device config pages; Device config's RX delay - had no rounding at all. - -- The source distribution ships the package, its tests and the manual, not - the repository: the Claude skills, the GitHub config, the PyInstaller spec and the device - scripts are out of it. - -### Fixed - -- **A share code's bottom-left finder was one cell skewed**, so a camera could not lock on. - Rich's centre justify strips a row's trailing spaces before padding it, so a code row whose - right edge was light modules landed a column off its neighbours. A code is now moved across - a line one way only, the same indent in front of every row. - -## [0.3.2] — 2026-09-11 - -### Added - -- **Device config holds every setting the companion firmware has, and the device actions - with them.** Clock sync, backup and restore, the identity key, reboot and factory reset - were a menu entry of their own; they are an Actions section on the Device config page - now, and the page takes the repeater admin page's shape — full-screen, titled with the - node's name and a staged count, with Apply sending in place one value at a time and - leaving a value the device refused staged beside its reason. - - The settings were checked against the firmware itself rather than the library that - talks to it. New are client repeat, the auto-add hop limit and a row for each custom - variable the device reports, and the firmware's own bounds apply: a PIN of 0 or six - digits, path-hash modes 0–2, 150–2500 MHz, 7–500 kHz, TX power from −9 dBm. - -- **Both editor pages pick a location on the map.** A "Pick location on map…" row sits - above separate latitude and longitude rows, opens the map on the position as staged — or - as the device last reported it — and stages both coordinates at once. Device config's - single Location row and its Pick / Type / Clear dialog are gone. - -### Changed - -- **Repeater admin speaks the firmware's own CLI, every setting of it.** The catalog had - been written from convention, and the firmware disagreed: the delays are floats (0.5 - showed as 0, and typing it back was refused), bandwidth, spreading factor and coding rate - are one `radio` setting rather than three keys, the guest password is readable, and - `path.hash.mode` and twenty-odd others were missing. It is transcribed from the firmware - source now. Read settings leaves every row answered — a value, `empty`, or `n/a` where - the node's firmware lacks the setting — and `^R` (F3 on the PicoCalc) re-reads the - highlighted row alone. - -- Repeater admin fills the frame like Device config, instead of floating over the node - picker it was opened from. - -- **A packet's relay chain is drawn as the middle of a route.** The `via` field names the - repeaters that forwarded a frame and neither the node that sent it nor the one that - heard it, yet its ribbon opened and closed square — the mark for *the route began and - ended here*. Both ends wear the chevron now, as the TX sweep's composed relays do, and - arrow mode says the same with a leading and a trailing `→`. Under the chain, the note - about which node the reception described gives way to the hop count. - -### Fixed - -- **Retuning a companion's radio switched off its relaying.** The firmware reads the radio - command's trailing client-repeat byte as *off* when it is missing, and nothing sent it. - Every radio edit restates it now. - -- The location picker opens at once. It waited on the radio for the contact list first, - and a busy companion held it shut for twenty-odd seconds only to open with no contacts - anyway; it opens on what is cached now and adds the rest when the radio answers. - -- A repeater setting its firmware lacks reads `n/a`, rather than the `?` that says it was - never asked. - -- Every icon-led list starts its labels in the same cell. Some icons draw one cell wide and - most emoji two, so a row led by `↻`, `⌨` or `🗑` started its words a column early; each - list pads its icons out to the widest one now. - -- Neither editor page slides its rows sideways any more — a long row ends at the edge. - -## [0.3.1] — 2026-09-09 - -### Added - -- **Devices you never want to connect to can be hidden from the splash.** A machine with a - debug probe, a programmer and a USB adapter soldered into it listed all three on every - start, in front of the one radio you came for. `h` drops the highlighted device and - remembers it, `⇧H` brings every hidden one back, and both keys are named in the footer - only where they would act. - - Hiding is a listing choice and nothing else — a hidden device is still remembered, still - resolvable by `--port`, and shows itself again the moment it is connected to. The - highlight stays where the row was rather than jumping back to the remembered default, so - a run of adapters clears with a run of presses. - -### Changed - -- **The chat picker's conversation lane is measured against the names the list actually - holds**, instead of a flat 22 cells. Every cell past the longest name was padding in - front of a short one, taken straight out of the last-message preview — the only run on - the row with something to say, and on the PicoCalc's 53 columns it left the message 12 - cells. The preview keeps what it saves: 31 cells to 37 on a 72-column terminal, 12 to 18 - on the console. What the cap costs is the tail of a long name, so `←→` now slides the - message under the pinned columns to read one past its own edge. - -- **Channels shows the wait instead of announcing the result.** Every action banked a - "created X" note that surfaced only once the manager closed — by which time the refreshed - list had already shown the result — and the notes accumulated: at three changes in one - visit the outcome outgrew the acknowledgement popup and came back as the full-frame - result window instead, so the same visit reported itself two different ways depending on - how much had been done in it. Every device action now reports while it runs, under a - modal busy card, and the count stays in the run log. - -- Renaming a channel no longer closes the page it renamed. The channel is still there and - that is still its page, so the new name and key are read back into the title and the - rows, and the reader stays put. Only clearing it closes it, which is the one case where - what the page was about is gone. - -- `Esc` on the device splash says **bye** rather than quit — the splash is the door, and - nothing has been started there to quit out of. - -### Fixed - -- **macOS and Linux were drawing the whole app in 256 colours.** prompt_toolkit picks the - colour depth per output class, and its two classes disagree: the Windows one returns - truecolor outright, the VT100 one returns 8-bit for every `TERM` but `linux`. Nothing - ever passed a depth, so the same build drawing the same theme was 24-bit on Windows and - quantized to the 216-colour cube everywhere else — silently, which is why it went - unnoticed. The app looked fine, just flatter. - - It costs exactly what this palette is made of. The seven heat steps and the per-node hue - wheel are close pastels chosen to be told apart; snapped to the cube, neighbours collide - and an identity stops being distinguishable by hue. `COLORTERM` is not consulted by - prompt_toolkit at all, so a terminal announcing truecolor the conventional way was still - handed 256. - - The resolver only ever raises the verdict. A terminal that cannot be shown to do better - keeps precisely the depth it had, because guessing 24-bit at a terminal without it costs - not a duller palette but the colour entirely. A 16-slot console is never promoted — the - theme addresses its palette by index, and RGB has nowhere to land there. - -- **Adding a channel could write straight over one that was already there.** The free-slot - probe stops for two very different reasons and returned the same short list either way: - off the end of the configured slots it is the truth, but a read that simply failed left - whatever it had — and that was cached for the session, an empty one reading exactly like - a device with no channels, a truncated one making the next free slot look free with a - channel sitting in it. The probe now says whether it finished, an unfinished one is - answered but not kept, and every add confirms the slot is empty before anything is - written to it. - -- Keys tapped during a slow channel write were landing in the filter of the list - underneath, which then came back showing nothing. The busy card is modal, so the press - dies on the card instead. - -- **Channels at 53 columns.** The column header was a hand-built string with no idea of the - render width: it ran to 54 cells and wrapped, costing a content row out of twenty-six and - drawing the pinned landmark twice. The command rows hand-padded their icons, so the - one-cell marks started their labels a column left of the two-cell ones. The empty-message - count was `·`, which in that same row is also the status separator and what the console - folds 🔕 to — a muted channel with no messages drew it twice, two cells apart, meaning - different things; it is `○` now, the app's empty mark. And the detail title was a 42-cell - parenthesised blob that filled the PicoCalc's whole title bar, leaving nowhere to say Esc - leaves. - -- Every channel edit paid up to 64 device round-trips before the screen could redraw. The - chat service's slot map was walking the radio itself, unbounded, skipping empty slots all - the way to the 64-slot cap on firmware that never rejects an index — on top of the - manager's own re-read. It builds from the probe that was being made anyway. - -- A channel write that landed and then raised never invalidated what it had made stale, so - a slot list and a chat slot map outlived a layout that had already moved. A reorder that - drops partway now says so at once, since the reader is about to be looking straight at - the half-applied layout, and the standard Public channel can no longer be added twice by - a second press already on its way. - -- Two of the Trophy case's seven discipline marks started their titles a column left of - their siblings: 🛣 and 🕸 sit outside Emoji_Presentation, so Rich and wcwidth both measure - them at one cell where the other five measure two. The marks now pad out to the widest of - the seven, measured against what the platform will actually draw rather than written - down. - -- A route that has finished no longer ends on the chevron that means "the path runs on past - the edge" — it ends square, and the cell that frees up goes back to the route. - -- **A fully charged pack redrew the activity sparkline twice a minute.** The charger cuts - out at full, the pack settles back to 99, the charger restarts, and the companion reports - that flip every poll for as long as the thing is plugged in. The gauge is pinned to the - header's right edge and the pulse takes whatever is left, so the fourth cell `100%` - needed came off the sparkline and redrew the whole activity history at a new scale, on a - device sitting still. A full pack reads `100` now, with no sign: three cells, exactly as - the `99%` it keeps flipping back to, and the one reading whose `%` can be inferred. - -- On the PicoCalc every channel row in the chat picker sat a cell to the left of every - direct row, with the header over neither — the marker lane padded to a two-cell channel - glyph that the console font draws in one. - -- The device splash's own header wrapped at 53 columns, and its DEVICE lane sized itself to - the longest name — which for a USB adapter is a forty-character product string, pushing - HARDWARE off the edge entirely. The name lane yields now, and `←→` reads the rest of a - long model string with the lanes in front of it pinned. - -## [0.3.0] — 2026-09-08 - -The command line was rebuilt around the fact that it has **two readers**, and that trying -to serve both with one stream had been making it worse at each. - -### Added - -- **`--json`, on every command.** It used to be honoured by two of twenty and silently - ignored by the rest, so `meshterm --json contacts | jq` failed while MeshTerm reported - success. It now covers all 49 subcommands: an array for a listing, an object for a set of - facts, one compact line per document, and no envelope — `contacts --json | jq '.[].node.name'` - reads the way it looks. - - Values are typed (`20`, not `"20"`), absent is `null` and never an omitted key, - timestamps are UTC RFC 3339 to the second so string comparison is time comparison, and a - numeric field carries its unit in its key (`snr_db`, `uptime_s`, `rtt_ms`). A key is never - truncated — a short id is a `hash`, because a truncated key cannot go back into `--to`. - `monitor` and `chat listen` emit one document per record as they arrive, all the same - shape. - - `--json` changes the rendering and never the report: the same exit status, the same - records. An empty result prints its empty document **and still exits `5`**. - -- **A documented exit status per outcome**, listed under every `--help`: `0` success, `1` - failure, `2` usage error, `3` no device, `4` the device was reached but the operation - failed, `5` nothing to report. - - `5` is the one worth knowing about. A command that ran fine and found nothing — an empty - contact list, a trace that never came home — returns it, so a script can tell "found - nothing" from "worked" without counting lines. - -- **`--absolute`**, restoring ISO-8601 timestamps for a run, over a new `cli_time_format` - preference. JSON is always absolute UTC regardless: a local offset is a fact about the - machine that ran the command, not about the event. - -- **A global option may be typed anywhere.** `meshterm contacts --json` and - `meshterm --json contacts` are the same run. The first is what everyone types and it used - to fail with a bare usage error. - -- **`contacts` gains `HASH` and `LOCATION`.** The hash is the token `--path` takes; you used - to slice it out of `KEY` by hand. `preferences show` gains `DESCRIPTION` back, and `info` - glosses its second-valued readings (`uptime_s 93784 (1d 2h)`). - -- **[`docs/cli.md`](docs/cli.md)**, a manual for the whole thing: both faces of every - command, where MeshTerm keeps its state, what each failure looks like, and recipes. Every - sample in it is captured from a real run rather than written. - -### Changed - -- **The plain face is for a person now, and says so.** It had grown the menu's manners — - colour, boxes, section headings, glyph columns, a closing ✓ — and then briefly overcorrected - into something only `awk` could love. Machine-readability is `--json`'s job, so the plain - face is free to be read: no colour and no frames, still, but **alignment is the delimiter** - and names are bare rather than `"quoted"`. - -- **A time is an age** — `now`, `5m`, `3h`, `never` — because "recently?" is the question you - typed the command to ask. An absolute instant survives where the instant *is* the fact: the - device clock, an appointment, a live capture's own `TIME`. - -- **A route is drawn and a path is typed.** A route reads - `Yagi-Repeater (a1) → Alice (d4) → MockCompanion (00)`; a `--path` spec stays the - comma-joined hex it has to be, because it is the one line that round-trips. Our own node is - a hop like any other — the menu's `★` says "you already know who this is", which is true of - the reader and false of whoever opens the file later. - -- **A value read out of `show` can be typed back into `set`.** The settings dump printed an - enum as `0 (off)` and an unset string as `(not set)`, neither of which `config set` would - take back. - -- **`ui.ack` and a tool's closing message go to stderr** rather than being dropped. stderr - keeps the promise the dropping was made to keep — a redirect catches only the answer — - while giving the person at the prompt back their ✓ and their count. - -- **`--help` is Click's own plain help**, with no boxed panels and no markup to strip out of - a pipe. - -### Fixed - -- **A node name could not be printed safely.** A node broadcasts its own name, and Rich read - `[...]` in one as console markup: a node called `[bold]Loud` printed as `Loud` — silently - no longer the string that identifies it — and one called `[/]Bob` raised `MarkupError` and - took the command down, then took the error handler down with it while reporting the crash. - -- **A control character in a name reached stdout.** Escaping covered the newline, the tab and - the return and let **ESC** through, which put a live colour run into the caller's file — the - one thing "no escape sequences" exists to prevent. BEL was dropped in silence, so the - printed name was not the advertised one, and U+2028 ended the record like a newline. It is - now every C0 and C1 control plus the Unicode separators. - -- **Numeric columns never right-aligned.** Rich's `Text.wrap` returns early on - `overflow="ignore"`, above the justify step, so asking for one threw the other away — while - the standard, the code's own docstring and the manual all promised alignment. - -- **`config show` printed an empty string as `""`**, which `config set` read as those two - characters. Feeding a dump back replaced every empty setting with a pair of quote marks, and - the next dump looked identical, so nothing ever said so. It also masked an **unreported** - PIN behind bullets, telling the reader the radio was PIN-locked when it was not. - -- **`meshterm about` drew the rule above its colophon at console width** — one 48KB line in a - 4KB page. - -- **`config export-key > key.hex`** wrote a file with `[muted]` tags around the key that was - supposed to be its whole content. - -- **Failures were classified wrong in six places.** Nothing transmitted is not a device - failure: an unopenable `--port` is `3` rather than `4` with a message about a connection - there had never been, and a missing `--yes`, an unknown `--sort`/`--category`/`--profile`, - a missing admin password, an unknown contact and a bad `--at` are all usage errors. A - `--category` typo used to come back as `5` — indistinguishable from a real empty result — - and an unknown `--profile` fell through to whichever radio was attached, which is the one - wrong answer that puts a packet on the air. - -- **Errors go to stderr**, in the `meshterm: what went wrong` shape, and stop folding at 80 - columns — a sentence broken across three lines is a sentence `grep` cannot find. Progress - bars moved there too and draw nothing off a terminal. - -- **An expected failure no longer dumps a traceback.** A lost serial link, or an argument a - command rejected on its own, reads as one line and the right exit status. - -### Removed - -- **`meshterm map`.** A map is a picture — braille cells whose meaning is their position, and - whose nodes are told apart by colour. Stripped of colour it would be unreadable; left - coloured it was the one command whose output could not be piped anywhere useful. The map - stays in the menu, and the located nodes are scriptable through `contacts`, which now - carries their coordinates. - -- **The QR codes** from `channels share`, `config share` and the About pages' scripted face. - A QR is a second rendering of a link the output already prints, drawn for a phone pointed at - a screen; redirected into a file it was a block of block characters wrapped around the one - thing that was actually the answer. The menu still draws them. - -## [0.2.8] — 2026-09-07 - -### Fixed - -- **The window MeshTerm opened for itself was too small, and it showed.** The startup - wordmark is 71 columns wide; below that the splash quietly swaps to the narrow one drawn - for the PicoCalc — so a desktop session came up wearing the handheld's mark, with every - menu description truncated onto the pager underneath it. - - MeshTerm was opening a *tab* in whatever Windows Terminal window happened to be around, - inheriting its size (64 columns on the machine this was found on). It now asks for a - window of its own, sized from the platform's own minimum plus a margin. Windows Terminal - clamps that to what the display can show, so a small screen degrades exactly as before. - -- The tab it opens is titled **MeshTerm**, not the executable's full path — which on a - downloaded build was a line of Downloads folder. - -## [0.2.7] — 2026-09-07 - -### Changed - -- **MeshTerm moves to Windows Terminal rather than asking to.** It still says so, and - still tells you how to stop it, but it doesn't put the question. The classic console - can't draw a single icon whatever font it's given, so there was only one sensible - answer — and it was being asked of someone who hadn't seen the app yet and had nothing - to judge it by. - - Installing a **font** still asks, because that writes a file to your machine. Moving - window doesn't. Preferences → Display → Console setup stops both. - -## [0.2.6] — 2026-09-07 - -### Fixed - -- **Accepting "reopen in Windows Terminal" killed the session** in the downloadable - builds. A new window opened, printed `Security validation failure: parent process has - different executable!`, and exited — while the window that made the offer had already - closed. Worse than never offering. - - A one-file build unpacks itself and re-runs itself, the two halves coordinating through - private environment variables. Those were being handed to the new copy, which concluded - it was the second half of a launch it never made, checked that its parent was the same - program, found Windows Terminal instead, and stopped. They are stripped now. - - It worked perfectly when run from source, which is the whole lesson: the packaged build - is its own platform. - -## [0.2.5] — 2026-09-07 - -### Fixed - -- **Every icon was a box in the classic Windows console.** That console — which is what a - double-clicked build still gets — draws no emoji at all, whatever font it is set to, so - the menu came up as rows of replacement characters. - - MeshTerm now works out which console it is running in and, on that one, draws its icons - through the compact single-character table it already keeps for the PicoCalc: `☼` for an - advert, `¶` for a message, `★` for the trophy case. Nothing else about the desktop - layout changes — same width, same colours, same accented names. Running in Windows - Terminal or VS Code's terminal keeps the emoji, and `MESHTERM_EMOJI=1` or `=0` forces - the matter either way. `meshterm platform` reports which it chose and why. - -### Added - -- **MeshTerm offers to reopen itself in Windows Terminal.** The classic Windows console — - what you get from a double-click — can't show the emoji icons at all, whatever font it - is given: the only two Windows fonts with emoji in them are proportional, and a console - won't take one. A terminal is the only thing that fixes it, so MeshTerm now offers to - move there. One keypress, nothing installed, nothing changed — and you get the app as - it was drawn: icons, charts, path chips and all. - -- **MeshTerm can install a font its charts will actually draw in** — for anyone who stays - in the classic console. That console does no font fallback at all, and none of the fonts - it offers can draw braille, which every timeline and activity graph in MeshTerm is made - of. Neither can Hack Nerd Font, JetBrains Mono, Fira Code, Source Code Pro, or even - DejaVu Sans Mono; the usual advice works only because most terminals quietly fall back - to a proportional font. - - So MeshTerm now ships Cascadia Mono PL — Microsoft's console font, SIL OFL — and on - that console offers to use it. If you already have Cascadia (every Windows 11 machine, - and anyone with Windows Terminal) it just switches to it. If not, it offers to install - it for you: no administrator rights, nothing downloaded, 723 KB. Saying no explains - what saying no looks like and asks once more; a second no is remembered and never - raised again. Preferences → Display → Console setup changes your mind about either - offer. - -## [0.2.4] — 2026-09-07 - -### Fixed - -- **MeshTerm crashed on startup on Windows.** Building the screen raised - `ctypes.ArgumentError: expected LP__CSBI instance instead of pointer to - CONSOLE_SCREEN_BUFFER_INFO`, and on a double-clicked build the traceback and the window - it was printed in were destroyed together. - - `ctypes.windll.kernel32` is a cache: every caller in a process gets the same object, and - each function on it is a shared attribute. The emoji-width probe declared - `GetConsoleScreenBufferInfo` as taking a pointer to *its* copy of the screen-buffer - struct — which rewrote the signature for prompt_toolkit, which then called that same - function with a pointer to *its* copy. Same layout, different class; ctypes refused. - - Nothing in MeshTerm reaches for `ctypes.windll` any more. Five call sites now take - private handles from `core/win32dll.py`, so a signature declared for one purpose is - invisible to everything else in the process. - -### Changed - -- The log-detail preference offers the plain level names — Error, Warning, Info, Debug — - rather than descriptions of them. Someone being talked through a problem is told "set it - to debug", not "set it to everything". - -## [0.2.3] — 2026-09-07 - -### Changed - -- The log is a plain text file, `~/.meshterm/meshterm.log`, one readable line per record - with the traceback under the line that raised it. It was JSON Lines on the theory that - something would replay it; nothing ever did, and the job it actually has is being opened - by a person who has hit a problem. -- It rotates, at 2 MB across three files. It used to grow without limit — a real one - reached 11 MB in ten weeks, which is a lot of disk on a handheld and an unreasonable - thing to attach to an issue. -- **How much it keeps is a preference** — `log_level`, under a new Diagnostics group, - defaulting to `WARNING`. The file now holds the problems rather than a narration of a - working session: of the last twenty thousand records in a real log, twelve were a - warning or an error. - -### Added - -- One interactive MeshTerm per data directory. A second one is turned away with the way - out in the message, rather than quietly overwriting the first one's contacts and - settings. The lock is held by the operating system, so a crash releases it — a process - id in a file would leave a lock nobody could explain. -- Failures are written down. Every unhandled error reaches the log with its traceback and - the terminal says where to find it; expected errors go in as warnings, because a log - that only holds crashes cannot answer "what happened just before". The bug report form - asks for the file. -- The window stays open after a crash when MeshTerm owns it. Double-clicking the - executable on Windows gives the process its own console, which is destroyed the instant - the process ends — so an error was drawn and erased together and all anyone saw was a - flash. It now waits for a keypress, but only on that path: a run from a shell leaves its - output on screen already, and a successful run should not make anyone press a key. -- A readable answer when there is no Windows console. Git Bash, MSYS and Cygwin are not - Windows consoles, and prompt_toolkit's own error says so in the language of its - internals, arriving as a traceback that reads like a broken app rather than the - instruction it is. - -### Fixed - -- All ten stores write through one helper, and the file each writes to first is named - for the process writing it. They shared a fixed `.tmp` name, so two copies of MeshTerm - saving at the same moment took turns inside one scratch file and then each renamed - whatever was in it over the real one. The rename was always atomic; what was being - renamed was not. - -## [0.2.2] — 2026-09-07 - -### Fixed - -- **The downloadable builds were missing every tool.** They started, printed help and drew - the specimen card, and had a menu with nothing in it but Quit — no contacts, no map, no - trace, no chat. Tools are found at runtime with `pkgutil.iter_modules`, which finds - nothing inside a frozen bundle, so none of them registered. The packaging now collects - them, and the smoke tests run a tool-provided subcommand instead of only `--help`, which - a gutted build passes perfectly well. - -### Added - -- `MESHTERM_HOME` points config and data somewhere other than `~/.meshterm`. Every copy of - MeshTerm shared one directory, so trying a downloaded build meant letting it open the - same history database as the one you use — the valuable half of an install, with no way - to keep a second copy away from it. Also useful for a portable install, or two radios - kept apart. - -## [0.2.1] — 2026-09-07 - -### Fixed - -- The formatter is pinned. `ruff>=0.4` meant CI installed a newer ruff than a laptop had, - and the two disagreed about whether the tree was clean — 0.16 formats Python blocks - inside Markdown and 0.15 doesn't, so the first CI run went red on a file that was fine - locally. An unpinned formatter isn't a gate. -- `docs/` is out of ruff's reach. The survey documents quote code as it looked on a past - date, in fragments that were never whole programs; a formatter rewriting a quotation - edits the record. -- The installer builds only run for `main`. Every Dependabot branch that bumped an action - touched the workflow file, matched its path filter, and started a five-platform build. - -### Changed - -- The distribution is named `mesh-term`. PyPI prohibits `meshterm` (pypi/support#12162), - and the name you install is the only thing that changes: the import package and the - command are both still `meshterm`. -- `pyproject.toml` carries the metadata a stranger sees on a package page — project URLs, - trove classifiers, and the licence as a PEP 639 expression. - -### Added - -- Continuous integration. Lint once, tests across seven OS and Python combinations, and a - packaging job that installs the built wheel into a clean environment and runs the command - from outside the source tree. -- A release workflow with no automatic trigger, which publishes nothing until a human runs - it on purpose and types the version. -- A security policy, a code of conduct, issue and pull request templates, weekly - dependency updates, and the funding links the app's own support page already offers. -- A Releases page. Pushing a version tag builds the binaries and attaches them, with a - combined `SHA256SUMS` and release notes taken from this file. It stays inside GitHub - and is not the PyPI publish, which remains a separate and deliberate act. -- Standalone builds. A PyInstaller spec and a workflow that builds a single runnable - file for Windows, macOS (Intel and Apple silicon) and Linux (x64 and ARM64), checks - each one runs from outside the source tree, and writes a checksum beside it. Nothing - is published; the files are workflow artifacts until someone attaches them to a - release. - -## [0.2.0] — 2026-09-07 - -The first version to carry a changelog. Everything before it is in the commit log and is -deliberately not reconstructed here. - -### Added - -- This file. - -### Changed - -- The project description now says what MeshTerm is, how it reaches a radio, and which - systems it runs on, instead of naming a category. It is the PyPI summary and the package - docstring, which no longer disagree. -- The version is written in exactly one place, `meshterm/__init__.py`. The distribution - metadata reads it from there rather than repeating it, so the number on the splash screen - and the number in a built wheel cannot drift apart. -- The basemap user agent identifies the real repository and reports the running version. - It previously sent a bare `https://github.com/` and a hardcoded `0.1` to OpenFreeMap, - which is the only handle a tile operator has on a client. -- `macOS` and `Bluetooth` are spelled one way across the project. `BLE` stays where it is - an identifier — the transport name, the config keys, the CLI. -- `meshterm --help` opens with the same description as the package summary and the - PyPI page, instead of a fourth wording of its own. - -### Fixed - -- The three commands `CONTRIBUTING.md` asks contributors to run all pass: - `python -m pytest -q`, `ruff check .`, and `ruff format --check .`. The lint gate had been - reporting 2222 errors and the format gate 154 files, so a first contributor could not tell - their own diff from the background. -- Two `TYPE_CHECKING` imports the test suite referenced but never imported, on paths that - happened never to run. - -[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.7...HEAD -[0.3.7]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.7 -[0.3.6]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.6 -[0.3.5]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.5 -[0.3.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.4 -[0.3.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.3 -[0.3.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.2 -[0.3.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.1 -[0.3.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.0 -[0.2.8]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.8 -[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 -[0.2.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.1 -[0.2.0]: https://github.com/jpmartineau/MeshTerm/tree/v0.2.0 +- `--json` and the plain face grew a shared projection for a set of facts + (`ui/renderers.facts_pairs`), so the Diagnostics page cannot drift from what + `meshterm diagnostics` prints: a field added to the report reaches both without anyone + remembering to. A path also gained the lane constructor it never had + (`ui/fields.path`) — the machine face had always spelled one correctly and the plain + face could not render one at all. + +## [0.9.0] — 2026-09-22 + +**The first public release.** Everything in MeshTerm is new today, so instead of a list of +changes, here is what it does. + +MeshTerm is a program you run in a terminal to work with a **MeshCore** radio — one of the +small LoRa boards people use to build long-range mesh networks. A mesh like that needs no +internet, no phone signal, and nobody in the middle: the radios pass messages along to each +other until they arrive. You plug one into your computer, start MeshTerm, and it shows you +what the mesh is doing. + +### What you can do with it + +- **Run it two ways, and it behaves the same either way.** Type `meshterm` on its own and + you get a full-screen menu you drive with the arrow keys. Type a command instead — + `meshterm contacts`, `meshterm map` — and it prints an answer and exits, which is what you + want in a script. Add `--json` to any command and the answer comes back as data for + another program to read. + +- **It listens all the time, and writes down what it hears.** Radios on a mesh announce + themselves. From the moment yours is connected, MeshTerm notes down every announcement it + overhears: which radio it was, how strong the signal was, and where it said it was. All of + it is saved on your own computer. That is why the lists and charts can show you not only + what is happening now, but what was happening last Tuesday. + +- **You can see where everyone is.** There is a street map, drawn in the terminal, with the + radios on it. You can pan it, zoom it, and search it. There is also a Time Machine that + winds the map back so you can watch the mesh change over days. + +- **You can talk to people.** Group channels and one-to-one messages, both live. If someone + is out of range right now, the courier holds your message and delivers it when they come + back. Channels can be shared as a QR code someone else scans, or as a link. + +- **You can measure the mesh, not just guess at it.** Trace a message's route and see which + hop is the weak one. Sweep your transmit power from coarse to fine to find the lowest + setting that still gets through. Walk a map of how the whole mesh actually hangs together, + which is often not how you thought it did. + +- **It talks to your radio however your radio talks.** USB cable, Bluetooth, or over the + network. It finds the ones it can find on its own and asks you to pick. If a connection + drops it reconnects by itself. No radio yet? `meshterm --mock` runs the whole program + against a simulated mesh. + +- **It runs on the small machines too.** There is a second layout built for handheld + consoles — the PicoCalc's 53-column screen and the uConsole — with its own fonts, colours + and on-screen key labels, not just the desktop screen squeezed. + +- **It works with no internet.** Map tiles are kept on disk once fetched, and when there is + no network at all the map falls back to a plain grid. Nothing about MeshTerm needs to + phone home. + +### Getting it + +One file to download, with nothing else to install, for Windows, macOS (both Intel and +Apple silicon) and Linux (including 64-bit ARM, so handhelds are covered). If you would +rather use Python, `pipx install` works too. The README has the exact commands. + +MeshTerm is free and open source under the Apache 2.0 licence. The name and the logo are +not — see `NOTICE` — so a fork is welcome, under its own name. + +[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.9.0...HEAD +[0.9.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.9.0 diff --git a/docs/cli.md b/docs/cli.md index 2401780..f0c4272 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1476,9 +1476,98 @@ bothers to draw a QR of. `text` is the page's plain rendering, not a fold of the into nested JSON — a structured markdown tree would be a second rendering rather than data, and nothing would consume it. +#### `meshterm diagnostics` + +Everything a bug report opens with, in one block: which MeshTerm, on which host, in which +terminal, talking to which radio, over how much stored history. It is the answer to the +three or four questions that otherwise get asked one at a time in an issue thread, and the +person who hit the bug is usually the one least able to answer them — half of it is +resolved at boot and shown on no screen. + +```console +$ meshterm diagnostics +meshterm 0.3.7 +install frozen + +os Windows 11 +os_build 10.0.26100 +arch AMD64 +python 3.13.1 +terminal Windows Terminal +term - +colorterm truecolor +terminal_size 120x30 +over_ssh no +platform regular +icons yes +icons_source windows-terminal +font Cascadia Mono PL (windows-terminal) +powerline full (font:windows-terminal) + +connected yes +transport ble +port - +device_role client +device_model Heltec V3 +firmware v1.7.1 1c3f9a2 +radio_freq_mhz 906.8750 +radio_bw_khz 250.00 +radio_sf 10 +radio_cr 5 +tx_power_dbm 20 +error - + +config_dir C:\Users\jp\.meshterm +db_size_kb 41280 +log_level WARNING +log_size_kb 96 +runs_failed 3 +first_heard 8mo +last_heard now + +TABLE ROWS +observations 418203 +runs 912 +traces 47 +... + +PREFERENCE VALUE +log_level DEBUG +map_style terrain +``` + +**The radio is asked but never required.** A report about a companion that will not connect +is exactly the report most worth filing, so a failure to reach it becomes `connected no` +with the radio's own words in `error`, and every other fact still arrives. + +**Only overridden preferences are listed.** The defaults are in the source and identical for +everyone; the two the reporter changed are the two that can explain anything. + +**The mesh is described in aggregate and only in aggregate** — a row count per table, and +the span of time the observations cover. The counts come off the schema rather than a list +written down somewhere, so a table added later starts being reported the day it lands. + +**Nothing in the block is private.** No pairing PIN, no admin password, no channel secret, +no private key, no position, and no contact's name or key. That is a property of the +feature and not a habit: the block is designed to be pasted in public by somebody who has +not read it, and `tests/test_diagnostics.py` plants real secrets in every store that holds +one and fails if any of them — or a field merely *named* like one — reaches either face. + +Under `--json`, the three fact blocks merge into one flat object and the two listings keep +a key of their own: + +```json +{"meshterm":"0.3.7","install":"frozen","os":"Windows 11","…":"…","tables":[{"table":"observations","rows":418203}],"preferences":[{"preference":"log_level","value":"DEBUG"}]} +``` + +In the menu this is the **Diagnostics** page under *This app*, drawn on a bare frame — no +border, no header, no footer, no key hints — because a terminal is selected by dragging and +every one of those is a character the clipboard would carry into the issue. + #### `meshterm platform` -A diagnostic for which UI flavour a given invocation resolves to, and why. +The narrow sibling of `diagnostics`: which UI flavour a given invocation resolves to, and +why, without asking the radio anything. ```console $ meshterm platform diff --git a/meshterm/core/hostinfo.py b/meshterm/core/hostinfo.py new file mode 100644 index 0000000..f8f290d --- /dev/null +++ b/meshterm/core/hostinfo.py @@ -0,0 +1,238 @@ +# SPDX-License-Identifier: Apache-2.0 +"""What MeshTerm is running on, read once so a bug report does not have to be interviewed. + +Every value here answers a question that otherwise costs a round trip with whoever +reported the problem: which build of MeshTerm, on which OS, in which terminal, how wide, +with which colour depth. None of it is about the mesh and none of it is private — this +module deliberately knows nothing about contacts, keys, positions or messages, so the +block it feeds can be pasted in public without anyone having to read it first. + +Two probes are **ladders over environment variables** rather than protocol queries, which +is a concession worth naming: a terminal has no portable way to be asked who it is. The +one sequence that comes close (``CSI > q``) needs a live tty, a reply that may never +arrive, and a timeout budget on a path that must never hang. So each terminal is +identified by the variable it sets about *itself* — which is the same ladder +:func:`~meshterm.ui.termfont.detect_terminal_font` already climbs for the font — and +anything unrecognised falls back to ``TERM`` rather than to a guess. +""" + +from __future__ import annotations + +import os +import platform +import shutil +import sys +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +#: The first Windows 11 build. Windows 11 still reports its release as ``"10"`` through +#: every documented API, so the build number is the only thing that separates the two. +_WINDOWS_11_BUILD = 22000 + +#: Terminals that announce themselves, by the variable each one sets. Ordered most to +#: least specific: an emulator hosting another (VS Code's terminal, a multiplexer) sets +#: its own marker *and* inherits the outer one, so the inner identity has to win. +#: +#: ``TERM_PROGRAM`` is checked separately, between these and the ``TERM`` fallback, +#: because its *value* is the name — it identifies terminals this table never has to list. +_TERMINAL_VARS: tuple[tuple[str, str], ...] = ( + ("WT_SESSION", "Windows Terminal"), + ("ConEmuPID", "ConEmu"), + ("TERMINAL_EMULATOR", ""), # JetBrains IDEs put the name in the value + ("KITTY_WINDOW_ID", "kitty"), + ("ALACRITTY_WINDOW_ID", "Alacritty"), + ("KONSOLE_VERSION", "Konsole"), + ("GNOME_TERMINAL_SCREEN", "GNOME Terminal"), + ("VTE_VERSION", "VTE-based"), +) + +#: What ``TERM_PROGRAM`` calls terminals whose own name is not what a person calls them. +#: Only the ones that genuinely differ; every other value passes through as it is, which +#: is what keeps this from growing into a catalogue of every terminal there is. +_TERM_PROGRAM_NAMES: dict[str, str] = { + "Apple_Terminal": "Terminal.app", + "vscode": "VS Code", +} + + +@dataclass(frozen=True, slots=True) +class Host: + """The machine and the Python running MeshTerm. + + Attributes: + install: How this copy was installed — ``"frozen"`` (a one-file build, which has + its own bootloader, its own relaunch rules and no source tree to read), + ``"source"`` (a checkout, so a commit can be asked for), or ``"package"`` + (a wheel from an index). The three fail in different ways and the reporter + usually does not know which one they have. + os: The flavour and its version as a person writes it — ``Windows 11``, + ``macOS 15.6``, ``Debian GNU/Linux 12 (bookworm)``. + os_build: The exact build under that name: a Windows build number, or the kernel + release everywhere else. What separates two hosts that call themselves the + same thing. + arch: The processor architecture, which is what tells an arm64 build from an + x86_64 one when a binary misbehaves. + python: The interpreter version, three parts. + """ + + install: str + os: str + os_build: str + arch: str + python: str + + +@dataclass(frozen=True, slots=True) +class Terminal: + """The terminal MeshTerm is drawing into, and what it can draw. + + Attributes: + program: The emulator's name, or ``TERM``'s value where nothing identified + itself, or ``None`` when even that is unset. + term: The ``TERM`` value, verbatim. Kept beside ``program`` rather than folded + into it: a terminal lying about ``TERM`` is itself a common cause of a + rendering report. + colorterm: The ``COLORTERM`` value, which is how a terminal claims truecolor and + the first thing to check when a screen arrives in the wrong colours. + cols: The terminal's width in cells. + rows: The terminal's height in cells. + over_ssh: Whether this is an ssh session. It changes what is knowable — the font + lives on the far client — and it is why several other verdicts here read + ``unknown`` rather than wrong. + """ + + program: str | None + term: str | None + colorterm: str | None + cols: int + rows: int + over_ssh: bool + + +def install_kind() -> str: + """How this copy of MeshTerm got here: ``frozen``, ``source`` or ``package``. + + A frozen build is unambiguous — the bootloader sets the flag. Past that, the question + is whether there is a repository above the package: a checkout can be asked for its + commit and can carry uncommitted work, and a wheel can do neither. + + Returns: + One of ``"frozen"``, ``"source"`` or ``"package"``. + """ + if getattr(sys, "frozen", False): + return "frozen" + # meshterm/core/hostinfo.py -> meshterm/core -> meshterm -> the tree that holds it + if (Path(__file__).resolve().parents[2] / ".git").exists(): + return "source" + return "package" + + +def host() -> Host: + """Read the machine's own facts. + + Returns: + The :class:`Host` block. + """ + name, build = _os_name_and_build() + return Host( + install=install_kind(), + os=name, + os_build=build, + arch=platform.machine() or "unknown", + python=platform.python_version(), + ) + + +def terminal(environ: Mapping[str, str] | None = None) -> Terminal: + """Read the terminal's own facts. + + Args: + environ: The environment to inspect (defaults to ``os.environ``), injectable so + the ladder can be tested without a terminal of the right kind to hand. + + Returns: + The :class:`Terminal` block. + """ + env = os.environ if environ is None else environ + size = shutil.get_terminal_size() + return Terminal( + program=terminal_program(env), + term=env.get("TERM") or None, + colorterm=env.get("COLORTERM") or None, + cols=size.columns, + rows=size.lines, + over_ssh=bool(env.get("SSH_TTY") or env.get("SSH_CONNECTION")), + ) + + +def terminal_program(environ: Mapping[str, str] | None = None) -> str | None: + """Name the terminal emulator from what it says about itself. + + The ladder: the terminals in :data:`_TERMINAL_VARS` that set a marker variable, then + ``TERM_PROGRAM`` (whose *value* is the name, so it covers terminals this module has + never heard of), then ``TERM`` as the last honest thing there is to say. + + Args: + environ: The environment to inspect (defaults to ``os.environ``). + + Returns: + The terminal's name, or ``None`` when nothing in the environment names one. + """ + env = os.environ if environ is None else environ + for var, name in _TERMINAL_VARS: + value = env.get(var) + if value: + return name or value + program = env.get("TERM_PROGRAM") + if program: + return _TERM_PROGRAM_NAMES.get(program, program) + return env.get("TERM") or None + + +def _os_name_and_build() -> tuple[str, str]: + """The OS as a person writes it, and the exact build under that name.""" + system = platform.system() + if system == "Windows": + return _windows() + if system == "Darwin": + version = platform.mac_ver()[0] + return (f"macOS {version}" if version else "macOS"), platform.release() + if system == "Linux": + return (_pretty_name() or "Linux"), platform.release() + return (system or "unknown"), platform.release() + + +def _windows() -> tuple[str, str]: + """Windows by the name it is sold under, and its build number. + + ``platform.win32_ver`` reports release ``"10"`` on Windows 11 as well — the rename + never reached the version APIs — so the build number is what decides which one this + is. The build is also the useful half of the pair: it is what a fix is shipped + against. + """ + release, version, _service_pack, _kind = platform.win32_ver() + try: + build = int(version.rsplit(".", 1)[-1]) + except ValueError: + build = 0 + name = "Windows 11" if build >= _WINDOWS_11_BUILD else f"Windows {release or '?'}" + return name, version or platform.version() + + +def _pretty_name() -> str | None: + """The distribution's own name for itself, from ``/etc/os-release``. + + The one place a Linux flavour is stated by the system rather than inferred, and the + reason a report can say *Calculinux* or *Raspberry Pi OS* instead of ``Linux``. + A host without the file (or without permission to read it) simply has no answer. + """ + try: + text = Path("/etc/os-release").read_text(encoding="utf-8") + except OSError: + return None + for line in text.splitlines(): + key, _, value = line.partition("=") + if key == "PRETTY_NAME": + return value.strip().strip('"') or None + return None diff --git a/meshterm/persistence/repository.py b/meshterm/persistence/repository.py index 2cdbe78..4bf4a49 100644 --- a/meshterm/persistence/repository.py +++ b/meshterm/persistence/repository.py @@ -1068,6 +1068,69 @@ class Repository: row = self._conn.execute("SELECT COUNT(*) AS n FROM observations").fetchone() return int(row["n"]) if row else 0 + def table_counts(self) -> dict[str, int]: + """Return a row count for every table in the database, by name. + + Read off ``sqlite_master`` rather than a list written out here, which is the whole + point: a table added to the schema starts being reported the day it lands, and a + table removed stops, with nothing to keep in step. The one thing this must never + become is a curated set of the counts somebody thought were interesting — the + shape of a database is the aggregate, and the interesting one is always the table + nobody expected to be full. + + Every value is a *count*. Nothing here reads a row, so the result says how much + history a report's author has without saying a word about who they talk to. + + Returns: + Table name to row count, alphabetically, SQLite's own internal tables + excluded (they describe the file format, not this app's data). + """ + names = [ + str(row["name"]) + for row in self._conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' " + "AND name NOT LIKE 'sqlite_%' ORDER BY name" + ).fetchall() + ] + counts: dict[str, int] = {} + for name in names: + # The names come from sqlite_master, so they are this database's own tables + # and not caller input; quoted anyway, because an identifier cannot be bound. + row = self._conn.execute(f'SELECT COUNT(*) AS n FROM "{name}"').fetchone() + counts[name] = int(row["n"]) if row else 0 + return counts + + def failed_run_count(self) -> int: + """Return how many recorded tool runs ended in an error. + + The single most useful number a reporter can hand over: it says whether this + install has been failing quietly for weeks or broke for the first time today, + and it costs one query rather than an interview. + + Returns: + The number of ``runs`` rows whose status is ``error``. + """ + row = self._conn.execute("SELECT COUNT(*) AS n FROM runs WHERE status = 'error'").fetchone() + return int(row["n"]) if row else 0 + + def observation_span(self) -> tuple[datetime | None, datetime | None]: + """Return the first and last times anything was overheard. + + How much mesh this database has actually seen. A report whose span is an hour and + a report whose span is a year describe different installs, and the difference + explains a class of "it works here" before anyone else has to ask for it. + + Returns: + ``(first, last)`` as aware UTC times, or ``(None, None)`` when nothing has + ever been heard. + """ + row = self._conn.execute( + "SELECT MIN(observed_at) AS first, MAX(observed_at) AS last FROM observations" + ).fetchone() + if row is None or row["first"] is None: + return None, None + return _as_when(row["first"]), _as_when(row["last"]) + def recent_observations(self, *, since: datetime, limit: int = 4000) -> list[Observation]: """Return raw stored observations in a recent window, oldest first. diff --git a/meshterm/tools/diagnostics.py b/meshterm/tools/diagnostics.py new file mode 100644 index 0000000..da7ed98 --- /dev/null +++ b/meshterm/tools/diagnostics.py @@ -0,0 +1,375 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The ``diagnostics`` tool: everything a bug report needs, in one block worth pasting. + +A report that opens with "it crashed" costs two or three round trips before anyone knows +which MeshTerm, which OS, which terminal, which radio — and the person who hit the bug is +usually the one least able to answer, because half of it is resolved at boot and never +shown. This states all of it at once. + +**What it deliberately does not say.** The block is designed to be pasted in public by +somebody who has not read it, so nothing that identifies a person or unlocks anything may +be in it: no pairing PIN, no admin password, no channel secret, no private key, no +position, and no contact's name or key. The mesh is described in **aggregate** — how many +rows of each kind, and the span of time they cover — which is the half that explains a bug +(a database with four observations and one with four hundred thousand fail differently) +without naming anyone the reporter talks to. :func:`~meshterm.core.hostinfo.host` and +:meth:`~meshterm.persistence.repository.Repository.table_counts` hold that line on their +own side, so it cannot be crossed here by accident. + +**One report, three faces**, which is the whole reason this is a report and not a +``print``: the menu draws it on a bare frame with nothing around it to select by mistake +(see :mod:`meshterm.ui.diagnostics`), ``meshterm diagnostics`` prints the same rows for +anyone who would rather redirect than copy, and ``--json`` hands the same facts to +whatever files the issue. + +The device is asked but never required. A report about a radio that will not connect is +exactly the report that most needs to be filed, so a failure to reach it becomes a fact in +the block (``connected``/``error``) rather than an error that replaces it. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..context import AppContext +from .base import Tool, ToolResult, register + +if TYPE_CHECKING: # pragma: no cover - typing only + from ..ui.report import Report + +#: MeshCore advert-type byte -> the role a person calls it. Duplicated from ``info`` +#: rather than shared: that module's copy is part of the device-status answer, and a +#: cross-import between two tools to save five entries buys a dependency and no clarity. +_ROLES = {1: "client", 2: "repeater", 3: "room server", 4: "sensor"} + + +@register +class DiagnosticsTool(Tool): + """What MeshTerm, this machine, this terminal and this radio actually are.""" + + name = "diagnostics" + title = "Diagnostics" + icon = "🩺" + help = "Version, host, terminal and device facts for a bug report" + category = "This app" + order = 7 # after Preferences (the row that changes MeshTerm), before the pages + + async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None: + """Open the page in the menu; returning ``None`` completes with no run row. + + The same shape the About pages use: the menu's whole interaction *is* the screen, + and a run row recording that somebody looked at their own version number would be + a line in the log for every glance. + + Args: + ctx: Shared application context. + + Returns: + Always ``None`` in the menu; the CLI never reaches this. + """ + from ..ui.diagnostics import open_diagnostics_page + + await open_diagnostics_page(ctx, await build_report(ctx)) + return None + + async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: + """State the diagnostics — only reachable from the CLI (the menu opens the screen). + + Args: + ctx: Shared application context. + params: Unused beyond the injected ``_run_id``. + + Returns: + A :class:`ToolResult` carrying the whole block as its report. + """ + return ToolResult(summary={"tool": self.name}, report=await build_report(ctx)) + + +async def build_report(ctx: AppContext) -> Report: + """Gather every diagnostic fact, as the report both faces render. + + Five blocks in the order a reader needs them: what this program is, what it is running + on, what it is drawing into, what radio it found, and how much history it has. The + three :class:`~meshterm.ui.report.Facts` blocks merge into one flat JSON object and the + two :class:`~meshterm.ui.report.Listing` blocks keep a key of their own, which is also + why the table counts are a listing — a table named after a fact would otherwise be able + to collide with it. + + Args: + ctx: Shared application context. + + Returns: + The five-block report. + """ + return ( + _app_facts(), + _host_facts(), + await _device_facts(ctx), + _mesh_facts(ctx), + _table_listing(ctx), + _preference_listing(ctx), + ) + + +def _app_facts() -> Any: + """What this program is: version, how it was installed, and where it keeps things.""" + from .. import __version__ + from ..core import hostinfo + from ..ui import fields + from ..ui.report import Facts + + return Facts( + key="meshterm", + fields=( + fields.word("meshterm", "meshterm"), + fields.word("install", "install"), + ), + values={"meshterm": __version__, "install": hostinfo.install_kind()}, + ) + + +def _host_facts() -> Any: + """The machine and the terminal, including every verdict resolved once at boot. + + The boot-time verdicts are the point of this block. Whether icons draw, whether the + path widget's separators draw, which platform flavour resolved and *why* — each is + decided from the environment before the first frame, none is visible on any screen, + and each is the answer to a whole family of "it looks wrong" reports. + """ + from ..core import hostinfo + from ..platforms import get_platform + from ..ui import fields + from ..ui.report import Facts + from ..ui.termfont import detect_terminal_font, emoji_support, powerline_support + + machine, term = hostinfo.host(), hostinfo.terminal() + emoji = emoji_support() + powerline = powerline_support() + font = detect_terminal_font() + return Facts( + key="host", + fields=( + fields.word("os", "os"), + fields.word("os_build", "os_build"), + fields.word("arch", "arch"), + fields.word("python", "python"), + fields.word("terminal", "terminal"), + fields.word("term", "term"), + fields.word("colorterm", "colorterm"), + fields.word("terminal_size", "terminal_size"), + fields.flag("over_ssh", "over_ssh"), + fields.word("platform", "platform"), + fields.flag("icons", "icons"), + fields.word("icons_source", "icons_source"), + fields.word("font", "font"), + fields.word("powerline", "powerline"), + ), + values={ + "os": machine.os, + "os_build": machine.os_build, + "arch": machine.arch, + "python": machine.python, + "terminal": term.program, + "term": term.term, + "colorterm": term.colorterm, + "terminal_size": f"{term.cols}x{term.rows}", + "over_ssh": term.over_ssh, + "platform": get_platform().name, + "icons": emoji.supported, + "icons_source": emoji.source, + # The face, and which terminal it was read from — an unidentified terminal + # (or an ssh session, where the font lives on the far client) has no answer, + # and saying so is worth more than naming a font nobody here can see. + "font": f"{font.face} ({font.source})" if font else None, + # Level and source together: `none (font:windows-terminal)` is a measurement + # and `unknown (ssh)` is an admission, and they lead somewhere different. + "powerline": f"{powerline.level} ({powerline.source})", + }, + ) + + +async def _device_facts(ctx: AppContext) -> Any: + """The radio: what it is, how it is attached, and what its link is set to. + + Best-effort by design. Every value here is ``None`` on a machine whose radio is + unplugged, asleep, or refusing to pair — and ``connected: false`` with the failure's + own words in ``error`` is a better first line for that bug report than a command that + refused to produce one. + """ + from ..ui import fields + from ..ui.report import Facts + + values: dict[str, Any] = dict.fromkeys( + ( + "connected", + "transport", + "port", + "device_role", + "device_model", + "firmware", + "radio_freq_mhz", + "radio_bw_khz", + "radio_sf", + "radio_cr", + "tx_power_dbm", + "error", + ) + ) + values["connected"] = False + try: + device = await ctx.device() + snapshot = await _snapshot(ctx, device) + info = await _device_info(device) + adv_type = snapshot.get("adv_type") + values.update( + connected=True, + transport="mock" if ctx.mock else (ctx.active_transport or "serial"), + port=ctx.active_port, + device_role=( + _ROLES.get(int(adv_type), f"type {adv_type}") if adv_type is not None else None + ), + device_model=info.get("model") or None, + firmware=" ".join(str(info[k]) for k in ("ver", "fw_build") if info.get(k)) or None, + radio_freq_mhz=snapshot.get("radio_freq"), + radio_bw_khz=snapshot.get("radio_bw"), + radio_sf=snapshot.get("radio_sf"), + radio_cr=snapshot.get("radio_cr"), + tx_power_dbm=snapshot.get("tx_power"), + ) + except Exception as exc: # noqa: BLE001 - any failure to reach the radio is the fact + values["error"] = str(exc) or exc.__class__.__name__ + + return Facts( + key="device", + fields=( + fields.flag("connected", "connected"), + fields.word("transport", "transport"), + fields.word("port", "port"), + fields.word("device_role", "device_role"), + fields.word("device_model", "device_model"), + fields.word("firmware", "firmware"), + fields.decimal("radio_freq_mhz", "radio_freq_mhz", ".4f"), + fields.decimal("radio_bw_khz", "radio_bw_khz", ".2f"), + fields.integer("radio_sf", "radio_sf"), + fields.integer("radio_cr", "radio_cr"), + fields.integer("tx_power_dbm", "tx_power_dbm"), + fields.free("error", "error"), + ), + values=values, + ) + + +def _mesh_facts(ctx: AppContext) -> Any: + """How much history this install holds, and where it holds it. + + Counts and a span, never a row. The span is the fact that sizes every other number + here: a thousand observations over two years and a thousand in an afternoon describe + two different installs, and only one of them has a radio worth suspecting. + """ + from ..core.preferences import current + from ..persistence.logging import log_path + from ..ui import fields + from ..ui.report import Facts + + preferences = ctx.preferences or current() + db_path = ctx.settings.db_path + first, last = ctx.repo.observation_span() + return Facts( + key="mesh", + fields=( + fields.path("config_dir", "config_dir"), + fields.integer("db_size_kb", "db_size_kb"), + fields.word("log_level", "log_level"), + fields.integer("log_size_kb", "log_size_kb"), + fields.integer("runs_failed", "runs_failed"), + fields.instant("first_heard", "first_heard"), + fields.instant("last_heard", "last_heard"), + ), + values={ + "config_dir": ctx.settings.config_dir, + "db_size_kb": _size_kb(db_path), + "log_level": preferences.log_level, + # How much the log holds, so a maintainer asking for it knows what they are + # asking for — and so a file still at zero says the level never let it write. + "log_size_kb": _size_kb(log_path(ctx.settings.config_dir)), + "runs_failed": ctx.repo.failed_run_count(), + "first_heard": first, + "last_heard": last, + }, + ) + + +def _table_listing(ctx: AppContext) -> Any: + """Every table in the database and how many rows it holds. + + A listing rather than more facts, for two reasons. The keys are discovered from the + schema rather than declared, so a table named after a fact could otherwise collide + with it in the merged JSON object; and this genuinely is a set of records — one per + table — which is the shape a reader and a parser both already know. + """ + from ..ui import fields + from ..ui.report import Column, Listing + + counts = ctx.repo.table_counts() + return Listing( + key="tables", + columns=(fields.word("table", "TABLE"), Column(key="rows", lanes=(_rows_lane(),))), + rows=[{"table": name, "rows": n} for name, n in counts.items()], + ) + + +def _preference_listing(ctx: AppContext) -> Any: + """The preferences that differ from their defaults — the whole list, and only those. + + An override is the only part of the preference set that can explain anything: the + defaults are in the source, the same for everyone, and dumping all forty would bury + the two the reporter actually changed. + """ + from ..core.preferences import current + from ..ui import fields + from ..ui.report import Listing + + preferences = ctx.preferences or current() + return Listing( + key="preferences", + columns=(fields.word("preference", "PREFERENCE"), fields.free("value", "VALUE")), + rows=[ + {"preference": key, "value": str(value)} + for key, value in preferences.overrides().items() + ], + ) + + +def _rows_lane() -> Any: + """The row-count lane: right-aligned, because a column of magnitudes only compares aligned.""" + from ..ui.report import Lane + + return Lane(header="ROWS", render=lambda v: str(v if v is not None else 0), align="right") + + +def _size_kb(path: Any) -> int | None: + """A file's size in whole kilobytes, or ``None`` when it is not there to measure.""" + if path is None: + return None + try: + return round(path.stat().st_size / 1024) + except OSError: + return None + + +async def _snapshot(ctx: AppContext, device: Any) -> dict: + """The device's settings snapshot, or an empty one when the read fails.""" + from ..ui.config_editor import cached_snapshot + + try: + return await cached_snapshot(ctx, device) + except Exception: # noqa: BLE001 - a settings read that fails leaves the radio facts blank + return {} + + +async def _device_info(device: Any) -> dict: + """The device's self-description, or an empty one when the firmware does not answer.""" + try: + return await device.get_device_info() or {} + except Exception: # noqa: BLE001 - firmware predating the query contributes nothing + return {} diff --git a/meshterm/ui/diagnostics.py b/meshterm/ui/diagnostics.py new file mode 100644 index 0000000..f8c520b --- /dev/null +++ b/meshterm/ui/diagnostics.py @@ -0,0 +1,153 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The Diagnostics page: the bug-report block, on a frame with nothing else on it. + +**A bare frame, and the reason is mechanical rather than aesthetic.** The one thing this +page is for is getting its text out of the terminal and into an issue, and a terminal is +selected by dragging: a panel border is characters sitting on the *same lines* as the +content, so every line of a line-wise selection arrives with a box-drawing glyph welded to +each end. Same for a header, a footer hint and an F-key lane, which arrive as text that +looks like part of the report to whoever reads the paste. So this page draws its rows and +nothing at all besides (:attr:`~meshterm.ui.tui.screen.Screen.bare`), the same frame the +share screen uses for the same kind of reason — there, a camera wants no contrast arguing +with the code; here, a clipboard wants no furniture pretending to be data. Esc leaves, as +everywhere. + +The rows come from the tool's :mod:`~meshterm.ui.report`, projected through the very lanes +the command line prints (:func:`~meshterm.ui.renderers.facts_pairs`), so what is copied off +this screen and what ``meshterm diagnostics`` writes to a pipe are the same facts in the +same words. Only the *layout* differs, and in one respect: the scripted face crops a value +too wide for its lane, which is right for a record somebody will split on whitespace and +wrong for a page whose entire job is to be complete — so here a long value hangs under its +own block instead (the app-wide rule for a wrapped labelled row). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rich.console import Group, RenderableType +from rich.table import Table +from rich.text import Text + +from .tui.screen import ScrollScreen + +if TYPE_CHECKING: # pragma: no cover - typing only + from ..context import AppContext + from .report import Facts, Listing, Report + + +class DiagnosticsPage(ScrollScreen): + """The bug-report block, full-screen and unadorned. + + Bare, so a mouse drag takes the facts and only the facts; scrollable, because the + block outgrows a 26-row console and a truncated bug report is worse than a scrolled + one. Read-only: there is nothing here to change, and the page is deliberately not a + place to change it from. + """ + + bare = True + + def __init__(self, report: Report) -> None: + """Lay the report out as the page's body. + + Args: + report: The tool's report — the same blocks the CLI renders. + """ + super().__init__(render_report(report), title="Diagnostics", floating=False) + + +async def open_diagnostics_page(ctx: AppContext, report: Report) -> None: + """Open the Diagnostics page full-screen and hold it until the reader backs out. + + Args: + ctx: The shared application context (must be running the interactive TUI). + report: The block to show. + + Raises: + RuntimeError: If called outside the interactive menu (no full-screen session). + """ + from .surface import TuiUi + + if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller + raise RuntimeError("the Diagnostics page is only available in the menu") + await ctx.ui.session.run_screen(DiagnosticsPage(report)) + + +def render_report(report: Report) -> RenderableType: + """Lay a report out as the page's body: each block in order, a blank row between. + + A block that would draw nothing — a listing with no rows — is left out along with its + separator, exactly as the scripted face leaves it out, so the two never disagree about + whether a section exists. + + Args: + report: The blocks to draw. + + Returns: + The page body. + """ + from .report import Facts + + parts: list[RenderableType] = [] + for block in report: + drawn = _facts(block) if isinstance(block, Facts) else _listing(block) + if drawn is None: + continue + if parts: + parts.append(Text("")) + parts.append(drawn) + return Group(*parts) + + +def _facts(block: Facts) -> RenderableType | None: + """One facts block: key on the left, value on the right, wrapping under itself.""" + from .renderers import facts_pairs + + rows = facts_pairs(block) + if not rows: + return None + table = _grid() + table.add_column(no_wrap=True) + # The one column allowed to wrap, and the reason this page is not just the scripted + # renderer at a different width: a config directory or a connection error cropped to + # the frame is a bug report missing the half that mattered. + table.add_column(overflow="fold") + for key, value in rows: + table.add_row(Text(key, style="muted"), Text(value)) + return table + + +def _listing(block: Listing) -> RenderableType | None: + """One listing: its header row, then its records. Nothing where there are no records.""" + if not block.rows: + return None + lanes = block.lanes() + table = _grid() + for _column, lane in lanes: + table.add_column(justify=lane.align, no_wrap=True) + table.add_row(*(Text(lane.header, style="muted") for _column, lane in lanes)) + for row in block.rows: + table.add_row(*(Text(lane.render(row.get(column.key))) for column, lane in lanes)) + return table + + +def _grid() -> Table: + """A frameless table that starts in column zero. + + No box, no edge padding and no leading pad: every cell of chrome on a row is a + character the clipboard carries, and an indent before the first key is one the reader + of the issue has to strip off every line. + """ + from .script import GUTTER + + return Table( + box=None, + show_header=False, + show_edge=False, + pad_edge=False, + expand=False, + padding=(0, GUTTER, 0, 0), + ) + + +__all__ = ["DiagnosticsPage", "open_diagnostics_page", "render_report"] diff --git a/meshterm/ui/fields.py b/meshterm/ui/fields.py index 3c8504c..50bd4a6 100644 --- a/meshterm/ui/fields.py +++ b/meshterm/ui/fields.py @@ -332,6 +332,21 @@ def name(key: str, header: str) -> Column: return Column(key=key, lanes=(Lane(header=header, render=script.name),)) +def path(key: str, header: str) -> Column: + """A place on this filesystem: a config directory, a database, a file just written. + + Its own constructor rather than :func:`word` because the *typed* value is a + :class:`~pathlib.Path` and nothing else in the lane vocabulary turns one into text — + ``word`` hands it straight to Rich, which cannot render it. The machine face was + already right: :func:`~meshterm.ui.report.normalise` has always spelled a Path as its + string, so this closes the plain half of a shape that was only ever half-declared. + + Printed as the host writes it, separators and all, since the one thing a reader does + with a path is paste it back into their own shell. + """ + return Column(key=key, lanes=(Lane(header=header, render=_path_cell),)) + + def free(key: str, header: str) -> Column: """Free text a stranger filled in: escaped so it can never end its own record. @@ -443,6 +458,11 @@ def _rendered_cell(value: Rendered | None) -> str: return script.NONE if value is None else value.text +def _path_cell(value: Any) -> str: + """A path as its own text, or the absent token where there is none.""" + return script.NONE if value is None else str(value) + + 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) diff --git a/meshterm/ui/renderers.py b/meshterm/ui/renderers.py index ab3cc1a..9f0b35b 100644 --- a/meshterm/ui/renderers.py +++ b/meshterm/ui/renderers.py @@ -61,6 +61,34 @@ class OutputFormat(str, Enum): JSON = "json" +def facts_pairs(block: Facts) -> list[tuple[str, str]]: + """Project a :class:`Facts` block into its ``(key, value)`` text rows. + + The plain face's half of a facts block, lifted out of :class:`PlainRenderer` because + one surface in the menu wants the same rows laid out differently: the Diagnostics page + hangs a long value under its key rather than cropping it, since a cropped path in a + bug report is worse than a wrapped one. Sharing the projection is what keeps the two + from drifting — a field added to a report appears on both without anyone remembering. + + Laying the rows out is the caller's business; turning a typed value into text is not, + and that stays here, in the declared :class:`~meshterm.ui.report.Lane`. + + Args: + block: The facts block to project. + + Returns: + One ``(header, cell)`` pair per lane, in the order the block declares them. + """ + 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 rows + + class Renderer: """Turns a report into bytes. One subclass per format.""" @@ -157,14 +185,7 @@ class PlainRenderer(Renderer): # 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) + return script.pairs(facts_pairs(block)) @contextmanager def stream(self, listing: Listing) -> Iterator[Any]: diff --git a/meshterm/ui/theme.py b/meshterm/ui/theme.py index fe8de8a..918f7ad 100644 --- a/meshterm/ui/theme.py +++ b/meshterm/ui/theme.py @@ -695,6 +695,7 @@ _GLYPH_MAP: dict[str, str] = { "📶": "≥", # TX power sweep — the power ramp "🔧": "⚙", # config (parameter concept) "📋": "i", # info + "🩺": "?", # diagnostics — the question a diagnosis answers (🎭 shares the mark) "📰": "…", # live feed — a stream of items "🎧": "≈", # monitor — listening to the waveform "🗼": "▲", # repeater admin — the repeater mark itself diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..42e02d4 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,362 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The Diagnostics block: what it says, what it refuses to say, and how it says it. + +The privacy tests here are the load-bearing ones. This block exists to be pasted in public +by somebody who has not read it, so "no secret is in it" has to be a property of the +feature rather than a habit of whoever last edited it. Two tests hold that line from +different sides: one plants real secrets in the stores that hold them and asserts none +came out, and one refuses the *names* such a field would go under — so a value the suite +has no way to plant is still caught by the key it would arrive as. +""" + +from __future__ import annotations + +import asyncio +import re +from pathlib import Path + +import pytest +from rich.console import Console + +from meshterm.context import AppContext +from meshterm.core import hostinfo +from meshterm.core.admin_store import AdminStore +from meshterm.core.channel_store import ChannelStore +from meshterm.core.config import Settings +from meshterm.core.device_store import DeviceStore +from meshterm.core.models import Contact +from meshterm.core.preferences import Preferences +from meshterm.persistence.repository import Repository +from meshterm.tools.diagnostics import build_report +from meshterm.ui.renderers import JsonRenderer, PlainRenderer, facts_pairs +from meshterm.ui.report import Facts, Listing + +#: Planted into the stores that really hold this kind of value. Each is the kind of thing +#: the block must never carry: one administers a repeater, one decrypts a channel. +_ADMIN_PASSWORD = "correct-horse-battery" +_CHANNEL_SECRET = "8f" * 16 + +#: Key *segments* no field in this block may be built from — matched on the underscore +#: parts of a key rather than as substrings, so ``platform`` is not read as a latitude. +#: The first row names a secret and the second names a person or a place; ``key`` and +#: ``name`` close the set, because the block identifies no node at all, not even ours. +_FORBIDDEN_SEGMENTS = frozenset( + { + "password", + "passphrase", + "secret", + "private", + "privkey", + "pin", + "token", + "credential", + "contact", + "contacts", + "lat", + "latitude", + "lon", + "longitude", + "position", + "message", + "messages", + "key", + "name", + } +) + + +@pytest.fixture +def ctx(tmp_path: Path) -> AppContext: + """A context on a throwaway home, with a secret planted in the stores that hold them.""" + settings = Settings(config_dir=tmp_path) + admin_store = AdminStore(tmp_path / "admin.json") + admin_store.remember( + Contact(name="Hilltop-Repeater", public_key="3d" * 32, key_prefix="3d63c642"), + _ADMIN_PASSWORD, + ) + channel_store = ChannelStore(tmp_path / "channels.json") + channel_store.remember("ab" * 32, 1, "Lakeside", bytes.fromhex(_CHANNEL_SECRET)) + return AppContext( + console=Console(), + settings=settings, + repo=Repository(settings.db_path), + device_store=DeviceStore(tmp_path / "device.json"), + admin_store=admin_store, + channel_store=channel_store, + preferences=Preferences(tmp_path / "preferences.toml", {"log_level": "DEBUG"}), + mock=True, + ) + + +# --- what it must never say ----------------------------------------------------------- + + +def test_no_planted_secret_reaches_either_face(ctx: AppContext) -> None: + """A remembered admin password and a channel secret appear in neither face. + + The point of the feature: a reporter pastes this into a public issue without reading + it first. + """ + report = _report(ctx) + for secret in (_ADMIN_PASSWORD, _CHANNEL_SECRET): + assert secret not in _plain(report) + assert secret not in _machine(report) + + +def test_no_field_is_named_after_a_secret_or_a_person(ctx: AppContext) -> None: + """No key in the block reads like a credential, a position or somebody's identity. + + The durable half of the guarantee. A value this suite cannot plant — a PIN read live + off a radio, a private key exported on demand — still has to arrive under a name, and + every name a secret would plausibly take is refused here. + """ + for key in _keys(_report(ctx)): + offending = _FORBIDDEN_SEGMENTS & set(key.lower().split("_")) + assert not offending, f"{key!r} is named after {', '.join(sorted(offending))}" + + +def test_the_mesh_is_described_only_in_aggregate(ctx: AppContext) -> None: + """The mesh reaches the block as counts, never as a row anybody could be named in. + + Every table is *counted*, and the count is an integer: the moment a listing starts + carrying a row out of one of those tables, a contact's name or a message body is one + edit away from the clipboard. + """ + tables = _flat(_report(ctx))["tables"] + assert tables, "the schema should always have tables to count" + assert all(set(row) == {"table", "rows"} for row in tables) + assert all(isinstance(row["rows"], int) for row in tables) + + +# --- what it must say ----------------------------------------------------------------- + + +def test_states_the_facts_a_bug_report_opens_with(ctx: AppContext) -> None: + """Version, install shape, host, terminal and device all reach the block.""" + from meshterm import __version__ + + values = _flat(_report(ctx)) + assert values["meshterm"] == __version__ + assert values["install"] in {"frozen", "source", "package"} + assert values["os"] and values["python"] and values["arch"] + assert "x" in values["terminal_size"] + assert values["platform"] in {"regular", "picocalc"} + # The simulator answers like a radio, so the device half is populated rather than + # skipped: this is the shape a real connected run produces. + assert values["connected"] is True + assert values["transport"] == "mock" + assert values["firmware"] + + +def test_an_unreachable_radio_becomes_a_fact_not_a_failure( + ctx: AppContext, monkeypatch: pytest.MonkeyPatch +) -> None: + """A radio that will not connect leaves the block intact and says why. + + The report about a radio that never connects is exactly the one most worth filing, so + the device read is best-effort: the failure lands in ``error`` and every other fact + still arrives. + """ + + async def refuse(self): + raise RuntimeError("no companion answered on COM7") + + monkeypatch.setattr(AppContext, "device", refuse) + values = _flat(_report(ctx)) + assert values["connected"] is False + assert "COM7" in values["error"] + assert values["firmware"] is None + assert values["meshterm"] # the rest of the block is untouched + + +def test_only_overridden_preferences_are_listed(ctx: AppContext) -> None: + """The preference listing carries the disagreements with the defaults, and only those. + + A dump of all forty would bury the one the reporter actually changed; the defaults are + in the source and identical for everyone. + """ + assert _flat(_report(ctx))["preferences"] == [{"preference": "log_level", "value": "DEBUG"}] + + +# --- how it says it ------------------------------------------------------------------- + + +def test_the_page_and_the_command_line_state_the_same_facts(ctx: AppContext) -> None: + """Every key/value the page draws is one the scripted face prints, in the same words. + + The two faces share :func:`~meshterm.ui.renderers.facts_pairs` precisely so a field + added to the report reaches both without anyone remembering to, and this is what would + catch the page growing a projection of its own. + """ + report = _report(ctx) + printed = _plain(report) + for block in report: + if isinstance(block, Facts): + for key, value in facts_pairs(block): + assert key in printed + assert value in printed + + +def test_the_page_draws_no_chrome(ctx: AppContext) -> None: + """The page is bare, and its rows start in column zero. + + Both are the same requirement seen twice: a terminal is selected by dragging, so a + border, a header or a leading indent is a character the clipboard carries into the + issue. + """ + from meshterm.ui.diagnostics import DiagnosticsPage + + page = DiagnosticsPage(_report(ctx)) + assert page.bare is True + lines = [_strip(line) for line in page.render_body(72)] + content = [line for line in lines if line.strip()] + assert content, "the page should have drawn something" + assert not content[0].startswith(" ") + + +def test_an_empty_listing_is_drawn_by_neither_face() -> None: + """A listing with no rows is absent from the page exactly as it is from the output. + + The two faces must not disagree about whether a section exists: a reader comparing a + pasted block against their own would otherwise read the difference as meaningful. + """ + from meshterm.ui.diagnostics import render_report + + empty = (Listing(key="preferences", columns=(), rows=[]),) + assert _plain(empty).strip() == "" + assert _render(render_report(empty)).strip() == "" + + +# --- the host probe ------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "environ,expected", + [ + ({"WT_SESSION": "abc", "TERM": "xterm"}, "Windows Terminal"), + ({"TERM_PROGRAM": "Apple_Terminal", "TERM": "xterm-256color"}, "Terminal.app"), + ({"TERM_PROGRAM": "vscode"}, "VS Code"), + # A terminal this module has never heard of still names itself, because + # TERM_PROGRAM's *value* is the name — which is why that rung exists at all. + ({"TERM_PROGRAM": "WezTerm"}, "WezTerm"), + ({"TERM": "linux"}, "linux"), + ({}, None), + ], +) +def test_the_terminal_is_identified_from_what_it_says_about_itself( + environ: dict, expected: str | None +) -> None: + """Each rung of the ladder, down to the honest ``None`` when nothing names one.""" + assert hostinfo.terminal_program(environ) == expected + + +def test_an_inner_terminal_wins_over_the_one_hosting_it() -> None: + """A marker variable beats ``TERM_PROGRAM``, because the inner emulator is the answer. + + VS Code's terminal inherits the outer terminal's environment and adds its own marker; + reporting the outer one would name a terminal that is not drawing anything. + """ + env = {"WT_SESSION": "abc", "TERM_PROGRAM": "vscode"} + assert hostinfo.terminal_program(env) == "Windows Terminal" + + +def test_ssh_is_read_from_either_variable() -> None: + """Being over ssh is a fact the block states, because it changes what is knowable.""" + assert hostinfo.terminal({"SSH_TTY": "/dev/pts/0"}).over_ssh is True + assert hostinfo.terminal({"SSH_CONNECTION": "10.0.0.2 51000 10.0.0.1 22"}).over_ssh is True + assert hostinfo.terminal({}).over_ssh is False + + +def test_the_host_block_is_populated_on_whatever_runs_the_suite() -> None: + """Every host field has an answer, on any OS CI runs this on.""" + machine = hostinfo.host() + assert machine.install in {"frozen", "source", "package"} + assert machine.os and machine.arch and machine.python + assert machine.python.count(".") == 2 + + +# --- the repository aggregates -------------------------------------------------------- + + +def test_table_counts_are_discovered_from_the_schema(tmp_path: Path) -> None: + """Every table is counted, and SQLite's own internal tables are not. + + Read off ``sqlite_master`` so a table added to the schema starts being reported the day + it lands. A curated list would go stale silently, and the interesting count is always + the table nobody expected to be full. + """ + counts = Repository(tmp_path / "meshterm.db").table_counts() + assert {"runs", "observations", "messages", "traces"} <= set(counts) + assert not any(name.startswith("sqlite_") for name in counts) + assert all(isinstance(n, int) for n in counts.values()) + + +def test_the_observation_span_is_absent_until_something_is_heard(tmp_path: Path) -> None: + """An empty database has no span, rather than a span of nothing.""" + repo = Repository(tmp_path / "meshterm.db") + assert repo.observation_span() == (None, None) + assert repo.failed_run_count() == 0 + + +# --- helpers --------------------------------------------------------------------------- + + +def _report(ctx: AppContext): + """Build the report, as both faces do.""" + return asyncio.run(build_report(ctx)) + + +def _flat(report) -> dict: + """Every fact in the report, flattened the way the JSON face flattens it.""" + flat: dict = {} + for block in report: + if isinstance(block, Facts): + flat.update(block.values) + else: + flat[block.key] = list(block.rows) + return flat + + +def _keys(report) -> list[str]: + """Every key the block declares, facts and listing columns alike.""" + keys: list[str] = [] + for block in report: + if isinstance(block, Facts): + keys.extend(column.key for column in block.fields) + else: + keys.extend(column.key for column in block.columns) + return keys + + +def _plain(report) -> str: + """The whole block as the plain face writes it.""" + console = _console(200) + with console.capture() as captured: + PlainRenderer(console).render(report) + return captured.get() + + +def _machine(report) -> str: + """The machine face's document, as text.""" + console = _console(10_000) + with console.capture() as captured: + JsonRenderer(console).render(report) + return captured.get() + + +def _render(renderable) -> str: + """A renderable as plain text at the standard width.""" + console = _console(72) + with console.capture() as captured: + console.print(renderable) + return captured.get() + + +def _console(width: int) -> Console: + """A capture console with nothing between the content and the string.""" + return Console(width=width, color_system=None, highlight=False, markup=False) + + +def _strip(line: str) -> str: + """One rendered line with its SGR runs removed.""" + return re.sub(r"\x1b\[[0-9;]*m", "", line) diff --git a/tests/test_gallery.py b/tests/test_gallery.py index fc55d33..077e225 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -984,6 +984,77 @@ def _join_discord(cols: int, rows: int) -> Screen: return AboutPage("Join Discord", join_discord()) +def _diagnostics(cols: int, rows: int) -> Screen: + """The Diagnostics page, carrying the widest values a real one can. + + A deep Windows config directory and a connection error in the radio's own words are + the two fields with no natural bound, and both are here on purpose: this page folds + where the scripted face crops, so the fold is what has to survive 53 columns. + """ + from meshterm.ui.diagnostics import DiagnosticsPage + + return DiagnosticsPage(_diagnostics_report()) + + +def _diagnostics_report(): + """A representative diagnostics report, shaped exactly as the tool states one.""" + from meshterm.ui import fields + from meshterm.ui.report import Column, Facts, Lane, Listing + + return ( + Facts( + key="meshterm", + fields=(fields.word("meshterm", "meshterm"), fields.word("install", "install")), + values={"meshterm": "0.3.7", "install": "frozen"}, + ), + Facts( + key="host", + fields=( + fields.word("os", "os"), + fields.word("python", "python"), + fields.word("terminal", "terminal"), + fields.word("terminal_size", "terminal_size"), + fields.flag("over_ssh", "over_ssh"), + fields.word("powerline", "powerline"), + ), + values={ + "os": "Debian GNU/Linux 12 (bookworm)", + "python": "3.11.2", + "terminal": "Windows Terminal", + "terminal_size": "53x26", + "over_ssh": True, + "powerline": "none (font:windows-terminal)", + }, + ), + Facts( + key="device", + fields=( + fields.flag("connected", "connected"), + fields.path("config_dir", "config_dir"), + fields.free("error", "error"), + ), + values={ + "connected": False, + "config_dir": Path("C:/Users/somebody/AppData/Roaming/meshterm/profiles/handheld"), + "error": "no companion answered on COM7 within 10s; the port is open elsewhere", + }, + ), + Listing( + key="tables", + columns=( + fields.word("table", "TABLE"), + Column(key="rows", lanes=(Lane(header="ROWS", render=str, align="right"),)), + ), + rows=[{"table": "observations", "rows": 418_203}, {"table": "runs", "rows": 91}], + ), + Listing( + key="preferences", + columns=(fields.word("preference", "PREFERENCE"), fields.free("value", "VALUE")), + rows=[{"preference": "log_level", "value": "DEBUG"}], + ), + ) + + def _share_qr(cols: int, rows: int) -> Screen: """The share screen: a contact card's code and link on a bare frame (the widest QR).""" from meshterm.ui.qr import QrScreen @@ -1032,6 +1103,7 @@ _ENTRIES: list[_Entry] = [ _Entry("join_discord", _join_discord), _Entry("support_project", _support_project), _Entry("share_qr", _share_qr), + _Entry("diagnostics", _diagnostics), ] #: (platform, cols, rows) combos every entry above renders under. PicoCalc gets both its diff --git a/tests/test_menus.py b/tests/test_menus.py index 66075ed..7547a35 100644 --- a/tests/test_menus.py +++ b/tests/test_menus.py @@ -184,8 +184,8 @@ def test_main_menu_sections_answer_the_menus_own_question() -> None: someone else's over the mesh, or MeshTerm itself. This app is the last of the three scopes and the reason it sorts last: it holds the - preferences that change the program and the pages that describe it, neither of which - is a thing to do *on the mesh*. + preferences that change the program, the diagnostics that state what it currently is, + and the pages that describe it — none of which is a thing to do *on the mesh*. """ from meshterm.tools import load_all_tools from meshterm.tools.base import _CATEGORY_ORDER, all_tools @@ -203,11 +203,22 @@ def test_main_menu_sections_answer_the_menus_own_question() -> None: "Explore": ["map", "walk", "trace", "trace-path", "records"], "This node": ["info", "config", "advert"], "Other nodes": ["repeater-admin", "tx-optimize"], - "This app": ["preferences", "about", "about-author", "discord", "support"], + "This app": [ + "preferences", + "diagnostics", + "about", + "about-author", + "discord", + "support", + ], } # No section is so big it stops being a grouping (the old Mesh bucket held seven of - # nineteen rows), and none is a bucket of one. - assert all(2 <= len(names) <= 5 for names in sections.values()) + # nineteen rows), and none is a bucket of one. This app is the one at six: it runs + # mutable, then live, then fixed — the row that *changes* MeshTerm, the row that states + # what MeshTerm is *right now* on this machine, then the four pages that say what + # MeshTerm is in general. Six of twenty-four rows is still a grouping; seven of + # nineteen was not. + assert all(2 <= len(names) <= 6 for names in sections.values()) #: Two lexicon icons the terminal genuinely draws in different widths — the whole reason