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