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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Adds a Device.listen capability (real + mock), an Observation/HeardNode
domain model, an observations table with Repository.record_observation /
heard_nodes aggregation, and a `monitor` tool that listens for a window and
logs every overheard advert/telemetry (SNR, RSSI, shared location) to the
database. This is the longitudinal substrate later features read back.
The real-hardware event-payload mapping is best-effort and, like the trace
mapping, flagged for validation against the firmware's event schema; the mock
device synthesizes plausible adverts so the tool and tests run without a radio.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Predict time-on-air, receiver sensitivity, max path loss, range, and
duty-cycle/dwell headroom for a LoRa radio config with no radio attached.
Radio params default to the connected device's config but every value is
overridable; a fully specified invocation never opens the serial port.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two correctness fixes for remote-admin TX optimization:
1. Reverse/round-trip measurement path. The optimizer traced a one-way path
ending at the (possibly far) target and waited for the target itself to
originate the trace reply, which never returns when the target is only
reachable via other repeaters, so every trace timed out. Now we trace out
to the target and back (the MeshCore A,B,A convention) so a reachable
near node answers; the target only forwards. Its received SNR is read from
its hop by hash (the round trip turn-around) rather than by position.
2. Restore original TX on no result. If not one trace gets through at any
level there is no meaningful winner, so leave the node at its original
power (applied=False) and warn, instead of writing a bogus value.
Mock updated to model each hop SNR from the node that transmitted into it and
to drop traces on the bottleneck hop. Added round-trip / target-hash tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Remove unused utcnow / HopEdge imports (F401).
- Assert DeviceCommandError instead of bare Exception in the admin-login test (B017).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tune the TX power of a remote node we have admin rights on (the hop before
a chosen target) to maximize the SNR the target receives from it, instead of
tuning the local device against a path bottleneck.
- Device gains admin_login / get_remote_tx_power / set_remote_tx_power
(MeshCoreDevice via send_login_sync + `set tx <dbm>` send_cmd; MockDevice
models target SNR as an inverted-U in the admin node's TX with a reliability
dropoff at the band edges).
- tx_optimizer: forced-path sweep with a lexicographic objective (reliability
first, then SNR), median-based levels, coarse+refine search, and a verify
pass; near-SNR ties break toward lower power. Outlier-robust by construction.
- AdminStore remembers per-node admin passwords in <config_dir>/admin.json
(owner-only perms), cleared automatically when a login is rejected.
- Configurable 12-28 dBi default range (tx_opt_min/tx_opt_max).
- tx-optimize tool: path/password prompts, live progress, persistence, applies
the winner on finish, and renders an interactive HTML chart.
- Rewrote TX result models, widgets, plot, repository.record_tx_sample, and the
TX tests for the new flow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Annotate our own device with its hash on the route line, the per-trace
From→To table, and the per-hop medians table, truncated to the command's
path-hash width like every other hop. Unify link rendering on
_route_node_text so all three reads consistently as name (hash).
In the info panel, replace the standalone node-hash line with a Nodes
table: our node listed first with its name highlighted, and every node's
full public key shown with its path-hash prefix highlighted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trace:
- Auto-path now ends every route at the target's own hash (verified on
hardware: a trace only replies when its destination is the final hop), so
direct neighbors and learned multi-hop routes both resolve correctly.
- Retry the transient "no event received during contacts retrieval" blip and
surface a clean DeviceCommandError instead of a raw traceback.
- Route summary annotates each node with its hash at the command's path-hash
width, wrapping after an arrow when the line is too long.
- Default trace count changed from 5 to 3.
Also includes the in-progress device-config feature (config_io, device_config,
config tool, config editor UI, tests) and related supporting edits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MeshTools is a Rich+Questionary interactive / Typer-scriptable toolkit for
tuning a MeshCore mesh over a serial companion device, with SQLite-logged runs
and Plotly visualizations. Includes the skeleton (trace, info, history),
TX-power optimization, and a MockDevice simulator (--mock).
Device discovery & selection:
- core/discovery.py: enumerate serial ports (pyserial), flag/sort likely LoRa
hardware by USB vendor id, stable_id for replug-safe identity.
- core/device_store.py: remember the last successfully connected device in
<config_dir>/devices.json (independent of --db).
- core/selection.py: non-interactive resolver (--port > profile > remembered >
sole device > guidance error) for the CLI.
- ui/device_picker.py + menu startup: interactive picker preselecting the
remembered default; banner reflects the choice.
- tools/devices.py: `meshtools devices` lists/marks attached devices.
- AppContext resolves the port and records last-known-good on connect success.
Fixes:
- theme.py: force UTF-8 on stdout/stderr so glyphs don't crash legacy consoles.
- connection.py: friendly message when meshcore is missing; pin meshcore>=2.3.
- trace/tx-optimize: fall back to free-text target entry when the device
reports no contacts (autocomplete requires non-empty choices).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>