Commit Graph
82 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5.5 85315dc3b7 The Cardputer Zero's Cap LoRa-1262 works with no setup
On a Cardputer Zero the device screen lists the Cap as "Cap LoRa-1262",
and --spi reaches it, with no profile: its wiring ships beside the
AIO's, listed when the board's own ext_5v_out switch says this is a
Cardputer Zero (spidev0.1 alone would claim every Pi with SPI on).

The Cap needs two things before the chip answers, and the wiring can
now say both. `leds` sets the switches the device tree hands to
gpio-leds — ext_5v_out powers the header, ext_usb_gpio_fun=0 keeps
the reset and interrupt pins on GPIO rather than USB — and puts them
back when the node ends, so the header is unpowered again after
MeshTerm quits. `pi4io_*` drives the Cap's PI4IOE5V6408 at 0x43, which
enables the RF path; a silent expander is reported as the Cap not
being attached.

Checked on the hardware: reset 26, busy 22, irq 23 on spidev0.1 (the
lines M5's factory test drives), the node comes up from a cold
header, and it hears the mesh at +12 dB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 15:29:40 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 61d7463ddf A connection that won't open says why, wherever it fails
The September messages reached the device picker and the command line, but
three other places still lost them. The reconnect dialog retried every
failure in silence, so a device that came back and refused (its port taken
by the MeshCore app, a stale pairing) kept the spinner up forever; from the
second failure in a row it now shows the reason under the spinner, and goes
back to waiting if the device is unplugged again. A device named on the
command line skipped the picker, and a failed start was dropped unlogged; it
is now shown before the menu opens. And a failed open left the dead device
behind, so is_connected said yes and every later tool failed with "not
connected": it is dropped, and the next tool tries again and says why.

TCP failures are named one by one (host not found, nothing on the port, no
route, no answer, hung up) instead of all reading "no response from a
MeshCore companion". A failure MeshTerm has no name for is no longer called
"not a MeshCore device": it is said in its own words, its traceback is
logged once, and the dialog says where the log file is. The tool-failure
dialog stops reading an error's brackets as markup, which had silently
deleted "[org.bluez.Error.Failed]" from Linux Bluetooth errors.

docs/guide/connecting.md lists the TCP messages and where each message
appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 14:43:53 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b6e7cd5c16 The handhelds are named for their variant: picocalc-lyra and cardputer-zero
M5Stack makes three Cardputers and only the Zero runs Linux; the PicoCalc
takes several cores and only the Luckfox Lyra runs MeshTerm. So the bare
names were ambiguous, and every reference to the two devices now carries
the variant: the --platform values and MESHTERM_PLATFORM, the Platform
constants (PICOCALC_LYRA, CARDPUTER_ZERO), their lane decks and every
screen's picocalc_lyra_lane / cardputer_zero_lane, the font inventories,
the lane chips' theme styles, the scripts/ folders, the two chrome test
files, and the docs and CLAUDE.md that name them.

There is no alias: --platform picocalc is refused with the three real
choices listed, rather than falling back to the desktop layout. The
PicoCalc is auto-detected from its device tree, so it needs nothing.
What names hardware or a user's data keeps its name: the PicoCalc
keyboard's input-device name, its battery's sysfs supply, and the serial
profile the Lyra setup script writes. .gitignore follows the firmware's
folder, and the changelog keeps the words each release used.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 11:52:41 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 fedb56bf9e On the Cardputer the header folds into the title bar
The Cardputer's 14 rows had a whole one spent on the header. It has no
header row now: its unread and Watchtower badges and the battery ride the
title bar's right end, after the way out, so they sit in the top-right
corner they always had, one row up, and every screen gets that row back.
The way out gives way first and then the title, never the status. The
wordmark moves to the main menu's title, in the header's own colours, and
the activity pulse goes; the dashboard draws the mesh's activity in full.

A tall dialog now draws over the bar, eleven rows instead of ten, but
never over the lane, which is the dialog's own and names keys nowhere
else. Where a box covers the bar, the bar's ends either side of it are
blanked, since a stray "7%" beside the box would read as the battery.

JP chose the corner placement and the dialog height from rendered
mockups. The PicoCalc and the desktop are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 17:00:22 -04:00
Jean-Pierre Martineau 032e686beb Merge branch 'main' into cardputer-zero 2026-10-02 03:13:56 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b7fe257e2a The quit confirm keeps the chip that asked: F3 again leaves
On the PicoCalc the main menu's middle chip, Quit?, opens the quit
confirm, and the confirm's own lane was empty, so leaving took a move
to the Quit button and Enter. The confirm now carries Quit! on that same
F3, sending the same quit, which with the question up is the leaving, as
a second ^Q is. A button dialog can take a lane for that one case; every
other prompt keeps its empty one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 03:11:20 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 6eb7d8f6bd Each handheld deals its own F-key lane, and the Cardputer Zero joins as a platform
A lane's keyboard-bound half is now a LaneDeck named by Platform.lane_deck: which
keycodes drive the slots, where the chips sit, how they are drawn, and which of a
screen's lane definitions it reads. Screens define picocalc_lane; cardputer_lane
follows it until a screen overrides it, so the two can part ways one screen at a
time. The Cardputer deck is Fn+4..8, Shift with them as F16-F20, eight-cell chips
centred over their keys in the fn key's orange-red and the Shift blue.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:33:44 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 c1d0772baf Lint and format the last few commits
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 02:09:04 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 2d6b8ada6e The main menu's quit hint reads ^Q quit?, since it asks first
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:52:49 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 dd64ea8e87 The PicoCalc's main menu shows its way out, with Quit? on F3
With Esc inert at the main menu, the PicoCalc had nowhere to say how to
leave. Its title bar now ends on ^Q quit there (Esc clear while a filter is
typed), and the menu's F-key lane puts Quit? on F3, which asks first, with
Quit! behind it on F8 to leave without asking. Follows up issue #22.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:46:06 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7071214660 ^Q asks before it quits, and Esc at the main menu stays put
Esc-mashing back to the menu used to land on the quit confirm, and ^Q, one
key from ^W, quit with no question at all. Now Esc at the main menu only
clears a typed filter, and ^Q floats the same quit confirm the Quit row
opens, from anywhere and without stopping what runs underneath; a second
^Q while it asks leaves at once. The main menu's hint names ^Q quit, the
only place the chord is shown. Option D from issue #22.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:31:03 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 cdea534fa0 A reboot sent from Device config raises the reconnect dialog at once
The reboot flow used to send the command and then wait for a liveness poll
to see the link drop. A quick reboot behind a port that never goes away
(a USB-UART bridge) was never seen, so the app went on sending commands to
a board mid-restart. Sending the reboot now declares the link down itself:
the session watcher wakes on the announcement, the Device rebooting dialog
takes over, and the first reconnect attempt waits a moment for the board to
actually begin restarting.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:09:32 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 9538bfedce MeshTerm drives an SPI radio itself, without the bridge service
A LoRa chip on the host's own SPI bus (the uConsole AIO) is now a
transport like serial, BLE, and TCP: --spi, an SPI profile, or the
picker's row for /dev/spidev1.0. MeshTerm starts a software node as a
child process under whichever Python has openhop-core, talks to it over
a loopback port, and ends it on disconnect; the node also exits with its
parent, so the pins are free whenever MeshTerm is not running.

The node keeps identity, prefs, channels, and contacts under
~/.meshterm/radio/<spidev>/, and on first use copies the bridge's
identity, contacts, and radio settings so it stays the same node.
Board wiring lives in a profile's spi table; radio settings are the
node's own, edited on Device config. Closes #21.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:48:32 -04:00
Jean-Pierre MartineauandClaude Opus 5 38d9ffacc5 Every glyph that may be drawn as an emoji is reserved two cells
The curated narrow and wide sets are gone, with the probe that decided when
to apply them and the add-narrow-emoji skill that fed them. They lined up
the rows somebody had already looked at and none of the rows nobody had,
and a contact's name arrives off the air carrying whatever its owner typed.

In their place, one structural rule: a glyph built from several codepoints
(a selector sequence, a ZWJ sequence, a keycap, a flag) or a lone
text-default emoji or Regional Indicator is reserved two cells, the most a
single glyph draws. Pinned into two cells, a glyph the font draws in two
fills them and one it draws in one leaves a blank beside it, so the text
after any emoji starts in the same column on every row. Pinning alone could
only hold the lanes after it.

Two exceptions keep their one cell: Rich's single-cell ranges, which its
fast path measures without asking, and the nine text-default emoji the app
draws itself as marks. That list is closed by a test over the package's
source, so a new mark cannot slip into the reserve unnoticed.

Rich measures in three places and prompt_toolkit in a fourth, and all four
are patched from the same rule at once: Rich's segment splitter walks a
string until a whole-string width meets its cut, and a character measured
differently from the string around it is a cut that walk never meets. Every
multi-codepoint glyph is also merged into one fragment before layout, so a
mark followed by a selector is not folded into a one-cell slot.

Installed only by the full-screen app on a platform that draws emoji. The
command line keeps the stock tables, because nothing pins its output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:29:02 -04:00
Jean-Pierre MartineauandClaude Opus 5 ac0c86d5d3 A contacts dump ends on silence, not on a deadline it outgrew
The library arms one five-second future for the whole table and never re-arms
it, so a node holding four hundred contacts fails every read with "no event
received" however healthy the radio is. What means "the companion stopped
answering" is a gap between records, so that is what is timed now: each record
restarts the clock and the dump may take as long as it takes. An uncorrelated
ERROR mid-stream is somebody else's command and no longer kills the read.

A tool that failed is a caution rather than a loss, so it takes the amber tone
and the popup wraps it instead of handing a long sentence to the full-frame
result window.

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 b1dbbe5a5a A popup with nothing under it floats over the menu, never over an empty frame
The menu is popped while a tool runs, so a lead-in question — the TX optimize link, the
node to manage, the password that follows it — had a blank base pushed under it to draw
as a box at all. A dialog is smaller than the frame, and what shows around it should be
the page it was reached from: the root the menu already declares for ^W is now the base
_floated pushes on an empty stack, and the reconnect dialog draws over it too, so the
menu loop stops forgetting its root on the way out. A session with no root declared
keeps the blank fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 02:16:01 -04:00
Jean-Pierre MartineauandClaude Opus 5 61dc9dc110 Every icon-led list measures its icon column, so no label starts a column early
The terminal draws some icons in one cell (↻ ⌨ 🗑 ✓ ✗ ⚠) and most emoji in two, so
a row written icon-plus-space started its words a column left of its siblings. The
fix kept landing one screen at a time. menus.align_icons now pads every icon in a
list out to the widest one, and menu_rows applies it itself, so a list built there
cannot misalign.

Hand-built lists are brought along: the courier outbox and entry actions, the
watchtower actions and rule popover, the record card's actions, the main menu's
Quit row and the device splash's action rows. The repeater admin's regression
test, dropped by a concurrent rewrite of its file, is restored.

tests/test_icon_alignment.py sweeps every gallery screen on both platforms and
fails, naming the rows and their start cells, when a list's icon-led rows start
their words in different cells.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:26:12 -04:00
Jean-Pierre MartineauandClaude Opus 5 d3639e0ceb The main menu pads its icon column like every other list
Preferences' title sat a column left of the twenty-three rows above it. Its icon
is the reason: the terminal draws the gear in one cell where it draws every other
menu mark in two, and the menu was writing `icon + " "` -- the exact bug icon_lane
and icon_mark were built for on the node page in September, on a list that had
since been the only one taught the lesson.

The menu is the list most likely to drift, too, because it is the one that never
declares its icons: it takes whatever the tool registry hands it, so the next tool
to arrive with a narrow mark would have re-opened this on its own. The column is
now measured across every visible tool and each mark padded out to it, which on a
platform with no icon lane measures zero and leaves the titles flush, as before.

The label builder lifts out of the menu loop to _menu_labels so the alignment has
a seam to be tested through. The test asserts the shared start column rather than
the gear, and first asserts the menu still mixes both widths -- a menu that lost
its narrow icon would otherwise keep passing while proving nothing.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 03:11:16 -04:00
Jean-Pierre MartineauandClaude Opus 5 8ac0be0928 The battery gauge is a block, filled in eighths
One braille cell was four coarse levels lit in one colour. It is now a
filled block whose two channels answer two different questions.

Colour answers "roughly how much" from across the room: a lit
foreground over its own darker ground of the same hue — light green on
green over half, yellow on brown over a quarter, light red on red
below. The braille fill inside the block answers "exactly how much" on
a second look: each dot row lights left before right, so one cell
carries eight rungs, one per 12.5% of charge. The band holds still
while the dots climb through it, which is what keeps the two readable
as two.

The charging sweep keeps its five whole-row frames and does not colour
itself — a frame of an animation is not a reading, so the band goes on
answering for the real charge underneath it, exactly as the number
beside it does.

That leaves the alarm nothing to say in hue, so it says it in ground:
under 6.25% — half the last dot's worth of charge — the cell alternates
black dots on red with the light red on the bare page. Where every
other cell is filled, a beat with no fill at all is the loudest thing
the palette has. It is drawn a frame at a time because a terminal's own
blink only ever toggles the foreground, and the PicoCalc's framebuffer
console ignores SGR 5 outright — device-checked, along with its want of
bright backgrounds, which is why the grounds are all dim-bank slots.

Neither animation sits behind Platform.effects any more. The header is
rebuilt on the idle tick regardless, so both cost a colour swap in a
frame already being painted, and the one platform the alarm is *for*
was the one platform never drawing it. effects keeps the braille
spinner, which is what it was really gating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 21:14:07 -04:00
Jean-Pierre MartineauandClaude Opus 5 7407358ede A button row's Enter belongs to whichever chip is lit
The quit confirm promised "Enter quit · Esc cancel", which held only while Quit
was the highlighted button — and on a bonded device there is an "Unpair & quit"
between Cancel and Quit, so Enter has three things it might do. The hint also
left out ←→, the one key that changes which. Both dialogs (the TX winner's
"Enter apply" is the same shape) drop back to the dialog default, which says the
mechanism instead of guessing the outcome: ←→ choose · Enter select · Esc cancel.

A row-picking list keeps its licence to name a committing verb, because every
row commits the same way. A lone-button acknowledgement keeps "Enter OK" too,
having nothing to choose between — that is the exception the new standards test
walks the package for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 03:02:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 ea68b9f890 Navigation: the stack is the model, and ^W leaves it all at once
Navigation was almost a stack already, and the survey in docs/survey-navigation.md
catalogues where it wasn't. This is the machinery for the strict version, plus
the one shortcut past it.

A screen that owns a loop can now keep itself pushed for the whole visit:

    async with session.stay(screen) as visit:
        while True:
            chosen = await visit.result()

One push, one pop, and the *object* survives its sub-screens — so the cursor,
the sort, the scroll offset and any live filter are simply still there on the
way back, rather than being approximated by a `default=` restore that can only
recover the cursor and only when its row still exists. It also retires the
pop-then-re-push-as-a-backdrop workaround: a screen that never left the stack
already is one.

^W unwinds every frame at once, back to the main menu. It resolves the top
screen with POP_ALL, which each navigation boundary turns into PopToMenu, and
every frame pops itself in a finally on the way out — the same single-pop
discipline applied N times. PopToMenu derives from BaseException so it crosses
the `except Exception` guards that stop a broken tool from taking the menu down;
those must not report an unwind as a tool failure. The flag it sets matters
independently of the future: pressed during a device read there is nothing
armed to carry the sentinel, so the next screen the flow tries to open refuses
to open instead.

It declines to fire over anything modal — an unanswered dialog, a progress bar,
a busy splash — which is what `modal` is for. `floating` is only about being
drawn as a box; a select list floats and is not modal, the busy splash is modal
and does not float.

^Q quits from anywhere. Both chords are deliberately unadvertised: a global verb
has no screen to belong to, so a footer atom or an F-key chip would ride on
every screen, and the lane only has three free slots to begin with. A test
walks every string literal in the package to keep them out of the UI.

Quitting mid-screen used to abandon the coroutine driving the app: the chord
exits the application straight from the key handler, so `run_async` returned
while main() was still parked on a screen, and its finally — which closes the
history and chat runs, stops the services and arms the exit watchdog — never
ran. It is cancelled and awaited now.

The menu is the root: built once, kept for the session, and the one frame that
catches the unwind.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 03:40:42 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 8f1fba135e Platform seam: meshterm/platforms.py, --platform flag, dual-platform gallery
One frozen Platform spec (REGULAR/PICOCALC), resolved once at boot
(--platform flag > MESHTERM_PLATFORM env > /proc/device-tree/model
auto-detect > REGULAR) and read via get_platform()/set_platform() -
never a stale `from platforms import PLATFORM`, which the module
docstring calls out. `meshterm platform` reports the resolution and
everything it looked at.

Wires the two correctness bindings that matter before any flavour
work lands: width_reclaim off on PicoCalc's exact-width console
(session._reclaim_last_column, env override still wins), and the
emoji-width calibration probe skipped outright when a platform never
draws emoji.

tests/test_gallery.py renders all 16 screens the interactive menu
opens against REGULAR (72x24) and PICOCALC (53x26 live floor, 53x40
lux case), asserting no line overflows its platform - 35 passing
cases, 13 xfailed as a real P6 worklist (contacts, map, walk,
livefeed, remote_cli, path_composer). Also caught and left alone: the
map screen's footer hint already overflowed the *existing* 72-col
standard before this seam existed, unrelated to picocalc - xfailed
rather than fixed as a drive-by. tests/test_platforms.py unit-tests
the module directly; conftest.py gained an autouse fixture resetting
the platform between tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 02:18:22 -04:00
Jean-Pierre MartineauandClaude Opus 5 0a41303366 Lists: what stays overhead is the section you are in
Two things a grouped list has to keep in view while it scrolls, and neither one
was staying: the lane names that read every row, and the heading that says which
group a row belongs to.

The lanes come first. A column header means the same thing in every section, so
it is not a section landmark at all — it pins for the *whole* list, above any
heading, once it scrolls off (`Separator(pinned=True)`). It is drawn outside the
body slice, so it stays exactly one row: too wide for the terminal it ellipsizes
rather than wrapping and stealing a second reserved row from the content. And it
can now size itself — a Separator title may be a callable taking the render
width — so `column_header`/`Lane` lay each label over its own lane and hand back
the shorter form (DESCRIPTION → DESC) only for the lanes that actually overrun,
from the right, where the trailing lane is the only one with a cell to give.
`lane_header` is that header for `lane_row`'s lanes, shared by the two editors so
they head identical lanes identically.

The headings were the other half, and they were pinning the wrong row. Every
non-pinned separator was a candidate, and the rule takes the last one at or above
the scroll — so what pinned was whatever muted line sat lowest under the heading.
In the Trophy case that meant a discipline's *description* stuck to the top and
the heading itself never did; at the foot of any list, the blank spacer above
Back pinned a blank row. So heading-ness is explicit now (`Separator.heading`,
conferred by `section_heading`, which is why every hand-rolled `── … ──` in the
app — the main menu's categories, the chat picker's Channels/Direct, Trace path's
candidate and ranked lists — now goes through it, and why three lanes headers
that genuinely *are* their block's only landmark say so).

A landmark is also no longer one line but a block: a heading plus the separators
written immediately under it, before the section's first row. Prose written there
is the section's preamble — the Trophy case's "what this discipline scores" — and
it belongs overhead with the heading rather than scrolling away from the rows it
explains. The block hands its rows over one at a time as they leave, so the
heading pins while its description is still the top content row and the two pin
together once both are gone: what is overhead always continues seamlessly into
the body. It never takes more than half the viewport, giving rows up from the
end, so the row that says *which* section this is is the last one dropped.

The frame settles the scroll against however many rows come back rather than
assuming one, and re-asks after sliding the window at the bottom — a heading
among the dropped lines would otherwise vanish instead of pinning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 04:13:14 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 842a58a515 Settings: remember MeshTerm-changed settings across sessions
A firmware-less radio bridge holds its configuration only in RAM, so the node
name, radio parameters, and tuning set through MeshTerm are back to firmware
defaults when the bridge process restarts. Record every setting changed through
MeshTerm (editor, config CLI, and TOML restore all funnel through _apply_setting),
keyed by the device's own public key.

Unlike channels — silently replayed because filling an empty slot overwrites
nothing — a setting is a single canonical value, so replaying a stale one could
clobber a change made elsewhere. So the store never writes on its own: on connect
it detects drift between what it remembers and what the device reports, and a
startup offer lets the operator restore the saved values, adopt the device's
current ones, or stop remembering the device (the "don't ask again" exit). The
offer is quiet unless a connected device actually drifts, so a firmware radio you
don't manage through MeshTerm is never interrupted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 20:59:00 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 4bce531465 Device picker: list configured TCP profiles in the enumerator
A [profiles.*] TCP entry in config.toml was reachable only via
`meshterm -p <alias>` — the interactive picker built its list from the
live scan plus TCP devices remembered in the DeviceStore, never from the
user's own config. So a hand-authored network companion stayed invisible
until after it had been connected to once.

Fold configured TCP profiles into the picker the same way remembered ones
are: _profile_tcp_devices() rebuilds each into a DiscoveredDevice carrying
its alias as the DEVICE-column name, deduped by stable_id so an endpoint
that's also scanned or remembered keeps its richer row. An unconfirmed
profile shows the muted "network companion" tag and turns "MeshCore
device" once its smoke test passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 03:32:31 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 62a2d76e8c Node detail: a full-screen page for each contact, reached by Enter
Pressing Enter on a contact now opens its whole story on one page instead of
a bare row: identity (name in its hue, key with the routing hash lit, type,
first/last heard, packet tally, reception SNR/RSSI), an inline location
preview centred on the node (a small static basemap via the new MiniMap), the
observed routes to it, and an action list (trace armed with the suggested best
path, open full map, time machine). Contacts owns the show/open/reshow loop;
topology.suggested answers "the best evidence-backed way to reach this node",
wired ahead of the firmware route in the trace arming precedence. Timemachine
and Trace grew the entry points the page opens.

Four fixes fell out of driving it on real hardware:

Slow open. Each located-node page built a fresh BasemapSource and resolved its
TileJSON -- a blocking HTTPS round-trip (~100ms, up to the 12s timeout on a bad
link) -- on every open. Hold one source for the session (ctx.basemap_source),
shared by the map, the picker, and the preview, and warm it behind the menu at
login, so the resolve is paid once, off the navigation path.

Black map. A companion advertising nonsense coordinates (Homestead reports
lat -97, lon -1042) flung the viewport off the world, leaving the map a screen
of empty/water fill that reads as solid black -- both from "open full map" and
from filtering to it and framing. The old guard only caught the 0/0 no-GPS
null island; the new geo.usable_fix() rejects any out-of-range fix too, applied
to the map markers and the location preview alike. Verified on-device: of 265
located contacts, only Homestead drops.

Path to node. The route/suggest rows render through THE path widget with a
1-byte hash in parentheses (a "name (3d)" tag), so they cross-read hop for hop
against the route graph's byte labels.

Route graph. Draw us on the left and the target on the right (us to target,
"how we reach it", left to right) via a local endpoint swap that leaves the
shared route_graph_style untouched for its other callers, and give the fan a
generous lane step and row budget so a well-connected node's candidate paths
spread into separate, legible tracks instead of a cramped tangle -- the height
stays adaptive, so a one- or two-route node still draws compact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:41:52 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 fa07d23adb Activity sparkline: drop the recency term that collapsed the scale
The shared scaling peak (activity_peak) was fully-lighting ~60% of the
header pulse's bars on a healthy 1–18 packets/min history. Cause: the
recency damper decayed each bucket's *count* by 0.5^(age/half-life)
before taking the 90th percentile, then that percentile scaled the
*undecayed* bars drawn against it — a unit mismatch. With a 30-min
half-life over a 6h pool, most traffic mass sits older than one
half-life, so the weighted percentile collapsed (1.14 here) and the
floor (3) became the ceiling; every minute with ≥3 packets, anywhere in
the drawn history, then saturated.

Drop recency; keep the two dampers that actually deliver the steady,
shared ceiling: an outlier-robust high percentile over a pool deeper
than is drawn (steady — the percentile barely stirs as one bucket
scrolls off), plus a floor (a lone packet stays a green nub). On the
live DB this restores peak 8, ~4–13% of bars full, heights spread 1–4.

activity_peak loses its bucket_seconds/half_life_min params; menu.py and
channels.py drop the now-unused bucket-seconds plumbing. The tall
dashboard chart was already on an honest max and is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:52:41 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 241c2c062a Activity sparkline: a steady, shared scaling peak instead of the bare window max
Relative-to-peak scaling reads a sparkline's shape well but is jumpy: the plain
window maximum lurches whenever the busiest bucket enters or ages out, and a lone
packet in a quiet window fills the column because it *is* the max. Add
`activity_peak`, a pure function that folds three dampers into one ceiling, each on
a different axis:

- recency — each bucket discounted by `decay ** age` (half-life 30 min), so an
  aging burst releases its grip smoothly and the ceiling glides rather than snaps;
- robustness — a high percentile (P90) of the weighted counts, not their max, so a
  freak-busy bucket clips to full height instead of crushing every other bar;
- floor — never below a small floor, so a stray packet in a lull stays a green nub
  on the baseline, not a full column.

The header pulse scales over the monitor's full six-hour history (deeper than the
row draws), and the channel manager pools every visible channel's histogram into
one shared ceiling (memoised per snapshot), so the rows stay comparable. To give
the channel peak history to decay over, widen its query to 6h/72 buckets while the
sparkline still draws only the newest 24 (2h). Split the dashboard's 2h
observation window off into its own OBSERVATION_WINDOW so it keeps its "reception
over 2 h" meaning. The tall dashboard chart is left on its honest max — it quotes
real magnitudes on its axis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:31:02 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 f07f8c1b93 Activity sparkline: scale to a peak, shared across a column
Bar heights now scale relative to a peak the way the dashboard's tall
activity chart does, instead of stepping at fixed count thresholds: the
busiest bucket fills the column and the rest draw in proportion.

A new `peak` argument is the scaling ceiling and the sharing mechanism.
Left None, each sparkline self-scales to its own window (the header
pulse, alone on its row). Passed a value, every sparkline handed the
same one shares a scale, so their bars are comparable — the channel
manager computes the busiest bucket across all channels via a new
_LiveStats.peak() and hands it to each row. No hidden cross-instance
state: the scale is a pure function of each call's inputs, which stays
correct for the windowed channel list where only visible rows render.

Drops the _HEADER_ACTIVITY_LEVELS / _ACTIVITY_LEVELS threshold ladders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 03:32:44 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 965783d276 Network (TCP) companions: connect, remember, and manage from the picker
Add TCP alongside serial and BLE as a first-class companion transport, end to
end: discovery/connection/selection learn a tcp: stable_id and host:port target,
--tcp and TCP config profiles reach a WiFi board or network proxy, and a network
companion -- which isn't discoverable -- is rebuilt into the picker from its
remembered endpoint.

Device picker gains the network-device affordances:
- "Add a network device" prompts for host:port, smoke-tests it, and remembers it.
  The row is marked "- experimental" in the cautionary hue.
- Delete on a network row opens a chromeless Cancel/Remove confirm (Cancel left,
  Remove right and default: Enter removes, Esc cancels), then DeviceStore.forget
  prunes it and re-draws the list. Serial/BLE rows stay inert.
- The "Del remove" footer atom now follows the highlight, surfacing on the box's
  bottom border only while the cursor sits on a deletable row; the box is sized
  to the fullest footer so it never widens mid-navigation.

Folds in the accompanying courier, channels, and config refinements built along
the way. Screens stay readable at 72 cols; full suite green (745 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 02:38:11 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 a27b5951b7 Copyright: one canonical notice, hung under the logo
Consolidate the copyright into a single meshterm.copyright_notice() beside
__version__ (was defined twice, disagreeing on wording, holder, and year
span). Every startup splash draws from it, so it can never drift.

Place it immediately under the wordmark, right-justified to the logo edge, so
the two read as one signed block. Simplify the display logic: it now rides
every splash that shows the logo (picker, spinner, PIN, notices) rather than
being dropped after the first selection.

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