Commit Graph
520 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 8e8a73fc36 Busy card: the chip already says "working", so the LED bar goes
The skeleton card floated between screens carried a Knight-Rider scanning bar
under its caption: eighteen braille lamps, a red head and a persistence-of-vision
trail sweeping back and forth. It drew the eye harder than the words did, on a
card the reader is only ever passing through, so it and its colour ramp are gone
— the one-cell spinner beside the caption is the whole animation, and the card is
two lines.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:24:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 972c0b6f03 Screens: Esc is the way out, so no row spends two lines saying so
The app-wide Back row existed because it duplicated Esc — two of its call
sites said as much in a comment. On both platforms Esc is a labelled key
and every footer hint names it, so the row bought nothing for the blank
separator and line it took out of every screen; on the PicoCalc's 26 rows
that is a row in thirteen.

back_rows() is gone, and with it the four hand-drawn copies: ReorderScreen's
clean-state row, the live feed's pinned foot stop (the cursor now clamps on
the oldest packet), node detail's tail action, and the action-list rows on
the trace and TX-sweep screens.

Kept, because there leaving is a choice rather than an exit — the same rule
that keeps a dialog's Cancel button: exit_rows()' staged pair, where Apply
has no key of its own and ✗ Back names what the other way out costs;
ReorderScreen's dirty-state pair; the two Quit rows, which initiate the
app's terminal action behind a confirm; and the new-record dialog's Close,
which is its default, so dropping it would let a reflex Enter navigate.

With no Back row there is no Back value: the six spellings callers used to
test three at a time (_BACK, "__back__", _CANCEL, two tuple shapes, bare
None) collapse to `is CANCEL` / `is None`. The gallery now asserts the
absence of an exit row on every screen, on both platforms.

Resolves findings E and §10 of docs/survey-navigation.md. Finding D — Esc
peeling a filter on three screens and leaving on two — still stands, and is
now the only thing left telling a user what Esc does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:15:53 -04:00
Jean-Pierre MartineauandClaude Opus 5 9d524a27a2 Emoji: a joined sequence is one glyph, so it costs one glyph's cells
A ZWJ sequence — 🤷‍♂️, 👨‍👩‍👧, 🏳️‍🌈 — is a single grapheme the terminal draws
in the width of its base, and Rich's stock measurement already knows that.
prompt_toolkit's does not: it adds the codepoints up, reserving three cells for
the shrug and six for the family, and it is prompt_toolkit that places the right
border. Every joined emoji has been pulling a row's border in by the excess.

Correct it in both authorities the same way, by dropping what the joiner folds
in before measuring, so a sequence inherits whatever its base is worth and the
two curated sets still reach it. Rich needed this too: the selector-skipping
replacement was losing the stock loop's own ZWJ handling and counting the male
sign as a cell.

Measuring is only half of it — prompt_toolkit lays out one codepoint at a time,
so the sequence has to arrive as one fragment or it is still laid out in pieces.
ClusterTextControl merges the run on the finished lines (the last point they are
still ours: split_lines returns plain str and drops the marking), and the window
makes one two-cell Char of it. Every codepoint still reaches the terminal, in
order, so the glyph composes exactly as before.

The rule holds at either emoji width, so the width-2 path installs it alone —
no narrowing, no flag category, Rich untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:27:37 -04:00
Jean-Pierre MartineauandClaude Opus 5 e9f07dee0e Chat: a direct thread picks a message too, whatever the comments said
Two comments claimed the pick was channels-only, one of them flatly stating that
direct threads never select. Neither is true: _move_selection and _select_section
carry no is_channel gate, and footer_hint has offered a picked-message hint for
both kinds all along. What actually differs is what Enter does with the pick — a
reply @mention in a channel, the delivery paths in a direct thread.

Both also called it the "reply target", which names only the channel half of
that; it is the picked message, and only one kind replies to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 03:40:49 -04:00
Jean-Pierre Martineau 955ee2549f Updated splash screen (ANSI art, CP437) 2026-08-27 00:53:06 -04:00
Jean-Pierre MartineauandClaude Opus 5 9ec72dbf48 Skill: blank the NULs in ANSI art without touching its SAUCE record
An unpainted cell exports as 0x00, which no terminal draws as nothing — it
comes out as tofu, as a stray glyph, or swallowed with the rest of the row
shifted left. One byte wide to fix, and this makes that one substitution and
no other: same length out, every escape sequence where it was.

The trap it exists to avoid is the obvious version of the job. An ANSI file's
last 128 bytes are a SAUCE record whose NULs are structure — binary fields and
the padding on the font name — so a tr over the whole file corrupts the
metadata and fixes no art at all. It cuts at the 0x1A that opens the record and
counts what it left alone. Both splashes are in exactly that state today: 24
NULs each, all of them SAUCE.

A space is only black if the span it lands in left the background black, so
the ones that would come out coloured are named by row and column rather than
quietly repainted — that is an editing call, not a substitution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:59:51 -04:00
Jean-Pierre MartineauandClaude Opus 5 80a2a9bd0b Contacts: a contact is dropped one at a time, from its own page
The list could only sweep contacts by age — a purge or nothing. Removing one
named contact now lives where the reader is already looking at it: the last
action on its Node detail page, behind a red Cancel/Remove confirm, and the
list it returns to rebuilds without the row.

The deletion lands in both halves of what the list shows — the device's table
and the cross-session store — because forgetting only the radio's half merges
the contact straight back on the next read. A refusal is put in a dialog and
nothing is forgotten: a contact the radio still holds must not vanish from the
list. Only the contact goes; its reception history and chat messages stay, and
a later send offers to write it back.

An action row's tint now survives the icon lane being dropped — it lands on the
label, as marked_label already does for menu rows — so the delete still reads
as a delete on the PicoCalc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:39:22 -04:00
Jean-Pierre MartineauandClaude Opus 5 c66e2c86b6 Frames: the border is one colour again, so drop the corner light
Every framed box was post-processed on the way to the screen: the top-left
corner blended toward white and the highlight decayed along the top and left
edges, so a box read as lit from that corner. It reads as a gradient laid over
the chrome rather than as the box being a box. The borders draw flat now.

The pass goes with it — ui/tui/glow.py, and the three composers that ran it
(base, startup, dialog), each of which also carried the platform gate that kept
it off the 16-slot console where it could only ever hand back the same lines.
Nothing else called it, and its memo went with the module. Text colour is
untouched: the pass only ever recoloured box-drawing glyphs, and titles and
subtitles were already skipped.

The four glow tests become one that pins the outcome instead of the mechanism —
every box glyph in a composed frame, nested panels and dialogs included, carries
the border's own colour and no other. Verified against the old pass: with it
back, that set holds sixty-five.

Platform.effects keeps its other two claims (the header blink, the braille
spinner); it just no longer mentions a pass that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:18:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 ad2b852018 Wordmark: a mark's file says how wide it is, and the folder is the ladder
The two marks sat beside the written pages in assets/ under names that spelled
their widths differently and only by accident — logo.ans and logo_53.ans, where
the first is 71 columns and says so nowhere. They move into assets/splash/ as
logo.71.ans and logo.53.ans: one file per canvas, each named for the canvas.

So the size ladder is now the folder listing. _variants() reads the widths out
of the names and walks them widest-first, which means a new size is a file
dropped in rather than a file dropped in *and* a tuple edited. The name only
orders the ladder — which mark fits is still settled by measuring the art, so a
name that overstates its canvas can't draw a torn mark. A test keeps the two
honest with each other: every mark is as wide as its name says.

The old 8.3 spelling goes with the rename. It was there for art editors that
predate long filenames, which manage a second dot fine, and a name that states
its width is worth more than a name DOS could have opened.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:43:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 5f4ad341c5 Splash: a short terminal thins the mark instead of clipping the box
The block was laid out at its full height and whatever overran the screen was
cut off the bottom — which is the box: the device list, the PIN prompt, the
thing the user opened the app to use. The mark kept every row it had.

So a terminal too short for all of it now gives rows back, cheapest first: the
blank line under the mark, which is breathing room and nothing else, and then
the top of the mark itself, up to nine rows of it. Nine is the globe standing
over the lettering — decoration, where the lettering is what says whose splash
this is. Cropping from the top means the lettering and its copyright hold their
place while the mark thins above them, rather than the whole block sliding.

The bound is the art's own height too, so a mark drawn without a globe is never
sheared into its letters, and past the bound the old clip still applies.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 02:12:26 -04:00
Jean-Pierre Martineau fae342c2bd Updated splash screen (ANSI art, CP437) 2026-08-25 01:47:52 -04:00
Jean-Pierre Martineau eb8ecb46c8 Updated splash screen (ANSI art, CP437) 2026-08-25 01:31:47 -04:00
Jean-Pierre MartineauandClaude Opus 5 ed06492bd0 Splash: the wordmark states its own copyright, and blank is a space
The mark's art signs itself now, so no splash adds a line under it — the
picker, the smoke-test spinner, the PIN prompt, the failure notices, the
scan and the settings offer all drop the footnote they were passing, and
nothing draws the notice twice. The written About pages keep their {copyright}
colophon: that is a page of prose, not a screen with the mark on it.

Moebius spells an untouched cell as a NUL, which a DOS console draws blank and
Rich measures as nothing at all — so every one swallowed a column and slid the
rest of its row leftwards, eighteen of them on the worst line. They are spaces
in the file now, and the loader translates any that come back (with codepage
437's hard space) so re-exporting the art can't reintroduce the drift.

Those NULs were also the only reason git guessed the file was binary. With them
gone, .gitattributes has to say so outright, before core.autocrlf starts
rewriting CRLFs that are part of the canvas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 01:15:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 c439dec289 Wordmark: an .ans is CP437, and its newlines are not its rows
The extension is .ans now, not .ansi — the software that draws this stuff is
old and particular about 8.3 names, and .ans is what it expects to open.

The wide mark is a real ANSI-art export rather than something colour-logo.py
painted, which brings two DOS habits the loader had never met. Its blocks and
box-drawing are single bytes in codepage 437, so reading it as UTF-8 raised
instead of drawing; we try UTF-8 first and fall back, and the 53-column mark
keeps its own encoding either way. And an art editor drops the line break on a
row that fills the canvas, trusting the terminal to wrap it — so the file has
nine newlines for sixteen rows. The canvas width comes from the SAUCE record,
which is trimmed off in the same breath, being metadata and not art.

Re-breaking a wrapped row has to restate the colour across the seam: each row
becomes its own Text.from_ansi, so nothing carries state the way a terminal
does when it never stopped reading the one stream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 01:04:53 -04:00
Jean-Pierre MartineauandClaude Opus 5 f870bff990 Node pickers: a repeater is still its own identity
The repeater-admin picker drew every name in the repeater violet, so a section of them
read as one node repeated — the one list in the app where telling two repeaters apart is
the whole point, and the only one that refused to.

Nothing regressed it; it was built that way. The row was `Text(glyph, style=type_style)`
with the name appended after, and a style on the Text *is* the row's base — Rich lays it
under every span, so the type colour that should have coloured one mark coloured the whole
line. The name never carried a style of its own to argue with it. The TX-optimize "measure
at" picker was the same row, copied.

So both build the row the way every other list of nodes does: the glyph takes its type
style as a span and the name takes `name_style(name, key)`, hued by the node's key like a
contact row, a watchlist row, a relay hop. That also puts the row back inside the selection
language — with no base style of its own, the cursor row's `cursor` white now reaches the
name span and folds it, which a type-styled base was quietly blocking.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 04:09:33 -04:00
Jean-Pierre MartineauandClaude Opus 5 afe1424819 Selection: white says "this one", and a chip is grey
Teal was still answering three questions it had no business answering. The wordmark's
colour is the app's identity, and it is also a node hue — so a cyan-keyed node could
vanish into the very highlight pointing at it.

- A node's key-derived hue folds to the cursor white on the cursor row. A row is a
  column of identities right up until it is the row you are on, and then it is
  answering a different question; a hue on it argues with its own selection. Folded
  once at the render boundary for any Text whose *base* style is `cursor` (which is how
  every list and every screen drawing its own rows already marks it), so no screen has
  to remember the rule. `theme.is_identity_style` is node_style read backwards — every
  hue a key can mint, on both platforms — and only those fold: the heat of an age, an
  SNR reading, a red badge, a path line's chip fills all keep what they were given. So
  does the grey of a node no key could place: the highlight knows where you are, not who.

- The reverse-video chip — a dialog's committing button, a running action's Abort, the
  arrow-mode composer slot, the editor cursor — is grey. A chip marks where a press
  lands. It is chrome, and chrome does not get a hue. Light grey slot 7 on the VT: the
  one grey a reverse can put behind text there, and the fill the F-key lane already uses.

- The active sort column's heading and triangle take the same white as the cursor row,
  being the same claim one axis over. They were a bare `#22d3ee`, which put a selection
  cue back on the node spectrum and, on the console, straight onto the brand's own slot.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 01:32:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 602bceea3b Theme: the cursor row is white, not the wordmark's teal
The row `❯` points at borrowed `brand` — so the app's identity ink and
"the row you're on" were the same hue, and worse, teal/cyan is itself a
node hue: a cyan-keyed node's name vanished into its own highlight.

Give the highlight its own name. `cursor` is white on both platforms
(slot 15 on the VT, the top of the bright bank, so the row's bold cannot
promote it further and every dim-slot `not bold` pin inside a highlighted
row goes on meaning what it meant). White sits outside the per-node
spectrum, so it can never collide with an identity; it does share ink
with `you`, which is the accepted cost — spans that set their own colour
keep it over the base, so only unstyled text goes white, and only on the
row already under the reader's eye.

Every surface that draws its own cursor row follows: the select list and
its reorder mode, the feed, chat, routes and actions on the node page,
the composer, records, trace, tx, the walk, the paths screen, the prompt's
suggestions. The composer's insertion-slot chip takes the same white — its
docstring already tied it to "the colour the list cursor below it wears",
and the chip ink was already picked to stay readable on white.

Reverse-video chips keep `selected`: on the VT a reversed white lands on
the light grey the F-key lane already owns, so a white default button
would stop reading as the committing verb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:51:37 -04:00
Jean-Pierre Martineau 1b6a5cc1e7 Packet viewer: a walked trace is not a direct shot
Emptying a trace's relay path — right, since those bytes were readings all
along — left the card contradicting itself: three legs on the links row,
"direct — no relays" two lines under it, and a note crediting the reading
to a sender nothing had relayed for.

An empty chain means different things by class. For every other frame it
means the sender was heard straight off the air. For a trace it means the
relays went unrecorded, which is not the same claim at all — the readings
are proof something forwarded it. So the row says how far the packet got,
which is the one thing those readings do establish, and the reception note
goes back to naming the last relay.
2026-08-22 14:27:15 -04:00
Jean-Pierre Martineau bfd396da69 Docs: two surveys — navigation exceptions, and UI consistency
Both are catalogues, not changes. Nothing in either has been acted on;
each finding says where it is, what it does, and what makes it a question,
so the decisions can be taken one at a time instead of discovered one at
a time. Every code reference in both was machine-checked (144 of them).

The navigation survey lists every place the app does something other than
"Esc pops and you carry on". The headline is that three screens rebuild
themselves each round and lose your place — node detail loses the open tab
too, so working through the Routes list means re-opening it after every
trace — while eight others carefully restore it and four sidestep the
problem entirely by keeping one screen for the whole visit. Also: Esc
peels a live filter on three screens and abandons the whole screen on two,
which is the same keys giving opposite results on the app's most-used
list; six different spellings of the value a Back row resolves with, so
callers test three at once; and a pop-then-re-push workaround repeated
five times where the session API has a gap.

The consistency survey is mostly good news — footer hints, titles, the
lexicon, status marks and the query line all came back clean under
mechanical sweeps, which is what makes the nine remaining items worth
trusting as close to the whole list. The ones that show: four screens
whose empty state shouts in sentence case when the empty state is the only
thing on screen, a dialog with no Cancel whose default lands on an action
and whose rightmost button is the destructive one, and two full screens —
Trace among them — that never had a PicoCalc lane pass and so advertise
pager chips that do nothing.
2026-08-22 14:22:45 -04:00
Jean-Pierre Martineau e86d6a705a Path composer: Esc already cancels, so the Cancel row goes
The row's whole implementation was `super().handle("escape")` — Esc, spelled
out as a cursor stop. It cost a keypress on every platform (End then Enter,
against one Esc), sat below the row that commits so End landed on the way
out rather than on the thing you came to do, and taught nothing: Esc leaves
every other screen in the app without being told to.

The action group now ends where the actions do — Use this path, then Auto
in target mode. Cancel stays where it earns its place: a dialog asking a
real question, where the choice is the point.
2026-08-22 14:08:22 -04:00
Jean-Pierre Martineau 71f60c0eeb Packet viewer: show a trace's links, and say whose signal the reading is
A trace reports on the mesh as it crosses it — every node that forwards
one records the SNR it heard its predecessor at — so an overheard trace
arrives carrying a reading per hop travelled. Those are now a `links` row,
in walk order and coloured by quality like every other reception figure,
which for a walk out and back through a repeater reads as one strong
outbound leg and two weaker ones home.

The card's standing caveat also stopped being true. "Reception describes
the last relay, not the origin" is right for a relayed frame and wrong for
one that crossed nothing — those are only listed at all as of this
branch — where the reading *is* the origin's, and worth saying so: a frame
heard straight off its sender is the best evidence a link ever gives.
2026-08-22 14:03:23 -04:00
Jean-Pierre Martineau c246fb8080 Persistence: a trace's link readings get a column, not a borrowed one
The readings a trace collects arrive where every other class keeps its
relay hashes, so `path` was exactly the wrong place to keep them — and
keeping them there fed the topology graph links nobody observed. They get
`observations.trace_snrs` instead, written and read like the crypto trio
and the addressing columns before them, so a replayed frame shows the same
links a live one does rather than a blank lane.

The 1606 rows already stored were migrated in place: 2454 readings
recovered, every one of them at the 1-byte width the wire uses, and every
fabricated path cleared. The pairs read as what they always were — one
trace heard twice, once a hop out (+13.25 dB) and once on the way home
(+13.25, -5.0, -4.25), the return legs weaker exactly as a longer walk
should be.
2026-08-22 14:00:48 -04:00
Jean-Pierre Martineau 6217a7dc58 Packets: the frames a neighbour sends were being thrown away
Two silent drops in the RX-log mapper, and between them a device sitting
beside this one could trace all day and never appear in the feed.

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

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

The 1604 rows already stored keep their bad paths; a one-shot cleanup
follows separately.
2026-08-22 13:57:06 -04:00
Jean-Pierre MartineauandClaude Opus 5 c19711c463 Map: don't build a street name the frame is about to throw away
_compose will not place a label under its own min_zoom, and street names
carry one of 15. _draw_tile built them anyway, at every zoom: a named
road's label is the expensive half of it — the line clipped to the canvas
segment by segment, each surviving piece measured, the pieces sorted, and
feature_to_dot called once per vertex, which is the per-vertex function
call the geometry path beside it deliberately inlined away. A central
Montréal tile carries 270 of them.

Measured on the desktop against the committed renderer, one tile at
PicoCalc geometry: 16.8 ms of a 46 ms frame at z13, all of it discarded a
moment later. Below the gate a frame is now 27-52% cheaper across z9-z14,
which on the device is a third of a second off the two zooms the map
most often sits at — it opens on the region fit, and z13/z14 is where a
mesh-sized frame lands.

The zoom is the display zoom, not the tile's: an overzoomed view reads
its ground off a lower tile but is still zoomed in, and still wants its
names. The 15 is a constant now, read by the queue-time gate and the
place-time test both, so the two cannot drift.

Nothing in the picture moves. Rendered against the pre-change renderer at
z9-z18 on three geometries, coarse pass included: every frame
byte-identical, every Ghost raster identical (a label was never in the
raster — that is the overlay). The skipped candidates never reached the
max_labels budget either, since the placement loop continues past an
under-zoom label rather than counting it, and sorted() is stable, so what
does get placed is unchanged. Crossing the gate changes the viewport and
so the ground key, which means a zoom across it always redraws rather
than serving a frame short of its names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 01:58:58 -04:00
Jean-Pierre MartineauandClaude Opus 5 c79ade3fab Map: the rough first pass is off, behind a switch rather than a deletion
_COARSE_PREVIEW turns off the two-pass draw: every view now waits for the
whole picture, standing on the last frame's ground reprojected until it
lands, or on nothing where the view has run past that too.

None of the machinery goes. Both passes still draw, render_ground still
takes coarse=, and the abandonment point between them is still where a
held pan key gets off — what the switch decides is only whether a moved
view is offered the first pass, which is why it is read where the raster
is asked for and not inside the renderer. Turning it back on is one line.

The abandonment test needed to say what it means to survive the change:
it asserted no finished pass appeared in a list it read after the loop
closed, which quietly depended on the *next* view's draw not getting a
scheduler step in time. It reads the list where the abandoned key's draw
returns now, and asks only about that key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 01:09:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 b4db55961f Map: three optimisations that were undoing each other's work
The rough first pass and the prefetcher each state a rule about when they
must stand down, and each was reading that rule off the wrong thing.

The prefetcher's rule is that speculation is never the thing in the way:
no guessing while a tile is in flight or a raster is running or queued.
It was asked from inside _ensure_tiles, which runs before _ground — so
what it read was the *previous* paint's answer. On a settled map a find
keystroke moves nothing, so the view is still the one it is entitled to
guess from, and it started a tile decode in the very paint that queued
the frame the reader was waiting for: the ~110 ms in front of the next
frame the pacing was written to avoid. It is asked at the end of the
paint now, once _ground has said what this frame owes.

The rough pass's rule is that it is skipped where the ground is already
drawn, because a coarse picture of streets the reader can see takes
detail away. It decided that on _aligned, which also compares the find
query and the marker count — the *overlay*. Change one and the ground
underneath is the same ground, still drawn, still correct; but the
answer came back "not aligned", so every letter of a find query flattened
the streets to the rough pass and drew them back, once per keystroke.
That question is now its own — _ground_drawn, which asks about the view
alone — and _aligned goes back to being only about the picture.

And a guess must not push past a silence: a tile the source has just gone
quiet about is on a cooldown so an offline map isn't retrying it on a
loop, and the prefetcher was walking straight past that to re-ask it off
its own initiative. It leaves cooling tiles alone; when the cooldown
expires the tile is in the plan again, and if the reader has panned onto
it by then the fetch is theirs, made on their behalf, which is the one
the cooldown was always sized for.

Two constants went with them, both describing mechanisms that aren't
there: _PREFETCH_LEAD, which measured the lead in pan steps the plan
stopped working in, and _COARSE_ROADS, whose class list left out the two
classes (rail, and the unrecognised default) the priority test it was
supposed to mirror actually drops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 00:39:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 98e8e5f260 Map: fetch the ground the next move will want, while nobody is waiting
A tile is 1.4 s off the PicoCalc's Wi-Fi on a good day and a few hundred
milliseconds to decode the first time, so ground fetched only once it is
looked at arrives after it was wanted. It does not have to: whatever the
reader presses next, the ground it reveals comes from the ring of tiles
around the ones on screen, or from one of the two neighbouring zooms —
the only moves whose tiles are a different set entirely. That is a plan,
not a prediction, and what the reader just did orders it.

A run of pans in one direction is a heading, and the side of the ring it
points at is fetched first; once the run is long enough to be a heading
rather than a nudge, the corners flanking it come too, because a diagonal
is two keys and readers steer. Then the step out and the step in. Then the
rest of the ring, which with no heading at all is the whole of the plan,
nearest neighbours before corners. A zoom or a jump home clears the
heading: that is looking around, not travelling on.

Working in tiles rather than in pan steps matters at the zooms where one
keypress uncovers no new tile at all — the plan asks for the ground a move
reaches, not the ground one press lands on.

The pacing is the load-bearing part, because speculation must never be the
thing in the way. One fetch at a time; never while a visible tile is in
flight or a raster is running or queued; and not until a second of quiet
has passed since the last of that cleared — measured from when the screen
went quiet, not from when the view stopped moving, since a frame that took
a second to draw has not been looked at for a second. Without that wait a
raster landing between two keypresses of a pan reads as a pause, and the
tile decode it starts lands in front of the next frame: measured at ~110 ms
on the device, which is exactly what this must not cost.

What a prefetch leaves behind is a tile in the source's cache, on disk and
in its small resident memo. The screen keeps only what it draws.

Measured on the device, panning on after a pause, onto ground never
fetched (zoom 12, three runs):

  without: 0/1 tiles ready, view complete after 2297-2324 ms
  with:    1/1 tiles ready, view complete after   81-112 ms

and the pan-after-idle path is unchanged: 621 ms to ground without, 535 with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:56:08 -04:00
Jean-Pierre MartineauandClaude Opus 5 8b5c27909b Map: draw a rough frame first, so a moved view is never black for long
A coarse pan step is 30% of the view, so four keypresses leave nothing of
the last frame under the screen — there is no ground to reproject, and the
map went black. It then stayed black for a whole raster, which is most of
a second on the PicoCalc, and often for two: the raster in flight was
being drawn for a view the user had already left, and the one they were
actually looking at could not start until it finished.

So the raster is two passes now. The first draws the ground fills, the
watercourses and the through-roads (tertiary and up, derived from the road
styles so the two sets cannot drift) — no buildings, no back streets, no
text at all. It is a quarter of the work: 249 ms against 962 at zoom 13 on
the device, 120 against 529 at zoom 16. The second draws the finished
frame over it.

The gap between them is where a moving view gets off. Checking _wanted
there cuts the abandonable unit of work from a whole raster to the rough
one, so a pan being held is answered with real ground about four times as
often and the finished frame is drawn for where the user actually stopped.

Measured on the device, four quick pan steps from a settled map to the
first ground back on screen:

  zoom 13:  1984 ms -> 617 ms
  zoom 15:   970 ms -> 348 ms

The rough pass is skipped where a frame for this view is already on screen
and merely waiting on a tile: there a coarser picture would take away
detail the reader can already see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:19:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 d12873694e Map: magnify the stand-in ground by the dot, not by the glyph
Zooming in reprojected the last frame's ground a cell at a time, which
means a source cell that now covers a 2x2 block of cells was stamped into
all four of them whole. That is double vision: the same 2x4 dot pattern
side by side and one above the other, four times over at two steps. It
reads as a rendering fault rather than as the coarse preview it is, and
it puts the ground up to a cell away from where it really is.

A braille cell is 2x4 dots, so the honest mapping is fixed arithmetic:
each cell of the block shows its own quarter (or sixteenth) of the source,
enlarged. That is 256 patterns by n*n sub-positions and it never varies,
so _magnified() builds it once per factor and the paste reads it with a
single index — the same one lookup per cell the naive version cost, and
none of it on the paint path. _ghost_axis now carries the remainder of the
cell division alongside the quotient, which is the sub-position.

Measured against the real frame that lands a moment later, the stand-in's
dots now sit on the ground's dots 85% of the time at one step (was 83%)
and 64% at two (was 61%) — and at two steps its best fit moved to a zero
offset, from a dot column out.

Reduction is deliberately left alone: a view that zoomed out keeps the
whole source glyph in the one cell it shrank to, because dropping three
quarters of its dots would break every thin line into dashes exactly as
it gets harder to see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:54:50 -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 2731af12cb Battery: the sweep is the charging state, so it runs everywhere
The gauge says charging by sweeping its fill bottom-to-full, holding the
charge colour. That is the state itself, not decoration, so gating it on
Platform.effects left the PicoCalc with no way to say it at all — and,
worse, frozen on the sweep's empty frame. Only the low-battery blink is
decoration over a charge the cell already shows; that alone stays behind
`effects`, and `animate` now says so.

The sweep steps on the platform's own idle repaint interval rather than a
fixed second, so each repaint advances it one clean step instead of
aliasing across skipped frames: 1 s on the desktop, 2 s on the handheld.

A pack at 100% draws as not charging whichever way the flag reads — a full
cell resting full is the honest picture, and a charger left plugged in
shouldn't leave the gauge sweeping forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:16:09 -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 4.8 16532ea14b Bridge: forward the RX packet log so the live feed isn't advert-only
MeshTerm's live feed is built from RX_LOG_DATA events — every overheard
frame with its SNR/RSSI. pymc_core's frame server has push_rx_raw (the
0x88 push) but its _setup_push_callbacks never registers on_rx_log_data,
so it is never called: the promiscuous log is dropped and only adverts —
which carry their own wired advert_received push — reach the feed. The
radio was plainly hearing requests and channel text too; none of it showed.

Subscribe to the dispatcher's raw-packet feed directly and push each frame.
Routing through the dispatcher rather than on_rx_log_data keeps it clear of
the frame server's per-connection clear_push_callbacks(), and that feed
fires before dedup, so the client sees every repeat — what a live feed wants.

Same missing-wiring shape as the trace push; upstream fix is likewise to
register on_rx_log_data -> push_rx_raw in _setup_push_callbacks. Confirmed
against a live mesh: the feed now shows the full packet mix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 15:22:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 ec4836f63d Live feed: the top of the list is two stops, watching and reading
The newest row now carries a pin — the stop above row 0, which holds the
topmost position rather than a packet. Every arrival takes the highlight
with it, so the cursor stays on the traffic instead of being pushed down
into history by it, and it draws ^ rather than ❯: the mark points past
the top of the list, at the packets still to come, because that is what
it is attached to.

One ↓ off the pin does not move a row. It lands on that same newest
packet, now normally selected under a plain ❯, and from there the
highlight belongs to that packet and rides down as newer ones arrive. So
the pin follows the stream and a selection follows a packet, and the
cursor lane says which you are doing. ↑ from the newest packet re-pins,
Home jumps back to the pin from anywhere, and the F-lane's Top chip is
how the PicoCalc reaches it with no hint line to name it.

The pin is a real cursor stop, not a flag beside one, so every move
stays a clamp on the single index _cursor already was — which is what
makes ↓ off it land on the newest packet rather than the second. Opening
a packet drops the pin: Enter names *this* packet, and arrivals during a
long read would otherwise walk the cursor off it and land the closing
viewer somewhere the reader never chose.

^ is plain ASCII, so it needs no console-font glyph. It is deliberately
not ↑, which this screen already spends on the window's "↑ n more" and
would read here as "there is more above" — the one thing the top of a
feed can never mean; nor ▲, which is the repeater in the node-type set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:05:48 -04:00
Jean-Pierre MartineauandClaude Opus 5 3c714e4c84 Map picker: draw the frame the crosshair was in, not the one after it
The picker's crosshair is a per-frame marker: render_body appends it to
_markers, calls up, and takes it straight back out in a finally. But the
real ground raster runs off the paint path, and _draw_ground read
self._markers when the *thread* ran — after that restore. So every
finished basemap frame was drawn without the crosshair, and landing it
erased the pick: pan and the crosshair rides the ghost, stop and the
streets arrive over it.

Snapshot the whole scene — viewport, markers, find query — when the
raster is requested, and hand it to the draw. A frame filed under a key
has to be the scene that key stands for.

Two things fall out of it. The "same view, only tiles differ" shortcut
now compares everything but the tiles, since a frame drawn for a
different marker set is a picture missing a node, not one missing
streets. And _ensure_viewport splits out of render_body, so the picker
settles its view without rendering a whole crosshair-less frame first —
one less full raster on open, and the crosshair rides a resize too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:58:54 -04:00
Jean-Pierre Martineau 9cc2d20747 Keep .patch files LF, like the scripts they sit beside 2026-08-13 00:58:54 -04:00
Jean-Pierre Martineau 3a02a14be7 XIAO radio: build upstream's serial env, not our own Serial1 patch
MeshCore has carried companion-over-hardware-UART for a long time — the defines
SERIAL_RX/SERIAL_TX, packaged per board as a *_companion_radio_serial env, and the
ESP32-S3 twin of this very board already uses the same D6/D7 pads. Our patch was
reimplementing it for the one platform it had never been wired into: at our old pin the
SERIAL_RX branches lived inside the ESP32 and RP2040 blocks and nRF52 fell through to
Serial. So the patch is now the two things actually missing, both upstream-shaped and
submitted: a Serial1 reference so the shared block compiles on nRF52, and the
Xiao_nrf52_companion_radio_serial env carrying the I2C escape in build_unflags rather
than editing the flags every XIAO env inherits.

Pin moves to a dev commit, since dev is the branch MeshCore takes work on.

flash.py grew the check that was missing when it mattered: a drive letter is not an
identity — when one board leaves the USB bus another inherits its letter — so it now
reads INFO_UF2.TXT and refuses a bootloader that isn't a XIAO, or that carries S140
6.1.1 and would take this S140-v7 image at the wrong address and never boot. It also
stops pretending copy-to-drive is the only route: our bootloader exposes a serial port
and no mass storage at all, so it prints the serial-DFU command instead of a dead end.

Verified from a clean clone at the new pin: env builds, uf2 converts to the same 811008
bytes at 0x27000 that is now running on the radio, and both Xiao_nrf52_companion_radio_usb
and Xiao_S3_WIO_companion_radio_serial still build, so neither side of the new #if moved.
2026-08-13 00:58:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 b015091ae0 Credit MeshCore at meshcore.io, its authors' own site
Both README links and the About page's attribution pointed at
meshcore.co.uk. The project's home is meshcore.io; a credit is worth
nothing if it sends the reader somewhere other than to the people it
names.

The About page's line moves with the page's other uncommitted edits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:58:54 -04:00