Skip to content

feat(admin): manage repeaters remotely: status, neighbours, settings and access list - #106

Draft
berikobama wants to merge 72 commits into
cwill747:mainfrom
berikobama:repeater-admin
Draft

berikobama wants to merge 72 commits into
cwill747:mainfrom
berikobama:repeater-admin

Conversation

@berikobama

Copy link
Copy Markdown

Draft: depends on #105. This branch is built on #105 (review fixes), so until that is merged GitHub shows its 69 commits here too. Only the last 3 commits are this PR; once #105 is merged I'll rebase and mark it ready.

Summary

Closes #74. Remote repeater management in the Admin view, like the phone app's: next to the existing login and console (which #105 makes work against real repeaters), the view gets tabs for what a repeater reports and the settings it accepts.

  • Overview: firmware version, owner info, battery, uptime, TX/RX airtime (and its share of uptime), noise floor, last RSSI/SNR, packet and duplicate counters. Admins also see the repeater clock and get Sync clock and Send advert.
  • Neighbours: who the repeater hears directly, named from the app's known peers, with SNR and last heard, sortable by either. Admins can remove an entry.
  • Settings (admin): grouped forms for identity and static location (with "Use my position" from this device's GPS, and an optional advert after applying so a new name or position goes out), adverts, repeating, timing, radio and access. Apply validates against the limits the firmware enforces, sends only what changed and shows each key's reply. Radio changes ask first (wrong parameters cut the repeater off until it is fixed on site). The admin password is changed with a typed-twice confirmation.
  • Access (admin): the access list, read-only. Entries are 6-byte key prefixes; changing permissions stays a console job (setperm).

Nothing polls: a tab fetches when it is first shown after a login, and on Refresh/Load.

How it works

  • Status, owner info, neighbours and access list are the binary requests a repeater answers after login (ef446ef). Replies come through openhop_core's binary response callback, matched to the request by its reflected tag, and are decoded with parse_binary_response(). The parsed-waiter path can't be used here: it guesses the reply type, and a neighbours page of 56 bytes or more would be read as a status reply and cut to 56 bytes. Neighbours are fetched page by page.
  • Requests and CLI commands now carry strictly increasing timestamps: a repeater drops a request whose timestamp isn't newer than the client's previous one, so two requests within one second lost the second.
  • Settings are get <key> / set <key> <value> CLI round trips (cd3ae14). The schema carries the limits of firmware v1.16-v1.18, so the form refuses what the repeater would refuse, with the reason. Keys a repeater's firmware doesn't know (??: key on v1.16, > unknown) are hidden rather than kept in a per-version table.
  • CLI replies carry no request id, so all requests, the console's included, share one queue of length one, and reading or applying settings stops at the first key without a usable reply instead of taking a late reply for the next key's answer.
  • Protocol details were checked against the MeshCore firmware sources (v1.16.0, v1.18.0): request layouts, reply formats, CLI keys and their limits.

Testing

  • On air against a MeshCore v1.16 repeater, from a uConsole CM5 + AIOv2 (SX1262).
  • 38 new unit tests (request/reply decoding through openhop_core's real response handler, settings parsing and validation, tab helpers); 339 pass at the tip, and each of the 3 commits passes ruff, compileall and the test suite on its own.
  • The window's minimum width is unchanged (941 px, measured with the mock service), so it still fits the uConsole's 1024 px wide screen.

AI disclosure

This PR was written with an AI assistant (Claude, via Claude Code), working under my direction: the code, the tests and the commit messages are AI-generated. I tested the result on my own uConsole hardware, on air, against my repeater. 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 30 commits October 28, 2026 08:30
…thors

The client ignored a direct message's txt_type:
- a repeater's CLI reply (TXT_TYPE_CLI_DATA) that arrived after its command
  timed out was filed as a DM from the repeater, so Admin output surfaced in
  a chat thread;
- a room server's post (TXT_TYPE_SIGNED_PLAIN) carries the author's 4-byte key
  prefix, but was shown as written by the room itself.

Late CLI replies now go to the Admin console of that repeater, marked
"(late reply)", and room posts show their author (the known peer whose key
starts with the prefix, else the prefix) in the room's thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
The database kept the newest 500 messages across all channels, so a busy
public channel (or anyone posting 500 times to it) deleted stored DMs and
private-channel history; the UI kept showing them until the next restart. The
in-memory message list was never trimmed at all. Both now keep the newest 500
messages per channel.

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

Removing a channel kept an imported (private) secret, by design since cwill747#81
because it cannot be re-derived. But the kept secret kept the channel
decrypting, the channel reappeared at the next start (_sync_channel_secrets_to_ui
rebuilds UI channels from secrets) and as soon as one of its messages arrived,
so a removed private channel was never actually gone.

Removing a channel now deletes its secret too. To keep cwill747#81's concern covered,
the confirmation dialog for a private channel says that the key is deleted and
will be needed again to rejoin (new MeshcoreService.is_private_channel()).

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

The Peers view rebuilds both lists whenever any peer's data changes, i.e. on
every advert anywhere in the mesh. Each rebuild re-rendered the details panel
(it jumped back to the top, an in-flight telemetry request lost its spinner
and the button re-armed, inviting duplicate requests), and with nothing
selected keyboard focus moved to another row and selected it. Clearing the
selection also left the old peer recorded as selected, so the next refresh
selected it again.

The rebuild now leaves the details panel alone (it only picks up the fresh
Peer object for the same selection), restores keyboard focus to the same row
once the new rows are mapped, never selects a row by itself, and clearing the
selection really clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
Setting a channel's region scope and removing a channel were only offered on a
right-click of the channel row, so they could not be reached from the
keyboard (and needed a secondary button on the uConsole's trackball). A
"Channel actions" button in the thread header now opens the same actions for
the open channel; the right-click menu stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
Every right-click on a channel row created a new popover parented to the row
and never removed it, so rows collected popovers for as long as the app ran
(a reviewer counted 3 after 3 right-clicks). The popover is now unparented
once it closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
Route displays show each hop as a 2-character node badge, whose name was
only reachable through a hover tooltip or a mouse click: the badge could not
take keyboard focus. Badges are now focusable (with the focus outline other
controls use); Enter or Space opens the node details, Shift+Enter goes to the
peer like the right-click does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
Admin runs one request at a time and a CLI command can take 20 s, but while
one was pending Login, Logout, quick commands and Enter in the command line
did nothing at all. They now print a "still waiting for the previous request"
note in the console.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
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
Add the data side of remote repeater management, using the binary requests a
MeshCore repeater answers after login:
- get_repeater_status(): battery, uptime, TX/RX airtime, noise floor, last
  RSSI/SNR, packet and duplicate counters (REQ_TYPE_GET_STATUS);
- get_repeater_owner_info(): firmware version, name, owner info;
- get_repeater_neighbours(): every neighbour with key prefix, last heard and
  SNR, fetched page by page and named from the known peers;
- get_repeater_access_list(): key prefixes and roles (admin login only).

Replies are taken from openhop_core's binary response callback, routed to the
pending request by its reflected tag, and decoded with openhop_core's
parse_binary_response(). The parsed-waiter path can't be used for these:
openhop_core guesses a reply's type, and a neighbours page of 56 bytes or more
would be read as a status reply and cut to 56 bytes.

Repeater requests and CLI commands now carry strictly increasing timestamps:
a repeater drops a request whose timestamp is not newer than the client's
previous one, so two requests within the same second lost the second.

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

A repeater has no request that returns all its settings: each one is a
"get <key>" / "set <key> <value>" CLI round trip. repeater_settings holds the
pure part of a settings form:
- the schema: name, static lat/lon, owner info, advert intervals, repeat,
  flood hop limits, loop detection, path hash size, timing factors, radio
  parameters, TX power, read-only logins and the guest password, grouped,
  with the limits firmware v1.16-v1.18 enforces (so the form refuses what
  the repeater would refuse, with the reason);
- reply parsing: "> value", unknown keys ("??: key" on v1.16, "> unknown"),
  and set results ("OK", "OK - reboot to apply", "Error...");
- read_settings()/apply_settings(), which stop at the first command
  without a usable reply: CLI replies carry no request id, so a reply that
  arrives after its timeout would be taken for the next key's answer.

The mock repeater answers get/set from a small settings table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt
The Admin view gets the tabs the phone app has, next to the console:
- Overview: firmware, owner info, battery, uptime, TX/RX airtime (and its
  share of uptime), noise floor, last RSSI/SNR, packet and duplicate
  counters; for admins also the repeater clock, Sync clock and Send advert.
- Neighbours: who the repeater hears directly, named from known peers, with
  SNR and last heard, sortable by either; admins can remove an entry.
- Settings (admin): grouped forms for identity and static location (with
  "Use my position" from this device's GPS, and an advert after applying so
  the new name or position goes out), adverts, repeating, timing, radio and
  access. A group loads on demand; Apply validates against the firmware's
  limits, sends only what changed and shows each key's reply. Keys the
  repeater's firmware doesn't know are hidden. Radio changes ask first:
  wrong parameters cut the repeater off until fixed on site. The admin
  password is changed with a typed-twice confirmation; a saved copy of
  the old one is removed.
- Access (admin): the access list, read-only (entries are 6-byte key
  prefixes; changing them stays a console job).

Nothing polls. A tab fetches when it is first shown after a login and on
Refresh/Load. All requests, the console's included, share one queue of
length one, as CLI replies carry no request id; a tab refused because a
request was running loads when it finishes.

After a login the view now stays on the current tab, which loads, instead
of switching to the console. A repeater renamed from Settings stays
selected.

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

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.

Add repeater admin ability

1 participant