Commit Graph
611 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5.5 d019c840a8 The live feed never collapses a packet's class name, and the PicoCalc drops its icon
The class name was dropped below the width the whole row needed, leaving only its icon. It is now drawn at every width, like the scope; a row wider than the screen is read by scrolling the highlighted one with ←→. The class icon beside the name is a family cue once the name is always there, so it follows Platform.menu_icons: on the PicoCalc, where it could only be a one-glyph stand-in, the lane is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 02:38:41 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 01a8f711d6 The session flood scope is framed by MeshTerm, so an older meshcore 2.3 can send scoped
reset_flood_scope and force_unscoped arrived late in meshcore 2.3.x. The uConsole's 2.3.7 satisfies meshcore>=2.3 without them, so every scoped channel send would have crashed on restoring the scope. set_flood_scope now sends the firmware's command 54 itself ([54][0][key], [54][0], [54][1]), as the default scope's command 63 already was. Every other library call MeshTerm makes exists in 2.3.7.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 02:06:44 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b0d89a5ece A received channel message's card names the region it was flooded into
The radio hands a message over as text, not as the frame it rode in, so a 💬 row opened from the live feed had no scope to show. Its card now finds the message's flooded copies in the packet log, the way the message paths dialog does, and reads the scope off them into a scope row. The copies found are kept per row; the region is resolved on every paint, so a name learned while the card is open names it. Direct messages get no row: their copies are encrypted end to end, and a routed one has no scope.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:42:45 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b89cd266cc The region editor says when a repeater has no home region
The firmware parks its home mark on the wildcard when no region is home, so *^ means no home region set. The wildcard row's note now reads 'unscoped · no home' then, and 'unscoped floods' once a region is home.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:38:00 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 79fe79a857 ^R resends the picked message unscoped, not only the newest
With a message picked, ^R (the Resend chip on the PicoCalc) offers that one: a scoped message of ours is resent unscoped, and a pick that isn't one offers nothing rather than reaching past it to another. With nothing picked it is still the newest. The pick clears once the resend lands at the tail, and the picked-row hint names ^R when it would act.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:37:20 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 bdf166ce6d The live feed's SCOPE column never collapses
It came and went with the terminal's width: hidden between 70 and 83 columns, where the class label won, and always on the PicoCalc. It is now drawn at every width, and the class label is what gives way on a narrow screen, the icon still saying what the packet is. Beside the subject, the lane is on screen even at 53 columns; there it is the readings after it that scroll off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:24:49 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 fcd70c2e2e The packet viewer states a flood's scope on a row of its own
The region rode the route row as a trailing atom (flood · scope harbour), easy to miss on a card opened from the feed's SCOPE lane to see it. Route now says how the packet was routed (flood, direct) and a labelled scope row underneath says where: the region, 'unknown region · code 3fa1', or 'unscoped'. A direct packet has no scope row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:14:51 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7f59bfa5a0 A channel's region is a bare title atom, and a channel with no scope answers -
The chat title reads "#devtest · onqc" rather than "#devtest · scope onqc". `channels scope N` on a channel with no scope prints - (null in --json) and exits 0: sending under the device default is an answer. Only an empty slot still exits 5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:40:47 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7b531fa83a The live feed's scope sits beside the subject, a dash when unscoped
SCOPE moves ahead of the SNR and RSSI readings, since where a flood was allowed to go is a fact about the packet like its subject; the readings are about how it reached us. A plain flood draws a muted dash instead of the word, so the lane is quiet until a row is scoped, and the lane narrows to 10 cells. The Channels list's column test rides along.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:40:47 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 326f6f5479 Another command's refusal no longer ends the channel list early
The meshcore library does not serialize commands, and every command
waiting for "OK or ERROR" takes the first ERROR to arrive. At connect the
channel prewarm runs beside the clock sync, which this companion refuses
as malformed; a slot read handed that refusal took it for "no such slot",
ended the probe there, and cached a list missing every channel after it
for the rest of the session.

Only the firmware's NOT_FOUND (or a code-less refusal from old firmware)
now ends the list. Any other refusal is asked again once, and a second
one, or no answer at all, is a failed read the probe reports as
unfinished and does not cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:48:08 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 470959aeb1 The Channels list has a SCOPE column between the channel and its unread
The region each channel's messages are flooded into, blank for one that
sends under the device default. The lane is only as wide as the longest
scope, and shares a budget with the name lane so that at 72 columns it
never cuts the activity sparkline: with a name at its ceiling it gets
eight cells, what the row had spare. On the PicoCalc the row was already
cut inside the activity lane, so there it costs the LAST ages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:38:55 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 169c3272c7 The wildcard scope reaches the command, and an empty slot can't be scoped
On Windows, Click expands any argument holding `*` into the files it
matches, quoted or not, so `chat send --scope '*'` arrived as the working
directory's file names. The entry point turns the expansion off; nothing
MeshTerm takes is a glob.

`channels scope N REGION` on an empty slot did nothing and exited 5, which
tells a script the scope was set and read back empty. A write there is
now a usage error; a read still exits 5, as `share` does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:37:38 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 acd14b3adf An unnamed scope reads as one fact, not two title atoms
"scoped · 3fa1" put a `·` inside the reading, so in the message paths
title it chained as two status atoms. It is now "unknown scope 3fa1",
and "? 3fa1" in the live feed's SCOPE lane, whose heading already says
what the column holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:10:38 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7c61c78696 A repeater's regions can be asked from its node page and edited on its admin page
The node page of a repeater shows the regions it was last heard to relay
floods for, whether it relays unscoped floods too, and when it said so, and
asks it once on request. The region store keeps that unscoped flag and the
answer's time beside the regions.

Repeater admin gains a Regions page: the region tree read over the remote
CLI (core/region_admin.py parses the dump, drops a line the 160-byte reply
cut in half, and recovers the rest from the flat lists), flood allowed or
denied per region and for the wildcard, home, default scope, add, remove
and save. Each edit is one command applied at once; the title counts what
a reboot would undo. meshterm regions NODE asks from the command line.

The simulator transcribes the firmware's region CLI, reply cap included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:07:40 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7cdc6d9ba3 A channel can be given a region its messages are flooded into
The firmware has no per-channel scope, so a channel send is now a window
on the companion's session scope: set it, send, restore it, under a
transmit lock every flood-capable send also takes (DMs, adverts, admin
commands, logins, regions/neighbour requests), so nothing interleaves.
Ack waits stay outside the lock. A scope the radio can't take stops the
message (FloodScopeError naming the firmware it needs) rather than sending
it unscoped or under the default; * checks the firmware version, since
before 1.16 the override would quietly fall back to the default scope.
A failed restore is repaired by the next transmission.

Channel scopes are set from the channel page's Send scope picker (known
regions with their carriers, or a typed name) and from the CLI (channels
scope, chat send --scope). The chat's title carries the scope as a status
atom, ^R/Resend sends the newest scoped message again unscoped after an
amber confirm, and the scope each sent message went under is recorded
(messages.scope, schema v17) so the paths dialog can say what it went
under and when no known repeater carries that region. Device config now
teaches the region store the default scope it reads or sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:02:34 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 2785380d6b Packets say which region a flood was scoped to
The packet viewer's route row reads "flood · scope harbour" (or
"scoped · 3fa1" for a region nobody here has named, or "unscoped")
instead of the library's bare "tc flood"; a direct frame keeps its
route word and states no scope. The live feed gains a SCOPE lane where
the width holds it, 72 columns included (the subject lends it one
cell); the PicoCalc's row already scrolls at 53 and leaves the scope to
the viewer. The message paths dialog states a message's scope once, as
a title atom, read off its arrivals' frames. monitor streams a SCOPE
lane before NAME, and the scope object (or null) under --json.

The viewer and the feed take the region store's scope_of by injection,
falling back to reading the frame alone, and the viewer's card cache
keys on the scope so a region learned while it is open renames it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:50:46 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 c3c0ccf9f9 A scoped flood can be traced back to the region it was sent into
The groundwork for region scopes. core/regions.py holds the firmware's
maths (key = SHA-256("#"+name)[:16] over UTF-8, code = HMAC over type byte
+ payload with the reserved values stepped off) and the Scope reading of a
received frame. core/region_store.py keeps the regions known by name,
which repeaters carry them, and each channel's send scope, and memoizes
resolution. Observations gain transport_code and scope_body (schema v16)
so a scoped frame resolves against a name learned after it was heard.

Device gains set_flood_scope (the session override, * forcing unscoped)
and request_regions (the anonymous REGIONS request). The default scope
is now framed by MeshTerm: the library stored it with a # the firmware
and apps leave off, and padded by characters rather than bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:36:51 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 430f5b2daa --spi picks the one radio there is, and refuses to guess between two
--spi used to take whichever SPI profile config.toml listed first, a
coin toss that transmits once two radios are configured. It now counts
the radios actually attached: exactly one is used whether or not a
profile names it, which keeps the common single-radio case to a bare
--spi. Two attached, or two SPI profiles with nothing attached to count,
is a selection error (exit 3) that lists them and asks for -p, the same
way several serial devices with no default already are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:12:35 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 f50db48074 meshterm devices lists the radio on the SPI bus
The device screen listed an SPI radio but the CLI inventory didn't, so
the one command for "what is attached?" was silent about it. The listing
moves into spiradio.spi_radios, which both now use, so the two can never
disagree; the row is marked active under --spi or an SPI profile.
resolve_spi becomes public for that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:59:05 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 dee4674942 The SPI radio listens for the preamble the mesh actually sends
MeshCore transmits a 32-symbol preamble up to SF8 (16 above), but the
node and the bridge configured the SX1262 for the library's fixed 12.
The chip waits for the sync word only about as long as the preamble it
expects, so it locked onto each packet, gave up, and locked on again
further along the same preamble - decoding a packet only when it
happened to catch the tail. On the uConsole that meant 0 of 5 detected
packets decoded after a send; with 32 symbols, 4 of 4.

The node now derives the preamble from the spreading factor as MeshCore
does, and re-sends it to the listening chip when Device config changes
the SF. preamble_length leaves the SPI wiring table (it was protocol,
never wiring) and is retired; the bridge defaults to the same rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 03:12:52 -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.5 85fef1f430 The advert test's random source takes its draws as one sequence
Python 3.10's Random.__new__ refuses more than one positional argument, so
_Rng(*draws) broke CI on 3.10 whenever a test fed it two draws.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 23:15:32 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 5021ab8de5 The weekly advert says where its week stands
A muted line under the Weekly advert row on the Preferences page, set in
the value lane: "next advert in 4d" while the week runs, "due — waiting
for a packet" and then "due — after 30 s of quiet" once it is due, "no
device connected", and "the week starts on Apply" while it is only staged
on. The line is a width-aware separator that reads the scheduler's new
status() on every paint, from state the loop already keeps, so it follows
the wait live without touching the file. It is drawn, even blank, for as
long as the preference is on, so the rows below never shift; every message
fits the PicoCalc's 53 columns, and a gallery specimen holds it there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:29:20 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 6b13755c2b Every store writes only the fields its records know
The admin and remote stores round-tripped the raw JSON they read, so any
field a past version wrote survived forever. Both now read into typed
records (AdminCredential; _RemoteNode holding CachedValue settings) and
write out of them, keeping the file's existing spelling. The settings store
keeps only keys device_config still registers: it offers remembered values
back to the radio, so an unregistered key must not reach a restore.

Every persisted record now declares a RETIRED set (all empty: the history
holds no removed fields), device_config gains its own RETIRED registry, and
test_retired pins each store's records so the sweep cannot go vacuous.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:07:48 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 6a8efa2566 A retired name is never reused, and files keep only what the code knows
A value can outlive its key in a file nobody saved since; if the name came
back with a new meaning, an old value that passes the new spec would load
cleanly and mean something else. So every name that leaves is recorded as
retired: preference keys in preferences.RETIRED (the two old advert cadence
keys), stored fields in their record dataclass's own RETIRED set. A test
walks meshterm.core for such records and fails any live name found retired.

Loading preferences refuses retired keys and records every drop (retired,
unknown, invalid); report_dropped logs them once logging exists, INFO for a
retired key and WARNING naming a typo. adverts.json now reads into typed
records and writes out of them, so unknown fields die at the next write.

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 20:54:23 -04:00
Jean-Pierre MartineauandClaude Opus 5 5b7481af8a Python 3.14 is supported, and CI proves it
JP moved to 3.14. Every dependency installs on it -- the Windows Bluetooth packages
included -- and the suite passes: 2705 passed, 1 skipped, on 3.14.7 as on 3.10.

- pyproject gains the 3.14 classifier. requires-python stays >=3.10: the uConsole on
  bookworm runs 3.11, and a pip or pipx install on an older system should keep working.
- CI keeps its shape, which is Linux over the whole range and the other two runners at
  its ends. Linux gains a 3.14 leg, and 3.14 replaces 3.13 as the top end on Windows and
  macOS.
- Three tests reached a loop through asyncio.get_event_loop_policy(), which 3.14
  deprecates and 3.16 removes, and never closed the loop they made. The two countdown
  tests now make one with new_event_loop() and close it; the sweep test uses
  asyncio.run(), which does both. On 3.14 the suite is back to no warnings.

The standalone installers still bundle 3.10; moving their build to 3.14 is for 0.9.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:44:37 -04:00
Jean-Pierre MartineauandClaude Opus 5 a446da2683 A type-1 node is a companion, everywhere
0.9.0 called the same advert type three things: `companion` in info, `client` in
diagnostics, and `node` in contacts and every other screen that names a node's type.
Room servers were `room server` in two places and `room` in the rest. JP picked
companion. It is the ecosystem's word -- companion firmware, companion apps -- and the
one MeshTerm's own docs already used most. "client" now belongs to MeshTerm itself, which
the one-liner calls a MeshCore client, and "node" is the lexicon's umbrella for every type,
so a TYPE column that says "node" for one of them tells a reader nothing.

There is now one table, NODE_TYPE_LABELS, and one helper, node_type_label(), which gives
"type N" for an advert type MeshTerm does not know. info and diagnostics lose the two
role tables that had drifted apart, and the packet viewer its own copy of the fallback.

The longer names made the node-type legend 59 cells wide, which the PicoCalc's 53 would
have cropped, cutting "sensor" off. node_type_legend() now takes the width it has and
breaks between entries, never inside one, so it stays on one line on a desktop and takes
two on the PicoCalc. The four screens that draw it pass their width and take its lines.

This changes --json output, so it goes under [Unreleased] for 0.9.1. docs/cli.md's samples,
the value lists and CLAUDE.md's lexicon say companion and room server. Two tests pin the
names and the PicoCalc legend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:45:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 f962baec58 Two files the formatter wanted, so CI is green again
2dbae7a went red on lint, and it was never ruff check -- that passed. It was
ruff format --check, on two files from the last few commits: a nested def with no blank
line above it in the session, and a sum() in test_logo that fits on one line once
joined. Both pure layout; neither touches a hand-aligned table, and ruff check stays
clean after, which is the thing this repository has been bitten by before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 04:01:28 -04:00
Jean-Pierre MartineauandClaude Opus 5 2dbae7af77 The splash slides the whole row, because the whole row is what gets cut
The device rows pinned their fixed lanes and slid only the hardware model
underneath them. The reasoning was borrowed from the Trophy case, where the
rank, date and score always fit and only the walk overflows -- there the lanes
really are the reader's place in a long list, and sliding them off would cost
the row its identity to read something the columns already told you.

This list inverts that. On a narrow terminal it is the device name and the
address that get cut, so pinning them pinned the truncation in place: the one
thing the arrow keys could not reach was the text somebody most wanted to
finish reading. A name is not a landmark when it is the thing being elided.

So no lane is pinned, and the row slides whole. That needed the scroll to be
asked for outright, because what normally turns it on is a row pinning a head
block, and none of these do any more -- `hscroll` travels from the picker
through the splash surface to the list, the same path `live` took.

The test that covered the old behaviour is rewritten rather than removed. It
was a fair test of a decision that has changed, and the argument for the new
one belongs where the old one was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 03:46:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 551a86f47a The splash surface learns the argument the session already knew
`live` went onto `TuiSession.select_startup` and nowhere else, but the picker
calls the *surface* -- `ui.select_startup`, which delegates. So MeshTerm crashed
on the startup path with a `TypeError` about an unexpected keyword, on the first
screen, every time.

The whole test suite passed through it. Every fake Ui in `test_tui.py` takes
`**kwargs`, which is the reasonable thing for a double to do and is exactly what
hid this: the picker's new argument was absorbed by a stand-in that would accept
anything, while the real surface would accept nothing of the sort. Five tests
written specifically for the rescan ran green against code that could not start.

So the double stops being infinitely accommodating. The test added here binds
what the picker sends against the real `Ui.select_startup` signature, which
raises on an unknown keyword exactly where a running MeshTerm does. It fails
against this commit's parent and passes against this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 03:37:03 -04:00
Jean-Pierre MartineauandClaude Opus 5 f9e9271b29 The connect screen keeps looking, instead of looking once
It scanned on the way in and never again, so a radio plugged in ten seconds
late was invisible until MeshTerm was restarted. The worst version of that is
the first run: the screen says no companion devices detected and offers a
network address typed by hand, or quitting. Going to fetch the cable is the
obvious thing to do and it was the one thing that did not work.

The three transports want three different answers, and the reason is cost.
Enumerating serial ports is a SetupAPI walk -- not free, but cheap enough to
repeat every couple of seconds off the event loop without anyone noticing, and
it covers the case this is really about. Bluetooth is a bounded listen, so
"keep scanning" there means keeping the radio listening for as long as the
splash is open, on the handhelds this program exists for, on a battery, on the
screen somebody leaves up while they walk away. It gets a window every fifteen
seconds instead, eight of them, and the count is ignored while the list is
still empty -- an empty splash is precisely where somebody is waiting, and
another listen is the cost of being useful. TCP is not scanned at all, because
it never was: a network companion does not advertise, and its rows are read
from the registry and the profiles, which change only when the reader changes
them.

Only a change redraws. A poll that finds the same ports says nothing, which
matters for somebody part-way through arrowing down the list: a redraw they did
not ask for, on a list that has not moved, is the screen shifting under their
hands for no reason. When it does redraw, `replace_items` keeps their place by
value rather than by index, so a device appearing above the highlight does not
carry the selection with it.

`select_startup` grew a `live` argument to make this possible -- work to do
while the list is on screen, handed a `redraw(items)` rather than the screen
itself, since a caller holding a screen could not repaint it anyway. It is
cancelled when the screen resolves, so nothing outlives the list it was
redrawing and no repaint lands on the PIN prompt or the smoke test that opens
over it.

Fixes: #13

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 03:26:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 d3f901a5a1 A bright run says so in the colour it already had
Moving brightness out of bold and into the `9N` bank settled what a span says
when it names a colour. It left unsettled what happens when a sequence changes
the intensity and names no colour at all -- which is exactly how the art
announces a bright run. `ESC[1m` had its `1` stripped, the brightness was
remembered for whichever later sequence happened to name a foreground, and the
colour already in force was never restated. The run carried on in the dim bank
wearing the `22` that had been forced in front of it.

The wide mark does this twenty times, and five more as `ESC[1;4Nm`, where the
background survived and the brightness did not. On screen it reads as pairs of
half-blocks that should fade dark into light both coming out dark, most visibly
in the run beside the copyright line.

So the standing foreground is tracked, and restated in whichever bank now
applies whenever the intensity changes without one being named. A reset takes
the standing colour with it, because it takes the terminal's too.

There were no tests over the art loader at all -- `test_theme16.py` covers the
theme's own bold-brightness rules, which is a different question -- so this adds
some. The per-sequence invariant is the one that matters: counting bright
emissions across a file proves nothing, because one `1m` brightens every later
sequence and the totals stay high while individual runs go missing (149 emitted
against 64 requested, in a file that was drawing the bug). Every `1m` that fires
with a colour standing has to leave a bright foreground behind, and now each one
is checked.

Fixes: #11

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 02:52:44 -04:00
Jean-Pierre MartineauandClaude Opus 5 ff86ff059f The simulator writes its own history, not yours
`--mock` is a fake radio, not a fake MeshTerm: what it adverted was recorded to
`meshterm.db` like anything a real companion said, so its four invented contacts
turned up in the mesh walk, the dashboard and the map of the mesh actually being
run. A `--mock` run that names no database now records to
`<config_dir>/meshterm-mock.db`, created on first use.

`--db` still wins — where a run records and which radio it talks to are different
questions, so an explicit path is a precedence rule and not the usage error
`--mock --port` is. `$MESHTERM_HOME` remains the only thing that isolates a run
whole: the contact and channel caches, the outbox and the remembered devices are
untouched by this, and the docs say so where they used to warn that `--mock`
recorded into the real history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:59:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 97d19a7978 The popup shows a mark, not the tags around one
message_dialog draws the message it is handed and reads its spans to pick
a border tone, so a markup string reaches it unparsed: the reader saw a
literal [ok] and [/ok] around the checkmark, and a failed write would have
been framed in the success colour, since a plain string has no spans to
read a tone from. It is a styled Text now, the way every other caller
writes one.

Only the mark is styled. That is enough for the border to read the tone,
and it leaves the path in body ink where it stays legible -- which is what
the mark is for. The reason for the failure follows it muted, on its own
line.

The test asserts on the spans rather than on the words, because the bug
was invisible to anything reading the text: the tags were *in* the string
either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 17:17:31 -04:00
Jean-Pierre MartineauandClaude Opus 5 72f72b84cd Diagnostics is prose, so it is a written page
The block is a title, a standfirst, sections and a list under each. That
is a page of writing, not a screenful of rows, and the app already knows
how to draw one: the About pages' markdown renderer. So the tool states
its report as before and a third projection turns it into markdown, which
is exactly what the report seam was built for -- no tool touched, no
report changed, one new function beside the other two faces.

Being a written page buys the two things a long block most needs and that
a hand-built screen had neither of. Its ## headings are landmarks, so each
pins to the top row while its own section scrolls under it and ^PgUp/^PgDn
step section by section; and the PicoCalc lane picks up Sect up / Sect
down for the same reason a grouped list does. AboutPage already does all
of it, so the page is that page with one verb added.

The frame comes back with it. The bare frame was there to keep a border
off every line a mouse would drag through, which mattered while copying
off the screen was the only way out; saving is the way out now, so the
page can be a normal screen and the save can be advertised where this app
advertises verbs -- the footer hint on a desktop, the lane's one free F3
slot on the console -- instead of in the page's own body, which belongs to
the report. The answer moves to a popup with it: a path is worth reading
carefully, a write can fail, and the page underneath is unchanged and
still there when the popup goes.

Saving markdown is the same decision as drawing it. One source is what the
page renders and what the file holds, and an issue tracker renders it as
written, with no fence and no apology. Every value is a code span, which
is correctness and not taste: a Windows path is full of backslashes that
markdown reads as escapes, and an error a stranger's firmware wrote can
hold an asterisk. A value already holding a backtick gets a longer fence.

Rows are a list and not a table, measured rather than assumed -- a
markdown table's cells are cropped with an ellipsis at 53 columns, and a
bug report whose one long value is cut is missing the half that mattered.
A list item wraps and hangs under its own text at any width.

Two smaller things fell out. A block may carry a caption, the heading a
format that has headings draws above it -- plain and JSON have no use for
one and ignore it. And the host block splits in two, because a "it looks
wrong" report is about the terminal and its reader should not have to pass
the processor architecture on the way to it.

The command line is untouched: aligned records, because that face is a
stream to grep and headings are furniture on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 17:10:31 -04:00
Jean-Pierre MartineauandClaude Opus 5 41c2cf416b The profile says a path, so three launches leave one profile
Verified on the Mac, and three of the four things it taught were the
opposite of what the code assumed.

RunCommandAsShell is false. Under *true* Terminal takes the whole command
string as the name of a program and the window reads "Command not found:
MESHTERM_REOPENED=1 / Could not create a new process and open a
pseudo-tty". The key's name says otherwise; the measurement wins.

The environment rides on /usr/bin/env rather than on a VAR=value prefix,
for the same reason. env is a real executable, so the line is valid as a
bare argv and as a shell command both, and the window cannot land on that
message however Terminal decides to read the string.

Terminal names an imported settings set after the FILE, not the name key
inside it -- and re-importing the same bytes reuses that profile, while
different bytes under the same filename add "MeshTerm 1", then
"MeshTerm 2". Opening one file four times, three identical and one a word
apart, left exactly two profiles. So the command cannot live in the
profile: it carries argv and the reader's MESHTERM_* variables, and a
profile per command line would fill their settings list with junk only
they can remove. The variable part moved behind a fixed path instead --
the profile says ~/.meshterm/launch.sh and never changes, and the script
underneath says whatever this launch needs.

Three launches with three different command lines now leave one profile
named MeshTerm, the window drawing CascadiaMonoPL-Regular at 13pt on
opaque black at 80x30, with the app running in it. The reader's default
profile is still theirs, blur and all.

shellExitAction 1 while here: tidy the window away on a clean exit, keep
it on a crash, because a crash nobody sees is a bug report nobody writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 16:42:51 -04:00
Jean-Pierre MartineauandClaude Opus 5 3c4c6ec05a The block is worth attaching, not only pasting
Copying off a screen is the wrong shape for anything long: you select,
you scroll, you select again, and on the PicoCalc there is no clipboard
to select into at all. So the page saves. s writes
meshterm-diagnostics.txt into the config directory beside the log, which
is the folder "send me both" already names, and meshterm diagnostics
--out PATH does the same from a shell -- spelled the way config
export-key --out already spells it rather than inventing a second word
for writing an answer to a path.

The save key forced the one concession on the bare frame. A bare frame
has no footer and no F-key lane, so a page that could save and never said
so would be a key nobody could find, and on the PicoCalc the lane is
normally the only place an affordance is advertised at all. The page
carries its own last line instead. One trailing line after a blank row
costs a paste far less than a border costs it on every line, and a drag
that follows the block stops above it of its own accord.

Saving answers on that same line. A dialog would cover the block the
reader was about to copy, and the line has to be able to say a write
failed -- a read-only home, a full disk -- without inventing an error
screen for it. The block stays readable either way, which is the whole
reason both routes exist.

The file is always the plain block, even under --json: it is there to be
read by whoever opens the issue, and a document of the same facts helps
them less. It is rendered at a fixed wide width so no lane crops, and
with the trailing pad stripped, since Rich pads every cell to its column
and that is invisible on a terminal and noise on disk.

With --out the answer becomes the path, the trade config export-key
already makes: a caller who asked for a file wants to be told where it is,
not handed the contents they just redirected into it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 16:33:34 -04:00
Jean-Pierre MartineauandClaude Opus 5 6046b4ff0e A bug report should be one page, and a page nobody has to read first
A report that opens with "it crashed" costs three round trips before
anyone knows which MeshTerm, on which OS, in which terminal, talking to
which radio. The person who hit the bug is the one least able to answer:
half of it is resolved at boot and shown on no screen -- whether icons
draw, whether powerline separators do, which platform flavour resolved and
why. Diagnostics states all of it at once, as a page under This app and as
meshterm diagnostics.

The page is a bare frame, and that is mechanical rather than aesthetic.
The one thing it is for is getting its text out of the terminal and into
an issue, and a terminal is selected by dragging: a border is characters
on the same lines as the content, so every line of a line-wise selection
arrives with a box-drawing glyph welded to each end. A header and a footer
hint arrive as text that looks like part of the report to whoever reads
the paste. So the page draws its rows and nothing besides, the frame the
share screen already uses for the same kind of reason.

Nothing in the block is private, and that has to be a property rather than
a habit, because the whole premise is that it gets pasted in public by
somebody who did not read it. No pairing PIN, no admin password, no
channel secret, no private key, no position, no contact's name or key. The
mesh arrives as aggregate -- a row count per table and the span the
observations cover -- which is the half that explains a bug without naming
anyone the reporter talks to. Two tests hold the line from opposite sides:
one plants real secrets in the stores that hold them and asserts none came
out, and one refuses the key *names* such a field would take, so a value
the suite cannot plant is still caught by what it would arrive as.

The radio is asked and never required. A report about a companion that
will not connect is exactly the report most worth filing, so the failure
becomes connected=no with the radio's own words in error, and every other
fact still arrives.

Table counts come off sqlite_master rather than a list written down here,
so a table added later starts being reported the day it lands; the
interesting count is always the table nobody expected to be full. Only
overridden preferences are listed -- the defaults are in the source and
identical for everyone.

Both faces share one projection of a facts block, so the page cannot drift
from what the command prints: a field added to the report reaches both
without anyone remembering to. Only the layout differs, in one respect --
the scripted face crops a value too wide for its lane, which is right for
a record somebody will split on whitespace and wrong for a page whose
whole job is to be complete, so a long value hangs under its own block
instead. A path also gains the lane constructor it never had; the machine
face had always spelled one correctly and the plain face could not render
one at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 15:58:12 -04:00
Jean-Pierre MartineauandClaude Opus 5 2d31810801 MeshTerm gets its own window on a Mac, and leaves yours alone
No Mac monospace face carries the Braille Patterns block, so Terminal --
which falls back generously rather than drawing a box -- borrows the
charts and the map's terrain from a proportional face and lands them at
the wrong width. Nothing announces itself as broken; it is just crooked,
and a reader with nothing to compare against never finds out why.

Fixing it means a font, and a font means a profile. The question that
shapes this module is whose terminal it is. MeshTerm's palette is the
16-colour VT set, crude on purpose, and nobody should have to read their
mail in it -- so nothing here touches the default profile or edits their
preferences. It adds a profile named MeshTerm and opens our own window in
it; theirs keeps its translucency, and quitting leaves nothing behind.

That is also the only route that works at all. Terminal rewrites its
preferences from memory when it quits, so a file written while it runs --
and MeshTerm always runs inside it -- is discarded later. Handing Terminal
a .terminal file lets Terminal do the importing.

The profile is generated from _VT_SLOTS rather than shipped, so the window
cannot disagree with the screen it holds. The NSColor and NSFont archives
are pinned to bytes read back off a profile that was installed on a real
Mac and confirmed rendering, not to a reading of the format.

relaunch.py is the half that was never Windows-specific -- own_command,
child_environment and its _PYI trap, the wanted size -- moved out of
winterminal so both terminals share one copy.

Not yet wired into startup: the launch itself wants a Mac to confirm, and
that machine is asleep.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 02:34:25 -04:00
Jean-Pierre MartineauandClaude Opus 5 9a58bc04bb The wordmark says bright as a colour, because bold is not brightness everywhere
On a Mac the splash drew muted, and the dithers that fade one colour into
another faded toward the wrong end.

An art editor spells brightness the DOS way: 1m lifts the foreground into the
bright bank and the spans after it inherit that. It is true of a DOS console and
of the PicoCalc's, and it is false on macOS Terminal, where bold asks for a
heavier face and leaves the colour alone. There 1;30 is not dark grey but plain
black -- and the wide mark carries seventy-nine such spans, much of it on a
black ground, so the shading and bevels flattened into the background. The
gradients went with them: a fade drawn as a bright stipple over a dim ground
loses its top end when the bright half stops being bright, and what is left
reads as a dotted pattern that has not come out right.

So the bright bank is emitted as the colour it means -- 9N, which cannot be
mistaken for a font weight, and which is how the PicoCalc's own console spells
bright anyway. Which colour is being asked for does not change, and neither does
a single cell: only the spelling, from one a terminal may read as a weight to
one that can only be a colour. Bold is now asked for nowhere in the mark.

The art files are untouched. They stay authored the way the editors that drew
them write, and the loader goes on being the place where what DOS assumed is
translated into what a terminal can be told.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 01:14:28 -04:00
Jean-Pierre MartineauandClaude Opus 5 b1f21664ff The name is what the reader came to read, so the address is what gives
Capping the address lane stopped it eating the row, but left the old priority
in place underneath: DEVICE was still sized from whatever the address had
finished taking. Turn it around. The name is sized first, in full, and the
address takes what is left.

That is the right way round because of what each lane is for. A name is what
the reader recognises their own radio by; an address is a disambiguator, and a
disambiguator does its whole job from its last ten cells -- which is already
why the cut takes the front and keeps the tail. So the address shrinks to that
floor before the name gives up a single cell, and never reserves more than it
actually wants: a lone COM3 hands its slack to the name rather than sitting in
a ten-cell lane holding four.

The name still has to stop somewhere. A USB adapter's product string runs to
forty-odd characters, and a lane sized to that pushed everything after it off
the edge -- the row ended mid-port with HARDWARE not on screen at all. So
HARDWARE keeps its readable stub out of the budget, and past that the name
ellipsizes. Where it must, the address is handed back its full width instead
of being ellipsized alongside: the name is being cut whatever happens, and
spending two more of its cells leaves one cut in the row rather than two.

On a Mac the name now shows whole at 28 cells where it had six, and the 36-cell
UUID behind it draws as its last nine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 00:56:34 -04:00
Jean-Pierre MartineauandClaude Opus 5 1933d725b0 The address lane stops eating the columns worth reading
On a Mac the splash's DEVICE column was squeezed to the width of its own
heading, and HARDWARE went with it.

The lane sized itself to the longest target with no cap, and `_name_width`
sizes DEVICE from whatever that leaves. On Windows and Linux the assumption
held -- a MAC is 17 cells, /dev/ttyUSB0 is 12 -- so nothing ever pressed on it.
macOS breaks it twice: CoreBluetooth reports no MAC at all but a per-machine
128-bit UUID, 36 cells of it, and even the ports run long (every Mac carries a
/dev/cu.Bluetooth-Incoming-Port, which is 31). One of those took 36 of the 58
cells a row has, leaving DEVICE six.

So the lane is capped and ellipsizes, which is what the name lane beside it has
always done. The cut takes the **front**: what tells two targets apart is their
tail -- usbmodem1101 from usbmodem1102, ttyUSB0 from ttyUSB1 -- while the head
they share is boilerplate, and cutting the back would draw two different ports
identically, which is the one thing a picker must never do. A CoreBluetooth
UUID reads the same from either end, so the rule that is right for ports costs
the platform that needed the cap nothing. That is also why this is not
`menus.fit_cells`, whose ellipsis keeps the head.

At 20 cells the cap is above every target the other platforms produce, so they
are untouched by it and the change shows up only where something was wrong.
DEVICE goes from 6 cells back to 18.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:28:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 71ce60713f On macOS the refusal is the pairing starting, not the pairing failing
Selecting a PIN-protected companion on a Mac reported an error, and selecting it
a second time connected -- the first attempt having quietly done the bonding.

CoreBluetooth has no pairing API: a peripheral pairs implicitly when something
touches a characteristic requiring encryption. The companion firmware puts its
UART characteristic at ENC+MITM precisely so that happens, so the unbonded
subscribe is answered with Insufficient Authentication *and* that answer is what
makes macOS raise its Passkey dialog. The GATT operation is dead by then; the
bond it began arrives seconds later, once a person has read six digits off a
device and typed them. Treating the refusal as a verdict therefore reported
failure for a pairing that was succeeding.

So on macOS the connect now waits for the dialog to be answered instead of
giving up on it, bounded so a dismissed dialog still fails in the end. Elsewhere
a rejection keeps meaning what it always did -- Windows and Linux can pair
explicitly, and do, before the link is opened.

The message that survives a dismissal no longer recommends --ble-pin there
either: macOS collects the code itself, so the flag cannot help, and naming it
sends the reader somewhere with nothing to offer. It names the dialog, and the
Bluetooth permission the terminal needs for one to appear at all.

The tests that cover a rejection now state which platform they mean, rather than
inheriting whichever runner they landed on -- the macOS leg of the matrix would
have failed on the old wording, and on a PIN it no longer forwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 22:46:10 -04:00
Jean-Pierre MartineauandClaude Opus 5 add17b49d6 A Mac can find its own companion
Three faults, one cause: macOS is the only platform that permanently presents
ports nobody attached. Every Mac carries /dev/cu.Bluetooth-Incoming-Port and
/dev/cu.debug-console, with no USB VID/PID between them.

Selection counted them. `len(devices) == 1` was therefore unreachable on a Mac --
one real board attached made three devices -- so auto-detection could never fire
there and every Mac user met "Multiple companion devices detected" on a first
run, naming two virtual ports as the companions to choose between. Which device
is plausible was already known and already printed in that very listing as
"[likely LoRa]"; it now decides with it instead of only saying it. A lone
unrecognized adapter still resolves, because an unlisted VID is a real board more
often than not, and several implausible ports no longer claim to be companions.

A refused Bluetooth scan read as silence. bleak states the case precisely and
hands over a typed reason, and that was caught as a driver hiccup and logged at
debug -- under a log that defaults to WARNING, so a denial left no trace at all
and looked exactly like an empty room. On macOS this is the routine case rather
than an exotic one: Bluetooth is granted to the terminal MeshTerm runs in, never
to MeshTerm, so an ungranted terminal scans and hears nothing. The sentence bleak
wrote is kept and shown, since it already names the remedy.

The PIN broke the connection it was for. Handed one, the meshcore transport calls
bleak's pair(), and CoreBluetooth has no pairing API -- the macOS backend raises
outright, and the library then disconnects and re-raises, so a correct PIN was
what failed. Pairing there is the OS's to run: the firmware puts its UART
characteristic at ENC+MITM, the unbonded subscribe is answered with Insufficient
Authentication, and macOS responds by running Passkey Entry and asking for the
code itself. Withholding the PIN is what lets a protected companion bond. Windows
and Linux do expose explicit pairing and keep it.

Found by running the 0.3.6 arm64 build on a Mac. Detection over SSH is a separate
thing and stays impossible: an ssh session is a Background session, and macOS
refuses to prompt for Bluetooth in one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 22:22:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 fc4406ba86 Only the reader removes a discovered setting; a read may only take it to n/a
A discovered row was dropped when a read came back as anything that could not
be its value, on the reasoning that the node's answer was the row's whole
claim to exist. The reasoning holds and the cost does not.

A catalog row that misreads costs an n/a and nothing is lost: the catalog
still names the key, and the next read puts the value back. A discovered row's
key is recorded nowhere else. Deleting it throws away the one thing about this
node that only the reader could have found, and they may no longer remember
what it was -- for a reply that misread rather than a key that went away. A
truncated line, a node answering mid-reboot, a stray frame correlated to the
wrong command, and the row is gone with no way back but memory.

So a read now takes a discovered row to n/a like any other, and Del is the
only thing that removes one. The board-swap case the automatic drop was for is
still served: the row reads n/a, which is visible and says what happened, and
forgetting it is a keystroke.

A dropped packet was never the danger -- read_settings folds only a reply it
actually received, and a timeout leaves the row alone -- but the rows it can
delete were never worth the ones it tidied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:06:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 a7d201f4da A setting you use on a node's command line becomes a row on that node's page
Third-party builds carry knobs the catalog has never heard of -- EasySkyMesh's
radio.rxps and reboot.interval among them -- and MeshTerm cannot know which
build a repeater is running without asking it. Asking is the whole problem:
every question is one paced round trip, so probing for a fork's keys would
spend a sweep on every node to benefit a few, and listing them outright would
leave dead n/a rows on everyone else's page.

So nothing is probed speculatively and the catalog does not grow. The command
line is already the only place those settings can be reached, so it is now
also where they are found: a get/set the reader ran, which this node answered,
earns a row on this node's page under Extra. The round trip was one the reader
was spending anyway, and no other node's page changes.

The two verbs are not equal evidence, which the firmware settles. handleSetCmd
compares each key with its trailing space -- "radio " -- so a longer key can
never land in a shorter one's branch, and an unknown one answers "unknown
config:". handleGetCmd compares a bare prefix, so stock firmware answers
"get radio.rxps" out of the get radio branch with a perfectly plausible
910.525,62.5,7,5. A catalog row survives that because its typing rejects it; a
discovered key has no typing, so a read is trusted only when nothing shadows
the key, or when the reply differs from what the shadow last said.

A discovered row's whole claim to exist is that the node answered it, so when
it stops answering the row goes rather than earning the n/a that only a
catalog key can -- which is what tidies up after a board swap onto a reused
identity. Del forgets one without waiting for a read, and asking the key again
brings it back. Its values are fitted to the lane the catalog's rows already
need, since the value lane is one width for the whole page.

prv.key is never stored, whichever way it was asked. The firmware answers it on
the serial console only, and a reply that reached us anyway must not be the
first place a repeater's private key lands in a file.

Two things fall out: get tx at the command line no longer leaves the TX power
row showing last week's value, since catalog keys now fold into the cache the
same way, and a discovered key completes at the prompt where it was learned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:29:26 -04:00
Jean-Pierre MartineauandClaude Opus 5 899bb84830 The archive preview leads each contact with its type icon, as the Contacts list does
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 01:26:15 -04:00