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>
A one-line result like "✓ flood advertisement sent" used to open the
full scrollable result window — a huge frame for a single sentence.
TuiUi.present() now collapses small, text-only output (at most 3 lines,
none wider than 76 cells) into a centered popup with an OK button,
floating over whatever screen is beneath; tables, long errors, and
multi-line output keep the window. Fixed centrally in present(), the
upgrade covers every such site at once: adverts, clock sync, backups,
key import, "applied N changes", tool failures, and future ones.
ButtonDialog learns to render a pre-styled (multi-line) Text prompt so
note markup survives into the popup, and the border echoes the message's
strongest tone (err > warn > accent). When the popup would sit on an
empty stack (a tool run from the popped main menu), a blank base frame
is pushed beneath it first — the same trick as the reconnect dialog —
so it draws as a floating box, not as the base.
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>
Checked against companion firmware (MyMesh.cpp CMD_SET_TUNING_PARAMS):
rx_delay_base and airtime_factor are floats carried x1000 on the wire
(constrained to 0-20 s and 0-9 respectively), so the settings now speak
real units — previously the raw wire integers were shown and edited,
making "2" mean 0.002.
The TX delay factors added in the previous commit are removed: the
companion handler reads exactly the two fields and ignores trailing
bytes, so they were silent no-ops. They exist only as remote-CLI
settings on repeater/room-server/sensor firmware.
Airtime factor now leads the Tuning section, ahead of RX delay.
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>
A direct message was transmitted once and marked failed the moment its
single delivery-ack window lapsed, leaving Ctrl-R as the only recourse.
Now the send path re-sends an unacknowledged message automatically before
giving up, matching the mesh convention of a few tries.
_deliver_direct loops send_direct_message until an ack lands or the budget
is spent; both the initial send and the manual Ctrl-R resend route through
it. Each attempt already blocks a full ack window before the next, so the
retries are spaced by that window rather than hammering the radio, and a
hard send rejection propagates without retry.
The budget is the new direct_message_soft_retries setting: retries + 1
tries, clamped to 0..2 so a message is never re-broadcast more than twice
(0 = one shot, default 2 = up to three tries).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Menu navigation over a slow link (Bluetooth especially, but serial too)
can sit on a blank frame while the companion answers — the menu screen is
popped and the tool's first prompt not yet pushed. Fill that gap with a
top-of-z-order spinner so the wait reads as work, not a hang.
- RingSpinner: a reusable, screen-agnostic braille circle with a cyan->blue
comet chasing the rim. Plotted a quarter-arc and mirrored for exact 4-fold
symmetry; stroked over a band of radii so it reads clearly as a circle.
- BusyOverlay + TuiSession.busy_overlay: draw the ring as the top-most float,
centered, only in the gaps between screens (empty stack) so it never buries
a prompt. It holds black for 200ms then fades in over 400ms, so quick work
shows nothing and longer work never pops in. The fade restarts every time
the ring is re-exposed (pop/reset), not just on first display.
- Wire it into menu navigation and the post-splash monitor resume for any
real device link (mock simulator opts out); surfaced via ctx.ui.busy_overlay.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The quit dialog grows a third button, shown only when the session is on a
Bluetooth link that Windows actually holds a bond for (never serial, an open
PIN-less companion, or a non-Windows platform). Choosing it disconnects the
link and then drops the OS-level pairing during teardown, so the next launch
re-runs the PIN ceremony. MeshTerm's remembered-device record is deliberately
kept — unpairing forgets the credential, not the device identity.
Backing this: static WinRT helpers is_ble_paired / unpair_ble mirroring
_pair_ble_windows (Windows-gated, best-effort), an active_address on the
context, and _can_unpair / _unpair_on_exit in the menu.
Also close the WinRT BluetoothLEDevice handles in every helper. Each
from_bluetooth_address_async returns an IClosable that pins the OS ACL link
open; unpair removes the bond but not the link, so a leaked handle left the
device "unpaired but still connected" and refusing to re-pair until it was
power-cycled. Closing the handle in a finally is what finally tears the link
down.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MeshCore pairing PINs are always six digits, so the PIN dialog now caps input
at six characters and draws a fixed six-slot field: each typed digit shows as a
bullet and each still-blank slot as a muted centre dot (· ), so the field reads
as a row of PIN boxes rather than a growing masked line. _LineEditor gains an
optional max_length (swallows keys past the cap, truncates an over-long paste)
and a slots render mode backing the field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bleak's pair() on Windows only performs the CONFIRM_ONLY ("Just Works")
ceremony and never sends a passkey, so a PIN-protected MeshCore companion
bonds without authentication and then rejects the GATT subscribe on its
authenticated UART characteristic with Insufficient Authentication -- a
correct --ble-pin was reported as "rejected" and the device could never
connect.
MeshCoreDevice now runs the WinRT PROVIDE_PIN ceremony itself before opening
the link, creating the ENCRYPTION_AND_AUTHENTICATION bond the characteristic
requires; Windows persists it, so later reconnects need no PIN. A first GATT
auth failure with a supplied PIN also triggers one unpair-and-re-pair retry to
heal a stale unauthenticated bond from an older attempt. The step is a
best-effort no-op off Windows, when no PIN is set, or when already bonded.
Verified on hardware (AA:BB:CC:DD:EE:FF, MeshCore-Homestead): correct PIN
pairs and connects (reads model Seeed Tracker T1000-E), wrong PIN fails clean
with the "rejected" DeviceAuthenticationError, no PIN reports "requires a PIN",
and a bonded device reconnects with no PIN. Full CLI path exercised end to end.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a companion needs pairing, the interactive picker now opens a centered PIN
popup (its own bordered box under the wordmark, matching the splash) and retries the
connection with the entered code — re-asking with a "rejected" note on a wrong PIN,
and returning to the device list on Esc. The masked field clears its stale error as
soon as the user types a fresh code.
The connect failure is now a distinct DeviceAuthenticationError (a DeviceCommandError
subclass), so the picker can catch "needs a PIN" specifically while the scripted CLI
still catches the base class, prints the remedy (pass --ble-pin), and bails without a
prompt — no interactivity for scripted runs. --ble-pin already feeds the first attempt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A BLE companion that requires pairing accepts the link-layer connection but rejects
the GATT subscribe with "Insufficient Authentication", which bleak raised as a bare
BleakGATTProtocolError — escaping connect() as an unhandled traceback.
Translate that (and related "Insufficient Encryption"/"not paired" rejections) into
a clean DeviceCommandError that names the fix: pass --ble-pin, and pair once in the
OS Bluetooth settings on Windows. It's classified as its own actionable case, never
as a dropped link, so the session offers a PIN rather than a pointless reconnect.
The startup probe now lets this actionable error through instead of collapsing every
failure to a bare "didn't answer", so the picker shows the real remedy in the splash.
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>
Reworks the startup device picker so it reads honestly at a glance:
- VENDOR is now just the hardware maker. A BLE advert rarely carries one
(confirmed against real hardware: the companion advertises no
manufacturer_data), so it stays blank instead of mislabelling the
transport "Bluetooth" as a vendor.
- A new TYPE column marks the transport with an icon: a plug emoji for
wired serial, and the Bjarkan rune the Bluetooth logo is drawn from,
rendered white on Bluetooth blue and flanked by half-block slivers so
it reads as a slim rounded badge.
- Every confirmed companion (not just the most-recent one) is shown by
the mesh node name learned at connect time, so a known COM11 reads as
its node name rather than the OS's generic "USB Serial Device".
- Confirmed companions sort to the top, most-recently-used first, and
wear their name in white so they stand out from merely-detected 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>
Keep the Name/Heard/Pkts/Key labels pinned to the top row once the rows
scroll past them, and break metric ties by name ascending so equal-heard
nodes no longer flip between sort directions. Mute the packet count to
match the Heard column, and drop placeholder dashes to a darker gray so
they recede below the real values. Always list every node type in the
legend instead of only those currently present.
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>
Watch the companion link while the menu runs; if it disconnects, float a
centered dialog with a spinner and a single Abort button while a
background task retries the connection. The dialog dismisses itself the
moment the device returns, so the user's only action is to give up:
Enter aborts, and Esc is deliberately inert so a stray keypress can't
drop the session while a replug may be a second away.
Give every popup dialog the same cyan reverse-highlight button. This
adds a "selected" theme style (and "err.reverse"): Rich silently renders
a style string that mixes a theme name with an attribute ("reverse
brand") as plain text, so the reverse is baked into a single-name theme
entry instead. Center the button chips with manual padding rather than
Text(justify="center"), which strips a styled span's trailing whitespace
and left the label hugging the fill's left edge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The block-element fallback halved the vertical resolution and looked
poor, so remove the braille/block toggle entirely. Drops the `t` key,
the persisted map_block preference, and the quadrant-glyph rendering
path; the map now always draws 2x4 braille dots.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Buttons read Cancel then Quit, with Quit highlighted so Enter commits it
and Esc cancels. The question is orange, the selected button cyan, and the
idle button gray, so the current choice is unmistakable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The braille base map leans on glyphs a terminal font may lack; the terminal
draws each missing glyph as a double-width fallback, which shoves the row and
smears the panel's right padding and border. prompt_toolkit's differential
paint models those cells as single-width, so its picture of the screen diverges
from the terminal and it never rewrites the smear.
The previous fix forced a full-frame repaint every paint while in braille mode,
which the 1s header-refresh tick turned into a visible periodic flicker. Replace
it with a targeted scrub: after a move, force just the two right-edge columns
(padding + border) to repaint by poking a sentinel into pt's cached frame. The
map body redraws wholesale as it pans, so its interior self-heals; only the
static edge needed help. Block mode can't smear, so it scrubs nothing.
Also:
- Persist the braille/block mode (app_state.map_block) so the map reopens the
way it was left; braille stays the default.
- Blank the frame on exit (drop pt's cached frame once) so the menu drawn
underneath doesn't inherit leftover braille garbage.
- Reword the t hint to "toggle braille/block".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make sticky headers a reusable Screen feature and lift paging and
section jumps onto the base Screen, so every scrollable surface shares
one implementation: the frame records each render's body height and
viewport, and the shared helpers page by a screenful, clamp to the
content, and walk between the boundaries a screen records in
_sticky_headers (a select list's group dividers, the chat transcript's
day dividers).
Remap keys to match a good editor:
- PageUp/PageDown scroll by a screenful rather than a fixed step.
- Ctrl+Home/End reach the top/bottom (the live tail in chat).
- Ctrl+PageUp/PageDown jump to the current/previous and next section.
- In chat, Home/End and Ctrl+Left/Right act on the compose line; word
motion (start of current word, else previous/next) is added to the
shared line editor, so every text prompt gains it too.
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>
Wrapped message bodies now align their continuation lines under the body
of the first line instead of falling back to the timestamp gutter, via a
new render_hanging helper. And an @[Name] token primed by the reply flow
renders as a bare @Name in that sender's stable hue, so a mention reads
cleanly and is tied to the person it names.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the static ⏳ hourglass on an in-flight direct message with the
reusable tui.Spinner, animated for the whole wait-for-ack window:
- _delivery_glyph is now a method returning the spinner's current frame when
acked is None (✅/❌ once resolved); the _SENDING constant is gone.
- _spin_while wraps the awaited send and retry, running a background ticker
that advances the shared spinner and repaints, then cancels-and-awaits it
(same no-stray-task discipline as the startup splash). Only one send or
retry is ever in flight (both gated by _sending), so one spinner suffices.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework the startup device picker into a self-contained splash flow:
- probe_meshcore (renamed from smoke_test_meshcore) now leaves the confirmed
connection open and returns it, so AppContext.adopt_device reuses that live
radio instead of reopening (many boards reset on each serial open). Restore
the library's normal per-command timeout once the handshake succeeds.
- Extract the Braille animation into a reusable tui.Spinner; BusyScreen now
delegates to it.
- Pin the wordmark to a fixed vertical anchor so it never shifts as the box
swaps between the device list, spinner, and messages.
- Esc or a trailing Quit row leaves the splash and exits the program.
- Show the copyright only on the opening splash; retire it once a device is
chosen, even across a failed smoke test and re-pick.
- Disable prompt_toolkit's loop exception handler and route asyncio (and the
awaited spinner ticker) errors to the file log, so a stray background-task
error can't dump a traceback over the full-screen UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>