Commit Graph
59 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 4.8 77390860da Handle PIN-protected Bluetooth companions gracefully
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>
2026-07-09 19:26:31 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 d4fa056526 Identify companion hardware by its firmware model
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>
2026-07-09 19:17:43 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 6d51b4e2ab Add a TYPE column and name known devices on the picker
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>
2026-07-09 18:59:54 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 7850587e2a Add Bluetooth LE companion support alongside serial
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>
2026-07-09 18:44:39 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 6decc11c65 Restyle the info page to match the nodes list
Give the device-config table the same frameless SIMPLE_HEAD look as the
nodes list: left-justified accent title, muted header rule, no borders.
Indent each setting under its section heading, drop the redundant
(key) suffixes, and tighten the descriptions (no trailing periods).
Collapse the adv_loc_policy labels to off/on so the value cell no longer
duplicates the description.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:37:10 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 8e59f41886 Recolour the wordmark with the node-list heat gradient
Regenerate logo.ansi from logo.txt so the block rows fade white (top) to
red (bottom) along the node list's recency ramp, with the box-drawing
shadow lines in a dark slate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:02:08 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 030c333450 Refine the node table: pinned header, steady sort, legend, and colours
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>
2026-07-09 02:55:20 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 6cfe435d55 Redesign the nodes page with heat-map recency and live sorting
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>
2026-07-09 02:38:28 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 76202ec0d1 Hide the devices inventory from the menu, keep it on the CLI
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>
2026-07-09 00:23:26 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 dd097e48e5 Prompt to reconnect or abort when the device drops mid-session
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>
2026-07-09 00:07:45 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 b02e3b9f4b Always render the map in braille, dropping block mode
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>
2026-07-08 23:18:51 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 e0487b46e6 Confirm quitting with a centered Cancel/Quit dialog
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>
2026-07-08 22:41:05 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 bfae10b387 Repaint the map's edge instead of the whole frame, and remember the mode
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>
2026-07-08 22:20:09 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 ebac6c3a4f Confirm quitting with a vertical leave dialog
Choosing "quit" or pressing Esc at the top menu asked to leave through a
horizontal Yes/No toggle that sat at odds with the rest of the TUI, which is
select lists everywhere else. Replace it with a small select dialog in that
same style: "Quit and disconnect" is the highlighted default so Enter leaves,
while Esc — or choosing "Stay" — returns to the menu.

select() grows optional filterable/footer_hint passthroughs so this short,
fixed choice skips type-to-filter and shows a tailored "Enter quit · Esc stay"
hint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 21:45:27 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 6b84281d0b Render the map so any terminal font can show it
The braille base map and its labels lean on Unicode a terminal font may not
have: 8-dot braille (U+2840-28FF) that many fonts omit, and place/node names
in scripts or emoji outside a Latin monospace font. A missing glyph renders as
double-width tofu, which both looks wrong and shoves the panel right border.

- Prefer a romanized name (name:latin/name:en) over the local script, and strip
  labels to single-cell characters, so foreign names and emoji node names can no
  longer tofu or knock the grid out of alignment.
- Add a block-element base-map mode, toggled with t: 2x2 quadrant blocks instead
  of 2x4 braille - half the vertical resolution, but glyphs essentially every
  monospace font has. Braille stays the default.
- Zoom with PageUp/PageDown as well as +/-.
- While braille mode is active, force a full repaint each paint so the
  double-width-fallback corruption on the right border is scrubbed on every move.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 21:09:18 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 bdbe5cce30 Navigate the TUI like a text editor
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>
2026-07-08 20:30:06 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 8906e4ed53 Enliven the conversation list and keep section headings in view
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>
2026-07-08 20:03:31 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 aa537d5503 Force process exit if teardown wedges on quit
The app sometimes hung on exit. The device/async teardown is clean; the
culprit is prompt_toolkit's Windows input reader, which runs on a
non-daemon executor thread blocked in a Win32 wait. In a rare teardown
race that thread stays blocked and the interpreter's atexit thread joins
hang the process after cleanup is already done.

Arm a daemon watchdog once the user commits to quitting: if a clean exit
doesn't complete within a grace period it flushes and os._exit(0)s. A
daemon thread still runs while the main thread is stuck in that join, so
it always terminates. The happy path is untouched (the process is gone
before the timer fires) and committed DB writes are durable regardless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:35:25 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 5b7fe854cb Hang wrapped chat lines and color @mentions
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>
2026-07-08 19:03:33 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 fb3c7a15e2 Spin the delivery glyph while a direct message awaits its ack
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>
2026-07-08 18:32:29 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 101b48bc71 Adopt the probed device and polish the startup splash
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>
2026-07-08 18:09:27 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 b692cce463 Confirm devices by smoke test on the startup splash
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>
2026-07-08 17:52:30 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 e52e5daf87 Draw the splash wordmark from a colored logo.ansi file
Replace the baked-in block-glyph constant with a pre-coloured logo.ansi read
fresh on every render, so the wordmark can be re-styled by editing that file
alone. It uses the ANSI Shadow face with green letters (#4ade80) and a
single-line dark-green bevel (#166534).

The banner path in compose_startup now centers already-coloured ANSI rows (as
one left-aligned block) rather than force-styling them a single colour, and a
new muted, centered footnote sits below the box — the splash uses it for a
"© <year> Homestead" copyright notice. The plain-text source is kept as
logo.txt for regenerating the art, and the old ansilogo.txt is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:32:38 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 b66d1f7626 Redraw the startup device picker as a centered splash
The companion-device picker no longer borrows the in-menu frame. It is now a
chromeless splash: the MeshTerm wordmark, centered, above a box sized to the
device list rather than stretched across the terminal, with no header/footer
status bars.

A new `chrome` flag on Screen routes a base screen to `frame.compose_startup`,
which draws the banner and centers a content-sized panel. The device rows are
laid out in aligned columns (name / port / vendor / tag) under a muted header,
and type-to-filter is disabled (new `filterable` flag on SelectScreen) since the
list is short and fixed. The no-devices case gets the same splash treatment.

The wordmark lives in a code constant (meshterm/ui/logo.py, generated from
ansilogo.txt) so it survives installation regardless of packaged data files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:19:47 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 97557fc9b8 Frame the map's dense node core by default, tunable with --fraction
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>
2026-07-07 22:03:26 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 61f4eff8dc Drop the Skip row from the device picker
The picker now lists only real discovered devices. Cancelling with Esc
still falls back to --mock / configure-later, so no behavior is lost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:13:57 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 0c0880b37d Pick the companion device at startup only
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>
2026-07-07 20:36:51 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 7dbe4373be Store config and data under ~/.meshterm
Move the default config directory from ~/.config/meshterm (with
XDG_CONFIG_HOME support) to a plain ~/.meshterm dotfolder under the
OS-defined home directory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:08:06 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 2ec18138a7 Rename project from MeshTools to MeshTerm
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>
2026-07-07 19:40:49 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 850a1b3f34 Remember the map viewport across sessions
The interactive map now reopens on the centre and zoom the user left it
at, instead of always refitting to the nodes. Adds a generic app_state
key/value table (schema v6) and typed repository accessors; MapScreen
restores a saved view on open and persists every pan/zoom. Pressing r
still refits to the nodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:12:40 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 a9091006eb Brighten map markers where nodes pile on one cell
A braille cell shows a single glyph, so nodes landing on the same spot
hid the crowd behind one marker. Count the nodes per cell and wash the
surviving glyph toward white on a log2 ramp, so a bright marker reads as
a busy cluster. Shape still encodes node type; only brightness encodes
count. A lone node keeps its exact base colour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:41:24 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 18368d6ca1 Resolve inbound channel identity from the live slot, not a stale cache
Channel messages carry only a slot index on the wire, which the firmware
assigns from the channel's current position. The old idx->identity cache
was trusted on a hit, so a slot reorder or re-key (in-app, via the CLI/
config tool, or on another client) left the cache stale and misfiled
later messages under the wrong channel.

channel_id_for now reads the occupying slot fresh from the device on
every call, so identity always reflects who is actually in the slot right
now; _channel_ids is demoted to a priming/backfill warm map and a
read-failure fallback. Inbound recording moves onto a single serialized
worker draining an asyncio.Queue, so channel messages still persist in
arrival order despite each needing an async device read, and a slow read
can no longer stall the event pump.

Second root cause, same symptom: a Public message could still land in
another channel (e.g. #bots) even after the fresh-read fix. The meshcore
library's get_channel waits for "the next CHANNEL_INFO event" with no
correlation to the slot it asked for, and the dispatcher fans that event
to every in-flight waiter. Two concurrent get_channel calls therefore both
resolve on whichever response arrives first, and one caller silently
receives the other's channel — which fired whenever the chat worker's read
raced any other channel read (conversation-list builder, channels screen,
refresh_channels). Fixed in the device wrapper: a per-device asyncio.Lock
serializes channel reads so at most one is outstanding and the response is
unambiguously ours, plus get_channel now validates the response's
channel_idx matches the requested slot and raises rather than handing back
foreign channel data (the chat layer then falls back to the last-known
identity instead of misrouting).

Adds tests for filing by the current slot occupant after an out-of-band
reorder, for arrival-order preservation across a burst, and for the
serialized/index-validated channel read (no cross-talk, mismatch rejected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:14:31 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 8c1c9959e3 Plot contacts on the map and add fine cell-by-cell panning
Drive map markers from the device's contact list, the authoritative
source for a node's name, type, and advertised position, enriching each
with reception detail from passive-monitor observations when overheard.
Contacts advertising 0.0/0.0 (null island) are treated as unlocated.

On crowded maps, draw every marker glyph but place labels highest-
priority first (self, then repeaters, then leaf nodes), dropping labels
that would collide so important ones win the space. Labels now also keep
a one-row vertical gap so dense areas don't silt into a block of text.

Add Shift+arrow / uppercase WASD to pan by a single character cell for
fine positioning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:52:05 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 d652a4e012 Add an interactive braille OpenStreetMap map of mesh nodes
New `map` tool (menu + `meshtools map` CLI) that plots the mesh's
location-sharing nodes over a real OSM street basemap rendered as Unicode
braille — streets, rivers, water, and place names — the mapscii idea, but
able to plot our own nodes and live inside the TUI.

- core/mvt.py: dependency-free MVT/protobuf vector-tile decoder.
- core/geo.py: Web Mercator + a pannable/zoomable Viewport and tile math.
- services/basemap.py: OpenFreeMap vector tiles (free, no API key), resolved
  from the versioned TileJSON, disk-cached, offline-safe (stdlib urllib only).
- ui/mapcanvas.py: colored braille canvas (per-cell priority, line raster,
  polygon fill, collision-avoided labels), emits truecolor ANSI.
- ui/map_render.py: compositor — basemap styling + node markers with names.
- ui/map_screen.py: full-screen screen; wasd/arrows pan, =/- zoom, r reset,
  Esc/q exit; background tile loading. Offline falls back to nodes on a grid.
- tools/map.py: interactive in the menu, one-shot static render on the CLI.

Repeaters are prioritised over leaf nodes via advert type: threaded `node_type`
through Observation/HeardNode and the persistence layer (schema v4->v5, with an
idempotent migration adding observations.node_type). Added
TuiSession.base_body_size() so a full-screen screen can size itself to the frame.

Tests: tests/test_map.py (24) cover MVT decode against a real cached tile
fixture, projection, the canvas, the compositor, offline behavior, and the
interactive pan/zoom/reset logic. Full suite: 261 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:38:39 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 a03dedb09c Add message delivery retry, channel capacity discovery, and a compose byte budget
Three interrelated improvements to the chat/channel experience:

Outgoing byte budget & unified transcript (ui/chat.py, ui/tui/prompt.py):
- Cap outgoing messages at MeshCore's UTF-8 limits (150B direct, 130B
  channel) and block over-limit sends, keeping the buffer intact to trim.
- Show a live `used/limit` gauge in the compose bar whose used count steps
  green→yellow→orange→red by bytes remaining, with truer yellow/orange hues.
- Color the overflow characters red in the compose line (multibyte-safe).
- Render direct chats with the same grouped, per-sender layout as channels
  (headers, day dividers) instead of one line per message.

Direct-message delivery retry (services/chat_service.py, persistence,
core/models.py, tools/chat.py, ui/tui/session.py):
- Track outbound acked state (None awaiting / True / False) with a persisted
  row_id, add resend_direct + update_chat_ack, and wire Ctrl-R to retry the
  latest unacknowledged message in place with ⏳/✅/❌ delivery glyphs.

Channel slot capacity discovery (core/channels.py, core/connection.py,
ui/channels.py, tools):
- Discover the firmware's real channel-slot count via a bounded read-only
  probe (Device.channel_capacity) instead of assuming 8; show used/total in
  the manager and refresh the chat slot→identity cache after any mutation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:12:25 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 61101cf778 Align emoji cell width with the terminal's real rendering
Rich counts a VS16 emoji like the sun as 2 columns (Unicode TR51), but
many terminals paint it as 1, so padded content and the panel border that
follows it landed a column early.

Add a startup calibration that decides the VS16 width in order of trust:
an env override, a fingerprint of split-renderer terminals (VS Code,
Windows Terminal) whose PTY cursor accounting disagrees with what they
paint, then a live cursor probe where that is valid. When the width is 1,
patch Rich's string measurement so every panel, table, and chat bubble
lines up while genuine wide emoji stay 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:47:10 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 4c725bd22a Key channel history by identity and mark channel openness in chat
Store channel messages under a slot-independent channel identity (sha256 of
the effective secret) instead of the raw slot index, so reordering or re-keying
slots no longer scrambles history. DB schema v3->v4 adds a channel_id column
with an idempotent migration and a lazy backfill that maps legacy rows to the
channel currently in each slot.

Recognize the firmware's fixed-key default "Public" channel as public: add
DEFAULT_PUBLIC_SECRET / KNOWN_PUBLIC_SECRETS and split is_name_derived (key
reproducible from the name, so it can be re-derived on write) from
is_public_channel (shared meshwide). The default channel no longer mislabels
itself as private, and its well-known key is preserved on edit/reorder.

Lead each chat picker row with a glyph: the channel's openness marker
(# name-derived, globe fixed-key public, lock private) or, for a direct chat,
a speech balloon once we've exchanged messages and a person before. A shared
channel_glyph() keeps the channel manager and the picker in sync.

Round out the messaging UX: live callable row titles and plain-text filtering
in the TUI select screen, plus assorted device-picker, monitor, route-map, and
config-editor menu refinements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:31:44 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 b289565d96 Sharpen device discovery: node names, tiered confidence, no double port
Improve the device picker and inventory in three ways:

- Remember a device's mesh node name. On a successful connect, learn the
  radio's own name (adv_name/name, best-effort) and persist it with the
  remembered device; the picker leads its row with it and the devices table
  shows it. A failed probe never blocks the connection and preserves any
  name already on file.
- Qualify devices in tiers instead of one over-generous "likely LoRa" flag.
  A native-USB dev board is a strong "LoRa device"; a bare USB-UART bridge
  (CP210x/CH340/FTDI) is only a "serial adapter" / "maybe", since the same
  chip shows up on plenty of non-LoRa hardware. Sorting and the table follow
  the board > bridge > unknown ranking; JSON gains a `confidence` field.
- Stop repeating the COM port. Windows descriptions often already end with
  "(COM11)", so the label no longer renders "… (COM11) (COM11)".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:15:33 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 7330796829 Add a first-class channel manager with QR sharing and reordering
Replace the primitive channel editor that lived inside the config menu with
a dedicated `channels` tool: list the device's slots and, per channel, show a
scannable QR share code, reveal the key, open it in chat, rename/re-key, or
clear it. Create channels four ways — new private (random key), public `#`
(key derived from the name), join-by-key, or import a `meshcore://` link — with
every change written to the device immediately.

- core/channels.py: pure, hardware-free logic (secret derivation, tolerant key
  parsing, share-URL round-tripping, channel hash). full_channel_hash exposes
  the complete digest; the short channel_hash is its leading byte.
- ui/qr.py: half-block QR rendering (segno), drawn black-on-white so it scans
  regardless of terminal theme.
- ui/channels.py: the interactive manager. Channels display by their real name;
  reorder rows by dragging with the arrow keys; the key view shows the full hash
  with the first byte highlighted.
- ui/tui: a ReorderScreen (grab/move/drop) plus a ctx.ui.reorder surface method.
- tools/channels.py: the tool + `channels list/add/join/import/share` CLI.
- Pin Chat + Channels to the top of the main menu via an explicit category order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:23:47 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 6856b682b1 Add channel/direct chat feature and polish the passive-monitor UX
Introduce channel and direct messaging: an always-on ChatService that
records inbound messages to history, a live full-screen chat screen, and
a chat tool (interactive picker plus send/history/list/listen CLI). This
bumps the DB schema to v3 and adds the supporting models, repository
queries, and connection wiring.

Also polish the monitor/menu UX:
- Stop console log handlers from corrupting the full-screen TUI by
  detaching the RichHandler for the life of the session (file logging
  continues); most visible when toggling the monitor.
- Colour only the header's monitor glyph (green ● on, red ○ off).
- Crop the header to a single line instead of wrapping on narrow terminals.
- Drop the redundant toggle-confirmation window so toggling returns
  straight to the main menu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:41:12 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 20c32f9642 Introduce an always-on event hub and broaden the device event surface
Decouple listening from consuming as the first step toward a full MeshCore
client. A new always-on EventHub owns the single device subscription and fans
typed MeshEvents out to any number of subscribers; passive-monitor history
logging becomes just one subscriber, so listening is now unconditional while the
on/off toggle controls only persistence.

- core/events.py: MeshEvent envelope + EventKind (OBSERVATION/MESSAGE/ACK)
- services/event_hub.py: subscribe/stream/wait_for fan-out, always-on pump
- connection.py: Device.subscribe_observations -> unified subscribe_events
  emitting observations, inbound messages, and acks; add message_from_event /
  ack_from_event mappers and a simulated message stream on MockDevice
- core/models.py: Message and Ack domain models
- monitor_service.py: slim to a hub subscriber (recording, not listening)
- context.py/menu.py: wire ctx.events into the session lifecycle
- config.py: connect_on_start setting (default on) to allow lazy connect
- Remove monitor --seconds / foreground capture and Device.listen; monitoring
  is passive/background only

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:20:24 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 2a9facc56e Replace questionary with a full-screen prompt_toolkit TUI
Introduce a UI surface abstraction (ctx.ui) that tools use for all input
and output instead of touching questionary or the console directly. Two
backends implement it: PlainUi for scripted CLI runs (prints immediately,
byte-for-byte unchanged) and TuiUi for the interactive menu (bounded,
scrollable result windows over a full-screen TuiSession).

The new meshtools/ui/tui package reimplements the slice of questionary the
app used — validated text input, confirm, autocomplete, and selectable
lists — plus framing, progress, and screen management. Swap the dependency
from questionary to prompt_toolkit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:09:18 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 2cd0fce032 Split the node/contact list out of info into its own nodes tool
The info tool showed both the device config and the node/contact list. Move
the list into a dedicated nodes tool (Device category, right after info) so
each feature does one thing; info now shows identity and radio config only.

- nodes tool renders this node + contacts with the path-hash prefix
  highlighted, reading self-info + contacts + path-hash mode directly.
- info drops the contacts read and nodes_table render (and its contacts
  summary field); the nodes_table widget and its test are unchanged.
- README: add a Node list feature row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:49:58 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 1036ff52f6 Make passive monitor a non-blocking background toggle with live counts
The monitor was a blocking listen window that monopolized the app. Convert
it to an always-on background subscription that logs overheard adverts/
telemetry while the user keeps using the menu.

- Device: add subscribe_observations() as the non-blocking capture primitive;
  listen() is now a thin foreground wrapper over it. Mock streams a live
  packet burst + cadence instead of a bounded synth loop.
- MonitorService (ctx.monitor): owns the subscription, session/total counters,
  and the on/off preference; logs each packet as it arrives.
- MonitorStore: persist on/off in <config_dir>/monitor.json, default off,
  remembered between sessions and auto-resumed at launch.
- Menu: the monitor entry is now a toggle/heard-nodes control panel; the
  header shows live session/total counts and repaints ~1s via refresh_interval.
- CLI monitor gains --on/--off (preference) and keeps --seconds for a scripted
  foreground capture.
- Add Repository.observation_count(); README + tests (129 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:35:43 -04:00
Jean-Pierre MartineauandClaude Opus 4.6 923c771817 Set permissions to dontAsk mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-21 19:25:03 -04:00
Jean-Pierre MartineauandClaude Opus 4.6 1add7abf92 Add route-map to README feature table
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-21 19:25:02 -04:00
Jean-Pierre MartineauandClaude Opus 4.6 fa585b333d Add Repository unit tests: hydration fidelity, ordering, limits, edge cases
19 tests covering the persistence layer: field round-trip for successful
and failed traces (hops, RTT, tx_power, timestamp), recent_traces
ordering/limit/target filtering, all_traces cross-target span,
traced_targets count-ordering, latest_trace success_only variants,
and full run lifecycle (start → finish, error without summary, limit,
ordering).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-21 19:09:28 -04:00
Jean-Pierre MartineauandClaude Opus 4.6 8d2320ba03 Harden route-stability: fix is_self detection bug, add coverage, tighten error handling
Fix: self_label was computed via _name(None, resolve) but edges already
resolve None→device_label, so the resolver could never rename the self
node. Use _name(device_label, resolve) instead.

Quality: replace assert on output_dir with graceful skip, validate
--samples requires --target, add _slug fallback for empty input, fix
Callable import (collections.abc).

Tests: 6 → 25 — adds three-way entropy, single-hop topology, median SNR,
link sorting, degree counting, resolver edge cases (returns None, renames
self), empty/mixed traces, multi-target graph, empty-graph render, output
dir creation, filename slug, SNR colour thresholds, Rich panel branches,
and the questionary validator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-21 18:11:41 -04:00
Jean-Pierre MartineauandClaude Opus 4.6 736fb4e762 Add route-stability tool: aggregate trace history into churn metrics and interactive graph
Introduces `route-map`, which turns stored traces into a directed mesh graph
with per-link traversal counts, median SNR, and route-churn metrics (stability
score, Shannon entropy, distinct-route count). Includes pyvis HTML renderer
and a refactored _hydrate_trace helper in repository.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-21 16:28:22 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 6405e7ec7b Add link-health regression detection against a rolling baseline
Splits a target's persisted trace history into a recent window and an older
baseline and flags end-to-end (bottleneck SNR, success rate) and per-hop link
SNR metrics that dropped beyond a threshold, with warn/critical severity.

Adds Repository.recent_traces / traced_targets, a pure link_quality service,
and a `link-health` tool that can analyze stored history or take fresh traces
first. Also tidies the repository import ordering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:57:31 -04:00