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>
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>
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>
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.
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.
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.
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.
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.
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.
_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>
_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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>
A transcript's ``── Wed Aug 12 ──`` does exactly what a grouped list's
heading does — it pins to the top row while its day scrolls under it, and
^PgUp/^PgDn step by it — but it was drawn muted, which reads as chrome and
put the one landmark in the scrollback below the messages it labels. It
takes the accent every other section heading in the app wears.
The rule above the compose line keeps its grey: that one really is chrome.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A name standing alone above its messages was a coloured word with nothing
to say it was a label — at a glance it read like something someone typed.
It now sits in a chip, and not a chip of its own invention: ui/widgets.
name_chip builds a one-hop PathLine and asks it for its line, so the fill,
the ink, the padding, the rounded cap and the closing point are whatever a
route's segments are wearing today and cannot drift from them. A sender
wears exactly what that node wears as a hop — hue in the fill under dark
ink, the keyless grey where no key places them, and our own messages
headed by the ★ on its neutral grey rather than by the word "you".
Only the group header. An @mention inside a body is part of what was said,
and framing it would put a chip in the middle of a sentence.
Degrading comes free with the borrowing: where the terminal can't draw
separators — the PicoCalc console among them — a path line is arrow text
and one hop is just the name in its hue, which is what a sender label was
before it was framed. The specimen card grows a chips row, which doubles
as a one-hop sample of the route language.
And the unknown sender takes node.unknown rather than muted: one hex apart
here, a whole slot apart on the console, and an unidentified sender is
content you can still act on, not chrome.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first cut pushed TRACE_DATA for any received trace packet, so every
trace read as an instant success: the dispatcher is promiscuous and fires
the packet-received callback for strangers' traces and for our own
outbound echoed back by the first repeater (same tag, still in flight).
Two corrections:
- Correlate against traces we originated. Wrap send_trace_path_raw (the one
send seam the frame server calls) to remember each tag and its route, and
push only a received trace whose tag we sent. Entries expire after 90s so a
never-answered trace can't match a recycled tag.
- Read the SNRs from the right place. A TRACE keeps its route (hop hashes)
in the payload unchanged; the per-hop SNRs ride in the packet *header* path
field, one signed byte (SNR*4) per hop. The earlier code waited for SNRs to
appear in the payload, so it never fired. Now completeness is "the header
has accumulated an SNR for every route hop", and that's the only thing we
push — the finished reply, with genuine per-hop SNRs.
Pending on-air verification (RF conditions poor at commit time); leaves
verbose per-trace INFO logging in place for that test, to be trimmed once a
clean multi-hop trace is confirmed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pymc_core never forwards a received trace to the companion client: its
frame server registers every push callback except on_trace_received, and
nothing in the library ever fires the trace_received event, so the whole
path is dead code — the only push_trace_data call is the self-hop
shortcut in _cmd_send_trace_path. A trace sent from MeshTerm therefore
returns RESP_CODE_SENT and then times out, because the node parses the
reply and drops it.
Shim it at the dispatcher's packet-received callback (where CompanionRadio
already watches every RX packet with its measured SNR): wrap it, and for a
TRACE payload re-slice the on-air bytes into the hash/SNR blocks
push_trace_data wants, emitting the standard 0x89 frame the meshcore client
decodes. Verified byte-exact end to end. Handles both the SNR-per-hop
layout (final SNR from our reception) and firmware that trails the final
SNR in the payload.
Upstream fix (no local pymc_core checkout to carry it): mirror every other
push — fire trace_received from _on_packet_received and register
on_trace_received in frame_server._setup_push_callbacks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A name standing alone above its messages was a coloured word with nothing
to say it was a label — at a glance it read like something someone typed.
It now sits on a field: the node's own hue as ink on the neutral dark grey
pathline already fills our ★ chip with, closed at both ends by the rounded
caps a path line closes on, or by half blocks where the font has none.
Only the group header. An @mention inside a body is part of what was said,
and framing it would put a chip in the middle of a sentence.
The widget is ui/widgets.name_chip — the ink arrives as whatever style the
caller would have drawn the name in and is *added* to the fill as a Style,
because Rich silently drops "node.unknown on #3f3f46" and renders the name
bare. The console has no field to offer (its one dark grey is slot 8 and
backgrounds stop at 7), so the name draws unframed there, bound on
truecolor like the heat gradient. The specimen card grows a chips row so
both dresses are visible on the acceptance screen.
And the unknown sender takes node.unknown rather than muted: one hex apart
here, a whole slot apart on the console, and an unidentified sender is
content you can still act on, not chrome.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two rows renamed, and the rename carried into the screens they open —
the config editor's table title and its staged-count header, the support
page's own title (it comes from the tool, so it followed on its own).
"Support this project" never said which project; the menu is MeshTerm's
and the section above it is already About MeshTerm, so the name is the
useful half of that phrase. "Device configuration" was the longest label
in the list and set the width of the whole name column: at 19 cells
instead of 23, every description now gets 51 and the widest row is 68.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The label column is the widest tool title plus two cells of gap, which
leaves the muted description 47 to work in. All 22 of them ran past it —
"Act on the device itself — clock, backup, identity key, reboot, reset"
needed 94 columns of terminal to say what 67 could. The row scrolled
under ←→ rather than lying, but a description you have to scroll is a
description nobody reads.
Rewritten to fit: the subject moves into the title's shadow (the row
already says Device actions, so the description just lists them), the
parenthetical asides go, and the trailing clause that repeated the
obvious goes with them. Widest row is now exactly 72.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New file laying out the ground rules for outside contributions ahead
of opening the repo up: dev setup, the test/lint gates a PR needs to
pass, and where the style guide lives (CLAUDE.md is that guide, not
just an AI aid).
The licensing section goes one step past what Apache-2.0's inbound
license clause alone gives — a PR also grants the right to relicense
the contribution later, so the project doesn't end up needing every
past contributor's sign-off if the license ever changes. Framed as
automatic (opening the PR is the agreement), no signature bot.
README's Development section now points to it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MeshTerm goes out under the Apache License, Version 2.0 — free to use,
modify, and redistribute, commercially or otherwise. The patent grant
and the built-in trademark clause (§6) are why Apache over MIT.
The "MeshTerm" name and any logo are reserved as trademarks outside
the code license (NOTICE), and the app's donate link is called out
as supporting this project specifically — a fork can take the code,
but not the name or the donations that come with it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The three sections were placeholders holding a shape. This writes the
first one: what MeshTerm is (one screen for what the radio can tell you,
the five menu sections as a list, and the standing promise that nothing
transmits unasked), where it came from (June 2026, a few serial scripts,
grown by the recording; the mesh's scarce airtime is why it listens more
than it speaks; later the PicoCalc), and the licence — MIT, as the
package metadata declares, with the credit MeshCore and OpenStreetMap
are owed, the ODbL by name.
The layout is unchanged: title, standfirst, three sections, rule,
colophon. It just runs longer than a screenful now, which is what the
sticky headings and the section jumps were for.
Link targets also drop a leading www., so the OSM copyright URL reads as
the host it is rather than a subdomain nobody types.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three About pages were scaffolding built out of Text() calls, which
made writing them a Python edit and capped what they could ever say. They
are markdown now — meshterm/assets/pages/*.md, drawn by ui/markdown.py
into the app's own language rather than markdown's: # the page's name in
brand, ## a section in the body accent (with the muted · aside), ###
indented with its prose, paragraphs and lists hanging as blocks, quotes
and fences behind a rail that survives wrapping, tables through Rich, and
links that say where they go — nothing is clickable on a framebuffer
console. Two paragraphs are page frame rather than body and sit flush and
muted: the standfirst under the title, the colophon under the closing
rule, which is where the copyright lands.
The pages stay scaffolded on purpose — the placeholders are unchanged —
but filling one in is now editing its .md, and the live package facts
arrive as {version}/{author}/{copyright} so a page still can't drift from
the build it describes.
Being a document earns the screen two things a block never had: its ##
headings are landmarks, so a heading pins while its own prose scrolls
under it and ^PgUp/^PgDn step by them, and the console lane claims
Sect ↑/Sect ↓ on the free F1/F2 pair once a page has more than one
section — dimmed while the whole page fits, since the lane never
advertises a dead key.
Parsing is markdown-it-py (already under Rich, now declared): a real
CommonMark parser, so an author writes ordinary markdown. The md.* styles
are defined in both themes; the new test renders the whole vocabulary at
53 cells and asserts it stays inside the console font and the 16 slots.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The menu asks "What would you like to do?" and its five sections each
name a doing. "What is this thing, and who made it" is not one, and had
nowhere to go — so it gets the sixth section, the one that names a
subject, sorted last where a reader who has run out of things to do finds
it. Three pages, in the order a stranger asks:
About MeshTerm what this is, where it came from, its terms
About the author the person behind it, on the mesh and off it
Support this project what keeps it going, and the ways to help
Every page is scaffolding: the headings are the settled part, and each
section carries one lowercase muted "placeholder — …" line where its
prose will go. That is the app's own empty-state voice, so an unwritten
page reads as deliberately unwritten rather than as a screen that failed
to load, and filling one in means replacing its placeholder — the screen,
the menu rows and the CLI faces all follow unchanged. What is not a
placeholder is the version, the author and the copyright span: they come
from the package itself, so the About page can never describe a build it
isn't running inside.
The pages act on nothing — no radio, no database, no device — so each
opens straight to its screen with no prompt first, and each keeps a CLI
face (meshterm about / about-author / support) printing the same page to
a shell.
Two new icons, both funnelled for the console: 📖 read/about becomes ¶,
the page-of-prose mark, and 💰 support/donate becomes $.
ScrollScreen earns a derived footer hint on the way through. A read-only
body has only two keys to name, the pager and Esc, so passing "↑↓ PgUp/
PgDn scroll · Esc close" at every call site was both repetition and a lie
on a body that fits — the standing rule is that a footer never advertises
a key that would do nothing. Left unset, it now names the pager only
while the content actually overflows, and takes its Esc verb from the
surface: a floating view closes, a full screen goes back. Every existing
caller passes its hint explicitly and is untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
The menu asks "What would you like to do?" and then grouped its rows by
subject, which is not an answer to that. Mesh had grown to seven of the
nineteen rows — live watching, passive browsing, and one transmit action
in the same bucket — and Tools said nothing at all, since everything in
the menu is a tool.
So each section now names a doing, and holds what shares it:
Message Chat · Channels · Courier · Contacts
Watch Dashboard · Live feed · Watchtower · Time machine
Explore Map · Mesh walk · Trace target · Trace path · Trophy case
This node Device info · Device configuration · Device actions · Send advert
Other nodes Repeater admin · TX optimize
Four rows moved for a reason. Contacts is the address book the three
features above it pick a recipient from, not a mesh view. Time machine is
the past tense of the Dashboard, and was stranded in the grab-bag. Send
advert is your own node announcing itself — a device action, filed with
the mesh views only because it was about the mesh. And Repeater admin and
TX optimize are the same job — someone else's radio, over the mesh, with
credentials — which the old Device-management/Tools split hid.
The two walks join the topology views they walk over, with the boards
that score them underneath: see a node, walk to it, get scored. Nothing
is renamed (a menu row still names the screen it opens) and no section is
now larger than five rows or smaller than two.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tool's description is written to say what the tool is for, not to fit
whatever column is left after the longest name — so it was read to the
right edge and no further. The list screen has had per-row h-scrolling
for a while (the Trophy case, the Watchtower log); the label+description
lists just had no way to ask for it, because the builders that lay them
out never see the screen they will be opened in.
So the row asks. menu_rows and the main menu pin their label lane as each
row's hscroll_from, and a list turns its scrolling on when any row
declares one — a head block is only meaningful as the part that stays
put, so a row that pins one is a row built to scroll. The name holds its
place, the description slides under it, and a list whose rows all fit
still ignores ←→ and says nothing about them in its footer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every icon on the console is a one-glyph stand-in for an emoji, and the
menu is where that pinches hardest: the main menu drew @ for both Map and
Mesh walk, … for both Live feed and Time machine, and ?? for Trace path,
in a lane costing three of fifty-three columns on every row (JP,
on-device).
So drop the lane there, mark and emoji alike. A command row's icon is
decoration — it names the action's family while the label names the
action — so where it can only be approximated it is worth less than the
cells it takes, and the label and its description get them back. This is
a platform choice (Platform.menu_icons), not a per-emoji one: a glyph
that carries *data* is not an icon in this sense and never goes, which is
why the Channels page keeps its #/🌐/🔒 openness mark, the feed keeps the
class icon it shows in place of the class word at 53 columns, and node
types, status marks and the mute mark in the channel lane all stay put.
Three funnels do the work, so a screen never asks the platform itself:
command_label for a label carrying its own icon, command_icon for a row
that builds its own lane, and marked_label where the icon carried a tint
— a delete row's red moves onto its words rather than leaving with the 🗑.
Section headings go through the same cut (a stand-in beside the ── rule
is pure noise), which also retires the ?? that No revisits was drawing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The console's compact glyphs for REQ and RESPONSE read them as inbound
and outbound mail — ↓ for the ask, ↑ for the answer. That is the wrong
axis: what a reader is tracking is the exchange, not the mailbox, and in
an exchange the question goes out and the answer comes back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
On the PicoCalc the viewer's reception row folded the bare word "rssi"
onto a line of its own. Two things were spending the cells it needed.
The dialog gutter: every floating box left six columns to the backdrop it
sits over. Three a side reads as floating at 72; at 53 the same gutter is
a twelfth of the whole display, and it costs content. How wide the gutter
is worth being is now the platform's call (Platform.dialog_margin), and
the console's is four — still floating, two columns back.
The separator: the row chained its two atoms on the roomy " · " no
matter the lane. That is the header's problem too, and the header already
solved it — roomy by default, compact when the cells run out — so the row
now takes the same trade, sizing itself at render time like the via path
beside it. Two cells alone would have left a two-digit SNR beside a
three-digit RSSI still folding; together they fit with a cell to spare.
The roomy/compact pair moves to ui/menus.py, one definition for both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every action row led with its mark and a hard space, which only aligns
while every mark is one cell wide. ⚡ is an emoji — two — so Explore's
label sat a column right of Compose's, Trace's, and the rest.
The marks now go through a measured icon column: each pads out to the
widest of them, so the labels share one column whatever they lead with.
Measured rather than fixed at two because the marks change width with the
platform — on PicoCalc every icon funnels through theme.glyph to a single
console-font character, so the column tightens to one cell and the labels
move left with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tightest rung was a week, which is no help to a device whose contact
table filled up in one busy afternoon. Nothing else moves: the rung sweeps
only contacts actually heard and since gone quiet, the never-heard bucket
still stands apart, and the typed `delete` gate still names the span.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sweep is one companion command per victim over a link that can be slow,
so its end is a count away — but it ran under the busy overlay, which only
ever says "working" and is drawn only in the gaps *between* screens. Over the
pushed contacts list it drew nothing at all: a hundred-contact purge looked
like a screen that had stopped answering, while the arrow keys still moved a
cursor nothing was being done with. And what it removed went to a note the
reader would only meet on their way off the screen.
Now the app's progress dialog counts the sweep down against a real total and
swallows every key while it runs, and the outcome lands in a dialog dismissed
in front of the list it just purged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Use this path" was respelling the composed spec as raw hex, two rows under
the live preview that *is* the path — the same thing said worse, growing by a
hop every time the route did. The row now says what Enter does and lets the
picture above it say what to. Only the unarmed case still needs words.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
←→ was sliding the whole alert row, so reading a long message to its end cost
the marker, age, kind, glyph and node name off the left edge — the lanes that
say *which* alert this is, and the part that already fitted. Each row now
declares them (`Choice.hscroll_from`, the seam the Trophy case cut), keeping
the `—` with the head so the message still arrives introduced.
The ←→ footer atom is gated on the run rather than the whole label, so a row
whose pinned lanes make it wide but whose message fits no longer advertises a
key that would do nothing.
The section jumps were already there: two `section_heading` rows earn the
list its ^PgUp/^PgDn and their F1/F2 chips automatically.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>