217625dcf7f4d7fff205f1fde2f4160254056caf
103
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |