Repository navigation
feat(admin): manage repeaters remotely: status, neighbours, settings and access list - #106
Draft
berikobama wants to merge 72 commits into
Draft
berikobama wants to merge 72 commits into
berikobama wants to merge 72 commits into
Conversation
…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>
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
setperm).Nothing polls: a tab fetches when it is first shown after a login, and on Refresh/Load.
How it works
ef446ef). Replies come through openhop_core's binary response callback, matched to the request by its reflected tag, and are decoded withparse_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.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 (??: keyon v1.16,> unknown) are hidden rather than kept in a per-version table.Testing
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