The session cache fixed the reads screens make on open, but opening the
heaviest screens still stalled for seconds — because the pickers and
screen-openers that run *in front of* those screens were still reading the
radio directly. The menu wraps prompt-gathering and execution in one busy
overlay, so a Trace or Chat open pays its picker's slow round-trips before
the (already-cached) screen behind it ever builds, which is why caching the
screens alone showed no improvement.
Route the interactive-open reads through ctx.devstate too:
- Chat's conversation picker read the channel-slot probe *and* the contacts
table on every open (~5s) — both now come from the cache (channels mapped
from the cached slots via the new _channels_from_slots, contacts served
cached). The CLI list/send path keeps reading fresh.
- Trace and TX-optimize target pickers now read contacts from the cache.
- The repeater-admin screen and the channels manager (its slot probe, on a
local copy so mutations still re-probe the fresh layout after invalidation)
read through the cache.
Small, session-stable reads (self-info, build_snapshot's tuning/path-hash)
are left as direct reads — they were never the seconds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a screen re-read the same stable facts from the companion every time — the contacts table, self-info, the path-hash mode, the channel-slot probe. Over Bluetooth each is a round-trip, and on a busy node two dominate: get_contacts (~2.4 s with hundreds of contacts) and the channel probe (~2.6 s, it walks every slot index), so navigation stalled for seconds on each screen. Measured on hardware; and asyncio.gather makes it worse, not better — the meshcore BLE UART serializes commands, so concurrent reads collide.
DeviceState (ctx.devstate) reads each fact once and serves it from cache. Contacts use stale-while-revalidate on a 90 s TTL — a read past it returns the cached list instantly and refreshes behind it, never blocking navigation on the slow call. Self-info, path-hash mode, and channel slots are held until an in-app write invalidates them: config apply drops self-info + mode, the channel editor drops channels, a reconnect resets everything. The hot screen openers (nodes, dashboard, chat, trace, TX, map, atlas, time machine, courier, watchtower) now read through the cache. The first screen opened in a session still pays the one read — shared across every screen — and every open after is instant. Transport-agnostic, so serial benefits too; harmless on --mock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The whole-graph radar view drew every node on concentric hop rings at
once — a hairball past a dozen nodes, its pan/zoom camera unable to fix
what was fundamentally overplotted. The atlas is now a graph walker:
* One node holds the focus (you, to open). The canvas draws only that
neighbourhood — focus centred, direct neighbours fanned around it,
braille edges coloured by SNR and faded by evidence age — and sizes
itself to the crowd, so a two-node link is a tight six rows while a
hub earns the full fan. The node the trail came from anchors due
west: walking reads as moving right, backing up as moving left.
* Beneath it the links list the same neighbours as selectable rows,
strongest first: glyph, name (palette hue, you in white), hash in the
shared hash widget, SNR with a quality bar, samples, source tags,
age, and how many links continue onward. The highlighted row's marker
lights white on the canvas.
* Enter walks (the breadcrumb trail grows across the top), ⌫ steps
back, walking into the trail-back row retraces instead of ping-pong
growing, Home refocuses you. Typing finds any node in the graph —
nearest first, islands flagged — and Enter teleports there, the
trail restarting since the walk never crossed the gap. ^R rebuilds
from storage and keeps whatever of the trail survives.
The per-node details dialog is gone (focusing a node is its details),
and the ring-relaxation layout service with it — the fan needs no
global relaxation. PgUp/PgDn page the highlight, dashboard-style.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every select list now builds its closing furniture through one module,
so the conventions hold by construction instead of by copy-paste:
* Back is THE exit word — bare, no arrow, no icon — with exactly one
blank separator above it (back_rows). Courier and Watchtower lose
their "← Back"; the rules popover's "← Close" becomes Back too.
* The staged-changes exit pair (✓ Apply … / ✗ Back — discard) moves to
exit_rows, shared by the config editor and repeater admin, along with
changes_phrase and one confirm_discard dialog (apply/send verb
parametrised).
* Label + description rows share menu_rows (cell-padded lanes): Device
actions, channel detail, repeater actions, and — converted from the
inline "label — description" style — Send advert, Identity key, and
the channel manager's Add-a-channel section, whose rows now read as
two aligned lanes with the trailing … that marks "opens a prompt".
* Grouped-list headings share section_heading (── Label ── in accent):
Watchtower, Courier, the Time Machine picker, and the admin picker
(which also drops the stray 🔑 inside its heading).
* Status marks normalize to ✓/✗ (U+2713/17): Watchtower's ✔ and the
✖ on Cancel-this-message / Stop-watching become err-tinted ✗ rows.
* fit_cells replaces the four private _fit copies (channels, dashboard,
time machine, chat picker) and measures display cells, so a wide
glyph in a name can't skew a lane; empty-state notes drop their
parentheses ("no channels configured yet", "no matches").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
format_ago in widgets.py is now the one prose form for relative ages —
bare "now"/"never", "5m ago" otherwise — and every row that stitched
_format_age plus a hard-coded " ago" suffix (packet viewer heard row,
Time Machine record/first-heard lines, Courier finished rows, channel
detail summary) goes through it, killing "now ago" everywhere at once.
The channel summary drops its one-off "just now" special case for the
same app-wide wording. Also: the Device actions description no longer
claims an advert action — that lives in its own main-menu entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A follow-up interactive-polish pass over the charts, the menu popups, and the
Time Machine.
Charts (braillechart)
- axis_chart now notches ┬ x-axis ticks under a continuous chart's labels too, the
same grammar the columnar day charts use — one axis grammar everywhere. The
dashboard activity chart and the node-history Volume/SNR/Rhythm charts pick this
up through the shared widget; single-line sparklines are untouched.
Time Machine (mesh page)
- Empty days show as zero bars instead of being skipped, so the day charts read as
real calendar time (a fortnight with two dead days no longer fuses to twelve
adjacent bars). Never invents days from before recording began.
- The rhythm chart shares the day charts' y-axis gutter, so its left edge lines up
with them; its own narrower width is unchanged. Its whole-window peak is folded
into the shared gutter sizing (a slice can top a single day's total).
Packet viewer
- Holds a minimum body height so paging between a short packet and a tall one no
longer resizes the dialog out from under the reader.
Menu popups
- Floating dialogs force a full frame rewrite as they're torn down, scrubbing any
double-width glyph a terminal painted a cell past where the differential renderer
tracks it — the "stray characters in a dialog's wake".
- Send advert: reworded a choice, and the fixed list is non-filterable (a stray key
can't narrow/resize it); a select's default footer now only mentions type-to-filter
when it actually filters.
- Watchtower Rules: a blank separator sits above the Close item.
Menu
- The Mesh atlas tool icon is a single-codepoint 🌐 instead of a VS16 🕸️: the
emoji-width calibration strips VS16 on terminals that render it narrow, which
measured the old icon a cell short and drifted that row's help text right.
630 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four small UX gaps closed together:
- Dashboard's footer hint was missing Home/End even though the screen
already handled them.
- The packet viewer's paging keys were reversed relative to the list
that opens it: PgUp/PgDn walked packets while the dashboard feed
walks with up/down. Swapped so up/down page through packets (matching
the feed) and PgUp/PgDn scroll a tall body (matching every other
screen's scroll keys).
- A raw "packet" entry (an overheard RX_LOG_DATA frame) now shows its
parsed payload class and route, and — for a channel-text frame naming
a channel we hold the key for — its decrypted text. The AES/HMAC
logic lives in core/channels.py as pure, unit-tested crypto
(decrypt_channel_text), independent of the vendored meshcore
library's own reader internals; the dashboard fetches the device's
channel secrets once at open time and hands them down to each
PacketViewer it opens. Raw-field dumps now skip fields already folded
into a flavoured row instead of repeating them.
- The Time Machine's whole-mesh charts (packets/day, nodes/day, rhythm)
gained the same mirrored numeric y-axis the dashboard's activity
chart already had. That gutter-drawing logic moved into
braillechart.axis_chart, a shared widget, and the dashboard's own
activity chart was refactored to use it too instead of hand-rolling
its own copy.
meshterm/ui/channels.py's _read_slots is now public (read_channel_slots)
since the dashboard opener needs it too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chat, Channels, and Courier move into their own Messaging section at
the top; Mesh keeps the views over the mesh itself - Dashboard, Nodes,
Map, Atlas, Watchtower, Send advert - dropping from nine rows to six.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ground-up rework of the unusably busy first cut. A new atlas_layout
service lays the evidence graph out in camera-independent unit space:
radii stay pinned to evidenced hop distance (the atlas's core question)
while angles relax toward each node's strength-weighted neighbour mean —
chains straighten into spokes, clusters gather into wedges — then each
ring enforces a minimum angular separation. Deterministic throughout, so
the mesh keeps its shape across rebuilds.
The screen becomes a camera over that layout, with the street map's
exact controls: arrows pan (Shift fine, clamped so the graph can't be
lost), PgUp/PgDn zoom 1–12x, Home refits, and every letter feeds a
find-as-you-type filter — matches labelled bright white over dimmed
context, Enter adopting the first match. Tab walks the selection
(recentring it when off-screen), Ctrl+R rebuilds, Esc peels find →
selection → screen. Hop rings draw as faint dotted guides, and the
label budget grows with the zoom's square, so 1x is a calm overview and
zooming in is what buys the detail. Panel and footer rows all fit 72
columns.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Queue a direct message for a node that isn't reachable right now and a
session-long background loop delivers it when the recipient is next
heard — or at a scheduled local time ("next 07:00", "in 3 h", any
HH:MM). Each attempt is an ordinary chat send (it lands in the
conversation history with its ack state, exactly as if typed); failures
back off exponentially and, after the first shot, wait until the node
is heard again, so an absent node is never hammered on faith. One
attempt per pass keeps a backlog draining politely, and after five
unacknowledged tries the courier gives up and says so. Outcomes raise
Watchtower alerts, so the header's badge lights when an overnight
delivery finally lands.
The outbox persists in courier.json (a queued message must survive
restarts or the promise is hollow), a Mesh-section screen carries the
queueing flow, per-entry Send now / Cancel, and the finished history,
and a scripted `courier <node> <text…> [--at HH:MM]` queues without
transmitting anything.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The passive monitor has been writing every overheard packet to the
database since the first session; nothing surfaced that history beyond
the dashboard's two-hour window. A new Tools-section explorer pays it
off: pick the whole mesh or any node ever heard and read its past as
braille charts over a switchable 24 h / 7 d / 30 d / all-time window
(w cycles). A node shows its reception volume, a median-SNR band
scaled between its own extremes and coloured by quality, its
hour-of-day rhythm, and the roll-up record; the mesh page shows
packets and nodes per day, first-ever arrivals, and the all-time
ledger. Histories read chronologically — oldest left, now right —
unlike the live dashboard's sliding chart.
Three repository queries feed it: a windowed per-node observation
record (packet rows excluded, as everywhere reception is attributed),
per-UTC-day activity totals, and first-ever appearances with names.
Everything is stored data; no device is needed and nothing transmits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Star the nodes that matter (the roof repeater, the far gateway) and
session-long rules watch what the always-on hub already hears: a
silence alarm when a starred node goes quiet past its threshold
(latched per quiet spell, re-armed — with a recovery note — when it
returns), an SNR-sag warning when its receptions' median drops well
below the run before it, and a mesh-wide heads-up the first time a
never-before-seen node id appears (baselined against the whole stored
observation history, announced at most once ever). Nothing transmits.
Alerts persist in watchtower.json alongside the watchlist and rules,
show as a red ▲ badge in the header, and are read, acknowledged, and
configured in a new Mesh-section Watchtower screen. A remote-battery
rule was considered and dropped: observations carry no parsed battery
telemetry, so there is no honest data source for it yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The trace path composer's evidence graph — trace walks, firmware
routes, overheard relay chains, repeater neighbour tables — finally
gets a face. A new full-screen Mesh section tool draws every link the
graph holds on the street map's braille canvas: our node at the
centre, everything else on concentric rings at its evidenced hop
distance, edges coloured by median SNR and faded by evidence age.
Clusters we only know exist from a repeater's neighbour table (no
observed path back to us) ride an outermost island ring and say so.
Rings place members at even angular slots ordered by the bearing of
their strongest parent, with the whole ring rotated to best fit those
wishes plus a per-ring twist — sparse meshes are pairs all the way
out, and without the twist every ring collapses onto one crowded line.
←/→ walk the graph node by node (selection brightens its links and
fills the bottom panel strongest-first), Enter floats a per-link
detail dialog, r rebuilds from storage. Nothing transmits, ever: the
atlas is a reading of what the radio already heard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A new Device management entry for administering repeaters and room
servers. The shared credentialed-first picker (extracted from TX
optimize) chooses the node, login runs on remembered passwords with a
one-time prompt otherwise (forgotten on rejection), and the editor
lands in the local device-configuration screen's exact lanes — but
speaking the node's text CLI through a new public
Device.send_remote_command, which brings the repeater-only knobs the
local editor never had: TX delay, Direct TX delay, RX delay, airtime
factor, advert intervals, flood max, repeat, guest password.
Reads cost one paced mesh round trip each, so the editor never
bulk-reads on open: values show what the last read said, age-stamped,
from a new per-node remote.json cache; Read settings refreshes them
under an abortable progress dialog and Apply sends the staged sets
the same way, folding confirmed values back into the cache and
keeping refused ones staged. Advert, clock sync, password change, and
reboot run as confirmed popups.
The Command line is a readline-style screen onto the node's raw CLI:
per-node history persisted across sessions (↑↓ recall, adjacent-dupe
suppression, capped), Tab completion over the known verbs plus every
catalog get/set spelling with ghost-text preview, one command in
flight at a time. The simulator grows a full repeater CLI so all of
it drives against --mock, and a scripted one-shot
(repeater-admin <node> <command…>) covers automation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One full-screen, live view of everything going on around the node,
stacked coarsest first. Activity: a three-row braille bar chart of
every packet heard over the trailing two hours — the header
indicator's big sibling, drawn from the same monitor buckets — with a
pulse line reading the rate, nodes heard, and the window's busiest
transmitter. Traffic: session tallies by packet class with
proportional bars (the monitor grows per-kind counters). RF health:
median SNR on the trace tool's quality bar and RSSI extremes from the
stored window, beside the radio's own live numbers — noise floor,
last RSSI/SNR, airtime, battery — polled the way Device info reads
them. Feed: the latest packets in fixed lanes (time, class, node,
reception, relay path), seeded from stored history via a new
recent_observations window query so the screen opens full, then
streamed straight off the event hub.
Menu-only (it rides the hub and repaints every second); scripted
inspection stays with the monitor and nodes subcommands.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The old flow asked for a hand-typed comma-separated path, logged in
before showing anything, and started transmitting the moment the
screen opened, on parameters you couldn't touch. It now walks like the
trace feature: two pickers choose the link — the node to tune
(credentialed repeaters lead, wearing the key glyph), then the target
whose reception is optimized — and the screen opens armed but idle.
An action list drives it: Route opens the trace path composer pinned
at the tuned node (suggestions from observed links; direct by
default), Range/Step/Samples float small dialogs, and the Sweep row
always quotes the worst-case transmission count, so the cost of a
commit is on screen before anything goes on the air (coarse_levels
goes public to price it). Sweep logs in first — the password prompt
floats only when nothing is remembered — then runs coarse → refine →
verify under the trace tool's in-flight dialog with Abort, levels
landing in the bar chart behind it, best starred. Completion parks the
cursor on a new Apply-winner action row (the after-sweep dialog's
second chance), and a finished sweep leaves the screen armed: adjust
and sweep again, the chart clearing as a new measurement starts.
The scripted CLI keeps its one-shot flag set, table, and HTML chart.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Messaging becomes Mesh and takes in Nodes (between Channels and Map —
the "who's out there" view) and Send advert (last in section: its one
action, after its views), so everything mesh-wide lives under one
heading. Device becomes Device management and keeps the box-level
tools; Diagnostics becomes Tools.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The main-menu advert entry ran the same flow as Device actions but
replaced the screen instead of floating over it. Tools can now declare
popup = True to run while the menu stays pushed — their prompts and
result draw as modal dialogs over it, the quit-dialog pattern — and
the advert tool does, so both doors into the flow looked and felt the
same. With the popup one keystroke away there was no reason to keep
the second door: the Device actions row is gone, leaving that screen
to the box-level operations (clock, backup, key, reboot, reset).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tools gain an icon attribute — menu-only, so result windows and the
CLI keep the plain title — and the menu loop composes it into the
aligned name lane. Single-codepoint, emoji-presentation glyphs only,
so the two-cell width holds across terminals: 💬 Chat, 📻 Channels,
🌍 Map, 👥 Nodes, 📡 Send advert, 🔧 configuration, 🔨 actions,
📋 info, 🎯/👣 the traces, 🎧 Monitor, 📶 TX optimize, 🚪 Quit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MeshTerm now announces the connected node by itself: a zero-hop advert
every 1/2/3/4 hours (default hourly) and a flood advert every
3/6/12/24/48/168 hours (default daily), each switchable off. The
cadences are staged and applied from a new Background adverts section
in the device-configuration editor, and persist per device (keyed by
public key) in adverts.json alongside the last-sent mark for each type
— so the countdown survives restarts, and a manual send from any
advert flow resets the same clock the scheduler reads.
The scheduler stays transmission-shy: at most one advert per 30 s pass
(a flood due together with a direct waits a pass), and a never-sent
device is armed to count from now rather than advertised immediately.
It holds no device subscription — each pass just skips when nothing is
connected — so reconnects need no special handling, and scripted CLI
runs never start it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Esc (or Back) from the Trace target screen now returns to the main
menu instead of looping through the target picker. The action list
breathes: a blank line after Explore paths and after Sample count
groups the rows, and the previous-route stamp sits flush left under
the route instead of indented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A trace is one walked path and "target" is pure UX (hardware-verified),
so the feature now splits along that line: Trace target keeps the
symmetric mirror-and-return flow (picker, Explore paths), while the new
Trace path skips the picker entirely and walks a hand-composed route —
no Auto, no Explore, Trace gated until a path exists, history recorded
under the "(path)" sentinel so composed walks never pollute the target
picker. The composer loses its mode toggle: a pinned target means
mirrored, no target means verbatim, and its preview opens as us → us.
Sampling returns, deliberately: a Sample count action (1/2/3/5/8) runs
that many traces per commit, paced by trace_cooldown_s between sends and
counted off on the in-flight dialog — never a burst. Actions reorder to
compose/explore/width/samples/trace with a Back row; the w/p/x hotkeys
retire.
Display: route nodes drop the underscore (prompt_toolkit draws U+00A0 as
"_", so name (hash) now joins with plain spaces), the composer renders
nodes exactly like the route line, the (previous · …) stamp gets its own
line, the path row ends with the displayed hop count, Explore rows lead
with the route instead of an "observed path" label, and the empty Traces
placeholder is gone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Repeaters penalize (and can blacklist) nodes that burst traffic, so
every trace-transmitting surface now sends exactly one transmission
per human action: Enter on the live screen runs a single trace (the
screen still aggregates the session's medians), the scenario probe
measures each candidate path once, and the CLI drops --samples to
become a true one-shot. trace_runner.measure and the s-key sample
cycle go with it.
Also de-pollute the target picker's "Recently traced" section: the
repository now returns a short recency-ordered list instead of the
ever-growing all-time tally, and stored hex prefixes fold back to
their contact names so a node shows once.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The topology graph gains its fourth evidence source — the only one not
derived from our own reception. A repeater we hold admin rights on
reports its neighbour table (who it hears directly, at what SNR) when
asked over the mesh, revealing links and whole nodes our radio has
never received anything from: exactly the second vantage point the
composer runs dry without, two hops out.
Probed live against YUL-Cartierville (v1.15.0) through the Waymarker
before building: firmware answers the binary NEIGHBOURS request but
silently ignores guests, so the flow is login-first, tx-optimize style
(stored password, one-time prompt, forget on rejection). An empty table
is a normal answer — the gateway's own table was empty during the probe
(confirmed via its text CLI: '-none-') — so the UI says "ask again
later" instead of erroring.
Standing on a fetchable repeater, the composer shows a "fetch
neighbours" row with the suggestions it extends; the fetch runs under
the abortable spinner dialog, persists as a runs-row-owned snapshot
(schema v8, neighbour_reports), and the composer reopens mid-thought
with the same hops and refreshed suggestions tagged N. Refetching
supersedes the old snapshot per (repeater, neighbour) pair rather than
inflating the link's sample count: a table is current state, not an
event stream.
Verified end-to-end on hardware via MeshCoreDevice over BLE (login +
fetch + clean empty-table return) and in the mock (guest refusal,
discovery of a never-heard node, composer render + hand-off). 441
tests pass; the real database migrated v7 -> v8 cleanly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Everything we receive carries a fragment of the mesh's shape: successful
traces crossed every link in their path (out and back — the reply retraces
the reverse path, so links read both ways), each contact's out_path is a
route the firmware distilled from received floods, and RX-logged packets
report the relay chain they rode in on. A new topology service folds those
into one undirected evidence graph (schema v7 persists packet paths).
The trace screen stops auto-starting on target pick. Enter runs a burst
under a floating tracing dialog (spinner chip, live progress, Abort); "p"
opens a hop-by-hop path composer whose suggestions are the tail's observed
neighbours, strongest link first, with raw hex entry for unseen nodes; "x"
browses ranked path scenarios (device route, direct, best observed
alternatives) and can probe them all — a measured burst per candidate,
recorded as path_candidates rows, reliability-first ranking, winner offered
for adoption.
Composed specs are outbound-only, always ending at the target's hash: the
stored data shows the firmware auto-replies along the reversed path, and
user-composed "there and back" loops (3d,f2,3d) never succeeded. Specs
collapse to a uniform per-hop width the narrowest known hash can honour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The six-prompt chain (path, samples, min, max, step, apply-up-front)
collapses to one: pick the path, and the live sweep screen takes it
from there. Levels land in an SNR bar chart as they are measured,
ascending by power with the current best starred, while the optimizer
announces its phase — coarse sweep, refining around the leader,
verifying — through a new on_phase callback. Reliability and trace
counts sit beside every bar, and a level nothing got through at reads
as an explicit no-reply row.
The apply decision moves to where it belongs: after the sweep, a
floating dialog offers the winner against the node's original power
(Cancel left, Apply right, Enter commits), and `a` re-offers it if
first declined. The sweep itself runs with apply off, so Esc at any
point stops the search and the optimizer's unwind restores the
original transmit power.
The sweep persists under one runs row (levels and traces alike), the
scripted CLI keeps its full flag set, chart, and --apply semantics,
and the tool moves in with Trace under Diagnostics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The menu's trace flow joins the living-screen pattern (chat, nodes,
map). One recency-ordered picker — recently traced targets first, then
contacts by freshness — replaces the target/samples/path prompt chain,
and the screen opens tracing immediately: each reply lands in a
newest-first log as it arrives, the route line and per-hop medians
(with SNR quality bars) update live, and Enter / s / p run another
burst, cycle the burst size, and edit the forced path without leaving
the screen. Esc steps back to the picker, chat-style. The route line
opens seeded from the target's last stored trace, so the path history
last saw shows while the first reply is still in flight.
Every burst persists exactly like a scripted run — one runs row, each
trace recorded under it — and the scripted CLI keeps its one-shot
table output unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five features leave the menu and CLI: the heard-nodes review, the
baseline-regression checker, the offline link-budget calculator, the
route-stability graph, and the run-history browser — along with their
services (link_quality, link_budget, route_stability), the pyvis route
renderer, and the pyvis/networkx dependencies.
The data layer is untouched where it still earns its keep: passive
monitoring keeps recording every overheard packet (the header counters,
Nodes, and Map read it, and `meshterm monitor` still captures a bounded
window from the CLI), run logging keeps writing `runs` rows, and trace
history stays queryable for the trace tools. Only the route-map-only
`all_traces` query goes.
Also fixes a latent tie-break flake in the nodes sort: ages are now
measured from one clock snapshot, so identical last-seen stamps really
tie and keep their A→Z order in both directions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The on/off switch never made sense for a pure listener: recording is
now unconditional, so the toggle submenu, the persisted preference
(MonitorStore/monitor.json), and the header's ON/OFF framing are gone.
The monitor registers its hub subscription at startup — a device-free
step that cannot fail — and its run row opens lazily on the first
observation, so recording follows the hub wherever it opens (eagerly,
or deferred by connect_on_start) and silent sessions leave no empty
runs. The menu entry is now just the read side, "Heard nodes", and the
header shows live heard counters instead of a switch state.
meshterm monitor on the CLI drops --on/--off for a bounded foreground
capture (--seconds N, 0 = until Ctrl-C, mirroring chat listen): it
tails each overheard packet live and summarizes the window's heard
nodes when it ends, while persisting history exactly like a session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Announcing the node is the everyday operation in the Device actions
bucket, but it sat two screens deep and out of reach of the main menu's
type-to-filter. Give it a top-level entry (order 10, right after Device
actions; Device info shifts to 11) via a new menu-only tool that reuses
the existing advert flow through a standalone send_advert() entry point.
Only SELF_INFO is read on open — the advert op and contact card never
need the full snapshot — so it stays snappy over Bluetooth. The row
inside Device actions stays, and scripting remains under `config
advert` / `config share`, so no new CLI subcommand.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New staged settings: contact auto-add bitmask, default flood scope
(hashtag-normalized, 31-byte slot), and the two TX delay factors the
library always zeroed (write-only on hardware, so unknown values fall
back to 0 exactly as before). TX power is now capped by the board's
reported max_tx_power instead of a hardcoded 22, with the prompt hint
and validation sharing one effective_maximum.
Device actions gain Sync clock (drift shown before writing host time;
also a config sync-clock CLI command), and Device info now leads with
a live Status panel: role, model/firmware, battery, storage, clock
drift, uptime, and radio/packet statistics — every read best-effort so
older firmware still renders.
The simulator models all of it, including a seeded clock drift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Dialog footer hints take the border's hue one shade darker (hint.*
theme styles), mirroring the brighter title tinting.
- Dialogs get a blank breathing row above and below the body whenever
it doesn't cost visible content.
- The chat conversation picker gains column headers over its lanes.
- The map screen titles itself "Map", matching its menu item.
- The config editor lays settings out in aligned SETTING / VALUE /
DESCRIPTION columns under a header line, and with changes staged the
Apply and Back rows sit together below the blank separator.
- Device actions move out of the config editor into their own screen
behind a new menu-only device-actions tool (CLI equivalents stay
under the config group).
- Type-to-filter keeps section headers (and column headers) in place,
and section jumps now work over the filtered rows too.
- The main menu aligns tool names and muted descriptions into two
columns, headerless.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enforce one voice across every screen: column and section headers are
UPPERCASE, menu items are Capitalized buttons, and short descriptions
drop their trailing period (with real ellipses instead of "...").
Tools gain a display `title` separate from their kebab-case CLI name,
so a menu item now reads exactly like the screen it opens ("Device
configuration" opens Device configuration) and result windows inherit
the same name. Every sub-screen backs out via a "Back" row below a
blank separator (the config editor was missing both; its Close is now
Back, spelling out the discard when changes are staged), while the main
menu and device picker keep "Quit". Panel and dialog titles now wear a
brighter tint of their border colour via new title.* theme styles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rework the device-configuration screen from a flat list with an
advert-laden "danger zone" into one grouped menu whose every prompt is
a real floating modal popup over a persistent backdrop, matching the
quit dialog. The main menu now stays pushed on the session stack for
the whole editor session, so sub-prompts float over it rather than
replacing the screen.
Settings are staged and applied together (with a dirty-cancel
confirmation); device actions — adverts, reboot, backup/restore,
identity key, factory reset — run immediately behind their own
dialogs. Adverts get a dedicated menu (zero-hop, flood, or share this
node as a QR/URI contact card). Destructive actions gate behind a new
TypedConfirmDialog that requires typing RESET / IMPORT. Reboot hands
off to the reconnect dialog and waits for the companion to return.
Location can be set by pointing at the full-screen map (new
LocationPickScreen) or by typing coordinates.
Make every dialog consistent: a short title in the border, the
instruction as an in-body prompt, and a width sized to its content
instead of a fixed wide cap. TextScreen/SelectScreen/AutocompleteScreen
gain an optional in-body prompt, and those plus ConfirmScreen gain a
dialog_width; the prompt is threaded through the surface and session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Query MeshCore's device-info frame (send_device_query) on connect to learn the
board's own model string (e.g. "Seeed Tracker T1000-E") — the only reliable source,
since BLE companions advertise no manufacturer data, use randomized addresses with
no IEEE OUI, and expose no GATT Device Information Service.
The probe folds the model into the identity dict, so it's captured during the
connection that already happens, then remembered in the device store (preserved
across reconnects like the node name). The device list and splash picker rename
their "Vendor" column to "Hardware": it shows the firmware model for confirmed
devices and falls back to the USB vendor name for merely-attached serial ports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Companions can now be reached over Bluetooth as well as USB. Rather than
a parallel BLE path, this generalizes the existing serial abstractions so
a device is identified by a transport (serial | ble) plus a target (COM
port or Bluetooth address); every feature rides the same Device interface
and works over either link unchanged.
- discovery: DiscoveredDevice gains transport/address/name and
transport-aware stable_id/label/confidence/target; discover_ble_devices
scans for MeshCore* adverts via bleak, discover_all merges both.
- connection: MeshCoreDevice opens create_ble vs create_serial by
transport; new link_present() liveness (OS port enumeration for serial,
the BLE client's is_connected flag for Bluetooth); is_connection_lost
recognizes Bleak errors and BLE transport callbacks; probe_device is the
transport-aware smoke test.
- disconnect: the session watcher polls transport-neutral link_alive(), so
a dropped BLE link raises the same reconnect dialog and auto-resume as an
unplugged cable; reconnect rebuilds the same endpoint by address.
- selection/context/store/profiles: --ble address, remembered BLE default,
and BLE device profiles all resolve to the right transport.
- UI/CLI: startup picker scans both transports behind a spinner and tags
Bluetooth companions; `devices` lists them (--no-ble to skip); global
--ble/--ble-pin flags; README and example config updated.
Validated on hardware: discovery, connect, reconnect, and channel
messaging (send + receive) over BLE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sort contacts alphabetically with our own node pinned first, and add
Heard (relative age) and Pkts (overheard-packet) columns alongside a
per-type glyph and the full path-hash-highlighted key. Drop the boxed
table for a clean header rule and a glyph legend.
Colour each name by a continuous log-scale heat map of how recently it
was heard (white -> yellow -> orange -> red -> grey), and give the
node-type icons a shared, map-safe palette across the whole app: a
yellow star for us, a loud pink node and a calmer violet repeater
(both in hues no basemap uses, so nodes pop more than repeaters),
a white room and an orange sensor.
In the menu the list becomes an interactive screen: left/right pick the
sort column (cyan header + direction triangle), up/down set ascending/
descending, with column widths reserved so toggling never shifts the
row. The scripted CLI keeps a one-shot render via --sort.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
By the time the interactive menu is up a companion is already selected,
so the read-only devices listing has no job there; it belongs on the
command line as a startup-time "which port is my radio?" diagnostic.
Add a menu_visible flag to the Tool contract (skipped by the menu loop,
never by CLI registration) and set it False on the devices tool.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Color-code the chat picker rows: a contact's dot takes that person's
chat hue (filled once talked-to, hollow before), previews light their
sender names and @mentions in-hue, and a relative age column sits
between the unread badge and the message text.
Pin a grouped list's section heading to the top row once it scrolls off
(sticky header in the frame), add PageUp/PageDown to SelectScreen, and
drop arrow wraparound in the conversation picker so it doesn't jump
across the section dividers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reserve the "MeshCore device" tag for hardware we have actually talked to,
rather than guessing from the USB vendor ID:
- DeviceStore now keeps a registry of every confirmed device (keyed by
stable serial id), remembered forever, migrating the old single-record
file transparently.
- Selecting a device on the splash runs an immediate smoke test
(smoke_test_meshcore): a genuine companion is remembered and confirmed;
anything else re-prompts with a message to choose another. The probe uses
a short handshake window so a non-MeshCore port is rejected cleanly in a
few seconds instead of blocking on meshcore's full default timeout.
- The probe renders in place on the chromeless splash via a new BusyScreen
(animated Braille spinner) driven by session.busy_startup, so the wordmark
and box stay put instead of a separate progress dialog.
- Silence the meshcore library's console logging (its import calls
logging.basicConfig, which printed "INFO:meshcore:..." over the TUI):
route it to the JSON-lines file log with a NullHandler guard on root.
- The devices tool now marks confirmed devices and stops asserting "LoRa"
before a device is proven.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The default view fitted every located node, so a few distant outliers
(here a node near Toronto, 500+ km from the Montréal core) zoomed the
whole mesh out to a continental scale. Frame the densest half instead —
the nodes nearest the median centre — so the map opens on a useful local
region; the rest simply fall off the edges. Verified against a live
159-node mesh: 0.5 lands on the Greater Montréal metro (~77 km across,
two-thirds of nodes visible) rather than eastern Canada.
Viewport.fit gains a `fraction` argument (default 1.0, unchanged for
existing callers); the map's opening view and `r` reset pass 0.5, and
`meshterm map --fraction/-f` exposes the knob on the CLI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The devices tool doubled as a mid-session device switcher: its menu
prompt re-ran the picker and reassigned the active device. Drop that so
selection is a startup-only concern (or --port on the CLI); the tool is
now a read-only inventory in both the menu and CLI.
With the re-pick gone, startup is the sole caller of prompt_device, so
its cancel_label parameter (added only to swap in a "Back" label) is
removed and the "Skip…" label inlined.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename the package directory, imports, brand strings, env-var prefixes,
console-script entry point, and the config/data dir (~/.config/meshterm).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>