Commit Graph
94 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5.5 5021ab8de5 The weekly advert says where its week stands
A muted line under the Weekly advert row on the Preferences page, set in
the value lane: "next advert in 4d" while the week runs, "due — waiting
for a packet" and then "due — after 30 s of quiet" once it is due, "no
device connected", and "the week starts on Apply" while it is only staged
on. The line is a width-aware separator that reads the scheduler's new
status() on every paint, from state the loop already keeps, so it follows
the wait live without touching the file. It is drawn, even blank, for as
long as the preference is on, so the rows below never shift; every message
fits the PicoCalc's 53 columns, and a gallery specimen holds it there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:29:20 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 a7b664cdfb One flood advert a week replaces the background advert cadences
A MeshCore companion never advertises by itself, so the hourly zero-hop and
daily flood adverts MeshTerm sent by default were the only automatic adverts
on the air, and more than a companion needs. The Background adverts rows leave
Device config and `config advert-cadence` goes with them.

In their place, two preferences under Sending: weekly_flood_advert (off by
default) and advert_quiet_s (5/15/30/60 s, default 30). Each device keeps its
own real-time week, running from the latest of the switch-on, its last flood
advert (automatic or by hand), and its first connection; a due advert survives
a restart. Before sending, each connection must hear a packet, then the air
must stay quiet (nothing heard, nothing of ours sent) for the spell plus a
random 0-5 s, drawn afresh at every break so two waiting radios part.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 20:54:23 -04:00
Jean-Pierre MartineauandClaude Opus 5 2e40603a64 Satisfy the lint gates the macOS work walked past
Import order, a docstring reworded rather than have "macOS" capitalised
into something that is not the product's name, two over-long test lines,
and _tls_context moved below the logger it uses so the module keeps its
shape: docstring, imports, constants, logger, then code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 02:34:25 -04:00
Jean-Pierre MartineauandClaude Opus 5 730230461c The map draws no ground when the build has no certificates to trust
On a Mac the map showed nodes floating over nothing: overlays come from the
local database and arrived fine, while every base tile silently failed.

A frozen build can have no trust store. The default TLS context asks OpenSSL for
one, and on macOS and Linux that is a filesystem path baked into the
interpreter's own build -- which need not exist on a machine that never
installed that Python, which is every machine a PyInstaller bundle lands on.
Each tile fetch ended in CERTIFICATE_VERIFY_FAILED, and since the fetcher cannot
tell a refusal from empty terrain, the map drew what it had. Windows is the
exception that hid this: its default context reads the OS certificate store, so
the binary there always worked, and so did every checkout on every platform.

So the context now keeps the default when that default actually loaded
certificates, and otherwise points it at the bundle the operating system ships.
Those are the trust decisions the machine's administrator has made and the OS
keeps current; carrying our own copy would freeze a snapshot of them into every
release, and certifi -- the obvious way to do that -- is MPL-2.0, which this
project's own dependency policy refuses. `tests/test_notices.py` said so, which
is what that gate is for.

Verification is not weakened anywhere. With no bundle to be found the fetch
still verifies and still fails, because a map tile is not worth teaching the app
to skip certificate checks, and an unverified fetcher would be one import away
from being reused where it matters.

Confirmed against the 0.3.6 arm64 build on a Mac: handing the same binary the
OS bundle turned one hard failure into a fetched TileJSON and 7.3 MB of tiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:28:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 8076ac1081 Purge contacts is Archive contacts, because it archives them
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:26:43 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 12941b8f3c scripts/ is sorted by handheld: picocalc/ and uconsole/, indexed from the hardware manual
The Calculinux bring-up, both console-font builds, the GPL-2.0 text that belongs
beside the 6x8 one, and the xiao-radio firmware kit move under scripts/picocalc/;
the SPI bridge is scripts/uconsole/meshterm-spi-bridge. The folder's own README is
gone: its per-file index is now a section of docs/hardware.md, next to the table
that says which manual is yours. Every path in the manuals, NOTICE, CLAUDE.md, the
tests, the package docstrings and the scripts' own headers follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 22:36:49 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 657cc93691 The companion's clock is set from this computer when it connects, if a preference asks
A new "Set clock on connect" preference (Device group, off by default) has the
interactive session write the host time to the radio once per connection — at
launch, on a deferred connect, and after a reconnect, since a link drop is exactly
when a board without a real-time clock may have rebooted and lost the time. The
set runs in a background task off the connect path and reports only in the log,
with the drift it corrected; a refused write costs a warning line, never the
session. Scripted CLI runs never start the service, so a one-shot command that
only reads the radio never writes its clock.

The device-side step is the one the Device config screen's Sync clock action
already ran inline in apply_ops; it is lifted into services/clock_sync.set_clock
and the action and the service both call it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 21:11:35 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 49a609fabf The 6x12 console-font script carries its size in its name, like the 6x8 one
scripts/calculinux-console-font.sh is now calculinux-console-font-6x12.sh.
Every reference follows: the setup script's hand-off, the 6x8 script's
SHARED default and comments, the theme16 test that parses it, the module
docstrings, the two manuals, the scripts index and CLAUDE.md. The font files
it builds keep their names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:00:46 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 3b67c6813e The PicoCalc's console font is a preference, and the shell gets its own back
A Linux VT loads one font for the whole console, so asking for the 6x8 build
(53x40, fourteen rows more) changes what the shell is drawn in too. The new
console_font preference (6x12 default, 6x8 the alternative, offered on the
PicoCalc only, round-tripping through preferences.yaml on every platform)
therefore comes with services/consolefont.py: remember the font the shell had
with `setfont -O`, apply the one the preference names, and restore it on every
way out -- the menu's finally and an atexit hook, since ^Q and an unhandled
error leave by other doors. Verified on the device that setfont -O captures
the current font with its unicode table and that feeding it back reproduces it
exactly, with no sudo, because the VT is the process's own controlling
terminal. Only the interactive menu switches; a scripted run never touches the
console it was invited into. Changing the preference on its page applies live.

Shipping both fonts needed the licence untangled: the 6x8 base is the Linux
kernel's font_6x8 bitmap, GPL-2.0, so its build moves into
calculinux-console-font-6x8.sh under that licence with the GPLv2 text beside it
in scripts/LICENSE.GPL-2.0. The shared generator stays in the 6x12 script
between BEGIN/END markers and the 6x8 script executes that block, so the marks,
donors and aliases the font contract pins exist in one place. The produced PSF
is a data file the kernel console reads; MeshTerm never links or bundles it, and
NOTICE and the hardware manual say so. PrefSpec gains a platforms gate for the
page; the labels use an ASCII x because U+00D7 is not in the console font.
The 6x8 base has no Cyrillic; the manual says so and the owner accepts it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:52:54 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 ffd2649a35 Shared logic sits in the layer that reads it, and the CLI answers --version
A pre-release architecture and QA pass, applied as one change because the moves
share files and only the whole set was gate-tested (2480 passed, ruff clean).

Three new modules pull logic down out of layers that should not own it:
core/channel_probe.py (ChannelSlot, read_channel_slots, probe_channel_slots)
so services/device_state.py no longer imports a screen; services/markers.py
(gather_markers, with MapMarker a core/models dataclass) so two screens stop
importing a tool; core/tracing.py (trace_timeout, path_hash_flags) so
core/connection.py no longer imports services. The routing-prefix width is
DeviceState.routing_prefix_bytes(), replacing five private cross-module imports.

One definition per thing: is_path_hash, the NodeResolver/NameKeyResolver
aliases, TILE_RETRY_SECONDS, haversine_km, marks.SELF_MARK, MENTION; nine
private names that crossed module boundaries got public ones and menus.__all__
lists what other modules import. A preference's default is written once
(ALERT_CAP, DONE_CAP, DEFAULT_VIEW_FRACTION read the registry), the stranded
PREFERENCES doc comment sits on its subject, and three docstrings stop naming
questionary, a library dropped long ago.

The command line gains --version (eager, exit 0, documented, tested) and drops
the -p short form on trace, trace-path and tx-optimize: the root lifts -p as
--profile before a leaf ever sees it, so the leaf's could never work, and a
derived test now checks every leaf against every root short option.
tx-optimize with a one-hop --path is a usage error, exit 2, because nothing
transmitted is not a device failure. Log handlers are closed before they are
cleared (197 unclosed-file warnings gone), chat's fire-and-forget tasks keep a
reference, coroutines use get_running_loop(), the never-awaited MapScreen._load
in a test is awaited, and the CLI sweep passes --no-ble to devices so a test
run stops turning on the Bluetooth radio three times (the suite is 40 % faster).
The about page lists every dependency, and the release skill bumps the README's
download names, which still said 0.2.8.

apply_ops stays in tools/: it and its helpers build report blocks and open two
screens, so moving it to core/ would invert core over ui. The widgets CLAUDE.md
names by their private spellings (_format_age, _NODE_GLYPHS) keep them until
CLAUDE.md is edited alongside.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 19:19:45 -04:00
Jean-Pierre MartineauandClaude Opus 5 e5231687de The location picker opens at once instead of waiting on the radio for contacts
Pick location on map gathered its surrounding nodes before opening, and that
meant reading the contact list from the radio whenever the session hadn't
cached it. A companion refusing that read (ERR_CODE_BAD_STATE, which the log
shows all session) held the picker closed through every retry, twenty-odd
seconds, only to open with no contacts anyway. Our own position waited the
same way after Device config dropped its cached self-info.

The picker now opens on what is already in hand: cached contacts and
self-info (DeviceState.peek_contacts / peek_self_info) and our own history,
via gather_markers(wait=False). Whatever the cache lacked is fetched behind
the open map and joins it through MapScreen.set_markers when the radio
answers. The read is never cancelled, so a fetch another screen shares
survives the picker closing. The Map tool and the node page keep waiting,
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 03:58:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 4c01e1da7e A short list is not a layout, and a free slot is asked before it is used
The slot probe stops for two very different reasons and returned the
same short list either way. Off the end of the configured slots it is
the truth; a read that simply failed leaves whatever it had, and that
was cached for the session - an empty one reading exactly like a
device with no channels, a truncated one making the next free slot
look free with a channel sitting in it, which the add flows then wrote
straight over. The two endings raise different things, so the probe
now says whether it finished, an unfinished one is answered but not
kept, and every add confirms the slot is empty before anything is
written to it.

The chat service's slot map was also walking the radio itself, and
unbounded - it skipped empty slots all the way to the 64-slot cap, so
on firmware that never rejects an index every edit paid 64 round-trips
before the screen could redraw, on top of the manager's own re-read.
It now builds from the probe the manager was making anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:41:36 -04:00
Jean-Pierre MartineauandClaude Opus 5 4ba8da7f03 Channels shows the wait instead of announcing the result
Three things, all the same story - the manager was silent while it
worked and talkative once the work was already visible.

The acknowledgement goes. It reported changes the reader had just
watched land in the list in front of them, after the screen showing
them had closed. The count stays in summary, for the run log.

Every device action now reports while it runs, under the modal busy
card: write_channel draws its own (it is the one boundary every
mutation crosses), a reorder draws one card for the batch and retitles
it per move, and the re-read that follows a change draws one rather
than three. Modal is the point - the list underneath stays armed the
whole time it waits, so keys tapped during a slow write were landing
in its filter and coming back as a list showing nothing.

And "a write happened" moves off the change count onto the cache the
write drops. A write that landed and then raised - a QR view unwound
with ^W, a link lost mid-reorder - never reached changes += 1, so the
stale slot list and the chat service's slot map outlived a layout that
had already moved. write_channel now invalidates as it writes, which
the four CLI subcommands were never doing at all, and the manager
compares the epoch across a round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:32:19 -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 8d8c8b0f2b The basemap user agent is one an operator can actually answer
`MeshTerm/0.1 (+https://github.com/; mesh node map)` went out on every
OpenFreeMap tile request and was wrong twice over: the URL is a bare host
nobody can visit, and `0.1` was a hand-written number that had already
drifted from the package's `0.1.0` and would drift again on the next bump.
OpenFreeMap issues no API key, so this header is the entire handle a tile
operator has on a client — the difference between being emailed about
traffic and being blocked for it.

The URL now points at the real repository, and the version is the package's
own `__version__`, which is what every other surface that shows a version
already reads (the About page, the splash wordmark, the menu mark, a trace
export). That is the single source of truth here, so reusing it is what
keeps the string honest.

Deliberately *not* `importlib.metadata.version("meshterm")`, despite that
being the usual advice: MeshTerm is run straight from a checkout at least as
often as from a wheel — this repo's own venv has no `meshterm` distribution
installed, nor does the PicoCalc deploy — so the metadata lookup would raise
`PackageNotFoundError` in exactly the setups it is developed and tested in,
and a fallback around it would be a second version literal to keep in step
with the first. Reading the package attribute has no failure mode at all,
which is also why the value stays a module constant rather than becoming a
function: it never changes, computing it cannot raise, and the tile fetch
path is no place to discover either.

Three tests in the basemap section of `test_map.py`: the string is exactly
the repo, the live version and the descriptive tail; it rides the header of
the request that actually leaves; and the module still imports with a real
version in it when the metadata lookup is made to fail outright, which is
the regression guard against someone reaching for `importlib.metadata` later
without a fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 00:48:41 -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 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 211f38255b A path field means two different things, and now says which
No, it isn't the ack — ACK is its own payload class and was never in the match.
Both symptoms come from the same place: a packet header's route type, which we
never recorded, decides what its `path` field *is*.

A FLOOD packet accumulates: every relay appends its hash, so the path is the
route it travelled to reach us. A DIRECT packet is the opposite — the path is a
routing instruction the sender wrote and the relays consume — so it says where
the packet is going, and an empty one means the route was used up, not that the
packet arrived in a single leap. Reading every path the first way is what drew a
message from five hops out as having appeared from nowhere.

The evidence is in your own log. The four-hop route on those sends is identical
across four consecutive messages, where a flood-accumulated path varies every
time; it ends on 40ef0f, which is the first hop of everything hairu sends *us*;
and a PATH frame carrying exactly those four hops crosses the log seconds before
the first message that uses it. That is a learned route to hairu being announced
and then used — not a route anything took to us.

So route_typename is stored (schema v15, a `route` column beside the crypto and
addressing ones added for the same reason), and an Arrival carries whether it
was routed. A routed frame with no path has route_known False: it is kept as a
copy that reached us — the count and the time are real — but it is drawn as
"routed · route not carried" instead of a confident zero hops, and it puts no
lane in the fan, because an empty lane there is a claim of adjacency the frame
never made. Frames from before the column read None and fall back to the old
behaviour rather than to a guess.

The round trip is a second bug and needed no new data. The path line closed on
our own ★ unconditionally, and for an outgoing message its head is our ★ too —
so every send drew ★ ▶ … ▶ ★. The graph did the same, DST_NODE having been
hardcoded to us. Both now take a destination: the far end for a message we sent,
ourselves for one we received. It is the rule the line's head already followed —
a path runs between the nodes it went between — applied at last to its tail. A
channel broadcast keeps us at both ends, because there the copies we log really
are our own message coming back.

collapse now keys on the route type as well as the hops, so a route travelled
and a route intended never fold into one row even when the hashes match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 00:16:55 -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 f5d0fb3677 Map: silence about a tile is not the source saying it's empty
A tile fetch that gets no answer was being remembered as one that did.
BasemapSource is careful about the difference — a timeout is never written
to disk, precisely so the next pan can ask again — and both callers then
threw it away, filing every None as "fetched, empty/absent" and never
asking again for the life of the screen. The same latch sat one level up:
_resolve() marked itself done whether or not it had resolved anything, so
one failed TileJSON round-trip made `available` False, and _ensure_tiles
skipped every fetch, for the whole session.

That is a desktop no-op and a PicoCalc bug. The device brings its Wi-Fi up
some forty seconds into the boot, well after an app started alongside it
reaches the menu and warms the tile source — so the map answers "offline"
before the answer can be true and draws the entire session from whatever
the cache already held. JP, 2026-08-18: black tiles at the two highest
zooms, where one z14 tile is the whole screen and a hole is the picture.
VE2RAD_FH_50m's tile (14/4842/5861) was simply absent from the device
cache; nothing there had been written in ten days of using the map, and it
fetched in 1.4s the moment it was asked for.

So: `available` reports what is known and never itself goes to the network
(it is read on the paint path), a failed resolve stands for 30s rather than
for ever, `answered_empty` lets a caller tell the two Nones apart, and a
tile nobody answered about waits out a 20s cooldown instead of a restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:28:34 -04:00
Jean-Pierre Martineau 64d285a986 Revert "Battery: a frame a second, bought where the tick won't give it"
This reverts commit 4b491bb04f33bc040298d5ec19d079b4349d1776.
2026-08-13 23:52:40 -04:00
Jean-Pierre MartineauandClaude Opus 5 385654ab5f Battery: a frame a second, bought where the tick won't give it
The sweep read a frame per idle repaint, so it climbed at half speed on the
handheld — a platform that ticks every 2 s because a frame costs 7-11% of a
core there. Pacing it off the tick was solving the wrong problem: the gauge
should climb at a second a frame everywhere, and the repaints that takes are
worth buying, since a pack only sweeps while it is plugged in.

So the poller now buys them, and only while all three hold: the gauge is
actually sweeping (a pack sitting full on the charger is not — which is where
a forgotten handheld spends the night), the platform idles slower than a
frame, and a surface is listening. Everything else sleeps out the interval as
before. BATTERY_ANIM_S moves to the widget as the one cadence both read, and
Ui.invalidate gives a service its first channel to repaint live chrome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:30:39 -04:00
Jean-Pierre MartineauandClaude Opus 5 88b2d347dd Battery: a gauge that can't animate still says it's charging
The PicoCalc reads its charging flag straight off the handheld's own BMS
— the kernel's power_supply status, backed by the keyboard MCU's battery
register — so the state was known all along; the header simply never drew
it. Charging replaces the fill with a sweep frame, and a platform without
effects holds frame 0, so a charging pack drew an *empty* cell: the gauge
under-read the pack outright, exactly while it was filling.

Animation is now the caller's claim rather than something inferred from a
held frame. Where nothing animates the gauge draws its resting state — the
true fill, no blink — and says charging with an arrow beside it instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:04:31 -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 Opus 5 80f18e235a Trophy case: a board for the longest leg
Six disciplines measured the whole walk — how far it travelled, how far
out it reached, how many nodes it touched. None of them measured a single
link, and that is its own game: a node reached over a chain of short hops
scores far, not long, while one huge jump between two relays scores
nothing today.

Longest leg (🏹) takes the greatest distance the walk crossed in one hop,
both ends positioned. The measurement rides the loop that already sums
the circuit, so it costs nothing extra, and it carries the ids of the two
ends it spanned — our own end left null — so the record card can name the
link on THE path widget, ★ at home like every other route we draw. A
record set before the discipline existed simply has no leg lane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:29:41 -04:00
Jean-Pierre MartineauandClaude Opus 5 714bb15fe6 Mesh walk: one neighbour table, carried across the merges
The prefix settle walked every link in the graph three times per merge —
to derive the node set, to build the neighbour table the corroboration
vote reads, and to hunt down the links naming the id being folded. On the
device's mesh that is ~1150 links, three times over, some eighty times:
the largest remaining cost of opening the walk.

All three questions are one table's to answer, and a merge's effect on it
is knowable where it happens. So build it once and keep it: its keys are
the live node set, a fold relabels its own neighbours, and a stub whose
every link ran to its owner takes the owner out with it. Both passes now
walk only the under-specified ids, since a full-width one extends
nothing.

Ties in the shortest-first order break lexicographically too. Length
alone left equal-width ids in whatever order the node set iterated, and
CPython randomizes string hashing per process — so two runs over the same
evidence could take their merges in different orders and, where one fold
changes what a later one sees, settle to different graphs.

Verified byte-identical against the previous algorithm over twelve
randomized graphs, and 6.3x on the settle at that scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 05:42:20 -04:00
Jean-Pierre MartineauandClaude Opus 5 6a002494c7 Device state: the routing mode was the one fact nothing warmed
The prewarm at connect filled contacts and the two slot probes, so the
first Contacts or Trace open still queued a path-hash-mode round trip
behind whichever probe was walking the slot table at the time — and the
slot walks are measured in seconds.

Warm every cached fact, cheapest first: self-info and the routing mode
(single round trips, and what every list screen needs), then contacts,
then the two probes only the channel manager waits on. The mode gets a
lock like its siblings, so a screen opening beside the warm awaits the
read in flight instead of issuing a second one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 05:42:07 -04:00
Jean-Pierre MartineauandClaude Fable 5 077f8d6a1b Mesh walk: the prefix settle was scanning the whole graph, per merge
Opening the mesh walk took 4.9 s, and 4.2 s of it was build_topology.

coalesce_prefixes folds a short hop hash onto the longer id it abbreviates.
It restarts the whole pass after each merge; each pass asks _extensions
once per node; and _extensions scanned every node in the graph. On this
device - 3053 stored packet paths, 1145 links, ~80 merges to settle - that
came to 24 million len calls and 7.6 million startswith per build.

Ids sharing a prefix are contiguous once sorted, so the extensions of a
short id are a range, not a search: bisect to its start and stop at the
first id that no longer carries the prefix. build_topology 4.22 -> 1.23 s
with real contacts, and the graph is byte-identical - 1133 links, 345
nodes, both dumped and diffed. On the console the open is 4.94 -> 1.95 s.

Contacts' 1.3 s open turns out not to be ours at all: heard_nodes 23 ms,
the screen 131 ms, first render 43 ms. The rest is the first devstate read
over the UART, already cached for every later open - a warm one is 230 ms.
Warming devstate at connect is the fix, and it is now first in the plan.

The harness needed two things to measure any of this, both folded back into
the skill. An open is not a repaint - it paints the menu closing, falls
silent while it loads, then paints the new screen - so quiescence stopped
the clock on the first burst and reported the walk opening in 124 ms.
Widening the silence window does not help either: past a second the 2 s
header tick lands inside every measurement. `expect TEXT` watches
/dev/vcs1 until the screen is actually there. Its needle has to be unique
to the destination, because every main-menu row carries its own screen's
name - `expect Mesh walk` matched the menu and made a 2.3x win read as no
change at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 02:43:47 -04:00
Jean-Pierre MartineauandClaude Fable 5 57386fe879 Repaint: we already composed the frame - stop having it read back
Every paint handed prompt_toolkit a finished ANSI frame and let it parse
that back into fragments, write all ~1400 cells into a fresh Char grid,
diff the grid, and re-emit ANSI. Measured on the PicoCalc that round trip
is 70-125 ms, and it is paid in full whether the frame changed by one row,
by all of them, or not at all - the 2 s header tick recomposes an
identical frame and still pays it.

Since the frame is already rows of text, FastRenderer compares this
frame's rows against the last frame's and rewrites only the ones that
differ. Opt-in for now (MESHTERM_FASTRENDER=1) and deliberately narrow:
a floating dialog or the busy overlay is placed by pt's float containers,
so those paints fall straight through to the stock renderer.

The erase has to lead the row, never follow it. A row that exactly fills
53 columns leaves the cursor in the last column with wrap pending, and an
erase-to-end there clears the character just written - the last cell of
every row went missing until the clear moved in front.

Alongside it, three costs that were pure waste on this platform:

  * The redraw was postponed up to max_render_postpone_time before it
    even started - a flat 3 ms on the Lyra, 10 ms on desktop, on every
    key. That default guards a UI whose own output floods it with
    invalidations; here they are keystrokes and a 2 s tick.
  * The splash rendered its body twice per paint - once to measure the
    box, once to fill it - and ran the corner glow ungated, walking every
    line to hand back the identical list on a console with no truecolor
    to blend. compose_dialog had the same ungated glow.
  * A tile's decoded layers were re-read from the SD card on every frame
    (~96 ms each), so panning one dot re-read every tile that had not
    moved. They are held in RAM now, bounded at a viewport plus its ring.

On the device, driven over the real radio: main menu 115 -> 40 ms,
Contacts 155 -> 73 ms, Dashboard 213 -> 117 ms, leaving a screen
375 -> 103 ms. Console readback confirms the frames are byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 01:29:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 81e58803ee Heard: our own ears outrank the sender's clock
A contact's heard time came straight from the firmware's last_advert,
which the advertising node stamps with its own clock. That makes it
hearsay, and advert_time can only refuse a stamp from the future — a
node whose RTC runs days behind reports a plausible-looking time that
never catches up, so the lane sits at "4d ago" while we are receiving
from it right now. Our own history, stamped when we actually received
something, could only fill the gap when the device reported nothing at
all.

So merge instead of fill: take the later of the device's time and ours.
A device time still wins whenever the firmware caught an advert we never
recorded, and we only override it with proof of a later reception.

A direct message now counts too. In this app's lexicon "heard" means
received from, and a message is received from its sender — but a DM
never touches last_advert and is stored as a message, not an
observation, so a node we chat with daily could read stale and be swept
by the purge ladder's quiet rungs while talking to us. Read from the
messages table rather than recording an observation: a routed message's
SNR belongs to its last relay, which is why heard_nodes excludes packet
rows, and counting it would inflate a lane that means overheard traffic.

The merge runs on every contacts fetch now, so both halves are GROUP BY
queries that aggregate in SQLite and yield one row per node, instead of
heard_nodes streaming all history into Python to build stat objects.

Node detail takes the merged contact time, so it and the contact list
can no longer disagree about one node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 03:49:46 -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 649a55b558 Tile cache: a flaky link's silence is not an empty tile
Every fetch failure — offline, DNS, timeout, reset, 5xx — collapsed into
None and then into b"", which the loader wrote to disk as "this tile is
legitimately empty". One 12-second Wi-Fi stall on the PicoCalc therefore
poisoned that tile for good: every later pan and every later session read
the zero-byte marker and drew a black square. A body cut short mid-link
did the same by another door, cached and undecodable ever after. The PC's
network simply never flaked long enough to trip it.

So the cache now holds only tiles that decode to real geometry. A GET
returns whether the source actually answered: a 2xx body or a 404 is an
answer, everything else is silence and is never written down, so the next
pan asks again. A body short of its Content-Length is truncation, not a
tile. A tile the source really calls empty is remembered in memory for
the session alone — no repeat requests while the map is open, no
permanent hole on disk.

Cached entries that no longer decode are pruned on read, so a cache
already poisoned this way repairs itself as you pan over it. Writes go
through a temp file and os.replace, since the map and a mini-map share
one source and can fetch the same tile from their own threads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 22:52:18 -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 Opus 5 ba6e92107d Message paths: direct frames never matched at all
The direct-message path view looked for frames of class TXT_MSG. The
meshcore library reports that class as TEXT_MSG — the spelling every
other reader uses (core.frames, the packet viewer's lane, the dashboard's
traffic keys) — so the filter matched nothing, and every direct message
reported an empty window no matter how much of the conversation the radio
had overheard. Its test fabricated rows under the same wrong name, so it
passed against a mesh that can't exist.

With the class fixed, the evidence can also be far better than "±90 s of
every direct frame we heard": a TEXT_MSG frame is encrypted, but its
addressing is not — both ends ride in the clear as one-byte key hashes,
and core.frames has been recovering and storing them all along. The
window now narrows to frames running between this conversation's two ends
in either direction, and the summary bills it as matched by address and
time. A frame whose addressing was never recovered drops out rather than
being counted: paths are evidence, and a hash that isn't there proves
nothing.

Still not a content match — a one-byte hash collides — so the view keeps
its warn-coloured summary and makes no claim it can't back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:21:29 -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 Opus 5 6b13808ec6 Chat: count only conversations the picker can show
The header's unread badge summed every conversation the recorder filed a
message under, but the picker only builds rows from what the *device*
reports — its channel slots and the messageable contacts in its table.
Anything outside that set was counted and unreachable: no row to open,
so no way to clear it. The badge sat there for the rest of the session
saying there were new messages while the Chat list showed none.

The badge is a pointer, not a tally — its job is to send you to a thread
you can open — so it now counts only what the picker can list. The rest
is still recorded to history, just silently. In practice what that drops
is machine traffic rather than correspondence:

  * a direct message from a node that isn't a DM recipient — a repeater,
    room server, or sensor. Every remote-CLI reply from a repeater you
    administer arrives this way, so an admin session left a stuck badge;
  * a direct message from a sender the contact table doesn't hold;
  * a channel message the device has no configured slot for, including
    one whose slot couldn't be identified at all.

Muting joins those under one rule instead of standing alone, and the
device is consulted through the session cache, so a message costs no
extra round-trip. Anything the cache can't answer notifies: a cold or
broken cache is a reason to over-notify, never to swallow a message.

That leaves one real divergence to fix rather than paper over. The
recorder resolves a channel slot fresh per message while the screens
read a session-cached list that only an in-app channel edit drops — so
a channel re-keyed or cleared on another client (the phone app) had the
picker listing the old one all session, and now would have silenced the
new one too. Resolving a slot to a different identity drops that cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:25:45 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 599893e03a Monitor: queue observation writes off the event loop
MonitorService recorded every overheard packet — every advert,
telemetry response, and RX-log frame, not just messages — with a
synchronous SQLite commit inline in the event hub's dispatch, on the
same single-threaded loop that drives prompt_toolkit's rendering and
key input. On a busy mesh that stalls the whole UI several times a
second, on any screen, scaling with mesh traffic rather than message
count.

ChatService already avoids this for inbound messages by handing them
to a queue and writing on a separate worker task. MonitorService now
does the same: the hub callback just enqueues the observation and
returns immediately; the run-row creation and the database write
happen on a background worker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 01:52:20 -04:00
Jean-Pierre MartineauandClaude Haiku 4.5 45eacf3968 F-lane: coloured chips, plain-key priority, shift latch
Rebuild the PicoCalc F-key lane per JP's spec: five fixed 9-cell
chips (F1 DESCRI, "10" standing in for the overflow F10 label),
gray fill on the plain F1-F5 bank and green while Shift's bank is
showing, white text on both. DEFAULT_LANE moves to an
adjacent-plain-key priority model: commit/leave take the easiest-
to-reach F4/F5 with no Shift needed, jump-to-top/end take F1/F2
with paging as their Shift companions, and F3 stays the one lone
slot a screen's own lane reaches for first. FPair's Shift half is
now optional, so a lone slot renders blank in the shifted bank
instead of forcing an unrelated opposite action onto it.

Also bridges the on-device quirk where the MCU's F6-F10 translation
can appear to release Shift an instant before the translated code
lands, flipping the lane back mid-chord: modifier_watch now latches
shift_down() true for a short grace window after any F6-F10 press
resolves, on top of the raw watcher state.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-01 17:45:56 -04:00
Jean-Pierre MartineauandClaude Fable 5 89eef7f4d2 P5: device integration - host battery, the 6x8 A/B font, deploy updates
- BatteryService grows the host provider (Platform.battery="host"): the
  PicoCalc's own pack through the kernel power_supply driver - a true
  percent and charging flag from sysfs, no voltage-curve estimation, no
  radio connection needed. Unreadable supply = absent, gauge hidden.
- The console-font script now builds TWO 512-glyph PSF2 fonts: the 6x12
  Terminus-based default (53x26) and a 6x8 A/B candidate (53x40) based
  on the kernel's own font_6x8 (CP437 coverage - documented as having
  no Cyrillic), with the braille block at 2px dot pitch and all 18
  marks redrawn for the shorter cell. Built and verified live on the
  Lyra: stty reports 53x40 under meshterm8, specimen legible
  (.claude/plans/p5-specimen-6x8.png); flip with setfont, persist the
  winner in vconsole.conf. JP judges the A/B by eye.
- calculinux-setup.sh puts the deploy user in input/dialout/video
  (input is the modifier watch's /dev/input access).
- theme's platform binding now drops the fold table and cache on every
  switch: its emoji pads bake in cell_len at build time, which the
  regular platform's emoji-width calibration can re-measure - a stale
  table survived platform swaps in randomized test runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 13:08:17 -04:00
Jean-Pierre MartineauandClaude Fable 5 2b658f47a9 P4: PicoCalc chrome & input - borderless frame, slim header, F-key lane
- compose_base grows a borderless mode (Platform.frame_border=False): a
  one-row title bar (rule + title + clip arrows) replaces the Panel's
  border and padding, so the body wins back four columns and one row on
  the 53-column console. Regular keeps its Panel untouched.
- The header composes only Platform.header_atoms: picocalc drops the
  version mark and the activity pulse, keeping device, badges, battery.
- The F-key lane (ui/tui/fkeys): five FPair slots per screen, F1-F5
  primary and F6-F10 their opposites - which is physical Shift+F1..F5,
  translated to plain keycodes by the keyboard MCU. The session binds
  all ten and resolves a press against the top screen's lane into a
  normal screen action, so screens gain F-keys with no key handling of
  their own. Default lane duplicates the Fn-layered nav cluster plus
  OK/Back. Alt+Fn stays unbound (kernel VT switch).
- services/modifier_watch: a hand-rolled evdev reader (no keyboard-lib
  dependency) tracking physical Shift so the lane flips to the F6-F10
  labels mid-chord; engages only when the platform asks and the device
  exists, degrades to the static lane on any failure.
- The popup-qualifying width gate (surface._dialog_max_cells) shrinks
  to 43 on picocalc, matching the 47-outer dialog cap; the dialog frame
  itself already clamps to cols-6, so prompt.py needed nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:56:49 -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 Haiku 4.5 c94a242812 P3: PicoCalc visual language — 16-slot palette and compact glyphs
Add the PicoCalc visual system:
- MESH_THEME_16: 16-slot palette with deliberate RGB values for truecolor=False
- make_console(): picks theme based on platform.truecolor
- glyph(): central emoji→single-BMP-char mapping for the 512-glyph font
- Type-based name_style() and register_node_type() for PicoCalc colour (key→type)
- Text folding: fold_text() with NFKD accent stripping and emoji→placeholder
- Platform-aware icon rendering in packet_viewer, dashboard_screen, courier_screen
- Reception layer registers node types for colour lookup
- Gallery tests check truecolor constraint on PICOCALC

All 1239 tests pass; 13 xfail (P6 width worklist).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-01 11:36:20 -04:00
Jean-Pierre MartineauandClaude Fable 5 19ac17a262 Heard ages: a sender's future clock no longer pins the list
A contact's last_advert is stamped by the advertising node's own clock;
a mis-set one lands in the future, where the age clamp read it as a
permanent "now" and the heard sort pinned it to the top — for as long
as the bogus stamp stayed ahead of the wall clock, since the firmware
only ever advances the field (its replay filter).

advert_time (models.py) is now THE converter for every last_advert
epoch — the live contact table and the cross-session store both pass
through it — and refuses a stamp more than 5 minutes ahead as unknown.
The devstate contacts fetch then fills the gap from our own recorded
receptions (stamped by our clock), so a refused contact we really do
hear shows an honest age, and one we never heard reads never.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 00:56:21 -04:00
Jean-Pierre MartineauandClaude Opus 5 0756537bdf Topology: a stub the hash can't place, placed by the company it keeps
A 1-byte path hash off an RX-logged relay chain often can't be widened: `3d`
opens both YUL-Cartierville (3d63…) and T MeshPocket (3d1f…), so coalesce_prefixes
refused to guess and left it standing as its own node. On the live graph that was
26 stubs — and they are not harmless labels. `3d` sat in the first ring beside
YUL-Cartierville at the same +13.5 dB with 885 of its own samples, claiming *more*
onward links than the real node. Worse, a stub pools the links of every node it
could be, so it acts as a phantom hub joining neighbourhoods that were never
adjacent, and every hop distance measured through it comes back short.

Prefix alone is a coin toss, but the graph is not out of evidence: the stub carries
the links of whichever node it really was, so the company it keeps names its owner.
Neighbours that can tell the candidates apart — ones linking to exactly one of them
— vote; neighbours both candidates share abstain, because two repeaters in the same
city have plenty of those and they would drown the signal in local density.

The bar is high, since a wrong merge does the very thing this routine exists to
prevent. A winner needs two discriminating votes and a 3× lead, so a genuinely
contested stub — one whose neighbours point both ways, i.e. one that really is two
nodes' traffic pooled under one hash — stays exactly where it was: under-named but
honest. The two passes interleave, certain-first, because every merge can resolve
the next ambiguity.

On the live graph: 244 nodes / 852 links → 229 / 731, ambiguous stubs 26 → 11, and
29 nodes land on a truer ring. Adds ~2% to a build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:12:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 722c8f0e4f Mesh walk: the atlas is a walk, everywhere it's named
"Atlas" named a book of maps — a thing you leaf through — and this screen has
never been that. It holds one node in focus, fans its neighbourhood, and moves
when you press Enter. The trail across the top is a walk, the list heading says
Enter walks, the tests call the helper walk_to. Only the name disagreed.

So the name goes: the tool is `walk` / "Mesh walk", the screen is WalkScreen in
ui/walk_screen.py behind open_walk, and every prose mention follows — the
README feature table, the node-vs-contact boundary in CLAUDE.md, the
windowed-list pattern note that cites this screen as its origin, pathline's
separator example, topology's whole-graph view and its prefix-coalescing
rationale, and the purge-test-data skill that warns about fake nodes showing up
here.

No behaviour moves. The tool keeps its 🌐 icon, its Mesh category and its
order beside Map — the logical shape next to the geographic one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 20:59:25 -04:00