Skip to content

fix: review fixes for repeater admin, routing, privacy, robustness and small screens - #105

Open
berikobama wants to merge 77 commits into
cwill747:mainfrom
berikobama:review-fixes
Open

berikobama wants to merge 77 commits into
cwill747:mainfrom
berikobama:review-fixes

Conversation

@berikobama

@berikobama berikobama commented Oct 28, 2026 •

Copy link
Copy Markdown

Summary

The fixes from a code review of the app and from running it on a uConsole: one commit per fix, 77 commits, each with a message that explains the cause and the change, so the PR can be reviewed (and bisected, or partly reverted) commit by commit. Every commit passes ruff check + format, compileall and the test suite on its own; cz check passes for the whole range.

No dependency or packaging changes (openhop-core>=1.1.3, as on main). Two commits are small features that fixes depend on or that came out of field use; they are marked below.

Tested on: uConsole CM5 + HackerGadgets AIOv2 (SX1262), Debian 12 bookworm (libadwaita 1.2, GTK 4.8), openhop_core 1.1.3, on air in a live MeshCore network with a v1.16 repeater. Protocol behaviour was checked against the MeshCore firmware sources (v1.16.0 and v1.18.0). New tests cover each fix where it can be tested without a radio: 301 pass (1 skipped) at the tip.

Related issues

Open:

  • Refs Add repeater admin ability #74 (repeater admin): the existing Admin view did not work against openhop_core 1.1 and real repeaters; this fixes login, CLI, logout, permissions, reboot confirmation and the quick commands. The remaining management UI (status, neighbours, settings, access list) is a separate feature PR on top of this one.
  • Add error state for if meshtastic service is running #48 (error state when meshtasticd holds the radio) is not addressed here. a7022e9 only makes a failed connect release the GPIO lines and lets the retries run.

Follow-ups to closed issues (the issue's fix landed; these fix what was still wrong in the same area):

Behaviour changes worth a look

  • Adverts no longer set contact routes; routes come from PATH returns (and a zero-hop discover answer), DMs flood until then (c8d9438).
  • Peers are only created from adverts openhop_core verified, and another key can no longer take over a contact's name, nor a name made distinct from one (cc8aa1b, adb4711, 64bdeda); the peer list is bounded (75536ac).
  • Saved repeater passwords are bound to the repeater's public key instead of its name (069555d, v10 migration). Older builds cannot read the new repeater_passwords table, so going back after the upgrade loses saved passwords.
  • Logs no longer contain message text or positions (687fc6e); the identity seed, database and logs are created owner-only (4deab07).
  • DM and group threads get separate channel ids; a DM thread's id is the peer's public key, so it survives renames, and group names starting with dm: are refused (12e341b, 0148be6, 077fde7; existing rows are migrated, v9 and v11).
  • Message history is kept per channel instead of 500 across all channels (57c8512).
  • Radio: new EU/UK Narrow preset and MeshCore 1.16 preamble lengths; the sync word is passed explicitly (4fc3544, da28aac).
  • The AIOv2 preset now switches GPIO 16 instead of 27 (3e2c91c): existing installs using that preset power the radio, not the GPS.
  • The map view is stored as its own settings row (ui.map_view), not as a setting (601d6e2).
  • Path views (Peers, Messages, Analyzer) draw the hops from this device outwards; every path of two or more hops was drawn reversed (a5a507c).
  • New (feature) commits: "Discover Nearby" (zero-hop discover, so Admin can log in to a neighbour without waiting for its advert, 60b67d8) and a switch to stop downloading map tiles (c90bb53).
All 77 commits, by area

Repeater admin

  • 93ce68c fix(repeater): use the openhop_core 1.1 handler API for login, CLI and telemetry
  • d8eb6ff fix(repeater): make logout local instead of sending a CLI "logout"
  • 21f75e2 fix(ui): rebuild the admin repeater list whenever its repeaters change
  • 60b67d8 feat: discover nearby repeaters and ask them for their names
  • 1af9c4f fix(admin): show the logged-out state after a disconnect
  • 62ca82c fix(admin): enable the command line only for admin logins
  • 49a8331 fix(admin): ask before rebooting a repeater
  • b63190a fix(admin): say "still waiting" instead of ignoring requests while busy
  • e89a711 fix(admin): let Up/Down leave the command line when there is no history
  • dfd804d fix(admin): open the console after an admin login
  • 485fc3c fix(admin): replace the "telemetry" quick command, which repeaters don't have

Identity, security and privacy

  • 6133712 fix: use the Ed25519 identity key as the node's own public key
  • 4deab07 fix(security): keep the identity seed, database and logs owner-only
  • 687fc6e fix(logging): keep message text, positions and control characters out of logs
  • 3c75e58 fix(privacy): send the position in telemetry replies only when it is shared
  • 5cc2eb6 fix(privacy): leave anonymous requests to openhop_core's handler
  • d3e00d5 fix(privacy): rate-limit discovery answers and honour the node-type filter
  • cc8aa1b fix(security): build peers only from adverts openhop_core has verified
  • adb4711 fix(security): never let another key take over a contact's name
  • 077fde7 fix(channels): keep group channels out of the DM thread ids
  • 069555d fix(admin): bind a saved repeater password to the repeater's key
  • 64bdeda fix(peers): give a name collision a name nobody else holds
  • 0148be6 fix(messages): key DM threads by the peer's public key
  • 75536ac fix(security): bound the peer list
  • 8894882 fix(requests): skip a contact with an unusable key instead of dropping the request
  • fab3433 fix(requests): don't answer replayed requests or transit copies

Routing and messages

  • c8d9438 fix(routing): flood DMs until a route is known, and stop taking routes from adverts
  • d0bae8c fix(routing): don't restore routes from stored advert paths at startup
  • 12e341b fix: give DM threads their own channel ids
  • 48a00bd fix(messages): send without freezing the UI, retry DMs, and report delivery
  • d2ba4c1 fix(messages): stop dropping a repeated DM sent minutes later
  • 47b1d2c fix(messages): pair each decoded message with its own packet
  • 3fcf99e fix(messages): show the real hop count instead of "255 hops"
  • 59dafb0 fix(messages): show late CLI replies in Admin and credit room-post authors
  • 57c8512 fix(messages): keep message history per channel
  • aafcb9e fix(channels): remove a private channel's key with the channel, after warning
  • a29defc fix(messages): keep the newest message in view as messages arrive
  • 1b28109 fix(messages): scroll to a new message after the layout, not during it

Client robustness and storage

  • 62822e6 fix: deliver bursts of events to the UI without dropping the oldest
  • a93e4b5 fix(client): never leave a radio call waiting forever
  • a7022e9 fix(client): serialise connect/disconnect and clean up a failed connect
  • dacd2b5 fix(db): run schema upgrades in one transaction
  • 388e36f fix(client): keep processing a batch of events when one of them fails
  • d61624f fix(settings): fall back to the default for an unreadable settings row
  • 80d4be1 fix(client): close the database connection of a replaced session
  • bfd50f2 fix(analyzer): store each received packet once
  • c386a77 fix(client): record app-generated events once in the event history

Radio, GPS and hardware

  • 4fc3544 fix(radio): add the EU/UK Narrow preset and use MeshCore 1.16 preamble lengths
  • da28aac fix(radio): pass MeshCore's sync word to the radio explicitly
  • 3e2c91c fix(presets): power the AIOv2 LoRa module from GPIO 16, not the GPS rail
  • d6c2220 fix(gps): report a gpsd position only while there is a current fix
  • b1b60ca fix(gps): don't power the GPS rail off when the serial fallback stops

UI: small screens, libadwaita 1.2, keyboard

  • 4255193 fix(ui): show toast text literally on libadwaita 1.2
  • 918d39d fix(ui): make Export Logs work on GTK 4.8
  • f5bbc6f fix(ui): close Add Channel and Region Scope dialogs when Enter confirms them
  • 0e19dd3 fix(ui): keep the main window within a 1024x576 screen
  • e6fa249 fix(messages): keep a message's details within the screen
  • 360f5b3 fix(ui): keep the open channel and unread counts updating
  • c312982 fix(peers): keep details, focus and selection when the peer list refreshes
  • fd8e561 fix(ui): reach channel actions from the keyboard
  • aac8cd5 fix(ui): stop leaking a popover per channel right-click
  • a7c85be fix(ui): make node badges usable from the keyboard
  • 10f499d fix(ui): URL-encode the node name in the contact QR code
  • 1ca7fd4 fix(ui): stop network paths drawing over the public key
  • a5a507c fix(ui): draw a path from this device outwards, not backwards
  • bd7350b fix(analyzer): keep collecting packets while paused, and stop inventing signal values

Map and settings

  • e8cb316 fix(settings): allow a fixed position to be cleared or set to 0
  • 7ed9dd3 fix(map): update markers when peers move or are renamed
  • c90bb53 feat(map): add a switch to stop downloading map tiles
  • b9c2bae fix(map): show this device's fixed position on the map even when not shared
  • 601d6e2 fix(map): open where the map was left, or at this device, not on the USA

CLI

  • af4fc1e fix(cli): use the saved settings for listen, send and advert
  • 8d23f2a fix(cli): let import-channel take the key without the command line
  • c69342b fix(cli): let send reach the peers the app knows
  • dd741c2 fix(cli): end listen --duration on time even when nothing is received

Tests and logging

  • d61ab35 test: keep the test suite out of the real app state directories
  • cea4e6d fix(logging): keep the log in the state directory that XDG_STATE_HOME names

AI disclosure

This PR was written with an AI assistant (Claude, via Claude Code), working under my direction: the review, the code, the tests and the commit messages are AI-generated. I tested the result on my own uConsole hardware, on air. Please review it with that in mind.

🤖 Generated with Claude Code

https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt

berikobama and others added 30 commits October 27, 2026 21:34
…d telemetry

Repeater login, repeater CLI commands and telemetry requests called handler
methods that openhop_core 1.1 no longer has (set_login_callback,
set_command_response_callback) or used the old two-argument
set_response_callback(hash, callback). Every attempt raised AttributeError or
TypeError before anything was transmitted, so Admin and Request Telemetry could
never work against openhop_core >= 1.1.3 (the version pyproject requires).

Register waiters the way 1.1 matches replies: login and CLI by the target's
full public key, telemetry by (public key, reflected request tag). The login
password is now stored inside the try block, so a failed send no longer leaves
it in the handler's password table for the rest of the session.

The new tests pin the handler signatures, so a future API change fails loudly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MeshCore firmware has no logout command: the companion's CMD_LOGOUT only drops
the local connection state. The CLI "logout" sent here drew an "Unknown command"
reply from the repeater, which then showed up as a direct message from it.
Logout now only forgets the session locally, like the firmware companion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_public_key() returned LocalIdentity.get_shared_public_key(), the X25519
key used for ECDH, not the Ed25519 identity key that adverts, path hashes and
contacts use. As a result the QR code and Settings showed a key nobody can
message, discovery responses advertised that wrong key, the "me" path badge
prefix was wrong, and the "skip our own advert echoed by a repeater" check
never matched, so the node listed itself as a peer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Admin view refreshed its repeater list only on repeater_login,
repeater_logout, advert_received and contact_received events. Nothing emits
the last two: adverts heard on the air arrive as plain "packet" events, plus
mesh.contact.* and mesh.network.node_discovered. So a repeater that appeared
while the app was running never showed up in Admin until a restart (or the
user's own login), and could not be logged into.

Compare what the list shows (repeater names, keys, favourites, logged-in set)
after each event batch instead of enumerating event types, as the Peers view
already does with its snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add "Discover Nearby" to the advert menu. It sends MeshCore's zero-hop CONTROL
discover request (filter: repeaters, room servers, sensors; random tag) and
collects the answers for 12 s through openhop_core's ControlHandler tag
callback. Firmware repeaters answer with their node type, the SNR they heard
the request at, and their full public key; each answer becomes a peer with a
direct (zero-hop) route, so Admin login works without waiting for an advert.

A discover answer carries no name, so unknown nodes are asked with an
anonymous ANON_REQ_TYPE_OWNER request (no login needed; the reply is
"name\nowner"). Without an answer the node is listed as e.g.
"Repeater 13ab5c7e" until its next advert renames it in place; the rename keeps
the peer row, contact, login session, DM thread, saved password and favourite,
and never takes a name that belongs to another key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MeshcoreClient() and OpenHopCoreSession() open the default database through
XDG_STATE_HOME even when a test injects its own stores, and
tests/integration/test_settings_apply.py builds a real session via
update_settings(). Running the suite on a machine that also runs the app
therefore opened and migrated the user's live database (and seeded rows into
it), and anything that configured logging wrote into the live app.log.

An autouse fixture now points XDG_STATE_HOME, XDG_DATA_HOME, XDG_CONFIG_HOME
and the log directory at a per-test temp dir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… names

The log directory was hard-coded to ~/.local/state/meshcore-uconsole while the
database follows XDG_STATE_HOME, so with XDG_STATE_HOME set the log landed
somewhere else than the rest of the app's state, and every process (tests,
mock-mode runs, a second instance) shared and rotated the same app.log.
Resolve the log directory through paths.state_dir() when logging is set up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The identity seed (the node's private key) was written with the process umask,
typically 0644, and so were the database with its WAL/SHM files (channel
secrets, repeater admin passwords, message history), the logs and exported
logs. Any other local account or process could read them.

Set umask 077 at startup, create the seed with O_EXCL and mode 0600, create
the state directories 0700, and tighten files that earlier versions already
wrote 0644 when they are next opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… of logs

The file log runs at DEBUG and is bundled by Export Logs for bug reports, yet it
held private content:
- openhop_core's Dispatcher logs every decrypted direct message and channel
  message (private channels included) at INFO;
- GPS fixes and telemetry replies were logged with full coordinates;
- the click debug hook logged the text of any tapped container, i.e. message
  bubbles.

A PrivacyFilter on both handlers now withholds the text of decrypted messages
(keeping channel and sender), the GPS and telemetry lines no longer include
coordinates, and the debug hook only reads labels of buttons. The filter also
escapes control characters: names and messages come from the air, and a newline
or terminal escape sequence in them could forge log lines or drive the terminal
showing the log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…shared

Telemetry replies included the live GPS position (or the fixed position) even
with "Share position" off. allow_telemetry defaults to on, favourites-only to
off, the requester picks whether location is included, and anonymous requests
need no contact entry, so any node that had heard one advert could ask where
the device currently is.

Location is now part of a telemetry reply only when share_position is on, the
switch that already governs the position in adverts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app replaced openhop_core's ANON_REQ handler with one that treated
sub-type 0x03 as a telemetry request. In an anonymous request 0x03 is
ANON_REQ_TYPE_BASIC (remote clock), and anonymous senders are by definition
not contacts, so any node could fetch battery voltage and position without
being known, without openhop's rate limiter, and with replies that failed for
requests arriving over one or more hops (create_path_return without
path_len_encoded).

Drop the override. Telemetry stays available to contacts through the
authenticated REQ path; BASIC, OWNER and REGIONS go back to openhop_core's
firmware-compatible, rate-limited handler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ilter

Every CONTROL discover request was answered immediately, whatever node types it
asked for, with no limit. Anyone in radio range could make the handheld
transmit on demand: a presence and direction-finding beacon, battery drain,
and enough airtime to break the EU868 10 % duty cycle (each answer is about
half a second at SF8/62.5 kHz). A repeater's own discover.neighbors, which only
asks for repeaters, also got an answer it then discards.

Answer only requests whose filter includes chat nodes, at most 4 per 2 minutes
like firmware repeaters, after a short random delay so several answerers don't
collide, and keep a reference to the send task so it can't be garbage
collected mid-flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libadwaita before 1.4 always parses a toast title as Pango markup (the
use-markup property only arrived in 1.4), and Debian bookworm ships 1.2. Toasts
carry node names from the air (the Discover summary) and error texts, so a name
like "R&D <Hill>" blanked the toast with a Gtk warning, and markup in a name
could restyle it. All toasts now go through compat.new_toast(), which escapes
the title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings > Export Logs used Gtk.FileDialog, which only exists from GTK 4.10;
on Debian bookworm (GTK 4.8.3) the button raised AttributeError. Add
compat.choose_save_path(), which uses Gtk.FileDialog where available and
Gtk.FileChooserNative otherwise, and use it for the export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ms them

Pressing Enter in these dialogs calls compat.activate_dialog_response(). On
libadwaita < 1.5 that used Adw.MessageDialog.response(), which only emits the
response signal: the channel was added (or the scope set) but the dialog stayed
open, and a second Enter ran the action again. Close the dialog after the
response, without the extra "close" response that close() would emit, as a
click on the button does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the uConsole's 1024x576 logical screen the right edge of every page and the
header's status and Connect controls were cut off. Measured minimum window
sizes (mock data): 1164 px wide with a peer selected, 1046 px otherwise, and
706 px tall after opening packet details. Causes:
- the page Gtk.Stack is homogeneous, so every page took the size of the
  largest one;
- the Analyzer toolbar's filter toggles needed 1030 px in a plain Box;
- the Peers detail action buttons sat in one non-wrapping row.

Size the stack by the visible page, put the Analyzer filters in a horizontal
scroller, and let the Peers action buttons wrap. The largest minimum is now
962x418.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Messages view detected new messages by comparing the number returned by
list_messages_for_channel(limit=100) with the previous number. Once a channel
held 100 messages that number stayed 100, so new messages in the open channel
were never shown until the user switched channels. Unread counts of the other
channels were only refreshed when the number of channels changed, so they
stayed stale.

Track the id of the last shown message instead (rebuild when it leaves the
window) and refresh every channel's unread label on each update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The settings form read the fixed latitude/longitude as
`_parse_float(...) or current.latitude`, so entering 0 kept the old value, and
an empty field raised "cannot be empty" on save. Once entered, a fixed position
could not be removed, and it keeps going out in adverts and telemetry when
position sharing is on. An empty field now clears the position (0.0, the
app's "unset"), 0 is accepted, and values outside +-90/+-180 are rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng signal values

While paused the analyzer stopped advancing its event cursor. The event store
keeps a bounded window and since() returns at most 200 events, so packets that
arrived during a pause were skipped on resume (a reviewer saw 0 of 300 shown
after resuming). Pausing now only stops rendering; collected packets are drawn
when the stream resumes.

Missing RSSI/SNR were replaced with -112 dBm / -9.0 dB ("or" defaults), which
also turned a real SNR of 0.0 into -9.00 and made unmeasured packets look
measured. Missing values now show as n/a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Several layers each kept only the newest part of a burst:
- poll_events() processed every drained event but returned only the newest
  `limit` (100) of them to the UI, including the app's own buffered events;
- the event store's idle pump didn't reschedule after a full batch, so a
  backlog waited for the next event or the 30 s safety timer;
- the analyzer asked the store for at most 200 events per update;
- the client's event buffer was drained with extend() then clear(), so an
  event appended by a worker thread in between was lost.

Return everything that was drained, reschedule the pump after a full batch,
let the analyzer read the store's whole window, and drain the buffer with
deque.popleft(). A reviewer's 300-packet burst now arrives complete (it was
0 of 300, then 100, in earlier runs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_run_async() waited on future.result() without a timeout, and the only timeout
was an asyncio.wait_for() inside the event loop. disconnect() stops and closes
that loop with tasks still pending, so a caller in the middle of an operation
waited forever: Discover kept saying "still listening", Admin stayed busy and
Telemetry stayed on "Requesting..." until the app was restarted.

openhop_core also calls sys.exit() on GPIO errors. Raised inside a task, that
re-raises out of the loop and kills the event loop thread, with the same hang.

Pending tasks are now cancelled before the loop stops (callers get an error
right away), every wait has a deadline on the caller's side too, and
SystemExit inside a radio call becomes a RuntimeError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Send, Advert, Discover and the Admin actions connect implicitly from worker
  threads, and nothing serialised connect(): with autoconnect still running,
  pressing Advert could run a second session.start(), initialising the radio
  twice on the same GPIO lines and orphaning the first node task. connect()
  and disconnect() now share a lock.
- The 8 s connect timeout was shorter than session.start()'s own radio.begin()
  retry back-off (2 + 4 + 6 s), so the later retries never ran; it is now 30 s.
- A failed connect replaced the session without stopping it, so the next
  attempt found the GPIO lines still held. The failed session is now stopped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Python's sqlite3 runs DDL outside its implicit transactions, so every ALTER of
an upgrade committed on its own, while schema_version was bumped once at the
end. If the upgrade was interrupted (power loss on a battery handheld, or an
exception in a backfill), the next start re-ran the ALTERs against columns
that already existed and failed with "duplicate column name" every time,
leaving the app unable to start.

Wrap the whole upgrade, backfills and version bump in BEGIN IMMEDIATE ...
COMMIT (SQLite DDL is transactional) and roll back on any error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
poll_events() caught only OSError around storing packets, and nothing around
peer/message processing. A sqlite3.Error (database locked, disk full, I/O
error) is not an OSError, and a malformed field raised ValueError: either
escaped poll_events(), so the rest of the already-drained batch, including
received messages, was lost. When it happened in the 30 s safety pump, GLib
also removed that timer for good. Each event is now processed on its own and
failures are logged with their traceback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The headless commands built their radio config from built-in defaults plus
environment variables only: 910.525 MHz, SF7, CR 4/5, preamble 17, gpiochip 0
and the name "uconsole-node", while `doctor` and the GTK app use the saved
settings and all of them use the node's real identity key. On a configured EU
device `listen` heard nothing, and `advert` would have renamed the node
mesh-wide at a US frequency. They now load the saved settings with the
environment on top, as the app does; --node-name only overrides the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
disconnect() ends every repeater session, but the Admin panel kept showing
"Logged in (admin)" with the command line and Logout enabled; the next command
then silently reconnected and timed out after 20 s with no session. When the
repeater list's state changes (it includes the logged-in set), the selected
repeater's login controls are now refreshed too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MeshCore repeaters only run CLI commands from admin sessions (simple_repeater
checks isAdmin()), but the command entry, Send and quick-command buttons were
enabled for guest logins too, where every command silently timed out after
20 s. They are now enabled only when the login is an admin one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"reboot" was a one-click quick-command button between "get repeat" and "ver";
a trackball slip took a remote repeater offline. It now asks for confirmation
first, styled like the other destructive confirmations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Peers were created and updated from the raw "packet" event, which openhop_core
emits for every received frame before deduplication and before AdvertHandler
checks the advert's signature. A node in radio range could forge adverts
without owning any key and rewrite a contact's key, route, map position,
repeater flag or name, and any captured advert could be replayed later.

Take peers only from mesh.network.node_discovered, which openhop_core
publishes after the signature check, dedupe and own-advert filter (and from
the app's own discovery results). Ignore adverts whose signed timestamp is not
newer than the last one seen for that key. Since that event has no sender_id,
derive the peer id from the key prefix as packet events did, and decode the
hop list from its inbound path for display.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Peers and contacts are keyed by display name, and an advert with an existing
name replaced that peer's public key in memory, in the database and in the
contact book. Any node anywhere on the mesh could advertise "Alice" with its
own (validly signed) key: the user's DMs to Alice were then encrypted to the
impostor, the favourite (and so telemetry favourites-only) moved to the
impostor, and a repeater's saved admin password would be encrypted to it on
the next login.

A name now belongs to the first key seen with it; another key advertising the
same name becomes a separate peer, "Alice (9f3a1c)". ContactBook.add_contact()
no longer rebinds a name to a different key either. A verified advert with a
new name from a known key renames that peer in place (generalising the
discovery-placeholder rename), so a node that renames itself no longer appears
twice, loses its favourite, or keeps receiving DMs under its old name.

An end-to-end check through openhop_core's AdvertHandler confirms that after a
validly signed impostor advert the DM to "Alice" is readable by Alice only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
berikobama and others added 19 commits October 28, 2026 08:39
The command entry consumed every Up and Down key press, even with no command
history to move through, so keyboard users could not move focus out of it
with the arrow keys. The keys are now only consumed while they actually step
through the history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
After logging in as admin, the Status tab stayed in front, so the command
line was neither visible nor focused and the user had to switch tabs first.
An admin login now shows the Console tab with the command line focused (a
guest login stays on Status, since guests have no CLI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
The map refreshed its peer markers only when the number of located peers
changed, so a peer that moved stayed at its old position and a renamed peer
kept its old label as long as the count was unchanged. It now compares what
the markers show (name, position, repeater flag), and rebuilds a marker whose
label or style changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
GpsdProvider kept returning the last position after the fix was lost (gpsd
reporting mode < 2), after the gpsd connection dropped, and even after the
provider was stopped. That stale position went into adverts and telemetry (when
sharing is on) and onto the map as the device's current location. It now
returns a position only while it has a current fix, and forgets it on stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
When gpsd is not reachable, the app reads the GPS UART itself and switches the
GPS module's power on (GPIO 27 on the uConsole AIO). On disconnect it drove
that pin LOW and then GPIO.cleanup() floated it, powering the GPS off under
gpsd, other applications and the board's own power tools, which share the
rail. The fallback now leaves the rail as it is when it stops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
`meshcore-console import-channel --secret <hex>` required the private channel
key as an argument, where it ends up in the shell history and is readable in
/proc/<pid>/cmdline by every process. --secret is now optional: without it the
key is asked for at a hidden prompt, or read from stdin when piped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
Every OpenHopCoreSession opens its own SQLite connection (for openhop_core's
channel-secret lookups), and the client builds a new session on every
disconnect, failed connect and settings change while disconnected. The old
sessions' connections were never closed. Sessions now have close(), and the
client calls it on each session it replaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
Every received frame is emitted twice, as a "packet" and a "raw_packet" event,
and both were written to the packet history, each with its own commit on the
GTK thread. The analyzer only ever shows "packet" events, so half of the 1000
kept rows were unusable (about 200 of the 400 loaded at startup) and every
packet cost two database writes. Only "packet" events are stored now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
On connect the contact book was seeded with each peer's route rebuilt from
its stored last_path, which is the inbound path of an advert: the hops from
the advertiser to us, i.e. the reverse of a route a direct packet can follow,
and possibly days old. So after a restart, DMs to peers two or more hops
away were again sent along a backwards route (the earlier routing fix only
covered adverts heard while running). Seeded contacts now start with an
unknown route; DMs flood until openhop_core learns the route from a PATH
return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
`meshcore-console send --peer NAME` started a bare session whose contact book
was never filled, so every send failed with "Contact '<name>' not found". The
stored peers are now loaded into the contact book first, as the GTK client
does on connect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
Events the client generates itself were added to the history when queued and
again when poll_events() delivered them, so list_recent_events() returned each
of them twice. History is now recorded once, on delivery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
The listen loop only compared the elapsed time after an event arrived, so on a
quiet channel `meshcore-console listen --duration 180` never stopped. The
duration is now a deadline on the listen itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
…g the request

An incoming request is tried against every contact whose key starts with the
request's 1-byte source hash. The key conversion ran outside the per-contact
try, so one contact with an invalid stored key raised out of the handler and
the request went unanswered for every other contact sharing that hash byte.
The key handling is now part of the per-contact attempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
The app's REQ handler bypassed the rules openhop_core's own request handler
and firmware apply:
- there was no replay check, so a captured telemetry request replayed later
  was answered again (another transmission, and fresh battery/position for
  whoever replayed it); like firmware's per-client last_timestamp, a request
  not newer than the contact's last one is now ignored;
- it was registered without local_delivery, so direct packets still in
  transit (hops left on their path) reached it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
The map always fetched OpenStreetMap tiles over the internet. "Center on
device" loads the tiles around the live GPS position, so the tile server learns
the user's IP address and approximate location, and the on-disk tile cache
becomes a record of where the device has been. A new "Download Map Tiles"
setting (on by default, so nothing changes unless it is turned off) detaches
the online tile source; the map then shows markers only, with a note saying
why the map is blank.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
…n't have

The Admin console's "telemetry" quick button sent the CLI text "telemetry",
which no MeshCore repeater firmware implements (checked against v1.16 and
v1.18): the repeater answered "Unknown command". Telemetry is a binary request
(available from the Peers view). The button now sends "clock", which shows the
repeater's time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
PathVisualization is a FlowBox aligned START. GTK then gives it the width it
asks for at its allocated height, and at the one-row height its parent
reserves that is a single badge: the badges were stacked one per row inside
a one-row allocation, drawn over the sections below. In Peers that is the
public key; the message and packet details had the same overlap.

Let the FlowBox fill the width, so it wraps by the width it really has and
gets the height of the rows it draws, and keep each arrow+badge at its
natural size inside its cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
…shared

get_device_location() only fell back to the fixed position from Settings
when "share position" was on, so with sharing off and no GPS fix the map
had no device marker and "Center on device" found nothing, although the
position was entered. That setting decides what goes on air; send_advert()
and the telemetry reply already check it themselves, and nothing else
transmits get_device_location().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
The map always opened on the middle of the contiguous US at zoom 4 (mock
mode: San Francisco), whatever the device's position, and forgot where the
user left it.

Now it opens on, in order: the current GPS fix; the view it was left at;
this device's fixed position from Settings; the area covering the peers
with known positions; the whole world. While the user hasn't moved the map
since starting, the first GPS fix centres it once, so a moved van doesn't
keep opening where it parked last.

The view is saved a few seconds after the map stops moving (one write per
pan, not per frame) and when the window closes, as its own row in the
settings table: it is app state, not a setting, and update_settings() has
side effects (a SETTINGS_UPDATED event, a fresh session).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
berikobama and others added 4 commits October 29, 2026 12:19
The path of a packet holds its relays in the order they relayed it: each
repeater appends its hash, so the sender's side comes first and the
relay that this device heard comes last. The path views in Peers,
Messages and the Analyzer start the chain at this device but drew the
hops in list order, so every route of two or more hops was reversed:
the relay next to the sender was drawn next to "Me", and the one this
device actually hears was drawn next to the sender.

Seen on air: all 787 multi-hop packets the uConsole stored on
2026-10-28/29 end with the hash of the one repeater it hears directly,
which the views drew at the far end.

The chain now walks the hops backwards from this device. Hop counts and
everything else that reads paths were not affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
With a channel open at its newest message, every arriving message ended
up just below the bottom edge: the view stopped at the end of the
messages before it, as far short as the new bubble was tall (69 and 90 px
in the mock for a one- and a three-line message). The view still counted
as "at the bottom", so it never caught up and stayed one message behind;
on air it looked like chats only sometimes scrolled.

New bubbles change the scroll range only when the next frame lays them
out, and the scroll to the end ran from an idle callback, which can run
before that frame. The view now follows the range instead: whenever it
grows (or the view shrinks) while the view was at the bottom, the view
moves to the new end. Opening a channel starts at the bottom (or at the
first unread message, as before) even when the previous channel was
scrolled up.

Unchanged: a view scrolled up to read older messages stays where it is
when a message arrives, and shows the jump-to-latest button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
a29defc moved the view to the end from the adjustment's "changed"
signal. That signal arrives while the chat is being laid out, and a
position set there is lost: the adjustment said "at the end", but the
messages stayed where they were, so the newest one was still just below
the bottom edge.

The view now moves in an idle callback right after the layout, at a
priority above redraws: at the default idle priority it ran up to 1.7 s
after the layout in the mock, now right after that frame is drawn. The
callback checks again that the view should still follow, so it can no
longer override a jump to the first unread message: a channel with
unread messages opens there and follows new ones only once scrolled to
the end. An empty channel follows from its first message.

Checked in the mock by which messages are actually visible, three runs
each: after each of six arriving messages the newest is visible, a view
scrolled up stays put and shows the jump-to-latest button, and an unread
channel opens at its first unread message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
The details of a message grew with their content: for a message relayed
over six repeaters they were 358 px tall (route, signal, time, buttons),
and the chat panel then needed 566 px. On the uConsole's 576 px screen
the window grew past the bottom edge (834 px in the mock), so the
details' Close button and the message entry could not be reached.

The details now scroll inside at most 220 px; the message list keeps
the rest of the panel. Short details take only their own height, as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt

@cwill747 cwill747 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review of the locally cloned PR (tip e6fa249). Existing suite: 301 passed, 1 skipped. Reviewer regressions: 4 failed, 1 passed — each failure reproduces one of the findings below (tests in review-pr-105/test_adversarial.py). Full report in review-pr-105/REVIEW.md.

Comment thread src/meshcore_console/meshcore/client.py Resolved
Comment thread src/meshcore_console/meshcore/client.py Resolved
Comment thread src/meshcore_console/meshcore/client.py Resolved
Comment thread src/meshcore_console/meshcore/client.py Resolved
berikobama and others added 4 commits November 2, 2026 11:53
DM threads got their own ids ("dm:<name>"), but nothing kept a group from
taking one: importing a private channel named "dm:bob" created a group with
Bob's DM thread id, so the next "Message Bob" opened that group and the reply
went out with send_group_text to everyone holding its key. Bob's DMs filed
into it as well.

A group name in the DM namespace is now refused wherever a group can come
from: the channel secrets store (and with it import_channel, "+ Add Channel"
with or without a key, and radio_cli import-channel) and ensure_channel. What
was stored before is not used: secret rows under such a name are left out of
the channels handed to openhop_core, a stored channel whose kind does not
match its id is not loaded (both stay in the database), and a group message
naming one is dropped. send_message() takes DM or group from the id itself,
so a DM thread's id can never broadcast. import_channel's check for a DM of
the same name goes: since the DM namespace, a group name cannot hit one.

Found by cwill747's review of cwill747#105.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
Saved admin passwords were stored by peer name, and the Admin view
pre-fills the one saved for the selected repeater's name. A name only stays
with its key while the peer is known: the peer list evicts logged-out
repeaters when it is full, and once "Hill repeater" was evicted, any key
could advertise the name, own it, and get the old password pre-filled; the
next login sent it to that key.

The store is now keyed by the repeater's full public key, and the client
looks a password up through the key of the peer holding the name now, so a
password never follows a name to another key. A repeater that comes back
with its key has its password again, also under an alias name. A rename no
longer has to move the password.

The v10 migration rebuilds repeater_passwords keyed by public_key. Each
password moves to the key of the one peer of its name; one whose name no
peer (or more than one) has is dropped, as there is no telling whose it was.

Found by cwill747's review of cwill747#105.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
A second key advertising a name already owned gets a name of its own,
"Alice (333333)", but that name was not checked in turn: when a node had
already advertised "Alice (333333)" itself, the next "Alice" from a key
starting 333333 was filed into that node's peer, and its public key was
replaced while its peer id, favourite and name-keyed state stayed.

_claim_name now tries longer key prefixes, then the full key with a counter,
until a name is free or this key's own; a keyless legacy peer is taken over
only by a key that has no peer yet. Updating a peer from an advert of another
key, or renaming onto a name that is held, is refused instead of
overwriting.

Found by cwill747's review of cwill747#105.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
A DM thread's id was its peer's lowercased name ("dm:alice"). A rename
relabelled the thread but could not move its id, so after Alice advertised
herself as Bob the old thread showed "Bob" while Bob's next DM opened a
second thread "dm:bob": two conversations with one peer, the history split
between them. The same happened when a discovery placeholder learned its
real name. And "Bob" and "bob", two keys, shared one thread: the second one's
DMs showed up in the first one's conversation.

A thread is now "dm:<public key>". Incoming DMs carry the sender's key
(contact_pubkey), and ensure_dm_channel() takes the named peer's key, so a
rename only relabels the thread. A peer whose key is not known still gets a
name id.

The v11 migration moves each thread to the key of the one peer with its
peer_name and merges threads that land on one key, so a conversation split
by a rename is whole again. A thread of no single known key keeps its id.

Found by cwill747's review of cwill747#105.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
@cwill747

cwill747 commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Looks good, just want to run it through my uconsole before merging

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants