From 93ce68c292a5d99cb72ee38b039ab99b69962adb Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:34:52 +0100 Subject: [PATCH 01/72] fix(repeater): use the openhop_core 1.1 handler API for login, CLI and 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 --- src/meshcore_console/meshcore/operations.py | 24 +-- tests/unit/test_repeater_ops.py | 189 ++++++++++++++++++++ 2 files changed, 203 insertions(+), 10 deletions(-) create mode 100644 tests/unit/test_repeater_ops.py diff --git a/src/meshcore_console/meshcore/operations.py b/src/meshcore_console/meshcore/operations.py index 61d32ba..c63c169 100644 --- a/src/meshcore_console/meshcore/operations.py +++ b/src/meshcore_console/meshcore/operations.py @@ -96,7 +96,9 @@ async def request_telemetry( contact = _resolve_contact(node, contact_name) - pkt, _ts = PacketBuilder.create_telem_request( + # The request timestamp is the tag the responder reflects; openhop_core + # matches replies on (full public key, tag). + pkt, tag = PacketBuilder.create_telem_request( contact=contact, local_identity=node.identity, want_base=True, @@ -104,7 +106,7 @@ async def request_telemetry( want_environment=False, ) - contact_hash = bytes.fromhex(contact.public_key)[0] + pubkey = bytes.fromhex(contact.public_key) response_handler = node.dispatcher.protocol_response_handler response_event = asyncio.Event() @@ -116,7 +118,7 @@ def _on_response(success: bool, text: str, parsed: dict) -> None: response_data["parsed"] = parsed response_event.set() - response_handler.set_response_callback(contact_hash, _on_response) + response_handler.set_response_callback(pubkey, tag, _on_response) t0 = time.monotonic() try: await node.dispatcher.send_packet(pkt, wait_for_ack=False) @@ -124,7 +126,7 @@ def _on_response(success: bool, text: str, parsed: dict) -> None: except asyncio.TimeoutError: got_response = False finally: - response_handler.clear_response_callback(contact_hash) + response_handler.clear_response_callback(pubkey, tag) rtt = (time.monotonic() - t0) * 1000 @@ -166,8 +168,8 @@ async def send_login( contact = _resolve_contact(node, peer_name) login_handler = node.dispatcher.login_response_handler - dest_hash = bytes.fromhex(contact.public_key)[0] - login_handler.store_login_password(dest_hash, password) + pubkey = bytes.fromhex(contact.public_key) + dest_hash = pubkey[0] login_event = asyncio.Event() login_result: dict = {"success": False, "data": {}} @@ -177,8 +179,9 @@ def _on_login(success: bool, data: dict) -> None: login_result["data"] = data login_event.set() - login_handler.set_login_callback(_on_login) + login_handler.register_login_callback(pubkey, _on_login) try: + login_handler.store_login_password(dest_hash, password) pkt = PacketBuilder.create_login_packet( contact=contact, local_identity=node.identity, @@ -189,7 +192,7 @@ def _on_login(success: bool, data: dict) -> None: except asyncio.TimeoutError: return {"success": False, "reason": "Login response timeout"} finally: - login_handler.set_login_callback(None) + login_handler.remove_login_callback(pubkey, _on_login) login_handler.clear_login_password(dest_hash) data = login_result["data"] @@ -243,7 +246,8 @@ def _on_response(message_text: str, sender_contact: object) -> None: response_data["text"] = message_text response_event.set() - text_handler.set_command_response_callback(_on_response) + pubkey = bytes.fromhex(expected_key) + text_handler.register_command_response(pubkey, _on_response) try: pkt, _crc = PacketBuilder.create_text_message( contact=contact, @@ -268,7 +272,7 @@ def _on_response(message_text: str, sender_contact: object) -> None: "reason": "Command response timeout", } finally: - text_handler.set_command_response_callback(None) + text_handler.unregister_command_response(pubkey, _on_response) async def send_advert( diff --git a/tests/unit/test_repeater_ops.py b/tests/unit/test_repeater_ops.py new file mode 100644 index 0000000..588dbd3 --- /dev/null +++ b/tests/unit/test_repeater_ops.py @@ -0,0 +1,189 @@ +"""Repeater operations against openhop_core 1.1.3's handler API. + +Login, CLI and telemetry once called handler methods that openhop_core 1.1 +no longer has (set_login_callback, set_command_response_callback, a 2-arg +set_response_callback), so every one raised before transmitting. These tests +pin the real signatures and drive each operation through fakes that only offer +those methods. +""" + +from __future__ import annotations + +import asyncio +import inspect + +from openhop_core.node.handlers.login_response import LoginResponseHandler +from openhop_core.node.handlers.protocol_response import ProtocolResponseHandler +from openhop_core.node.handlers.text import TextMessageHandler +from openhop_core.protocol.identity import LocalIdentity + +from meshcore_console.meshcore.contact_book import Contact, ContactBook +from meshcore_console.meshcore.operations import ( + request_telemetry, + send_login, + send_repeater_command, +) + + +def params(fn) -> list[str]: + return list(inspect.signature(fn).parameters)[1:] # drop self + + +def test_handler_api_is_what_operations_calls() -> None: + assert params(ProtocolResponseHandler.set_response_callback) == [ + "contact_pubkey", + "request_tag", + "callback", + ] + assert params(ProtocolResponseHandler.clear_response_callback) == [ + "contact_pubkey", + "request_tag", + ] + assert params(LoginResponseHandler.register_login_callback) == ["pubkey", "callback"] + assert params(LoginResponseHandler.remove_login_callback) == ["pubkey", "callback"] + assert params(TextMessageHandler.register_command_response) == ["pubkey", "callback"] + assert params(TextMessageHandler.unregister_command_response) == ["pubkey", "callback"] + for gone in ("set_login_callback", "set_command_response_callback"): + assert not hasattr(LoginResponseHandler, gone) + assert not hasattr(TextMessageHandler, gone) + + +class FakeResponseHandler: + def __init__(self, parsed: dict | None) -> None: + self.parsed = parsed + self.waiters: dict[tuple[bytes, int], object] = {} + self.registered: list[tuple[bytes, int]] = [] + + def set_response_callback(self, contact_pubkey, request_tag, callback) -> None: + self.waiters[(bytes(contact_pubkey), request_tag)] = callback + self.registered.append((bytes(contact_pubkey), request_tag)) + + def clear_response_callback(self, contact_pubkey, request_tag) -> None: + self.waiters.pop((bytes(contact_pubkey), request_tag), None) + + def reply(self) -> None: + if self.parsed is not None: + for callback in list(self.waiters.values()): + callback(True, "reply", self.parsed) + + +class FakeLoginHandler: + def __init__(self, succeed: bool) -> None: + self.succeed = succeed + self.pending: dict[bytes, object] = {} + self.passwords: dict[int, str] = {} + + def register_login_callback(self, pubkey, callback) -> None: + self.pending[bytes(pubkey)] = callback + + def remove_login_callback(self, pubkey, callback) -> None: + if self.pending.get(bytes(pubkey)) is callback: + del self.pending[bytes(pubkey)] + + def store_login_password(self, dest_hash, password) -> None: + self.passwords[dest_hash] = password + + def clear_login_password(self, dest_hash) -> None: + self.passwords.pop(dest_hash, None) + + def reply(self) -> None: + if self.succeed: + for callback in list(self.pending.values()): + callback(True, {"is_admin": True, "keep_alive_interval": 0}) + + +class FakeTextHandler: + def __init__(self, contact: Contact) -> None: + self.contact = contact + self.pending: dict[bytes, object] = {} + + def register_command_response(self, pubkey, callback) -> None: + self.pending[bytes(pubkey)] = callback + + def unregister_command_response(self, pubkey, callback) -> None: + if self.pending.get(bytes(pubkey)) is callback: + del self.pending[bytes(pubkey)] + + def reply(self) -> None: + for callback in list(self.pending.values()): + callback("v1.18.0", self.contact) + + +class FakeDispatcher: + def __init__(self, handlers: list, fail: bool = False) -> None: + self.handlers = handlers + self.fail = fail + self.sent: list = [] + + async def send_packet(self, packet, wait_for_ack=False, **_kw) -> bool: + if self.fail: + raise OSError("radio gone") + self.sent.append(packet) + for handler in self.handlers: + handler.reply() + return True + + +class FakeNode: + def __init__(self, contact: Contact) -> None: + self.identity = LocalIdentity() + self.contacts = ContactBook() + self.contacts.add_contact(contact) + + +def repeater_contact() -> tuple[Contact, bytes]: + key = LocalIdentity().get_public_key() + return Contact(name="Bergblick", public_key=key.hex(), out_path=[], out_path_len=0), key + + +def run(coro): + return asyncio.run(coro) + + +def test_telemetry_waits_on_pubkey_and_reflected_tag() -> None: + contact, key = repeater_contact() + node = FakeNode(contact) + rh = FakeResponseHandler({"type": "telemetry", "sensors": []}) + node.dispatcher = FakeDispatcher([rh]) + node.dispatcher.protocol_response_handler = rh + + result = run(request_telemetry(node=node, contact_name="Bergblick", timeout=1)) + + assert result["success"] is True + assert rh.registered[0][0] == key + assert rh.waiters == {} # cleared + + +def test_login_registers_by_pubkey_and_always_clears_the_password() -> None: + contact, key = repeater_contact() + node = FakeNode(contact) + lh = FakeLoginHandler(succeed=True) + node.dispatcher = FakeDispatcher([lh]) + node.dispatcher.login_response_handler = lh + + result = run(send_login(node=node, peer_name="Bergblick", password="pw", timeout=1)) + assert result["success"] is True and result["is_admin"] is True + assert lh.pending == {} and lh.passwords == {} + + lh_fail = FakeLoginHandler(succeed=False) + node.dispatcher = FakeDispatcher([lh_fail], fail=True) + node.dispatcher.login_response_handler = lh_fail + try: + run(send_login(node=node, peer_name="Bergblick", password="pw", timeout=1)) + except OSError: + pass + assert lh_fail.pending == {} and lh_fail.passwords == {} # no leaked password + assert key[0] not in lh_fail.passwords + + +def test_cli_command_registers_by_pubkey() -> None: + contact, _key = repeater_contact() + node = FakeNode(contact) + th = FakeTextHandler(contact) + node.dispatcher = FakeDispatcher([th]) + node.dispatcher.text_message_handler = th + + result = run(send_repeater_command(node=node, peer_name="Bergblick", command="ver", timeout=1)) + + assert result["success"] is True and result["response_text"] == "v1.18.0" + assert th.pending == {} From d8eb6ffa678342ad11923e2e7f2547913c674e02 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:35:16 +0100 Subject: [PATCH 02/72] fix(repeater): make logout local instead of sending a CLI "logout" 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 --- src/meshcore_console/meshcore/operations.py | 14 ++++---------- tests/unit/test_repeater_ops.py | 9 +++++++++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/meshcore_console/meshcore/operations.py b/src/meshcore_console/meshcore/operations.py index c63c169..db07747 100644 --- a/src/meshcore_console/meshcore/operations.py +++ b/src/meshcore_console/meshcore/operations.py @@ -208,16 +208,10 @@ def _on_login(success: bool, data: dict) -> None: async def send_logout(*, node: MeshNodeProtocol, peer_name: str) -> dict: - """Send a logout/disconnect to a repeater.""" - from openhop_core.protocol.packet_builder import PacketBuilder - - contact = _resolve_contact(node, peer_name) - - pkt, _crc = PacketBuilder.create_logout_packet( - contact=contact, - local_identity=node.identity, - ) - await node.dispatcher.send_packet(pkt, wait_for_ack=False) + """Forget the repeater session. Local only: MeshCore firmware has no logout + command (companion CMD_LOGOUT just drops the connection), and the old CLI + "logout" only drew an "Unknown command" reply that surfaced as a DM.""" + _resolve_contact(node, peer_name) return {"success": True, "repeater": peer_name} diff --git a/tests/unit/test_repeater_ops.py b/tests/unit/test_repeater_ops.py index 588dbd3..d68ed15 100644 --- a/tests/unit/test_repeater_ops.py +++ b/tests/unit/test_repeater_ops.py @@ -21,6 +21,7 @@ from meshcore_console.meshcore.operations import ( request_telemetry, send_login, + send_logout, send_repeater_command, ) @@ -187,3 +188,11 @@ def test_cli_command_registers_by_pubkey() -> None: assert result["success"] is True and result["response_text"] == "v1.18.0" assert th.pending == {} + + +def test_logout_is_local_only() -> None: + contact, _key = repeater_contact() + node = FakeNode(contact) + node.dispatcher = FakeDispatcher([]) + assert run(send_logout(node=node, peer_name="Bergblick"))["success"] is True + assert node.dispatcher.sent == [] From 6133712743a12e39b908f134ea4d81bd94da7b63 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:35:46 +0100 Subject: [PATCH 03/72] fix: use the Ed25519 identity key as the node's own public key 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 --- src/meshcore_console/meshcore/session.py | 23 ++++++----------------- tests/unit/test_identity_key.py | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 17 deletions(-) create mode 100644 tests/unit/test_identity_key.py diff --git a/src/meshcore_console/meshcore/session.py b/src/meshcore_console/meshcore/session.py index f1a7431..681b2a5 100644 --- a/src/meshcore_console/meshcore/session.py +++ b/src/meshcore_console/meshcore/session.py @@ -125,13 +125,9 @@ def _on_discovery_request(request_data: dict) -> None: prefix_only = request_data.get("prefix_only", False) inbound_snr = request_data.get("snr", 0.0) - # Get our public key - get_pk = getattr(identity, "get_shared_public_key", None) - pub_key = get_pk() if callable(get_pk) else None - if pub_key is None: - return - if isinstance(pub_key, str): - pub_key = bytes.fromhex(pub_key) + # Our Ed25519 identity key (what adverts carry), not the X25519 + # get_shared_public_key() ECDH key. + pub_key = identity.get_public_key() response = PacketBuilder.create_discovery_response( tag=tag, @@ -731,13 +727,6 @@ def get_public_key(self) -> str | None: """Return this node's public key as a hex string, or None if unavailable.""" if self._identity is None: return None - # openhop_core LocalIdentity exposes get_shared_public_key() - get_pk = getattr(self._identity, "get_shared_public_key", None) - pk = get_pk() if callable(get_pk) else None - if pk is None: - return None - if isinstance(pk, bytes): - return pk.hex() - if isinstance(pk, str): - return pk - return None + # The Ed25519 identity key: what adverts, path hashes and contacts use. + # get_shared_public_key() is the X25519 ECDH key, nobody's address. + return self._identity.get_public_key().hex() diff --git a/tests/unit/test_identity_key.py b/tests/unit/test_identity_key.py new file mode 100644 index 0000000..24916ca --- /dev/null +++ b/tests/unit/test_identity_key.py @@ -0,0 +1,22 @@ +"""The node's own public key is its Ed25519 identity key.""" + +from __future__ import annotations + +from openhop_core.protocol.identity import LocalIdentity + +from meshcore_console.meshcore.config import runtime_config_from_settings +from meshcore_console.meshcore.session import OpenHopCoreSession +from meshcore_console.meshcore.settings import MeshcoreSettings + + +def test_own_public_key_is_the_identity_key_not_the_ecdh_key(tmp_path, monkeypatch) -> None: + """Adverts, path hashes and every MeshCore address use the Ed25519 key; + get_shared_public_key() is the X25519 key used only for ECDH.""" + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + session = OpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())) + identity = LocalIdentity() + session._identity = identity + + assert session.get_public_key() == identity.get_public_key().hex() + assert session.get_public_key() != identity.get_shared_public_key().hex() From 21f75e2b4a9decffd21e2fb949eab9ff87f9df27 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:36:48 +0100 Subject: [PATCH 04/72] fix(ui): rebuild the admin repeater list whenever its repeaters change 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 --- src/meshcore_console/ui_gtk/views/admin.py | 33 +++++++++++++--------- tests/unit/test_admin_list.py | 21 ++++++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) create mode 100644 tests/unit/test_admin_list.py diff --git a/src/meshcore_console/ui_gtk/views/admin.py b/src/meshcore_console/ui_gtk/views/admin.py index b98ebd5..de6c283 100644 --- a/src/meshcore_console/ui_gtk/views/admin.py +++ b/src/meshcore_console/ui_gtk/views/admin.py @@ -27,6 +27,14 @@ _MAX_HISTORY = 50 +def repeater_list_state(peers: list[Peer], logged_in: list[str]) -> tuple: + """What the repeater list shows; the list is rebuilt only when this changes.""" + shown = sorted( + (p.display_name, p.public_key or "", p.is_favorite) for p in peers if p.is_repeater + ) + return tuple(shown), tuple(sorted(logged_in)) + + class AdminView(Gtk.Box): def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: Layout) -> None: super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) @@ -38,6 +46,7 @@ def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: self._history_index = -1 self._busy = False self._refreshing_list = False + self._list_state: tuple | None = None # -- Left: repeater list ----------------------------------------------- list_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) @@ -338,7 +347,9 @@ def _refresh_list(self) -> bool: [p for p in peers if p.is_repeater], key=lambda p: (not p.is_favorite, p.display_name), ) - logged_in = set(self._service.list_logged_in_repeaters()) + logged_in_names = self._service.list_logged_in_repeaters() + self._list_state = repeater_list_state(peers, logged_in_names) + logged_in = set(logged_in_names) # Suppress selection handler during rebuild self._refreshing_list = True @@ -700,18 +711,14 @@ def _do_scroll() -> bool: # -- Events ---------------------------------------------------------------- def _on_events(self, _store: object) -> None: - self._event_cursor, events = self._event_store.since(self._event_cursor, limit=200) - needs_refresh = False - for event in events: - etype = event.get("type", "") - if etype in ( - "repeater_login", - "repeater_logout", - "advert_received", - "contact_received", - ): - needs_refresh = True - if needs_refresh: + self._event_cursor, _events = self._event_store.since(self._event_cursor, limit=200) + # Repeaters arrive through adverts (plain "packet" events), discovery, + # contact events and logins; compare what the list shows instead of + # enumerating event types (the old list matched types nothing emits). + state = repeater_list_state( + self._service.list_peers(), self._service.list_logged_in_repeaters() + ) + if state != self._list_state: self._refresh_list() # -- Public API for MainWindow.navigate_to --------------------------------- diff --git a/tests/unit/test_admin_list.py b/tests/unit/test_admin_list.py new file mode 100644 index 0000000..71fcc6a --- /dev/null +++ b/tests/unit/test_admin_list.py @@ -0,0 +1,21 @@ +"""The Admin view rebuilds its repeater list whenever what it shows changes.""" + +from __future__ import annotations + +from meshcore_console.core.models import Peer +from meshcore_console.ui_gtk.views.admin import repeater_list_state + + +def peer(name: str, key: str, repeater: bool = True) -> Peer: + return Peer(peer_id=key[:16], display_name=name, public_key=key, is_repeater=repeater) + + +def test_state_tracks_repeaters_names_and_logins() -> None: + base = [peer("Bergblick", "aa" * 32), peer("Bob", "bb" * 32, repeater=False)] + state = repeater_list_state(base, []) + + assert repeater_list_state(list(reversed(base)), []) == state # order-independent + assert repeater_list_state([*base, peer("Bob", "bb" * 32, repeater=False)], []) == state + assert repeater_list_state([*base, peer("Talblick", "cc" * 32)], []) != state # new repeater + assert repeater_list_state([peer("Bergblick Sued", "aa" * 32)], []) != state # renamed + assert repeater_list_state(base, ["Bergblick"]) != state # logged in From 60b67d8473ec11db3964f2e6faf614f7d0bcb178 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:37:22 +0100 Subject: [PATCH 05/72] feat: discover nearby repeaters and ask them for their names 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 --- src/meshcore_console/core/services.py | 4 + src/meshcore_console/meshcore/client.py | 74 +++++ src/meshcore_console/meshcore/discovery.py | 144 ++++++++++ src/meshcore_console/meshcore/operations.py | 40 +++ src/meshcore_console/meshcore/session.py | 47 +++- src/meshcore_console/mock/client.py | 14 + .../ui_gtk/windows/main_window.py | 33 +++ tests/unit/test_discovery.py | 264 ++++++++++++++++++ tests/unit/test_repeater_ops.py | 40 +++ 9 files changed, 659 insertions(+), 1 deletion(-) create mode 100644 src/meshcore_console/meshcore/discovery.py create mode 100644 tests/unit/test_discovery.py diff --git a/src/meshcore_console/core/services.py b/src/meshcore_console/core/services.py index 929995d..e599a6f 100644 --- a/src/meshcore_console/core/services.py +++ b/src/meshcore_console/core/services.py @@ -59,6 +59,10 @@ def send_advert( self, name: str | None = None, *, route_type: str = "flood" ) -> SendResultDict: ... + def discover_nodes(self) -> list[dict]: + """Zero-hop discover of nearby repeaters/sensors; blocks for the answer window.""" + ... + def poll_events(self, limit: int = 50) -> list[MeshEventDict]: ... def list_stored_packets(self, limit: int = 100) -> list[MeshEventDict]: diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index e1891f2..42773a0 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -21,6 +21,11 @@ from meshcore_console.meshcore.region import normalize_region_scope from meshcore_console.meshcore.config import runtime_config_from_settings from meshcore_console.meshcore.db import open_db +from meshcore_console.meshcore.discovery import ( + DISCOVER_WINDOW_S, + is_placeholder_for, + placeholder_name, +) from meshcore_console.meshcore.logging_setup import install_radio_error_handler from meshcore_console.meshcore.packet_codec import repair_utf8 from meshcore_console.meshcore.packet_store import PacketStore @@ -442,6 +447,72 @@ def send_advert(self, name: str | None = None, *, route_type: str = "flood") -> ) return result + def discover_nodes(self) -> list[dict]: + """Zero-hop discover; each answer becomes a peer, under a placeholder name + until its advert names it. Returns the answers, each with its peer ``name``.""" + if not self._connected: + self.connect() + results = self._run_async(self._session.discover_nodes(), timeout=DISCOVER_WINDOW_S + 10) + # snapshot: the GTK thread mutates _peers while this runs on a worker + key_of = {name: (p.public_key or "").lower() for name, p in list(self._peers.items())} + name_of = {key: name for name, key in key_of.items() if key} + for result in results: + key = result["pub_key"] + current = name_of.get(key) + name = current or placeholder_name(result["node_type"], key) + if current is None or is_placeholder_for(current, key): + # a discover answer has no name; the node tells us when asked + real = self._run_async(self._session.request_owner_name(key), timeout=15) + if real and key_of.get(real, key) == key: # never take another key's name + name = real + result["name"] = name + self._append_event( + { + "type": EventType.MESH_NODE_DISCOVERED, + "data": { + "name": result["name"], + "peer_id": key[:16], # what its adverts carry as sender_id + "public_key": key, + "contact_type": result["node_type"], + "snr": result["snr_here"], + "rssi": result["rssi"], + "path_hops": [], # it answered zero-hop: a direct neighbour + }, + } + ) + return results + + def _peer_name_for_key(self, public_key: str) -> str | None: + key = public_key.lower() + for name, peer in self._peers.items(): + if peer.public_key and peer.public_key.lower() == key: + return name + return None + + def _replace_placeholder(self, public_key: str, real_name: str) -> None: + """A node discovery listed as ``Repeater 13ab5c7e`` has advertised its real + name: rename that peer (same peer_id, so the DB row is replaced, not doubled).""" + old = self._peer_name_for_key(public_key) + if old is None or old == real_name or not is_placeholder_for(old, public_key.lower()): + return + peer = self._peers.pop(old) + peer.display_name = real_name + self._peers[real_name] = peer + if self._connected: # the contact book exists only on a live session + contact = self._session.contact_book.get_by_name(old) + if contact is not None: + contact.name = real_name + if old in self._repeater_sessions: + self._repeater_sessions[real_name] = self._repeater_sessions.pop(old) + for channel in self._channels.values(): + if channel.kind == "dm" and channel.peer_name == old: + channel.peer_name = real_name + self._channel_store.add_or_update(channel) + password = self._repeater_password_store.get_password(old) + if password is not None: + self._repeater_password_store.save_password(real_name, password) + self._repeater_password_store.delete_password(old) + def poll_events(self, limit: int = 50) -> list[MeshEventDict]: events: list[MeshEventDict] = [] try: @@ -634,6 +705,9 @@ def _process_advert_event(self, data: MeshEventDict) -> None: if self_pubkey and public_key and self_pubkey == public_key: return + if public_key and peer_name not in self._peers: + self._replace_placeholder(public_key, peer_name) + path_hops = data.get("path_hops", []) rssi_raw = data.get("rssi") snr_raw = data.get("snr") diff --git a/src/meshcore_console/meshcore/discovery.py b/src/meshcore_console/meshcore/discovery.py new file mode 100644 index 0000000..26f05a9 --- /dev/null +++ b/src/meshcore_console/meshcore/discovery.py @@ -0,0 +1,144 @@ +"""Node discovery: MeshCore's zero-hop CONTROL discover request/response. + +A discover request (CTL_TYPE_NODE_DISCOVER_REQ) carries a node-type filter and +a random tag. Firmware repeaters and sensors that match the filter answer +with a zero-hop DISCOVER_RESP: their node type, the SNR they heard the request +at, the tag, and their full public key. Repeaters rate-limit this to 4 +answers per 2 minutes and delay each answer randomly, so answers trickle in +over a few seconds. + +The answer carries no name. A discovered node therefore enters the peer list +under a placeholder name (``Repeater 13ab5c7e``) until its next advert +replaces it with the real one (see ``is_placeholder_for``). +""" + +from __future__ import annotations + +import asyncio +import secrets +from typing import Any, Callable + +# ADV_TYPE_* node types, as carried in the low nibble of a DISCOVER_RESP. +NODE_TYPE_LABELS: dict[int, str] = {1: "Chat", 2: "Repeater", 3: "Room", 4: "Sensor"} +NODE_TYPE_CHAT = 1 +NODE_TYPE_REPEATER = 2 + +# Ask repeaters, room servers and sensors (bit = 1 << node type). Chat nodes +# are left out: firmware companions don't answer, and people's handhelds +# aren't infrastructure worth listing. +DISCOVER_FILTER = (1 << 2) | (1 << 3) | (1 << 4) + +# Long enough for the firmware's widened random answer delay at SF8/62.5 kHz. +DISCOVER_WINDOW_S = 12.0 + +PUB_KEY_HEX_LEN = 64 + + +def wants_node_type(filter_mask: int, node_type: int) -> bool: + """True if a discover request with ``filter_mask`` asks for ``node_type``.""" + return bool(filter_mask & (1 << node_type)) + + +def placeholder_name(node_type: int, pub_key_hex: str) -> str: + """Name for a discovered node until its advert names it: ``Repeater 13ab5c7e``.""" + return f"{NODE_TYPE_LABELS.get(node_type, 'Node')} {pub_key_hex[:8].lower()}" + + +def is_placeholder_for(name: str, pub_key_hex: str) -> bool: + """True if ``name`` is the placeholder discovery gave the node with this key.""" + return any( + name == placeholder_name(node_type, pub_key_hex) for node_type in (*NODE_TYPE_LABELS, 0) + ) + + +def accept_response(response: dict[str, Any], filter_mask: int) -> bool: + """Keep answers with a full public key from a node type we asked for.""" + key = str(response.get("pub_key") or "") + node_type = int(response.get("node_type", 0)) + return len(key) == PUB_KEY_HEX_LEN and wants_node_type(filter_mask, node_type) + + +async def discover( + send_packet: Callable[[Any], Any], + control_handler: Any, + build_request: Callable[..., Any], + *, + self_pub_key: str | None = None, + filter_mask: int = DISCOVER_FILTER, + window: float = DISCOVER_WINDOW_S, +) -> list[dict[str, Any]]: + """Send one discover request and collect the answers for ``window`` seconds. + + ``send_packet`` is the dispatcher's async send, ``control_handler`` its + ControlHandler (answers are matched by tag there), ``build_request`` is + ``PacketBuilder.create_discovery_request``. Returns one dict per node, + first answer wins, in arrival order. + """ + tag = secrets.randbits(32) + found: dict[str, dict[str, Any]] = {} + + def on_response(response: dict[str, Any]) -> None: + if not accept_response(response, filter_mask): + return + key = str(response["pub_key"]).lower() + if key == (self_pub_key or "").lower() or key in found: + return + found[key] = { + "pub_key": key, + "node_type": int(response["node_type"]), + "snr_there": response.get("inbound_snr"), + "snr_here": response.get("response_snr"), + "rssi": response.get("rssi"), + } + + control_handler.set_response_callback(tag, on_response) + try: + await send_packet(build_request(tag=tag, filter_mask=filter_mask)) + await asyncio.sleep(window) + finally: + control_handler.clear_response_callback(tag) + return list(found.values()) + + +def decode_owner_reply(parsed: dict[str, Any]) -> tuple[str, str] | None: + """(name, owner) from an ANON_REQ_TYPE_OWNER reply, or None. + + Firmware replies tag(4) + clock(4) + "name\nowner". openhop_core guesses a + type for every reply, so the bytes sit wherever its guess put them: "hex" + for binary (the usual case: the clock bytes aren't text), "raw_bytes" after + the tag for telemetry/stats lookalikes, "content" after the tag for text. + """ + body: bytes | None = None + if parsed.get("hex"): + body = bytes.fromhex(parsed["hex"])[8:] + elif isinstance(parsed.get("raw_bytes"), (bytes, bytearray)): + body = bytes(parsed["raw_bytes"])[4:] + elif isinstance(parsed.get("content"), str): + body = parsed["content"].encode("utf-8", "replace")[4:] + if not body: + return None + name, _, owner = body.rstrip(b"\0").decode("utf-8", "replace").partition("\n") + name = name.strip() + if not name or len(name) > 32 or not name.isprintable(): + return None + return name, owner.strip() + + +def summarize(results: list[dict[str, Any]], names: dict[str, str]) -> str: + """One-line toast text: ``Discovered 1 node: Bergblick (SNR 11.5 here, 12.0 there)``.""" + if not results: + return "Discover: no answers (repeaters answer at most 4 times per 2 min)" + parts = [] + for result in results: + name = names.get(result["pub_key"]) or placeholder_name( + result["node_type"], result["pub_key"] + ) + here, there = result.get("snr_here"), result.get("snr_there") + snr = ( + f" (SNR {here:.1f} here, {there:.1f} there)" + if here is not None and there is not None + else "" + ) + parts.append(f"{name}{snr}") + count = len(results) + return f"Discovered {count} node{'' if count == 1 else 's'}: " + ", ".join(parts) diff --git a/src/meshcore_console/meshcore/operations.py b/src/meshcore_console/meshcore/operations.py index db07747..e16f36f 100644 --- a/src/meshcore_console/meshcore/operations.py +++ b/src/meshcore_console/meshcore/operations.py @@ -155,6 +155,46 @@ def _on_response(success: bool, text: str, parsed: dict) -> None: } +async def request_owner_info( + *, + node: MeshNodeProtocol, + contact: object, + timeout: float = 8.0, +) -> dict | None: + """Anonymous ANON_REQ_TYPE_OWNER: a repeater answers "name\nowner", no login. + + Firmware only answers direct (``isRouteDirect``) requests, so ``contact`` + needs a known route (``out_path_len >= 0``; 0 for a zero-hop neighbour) and + must be in the contact book for openhop_core to decrypt the reply. Returns + openhop's parsed reply dict, or None on timeout. + """ + from openhop_core.protocol.constants import ANON_REQ_TYPE_OWNER + from openhop_core.protocol.packet_builder import PacketBuilder + + # req_data = [type][reply path length byte][reply path...]; 0 = zero-hop reply + pkt, tag = PacketBuilder.create_anon_request( + contact, node.identity, req_data=bytes([ANON_REQ_TYPE_OWNER, 0x00]) + ) + pubkey = bytes.fromhex(contact.public_key) # type: ignore[attr-defined] + response_handler = node.dispatcher.protocol_response_handler + reply: dict = {} + done = asyncio.Event() + + def _on_response(_success: bool, _text: str, parsed: dict) -> None: + reply.update(parsed) + done.set() + + response_handler.set_response_callback(pubkey, tag, _on_response) + try: + await node.dispatcher.send_packet(pkt, wait_for_ack=False) + await asyncio.wait_for(done.wait(), timeout=timeout) + except asyncio.TimeoutError: + return None + finally: + response_handler.clear_response_callback(pubkey, tag) + return reply + + async def send_login( *, node: MeshNodeProtocol, diff --git a/src/meshcore_console/meshcore/session.py b/src/meshcore_console/meshcore/session.py index 681b2a5..5509584 100644 --- a/src/meshcore_console/meshcore/session.py +++ b/src/meshcore_console/meshcore/session.py @@ -40,10 +40,12 @@ from .channel_db import ChannelDatabase from .config import RuntimeRadioConfig, load_hardware_config_from_env -from .contact_book import ContactBook +from .contact_book import Contact, ContactBook from .db import open_db +from .discovery import decode_owner_reply, discover from .event_bridge import attach_dispatcher_callbacks, attach_event_service_subscriber from .operations import ( + request_owner_info, request_telemetry, send_advert, send_group_text, @@ -653,6 +655,49 @@ async def send_advert( route_type=route_type, ) + async def discover_nodes(self) -> list[dict[str, Any]]: + """Zero-hop discover: which repeaters/sensors hear us directly (see discovery.py).""" + if self._node is None: + raise RuntimeError("Session is not started.") + from openhop_core.protocol.packet_builder import PacketBuilder + + dispatcher = self._node.dispatcher + + async def send(packet: Any) -> None: + await dispatcher.send_packet(packet, wait_for_ack=False) + + results = await discover( + send, + dispatcher.control_handler, + PacketBuilder.create_discovery_request, + self_pub_key=self.get_public_key(), + ) + self._log(f"discover: {len(results)} answer(s)") + return results + + async def request_owner_name(self, pub_key_hex: str) -> str | None: + """Ask a zero-hop neighbour (e.g. a discovered repeater) for its name. + + openhop_core decrypts the reply through the contact book, so a stand-in + contact (direct zero-hop route) sits there for the duration of the request. + """ + if self._node is None: + raise RuntimeError("Session is not started.") + contact = Contact( + name=f"owner-query {pub_key_hex[:8]}", + public_key=pub_key_hex, + out_path=[], + out_path_len=0, + ) + self._contact_book.contacts.append(contact) + try: + reply = await request_owner_info(node=self._node, contact=contact) + finally: + self._contact_book.contacts.remove(contact) + decoded = decode_owner_reply(reply) if reply else None + self._log(f"owner query {pub_key_hex[:8]}: {decoded[0] if decoded else 'no answer'}") + return decoded[0] if decoded else None + async def send_telemetry_request( self, contact_name: str, diff --git a/src/meshcore_console/mock/client.py b/src/meshcore_console/mock/client.py index d827763..ad00923 100644 --- a/src/meshcore_console/mock/client.py +++ b/src/meshcore_console/mock/client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import time from datetime import UTC, datetime from typing import Callable from uuid import uuid4 @@ -207,6 +208,19 @@ def send_advert(self, name: str | None = None, *, route_type: str = "flood") -> ) return {"success": True, "tx_metadata": {"mock": True}, "dispatcher_result": True} + def discover_nodes(self) -> list[dict]: + time.sleep(1.0) # stands in for the answer window + return [ + { + "pub_key": "13ab5c7e" + "00" * 28, + "node_type": 2, + "snr_here": 11.5, + "snr_there": 12.0, + "rssi": -30, + "name": "Repeater 13ab5c7e", + } + ] + def poll_events(self, limit: int = 50) -> list[dict]: events: list[dict] = [] try: diff --git a/src/meshcore_console/ui_gtk/windows/main_window.py b/src/meshcore_console/ui_gtk/windows/main_window.py index 426b7aa..bfadede 100644 --- a/src/meshcore_console/ui_gtk/windows/main_window.py +++ b/src/meshcore_console/ui_gtk/windows/main_window.py @@ -19,6 +19,7 @@ from gi.repository import Pango from meshcore_console.core.services import MeshcoreService +from meshcore_console.meshcore.discovery import summarize from meshcore_console.platform.conflicts import ConflictError, ConflictReport from meshcore_console.ui_gtk.compat import build_alert_dialog, build_toolbar_page, present_dialog from meshcore_console.ui_gtk.state import UiEventStore @@ -197,6 +198,11 @@ def _build_header_bar(self) -> Adw.HeaderBar: direct_btn = Gtk.Button.new_with_label("Direct Advert") direct_btn.connect("clicked", self._on_advert_direct, advert_popover) advert_box.append(direct_btn) + discover_btn = Gtk.Button.new_with_label("Discover Nearby") + discover_btn.set_tooltip_text("Ask repeaters in direct range to answer") + discover_btn.connect("clicked", self._on_discover, advert_popover) + advert_box.append(discover_btn) + self._discovering = False advert_popover.set_child(advert_box) self._advert_btn.set_popover(advert_popover) header_bar.pack_end(self._advert_btn) @@ -321,6 +327,33 @@ def _on_advert_direct(self, _button: Gtk.Button, popover: Gtk.Popover) -> None: popover.popdown() self._send_advert(route_type="direct") + def _on_discover(self, _button: Gtk.Button, popover: Gtk.Popover) -> None: + logger.debug("UI: discover button clicked") + popover.popdown() + if self._discovering: + self._toast_overlay.add_toast(Adw.Toast.new("Discover is still listening")) + return + self._discovering = True + self._toast_overlay.add_toast(Adw.Toast.new("Discovering nearby nodes...")) + + def do_discover() -> None: + text: str + try: + results = self._service.discover_nodes() + text = summarize(results, {r["pub_key"]: r["name"] for r in results}) + except Exception as exc: # noqa: BLE001 + logger.error("Discover failed: %s", exc) + text = f"Discover failed: {str(exc) or type(exc).__name__}" + + def on_done() -> bool: + self._discovering = False + self._toast_overlay.add_toast(Adw.Toast.new(text)) + return False + + GLib.idle_add(on_done) + + threading.Thread(target=do_discover, daemon=True).start() + def _send_advert(self, route_type: str) -> None: def do_send() -> None: error: str | None = None diff --git a/tests/unit/test_discovery.py b/tests/unit/test_discovery.py new file mode 100644 index 0000000..6cb24a9 --- /dev/null +++ b/tests/unit/test_discovery.py @@ -0,0 +1,264 @@ +"""Zero-hop node discovery: request/answer matching and placeholder peers.""" + +from __future__ import annotations + +import asyncio + +from meshcore_console.core.enums import EventType +from meshcore_console.meshcore.db import open_db +from meshcore_console.meshcore.discovery import ( + DISCOVER_FILTER, + NODE_TYPE_CHAT, + NODE_TYPE_REPEATER, + accept_response, + discover, + is_placeholder_for, + placeholder_name, + summarize, + wants_node_type, +) + +KEY = "13ab5c7e" + "11" * 28 +OTHER = "77cc0011" + "22" * 28 +SELF = "46a5a5ad" + "33" * 28 + + +def answer(key: str, node_type: int = NODE_TYPE_REPEATER, tag: int = 0) -> dict: + return { + "tag": tag, + "node_type": node_type, + "inbound_snr": 12.0, + "response_snr": 11.5, + "rssi": -30, + "pub_key": key, + } + + +class FakeControlHandler: + def __init__(self) -> None: + self.callbacks: dict[int, object] = {} + self.cleared: list[int] = [] + + def set_response_callback(self, tag, callback) -> None: + self.callbacks[tag] = callback + + def clear_response_callback(self, tag) -> None: + self.callbacks.pop(tag, None) + self.cleared.append(tag) + + +def run_discover(answers: list[dict], **kwargs) -> tuple[list[dict], FakeControlHandler, list]: + """Each answer is delivered to the callback for the request's tag, as the + ControlHandler would when the radio hears it.""" + handler = FakeControlHandler() + built: list[dict] = [] + + def build_request(*, tag: int, filter_mask: int) -> dict: + built.append({"tag": tag, "filter_mask": filter_mask}) + return built[-1] + + async def send(packet: dict) -> None: + callback = handler.callbacks[packet["tag"]] + for a in answers: + callback({**a, "tag": packet["tag"]}) + + results = asyncio.run(discover(send, handler, build_request, window=0.01, **kwargs)) + return results, handler, built + + +def test_filter_bits_follow_firmware() -> None: + # firmware tests `filter & (1 << ADV_TYPE_REPEATER)`, ADV_TYPE_REPEATER = 2 + assert wants_node_type(0x04, NODE_TYPE_REPEATER) + assert wants_node_type(DISCOVER_FILTER, NODE_TYPE_REPEATER) + assert not wants_node_type(DISCOVER_FILTER, NODE_TYPE_CHAT) + + +def test_placeholder_names_round_trip() -> None: + assert placeholder_name(NODE_TYPE_REPEATER, KEY) == "Repeater 13ab5c7e" + assert placeholder_name(4, KEY.upper()) == "Sensor 13ab5c7e" + assert placeholder_name(9, KEY) == "Node 13ab5c7e" + assert is_placeholder_for("Repeater 13ab5c7e", KEY) + assert not is_placeholder_for("Repeater 13ab5c7e", OTHER) + assert not is_placeholder_for("Bergblick", KEY) + + +def test_accept_needs_full_key_and_asked_type() -> None: + assert accept_response(answer(KEY), DISCOVER_FILTER) + assert not accept_response(answer(KEY[:16]), DISCOVER_FILTER) # prefix-only answer + assert not accept_response(answer(KEY, NODE_TYPE_CHAT), DISCOVER_FILTER) + + +def test_discover_collects_unique_answers_and_clears_callback() -> None: + results, handler, built = run_discover( + [answer(KEY), answer(KEY), answer(OTHER, 4), answer(SELF), answer(KEY[:16])], + self_pub_key=SELF, + ) + assert [r["pub_key"] for r in results] == [KEY, OTHER] + assert results[0] == { + "pub_key": KEY, + "node_type": NODE_TYPE_REPEATER, + "snr_there": 12.0, + "snr_here": 11.5, + "rssi": -30, + } + assert built[0]["filter_mask"] == DISCOVER_FILTER + assert handler.callbacks == {} and handler.cleared == [built[0]["tag"]] + + +def test_discover_clears_callback_when_send_fails() -> None: + handler = FakeControlHandler() + + async def send(_packet) -> None: + raise OSError("radio gone") + + try: + asyncio.run(discover(send, handler, lambda **kw: kw, window=0.01)) + except OSError: + pass + assert handler.callbacks == {} and len(handler.cleared) == 1 + + +def test_summarize() -> None: + assert summarize([], {}).startswith("Discover: no answers") + one = [{"pub_key": KEY, "node_type": 2, "snr_here": 11.5, "snr_there": 12.0}] + assert summarize(one, {KEY: "Bergblick"}) == ( + "Discovered 1 node: Bergblick (SNR 11.5 here, 12.0 there)" + ) + assert summarize(one, {}).startswith("Discovered 1 node: Repeater 13ab5c7e") + + +def _client(tmp_path, monkeypatch): + from meshcore_console.meshcore import client as client_mod + from meshcore_console.meshcore.client import MeshcoreClient + from meshcore_console.meshcore.config import runtime_config_from_settings + from meshcore_console.meshcore.settings import MeshcoreSettings + from meshcore_console.mock import MockOpenHopCoreSession + + db_conn = open_db(str(tmp_path / "test.db")) + monkeypatch.setattr(client_mod, "open_db", lambda *a, **k: db_conn) + return MeshcoreClient( + session=MockOpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())), + require_openhop=False, + ) + + +def discovered_event(name: str, key: str = KEY) -> dict: + return { + "type": EventType.MESH_NODE_DISCOVERED, + "data": { + "name": name, + "peer_id": key[:16], + "public_key": key, + "contact_type": NODE_TYPE_REPEATER, + "snr": 11.5, + "rssi": -30, + "path_hops": [], + }, + } + + +def advert_event(name: str, key: str = KEY) -> dict: + return { + "type": EventType.PACKET, + "data": { + "payload_type_name": "ADVERT", + "sender_name": name, + "sender_id": key[:16], + "sender_pubkey": key, + "advert_type": NODE_TYPE_REPEATER, + "path_hops": ["13"], + "rssi": -40, + "snr": 9.0, + }, + } + + +def test_advert_renames_discovered_placeholder(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch) + client._process_event_for_peers(discovered_event("Repeater 13ab5c7e")) + client._repeater_password_store.save_password("Repeater 13ab5c7e", "pw") + client.set_favorite(KEY[:16], True) + + client._process_event_for_peers(advert_event("Bergblick")) + + peers = {p.display_name: p for p in client.list_peers()} + assert list(peers) == ["Bergblick"] + assert peers["Bergblick"].peer_id == KEY[:16] + assert peers["Bergblick"].is_favorite and peers["Bergblick"].is_repeater + assert client.get_saved_repeater_password("Bergblick") == "pw" + assert client.get_saved_repeater_password("Repeater 13ab5c7e") is None + + +def test_advert_leaves_a_real_name_alone(tmp_path, monkeypatch) -> None: + """Only discovery placeholders are renamed; a node changing its name isn't + this feature's business.""" + client = _client(tmp_path, monkeypatch) + client._process_event_for_peers(advert_event("Bergblick")) + client._process_event_for_peers(advert_event("Talblick")) + assert "Bergblick" in {p.display_name for p in client.list_peers()} + + +def test_discovery_updates_a_known_peer_by_key(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch) + client._process_event_for_peers(advert_event("Bergblick")) + assert client._peer_name_for_key(KEY.upper()) == "Bergblick" + client._process_event_for_peers(discovered_event("Bergblick")) + (peer,) = client.list_peers() + assert peer.display_name == "Bergblick" and peer.last_path == ["13"] and peer.snr == 11.5 + + +def _live_client(tmp_path, monkeypatch, owner_name): + """A client whose session answers discover with KEY and the owner query with + ``owner_name``; marked connected so the contact book is in play.""" + from meshcore_console.meshcore.contact_book import ContactBook + + client = _client(tmp_path, monkeypatch) + + async def discover_nodes(): + answer = {"pub_key": KEY, "node_type": 2, "snr_here": 11.5, "snr_there": 12.0} + return [{**answer, "rssi": -30}] + + async def request_owner_name(_key): + return owner_name + + monkeypatch.setattr(client._session, "discover_nodes", discover_nodes, raising=False) + monkeypatch.setattr(client._session, "request_owner_name", request_owner_name, raising=False) + monkeypatch.setattr(client._session, "contact_book", ContactBook(), raising=False) + # only our own events: the mock session's canned packets carry non-hex path hops + monkeypatch.setattr(client._session, "drain_events", lambda max_items=50: [], raising=False) + client._connected = True + return client + + +def test_discover_asks_a_placeholder_for_its_name(tmp_path, monkeypatch) -> None: + from meshcore_console.core.models import RepeaterLoginState + + client = _live_client(tmp_path, monkeypatch, "Bergblick") + client._process_event_for_peers(discovered_event("Repeater 13ab5c7e")) + placeholder = "Repeater 13ab5c7e" + client._repeater_sessions[placeholder] = RepeaterLoginState(peer_name=placeholder) + + results = client.discover_nodes() + client.poll_events() + + assert results[0]["name"] == "Bergblick" + assert [p.display_name for p in client.list_peers()] == ["Bergblick"] + assert list(client._repeater_sessions) == ["Bergblick"] + assert client._session.contact_book.get_by_name("Bergblick").public_key == KEY + + +def test_discover_never_takes_another_keys_name(tmp_path, monkeypatch) -> None: + client = _live_client(tmp_path, monkeypatch, "Bergblick") + client._process_event_for_peers(advert_event("Bergblick", OTHER)) + + results = client.discover_nodes() + client.poll_events() + + assert results[0]["name"] == "Repeater 13ab5c7e" + peers = {p.display_name: p.public_key for p in client.list_peers()} + assert peers == {"Bergblick": OTHER, "Repeater 13ab5c7e": KEY} + + +def test_discover_keeps_the_placeholder_when_nobody_answers(tmp_path, monkeypatch) -> None: + client = _live_client(tmp_path, monkeypatch, None) + assert client.discover_nodes()[0]["name"] == "Repeater 13ab5c7e" diff --git a/tests/unit/test_repeater_ops.py b/tests/unit/test_repeater_ops.py index d68ed15..f7ec048 100644 --- a/tests/unit/test_repeater_ops.py +++ b/tests/unit/test_repeater_ops.py @@ -11,6 +11,7 @@ import asyncio import inspect +import struct from openhop_core.node.handlers.login_response import LoginResponseHandler from openhop_core.node.handlers.protocol_response import ProtocolResponseHandler @@ -18,7 +19,9 @@ from openhop_core.protocol.identity import LocalIdentity from meshcore_console.meshcore.contact_book import Contact, ContactBook +from meshcore_console.meshcore.discovery import decode_owner_reply from meshcore_console.meshcore.operations import ( + request_owner_info, request_telemetry, send_login, send_logout, @@ -196,3 +199,40 @@ def test_logout_is_local_only() -> None: node.dispatcher = FakeDispatcher([]) assert run(send_logout(node=node, peer_name="Bergblick"))["success"] is True assert node.dispatcher.sent == [] + + +def owner_reply(name: bytes, owner: bytes = b"Erik") -> bytes: + return struct.pack(" None: + contact, key = repeater_contact() + node = FakeNode(contact) + rh = FakeResponseHandler({"type": "binary", "hex": owner_reply(b"Bergblick").hex()}) + node.dispatcher = FakeDispatcher([rh]) + node.dispatcher.protocol_response_handler = rh + + parsed = run(request_owner_info(node=node, contact=contact, timeout=1)) + + assert decode_owner_reply(parsed) == ("Bergblick", "Erik") + assert node.dispatcher.sent[0].is_route_direct() # firmware answers direct only + assert rh.registered[0][0] == key and rh.waiters == {} + + +def test_owner_query_times_out_to_none() -> None: + contact, _key = repeater_contact() + node = FakeNode(contact) + rh = FakeResponseHandler(None) + node.dispatcher = FakeDispatcher([rh]) + node.dispatcher.protocol_response_handler = rh + assert run(request_owner_info(node=node, contact=contact, timeout=0.05)) is None + assert rh.waiters == {} + + +def test_decode_owner_reply_wherever_openhop_put_the_bytes() -> None: + data = owner_reply("Bergblick Süd".encode()) + assert decode_owner_reply({"type": "binary", "hex": data.hex()}) == ("Bergblick Süd", "Erik") + assert decode_owner_reply({"type": "telemetry", "raw_bytes": data[4:]})[0] == "Bergblick Süd" + assert decode_owner_reply({"type": "binary", "hex": owner_reply(b"", b"").hex()}) is None + assert decode_owner_reply({"type": "binary", "hex": owner_reply(b"x" * 40).hex()}) is None + assert decode_owner_reply({}) is None From d61ab35698c38ce44da4a1234a909de83daa73da Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:38:23 +0100 Subject: [PATCH 06/72] test: keep the test suite out of the real app state directories 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 --- tests/conftest.py | 23 +++++++++++++++++++++++ tests/unit/test_isolation.py | 14 ++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tests/unit/test_isolation.py diff --git a/tests/conftest.py b/tests/conftest.py index 223aa83..cb4cc79 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,3 +17,26 @@ def _clear_hardware_env(monkeypatch: pytest.MonkeyPatch) -> None: """ for _field, name, _parse in _HARDWARE_ENV_OVERRIDES: monkeypatch.delenv(name, raising=False) + + +@pytest.fixture(autouse=True) +def _isolate_user_dirs(tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch): + """Point the app's database, identity, config and log at a temp dir. + + MeshcoreClient() and OpenHopCoreSession() open the default database + (XDG_STATE_HOME) even when a test injects its own stores, and the log + directory is fixed at import time, so without this a test run writes into + the developer's real ~/.local/state/meshcore-uconsole. + """ + from meshcore_console.meshcore import logging_setup + + root = tmp_path_factory.mktemp("xdg") + for var, sub in ( + ("XDG_STATE_HOME", "state"), + ("XDG_DATA_HOME", "data"), + ("XDG_CONFIG_HOME", "config"), + ): + monkeypatch.setenv(var, str(root / sub)) + log_dir = root / "state" / "meshcore-uconsole" + monkeypatch.setattr(logging_setup, "LOG_DIR", log_dir) + monkeypatch.setattr(logging_setup, "LOG_FILE", log_dir / "app.log") diff --git a/tests/unit/test_isolation.py b/tests/unit/test_isolation.py new file mode 100644 index 0000000..1f82d0b --- /dev/null +++ b/tests/unit/test_isolation.py @@ -0,0 +1,14 @@ +"""Tests must never touch the developer's real app state.""" + +from __future__ import annotations + +from pathlib import Path + +from meshcore_console.meshcore import logging_setup, paths + + +def test_app_dirs_are_redirected_away_from_home() -> None: + real = Path.home() + for path in (paths.db_path(), paths.identity_key_path(), paths.config_dir()): + assert real / ".local" not in path.parents and real / ".config" not in path.parents + assert real / ".local" not in logging_setup.LOG_FILE.parents From cea4e6d38def3ff363b04be006719410f3bd00f2 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:39:09 +0100 Subject: [PATCH 07/72] fix(logging): keep the log in the state directory that XDG_STATE_HOME 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 --- .../meshcore/logging_setup.py | 27 ++++++++++++++----- tests/conftest.py | 11 +++----- tests/unit/test_isolation.py | 2 +- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/meshcore_console/meshcore/logging_setup.py b/src/meshcore_console/meshcore/logging_setup.py index 87d08bd..2c727dc 100644 --- a/src/meshcore_console/meshcore/logging_setup.py +++ b/src/meshcore_console/meshcore/logging_setup.py @@ -18,8 +18,12 @@ from pathlib import Path from typing import Callable -LOG_DIR = Path.home() / ".local" / "state" / "meshcore-uconsole" -LOG_FILE = LOG_DIR / "app.log" +from .paths import state_dir + +# None = the app's state directory (XDG_STATE_HOME/meshcore-uconsole, next to +# the database), resolved when used; tests may point these elsewhere. +LOG_DIR: Path | None = None +LOG_FILE: Path | None = None LOG_FORMAT = "[%(name)s] %(message)s" FILE_LOG_FORMAT = "%(asctime)s %(levelname)-8s [%(name)s] %(message)s" MAX_BYTES = 1_000_000 # 1 MB per file @@ -31,6 +35,14 @@ _configured = False +def log_dir() -> Path: + return LOG_DIR if LOG_DIR is not None else state_dir() + + +def log_file() -> Path: + return LOG_FILE if LOG_FILE is not None else log_dir() / "app.log" + + def configure_logging(console_level: str | None = None) -> None: """Set up root logger with stderr and rotating file handlers. @@ -64,8 +76,8 @@ def configure_logging(console_level: str | None = None) -> None: root.addHandler(_stderr_handler) # Rotating file handler - LOG_DIR.mkdir(parents=True, exist_ok=True) - file_handler = RotatingFileHandler(LOG_FILE, maxBytes=MAX_BYTES, backupCount=BACKUP_COUNT) + log_dir().mkdir(parents=True, exist_ok=True) + file_handler = RotatingFileHandler(log_file(), maxBytes=MAX_BYTES, backupCount=BACKUP_COUNT) file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(logging.Formatter(FILE_LOG_FORMAT)) root.addHandler(file_handler) @@ -84,13 +96,14 @@ def set_stderr_level(level_name: str) -> None: def get_log_files_chronological() -> list[Path]: """Return all log files oldest-first (backup.3 -> backup.1 -> current).""" + current = log_file() files: list[Path] = [] for i in range(BACKUP_COUNT, 0, -1): - p = LOG_FILE.with_suffix(f".log.{i}") + p = current.with_suffix(f".log.{i}") if p.exists(): files.append(p) - if LOG_FILE.exists(): - files.append(LOG_FILE) + if current.exists(): + files.append(current) return files diff --git a/tests/conftest.py b/tests/conftest.py index cb4cc79..8eb7fbf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,12 +24,10 @@ def _isolate_user_dirs(tmp_path_factory: pytest.TempPathFactory, monkeypatch: py """Point the app's database, identity, config and log at a temp dir. MeshcoreClient() and OpenHopCoreSession() open the default database - (XDG_STATE_HOME) even when a test injects its own stores, and the log - directory is fixed at import time, so without this a test run writes into - the developer's real ~/.local/state/meshcore-uconsole. + (XDG_STATE_HOME) even when a test injects its own stores, and the log lives + in the same directory, so without this a test run writes into the + developer's real ~/.local/state/meshcore-uconsole. """ - from meshcore_console.meshcore import logging_setup - root = tmp_path_factory.mktemp("xdg") for var, sub in ( ("XDG_STATE_HOME", "state"), @@ -37,6 +35,3 @@ def _isolate_user_dirs(tmp_path_factory: pytest.TempPathFactory, monkeypatch: py ("XDG_CONFIG_HOME", "config"), ): monkeypatch.setenv(var, str(root / sub)) - log_dir = root / "state" / "meshcore-uconsole" - monkeypatch.setattr(logging_setup, "LOG_DIR", log_dir) - monkeypatch.setattr(logging_setup, "LOG_FILE", log_dir / "app.log") diff --git a/tests/unit/test_isolation.py b/tests/unit/test_isolation.py index 1f82d0b..38f7030 100644 --- a/tests/unit/test_isolation.py +++ b/tests/unit/test_isolation.py @@ -11,4 +11,4 @@ def test_app_dirs_are_redirected_away_from_home() -> None: real = Path.home() for path in (paths.db_path(), paths.identity_key_path(), paths.config_dir()): assert real / ".local" not in path.parents and real / ".config" not in path.parents - assert real / ".local" not in logging_setup.LOG_FILE.parents + assert real / ".local" not in logging_setup.log_file().parents From 4deab07583a82432b0028f172322624a0b78d035 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:40:08 +0100 Subject: [PATCH 08/72] fix(security): keep the identity seed, database and logs owner-only 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 --- src/meshcore_console/main.py | 4 ++ src/meshcore_console/meshcore/db.py | 5 +- .../meshcore/logging_setup.py | 8 ++- src/meshcore_console/meshcore/paths.py | 14 +++++ src/meshcore_console/meshcore/runtime.py | 13 +++-- tests/unit/test_private_files.py | 58 +++++++++++++++++++ 6 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 tests/unit/test_private_files.py diff --git a/src/meshcore_console/main.py b/src/meshcore_console/main.py index c94856e..c5039f4 100644 --- a/src/meshcore_console/main.py +++ b/src/meshcore_console/main.py @@ -1,12 +1,16 @@ from __future__ import annotations import argparse +import os import asyncio import importlib.metadata import traceback def main() -> int: + # Everything the app writes is private: identity seed, database (channel + # secrets, repeater passwords, messages), logs and log exports. + os.umask(0o077) parser = argparse.ArgumentParser( prog="meshcore-console", description="MeshCore console — GUI and headless CLI", diff --git a/src/meshcore_console/meshcore/db.py b/src/meshcore_console/meshcore/db.py index 1e8a942..687cfd0 100644 --- a/src/meshcore_console/meshcore/db.py +++ b/src/meshcore_console/meshcore/db.py @@ -7,7 +7,7 @@ import sqlite3 from .channel_db import PUBLIC_CHANNEL_SECRET -from .paths import db_path +from .paths import db_path, make_private logger = logging.getLogger(__name__) @@ -193,12 +193,13 @@ def _migrate(conn: sqlite3.Connection) -> None: def open_db(path: str | None = None) -> sqlite3.Connection: """Open (and migrate if needed) the application database.""" db = db_path() if path is None else __import__("pathlib").Path(path) - db.parent.mkdir(parents=True, exist_ok=True) + db.parent.mkdir(parents=True, exist_ok=True, mode=0o700) # check_same_thread=False is required because openhop_core reads the # channel_secrets table from the meshcore-aio thread while the connection # is created on the main thread. WAL mode makes concurrent access safe. conn = sqlite3.connect(str(db), check_same_thread=False) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") + make_private(db, db.with_name(db.name + "-wal"), db.with_name(db.name + "-shm")) _migrate(conn) return conn diff --git a/src/meshcore_console/meshcore/logging_setup.py b/src/meshcore_console/meshcore/logging_setup.py index 2c727dc..8d3e9b7 100644 --- a/src/meshcore_console/meshcore/logging_setup.py +++ b/src/meshcore_console/meshcore/logging_setup.py @@ -18,7 +18,7 @@ from pathlib import Path from typing import Callable -from .paths import state_dir +from .paths import make_private, state_dir # None = the app's state directory (XDG_STATE_HOME/meshcore-uconsole, next to # the database), resolved when used; tests may point these elsewhere. @@ -76,8 +76,9 @@ def configure_logging(console_level: str | None = None) -> None: root.addHandler(_stderr_handler) # Rotating file handler - log_dir().mkdir(parents=True, exist_ok=True) + log_dir().mkdir(parents=True, exist_ok=True, mode=0o700) file_handler = RotatingFileHandler(log_file(), maxBytes=MAX_BYTES, backupCount=BACKUP_COUNT) + make_private(*get_log_files_chronological()) file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(logging.Formatter(FILE_LOG_FORMAT)) root.addHandler(file_handler) @@ -110,7 +111,8 @@ def get_log_files_chronological() -> list[Path]: def export_logs_to_path(dest: str | Path) -> Path: """Concatenate all log files into *dest* (oldest first). Returns dest path.""" dest = Path(dest) - with dest.open("w") as out: + fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as out: for log_file in get_log_files_chronological(): with log_file.open() as f: shutil.copyfileobj(f, out) diff --git a/src/meshcore_console/meshcore/paths.py b/src/meshcore_console/meshcore/paths.py index c46100d..763f4b9 100644 --- a/src/meshcore_console/meshcore/paths.py +++ b/src/meshcore_console/meshcore/paths.py @@ -41,6 +41,20 @@ def db_path() -> Path: return state_dir() / "meshcore.db" +def make_private(*paths: Path) -> None: + """Restrict existing files to the owner (0600). + + New files are private through the umask set at startup; this fixes state + written by earlier versions (identity seed, database, logs were 0644). + """ + for path in paths: + try: + if path.exists() and path.stat().st_mode & 0o077: + path.chmod(0o600) + except OSError: + pass + + def identity_key_path() -> Path: """Return the path to the persisted identity seed (identity.key)""" return data_dir() / "identity.key" diff --git a/src/meshcore_console/meshcore/runtime.py b/src/meshcore_console/meshcore/runtime.py index 1d49e41..92c29c3 100644 --- a/src/meshcore_console/meshcore/runtime.py +++ b/src/meshcore_console/meshcore/runtime.py @@ -1,6 +1,7 @@ from __future__ import annotations import inspect +import os from typing import Any from meshcore_console.core.types import ( @@ -13,7 +14,7 @@ ) from .config import HardwareRadioConfig -from .paths import identity_key_path +from .paths import identity_key_path, make_private def import_openhop_core() -> tuple[ @@ -86,13 +87,17 @@ def create_mesh_node( ) -> tuple[LocalIdentityProtocol, MeshNodeProtocol]: key_path = identity_key_path() if key_path.exists(): + make_private(key_path) seed = key_path.read_bytes() else: - # Generate a new identity and persist the seed for future sessions + # Generate a new identity and persist the seed for future sessions; + # the seed is the node's private key, so create it owner-only. tmp = local_identity_type() seed = tmp.get_signing_key_bytes() - key_path.parent.mkdir(parents=True, exist_ok=True) - key_path.write_bytes(seed) + key_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + fd = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as f: + f.write(seed) identity = local_identity_type(seed) config_payload = {"node": {"name": node_name}} if node_config: diff --git a/tests/unit/test_private_files.py b/tests/unit/test_private_files.py new file mode 100644 index 0000000..9e08384 --- /dev/null +++ b/tests/unit/test_private_files.py @@ -0,0 +1,58 @@ +"""Identity seed, database and logs are owner-only (0600).""" + +from __future__ import annotations + +import os +import stat +from pathlib import Path + +from openhop_core.protocol.identity import LocalIdentity + +from meshcore_console.meshcore import logging_setup, paths +from meshcore_console.meshcore.db import open_db +from meshcore_console.meshcore.runtime import create_mesh_node + + +def mode(path: Path) -> int: + return stat.S_IMODE(path.stat().st_mode) + + +def make_node(**_kwargs: object) -> object: + return object() + + +def test_new_identity_seed_is_owner_only() -> None: + old = os.umask(0o022) # even under a permissive umask + try: + create_mesh_node(make_node, LocalIdentity, radio=None, event_service=None, node_name="n") + finally: + os.umask(old) + assert mode(paths.identity_key_path()) == 0o600 + + +def test_existing_world_readable_seed_is_tightened() -> None: + key = paths.identity_key_path() + key.parent.mkdir(parents=True) + key.write_bytes(LocalIdentity().get_signing_key_bytes()) + key.chmod(0o644) + create_mesh_node(make_node, LocalIdentity, radio=None, event_service=None, node_name="n") + assert mode(key) == 0o600 + + +def test_database_files_are_owner_only(tmp_path: Path) -> None: + db = tmp_path / "meshcore.db" + open_db(str(db)).close() + db.chmod(0o644) + conn = open_db(str(db)) + assert mode(db) == 0o600 + for extra in ("meshcore.db-wal", "meshcore.db-shm"): + if (tmp_path / extra).exists(): + assert mode(tmp_path / extra) == 0o600 + conn.close() + + +def test_log_export_is_owner_only(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(logging_setup, "LOG_DIR", tmp_path) + (tmp_path / "app.log").write_text("line\n") + dest = logging_setup.export_logs_to_path(tmp_path / "export.txt") + assert dest.read_text() == "line\n" and mode(dest) == 0o600 From 687fc6eef7aee7484bd0450d88afc328d36b91e7 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:41:21 +0100 Subject: [PATCH 09/72] fix(logging): keep message text, positions and control characters out 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 --- .../meshcore/logging_setup.py | 31 ++++++++++++++ src/meshcore_console/meshcore/session.py | 4 +- src/meshcore_console/platform/gps.py | 6 +-- src/meshcore_console/ui_gtk/debug_hooks.py | 5 ++- tests/unit/test_log_privacy.py | 40 +++++++++++++++++++ 5 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_log_privacy.py diff --git a/src/meshcore_console/meshcore/logging_setup.py b/src/meshcore_console/meshcore/logging_setup.py index 8d3e9b7..efed301 100644 --- a/src/meshcore_console/meshcore/logging_setup.py +++ b/src/meshcore_console/meshcore/logging_setup.py @@ -10,6 +10,7 @@ import logging import os +import re import shutil import sys import threading @@ -73,6 +74,7 @@ def configure_logging(console_level: str | None = None) -> None: _stderr_handler.setLevel(getattr(logging, effective_level)) _stderr_handler.setFormatter(logging.Formatter(LOG_FORMAT)) _stderr_handler.addFilter(RfNoiseFilter(_stderr_handler)) + _stderr_handler.addFilter(PrivacyFilter()) root.addHandler(_stderr_handler) # Rotating file handler @@ -81,6 +83,7 @@ def configure_logging(console_level: str | None = None) -> None: make_private(*get_log_files_chronological()) file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(logging.Formatter(FILE_LOG_FORMAT)) + file_handler.addFilter(PrivacyFilter()) root.addHandler(file_handler) _configured = True @@ -232,6 +235,34 @@ def get_rf_noise_counts() -> dict[str, int]: return _rf_noise_counter.totals() +# openhop_core's Dispatcher logs every decrypted message at INFO; keep who and +# where, drop what was said. +_WITHHELD = ( + (re.compile(r"^(Received TXT_MSG: ).*", re.S), r"\1(text withheld)"), + (re.compile(r"^(<<< Channel \[[^\]]*\] [^:]*: ).*( >>>)$", re.S), r"\1(text withheld)\2"), +) + + +class PrivacyFilter(logging.Filter): + """Withhold decrypted message text and neutralise control characters. + + Message text and node names come from the air: a newline or an escape + sequence in them would forge log lines or drive the terminal reading the log. + """ + + def filter(self, record: logging.LogRecord) -> bool: + message = record.getMessage() + if record.name == "Dispatcher": + for pattern, replacement in _WITHHELD: + message = pattern.sub(replacement, message) + cleaned = "".join( + ch if ch.isprintable() else ch.encode("unicode_escape").decode("ascii") + for ch in message + ) + record.msg, record.args = cleaned, None + return True + + class RfNoiseFilter(logging.Filter): """Drops RF reception noise from the handler it is attached to. diff --git a/src/meshcore_console/meshcore/session.py b/src/meshcore_console/meshcore/session.py index 5509584..1d47131 100644 --- a/src/meshcore_console/meshcore/session.py +++ b/src/meshcore_console/meshcore/session.py @@ -186,7 +186,9 @@ def _handle_telemetry(client: Any, _timestamp: int, req_data: bytes) -> bytes | if not payload: self._log("telemetry request: no sensor data available") return None - self._log(f"responding to telemetry request (voltage={voltage}, lat={lat}, lon={lon})") + self._log( + f"responding to telemetry request (voltage={voltage}, location={lat is not None})" + ) return payload return _handle_telemetry diff --git a/src/meshcore_console/platform/gps.py b/src/meshcore_console/platform/gps.py index 914532b..09cb2be 100644 --- a/src/meshcore_console/platform/gps.py +++ b/src/meshcore_console/platform/gps.py @@ -190,7 +190,7 @@ def _update_location(self, lat: float, lon: float) -> None: return if not self._has_fix: self._has_fix = True - logger.info("GPS: fix acquired: %.6f, %.6f", lat, lon) + logger.info("GPS: fix acquired") # never log the position itself self._latitude = lat self._longitude = lon if self._callback: @@ -202,7 +202,7 @@ def _update_location_if_changed(self, lat: float, lon: float) -> None: return if not self._has_fix: self._has_fix = True - logger.info("GPS: fix acquired: %.6f, %.6f", lat, lon) + logger.info("GPS: fix acquired") # never log the position itself old_lat, old_lon = self._latitude, self._longitude self._latitude = lat self._longitude = lon @@ -463,7 +463,7 @@ def _update_location(self, lat: float, lon: float) -> None: return if not self._has_fix: self._has_fix = True - logger.info("GpsdProvider: fix acquired: %.6f, %.6f", lat, lon) + logger.info("GpsdProvider: fix acquired") # never log the position itself self._latitude = lat self._longitude = lon if self._callback: diff --git a/src/meshcore_console/ui_gtk/debug_hooks.py b/src/meshcore_console/ui_gtk/debug_hooks.py index 18eb391..e6dee26 100644 --- a/src/meshcore_console/ui_gtk/debug_hooks.py +++ b/src/meshcore_console/ui_gtk/debug_hooks.py @@ -39,8 +39,9 @@ def _widget_label(widget: Gtk.Widget) -> str: if text: return text - # Child label (icon buttons with a Label child) - if hasattr(widget, "get_child"): + # Child label (icon buttons with a Label child). Only buttons: other + # containers (message bubbles, peer rows) hold text from the air. + if isinstance(widget, Gtk.Button) and hasattr(widget, "get_child"): child = widget.get_child() if isinstance(child, Gtk.Label): return child.get_text() or "" diff --git a/tests/unit/test_log_privacy.py b/tests/unit/test_log_privacy.py new file mode 100644 index 0000000..e0eaf26 --- /dev/null +++ b/tests/unit/test_log_privacy.py @@ -0,0 +1,40 @@ +"""Logs keep message text, positions and control characters out.""" + +from __future__ import annotations + +import logging + +from meshcore_console.meshcore.logging_setup import PrivacyFilter +from meshcore_console.platform.gps import GpsdProvider + + +def filtered(name: str, msg: str, *args: object) -> str: + record = logging.LogRecord(name, logging.INFO, __file__, 1, msg, args or None, None) + assert PrivacyFilter().filter(record) + return record.getMessage() + + +def test_decrypted_message_text_is_withheld() -> None: + assert filtered("Dispatcher", "Received TXT_MSG: meet at the van 21:00") == ( + "Received TXT_MSG: (text withheld)" + ) + assert filtered("Dispatcher", "<<< Channel [ops] Bob: the code is 1234 >>>") == ( + "<<< Channel [ops] Bob: (text withheld) >>>" + ) + # other Dispatcher lines and other loggers keep their text + assert filtered("Dispatcher", "TX %d bytes", 108) == "TX 108 bytes" + assert filtered("app", "Received TXT_MSG: x") == "Received TXT_MSG: x" + + +def test_control_characters_cannot_forge_lines_or_drive_the_terminal() -> None: + out = filtered("app", "peer %s", "hi\n2026-10-27 INFO fake\x1b]0;pwned\x07") + assert "\n" not in out and "\x1b" not in out and "\x07" not in out + assert out.startswith("peer hi\\n2026-10-27 INFO fake\\x1b") + + +def test_gps_fix_is_logged_without_the_position(caplog) -> None: + gps = GpsdProvider(host="127.0.0.1", port=1) + with caplog.at_level(logging.DEBUG): + gps._update_location(52.516389, 13.377778) + assert "fix acquired" in caplog.text + assert "52.5" not in caplog.text and "13.3" not in caplog.text From 3c75e5810b6fead4234b43801fa725fa86541c65 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:41:56 +0100 Subject: [PATCH 10/72] fix(privacy): send the position in telemetry replies only when it is 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 --- src/meshcore_console/meshcore/client.py | 8 +++-- tests/unit/test_telemetry_privacy.py | 46 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_telemetry_privacy.py diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 42773a0..ed9e819 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -1139,8 +1139,12 @@ def delete_saved_repeater_password(self, peer_name: str) -> None: self._repeater_password_store.delete_password(peer_name) def _get_local_telemetry(self) -> dict: - """Provide local telemetry data for inbound requests.""" - pos = self._resolve_position(use_settings=self._settings.allow_telemetry) + """Provide local telemetry data for inbound requests. + + The position goes out only when the user shares it (the same switch + adverts use); otherwise any node could ask where this device is. + """ + pos = self._resolve_position() if self._settings.share_position else None return { "allow": self._settings.allow_telemetry, "favorites_only": self._settings.telemetry_favorites_only, diff --git a/tests/unit/test_telemetry_privacy.py b/tests/unit/test_telemetry_privacy.py new file mode 100644 index 0000000..815e42c --- /dev/null +++ b/tests/unit/test_telemetry_privacy.py @@ -0,0 +1,46 @@ +"""Telemetry replies carry the position only when the user shares it.""" + +from __future__ import annotations + +from meshcore_console.meshcore.db import open_db + + +class FixedGps: + def get_location(self) -> tuple[float, float]: + return 52.516389, 13.377778 + + def start(self) -> None: ... + + def stop(self) -> None: ... + + +def _client(tmp_path, monkeypatch, **settings): + from meshcore_console.meshcore import client as client_mod + from meshcore_console.meshcore.client import MeshcoreClient + from meshcore_console.meshcore.config import runtime_config_from_settings + from meshcore_console.meshcore.settings import MeshcoreSettings + from meshcore_console.mock import MockOpenHopCoreSession + + db_conn = open_db(str(tmp_path / "test.db")) + monkeypatch.setattr(client_mod, "open_db", lambda *a, **k: db_conn) + client = MeshcoreClient( + session=MockOpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())), + require_openhop=False, + gps_provider=FixedGps(), + ) + for key, value in settings.items(): + setattr(client._settings, key, value) + return client + + +def test_no_position_unless_shared(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch, allow_telemetry=True, share_position=False) + data = client._get_local_telemetry() + assert data["allow"] is True + assert data["lat"] is None and data["lon"] is None + + +def test_position_when_shared(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch, allow_telemetry=True, share_position=True) + data = client._get_local_telemetry() + assert (data["lat"], data["lon"]) == (52.516389, 13.377778) From 5cc2eb6b43dbf1bda4625a88321aa86812c7c1b1 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:43:11 +0100 Subject: [PATCH 11/72] fix(privacy): leave anonymous requests to openhop_core's handler 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 --- src/meshcore_console/meshcore/session.py | 95 ++---------------------- tests/unit/test_request_handlers.py | 52 +++++++++++++ 2 files changed, 57 insertions(+), 90 deletions(-) create mode 100644 tests/unit/test_request_handlers.py diff --git a/src/meshcore_console/meshcore/session.py b/src/meshcore_console/meshcore/session.py index 1d47131..85a6b2e 100644 --- a/src/meshcore_console/meshcore/session.py +++ b/src/meshcore_console/meshcore/session.py @@ -194,26 +194,23 @@ def _handle_telemetry(client: Any, _timestamp: int, req_data: bytes) -> bytes | return _handle_telemetry def _register_req_handler(self) -> None: - """Register handlers for incoming REQ and ANON_REQ packets. + """Register a handler for incoming REQ packets. openhop_core's register_default_handlers() does not register a handler for PAYLOAD_TYPE_REQ (0x00), so incoming requests are silently dropped. We register ProtocolRequestHandler here and wrap it so that any generated RESPONSE packet is actually transmitted. - ANON_REQ (0x07) packets carry the sender's public key inline, so - they don't require prior key exchange. The default AnonReqResponseHandler - only handles login responses; we replace it with one that also handles - telemetry requests. + ANON_REQ (0x07) stays with openhop_core's own handler: in an anonymous + request 0x03 is ANON_REQ_TYPE_BASIC (clock), not a telemetry request, + and telemetry is only for senders already in the contact book. """ import struct from openhop_core.node.handlers.protocol_request import ProtocolRequestHandler - from openhop_core.protocol import CryptoUtils, Identity, PacketBuilder + from openhop_core.protocol import CryptoUtils, Identity from openhop_core.protocol.constants import ( - PAYLOAD_TYPE_ANON_REQ, PAYLOAD_TYPE_REQ, - PAYLOAD_TYPE_RESPONSE, ) REQ_TYPE_GET_TELEMETRY_DATA = 0x03 # noqa: N806 @@ -313,88 +310,6 @@ async def _handle_req(pkt: Any) -> None: dispatcher.register_handler(PAYLOAD_TYPE_REQ, _handle_req) - # --- ANON_REQ handler --- - existing_anon = dispatcher._handler_instances.get(PAYLOAD_TYPE_ANON_REQ) - - async def _handle_anon_req(pkt: Any) -> None: - if len(pkt.payload) < 34: - return - - dest_hash = pkt.payload[0] - if dest_hash != our_hash: - # Not addressed to us — delegate to existing handler - # (e.g. login responses where dest_hash is a repeater) - if existing_anon is not None: - await existing_anon(pkt) - return - - client_pubkey = bytes(pkt.payload[1:33]) - encrypted_data = bytes(pkt.payload[33:]) - client_identity = Identity(client_pubkey) - shared_secret = client_identity.calc_shared_secret(our_privkey) - aes_key = shared_secret[:16] - - try: - plaintext = CryptoUtils.mac_then_decrypt( - aes_key, - shared_secret, - encrypted_data, - ) - except Exception as e: - self._log(f"ANON_REQ decrypt failed: {e}") - if existing_anon is not None: - await existing_anon(pkt) - return - - if len(plaintext) < 5: - self._log("ANON_REQ payload too short") - if existing_anon is not None: - await existing_anon(pkt) - return - - timestamp = struct.unpack(" 5 else b"" - - handler_fn = request_handlers.get(req_type) - if handler_fn is None: - # Not a request type we handle (e.g. login) — delegate - if existing_anon is not None: - await existing_anon(pkt) - return - - contact = contact_book.get_by_key(client_pubkey) - sender = contact.name if contact else f"{client_pubkey[:4].hex()}..." - self._log(f"ANON_REQ from {sender} type=0x{req_type:02X} ts={timestamp}") - - response_payload = handler_fn(contact, timestamp, req_data) - if response_payload is None: - self._log("ANON_REQ: handler returned no data") - return - - reply_data = struct.pack(" 0 else [] - - try: - response_pkt = PacketBuilder.create_path_return( - dest_hash=client_hash, - src_hash=our_hash, - secret=shared_secret, - path=path_list, - extra_type=PAYLOAD_TYPE_RESPONSE, - extra=bytes(reply_data), - ) - self._log( - f"ANON_REQ: sending PATH response to " - f"0x{client_hash:02X} ({len(response_pkt.payload)} bytes)" - ) - await dispatcher.send_packet(response_pkt, wait_for_ack=False) - except Exception as e: - self._log(f"ANON_REQ: failed to build/send response: {e}") - - dispatcher.register_handler(PAYLOAD_TYPE_ANON_REQ, _handle_anon_req) - async def _call_maybe_async(self, target: Any, name: str) -> None: fn = getattr(target, name, None) if not callable(fn): diff --git a/tests/unit/test_request_handlers.py b/tests/unit/test_request_handlers.py new file mode 100644 index 0000000..a921a41 --- /dev/null +++ b/tests/unit/test_request_handlers.py @@ -0,0 +1,52 @@ +"""Which inbound requests the node answers, checked on a real openhop MeshNode.""" + +from __future__ import annotations + +from openhop_core.node.node import MeshNode +from openhop_core.protocol.constants import PAYLOAD_TYPE_ANON_REQ, PAYLOAD_TYPE_REQ +from openhop_core.protocol.identity import LocalIdentity + +from meshcore_console.meshcore.config import runtime_config_from_settings +from meshcore_console.meshcore.session import OpenHopCoreSession +from meshcore_console.meshcore.settings import MeshcoreSettings + + +class FakeRadio: + def set_rx_callback(self, callback) -> None: + self.callback = callback + + async def send(self, raw: bytes) -> dict: + return {"ok": True} + + def get_last_rssi(self) -> int: + return -50 + + def get_last_snr(self) -> float: + return 5.0 + + +def session_on_real_node() -> tuple[OpenHopCoreSession, MeshNode]: + session = OpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())) + identity = LocalIdentity() + node = MeshNode( + FakeRadio(), + identity, + config={"node": {"name": "me"}}, + contacts=session.contact_book, + channel_db=None, + ) + session._node, session._identity = node, identity + return session, node + + +def test_anonymous_requests_stay_with_openhop() -> None: + """In an ANON_REQ, 0x03 is ANON_REQ_TYPE_BASIC (clock), not telemetry; the + app used to replace openhop's rate-limited handler and answer it with + battery and position to any stranger.""" + session, node = session_on_real_node() + anon_before = node.dispatcher._handler_instances.get(PAYLOAD_TYPE_ANON_REQ) + + session._register_req_handler() + + assert node.dispatcher._handler_instances.get(PAYLOAD_TYPE_ANON_REQ) is anon_before + assert node.dispatcher._handler_instances.get(PAYLOAD_TYPE_REQ) is not None From d3e00d5b71a69241370f829b3f0d39e0663cefe2 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:44:02 +0100 Subject: [PATCH 12/72] fix(privacy): rate-limit discovery answers and honour the node-type filter 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 --- src/meshcore_console/meshcore/discovery.py | 26 +++++++++++++++ src/meshcore_console/meshcore/session.py | 29 +++++++++++++--- tests/unit/test_request_handlers.py | 39 ++++++++++++++++++++++ 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/meshcore_console/meshcore/discovery.py b/src/meshcore_console/meshcore/discovery.py index 26f05a9..ec9fd3f 100644 --- a/src/meshcore_console/meshcore/discovery.py +++ b/src/meshcore_console/meshcore/discovery.py @@ -16,6 +16,7 @@ import asyncio import secrets +import time from typing import Any, Callable # ADV_TYPE_* node types, as carried in the low nibble of a DISCOVER_RESP. @@ -34,6 +35,31 @@ PUB_KEY_HEX_LEN = 64 +class AnswerLimiter: + """At most ``limit`` discovery answers per ``window_s`` seconds. + + Mirrors the firmware repeater's ``discover_limiter(4, 120)``: anyone in + radio range can send discover requests, and each answer costs airtime + (EU868: 10 % duty cycle) and battery. + """ + + def __init__( + self, limit: int = 4, window_s: float = 120.0, clock: Callable[[], float] | None = None + ) -> None: + self._limit = limit + self._window_s = window_s + self._clock = clock or time.monotonic + self._sent: list[float] = [] + + def allow(self) -> bool: + now = self._clock() + self._sent = [t for t in self._sent if now - t < self._window_s] + if len(self._sent) >= self._limit: + return False + self._sent.append(now) + return True + + def wants_node_type(filter_mask: int, node_type: int) -> bool: """True if a discover request with ``filter_mask`` asks for ``node_type``.""" return bool(filter_mask & (1 << node_type)) diff --git a/src/meshcore_console/meshcore/session.py b/src/meshcore_console/meshcore/session.py index 85a6b2e..c811b34 100644 --- a/src/meshcore_console/meshcore/session.py +++ b/src/meshcore_console/meshcore/session.py @@ -5,6 +5,7 @@ import inspect import logging import queue +import random import threading import time from typing import Any, AsyncIterator, Callable @@ -42,7 +43,13 @@ from .config import RuntimeRadioConfig, load_hardware_config_from_env from .contact_book import Contact, ContactBook from .db import open_db -from .discovery import decode_owner_reply, discover +from .discovery import ( + NODE_TYPE_CHAT, + AnswerLimiter, + decode_owner_reply, + discover, + wants_node_type, +) from .event_bridge import attach_dispatcher_callbacks, attach_event_service_subscriber from .operations import ( request_owner_info, @@ -66,6 +73,7 @@ def __init__(self, config: RuntimeRadioConfig, logger: LoggerCallback | None = N self._logger = logger self._radio: SX1262RadioProtocol | None = None self._identity: LocalIdentityProtocol | None = None + self._background_tasks: set[asyncio.Task[None]] = set() self._node: MeshNodeProtocol | None = None self._event_service: EventServiceProtocol | None = None self._event_subscriber: EventSubscriberProtocol | None = None @@ -107,8 +115,10 @@ def _emit(self, payload: MeshEventDict) -> None: def _register_discovery_handler(self) -> None: """Register a handler that responds to incoming discovery requests. - When another node sends a CONTROL discovery request, we reply with - our public key and node type so they can discover us. + When another node sends a CONTROL discovery request for chat nodes, we + reply with our public key and node type so they can discover us, at + most 4 times per 2 minutes (like firmware repeaters) and after a random + delay so simultaneous answerers don't collide. """ from openhop_core.protocol.constants import ADVERT_FLAG_IS_CHAT_NODE from openhop_core.protocol.packet_builder import PacketBuilder @@ -121,8 +131,14 @@ def _register_discovery_handler(self) -> None: # Capture identity ref for the closure identity = self._identity + limiter = AnswerLimiter() def _on_discovery_request(request_data: dict) -> None: + if not wants_node_type(request_data.get("filter", 0), NODE_TYPE_CHAT): + return # e.g. a repeater's discover.neighbors, which asks for repeaters + if not limiter.allow(): + self._log("discovery request not answered (rate limit)") + return tag = request_data.get("tag", 0) prefix_only = request_data.get("prefix_only", False) inbound_snr = request_data.get("snr", 0.0) @@ -140,6 +156,7 @@ def _on_discovery_request(request_data: dict) -> None: ) async def _send() -> None: + await asyncio.sleep(random.uniform(0.2, 2.0)) try: await dispatcher.send_packet(response, wait_for_ack=False) self._log(f"sent discovery response for tag=0x{tag:08X}") @@ -148,9 +165,11 @@ async def _send() -> None: try: loop = asyncio.get_running_loop() - loop.create_task(_send()) except RuntimeError: - pass # No running loop — skip + return # No running loop — skip + task = loop.create_task(_send()) + self._background_tasks.add(task) # keep a reference until it is done + task.add_done_callback(self._background_tasks.discard) control_handler.set_request_callback(_on_discovery_request) diff --git a/tests/unit/test_request_handlers.py b/tests/unit/test_request_handlers.py index a921a41..8c23085 100644 --- a/tests/unit/test_request_handlers.py +++ b/tests/unit/test_request_handlers.py @@ -50,3 +50,42 @@ def test_anonymous_requests_stay_with_openhop() -> None: assert node.dispatcher._handler_instances.get(PAYLOAD_TYPE_ANON_REQ) is anon_before assert node.dispatcher._handler_instances.get(PAYLOAD_TYPE_REQ) is not None + + +def test_discovery_answers_are_filtered_and_rate_limited(monkeypatch) -> None: + import asyncio + import random + + from meshcore_console.meshcore.discovery import NODE_TYPE_CHAT, NODE_TYPE_REPEATER + + monkeypatch.setattr(random, "uniform", lambda _a, _b: 0.0) + session, node = session_on_real_node() + sent: list = [] + + async def send_packet(packet, wait_for_ack=False, **_kw) -> bool: + sent.append(packet) + return True + + monkeypatch.setattr(node.dispatcher, "send_packet", send_packet) + session._register_discovery_handler() + on_request = node.dispatcher.control_handler._request_callbacks[0] + + async def burst() -> None: + on_request({"tag": 1, "filter": 1 << NODE_TYPE_REPEATER, "snr": 5.0}) # not for us + for tag in range(6): + on_request({"tag": tag, "filter": 1 << NODE_TYPE_CHAT, "snr": 5.0}) + await asyncio.sleep(0.05) + + asyncio.run(burst()) + assert len(sent) == 4 # firmware's 4 answers per 2 minutes + assert not session._background_tasks + + +def test_answer_limiter_window() -> None: + from meshcore_console.meshcore.discovery import AnswerLimiter + + now = [0.0] + limiter = AnswerLimiter(limit=2, window_s=10.0, clock=lambda: now[0]) + assert [limiter.allow() for _ in range(3)] == [True, True, False] + now[0] = 10.5 + assert limiter.allow() is True From 425519368261f562c0211141ec691cb5cac2dfdf Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:44:46 +0100 Subject: [PATCH 13/72] fix(ui): show toast text literally on libadwaita 1.2 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 " 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 --- src/meshcore_console/ui_gtk/compat.py | 14 +++++++++- src/meshcore_console/ui_gtk/views/map.py | 4 +-- .../ui_gtk/windows/main_window.py | 27 +++++++++++-------- tests/unit/test_toast.py | 15 +++++++++++ 4 files changed, 46 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_toast.py diff --git a/src/meshcore_console/ui_gtk/compat.py b/src/meshcore_console/ui_gtk/compat.py index 4a50517..7335bdd 100644 --- a/src/meshcore_console/ui_gtk/compat.py +++ b/src/meshcore_console/ui_gtk/compat.py @@ -18,7 +18,7 @@ gi.require_version("Adw", "1") gi.require_version("Gtk", "4.0") -from gi.repository import Adw, Gtk # noqa: E402 +from gi.repository import Adw, GLib, Gtk # noqa: E402 #: ``Adw.ToolbarView`` exists since libadwaita 1.4. HAS_TOOLBAR_VIEW = hasattr(Adw, "ToolbarView") @@ -26,6 +26,18 @@ HAS_ALERT_DIALOG = hasattr(Adw, "AlertDialog") +def toast_title(text: str) -> str: + """Escape text for a toast title: libadwaita < 1.4 always parses it as Pango + markup (``use-markup`` only arrived in 1.4), and toasts carry node names from + the air, so ``R&D `` would blank the toast or restyle it.""" + return GLib.markup_escape_text(text, -1) + + +def new_toast(text: str) -> Adw.Toast: + """An Adw.Toast showing ``text`` literally on every libadwaita version.""" + return Adw.Toast.new(toast_title(text)) + + def build_toolbar_page(header_bar: Gtk.Widget, content: Gtk.Widget) -> Gtk.Widget: """Return a page with ``header_bar`` above ``content``. diff --git a/src/meshcore_console/ui_gtk/views/map.py b/src/meshcore_console/ui_gtk/views/map.py index 53e42dd..9c12318 100644 --- a/src/meshcore_console/ui_gtk/views/map.py +++ b/src/meshcore_console/ui_gtk/views/map.py @@ -35,6 +35,7 @@ from meshcore_console.core.time import to_local from meshcore_console.platform.mbtiles import MBTilesReader, find_mbtiles_files from meshcore_console.ui_gtk.widgets import DetailRow, EmptyState +from meshcore_console.ui_gtk.compat import new_toast # Tile URL templates - Shumate uses {z}, {x}, {y} placeholders @@ -348,7 +349,6 @@ def _show_toast(self, message: str) -> None: import gi gi.require_version("Adw", "1") - from gi.repository import Adw root = self.get_root() if root is None: @@ -357,7 +357,7 @@ def _show_toast(self, message: str) -> None: # Look for toast overlay on the main window toast_overlay = getattr(root, "_toast_overlay", None) if toast_overlay is not None and hasattr(toast_overlay, "add_toast"): - toast_overlay.add_toast(Adw.Toast.new(message)) + toast_overlay.add_toast(new_toast(message)) except Exception as e: print(f"[MapView] Toast error: {e}", file=sys.stderr) diff --git a/src/meshcore_console/ui_gtk/windows/main_window.py b/src/meshcore_console/ui_gtk/windows/main_window.py index bfadede..4a782f1 100644 --- a/src/meshcore_console/ui_gtk/windows/main_window.py +++ b/src/meshcore_console/ui_gtk/windows/main_window.py @@ -21,7 +21,12 @@ from meshcore_console.core.services import MeshcoreService from meshcore_console.meshcore.discovery import summarize from meshcore_console.platform.conflicts import ConflictError, ConflictReport -from meshcore_console.ui_gtk.compat import build_alert_dialog, build_toolbar_page, present_dialog +from meshcore_console.ui_gtk.compat import ( + build_alert_dialog, + build_toolbar_page, + new_toast, + present_dialog, +) from meshcore_console.ui_gtk.state import UiEventStore from meshcore_console.ui_gtk.widgets import ConflictScreen, LoadingScreen, StatusPill @@ -331,10 +336,10 @@ def _on_discover(self, _button: Gtk.Button, popover: Gtk.Popover) -> None: logger.debug("UI: discover button clicked") popover.popdown() if self._discovering: - self._toast_overlay.add_toast(Adw.Toast.new("Discover is still listening")) + self._toast_overlay.add_toast(new_toast("Discover is still listening")) return self._discovering = True - self._toast_overlay.add_toast(Adw.Toast.new("Discovering nearby nodes...")) + self._toast_overlay.add_toast(new_toast("Discovering nearby nodes...")) def do_discover() -> None: text: str @@ -347,7 +352,7 @@ def do_discover() -> None: def on_done() -> bool: self._discovering = False - self._toast_overlay.add_toast(Adw.Toast.new(text)) + self._toast_overlay.add_toast(new_toast(text)) return False GLib.idle_add(on_done) @@ -366,10 +371,10 @@ def on_done() -> bool: if error: logger.error("Advert failed: %s", error) logger.debug("UI: toast 'Advert failed: %s'", error) - self._toast_overlay.add_toast(Adw.Toast.new(f"Advert failed: {error}")) + self._toast_overlay.add_toast(new_toast(f"Advert failed: {error}")) else: logger.debug("UI: toast 'Sent %s advert'", route_type) - self._toast_overlay.add_toast(Adw.Toast.new(f"Sent {route_type} advert")) + self._toast_overlay.add_toast(new_toast(f"Sent {route_type} advert")) return False GLib.idle_add(on_done) @@ -573,11 +578,11 @@ def _finish_connect_toggle( elif error: logger.error("Connection error: %s", error) logger.debug("UI: toast 'Connection error: %s'", error) - self._toast_overlay.add_toast(Adw.Toast.new(f"Connection error: {error}")) + self._toast_overlay.add_toast(new_toast(f"Connection error: {error}")) else: msg = "Disconnected" if was_disconnect else "Connected" logger.debug("UI: toast '%s'", msg) - self._toast_overlay.add_toast(Adw.Toast.new(msg)) + self._toast_overlay.add_toast(new_toast(msg)) self._refresh_connection_state() return False # Don't repeat @@ -652,11 +657,11 @@ def on_done() -> bool: if error: logger.error("Failed to stop %s: %s", service_name, error) self._toast_overlay.add_toast( - Adw.Toast.new(f"Could not stop {service_name}: {error}") + new_toast(f"Could not stop {service_name}: {error}") ) else: logger.info("Stopped %s, retrying connection", service_name) - self._toast_overlay.add_toast(Adw.Toast.new(f"Stopped {service_name}")) + self._toast_overlay.add_toast(new_toast(f"Stopped {service_name}")) # Auto-retry after a brief delay GLib.timeout_add(1000, self._delayed_retry) return False @@ -769,7 +774,7 @@ def _on_events_available(self, _store: object) -> None: elif etype == "radio_error": msg = (event.get("data") or {}).get("message", "Unknown radio error") if self._service.get_settings().show_radio_error_toasts: - self._toast_overlay.add_toast(Adw.Toast.new(msg)) + self._toast_overlay.add_toast(new_toast(msg)) self._flash_radio_error() self._service.flush_stores() diff --git a/tests/unit/test_toast.py b/tests/unit/test_toast.py new file mode 100644 index 0000000..3c5d6d1 --- /dev/null +++ b/tests/unit/test_toast.py @@ -0,0 +1,15 @@ +"""Toast titles show text literally (libadwaita < 1.4 parses them as markup).""" + +from __future__ import annotations + +from meshcore_console.ui_gtk.compat import toast_title + + +def test_names_from_the_air_are_escaped() -> None: + assert toast_title("Discovered 1 node: R&D ") == ( + "Discovered 1 node: R&D <Hill>" + ) + assert toast_title('x') == ( + "<span foreground="red">x</span>" + ) + assert toast_title("Sent flood advert") == "Sent flood advert" From 918d39d04ba34a1ade9c9c89a4c8639ef262c8c3 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:45:58 +0100 Subject: [PATCH 14/72] fix(ui): make Export Logs work on GTK 4.8 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 --- src/meshcore_console/ui_gtk/compat.py | 59 ++++++++++++++++++- src/meshcore_console/ui_gtk/views/settings.py | 30 ++++------ 2 files changed, 69 insertions(+), 20 deletions(-) diff --git a/src/meshcore_console/ui_gtk/compat.py b/src/meshcore_console/ui_gtk/compat.py index 7335bdd..bec5cd6 100644 --- a/src/meshcore_console/ui_gtk/compat.py +++ b/src/meshcore_console/ui_gtk/compat.py @@ -13,12 +13,14 @@ from __future__ import annotations +from typing import Callable + import gi gi.require_version("Adw", "1") gi.require_version("Gtk", "4.0") -from gi.repository import Adw, GLib, Gtk # noqa: E402 +from gi.repository import Adw, Gio, GLib, Gtk # noqa: E402 #: ``Adw.ToolbarView`` exists since libadwaita 1.4. HAS_TOOLBAR_VIEW = hasattr(Adw, "ToolbarView") @@ -38,6 +40,61 @@ def new_toast(text: str) -> Adw.Toast: return Adw.Toast.new(toast_title(text)) +def choose_save_path( + parent: Gtk.Window | None, + title: str, + initial_name: str, + patterns: tuple[str, str], + on_chosen: Callable[[str | None], None], +) -> None: + """Ask for a file to save to; call ``on_chosen`` with its path, or None. + + Gtk.FileDialog only exists from GTK 4.10; bookworm ships 4.8, where the + file chooser is Gtk.FileChooserNative. ``patterns`` is (filter name, glob). + """ + file_filter = Gtk.FileFilter() + file_filter.set_name(patterns[0]) + file_filter.add_pattern(patterns[1]) + + if hasattr(Gtk, "FileDialog"): + dialog = Gtk.FileDialog() + dialog.set_title(title) + dialog.set_initial_name(initial_name) + filters = Gio.ListStore.new(Gtk.FileFilter) + filters.append(file_filter) + dialog.set_filters(filters) + + def on_done(dlg: Gtk.FileDialog, result: Gio.AsyncResult) -> None: + try: + gfile = dlg.save_finish(result) + except GLib.Error: + on_chosen(None) # cancelled + return + on_chosen(gfile.get_path() if gfile else None) + + dialog.save(parent, None, on_done) + return + + chooser = Gtk.FileChooserNative.new( + title, parent, Gtk.FileChooserAction.SAVE, "_Save", "_Cancel" + ) + chooser.set_current_name(initial_name) + chooser.add_filter(file_filter) + + def on_response(native: Gtk.FileChooserNative, response: int) -> None: + gfile = native.get_file() if response == Gtk.ResponseType.ACCEPT else None + native.destroy() + _open_choosers.discard(native) + on_chosen(gfile.get_path() if gfile else None) + + _open_choosers.add(chooser) # a native chooser is freed if nothing holds it + chooser.connect("response", on_response) + chooser.show() + + +_open_choosers: set[Gtk.FileChooserNative] = set() + + def build_toolbar_page(header_bar: Gtk.Widget, content: Gtk.Widget) -> Gtk.Widget: """Return a page with ``header_bar`` above ``content``. diff --git a/src/meshcore_console/ui_gtk/views/settings.py b/src/meshcore_console/ui_gtk/views/settings.py index eef09de..81e7b99 100644 --- a/src/meshcore_console/ui_gtk/views/settings.py +++ b/src/meshcore_console/ui_gtk/views/settings.py @@ -6,7 +6,7 @@ gi.require_version("Gtk", "4.0") -from gi.repository import GLib, Gio, Gtk +from gi.repository import GLib, Gtk from meshcore_console.core.services import MeshcoreService from meshcore_console.meshcore.config import hardware_env_overrides, parse_pin_list @@ -22,6 +22,7 @@ apply_hardware_preset, apply_preset, ) +from meshcore_console.ui_gtk.compat import choose_save_path from meshcore_console.ui_gtk.widgets.qr_dialog import QrCodeDialog logger = logging.getLogger(__name__) @@ -450,28 +451,19 @@ def _on_export_logs(self, _button: Gtk.Button) -> None: from datetime import datetime, timezone ts = datetime.now(tz=timezone.utc).strftime("%Y%m%d-%H%M%S") - dialog = Gtk.FileDialog() - dialog.set_title("Export Logs") - dialog.set_initial_name(f"meshcore-logs-{ts}.txt") - txt_filter = Gtk.FileFilter() - txt_filter.set_name("Text files") - txt_filter.add_pattern("*.txt") - filters = Gio.ListStore.new(Gtk.FileFilter) - filters.append(txt_filter) - dialog.set_filters(filters) - window = self.get_root() parent = window if isinstance(window, Gtk.Window) else None - dialog.save(parent, None, self._on_export_save_done) + choose_save_path( + parent, + "Export Logs", + f"meshcore-logs-{ts}.txt", + ("Text files", "*.txt"), + self._on_export_path_chosen, + ) - def _on_export_save_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None: - try: - gfile = dialog.save_finish(result) - except Exception: # noqa: BLE001 - return # user cancelled - dest = gfile.get_path() + def _on_export_path_chosen(self, dest: str | None) -> None: if not dest: - return + return # user cancelled try: export_logs_to_path(dest) self._status_label.set_text(f"Logs exported to {dest}") From f5bbc6f91d364081638281cb6b56ab1be728dab7 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:46:51 +0100 Subject: [PATCH 15/72] fix(ui): close Add Channel and Region Scope dialogs when Enter confirms 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 --- src/meshcore_console/ui_gtk/compat.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/meshcore_console/ui_gtk/compat.py b/src/meshcore_console/ui_gtk/compat.py index bec5cd6..dfca25c 100644 --- a/src/meshcore_console/ui_gtk/compat.py +++ b/src/meshcore_console/ui_gtk/compat.py @@ -139,8 +139,15 @@ def present_dialog(dialog, parent: Gtk.Widget) -> None: def activate_dialog_response(dialog, response: str) -> None: - """Activate a dialog response programmatically across libadwaita versions.""" + """Activate a dialog response programmatically across libadwaita versions, + closing the dialog like a click on that response's button would. + + ``Adw.AlertDialog.choose_response`` closes the dialog, but + ``Adw.MessageDialog.response`` only emits the signal: the dialog stayed open + and a second Enter ran the response handler again. + """ if HAS_ALERT_DIALOG: dialog.choose_response(response) else: dialog.response(response) + dialog.destroy() # close() would emit a second, "close" response From 0e19dd3d5d03f1eb496a0a6c606c2a772b2c6d84 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:48:55 +0100 Subject: [PATCH 16/72] fix(ui): keep the main window within a 1024x576 screen 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 --- src/meshcore_console/ui_gtk/views/analyzer.py | 19 ++++++++++++------- src/meshcore_console/ui_gtk/views/peers.py | 8 ++++++-- .../ui_gtk/windows/main_window.py | 4 ++++ 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/meshcore_console/ui_gtk/views/analyzer.py b/src/meshcore_console/ui_gtk/views/analyzer.py index a6ec2ce..d4039d7 100644 --- a/src/meshcore_console/ui_gtk/views/analyzer.py +++ b/src/meshcore_console/ui_gtk/views/analyzer.py @@ -75,6 +75,16 @@ def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: toolbar.add_css_class("analyzer-toolbar") self.append(toolbar) + # The filters scroll sideways instead of widening the window past a + # 1024 px screen. + filters = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + filter_scroll = Gtk.ScrolledWindow() + filter_scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER) + filter_scroll.set_propagate_natural_width(True) + filter_scroll.set_hexpand(True) + filter_scroll.set_child(filters) + toolbar.append(filter_scroll) + self._filter_buttons: dict[AnalyzerFilter, Gtk.ToggleButton] = {} for filter_type in AnalyzerFilter: button = Gtk.ToggleButton.new_with_label(filter_type.value) @@ -82,7 +92,7 @@ def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: button.add_css_class(f"filter-{self._type_class(filter_type.value)}") button.connect("clicked", self._on_filter_clicked, filter_type) self._filter_buttons[filter_type] = button - toolbar.append(button) + filters.append(button) self._filter_buttons[AnalyzerFilter.ALL].set_active(True) # Dedupe toggle @@ -90,12 +100,7 @@ def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: self._dedupe_btn.add_css_class("analyzer-filter") self._dedupe_btn.set_tooltip_text("Hide duplicate packets (same hash, different hops)") self._dedupe_btn.connect("toggled", self._on_dedupe_toggled) - toolbar.append(self._dedupe_btn) - - # Spacer to push play/pause to the right - spacer = Gtk.Box() - spacer.set_hexpand(True) - toolbar.append(spacer) + filters.append(self._dedupe_btn) # Play/pause toggle button with icon self._pause = Gtk.ToggleButton() diff --git a/src/meshcore_console/ui_gtk/views/peers.py b/src/meshcore_console/ui_gtk/views/peers.py index b6cb2c8..ec8bdb8 100644 --- a/src/meshcore_console/ui_gtk/views/peers.py +++ b/src/meshcore_console/ui_gtk/views/peers.py @@ -326,8 +326,12 @@ def _show_peer_details(self, peer: Peer) -> None: if cached is not None: self._render_telemetry(cached) - # === Action buttons === - actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + # === Action buttons === (wrap rather than widen the window) + actions = Gtk.FlowBox() + actions.set_selection_mode(Gtk.SelectionMode.NONE) + actions.set_column_spacing(8) + actions.set_row_spacing(8) + actions.set_max_children_per_line(3) actions.set_margin_top(16) fav_label = "☆ Favorite" if not peer.is_favorite else "★ Unfavorite" diff --git a/src/meshcore_console/ui_gtk/windows/main_window.py b/src/meshcore_console/ui_gtk/windows/main_window.py index 4a782f1..3a2cbc0 100644 --- a/src/meshcore_console/ui_gtk/windows/main_window.py +++ b/src/meshcore_console/ui_gtk/windows/main_window.py @@ -89,6 +89,10 @@ def _build_main_ui(self) -> bool: self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) self._stack.set_hexpand(True) self._stack.set_vexpand(True) + # Size the window by the visible page, not the largest one: the + # uConsole's logical screen is 1024x576. + self._stack.set_hhomogeneous(False) + self._stack.set_vhomogeneous(False) self._stack.add_named(AnalyzerView(self._service, self._event_store, layout), "analyzer") self._stack.add_named(PeersView(self._service, self._event_store, layout), "peers") self._stack.add_named(MessagesView(self._service, self._event_store, layout), "messages") From 360f5b3ef6e70765ee22d82c503fd82d0eb8fe74 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:50:46 +0100 Subject: [PATCH 17/72] fix(ui): keep the open channel and unread counts updating 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 --- src/meshcore_console/ui_gtk/views/messages.py | 38 +++++++++++++------ tests/unit/test_messages_view_updates.py | 28 ++++++++++++++ 2 files changed, 55 insertions(+), 11 deletions(-) create mode 100644 tests/unit/test_messages_view_updates.py diff --git a/src/meshcore_console/ui_gtk/views/messages.py b/src/meshcore_console/ui_gtk/views/messages.py index cdd98fc..0a9943b 100644 --- a/src/meshcore_console/ui_gtk/views/messages.py +++ b/src/meshcore_console/ui_gtk/views/messages.py @@ -34,13 +34,28 @@ logger = logging.getLogger(__name__) +def messages_after(messages: list[Message], last_id: str | None) -> list[Message] | None: + """The messages newer than the one with ``last_id``, oldest first. + + None when ``last_id`` is no longer among ``messages`` (the window moved past + it or it was removed), meaning the view must be rebuilt. Comparing counts + instead stopped working once a channel held a full window of 100 messages. + """ + if last_id is None: + return messages + for index, message in enumerate(messages): + if message.message_id == last_id: + return messages[index + 1 :] + return None + + class MessagesView(Gtk.Box): def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: Layout) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) self._service = service self._event_store = event_store self._selected_channel_id = "public" - self._last_message_count = 0 + self._last_message_id: str | None = None self._last_channel_count = 0 self._last_message_date: str | None = None self._selected_message: Message | None = None @@ -200,12 +215,15 @@ def _poll_messages(self) -> bool: self._reload_channels() else: messages = self._service.list_messages_for_channel(self._selected_channel_id, limit=100) - new_count = len(messages) - if new_count > self._last_message_count: + new_messages = messages_after(messages, self._last_message_id) + if new_messages is None: + # The last shown message left the window (cleared, pruned, or + # more new messages than the window holds): rebuild. + self._reload_messages() + elif new_messages: # Append only the new messages instead of full rebuild - new_messages = messages[self._last_message_count :] # Remove the "no messages" placeholder if it was showing - if self._last_message_count == 0: + if self._last_message_id is None: clear_children(self._message_box) for message in new_messages: msg_date = to_local(message.created_at).strftime("%Y-%m-%d") @@ -215,14 +233,12 @@ def _poll_messages(self) -> bool: self._message_box.append( MessageBubble(message, self._service, self._on_message_clicked) ) - self._last_message_count = new_count + self._last_message_id = new_messages[-1].message_id if self._is_at_bottom: GLib.idle_add(self._scroll_to_bottom) self._service.mark_channel_read(self._selected_channel_id) - self._update_channel_unread_display(self._selected_channel_id, 0) - elif new_count < self._last_message_count: - # Message count decreased (e.g. clear) - full rebuild - self._reload_messages() + for channel in self._service.list_channels(): + self._update_channel_unread_display(channel.channel_id, channel.unread_count) self._refresh_compose_state() return True @@ -348,7 +364,7 @@ def _reload_messages(self) -> None: break messages = self._service.list_messages_for_channel(self._selected_channel_id, limit=100) - self._last_message_count = len(messages) + self._last_message_id = messages[-1].message_id if messages else None if not messages: self._last_message_date = None diff --git a/tests/unit/test_messages_view_updates.py b/tests/unit/test_messages_view_updates.py new file mode 100644 index 0000000..1f83874 --- /dev/null +++ b/tests/unit/test_messages_view_updates.py @@ -0,0 +1,28 @@ +"""The Messages view finds new messages by id, not by count.""" + +from __future__ import annotations + +from meshcore_console.core.models import Message +from meshcore_console.ui_gtk.views.messages import messages_after + + +def window(first: int, last: int) -> list[Message]: + return [Message(message_id=f"m{i}", sender_id="Bob", body=str(i)) for i in range(first, last)] + + +def test_new_messages_after_a_full_window() -> None: + # 100 shown (m0..m99); one arrives: the newest-100 window is m1..m100 + assert [m.message_id for m in messages_after(window(1, 101), "m99")] == ["m100"] + + +def test_nothing_new() -> None: + assert messages_after(window(0, 100), "m99") == [] + + +def test_everything_is_new_when_nothing_was_shown() -> None: + assert len(messages_after(window(0, 3), None)) == 3 + + +def test_rebuild_when_the_last_shown_message_left_the_window() -> None: + assert messages_after(window(150, 250), "m99") is None # >100 arrived at once + assert messages_after([], "m99") is None # channel cleared From e8cb316b29787dcc70633b36b1ca98254b40d8ca Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:51:23 +0100 Subject: [PATCH 18/72] fix(settings): allow a fixed position to be cleared or set to 0 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 --- src/meshcore_console/ui_gtk/views/settings.py | 34 +++++++++++++++++-- tests/unit/test_settings_coordinates.py | 24 +++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_settings_coordinates.py diff --git a/src/meshcore_console/ui_gtk/views/settings.py b/src/meshcore_console/ui_gtk/views/settings.py index 81e7b99..3c22d43 100644 --- a/src/meshcore_console/ui_gtk/views/settings.py +++ b/src/meshcore_console/ui_gtk/views/settings.py @@ -37,6 +37,26 @@ def _abbreviate_key(key: str | None) -> str: return f"{key[:8]}...{key[-8:]}" +def parse_coordinate(text: str, key: str, limit: float) -> float: + """A fixed-position coordinate from the settings form. + + Empty means "no fixed position" (0.0, which the app treats as unset) and an + explicit 0 is a valid value: the old ``parsed or current`` kept the previous + position in both cases, so a fixed position, once entered, could not be + removed and kept going out in adverts. + """ + text = text.strip() + if not text: + return 0.0 + try: + value = float(text) + except ValueError as exc: + raise ValueError(f"{key} must be a number") from exc + if not -limit <= value <= limit: + raise ValueError(f"{key} must be between -{limit:g} and {limit:g}") + return value + + class SettingsView(Gtk.Box): """Settings view matching official MeshCore app layout.""" @@ -649,8 +669,8 @@ def _collect_settings(self, allow_partial: bool = False) -> MeshcoreSettings: # Public Info out.node_name = self._entries["node_name"].get_text().strip() or current.node_name - out.latitude = self._parse_float("latitude", allow_partial) or current.latitude - out.longitude = self._parse_float("longitude", allow_partial) or current.longitude + out.latitude = self._parse_coordinate("latitude", 90.0, allow_partial, current.latitude) + out.longitude = self._parse_coordinate("longitude", 180.0, allow_partial, current.longitude) out.share_position = self._switches["share_position"].get_active() out.allow_telemetry = self._switches["allow_telemetry"].get_active() out.telemetry_favorites_only = self._switches["telemetry_favorites_only"].get_active() @@ -707,6 +727,16 @@ def _collect_settings(self, allow_partial: bool = False) -> MeshcoreSettings: return out + def _parse_coordinate( + self, key: str, limit: float, allow_partial: bool, fallback: float + ) -> float: + try: + return parse_coordinate(self._entries[key].get_text(), key, limit) + except ValueError: + if allow_partial: + return fallback + raise + def _parse_float(self, key: str, allow_partial: bool) -> float | None: text = self._entries[key].get_text().strip() if not text: diff --git a/tests/unit/test_settings_coordinates.py b/tests/unit/test_settings_coordinates.py new file mode 100644 index 0000000..ce30b0b --- /dev/null +++ b/tests/unit/test_settings_coordinates.py @@ -0,0 +1,24 @@ +"""A fixed position can be cleared and set to 0 in the settings form.""" + +from __future__ import annotations + +import pytest + +from meshcore_console.ui_gtk.views.settings import parse_coordinate + + +def test_empty_clears_the_fixed_position() -> None: + assert parse_coordinate("", "latitude", 90.0) == 0.0 + assert parse_coordinate(" ", "longitude", 180.0) == 0.0 + + +def test_values_including_zero() -> None: + assert parse_coordinate("0", "latitude", 90.0) == 0.0 + assert parse_coordinate(" 51.3397 ", "latitude", 90.0) == 51.3397 + assert parse_coordinate("-12.5", "longitude", 180.0) == -12.5 + + +def test_rejects_garbage_and_out_of_range() -> None: + for text, limit in (("north", 90.0), ("91", 90.0), ("-180.5", 180.0)): + with pytest.raises(ValueError): + parse_coordinate(text, "x", limit) From bd7350b2dd34cb728b15dde1b86247bc96a9a75c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:53:06 +0100 Subject: [PATCH 19/72] fix(analyzer): keep collecting packets while paused, and stop inventing 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 --- src/meshcore_console/ui_gtk/views/analyzer.py | 38 ++++++++++++++----- tests/unit/test_analyzer_signal.py | 15 ++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 tests/unit/test_analyzer_signal.py diff --git a/src/meshcore_console/ui_gtk/views/analyzer.py b/src/meshcore_console/ui_gtk/views/analyzer.py index d4039d7..f62de73 100644 --- a/src/meshcore_console/ui_gtk/views/analyzer.py +++ b/src/meshcore_console/ui_gtk/views/analyzer.py @@ -25,6 +25,15 @@ from meshcore_console.ui_gtk.widgets.node_badge import STYLE_DEFAULT, STYLE_SELF +def signal_part(value: float | None, fmt: str) -> str: + return fmt.format(value) if value is not None else "n/a" + + +def signal_text(rssi: int | None, snr: float | None) -> str: + """Row label "RSSI / SNR", with n/a for values that weren't measured.""" + return f"{signal_part(rssi, '{:d}')} / {signal_part(snr, '{:.2f}')}" + + @dataclass(slots=True) class PacketRecord: timestamp: str @@ -32,8 +41,8 @@ class PacketRecord: packet_type: str node: str content: str - rssi: int - snr: float + rssi: int | None # None: not measured (e.g. our own TX) + snr: float | None packet_id: str raw_hex: str payload_text: str @@ -67,6 +76,7 @@ def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: self._packets: deque[PacketRecord] = deque(maxlen=400) self._selected_packet: PacketRecord | None = None self._paused = False + self._render_pending = False self._dedupe = False self._active_filter = AnalyzerFilter.ALL @@ -203,6 +213,9 @@ def _on_pause_toggled(self, button: Gtk.ToggleButton) -> None: else: self._pause_icon.set_from_icon_name("media-playback-pause-symbolic") button.set_tooltip_text("Pause stream") + if self._render_pending: + self._render_pending = False + self._refresh_all() def _on_dedupe_toggled(self, button: Gtk.ToggleButton) -> None: self._dedupe = button.get_active() @@ -216,8 +229,9 @@ def _on_filter_clicked(self, _button: Gtk.ToggleButton, filter_type: AnalyzerFil self._refresh_all() def _poll_events(self) -> bool: - if self._paused: - return True + # Keep collecting while paused (only rendering stops): the event store + # keeps a bounded window, so a paused cursor fell behind and the + # packets received meanwhile were skipped on resume. self._cursor, events = self._event_store.since(self._cursor, limit=200) if not events: return True @@ -239,7 +253,10 @@ def _poll_events(self) -> bool: self._packets.appendleft(record) new_records.append(record) if new_records: - self._append_new_rows(new_records) + if self._paused: + self._render_pending = True + else: + self._append_new_rows(new_records) if self._geom_debug: print(f"[ui-geom] analyzer post-refresh packets={len(self._packets)}") self._debug_width_report("post") @@ -405,8 +422,10 @@ def _event_to_record(self, event: dict[str, object]) -> PacketRecord | None: signature = f"{packet_type}:{node}:{data.get('payload_hex', '')[:32]}" digest = hashlib.sha1(signature.encode("utf-8")).hexdigest()[:8].upper() - rssi = int(data.get("rssi") or -112) - snr = float(data.get("snr") or -9.0) + # Missing values stay missing: "or -112" turned a real SNR of 0.0 into + # -9.00 and made unmeasured packets look measured. + rssi = int(data["rssi"]) if data.get("rssi") is not None else None + snr = float(data["snr"]) if data.get("snr") is not None else None route_type = str(data.get("route_type_name") or "FLOOD") # packet events don't have raw_hex, only payload_hex raw_hex = str(data.get("payload_hex") or "") @@ -647,7 +666,7 @@ def _build_stream_row(self, packet: PacketRecord) -> Gtk.ListBoxRow: content_label.set_ellipsize(Pango.EllipsizeMode.END) line.append(content_label) - sig_label = Gtk.Label(label=f"{packet.rssi} / {packet.snr:.2f}") + sig_label = Gtk.Label(label=signal_text(packet.rssi, packet.snr)) sig_label.add_css_class("analyzer-rssi") sig_label.set_xalign(0) sig_label.set_width_chars(self.COL_SIGNAL_CHARS) @@ -753,7 +772,8 @@ def _refresh_details(self) -> None: self._details_content.append( DetailBlock( "Radio Signal", - f"RSSI {packet.rssi} dBm SNR {packet.snr:.2f} dB", + f"RSSI {signal_part(packet.rssi, '{:d} dBm')} " + f"SNR {signal_part(packet.snr, '{:.2f} dB')}", wrap_chars=wrap, ) ) diff --git a/tests/unit/test_analyzer_signal.py b/tests/unit/test_analyzer_signal.py new file mode 100644 index 0000000..881bcc3 --- /dev/null +++ b/tests/unit/test_analyzer_signal.py @@ -0,0 +1,15 @@ +"""The analyzer shows measured signal values, and n/a for missing ones.""" + +from __future__ import annotations + +from meshcore_console.ui_gtk.views.analyzer import signal_part, signal_text + + +def test_zero_snr_is_a_real_value() -> None: + assert signal_text(-30, 0.0) == "-30 / 0.00" + + +def test_missing_values_are_not_invented() -> None: + assert signal_text(None, None) == "n/a / n/a" + assert signal_part(None, "{:d} dBm") == "n/a" + assert signal_part(-112, "{:d} dBm") == "-112 dBm" From 62822e6f1e87f45ae0a09ed55deb625b45c80da9 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:54:49 +0100 Subject: [PATCH 20/72] fix: deliver bursts of events to the UI without dropping the oldest 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 --- src/meshcore_console/meshcore/client.py | 12 +++---- src/meshcore_console/mock/client.py | 10 +++--- .../ui_gtk/state/event_store.py | 2 ++ src/meshcore_console/ui_gtk/views/analyzer.py | 3 +- tests/unit/test_event_bursts.py | 35 +++++++++++++++++++ 5 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 tests/unit/test_event_bursts.py diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index ed9e819..178743e 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -66,7 +66,8 @@ def __init__( ) -> None: self._connected = False self._event_notify: Callable[[], None] | None = None - self._event_buffer: list[MeshEventDict] = [] + # deque: worker threads append while the GTK thread drains it + self._event_buffer: deque[MeshEventDict] = deque() self._event_history: list[MeshEventDict] = [] # Cross-batch queues for enriching packet events with handler data. # The raw packet callback fires before handler decryption, so these @@ -521,9 +522,8 @@ def poll_events(self, limit: int = 50) -> list[MeshEventDict]: events.extend(drained) except (RuntimeError, OSError) as exc: logger.debug("drain_events error: %s", exc) - if self._event_buffer: - events.extend(self._event_buffer) - self._event_buffer.clear() + while self._event_buffer: # popleft is atomic; extend()+clear() lost appends + events.append(self._event_buffer.popleft()) # Enrich packet events with sender names before processing/persisting self._enrich_sender_names(events) @@ -539,8 +539,8 @@ def poll_events(self, limit: int = 50) -> list[MeshEventDict]: # Log but don't let storage failures break event processing logger.warning("packet_store error: %s: %s", type(exc).__name__, exc) - if len(events) > limit: - return events[-limit:] + # Every event here was drained and processed: hand all of them to the + # UI (``limit`` only bounds the drain), or a burst loses its oldest ones. return events def _build_peer_lookup(self) -> dict[str, str]: diff --git a/src/meshcore_console/mock/client.py b/src/meshcore_console/mock/client.py index ad00923..74b1066 100644 --- a/src/meshcore_console/mock/client.py +++ b/src/meshcore_console/mock/client.py @@ -3,6 +3,7 @@ from __future__ import annotations import time +from collections import deque from datetime import UTC, datetime from typing import Callable from uuid import uuid4 @@ -35,7 +36,7 @@ def __init__(self, node_name: str = "uconsole-node") -> None: self._gps_provider = MockGps() self._connected = True self._event_notify: Callable[[], None] | None = None - self._event_buffer: list[dict] = [] + self._event_buffer: deque[dict] = deque() self._event_history: list[dict] = [] self._repeater_sessions: dict[str, RepeaterLoginState] = {} @@ -230,16 +231,13 @@ def poll_events(self, limit: int = 50) -> list[dict]: except (RuntimeError, OSError): pass - if self._event_buffer: - events.extend(self._event_buffer) - self._event_buffer.clear() + while self._event_buffer: + events.append(self._event_buffer.popleft()) for event in events: self._append_history(event) self._process_event_for_messages(event) - if len(events) > limit: - return events[-limit:] return events def list_recent_events(self, limit: int = 50) -> list[dict]: diff --git a/src/meshcore_console/ui_gtk/state/event_store.py b/src/meshcore_console/ui_gtk/state/event_store.py index 469ce3c..4e10975 100644 --- a/src/meshcore_console/ui_gtk/state/event_store.py +++ b/src/meshcore_console/ui_gtk/state/event_store.py @@ -39,6 +39,8 @@ def _do_pump(self) -> bool: self._events.append((self._seq, event)) if events: self.emit("events-available") + if len(events) >= 100: + self.schedule_pump() # a full batch: more may be waiting return False # One-shot idle def pump(self, limit: int = 100) -> list[MeshEventDict]: diff --git a/src/meshcore_console/ui_gtk/views/analyzer.py b/src/meshcore_console/ui_gtk/views/analyzer.py index f62de73..1ffcf82 100644 --- a/src/meshcore_console/ui_gtk/views/analyzer.py +++ b/src/meshcore_console/ui_gtk/views/analyzer.py @@ -232,7 +232,8 @@ def _poll_events(self) -> bool: # Keep collecting while paused (only rendering stops): the event store # keeps a bounded window, so a paused cursor fell behind and the # packets received meanwhile were skipped on resume. - self._cursor, events = self._event_store.since(self._cursor, limit=200) + # the whole store window (500): a smaller limit dropped the oldest of a burst + self._cursor, events = self._event_store.since(self._cursor, limit=500) if not events: return True if self._geom_debug: diff --git a/tests/unit/test_event_bursts.py b/tests/unit/test_event_bursts.py new file mode 100644 index 0000000..39d50cf --- /dev/null +++ b/tests/unit/test_event_bursts.py @@ -0,0 +1,35 @@ +"""A burst of events reaches the UI whole.""" + +from __future__ import annotations + +from meshcore_console.meshcore.db import open_db + + +def _client(tmp_path, monkeypatch): + from meshcore_console.meshcore import client as client_mod + from meshcore_console.meshcore.client import MeshcoreClient + from meshcore_console.meshcore.config import runtime_config_from_settings + from meshcore_console.meshcore.settings import MeshcoreSettings + from meshcore_console.mock import MockOpenHopCoreSession + + db_conn = open_db(str(tmp_path / "test.db")) + monkeypatch.setattr(client_mod, "open_db", lambda *a, **k: db_conn) + client = MeshcoreClient( + session=MockOpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())), + require_openhop=False, + ) + burst = [{"type": "radio_error", "data": {"message": f"e{i}"}} for i in range(100)] + monkeypatch.setattr(client._session, "drain_events", lambda max_items=50: burst[:max_items]) + return client + + +def test_poll_returns_every_drained_and_buffered_event(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch) + for i in range(3): + client._append_event({"type": "advert_sent", "data": {"n": i}}) + + events = client.poll_events(limit=100) + + assert len(events) == 103 # was truncated to the newest 100 + assert events[0]["data"]["message"] == "e0" + assert not client._event_buffer From a93e4b50fa0e65cc051e6c19a9665448961cc5f7 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:56:27 +0100 Subject: [PATCH 21/72] fix(client): never leave a radio call waiting forever _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 --- src/meshcore_console/meshcore/client.py | 62 +++++++++++++++++++---- tests/unit/test_client_async.py | 66 +++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 10 deletions(-) create mode 100644 tests/unit/test_client_async.py diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 178743e..f702634 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import concurrent.futures import logging import threading from collections import deque @@ -48,6 +49,28 @@ def _propagate_routing_fields(packet_data: dict, handler_data: dict) -> None: handler_data[key] = list(val) if isinstance(val, list) else val +# Upper bound for a blocking radio call without its own timeout (a DM waits for +# listen-before-talk, airtime and the ACK). +RUN_ASYNC_CAP_S = 60.0 + + +async def _no_system_exit(coro: object) -> object: + """openhop_core calls sys.exit() on fatal GPIO errors. Raised inside a task + that would stop the whole event loop thread; turn it into an error instead.""" + try: + return await coro # type: ignore[misc] + except SystemExit as exc: + raise RuntimeError(f"Radio hardware error (exit code {exc.code})") from exc + + +async def _cancel_pending_tasks() -> None: + current = asyncio.current_task() + tasks = [task for task in asyncio.all_tasks() if task is not current] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + class MeshcoreClient(MeshcoreService): """openhop_core-backed adapter for the UI layer.""" @@ -140,16 +163,39 @@ def _ensure_loop(self) -> asyncio.AbstractEventLoop: return loop def _run_async(self, coro: object, *, timeout: float | None = None) -> object: - """Submit a coroutine to the persistent loop and block for its result.""" + """Submit a coroutine to the persistent loop and block for its result. + + The wait always ends: ``timeout`` (or RUN_ASYNC_CAP_S) is also enforced + on this side, because a ``wait_for`` inside the loop never fires once + the loop has been stopped by a disconnect. + """ loop = self._ensure_loop() if timeout is not None: coro = asyncio.wait_for(coro, timeout=timeout) # type: ignore[arg-type] - future = asyncio.run_coroutine_threadsafe(coro, loop) # type: ignore[arg-type] - return future.result() + future = asyncio.run_coroutine_threadsafe(_no_system_exit(coro), loop) # type: ignore[arg-type] + deadline = (timeout + 5.0) if timeout is not None else RUN_ASYNC_CAP_S + try: + return future.result(timeout=deadline) + except concurrent.futures.TimeoutError as exc: + future.cancel() + raise TimeoutError(f"radio operation did not finish within {deadline:.0f}s") from exc + except concurrent.futures.CancelledError as exc: + raise RuntimeError("cancelled: the radio was disconnected") from exc def _shutdown_loop(self) -> None: - """Stop the persistent event loop and join the thread.""" + """Cancel pending work, stop the persistent event loop and join the thread. + + Cancelling first resolves the futures callers are blocked on; stopping + the loop with tasks still pending left those callers waiting forever + (Discover "still listening", Admin busy, Telemetry "Requesting..."). + """ if self._loop is not None and self._loop.is_running(): + try: + asyncio.run_coroutine_threadsafe(_cancel_pending_tasks(), self._loop).result( + timeout=3.0 + ) + except Exception: # noqa: BLE001 + pass # best effort; stopping the loop below still happens self._loop.call_soon_threadsafe(self._loop.stop) if self._loop_thread is not None: self._loop_thread.join(timeout=3.0) @@ -180,13 +226,9 @@ def connect(self) -> None: raise ConflictError(report) try: + # (a sys.exit() from openhop_core's GPIO code arrives as RuntimeError, + # see _no_system_exit) self._run_async(self._session.start(), timeout=8.0) - except SystemExit as exc: - # openhop_core calls sys.exit() on fatal GPIO errors. Convert to - # RuntimeError so the UI can show the failure instead of crashing. - self._session = self._new_session() - self._connected = False - raise RuntimeError(f"Radio hardware init failed (exit code {exc.code})") from exc except Exception: # Recover from partial startup by creating a clean session for next attempt. self._session = self._new_session() diff --git a/tests/unit/test_client_async.py b/tests/unit/test_client_async.py new file mode 100644 index 0000000..7eaec37 --- /dev/null +++ b/tests/unit/test_client_async.py @@ -0,0 +1,66 @@ +"""Blocking radio calls always end: on disconnect, timeout or sys.exit().""" + +from __future__ import annotations + +import asyncio +import threading +import time + +import pytest + +from meshcore_console.meshcore.db import open_db + + +def _client(tmp_path, monkeypatch): + from meshcore_console.meshcore import client as client_mod + from meshcore_console.meshcore.client import MeshcoreClient + from meshcore_console.meshcore.config import runtime_config_from_settings + from meshcore_console.meshcore.settings import MeshcoreSettings + from meshcore_console.mock import MockOpenHopCoreSession + + db_conn = open_db(str(tmp_path / "test.db")) + monkeypatch.setattr(client_mod, "open_db", lambda *a, **k: db_conn) + return MeshcoreClient( + session=MockOpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())), + require_openhop=False, + ) + + +def test_disconnect_releases_a_caller_waiting_on_the_radio(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch) + outcome: list[BaseException] = [] + + def worker() -> None: + try: + client._run_async(asyncio.sleep(30), timeout=20) + except BaseException as exc: # noqa: BLE001 + outcome.append(exc) + + thread = threading.Thread(target=worker) + thread.start() + time.sleep(0.2) + started = time.monotonic() + client._shutdown_loop() + thread.join(timeout=5) + + assert not thread.is_alive() and time.monotonic() - started < 5 + assert isinstance(outcome[0], RuntimeError) + + +def test_sys_exit_in_a_radio_call_does_not_kill_the_loop(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch) + + async def gpio_busy() -> None: + raise SystemExit(1) + + with pytest.raises(RuntimeError, match="exit code 1"): + client._run_async(gpio_busy()) + assert client._run_async(asyncio.sleep(0, result="alive")) == "alive" + client._shutdown_loop() + + +def test_inner_timeout_still_applies(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch) + with pytest.raises(TimeoutError): + client._run_async(asyncio.sleep(5), timeout=0.1) + client._shutdown_loop() From a7022e926fb6f40cf1a1b995883633110a71d587 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:57:41 +0100 Subject: [PATCH 22/72] fix(client): serialise connect/disconnect and clean up a failed connect - 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 --- src/meshcore_console/meshcore/client.py | 26 +++++++++++-- tests/unit/test_client_async.py | 51 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index f702634..d600ce9 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -52,6 +52,7 @@ def _propagate_routing_fields(packet_data: dict, handler_data: dict) -> None: # Upper bound for a blocking radio call without its own timeout (a DM waits for # listen-before-talk, airtime and the ACK). RUN_ASYNC_CAP_S = 60.0 +CONNECT_TIMEOUT_S = 30.0 async def _no_system_exit(coro: object) -> object: @@ -88,6 +89,7 @@ def __init__( gps_provider: GpsProvider | None = None, ) -> None: self._connected = False + self._connect_lock = threading.RLock() self._event_notify: Callable[[], None] | None = None # deque: worker threads append while the GTK thread drains it self._event_buffer: deque[MeshEventDict] = deque() @@ -205,6 +207,12 @@ def _shutdown_loop(self) -> None: self._loop_thread = None def connect(self) -> None: + # Serialised: Send, Advert, Discover and Admin connect implicitly from + # worker threads, and two concurrent starts initialised the radio twice. + with self._connect_lock: + self._connect_locked() + + def _connect_locked(self) -> None: if not self._openhop_available: raise RuntimeError( "openhop_core is not installed. Run in mock mode or install openhop_core." @@ -227,12 +235,18 @@ def connect(self) -> None: try: # (a sys.exit() from openhop_core's GPIO code arrives as RuntimeError, - # see _no_system_exit) - self._run_async(self._session.start(), timeout=8.0) + # see _no_system_exit). The timeout covers start()'s own radio.begin() + # retries (2 + 4 + 6 s of back-off plus four init attempts). + self._run_async(self._session.start(), timeout=CONNECT_TIMEOUT_S) except Exception: - # Recover from partial startup by creating a clean session for next attempt. - self._session = self._new_session() + # Recover from partial startup with a clean session for the next + # attempt, releasing the GPIO lines/SPI the failed one may hold. + failed, self._session = self._session, self._new_session() self._connected = False + try: + self._run_async(failed.stop(), timeout=10.0) + except Exception: # noqa: BLE001 + pass raise self._connected = True self._session.set_is_favorite_fn(self._is_peer_favorite) @@ -244,6 +258,10 @@ def connect(self) -> None: ) def disconnect(self) -> None: + with self._connect_lock: + self._disconnect_locked() + + def _disconnect_locked(self) -> None: runtime_connected = bool(self._session.status().get("connected")) if self._connected or runtime_connected: try: diff --git a/tests/unit/test_client_async.py b/tests/unit/test_client_async.py index 7eaec37..23e9cfd 100644 --- a/tests/unit/test_client_async.py +++ b/tests/unit/test_client_async.py @@ -64,3 +64,54 @@ def test_inner_timeout_still_applies(tmp_path, monkeypatch) -> None: with pytest.raises(TimeoutError): client._run_async(asyncio.sleep(5), timeout=0.1) client._shutdown_loop() + + +def test_concurrent_connects_start_the_radio_once(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch) + client._openhop_available = True + client._config.hardware = None # no preflight probing of real GPIO + starts: list[int] = [] + original_start = client._session.start + + async def slow_start() -> None: + starts.append(1) + await asyncio.sleep(0.2) + await original_start() + + monkeypatch.setattr(client._session, "start", slow_start) + monkeypatch.setattr(client._gps_provider, "start", lambda: None) + # post-connect hooks the mock session doesn't implement + for hook in ("set_is_favorite_fn", "set_telemetry_data_fn"): + monkeypatch.setattr(client._session, hook, lambda _fn: None, raising=False) + monkeypatch.setattr(client, "_seed_contact_book", lambda: None) + threads = [threading.Thread(target=client.connect) for _ in range(3)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + assert len(starts) == 1 and client._connected + client._shutdown_loop() + + +def test_failed_connect_stops_the_half_started_session(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch) + client._openhop_available = True + client._config.hardware = None + failed = client._session + stopped: list[int] = [] + + async def broken_start() -> None: + raise RuntimeError("radio.begin() returned False") + + async def stop() -> None: + stopped.append(1) + + monkeypatch.setattr(failed, "start", broken_start) + monkeypatch.setattr(failed, "stop", stop) + monkeypatch.setattr(client, "_new_session", lambda: failed.__class__(failed.config)) + + with pytest.raises(RuntimeError): + client.connect() + assert stopped == [1] and client._session is not failed and not client._connected + client._shutdown_loop() From dacd2b5f61d5911545ee08d031e7a719e20db35c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:58:25 +0100 Subject: [PATCH 23/72] fix(db): run schema upgrades in one transaction 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 --- src/meshcore_console/meshcore/db.py | 30 ++++++++++++------ tests/unit/test_db_migration_atomic.py | 42 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 tests/unit/test_db_migration_atomic.py diff --git a/src/meshcore_console/meshcore/db.py b/src/meshcore_console/meshcore/db.py index 687cfd0..65df594 100644 --- a/src/meshcore_console/meshcore/db.py +++ b/src/meshcore_console/meshcore/db.py @@ -177,16 +177,26 @@ def _migrate(conn: sqlite3.Connection) -> None: if current >= target: return logger.info("Migrating database from v%d to v%d", current, target) - for version_index in range(current, target): - for stmt in MIGRATIONS[version_index]: - conn.execute(stmt) - # Data backfills run after all DDL so the columns they read (e.g. channels.kind, - # added in v5) are guaranteed to exist. - if current < 7: - _backfill_channel_secrets(conn) - if current < 8: - _add_region_scope_column(conn) - conn.execute("UPDATE schema_version SET version = ?", (target,)) + # One transaction for the whole upgrade (SQLite DDL is transactional). + # Python's sqlite3 runs DDL outside its implicit transactions, so each + # ALTER used to commit on its own while the version was bumped only at the + # end: an interrupted upgrade (power loss on a battery handheld) left + # columns that the next start's ALTER rejected as duplicates, forever. + conn.execute("BEGIN IMMEDIATE") + try: + for version_index in range(current, target): + for stmt in MIGRATIONS[version_index]: + conn.execute(stmt) + # Data backfills run after all DDL so the columns they read (e.g. + # channels.kind, added in v5) are guaranteed to exist. + if current < 7: + _backfill_channel_secrets(conn) + if current < 8: + _add_region_scope_column(conn) + conn.execute("UPDATE schema_version SET version = ?", (target,)) + except BaseException: + conn.rollback() + raise conn.commit() diff --git a/tests/unit/test_db_migration_atomic.py b/tests/unit/test_db_migration_atomic.py new file mode 100644 index 0000000..2f75bd6 --- /dev/null +++ b/tests/unit/test_db_migration_atomic.py @@ -0,0 +1,42 @@ +"""An interrupted schema upgrade leaves the database as it was.""" + +from __future__ import annotations + +import sqlite3 + +import pytest + +from meshcore_console.meshcore import db as db_mod + + +def v1_database(path) -> None: + conn = sqlite3.connect(str(path)) + for stmt in db_mod.MIGRATIONS[0]: + conn.execute(stmt) + conn.commit() + conn.close() + + +def test_failed_upgrade_rolls_back_and_the_next_open_succeeds(tmp_path, monkeypatch) -> None: + path = tmp_path / "meshcore.db" + v1_database(path) + + def power_cut(_conn) -> None: + raise OSError("interrupted") + + monkeypatch.setattr(db_mod, "_add_region_scope_column", power_cut) + with pytest.raises(OSError): + db_mod.open_db(str(path)) + monkeypatch.undo() + + raw = sqlite3.connect(str(path)) + assert raw.execute("SELECT version FROM schema_version").fetchone()[0] == 1 + columns = [row[1] for row in raw.execute("PRAGMA table_info(channels)")] + assert "peer_name" not in columns # the v2 ALTER was rolled back with the rest + raw.close() + + conn = db_mod.open_db(str(path)) # used to fail with "duplicate column name" + assert conn.execute("SELECT version FROM schema_version").fetchone()[0] == len( + db_mod.MIGRATIONS + ) + conn.close() From 388e36f0edcbd169af52d98c0de83f83cac4f99a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:59:02 +0100 Subject: [PATCH 24/72] fix(client): keep processing a batch of events when one of them fails 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 --- src/meshcore_console/meshcore/client.py | 16 +++++++++------- tests/unit/test_event_bursts.py | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index d600ce9..ee0a00c 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -590,14 +590,16 @@ def poll_events(self, limit: int = 50) -> list[MeshEventDict]: for event in events: self._append_history(event) - self._process_event_for_peers(event) - # Persist packet events to state storage - if event.get("type") in (EventType.PACKET, EventType.RAW_PACKET): - try: + # One bad event (a sqlite3.Error from a locked or full database, a + # malformed field) must not take the rest of the batch with it: + # those events were already drained and would be lost, messages too. + try: + self._process_event_for_peers(event) + # Persist packet events to state storage + if event.get("type") in (EventType.PACKET, EventType.RAW_PACKET): self._packet_store.append(event) - except OSError as exc: - # Log but don't let storage failures break event processing - logger.warning("packet_store error: %s: %s", type(exc).__name__, exc) + except Exception: # noqa: BLE001 + logger.exception("could not process %s event", event.get("type")) # Every event here was drained and processed: hand all of them to the # UI (``limit`` only bounds the drain), or a burst loses its oldest ones. diff --git a/tests/unit/test_event_bursts.py b/tests/unit/test_event_bursts.py index 39d50cf..750c537 100644 --- a/tests/unit/test_event_bursts.py +++ b/tests/unit/test_event_bursts.py @@ -33,3 +33,23 @@ def test_poll_returns_every_drained_and_buffered_event(tmp_path, monkeypatch) -> assert len(events) == 103 # was truncated to the newest 100 assert events[0]["data"]["message"] == "e0" assert not client._event_buffer + + +def test_one_failing_event_does_not_drop_the_rest(tmp_path, monkeypatch) -> None: + import sqlite3 + + client = _client(tmp_path, monkeypatch) + batch = [{"type": "packet", "data": {"n": i}} for i in range(3)] + monkeypatch.setattr(client._session, "drain_events", lambda max_items=50: list(batch)) + stored: list[int] = [] + + def process(event) -> None: + if event["data"]["n"] == 0: + raise sqlite3.OperationalError("database is locked") + + monkeypatch.setattr(client, "_process_event_for_peers", process) + monkeypatch.setattr(client._packet_store, "append", lambda e: stored.append(e["data"]["n"])) + + events = client.poll_events(limit=100) + + assert len(events) == 3 and stored == [1, 2] From af4fc1ed9f0d2d8c4dbb2b557ea876bfa2a00bc0 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 21:59:51 +0100 Subject: [PATCH 25/72] fix(cli): use the saved settings for listen, send and advert 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 --- src/meshcore_console/radio_cli.py | 24 +++++++++++++++++--- tests/unit/test_cli_config.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_cli_config.py diff --git a/src/meshcore_console/radio_cli.py b/src/meshcore_console/radio_cli.py index e2d5292..82dea3a 100644 --- a/src/meshcore_console/radio_cli.py +++ b/src/meshcore_console/radio_cli.py @@ -7,12 +7,12 @@ import time from typing import Any -from meshcore_console.meshcore.config import load_runtime_config +from meshcore_console.meshcore.config import RuntimeRadioConfig, runtime_config_from_settings from meshcore_console.meshcore.session import OpenHopCoreSession def _add_global_args(p: argparse.ArgumentParser) -> None: - p.add_argument("--node-name", default="uconsole-node", help="Mesh node name") + p.add_argument("--node-name", default=None, help="Mesh node name (default: the saved one)") p.add_argument( "--debug", dest="debug", @@ -70,6 +70,24 @@ def register_subcommands(sub: argparse._SubParsersAction) -> None: # type: igno ) +def load_saved_runtime_config(node_name: str | None = None) -> RuntimeRadioConfig: + """The saved settings with the environment on top, exactly what the GTK app + uses. listen/send/advert used built-in defaults instead (910.525 MHz, SF7, + gpiochip 0, node name "uconsole-node") while using the node's real identity, + so an `advert` could rename the node mesh-wide on the wrong frequency.""" + from meshcore_console.meshcore.db import open_db + from meshcore_console.meshcore.settings_store import SettingsStore + + conn = open_db() + try: + settings = SettingsStore(conn).load() + finally: + conn.close() + if node_name: + settings.node_name = node_name + return runtime_config_from_settings(settings) + + def _doctor_hardware_config() -> Any: """Return the hardware config that the radio will actually use. @@ -295,7 +313,7 @@ async def _async_main(args: argparse.Namespace) -> int: _debug(args.debug, "running doctor checks") return _doctor() - config = load_runtime_config(node_name=args.node_name) + config = load_saved_runtime_config(node_name=args.node_name) session = OpenHopCoreSession(config, logger=lambda msg: _debug(args.debug, f"session: {msg}")) if args.command == "listen": diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py new file mode 100644 index 0000000..a62a138 --- /dev/null +++ b/tests/unit/test_cli_config.py @@ -0,0 +1,37 @@ +"""The headless CLI radios with the saved settings, like the GTK app.""" + +from __future__ import annotations + +from meshcore_console.meshcore.db import open_db +from meshcore_console.meshcore.settings import MeshcoreSettings +from meshcore_console.meshcore.settings_store import SettingsStore +from meshcore_console.radio_cli import load_saved_runtime_config + + +def save(**values) -> None: + conn = open_db() + settings = MeshcoreSettings() + for key, value in values.items(): + setattr(settings, key, value) + SettingsStore(conn).save(settings) + conn.close() + + +def test_cli_uses_saved_radio_and_name() -> None: + save(node_name="DF9ES", frequency=869_618_000, spreading_factor=8, coding_rate=8, gpio_chip=15) + + config = load_saved_runtime_config() + + assert config.node_name == "DF9ES" + hw = config.hardware + assert (hw.frequency, hw.spreading_factor, hw.coding_rate, hw.gpio_chip) == ( + 869_618_000, + 8, + 8, + 15, + ) + + +def test_cli_name_override() -> None: + save(node_name="DF9ES") + assert load_saved_runtime_config("bench").node_name == "bench" From 1af9c4fed07650672ebfb4dc93aca67951bfbf82 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:01:13 +0100 Subject: [PATCH 26/72] fix(admin): show the logged-out state after a disconnect 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 --- src/meshcore_console/ui_gtk/views/admin.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/meshcore_console/ui_gtk/views/admin.py b/src/meshcore_console/ui_gtk/views/admin.py index de6c283..81956db 100644 --- a/src/meshcore_console/ui_gtk/views/admin.py +++ b/src/meshcore_console/ui_gtk/views/admin.py @@ -720,6 +720,12 @@ def _on_events(self, _store: object) -> None: ) if state != self._list_state: self._refresh_list() + if self._selected_repeater is not None: + # e.g. a disconnect ended every repeater session: the panel + # kept showing "Logged in" with the command line enabled. + self._update_login_ui( + self._service.get_repeater_login_state(self._selected_repeater.display_name) + ) # -- Public API for MainWindow.navigate_to --------------------------------- From 62ca82ca8915bc550a7b0490b3005e2bd7358c56 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:01:31 +0100 Subject: [PATCH 27/72] fix(admin): enable the command line only for admin logins 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 --- src/meshcore_console/ui_gtk/views/admin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/meshcore_console/ui_gtk/views/admin.py b/src/meshcore_console/ui_gtk/views/admin.py index 81956db..82f3f10 100644 --- a/src/meshcore_console/ui_gtk/views/admin.py +++ b/src/meshcore_console/ui_gtk/views/admin.py @@ -475,7 +475,9 @@ def _update_login_ui(self, state: RepeaterLoginState | None) -> None: self._guest_btn.set_sensitive(False) self._password_entry.set_sensitive(False) self._logout_btn.set_sensitive(True) - self._set_cli_sensitive(True) + # The repeater only runs CLI commands for admin sessions (firmware + # checks isAdmin()); a guest's commands just timed out. + self._set_cli_sensitive(state.is_admin) def _set_cli_sensitive(self, sensitive: bool) -> None: self._cmd_entry.set_sensitive(sensitive) From 49a8331da131b95276fcbceaaa4dccd956b09fb2 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:02:19 +0100 Subject: [PATCH 28/72] fix(admin): ask before rebooting a repeater "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 --- src/meshcore_console/ui_gtk/views/admin.py | 27 +++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/meshcore_console/ui_gtk/views/admin.py b/src/meshcore_console/ui_gtk/views/admin.py index 82f3f10..abc2e26 100644 --- a/src/meshcore_console/ui_gtk/views/admin.py +++ b/src/meshcore_console/ui_gtk/views/admin.py @@ -6,14 +6,16 @@ import gi +gi.require_version("Adw", "1") gi.require_version("Gtk", "4.0") -from gi.repository import Gdk, GLib, Gtk, Pango +from gi.repository import Adw, Gdk, GLib, Gtk, Pango from meshcore_console.core.models import Peer from meshcore_console.core.radio import format_rssi, format_snr from meshcore_console.core.services import MeshcoreService from meshcore_console.core.time import to_local +from meshcore_console.ui_gtk.compat import build_alert_dialog, present_dialog from meshcore_console.ui_gtk.helpers import clear_children from meshcore_console.ui_gtk.layout import Layout from meshcore_console.ui_gtk.state import UiEventStore @@ -590,8 +592,31 @@ def _on_forget_clicked(self, _btn: Gtk.Button) -> None: # -- CLI actions ----------------------------------------------------------- def _on_quick_cmd(self, _btn: Gtk.Button, command: str) -> None: + if command == "reboot": + self._confirm_reboot() + return self._send_command(command) + def _confirm_reboot(self) -> None: + """Reboot sits next to harmless quick commands; one slip of the + trackball shouldn't take a remote repeater offline.""" + name = self._selected_repeater.display_name if self._selected_repeater else "repeater" + dialog = build_alert_dialog( + self.get_root(), + f"Reboot {name}?", + "The repeater stops relaying until it has restarted.", + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("reboot", "Reboot") + dialog.set_response_appearance("reboot", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_default_response("cancel") + dialog.set_close_response("cancel") + dialog.connect( + "response", + lambda _d, response: self._send_command("reboot") if response == "reboot" else None, + ) + present_dialog(dialog, self.get_root()) + def _on_cmd_activate(self, _entry: Gtk.Entry) -> None: text = self._cmd_entry.get_text().strip() if text: From cc8aa1b35132b93e8ea20cec763bc8e33b09bc68 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:04:56 +0100 Subject: [PATCH 29/72] fix(security): build peers only from adverts openhop_core has verified 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 --- src/meshcore_console/meshcore/client.py | 43 +++++++++++-- tests/unit/test_discovery.py | 21 ++++--- tests/unit/test_verified_adverts.py | 80 +++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 tests/unit/test_verified_adverts.py diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index ee0a00c..3812939 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -55,6 +55,18 @@ def _propagate_routing_fields(packet_data: dict, handler_data: dict) -> None: CONNECT_TIMEOUT_S = 30.0 +def hops_from_path(path: bytes | None, path_len_encoded: int | None) -> list[str]: + """Hop hashes (hex) from a packet path; the path length byte carries the + hash size in bits 6-7 (size - 1) and the hop count in bits 0-5.""" + if not path or not path_len_encoded: + return [] + size = (path_len_encoded >> 6) + 1 + count = path_len_encoded & 0x3F + return [ + path[i * size : (i + 1) * size].hex() for i in range(count) if (i + 1) * size <= len(path) + ] + + async def _no_system_exit(coro: object) -> object: """openhop_core calls sys.exit() on fatal GPIO errors. Raised inside a task that would stop the whole event loop thread; turn it into an error instead.""" @@ -90,6 +102,7 @@ def __init__( ) -> None: self._connected = False self._connect_lock = threading.RLock() + self._advert_timestamps: dict[str, int] = {} # newest advert per public key self._event_notify: Callable[[], None] | None = None # deque: worker threads append while the GTK thread drains it self._event_buffer: deque[MeshEventDict] = deque() @@ -728,10 +741,12 @@ def _process_event_for_peers(self, event: MeshEventDict) -> None: if event_type == EventType.RAW_PACKET: return - payload_type_name = data.get("payload_type_name", "") - - # Check for ADVERT packets to add new peers - if payload_type_name == PayloadType.ADVERT or event_type in ( + # Peers come only from adverts openhop_core has verified (signature, + # dedupe, own-advert filter) and published as mesh.network.node_discovered, + # plus the app's own discovery results. Raw "packet" ADVERT events arrive + # before any of that: a forged or replayed advert used to rewrite a + # contact's key, route, position or name. + if event_type in ( EventType.CONTACT_RECEIVED, EventType.ADVERT_RECEIVED, EventType.MESH_CONTACT_NEW, @@ -759,18 +774,34 @@ def _process_advert_event(self, data: MeshEventDict) -> None: return peer_name = repair_utf8(str(peer_name)) - peer_id = data.get("sender_id") or data.get("peer_id") public_key = data.get("sender_pubkey") or data.get("public_key") + # node_discovered carries no sender_id; use the same key prefix that + # packet events call sender_id, so a peer keeps one row and id. + peer_id = ( + data.get("sender_id") + or data.get("peer_id") + or (public_key[:16] if public_key else None) + ) # Skip our own advert reflected back through a repeater. self_pubkey = self.get_self_public_key() if self_pubkey and public_key and self_pubkey == public_key: return + # Ignore replays: an advert is signed with its timestamp, so an old one + # replayed later still verifies, and would roll name/position/route back. + advert_ts = data.get("advert_timestamp") + if public_key and advert_ts is not None: + if advert_ts <= self._advert_timestamps.get(public_key, -1): + return + self._advert_timestamps[public_key] = advert_ts + if public_key and peer_name not in self._peers: self._replace_placeholder(public_key, peer_name) - path_hops = data.get("path_hops", []) + path_hops = data.get("path_hops") or hops_from_path( + data.get("inbound_path"), data.get("path_len_encoded") + ) rssi_raw = data.get("rssi") snr_raw = data.get("snr") diff --git a/tests/unit/test_discovery.py b/tests/unit/test_discovery.py index 6cb24a9..067ef1e 100644 --- a/tests/unit/test_discovery.py +++ b/tests/unit/test_discovery.py @@ -157,16 +157,23 @@ def discovered_event(name: str, key: str = KEY) -> dict: } +_advert_clock = [1_700_000_000] + + def advert_event(name: str, key: str = KEY) -> dict: + """A verified advert, as openhop_core's AdvertHandler publishes it.""" + _advert_clock[0] += 1 return { - "type": EventType.PACKET, + "type": EventType.MESH_NODE_DISCOVERED, "data": { - "payload_type_name": "ADVERT", - "sender_name": name, - "sender_id": key[:16], - "sender_pubkey": key, - "advert_type": NODE_TYPE_REPEATER, - "path_hops": ["13"], + "name": name, + "public_key": key, + "contact_type": NODE_TYPE_REPEATER, + "lat": 0.0, + "lon": 0.0, + "advert_timestamp": _advert_clock[0], + "inbound_path": bytes.fromhex("13"), + "path_len_encoded": 1, "rssi": -40, "snr": 9.0, }, diff --git a/tests/unit/test_verified_adverts.py b/tests/unit/test_verified_adverts.py new file mode 100644 index 0000000..9f61a78 --- /dev/null +++ b/tests/unit/test_verified_adverts.py @@ -0,0 +1,80 @@ +"""Peers come only from verified adverts, and replays don't roll them back.""" + +from __future__ import annotations + +from meshcore_console.core.enums import EventType +from meshcore_console.meshcore.client import hops_from_path +from meshcore_console.meshcore.db import open_db + +KEY = "13ab5c7e" + "11" * 28 + + +def _client(tmp_path, monkeypatch): + from meshcore_console.meshcore import client as client_mod + from meshcore_console.meshcore.client import MeshcoreClient + from meshcore_console.meshcore.config import runtime_config_from_settings + from meshcore_console.meshcore.settings import MeshcoreSettings + from meshcore_console.mock import MockOpenHopCoreSession + + db_conn = open_db(str(tmp_path / "test.db")) + monkeypatch.setattr(client_mod, "open_db", lambda *a, **k: db_conn) + return MeshcoreClient( + session=MockOpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())), + require_openhop=False, + ) + + +def verified(name: str, ts: int, lat: float = 0.0) -> dict: + return { + "type": EventType.MESH_NODE_DISCOVERED, + "data": { + "name": name, + "public_key": KEY, + "contact_type": 2, + "lat": lat, + "lon": 13.4 if lat else 0.0, + "advert_timestamp": ts, + "inbound_path": bytes.fromhex("13"), + "path_len_encoded": 1, + "rssi": -40, + "snr": 9.0, + }, + } + + +def test_unverified_packet_adverts_make_no_peers(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch) + forged = { + "type": EventType.PACKET, + "data": { + "payload_type_name": "ADVERT", + "sender_name": "Alice", + "sender_pubkey": KEY, + "sender_id": KEY[:16], + }, + } + client._process_event_for_peers(forged) + assert client.list_peers() == [] + + +def test_verified_advert_makes_a_peer_keyed_like_packet_events(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch) + client._process_event_for_peers(verified("Bergblick", 100)) + (peer,) = client.list_peers() + assert (peer.display_name, peer.peer_id, peer.public_key) == ("Bergblick", KEY[:16], KEY) + assert peer.is_repeater and peer.last_path == ["13"] + + +def test_replayed_advert_is_ignored(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch) + client._process_event_for_peers(verified("Bergblick", 200, lat=52.5)) + client._process_event_for_peers(verified("Bergblick", 100, lat=48.1)) # older, replayed + client._process_event_for_peers(verified("Bergblick", 200, lat=48.1)) # same, replayed + (peer,) = client.list_peers() + assert peer.latitude == 52.5 + + +def test_hops_from_path() -> None: + assert hops_from_path(bytes.fromhex("13a7"), 2) == ["13", "a7"] + assert hops_from_path(bytes.fromhex("13a7c3d4"), (1 << 6) | 2) == ["13a7", "c3d4"] + assert hops_from_path(b"", 0) == [] and hops_from_path(None, None) == [] From adb4711a65ca0a716a3cfe9dc7114c59d033b8b7 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:07:08 +0100 Subject: [PATCH 30/72] fix(security): never let another key take over a contact's name 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 --- src/meshcore_console/meshcore/client.py | 49 +++++++++++++------ src/meshcore_console/meshcore/contact_book.py | 2 + tests/unit/test_contact_book.py | 16 ++++-- tests/unit/test_discovery.py | 26 ++++++++-- 4 files changed, 72 insertions(+), 21 deletions(-) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 3812939..28993bd 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -563,28 +563,49 @@ def _peer_name_for_key(self, public_key: str) -> str | None: return name return None - def _replace_placeholder(self, public_key: str, real_name: str) -> None: - """A node discovery listed as ``Repeater 13ab5c7e`` has advertised its real - name: rename that peer (same peer_id, so the DB row is replaced, not doubled).""" - old = self._peer_name_for_key(public_key) - if old is None or old == real_name or not is_placeholder_for(old, public_key.lower()): - return + def _claim_name(self, public_key: str, name: str) -> str: + """The peer name to use for a verified advert of ``name`` by ``public_key``. + + A name belongs to the first key seen with it. Another key advertising + the same name gets a distinct name (``Alice (9f3a1c)``) instead of taking + over the contact: before, any node could advertise "Alice" with its own + key and receive the user's DMs to Alice (and a repeater's saved admin + password). A known key advertising a new name is renamed in place, so a + renamed node (or a discovery placeholder) stays one peer. + """ + owner = self._peers.get(name) + if ( + owner is not None + and owner.public_key + and owner.public_key.lower() != public_key.lower() + ): + name = f"{name} ({public_key[:6].lower()})" + current = self._peer_name_for_key(public_key) + if current is not None and current != name: + self._rename_peer(current, name) + return name + + def _rename_peer(self, old: str, new: str) -> None: + """Move a peer and everything keyed by its name (contact, login session, + DM thread, saved password) to a new name; the peer_id, and so the DB + row, favourite and history, stay the same.""" peer = self._peers.pop(old) - peer.display_name = real_name - self._peers[real_name] = peer + peer.display_name = new + self._peers[new] = peer + self._peer_store.add_or_update(peer) if self._connected: # the contact book exists only on a live session contact = self._session.contact_book.get_by_name(old) if contact is not None: - contact.name = real_name + contact.name = new if old in self._repeater_sessions: - self._repeater_sessions[real_name] = self._repeater_sessions.pop(old) + self._repeater_sessions[new] = self._repeater_sessions.pop(old) for channel in self._channels.values(): if channel.kind == "dm" and channel.peer_name == old: - channel.peer_name = real_name + channel.peer_name = new self._channel_store.add_or_update(channel) password = self._repeater_password_store.get_password(old) if password is not None: - self._repeater_password_store.save_password(real_name, password) + self._repeater_password_store.save_password(new, password) self._repeater_password_store.delete_password(old) def poll_events(self, limit: int = 50) -> list[MeshEventDict]: @@ -796,8 +817,8 @@ def _process_advert_event(self, data: MeshEventDict) -> None: return self._advert_timestamps[public_key] = advert_ts - if public_key and peer_name not in self._peers: - self._replace_placeholder(public_key, peer_name) + if public_key: + peer_name = self._claim_name(public_key, peer_name) path_hops = data.get("path_hops") or hops_from_path( data.get("inbound_path"), data.get("path_len_encoded") diff --git a/src/meshcore_console/meshcore/contact_book.py b/src/meshcore_console/meshcore/contact_book.py index 24ffbd8..ff2cd95 100644 --- a/src/meshcore_console/meshcore/contact_book.py +++ b/src/meshcore_console/meshcore/contact_book.py @@ -65,6 +65,8 @@ def add_contact(self, data: dict[str, str] | Contact) -> None: return for i, existing in enumerate(self.contacts): if existing.name == entry.name: + if existing.public_key and existing.public_key != entry.public_key: + return # never rebind a contact's name to another key existing.public_key = entry.public_key if entry.out_path is not None: existing.out_path = entry.out_path diff --git a/tests/unit/test_contact_book.py b/tests/unit/test_contact_book.py index 6ec4573..977be94 100644 --- a/tests/unit/test_contact_book.py +++ b/tests/unit/test_contact_book.py @@ -48,17 +48,27 @@ def test_contact_book_update_preserves_out_path() -> None: contact.out_path_len = 1 # Re-adding with a dict (no path info) preserves existing path - book.add_contact({"name": "Alice", "public_key": "cd" * 32}) + book.add_contact({"name": "Alice", "public_key": "ab" * 32}) updated = book.get_by_name("Alice") assert updated is not None - assert updated.public_key == "cd" * 32 + assert updated.public_key == "ab" * 32 assert updated.out_path == b"\xa2" assert updated.out_path_len == 1 # Re-adding with a Contact that has explicit path overwrites - new_contact = Contact(name="Alice", public_key="ef" * 32, out_path=b"", out_path_len=0) + new_contact = Contact(name="Alice", public_key="ab" * 32, out_path=b"", out_path_len=0) book.add_contact(new_contact) updated2 = book.get_by_name("Alice") assert updated2 is not None assert updated2.out_path == b"" assert updated2.out_path_len == 0 + + +def test_contact_book_never_rebinds_a_name_to_another_key() -> None: + """A different key arriving under an existing name must not take over the + contact: DMs to that name would be encrypted to the newcomer.""" + book = ContactBook() + book.add_contact({"name": "Alice", "public_key": "ab" * 32}) + book.add_contact({"name": "Alice", "public_key": "cd" * 32}) + assert book.get_by_name("Alice").public_key == "ab" * 32 + assert len(book.list_contacts()) == 1 diff --git a/tests/unit/test_discovery.py b/tests/unit/test_discovery.py index 067ef1e..c82e865 100644 --- a/tests/unit/test_discovery.py +++ b/tests/unit/test_discovery.py @@ -196,13 +196,31 @@ def test_advert_renames_discovered_placeholder(tmp_path, monkeypatch) -> None: assert client.get_saved_repeater_password("Repeater 13ab5c7e") is None -def test_advert_leaves_a_real_name_alone(tmp_path, monkeypatch) -> None: - """Only discovery placeholders are renamed; a node changing its name isn't - this feature's business.""" +def test_a_renamed_node_stays_one_peer(tmp_path, monkeypatch) -> None: + """A verified advert with a new name from a known key renames that peer in + place, keeping its favourite and saved password.""" client = _client(tmp_path, monkeypatch) client._process_event_for_peers(advert_event("Bergblick")) + client.set_favorite(KEY[:16], True) + client._repeater_password_store.save_password("Bergblick", "pw") + client._process_event_for_peers(advert_event("Talblick")) - assert "Bergblick" in {p.display_name for p in client.list_peers()} + + (peer,) = client.list_peers() + assert peer.display_name == "Talblick" and peer.is_favorite + assert client.get_saved_repeater_password("Talblick") == "pw" + + +def test_another_key_cannot_take_over_a_name(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch) + client._process_event_for_peers(advert_event("Bergblick", KEY)) + client.set_favorite(KEY[:16], True) + + client._process_event_for_peers(advert_event("Bergblick", OTHER)) # impostor + + peers = {p.display_name: p for p in client.list_peers()} + assert peers["Bergblick"].public_key == KEY and peers["Bergblick"].is_favorite + assert peers["Bergblick (77cc00)"].public_key == OTHER def test_discovery_updates_a_known_peer_by_key(tmp_path, monkeypatch) -> None: From c8d9438dcad19fe7e1c06c51d48fbf024c9e56f5 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:08:27 +0100 Subject: [PATCH 31/72] fix(routing): flood DMs until a route is known, and stop taking routes from adverts Direct messages, and everything else to peers more than one hop away, were lost: - every advert set the contact's out_path to the advert's inbound path, which lists the hops from the advertiser to us; a direct packet consumes path[0] as the next hop, so the route pointed the wrong way; - every advert copy overwrote it, including a correct route openhop_core had learned from a PATH return; - DMs and repeater CLI commands were always built with create_text_message()'s default message_type="direct", so a contact with no route got a zero-hop DIRECT packet only a direct neighbour could hear. Adverts no longer set routes (only a node that answered our zero-hop discover gets the zero-hop route it proved), and DMs and CLI commands flood while the route is unknown, like firmware's sendMessage(). openhop_core learns the route from the PATH return, as it already does for login and requests. Co-Authored-By: Claude Opus 5.5 --- src/meshcore_console/meshcore/client.py | 31 +++++++++------------ src/meshcore_console/meshcore/operations.py | 10 +++++++ tests/unit/test_repeater_ops.py | 15 ++++++++++ tests/unit/test_verified_adverts.py | 20 +++++++++++++ 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 28993bd..9091232 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -550,7 +550,8 @@ def discover_nodes(self) -> list[dict]: "contact_type": result["node_type"], "snr": result["snr_here"], "rssi": result["rssi"], - "path_hops": [], # it answered zero-hop: a direct neighbour + "path_hops": [], + "direct_route": True, # it answered zero-hop: a direct neighbour }, } ) @@ -853,23 +854,17 @@ def _process_advert_event(self, data: MeshEventDict) -> None: advert_type = data.get("advert_type") or data.get("contact_type") is_repeater = int(advert_type) == 2 if advert_type is not None else False - # Extract raw routing path so the ContactBook Contact gets - # out_path/out_path_len for direct routing. NODE_DISCOVERED events - # carry inbound_path (bytes) + path_len_encoded directly; for "packet" - # events reconstruct from the decoded path_hops list. Only derive - # path when the event actually carries routing data ("path_hops" key - # present) — identity-only events like mesh.contact.new must not - # overwrite a previously learned route. - inbound_path: bytes | None = data.get("inbound_path") # type: ignore[assignment] - path_len_encoded: int | None = data.get("path_len_encoded") # type: ignore[assignment] - if path_len_encoded is None and public_key and "path_hops" in data: - if not path_hops: - inbound_path = b"" - path_len_encoded = 0 - else: - hash_size = len(path_hops[0]) // 2 or 1 - inbound_path = bytes.fromhex("".join(path_hops)) - path_len_encoded = ((hash_size - 1) << 6) | len(path_hops) + # Routes are not taken from adverts. An advert's path lists the hops + # from the advertiser to us, but a direct send consumes path[0] as the + # next hop, so that list points the wrong way; and every copy of an + # advert overwrote the route openhop_core had learned from a PATH + # return. Unknown routes flood (out_path_len -1) and openhop_core learns + # the real route from the reply. The only route set here is the + # zero-hop one of a node that answered our own discover request. + inbound_path: bytes | None = None + path_len_encoded: int | None = None + if data.get("direct_route"): + inbound_path, path_len_encoded = b"", 0 if peer_name in self._peers: self._update_existing_peer( diff --git a/src/meshcore_console/meshcore/operations.py b/src/meshcore_console/meshcore/operations.py index e16f36f..fa61be1 100644 --- a/src/meshcore_console/meshcore/operations.py +++ b/src/meshcore_console/meshcore/operations.py @@ -24,6 +24,14 @@ def _resolve_contact(node: MeshNodeProtocol, peer_name: str) -> object: raise RuntimeError(f"Contact '{peer_name}' not found") +def _route_type(contact: object) -> str: + """Flood while the route is unknown (out_path_len -1), like firmware's + sendMessage(); direct (incl. zero-hop) once a route is known. + create_text_message() defaults to direct, which sent zero-hop DIRECT packets + to contacts with no route, and nobody but a direct neighbour could hear them.""" + return "direct" if getattr(contact, "out_path_len", -1) >= 0 else "flood" + + def _text_message_contact(contact: object) -> object: """Return a PacketBuilder-compatible contact without changing the contact book.""" out_path = getattr(contact, "out_path", None) @@ -44,6 +52,7 @@ async def send_text(*, node: MeshNodeProtocol, peer_name: str, message: str) -> contact=contact, local_identity=node.identity, message=message, + message_type=_route_type(contact), ) success = await node.dispatcher.send_packet(pkt, wait_for_ack=True, expected_crc=ack_crc) return {"success": success, "crc": ack_crc} @@ -287,6 +296,7 @@ def _on_response(message_text: str, sender_contact: object) -> None: contact=contact, local_identity=node.identity, message=command, + message_type=_route_type(contact), txt_type=TXT_TYPE_CLI_DATA, ) await node.dispatcher.send_packet(pkt, wait_for_ack=False) diff --git a/tests/unit/test_repeater_ops.py b/tests/unit/test_repeater_ops.py index f7ec048..4dfbd96 100644 --- a/tests/unit/test_repeater_ops.py +++ b/tests/unit/test_repeater_ops.py @@ -236,3 +236,18 @@ def test_decode_owner_reply_wherever_openhop_put_the_bytes() -> None: assert decode_owner_reply({"type": "binary", "hex": owner_reply(b"", b"").hex()}) is None assert decode_owner_reply({"type": "binary", "hex": owner_reply(b"x" * 40).hex()}) is None assert decode_owner_reply({}) is None + + +def test_dm_floods_while_the_route_is_unknown() -> None: + from meshcore_console.meshcore.operations import send_text + + contact, _key = repeater_contact() + contact.out_path_len = -1 # no route learned yet + node = FakeNode(contact) + node.dispatcher = FakeDispatcher([]) + run(send_text(node=node, peer_name="Bergblick", message="hi")) + assert not node.dispatcher.sent[0].is_route_direct() + + contact.out_path_len = 0 # direct neighbour + run(send_text(node=node, peer_name="Bergblick", message="hi")) + assert node.dispatcher.sent[1].is_route_direct() diff --git a/tests/unit/test_verified_adverts.py b/tests/unit/test_verified_adverts.py index 9f61a78..c4a4c5f 100644 --- a/tests/unit/test_verified_adverts.py +++ b/tests/unit/test_verified_adverts.py @@ -78,3 +78,23 @@ def test_hops_from_path() -> None: assert hops_from_path(bytes.fromhex("13a7"), 2) == ["13", "a7"] assert hops_from_path(bytes.fromhex("13a7c3d4"), (1 << 6) | 2) == ["13a7", "c3d4"] assert hops_from_path(b"", 0) == [] and hops_from_path(None, None) == [] + + +def test_adverts_do_not_set_routes_but_discovery_does(tmp_path, monkeypatch) -> None: + from meshcore_console.meshcore.contact_book import ContactBook + + client = _client(tmp_path, monkeypatch) + monkeypatch.setattr(client._session, "contact_book", ContactBook(), raising=False) + client._connected = True + + client._process_event_for_peers(verified("Bergblick", 100)) # heard via hop 13 + contact = client._session.contact_book.get_by_name("Bergblick") + assert contact.out_path_len == -1 # unknown: DMs flood, openhop learns the route + + client._process_event_for_peers( + { + "type": EventType.MESH_NODE_DISCOVERED, + "data": {"name": "Bergblick", "public_key": KEY, "path_hops": [], "direct_route": True}, + } + ) + assert contact.out_path_len == 0 # it answered our zero-hop discover From 12e341bd94a6267326222e3a175d81bd774b1649 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:13:06 +0100 Subject: [PATCH 32/72] fix: give DM threads their own channel ids DM threads and group channels shared one id namespace: a DM thread's id was the lowercased peer name, a group's the lowercased channel name, and send_message()/ensure_channel() guessed the kind from the string. So: - a DM from "Test" was filed into an existing #test channel, and the reply typed there was broadcast to the whole channel; - "Message" on a node named "Public" opened the Public channel, so the "DM" went out as a public post; - a peer named "#ops" created a hashtag channel. DM threads now use "dm:" (a v9 migration moves existing DM threads and their messages). The Peers and Map views open a peer's thread through the new ensure_dm_channel(), and a bare peer name passed to send_message() means that peer's DM thread, never a group of the same name. A DM thread also follows its peer's rename now. Co-Authored-By: Claude Opus 5.5 --- src/meshcore_console/core/models.py | 12 +++ src/meshcore_console/core/services.py | 4 + src/meshcore_console/meshcore/client.py | 89 ++++++++++-------- src/meshcore_console/meshcore/db.py | 8 ++ src/meshcore_console/mock/client.py | 60 +++++++----- src/meshcore_console/ui_gtk/views/map.py | 2 +- src/meshcore_console/ui_gtk/views/messages.py | 4 + src/meshcore_console/ui_gtk/views/peers.py | 2 +- tests/integration/test_dm_channels.py | 32 +++---- tests/unit/test_dm_namespace.py | 93 +++++++++++++++++++ tests/unit/test_private_channels.py | 22 ++--- 11 files changed, 233 insertions(+), 95 deletions(-) create mode 100644 tests/unit/test_dm_namespace.py diff --git a/src/meshcore_console/core/models.py b/src/meshcore_console/core/models.py index e77296b..bb5e41e 100644 --- a/src/meshcore_console/core/models.py +++ b/src/meshcore_console/core/models.py @@ -2,6 +2,18 @@ from datetime import UTC, datetime +# DM threads live in their own channel-id namespace. They used to share it with +# group channels (both were the lowercased name), so a DM from "Test" landed in +# #test and the reply was broadcast there, and "Message" on a node named +# "Public" posted to the Public channel. +DM_PREFIX = "dm:" + + +def dm_channel_id(peer_name: str) -> str: + """The channel id of the DM thread with ``peer_name``.""" + return DM_PREFIX + peer_name.lower() + + @dataclass(slots=True) class DeviceStatus: node_id: str diff --git a/src/meshcore_console/core/services.py b/src/meshcore_console/core/services.py index e599a6f..2d15de5 100644 --- a/src/meshcore_console/core/services.py +++ b/src/meshcore_console/core/services.py @@ -55,6 +55,10 @@ def list_messages_for_channel(self, channel_id: str, limit: int = 50) -> list[Me def send_message(self, peer_id: str, body: str) -> Message: ... + def ensure_dm_channel(self, peer_name: str) -> Channel: + """The DM thread with ``peer_name`` (channel id ``dm:``), created if needed.""" + ... + def send_advert( self, name: str | None = None, *, route_type: str = "flood" ) -> SendResultDict: ... diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 9091232..ae81162 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -10,7 +10,15 @@ from uuid import uuid4 from meshcore_console.core.enums import EventType, PayloadType -from meshcore_console.core.models import Channel, DeviceStatus, Message, Peer, RepeaterLoginState +from meshcore_console.core.models import ( + DM_PREFIX, + Channel, + DeviceStatus, + Message, + Peer, + RepeaterLoginState, + dm_channel_id, +) from meshcore_console.core.radio import rssi_to_signal_percent from meshcore_console.core.services import MeshcoreService from meshcore_console.core.types import MeshEventDict, SendResultDict @@ -318,31 +326,47 @@ def list_channels(self) -> list[Channel]: self._channel_store.add_or_update(channel) return sorted(self._channels.values(), key=lambda c: c.display_name.lower()) + def ensure_dm_channel(self, peer_name: str) -> Channel: + """The DM thread with ``peer_name``, created if necessary.""" + channel_id = dm_channel_id(peer_name) + channel = self._channels.get(channel_id) + if channel is None: + channel = Channel( + channel_id=channel_id, + display_name=peer_name, + unread_count=0, + peer_name=peer_name, + kind="dm", + ) + self._channels[channel_id] = channel + self._channel_store.add_or_update(channel) + return channel + def ensure_channel(self, channel_id: str, display_name: str | None = None) -> Channel: """Ensure a channel exists, creating it if necessary.""" + if channel_id in self._channels: + return self._channels[channel_id] + if channel_id.startswith(DM_PREFIX): + return self.ensure_dm_channel(display_name or channel_id[len(DM_PREFIX) :]) is_group = ( channel_id == "public" or channel_id.startswith("#") or (display_name is not None and display_name.startswith("#")) ) - # DM channel IDs are always lowercase for consistency - normalized_id = channel_id if is_group else channel_id.lower() - if normalized_id in self._channels: - return self._channels[normalized_id] + if not is_group: + return self.ensure_dm_channel(channel_id) # a bare peer name channel = Channel( - channel_id=normalized_id, - display_name=display_name or (f"#{channel_id}" if is_group else channel_id), + channel_id=channel_id, + display_name=display_name or f"#{channel_id}", unread_count=0, - peer_name=channel_id if not is_group else None, - kind="group" if is_group else "dm", + kind="group", ) - self._channels[normalized_id] = channel + self._channels[channel_id] = channel self._channel_store.add_or_update(channel) - if is_group: - # Hashtag channels need a row in channel_secrets or openhop_core cannot - # encrypt for them at send time and cannot match them at receive - # time (issue #81). - self._ensure_channel_secret(channel) + # Hashtag channels need a row in channel_secrets or openhop_core cannot + # encrypt for them at send time and cannot match them at receive + # time (issue #81). + self._ensure_channel_secret(channel) return channel def import_channel(self, channel_id: str, display_name: str, secret: str) -> Channel: @@ -437,25 +461,11 @@ def send_message(self, peer_id: str, body: str) -> Message: if not self._connected: self.connect() - # Look up existing channel to determine kind; fall back to string heuristic - # for channels not yet in the store (e.g. first DM send). - existing = self._channels.get(peer_id.lower()) or self._channels.get(peer_id) - if existing is not None: - is_group = existing.kind == "group" - else: - is_group = peer_id == "public" or peer_id.startswith("#") - # DM channel IDs are always lowercase to match _process_message_event - channel_id = peer_id if is_group else peer_id.lower() - if channel_id not in self._channels: - display = f"#{channel_id}" if is_group else peer_id - channel = Channel( - channel_id=channel_id, - display_name=display, - peer_name=peer_id if not is_group else None, - kind="group" if is_group else "dm", - ) - self._channels[channel_id] = channel - self._channel_store.add_or_update(channel) + # ``peer_id`` is a channel id ("public", "ops", "dm:bob"); a bare peer + # name means that peer's DM thread, never a group that shares the name. + channel = self._channels.get(peer_id) or self.ensure_channel(peer_id) + is_group = channel.kind == "group" + channel_id = channel.channel_id if is_group: # Ensure a secret exists and resolve the name openhop_core knows the @@ -471,8 +481,7 @@ def send_message(self, peer_id: str, body: str) -> Message: else: # Use the original-case peer name from the channel so openhop_core # can find the contact in the contact book (case-sensitive lookup). - channel = self._channels.get(channel_id) - resolved_name = (channel.peer_name or channel.display_name) if channel else peer_id + resolved_name = channel.peer_name or channel.display_name self._run_async(self._session.send_text(peer_name=resolved_name, message=body)) message = Message( message_id=str(uuid4()), @@ -603,6 +612,8 @@ def _rename_peer(self, old: str, new: str) -> None: for channel in self._channels.values(): if channel.kind == "dm" and channel.peer_name == old: channel.peer_name = new + if channel.display_name == old: + channel.display_name = new self._channel_store.add_or_update(channel) password = self._repeater_password_store.get_password(old) if password is not None: @@ -989,11 +1000,7 @@ def _process_message_event(self, data: MeshEventDict, event_type: str = "") -> N or event_type == EventType.MESH_MESSAGE_NEW or not data.get("channel_name") ) - if is_direct: - raw_channel = sender_name - else: - raw_channel = data["channel_name"] - channel_name = raw_channel.lower() + channel_name = dm_channel_id(sender_name) if is_direct else data["channel_name"].lower() # Preserve the original-case sender name so we can resolve contacts later. peer_display_name = sender_name if is_direct else None diff --git a/src/meshcore_console/meshcore/db.py b/src/meshcore_console/meshcore/db.py index 65df594..02f452a 100644 --- a/src/meshcore_console/meshcore/db.py +++ b/src/meshcore_console/meshcore/db.py @@ -102,6 +102,14 @@ # The ALTER runs in _add_region_scope_column() so it stays idempotent # when schema_version is behind the actual schema. (), + # v8 -> v9: DM threads move to their own id namespace ("dm:"); they + # used to share ids with group channels of the same name. + ( + "UPDATE messages SET channel_id = 'dm:' || channel_id WHERE channel_id IN " + "(SELECT channel_id FROM channels WHERE kind = 'dm' AND channel_id NOT LIKE 'dm:%')", + "UPDATE channels SET channel_id = 'dm:' || channel_id " + "WHERE kind = 'dm' AND channel_id NOT LIKE 'dm:%'", + ), ] diff --git a/src/meshcore_console/mock/client.py b/src/meshcore_console/mock/client.py index 74b1066..4dda04b 100644 --- a/src/meshcore_console/mock/client.py +++ b/src/meshcore_console/mock/client.py @@ -9,7 +9,15 @@ from uuid import uuid4 from meshcore_console.core.enums import EventType, PayloadType -from meshcore_console.core.models import Channel, DeviceStatus, Message, Peer, RepeaterLoginState +from meshcore_console.core.models import ( + DM_PREFIX, + Channel, + DeviceStatus, + Message, + Peer, + RepeaterLoginState, + dm_channel_id, +) from meshcore_console.core.services import MeshcoreService from meshcore_console.meshcore.config import runtime_config_from_settings from meshcore_console.meshcore.region import normalize_region_scope @@ -82,24 +90,38 @@ def list_channels(self) -> list[Channel]: self._channels["public"] = channel return sorted(self._channels.values(), key=lambda c: c.display_name.lower()) + def ensure_dm_channel(self, peer_name: str) -> Channel: + channel_id = dm_channel_id(peer_name) + if channel_id not in self._channels: + self._channels[channel_id] = Channel( + channel_id=channel_id, + display_name=peer_name, + unread_count=0, + peer_name=peer_name, + kind="dm", + ) + return self._channels[channel_id] + def ensure_channel(self, channel_id: str, display_name: str | None = None) -> Channel: - """Ensure a channel exists, creating it if necessary.""" + """Ensure a channel exists, creating it if necessary (same rules as the real client).""" + if channel_id in self._channels: + return self._channels[channel_id] + if channel_id.startswith(DM_PREFIX): + return self.ensure_dm_channel(display_name or channel_id[len(DM_PREFIX) :]) is_group = ( channel_id == "public" or channel_id.startswith("#") or (display_name is not None and display_name.startswith("#")) ) - normalized_id = channel_id if is_group else channel_id.lower() - if normalized_id in self._channels: - return self._channels[normalized_id] + if not is_group: + return self.ensure_dm_channel(channel_id) channel = Channel( - channel_id=normalized_id, - display_name=display_name or (f"#{channel_id}" if is_group else channel_id), + channel_id=channel_id, + display_name=display_name or f"#{channel_id}", unread_count=0, - peer_name=channel_id if not is_group else None, - kind="group" if is_group else "dm", + kind="group", ) - self._channels[normalized_id] = channel + self._channels[channel_id] = channel return channel def import_channel(self, channel_id: str, display_name: str, secret: str) -> Channel: @@ -157,20 +179,8 @@ def set_channel_region_scope(self, channel_id: str, scope: str | None) -> None: self._channel_scopes[channel_id] = normalized def send_message(self, peer_id: str, body: str) -> Message: - existing = self._channels.get(peer_id.lower()) or self._channels.get(peer_id) - if existing is not None: - is_group = existing.kind == "group" - else: - is_group = peer_id == "public" or peer_id.startswith("#") - channel_id = peer_id if is_group else peer_id.lower() - if channel_id not in self._channels: - display = f"#{channel_id}" if is_group else peer_id - self._channels[channel_id] = Channel( - channel_id=channel_id, - display_name=display, - peer_name=peer_id if not is_group else None, - kind="group" if is_group else "dm", - ) + channel = self._channels.get(peer_id) or self.ensure_channel(peer_id) + channel_id = channel.channel_id message = Message( message_id=str(uuid4()), sender_id=self._settings.node_name, @@ -428,7 +438,7 @@ def _process_event_for_messages(self, event: dict) -> None: is_direct = payload_type_name == PayloadType.TXT_MSG if is_direct: - channel_name = sender_name.lower() + channel_name = dm_channel_id(sender_name) else: channel_name = (data.get("channel_name") or "public").lower() diff --git a/src/meshcore_console/ui_gtk/views/map.py b/src/meshcore_console/ui_gtk/views/map.py index 9c12318..293b4cd 100644 --- a/src/meshcore_console/ui_gtk/views/map.py +++ b/src/meshcore_console/ui_gtk/views/map.py @@ -630,4 +630,4 @@ def _on_close_details(self, _button: Gtk.Button) -> None: def _on_send_message_clicked(self, _button: Gtk.Button, peer: Peer) -> None: """Navigate to messages view and start a conversation with this peer.""" - navigate(self, "messages", ("select_channel", peer.display_name)) + navigate(self, "messages", ("select_dm", peer.display_name)) diff --git a/src/meshcore_console/ui_gtk/views/messages.py b/src/meshcore_console/ui_gtk/views/messages.py index 0a9943b..c6276f2 100644 --- a/src/meshcore_console/ui_gtk/views/messages.py +++ b/src/meshcore_console/ui_gtk/views/messages.py @@ -861,6 +861,10 @@ def _on_send(self, *_args: object) -> None: self._send_status.set_text(f"Sent to {display}") self._reload_messages() + def select_dm(self, peer_name: str) -> None: + """Open the DM thread with a peer (from the Peers and Map views).""" + self.select_channel(self._service.ensure_dm_channel(peer_name).channel_id) + def select_channel(self, channel_id: str) -> None: """Select a channel programmatically (for direct messaging from peers view).""" # Ensure the channel exists in the service before trying to select it diff --git a/src/meshcore_console/ui_gtk/views/peers.py b/src/meshcore_console/ui_gtk/views/peers.py index ec8bdb8..bb4bbb3 100644 --- a/src/meshcore_console/ui_gtk/views/peers.py +++ b/src/meshcore_console/ui_gtk/views/peers.py @@ -398,7 +398,7 @@ def _on_toggle_favorite_clicked(self, _button: Gtk.Button, peer: Peer) -> None: def _on_send_message_clicked(self, _button: Gtk.Button, peer: Peer) -> None: """Navigate to messages view and start a conversation with this peer.""" logger.debug("UI: send message to peer=%s", peer.display_name) - navigate(self, "messages", ("select_channel", peer.display_name)) + navigate(self, "messages", ("select_dm", peer.display_name)) def _on_request_telemetry_clicked(self, button: Gtk.Button, peer: Peer) -> None: """Request telemetry from a peer in a background thread.""" diff --git a/tests/integration/test_dm_channels.py b/tests/integration/test_dm_channels.py index fa0d028..d2303da 100644 --- a/tests/integration/test_dm_channels.py +++ b/tests/integration/test_dm_channels.py @@ -9,28 +9,28 @@ def test_dm_send_and_receive_use_same_channel() -> None: # Send a DM to "Alice" (mixed case) msg = client.send_message("Alice", "hello") - assert msg.channel_id == "alice", "DM channel_id should be lowercased" + assert msg.channel_id == "dm:alice", "DM threads use dm:" # Verify channel exists with lowercase id channels = {ch.channel_id: ch for ch in client.list_channels()} - assert "alice" in channels - assert channels["alice"].display_name == "Alice" - assert channels["alice"].peer_name == "Alice" + assert "dm:alice" in channels + assert channels["dm:alice"].display_name == "Alice" + assert channels["dm:alice"].peer_name == "Alice" # Simulate receiving a reply — _process_message_event lowercases the channel # In mock mode, we directly verify via list_messages_for_channel - messages = client.list_messages_for_channel("alice") + messages = client.list_messages_for_channel("dm:alice") assert len(messages) == 1 assert messages[0].body == "hello" # Sending again to the same peer should reuse the channel client.send_message("Alice", "second message") - messages = client.list_messages_for_channel("alice") + messages = client.list_messages_for_channel("dm:alice") assert len(messages) == 2 def test_dm_ensure_channel_normalizes_case() -> None: - """ensure_channel should normalize DM channel IDs to lowercase.""" + """ensure_channel should map any case of a peer name to one DM thread.""" client = MockMeshcoreClient() ch1 = client.ensure_channel("Alice") @@ -38,12 +38,12 @@ def test_dm_ensure_channel_normalizes_case() -> None: ch3 = client.ensure_channel("ALICE") # All should resolve to the same channel - assert ch1.channel_id == "alice" - assert ch2.channel_id == "alice" - assert ch3.channel_id == "alice" + assert ch1.channel_id == "dm:alice" + assert ch2.channel_id == "dm:alice" + assert ch3.channel_id == "dm:alice" # Only one channel should exist for Alice - dm_channels = [ch for ch in client.list_channels() if ch.channel_id == "alice"] + dm_channels = [ch for ch in client.list_channels() if ch.channel_id == "dm:alice"] assert len(dm_channels) == 1 @@ -63,13 +63,13 @@ def test_remove_channel() -> None: client = MockMeshcoreClient() client.send_message("Alice", "hello") - assert any(ch.channel_id == "alice" for ch in client.list_channels()) - assert len(client.list_messages_for_channel("alice")) == 1 + assert any(ch.channel_id == "dm:alice" for ch in client.list_channels()) + assert len(client.list_messages_for_channel("dm:alice")) == 1 - result = client.remove_channel("alice") + result = client.remove_channel("dm:alice") assert result is True - assert not any(ch.channel_id == "alice" for ch in client.list_channels()) - assert len(client.list_messages_for_channel("alice")) == 0 + assert not any(ch.channel_id == "dm:alice" for ch in client.list_channels()) + assert len(client.list_messages_for_channel("dm:alice")) == 0 def test_remove_public_channel_blocked() -> None: diff --git a/tests/unit/test_dm_namespace.py b/tests/unit/test_dm_namespace.py new file mode 100644 index 0000000..8628e8b --- /dev/null +++ b/tests/unit/test_dm_namespace.py @@ -0,0 +1,93 @@ +"""DM threads and group channels can't be confused for each other.""" + +from __future__ import annotations + +import sqlite3 + +from meshcore_console.core.enums import EventType +from meshcore_console.meshcore import db as db_mod +from meshcore_console.meshcore.db import open_db + + +def _client(tmp_path, monkeypatch): + from meshcore_console.meshcore import client as client_mod + from meshcore_console.meshcore.client import MeshcoreClient + from meshcore_console.meshcore.config import runtime_config_from_settings + from meshcore_console.meshcore.settings import MeshcoreSettings + from meshcore_console.mock import MockOpenHopCoreSession + + db_conn = open_db(str(tmp_path / "test.db")) + monkeypatch.setattr(client_mod, "open_db", lambda *a, **k: db_conn) + client = MeshcoreClient( + session=MockOpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())), + require_openhop=False, + ) + client._connected = True + sent: list[tuple[str, str]] = [] + + async def send_text(*, peer_name, message): + sent.append(("dm", peer_name)) + return {"success": True} + + async def send_group_text(*, channel_name, message, region_scope=None): + sent.append(("group", channel_name)) + return {"success": True} + + monkeypatch.setattr(client._session, "send_text", send_text, raising=False) + monkeypatch.setattr(client._session, "send_group_text", send_group_text, raising=False) + return client, sent + + +def dm_from(name: str, text: str) -> dict: + return { + "type": EventType.MESH_MESSAGE_NEW, + "data": {"sender_name": name, "message_text": text, "message_id": text}, + } + + +def test_dm_from_a_name_that_is_also_a_channel_stays_private(tmp_path, monkeypatch) -> None: + client, sent = _client(tmp_path, monkeypatch) + client.ensure_channel("test", "#test") # group #test + client._process_message_event(dm_from("Test", "psst")["data"], EventType.MESH_MESSAGE_NEW) + + assert [m.body for m in client.list_messages_for_channel("test")] == [] + assert [m.body for m in client.list_messages_for_channel("dm:test")] == ["psst"] + + client.send_message("dm:test", "reply") # replying in the DM thread + assert sent == [("dm", "Test")] + + +def test_messaging_a_node_named_public_is_a_dm(tmp_path, monkeypatch) -> None: + client, sent = _client(tmp_path, monkeypatch) + thread = client.ensure_dm_channel("Public") + client.send_message(thread.channel_id, "hi") + assert thread.channel_id == "dm:public" and sent == [("dm", "Public")] + assert client._channels["public"].kind == "group" + + +def test_migration_moves_existing_dm_threads(tmp_path) -> None: + path = tmp_path / "old.db" + conn = sqlite3.connect(str(path)) + for version in db_mod.MIGRATIONS[:8]: + for stmt in version: + conn.execute(stmt) + db_mod._backfill_channel_secrets(conn) + db_mod._add_region_scope_column(conn) + conn.execute("UPDATE schema_version SET version = 8") + conn.execute( + "INSERT INTO channels (channel_id, display_name, unread_count, peer_name, kind) " + "VALUES ('bob', 'Bob', 0, 'Bob', 'dm')" + ) + conn.execute( + "INSERT INTO messages (message_id, sender_id, body, channel_id, created_at) " + "VALUES ('m1', 'Bob', 'hi', 'bob', '2026-10-27T20:00:00+00:00')" + ) + conn.commit() + conn.close() + + conn = open_db(str(path)) + assert conn.execute("SELECT channel_id FROM channels WHERE kind = 'dm'").fetchall() == [ + ("dm:bob",) + ] + assert conn.execute("SELECT channel_id FROM messages").fetchall() == [("dm:bob",)] + conn.close() diff --git a/tests/unit/test_private_channels.py b/tests/unit/test_private_channels.py index 756acb1..815c76c 100644 --- a/tests/unit/test_private_channels.py +++ b/tests/unit/test_private_channels.py @@ -203,23 +203,23 @@ def test_import_channel_updates_existing_row_case_insensitively(tmp_path, monkey assert db.get_channel("Emergency")["secret"] == new_secret -def test_import_channel_rejects_name_colliding_with_dm(tmp_path, monkeypatch) -> None: - """Adding #ops while a DM with peer 'ops' exists must not silently - convert the DM into a group channel.""" +def test_import_channel_coexists_with_a_dm_of_the_same_name(tmp_path, monkeypatch) -> None: + """DM threads have their own id namespace ("dm:"), so adding #ops while + a DM with peer 'ops' exists creates the group without touching the DM.""" client, _db_conn = _client(tmp_path, monkeypatch, "dmcollide.db") - client.ensure_channel("ops", "ops") # a DM + client.ensure_channel("ops", "ops") # a DM (bare peer name) - with pytest.raises(ValueError, match="direct message"): - client.import_channel("ops", "#ops", "00112233445566778899aabbccddeeff") + client.import_channel("ops", "#ops", "00112233445566778899aabbccddeeff") - assert client._channels["ops"].kind == "dm" + assert client._channels["dm:ops"].kind == "dm" + assert client._channels["ops"].kind == "group" -def test_mock_client_import_channel_rejects_name_colliding_with_dm() -> None: +def test_mock_client_import_channel_coexists_with_a_dm_of_the_same_name() -> None: from meshcore_console.mock.client import MockMeshcoreClient client = MockMeshcoreClient() client.ensure_channel("alice", "alice") - with pytest.raises(ValueError, match="direct message"): - client.import_channel("alice", "#alice", "00112233445566778899aabbccddeeff") - assert client._channels["alice"].kind == "dm" + client.import_channel("alice", "#alice", "00112233445566778899aabbccddeeff") + assert client._channels["dm:alice"].kind == "dm" + assert client._channels["alice"].kind == "group" From 48a00bd15c40b0ce3587abe02d85ec0f335e8aa9 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:16:39 +0100 Subject: [PATCH 33/72] fix(messages): send without freezing the UI, retry DMs, and report delivery Sending ran on the GTK main thread: a DM waits for listen-before-talk, airtime and a 5 s ACK, so the whole UI froze for 5+ s per message (indefinitely if the radio wedged). The result was also ignored: the status always said "Sent to X" and the message was stored as sent even when the radio never transmitted, and an unacknowledged DM was never retried. - The Messages view now sends on a worker thread and shows "Sending...", then "Delivered to X", "Sent to X, but no acknowledgement came back", or the error (the text stays in the entry). - DMs are retried up to 3 times like the MeshCore apps (same timestamp, next attempt number); if a known direct route fails twice it is dropped and the last attempt floods. - A channel message the radio did not transmit raises instead of being recorded as sent. Message.delivered carries the DM outcome (not persisted). Co-Authored-By: Claude Opus 5.5 --- src/meshcore_console/core/models.py | 2 + src/meshcore_console/meshcore/client.py | 15 +++++- src/meshcore_console/meshcore/operations.py | 44 ++++++++++----- src/meshcore_console/mock/client.py | 2 + src/meshcore_console/ui_gtk/views/messages.py | 54 +++++++++++++++---- tests/unit/test_dm_namespace.py | 30 +++++++++++ tests/unit/test_repeater_ops.py | 41 ++++++++++++++ 7 files changed, 164 insertions(+), 24 deletions(-) diff --git a/src/meshcore_console/core/models.py b/src/meshcore_console/core/models.py index bb5e41e..33d3465 100644 --- a/src/meshcore_console/core/models.py +++ b/src/meshcore_console/core/models.py @@ -52,6 +52,8 @@ class Message: path_hops: list[str] = field(default_factory=list) snr: float | None = None rssi: int | None = None + # Outgoing DMs: True once ACKed, False if no ACK came; None otherwise (not stored). + delivered: bool | None = None @dataclass(slots=True) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index ae81162..e1667fb 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -61,6 +61,8 @@ def _propagate_routing_fields(packet_data: dict, handler_data: dict) -> None: # listen-before-talk, airtime and the ACK). RUN_ASYNC_CAP_S = 60.0 CONNECT_TIMEOUT_S = 30.0 +# Three DM attempts, each: listen-before-talk (up to 4 s), airtime, 5 s ACK wait. +DM_SEND_TIMEOUT_S = 45.0 def hops_from_path(path: bytes | None, path_len_encoded: int | None) -> list[str]: @@ -473,16 +475,24 @@ def send_message(self, peer_id: str, body: str) -> Message: # channels, so a channel that predates issue #81 is repaired too. channel_name = self._ensure_channel_secret(self._channels[channel_id]) region_scope = self._channel_secrets.get_region_scope(channel_name) - self._run_async( + result = self._run_async( self._session.send_group_text( channel_name=channel_name, message=body, region_scope=region_scope ) ) + if isinstance(result, dict) and result.get("success") is False: + raise RuntimeError("the radio did not transmit the message") + delivered = None # channel messages are not acknowledged else: # Use the original-case peer name from the channel so openhop_core # can find the contact in the contact book (case-sensitive lookup). resolved_name = channel.peer_name or channel.display_name - self._run_async(self._session.send_text(peer_name=resolved_name, message=body)) + result = self._run_async( + self._session.send_text(peer_name=resolved_name, message=body), + timeout=DM_SEND_TIMEOUT_S, + ) + acked = result.get("success") if isinstance(result, dict) else None + delivered = None if acked is None else bool(acked) message = Message( message_id=str(uuid4()), sender_id=self._settings.node_name, @@ -490,6 +500,7 @@ def send_message(self, peer_id: str, body: str) -> Message: channel_id=channel_id, created_at=datetime.now(UTC), is_outgoing=True, + delivered=delivered, ) self._messages.append(message) self._message_store.append(message) diff --git a/src/meshcore_console/meshcore/operations.py b/src/meshcore_console/meshcore/operations.py index fa61be1..a4bf913 100644 --- a/src/meshcore_console/meshcore/operations.py +++ b/src/meshcore_console/meshcore/operations.py @@ -42,20 +42,40 @@ def _text_message_contact(contact: object) -> object: return normalized -async def send_text(*, node: MeshNodeProtocol, peer_name: str, message: str) -> dict: - """Send a direct text message to a peer via PacketBuilder.""" - from openhop_core.protocol.packet_builder import PacketBuilder +DM_ATTEMPTS = 3 - contact = _text_message_contact(_resolve_contact(node, peer_name)) - pkt, ack_crc = PacketBuilder.create_text_message( - contact=contact, - local_identity=node.identity, - message=message, - message_type=_route_type(contact), - ) - success = await node.dispatcher.send_packet(pkt, wait_for_ack=True, expected_crc=ack_crc) - return {"success": success, "crc": ack_crc} +async def send_text( + *, node: MeshNodeProtocol, peer_name: str, message: str, attempts: int = DM_ATTEMPTS +) -> dict: + """Send a direct text message and wait for its ACK, retrying like the MeshCore apps. + + Each retry carries the next attempt number with the same timestamp (the + receiver drops duplicates; the expected ACK changes with the attempt). If + a known direct route fails twice, it is forgotten and the last attempt + floods. ``success`` is True only when an ACK arrived. + """ + from openhop_core.protocol.packet_builder import PacketBuilder + + original = _resolve_contact(node, peer_name) + timestamp = int(time.time()) + ack_crc = 0 + for attempt in range(attempts): + if attempt == attempts - 1 and attempt > 0 and getattr(original, "out_path_len", -1) >= 0: + original.out_path_len = -1 # type: ignore[attr-defined] # route went stale + original.out_path = [] # type: ignore[attr-defined] + contact = _text_message_contact(original) + pkt, ack_crc = PacketBuilder.create_text_message( + contact=contact, + local_identity=node.identity, + message=message, + attempt=attempt, + message_type=_route_type(contact), + timestamp=timestamp, + ) + if await node.dispatcher.send_packet(pkt, wait_for_ack=True, expected_crc=ack_crc): + return {"success": True, "attempts": attempt + 1, "crc": ack_crc} + return {"success": False, "attempts": attempts, "crc": ack_crc} async def send_group_text( diff --git a/src/meshcore_console/mock/client.py b/src/meshcore_console/mock/client.py index 4dda04b..ec95a01 100644 --- a/src/meshcore_console/mock/client.py +++ b/src/meshcore_console/mock/client.py @@ -187,6 +187,8 @@ def send_message(self, peer_id: str, body: str) -> Message: body=body, channel_id=channel_id, created_at=datetime.now(UTC), + is_outgoing=True, + delivered=True if channel.kind == "dm" else None, ) self._messages.append(message) if peer_id not in self._peers: diff --git a/src/meshcore_console/ui_gtk/views/messages.py b/src/meshcore_console/ui_gtk/views/messages.py index c6276f2..f60abba 100644 --- a/src/meshcore_console/ui_gtk/views/messages.py +++ b/src/meshcore_console/ui_gtk/views/messages.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import threading import gi @@ -34,6 +35,15 @@ logger = logging.getLogger(__name__) +def send_status_text(display: str, delivered: bool | None) -> str: + """What the compose status line says after a send.""" + if delivered is None: + return f"Sent to {display}" # channel messages are not acknowledged + if delivered: + return f"Delivered to {display}" + return f"Sent to {display}, but no acknowledgement came back" + + def messages_after(messages: list[Message], last_id: str | None) -> list[Message] | None: """The messages newer than the one with ``last_id``, oldest first. @@ -57,6 +67,7 @@ def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: self._selected_channel_id = "public" self._last_message_id: str | None = None self._last_channel_count = 0 + self._sending = False self._last_message_date: str | None = None self._selected_message: Message | None = None @@ -288,7 +299,7 @@ def _refresh_compose_state(self) -> None: """Enable/disable compose area based on connection status.""" connected = self._service.get_status().connected self._entry.set_sensitive(connected) - self._send_button.set_sensitive(connected) + self._send_button.set_sensitive(connected and not self._sending) self._emoji_btn.set_sensitive(connected) if connected: self._entry.set_placeholder_text("Type message and press Send") @@ -849,17 +860,40 @@ def _on_send(self, *_args: object) -> None: if not body: self._send_status.set_text("Enter a message before sending.") return - try: - self._service.send_message(peer_id=self._selected_channel_id, body=body) - except Exception as exc: # noqa: BLE001 - logger.error("Send failed: %s", exc) - self._send_status.set_text(f"Send failed: {exc}") + if self._sending: return + channel_id = self._selected_channel_id + display = self._get_channel_display_name(channel_id) + # A DM waits for its ACK, with retries: up to ~30 s. Sending on the GTK + # thread froze the whole UI for that long. + self._sending = True + self._send_button.set_sensitive(False) + self._send_status.set_text(f"Sending to {display}...") + + def work() -> None: + try: + message = self._service.send_message(peer_id=channel_id, body=body) + except Exception as exc: # noqa: BLE001 + logger.error("Send failed: %s", exc) + GLib.idle_add(self._on_send_done, body, display, None, str(exc)) + return + GLib.idle_add(self._on_send_done, body, display, message, None) - display = self._get_channel_display_name(self._selected_channel_id) - self._entry.set_text("") - self._send_status.set_text(f"Sent to {display}") - self._reload_messages() + threading.Thread(target=work, daemon=True).start() + + def _on_send_done( + self, body: str, display: str, message: Message | None, error: str | None + ) -> bool: + self._sending = False + self._refresh_compose_state() + if message is None: + self._send_status.set_text(f"Send failed: {error}") # the text stays in the entry + return False + if self._entry.get_text().strip() == body: + self._entry.set_text("") + self._send_status.set_text(send_status_text(display, message.delivered)) + self._poll_messages() + return False def select_dm(self, peer_name: str) -> None: """Open the DM thread with a peer (from the Peers and Map views).""" diff --git a/tests/unit/test_dm_namespace.py b/tests/unit/test_dm_namespace.py index 8628e8b..dd3c106 100644 --- a/tests/unit/test_dm_namespace.py +++ b/tests/unit/test_dm_namespace.py @@ -91,3 +91,33 @@ def test_migration_moves_existing_dm_threads(tmp_path) -> None: ] assert conn.execute("SELECT channel_id FROM messages").fetchall() == [("dm:bob",)] conn.close() + + +def test_send_reports_delivery_and_refuses_untransmitted_posts(tmp_path, monkeypatch) -> None: + import pytest + + client, _sent = _client(tmp_path, monkeypatch) + + async def no_ack(*, peer_name, message): + return {"success": False, "attempts": 3} + + async def not_transmitted(*, channel_name, message, region_scope=None): + return {"success": False} + + monkeypatch.setattr(client._session, "send_text", no_ack, raising=False) + monkeypatch.setattr(client._session, "send_group_text", not_transmitted, raising=False) + + message = client.send_message(client.ensure_dm_channel("Bob").channel_id, "hi") + assert message.delivered is False + + with pytest.raises(RuntimeError): + client.send_message("public", "hello all") + assert [m.body for m in client.list_messages_for_channel("public")] == [] + + +def test_send_status_text() -> None: + from meshcore_console.ui_gtk.views.messages import send_status_text + + assert send_status_text("Bob", True) == "Delivered to Bob" + assert send_status_text("Bob", False) == "Sent to Bob, but no acknowledgement came back" + assert send_status_text("#ops", None) == "Sent to #ops" diff --git a/tests/unit/test_repeater_ops.py b/tests/unit/test_repeater_ops.py index 4dfbd96..d03ce42 100644 --- a/tests/unit/test_repeater_ops.py +++ b/tests/unit/test_repeater_ops.py @@ -251,3 +251,44 @@ def test_dm_floods_while_the_route_is_unknown() -> None: contact.out_path_len = 0 # direct neighbour run(send_text(node=node, peer_name="Bergblick", message="hi")) assert node.dispatcher.sent[1].is_route_direct() + + +class AckAfter: + """send_packet that ACKs only from the n-th attempt on.""" + + def __init__(self, ack_from: int) -> None: + self.ack_from = ack_from + self.sent: list = [] + self.crcs: list[int] = [] + + async def send_packet(self, packet, wait_for_ack=False, expected_crc=None, **_kw) -> bool: + self.sent.append(packet) + self.crcs.append(expected_crc) + return len(self.sent) >= self.ack_from + + +def test_dm_retries_then_floods_when_the_direct_route_fails() -> None: + from meshcore_console.meshcore.operations import send_text + + contact, _key = repeater_contact() # direct, zero-hop route known + node = FakeNode(contact) + node.dispatcher = AckAfter(ack_from=3) + + result = run(send_text(node=node, peer_name="Bergblick", message="hi")) + + assert result["success"] is True and result["attempts"] == 3 + sent = node.dispatcher.sent + assert sent[0].is_route_direct() and sent[1].is_route_direct() + assert not sent[2].is_route_direct() # stale route dropped, last try floods + assert node.contacts.get_by_name("Bergblick").out_path_len == -1 + assert len(set(node.dispatcher.crcs)) == 3 # each attempt expects its own ACK + + +def test_dm_reports_no_ack() -> None: + from meshcore_console.meshcore.operations import send_text + + contact, _key = repeater_contact() + node = FakeNode(contact) + node.dispatcher = AckAfter(ack_from=99) + result = run(send_text(node=node, peer_name="Bergblick", message="hi")) + assert result == {"success": False, "attempts": 3, "crc": node.dispatcher.crcs[-1]} From d2ba4c12854d74f06ad0f842966fad576d9a04ef Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:17:24 +0100 Subject: [PATCH 34/72] fix(messages): stop dropping a repeated DM sent minutes later Received DMs were deduplicated by openhop_core's message_id, which hashes the sender timestamp rounded down to 1000 s with Python's hash() (randomised per process). So Bob sending "ok" twice within the same ~16-minute bucket showed only the first, and after a restart the ids changed again. DMs are now keyed on the sender's key, the exact sender timestamp and the text: a retry repeats timestamp and text and is still dropped, a new message is not. Co-Authored-By: Claude Opus 5.5 --- src/meshcore_console/meshcore/client.py | 22 +++++++++++++++++++--- tests/unit/test_dm_namespace.py | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index e1667fb..0cc3b66 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import hashlib import concurrent.futures import logging import threading @@ -65,6 +66,16 @@ def _propagate_routing_fields(packet_data: dict, handler_data: dict) -> None: DM_SEND_TIMEOUT_S = 45.0 +def dm_message_id(data: dict) -> str | None: + """A stable id for a received DM: sender key, exact sender timestamp, text.""" + pubkey, timestamp = data.get("contact_pubkey"), data.get("timestamp") + text = data.get("message_text") or data.get("payload_text") or data.get("text") + if not pubkey or timestamp is None or text is None: + return None + digest = hashlib.sha256(f"{pubkey}:{timestamp}:{text}".encode()).hexdigest()[:16] + return f"dm_{digest}" + + def hops_from_path(path: bytes | None, path_len_encoded: int | None) -> list[str]: """Hop hashes (hex) from a packet path; the path length byte carries the hash size in bits 6-7 (size - 1) and the hop count in bits 0-5.""" @@ -1016,9 +1027,14 @@ def _process_message_event(self, data: MeshEventDict, event_type: str = "") -> N peer_display_name = sender_name if is_direct else None # Deduplicate by message_id — handles radio retransmissions of the - # same packet (openhop_core derives a deterministic id from the decrypted - # timestamp + content hash, so copies of the same packet share an id). - msg_id = data.get("message_id") or str(uuid4()) + # same packet. Group messages carry the packet hash. For DMs, + # openhop_core's id hashes the sender timestamp rounded down to 1000 s + # with Python's per-process hash(), so two identical DMs ("ok") within + # ~16 minutes collapsed into one; key DMs on the exact timestamp instead + # (a retry repeats the timestamp and text, a new message doesn't). + msg_id = ( + (dm_message_id(data) if is_direct else None) or data.get("message_id") or str(uuid4()) + ) existing_ids = {m.message_id for m in self._messages[-100:]} if msg_id in existing_ids: return diff --git a/tests/unit/test_dm_namespace.py b/tests/unit/test_dm_namespace.py index dd3c106..9b106b1 100644 --- a/tests/unit/test_dm_namespace.py +++ b/tests/unit/test_dm_namespace.py @@ -121,3 +121,23 @@ def test_send_status_text() -> None: assert send_status_text("Bob", True) == "Delivered to Bob" assert send_status_text("Bob", False) == "Sent to Bob, but no acknowledgement came back" assert send_status_text("#ops", None) == "Sent to #ops" + + +def test_identical_dms_minutes_apart_both_show_retries_do_not(tmp_path, monkeypatch) -> None: + client, _sent = _client(tmp_path, monkeypatch) + bob = "bb" * 32 + + def dm(ts: int) -> dict: + return { + "sender_name": "Bob", + "contact_pubkey": bob, + "message_text": "ok", + "timestamp": ts, + "message_id": "rx_1761595000_5ab3c1f2", # openhop's 1000 s bucket id + } + + client._process_message_event(dm(1_761_595_000), EventType.MESH_MESSAGE_NEW) + client._process_message_event(dm(1_761_595_000), EventType.MESH_MESSAGE_NEW) # retry + client._process_message_event(dm(1_761_595_600), EventType.MESH_MESSAGE_NEW) # 10 min later + + assert [m.body for m in client.list_messages_for_channel("dm:bob")] == ["ok", "ok"] From 47b1d2c12d8300d78435b4c38e46b0500e6d64f2 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:18:41 +0100 Subject: [PATCH 35/72] fix(messages): pair each decoded message with its own packet openhop_core emits the raw packet event before decryption and the decoded message event afterwards, and the app paired them first-in, first-out. The queue also holds packets that never produce a message (our own echoes, duplicate copies, other channels' traffic, overheard DMs), so a message took the SNR, RSSI and path of whichever packet was oldest, and its text and sender were written into that packet's analyzer row. Pair them by packet hash instead (packet events carry its first 16 hex chars, message events the full hash); a message without its packet is simply not enriched. Co-Authored-By: Claude Opus 5.5 --- src/meshcore_console/meshcore/client.py | 28 ++++++++++++++++++++---- tests/unit/test_dm_namespace.py | 29 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 0cc3b66..2ea42fc 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -51,6 +51,26 @@ _ROUTING_FIELDS = ("path_hops", "path_len", "snr", "rssi") +def _take_matching_packet(queue: deque[dict], handler_data: dict) -> dict | None: + """Remove and return the queued packet event this decoded message came from. + + Matched by packet hash: packet events carry its first 16 hex chars, message + events the full hash. Pairing by arrival order instead gave a message the + SNR, RSSI and path of whatever packet was oldest in the queue (our own + echoes, duplicates, other channels' traffic), and wrote the message's text + into that packet's analyzer row. + """ + full = handler_data.get("packet_hash") or handler_data.get("message_id") + if not full: + return None + key = str(full)[:16].upper() + for packet in queue: + if str(packet.get("packet_hash", ""))[:16].upper() == key: + queue.remove(packet) + return packet + return None + + def _propagate_routing_fields(packet_data: dict, handler_data: dict) -> None: for key in _ROUTING_FIELDS: val = packet_data.get(key) @@ -729,8 +749,8 @@ def _enrich_sender_names(self, events: list[MeshEventDict]) -> None: self._unenriched_txt.append(data) elif event_type == EventType.MESH_CHANNEL_MESSAGE_NEW: - if self._unenriched_grp: - target = self._unenriched_grp.popleft() + target = _take_matching_packet(self._unenriched_grp, data) + if target is not None: updates: dict[str, str] = {} sender = data.get("sender_name") or data.get("peer_name") if sender and not target.get("sender_name"): @@ -751,8 +771,8 @@ def _enrich_sender_names(self, events: list[MeshEventDict]) -> None: _propagate_routing_fields(target, data) elif event_type == EventType.MESH_MESSAGE_NEW: - if self._unenriched_txt: - target = self._unenriched_txt.popleft() + target = _take_matching_packet(self._unenriched_txt, data) + if target is not None: sender = data.get("sender_name") or data.get("peer_name") if sender: target["sender_name"] = repair_utf8(str(sender)) diff --git a/tests/unit/test_dm_namespace.py b/tests/unit/test_dm_namespace.py index 9b106b1..2a2fcdd 100644 --- a/tests/unit/test_dm_namespace.py +++ b/tests/unit/test_dm_namespace.py @@ -141,3 +141,32 @@ def dm(ts: int) -> dict: client._process_message_event(dm(1_761_595_600), EventType.MESH_MESSAGE_NEW) # 10 min later assert [m.body for m in client.list_messages_for_channel("dm:bob")] == ["ok", "ok"] + + +def test_messages_pair_with_their_own_packet(tmp_path, monkeypatch) -> None: + client, _sent = _client(tmp_path, monkeypatch) + monkeypatch.setattr(client._packet_store, "update_by_hash", lambda *a, **k: None) + + def packet(hash16: str, snr: float) -> dict: + return { + "type": EventType.PACKET, + "data": {"payload_type_name": "GRP_TXT", "packet_hash": hash16, "snr": snr}, + } + + echo = packet("AAAAAAAAAAAAAAAA", -12.0) # our own post, echoed by a repeater + real = packet("BBBBBBBBBBBBBBBB", 7.0) + decoded = { + "type": EventType.MESH_CHANNEL_MESSAGE_NEW, + "data": { + "sender_name": "Bob", + "channel_name": "public", + "message_text": "hi", + "message_id": "BBBBBBBBBBBBBBBB", + "packet_hash": "BBBBBBBBBBBBBBBB" + "C" * 48, + }, + } + client._enrich_sender_names([echo, real, decoded]) + + assert decoded["data"]["snr"] == 7.0 # not the echo's -12 + assert real["data"]["payload_text"] == "hi" and "payload_text" not in echo["data"] + assert list(client._unenriched_grp) == [echo["data"]] From 3fcf99e2bfea406eb874f79818c099afa2984344 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:19:11 +0100 Subject: [PATCH 36/72] fix(messages): show the real hop count instead of "255 hops" Received messages stored openhop_core's encoded path length byte as the hop count. Its top bits carry the path hash size, and direct messages report 0xFF ("unknown"), so the message details showed "255 hops" for every DM and wrong counts with multi-byte path hashes. Decode the hop count from bits 0-5 and treat 0xFF as zero hops. Co-Authored-By: Claude Opus 5.5 --- src/meshcore_console/meshcore/client.py | 15 +++++++++++++-- tests/unit/test_verified_adverts.py | 9 +++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 2ea42fc..2ca86f8 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -96,6 +96,17 @@ def dm_message_id(data: dict) -> str | None: return f"dm_{digest}" +def hop_count(path_len: int | None) -> int: + """Hops from openhop_core's encoded path length byte. + + Bits 6-7 hold the hash size and bits 0-5 the hop count; direct messages + report 0xFF ("unknown"), which the details panel showed as "255 hops". + """ + if not path_len or path_len == 0xFF: + return 0 + return int(path_len) & 0x3F + + def hops_from_path(path: bytes | None, path_len_encoded: int | None) -> list[str]: """Hop hashes (hex) from a packet path; the path length byte carries the hash size in bits 6-7 (size - 1) and the hop count in bits 0-5.""" @@ -1061,7 +1072,7 @@ def _process_message_event(self, data: MeshEventDict, event_type: str = "") -> N snr = data.get("snr") rssi = data.get("rssi") - path_len = data.get("path_len") or 0 + path_len = hop_count(data.get("path_len")) path_hops = data.get("path_hops", []) message = Message( message_id=msg_id, @@ -1070,7 +1081,7 @@ def _process_message_event(self, data: MeshEventDict, event_type: str = "") -> N channel_id=channel_name, created_at=datetime.now(UTC), is_outgoing=False, - path_len=int(path_len) if path_len else 0, + path_len=path_len, path_hops=list(path_hops) if path_hops else [], snr=float(snr) if snr is not None else None, rssi=int(rssi) if rssi is not None else None, diff --git a/tests/unit/test_verified_adverts.py b/tests/unit/test_verified_adverts.py index c4a4c5f..70c9fa6 100644 --- a/tests/unit/test_verified_adverts.py +++ b/tests/unit/test_verified_adverts.py @@ -98,3 +98,12 @@ def test_adverts_do_not_set_routes_but_discovery_does(tmp_path, monkeypatch) -> } ) assert contact.out_path_len == 0 # it answered our zero-hop discover + + +def test_hop_count_from_the_encoded_path_length() -> None: + from meshcore_console.meshcore.client import hop_count + + assert hop_count(0xFF) == 0 # direct DM: "unknown", was shown as 255 hops + assert hop_count(3) == 3 + assert hop_count((1 << 6) | 2) == 2 # 2-byte hashes, 2 hops + assert hop_count(None) == 0 and hop_count(0) == 0 From 75536ac634426df7833086bc461db199a3b0ada0 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:20:24 +0100 Subject: [PATCH 37/72] fix(security): bound the peer list Every advert from a new key added a peer, a contact and a database row, with no limit or pruning. Keys cost nothing to generate, so anyone could flood signed adverts from throwaway keys (2000 in a reviewer's probe gave 2000 peers and rows), slowing the peer list and map, and every inbound DM is tried against each contact sharing its 1-byte hash, one key exchange each, on the radio's event loop. Cap the peer list at 500, like firmware's fixed contact table: the least recently heard peer is evicted (memory, database, contact book); favourites, peers with a DM thread and logged-in repeaters are kept. Co-Authored-By: Claude Opus 5.5 --- src/meshcore_console/meshcore/client.py | 32 +++++++++++++++++++ src/meshcore_console/meshcore/contact_book.py | 3 ++ src/meshcore_console/meshcore/state_store.py | 4 +++ tests/unit/test_verified_adverts.py | 23 +++++++++++++ 4 files changed, 62 insertions(+) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 2ca86f8..20a0861 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -84,6 +84,7 @@ def _propagate_routing_fields(packet_data: dict, handler_data: dict) -> None: CONNECT_TIMEOUT_S = 30.0 # Three DM attempts, each: listen-before-talk (up to 4 s), airtime, 5 s ACK wait. DM_SEND_TIMEOUT_S = 45.0 +MAX_PEERS = 500 def dm_message_id(data: dict) -> str | None: @@ -648,6 +649,36 @@ def _claim_name(self, public_key: str, name: str) -> str: self._rename_peer(current, name) return name + def _make_room_for_peer(self) -> None: + """Keep the peer list bounded, like firmware's fixed contact table. + + Valid keys cost nothing to generate, so signed adverts from throwaway + keys could otherwise grow the list (and the database) without limit, + and every inbound DM is tried against each contact sharing its 1-byte + hash. The least recently heard peer goes first; favourites, peers with + a DM thread and logged-in repeaters are kept. + """ + if len(self._peers) < MAX_PEERS: + return + dm_peers = {c.peer_name for c in self._channels.values() if c.kind == "dm"} + candidates = [ + (name, peer) + for name, peer in self._peers.items() + if not peer.is_favorite and name not in dm_peers and name not in self._repeater_sessions + ] + if not candidates: + return + name, peer = min( + candidates, + key=lambda item: item[1].last_advert_time or datetime.min.replace(tzinfo=UTC), + ) + del self._peers[name] + self._peer_store.remove(peer.peer_id) + if peer.public_key: + self._advert_timestamps.pop(peer.public_key, None) + if self._connected: + self._session.contact_book.remove_key(peer.public_key) + def _rename_peer(self, old: str, new: str) -> None: """Move a peer and everything keyed by its name (contact, login session, DM thread, saved password) to a new name; the peer_id, and so the DB @@ -1013,6 +1044,7 @@ def _create_new_peer( path_len_encoded: int | None = None, ) -> None: """Create a new peer from advert data.""" + self._make_room_for_peer() peer = Peer( peer_id=peer_id or peer_name, display_name=peer_name, diff --git a/src/meshcore_console/meshcore/contact_book.py b/src/meshcore_console/meshcore/contact_book.py index ff2cd95..4a8cae9 100644 --- a/src/meshcore_console/meshcore/contact_book.py +++ b/src/meshcore_console/meshcore/contact_book.py @@ -50,6 +50,9 @@ def get_by_key(self, public_key: bytes | str) -> Contact | None: return contact return None + def remove_key(self, public_key: str) -> None: + self.contacts = [c for c in self.contacts if c.public_key != public_key] + def update(self, contact: Contact) -> None: for i, existing in enumerate(self.contacts): if existing.public_key == contact.public_key: diff --git a/src/meshcore_console/meshcore/state_store.py b/src/meshcore_console/meshcore/state_store.py index 0a78c79..75bf0d5 100644 --- a/src/meshcore_console/meshcore/state_store.py +++ b/src/meshcore_console/meshcore/state_store.py @@ -133,6 +133,10 @@ def get_all(self) -> dict[str, Peer]: result[peer.display_name or peer.peer_id] = peer return result + def remove(self, peer_id: str) -> None: + self._conn.execute("DELETE FROM peers WHERE peer_id = ?", (peer_id,)) + self._conn.commit() + def set_favorite(self, peer_id: str, favorite: bool) -> None: """Update just the is_favorite flag for a peer.""" self._conn.execute( diff --git a/tests/unit/test_verified_adverts.py b/tests/unit/test_verified_adverts.py index 70c9fa6..47edd4d 100644 --- a/tests/unit/test_verified_adverts.py +++ b/tests/unit/test_verified_adverts.py @@ -107,3 +107,26 @@ def test_hop_count_from_the_encoded_path_length() -> None: assert hop_count(3) == 3 assert hop_count((1 << 6) | 2) == 2 # 2-byte hashes, 2 hops assert hop_count(None) == 0 and hop_count(0) == 0 + + +def test_peer_list_is_bounded_and_keeps_what_matters(tmp_path, monkeypatch) -> None: + from meshcore_console.meshcore import client as client_mod + + monkeypatch.setattr(client_mod, "MAX_PEERS", 3) + client = _client(tmp_path, monkeypatch) + + def advert(name: str, key: str, ts: int) -> dict: + event = verified(name, ts) + event["data"]["public_key"] = key + return event + + keys = {name: (name[0] * 2) * 32 for name in ("aa", "bb", "cc", "dd", "ee")} + client._process_event_for_peers(advert("Alpha", keys["aa"], 1)) + client._process_event_for_peers(advert("Bravo", keys["bb"], 2)) + client._process_event_for_peers(advert("Charlie", keys["cc"], 3)) + client.set_favorite(keys["aa"][:16], True) # oldest, but a favourite + client.ensure_dm_channel("Bravo") # has a DM thread + + client._process_event_for_peers(advert("Delta", keys["dd"], 4)) + assert sorted(p.display_name for p in client.list_peers()) == ["Alpha", "Bravo", "Delta"] + assert client._db.execute("SELECT COUNT(*) FROM peers").fetchone()[0] == 3 From 4fc3544aea48347142d9cc9c154ae2bae034c3ca Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:21:40 +0100 Subject: [PATCH 38/72] fix(radio): add the EU/UK Narrow preset and use MeshCore 1.16 preamble lengths The only European preset was MeshCore's older 869.525 MHz / 250 kHz / SF11 default. MeshCore's recommended EU/UK preset, used across e.g. Germany, is "EU/UK Narrow": 869.618 MHz / 62.5 kHz / SF8 / CR 4/8. Add it, and label the old one "EU/UK Long Range". All presets used a 17-symbol preamble. Since firmware 1.16 MeshCore sends 32 symbols at SF <= 8 and 16 above, to make preamble detection more reliable; use those values (and 32 as the default). Co-Authored-By: Claude Opus 5.5 --- src/meshcore_console/meshcore/config.py | 2 +- src/meshcore_console/meshcore/settings.py | 17 +++++++++++--- src/meshcore_console/ui_gtk/views/settings.py | 3 ++- tests/unit/test_radio_presets.py | 23 +++++++++++++++++++ 4 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_radio_presets.py diff --git a/src/meshcore_console/meshcore/config.py b/src/meshcore_console/meshcore/config.py index 1b8464a..f29c423 100644 --- a/src/meshcore_console/meshcore/config.py +++ b/src/meshcore_console/meshcore/config.py @@ -30,7 +30,7 @@ class HardwareRadioConfig: spreading_factor: int = 7 bandwidth: int = 62_500 coding_rate: int = 5 - preamble_length: int = 17 + preamble_length: int = 32 # MeshCore >= 1.16 at SF <= 8 is_waveshare: bool = False use_dio2_rf: bool = True use_dio3_tcxo: bool = True diff --git a/src/meshcore_console/meshcore/settings.py b/src/meshcore_console/meshcore/settings.py index cb3f355..49ff65a 100644 --- a/src/meshcore_console/meshcore/settings.py +++ b/src/meshcore_console/meshcore/settings.py @@ -25,7 +25,8 @@ class MeshcoreSettings: spreading_factor: int = 7 coding_rate: int = 5 tx_power: int = 22 - preamble_length: int = 17 + # MeshCore >= 1.16 sends 32 preamble symbols at SF <= 8 and 16 above. + preamble_length: int = 32 path_hash_mode: int = 0 flood_region: str = "" # Default region scope for flood packets; empty = unscoped (#90) @@ -66,15 +67,25 @@ def clone(self) -> "MeshcoreSettings": "bandwidth": 62_500, "spreading_factor": 7, "coding_rate": 5, - "preamble_length": 17, + "preamble_length": 32, "tx_power": 22, }, + # MeshCore's recommended EU/UK preset, used across e.g. Germany. + "meshcore-eu-narrow": { + "frequency": 869_618_000, + "bandwidth": 62_500, + "spreading_factor": 8, + "coding_rate": 8, + "preamble_length": 32, + "tx_power": 22, + }, + # The older EU default ("long range"), still used by some meshes. "meshcore-eu": { "frequency": 869_525_000, "bandwidth": 250_000, "spreading_factor": 11, "coding_rate": 5, - "preamble_length": 17, + "preamble_length": 16, "tx_power": 22, }, } diff --git a/src/meshcore_console/ui_gtk/views/settings.py b/src/meshcore_console/ui_gtk/views/settings.py index 3c22d43..dc76ad4 100644 --- a/src/meshcore_console/ui_gtk/views/settings.py +++ b/src/meshcore_console/ui_gtk/views/settings.py @@ -207,7 +207,8 @@ def _build_radio_panel(self) -> Gtk.Box: grid.attach(self._grid_label("Preset"), 0, 0, 1, 1) self._preset = Gtk.ComboBoxText.new() self._preset.append("meshcore-us", "USA/Canada") - self._preset.append("meshcore-eu", "Europe") + self._preset.append("meshcore-eu-narrow", "EU/UK Narrow") + self._preset.append("meshcore-eu", "EU/UK Long Range") self._preset.append("custom", "Custom") self._preset.set_active_id("meshcore-us") self._preset.connect("changed", self._on_preset_changed) diff --git a/tests/unit/test_radio_presets.py b/tests/unit/test_radio_presets.py new file mode 100644 index 0000000..4c3330a --- /dev/null +++ b/tests/unit/test_radio_presets.py @@ -0,0 +1,23 @@ +"""Radio presets match current MeshCore firmware.""" + +from __future__ import annotations + +from meshcore_console.meshcore.settings import MeshcoreSettings, apply_preset + + +def test_eu_uk_narrow_preset() -> None: + s = apply_preset(MeshcoreSettings(), "meshcore-eu-narrow") + assert (s.frequency, s.bandwidth, s.spreading_factor, s.coding_rate) == ( + 869_618_000, + 62_500, + 8, + 8, + ) + + +def test_preamble_follows_firmware_1_16() -> None: + """32 symbols at SF <= 8, 16 above.""" + for preset in ("meshcore-us", "meshcore-eu-narrow", "meshcore-eu"): + s = apply_preset(MeshcoreSettings(), preset) + assert s.preamble_length == (32 if s.spreading_factor <= 8 else 16), preset + assert MeshcoreSettings().preamble_length == 32 From da28aac4cc4fdecee4cca65dd8802e762a0048a4 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:22:28 +0100 Subject: [PATCH 39/72] fix(radio): pass MeshCore's sync word to the radio explicitly The SX1262 was created without a sync word, so openhop_core's default 0x3444 (public LoRaWAN) applies. It works today only because openhop_core 1.1.3's setSyncWord() has its register write commented out and the chip keeps its reset value 0x1424, which happens to be MeshCore's 0x12. Once that write is restored upstream (openhop_core PR #164), the radio would go deaf to every MeshCore node. Pass 0x1424 explicitly. Co-Authored-By: Claude Opus 5.5 --- src/meshcore_console/meshcore/runtime.py | 9 +++++++++ tests/unit/test_radio_presets.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/meshcore_console/meshcore/runtime.py b/src/meshcore_console/meshcore/runtime.py index 92c29c3..87fe432 100644 --- a/src/meshcore_console/meshcore/runtime.py +++ b/src/meshcore_console/meshcore/runtime.py @@ -34,6 +34,9 @@ def import_openhop_core() -> tuple[ return SX1262Radio, EventService, EventSubscriber, MeshNode, LocalIdentity # type: ignore[return-value] +MESHCORE_SYNC_WORD = 0x1424 + + def create_radio( sx1262_radio_type: type[SX1262RadioProtocol], config: HardwareRadioConfig, @@ -54,6 +57,12 @@ def create_radio( "bandwidth": config.bandwidth, "coding_rate": config.coding_rate, "preamble_length": config.preamble_length, + # MeshCore's private LoRa sync word 0x12, as the SX126x register value. + # openhop_core's default is 0x3444 (public LoRaWAN); it isn't written + # today (setSyncWord's register write is commented out in 1.1.3), so the + # chip keeps its reset value 0x1424, which happens to be MeshCore's. + # Pass it explicitly so a fixed setSyncWord can't make the radio deaf. + "sync_word": MESHCORE_SYNC_WORD, "is_waveshare": config.is_waveshare, "gpio_chip": config.gpio_chip, "use_gpiod_backend": config.use_gpiod_backend, diff --git a/tests/unit/test_radio_presets.py b/tests/unit/test_radio_presets.py index 4c3330a..d8b2860 100644 --- a/tests/unit/test_radio_presets.py +++ b/tests/unit/test_radio_presets.py @@ -21,3 +21,19 @@ def test_preamble_follows_firmware_1_16() -> None: s = apply_preset(MeshcoreSettings(), preset) assert s.preamble_length == (32 if s.spreading_factor <= 8 else 16), preset assert MeshcoreSettings().preamble_length == 32 + + +def test_radio_gets_meshcores_sync_word() -> None: + """0x12, MeshCore's private sync word, in SX126x register form; openhop_core's + own default is the public LoRaWAN 0x3444.""" + from meshcore_console.meshcore.config import HardwareRadioConfig + from meshcore_console.meshcore.runtime import create_radio + + captured: dict = {} + + class FakeRadio: + def __init__(self, sync_word: int = 0x3444, **kwargs) -> None: + captured["sync_word"] = sync_word + + create_radio(FakeRadio, HardwareRadioConfig(), lambda _msg: None) + assert captured["sync_word"] == 0x1424 From 3e2c91cabcfe6a505c62eb43a00ad2823af9563b Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:23:17 +0100 Subject: [PATCH 40/72] fix(presets): power the AIOv2 LoRa module from GPIO 16, not the GPS rail The "uConsole HG AIOv2" board preset drove GPIO 27 HIGH as the LoRa power enable (#85). On the AIOv2, GPIO 27 switches the GPS module and GPIO 16 the LoRa module: HackerGadgets' own aiov2_ctl maps GPS=27, LORA=16, and this app's UConsoleGps also enables the GPS on 27. With the preset, connecting switched the GPS on rather than the radio's supply. Use 16 and correct the docs. (On a CM5 AIOv2, the SX1262 initialises and receives with GPIO 16 high.) Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +++-- README.md | 6 +++--- src/meshcore_console/meshcore/settings.py | 5 +++-- tests/unit/test_gpio_chip_config.py | 7 ++++--- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 336ef1e..a686c9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,7 +159,7 @@ All commands below assume you are inside `nix develop`. | `MESHCORE_GPS_DEVICE` | GPS serial port; overrides auto-detection and gpsd (also in Settings > GPS Device) | | `MESHCORE_GPIO_CHIP` | `/dev/gpiochipN` number (default: 0; also in Settings > Hardware) | | `MESHCORE_USE_GPIOD_BACKEND=1` | Poll for IRQ edges instead of using kernel edge interrupts | -| `MESHCORE_EN_PINS` | Comma-separated GPIO pins driven HIGH at init to power the radio (HG AIOv2: `27`) | +| `MESHCORE_EN_PINS` | Comma-separated GPIO pins driven HIGH at init to power the radio (HG AIOv2: `16`) | Every hardware variable above overrides the matching value in Settings > Hardware. `_HARDWARE_ENV_OVERRIDES` in `meshcore/config.py` holds the whole @@ -183,7 +183,8 @@ configured chip and lists what the host actually has: `HARDWARE_PRESETS` in `meshcore/settings.py` owns the per-board pinout, including `en_pins`: the LoRa power-enable line belongs to the board, so every preset states it, and a switch between boards clears a stale pin. The -`hg-aiov2` preset is the uConsole pinout plus enable pin 27. +`hg-aiov2` preset is the uConsole pinout plus LoRa enable pin 16 (27 is the +AIOv2's GPS rail). `gpio_chip` is deliberately *not* in any preset. It follows the SoC and the kernel (CM4 vs CM5), not the radio board, so a preset must never clobber it. diff --git a/README.md b/README.md index af76c29..397adf7 100644 --- a/README.md +++ b/README.md @@ -153,9 +153,9 @@ command line. Notable radio bring-up flags: - `MESHCORE_GPIO_CHIP=15` — which `/dev/gpiochipN` carries the 40-pin header. It is 0 on CM4, but 15 on CM5 and Pi 5 kernels, where the header hangs off the RP1. `doctor` lists the chips your host actually has. -- `MESHCORE_EN_PINS=27` — GPIO pins driven HIGH at init to power the radio. - The HackerGadgets AIOv2 enable pin is 27; the **uConsole HG AIOv2** board - preset sets this for you. +- `MESHCORE_EN_PINS=16` — GPIO pins driven HIGH at init to power the radio. + The HackerGadgets AIOv2 LoRa enable pin is 16 (27 is its GPS rail); the + **uConsole HG AIOv2** board preset sets this for you. - `MESHCORE_USE_GPIOD_BACKEND=1` — poll for IRQ edges instead of asking the kernel for edge interrupts. Try this if the radio connects but receives nothing, which happens on kernels that reject the edge request. diff --git a/src/meshcore_console/meshcore/settings.py b/src/meshcore_console/meshcore/settings.py index 49ff65a..7eb84bc 100644 --- a/src/meshcore_console/meshcore/settings.py +++ b/src/meshcore_console/meshcore/settings.py @@ -118,7 +118,8 @@ def apply_preset(settings: MeshcoreSettings, preset: str) -> MeshcoreSettings: "en_pins": "", }, # Same wiring as the plain uConsole board, plus the LoRa power-enable pin - # that the HackerGadgets AIOv2 puts on GPIO 27 (#85). + # that the HackerGadgets AIOv2 puts on GPIO 16. GPIO 27 is its GPS rail + # (HackerGadgets' aiov2_ctl: GPS=27, LORA=16; UConsoleGps enables GPS on 27). "hg-aiov2": { "bus_id": 1, "cs_id": 0, @@ -131,7 +132,7 @@ def apply_preset(settings: MeshcoreSettings, preset: str) -> MeshcoreSettings: "is_waveshare": False, "use_dio2_rf": True, "use_dio3_tcxo": True, - "en_pins": "27", + "en_pins": "16", }, "waveshare": { "bus_id": 0, diff --git a/tests/unit/test_gpio_chip_config.py b/tests/unit/test_gpio_chip_config.py index ffb463f..6979abe 100644 --- a/tests/unit/test_gpio_chip_config.py +++ b/tests/unit/test_gpio_chip_config.py @@ -232,13 +232,14 @@ def test_hardware_env_overrides_reports_what_is_set(monkeypatch) -> None: def test_aiov2_preset_sets_the_lora_enable_pin() -> None: - """The HackerGadgets AIOv2 powers its LoRa module from GPIO 27 (#85).""" + """The HackerGadgets AIOv2 powers its LoRa module from GPIO 16; GPIO 27 is + its GPS rail (HackerGadgets' aiov2_ctl: GPS=27, LORA=16).""" settings = apply_hardware_preset(MeshcoreSettings(), "hg-aiov2") - assert settings.en_pins == "27" + assert settings.en_pins == "16" hardware = runtime_config_from_settings(settings).hardware assert hardware is not None - assert hardware.en_pins == (27,) + assert hardware.en_pins == (16,) def test_switching_away_from_the_aiov2_preset_clears_the_enable_pin() -> None: From d61624fa065fd50fb4d2f3ca3dbc6e1f2032c42f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:23:52 +0100 Subject: [PATCH 41/72] fix(settings): fall back to the default for an unreadable settings row SettingsStore.load() cast every stored row with int()/float(); one malformed value (hand-edited, or written by a different version) raised inside MeshcoreClient.__init__, so the app could not start at all. An invalid row now falls back to that field's default with a warning. Co-Authored-By: Claude Opus 5.5 --- src/meshcore_console/meshcore/settings_store.py | 12 +++++++++++- tests/unit/test_settings_store.py | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/meshcore_console/meshcore/settings_store.py b/src/meshcore_console/meshcore/settings_store.py index ac410a3..8c74fd2 100644 --- a/src/meshcore_console/meshcore/settings_store.py +++ b/src/meshcore_console/meshcore/settings_store.py @@ -1,10 +1,13 @@ from __future__ import annotations +import logging import sqlite3 from dataclasses import asdict, fields from .settings import MeshcoreSettings +logger = logging.getLogger(__name__) + class SettingsStore: def __init__(self, conn: sqlite3.Connection) -> None: @@ -19,7 +22,14 @@ def load(self) -> MeshcoreSettings: for field in fields(MeshcoreSettings): if field.name in stored: raw = stored[field.name] - defaults[field.name] = _cast(raw, field.type) + try: + defaults[field.name] = _cast(raw, field.type) + except (TypeError, ValueError): + # One bad row (hand-edited, or written by another version) + # used to raise here and keep the app from starting at all. + logger.warning( + "settings: ignoring invalid %s=%r, using the default", field.name, raw + ) return MeshcoreSettings(**defaults) def save(self, settings: MeshcoreSettings) -> None: diff --git a/tests/unit/test_settings_store.py b/tests/unit/test_settings_store.py index bd13008..e6fb9a9 100644 --- a/tests/unit/test_settings_store.py +++ b/tests/unit/test_settings_store.py @@ -55,3 +55,19 @@ def test_flood_region_defaults_empty_and_round_trips(tmp_path) -> None: store.save(MeshcoreSettings(flood_region="#germany")) assert store.load().flood_region == "#germany" conn.close() + + +def test_a_bad_row_falls_back_to_its_default(tmp_path) -> None: + from meshcore_console.meshcore.db import open_db + from meshcore_console.meshcore.settings import MeshcoreSettings + from meshcore_console.meshcore.settings_store import SettingsStore + + conn = open_db(str(tmp_path / "s.db")) + store = SettingsStore(conn) + store.save(MeshcoreSettings(node_name="DF9ES")) + conn.execute("UPDATE settings SET value = 'abc' WHERE key = 'frequency'") + conn.commit() + + settings = store.load() + assert settings.frequency == MeshcoreSettings().frequency + assert settings.node_name == "DF9ES" From 10f499d3bbf09dd47126161b038656ac86aa5e80 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 27 Oct 2026 22:24:26 +0100 Subject: [PATCH 42/72] fix(ui): URL-encode the node name in the contact QR code The contact QR code was built as meshcore://contact?name={name}&key={key} with the name inserted verbatim, so a node name containing "&", "=", "#" or a space produced a link that parses to a different name or drops the key. Co-Authored-By: Claude Opus 5.5 --- .../ui_gtk/widgets/qr_dialog.py | 16 ++++++++++++---- tests/unit/test_qr_data.py | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_qr_data.py diff --git a/src/meshcore_console/ui_gtk/widgets/qr_dialog.py b/src/meshcore_console/ui_gtk/widgets/qr_dialog.py index 13145ef..6bd0f0f 100644 --- a/src/meshcore_console/ui_gtk/widgets/qr_dialog.py +++ b/src/meshcore_console/ui_gtk/widgets/qr_dialog.py @@ -2,6 +2,8 @@ from __future__ import annotations +from urllib.parse import quote, urlencode + import io from typing import TYPE_CHECKING @@ -58,6 +60,15 @@ def _abbreviate_key(key: str) -> str: return f"{key[:8]}...{key[-8:]}" +def contact_qr_data(node_name: str, public_key: str | None) -> str: + """meshcore://contact?name=NAME&key=PUBKEY, with the name URL-encoded: a name + containing "&", "=", "#" or spaces otherwise produced a broken link.""" + query = {"name": node_name} + if public_key: + query["key"] = public_key + return "meshcore://contact?" + urlencode(query, quote_via=quote) + + class QrCodeDialog(Gtk.Dialog): """Dialog showing node identity QR code.""" @@ -134,10 +145,7 @@ def __init__( def _build_qr_data(self, node_name: str, public_key: str | None) -> str: """Build the QR code data string.""" - # Format: meshcore://contact?name=NAME&key=PUBKEY - if public_key: - return f"meshcore://contact?name={node_name}&key={public_key}" - return f"meshcore://contact?name={node_name}" + return contact_qr_data(node_name, public_key) def _draw_avatar( self, diff --git a/tests/unit/test_qr_data.py b/tests/unit/test_qr_data.py new file mode 100644 index 0000000..9379471 --- /dev/null +++ b/tests/unit/test_qr_data.py @@ -0,0 +1,18 @@ +"""The contact QR link survives names with URL syntax in them.""" + +from __future__ import annotations + +from urllib.parse import parse_qs, urlparse + +from meshcore_console.ui_gtk.widgets.qr_dialog import contact_qr_data + + +def test_name_is_url_encoded() -> None: + link = contact_qr_data("R&D Hill #2", "ab" * 32) + query = parse_qs(urlparse(link).query) + assert query == {"name": ["R&D Hill #2"], "key": ["ab" * 32]} + assert link.startswith("meshcore://contact?name=R%26D%20Hill%20%232&key=") + + +def test_without_key() -> None: + assert contact_qr_data("DF9ES", None) == "meshcore://contact?name=DF9ES" From 59dafb07da3e9e0ffec133466bbfcec7326ca24f Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:30:38 +0100 Subject: [PATCH 43/72] fix(messages): show late CLI replies in Admin and credit room-post authors 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/meshcore/client.py | 33 ++++++++++++++++++- src/meshcore_console/ui_gtk/views/admin.py | 12 ++++++- tests/unit/test_dm_namespace.py | 38 ++++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 20a0861..dc63100 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -78,6 +78,10 @@ def _propagate_routing_fields(packet_data: dict, handler_data: dict) -> None: handler_data[key] = list(val) if isinstance(val, list) else val +# MeshCore TXT_MSG types (flags >> 2) +TXT_TYPE_CLI_DATA = 1 # a CLI command or reply +TXT_TYPE_SIGNED_PLAIN = 2 # room-server post: 4-byte author key prefix + text + # Upper bound for a blocking radio call without its own timeout (a DM waits for # listen-before-talk, airtime and the ACK). RUN_ASYNC_CAP_S = 60.0 @@ -620,6 +624,15 @@ def discover_nodes(self) -> list[dict]: ) return results + def _name_for_key_prefix(self, prefix_hex: str) -> str: + """The peer whose key starts with ``prefix_hex``, else the prefix itself.""" + prefix = prefix_hex.lower() + if prefix: + for name, peer in list(self._peers.items()): + if peer.public_key and peer.public_key.lower().startswith(prefix): + return name + return prefix or "unknown author" + def _peer_name_for_key(self, public_key: str) -> str | None: key = public_key.lower() for name, peer in self._peers.items(): @@ -1089,6 +1102,24 @@ def _process_message_event(self, data: MeshEventDict, event_type: str = "") -> N # Preserve the original-case sender name so we can resolve contacts later. peer_display_name = sender_name if is_direct else None + txt_type = data.get("txt_type") if is_direct else None + if txt_type == TXT_TYPE_CLI_DATA: + # A repeater's CLI reply that arrived after its command timed out + # (in-time replies are caught by the command waiter). It is not a + # chat message: hand it to the Admin console instead of a DM thread. + self._append_event( + { + "type": EventType.REPEATER_COMMAND_RESPONSE, + "data": {"peer_name": sender_name, "late": True, "response_text": message_text}, + } + ) + return + author = sender_name + if txt_type == TXT_TYPE_SIGNED_PLAIN: + # A room server relays posts signed with the author's 4-byte key + # prefix: show who wrote it, not the room, in the room's thread. + author = self._name_for_key_prefix(str(data.get("sender_prefix") or "")) + # Deduplicate by message_id — handles radio retransmissions of the # same packet. Group messages carry the packet hash. For DMs, # openhop_core's id hashes the sender timestamp rounded down to 1000 s @@ -1108,7 +1139,7 @@ def _process_message_event(self, data: MeshEventDict, event_type: str = "") -> N path_hops = data.get("path_hops", []) message = Message( message_id=msg_id, - sender_id=sender_name, + sender_id=author, body=message_text, channel_id=channel_name, created_at=datetime.now(UTC), diff --git a/src/meshcore_console/ui_gtk/views/admin.py b/src/meshcore_console/ui_gtk/views/admin.py index abc2e26..bd24d97 100644 --- a/src/meshcore_console/ui_gtk/views/admin.py +++ b/src/meshcore_console/ui_gtk/views/admin.py @@ -11,6 +11,7 @@ from gi.repository import Adw, Gdk, GLib, Gtk, Pango +from meshcore_console.core.enums import EventType from meshcore_console.core.models import Peer from meshcore_console.core.radio import format_rssi, format_snr from meshcore_console.core.services import MeshcoreService @@ -738,7 +739,16 @@ def _do_scroll() -> bool: # -- Events ---------------------------------------------------------------- def _on_events(self, _store: object) -> None: - self._event_cursor, _events = self._event_store.since(self._event_cursor, limit=200) + self._event_cursor, events = self._event_store.since(self._event_cursor, limit=200) + for event in events: + data = event.get("data") or {} + if ( + event.get("type") == EventType.REPEATER_COMMAND_RESPONSE + and data.get("late") + and self._selected_repeater is not None + and data.get("peer_name") == self._selected_repeater.display_name + ): + self._append_console_response(f"(late reply) {data.get('response_text', '')}") # Repeaters arrive through adverts (plain "packet" events), discovery, # contact events and logins; compare what the list shows instead of # enumerating event types (the old list matched types nothing emits). diff --git a/tests/unit/test_dm_namespace.py b/tests/unit/test_dm_namespace.py index 2a2fcdd..69a12a3 100644 --- a/tests/unit/test_dm_namespace.py +++ b/tests/unit/test_dm_namespace.py @@ -22,6 +22,9 @@ def _client(tmp_path, monkeypatch): session=MockOpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())), require_openhop=False, ) + from meshcore_console.meshcore.contact_book import ContactBook + + monkeypatch.setattr(client._session, "contact_book", ContactBook(), raising=False) client._connected = True sent: list[tuple[str, str]] = [] @@ -170,3 +173,38 @@ def packet(hash16: str, snr: float) -> dict: assert decoded["data"]["snr"] == 7.0 # not the echo's -12 assert real["data"]["payload_text"] == "hi" and "payload_text" not in echo["data"] assert list(client._unenriched_grp) == [echo["data"]] + + +def test_late_cli_reply_goes_to_admin_not_a_dm_thread(tmp_path, monkeypatch) -> None: + client, _sent = _client(tmp_path, monkeypatch) + reply = {"sender_name": "Bergblick", "message_text": "v1.18.0", "txt_type": 1} + client._process_message_event(reply, EventType.MESH_MESSAGE_NEW) + + assert client.list_messages_for_channel("dm:bergblick") == [] + late = [e for e in client._event_buffer if e["type"] == EventType.REPEATER_COMMAND_RESPONSE] + assert late[0]["data"] == {"peer_name": "Bergblick", "late": True, "response_text": "v1.18.0"} + + +def test_room_post_shows_its_author(tmp_path, monkeypatch) -> None: + client, _sent = _client(tmp_path, monkeypatch) + client._process_event_for_peers( + { + "type": EventType.MESH_NODE_DISCOVERED, + "data": {"name": "Alice", "public_key": "a1b2c3d4" + "00" * 28, "advert_timestamp": 1}, + } + ) + post = { + "sender_name": "Leipzig Room", + "message_text": "hello room", + "txt_type": 2, + "sender_prefix": "a1b2c3d4", + } + client._process_message_event(post, EventType.MESH_MESSAGE_NEW) + unknown = dict(post, message_text="who?", sender_prefix="99887766") + client._process_message_event(unknown, EventType.MESH_MESSAGE_NEW) + + thread = client.list_messages_for_channel("dm:leipzig room") + assert [(m.sender_id, m.body) for m in thread] == [ + ("Alice", "hello room"), + ("99887766", "who?"), + ] From 57c8512f0c39804244e8d7a829d93a68f1e3a533 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:31:17 +0100 Subject: [PATCH 44/72] fix(messages): keep message history per channel 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/meshcore/client.py | 23 +++++++++++++++----- src/meshcore_console/meshcore/state_store.py | 15 ++++++++----- tests/unit/test_dm_namespace.py | 19 ++++++++++++++++ 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index dc63100..6ea95e8 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -43,7 +43,12 @@ from meshcore_console.meshcore.session import OpenHopCoreSession from meshcore_console.meshcore.settings import MeshcoreSettings from meshcore_console.meshcore.settings_store import SettingsStore -from meshcore_console.meshcore.state_store import MessageStore, PeerStore, UIChannelStore +from meshcore_console.meshcore.state_store import ( + MAX_MESSAGES, + MessageStore, + PeerStore, + UIChannelStore, +) from meshcore_console.platform.gps import GpsProvider, create_gps_provider logger = logging.getLogger(__name__) @@ -549,8 +554,7 @@ def send_message(self, peer_id: str, body: str) -> Message: is_outgoing=True, delivered=delivered, ) - self._messages.append(message) - self._message_store.append(message) + self._remember_message(message) self._append_event( { "type": EventType.MESSAGE_SENT, @@ -624,6 +628,16 @@ def discover_nodes(self) -> list[dict]: ) return results + def _remember_message(self, message: Message) -> None: + """Keep a message in memory and the database, at most MAX_MESSAGES per + channel in both (the in-memory list used to grow without bound).""" + self._messages.append(message) + self._message_store.append(message) + same = [m for m in self._messages if m.channel_id == message.channel_id] + if len(same) > MAX_MESSAGES: + drop = {id(m) for m in same[: len(same) - MAX_MESSAGES]} + self._messages = [m for m in self._messages if id(m) not in drop] + def _name_for_key_prefix(self, prefix_hex: str) -> str: """The peer whose key starts with ``prefix_hex``, else the prefix itself.""" prefix = prefix_hex.lower() @@ -1149,8 +1163,7 @@ def _process_message_event(self, data: MeshEventDict, event_type: str = "") -> N snr=float(snr) if snr is not None else None, rssi=int(rssi) if rssi is not None else None, ) - self._messages.append(message) - self._message_store.append(message) + self._remember_message(message) # Ensure channel exists if channel_name not in self._channels: diff --git a/src/meshcore_console/meshcore/state_store.py b/src/meshcore_console/meshcore/state_store.py index 75bf0d5..b8a2978 100644 --- a/src/meshcore_console/meshcore/state_store.py +++ b/src/meshcore_console/meshcore/state_store.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) -MAX_MESSAGES = 500 +MAX_MESSAGES = 500 # per channel class MessageStore: @@ -38,13 +38,18 @@ def append(self, message: Message) -> None: json.dumps(message.path_hops) if message.path_hops else None, ), ) - # Prune oldest messages beyond limit - count = self._conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0] + # Prune the oldest messages of this channel beyond the limit. The limit + # used to be global, so a busy public channel (or anyone posting 500 + # times to it) deleted the stored DMs and private-channel history. + count = self._conn.execute( + "SELECT COUNT(*) FROM messages WHERE channel_id = ?", (message.channel_id,) + ).fetchone()[0] if count > MAX_MESSAGES: self._conn.execute( "DELETE FROM messages WHERE message_id IN " - "(SELECT message_id FROM messages ORDER BY created_at ASC LIMIT ?)", - (count - MAX_MESSAGES,), + "(SELECT message_id FROM messages WHERE channel_id = ? " + "ORDER BY created_at ASC LIMIT ?)", + (message.channel_id, count - MAX_MESSAGES), ) self._conn.commit() diff --git a/tests/unit/test_dm_namespace.py b/tests/unit/test_dm_namespace.py index 69a12a3..fc3ad43 100644 --- a/tests/unit/test_dm_namespace.py +++ b/tests/unit/test_dm_namespace.py @@ -208,3 +208,22 @@ def test_room_post_shows_its_author(tmp_path, monkeypatch) -> None: ("Alice", "hello room"), ("99887766", "who?"), ] + + +def test_a_busy_channel_does_not_push_out_dms(tmp_path, monkeypatch) -> None: + from meshcore_console.meshcore import client as client_mod + from meshcore_console.meshcore import state_store + + monkeypatch.setattr(state_store, "MAX_MESSAGES", 5) + monkeypatch.setattr(client_mod, "MAX_MESSAGES", 5) + client, _sent = _client(tmp_path, monkeypatch) + dm = {"sender_name": "Bob", "message_text": "private", "message_id": "dm1"} + client._process_message_event(dm, EventType.MESH_MESSAGE_NEW) + for i in range(20): + post = {"sender_name": "X", "channel_name": "public", "message_text": f"p{i}"} + client._process_message_event(dict(post, message_id=f"p{i}"), "") + + assert [m.body for m in client.list_messages_for_channel("dm:bob")] == ["private"] + assert len(client.list_messages_for_channel("public", limit=100)) == 5 + rows = dict(client._db.execute("SELECT channel_id, COUNT(*) FROM messages GROUP BY channel_id")) + assert rows == {"dm:bob": 1, "public": 5} From aafcb9eb4ffad119ae0f2773e0ea2c308037371c Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:32:55 +0100 Subject: [PATCH 45/72] fix(channels): remove a private channel's key with the channel, after warning Removing a channel kept an imported (private) secret, by design since #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 #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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/core/services.py | 4 ++++ src/meshcore_console/meshcore/client.py | 18 +++++++++++++++--- src/meshcore_console/mock/client.py | 3 +++ src/meshcore_console/ui_gtk/views/messages.py | 12 +++++++----- tests/unit/test_channel_secrets.py | 11 ++++++----- tests/unit/test_private_channels.py | 17 +++++++++++------ 6 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/meshcore_console/core/services.py b/src/meshcore_console/core/services.py index 2d15de5..f3f009b 100644 --- a/src/meshcore_console/core/services.py +++ b/src/meshcore_console/core/services.py @@ -55,6 +55,10 @@ def list_messages_for_channel(self, channel_id: str, limit: int = 50) -> list[Me def send_message(self, peer_id: str, body: str) -> Message: ... + def is_private_channel(self, channel_id: str) -> bool: + """True for a group channel joined with its own (non-derivable) key.""" + ... + def ensure_dm_channel(self, peer_name: str) -> Channel: """The DM thread with ``peer_name`` (channel id ``dm:``), created if needed.""" ... diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 6ea95e8..58d1cd9 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -25,6 +25,7 @@ from meshcore_console.core.types import MeshEventDict, SendResultDict from meshcore_console.meshcore.channel_db import ( ChannelDatabase, + derive_channel_secret, normalize_channel_name, normalize_channel_secret, ) @@ -481,15 +482,26 @@ def remove_channel(self, channel_id: str) -> bool: return False removed = self._channels.pop(channel_id, None) if removed is not None and removed.kind == "group": - # Only derived hashtag secrets are dropped; an imported secret is - # not recoverable, so it outlives the channel entry (issue #81). + # The secret goes too, imported (private) ones included: a kept + # secret kept the channel decrypting, and it came back at the next + # start and with the next message. The confirmation dialog says the + # key is deleted (see is_private_channel). name = removed.display_name.lstrip("#") or removed.channel_id - self._channel_secrets.remove_derived_secret(name) + self._channel_secrets.remove_channel(name) self._messages = [m for m in self._messages if m.channel_id != channel_id] self._channel_store.remove(channel_id) self._message_store.remove_for_channel(channel_id) return True + def is_private_channel(self, channel_id: str) -> bool: + """True for a group channel joined with its own key (not derived from + its name), which cannot be re-derived once removed.""" + channel = self._channels.get(channel_id) + if channel is None or channel.kind != "group" or channel_id == "public": + return False + row = self._channel_secrets.get_channel(channel.display_name.lstrip("#") or channel_id) + return row is not None and row["secret"] != derive_channel_secret(row["name"]) + def mark_channel_read(self, channel_id: str) -> None: if channel_id in self._channels: self._channels[channel_id].unread_count = 0 diff --git a/src/meshcore_console/mock/client.py b/src/meshcore_console/mock/client.py index ec95a01..866e654 100644 --- a/src/meshcore_console/mock/client.py +++ b/src/meshcore_console/mock/client.py @@ -90,6 +90,9 @@ def list_channels(self) -> list[Channel]: self._channels["public"] = channel return sorted(self._channels.values(), key=lambda c: c.display_name.lower()) + def is_private_channel(self, channel_id: str) -> bool: + return False + def ensure_dm_channel(self, peer_name: str) -> Channel: channel_id = dm_channel_id(peer_name) if channel_id not in self._channels: diff --git a/src/meshcore_console/ui_gtk/views/messages.py b/src/meshcore_console/ui_gtk/views/messages.py index f60abba..f940a4e 100644 --- a/src/meshcore_console/ui_gtk/views/messages.py +++ b/src/meshcore_console/ui_gtk/views/messages.py @@ -678,11 +678,13 @@ def _on_remove_channel_clicked( popover.popdown() display_name = self._get_channel_display_name(channel_id) - dialog = build_alert_dialog( - self.get_root(), - f"Remove {display_name}?", - "This will remove the channel and all its messages. This cannot be undone.", - ) + body = "This will remove the channel and all its messages. This cannot be undone." + if self._service.is_private_channel(channel_id): + body = ( + "This will remove the channel, all its messages and its key. To rejoin " + "you will need the key again. This cannot be undone." + ) + dialog = build_alert_dialog(self.get_root(), f"Remove {display_name}?", body) dialog.add_response("cancel", "Cancel") dialog.add_response("remove", "Remove") dialog.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE) diff --git a/tests/unit/test_channel_secrets.py b/tests/unit/test_channel_secrets.py index 2173962..ce7b79b 100644 --- a/tests/unit/test_channel_secrets.py +++ b/tests/unit/test_channel_secrets.py @@ -113,17 +113,18 @@ def _client(tmp_path, monkeypatch, name): return client, db_conn -def test_remove_channel_keeps_imported_secret(tmp_path, monkeypatch) -> None: - """An imported secret cannot be re-derived, so it must survive removal.""" +def test_remove_channel_removes_an_imported_secret_after_warning(tmp_path, monkeypatch) -> None: + """An imported secret cannot be re-derived: the UI warns (is_private_channel) + and then removes it with the channel, which otherwise kept decrypting and + reappeared at the next start.""" client, db_conn = _client(tmp_path, monkeypatch, "imported.db") channel_db = ChannelDatabase(db_conn) channel_db.add_channel("MyPrivate", "00112233445566778899aabbccddeeff") client.ensure_channel("MyPrivate", "#MyPrivate") + assert client.is_private_channel("MyPrivate") is True assert client.remove_channel("MyPrivate") is True - row = channel_db.get_channel("MyPrivate") - assert row is not None, "removing a channel must not destroy an imported PSK" - assert row["secret"] == "00112233445566778899aabbccddeeff" + assert channel_db.get_channel("MyPrivate") is None @pytest.mark.parametrize( diff --git a/tests/unit/test_private_channels.py b/tests/unit/test_private_channels.py index 815c76c..fa1b612 100644 --- a/tests/unit/test_private_channels.py +++ b/tests/unit/test_private_channels.py @@ -139,17 +139,22 @@ async def capture(channel_name: str, message: str, region_scope: str | None = No assert sent == [("ops", "hello")] -def test_imported_secret_survives_channel_removal(tmp_path, monkeypatch) -> None: - """Removing the UI channel must not destroy an unrecoverable PSK (#81 rule - still applies to imported keys).""" +def test_removing_a_private_channel_removes_it_for_good(tmp_path, monkeypatch) -> None: + """A removed private channel used to keep its key, so it kept decrypting and + came back at the next start; its key now goes with it (the confirmation + dialog says so, via is_private_channel).""" from meshcore_console.meshcore.channel_db import ChannelDatabase client, db_conn = _client(tmp_path, monkeypatch, "survive.db") client.import_channel("vault", "#vault", "feedface" + "0123456789abcdef" + "55667788") + client.ensure_channel("ops", "#ops") + assert client.is_private_channel("vault") is True + assert client.is_private_channel("ops") is False + assert client.remove_channel("vault") is True - row = ChannelDatabase(db_conn).get_channel("vault") - assert row is not None - assert row["secret"] == "feedface0123456789abcdef55667788" + assert ChannelDatabase(db_conn).get_channel("vault") is None + client._sync_channel_secrets_to_ui() # what the next start does + assert "vault" not in client._channels # --------------------------------------------------------------------------- From c312982b3fa637ee680bcc7a7b67900410a652d8 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:35:25 +0100 Subject: [PATCH 46/72] fix(peers): keep details, focus and selection when the peer list refreshes 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/ui_gtk/views/peers.py | 59 ++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/src/meshcore_console/ui_gtk/views/peers.py b/src/meshcore_console/ui_gtk/views/peers.py index bb4bbb3..263e071 100644 --- a/src/meshcore_console/ui_gtk/views/peers.py +++ b/src/meshcore_console/ui_gtk/views/peers.py @@ -55,6 +55,7 @@ def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: self._event_store = event_store self._last_peer_snapshot: str = "" self._selected_peer: Peer | None = None + self._refreshing = False self._peer_telemetry: dict[str, dict] = {} # Column 1: Contacts @@ -159,10 +160,52 @@ def _poll_peers(self) -> bool: return True def _refresh_peers(self) -> None: - """Refresh the peers list display, preserving selection.""" - # Remember selected peer + """Refresh the peers list display, preserving selection, focus and the + details panel. + + This runs on every advert anywhere in the mesh. The rebuild used to + re-render the details panel (scroll jumped to the top, an in-flight + telemetry request lost its spinner and the button re-armed, inviting + duplicate requests) and move keyboard focus to another row. + """ selected_name = self._selected_peer.display_name if self._selected_peer else None - + focus_list, focus_index = self._focused_row() + self._refreshing = True + try: + self._rebuild_lists(selected_name) + finally: + self._refreshing = False + # The new rows aren't mapped until the next frame, so restore focus + # (and undo the selection GTK makes when focus falls back to the list) + # once they are. + GLib.idle_add(self._restore_focus, selected_name is None, focus_list, focus_index) + + def _restore_focus( + self, had_no_selection: bool, focus_list: Gtk.ListBox | None, focus_index: int + ) -> bool: + if focus_list is not None: + row = focus_list.get_row_at_index(focus_index) + if row is not None: + row.grab_focus() + if had_no_selection: + self._refreshing = True # a rebuild must not select anything by itself + try: + for listbox in (self._contacts_list, self._network_list): + listbox.unselect_all() + finally: + self._refreshing = False + return False + + def _focused_row(self) -> tuple[Gtk.ListBox | None, int]: + root = self.get_root() + focus = root.get_focus() if root is not None else None + if isinstance(focus, Gtk.ListBoxRow): + for listbox in (self._contacts_list, self._network_list): + if focus.get_parent() is listbox: + return listbox, focus.get_index() + return None, -1 + + def _rebuild_lists(self, selected_name: str | None) -> None: # Clear existing rows for listbox in (self._contacts_list, self._network_list): clear_listbox(listbox) @@ -202,11 +245,21 @@ def _populate_list(self, listbox: Gtk.ListBox, peers: list[Peer], empty_msg: str def _on_peer_selected(self, listbox: Gtk.ListBox, row: Gtk.ListBoxRow | None) -> None: """Handle peer selection to show details.""" + if self._refreshing: + # Rows are being rebuilt: keep the details panel as it is, only + # pick up the fresh Peer object for the same selection. + peer = getattr(row, "peer", None) if row is not None else None + if peer is not None and self._selected_peer is not None: + if peer.display_name == self._selected_peer.display_name: + self._selected_peer = peer + return # Deselect the other list other = self._network_list if listbox == self._contacts_list else self._contacts_list other.unselect_all() if row is None: + if listbox.get_selected_row() is None and other.get_selected_row() is None: + self._selected_peer = None # or the next refresh re-selects it self._show_empty_details() return From fd8e56176cff27c21292e1c786f980fffc8943f2 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:37:08 +0100 Subject: [PATCH 47/72] fix(ui): reach channel actions from the keyboard 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/ui_gtk/views/messages.py | 60 +++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/src/meshcore_console/ui_gtk/views/messages.py b/src/meshcore_console/ui_gtk/views/messages.py index f940a4e..ef2f64f 100644 --- a/src/meshcore_console/ui_gtk/views/messages.py +++ b/src/meshcore_console/ui_gtk/views/messages.py @@ -118,10 +118,25 @@ def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: self._thread_title = Gtk.Label(label="#public") self._thread_title.add_css_class("panel-title") self._thread_title.set_halign(Gtk.Align.START) + self._thread_title.set_hexpand(True) self._thread_title.set_margin_start(12) self._thread_title.set_margin_top(8) self._thread_title.set_margin_bottom(8) - self._chat_panel.append(self._thread_title) + # Channel actions (region scope, remove) were only on a right-click of + # the channel row: unreachable from the keyboard. The header button + # opens the same actions for the open channel. + self._channel_menu = Gtk.MenuButton.new() + self._channel_menu.set_icon_name("view-more-symbolic") + self._channel_menu.set_tooltip_text("Channel actions") + self._channel_menu.update_property([Gtk.AccessibleProperty.LABEL], ["Channel actions"]) + self._channel_menu.set_margin_end(8) + menu_popover = Gtk.Popover.new() + menu_popover.connect("show", self._fill_channel_menu) + self._channel_menu.set_popover(menu_popover) + thread_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + thread_header.append(self._thread_title) + thread_header.append(self._channel_menu) + self._chat_panel.append(thread_header) # Scrolled window for messages self._scroll = Gtk.ScrolledWindow.new() @@ -627,30 +642,17 @@ def _update_channel_unread_display(self, channel_id: str, count: int) -> None: idx += 1 row = self._channel_list.get_row_at_index(idx) - def _on_channel_right_click( - self, - gesture: Gtk.GestureClick, - _n_press: int, - x: float, - y: float, - row: Gtk.ListBoxRow, - ) -> None: - """Show context menu on right-click for channel actions.""" - channel_id = getattr(row, "channel_id", None) - logger.debug("UI: channel right-click channel_id=%s", channel_id) - if not channel_id: - return - + def _build_channel_actions(self, popover: Gtk.Popover, channel_id: str) -> bool: + """Put the actions for ``channel_id`` into ``popover``; False if none apply.""" channel = next( (c for c in self._service.list_channels() if c.channel_id == channel_id), None ) is_group = channel is not None and channel.kind == "group" can_remove = channel_id != "public" if not is_group and not can_remove: - return + return False action_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - popover = Gtk.Popover.new() popover.set_child(action_box) if is_group: @@ -664,6 +666,30 @@ def _on_channel_right_click( remove_btn.add_css_class("flat") remove_btn.connect("clicked", self._on_remove_channel_clicked, channel_id, popover) action_box.append(remove_btn) + return True + + def _fill_channel_menu(self, popover: Gtk.Popover) -> None: + """Header menu: the actions for the channel that is open right now.""" + if not self._build_channel_actions(popover, self._selected_channel_id): + popover.set_child(Gtk.Label(label="No actions for this channel", margin_top=8)) + + def _on_channel_right_click( + self, + gesture: Gtk.GestureClick, + _n_press: int, + x: float, + y: float, + row: Gtk.ListBoxRow, + ) -> None: + """Show context menu on right-click for channel actions.""" + channel_id = getattr(row, "channel_id", None) + logger.debug("UI: channel right-click channel_id=%s", channel_id) + if not channel_id: + return + + popover = Gtk.Popover.new() + if not self._build_channel_actions(popover, channel_id): + return popover.set_parent(row) popover.set_position(Gtk.PositionType.RIGHT) From aac8cd5b008de2f0f56bd70a189be8a8d4cfac6a Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:37:41 +0100 Subject: [PATCH 48/72] fix(ui): stop leaking a popover per channel right-click 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/ui_gtk/views/messages.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/meshcore_console/ui_gtk/views/messages.py b/src/meshcore_console/ui_gtk/views/messages.py index ef2f64f..89ae948 100644 --- a/src/meshcore_console/ui_gtk/views/messages.py +++ b/src/meshcore_console/ui_gtk/views/messages.py @@ -694,6 +694,9 @@ def _on_channel_right_click( popover.set_parent(row) popover.set_position(Gtk.PositionType.RIGHT) popover.set_pointing_to(Gdk.Rectangle()) + # A fresh popover per right-click: detach it once closed, or every + # right-click left one more popover parented to the row. + popover.connect("closed", lambda p: GLib.idle_add(p.unparent)) popover.popup() def _on_remove_channel_clicked( From a7c85beb8aff0c15fd4e466c54db18e5aa6c76a7 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:38:37 +0100 Subject: [PATCH 49/72] fix(ui): make node badges usable from the keyboard 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/ui_gtk/resources/app.css | 1 + .../ui_gtk/widgets/node_badge.py | 24 ++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/meshcore_console/ui_gtk/resources/app.css b/src/meshcore_console/ui_gtk/resources/app.css index f5f6a42..4894f09 100644 --- a/src/meshcore_console/ui_gtk/resources/app.css +++ b/src/meshcore_console/ui_gtk/resources/app.css @@ -95,6 +95,7 @@ combobox:focus-visible, togglebutton:focus-visible, switch:focus-visible, menubutton:focus-visible, +.node-badge:focus-visible, row:focus-visible { outline: 2px solid alpha(@mc_accent, 0.8); outline-offset: 2px; diff --git a/src/meshcore_console/ui_gtk/widgets/node_badge.py b/src/meshcore_console/ui_gtk/widgets/node_badge.py index b4777f1..cbd144d 100644 --- a/src/meshcore_console/ui_gtk/widgets/node_badge.py +++ b/src/meshcore_console/ui_gtk/widgets/node_badge.py @@ -11,7 +11,7 @@ gi.require_version("Gtk", "4.0") -from gi.repository import Gtk +from gi.repository import Gdk, Gtk from meshcore_console.core.models import Peer from meshcore_console.core.time import to_local @@ -89,6 +89,14 @@ def __init__( self.connect("destroy", self._on_destroy) + # Keyboard: Tab reaches the badge, Enter/Space opens its details and + # Shift+Enter goes to the peer (the right-click action). The name was + # otherwise only in a hover tooltip or a click popover. + self.set_focusable(True) + keys = Gtk.EventControllerKey.new() + keys.connect("key-pressed", self._on_key_pressed) + self.add_controller(keys) + # Right click → navigate to Peers tab if peer is not None: right_click = Gtk.GestureClick.new() @@ -105,6 +113,20 @@ def _on_left_click( self, gesture: Gtk.GestureClick, _n_press: int, _x: float, _y: float ) -> None: gesture.set_state(Gtk.EventSequenceState.CLAIMED) + self._toggle_popover() + + def _on_key_pressed( + self, _ctrl: Gtk.EventControllerKey, keyval: int, _code: int, state: Gdk.ModifierType + ) -> bool: + if keyval not in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_space): + return False + if state & Gdk.ModifierType.SHIFT_MASK and self._peer is not None: + self._navigate_to_peer() + else: + self._toggle_popover() + return True + + def _toggle_popover(self) -> None: if self._popover is not None and self._popover.get_visible(): self._popover.popdown() return From b63190a1b99ba92150f968d45a174367047f3a91 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:39:14 +0100 Subject: [PATCH 50/72] fix(admin): say "still waiting" instead of ignoring requests while busy 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/ui_gtk/views/admin.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/meshcore_console/ui_gtk/views/admin.py b/src/meshcore_console/ui_gtk/views/admin.py index bd24d97..6b8337d 100644 --- a/src/meshcore_console/ui_gtk/views/admin.py +++ b/src/meshcore_console/ui_gtk/views/admin.py @@ -501,8 +501,15 @@ def _on_login_clicked(self, _btn: Gtk.Button) -> None: def _on_guest_clicked(self, _btn: Gtk.Button) -> None: self._do_login("") + def _refused_while_busy(self) -> bool: + """One request at a time (a command can take 20 s); say so instead of + silently ignoring the click or Enter.""" + if self._busy: + self._append_console_error("Still waiting for the previous request; try again shortly.") + return self._busy + def _do_login(self, password: str) -> None: - if self._selected_repeater is None or self._busy: + if self._selected_repeater is None or self._refused_while_busy(): return peer_name = self._selected_repeater.display_name save = self._save_check.get_active() @@ -552,7 +559,7 @@ def done() -> bool: threading.Thread(target=work, daemon=True).start() def _on_logout_clicked(self, _btn: Gtk.Button) -> None: - if self._selected_repeater is None or self._busy: + if self._selected_repeater is None or self._refused_while_busy(): return peer_name = self._selected_repeater.display_name self._busy = True @@ -653,7 +660,7 @@ def _on_cmd_key( return False def _send_command(self, command: str) -> None: - if self._selected_repeater is None or self._busy: + if self._selected_repeater is None or self._refused_while_busy(): return peer_name = self._selected_repeater.display_name From e89a71121aa15af18149a5c259e7c45d62792c66 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:39:38 +0100 Subject: [PATCH 51/72] fix(admin): let Up/Down leave the command line when there is no history 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/ui_gtk/views/admin.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/meshcore_console/ui_gtk/views/admin.py b/src/meshcore_console/ui_gtk/views/admin.py index 6b8337d..2935469 100644 --- a/src/meshcore_console/ui_gtk/views/admin.py +++ b/src/meshcore_console/ui_gtk/views/admin.py @@ -642,21 +642,26 @@ def _on_cmd_key( _keycode: int, _state: Gdk.ModifierType, ) -> bool: + # Only consume Up/Down while there is history to move through, so they + # still move keyboard focus out of the entry otherwise. if keyval == Gdk.KEY_Up: if self._command_history and self._history_index < len(self._command_history) - 1: self._history_index += 1 self._cmd_entry.set_text(self._command_history[-(self._history_index + 1)]) self._cmd_entry.set_position(-1) - return True + return True + return False if keyval == Gdk.KEY_Down: if self._history_index > 0: self._history_index -= 1 self._cmd_entry.set_text(self._command_history[-(self._history_index + 1)]) self._cmd_entry.set_position(-1) - elif self._history_index == 0: + return True + if self._history_index == 0: self._history_index = -1 self._cmd_entry.set_text("") - return True + return True + return False return False def _send_command(self, command: str) -> None: From dfd804dd6323a82d9ea6280e701bcf3caef667ec Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:40:12 +0100 Subject: [PATCH 52/72] fix(admin): open the console after an admin login 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/ui_gtk/views/admin.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/meshcore_console/ui_gtk/views/admin.py b/src/meshcore_console/ui_gtk/views/admin.py index 2935469..e7b8a25 100644 --- a/src/meshcore_console/ui_gtk/views/admin.py +++ b/src/meshcore_console/ui_gtk/views/admin.py @@ -30,6 +30,9 @@ _MAX_HISTORY = 50 +CONSOLE_PAGE = 1 # Admin notebook: 0 Status, 1 Console + + def repeater_list_state(peers: list[Peer], logged_in: list[str]) -> tuple: """What the repeater list shows; the list is rebuilt only when this changes.""" shown = sorted( @@ -135,6 +138,7 @@ def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: # Console tab console_page = self._build_console_tab() self._notebook.append_page(console_page, Gtk.Label(label="Console")) + assert self._notebook.get_n_pages() - 1 == CONSOLE_PAGE admin_outer.append(self._notebook) self._panel_stack.add_named(admin_outer, "admin") @@ -550,6 +554,10 @@ def done() -> bool: state = self._service.get_repeater_login_state(peer_name) self._update_login_ui(state) self._populate_status(self._selected_repeater) + if state is not None and state.is_admin: + # what an admin logs in for: the console, ready to type + self._notebook.set_current_page(CONSOLE_PAGE) + self._cmd_entry.grab_focus() self._append_console_response("Login successful") self._refresh_list() return False From 7ed9dd311f625ed4eb933a466a4c720c279b7a96 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:41:30 +0100 Subject: [PATCH 53/72] fix(map): update markers when peers move or are renamed 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/ui_gtk/views/map.py | 31 ++++++++++++++++++------ tests/unit/test_map_markers.py | 17 +++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 tests/unit/test_map_markers.py diff --git a/src/meshcore_console/ui_gtk/views/map.py b/src/meshcore_console/ui_gtk/views/map.py index 293b4cd..ad97063 100644 --- a/src/meshcore_console/ui_gtk/views/map.py +++ b/src/meshcore_console/ui_gtk/views/map.py @@ -43,6 +43,17 @@ OSM_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" +def marker_state(peers: list[Peer]) -> tuple: + """What the peer markers show: name, position, repeater flag.""" + return tuple( + sorted( + (p.display_name, p.latitude, p.longitude, p.is_repeater) + for p in peers + if p.latitude is not None and p.longitude is not None + ) + ) + + class MapView(Gtk.Box): """Map view with peer markers and details panel.""" @@ -55,7 +66,8 @@ def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: self._peer_markers: dict[str, object] = {} # peer_id -> marker self._device_marker: object | None = None self._mbtiles: MBTilesReader | None = None - self._last_peer_count = 0 + self._marker_state: tuple = () + self._marker_looks: dict[str, tuple] = {} self._shown_gps_errors: set[str] = set() # Track shown errors to avoid repeats self._following = False # Whether map follows device location self._programmatic_move = False # Suppress follow-disable during go_to @@ -320,11 +332,11 @@ def _load_mbtiles(self) -> None: def _on_events_available(self, _store: object) -> None: """Handle events-available signal — refresh peer markers.""" - peers = self._service.list_peers() - peers_with_location = [ - p for p in peers if p.latitude is not None and p.longitude is not None - ] - if len(peers_with_location) != self._last_peer_count: + # Compare what the markers show, not how many there are: a moved or + # renamed peer kept its old marker while the count stayed the same. + state = marker_state(self._service.list_peers()) + if state != self._marker_state: + self._marker_state = state self._refresh_markers() def _poll_gps(self) -> bool: @@ -364,7 +376,7 @@ def _show_toast(self, message: str) -> None: def _refresh_markers(self) -> None: """Refresh all peer markers.""" peers = self._service.list_peers() - self._last_peer_count = sum(1 for p in peers if p.latitude is not None) + self._marker_state = marker_state(peers) # Track which peers we've seen seen_ids: set[str] = set() @@ -375,6 +387,11 @@ def _refresh_markers(self) -> None: seen_ids.add(peer.peer_id) + look = (peer.display_name, peer.is_repeater, peer.is_favorite) + if peer.peer_id in self._peer_markers and self._marker_looks.get(peer.peer_id) != look: + # renamed or restyled: the marker's label is built once, rebuild it + self._marker_layer.remove_marker(self._peer_markers.pop(peer.peer_id)) + self._marker_looks[peer.peer_id] = look if peer.peer_id in self._peer_markers: # Update existing marker position marker = self._peer_markers[peer.peer_id] diff --git a/tests/unit/test_map_markers.py b/tests/unit/test_map_markers.py new file mode 100644 index 0000000..30a86e8 --- /dev/null +++ b/tests/unit/test_map_markers.py @@ -0,0 +1,17 @@ +"""Map markers follow moves and renames, not just the number of peers.""" + +from __future__ import annotations + +from meshcore_console.core.models import Peer +from meshcore_console.ui_gtk.views.map import marker_state + + +def peer(name: str, lat: float | None, lon: float | None) -> Peer: + return Peer(peer_id=name, display_name=name, latitude=lat, longitude=lon) + + +def test_state_changes_on_move_and_rename_with_the_same_count() -> None: + base = marker_state([peer("Bergblick", 51.3, 12.4), peer("Nowhere", None, None)]) + assert marker_state([peer("Bergblick", 51.4, 12.4)]) != base # moved + assert marker_state([peer("Talblick", 51.3, 12.4)]) != base # renamed + assert marker_state([peer("Bergblick", 51.3, 12.4)]) == base # unlocated peers ignored From d6c22204cec912aa212215eab3ae54c1bbdecc69 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:42:12 +0100 Subject: [PATCH 54/72] fix(gps): report a gpsd position only while there is a current fix 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/platform/gps.py | 7 ++++++- tests/unit/test_log_privacy.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/meshcore_console/platform/gps.py b/src/meshcore_console/platform/gps.py index 09cb2be..ec66383 100644 --- a/src/meshcore_console/platform/gps.py +++ b/src/meshcore_console/platform/gps.py @@ -439,10 +439,14 @@ def start(self) -> None: def stop(self) -> None: self._running = False + self._has_fix = False + self._latitude = self._longitude = None logger.debug("GpsdProvider: stopped") def get_location(self) -> tuple[float, float] | None: - if self._latitude is not None and self._longitude is not None: + # Only a current fix: after the fix (or gpsd) was lost the last + # position went on into adverts, telemetry and the map as if current. + if self._has_fix and self._latitude is not None and self._longitude is not None: return (self._latitude, self._longitude) return None @@ -496,6 +500,7 @@ def _run(self) -> None: except Exception as e: if not self._running: return + self._has_fix = False # no gpsd, no current position self._last_error = f"gpsd connection error: {e}" logger.warning("GpsdProvider: %s (retry in %.0fs)", e, backoff) # Sleep in small increments so we can check _running diff --git a/tests/unit/test_log_privacy.py b/tests/unit/test_log_privacy.py index e0eaf26..a4e685e 100644 --- a/tests/unit/test_log_privacy.py +++ b/tests/unit/test_log_privacy.py @@ -38,3 +38,14 @@ def test_gps_fix_is_logged_without_the_position(caplog) -> None: gps._update_location(52.516389, 13.377778) assert "fix acquired" in caplog.text assert "52.5" not in caplog.text and "13.3" not in caplog.text + + +def test_gpsd_position_is_only_reported_with_a_current_fix() -> None: + gps = GpsdProvider(host="127.0.0.1", port=1) + gps._update_location(52.5, 13.4) + assert gps.get_location() == (52.5, 13.4) + gps._has_fix = False # what a mode < 2 TPV report or a lost gpsd connection does + assert gps.get_location() is None + gps._update_location(52.5, 13.4) + gps.stop() + assert gps.get_location() is None From b1b60cacd7c76471e27b4fa94f1552c95c9ae26f Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:42:49 +0100 Subject: [PATCH 55/72] fix(gps): don't power the GPS rail off when the serial fallback stops 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/platform/gps.py | 16 +++++----------- tests/unit/test_log_privacy.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/meshcore_console/platform/gps.py b/src/meshcore_console/platform/gps.py index ec66383..2c98708 100644 --- a/src/meshcore_console/platform/gps.py +++ b/src/meshcore_console/platform/gps.py @@ -71,7 +71,7 @@ class UConsoleGps: CM4-based units and /dev/ttyAMA0 on CM5-based units. The port can be overridden via the constructor (Settings > GPS Device) or the MESHCORE_GPS_DEVICE environment variable. - GPIO 27 is used to enable/disable the GPS module. + GPIO 27 powers the GPS module; it is switched on at start and left on. """ GPIO_ENABLE_PIN = 27 @@ -144,16 +144,10 @@ def stop(self) -> None: logger.debug("GPS: serial close error: %s", e) self._serial = None - # Disable GPS module and release the pin - if self._gpio_enabled: - try: - import RPi.GPIO as GPIO # type: ignore[import-not-found] - - GPIO.output(self.GPIO_ENABLE_PIN, GPIO.LOW) - GPIO.cleanup(self.GPIO_ENABLE_PIN) - self._gpio_enabled = False - except (RuntimeError, OSError) as e: - logger.debug("GPS: GPIO disable error: %s", e) + # Leave the GPS power rail as it is. It is the board's shared rail + # (gpsd, other apps and the board's own tools use it): driving it LOW, + # or GPIO.cleanup() floating the pin, powered the GPS off under them. + self._gpio_enabled = False def get_location(self) -> tuple[float, float] | None: if not self._running: diff --git a/tests/unit/test_log_privacy.py b/tests/unit/test_log_privacy.py index a4e685e..f319ff6 100644 --- a/tests/unit/test_log_privacy.py +++ b/tests/unit/test_log_privacy.py @@ -49,3 +49,26 @@ def test_gpsd_position_is_only_reported_with_a_current_fix() -> None: gps._update_location(52.5, 13.4) gps.stop() assert gps.get_location() is None + + +def test_serial_gps_stop_leaves_the_power_rail_alone(monkeypatch) -> None: + import sys + import types + + from meshcore_console.platform.gps import UConsoleGps + + calls: list[str] = [] + fake = types.SimpleNamespace( + output=lambda *a: calls.append("output"), + cleanup=lambda *a: calls.append("cleanup"), + LOW=0, + ) + rpi = types.ModuleType("RPi") + rpi.GPIO = fake + monkeypatch.setitem(sys.modules, "RPi", rpi) + monkeypatch.setitem(sys.modules, "RPi.GPIO", fake) + + gps = UConsoleGps(serial_port="/dev/null") + gps._gpio_enabled = True + gps.stop() + assert calls == [] From 8d23f2a318b381ca1bd352f714cc6ed79998125e Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:43:33 +0100 Subject: [PATCH 56/72] fix(cli): let import-channel take the key without the command line `meshcore-console import-channel --secret ` required the private channel key as an argument, where it ends up in the shell history and is readable in /proc//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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/radio_cli.py | 17 +++++++++++++++-- tests/unit/test_cli_secret.py | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_cli_secret.py diff --git a/src/meshcore_console/radio_cli.py b/src/meshcore_console/radio_cli.py index 82dea3a..bcd28cc 100644 --- a/src/meshcore_console/radio_cli.py +++ b/src/meshcore_console/radio_cli.py @@ -2,8 +2,10 @@ import argparse import asyncio +import getpass import json import os +import sys import time from typing import Any @@ -59,7 +61,11 @@ def register_subcommands(sub: argparse._SubParsersAction) -> None: # type: igno import_ch = sub.add_parser("import-channel", help="Import a channel by name and secret key") import_ch.add_argument("--name", required=True, help="Channel display name (e.g. 'Emergency')") - import_ch.add_argument("--secret", required=True, help="Channel secret key (hex string)") + import_ch.add_argument( + "--secret", + help="Channel secret key (hex). Leave it out to be asked for it (or pipe it on " + "stdin): a key on the command line lands in shell history and /proc.", + ) export = sub.add_parser("export-logs", help="Export application logs for bug reports") export.add_argument( @@ -255,6 +261,13 @@ async def _run_advert( return 0 +def read_secret() -> str: + """The channel key from a hidden prompt, or from stdin when it is piped.""" + if sys.stdin.isatty(): + return getpass.getpass("Channel secret (hex): ").strip() + return sys.stdin.readline().strip() + + def _import_channel(name: str, secret: str) -> int: from meshcore_console.core.models import Channel from meshcore_console.meshcore.channel_db import ( @@ -303,7 +316,7 @@ def _export_logs(output: str | None) -> int: async def _async_main(args: argparse.Namespace) -> int: if args.command == "import-channel": - return _import_channel(args.name, args.secret) + return _import_channel(args.name, args.secret or read_secret()) if args.command == "export-logs": return _export_logs(args.output) diff --git a/tests/unit/test_cli_secret.py b/tests/unit/test_cli_secret.py new file mode 100644 index 0000000..80abdae --- /dev/null +++ b/tests/unit/test_cli_secret.py @@ -0,0 +1,22 @@ +"""A channel key can be given without putting it on the command line.""" + +from __future__ import annotations + +import io + +from meshcore_console import radio_cli + + +def test_secret_is_read_from_piped_stdin(monkeypatch) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO("00112233445566778899aabbccddeeff\n")) + assert radio_cli.read_secret() == "00112233445566778899aabbccddeeff" + + +def test_secret_is_prompted_without_echo_on_a_terminal(monkeypatch) -> None: + class Tty(io.StringIO): + def isatty(self) -> bool: + return True + + monkeypatch.setattr("sys.stdin", Tty()) + monkeypatch.setattr(radio_cli.getpass, "getpass", lambda prompt: " aabb \n") + assert radio_cli.read_secret() == "aabb" From 80d4be1affcb79648b7a0da1690ea43cfb24ee0e Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:44:21 +0100 Subject: [PATCH 57/72] fix(client): close the database connection of a replaced session 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/meshcore/client.py | 7 +++++-- src/meshcore_console/meshcore/session.py | 6 ++++++ src/meshcore_console/mock/session.py | 3 +++ tests/unit/test_client_async.py | 21 +++++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 58d1cd9..96b4101 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -323,6 +323,7 @@ def _connect_locked(self) -> None: self._run_async(failed.stop(), timeout=10.0) except Exception: # noqa: BLE001 pass + failed.close() raise self._connected = True self._session.set_is_favorite_fn(self._is_peer_favorite) @@ -346,8 +347,9 @@ def _disconnect_locked(self) -> None: pass finally: # Always rotate to a fresh session after disconnect to avoid stale IRQ state. - self._session = self._new_session() + old, self._session = self._session, self._new_session() self._shutdown_loop() + old.close() self._connected = False self._repeater_sessions.clear() self._gps_provider.stop() @@ -1238,7 +1240,8 @@ def update_settings(self, settings: MeshcoreSettings) -> None: # but do NOT restart the radio here — the user must restart the app # for radio-parameter changes to take effect. if not self._connected: - self._session = self._new_session() + old, self._session = self._session, self._new_session() + old.close() self._append_event( {"type": EventType.SETTINGS_UPDATED, "data": {"node_name": updated.node_name}} diff --git a/src/meshcore_console/meshcore/session.py b/src/meshcore_console/meshcore/session.py index c811b34..d3e5a9d 100644 --- a/src/meshcore_console/meshcore/session.py +++ b/src/meshcore_console/meshcore/session.py @@ -551,6 +551,12 @@ async def stop(self) -> None: except queue.Empty: break + def close(self) -> None: + """Release the session's own database connection. The client makes a + new session on every disconnect or failed connect; the old ones kept + their connection open for as long as the app ran.""" + self._db.close() + async def send_text(self, peer_name: str, message: str) -> object: if self._node is None: raise RuntimeError("Session is not started.") diff --git a/src/meshcore_console/mock/session.py b/src/meshcore_console/mock/session.py index cc3acf2..d61b870 100644 --- a/src/meshcore_console/mock/session.py +++ b/src/meshcore_console/mock/session.py @@ -139,6 +139,9 @@ def status(self) -> SessionStatusDict: "openhop_core_version": "mock", } + def close(self) -> None: + pass + def get_public_key(self) -> str | None: """Return a mock public key for testing.""" return "6b547fd13630e0f7a6b167df23b9876543210abcdef0123456789abcdef0a619" diff --git a/tests/unit/test_client_async.py b/tests/unit/test_client_async.py index 23e9cfd..8b9c6c3 100644 --- a/tests/unit/test_client_async.py +++ b/tests/unit/test_client_async.py @@ -115,3 +115,24 @@ async def stop() -> None: client.connect() assert stopped == [1] and client._session is not failed and not client._connected client._shutdown_loop() + + +def test_replaced_sessions_release_their_database(tmp_path, monkeypatch) -> None: + import sqlite3 + + from meshcore_console.meshcore.config import runtime_config_from_settings + from meshcore_console.meshcore.session import OpenHopCoreSession + from meshcore_console.meshcore.settings import MeshcoreSettings + + session = OpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())) + session.close() + with pytest.raises(sqlite3.ProgrammingError): + session._db.execute("SELECT 1") + + client = _client(tmp_path, monkeypatch) + old = client._session + closed: list[int] = [] + monkeypatch.setattr(old, "close", lambda: closed.append(1)) + monkeypatch.setattr(client, "_new_session", lambda: old.__class__(old.config)) + client.update_settings(client._settings) # disconnected: prepares a new session + assert closed == [1] and client._session is not old From bfd50f2ee4ca61ac90a4dc051b230f501a2b1742 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:44:52 +0100 Subject: [PATCH 58/72] fix(analyzer): store each received packet once 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/meshcore/client.py | 6 ++++-- tests/unit/test_event_bursts.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 96b4101..87455af 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -766,8 +766,10 @@ def poll_events(self, limit: int = 50) -> list[MeshEventDict]: # those events were already drained and would be lost, messages too. try: self._process_event_for_peers(event) - # Persist packet events to state storage - if event.get("type") in (EventType.PACKET, EventType.RAW_PACKET): + # Persist packet events to state storage. Only "packet": every + # frame also arrives as "raw_packet", which the analyzer ignores, + # so storing both halved the kept history and doubled the writes. + if event.get("type") == EventType.PACKET: self._packet_store.append(event) except Exception: # noqa: BLE001 logger.exception("could not process %s event", event.get("type")) diff --git a/tests/unit/test_event_bursts.py b/tests/unit/test_event_bursts.py index 750c537..7c42932 100644 --- a/tests/unit/test_event_bursts.py +++ b/tests/unit/test_event_bursts.py @@ -53,3 +53,16 @@ def process(event) -> None: events = client.poll_events(limit=100) assert len(events) == 3 and stored == [1, 2] + + +def test_each_packet_is_stored_once(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch) + frame = {"payload_type_name": "ACK", "packet_hash": "AB" * 8} + batch = [{"type": "packet", "data": dict(frame)}, {"type": "raw_packet", "data": dict(frame)}] + monkeypatch.setattr(client._session, "drain_events", lambda max_items=50: list(batch)) + stored: list[str] = [] + monkeypatch.setattr(client._packet_store, "append", lambda e: stored.append(e["type"])) + + client.poll_events(limit=100) + + assert stored == ["packet"] From d0bae8c4ab8dd5c1c77df46d5327885efd12e26e Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:45:28 +0100 Subject: [PATCH 59/72] fix(routing): don't restore routes from stored advert paths at startup 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/meshcore/client.py | 22 +++++++++------------- tests/unit/test_verified_adverts.py | 13 +++++++++++++ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 87455af..04f1fd2 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -1437,21 +1437,17 @@ def _read_battery_voltage() -> float | None: return None def _seed_contact_book(self) -> None: - """Populate the session's contact book with known peers that have public keys.""" + """Populate the session's contact book with known peers that have public keys. + + Routes start unknown (DMs flood, openhop_core learns the route from the + PATH return). They used to be rebuilt from each peer's stored last + path, which is the inbound path of an advert: the reverse of a usable + route, and possibly stale. + """ book = self._session.contact_book for peer in self._peers.values(): - if not peer.public_key: - continue - book.add_contact({"name": peer.display_name, "public_key": peer.public_key}) - contact = book.get_by_name(peer.display_name) - if contact is not None and peer.last_path is not None: - if not peer.last_path: - contact.out_path = [] - contact.out_path_len = 0 - else: - hash_size = len(peer.last_path[0]) // 2 or 1 - contact.out_path = list(bytes.fromhex("".join(peer.last_path))) - contact.out_path_len = ((hash_size - 1) << 6) | len(peer.last_path) + if peer.public_key: + book.add_contact({"name": peer.display_name, "public_key": peer.public_key}) def _sync_contact_to_book( self, diff --git a/tests/unit/test_verified_adverts.py b/tests/unit/test_verified_adverts.py index 47edd4d..a1de291 100644 --- a/tests/unit/test_verified_adverts.py +++ b/tests/unit/test_verified_adverts.py @@ -130,3 +130,16 @@ def advert(name: str, key: str, ts: int) -> dict: client._process_event_for_peers(advert("Delta", keys["dd"], 4)) assert sorted(p.display_name for p in client.list_peers()) == ["Alpha", "Bravo", "Delta"] assert client._db.execute("SELECT COUNT(*) FROM peers").fetchone()[0] == 3 + + +def test_routes_are_not_restored_from_stored_advert_paths(tmp_path, monkeypatch) -> None: + from meshcore_console.meshcore.contact_book import ContactBook + + client = _client(tmp_path, monkeypatch) + client._process_event_for_peers(verified("Bergblick", 100)) # stored last_path ["13"] + book = ContactBook() + monkeypatch.setattr(client._session, "contact_book", book, raising=False) + + client._seed_contact_book() # what connect() does + + assert book.get_by_name("Bergblick").out_path_len == -1 From c69342b0a3de437c4d7468b67ec4275fe340e4aa Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:46:01 +0100 Subject: [PATCH 60/72] fix(cli): let `send` reach the peers the app knows `meshcore-console send --peer NAME` started a bare session whose contact book was never filled, so every send failed with "Contact '' 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/radio_cli.py | 20 ++++++++++++++++++++ tests/unit/test_cli_config.py | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/meshcore_console/radio_cli.py b/src/meshcore_console/radio_cli.py index bcd28cc..205f4ef 100644 --- a/src/meshcore_console/radio_cli.py +++ b/src/meshcore_console/radio_cli.py @@ -261,6 +261,25 @@ async def _run_advert( return 0 +def seed_contacts(session: OpenHopCoreSession) -> None: + """Load the known peers into the session's contact book, as the GTK client + does on connect; `send` otherwise failed with "Contact not found" for + every peer.""" + from meshcore_console.meshcore.db import open_db + from meshcore_console.meshcore.state_store import PeerStore + + conn = open_db() + try: + peers = PeerStore(conn).get_all() + finally: + conn.close() + for peer in peers.values(): + if peer.public_key: + session.contact_book.add_contact( + {"name": peer.display_name, "public_key": peer.public_key} + ) + + def read_secret() -> str: """The channel key from a hidden prompt, or from stdin when it is piped.""" if sys.stdin.isatty(): @@ -332,6 +351,7 @@ async def _async_main(args: argparse.Namespace) -> int: if args.command == "listen": return await _run_listen(session, args.duration, args.debug, args.start_timeout) if args.command == "send": + seed_contacts(session) return await _run_send(session, args.peer, args.message, args.debug, args.start_timeout) if args.command == "advert": return await _run_advert( diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index a62a138..8d560fd 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -35,3 +35,24 @@ def test_cli_uses_saved_radio_and_name() -> None: def test_cli_name_override() -> None: save(node_name="DF9ES") assert load_saved_runtime_config("bench").node_name == "bench" + + +def test_cli_send_knows_the_stored_peers() -> None: + from meshcore_console.core.models import Peer + from meshcore_console.meshcore.state_store import PeerStore + from meshcore_console.radio_cli import seed_contacts + + conn = open_db() + PeerStore(conn).add_or_update( + Peer(peer_id="aa" * 8, display_name="Bergblick", public_key="aa" * 32) + ) + conn.close() + + class Session: + from meshcore_console.meshcore.contact_book import ContactBook + + contact_book = ContactBook() + + session = Session() + seed_contacts(session) + assert session.contact_book.get_by_name("Bergblick").public_key == "aa" * 32 From c386a77c0db33afd7ebd07fc12b9cbbe627924f1 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:46:26 +0100 Subject: [PATCH 61/72] fix(client): record app-generated events once in the event history 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/meshcore/client.py | 2 +- tests/unit/test_event_bursts.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 04f1fd2..b768f58 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -1472,8 +1472,8 @@ def set_event_notify(self, notify_fn: Callable[[], None]) -> None: self._session.set_event_notify(notify_fn) def _append_event(self, event: MeshEventDict) -> None: + # (history is recorded once, when poll_events() delivers the event) self._event_buffer.append(event) - self._append_history(event) if self._event_notify is not None: try: self._event_notify() diff --git a/tests/unit/test_event_bursts.py b/tests/unit/test_event_bursts.py index 7c42932..9d6b584 100644 --- a/tests/unit/test_event_bursts.py +++ b/tests/unit/test_event_bursts.py @@ -66,3 +66,11 @@ def test_each_packet_is_stored_once(tmp_path, monkeypatch) -> None: client.poll_events(limit=100) assert stored == ["packet"] + + +def test_app_events_are_recorded_once_in_history(tmp_path, monkeypatch) -> None: + client = _client(tmp_path, monkeypatch) + monkeypatch.setattr(client._session, "drain_events", lambda max_items=50: []) + client._append_event({"type": "radio_error", "data": {"message": "x"}}) + client.poll_events() + assert [e["type"] for e in client.list_recent_events()] == ["radio_error"] From dd741c21d40a3e9dd85e7473470d5e78a083346e Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:47:08 +0100 Subject: [PATCH 62/72] fix(cli): end `listen --duration` on time even when nothing is received 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/radio_cli.py | 17 ++++++++++++----- tests/unit/test_cli_config.py | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/meshcore_console/radio_cli.py b/src/meshcore_console/radio_cli.py index 205f4ef..ddd16b8 100644 --- a/src/meshcore_console/radio_cli.py +++ b/src/meshcore_console/radio_cli.py @@ -6,7 +6,6 @@ import json import os import sys -import time from typing import Any from meshcore_console.meshcore.config import RuntimeRadioConfig, runtime_config_from_settings @@ -182,14 +181,22 @@ async def _run_listen( await asyncio.wait_for(session.start(), timeout=start_timeout) _debug(debug, "mesh node started") print(json.dumps({"status": "started", **session.status()})) - started = time.monotonic() _debug(debug, "waiting for events") - try: + + async def print_events() -> None: async for event in session.listen_events(): print(json.dumps(event, default=str)) - if duration > 0 and time.monotonic() - started >= duration: + + try: + if duration > 0: + # A deadline, not a check after each event: on a quiet channel the + # loop never woke up, so --duration never ended the listen. + try: + await asyncio.wait_for(print_events(), timeout=duration) + except asyncio.TimeoutError: _debug(debug, "listen duration reached, stopping") - break + else: + await print_events() finally: _debug(debug, "stopping mesh node") await session.stop() diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 8d560fd..d0e98a6 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -56,3 +56,29 @@ class Session: session = Session() seed_contacts(session) assert session.contact_book.get_by_name("Bergblick").public_key == "aa" * 32 + + +def test_listen_duration_ends_on_a_quiet_channel() -> None: + import asyncio + import time + + from meshcore_console.radio_cli import _run_listen + + class QuietSession: + stopped = False + + async def start(self) -> None: ... + + def status(self) -> dict: + return {"connected": True} + + async def listen_events(self): + await asyncio.sleep(3600) # nothing ever arrives + yield {} + + async def stop(self) -> None: + QuietSession.stopped = True + + started = time.monotonic() + assert asyncio.run(_run_listen(QuietSession(), 1, False, 5.0)) == 0 + assert time.monotonic() - started < 3 and QuietSession.stopped From 88948827b83b88a564f4308df4331605f457467b Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:48:40 +0100 Subject: [PATCH 63/72] fix(requests): skip a contact with an unusable key instead of dropping 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/meshcore/session.py | 10 +++--- tests/unit/test_request_handlers.py | 44 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/meshcore_console/meshcore/session.py b/src/meshcore_console/meshcore/session.py index d3e5a9d..3cbdaaf 100644 --- a/src/meshcore_console/meshcore/session.py +++ b/src/meshcore_console/meshcore/session.py @@ -277,12 +277,14 @@ async def _handle_req(pkt: Any) -> None: return for contact in candidates: - peer_id = Identity(bytes.fromhex(contact.public_key)) - ss = peer_id.calc_shared_secret(our_privkey) - aes_key = ss[:16] + # Key handling inside the try: one contact with an unusable + # stored key used to abort the request for every contact + # sharing its hash byte. try: + peer_id = Identity(bytes.fromhex(contact.public_key)) + ss = peer_id.calc_shared_secret(our_privkey) plaintext = CryptoUtils.mac_then_decrypt( - aes_key, + ss[:16], ss, encrypted_data, ) diff --git a/tests/unit/test_request_handlers.py b/tests/unit/test_request_handlers.py index 8c23085..42f1238 100644 --- a/tests/unit/test_request_handlers.py +++ b/tests/unit/test_request_handlers.py @@ -89,3 +89,47 @@ def test_answer_limiter_window() -> None: assert [limiter.allow() for _ in range(3)] == [True, True, False] now[0] = 10.5 assert limiter.allow() is True + + +def _telemetry_request_from(sender, us_key: bytes): + from openhop_core.protocol.packet_builder import PacketBuilder + + from meshcore_console.meshcore.contact_book import Contact + + us = Contact(name="us", public_key=us_key.hex(), out_path=[], out_path_len=0) + pkt, _ts = PacketBuilder.create_protocol_request(us, sender, protocol_code=0x03, data=b"\x00") + return pkt + + +def _answers(session, node, monkeypatch) -> list: + sent: list = [] + + async def send_packet(packet, wait_for_ack=False, **_kw) -> bool: + sent.append(packet) + return True + + monkeypatch.setattr(node.dispatcher, "send_packet", send_packet) + session.set_telemetry_data_fn( + lambda: {"allow": True, "favorites_only": False, "lat": None, "lon": None, "voltage": 4.1} + ) + session._register_req_handler() + return sent + + +def test_a_broken_contact_key_does_not_block_others_with_the_same_hash(monkeypatch) -> None: + import asyncio + + from meshcore_console.meshcore.contact_book import Contact + + session, node = session_on_real_node() + good = LocalIdentity() + good_key = good.get_public_key() + broken = Contact(name="broken", public_key=(good_key[:1] + b"\xff" * 31).hex()) + session.contact_book.add_contact(broken) + session.contact_book.add_contact(Contact(name="good", public_key=good_key.hex())) + sent = _answers(session, node, monkeypatch) + handler = node.dispatcher._handler_instances[PAYLOAD_TYPE_REQ] + + asyncio.run(handler(_telemetry_request_from(good, session._identity.get_public_key()))) + + assert len(sent) == 1 # answered despite the broken contact tried first From fab3433aa5610b53b92b80426c3e8d629152fe20 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:49:13 +0100 Subject: [PATCH 64/72] fix(requests): don't answer replayed requests or transit copies 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/meshcore/session.py | 13 ++++++++++++- tests/unit/test_request_handlers.py | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/meshcore_console/meshcore/session.py b/src/meshcore_console/meshcore/session.py index 3cbdaaf..0ed7802 100644 --- a/src/meshcore_console/meshcore/session.py +++ b/src/meshcore_console/meshcore/session.py @@ -255,6 +255,8 @@ def _register_req_handler(self) -> None: our_hash = our_pubkey[0] our_privkey = identity.get_private_key() + last_request_ts: dict[str, int] = {} # newest request timestamp per contact key + async def _handle_req(pkt: Any) -> None: # ProtocolRequestHandler._get_client matches on first byte only, # which collides when multiple contacts share a hash prefix. @@ -296,6 +298,13 @@ async def _handle_req(pkt: Any) -> None: timestamp = struct.unpack(" 5 else b"" + # Like firmware's per-client last_timestamp: a captured request + # replayed later would otherwise be answered (and transmitted + # for) again. + if timestamp <= last_request_ts.get(contact.public_key, -1): + self._log(f"REQ from {contact.name}: replayed (ts={timestamp}), ignoring") + return + last_request_ts[contact.public_key] = timestamp self._log( f"REQ from {contact.name} (0x{src_hash:02X}) " f"type=0x{req_type:02X} ts={timestamp}" @@ -329,7 +338,9 @@ async def _handle_req(pkt: Any) -> None: f"REQ from 0x{src_hash:02X}: HMAC failed for all {len(candidates)} candidate(s)" ) - dispatcher.register_handler(PAYLOAD_TYPE_REQ, _handle_req) + # local_delivery: like openhop_core's own payload handlers, so direct + # packets still in transit (hops left on their path) are not consumed. + dispatcher.register_handler(PAYLOAD_TYPE_REQ, _handle_req, local_delivery=True) async def _call_maybe_async(self, target: Any, name: str) -> None: fn = getattr(target, name, None) diff --git a/tests/unit/test_request_handlers.py b/tests/unit/test_request_handlers.py index 42f1238..15f253e 100644 --- a/tests/unit/test_request_handlers.py +++ b/tests/unit/test_request_handlers.py @@ -133,3 +133,23 @@ def test_a_broken_contact_key_does_not_block_others_with_the_same_hash(monkeypat asyncio.run(handler(_telemetry_request_from(good, session._identity.get_public_key()))) assert len(sent) == 1 # answered despite the broken contact tried first + + +def test_a_replayed_request_is_not_answered_again(monkeypatch) -> None: + import asyncio + + from meshcore_console.meshcore.contact_book import Contact + + session, node = session_on_real_node() + sender = LocalIdentity() + session.contact_book.add_contact( + Contact(name="sender", public_key=sender.get_public_key().hex()) + ) + sent = _answers(session, node, monkeypatch) + handler = node.dispatcher._handler_instances[PAYLOAD_TYPE_REQ] + request = _telemetry_request_from(sender, session._identity.get_public_key()) + + asyncio.run(handler(request)) + asyncio.run(handler(request)) # captured and replayed + + assert len(sent) == 1 From c90bb5387b883708339c0e958144ebeec2c4192e Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 08:50:41 +0100 Subject: [PATCH 65/72] feat(map): add a switch to stop downloading map tiles 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/meshcore/settings.py | 3 +++ src/meshcore_console/ui_gtk/views/map.py | 21 +++++++++++++++++-- src/meshcore_console/ui_gtk/views/settings.py | 11 ++++++++++ tests/unit/test_settings_store.py | 11 ++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/meshcore_console/meshcore/settings.py b/src/meshcore_console/meshcore/settings.py index 7eb84bc..fcd2ce5 100644 --- a/src/meshcore_console/meshcore/settings.py +++ b/src/meshcore_console/meshcore/settings.py @@ -13,6 +13,9 @@ class MeshcoreSettings: allow_telemetry: bool = True # Allow telemetry requests from other nodes telemetry_favorites_only: bool = False autoconnect: bool = False # Automatically connect to radio on app startup + # Fetch OpenStreetMap tiles; off keeps the map from telling the tile + # server your IP address and the area you look at. + online_map_tiles: bool = True gps_device: str = "" # GPS serial port path; empty = auto-detect (#82) suppress_service_dialog: bool = False # Don't prompt to stop conflicting services log_level: str = "INFO" # stderr log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) diff --git a/src/meshcore_console/ui_gtk/views/map.py b/src/meshcore_console/ui_gtk/views/map.py index ad97063..4a30329 100644 --- a/src/meshcore_console/ui_gtk/views/map.py +++ b/src/meshcore_console/ui_gtk/views/map.py @@ -129,9 +129,15 @@ def _build_map_ui(self) -> None: map_widget = self._map.get_map() self._viewport = map_widget.get_viewport() - # Set up tile source - CartoDB Dark Matter + # Set up tile source (OpenStreetMap), unless tile downloads are off self._tile_source = self._create_tile_source() - self._map.set_map_source(self._tile_source) + self._tiles_note = Gtk.Label(label="Map tiles are off (Settings > Download Map Tiles)") + self._tiles_note.add_css_class("panel-muted") + self._tiles_note.set_halign(Gtk.Align.CENTER) + self._tiles_note.set_valign(Gtk.Align.START) + self._tiles_note.set_margin_top(12) + self._online_tiles: bool | None = None + self._apply_tile_setting() # Set initial view - center on SF Bay Area if mock mode, otherwise US if self._service.is_mock_mode(): @@ -153,6 +159,7 @@ def _build_map_ui(self) -> None: # Add map as the base child of overlay overlay.set_child(self._map) + overlay.add_overlay(self._tiles_note) # Add control overlays self._add_map_controls(overlay) @@ -330,8 +337,18 @@ def _load_mbtiles(self) -> None: name = metadata.get("name", files[0].name) print(f"[MapView] Loaded offline tiles: {name}", file=sys.stderr) + def _apply_tile_setting(self) -> None: + """Attach the online tile source only while tile downloads are on.""" + online = self._service.get_settings().online_map_tiles + if online == self._online_tiles: + return + self._online_tiles = online + self._map.set_map_source(self._tile_source if online else None) + self._tiles_note.set_visible(not online) + def _on_events_available(self, _store: object) -> None: """Handle events-available signal — refresh peer markers.""" + self._apply_tile_setting() # Compare what the markers show, not how many there are: a moved or # renamed peer kept its old marker while the count stayed the same. state = marker_state(self._service.list_peers()) diff --git a/src/meshcore_console/ui_gtk/views/settings.py b/src/meshcore_console/ui_gtk/views/settings.py index dc76ad4..f9c1124 100644 --- a/src/meshcore_console/ui_gtk/views/settings.py +++ b/src/meshcore_console/ui_gtk/views/settings.py @@ -187,6 +187,15 @@ def _sync_fav_sensitivity(*_args: object) -> None: ) grid.attach(gps_entry, 1, 8, 2, 1) + # Row 9: Online map tiles + grid.attach(self._grid_label("Download Map Tiles"), 0, 9, 1, 1) + tiles_switch = self._grid_switch("online_map_tiles") + tiles_switch.set_tooltip_text( + "Fetch map tiles from OpenStreetMap. The tile server sees your IP address " + "and the area you look at, e.g. around your own position." + ) + grid.attach(tiles_switch, 1, 9, 1, 1) + panel.append(grid) return panel @@ -619,6 +628,7 @@ def _load_from_service(self) -> None: self._set_entry_float("longitude", settings.longitude) self._set_switch("share_position", settings.share_position) self._set_switch("allow_telemetry", settings.allow_telemetry) + self._set_switch("online_map_tiles", settings.online_map_tiles) self._set_switch("telemetry_favorites_only", settings.telemetry_favorites_only) self._set_switch("autoconnect", settings.autoconnect) self._set_entry("gps_device", settings.gps_device) @@ -674,6 +684,7 @@ def _collect_settings(self, allow_partial: bool = False) -> MeshcoreSettings: out.longitude = self._parse_coordinate("longitude", 180.0, allow_partial, current.longitude) out.share_position = self._switches["share_position"].get_active() out.allow_telemetry = self._switches["allow_telemetry"].get_active() + out.online_map_tiles = self._switches["online_map_tiles"].get_active() out.telemetry_favorites_only = self._switches["telemetry_favorites_only"].get_active() out.autoconnect = self._switches["autoconnect"].get_active() out.gps_device = self._entries["gps_device"].get_text().strip() diff --git a/tests/unit/test_settings_store.py b/tests/unit/test_settings_store.py index e6fb9a9..272430e 100644 --- a/tests/unit/test_settings_store.py +++ b/tests/unit/test_settings_store.py @@ -71,3 +71,14 @@ def test_a_bad_row_falls_back_to_its_default(tmp_path) -> None: settings = store.load() assert settings.frequency == MeshcoreSettings().frequency assert settings.node_name == "DF9ES" + + +def test_map_tile_downloads_default_on_and_persist(tmp_path) -> None: + from meshcore_console.meshcore.db import open_db + from meshcore_console.meshcore.settings import MeshcoreSettings + from meshcore_console.meshcore.settings_store import SettingsStore + + assert MeshcoreSettings().online_map_tiles is True # unchanged default behaviour + store = SettingsStore(open_db(str(tmp_path / "t.db"))) + store.save(MeshcoreSettings(online_map_tiles=False)) + assert store.load().online_map_tiles is False From 485fc3c0ae32109b4e5a8a5b02349fa70c166cd1 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 10:17:26 +0100 Subject: [PATCH 66/72] fix(admin): replace the "telemetry" quick command, which repeaters don'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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/ui_gtk/views/admin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/meshcore_console/ui_gtk/views/admin.py b/src/meshcore_console/ui_gtk/views/admin.py index e7b8a25..c0fc32c 100644 --- a/src/meshcore_console/ui_gtk/views/admin.py +++ b/src/meshcore_console/ui_gtk/views/admin.py @@ -212,7 +212,10 @@ def _build_console_tab(self) -> Gtk.Box: quick_row.set_margin_end(8) quick_row.set_margin_top(8) quick_row.set_margin_bottom(4) - for cmd in ("neighbors", "telemetry", "get repeat", "reboot", "ver"): + # Only real repeater CLI commands: there is no "telemetry" command + # (firmware answers "Unknown command"); telemetry is a binary request, + # available from the Peers view. + for cmd in ("neighbors", "clock", "get repeat", "reboot", "ver"): btn = Gtk.Button.new_with_label(cmd) btn.add_css_class("quick-cmd-btn") btn.connect("clicked", self._on_quick_cmd, cmd) From 1ca7fd418dd8a9dbd4f8dd4527827fa9398a847b Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 11:05:14 +0100 Subject: [PATCH 67/72] fix(ui): stop network paths drawing over the public key 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/ui_gtk/widgets/path_visualization.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/meshcore_console/ui_gtk/widgets/path_visualization.py b/src/meshcore_console/ui_gtk/widgets/path_visualization.py index 1d400cb..5ccd768 100644 --- a/src/meshcore_console/ui_gtk/widgets/path_visualization.py +++ b/src/meshcore_console/ui_gtk/widgets/path_visualization.py @@ -46,7 +46,11 @@ def __init__( self.set_homogeneous(False) self.set_row_spacing(4) self.set_column_spacing(2) - self.set_halign(Gtk.Align.START) + # FILL, not START: aligned START, the FlowBox is given the width it + # asks for at its one-row height, which is one badge wide; it then + # stacked the badges one per row inside a one-row allocation, drawing + # them over the widgets below (e.g. the public key in Peers). + self.set_halign(Gtk.Align.FILL) self.set_min_children_per_line(1) self.set_max_children_per_line(6) self.add_css_class("path-visualization") @@ -76,6 +80,7 @@ def _hop_pair( hide_arrow: bool = False, ) -> Gtk.Box: pair = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + pair.set_halign(Gtk.Align.START) # a line's spare width goes between pairs lbl = Gtk.Label(label=arrow) lbl.add_css_class("panel-muted") if hide_arrow: From b9c2bae89092a9ef40879d36b6fc76130a6938b4 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 11:14:55 +0100 Subject: [PATCH 68/72] fix(map): show this device's fixed position on the map even when not 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/meshcore/client.py | 16 ++++++++++------ tests/unit/test_telemetry_privacy.py | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index b768f58..6221de3 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -1261,19 +1261,23 @@ def _valid_coords(lat: float, lon: float) -> bool: and (lat != 0.0 or lon != 0.0) ) - def _resolve_position(self, *, use_settings: bool = True) -> tuple[float, float] | None: + def _resolve_position(self) -> tuple[float, float] | None: """Live GPS first, then settings fixed position as fallback.""" loc = self._gps_provider.get_location() if loc and self._valid_coords(loc[0], loc[1]): return loc - if use_settings: - lat, lon = self._settings.latitude, self._settings.longitude - if self._valid_coords(lat, lon): - return (lat, lon) + lat, lon = self._settings.latitude, self._settings.longitude + if self._valid_coords(lat, lon): + return (lat, lon) return None def get_device_location(self) -> tuple[float, float] | None: - return self._resolve_position(use_settings=self._settings.share_position) + """Where this device is, for this app's own display (the map). + + Not gated by share_position: that setting decides what goes on air, + and send_advert() and the telemetry reply check it themselves. The + map used to lose the fixed position whenever sharing was off.""" + return self._resolve_position() def _on_radio_error(self, message: str) -> None: """Callback from RadioErrorHandler — emit as a UI event.""" diff --git a/tests/unit/test_telemetry_privacy.py b/tests/unit/test_telemetry_privacy.py index 815e42c..fe8cc98 100644 --- a/tests/unit/test_telemetry_privacy.py +++ b/tests/unit/test_telemetry_privacy.py @@ -44,3 +44,28 @@ def test_position_when_shared(tmp_path, monkeypatch) -> None: client = _client(tmp_path, monkeypatch, allow_telemetry=True, share_position=True) data = client._get_local_telemetry() assert (data["lat"], data["lon"]) == (52.516389, 13.377778) + + +class NoGps(FixedGps): + def get_location(self) -> None: + return None + + +def test_own_map_shows_the_fixed_position_even_when_not_shared(tmp_path, monkeypatch) -> None: + client = _client( + tmp_path, monkeypatch, share_position=False, latitude=51.3397, longitude=12.3731 + ) + client._gps_provider = NoGps() + assert client.get_device_location() == (51.3397, 12.3731) + # ...while nothing about it goes on air + assert client._get_local_telemetry()["lat"] is None + sent: dict = {} + + async def send_advert(name=None, lat=0.0, lon=0.0, route_type="flood"): + sent.update(lat=lat, lon=lon) + return {"success": True} + + monkeypatch.setattr(client._session, "send_advert", send_advert) + client._connected = True + client.send_advert() + assert sent == {"lat": 0.0, "lon": 0.0} From 601d6e270cba3e4d2763870d62e8539247e57f38 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 11:17:56 +0100 Subject: [PATCH 69/72] fix(map): open where the map was left, or at this device, not on the USA 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/core/map_view.py | 61 ++++++++++++++++ src/meshcore_console/core/services.py | 6 ++ src/meshcore_console/meshcore/client.py | 8 ++ .../meshcore/settings_store.py | 12 +++ src/meshcore_console/mock/client.py | 7 ++ src/meshcore_console/ui_gtk/views/map.py | 73 ++++++++++++++++--- .../ui_gtk/windows/main_window.py | 6 ++ tests/unit/test_map_view.py | 68 +++++++++++++++++ 8 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 src/meshcore_console/core/map_view.py create mode 100644 tests/unit/test_map_view.py diff --git a/src/meshcore_console/core/map_view.py b/src/meshcore_console/core/map_view.py new file mode 100644 index 0000000..2a74062 --- /dev/null +++ b/src/meshcore_console/core/map_view.py @@ -0,0 +1,61 @@ +"""Where the map opens: the last view, else this device, else the known peers.""" + +from __future__ import annotations + +import math + +View = tuple[float, float, float] # latitude, longitude, zoom level + +DEVICE_ZOOM = 12.0 # streets around this device +WORLD: View = (20.0, 0.0, 2.0) +MAX_ZOOM = 19.0 + + +def encode_view(lat: float, lon: float, zoom: float) -> str: + return f"{lat:.6f},{lon:.6f},{zoom:.2f}" + + +def decode_view(raw: str | None) -> View | None: + """A stored view, or None if missing or not a sane view.""" + try: + lat, lon, zoom = (float(part) for part in (raw or "").split(",")) + except ValueError: + return None + if not (-90 <= lat <= 90 and -180 <= lon <= 180 and 0 <= zoom <= MAX_ZOOM): + return None + return lat, lon, zoom + + +def fit_view(points: list[tuple[float, float]], width_px: int = 600, height_px: int = 400) -> View: + """Centre and zoom showing every point on a map about this size + (256-pixel tiles, Web Mercator; ignores the antimeridian).""" + if len(points) == 1: + return points[0][0], points[0][1], DEVICE_ZOOM + lats = [p[0] for p in points] + lons = [p[1] for p in points] + lat, lon = (min(lats) + max(lats)) / 2, (min(lons) + max(lons)) / 2 + lat_span = max(max(lats) - min(lats), 1e-4) + lon_span = max(max(lons) - min(lons), 1e-4) + zoom_lon = math.log2(width_px * 360 / (256 * lon_span)) + zoom_lat = math.log2(height_px * 360 * math.cos(math.radians(lat)) / (256 * lat_span)) + return lat, lon, float(max(2, min(14, math.floor(min(zoom_lon, zoom_lat))))) + + +def initial_view( + *, + gps_fix: tuple[float, float] | None, + saved: View | None, + device: tuple[float, float] | None, + peers: list[tuple[float, float]], +) -> tuple[View, str]: + """The opening view and where it came from: "gps", "saved", "device" + (the fixed position from Settings), "peers" or "world".""" + if gps_fix is not None: + return (gps_fix[0], gps_fix[1], DEVICE_ZOOM), "gps" + if saved is not None: + return saved, "saved" + if device is not None: + return (device[0], device[1], DEVICE_ZOOM), "device" + if peers: + return fit_view(peers), "peers" + return WORLD, "world" diff --git a/src/meshcore_console/core/services.py b/src/meshcore_console/core/services.py index f3f009b..5b27915 100644 --- a/src/meshcore_console/core/services.py +++ b/src/meshcore_console/core/services.py @@ -85,6 +85,12 @@ def update_settings(self, settings: MeshcoreSettings) -> None: ... def get_device_location(self) -> tuple[float, float] | None: ... + def get_map_view(self) -> tuple[float, float, float] | None: + """The map's last view (latitude, longitude, zoom), if one was saved.""" + ... + + def save_map_view(self, lat: float, lon: float, zoom: float) -> None: ... + def is_mock_mode(self) -> bool: """Return True if running in mock mode.""" ... diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 6221de3..b842c6b 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -10,6 +10,7 @@ from typing import Callable from uuid import uuid4 +from meshcore_console.core.map_view import decode_view, encode_view from meshcore_console.core.enums import EventType, PayloadType from meshcore_console.core.models import ( DM_PREFIX, @@ -95,6 +96,7 @@ def _propagate_routing_fields(packet_data: dict, handler_data: dict) -> None: # Three DM attempts, each: listen-before-talk (up to 4 s), airtime, 5 s ACK wait. DM_SEND_TIMEOUT_S = 45.0 MAX_PEERS = 500 +MAP_VIEW_KEY = "ui.map_view" # settings-table row: the map's last view def dm_message_id(data: dict) -> str | None: @@ -1271,6 +1273,12 @@ def _resolve_position(self) -> tuple[float, float] | None: return (lat, lon) return None + def get_map_view(self) -> tuple[float, float, float] | None: + return decode_view(self._settings_store.load_value(MAP_VIEW_KEY)) + + def save_map_view(self, lat: float, lon: float, zoom: float) -> None: + self._settings_store.save_value(MAP_VIEW_KEY, encode_view(lat, lon, zoom)) + def get_device_location(self) -> tuple[float, float] | None: """Where this device is, for this app's own display (the map). diff --git a/src/meshcore_console/meshcore/settings_store.py b/src/meshcore_console/meshcore/settings_store.py index 8c74fd2..0c8c5d7 100644 --- a/src/meshcore_console/meshcore/settings_store.py +++ b/src/meshcore_console/meshcore/settings_store.py @@ -32,6 +32,18 @@ def load(self) -> MeshcoreSettings: ) return MeshcoreSettings(**defaults) + def load_value(self, key: str) -> str | None: + """App state kept beside the settings under its own key (not a + MeshcoreSettings field, so load() and save() never touch it).""" + row = self._conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return row[0] if row else None + + def save_value(self, key: str, value: str) -> None: + self._conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value) + ) + self._conn.commit() + def save(self, settings: MeshcoreSettings) -> None: data = asdict(settings) self._conn.executemany( diff --git a/src/meshcore_console/mock/client.py b/src/meshcore_console/mock/client.py index 866e654..62e90d7 100644 --- a/src/meshcore_console/mock/client.py +++ b/src/meshcore_console/mock/client.py @@ -46,6 +46,7 @@ def __init__(self, node_name: str = "uconsole-node") -> None: self._event_notify: Callable[[], None] | None = None self._event_buffer: deque[dict] = deque() self._event_history: list[dict] = [] + self._map_view: tuple[float, float, float] | None = None self._repeater_sessions: dict[str, RepeaterLoginState] = {} self._saved_passwords: dict[str, str] = {} @@ -276,6 +277,12 @@ def update_settings(self, settings: MeshcoreSettings) -> None: self._settings = settings.clone() self._config = runtime_config_from_settings(self._settings) + def get_map_view(self) -> tuple[float, float, float] | None: + return self._map_view + + def save_map_view(self, lat: float, lon: float, zoom: float) -> None: + self._map_view = (lat, lon, zoom) + def get_device_location(self) -> tuple[float, float] | None: return self._gps_provider.get_location() diff --git a/src/meshcore_console/ui_gtk/views/map.py b/src/meshcore_console/ui_gtk/views/map.py index 4a30329..20d732f 100644 --- a/src/meshcore_console/ui_gtk/views/map.py +++ b/src/meshcore_console/ui_gtk/views/map.py @@ -26,6 +26,7 @@ SHUMATE_AVAILABLE = False print("[MapView] libshumate not available", file=sys.stderr) +from meshcore_console.core.map_view import DEVICE_ZOOM, initial_view from meshcore_console.core.models import Peer from meshcore_console.core.radio import format_rssi, format_snr from meshcore_console.core.services import MeshcoreService @@ -71,6 +72,8 @@ def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: self._shown_gps_errors: set[str] = set() # Track shown errors to avoid repeats self._following = False # Whether map follows device location self._programmatic_move = False # Suppress follow-disable during go_to + self._await_fix = False # centre on the first GPS fix, until the user moves the map + self._save_view_id = 0 # pending save of the view, after it stops moving if not SHUMATE_AVAILABLE: self._build_fallback_ui() @@ -139,19 +142,29 @@ def _build_map_ui(self) -> None: self._online_tiles: bool | None = None self._apply_tile_setting() - # Set initial view - center on SF Bay Area if mock mode, otherwise US - if self._service.is_mock_mode(): - # SF Bay Area for mock data - self._viewport.set_zoom_level(12) - self._viewport.set_location(37.7749, -122.4194) - else: - # Centered on US - self._viewport.set_zoom_level(4) - self._viewport.set_location(39.8283, -98.5795) + # Open where the user left the map; on a first start at this device, + # else around the known peers (it used to open on the middle of the US) + device = self._service.get_device_location() + (lat, lon, zoom), source = initial_view( + gps_fix=device if self._service.has_gps_fix() else None, + saved=self._service.get_map_view(), + device=device, + peers=[ + (p.latitude, p.longitude) + for p in self._service.list_peers() + if p.latitude is not None + and p.longitude is not None + and (p.latitude, p.longitude) != (0.0, 0.0) + ], + ) + self._viewport.set_zoom_level(zoom) + self._viewport.set_location(lat, lon) + self._await_fix = source != "gps" # Detect user panning to disable follow mode self._viewport.connect("notify::latitude", self._on_viewport_moved) self._viewport.connect("notify::longitude", self._on_viewport_moved) + self._viewport.connect("notify::zoom-level", lambda *_a: self._schedule_view_save()) # Create marker layer self._marker_layer = Shumate.MarkerLayer.new(self._viewport) @@ -270,6 +283,7 @@ def _add_map_controls(self, overlay: Gtk.Overlay) -> None: def _on_zoom_in(self, _button: Gtk.Button) -> None: """Zoom in on the map.""" logger.debug("UI: map zoom in") + self._await_fix = False self._programmatic_move = True current = self._viewport.get_zoom_level() self._viewport.set_zoom_level(min(current + 1, 19)) @@ -278,6 +292,7 @@ def _on_zoom_in(self, _button: Gtk.Button) -> None: def _on_zoom_out(self, _button: Gtk.Button) -> None: """Zoom out on the map.""" logger.debug("UI: map zoom out") + self._await_fix = False self._programmatic_move = True current = self._viewport.get_zoom_level() self._viewport.set_zoom_level(max(current - 1, 0)) @@ -289,6 +304,7 @@ def _on_center_device(self, _button: Gtk.Button) -> None: location = self._service.get_device_location() if location: lat, lon = location + self._await_fix = False self._set_following(True) self._programmatic_move = True self._viewport.set_zoom_level(12) @@ -303,9 +319,37 @@ def _on_center_device(self, _button: Gtk.Button) -> None: def _on_viewport_moved(self, viewport: object, pspec: object) -> None: """Disable follow mode when user pans the map.""" - if not self._programmatic_move and self._following: + self._schedule_view_save() + if self._programmatic_move: + return + self._await_fix = False + if self._following: self._set_following(False) + def _schedule_view_save(self) -> None: + """Save the view once it has stopped moving (one write per pan, not per frame).""" + if self._save_view_id: + GLib.source_remove(self._save_view_id) + self._save_view_id = GLib.timeout_add_seconds(3, self._save_view_timeout) + + def _save_view_timeout(self) -> bool: + self._save_view_id = 0 + self.remember_view() + return False + + def remember_view(self) -> None: + """Store the current view; the map opens there next time.""" + if not SHUMATE_AVAILABLE: + return + try: + self._service.save_map_view( + self._viewport.get_latitude(), + self._viewport.get_longitude(), + self._viewport.get_zoom_level(), + ) + except Exception: # noqa: BLE001 + logger.exception("Could not save the map view") + def _set_following(self, active: bool) -> None: """Toggle follow mode and update button visual.""" self._following = active @@ -509,6 +553,15 @@ def _update_device_marker(self) -> None: else: self._device_marker.set_location(lat, lon) + # The first GPS fix since start: centre on it once, unless the user + # has moved the map meanwhile (a fixed position from Settings is no fix) + if self._await_fix and self._service.has_gps_fix(): + self._await_fix = False + self._programmatic_move = True + self._viewport.set_zoom_level(max(self._viewport.get_zoom_level(), DEVICE_ZOOM)) + self._viewport.set_location(lat, lon) + self._programmatic_move = False + # Keep map centered on device when following if self._following: self._programmatic_move = True diff --git a/src/meshcore_console/ui_gtk/windows/main_window.py b/src/meshcore_console/ui_gtk/windows/main_window.py index 3a2cbc0..f0a1786 100644 --- a/src/meshcore_console/ui_gtk/windows/main_window.py +++ b/src/meshcore_console/ui_gtk/windows/main_window.py @@ -229,6 +229,12 @@ def _on_close_request(self, _window: Adw.ApplicationWindow) -> bool: self._shutting_down = True logger.info("Shutdown requested, showing shutdown screen") + # the map saves its view a few seconds after it stops moving; catch a + # move made just before closing + map_view = self._stack.get_child_by_name("map") + if map_view is not None: + map_view.remember_view() + if self._radio_error_badge_id: GLib.source_remove(self._radio_error_badge_id) self._radio_error_badge_id = 0 diff --git a/tests/unit/test_map_view.py b/tests/unit/test_map_view.py new file mode 100644 index 0000000..01c6bdd --- /dev/null +++ b/tests/unit/test_map_view.py @@ -0,0 +1,68 @@ +"""Where the map opens.""" + +from __future__ import annotations + +from meshcore_console.core.map_view import ( + DEVICE_ZOOM, + WORLD, + decode_view, + encode_view, + fit_view, + initial_view, +) +from meshcore_console.meshcore.db import open_db + +LEIPZIG = (51.3397, 12.3731) +HALLE = (51.4825, 11.9700) + + +def test_opening_view_precedence() -> None: + saved = (48.137, 11.575, 9.0) + peers = [LEIPZIG, HALLE] + assert initial_view(gps_fix=LEIPZIG, saved=saved, device=LEIPZIG, peers=peers) == ( + (*LEIPZIG, DEVICE_ZOOM), + "gps", + ) + assert initial_view(gps_fix=None, saved=saved, device=LEIPZIG, peers=peers) == (saved, "saved") + assert initial_view(gps_fix=None, saved=None, device=LEIPZIG, peers=peers)[1] == "device" + assert initial_view(gps_fix=None, saved=None, device=None, peers=peers)[1] == "peers" + assert initial_view(gps_fix=None, saved=None, device=None, peers=[]) == (WORLD, "world") + + +def test_fit_view_shows_all_peers() -> None: + lat, lon, zoom = fit_view([LEIPZIG, HALLE]) + assert 51.33 < lat < 51.49 and 11.97 < lon < 12.38 + assert zoom == 11 # 0.40° of longitude is 587 px at zoom 11: fits ~600 px + assert fit_view([LEIPZIG]) == (*LEIPZIG, DEVICE_ZOOM) + assert fit_view([(51.0, 12.0), (51.0, 12.0)])[2] == 14 # same spot: capped + assert fit_view([(-40.0, -170.0), (60.0, 170.0)])[2] == 2 # spread: world + + +def test_stored_view_round_trip_and_garbage() -> None: + assert decode_view(encode_view(51.3397, 12.3731, 11.5)) == (51.3397, 12.3731, 11.5) + for raw in (None, "", "51,12", "a,b,c", "91,0,3", "0,0,25"): + assert decode_view(raw) is None + + +def test_client_keeps_the_view_beside_the_settings(tmp_path, monkeypatch) -> None: + from meshcore_console.meshcore import client as client_mod + from meshcore_console.meshcore.client import MeshcoreClient + from meshcore_console.meshcore.config import runtime_config_from_settings + from meshcore_console.meshcore.settings import MeshcoreSettings + from meshcore_console.meshcore.settings_store import SettingsStore + from meshcore_console.mock import MockOpenHopCoreSession + + db_conn = open_db(str(tmp_path / "test.db")) + monkeypatch.setattr(client_mod, "open_db", lambda *a, **k: db_conn) + client = MeshcoreClient( + session=MockOpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())), + require_openhop=False, + ) + assert client.get_map_view() is None + client.save_map_view(51.3397, 12.3731, 11.0) + assert client.get_map_view() == (51.3397, 12.3731, 11.0) + # an ordinary settings save/load neither drops it nor trips over it + store = SettingsStore(db_conn) + store.save(store.load()) + assert store.load() == MeshcoreSettings() + assert client.get_map_view() == (51.3397, 12.3731, 11.0) From ef446ef424ff7a6afd14e77cae0b67d56826c5b0 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 10:23:37 +0100 Subject: [PATCH 70/72] feat(repeater): fetch status, owner info, neighbours and access list 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- src/meshcore_console/core/services.py | 16 ++ src/meshcore_console/meshcore/client.py | 92 +++++++++++ src/meshcore_console/meshcore/operations.py | 47 ++++++ src/meshcore_console/meshcore/session.py | 41 ++++- src/meshcore_console/mock/client.py | 39 +++++ tests/unit/test_repeater_remote.py | 174 ++++++++++++++++++++ 6 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_repeater_remote.py diff --git a/src/meshcore_console/core/services.py b/src/meshcore_console/core/services.py index 5b27915..28c3c64 100644 --- a/src/meshcore_console/core/services.py +++ b/src/meshcore_console/core/services.py @@ -142,6 +142,22 @@ def logout_from_repeater(self, peer_name: str) -> None: ... def send_repeater_command(self, peer_name: str, command: str) -> dict: ... + def get_repeater_status(self, peer_name: str) -> dict | None: + """Repeater counters (battery, uptime, airtime, noise floor, packets); None on no reply.""" + ... + + def get_repeater_owner_info(self, peer_name: str) -> dict | None: + """{"version", "name", "owner_info"} from the repeater; None on no reply.""" + ... + + def get_repeater_neighbours(self, peer_name: str) -> list[dict] | None: + """[{"pubkey" (6-byte prefix), "name" or None, "secs_ago", "snr"}], newest first.""" + ... + + def get_repeater_access_list(self, peer_name: str) -> list[dict] | None: + """[{"key" (6-byte prefix), "role", "name" or None}]; admin login only.""" + ... + def get_repeater_login_state(self, peer_name: str) -> RepeaterLoginState | None: ... def list_logged_in_repeaters(self) -> list[str]: ... diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index b842c6b..aa3f403 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -4,6 +4,7 @@ import hashlib import concurrent.futures import logging +import secrets import threading from collections import deque from datetime import UTC, datetime @@ -97,6 +98,12 @@ def _propagate_routing_fields(packet_data: dict, handler_data: dict) -> None: DM_SEND_TIMEOUT_S = 45.0 MAX_PEERS = 500 MAP_VIEW_KEY = "ui.map_view" # settings-table row: the map's last view +# Repeater neighbour lists: firmware fills at most 130 bytes of entries per +# reply (key prefix + 4-byte age + 1-byte SNR each). +NEIGHBOUR_KEY_PREFIX = 6 +NEIGHBOURS_PER_PAGE = 130 // (NEIGHBOUR_KEY_PREFIX + 5) +NEIGHBOUR_PAGES_MAX = 10 +ACL_ROLES = {0: "guest", 1: "read-only", 2: "read-write", 3: "admin"} # PERM_ACL_* def dm_message_id(data: dict) -> str | None: @@ -1405,6 +1412,91 @@ def send_repeater_command(self, peer_name: str, command: str) -> dict: ) return result + # --- remote repeater management (binary requests; needs a login) ------- + + def _repeater_request(self, peer_name: str, req_type: int, data: bytes = b"") -> bytes | None: + if not self._connected: + self.connect() + return self._run_async( + self._session.repeater_request(peer_name, req_type, data), timeout=15.0 + ) + + def _repeater_key(self, peer_name: str) -> str: + peer = self._peers.get(peer_name) + return (peer.public_key or "") if peer else "" + + def get_repeater_status(self, peer_name: str) -> dict | None: + """The repeater's counters: battery, uptime, airtime, noise floor, packets.""" + from openhop_core.companion.binary_parsing import parse_binary_response + from openhop_core.companion.constants import BinaryReqType + + data = self._repeater_request(peer_name, BinaryReqType.STATUS) + if data is None: + return None + key_prefix = self._repeater_key(peer_name)[:12] or "000000000000" + return parse_binary_response(BinaryReqType.STATUS, data, pubkey_prefix=key_prefix) + + def get_repeater_owner_info(self, peer_name: str) -> dict | None: + """Firmware version, node name and owner info.""" + from openhop_core.companion.constants import BinaryReqType + + data = self._repeater_request(peer_name, BinaryReqType.OWNER_INFO) + if data is None: + return None + # the reply ends in the zero padding of its last AES block + text = data.rstrip(b"\x00").decode("utf-8", "replace") + version, name, owner = (text.split("\n", 2) + ["", ""])[:3] + return {"version": version.strip(), "name": name.strip(), "owner_info": owner.strip()} + + def get_repeater_neighbours(self, peer_name: str) -> list[dict] | None: + """Every neighbour the repeater has heard, newest first, fetched page by + page (one reply holds at most 11 entries with 6-byte key prefixes).""" + from openhop_core.companion.binary_parsing import parse_binary_response + from openhop_core.companion.constants import BinaryReqType + + neighbours: list[dict] = [] + offset = 0 + for _ in range(NEIGHBOUR_PAGES_MAX): + request = bytes([0, NEIGHBOURS_PER_PAGE]) + offset.to_bytes(2, "little") + request += bytes([0, NEIGHBOUR_KEY_PREFIX]) + secrets.token_bytes(4) + data = self._repeater_request(peer_name, BinaryReqType.NEIGHBOURS, request) + if data is None: + return neighbours or None + page = parse_binary_response( + BinaryReqType.NEIGHBOURS, + data, + context={"pubkey_prefix_length": NEIGHBOUR_KEY_PREFIX}, + ) + for entry in page.get("neighbours", []): + entry["name"] = self._known_name_for_prefix(entry["pubkey"]) + neighbours.append(entry) + offset += page.get("results_count", 0) + if not page.get("results_count") or offset >= page.get("neighbours_count", 0): + break + return neighbours + + def get_repeater_access_list(self, peer_name: str) -> list[dict] | None: + """Who may log in, by 6-byte key prefix and role (admin login only).""" + from openhop_core.companion.binary_parsing import parse_binary_response + from openhop_core.companion.constants import BinaryReqType + + data = self._repeater_request(peer_name, BinaryReqType.ACL, b"\x00\x00") + if data is None: + return None + entries = parse_binary_response(BinaryReqType.ACL, data).get("acl", []) + return [ + { + "key": e["key"], + "role": ACL_ROLES.get(e["perm"] & 3, "guest"), + "name": self._known_name_for_prefix(e["key"]), + } + for e in entries + ] + + def _known_name_for_prefix(self, prefix_hex: str) -> str | None: + name = self._name_for_key_prefix(prefix_hex) + return None if name == prefix_hex.lower() else name + def get_repeater_login_state(self, peer_name: str) -> RepeaterLoginState | None: return self._repeater_sessions.get(peer_name) diff --git a/src/meshcore_console/meshcore/operations.py b/src/meshcore_console/meshcore/operations.py index a4bf913..0b6a8b3 100644 --- a/src/meshcore_console/meshcore/operations.py +++ b/src/meshcore_console/meshcore/operations.py @@ -2,7 +2,9 @@ import asyncio import copy +import struct import time +from typing import Callable from meshcore_console.core.types import ( EmitCallback, @@ -224,6 +226,49 @@ def _on_response(_success: bool, _text: str, parsed: dict) -> None: return reply +async def request_repeater_data( + *, + node: MeshNodeProtocol, + peer_name: str, + req_type: int, + data: bytes, + timestamp: int, + waiters: dict[bytes, Callable[[bytes], None]], + timeout: float = 10.0, +) -> bytes | None: + """A binary REQ to a logged-in repeater (status, neighbours, access list, + owner info); returns the reply bytes after the reflected tag, or None. + + The reply comes back through openhop_core's binary response callback, + which the session routes to ``waiters`` by tag: unlike the parsed reply a + waiter gets, it carries the reply bytes unaltered (openhop_core's type + guess would read a long neighbours reply as status and cut it to 56 bytes). + """ + from openhop_core.protocol.packet_builder import PacketBuilder + + contact = _resolve_contact(node, peer_name) + pkt, tag = PacketBuilder.create_protocol_request( + contact, node.identity, protocol_code=req_type, data=data, timestamp=timestamp + ) + key = struct.pack(" None: + reply["data"] = payload + done.set() + + waiters[key] = _on_reply + try: + await node.dispatcher.send_packet(pkt, wait_for_ack=False) + await asyncio.wait_for(done.wait(), timeout=timeout) + except asyncio.TimeoutError: + return None + finally: + waiters.pop(key, None) + return reply["data"] + + async def send_login( *, node: MeshNodeProtocol, @@ -290,6 +335,7 @@ async def send_repeater_command( peer_name: str, command: str, timeout: float = 15.0, + timestamp: int | None = None, ) -> dict: """Send a CLI command to a repeater and wait for the response.""" from openhop_core.protocol.packet_builder import PacketBuilder @@ -318,6 +364,7 @@ def _on_response(message_text: str, sender_contact: object) -> None: message=command, message_type=_route_type(contact), txt_type=TXT_TYPE_CLI_DATA, + timestamp=timestamp, ) await node.dispatcher.send_packet(pkt, wait_for_ack=False) await asyncio.wait_for(response_event.wait(), timeout=timeout) diff --git a/src/meshcore_console/meshcore/session.py b/src/meshcore_console/meshcore/session.py index 0ed7802..8726a4f 100644 --- a/src/meshcore_console/meshcore/session.py +++ b/src/meshcore_console/meshcore/session.py @@ -53,6 +53,7 @@ from .event_bridge import attach_dispatcher_callbacks, attach_event_service_subscriber from .operations import ( request_owner_info, + request_repeater_data, request_telemetry, send_advert, send_group_text, @@ -74,6 +75,8 @@ def __init__(self, config: RuntimeRadioConfig, logger: LoggerCallback | None = N self._radio: SX1262RadioProtocol | None = None self._identity: LocalIdentityProtocol | None = None self._background_tasks: set[asyncio.Task[None]] = set() + self._raw_waiters: dict[bytes, Callable[[bytes], None]] = {} + self._last_request_ts = 0 self._node: MeshNodeProtocol | None = None self._event_service: EventServiceProtocol | None = None self._event_subscriber: EventSubscriberProtocol | None = None @@ -477,6 +480,10 @@ async def start(self) -> None: self._register_req_handler() self._register_discovery_handler() + # replies to repeater_request(), routed to their waiter by tag + self._node.dispatcher.protocol_response_handler.set_binary_response_callback( + self._on_binary_response + ) self._log("creating background task for node.start()") self._node_task = asyncio.create_task(self._node.start()) @@ -684,9 +691,41 @@ async def send_repeater_cli(self, peer_name: str, command: str, timeout: float = if self._node is None: raise RuntimeError("Session is not started.") return await send_repeater_command( - node=self._node, peer_name=peer_name, command=command, timeout=timeout + node=self._node, + peer_name=peer_name, + command=command, + timeout=timeout, + timestamp=self.next_request_timestamp(), + ) + + async def repeater_request( + self, peer_name: str, req_type: int, data: bytes = b"", timeout: float = 10.0 + ) -> bytes | None: + """A binary request to a logged-in repeater; the raw reply, or None.""" + if self._node is None: + raise RuntimeError("Session is not started.") + return await request_repeater_data( + node=self._node, + peer_name=peer_name, + req_type=req_type, + data=data, + timestamp=self.next_request_timestamp(), + waiters=self._raw_waiters, + timeout=timeout, ) + def next_request_timestamp(self) -> int: + """Strictly increasing request timestamps. A repeater drops a request + whose timestamp is not newer than the client's previous one (replay + protection), so two requests within the same second lost the second.""" + self._last_request_ts = max(int(time.time()), self._last_request_ts + 1) + return self._last_request_ts + + def _on_binary_response(self, tag: bytes, payload: bytes, _path_info: object) -> None: + waiter = self._raw_waiters.pop(bytes(tag), None) + if waiter is not None: + waiter(bytes(payload)) + async def listen_events(self) -> AsyncIterator[MeshEventDict]: if self._node is None: raise RuntimeError("Session is not started.") diff --git a/src/meshcore_console/mock/client.py b/src/meshcore_console/mock/client.py index 62e90d7..991cf10 100644 --- a/src/meshcore_console/mock/client.py +++ b/src/meshcore_console/mock/client.py @@ -423,6 +423,45 @@ def send_repeater_command(self, peer_name: str, command: str) -> dict: "response_text": response_text, } + def get_repeater_status(self, peer_name: str) -> dict | None: + time.sleep(0.3) + return { + "bat": 4012, + "tx_queue_len": 0, + "noise_floor": -112, + "last_rssi": -71, + "nb_recv": 18234, + "nb_sent": 6110, + "airtime": 1834, + "uptime": 3 * 86400 + 5 * 3600, + "sent_flood": 4021, + "sent_direct": 2089, + "recv_flood": 15210, + "recv_direct": 3024, + "full_evts": 0, + "last_snr": 7.25, + "direct_dups": 12, + "flood_dups": 940, + "rx_airtime": 9120, + } + + def get_repeater_owner_info(self, peer_name: str) -> dict | None: + return {"version": "v1.16.0-mock", "name": peer_name, "owner_info": "mock owner"} + + def get_repeater_neighbours(self, peer_name: str) -> list[dict] | None: + time.sleep(0.3) + return [ + {"pubkey": "a1b2c3d4e5f6", "name": None, "secs_ago": 42, "snr": 9.5}, + {"pubkey": "13ab5c7e0011", "name": "Hilltop", "secs_ago": 610, "snr": -3.25}, + {"pubkey": "77cc0011aabb", "name": None, "secs_ago": 86400, "snr": 1.0}, + ] + + def get_repeater_access_list(self, peer_name: str) -> list[dict] | None: + return [ + {"key": "46a5a5adaf41", "role": "admin", "name": self._settings.node_name}, + {"key": "a1b2c3d4e5f6", "role": "guest", "name": None}, + ] + def get_repeater_login_state(self, peer_name: str) -> RepeaterLoginState | None: return self._repeater_sessions.get(peer_name) diff --git a/tests/unit/test_repeater_remote.py b/tests/unit/test_repeater_remote.py new file mode 100644 index 0000000..09b03d5 --- /dev/null +++ b/tests/unit/test_repeater_remote.py @@ -0,0 +1,174 @@ +"""Remote repeater management: binary requests and their replies.""" + +from __future__ import annotations + +import asyncio +import struct + +from openhop_core.node.node import MeshNode +from openhop_core.protocol import CryptoUtils, Identity, Packet +from openhop_core.protocol.constants import PAYLOAD_TYPE_RESPONSE +from openhop_core.protocol.identity import LocalIdentity +from openhop_core.protocol.packet_builder import PacketBuilder + +from meshcore_console.meshcore.config import runtime_config_from_settings +from meshcore_console.meshcore.contact_book import Contact +from meshcore_console.meshcore.db import open_db +from meshcore_console.meshcore.session import OpenHopCoreSession +from meshcore_console.meshcore.settings import MeshcoreSettings + + +class FakeRadio: + def set_rx_callback(self, callback) -> None: + self.callback = callback + + async def send(self, raw: bytes) -> dict: + return {"ok": True} + + def get_last_rssi(self) -> int: + return -50 + + def get_last_snr(self) -> float: + return 5.0 + + +def neighbours_page(total: int, entries: list[tuple[bytes, int, int]]) -> bytes: + """Firmware layout: total(2) + count(2) + [prefix(6) + secs_ago(4) + snr*4(1)]...""" + body = b"".join( + p + struct.pack(" None: + """A full neighbours page is 125 bytes: through openhop_core's parsed path + it would be taken for a status reply and cut to 56.""" + session = OpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())) + me, repeater = LocalIdentity(), LocalIdentity() + node = MeshNode(FakeRadio(), me, config={"node": {"name": "me"}}, contacts=session.contact_book) + session._node, session._identity = node, me + node.dispatcher.protocol_response_handler.set_binary_response_callback( + session._on_binary_response + ) + session.contact_book.add_contact( + Contact( + name="Hilltop", public_key=repeater.get_public_key().hex(), out_path=[], out_path_len=0 + ) + ) + page = neighbours_page(11, [(bytes([i]) * 6, 30 * i, 4 * i) for i in range(11)]) + handler = node.dispatcher._handler_instances[PAYLOAD_TYPE_RESPONSE] + secret = Identity(me.get_public_key()).calc_shared_secret(repeater.get_private_key()) + seen: list[int] = [] + + async def send_packet(pkt, wait_for_ack=False, **_kw) -> bool: + # what the repeater does: decrypt, reflect the timestamp as the tag, reply + plain = CryptoUtils.mac_then_decrypt(secret[:16], secret, bytes(pkt.payload[2:])) + seen.append(plain[4]) + reply = PacketBuilder.create_datagram( + PAYLOAD_TYPE_RESPONSE, Identity(me.get_public_key()), repeater, secret, plain[:4] + page + ) + received = Packet() + received.read_from(reply.write_to()) + asyncio.get_running_loop().create_task(handler(received)) + return True + + monkeypatch.setattr(node.dispatcher, "send_packet", send_packet) + + data = asyncio.run(session.repeater_request("Hilltop", 0x06, b"\x00\x0b\x00\x00\x00\x06")) + + assert seen == [0x06] and session._raw_waiters == {} + # whole page, plus the zero padding of the last AES block (as from firmware) + assert data[: len(page)] == page and not data[len(page) :].strip(b"\x00") + + +def test_request_timestamps_strictly_increase() -> None: + session = OpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())) + stamps = [session.next_request_timestamp() for _ in range(5)] + assert stamps == sorted(set(stamps)) + + +def _client(tmp_path, monkeypatch, replies: dict[int, list[bytes]]): + from meshcore_console.meshcore import client as client_mod + from meshcore_console.meshcore.client import MeshcoreClient + from meshcore_console.mock import MockOpenHopCoreSession + + db_conn = open_db(str(tmp_path / "test.db")) + monkeypatch.setattr(client_mod, "open_db", lambda *a, **k: db_conn) + client = MeshcoreClient( + session=MockOpenHopCoreSession(runtime_config_from_settings(MeshcoreSettings())), + require_openhop=False, + ) + from meshcore_console.meshcore.contact_book import ContactBook + + monkeypatch.setattr(client._session, "contact_book", ContactBook(), raising=False) + client._connected = True + requests: list[tuple[int, bytes]] = [] + + async def repeater_request(peer_name, req_type, data=b"", timeout=10.0): + requests.append((req_type, data)) + return replies[req_type].pop(0) + + monkeypatch.setattr(client._session, "repeater_request", repeater_request, raising=False) + return client, requests + + +def test_neighbours_are_fetched_page_by_page_and_named(tmp_path, monkeypatch) -> None: + from meshcore_console.core.enums import EventType + + first = [(bytes([0xA0 + i]) * 6, 10 * i, 8) for i in range(11)] + pages = [neighbours_page(13, first), neighbours_page(13, [(b"\x13" * 6, 600, -12)] * 2)] + client, requests = _client(tmp_path, monkeypatch, {0x06: pages}) + client._process_event_for_peers( + { + "type": EventType.MESH_NODE_DISCOVERED, + "data": {"name": "Hilltop", "public_key": "13" * 32, "advert_timestamp": 1}, + } + ) + + neighbours = client.get_repeater_neighbours("Bergblick") + + assert len(neighbours) == 13 + # request data: version, count, offset (u16), order, key prefix length, random + assert [r[1][2:4] for r in requests] == [b"\x00\x00", b"\x0b\x00"] # offsets 0, 11 + assert neighbours[-1] == {"pubkey": "13" * 6, "secs_ago": 600, "snr": -3.0, "name": "Hilltop"} + assert neighbours[0]["name"] is None + + +def test_status_owner_and_access_list_decode(tmp_path, monkeypatch) -> None: + status = struct.pack( + " Date: Wed, 28 Oct 2026 10:27:05 +0100 Subject: [PATCH 71/72] feat(repeater): describe remote repeater settings and read/write them over the CLI A repeater has no request that returns all its settings: each one is a "get " / "set " 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- .../meshcore/repeater_settings.py | 317 ++++++++++++++++++ src/meshcore_console/mock/client.py | 39 ++- tests/unit/test_repeater_settings.py | 135 ++++++++ 3 files changed, 489 insertions(+), 2 deletions(-) create mode 100644 src/meshcore_console/meshcore/repeater_settings.py create mode 100644 tests/unit/test_repeater_settings.py diff --git a/src/meshcore_console/meshcore/repeater_settings.py b/src/meshcore_console/meshcore/repeater_settings.py new file mode 100644 index 0000000..cb81cce --- /dev/null +++ b/src/meshcore_console/meshcore/repeater_settings.py @@ -0,0 +1,317 @@ +"""Remote repeater settings over the CLI: what can be set, and how to talk about it. + +A MeshCore repeater has no "dump all settings" request: every setting is one +``get `` / ``set `` CLI round trip (admin login only), about +2-3 s each to a direct neighbour. This module is the pure part: the schema +(keys, types and the limits firmware v1.16-v1.18 enforces), reply parsing and +command building. Keys a repeater's firmware doesn't know answer with +something other than ``> value``; the UI hides those instead of keeping a +per-version table. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable + +IDENTITY = "Identity & location" +ADVERTS = "Adverts" +REPEATING = "Repeating" +TIMING = "Timing" +RADIO = "Radio" +ACCESS = "Access" +GROUPS = (IDENTITY, ADVERTS, REPEATING, TIMING, RADIO, ACCESS) + +NAME_FORBIDDEN = "[]\\:,?*" # firmware isValidName() + + +@dataclass(frozen=True) +class RepeaterSetting: + key: str # CLI key: "get " / "set " + label: str + group: str + kind: str # "text" | "int" | "float" | "onoff" | "choice" | "radio" | "secret" + help: str = "" + unit: str = "" + minimum: float | None = None + maximum: float | None = None + zero_is_off: bool = False # 0 is accepted outside [minimum, maximum] + choices: tuple[str, ...] = () + max_len: int = 0 + reboot: bool = False # takes effect after a reboot + dangerous: bool = False # a wrong value can make the repeater unreachable + + +SETTINGS: tuple[RepeaterSetting, ...] = ( + RepeaterSetting("name", "Name", IDENTITY, "text", max_len=31), + RepeaterSetting( + "lat", + "Latitude", + IDENTITY, + "float", + help="Static position sent in the repeater's adverts.", + minimum=-90, + maximum=90, + ), + RepeaterSetting("lon", "Longitude", IDENTITY, "float", minimum=-180, maximum=180), + RepeaterSetting( + "owner.info", + "Owner info", + IDENTITY, + "text", + help="Free text, e.g. contact details. Line breaks are kept.", + max_len=119, + ), + RepeaterSetting( + "advert.interval", + "Local advert every", + ADVERTS, + "int", + help="Zero-hop advert for direct neighbours. 0 = off.", + unit="min", + minimum=60, + maximum=240, + zero_is_off=True, + ), + RepeaterSetting( + "flood.advert.interval", + "Flood advert every", + ADVERTS, + "int", + help="Advert flooded across the mesh. 0 = off.", + unit="h", + minimum=3, + maximum=168, + zero_is_off=True, + ), + RepeaterSetting("repeat", "Repeat packets", REPEATING, "onoff"), + RepeaterSetting( + "flood.max", "Max flood hops", REPEATING, "int", unit="hops", minimum=0, maximum=64 + ), + RepeaterSetting( + "flood.max.advert", + "Max flood hops (adverts)", + REPEATING, + "int", + unit="hops", + minimum=0, + maximum=64, + ), + RepeaterSetting( + "flood.max.unscoped", + "Max flood hops (unscoped)", + REPEATING, + "int", + unit="hops", + minimum=0, + maximum=64, + ), + RepeaterSetting( + "loop.detect", + "Loop detection", + REPEATING, + "choice", + choices=("off", "minimal", "moderate", "strict"), + ), + RepeaterSetting( + "path.hash.mode", + "Path hash size", + REPEATING, + "choice", + help="0 = 1-byte, 1 = 2-byte, 2 = 3-byte hop hashes.", + choices=("0", "1", "2"), + ), + RepeaterSetting("multi.acks", "Multiple ACKs", REPEATING, "choice", choices=("0", "1")), + RepeaterSetting( + "af", "Airtime factor", TIMING, "float", help="Duty-cycle budget factor.", minimum=0 + ), + RepeaterSetting("rxdelay", "RX delay base", TIMING, "float", minimum=0, maximum=20), + RepeaterSetting("txdelay", "Flood TX delay factor", TIMING, "float", minimum=0, maximum=2), + RepeaterSetting( + "direct.txdelay", "Direct TX delay factor", TIMING, "float", minimum=0, maximum=2 + ), + RepeaterSetting( + "radio", + "Frequency, bandwidth, SF, CR", + RADIO, + "radio", + help="MHz,kHz,SF,CR e.g. 869.618,62.5,8,8 (EU/UK Narrow).", + reboot=True, + dangerous=True, + ), + RepeaterSetting("tx", "TX power", RADIO, "int", unit="dBm", minimum=-9, maximum=30), + RepeaterSetting("allow.read.only", "Allow read-only logins", ACCESS, "onoff"), + RepeaterSetting( + "guest.password", + "Guest password", + ACCESS, + "secret", + help="Empty = guests log in without a password.", + max_len=15, + ), +) + +BY_KEY = {s.key: s for s in SETTINGS} + + +UNSUPPORTED = object() # parse_get_reply(): the firmware doesn't know the key + + +def parse_get_reply(reply: str | None) -> str | object | None: + """The value from a ``get`` reply ("> 869.618,62.5,8,8"); UNSUPPORTED when + the firmware doesn't know the key ("??: key", "> unknown", an error); None + for anything else, e.g. a late reply to an earlier command.""" + if reply is None: + return None + if reply.startswith(">"): + value = reply[1:].strip() + return UNSUPPORTED if value == "unknown" else value + if reply.startswith(("??", "unknown", "Err", "ERR")): + return UNSUPPORTED + return None + + +def set_succeeded(reply: str | None) -> bool: + """Firmware answers a successful ``set`` with "OK", "OK - reboot to apply", + "OK - repeat is now ON", ...; errors start with "Error"/"ERROR"/"unknown".""" + return bool(reply) and reply.lstrip("(").upper().startswith("OK") + + +def display_value(setting: RepeaterSetting, value: str) -> str: + """A ``get`` value as the form shows it (owner info keeps line breaks as "|").""" + return value.replace("|", "\n") if setting.key == "owner.info" else value + + +def validate(setting: RepeaterSetting, text: str) -> str: + """The value to send for ``text``, or ValueError with a message for the form.""" + text = text.strip() if setting.kind != "text" else text + if setting.kind == "text": + value = text.replace("\n", "|") if setting.key == "owner.info" else text.strip() + if setting.key == "name": + if not value: + raise ValueError("Name can't be empty") + bad = sorted({c for c in value if c in NAME_FORBIDDEN}) + if bad: + raise ValueError(f"Name can't contain {' '.join(bad)}") + if setting.max_len and len(value.encode()) > setting.max_len: + raise ValueError(f"At most {setting.max_len} bytes") + return value + if setting.kind == "secret": + if setting.max_len and len(text.encode()) > setting.max_len: + raise ValueError(f"At most {setting.max_len} characters") + return text + if setting.kind == "onoff": + if text not in ("on", "off"): + raise ValueError("on or off") + return text + if setting.kind == "choice": + if text not in setting.choices: + raise ValueError("One of " + ", ".join(setting.choices)) + return text + if setting.kind == "radio": + return _validate_radio(text) + number = _number(setting, text) + return str(int(number)) if setting.kind == "int" else _float_text(number) + + +def set_command(setting: RepeaterSetting, value: str) -> str: + return f"set {setting.key} {value}" + + +# A CLI round trip: command -> the client's result dict ("success", +# "response_text"); MeshcoreService.send_repeater_command bound to a repeater. +RunCommand = Callable[[str], dict] + + +@dataclass +class ReadResult: + values: dict[str, str] = field(default_factory=dict) + unsupported: list[str] = field(default_factory=list) + failed: str | None = None # the key reading stopped at (no or odd reply) + + +def read_settings(run: RunCommand, keys: list[str]) -> ReadResult: + """``get`` each key in turn. + + Stops at the first key without a usable reply: CLI replies carry no + request id, so a reply arriving after its timeout would otherwise be + taken for the answer to the next key.""" + result = ReadResult() + for key in keys: + reply = run(f"get {key}") + value = parse_get_reply(reply.get("response_text") if reply.get("success") else None) + if value is UNSUPPORTED: + result.unsupported.append(key) + elif value is None: + result.failed = key + break + else: + result.values[key] = str(value) + return result + + +@dataclass +class ApplyResult: + key: str + ok: bool + reply: str # the repeater's reply, or why there is none + + +def apply_settings(run: RunCommand, changes: list[tuple[str, str]]) -> list[ApplyResult]: + """Send ``(command, key)`` pairs in turn; stops after the first one without + a reply (same reason as read_settings).""" + results: list[ApplyResult] = [] + for command, key in changes: + reply = run(command) + text = reply.get("response_text") if reply.get("success") else None + if text is None: + results.append(ApplyResult(key, False, "No reply; stopped here")) + break + results.append(ApplyResult(key, set_succeeded(text), text)) + return results + + +def admin_password_command(password: str) -> str: + """The admin password is changed with the ``password`` command, not ``set``.""" + if not password or len(password.encode()) > 15: + raise ValueError("1-15 characters") + if password != password.strip(): + raise ValueError("No leading or trailing spaces") + return f"password {password}" + + +def _number(setting: RepeaterSetting, text: str) -> float: + try: + number = int(text) if setting.kind == "int" else float(text) + except ValueError as exc: + raise ValueError( + "Must be a whole number" if setting.kind == "int" else "Must be a number" + ) from exc + if setting.zero_is_off and number == 0: + return number + low, high = setting.minimum, setting.maximum + if (low is not None and number < low) or (high is not None and number > high): + span = f"{low:g}-{high:g}" if low is not None and high is not None else "" + if not span: + span = f">= {low:g}" if low is not None else f"<= {high:g}" + raise ValueError(f"Range {span}{' (or 0 = off)' if setting.zero_is_off else ''}") + return number + + +def _validate_radio(text: str) -> str: + """ "freq,bw,sf,cr" within the ranges firmware accepts (set radio).""" + parts = [p.strip() for p in text.split(",")] + if len(parts) != 4: + raise ValueError("Four values: MHz,kHz,SF,CR") + try: + freq, bw = float(parts[0]), float(parts[1]) + sf, cr = int(parts[2]), int(parts[3]) + except ValueError as exc: + raise ValueError("Numbers: MHz,kHz,SF,CR") from exc + if not (150 <= freq <= 2500 and 7 <= bw <= 500 and 5 <= sf <= 12 and 5 <= cr <= 8): + raise ValueError("150-2500 MHz, 7-500 kHz, SF 5-12, CR 5-8") + return f"{_float_text(freq)},{_float_text(bw)},{sf},{cr}" + + +def _float_text(number: float) -> str: + return f"{number:.6f}".rstrip("0").rstrip(".") diff --git a/src/meshcore_console/mock/client.py b/src/meshcore_console/mock/client.py index 991cf10..1eb236f 100644 --- a/src/meshcore_console/mock/client.py +++ b/src/meshcore_console/mock/client.py @@ -47,6 +47,29 @@ def __init__(self, node_name: str = "uconsole-node") -> None: self._event_buffer: deque[dict] = deque() self._event_history: list[dict] = [] self._map_view: tuple[float, float, float] | None = None + self._mock_repeater_settings = { + "name": "Hilltop", + "lat": "51.3397", + "lon": "12.3731", + "owner.info": "Mock owner|mock@example.org", + "advert.interval": "0", + "flood.advert.interval": "47", + "repeat": "on", + "flood.max": "64", + "flood.max.advert": "8", + "flood.max.unscoped": "64", + "loop.detect": "minimal", + "path.hash.mode": "0", + "multi.acks": "0", + "af": "1.0", + "rxdelay": "0.0", + "txdelay": "0.5", + "direct.txdelay": "0.2", + "radio": "869.618,62.5,8,8", + "tx": "22", + "allow.read.only": "off", + "guest.password": "", + } self._repeater_sessions: dict[str, RepeaterLoginState] = {} self._saved_passwords: dict[str, str] = {} @@ -402,8 +425,8 @@ def logout_from_repeater(self, peer_name: str) -> None: def send_repeater_command(self, peer_name: str, command: str) -> dict: stripped = command.strip().lower() cmd_key = stripped.split()[0] if stripped else "" - response_text = MOCK_CLI_RESPONSES.get(stripped) or MOCK_CLI_RESPONSES.get( - cmd_key, f"OK: {command}" + response_text = self._mock_setting_reply(command.strip()) or ( + MOCK_CLI_RESPONSES.get(stripped) or MOCK_CLI_RESPONSES.get(cmd_key, f"OK: {command}") ) self._append_event( { @@ -423,6 +446,18 @@ def send_repeater_command(self, peer_name: str, command: str) -> dict: "response_text": response_text, } + def _mock_setting_reply(self, command: str) -> str | None: + """A repeater's settings, answering "get " / "set ".""" + verb, _, rest = command.partition(" ") + key, _, value = rest.partition(" ") + if verb == "get": + value = self._mock_repeater_settings.get(key) + return f"??: {key}" if value is None else f"> {value}" + if verb == "set" and key in self._mock_repeater_settings: + self._mock_repeater_settings[key] = value + return "OK - reboot to apply" if key == "radio" else "OK" + return None + def get_repeater_status(self, peer_name: str) -> dict | None: time.sleep(0.3) return { diff --git a/tests/unit/test_repeater_settings.py b/tests/unit/test_repeater_settings.py new file mode 100644 index 0000000..92e65a9 --- /dev/null +++ b/tests/unit/test_repeater_settings.py @@ -0,0 +1,135 @@ +"""Remote repeater settings: firmware CLI replies, validation, batching.""" + +from __future__ import annotations + +import pytest + +from meshcore_console.meshcore import repeater_settings as rs + + +def test_get_replies_from_firmware() -> None: + assert rs.parse_get_reply("> 869.618,62.5,8,8") == "869.618,62.5,8,8" + assert rs.parse_get_reply("> ") == "" # e.g. an empty guest password + assert rs.parse_get_reply("??: loop.detect") is rs.UNSUPPORTED # v1.16, unknown key + assert rs.parse_get_reply("> unknown") is rs.UNSUPPORTED + assert rs.parse_get_reply("OK") is None # a late reply to an earlier set + assert rs.parse_get_reply(None) is None + + +def test_set_replies_from_firmware() -> None: + for ok in ("OK", "OK - reboot to apply", "(OK - stats reset)"): + assert rs.set_succeeded(ok) + for bad in ( + "Error: interval range is 3-168 hours", + "Error, bad chars", + "unknown config: x", + "", + ): + assert not rs.set_succeeded(bad) + + +@pytest.mark.parametrize( + ("key", "text", "sent"), + [ + ("lat", " 51.3397 ", "51.3397"), + ("lon", "-0.5", "-0.5"), + ("advert.interval", "0", "0"), + ("advert.interval", "120", "120"), + ("flood.advert.interval", "168", "168"), + ("radio", "869.618, 62.5, 8, 8", "869.618,62.5,8,8"), + ("owner.info", "Erik\nLeipzig", "Erik|Leipzig"), + ("repeat", "off", "off"), + ("txdelay", "0.50", "0.5"), + ("guest.password", "", ""), + ], +) +def test_valid_values_are_sent_as_firmware_expects(key: str, text: str, sent: str) -> None: + assert rs.validate(rs.BY_KEY[key], text) == sent + + +@pytest.mark.parametrize( + ("key", "text"), + [ + ("lat", "91"), + ("lon", "east"), + ("advert.interval", "30"), # firmware: 60-240 or 0 + ("flood.advert.interval", "2"), # firmware: 3-168 or 0 + ("flood.max", "65"), + ("radio", "869.618,62.5,13,8"), + ("radio", "869.618,62.5,8"), + ("name", "a,b"), + ("name", ""), + ("name", "x" * 32), + ("repeat", "yes"), + ("loop.detect", "aggressive"), + ], +) +def test_values_firmware_would_refuse_are_refused(key: str, text: str) -> None: + with pytest.raises(ValueError): + rs.validate(rs.BY_KEY[key], text) + + +def test_admin_password_command() -> None: + assert rs.admin_password_command("s3cret") == "password s3cret" + for bad in ("", "x" * 16, " lead"): + with pytest.raises(ValueError): + rs.admin_password_command(bad) + + +def test_display_value_shows_owner_info_line_breaks() -> None: + assert rs.display_value(rs.BY_KEY["owner.info"], "Erik|Leipzig") == "Erik\nLeipzig" + + +def _runner(replies: dict[str, str | None]): + sent: list[str] = [] + + def run(command: str) -> dict: + sent.append(command) + text = replies[command] + return {"success": text is not None, "response_text": text} + + return run, sent + + +def test_read_skips_unsupported_keys_and_stops_at_a_silent_one() -> None: + run, sent = _runner( + { + "get name": "> DF9ES_Home", + "get loop.detect": "??: loop.detect", + "get repeat": None, + "get tx": "> 22", + } + ) + result = rs.read_settings(run, ["name", "loop.detect", "repeat", "tx"]) + + assert result.values == {"name": "DF9ES_Home"} + assert result.unsupported == ["loop.detect"] + assert result.failed == "repeat" + assert sent == ["get name", "get loop.detect", "get repeat"] # tx never asked + + +def test_apply_reports_each_key_and_stops_after_no_reply() -> None: + run, sent = _runner( + { + "set lat 51.3397": "OK", + "set flood.advert.interval 2": "Error: interval range is 3-168 hours", + "set lon 12.3731": None, + "advert": "OK - Advert sent", + } + ) + results = rs.apply_settings( + run, + [ + ("set lat 51.3397", "lat"), + ("set flood.advert.interval 2", "flood.advert.interval"), + ("set lon 12.3731", "lon"), + ("advert", "advert"), + ], + ) + + assert [(r.key, r.ok) for r in results] == [ + ("lat", True), + ("flood.advert.interval", False), + ("lon", False), + ] + assert "advert" not in sent From cd4296c6460ebb5892a6fdd0d2759c2d40e2ea97 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 28 Oct 2026 10:37:55 +0100 Subject: [PATCH 72/72] feat(admin): manage a repeater from tabs instead of only the console 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 Claude-Session: https://claude.ai/code/session_018DBViqusT2T1L2jPFYWKkt --- .../meshcore/repeater_settings.py | 19 + src/meshcore_console/ui_gtk/views/admin.py | 102 +- .../ui_gtk/views/repeater_tabs.py | 868 ++++++++++++++++++ tests/unit/test_repeater_settings.py | 17 + tests/unit/test_repeater_tabs.py | 43 + 5 files changed, 1023 insertions(+), 26 deletions(-) create mode 100644 src/meshcore_console/ui_gtk/views/repeater_tabs.py create mode 100644 tests/unit/test_repeater_tabs.py diff --git a/src/meshcore_console/meshcore/repeater_settings.py b/src/meshcore_console/meshcore/repeater_settings.py index cb81cce..e95d258 100644 --- a/src/meshcore_console/meshcore/repeater_settings.py +++ b/src/meshcore_console/meshcore/repeater_settings.py @@ -214,6 +214,25 @@ def validate(setting: RepeaterSetting, text: str) -> str: return str(int(number)) if setting.kind == "int" else _float_text(number) +def changed_value(setting: RepeaterSetting, loaded: str | None, text: str) -> str | None: + """What to send for a form field showing ``text``: None if it equals the + value read from the repeater (or nothing was read); ValueError if invalid. + A secret is never read back: any text in its field is a change.""" + if setting.kind == "secret": + return validate(setting, text) if text else None + if loaded is None: + return None + if setting.kind == "choice" and not text: + return None # the repeater has a value this app doesn't list; left alone + value = validate(setting, text) + if setting.kind in ("float", "radio"): + try: # compare as the app would send it, not as firmware printed it + loaded = validate(setting, loaded) + except ValueError: + pass + return None if value == loaded else value + + def set_command(setting: RepeaterSetting, value: str) -> str: return f"set {setting.key} {value}" diff --git a/src/meshcore_console/ui_gtk/views/admin.py b/src/meshcore_console/ui_gtk/views/admin.py index c0fc32c..c65e8b5 100644 --- a/src/meshcore_console/ui_gtk/views/admin.py +++ b/src/meshcore_console/ui_gtk/views/admin.py @@ -20,6 +20,13 @@ from meshcore_console.ui_gtk.helpers import clear_children from meshcore_console.ui_gtk.layout import Layout from meshcore_console.ui_gtk.state import UiEventStore +from meshcore_console.ui_gtk.views.repeater_tabs import ( + AccessTab, + NeighboursTab, + OverviewTab, + RepeaterJobs, + SettingsTab, +) from meshcore_console.ui_gtk.widgets import DetailRow if TYPE_CHECKING: @@ -30,7 +37,8 @@ _MAX_HISTORY = 50 -CONSOLE_PAGE = 1 # Admin notebook: 0 Status, 1 Console +# Admin notebook: 0 Overview, 1 Neighbours, 2 Settings, 3 Access, 4 Console +CONSOLE_PAGE = 4 def repeater_list_state(peers: list[Peer], logged_in: list[str]) -> tuple: @@ -50,7 +58,8 @@ def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: self._selected_repeater: Peer | None = None self._command_history: list[str] = [] self._history_index = -1 - self._busy = False + # one request to a repeater at a time, console and tabs alike + self._jobs = RepeaterJobs(on_idle=self._on_jobs_idle) self._refreshing_list = False self._list_state: tuple | None = None @@ -116,29 +125,47 @@ def __init__(self, service: MeshcoreService, event_store: UiEventStore, layout: sep.set_margin_end(12) admin_outer.append(sep) - # Notebook with Status and Console tabs self._notebook = Gtk.Notebook.new() self._notebook.set_vexpand(True) self._notebook.set_margin_start(4) self._notebook.set_margin_end(4) self._notebook.set_margin_bottom(4) + self._notebook.set_scrollable(True) - # Status tab + # Overview tab: what the repeater reports, then what this app knows + self._overview = OverviewTab(service, self._jobs) self._status_scroll = Gtk.ScrolledWindow.new() self._status_scroll.set_vexpand(True) self._status_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + overview_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + overview_page.set_margin_start(12) + overview_page.set_margin_end(12) + overview_page.set_margin_top(8) + overview_page.set_margin_bottom(8) + overview_page.append(self._overview) self._status_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - self._status_content.set_margin_start(12) - self._status_content.set_margin_end(12) - self._status_content.set_margin_top(8) - self._status_content.set_margin_bottom(8) - self._status_scroll.set_child(self._status_content) - self._notebook.append_page(self._status_scroll, Gtk.Label(label="Status")) - - # Console tab + overview_page.append(self._status_content) + self._status_scroll.set_child(overview_page) + self._notebook.append_page(self._status_scroll, Gtk.Label(label="Overview")) + + self._neighbours = NeighboursTab(service, self._jobs) + self._notebook.append_page(self._neighbours, Gtk.Label(label="Neighbours")) + self._settings = SettingsTab(service, self._jobs) + self._notebook.append_page(self._settings, Gtk.Label(label="Settings")) + self._access = AccessTab(service, self._jobs) + self._notebook.append_page(self._access, Gtk.Label(label="Access")) + # notebook page -> the remote tab on it + self._remote_tabs = { + 0: self._overview, + 1: self._neighbours, + 2: self._settings, + 3: self._access, + } + console_page = self._build_console_tab() self._notebook.append_page(console_page, Gtk.Label(label="Console")) assert self._notebook.get_n_pages() - 1 == CONSOLE_PAGE + self._notebook.connect("switch-page", self._on_switch_page) admin_outer.append(self._notebook) self._panel_stack.add_named(admin_outer, "admin") @@ -374,11 +401,16 @@ def _refresh_list(self) -> bool: row = self._make_repeater_row(rp, rp.display_name in logged_in) self._repeater_list.append(row) - # Re-select if still valid, and update the peer object + # Re-select if still valid, and update the peer object; by key too, + # since a repeater renamed from the Settings tab comes back renamed if self._selected_repeater: + key = self._selected_repeater.public_key for i, rp in enumerate(repeaters): - if rp.display_name == self._selected_repeater.display_name: + if rp.display_name == self._selected_repeater.display_name or ( + key and rp.public_key == key + ): self._selected_repeater = rp + self._admin_title.set_text(rp.display_name) row_widget = self._repeater_list.get_row_at_index(i) if row_widget: self._repeater_list.select_row(row_widget) @@ -425,6 +457,8 @@ def _on_repeater_selected(self, _listbox: Gtk.ListBox, row: Gtk.ListBoxRow | Non if row is None: self._selected_repeater = None self._panel_stack.set_visible_child_name("empty") + for tab in self._remote_tabs.values(): + tab.set_repeater(None, None) return peer: Peer = row._peer # type: ignore[attr-defined] self._selected_repeater = peer @@ -455,7 +489,26 @@ def _show_admin_panel(self, peer: Peer) -> None: # Clear console for new repeater clear_children(self._console_box) + def _update_remote_tabs(self, state: RepeaterLoginState | None) -> None: + name = self._selected_repeater.display_name if self._selected_repeater else None + for tab in self._remote_tabs.values(): + tab.set_repeater(name, state) + current = self._remote_tabs.get(self._notebook.get_current_page()) + if current is not None: + current.on_shown() + + def _on_jobs_idle(self) -> None: + current = self._remote_tabs.get(self._notebook.get_current_page()) + if current is not None: + current.on_shown() + + def _on_switch_page(self, _notebook: Gtk.Notebook, _page: Gtk.Widget, num: int) -> None: + tab = self._remote_tabs.get(num) + if tab is not None: + tab.on_shown() + def _update_login_ui(self, state: RepeaterLoginState | None) -> None: + self._update_remote_tabs(state) if state is None: self._status_label.set_text("Not logged in") self._status_label.remove_css_class("login-status-admin") @@ -511,16 +564,16 @@ def _on_guest_clicked(self, _btn: Gtk.Button) -> None: def _refused_while_busy(self) -> bool: """One request at a time (a command can take 20 s); say so instead of silently ignoring the click or Enter.""" - if self._busy: + if self._jobs.busy: self._append_console_error("Still waiting for the previous request; try again shortly.") - return self._busy + return self._jobs.busy def _do_login(self, password: str) -> None: if self._selected_repeater is None or self._refused_while_busy(): return peer_name = self._selected_repeater.display_name save = self._save_check.get_active() - self._busy = True + self._jobs.busy = True self._login_btn.set_sensitive(False) self._guest_btn.set_sensitive(False) self._status_label.set_text("Logging in...") @@ -534,7 +587,7 @@ def work() -> None: error = str(exc) or type(exc).__name__ def done() -> bool: - self._busy = False + self._jobs.release() still_selected = ( self._selected_repeater is not None and self._selected_repeater.display_name == peer_name @@ -554,13 +607,10 @@ def done() -> bool: self._update_login_ui(None) else: if still_selected and self._selected_repeater is not None: + # the tab on show fetches from the repeater now state = self._service.get_repeater_login_state(peer_name) self._update_login_ui(state) self._populate_status(self._selected_repeater) - if state is not None and state.is_admin: - # what an admin logs in for: the console, ready to type - self._notebook.set_current_page(CONSOLE_PAGE) - self._cmd_entry.grab_focus() self._append_console_response("Login successful") self._refresh_list() return False @@ -573,7 +623,7 @@ def _on_logout_clicked(self, _btn: Gtk.Button) -> None: if self._selected_repeater is None or self._refused_while_busy(): return peer_name = self._selected_repeater.display_name - self._busy = True + self._jobs.busy = True self._logout_btn.set_sensitive(False) def work() -> None: @@ -584,7 +634,7 @@ def work() -> None: error = str(exc) or type(exc).__name__ def done() -> bool: - self._busy = False + self._jobs.release() if error: self._append_console_error(f"Logout failed: {error}") self._logout_btn.set_sensitive(True) @@ -688,7 +738,7 @@ def _send_command(self, command: str) -> None: self._cmd_entry.set_text("") self._append_console_command(command) - self._busy = True + self._jobs.busy = True self._send_btn.set_sensitive(False) def work() -> None: @@ -700,7 +750,7 @@ def work() -> None: error = str(exc) or type(exc).__name__ def done() -> bool: - self._busy = False + self._jobs.release() self._send_btn.set_sensitive(True) still_selected = ( self._selected_repeater is not None diff --git a/src/meshcore_console/ui_gtk/views/repeater_tabs.py b/src/meshcore_console/ui_gtk/views/repeater_tabs.py new file mode 100644 index 0000000..09bf89c --- /dev/null +++ b/src/meshcore_console/ui_gtk/views/repeater_tabs.py @@ -0,0 +1,868 @@ +"""Remote repeater management tabs for the Admin view. + +Overview, Neighbours, Settings and Access fetch from the selected repeater +over the air. Nothing polls: a tab loads when it is first shown after a +login, and again only on Refresh/Load. Each request takes seconds on +LoRa, so they all go through one RepeaterJobs queue of length one, shared +with the console (CLI replies carry no request id, so overlapping commands +could swap their answers). +""" + +from __future__ import annotations + +import threading +from typing import TYPE_CHECKING, Any, Callable + +import gi + +gi.require_version("Adw", "1") +gi.require_version("Gtk", "4.0") + +from gi.repository import Adw, GLib, Gtk, Pango + +from meshcore_console.meshcore import repeater_settings as rs +from meshcore_console.ui_gtk.compat import build_alert_dialog, present_dialog +from meshcore_console.ui_gtk.helpers import clear_children + +if TYPE_CHECKING: + from meshcore_console.core.models import RepeaterLoginState + from meshcore_console.core.services import MeshcoreService + + +# -- formatting (pure) --------------------------------------------------------- + + +def format_duration(secs: int) -> str: + """3 d 5 h / 5 h 12 m / 12 m 3 s / 42 s.""" + secs = max(0, int(secs)) + days, rest = divmod(secs, 86400) + hours, rest = divmod(rest, 3600) + mins, s = divmod(rest, 60) + if days: + return f"{days} d {hours} h" + if hours: + return f"{hours} h {mins} m" + if mins: + return f"{mins} m {s} s" + return f"{s} s" + + +def status_rows(status: dict) -> list[tuple[str, str]]: + """GET_STATUS counters as (label, value) rows, most useful first.""" + uptime = status.get("uptime", 0) + tx_air, rx_air = status.get("airtime", 0), status.get("rx_airtime", 0) + + def share(air: int) -> str: + return ( + f"{format_duration(air)} ({100 * air / uptime:.1f} %)" + if uptime + else format_duration(air) + ) + + return [ + ("Battery", f"{status.get('bat', 0) / 1000:.2f} V"), + ("Uptime", format_duration(uptime)), + ("TX airtime", share(tx_air)), + ("RX airtime", share(rx_air)), + ("Noise floor", f"{status.get('noise_floor', 0)} dBm"), + ( + "Last RSSI / SNR", + f"{status.get('last_rssi', 0)} dBm / {status.get('last_snr', 0):.1f} dB", + ), + ( + "Received", + f"{status.get('nb_recv', 0)} (flood {status.get('recv_flood', 0)}, direct {status.get('recv_direct', 0)})", + ), + ( + "Sent", + f"{status.get('nb_sent', 0)} (flood {status.get('sent_flood', 0)}, direct {status.get('sent_direct', 0)})", + ), + ( + "Duplicates", + f"flood {status.get('flood_dups', 0)}, direct {status.get('direct_dups', 0)}", + ), + ( + "TX queue / full events", + f"{status.get('tx_queue_len', 0)} / {status.get('full_evts', 0)}", + ), + ] + + +def sort_neighbours(neighbours: list[dict], by: str) -> list[dict]: + """by: "heard" (most recent first) or "signal" (strongest first).""" + if by == "signal": + return sorted(neighbours, key=lambda n: -n.get("snr", 0)) + return sorted(neighbours, key=lambda n: n.get("secs_ago", 0)) + + +# -- one request at a time ----------------------------------------------------- + + +class RepeaterJobs: + """Runs one repeater request at a time on a worker thread; ``done`` gets + (result, error) on the GTK main loop. ``on_idle`` runs after each request + (a tab refused while busy loads then).""" + + def __init__(self, on_idle: Callable[[], None] | None = None) -> None: + self.busy = False + self._on_idle = on_idle + + def release(self) -> None: + """A request finished (also for the Admin view's own login/console threads).""" + self.busy = False + if self._on_idle is not None: + GLib.idle_add(lambda: self._on_idle() if not self.busy else None) + + def run(self, work: Callable[[], Any], done: Callable[[Any, str | None], None]) -> bool: + if self.busy: + return False + self.busy = True + + def thread() -> None: + result: Any = None + error: str | None = None + try: + result = work() + except Exception as exc: + error = str(exc) or type(exc).__name__ + + def finish() -> bool: + self.release() + done(result, error) + return False + + GLib.idle_add(finish) + + threading.Thread(target=thread, daemon=True).start() + return True + + +# -- shared tab plumbing ------------------------------------------------------- + + +def _muted(text: str) -> Gtk.Label: + label = Gtk.Label(label=text) + label.add_css_class("panel-muted") + label.set_halign(Gtk.Align.START) + label.set_xalign(0.0) + label.set_wrap(True) + label.set_wrap_mode(Pango.WrapMode.WORD_CHAR) + label.set_size_request(10, -1) # natural width must not follow the text (AGENTS.md) + return label + + +class _RemoteTab(Gtk.Box): + """A tab whose content comes from the selected repeater.""" + + needs_admin = False + what = "" # for "Log in to see ." + + def __init__(self, service: MeshcoreService, jobs: RepeaterJobs) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self.set_margin_start(12) + self.set_margin_end(12) + self.set_margin_top(8) + self.set_margin_bottom(8) + self._service = service + self._jobs = jobs + self._peer_name: str | None = None + self._state: RepeaterLoginState | None = None + self._loaded = False + self._auto_tried = False + + self._bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self._note = _muted("") + self._note.set_hexpand(True) + self._bar.append(self._note) + self.append(self._bar) + + # -- called by AdminView + + def set_repeater(self, peer_name: str | None, state: RepeaterLoginState | None) -> None: + """A repeater was selected, or its login changed: forget what was shown.""" + changed = peer_name != self._peer_name or _login_key(state) != _login_key(self._state) + self._peer_name, self._state = peer_name, state + if changed: + self._loaded = False + self._auto_tried = False + self._clear() + allowed = self.allowed + for child in self._actions(): + child.set_sensitive(allowed) + if state is None: + self._note.set_text(f"Log in to see {self.what}.") + elif not allowed: + self._note.set_text(f"Needs an admin login: {self.what} are admin only.") + elif changed: + self._note.set_text("") + + def on_shown(self) -> None: + """The tab became visible: fetch once per login (after a failure, the + user retries with Refresh; login-state updates don't re-send).""" + if self.allowed and self.auto_load and not self._loaded and not self._auto_tried: + # refused while busy (e.g. the login itself still running): the + # Admin view calls on_shown() again when the request is done + self._auto_tried = self.load() + if not self._auto_tried: + self._note.set_text("Waiting for the current request to finish...") + + @property + def allowed(self) -> bool: + return self._state is not None and (self._state.is_admin or not self.needs_admin) + + auto_load = True + + # -- for subclasses + + def load(self) -> bool: + """Start fetching; False if refused because another request runs.""" + raise NotImplementedError + + def _clear(self) -> None: + raise NotImplementedError + + def _actions(self) -> list[Gtk.Widget]: + return [] + + def _start( + self, busy_text: str, work: Callable[[str], Any], done: Callable[[Any], None] + ) -> bool: + """Run ``work(peer_name)``; ``done(result)`` only if the same repeater + is still selected.""" + peer_name = self._peer_name + if peer_name is None: + return False + + def finished(result: Any, error: str | None) -> None: + if peer_name != self._peer_name: + return + for child in self._actions(): + child.set_sensitive(self.allowed) + if error: + self._note.set_text(f"Failed: {error}") + return + done(result) + + if not self._jobs.run(lambda: work(peer_name), finished): + self._note.set_text("Busy with another request to a repeater; try again in a moment.") + return False + for child in self._actions(): + child.set_sensitive(False) + self._note.set_text(busy_text) + return True + + +def _login_key(state: RepeaterLoginState | None) -> tuple | None: + return None if state is None else (state.is_admin,) + + +def _button(label: str, callback: Callable[[], None], css: str | None = None) -> Gtk.Button: + button = Gtk.Button.new_with_label(label) + if css: + button.add_css_class(css) + button.connect("clicked", lambda _b: callback()) + return button + + +def _grid() -> Gtk.Grid: + grid = Gtk.Grid() + grid.set_column_spacing(12) + grid.set_row_spacing(4) + return grid + + +def _cell(text: str, css: str | None = None, expand: bool = False) -> Gtk.Label: + label = Gtk.Label(label=text) + label.set_halign(Gtk.Align.START) + label.set_xalign(0.0) + if css: + label.add_css_class(css) + if expand: + label.set_hexpand(True) + label.set_ellipsize(Pango.EllipsizeMode.END) + label.set_size_request(10, -1) + return label + + +# -- Overview ------------------------------------------------------------------ + + +class OverviewTab(_RemoteTab): + """Live counters, firmware and owner info; clock and advert for admins.""" + + what = "the repeater's status" + + def __init__(self, service: MeshcoreService, jobs: RepeaterJobs) -> None: + super().__init__(service, jobs) + self.set_margin_start(0) + self.set_margin_end(0) + self._refresh_btn = _button("Refresh", self.load) + self._clock_btn = _button("Sync clock", self._sync_clock) + self._clock_btn.set_tooltip_text("Set the repeater's clock to this device's time") + self._advert_btn = _button("Send advert", self._send_advert) + self._advert_btn.set_tooltip_text("The repeater floods an advert now") + for button in (self._refresh_btn, self._clock_btn, self._advert_btn): + self._bar.append(button) + self._grid = _grid() + self.append(self._grid) + + def set_repeater(self, peer_name: str | None, state: RepeaterLoginState | None) -> None: + super().set_repeater(peer_name, state) + admin = state is not None and state.is_admin + self._clock_btn.set_visible(admin) + self._advert_btn.set_visible(admin) + + def _actions(self) -> list[Gtk.Widget]: + return [self._refresh_btn, self._clock_btn, self._advert_btn] + + def _clear(self) -> None: + clear_children(self._grid) + + def load(self) -> bool: + admin = self._state is not None and self._state.is_admin + + def work(peer: str) -> dict: + status = self._service.get_repeater_status(peer) + owner = self._service.get_repeater_owner_info(peer) + clock = self._service.send_repeater_command(peer, "clock") if admin else None + return {"status": status, "owner": owner, "clock": clock} + + return self._start("Asking the repeater...", work, self._show) + + def _show(self, result: dict) -> None: + self._loaded = True + self._clear() + rows: list[tuple[str, str]] = [] + owner = result.get("owner") + if owner: + rows.append(("Firmware", owner.get("version") or "?")) + if owner.get("owner_info"): + rows.append(("Owner", owner["owner_info"])) + clock = result.get("clock") + if clock and clock.get("success") and clock.get("response_text"): + rows.append(("Clock", clock["response_text"])) + status = result.get("status") + if status: + rows.extend(status_rows(status)) + for i, (label, value) in enumerate(rows): + self._grid.attach(_cell(label, "panel-muted"), 0, i, 1, 1) + cell = _cell(value) + cell.set_wrap(True) + cell.set_wrap_mode(Pango.WrapMode.WORD_CHAR) + cell.set_size_request(10, -1) + cell.set_hexpand(True) + cell.set_selectable(True) + self._grid.attach(cell, 1, i, 1, 1) + missing = [n for n, v in (("status", status), ("owner info", owner)) if not v] + self._note.set_text(f"No reply for {' and '.join(missing)}." if missing else "") + + def _sync_clock(self) -> None: + self._command("clock sync") + + def _send_advert(self) -> None: + self._command("advert") + + def _command(self, command: str) -> None: + def done(result: dict) -> None: + text = result.get("response_text") if result.get("success") else None + self._note.set_text(text or "No reply") + + self._start( + f"Sending {command}...", lambda p: self._service.send_repeater_command(p, command), done + ) + + +# -- Neighbours ---------------------------------------------------------------- + + +class NeighboursTab(_RemoteTab): + """Who the repeater hears directly, with SNR and when it last heard them.""" + + what = "the repeater's neighbours" + + def __init__(self, service: MeshcoreService, jobs: RepeaterJobs) -> None: + super().__init__(service, jobs) + self._neighbours: list[dict] = [] + self._sort = Gtk.DropDown.new_from_strings(["Last heard", "Signal"]) + self._sort.set_tooltip_text("Sort by") + self._sort.connect("notify::selected", lambda *_a: self._render()) + self._refresh_btn = _button("Refresh", self.load) + self._bar.append(self._sort) + self._bar.append(self._refresh_btn) + + scroll = Gtk.ScrolledWindow.new() + scroll.set_vexpand(True) + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self._list = Gtk.ListBox.new() + self._list.set_selection_mode(Gtk.SelectionMode.NONE) + scroll.set_child(self._list) + self.append(scroll) + + def _actions(self) -> list[Gtk.Widget]: + return [self._refresh_btn] + + def _clear(self) -> None: + self._neighbours = [] + clear_children(self._list) + + def load(self) -> bool: + return self._start( + "Fetching neighbours...", self._service.get_repeater_neighbours, self._show + ) + + def _show(self, neighbours: list[dict] | None) -> None: + if neighbours is None: + self._note.set_text("No reply.") + return + self._loaded = True + self._neighbours = neighbours + self._note.set_text(f"{len(neighbours)} neighbour{'s' if len(neighbours) != 1 else ''}") + self._render() + + def _render(self) -> None: + clear_children(self._list) + by = "signal" if self._sort.get_selected() == 1 else "heard" + admin = self._state is not None and self._state.is_admin + for n in sort_neighbours(self._neighbours, by): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + row.add_css_class("admin-repeater-row") + name = _cell(n.get("name") or n["pubkey"], expand=True) + name.set_tooltip_text(n["pubkey"]) + if not n.get("name"): + name.add_css_class("panel-muted") + row.append(name) + row.append(_cell(f"{n.get('snr', 0):+.1f} dB")) + row.append(_cell(f"{format_duration(n.get('secs_ago', 0))} ago", "panel-muted")) + if admin: + remove = Gtk.Button.new_from_icon_name("user-trash-symbolic") + remove.add_css_class("flat") + remove.set_tooltip_text("Remove from the repeater's neighbour list") + remove.connect("clicked", lambda _b, key=n["pubkey"]: self._remove(key)) + row.append(remove) + self._list.append(row) + + def _remove(self, key: str) -> None: + def done(result: dict) -> None: + if result.get("success") and rs.set_succeeded(result.get("response_text")): + self._neighbours = [n for n in self._neighbours if n["pubkey"] != key] + self._note.set_text("Removed; it comes back when the repeater hears it again.") + self._render() + else: + self._note.set_text(result.get("response_text") or "No reply") + + self._start( + "Removing...", + lambda p: self._service.send_repeater_command(p, f"neighbor.remove {key}"), + done, + ) + + +# -- Access list --------------------------------------------------------------- + + +class AccessTab(_RemoteTab): + """Who may log in (read-only; keys are 6-byte prefixes).""" + + needs_admin = True + what = "access lists" + + def __init__(self, service: MeshcoreService, jobs: RepeaterJobs) -> None: + super().__init__(service, jobs) + self._refresh_btn = _button("Refresh", self.load) + self._bar.append(self._refresh_btn) + self._grid = _grid() + self.append(self._grid) + hint = _muted("Change permissions from the console: setperm <0-3>.") + self.append(hint) + + def _actions(self) -> list[Gtk.Widget]: + return [self._refresh_btn] + + def _clear(self) -> None: + clear_children(self._grid) + + def load(self) -> bool: + return self._start( + "Fetching the access list...", self._service.get_repeater_access_list, self._show + ) + + def _show(self, entries: list[dict] | None) -> None: + if entries is None: + self._note.set_text("No reply.") + return + self._loaded = True + self._clear() + me = (self._service.get_self_public_key() or "")[:12] + for i, e in enumerate(entries): + who = e.get("name") or e["key"] + if me and e["key"] == me: + who += " (you)" + name = _cell(who, expand=True) + name.set_tooltip_text(e["key"]) + self._grid.attach(name, 0, i, 1, 1) + self._grid.attach(_cell(e["role"], "panel-muted"), 1, i, 1, 1) + self._note.set_text(f"{len(entries)} entr{'ies' if len(entries) != 1 else 'y'}") + + +# -- Settings ------------------------------------------------------------------ + + +class _Field: + """One setting's row: the editor widget, and the value last read from the repeater.""" + + def __init__(self, setting: rs.RepeaterSetting) -> None: + self.setting = setting + self.loaded: str | None = None + self.label = _cell(setting.label) + self.label.set_tooltip_text(setting.help or setting.key) + self.result = _muted("") + self.widget = self._make_widget() + self.label.set_mnemonic_widget(self.widget) + + def _make_widget(self) -> Gtk.Widget: + s = self.setting + if s.kind == "onoff": + switch = Gtk.Switch() + switch.set_halign(Gtk.Align.START) + return switch + if s.kind == "choice": + return Gtk.DropDown.new_from_strings(list(s.choices)) + if s.key == "owner.info": + view = Gtk.TextView() + view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + view.set_size_request(10, 54) + view.add_css_class("cli-entry") + return view + entry = Gtk.PasswordEntry() if s.kind == "secret" else Gtk.Entry() + if s.kind == "secret": + entry.set_show_peek_icon(True) + entry.set_property("placeholder-text", "unchanged") + else: + entry.set_width_chars(8 if s.kind in ("int", "float") else 16) + if s.unit: + entry.set_placeholder_text(s.unit) + entry.set_hexpand(True) + return entry + + def show(self, value: str) -> None: + self.loaded = value + w, s = self.widget, self.setting + if isinstance(w, Gtk.Switch): + w.set_active(value == "on") + elif isinstance(w, Gtk.DropDown): + w.set_selected( + s.choices.index(value) if value in s.choices else Gtk.INVALID_LIST_POSITION + ) + elif isinstance(w, Gtk.TextView): + w.get_buffer().set_text(rs.display_value(s, value)) + else: + w.set_text(value) + + def text(self) -> str: + w, s = self.widget, self.setting + if isinstance(w, Gtk.Switch): + return "on" if w.get_active() else "off" + if isinstance(w, Gtk.DropDown): + i = w.get_selected() + return s.choices[i] if i < len(s.choices) else "" + if isinstance(w, Gtk.TextView): + buf = w.get_buffer() + return buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False) + return w.get_text() + + def change(self) -> str | None: + """The value to send, None if unchanged; ValueError if invalid.""" + return rs.changed_value(self.setting, self.loaded, self.text()) + + def set_result(self, text: str, error: bool = False) -> None: + self.result.set_text(text) + if error: + self.result.add_css_class("cli-error") + else: + self.result.remove_css_class("cli-error") + + +class _Group(Gtk.Box): + """A settings group: loaded and applied on its own (each key is a round trip).""" + + def __init__(self, tab: SettingsTab, title: str) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=4) + self._tab = tab + self.title = title + self.fields = [_Field(s) for s in rs.SETTINGS if s.group == title] + self.loaded = False + + head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + heading = Gtk.Label(label=title) + heading.add_css_class("message-detail-header") + heading.set_halign(Gtk.Align.START) + heading.set_hexpand(True) + head.append(heading) + self.load_btn = _button("Load", lambda: tab.load_group(self)) + self.apply_btn = _button("Apply", lambda: tab.apply_group(self), "suggested-action") + self.apply_btn.set_sensitive(False) + head.append(self.load_btn) + head.append(self.apply_btn) + self.append(head) + self.extra = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self.extra.set_visible(False) + self.append(self.extra) + + self.note = _muted("") + self.append(self.note) + self.grid = _grid() + self.grid.set_visible(False) + for i, f in enumerate(self.fields): + self.grid.attach(f.label, 0, 2 * i, 1, 1) + self.grid.attach(f.widget, 1, 2 * i, 1, 1) + self.grid.attach(f.result, 1, 2 * i + 1, 1, 1) + self.append(self.grid) + + def reset(self) -> None: + self.loaded = False + self.apply_btn.set_sensitive(False) + self.grid.set_visible(False) + self.note.set_text("") + for f in self.fields: + f.loaded = None + f.set_result("") + for w in (f.label, f.widget, f.result): + w.set_visible(True) + + +class SettingsTab(_RemoteTab): + """Grouped settings forms; Apply sends only what changed, key by key.""" + + needs_admin = True + what = "settings" + auto_load = False # ~2-3 s per key: the user loads the groups they want + + def __init__(self, service: MeshcoreService, jobs: RepeaterJobs) -> None: + super().__init__(service, jobs) + self._note.set_text("") + scroll = Gtk.ScrolledWindow.new() + scroll.set_vexpand(True) + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14) + box.set_margin_end(8) + self._groups = [_Group(self, title) for title in rs.GROUPS] + for group in self._groups: + box.append(group) + box.append(self._build_admin_password()) + scroll.set_child(box) + self.append(scroll) + + identity = self._groups[0] + self._gps_btn = _button("Use my position", self._use_my_position) + self._gps_btn.set_tooltip_text("Fill latitude/longitude from this device's GPS") + identity.extra.append(self._gps_btn) + self._advert_check = Gtk.CheckButton.new_with_label("Advert after apply") + self._advert_check.set_tooltip_text( + "Flood an advert so others see the new name or position" + ) + self._advert_check.set_active(True) + identity.extra.append(self._advert_check) + identity.extra.set_visible(True) + + def _build_admin_password(self) -> Gtk.Box: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + heading = Gtk.Label(label="Admin password") + heading.add_css_class("message-detail-header") + heading.set_halign(Gtk.Align.START) + heading.set_hexpand(True) + head.append(heading) + self._pw_btn = _button("Change", self._change_admin_password, "destructive-action") + head.append(self._pw_btn) + box.append(head) + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self._pw1, self._pw2 = Gtk.PasswordEntry(), Gtk.PasswordEntry() + for entry, hint in ((self._pw1, "new password"), (self._pw2, "again")): + entry.set_property("placeholder-text", hint) + entry.set_hexpand(True) + row.append(entry) + box.append(row) + self._pw_note = _muted("A typo here locks you out until someone resets it on site.") + box.append(self._pw_note) + return box + + def _actions(self) -> list[Gtk.Widget]: + widgets: list[Gtk.Widget] = [self._pw_btn, self._gps_btn] + for g in self._groups: + widgets.append(g.load_btn) + if g.loaded: + widgets.append(g.apply_btn) + return widgets + + def _clear(self) -> None: + for g in self._groups: + g.reset() + self._pw1.set_text("") + self._pw2.set_text("") + + def load(self) -> bool: + return False # per group, from its Load button + + # -- load / apply + + def load_group(self, group: _Group) -> None: + keys = [f.setting.key for f in group.fields if f.setting.kind != "secret"] + + def done(result: rs.ReadResult) -> None: + group.grid.set_visible(True) + for f in group.fields: + f.set_result("") + key = f.setting.key + if key in result.unsupported: + for w in (f.label, f.widget, f.result): + w.set_visible(False) + elif key in result.values: + f.show(result.values[key]) + group.loaded = result.failed is None + group.apply_btn.set_sensitive(group.loaded) + if result.failed: + group.note.set_text(f"No usable reply for {result.failed}; stopped. Load again.") + else: + group.note.set_text("") + self._note.set_text("") + + self._start( + f"Reading {group.title.lower()}...", + lambda peer: rs.read_settings( + lambda c: self._service.send_repeater_command(peer, c), keys + ), + done, + ) + + def apply_group(self, group: _Group) -> None: + changes: list[tuple[str, str]] = [] + bad = False + for f in group.fields: + if not f.widget.get_visible(): + continue + try: + value = f.change() + except ValueError as exc: + f.set_result(str(exc), error=True) + bad = True + continue + f.set_result("") + if value is not None: + changes.append((rs.set_command(f.setting, value), f.setting.key)) + if bad: + group.note.set_text("Fix the marked values first.") + return + if not changes: + group.note.set_text("Nothing changed.") + return + if group.title == rs.IDENTITY and self._advert_check.get_active(): + changes.append(("advert", "advert")) + dangerous = [k for _c, k in changes if k in rs.BY_KEY and rs.BY_KEY[k].dangerous] + if dangerous: + self._confirm( + "Change the radio parameters?", + "They take effect when the repeater reboots. If they don't match " + "this device's, you lose contact with the repeater until they are " + "fixed on site.", + "Change", + lambda: self._send_changes(group, changes), + ) + else: + self._send_changes(group, changes) + + def _send_changes(self, group: _Group, changes: list[tuple[str, str]]) -> None: + by_key = {f.setting.key: f for f in group.fields} + sent = {k: c.split(" ", 2)[2] for c, k in changes if c.startswith("set ")} + + def done(results: list[rs.ApplyResult]) -> None: + note = "" + for r in results: + field = by_key.get(r.key) + if field is None: # the advert + note = r.reply + elif not r.ok: + field.set_result(r.reply, error=True) + else: + field.set_result("Saved" if r.reply == "OK" else r.reply) + if field.setting.kind == "secret": + field.widget.set_text("") + else: + field.loaded = sent[r.key] + if len(results) < len(changes): + note = "The repeater stopped answering; the rest was not sent." + group.note.set_text(note) + self._note.set_text("") + + self._start( + "Applying...", + lambda peer: rs.apply_settings( + lambda c: self._service.send_repeater_command(peer, c), changes + ), + done, + ) + + def _use_my_position(self) -> None: + position = self._service.get_device_location() + identity = self._groups[0] + if position is None: + identity.note.set_text( + "This device has no position (no GPS fix, none set in Settings)." + ) + return + fields = {f.setting.key: f for f in identity.fields} + fields["lat"].widget.set_text(f"{position[0]:.6f}") + fields["lon"].widget.set_text(f"{position[1]:.6f}") + identity.note.set_text("Position filled in; Apply sends it.") + + def _change_admin_password(self) -> None: + pw1, pw2 = self._pw1.get_text(), self._pw2.get_text() + if pw1 != pw2: + self._pw_note.set_text("The two entries differ.") + return + try: + command = rs.admin_password_command(pw1) + except ValueError as exc: + self._pw_note.set_text(f"Password: {exc}") + return + self._confirm( + "Change the admin password?", + "Your current session stays logged in. Log in with the new password next time.", + "Change", + lambda: self._send_password(command), + ) + + def _send_password(self, command: str) -> None: + peer = self._peer_name + + def done(result: dict) -> None: + text = result.get("response_text") if result.get("success") else None + # firmware echoes the new password ("password now: ..."); don't show it + if text and text.startswith("password now"): + self._pw1.set_text("") + self._pw2.set_text("") + note = "Changed." + if peer and self._service.get_saved_repeater_password(peer): + self._service.delete_saved_repeater_password(peer) + note += " The saved (old) password was removed." + self._pw_note.set_text(note) + else: + self._pw_note.set_text( + "No confirmation from the repeater; check before logging out." + ) + self._note.set_text("") + + self._start( + "Changing the admin password...", + lambda p: self._service.send_repeater_command(p, command), + done, + ) + + def _confirm(self, heading: str, body: str, verb: str, then: Callable[[], None]) -> None: + dialog = build_alert_dialog(self.get_root(), heading, body) + dialog.add_response("cancel", "Cancel") + dialog.add_response("go", verb) + dialog.set_response_appearance("go", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_default_response("cancel") + dialog.set_close_response("cancel") + dialog.connect("response", lambda _d, response: then() if response == "go" else None) + present_dialog(dialog, self.get_root()) diff --git a/tests/unit/test_repeater_settings.py b/tests/unit/test_repeater_settings.py index 92e65a9..1e08c1d 100644 --- a/tests/unit/test_repeater_settings.py +++ b/tests/unit/test_repeater_settings.py @@ -133,3 +133,20 @@ def test_apply_reports_each_key_and_stops_after_no_reply() -> None: ("lon", False), ] assert "advert" not in sent + + +def test_only_changed_values_are_sent() -> None: + lat, radio = rs.BY_KEY["lat"], rs.BY_KEY["radio"] + assert rs.changed_value(lat, "51.339700", "51.3397") is None # same number + assert rs.changed_value(lat, "51.3397", "51.34") == "51.34" + assert rs.changed_value(radio, "869.618,62.500,8,8", "869.618,62.5,8,8") is None + assert rs.changed_value(lat, None, "51.0") is None # never read: never sent + assert rs.changed_value(rs.BY_KEY["loop.detect"], "aggressive", "") is None + with pytest.raises(ValueError): + rs.changed_value(lat, "51.3", "north") + + +def test_secrets_are_write_only() -> None: + guest = rs.BY_KEY["guest.password"] + assert rs.changed_value(guest, None, "") is None + assert rs.changed_value(guest, None, "hunter2") == "hunter2" diff --git a/tests/unit/test_repeater_tabs.py b/tests/unit/test_repeater_tabs.py new file mode 100644 index 0000000..c374a32 --- /dev/null +++ b/tests/unit/test_repeater_tabs.py @@ -0,0 +1,43 @@ +"""Remote repeater tabs: what they show from the repeater's replies.""" + +from __future__ import annotations + +from meshcore_console.ui_gtk.views.repeater_tabs import ( + format_duration, + sort_neighbours, + status_rows, +) + + +def test_durations_read_at_a_glance() -> None: + assert format_duration(42) == "42 s" + assert format_duration(610) == "10 m 10 s" + assert format_duration(5 * 3600 + 12 * 60) == "5 h 12 m" + assert format_duration(3 * 86400 + 5 * 3600 + 7) == "3 d 5 h" + assert format_duration(-5) == "0 s" # clock skew on the repeater + + +def test_status_rows_show_airtime_as_share_of_uptime() -> None: + rows = dict( + status_rows( + {"bat": 4012, "uptime": 10000, "airtime": 250, "rx_airtime": 1000, "noise_floor": -112} + ) + ) + assert rows["Battery"] == "4.01 V" + assert rows["TX airtime"] == "4 m 10 s (2.5 %)" + assert rows["RX airtime"] == "16 m 40 s (10.0 %)" + assert rows["Noise floor"] == "-112 dBm" + + +def test_status_rows_survive_a_fresh_boot() -> None: + assert dict(status_rows({"uptime": 0, "airtime": 0}))["TX airtime"] == "0 s" + + +def test_neighbour_sort_orders() -> None: + ns = [ + {"pubkey": "a", "secs_ago": 600, "snr": 9.0}, + {"pubkey": "b", "secs_ago": 30, "snr": -4.0}, + {"pubkey": "c", "secs_ago": 90, "snr": 2.5}, + ] + assert [n["pubkey"] for n in sort_neighbours(ns, "heard")] == ["b", "c", "a"] + assert [n["pubkey"] for n in sort_neighbours(ns, "signal")] == ["a", "c", "b"]