The day-boundary notch left its bottom dot lit: the zero baseline is
drawn per braille cell (both dot columns), so it filled in under the
notch's blank column, and the notch connected to the axis instead of
extending down to it. Worse, when the neighbouring bar was tall the
notch's bottom dot took the bar's colour, not even the faint grey.
Introduce a GAP sentinel in braillechart: a timeline column that
renders fully blank — no bar and no baseline — the one deliberate
break in the "grey is zero" flatline. _assemble now decides the
baseline per dot column rather than per cell, so a GAP column goes
blank clean to the axis while its cell-mate keeps its own zero line.
The Time Machine's day charts mark their notches with GAP instead of
None.
A genuine zero-value day still draws its faint grey baseline as
before — only the notch column breaks it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_day_columns floor-divided chars*2 by the day count and dropped the
remainder, so the stretched bars often fell short of the width the
axis border and caption assumed — the chart reading as shifted left
of its own axis. Now splits in character units (falling back to plain
dot columns when there are more days than characters) and spreads the
remainder across the oldest days, so the total always lands exactly on
2 x chars.
A day spanning more than one character also opens with a blank dot
column now — the left half of its leading braille cell — so
same-height neighbouring days still read as separate bars instead of
fusing into one solid block.
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>
The counter was its own last body line, and the pinned day-divider
header reserves the top viewport row without relaxing the bottom scroll
clamp - so exactly when the transcript filled the screen, the counter
fell off the bottom. The budget now trails the compose text inline
(wrapping along with it, costing no row), and the frame slides the
window down a row when a sticky header is pinned at the very bottom,
so the last body line always wins over the duplicate heading.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New packet_viewer module: per-kind icons and colours shared by every
packet list, and a floating viewer that lays any packet out in full -
advert identity/location, telemetry payload, a packet's relay path,
a message's text - pageable over its list with PgUp/PgDn/Home/End.
The feed stores packets as data and renders per paint: up/down walk
the rows, Enter opens the viewer, Esc peels the highlight; the class
lane drops to icon-only on narrow terminals. Node names take the
app-wide palette (ours white), RF health and pulse render as labelled
grids so wrapped items align with their block, traffic lanes ride the
shared braille meter, the chart caption gains intermediate time marks,
and history buckets draw grey like the header.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
timeline_rows and activity_sparkline accept per-column style overrides,
and the monitor exposes which histogram buckets this session actually
heard. The header pulse draws seeded history grey and live traffic
green, so a restart is visible in the chart instead of masquerading as
fresh reception; the dashboard chart follows in the next commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chart captions now draw through axis_caption, so a wide volume/SNR/day
chart gains quarter/mid time marks between its edge labels. Nodes whose
stored observations never carried a name ask the device's contact list
before rendering as "unknown" (picker and arrivals alike), and the hash
lanes go through the shared hash widget. make_node_resolver learns an
optional stored-names fallback and the repository can serve the latest
advertised name per node, for the dashboard feed to use next.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
highlighted_hash grows a width budget - shorter than the key shows an
ellipsis, shorter keys pad so lanes align - making it THE hash widget.
The chat sender palette moves to theme.name_style as the app-wide node
name colour rule; our own node is always the pure-white "you" style
(the nodes table's self row now follows it).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
meter() is the one horizontal proportion bar (two fill steps per cell,
full-height tally or slim-on-a-track gauge); the SNR bar now draws
through it. axis_caption() fills a timeline's caption with as many
intermediate marks as the width fits, instead of only its two ends.
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>
Panning is the arrow keys (Shift for fine), zoom is PgUp/PgDn alone, and
reset moves to Home — freeing every letter and +/- so typing can drive a
live name filter instead: matches keep bright white labels and always win
the label contest, everything else dims to bare context glyphs, Enter
frames the matches, Backspace edits, and Esc peels the filter before the
map. The footer swaps to the query while a find is active, the title
counts matches, and the location picker follows the same movement keys
with find disabled (typing there has no job). Hints fit 72 columns.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The subject picker becomes real columns under a header — type glyph,
name, hash, packets, heard — with the name heat-coloured by recency
("unknown" spelled out for nameless nodes, still heat-tinted so a fresh
mystery isn't dressed as a cold one) and the hash in its own lane with
the routing-width prefix lit, exactly as the Nodes list draws keys. The
whole-mesh page gains the same name/hash lanes for Arrivals (the per-row
"first heard" prefix now lives once, in the FIRST HEARD column header), a
mesh-wide hour-of-day Rhythm chart fed by a new O(24) hourly_activity
repository query, and the node page's volume heading says the direction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tables inside popups are border noise and steal the columns the values
need. Each field is now a muted header label with its value beneath, and
the hash, key, and link — strings that exist to be copied out whole —
fold across lines at the dialog width instead of dying at an ellipsis.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The openness glyph already says public/private and the hash is one
keypress away in Show key; the fifteen freed cells are what keep the
activity sparkline visible on a 72-column terminal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dashboard's activity chart flips to the app-wide direction (oldest
left, now against the right gutter; caption relabelled) and both screens
drop their private renderers for ui.braillechart. The Time Machine's SNR
band is now zero-anchored instead of min-scaled: readings hang below the
grey zero line by their actual depth (or rise above it), with a third
braille row buying back the swing resolution, and the heading quotes the
zero-folded scale so the label and drawing agree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both sparklines draw through ui.braillechart, so the current minute lands
at the right edge and history trails left (JP reversed the earlier
newest-left convention). The header also sheds the events-hub live-dot —
it cost cells and read as noise — and drops its roomy separators to a
compact " · " whenever they would squeeze the pulse below 24 cells, so a
72-column terminal keeps a readable stretch of graph.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A new ui.braillechart module every value-over-time row will draw through,
carrying three app-wide rules: time flows left-to-right (the rightmost
column is now), a faint grey baseline always marks value zero (mid-chart
when a series goes negative, so positives rise and negatives hang), and
both braille dot columns are used for two readings per character cell.
Existing charts are rewired onto it in follow-up commits.
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>
The persistent header's activity indicator now draws one minute per
braille dot column and stretches to consume every cell the fixed
segments leave, so a wider terminal simply shows deeper history instead
of a fixed two hours. Thresholds drop to 1/3/8/21 packets per minute to
match the finer buckets.
The session's header callable now receives the terminal width — it was
the only way for the header to know how far it may stretch, and the
width is already known at both call sites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The activity histogram moves to one-minute buckets held six hours deep,
so both the dashboard and the header can draw a dot column per minute
and still have history in reserve on wide terminals. The chart now
anchors "now" at the left edge (matching the header sparkline's
direction), stretches across every cell the terminal offers, and
mirrors its count scale on both gutters with per-row axis marks.
braille_bars gains the font's full horizontal resolution — each value
is one dot column, two per cell — and the traffic bars step in half
cells the same way. The pulse line quotes rates over exactly the span
the chart shows and sheds the word "heard" rather than wrap when the
terminal is too narrow for it.
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>
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>
The status bar drops the static "device:" prefix and the heard
counters. The device segment now leads with the node's own mesh name —
the remembered registry name, then the profile alias, then the
hardware name — with where it's reached in parentheses (the COM port,
or BLE), cached per connection so repaints never re-read the registry
file.
The heard numbers give way to an unread badge (channels and direct,
shown only when something waits) and a two-hour braille activity
indicator: every packet the hub hears — adverts, telemetry, messages,
acks — lands in a five-minute bucket, drawn twelve characters wide at
1/5/21/89 thresholds with the ●/○ live-light in front. The braille
sparkline renderer moves from the channel manager to widgets so both
draw with one brush.
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>
The path screen opened showing the previous walk's route yet refused to
trace it ("compose a path first"): the display seeded from history but
the spec never did. The last successful stored walk now feeds path
mode's auto route — verbatim, with "last walk" provenance — so the
route on screen is the route Enter walks.
Adopting a different spec (composed, explored, or re-rendered at a new
width) also clears the aggregates, per-hop medians, and trace log: they
described the old route. The screen-lifetime total the owner reports
survives the reset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SNR quality bars' unlit remainder now reuses the same full braille
cell as a lit step, dimmed to a new dark-gray track style, instead of
a plain "." placeholder — the meter reads as one glyph system, filled
in from a background rather than dots giving way to blocks. The
boundary half-step still carries the reading's own colour, since a
single character can't be split between it and the track.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The per-hop meters (shared by the trace and TX-optimize screens) drew
16 solid block characters. They now pack two fill steps into each of
8 braille characters, using only the middle two dot rows (2, 3, 5, 6)
so the glyph sits mid-cell instead of hugging the top or bottom edge
— half the width at the same 16-step resolution.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An auto (device-routed) Trace target went on the air as a bare
destination hash — an observer confirmed no repeater hops — so any
target past a direct neighbour could never answer. Dumping the
radio's contact table showed why: every contact reports
out_path_len -1 (flood). MeshCore firmware only learns a contact's
out_path from two-way addressed traffic, and tracing repeaters
teaches it nothing, so "device-routed" has nothing to route with.
Auto now resolves a route the session can prove, in order: the
device's learned route when one genuinely exists, else the outbound
leg of the last successful stored walk (_previous_outbound extracts
it from the recorded boomerang), else a bare direct attempt. One
resolver feeds both the wire and the screen, so the planned route
line renders exactly what Trace will transmit, with its provenance
under it — (auto · device route), (auto · last trace · stamp), or
(auto · direct — no known route) — and the summary's path row says
the same instead of claiming a device route that never existed.
The connection layer's path-less fallback keeps the mirrored return
leg for contacts that do carry a learned route (the trace protocol
reflects nothing home), and its docstring now says the firmware
never routes a trace itself. Resolution verified against the real
database and radio: a multi-hop target resolves to its full stored
boomerang, the direct gateway stays a single-hop spec.
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>
Four trace-feature upgrades, all UX-preserving:
- Stored traces recover their path-hash width from the (uniform) hop
hashes at rehydration, so a target's previous route renders short
hashes instead of the device's full 64-char public key.
- The trace screen's verbs are selectable action rows now — Trace,
Path width, Compose path, Explore paths. The cursor opens on Trace so
plain Enter still just traces; p/x/w hotkeys stay on their rows, and
the cursor only pins the view while it's actually being moved, so
PgUp/PgDn still read a long trace log freely.
- A floating path-width dialog picks the 1/2/4/8-byte hop addressing
(each row previews the target key with the addressed slice lit, the
device's routing width marked). Changing it re-renders a standing
forced path — hops widened back through their canonical hashes — and
shapes every spec composed or explored afterwards.
- The composer's new ⇄ row toggles symmetric (return mirrors the
outbound, the current behaviour) against asymmetric, where the whole
walk is composed by hand — target routed *through*, outbound
repeaters reusable, only the landing back on us implicit. Toggling
materializes the mirror into editable hops (or regenerates it from
what precedes the target), so the wire route never changes under the
toggle; the planned-route preview dims only palindromic return legs.
Also: the target now carries its hash in the composer preview like
every other node, and the composer's footer hint reads briefer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The trace protocol has no separate return-path field, so a forced
path (from the composer or an adopted scenario) has to spell out the
whole boomerang itself: outbound hops, the target, then those hops
mirrored back. We were only sending the outbound half — repeaters
never saw the return leg, so a forced multi-hop trace couldn't rely
on the destination reflecting it.
render_forced_spec() in topology.py is now the one place that builds
a forced-path spec; PathScenario.spec() and the composer's _spec()
both call it. The composer's live preview already showed the mirrored
return dimmed alongside the outbound hops - that display code stays,
but the trace screen's planned-route preview now splits the *actual*
wire spec at its midpoint instead of assuming it was outbound-only,
and the composer's reopen-with-current-path reseeding drops the whole
return leg instead of just the spec's last token.
Also normalized node-hash display in the composer to the spec's
path-hash width (matching the planned/completed route views) instead
of a hardcoded 6-hex-digit slice.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two long-standing flakes, one root cause each:
Quit with a serial companion often froze until the exit watchdog killed
the process. The meshcore dispatcher's stop() awaits queue.join(), but
its processor task exits after draining at most one event once stopped —
so a routine advert/RX-log burst (two+ events queued at that instant)
deadlocks the join forever (repro confirmed against the installed
library). MeshCoreDevice.disconnect now gives the graceful teardown two
seconds, then abandons it: the dispatcher task is cancelled synchronously
and the raw transport closed directly, so the port is still released and
even the forced path finishes well under the watchdog.
BLE startup often failed until the user re-selected the device. Connecting
by bare address makes bleak re-discover the peripheral with a fresh
internal scan, which on Windows intermittently misses a slow-advertising
companion. The discovery scan's live BLEDevice now rides along
(DiscoveredDevice -> probe/context -> create_ble) so the connect opens the
peripheral directly, and a transport-level ConnectionError is retried once
before surfacing — the manual re-select was never anything but a retry.
PIN/bond rejections still surface on the first attempt, unlooped.
Verified against hardware: BLE info read off the Waymarker connects and
exits cleanly.
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>
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 activity window shrinks from a week to two hours in 24 five-minute
buckets (12 braille cells), bucketed newest-first so "now" sits at the
left edge and traffic slides right as it ages. Bar heights switch from
peak-relative scaling to absolute tiers (1/3/8/21 messages per dot), so
the same traffic draws the same bar on every row, and silent cells drop
to a faint two-bottom-dot baseline — a quiet channel reads as a flatline
rather than a gap. The Reorder row's narrow ↕ arrow gains a second space
so its label aligns with the two-cell glyphs of its sibling rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enter is now the sole grab/drop key (Space is ignored), the rows drop
the openness/hash lanes since reordering is about position, and the
screen gains a dialog_width sized for its fullest state so the popup
fits its content without resizing mid-interaction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow the device-config editor's exit-group pattern: once the order
has actually changed, an ok-tinted Apply joins an err-tinted
"Back — discard changes"; untouched, a lone Back. Enter on a list row
now grabs/drops like Space (matching Enter-activates-the-row), and
committing happens on the Apply row instead of Enter-anywhere.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Esc used to be the only way out of the channel-reorder screen and it
always committed, so there was no way to back out of an accidental
shuffle. The screen now follows the other dialogs' Enter-accepts/
Esc-cancels contract: Space grabs and drops the highlighted row
(arriving as printable text from the session, with the normalized
"space" action accepted for symmetry), Enter resolves the final order
— dropping a still-carried row where it sits — and Esc resolves CANCEL,
which the session already maps back to the identity order so the
caller treats it as "no change". Footer hints re-phrase per state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three-threshold bar meter compressed a week of traffic into one of
four states; braille cells pack two dot columns of four rows each, so
the same 8-cell lane now draws a real 16-point histogram — each bucket
~10.5 hours — and a channel's rhythm (bursts, lulls, decay) is legible
at a glance. channel_stats() gains the per-bucket histogram, bucketing
the window's timestamps in Python off one filtered query; recent is
now simply the histogram's sum.
Bars scale to the row's own busiest bucket — the sparkline shows shape,
not cross-channel volume (the MSGS lane does that) — with non-empty
buckets floored at one dot so a lone message never vanishes. A silent
window keeps the muted ······· placeholder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The channel manager's flat name/hash rows become fixed, column-aligned
lanes under one header line — name, openness, hash, unread badge, total
messages, last-message age, and a three-bar activity meter over the
trailing week — so a glance shows which channels are alive, not just
which exist. A new Repository.channel_stats() aggregates totals, the
week's slice, and the last timestamp per channel identity in one SQL
pass; rows read it through a small TTL cache with callable titles, so
a message arriving while the list sits open updates its row in place
(the conversation picker's trick, unread badge styling included).
Both menus adopt the config editor's persistent-backdrop pattern: the
list stays pushed while every sub-prompt floats over it as a modal
popup instead of replacing the screen. The detail screen leads with a
vital-signs line (slot, totals, unread, last activity) and lays its
actions out in the Device-actions two-lane style, with the live unread
badge on Open in chat and the destructive Clear row tinted err.
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>
A new glow pass post-processes composed ANSI lines: it finds each
box's top-left corner glyph, lifts it toward white, and fades the
highlight quadratically along the top and left edges into the
border's own colour. It reads colours from the SGR stream itself, so
accent, muted, and warn frames each glow in their own hue and nested
tool panels are lit individually with no per-callsite wiring.
String surgery instead of a Rich parse/re-render round-trip keeps the
pass ~2ms per repaint. Replaces render_to_ansi_dialog, whose only
caller now composes through the glow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>