Commit Graph
103 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 c008aa5f01 Windows got sixteen million colours, everyone else got 256
prompt_toolkit picks the colour depth per output class, and its two classes
disagree: Windows10_Output returns TRUE_COLOR outright, Vt100_Output returns
DEPTH_8_BIT for every TERM but linux. Nothing ever passed color_depth, so the
same build drawing the same theme was 24-bit on Windows and quantized to the
216-colour cube on macOS and Linux — silently, which is why it went unnoticed:
the app looks fine, just flatter.

It costs exactly what this palette is made of. The seven heat.* steps and the
per-node hue wheel 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 — only its own
PROMPT_TOOLKIT_COLOR_DEPTH is — 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 returns None and keeps precisely the depth it had, so a host this has
never heard of cannot be made worse by it: 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:35:32 -04:00
Jean-Pierre MartineauandClaude Opus 5 1c24f9149d Let a tool state its answer instead of printing it
The CLI said what it found by printing it: run() called ctx.ui.show(<a table>)
and handed back a ToolResult carrying only bookkeeping. By the time the boundary
saw that result the contacts had gone down the pipe already rendered, and there
was nothing left to offer a second format — which is exactly why --json reached
two commands out of twenty and stopped, each one growing an `if ctx.json_output:`
branch above the rendering that restated the whole answer in a dialect nobody
else could reuse.

exit_code has always worked the other way round: the tool states it, the boundary
turns it into a process status, and no tool calls sys.exit. The answer now travels
the same way. A tool states a Report; a renderer turns it into bytes; --json picks
the renderer.

ui/report.py holds the model — a Listing of records or a set of Facts, whose rows
carry the typed value and whose columns carry both projections of it. ui/fields.py
is the shared shapes, one constructor per concept, so every listing that mentions
a node speaks the same five-key object by construction rather than by review.
ui/renderers.py is the two faces: aligned text for a person, and the bare document
for a program — an array for a listing, an object for a set of facts, no envelope,
because the report a caller needs is still $?.

Nothing is migrated yet: report defaults to None and every command renders exactly
as it did. The script vocabulary the plain face will need lands alongside — ages,
durations, locations, the arrow-drawn route, the comma-joined spec, pinned lanes
for a live stream, and a wrap for the written pages — with the quoting still in
place until its callers move off it.

Also here, because the field library needs it: cli_time_format, the preference that
says whether the command line prints ages or timestamps, with --absolute as its
per-run override rather than a second switch beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:16:57 -04:00
Jean-Pierre MartineauandClaude Opus 5 369e29e44a Five ways the scripted output lied, and the name that crashed it
The review pass found the CLI breaking its own written rules.

A node broadcasts its own name, so every name the CLI prints is remote
data — and Rich read `[...]` in it as a style tag. A node called
`[bold]Loud` printed as `Loud`, silently no longer the string that
identifies it; a node called `[/]Bob` raised MarkupError and took the
command down. The log handler then failed the same way while reporting
it, replacing the diagnostic with a traceback about the diagnostic. No
log line and no scripted line is ever marked up, so all three consoles
now say so.

`right=` never right-aligned anything. Rich's `Text.wrap` returns early
on `overflow="ignore"`, above the justify step, so asking for one threw
the other away — every numeric lane in the CLI was left-aligned while
CLAUDE.md, script.py's own docstring and the manual all said otherwise.
"crop" refuses to elide just as firmly and still aligns.

`config show` printed an empty string as `""` and `config set` read
those two characters literally, so feeding a dump back in 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 six bullets, telling the reader the radio was PIN-locked when it
was not: `conceal` already declined to mask the menu's absence token and
had simply never been shown the CLI's.

`meshterm about` drew the `---` above its colophon as a rule sized to
the console, which on a 16384-cell console is one 48KB line in a 4KB
page. A rule is a frame; the scripted copy drops it with the QR.

The contract fixture set only `--db`, which moves the history and
nothing else — so the suite read whatever was in the developer's own
~/.meshterm. That is how `[/]Bob`, planted by hand on one machine, could
crash the suite there and pass everywhere else. $MESHTERM_HOME moves the
whole directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 02:17:41 -04:00
Jean-Pierre MartineauandClaude Opus 5 da05dd402f Give the CLI its own voice, meant for a pipe rather than an eye
The CLI had grown the menu's manners -- colour, boxes, section headings,
glyph columns, a closing check mark -- and every one of them is in the way
of the thing it is actually for. It now prints like a standard Unix
utility: plain text, no escape sequences at all, no frames, one record per
line, and nothing wrapped, so a long line runs off the right instead of
folding its fields onto a second one.

meshterm/ui/script.py is the whole vocabulary. Listings are an uppercase
header and space-aligned records; key/value blocks are sysctl's shape, and
a `get` prints the bare value alone. Names are quoted wherever they share a
line, because a name can hold a space or a comma and a bare one is not a
field. A route reads "Origin" (3d),"Relay" (f2),"Us" (a1) -- the hash
outside the quotes, our own node a hop like any other rather than a star,
since the hash is what carries the identity that colour carries on screen.
Times are absolute local ISO-8601 and "-" is the one token for absent.

The return value is now half the report: 0 ok, 1 failure, 2 usage, 3 no
device, 4 the device was reached and the operation failed, 5 nothing to
report. That last one is the point -- a command that ran fine and found
nothing prints nothing and says so in $?, so a script never has to count
output lines to tell an empty mesh from a full one. Errors and progress
moved to stderr, and an expected failure no longer arrives as a traceback.

Two things lose their scripted face rather than get a degraded one. The map
is a picture whose nodes are told apart by colour: monochrome it would be
unreadable, coloured it could not be piped anywhere useful, and the nodes
themselves are still in `contacts`. The QR codes are a second rendering of
a link the output already prints.

ui.ack joins ui.note to draw the line the change needed: a note is output,
an acknowledgement is reassurance a person wants and a script must filter
out, so the menu prints it and the CLI drops it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 00:52:48 -04:00
Jean-Pierre MartineauandClaude Opus 5 2790159d04 Ask for a window MeshTerm fits in, rather than taking whatever is open
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
JP saw the PicoCalc's wordmark on a desktop session. The splash picks the
widest mark that fits, the desktop one is 71 columns, and below that it swaps
to the 53 drawn for the handheld -- so the app was announcing itself as the
handheld and truncating every menu description onto the pager underneath.

The window was 64 columns. reopen() passed no window argument, so wt.exe put
the session in a *tab* of whatever window happened to be open and it inherited
that size; with no window open it would instead have taken the reader's global
launch size, which is a setting about their shell and nothing to do with this
app. Either way the app could land somewhere too small for it, and the failure
was quiet and cosmetic rather than anything that announced itself.

It now asks for a window of its own at the platform's stated minimum plus a
margin -- 80x30 on the desktop, derived from readable_cols/readable_rows so it
follows them. Windows Terminal clamps to what the display can show, so a small
screen degrades exactly as it did before.

The tab is titled MeshTerm too. It was showing the executable's full path,
which on a downloaded build is a line of Downloads folder. The icon is not ours
to set -- Windows Terminal takes that from a profile and a bare command line is
not one.

Release 0.2.8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 20:28:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 690807370b Take them to the better terminal instead of asking permission to
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
The classic Windows console cannot draw a single MeshTerm icon whatever font
it is given, so "reopen in Windows Terminal?" had exactly one sensible answer
-- and was being put to someone who had not seen the app yet and so had nothing
to weigh it against, at the one moment nothing had been drawn.

So it moves. It still says what it is doing and still names the way to stop it,
because a program that opens a window you did not ask for should say so.

Installing a font keeps its consent, and the split is deliberate: moving window
costs nothing and undoes itself by closing, while a font is a file written to
someone's machine that Windows will not let go of until their next logon.

The console_setup preference reads auto/off now rather than ask/off, and its
help says what it does. An older stored "ask" is not "off", so it keeps working
and simply means auto.

JP's call, and it is the right one -- the reference experience should be what
an end user gets, not what they think to ask for.

Release 0.2.7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 19:42:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 564e051d1a The reopened session must not inherit the bundle's own bookkeeping
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
Found by testing the release artifact rather than the repo. A PyInstaller
one-file build unpacks itself and re-runs itself, the halves coordinating
through _PYI* environment variables. reopen() handed the whole environment to
the new copy, so its bootloader concluded it was stage two of a launch nobody
had made, checked that its parent was the same executable, found wt.exe, and
aborted:

    [PYI-33368:ERROR] Security validation failure: parent process has
    different executable!
    [process exited with code 4294967295]

A new window holding a message nobody can act on, with the window that offered
the move already gone. Strictly worse than not offering.

child_environment() strips them, and restores the loader paths the bootloader
parks aside. Verified against a locally frozen build: before, the bootloader
error; after, the session comes up in Windows Terminal with its emoji and its
charts.

The source build could never have shown this. Same shape as 0.2.2's empty tool
registry -- the packaged build is a platform of its own, and the only way to
test it is to run it.

Release 0.2.6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:57:10 -04:00
Jean-Pierre MartineauandClaude Opus 5 54bbb93233 Offer the terminal, then the font -- in that order
The font work made the classic Windows console as good as it can be, and it
still cannot show a single icon. No font fixes that: the only two fonts on a
Windows machine carrying emoji glyphs are Segoe UI Emoji and Segoe UI Symbol,
both proportional, and a console will not take a proportional font. Measured
across every font installed here, no monospace face has more than one of the
33 emoji MeshTerm draws.

What actually decides all of this is the terminal, not the font. A modern one
borrows a missing glyph from another font on the machine, which is why the app
looks right in Windows Terminal with a font (Hack Nerd Font) that holds no
braille and no emoji itself. The classic console is the one host that does not,
so the fix for it is to leave it.

So the first offer is now to reopen in Windows Terminal -- present on every
Windows 11 machine, free on 10 -- asked with yes as the default, since it
installs nothing and changes nothing. The font offer stays as the fallback for
a reader who declines or has no Windows Terminal, and keeps its own second ask.
One preference, console_setup, remembers a refusal of either.

Verified end to end: launched in cmd.exe, accepted, and the session came back
up in Windows Terminal with colour emoji, braille charts and rounded borders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:16:23 -04:00
Jean-Pierre MartineauandClaude Opus 5 5158268a73 A font we installed ourselves has to be loaded before it can be selected
Found by testing the second launch rather than the first. AddFontResourceW
adds a font to the running process's font table; the HKCU registration beside
it is what makes it permanent, and Windows reads that at the next logon. So
between an install and a logoff there is a window where a new MeshTerm process
finds its own registration, believes the font is ready, and selects nothing --
SetCurrentConsoleFontEx reports success and quietly keeps the old font.

consolefont.use() retries once after loading our own copy, which is a no-op
for the Cascadia that came with Windows and the fix for the one we put there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:00:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 f06b593a0f Ship a font the charts can be drawn in, and offer to install it
Every timeline in MeshTerm is made of braille characters, and almost no
monospace font has them. Reading the cmap of every font on this machine:
Consolas, Lucida Console, Hack Nerd Font, JetBrains Mono, Fira Code and
Source Code Pro all carry zero of the 256 -- and so does DejaVu Sans *Mono*,
which is where the widespread "install DejaVu for braille" advice falls down.
DejaVu *Sans*, proportional, has all 256. That is the whole trick: terminals
fall back to a proportional face for the block, and the charts have always
been drawn by a font nobody chose.

Which is fine everywhere except the one host that does no fallback. In the
classic Windows console -- what a double-clicked build gets -- Consolas draws
57 of the 122 characters we use and none of the charts.

So MeshTerm now bundles Cascadia Mono PL (SIL OFL, licence beside it) and
offers it there. Cascadia is one of two mainstream coder fonts with the block;
the PL build adds the powerline separators the path lines want, and at 723KB
covers exactly what the 2.4MB Nerd Font build does. A machine that already has
Cascadia -- every Windows 11, anyone with Windows Terminal -- is only offered
the switch.

Both halves are verified on Windows 10 22H2, and neither needs admin: a font
installs for one user under %LOCALAPPDATA% with AddFontResourceW and an HKCU
registration, and SetCurrentConsoleFontEx selects a face the console's own
properties dialog will not even list -- that dialog offers only what is under
the machine-wide Console\TrueTypeFont key, which here is Lucida Console and
Consolas, so without that API the font could be installed and still unpickable.

Declining is answered with what declining looks like -- charts as rows of
boxes -- and asked once more, because a reader who has never seen the app
cannot picture what it costs. A second no is remembered in the console_font
preference and never raised again.

Windows locks a font file once loaded: it cannot be deleted even elevated,
even after restarting the font cache, until the next logon. So nothing here
offers to uninstall one, and the install is written to be repeatable instead
of reversible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 16:56:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 d062b5b706 The Windows console call that broke the app is ours alone
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
MeshTerm died building its screen on Windows with a ctypes type error, and on a
double-clicked build the traceback and the window it printed to were destroyed
together — which is why this needed a log file before it could be looked at.

`ctypes.windll.kernel32` is a cache. Every caller in the process receives the same
object and each function on it is a shared attribute, so declaring `.argtypes` is
not configuration, it is a process-wide mutation where the last writer wins. The
emoji-width probe declared `GetConsoleScreenBufferInfo` as taking a pointer to its
own copy of the screen-buffer struct; prompt_toolkit then called that same function
object with a pointer to its own copy — same layout, different class — and ctypes
refused it.

Nothing here reaches for `ctypes.windll` any more. Five call sites take private
handles from `core/win32dll.py`, built by constructing `WinDLL` directly so each
one gets its own function cache and a declaration made for one purpose cannot be
seen by anything else. Each call returns a fresh handle, because two parts of
MeshTerm disagreeing about a signature is the same bug in miniature.

The regression test was checked against the defect rather than assumed to cover
it: with the old line restored it fails, and with the fix it passes. A test that
passes either way would only have recorded that somebody thought about the
problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 15:16:26 -04:00
Jean-Pierre MartineauandClaude Opus 5 bdc16fc448 The log is a text file that records what went wrong
Three things were true of the log at once: it was JSON Lines, it never rotated,
and it almost never recorded a failure. Of the last twenty thousand records in a
real one, nine were warnings and three were errors. It faithfully kept the boring
parts and missed the interesting ones, which is exactly backwards for the only
job it has.

It is plain text now, `~/.meshterm/meshterm.log`, one line per record with the
traceback indented under the line that raised it. JSON Lines was there in case
something replayed it; nothing ever did, and the actual reader is a person who has
hit a problem and is about to paste the last twenty lines into an issue. That
reader has a text editor.

It rotates, at 2MB across three files. The old handler grew without limit and a
real one reached 11MB over ten weeks — a lot of disk on a handheld, and an
unreasonable thing to ask someone to attach.

How much it keeps is a preference, `log_level` under a new Diagnostics group,
defaulting to WARNING. That is what makes the file useful rather than large: it
holds the problems, and turning it up to DEBUG for one run is how you chase
something. Loaded before logging is configured, since it decides how logging is
configured, and handed to the context afterwards so the file is not read twice.

And failures are written down at last. Every unhandled error reaches the log with
its traceback and the terminal says where to find it; expected errors — an
ambiguous device, a bad preference — go in as warnings, because a log that only
holds crashes cannot answer "what happened just before". The bug form now asks for
the file, which it had no reason to before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:43:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 e2161fd191 Two MeshTerms can share a machine, but not a directory
Every copy on a machine reads and writes the same ~/.meshterm — a checkout, a
downloaded build, a second one for a second radio — and nothing about how it was
installed changes that. Two at once will not corrupt anything, but each holds the
JSON stores in memory and rewrites them whole, so the second to save discards the
first's changes. A lost contact is much harder to notice than a crash.

The interactive session now takes an exclusive lock on the directory it is using,
and a second one is refused with the fix in the message: set MESHTERM_HOME. The
lock belongs to the operating system rather than to a process id written in a
file, because a pid file has to answer "is that still alive?", the answer differs
per platform, and it is wrong once a reboot recycles the number — so a crash
leaves a lock nobody can explain and everyone learns to delete it. One-shot
subcommands do not take it; blocking `meshterm contacts` because a menu is open in
another window would be an obstacle rather than a guard.

The scratch-file race is fixed at the same time, and it was ten places rather than
the three I first counted: every store under core/ carried the same three lines,
writing to a fixed `.tmp` name beside its file. Two processes saving together took
turns inside that one scratch file and then each renamed whatever was in it over
the real one — the rename is atomic, the thing being renamed was not. They now go
through one `write_atomically`, whose temporary carries the writing process's id,
and which cleans up after a failed write instead of leaving a partial file behind.

And the Windows console error is answered rather than raised. prompt_toolkit needs
a Win32 console; Git Bash, MSYS and Cygwin are not one, and its complaint arrives
as a traceback that reads like a broken application when the answer is "open a
different terminal". It now says that, and points out that every screen has a
subcommand which pipes perfectly well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:31:39 -04:00
Jean-Pierre MartineauandClaude Opus 5 141698678e The downloadable builds actually have the app in them
The Windows build launched, printed help, drew the specimen card, and showed a
menu containing one item: Quit. Not a permissions problem and nothing to do with
Bluetooth — the tools were simply not in the bundle.

`meshterm/tools/__init__.py` finds every tool with `pkgutil.iter_modules` and
imports it so its `@register` decorator fires. Nothing static ever names a tool
module, so PyInstaller had no reason to include one, and the registry came up
empty: no menu entries, no CLI subcommands, an app that starts and does nothing.
`collect_submodules("meshterm.tools")` fixes it, which is the same treatment
`bleak.backends` already had for the same reason — the spec knew about other
people's dynamic imports and not about ours.

The worse half is that CI said this was fine. The smoke test ran `--help` and
`specimen`, and a build with no tools at all passes both. It now runs a
tool-provided subcommand, so an empty registry fails the pipeline instead of
reaching a release page. Green tests that cannot see the defect are worse than no
tests, because they are believed.

`MESHTERM_HOME` comes out of the same session. Every copy of MeshTerm reads and
writes `~/.meshterm`, so trying a downloaded build meant pointing it at the same
34MB history database as the checkout it was built from, with no way to keep them
apart. The variable moves the whole directory. An empty or whitespace value falls
back rather than resolving to the current directory, because a shell exporting it
blank is common and scattering someone's history into their downloads folder is a
poor way to find that out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:49:37 -04:00
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 466fd33d34 The package's docstrings lead with a sentence
`D205` across the 20 sites in `meshterm/` and `scripts/`, which leaves the
package and the scripts fully ruff-clean.

None of these were a summary that happened to wrap. They were a paragraph with
no summary at all, so the fix is additive: a plain sentence saying what the
thing *is* goes on the first line, and the prose that was there becomes the
description under it. "The transport of the current/selected connection:
``"serial"``, ``"ble"``, ``"tcp"``, or ``None``" now opens with "Which
transport the current or selected connection uses" and lists the four values
below; `_emit` leads with what it returns before the four paragraphs on why a
wide glyph forces a whole-row repaint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:30:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 b5006cc415 Every line fits the hundred columns it was configured for
`E501` across 69 lines, wrapped rather than exempted.

The prose ones — module docstrings in `menu`, `node_detail_screen`,
`pathline`, `pathgraph`, `walk_screen`, `trace_runner` and `test_about` — are
reflowed as paragraphs, so the block keeps its shape instead of gaining one
short orphan line. Nothing they say changed.

The code ones mostly gained a line break inside a call. Where a break would
have read worse than a name, the sub-expression got one instead: `reach` in
`topology`'s quality clamp, `plain_cells` in `pathline`, `numbering` in the
trace list, `listed` in `flash.py`'s refusal, and a small `shape`/`is_strip`
helper in two tests that were comparing or matching the same shape twice on one
line. Four docstring summaries lost a word they were not using.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:22:52 -04:00
Jean-Pierre MartineauandClaude Opus 5 61b6387269 Every zip says whether its sides are the same length
`B905` over 56 sites, and the answer differs by idiom rather than by file.

21 were the sliding window `zip(xs, xs[1:])` — ragged by exactly one, on
purpose, so `strict=` is the wrong question entirely. Those become
`itertools.pairwise`, which is what they always meant and needs no flag: the
walk's SNR stops, the heat ladder, the route chains in `topology`/`records`,
every polyline in `mapcanvas`, and the graph's edge sets in `pathgraph`.

The other 35 pair sequences that must line up, so they now say `strict=True`
and a mismatch raises instead of silently truncating the longer side. Running
them under that assertion is what sorted the two groups: the first pass put
`strict=True` everywhere and 208 tests failed, all of them a sliding window.

One goes the other way. `mvt._decode_feature` reads tags off a network tile,
where an odd-length list means the tile is malformed — and this decoder answers
that with a shrug everywhere else (`if not geom_ints: return None`, bounds-
checked key and value indices). It keeps `strict=False`, now with a comment
saying why, so a bad basemap tile still can't crash the map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:12:07 -04:00
Jean-Pierre MartineauandClaude Opus 5 71b16ec7b7 The annotations, imports, and dead locals answer to ruff
`ruff check --fix` over the whole tree, ahead of putting the linter in CI:
1977 mechanical fixes, overwhelmingly `Optional[X]` → `X | None` (1186 in the
package alone) and the quoted annotations that `from __future__ import
annotations` already made unnecessary, plus import sorting and unused imports.
Nothing here changes behaviour — the suite is 1885 green before and after.

Four findings were real rather than stylistic and are fixed by hand: a dead
`cells` range in `MapCanvas`, a dead slot dict in the heat-ladder test, a
`ProgressScreen` task handle nobody read, and a tab loop binding a `label` it
never used. The two `F821`s were quoted forward references to names imported
inside their function bodies — now declared under `TYPE_CHECKING`, which keeps
`test_performance`'s local import (it guards the startup import graph) while
letting the linter resolve the name.

412 findings remain and are deliberately untouched: they need a decision about
the rule, not an edit to the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:26:12 -04:00
Jean-Pierre MartineauandClaude Opus 5 1e4fad4d2d A wait you can see is a wait you can leave
Every feature paced its own bursts privately, so nothing knew what anything else
had just put on the air, and a wait was a frozen screen -- which on a radio app
reads as a dead link rather than as courtesy.

There is a shared clock now. Every real transmission marks it, in the five send
methods that are the only places the radio is actually spoken to (the simulator
marks the same points, so the whole thing is walkable with no hardware). Anything
about to transmit can ask what is still owed.

Two clocks, because two kinds of transmission cost the mesh very differently. The
general one is the transmit cooldown, whose minimum is now 0.1s -- a floor, not an
off switch; there is no zero for duty-cycle courtesy. Flood adverts get their own,
floored at 5s and defaulting to a minute: every repeater in range rebroadcasts one,
and it waits out both clocks.

Under two seconds the wait just happens -- a dialog that flashes up is itself the
interruption. Over it, a countdown floats over whatever asked: the seconds left,
why it is waiting, and a Cancel chip. Enter and Esc both take it, sharing one hint
atom because they do the one thing on offer, and cancelling abandons the action
rather than merely the wait. It resolves itself at zero.

The suite resets the clock between tests. It is process-wide, which is right for a
session and wrong for a run: one test sending something left a cooldown standing
for the next, whose scripted flow then stopped on a countdown nobody wrote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:19:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 407b893819 A row is named after the feature it belongs to
The section holding the sweep bounds was called Transmit power, which is also
the name of a device setting one screen over -- so it read as a second place to
set the radio's power rather than as the search that finds one. It is TX
optimize now, after the feature it configures, and its rows are Range min and
Range max: the two ends of what that feature sweeps.

Their descriptions lost "the power search" along the way, which had been a third
name for the same thing. The heading says which feature; the rows only have to
say which end.

Pause between sends goes back to Transmit cooldown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:58:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 03f8e1496f Every row says what it does, not what it is
The descriptions were written for someone who already knew the answer. A user
reading "Airtime budget: duty cycle = 100 / (factor + 1) %" or "Per-hop
path-hash size; larger resists collisions, adds overhead" has to ask, which is
the opposite of what a description is for.

So both registries now answer the reader's actual question -- what does this do
to me -- in as few words as it takes. "Wider is faster, narrower reaches
further" instead of naming the unit twice. "Caps how much of the air we use;
0 = no cap" instead of the formula. "How loud this radio transmits."

Labels went with them where the name was ours to choose: Sweep floor and Sweep
ceiling are Lowest power and Highest power, Opening frame is Map fit, Silence
alarm is Alert after, Basemap source is Map tiles. The radio's own names stay --
someone matching Spreading factor against their phone app should find the words
they came for. History kept is just History.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:51:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 fe4780e070 The page keeps what MeshTerm alone decides
Background adverts came off it. The cadence is the *companion's*, remembered per
public key and set on Device config, so a preference could only ever be the value
a radio starts on before you give it one -- a default behind a default, and a row
that says "newly seen device" while meaning "your own radio, the first time".
adverts.json answers for it again; the store is back to the constants it had.

Transmit cooldown now defaults to 5s rather than 1s. It paces every burst we are
allowed to make, and on a shared mesh the polite number is the larger one.

The last-column choices answer their own row: it asks whether to use the column a
terminal hides, so the values are auto, yes, no instead of naming the mechanism a
second time. YAML 1.1 reads a bare yes/no as a boolean, so a hand-edited file that
writes the obvious thing is understood rather than silently dropped -- we still
quote ours on the way out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:46:40 -04:00
Jean-Pierre MartineauandClaude Opus 5 cef7a764af How MeshTerm behaves gets a page of its own
Three kinds of "setting" were tangled into two files and one of them had no
screen at all. The radio's settings live on the radio and are edited on Device
config. Machine setup -- where the database is, which companion to talk to --
stays in config.toml, a text editor's job. Everything in between was app
behaviour: six keys sitting in config.toml with nothing to open them, and a
dozen more that were only module constants a reader could never reach.

Those are preferences now, and they have a page. core/preferences.py is a
PrefSpec registry in the shape of the device-config one -- group, type, bounds,
help, and a default -- and the page is built from the same parts as the device
editor, deliberately: one grouped lane-aligned list, current -> new staged in
warn, Apply/Back appearing only once something is staged, the shared discard
confirm on the way out. Two editors that stage values should not be two screens
to learn. What it adds is a Defaults section holding one row that stages every
changed preference back to its built-in value; it is drawn only while a reset
would do something, and it stages rather than writes, so it is as reviewable and
as Esc-discardable as any other change and the file is still only ever written
by the one action at the bottom. Its rows scroll sideways where the device
editor's don't: the explanation is the widest thing on the row and the only part
that runs past the edge, so <-> slide it under a pinned setting and value.

The file is preferences.yaml and holds only the values you disagree with the
code about -- grouped and commented, each entry above its help and its default.
That is what lets a later change to a default reach an install that never asked
to be exempt; delete a line and the code takes over again. A hand edit gone
wrong costs that line, never the session.

Every preference is wired to something that reads it. ctx.preferences is the
explicit path; preferences.current() is for the render layer, which has no
context to reach through, the same reason get_platform() exists.

The About pages come along into a This app section, the third scope after this
node and other nodes, with Preferences leading it -- the one row there that
changes MeshTerm rather than describing it. A section of one would have broken
the menu's own no-bucket-of-one rule, and the app's scope is the honest home for
both halves anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:36:40 -04:00
Jean-Pierre MartineauandClaude Opus 5 6924ff7f45 The presets are MeshCore's, not ours
Every one of the region presets named a modulation no mesh runs any more, and
two of them named a frequency nobody was ever on.

MeshTerm's list was three regions on 250 kHz / SF11 — the shape MeshCore
shipped with — plus two trade-off rows built on the EU frequency. Through 2025
most regions left that modulation for a narrow 62.5 kHz one, so the EU row is
what MeshCore now calls "EU/UK (Deprecated)", the US row had the right
frequency with the wrong modem (910.525 is SF7/BW62.5/CR5), and "AU / NZ 915"
at 915.525 was not a preset at all: Australia is 915.800 wide, 916.575 narrow,
923.125 in SA/WA and QLD, and New Zealand is 917.375. Four parameters have to
match for two nodes to hear each other, so a stale table is a radio staged onto
an empty channel.

The list is now MeshCore's own suggested settings, mirrored verbatim — names,
order and values — from what its apps, the web flasher and config.meshcore.dev
all read at api.meshcore.nz/api/v1/config. Twenty-three regions, including the
two MeshCore still lists under its own "(Deprecated)" names, because nodes that
never re-tuned are on them and picking one is how you go and listen. The names
are spelled as MeshCore spells them so the row can be read against the phone
app.

Four regions also name a path-hash size, which the preset now stages alongside
the radio fields as the firmware's mode (size - 1), exactly as MeshCore's own
config GUI converts it. The rows moved to the two-lane menu shape — name, then
the parameters in MeshCore's print order — which fits 53 columns where the old
one-line title ran to 85, and the list opens on the preset the radio is already
tuned to, staged values included, so picking a neighbour is a comparison rather
than a guess.

The trade-off pair is gone with them. They were ours rather than MeshCore's,
they pinned a deprecated EU frequency onto whoever picked them, and the wide /
mid / narrow choice they were reaching for is one the region list now makes
properly per region.

The simulator boots on the firmware's build defaults too (869.618, BW 62.5,
SF8, CR5 per MeshCore's platformio.ini), rather than on the settings the EU
mesh left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 19:17:57 -04:00
Jean-Pierre MartineauandClaude Opus 5 0b719a4736 The client nobody closed, closed
A Bluetooth session that lost its companion could not get it back, and the
reason was one call that four separate layers each declined to make (JP,
2026-09-04).

When the peripheral is what goes away, bleak fires `BLEConnection`'s
dropped-link callback, which restores the connection's fields to what the
caller originally passed — setting `client` back to None and letting go of the
live object. `BLEConnection.disconnect` then guards on `self.client and
self.client.is_connected` and has nothing to close; `ConnectionManager
.disconnect` guards on a flag its own drop handler already cleared;
`_force_close_transport` reaches past both, but only as far as that same
`BLEConnection`, whose disconnect is the no-op above. Every guard is locally
right and the sum of them is that `BleakClient.disconnect()` is never called
on a link that dropped by itself.

On Windows the WinRT `BluetoothLEDevice` and its GATT session then stay open
for the life of the process, and every later connect to that address is served
a broken service table: the link comes up and the UART subscribe fails with
"Characteristic 6E400003-… was not found!". The device advertises at -61 dBm,
answers a hand-written APP_START over raw GATT with a valid SELF_INFO, and is
unreachable to this app until it restarts — while a fresh process connects to
it in 0.6s. That asymmetry is the whole tell, and it is what turned every
reconnect attempt into a fast, inexplicable failure.

So the client is ours now: taken at connect while the library still has one to
give, and closed unconditionally at teardown — never guarded on `is_connected`,
because the case that matters is exactly the one where it is already False.
Bounded and silent, since a teardown may not raise or hang, and a no-op on the
transports that have no client to hold.

Measured on the real companion: the session drops, is heard again 7.2s after
the device comes back, and is rebuilt 0.97s later — 8.3s from drop to live,
where it previously never recovered at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 03:49:37 -04:00
Jean-Pierre MartineauandClaude Opus 5 c090db829c A returning companion is heard, not guessed at
Bluetooth notices a drop cleanly — the client's connection flag flips the
moment the peripheral goes — but the way back was two guesses stacked on each
other, and neither held (JP, 2026-09-04).

The reconnect flow gave serial a real question to ask ("has the port
re-enumerated?") and gave Bluetooth nothing: it simply attempted the connect
every couple of seconds. Each of those blind attempts drove bleak's own
internal address lookup into a device that wasn't advertising, which is a slow
failure, so the attempts sprawled across and past the window in which the
companion actually came back. And when one finally did land, it landed holding
the `BLEDevice` the startup scan produced — a handle that names a peripheral
the OS no longer has, because a device that power-cycles comes back as a new
one. The session opened over BLE and then, having dropped, could not find its
way back to a radio sitting a foot away with its light on.

BLE now gets the same shape serial has, asked of the only registry Bluetooth
keeps: `find_ble_device` watches for that one address's advertisement and
returns the instant it is heard. The answer arrives with a live handle, so the
listening and the fixing are one step — the reconnect opens the peripheral
that is on the air now, and `reconnect(ble_device=...)` prefers it over
anything the picker scanned. A miss costs a scan window rather than a failed
connect, and nothing is attempted against a device that isn't there.

The ordering is load-bearing, so the teardown splits out as `release_link` and
runs before the first scan: a companion we are still holding a link to does
not advertise, and the old flow's first act was a connect attempt that dropped
the dead client on its way past. Waiting first would have waited out a device
that was powered on and silent purely because we hadn't let go of it. The
resume intent is captured there and held across retries exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 03:11:16 -04:00
Jean-Pierre MartineauandClaude Opus 5 c8c7eab128 A direct message's frames are its own, by the MAC over its ciphertext
The paths on a DM listed a message's own retransmissions, the other end's
traffic, and both of those for every other message sent in the same minute and
a half. On one real bob_caribou message that was 30 rows over 8 paths, of
which 23 were the same two one-hop routes repeated. Those one-hops were the
tell (JP): they were our *own* sends, overheard coming back off the two
repeaters in earshot, correctly showing one hop because one hop is all they had
travelled — but shown under the message as if they were its route.

The channel view has always matched by decrypted content, and asked for here.
A direct frame cannot be read, but it does not need to be: its body is
[dest 1B][src 1B][MAC 2B][ciphertext], and that MAC is a tag over this
message's plaintext. Copies of one message share it; the next message's differ.
frame_addressing already knew where those two bytes were and threw them away,
and the observations table already had a cipher_mac column its channel sibling
used — so a direct message now groups exactly as a channel one does, with no
schema change and nothing decrypted.

Three filters, in order. The frame ran between the conversation's two ends; it
travelled the direction this message did; it carries its group's MAC. The
middle one is doing more work than it looks: both ends' hashes sit on every
frame either way, so matching on the pair alone was what put our sends under a
received message.

The window is clamped by the conversation's own messages rather than a flat
±90 s — nine messages fell inside one such window. The clamp reads only
messages going the *same* way, which the first cut of it got wrong: an inbound
stamp is the sender's clock, so ordering it against our own sends compares two
clocks, and a received message came back with an empty window. The directions
are also bounded differently, because we know different things about them. A
send is stamped by our clock as it leaves, so no frame of it predates it and
all the room goes forward to the next send, where the retries are. A received
message keeps a centred window, halfway to the received messages either side.

Rows are paths now, not frames: identical routes fold into one line carrying
its copy count, its first sighting, and the best SNR that path managed. Twelve
identical lines said "twelve arrivals" where the truth was "two paths, six
times each".

Measured on the conversation that prompted this, whose history predates the
MAC and so exercises only the direction and window fixes:

  outbound "Allo! Ça marche JF"   30 rows / 8 paths -> 2 rows (x3 each)
  inbound  "J'ai pas de ack..."   30 rows / 8 paths -> 2 rows, 6 and 7 hops

which is exactly what the packet log says by hand. Old history keeps the
address-and-time fallback and is billed as such; frames recorded from now on
match exactly, and say so in the same words the channel view uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 23:50:22 -04:00
Jean-Pierre MartineauandClaude Opus 5 424c33ac93 The purge is an edit, and archiving is not a deletion
JP's pass over the sweep, and one cut corner it found: the gallery specimen
hand-stamped its percentiles, so three contacts rendered as the 2nd, 7th and
11th of a field of three. The number cannot be written down and still be true
— it is a rank against the others in the same list — so the specimen now
scores through rank_contacts like everything else and reads 10/30/50. The
computation was never wrong; the picture of it was.

The ladder answers in the number the reader is choosing: how many contacts
you *keep*, the one that has to fit the device, with what goes as the muted
remainder. It also stops rounding with round(), whose banker's habit sent 4.5
and 3.75 to the same 4 and made "keep 90%" and "keep 75%" identical rungs on
a five-contact table; ceil is both distinct and the safe direction on a keep.

The preview is editable. Delete lifts one contact out of the sweep and the
list refreshes through replace_items, so sparing the one node you recognised
no longer means backing out to a shallower rung that also spares forty you
didn't care about. Rows h-scroll with the percentile lane pinned out of the
shift — it is your place in a list ordered by it. Neither the scroll atom nor
the keep atom is written into the base hint; the select list splices each in
where its key would act, which is also what keeps all three inside 72 cells.
It is a hub now, held by session.stay: re-running run_screen on a screen that
had already resolved spun forever the first time a row was spared.

Opening a candidate's page from in there offered to delete it, which was two
answers to one question. open_node_detail takes manage=False: the rule is
that management verbs belong to the list a contact lives in, never to a page
opened out of a list about to act on it wholesale.

The confirm is amber and button-only. Two caution tiers exist for two costs,
and a red typed gate here spent the app's loudest warning on its most
reversible bulk action. Red is left to the deletion, which now says what it
is — the key forgotten by both sides, no restore, only being heard again —
and points at the archive as the reversible way to free the same slot. The
page carries all three verbs: archive a live contact, restore an archived
one, delete either for good.

Archived contacts get their own screen rather than a section nailed to the
foot of the Contacts list, reached by a row under the purge carrying the
tally. That needed the shared contact list to stop believing every list has
the same columns: the middle lanes are now a declared tuple of ContactLane
(NAME and KEY stay fixtures), read by the header, the row builder, the sort
metric and the width arithmetic alike, replacing a show_traced flag that had
already made three of those four say "and if it's the trace picker…". The
archived list declares NAME · ARCHIVED · KEY and sorts on exactly those.
ARCHIVED draws as HEARD does — a relative age under recency heat — because
it is the same question about a different event.

One simplification falls out: TRACED sorted on a raw timestamp and opened
descending to mean newest-first, alone among the age columns. Every age lane
now sorts by age with never at +inf, so traced opens ascending like heard,
and reads the same either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 02:03:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 99cd616ae5 The purge keeps the contacts worth a slot, and archives the rest
A device's contact table is finite, and a busy mesh fills it with whatever
adverts arrived — mostly nodes heard once, in passing, four hops out — until
there is no room left to discover anyone new. The age ladder could only ask
how long ago; it had nothing to say about which of two equally quiet contacts
was the one you would miss.

So contacts are now rated. Six weighted terms, each normalised and summed:
direct correspondence heaviest at 30, then how lately heard (25), how often
(15), channel posting (12), hops (10) and distance (8). Additive rather than
multiplicative, because a product lets one zero annihilate a contact that is
strong everywhere else — a node advertising no position would have scored
nothing however much you talk to it.

The rule the whole thing rests on is that an unknown scores *neutral*. Half a
real mesh advertises no location, plenty of nodes are never overheard as a
relayed frame so carry no hop count, and a channel poster whose name two
contacts share can be attributed to neither — the wire gives a channel frame
no sender key at all. A term that cannot be computed takes the population
median of the contacts that could, so the decision falls to the axes that are
known. Scoring those as zero would have swept by how much metadata a node
happens to broadcast. It is also why the two least reliable terms carry the
two smallest weights, and why distance calibrates against this mesh's own
median spread rather than a constant: 50 km is far downtown and unremarkable
in a valley.

A newcomer gets 25 points decaying to nothing over a fortnight, because every
other term rewards accumulated evidence and a node first heard this morning is
otherwise arithmetically identical to one quiet for a year. Four things are
never candidates at all: a watched node, anyone you have sent a message to, a
repeater whose admin login is stored, and a contact MeshTerm has never heard —
that last one predates this history, and sweeping it would be purging by how
long the app has been running. They still rank, since a percentile that
dropped them would not be a rank against your contacts; they just consume no
kept slot, so starring a node never deepens the next sweep.

Only the percentile is ever displayed. A raw 47.3 means nothing without the
distribution it came from and would need a legend the moment the weights were
retuned.

The victims are archived, not deleted: off the device, which is the scarce
resource, and kept here with a stamp. Everything else was already independent
of the contact row — reception history, map position, transcripts — and the
store keeps the full key, so a restore is one write, which the chat screen
already performs on demand when a send is rejected for an unknown recipient.
merge_contacts learns to withhold them, or the union would put them straight
back and the sweep would look like it did nothing.

The old age ladder survives as the second section of the one picker, because
"everything I haven't heard in a year" is a plain predictable operation a
ranking cannot express. Both routes converge on the same preview — weakest
first, with each row's percentile and the two or three facts that put it
there — so an age rung reaching into contacts that rank perfectly well says so
before the typed confirm rather than after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 00:32:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 0fa8290add Contacts: a contact the device never held is still ours to remove
The list a screen removes from is the union of the device's contact table and
the contacts MeshTerm remembers for it, so an entry can exist only on our side
— or the firmware can drop one since we last read. Removing such a contact hit
ERR_CODE_NOT_FOUND and reported "couldn't remove", leaving a row the reader had
no way to get rid of.

The device layer now raises ContactNotOnDeviceError for that code, the same
class (and the same table lookup) the send path already raises. The single
removal reports it as a warning naming what happened, forgets our half and ends
the visit; the purge sweep counts it as purged rather than failed. The
simulator refuses the same way, so the path is walkable under --mock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:24:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 c6e6ee8a60 Chat: a contact the device forgot is offered back, not a wire error
Sending to a contact the firmware no longer holds came back as
`send failed: failed to send message to 'bob_caribou': {'error_code': 2,
'code_string': 'ERR_CODE_NOT_FOUND'}` — a wire constant in a status line, and
no hint of what to do about it.

It is reachable for a contact MeshTerm itself listed. The list a screen sees is
the union of the device's live table and the contacts MeshTerm remembers for
that device (core/contact_store), which exists so a RAM-only radio bridge still
shows the nodes you know. The store claims those stay messageable without
writing anything back — true for the bridge, false for a firmware companion,
which addresses a direct message by looking the recipient up in its *own* table
by a key prefix and refuses when nothing matches. So a contact the firmware has
dropped still lists, still opens a chat, and only the send finds out it is gone.

Now the rejection is its own class carrying the contact, and the chat offers the
one write that fixes it: "bob_caribou isn't in this device's contacts, so the
radio can't address it. Add it back and send?" — Cancel or Add & send, then the
same send runs again. Declining reports the refusal in words. The write is
offered rather than silent because it changes what the device stores, and a full
contact table refuses it, which is worth seeing. Retry (^R) takes the same path.

Adding a contact is new to the Device contract (the inverse of remove_contact,
ADD_UPDATE_CONTACT on the wire): the record carries the key, name, type and last
advertised position we already hold, with no learned route, so the first message
floods exactly as it would for a freshly-heard node. The name is clipped to the
32 *bytes* the firmware field holds, not 32 characters.

Every other rejection now reads as a sentence too — reject_reason maps the six
error codes to plain words, so a send, an admin command, or any _ok()'d command
says "the device's table is full" rather than printing its payload dict. The
simulator refuses a recipient it doesn't hold, exactly as hardware does, so the
whole offer is walkable under --mock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:43:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 e0a681b293 Busy screens: stop the spinner starving the work it reports on
On the PicoCalc a repaint costs about 154ms, and these two loops waited 60ms
(the overlay) and 120ms (the splash) between them — rates hardcoded past
spinner_interval(), which every other animated wait in the app already sleeps
on. The animation asked for repaints faster than the console could finish them,
so it took the event loop from the device read it was covering for: the wait got
slower for being animated, and the spinner stuttered anyway. Both defer to the
platform now (0.5s there, 0.12s on the desktop).

Two calls were also blocking the loop outright, which stops the animation dead
however it is paced. Enumerating serial ports is a SetupAPI/sysfs walk — behind
the "Scanning for companion devices…" splash, and again on every poll of the
reconnect dialog — so both go to a thread. The UI itself cannot: prompt_toolkit
owns the loop for render and keys. Moving the blocking work off it is the same
fix from the other side.

Two tests: neither busy screen may carry a spin rate of its own, and the
spinner advances *during* an awaited read rather than only side of it.

Also drops the "· experimental" tag from the network-device row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 02:12:26 -04:00
Jean-Pierre MartineauandClaude Opus 5 4e3208b1c2 Admin login: the repeater is not charged for the queue ahead of it
Remote administration still failed. My last two commits classified the failure correctly
and fixed the credential policy, but the login itself kept coming back as no reply from
repeaters that were up, traced, and holding the right password.

The wait I added was counted from the wrong moment. `budget` is the *node's* answer window,
but the clock started before `send_login_sync`, and almost none of what that call spends is
the node's: it first waits its turn on meshcore's mesh-request lock — which every telemetry
poll, status read and courier retry holds for a whole round trip — and only then blocks
until a MSG_SENT the library correlates by event type alone arrives. Whatever that came to
was subtracted from the repeater's window, so the fix read as "wait longer" and behaved as
"wait the same ten seconds, minus the queue". A send that outlasted the budget left the node
no window at all, and the log bears the shape out: both attempts to YUL-UpperSalaberry ended
at 9985 ms and 10015 ms — the budget exactly, spent from before the request was on the air.

So the answer is timed from the send, the way `run_trace` times its reply. And the route
sizing is a floor rather than a verdict: a login is an admin exchange out and back, not a
trace's single small packet, so a contact we hold a one-hop route for was being given a
narrower window (7.2 s) than the routeless contact beside it that floods (10 s). Knowing
where a node is must not make us give up on it sooner.

The rest is making the next failure readable, because this one was not. A no-reply has three
causes that the old line could not tell apart, so it now reports where the time went — the
send's own duration, the budget, and how long the node had — and quotes the frames it heard
but could not act on: a LOGIN_SUCCESS/LOGIN_FAILED naming some other node (a key prefix that
did not match, not a quiet node), and a companion ERROR, which is the one failure that never
reaches the mesh and which `send_login_sync` erases into the same bare `None`. Uncorrelated,
so it is evidence in the log and never a verdict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 20:18:04 -04:00
Jean-Pierre MartineauandClaude Opus 5 7633f5153d Admin login: hear the answer, even when the library has stopped listening
A repeater that is up, with the right password, was answering "no reply". My last commit
did not cause that — it renamed it. The failure was already there, mislabelled as a wrong
password (and clearing the credential on its way past, which is how it got reported in
the first place).

`send_login_sync` does not start listening until its own send returns, and that send
blocks until a MSG_SENT arrives — which the library correlates by nothing but its event
type. So a scheduled advert, a telemetry poll or the courier can consume ours and leave
the send sitting on its 15-second default, and every answer that lands during the stall
is dispatched to no listener and dropped. The login the repeater accepted is recorded as
silence. The mirror case is as bad: catching another command's MSG_SENT takes its
`suggested_timeout` with it, and a neighbour's second or two is nowhere near a multi-hop
repeater's round trip.

`run_trace` already documents this exact trap and already solves it, so the login now
does what a trace does: subscribe to LOGIN_SUCCESS *and* LOGIN_FAILED before transmitting,
and keep listening across the whole exchange. `send_login_sync` still transmits — it is
the supported path, and its own listener is harmless, the dispatcher delivers to every
matching subscription — but ours is armed first and outlives it. When it gives up early
we keep waiting for the rest of a budget sized to the route: a login walks out along the
contact's hops and back, which is the shape `trace_timeout` already describes, so the
wire carries twice the stored one-way route. A contact we hold no route for floods, and
takes the flood budget.

The classification falls out of that — the two real frames now reach us, so ACCEPTED and
REFUSED are read off the frame instead of inferred from a `None`. The outcome is logged
at debug (budget, verdict, elapsed) so the next "no reply" is diagnosable from the JSONL
rather than by argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:55:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 667c0321e4 Admin login: silence from a node is not a wrong password
Administering a repeater that happened to be down made MeshTerm forget its admin
password. The node had said nothing at all, and we took the silence as a denial.

`admin_login` returned a bool, and all five callers read `False` as *wrong password* and
dropped the credential. Underneath, they could not have done better: meshcore's
`send_login_sync` waits for LOGIN_SUCCESS and only LOGIN_SUCCESS, so a refusal — which
the firmware does send, as a LOGIN_FAILED frame the library parses and dispatches like
any other event — times out exactly like an unreachable node and comes back as the same
`None`. Our own `etype in (ERROR, LOGIN_FAILED)` branch could never fire.

So the device now watches for that frame itself, subscribed before the request goes out
because the refusal can land the moment the request does, and answers with a three-way
`LoginResult`: ACCEPTED, REFUSED (the node said no), NO_REPLY (nothing came back — which
includes a companion-side error, where the request never even left the radio). It is
truthy only when logged in, so `if not await device.admin_login(...)` still reads "we are
not in"; a caller that acts on the failure has to name which failure.

What that means for the stored password now lives in one place, `AdminStore.record`:
remember on accepted, forget on refused, leave it exactly as it is on silence. It was
five copies of the policy, and every copy was wrong the same way. Each surface still
phrases its own message — the sweep says "sweep again", the CLI says "re-run" — and a
no-reply now says so plainly ("No reply from X — it may be out of reach, asleep, or busy.
The saved password was kept") instead of accusing the operator of a typo. The trace
screen's forget-on-DeviceAuthenticationError goes with it: the credential is settled
where the outcome is known, and an exception there is only a message to show.

A source-level test walks the package's AST and holds the line: anything that calls
`admin_login` records the outcome through the store, and nothing reaches for `forget()`
to decide this on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 04:09:33 -04:00
Jean-Pierre MartineauandClaude Opus 5 9ff25b3709 Device config: a coupled setting must not re-send a stale sibling
Restoring a backup onto a freshly flashed companion put the identity and
the channels back, reported all eighteen operations applied, and left the
node advertising from latitude 0.0 on the factory frequency.

Latitude and longitude reach the firmware through one command, and so do
the four radio parameters, so changing one means re-sending its siblings
unchanged. Those siblings are read from the caller's snapshot — taken
once, before the first write. Applying adv_lat preserved the stale
longitude; applying adv_lon then preserved the stale *latitude*, undoing
the first write. Same shape for radio_freq followed by radio_sf. Nothing
errored: each op did exactly what it was told, with a picture of the
device that was two writes out of date.

So each coupled applier now records what it set. Any later sibling in the
same batch preserves the new value rather than the one that was true
before the batch began — whether the batch comes from config restore or
from the editor's staged changes, which had the same hole.

And the Device PIN row, found while writing the restore instructions: it
read 'device_pin' out of a snapshot that only ever held SELF_INFO, while
real firmware reports the code as 'ble_pin' in the device-query frame. It
could therefore only ever render "?" on hardware — writable, with no way
to see what you wrote, so a typo sat in NVS until the next pairing. The
snapshot now folds in that frame (SELF_INFO still wins where they
overlap, as probe_device already had it), the row reads the firmware's
own name while keeping the key its backups and CLI use, and the simulator
reports it the same way so --mock shows the same screen.

Verified on the reflashed device: the row reads 701307, and model and
firmware version arrive with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 03:30:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 337792d4d1 BLE: closing the client is not closing the link
Owning the meshcore client was necessary but not sufficient, and the
device said so: the same probe still went off the air.

ConnectionManager.disconnect closes its transport only if _is_connected,
a flag it sets *after* connection.connect() returns. The GATT auth error
is thrown from start_notify, long after bleak brought the link up and
well before the manager records anything — so the manager is certain
there is nothing to close while BLEConnection is still holding a live,
connected BleakClient. Calling mc.disconnect() and stopping there looks
exactly like a teardown and leaks the link anyway.

So _discard_meshcore now runs both halves: the graceful call, which stops
the dispatcher and cancels any reconnect task, then the transport closed
directly, which is what actually drops the link. BLEConnection.disconnect
re-checks client.is_connected, so the second close is a no-op whenever the
first one sufficed. That forced path already existed inside disconnect()
for the dispatcher's queue.join() deadlock; it is factored out as
_force_close_transport and shared, so the two teardowns cannot drift.

The tests grow a fake that models the manager's guard rather than counting
disconnect() calls, so they assert the link went down instead of asserting
a method was invoked — the distinction the first attempt failed on. A
test named for that mistake pins it; reverting to graceful-only fails
seven of the ten.

Verified on hardware, instrumented: the auth path now shows
is_connected True -> False inside the discard, the client ends at None,
and the peripheral keeps advertising. Full lifecycle clean in one session
with no power-cycle — PIN-less probe, PIN retry, bonded session with real
traffic, mid-session bond query, Unpair & quit, and a relaunch after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 02:03:17 -04:00
Jean-Pierre MartineauandClaude Opus 5 d3735c8f7b BLE: a connect that fails must put the link back down
MeshCore.create_ble builds its client, calls connect() on it, and hands
it back only on success. A connect that *raises* therefore left that
client — and the bleak link it had already opened — orphaned inside the
library with no reference we could close. Our _mc was never assigned, so
disconnect() was a no-op and the probe's _safe_disconnect closed nothing.

That is not a corner: a PIN-protected companion answers the unbonded
notify-subscribe with a GATT authentication error, and the picker's first
attempt is always PIN-less. So selecting an unbonded companion leaked the
link, Windows held it for the life of the process, and the peripheral —
still believing it had a peer — stopped advertising. The PIN dialog then
asked for a code it could no longer deliver: the retry paired at the OS
level (the bond really is created) but could not form a GATT session, and
the user was told their correct PIN belonged to something that "isn't a
MeshCore companion". Since Unpair & quit is what leaves you unbonded, the
whole thing read as unpairing having changed the PIN.

Assemble the two objects here instead. _connect_owned_ble keeps the
handle, so both failure shapes close it: a raise (closed, then re-raised)
and a None handshake (closed, then reported as "not a companion"). The
teardown is shielded — the other way in is a probe whose wait_for expired
and cancelled the handshake, where a plain await would be cancelled the
moment it suspended and abandon the very close it was called to perform.

The picker's accepted PIN now also rides on the context, so a mid-session
reconnect doesn't re-run the whole "needs a PIN" dance against a companion
we already authenticated to. Session-lifetime only; nothing reaches disk.

Tests stop naming a real companion: the addresses and pairing code are
stand-ins now, and the fakes model the class we construct rather than the
create_ble we no longer call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 01:32:42 -04:00
Jean-Pierre Martineau 6217a7dc58 Packets: the frames a neighbour sends were being thrown away
Two silent drops in the RX-log mapper, and between them a device sitting
beside this one could trace all day and never appear in the feed.

A frame that crossed no relays was discarded as having "no topology
content". It has the most there is: nothing stood between us, so we heard
the transmitter itself. Only an advert survived, and only because it
carries an origin key — every trace, message, request and ack from an
adjacent node went into the bin. Now a frame is kept unless the library
could not even give it a payload class, and the empty path says what it
means: zero hops crossed.

The other is a trace reading its own header wrong. Every class keeps
relay hashes in the path field; a trace keeps one signed SNR byte per hop
it has traversed. Read as hashes those bytes resolved to whichever nodes
shared their leading digits and entered the topology graph as links that
were never observed. The population says so plainly: across 1604 stored
traces, all 2221 path entries land inside the LoRa SNR band when read as
signed bytes, spanning -10.5 to +15.0 dB, where hashes would put five in
six outside it. So a trace now yields no hops, and its readings — which
are worth having — ride alongside as trace_snrs.

The 1604 rows already stored keep their bad paths; a one-shot cleanup
follows separately.
2026-08-22 13:57:06 -04:00
Jean-Pierre MartineauandClaude Opus 5 936ab10770 Traces: listen for the reply before the send goes out
A trace subscribed to its tag only after send_trace returned. That send
waits on the companion's MSG_SENT, which the meshcore library correlates
by event type alone — so any other command in flight (a scheduled advert,
a telemetry poll, the courier) can consume ours and leave the send
blocked on its own 15-second default. Every reply that landed inside that
window was dispatched to no listener and dropped, and the walk recorded
as "no reply" though the mesh had answered it. Because the traffic that
steals the acknowledgement is periodic, the condition outlives the trace
that hit it: traces keep failing for as long as it lasts. Subscribe
first, stamp the reply's own arrival so a stalled send inflates no round
trip, and log every trace and every reply heard so a silent walk can be
told apart from a deaf one.

The other half is pacing. run_traces sleeps between its own samples and
never after the last one, so at one trace per candidate — the path
probe's default — it paced nothing at all, and the sweep walked every
candidate back to back. Same hole across the TX optimizer's level
boundaries, and on the trace screen, where aborting a walk and hitting
Enter again transmitted immediately. Repeaters penalize a node that
bursts, and a penalized node's every later trace comes home empty. Pace
between probe candidates and TX levels, and measure the screen's cooldown
from its last transmission rather than only from the previous sample.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 12:30:34 -04:00
Jean-Pierre MartineauandClaude Fable 5 e53e35523e Map: draw the ground off the keystroke, and stop reprojecting the world
A pan cost 0.9-2.4 s. It was never the tiles - those already load in a
thread - it was render_map, which rasterizes the vector ground on the
paint path: 900-2342 ms for a view holding ~16k features.

Two changes, in that order.

The projection was doing the same trigonometry once per vertex. Every
feature_to_dot call recomputed Viewport.origin_world - a sin/log pair -
and a 2** for values constant across the whole frame, 107k times a frame.
The mapping is affine in tile-local coordinates, so tile_transform folds
it into three numbers and _draw_tile spells the arithmetic out inline: two
multiplies and two adds per vertex, no call at all. That alone is 2.4x
(z12 2342 -> 991 ms).

The rest is genuinely per-feature Python and will not shrink to keystroke
size, so the raster moved off the paint path. A paint now returns the best
picture available immediately and a background task draws the real one:
unchanged view serves the finished frame; a view that only gained a tile
keeps the old raster, which is still correctly aligned; a view that *moved*
draws the nodes alone on the new viewport, which is a few ms, so panning
tracks the keys and the streets catch up. The title says "drawing..." while
it does.

Exactly one raster runs at a time and it is always the newest requested -
a held arrow hands us a new viewport every repaint, and starting a second
of thread-bound work per keypress would queue frames that are already
stale on arrival and slow the keystrokes this exists to protect.

On the device: pan 1205 -> 89 ms, 1156 -> 130 ms, 2449 -> 91 ms; zoom
1161 -> 142 ms; a settled view 37 ms.

Also flips MESHTERM_FASTRENDER on by default (=0 still opts out), and asks
whether a loop is running *before* building a coroutine, so a static render
no longer leaves an un-awaited one behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 01:58:08 -04:00
Jean-Pierre MartineauandClaude Opus 5 71e979eadb Tile decode: decide the layer once, on the way past
The narrowing walked each layer twice -- once for _layer_head to read its
name, once to decode it -- which is free when there is a lot to skip and a
straight loss when there isn't. Measured against a cache spanning zooms
2-14: low-zoom tiles carry no buildings, housenumbers or POIs at all
(a z10 tile is 98% drawn layers, a z2 tile 100%), so the whitelist saved
them nothing while the extra walk cost 16-18%.

The name is field 1 and in practice the first thing written, so the walk
already knows by then whether the rest is worth keeping; from there an
unwanted layer is stepped over rather than copied out. Field order isn't
guaranteed though, so the flag is only an optimisation -- the decision is
the check after the loop, which is right however the fields arrived and
also covers a layer declaring no name at all. _layer_head goes away.

On the regular platform this turns a 0.97x into a 1.03x on low-zoom
tiles and 1.00x on the largest, while urban z14 tiles keep their 3.19x;
the whole 293-tile cache goes 1.4x -> 1.6x. On the Lyra the median tile
is 3.24x and the largest 4.85x, both up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:46:32 -04:00
Jean-Pierre MartineauandClaude Opus 5 504ee37b9a Tile cache: write the decoding down, not just the bytes
The layer narrowing cut what a tile costs to parse; this stops us paying
it twice. Decoding is a pure function of the bytes and the layer set, but
the cache only held the bytes, so every session re-parsed the same tiles
from scratch -- and parsing is the single most expensive thing this app
does.

A decoded/ sidecar now sits beside the raw tiles holding the parsed
layers, via marshal: stdlib, C-speed, and it understands the plain
tuples and scalars a decoded tile reduces to. Deliberately not pickle --
this reads a file off disk, and marshal cannot be talked into importing a
module or calling a constructor.

The sidecar is derived data throughout, and the design leans on that. It
is never what makes a tile "known", so losing it costs only the decode it
was saving. Every way it can fail to be what this build expects -- bumped
wire version, a Python whose marshal differs, a truncated file -- reads
as a cache miss rather than an error. It carries a stamp of the layer set
it was decoded under, so widening DRAWN_LAYERS re-decodes instead of
serving a narrower blob. And because it runs ~2.4x the size of the bytes
it came from, it is the half of the cache that gets a budget: 64 MB, LRU
by mtime, swept once per 8 MB written rather than per tile, since the
walk is slow on the SD card the PicoCalc runs from.

Measured on the device over the 50 real cached tiles: a first session
costs 126 s and a later one 13 s, 9x, with all 50 tiles restoring
identically. Disk goes 7.8 MB -> 7.8 + 19.0 MB. Serialising adds ~13% to
the first decode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 00:45:21 -04:00
Jean-Pierre MartineauandClaude Opus 5 4e7bcacc37 Tile decode: don't pay for the layers the map never draws
Profiling on the Lyra found vector-tile decode dominating everything else
in the app by about two orders of magnitude: a median cached tile took
1236 ms of pure CPU, the largest 3.4 s, against 16 ms of SD read for a
whole viewport. Decoded tiles are memoised for the session, so this is a
first-pan cost -- but it runs on a GIL-bound worker thread, so it starves
the render loop for those seconds rather than running beside it.

Roughly 40% of that work was thrown away. A planet tile ships building,
housenumber, poi and mountain_peak; _draw_tile looks up ten layer names
and none of them is those. So decode_tile now takes the set of layers the
caller actually wants, reads each layer's identity with a cheap header
walk, and decodes the features only for the ones asked for. Unwanted
layers are still returned, named and empty -- which matters, because the
tile source decides whether a cache entry is real by whether the bytes
parsed into layers, and a tile whose every layer we skip must not read as
blank and get its perfectly good cache entry pruned and re-downloaded
every session. That test moves off "has features" and onto "parsed into
layers", which is sound because the other two ways a tile isn't real --
empty and corrupt -- decode to nothing and raise respectively.

Only the decode narrows; whole tiles are still cached, so drawing more
later costs a re-decode and never a re-download.

Measured on the device across the 46 real cached tiles: 183 s -> 51 s,
3.6x, with the drawn layers byte-identical on every one. The median tile
is 1259 -> 353 ms, the largest 3545 -> 759 ms.

_Reader.skip stopped slicing out the bytes it steps over while it was
here, which is most of what makes the header walk cheap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 00:04:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 935b4fb3b6 PicoCalc: nodes colour by key, like everywhere else
A platform's ceiling may change a rule's resolution, never the rule. P3
had picocalc colour node names by *type* instead of by key, so a name's
colour there meant something different than it does on the desktop — the
node's kind, not its identity. Both platforms now run one rule: the key's
first byte picks the hue. The console just spends fewer of them.

node_style gains the binding the heat scale already had. Regular keeps
the full 256-hue wheel; picocalc snaps the same hue to its sixth of it —
the six chromatic bright slots, ~43 first bytes each. It returns the
slot's own hex rather than a color(N), so every downsample on the way out
(Rich's, and the fold's quantizer) lands on that exact slot instead of
guessing a neighbour, and the callers that read an RGB back out of a
style string keep working.

The type marks needed the opposite treatment: their hues are fixed, not
derived, and a naive nearest-RGB match drained the repeater's violet to
grey — a repeater read as an unknown node. They move to the theme's
type.* entries so each platform picks its slot deliberately, and a
braille raster resolves the same entry through theme.mark_rgb, so the
canvas marker and its Rich-drawn twin can't drift.

With names off the type registry, nothing reads it: Platform.name_colour
and core/nodetypes.py go, along with the three reception-layer feeders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:08:31 -04:00
Jean-Pierre MartineauandClaude Fable 5 f69d1785b7 Performance sweep: cache the render funnel, the queries, the graphs
Every repaint re-rasterized every row and re-ran every layout; every
screen open re-scanned the whole history. Now work is done once per
change instead of once per frame:

Render pipeline — a content-keyed ANSI cache in render_to_ansi (every
Text/str row app-wide), memoized corner glow, and panel-wrap memos for
the base frame and stacked dialogs, all dropped on a platform switch or
emoji recalibration. Idle-frame compose falls 5-100x per screen
(contacts 5.6->0.1 ms, record card 8.5->0.07 ms, trace 7.6->0.6 ms).

Repository — an observed_at index turns every since-window query into a
range walk (recent_observations 7.5->1 ms); heard_nodes and first_seen
aggregate in one streaming pass by ISO-string comparison instead of
hydrating dataclasses and sorting; node_names groups in SQL (NOT
INDEXED past a slow planner choice).

Per packet — livefeed no longer repaints on events that drew nothing,
the channel list's stats TTL rises above the tick so the repository is
not queried per frame, watch-store alerts batch through the throttled
flush instead of a full-state disk write per new node, and the header's
360-bucket histogram/flags tuples are memoized per minute and packet.

Per open — the resolvers, topology canonical()/display_name(), and
Yen's route search memoize over their frozen inputs; timemachine, chat,
and livefeed drop duplicate queries; the config editor, device actions,
info, advert, and tx-optimize reuse the devstate session cache instead
of re-asking the radio.

Per frame, per screen — dashboard folds five window scans into one
digest; walk caches everything derived from its frozen graph (the
onward-count quadratic included); packet viewer, record dialog, trophy
rows, trace stats/log, and the node-detail route fan memoize on their
frozen inputs, invalidated by revision counters where the input can
move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:14:53 -04:00
Jean-Pierre MartineauandClaude Fable 5 84caae53f8 P3 rework: the PicoCalc visual language, done properly
The first P3 (a84abf3) ran on the wrong model and its palette never
reached the TUI: MESH_THEME_16 was written in 256-colour cube indices
instead of the 16 VT slots, and the platform theme pick was wired into
the scripted-CLI console while the real rasterizer stayed hardcoded to
truecolor. Replace it end to end:

- theme._VT_SLOTS: the canonical slot->RGB design (conventional hue
  families kept; slots 5/6 become the track/faint slates), MESH_THEME_16
  rewritten against color(0..15) with the VT's bold-as-brightness and
  dim-bank-background rules pinned by tests. /etc/vtrgb + a boot oneshot
  install it (console-font script), OSC fallback when kbd is absent.
- platforms.on_platform: set_platform now re-runs registered bindings,
  so theme, rasterizer and widgets swap implementations once per switch
  and hot paths read module globals - no per-call get_platform().
- The render-boundary fold (theme.fold_text): one str.translate pass
  (NFKD accent folds - the Terminus base has no accented Latin - plus
  symbol stand-ins and the width-padded emoji map), an embedded-SGR
  quantizer for canvas truecolor, and a ?-at-own-width catch-all that
  makes the no-wide-glyph short-circuit true by construction. Applied at
  the only two places every visible byte passes: render_to_ansi and
  MapCanvas.to_ansi_lines.
- ui/fontset.py freezes the installed font's inventory (dumped live);
  the gallery now asserts rendered picocalc ANSI carries no truecolor/
  256-colour SGR and no character outside it - the parity gate the
  first attempt faked by asserting a dataclass field.
- Font build: nine new 6x12 marks (ellipsis, warn, backspace, shift,
  gear, refresh, clock, crosshair, padlock) drawn into donor slots, with
  donor misses now fatal and a keeper canary (donating pi once erased
  Cyrillic pe - shared slot). Table re-dumped on-device: identical to
  fontset.py, 519 = 519.
- Type-based name colouring: registry moved to core/nodetypes (services
  no longer import ui), fed from monitor observations, heard_nodes and
  merge_contacts. Heat gradient quantizes to heat.* steps on picocalc.
- ui/marks.py: the P2-deferred constant relocation - widgets no longer
  drags map_render/mapcanvas/pathgraph for tuples and aliases.
- meshterm specimen: the visual language on one card through the real
  funnels - the on-device acceptance screen (JP's request).
- tools/chat: delivered/no-ack state uses the standard check/query
  marks, not the banned emoji pair.

Verified on the Lyra: font+palette installed, specimen framebuffer
captures in .claude/plans/. 1259 tests green, 13 xfailed (P6 list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:48:07 -04:00
Jean-Pierre MartineauandClaude Opus 5 63cd4e5f96 Performance: SQLite WAL, lighter startup, per-platform tick rates
PicoCalc plan phase P2, driven by measurements taken on the Lyra as a
paired A/B (git archive HEAD vs the working tree, run side by side
against the same venv).

The headline is the database. Every packet the monitor hears is one
durable commit, and on the SD card each was costing 33.78 ms of fsync.
db._tune() opens in WAL with temp_store=MEMORY: 0.59 ms per commit, a
54x cut. synchronous=NORMAL is requested only once WAL is confirmed
engaged - the pragma returns the mode it settled on rather than raising
when a filesystem refuses it - because under WAL it risks the last
transactions to a power cut, and under the rollback journal it risks the
file itself. This device runs on a battery pack.

Startup: bleak/dbus-fast were already lazy; the real finds were two
cheap *values* dragging heavy subtrees. core/connection.py imports one
constant from core/channels.py, which imported PyCryptodome at module
level (224 ms of CPU-feature probing per run) - now deferred into the
two functions that decrypt. And tools/map.py's register_cli, which runs
for every tool at boot, reached into ui/map_screen.py for a --fraction
default, dragging the basemap's urllib.request -> http.client -> ssl
behind it; DEFAULT_VIEW_FRACTION moves to core/geo.py (pure arithmetic,
already on the boot path) and is re-exported so existing importers are
untouched. 26 modules and 316 ms of import self-time gone.

Per-frame: the corner glow only ever recolours truecolor foregrounds, so
on a 16-slot console it scanned every line of every frame to hand back
the identical list - skipped outright where effects are off, along with
the battery blink and the braille spinner cycle. _has_wide_glyph answers
from the platform when no emoji can be drawn instead of scanning the
composed frame (6.5-9.1 ms per repaint on its own). The header built its
segments twice to measure the compact separator, on exactly the narrow
terminals that take that branch every frame; it now builds once and
measures arithmetically. Together: 101.2 -> 73.2 ms per frame at 53x26,
151.8 -> 110.4 at 53x40, and 72x24 unregressed at 91.7.

That finally gave numbers to set the clocks against. refresh_interval
becomes per-platform tick_s (2.0 s on picocalc: a 1 Hz idle tick alone
was 7-11% of a core doing nothing, and only the header's per-minute
pulse drifts, since screens push their own repaints via invalidate).
The 0.12 s spinner was untenable - 84% of a core at 53x26, and at 53x40
it did not fit in its own interval at all. Five duplicate
_SPINNER_INTERVAL = 0.12 constants across the UI collapse into one
platform-aware spinner_interval() beside the Spinner it paces.

The header refactor was verified equivalent over 540 renders, plain text
and style spans both. The span check earned its keep: the first cut
passed style= to the Text constructor, which makes it the object's base
style and blankets everything appended after, so the version string
would have rendered brand-under-muted. test_header.py only asserted on
.plain and never saw it; it now pins the styling too.

tests/test_performance.py pins each contract as behaviour rather than
timing, including a subprocess guard that fails if Crypto, the network
stack, or the map screen ever creep back onto the startup import graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 03:40:42 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 f5880bfc20 Device picker: list configured serial profiles (soldered platform UARTs)
A soldered SoC UART like the Luckfox Lyra's /dev/ttyS1 is never enumerated by
pyserial's comports(), so a serial [profiles.*] entry pointing at one could only
ever be reached via `meshterm --port` on the command line -- it never surfaced in
the startup picker, which listed only scanned devices (and TCP profiles). Fold
configured serial profiles into the picker too, mirroring the existing TCP-profile
folding, de-duplicated by port so a port pyserial *does* enumerate keeps its richer
scanned row.

Also fix the liveness poll for the same ports: serial_port_present() now treats an
existing /dev character device as present (platform UARTs aren't in comports() but
their node persists exactly as long as the port does; a USB node vanishes on unplug,
so this never masks a real disconnect). Without it the watcher would falsely report
a soldered radio unplugged mid-session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 02:02:33 -04:00