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/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/models.py b/src/meshcore_console/core/models.py index e77296b..33d3465 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 @@ -40,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/core/services.py b/src/meshcore_console/core/services.py index 929995d..28c3c64 100644 --- a/src/meshcore_console/core/services.py +++ b/src/meshcore_console/core/services.py @@ -55,10 +55,22 @@ 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.""" + ... + 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]: @@ -73,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.""" ... @@ -124,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/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/client.py b/src/meshcore_console/meshcore/client.py index e1891f2..aa3f403 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -1,26 +1,44 @@ from __future__ import annotations import asyncio +import hashlib +import concurrent.futures import logging +import secrets import threading from collections import deque from datetime import UTC, datetime 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 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 from meshcore_console.meshcore.channel_db import ( ChannelDatabase, + derive_channel_secret, normalize_channel_name, normalize_channel_secret, ) 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 @@ -28,7 +46,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__) @@ -36,6 +59,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) @@ -43,6 +86,76 @@ 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 +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 +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: + """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 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.""" + 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.""" + 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.""" @@ -60,8 +173,11 @@ def __init__( gps_provider: GpsProvider | None = None, ) -> 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 - 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 @@ -134,16 +250,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) @@ -153,6 +292,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." @@ -174,17 +319,20 @@ def connect(self) -> None: raise ConflictError(report) try: - 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 + # (a sys.exit() from openhop_core's GPIO code arrives as RuntimeError, + # 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 + failed.close() raise self._connected = True self._session.set_is_favorite_fn(self._is_peer_favorite) @@ -196,6 +344,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: @@ -204,8 +356,9 @@ def disconnect(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() @@ -239,31 +392,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: @@ -324,15 +493,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 @@ -358,25 +538,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 @@ -384,17 +550,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). - channel = self._channels.get(channel_id) - resolved_name = (channel.peer_name or channel.display_name) if channel else peer_id - self._run_async(self._session.send_text(peer_name=resolved_name, message=body)) + resolved_name = channel.peer_name or channel.display_name + 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, @@ -402,9 +575,9 @@ 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) + self._remember_message(message) self._append_event( { "type": EventType.MESSAGE_SENT, @@ -442,6 +615,145 @@ 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": [], + "direct_route": True, # it answered zero-hop: a direct neighbour + }, + } + ) + 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() + 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(): + if peer.public_key and peer.public_key.lower() == key: + return name + return None + + 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 _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 + row, favourite and history, stay the same.""" + peer = self._peers.pop(old) + 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 = new + if old in self._repeater_sessions: + 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 = 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: + self._repeater_password_store.save_password(new, password) + self._repeater_password_store.delete_password(old) + def poll_events(self, limit: int = 50) -> list[MeshEventDict]: events: list[MeshEventDict] = [] try: @@ -450,26 +762,29 @@ 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) 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. 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 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")) - 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]: @@ -528,8 +843,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"): @@ -550,8 +865,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)) @@ -595,10 +910,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, @@ -626,15 +943,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 - path_hops = data.get("path_hops", []) + # 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: + 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") + ) rssi_raw = data.get("rssi") snr_raw = data.get("snr") @@ -665,23 +1001,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( @@ -766,6 +1096,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, @@ -806,40 +1137,58 @@ 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 + 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 (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 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, - sender_id=sender_name, + sender_id=author, body=message_text, 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, ) - self._messages.append(message) - self._message_store.append(message) + self._remember_message(message) # Ensure channel exists if channel_name not in self._channels: @@ -902,7 +1251,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}} @@ -920,19 +1270,29 @@ 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_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: - 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.""" @@ -1052,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) @@ -1065,8 +1510,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, @@ -1092,21 +1541,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, @@ -1131,8 +1576,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/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/contact_book.py b/src/meshcore_console/meshcore/contact_book.py index 24ffbd8..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: @@ -65,6 +68,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/src/meshcore_console/meshcore/db.py b/src/meshcore_console/meshcore/db.py index 1e8a942..02f452a 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__) @@ -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:%'", + ), ] @@ -177,28 +185,39 @@ 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() 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/discovery.py b/src/meshcore_console/meshcore/discovery.py new file mode 100644 index 0000000..ec9fd3f --- /dev/null +++ b/src/meshcore_console/meshcore/discovery.py @@ -0,0 +1,170 @@ +"""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 +import time +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 + + +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)) + + +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/logging_setup.py b/src/meshcore_console/meshcore/logging_setup.py index 87d08bd..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 @@ -18,8 +19,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 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. +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 +36,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. @@ -61,13 +74,16 @@ 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 - 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, 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)) + file_handler.addFilter(PrivacyFilter()) root.addHandler(file_handler) _configured = True @@ -84,20 +100,22 @@ 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 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) @@ -217,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/operations.py b/src/meshcore_console/meshcore/operations.py index 61d32ba..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, @@ -24,6 +26,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) @@ -34,19 +44,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, - ) - 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( @@ -96,7 +127,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 +137,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 +149,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 +157,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 @@ -153,6 +186,89 @@ 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 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, @@ -166,8 +282,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 +293,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 +306,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"] @@ -205,16 +322,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} @@ -224,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 @@ -243,13 +355,16 @@ 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, local_identity=node.identity, 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) @@ -268,7 +383,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/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/repeater_settings.py b/src/meshcore_console/meshcore/repeater_settings.py new file mode 100644 index 0000000..e95d258 --- /dev/null +++ b/src/meshcore_console/meshcore/repeater_settings.py @@ -0,0 +1,336 @@ +"""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 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}" + + +# 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/meshcore/runtime.py b/src/meshcore_console/meshcore/runtime.py index 1d49e41..87fe432 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[ @@ -33,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, @@ -53,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, @@ -86,13 +96,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/src/meshcore_console/meshcore/session.py b/src/meshcore_console/meshcore/session.py index f1a7431..8726a4f 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 @@ -40,10 +41,19 @@ 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 ( + 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, + request_repeater_data, request_telemetry, send_advert, send_group_text, @@ -64,6 +74,9 @@ 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._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 @@ -105,8 +118,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 @@ -119,19 +134,21 @@ 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) - # 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, @@ -142,6 +159,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}") @@ -150,9 +168,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) @@ -188,32 +208,31 @@ 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 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 @@ -239,6 +258,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. @@ -261,12 +282,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, ) @@ -278,6 +301,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}" @@ -311,89 +341,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) - - # --- 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) + # 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) @@ -530,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()) @@ -617,6 +571,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.") @@ -657,6 +617,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, @@ -688,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.") @@ -731,13 +766,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/src/meshcore_console/meshcore/settings.py b/src/meshcore_console/meshcore/settings.py index cb3f355..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) @@ -25,7 +28,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 +70,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, }, } @@ -107,7 +121,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, @@ -120,7 +135,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/src/meshcore_console/meshcore/settings_store.py b/src/meshcore_console/meshcore/settings_store.py index ac410a3..0c8c5d7 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,9 +22,28 @@ 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 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/meshcore/state_store.py b/src/meshcore_console/meshcore/state_store.py index 0a78c79..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() @@ -133,6 +138,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/src/meshcore_console/mock/client.py b/src/meshcore_console/mock/client.py index d827763..1eb236f 100644 --- a/src/meshcore_console/mock/client.py +++ b/src/meshcore_console/mock/client.py @@ -2,12 +2,22 @@ from __future__ import annotations +import time +from collections import deque from datetime import UTC, datetime from typing import Callable 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 @@ -34,8 +44,32 @@ 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._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] = {} @@ -80,24 +114,41 @@ 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: + 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: @@ -155,26 +206,16 @@ 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, 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: @@ -207,6 +248,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: @@ -216,16 +270,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]: @@ -249,6 +300,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() @@ -368,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( { @@ -389,6 +446,57 @@ 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 { + "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) @@ -416,7 +524,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/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/src/meshcore_console/platform/gps.py b/src/meshcore_console/platform/gps.py index 914532b..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: @@ -190,7 +184,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 +196,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 @@ -439,10 +433,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 @@ -463,7 +461,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: @@ -496,6 +494,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/src/meshcore_console/radio_cli.py b/src/meshcore_console/radio_cli.py index e2d5292..ddd16b8 100644 --- a/src/meshcore_console/radio_cli.py +++ b/src/meshcore_console/radio_cli.py @@ -2,17 +2,18 @@ import argparse import asyncio +import getpass import json import os -import time +import sys 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", @@ -59,7 +60,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( @@ -70,6 +75,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. @@ -158,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() @@ -237,6 +268,32 @@ 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(): + 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 ( @@ -285,7 +342,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) @@ -295,12 +352,13 @@ 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": 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/src/meshcore_console/ui_gtk/compat.py b/src/meshcore_console/ui_gtk/compat.py index 4a50517..dfca25c 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, 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") @@ -26,6 +28,73 @@ 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 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``. @@ -70,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 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/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/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/admin.py b/src/meshcore_console/ui_gtk/views/admin.py index b98ebd5..c65e8b5 100644 --- a/src/meshcore_console/ui_gtk/views/admin.py +++ b/src/meshcore_console/ui_gtk/views/admin.py @@ -6,17 +6,27 @@ 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.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 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 +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: @@ -27,6 +37,18 @@ _MAX_HISTORY = 50 +# 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: + """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) @@ -36,8 +58,10 @@ 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 # -- Left: repeater list ----------------------------------------------- list_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) @@ -101,28 +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") @@ -196,7 +239,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) @@ -338,7 +384,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 @@ -353,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) @@ -404,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 @@ -434,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") @@ -464,7 +538,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) @@ -485,12 +561,19 @@ 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._jobs.busy: + self._append_console_error("Still waiting for the previous request; try again shortly.") + return self._jobs.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() - 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...") @@ -504,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 @@ -524,6 +607,7 @@ 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) @@ -536,10 +620,10 @@ 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 + self._jobs.busy = True self._logout_btn.set_sensitive(False) def work() -> None: @@ -550,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) @@ -577,8 +661,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: @@ -596,25 +703,30 @@ 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: - 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 @@ -626,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: @@ -638,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 @@ -701,18 +813,29 @@ def _do_scroll() -> bool: 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", + 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 ): - needs_refresh = True - if needs_refresh: + 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). + state = repeater_list_state( + self._service.list_peers(), self._service.list_logged_in_repeaters() + ) + 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 --------------------------------- diff --git a/src/meshcore_console/ui_gtk/views/analyzer.py b/src/meshcore_console/ui_gtk/views/analyzer.py index a6ec2ce..1ffcf82 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 @@ -75,6 +85,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 +102,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 +110,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() @@ -198,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() @@ -211,9 +229,11 @@ def _on_filter_clicked(self, _button: Gtk.ToggleButton, filter_type: AnalyzerFil self._refresh_all() def _poll_events(self) -> bool: - if self._paused: - return True - self._cursor, events = self._event_store.since(self._cursor, limit=200) + # 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. + # 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: @@ -234,7 +254,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") @@ -400,8 +423,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 "") @@ -642,7 +667,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) @@ -748,7 +773,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/src/meshcore_console/ui_gtk/views/map.py b/src/meshcore_console/ui_gtk/views/map.py index 53e42dd..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 @@ -35,6 +36,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 @@ -42,6 +44,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.""" @@ -54,10 +67,13 @@ 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 + 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() @@ -116,23 +132,39 @@ 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) - - # 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) + 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() + + # 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) @@ -140,6 +172,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) @@ -250,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)) @@ -258,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)) @@ -269,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) @@ -283,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 @@ -317,13 +381,23 @@ 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.""" - 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: + 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()) + if state != self._marker_state: + self._marker_state = state self._refresh_markers() def _poll_gps(self) -> bool: @@ -348,7 +422,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,14 +430,14 @@ 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) 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 +448,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] @@ -475,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 @@ -630,4 +717,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 cdd98fc..89ae948 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,14 +35,39 @@ 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. + + 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._sending = False self._last_message_date: str | None = None self._selected_message: Message | None = None @@ -92,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() @@ -200,12 +241,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 +259,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 @@ -272,7 +314,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") @@ -348,7 +390,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 @@ -600,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: @@ -637,10 +666,37 @@ 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) 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( @@ -651,11 +707,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) @@ -833,17 +891,44 @@ 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).""" + 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).""" diff --git a/src/meshcore_console/ui_gtk/views/peers.py b/src/meshcore_console/ui_gtk/views/peers.py index b6cb2c8..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 @@ -326,8 +379,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" @@ -394,7 +451,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/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/src/meshcore_console/ui_gtk/views/settings.py b/src/meshcore_console/ui_gtk/views/settings.py index eef09de..f9c1124 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__) @@ -36,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.""" @@ -166,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 @@ -186,7 +216,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) @@ -450,28 +481,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}") @@ -606,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) @@ -657,10 +680,11 @@ 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.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() @@ -715,6 +739,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/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 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: 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/src/meshcore_console/ui_gtk/windows/main_window.py b/src/meshcore_console/ui_gtk/windows/main_window.py index 426b7aa..f0a1786 100644 --- a/src/meshcore_console/ui_gtk/windows/main_window.py +++ b/src/meshcore_console/ui_gtk/windows/main_window.py @@ -19,8 +19,14 @@ 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.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 @@ -83,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") @@ -197,6 +207,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) @@ -214,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 @@ -321,6 +342,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(new_toast("Discover is still listening")) + return + self._discovering = True + self._toast_overlay.add_toast(new_toast("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(new_toast(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 @@ -333,10 +381,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) @@ -540,11 +588,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 @@ -619,11 +667,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 @@ -736,7 +784,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/conftest.py b/tests/conftest.py index 223aa83..8eb7fbf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,3 +17,21 @@ 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 lives + in the same directory, so without this a test run writes into the + developer's real ~/.local/state/meshcore-uconsole. + """ + 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)) 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_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 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" 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_cli_config.py b/tests/unit/test_cli_config.py new file mode 100644 index 0000000..d0e98a6 --- /dev/null +++ b/tests/unit/test_cli_config.py @@ -0,0 +1,84 @@ +"""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" + + +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 + + +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 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" diff --git a/tests/unit/test_client_async.py b/tests/unit/test_client_async.py new file mode 100644 index 0000000..8b9c6c3 --- /dev/null +++ b/tests/unit/test_client_async.py @@ -0,0 +1,138 @@ +"""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() + + +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() + + +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 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_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() diff --git a/tests/unit/test_discovery.py b/tests/unit/test_discovery.py new file mode 100644 index 0000000..c82e865 --- /dev/null +++ b/tests/unit/test_discovery.py @@ -0,0 +1,289 @@ +"""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": [], + }, + } + + +_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.MESH_NODE_DISCOVERED, + "data": { + "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, + }, + } + + +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_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")) + + (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: + 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_dm_namespace.py b/tests/unit/test_dm_namespace.py new file mode 100644 index 0000000..fc3ad43 --- /dev/null +++ b/tests/unit/test_dm_namespace.py @@ -0,0 +1,229 @@ +"""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, + ) + 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]] = [] + + 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() + + +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" + + +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"] + + +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"]] + + +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?"), + ] + + +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} diff --git a/tests/unit/test_event_bursts.py b/tests/unit/test_event_bursts.py new file mode 100644 index 0000000..9d6b584 --- /dev/null +++ b/tests/unit/test_event_bursts.py @@ -0,0 +1,76 @@ +"""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 + + +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] + + +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"] + + +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"] 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: 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() diff --git a/tests/unit/test_isolation.py b/tests/unit/test_isolation.py new file mode 100644 index 0000000..38f7030 --- /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 diff --git a/tests/unit/test_log_privacy.py b/tests/unit/test_log_privacy.py new file mode 100644 index 0000000..f319ff6 --- /dev/null +++ b/tests/unit/test_log_privacy.py @@ -0,0 +1,74 @@ +"""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 + + +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 + + +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 == [] 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 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) 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 diff --git a/tests/unit/test_private_channels.py b/tests/unit/test_private_channels.py index 756acb1..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 # --------------------------------------------------------------------------- @@ -203,23 +208,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" 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 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" diff --git a/tests/unit/test_radio_presets.py b/tests/unit/test_radio_presets.py new file mode 100644 index 0000000..d8b2860 --- /dev/null +++ b/tests/unit/test_radio_presets.py @@ -0,0 +1,39 @@ +"""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 + + +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 diff --git a/tests/unit/test_repeater_ops.py b/tests/unit/test_repeater_ops.py new file mode 100644 index 0000000..d03ce42 --- /dev/null +++ b/tests/unit/test_repeater_ops.py @@ -0,0 +1,294 @@ +"""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 +import struct + +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.discovery import decode_owner_reply +from meshcore_console.meshcore.operations import ( + request_owner_info, + request_telemetry, + send_login, + send_logout, + 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 == {} + + +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 == [] + + +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 + + +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() + + +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]} 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( + " 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 + + +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"] diff --git a/tests/unit/test_request_handlers.py b/tests/unit/test_request_handlers.py new file mode 100644 index 0000000..15f253e --- /dev/null +++ b/tests/unit/test_request_handlers.py @@ -0,0 +1,155 @@ +"""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 + + +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 + + +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 + + +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 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) diff --git a/tests/unit/test_settings_store.py b/tests/unit/test_settings_store.py index bd13008..272430e 100644 --- a/tests/unit/test_settings_store.py +++ b/tests/unit/test_settings_store.py @@ -55,3 +55,30 @@ 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" + + +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 diff --git a/tests/unit/test_telemetry_privacy.py b/tests/unit/test_telemetry_privacy.py new file mode 100644 index 0000000..fe8cc98 --- /dev/null +++ b/tests/unit/test_telemetry_privacy.py @@ -0,0 +1,71 @@ +"""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) + + +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} 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" diff --git a/tests/unit/test_verified_adverts.py b/tests/unit/test_verified_adverts.py new file mode 100644 index 0000000..a1de291 --- /dev/null +++ b/tests/unit/test_verified_adverts.py @@ -0,0 +1,145 @@ +"""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) == [] + + +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 + + +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 + + +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 + + +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