From 5bfd103e97822969029aa8ba0b9aeb72f1640e88 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Thu, 5 Feb 2026 18:10:53 +0100 Subject: [PATCH] Refactoring --- meshcore-gui.zip | Bin 88553 -> 0 bytes meshcore-gui/meshcore_gui/CHANGELOG_v4.0.md | 108 --- meshcore-gui/meshcore_gui/ble_worker.py | 652 ------------------ meshcore-gui/meshcore_gui/config.py | 81 --- meshcore-gui/meshcore_gui/main_page.py | 440 ------------ meshcore-gui/meshcore_gui/route_pagex.py | 306 -------- .../meshcore_gui.py => meshcore_gui.py | 12 +- meshcore_gui.zip | Bin 0 -> 60134 bytes .../meshcore_gui => meshcore_gui}/__init__.py | 2 +- .../__main__.py | 15 +- meshcore_gui/ble/__init__.py | 3 + meshcore_gui/ble/commands.py | 113 +++ meshcore_gui/ble/events.py | 216 ++++++ .../ble/packet_decoder.py | 0 meshcore_gui/ble/worker.py | 199 ++++++ meshcore_gui/config.py | 43 ++ meshcore_gui/core/__init__.py | 16 + meshcore_gui/core/models.py | 174 +++++ .../core}/protocols.py | 29 +- .../core}/shared_data.py | 177 ++--- meshcore_gui/gui/__init__.py | 3 + meshcore_gui/gui/constants.py | 11 + meshcore_gui/gui/dashboard.py | 165 +++++ meshcore_gui/gui/panels/__init__.py | 16 + meshcore_gui/gui/panels/actions_panel.py | 29 + meshcore_gui/gui/panels/contacts_panel.py | 87 +++ meshcore_gui/gui/panels/device_panel.py | 40 ++ meshcore_gui/gui/panels/filter_panel.py | 61 ++ meshcore_gui/gui/panels/input_panel.py | 59 ++ meshcore_gui/gui/panels/map_panel.py | 49 ++ meshcore_gui/gui/panels/messages_panel.py | 89 +++ meshcore_gui/gui/panels/rxlog_panel.py | 41 ++ .../gui}/route_page.py | 179 ++--- meshcore_gui/services/__init__.py | 3 + meshcore_gui/services/bot.py | 195 ++++++ meshcore_gui/services/dedup.py | 108 +++ .../services}/route_builder.py | 171 ++--- 37 files changed, 1940 insertions(+), 1952 deletions(-) delete mode 100644 meshcore-gui.zip delete mode 100644 meshcore-gui/meshcore_gui/CHANGELOG_v4.0.md delete mode 100644 meshcore-gui/meshcore_gui/ble_worker.py delete mode 100644 meshcore-gui/meshcore_gui/config.py delete mode 100644 meshcore-gui/meshcore_gui/main_page.py delete mode 100644 meshcore-gui/meshcore_gui/route_pagex.py rename meshcore-gui/meshcore_gui.py => meshcore_gui.py (90%) create mode 100644 meshcore_gui.zip rename {meshcore-gui/meshcore_gui => meshcore_gui}/__init__.py (88%) rename meshcore-gui/meshcore_gui/meshcore_gui.py => meshcore_gui/__main__.py (88%) create mode 100644 meshcore_gui/ble/__init__.py create mode 100644 meshcore_gui/ble/commands.py create mode 100644 meshcore_gui/ble/events.py rename meshcore-gui/meshcore_gui/packet_parser.py => meshcore_gui/ble/packet_decoder.py (100%) create mode 100644 meshcore_gui/ble/worker.py create mode 100644 meshcore_gui/config.py create mode 100644 meshcore_gui/core/__init__.py create mode 100644 meshcore_gui/core/models.py rename {meshcore-gui/meshcore_gui => meshcore_gui/core}/protocols.py (79%) rename {meshcore-gui/meshcore_gui => meshcore_gui/core}/shared_data.py (60%) create mode 100644 meshcore_gui/gui/__init__.py create mode 100644 meshcore_gui/gui/constants.py create mode 100644 meshcore_gui/gui/dashboard.py create mode 100644 meshcore_gui/gui/panels/__init__.py create mode 100644 meshcore_gui/gui/panels/actions_panel.py create mode 100644 meshcore_gui/gui/panels/contacts_panel.py create mode 100644 meshcore_gui/gui/panels/device_panel.py create mode 100644 meshcore_gui/gui/panels/filter_panel.py create mode 100644 meshcore_gui/gui/panels/input_panel.py create mode 100644 meshcore_gui/gui/panels/map_panel.py create mode 100644 meshcore_gui/gui/panels/messages_panel.py create mode 100644 meshcore_gui/gui/panels/rxlog_panel.py rename {meshcore-gui/meshcore_gui => meshcore_gui/gui}/route_page.py (62%) create mode 100644 meshcore_gui/services/__init__.py create mode 100644 meshcore_gui/services/bot.py create mode 100644 meshcore_gui/services/dedup.py rename {meshcore-gui/meshcore_gui => meshcore_gui/services}/route_builder.py (50%) diff --git a/meshcore-gui.zip b/meshcore-gui.zip deleted file mode 100644 index c8716f9d4e9cc574a0a8d741f05a93952ff00251..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 88553 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 0 and resolved_hops > 2 * msg_path_len: - resolved_hops = 0 - route['path_nodes'] = [] -``` - -## Andere wijzigingen - -- **Temporal matching i.p.v. SNR** — Alle RX_LOG correlatie gebruikt nu - tijdproximiteit (binnen 3 seconden) in plaats van SNR-vergelijking. - SNR wordt nog steeds weergegeven in de UI maar niet meer gebruikt voor matching. - -- **`PATH_LEN_SANITY_MARGIN`** als configureerbare constante in `config.py` - -## Bestanden gewijzigd - -| Bestand | Wijziging | -|---------|-----------| -| `meshcore_gui.py` | Versie → 4.0 | -| `meshcore_gui/__init__.py` | `__version__` → "4.0" | -| `meshcore_gui/config.py` | + `PATH_LEN_SANITY_MARGIN = 5` | -| `meshcore_gui/ble_worker.py` | Startup clear, temporal matching, path_len checks | -| `meshcore_gui/shared_data.py` | `clear_rx_archive()`, `find_rx_path()` met path_len check | -| `meshcore_gui/route_page.py` | Display-time sanity guard | -| `meshcore_gui/route_builder.py` | Geeft `msg_path_len` door aan archive lookup | -| `meshcore_gui/protocols.py` | + `clear_rx_archive()` in Writer protocol | - -## Installatie - -Vervang je huidige bestanden: - -```bash -# Backup -cp -r meshcore_gui meshcore_gui.bak -cp meshcore_gui.py meshcore_gui.py.bak - -# Vervang -cp -r meshcore-gui-v4.0/meshcore_gui ./ -cp meshcore-gui-v4.0/meshcore_gui.py ./ -``` - -## Verwacht gedrag na update - -Met `--debug-on` zie je nu bij opstarten: - -``` -DEBUG: Startup buffer+archive cleared — only post-init packets will be matched -BLE: Ready! -``` - -En bij een 7-hop bericht met correcte match: -``` -DEBUG: Forward match: dt=0.42s, hashes=7 -``` - -In plaats van het oude gedrag: -``` -DEBUG: No RX_LOG match: msg_snr=13.5, buffer_snrs=[14.0, 14.0, ...] -DEBUG: RX archive match: snr=14.0, hashes=[...22 total], time_diff=0.81s -``` diff --git a/meshcore-gui/meshcore_gui/ble_worker.py b/meshcore-gui/meshcore_gui/ble_worker.py deleted file mode 100644 index c00775f..0000000 --- a/meshcore-gui/meshcore_gui/ble_worker.py +++ /dev/null @@ -1,652 +0,0 @@ -""" -BLE communication worker for MeshCore GUI. - -Runs in a separate thread with its own asyncio event loop. Connects to -the MeshCore device, subscribes to events, and processes commands sent -from the GUI via the SharedData command queue. - -Single-source architecture -~~~~~~~~~~~~~~~~~~~~~~~~~~ -When a LoRa packet arrives the companion firmware pushes two events: - -1. ``RX_LOG_DATA`` — the *raw* LoRa frame with header, path hashes - and encrypted payload. -2. ``CHANNEL_MSG_RECV`` — the *decrypted* message text but **no** path - hashes (only the hop count ``path_len``). - -This module uses ``meshcoredecoder`` to fully decode the raw packet -from (1): message_hash, path_hashes, sender name, message text and -channel index are all extracted from that **single frame**. - -The ``CHANNEL_MSG_RECV`` event (2) serves only as a fallback for -packets that could not be decrypted from the raw frame (e.g. missing -channel key). - -Deduplication is done via ``message_hash``: if the same hash has -already been processed from RX_LOG_DATA, the CHANNEL_MSG_RECV event -is silently dropped. - -There is **no temporal correlation**, no ring buffer, no archive, and -no sanity-margin heuristics. -""" - -import asyncio -import threading -import time -from datetime import datetime -from typing import Dict, List, Optional, Set - -from meshcore import MeshCore, EventType - -from meshcore_gui.config import ( - BOT_CHANNEL, BOT_COOLDOWN_SECONDS, BOT_KEYWORDS, BOT_NAME, - CHANNELS_CONFIG, debug_print, -) -from meshcore_gui.packet_parser import PacketDecoder, PayloadType -from meshcore_gui.protocols import SharedDataWriter - - -# Maximum number of message_hashes kept for deduplication. -# Oldest entries are evicted first. 200 is generous for the -# typical message rate of a mesh network. -_SEEN_HASHES_MAX = 200 - - -class BLEWorker: - """ - BLE communication worker that runs in a separate thread. - - Attributes: - address: BLE MAC address of the device - shared: SharedDataWriter for thread-safe communication - mc: MeshCore instance after connection - running: Boolean to control the worker loop - """ - - def __init__(self, address: str, shared: SharedDataWriter) -> None: - self.address = address - self.shared = shared - self.mc: Optional[MeshCore] = None - self.running = True - - # Packet decoder (channel keys loaded at startup) - self._decoder = PacketDecoder() - - # BOT: timestamp of last reply (cooldown enforcement) - self._bot_last_reply: float = 0.0 - - # Deduplication: message_hash values already processed via - # RX_LOG_DATA decode. When CHANNEL_MSG_RECV arrives for the - # same packet, it is silently dropped. - # - # Two dedup strategies: - # 1. message_hash (from decoded packet) - # 2. content key (sender:channel:text) — because CHANNEL_MSG_RECV - # does NOT include message_hash in its payload - self._seen_hashes: Set[str] = set() - self._seen_hashes_order: List[str] = [] - self._seen_content: Set[str] = set() - self._seen_content_order: List[str] = [] - - # ------------------------------------------------------------------ - # Thread lifecycle - # ------------------------------------------------------------------ - - def start(self) -> None: - """Start the worker in a new daemon thread.""" - thread = threading.Thread(target=self._run, daemon=True) - thread.start() - debug_print("BLE worker thread started") - - def _run(self) -> None: - """Entry point for the worker thread.""" - asyncio.run(self._async_main()) - - async def _async_main(self) -> None: - """Connect, then process commands in an infinite loop.""" - await self._connect() - if self.mc: - while self.running: - await self._process_commands() - await asyncio.sleep(0.1) - - # ------------------------------------------------------------------ - # Connection - # ------------------------------------------------------------------ - - async def _connect(self) -> None: - """Connect to the BLE device and load initial data.""" - self.shared.set_status(f"🔄 Connecting to {self.address}...") - - try: - print(f"BLE: Connecting to {self.address}...") - self.mc = await MeshCore.create_ble(self.address) - print("BLE: Connected!") - - await asyncio.sleep(1) - - # Subscribe to events - self.mc.subscribe(EventType.CHANNEL_MSG_RECV, self._on_channel_msg) - self.mc.subscribe(EventType.CONTACT_MSG_RECV, self._on_contact_msg) - self.mc.subscribe(EventType.RX_LOG_DATA, self._on_rx_log) - - await self._load_data() - await self._load_channel_keys() - await self.mc.start_auto_message_fetching() - - self.shared.set_connected(True) - self.shared.set_status("✅ Connected") - print("BLE: Ready!") - - except Exception as e: - print(f"BLE: Connection error: {e}") - self.shared.set_status(f"❌ {e}") - - async def _load_data(self) -> None: - """ - Load device data with retry mechanism. - - Tries send_appstart and send_device_query each up to 5 times. - Channels come from hardcoded config. - """ - # send_appstart - self.shared.set_status("🔄 Device info...") - for i in range(5): - debug_print(f"send_appstart attempt {i + 1}") - r = await self.mc.commands.send_appstart() - if r.type != EventType.ERROR: - print(f"BLE: send_appstart OK: {r.payload.get('name')}") - self.shared.update_from_appstart(r.payload) - break - await asyncio.sleep(0.3) - - # send_device_query - for i in range(5): - debug_print(f"send_device_query attempt {i + 1}") - r = await self.mc.commands.send_device_query() - if r.type != EventType.ERROR: - print(f"BLE: send_device_query OK: {r.payload.get('ver')}") - self.shared.update_from_device_query(r.payload) - break - await asyncio.sleep(0.3) - - # Channels (hardcoded — BLE get_channel is unreliable) - self.shared.set_status("🔄 Channels...") - self.shared.set_channels(CHANNELS_CONFIG) - print(f"BLE: Channels loaded: {[c['name'] for c in CHANNELS_CONFIG]}") - - # Contacts - self.shared.set_status("🔄 Contacts...") - r = await self.mc.commands.get_contacts() - if r.type != EventType.ERROR: - self.shared.set_contacts(r.payload) - print(f"BLE: Contacts loaded: {len(r.payload)} contacts") - - async def _load_channel_keys(self) -> None: - """ - Load channel decryption keys for packet decoding. - - Strategy per channel: - - 1. Try ``get_channel(idx)`` from the device (returns the - authoritative 16-byte ``channel_secret``). - 2. If that fails, derive the key from the channel name via - ``SHA-256(name)[:16]``. This is correct for channels whose - name starts with ``#`` (like ``#test``). For other channels - the derived key may be wrong, but decryption will simply fail - gracefully. - """ - self.shared.set_status("🔄 Channel keys...") - - for ch in CHANNELS_CONFIG: - idx = ch['idx'] - name = ch['name'] - loaded = False - - # Strategy 1: get_channel from device (3 retries) - for attempt in range(3): - try: - r = await self.mc.commands.get_channel(idx) - if r.type != EventType.ERROR: - secret = r.payload.get('channel_secret') - if secret and isinstance(secret, bytes) and len(secret) >= 16: - self._decoder.add_channel_key(idx, secret[:16]) - print( - f"BLE: Channel key [{idx}] '{name}' " - f"loaded from device" - ) - loaded = True - break - except Exception as exc: - debug_print( - f"get_channel({idx}) attempt {attempt + 1} " - f"error: {exc}" - ) - await asyncio.sleep(0.3) - - # Strategy 2: derive from name - if not loaded: - self._decoder.add_channel_key_from_name(idx, name) - print( - f"BLE: Channel key [{idx}] '{name}' " - f"derived from name (fallback)" - ) - - print( - f"BLE: PacketDecoder ready — " - f"has_keys={self._decoder.has_keys}" - ) - - # ------------------------------------------------------------------ - # Deduplication - # ------------------------------------------------------------------ - - def _mark_seen(self, message_hash: str) -> None: - """Record a message_hash as processed. Evicts old entries.""" - if message_hash in self._seen_hashes: - return - self._seen_hashes.add(message_hash) - self._seen_hashes_order.append(message_hash) - while len(self._seen_hashes_order) > _SEEN_HASHES_MAX: - oldest = self._seen_hashes_order.pop(0) - self._seen_hashes.discard(oldest) - - def _mark_content_seen(self, sender: str, channel, text: str) -> None: - """Record a content key as processed. Evicts old entries.""" - key = f"{channel}:{sender}:{text}" - if key in self._seen_content: - return - self._seen_content.add(key) - self._seen_content_order.append(key) - while len(self._seen_content_order) > _SEEN_HASHES_MAX: - oldest = self._seen_content_order.pop(0) - self._seen_content.discard(oldest) - - def _is_seen(self, message_hash: str) -> bool: - """Check if a message_hash has already been processed.""" - return message_hash in self._seen_hashes - - def _is_content_seen(self, sender: str, channel, text: str) -> bool: - """Check if a content key has already been processed.""" - key = f"{channel}:{sender}:{text}" - return key in self._seen_content - - # ------------------------------------------------------------------ - # Command handling - # ------------------------------------------------------------------ - - async def _process_commands(self) -> None: - """Process all commands queued by the GUI.""" - while True: - cmd = self.shared.get_next_command() - if cmd is None: - break - await self._handle_command(cmd) - - async def _handle_command(self, cmd: Dict) -> None: - """ - Process a single command from the GUI. - - Supported actions: send_message, send_dm, send_advert, refresh. - """ - action = cmd.get('action') - - if action == 'send_message': - channel = cmd.get('channel', 0) - text = cmd.get('text', '') - is_bot = cmd.get('_bot', False) - if text and self.mc: - await self.mc.commands.send_chan_msg(channel, text) - # Bot replies appear via the radio echo (RX_LOG), - # so only add manual messages to the message list. - if not is_bot: - self.shared.add_message({ - 'time': datetime.now().strftime('%H:%M:%S'), - 'sender': 'Me', - 'text': text, - 'channel': channel, - 'direction': 'out', - 'sender_pubkey': '', - 'path_hashes': [], - }) - debug_print( - f"{'BOT' if is_bot else 'Sent'} message to " - f"channel {channel}: {text[:30]}" - ) - - elif action == 'send_advert': - if self.mc: - await self.mc.commands.send_advert(flood=True) - self.shared.set_status("📢 Advert sent") - debug_print("Advert sent") - - elif action == 'send_dm': - pubkey = cmd.get('pubkey', '') - text = cmd.get('text', '') - contact_name = cmd.get('contact_name', pubkey[:8]) - if text and pubkey and self.mc: - await self.mc.commands.send_msg(pubkey, text) - self.shared.add_message({ - 'time': datetime.now().strftime('%H:%M:%S'), - 'sender': 'Me', - 'text': text, - 'channel': None, - 'direction': 'out', - 'sender_pubkey': pubkey, - 'path_hashes': [], - }) - debug_print(f"Sent DM to {contact_name}: {text[:30]}") - - elif action == 'refresh': - if self.mc: - debug_print("Refresh requested") - await self._load_data() - - # ------------------------------------------------------------------ - # BOT — keyword-triggered auto-reply - # ------------------------------------------------------------------ - - def _bot_check_and_queue( - self, - sender: str, - text: str, - channel_idx, - snr, - path_len: int, - path_hashes: Optional[List[str]] = None, - ) -> None: - """Queue a BOT reply if all guards pass. - - Guards: - 1. BOT is enabled (checkbox in GUI) - 2. Message is on the configured BOT_CHANNEL - 3. Sender is not the BOT itself (prevent self-reply) - 4. Sender name does not end with 'Bot' (prevent bot-to-bot loops) - 5. Cooldown period has elapsed - 6. Message text contains a recognised keyword - """ - # Guard 1: BOT enabled? - if not self.shared.is_bot_enabled(): - return - - # Guard 2: correct channel? - if channel_idx != BOT_CHANNEL: - return - - # Guard 3: skip own messages (use BOT_NAME as identifier, not device name) - if sender == 'Me' or (text and text.startswith(BOT_NAME)): - return - - # Guard 4: skip other bots (name ends with "Bot") - if sender and sender.rstrip().lower().endswith('bot'): - debug_print(f"BOT: skipping message from other bot '{sender}'") - return - - # Guard 5: cooldown - now = time.time() - if now - self._bot_last_reply < BOT_COOLDOWN_SECONDS: - debug_print("BOT: cooldown active, skipping") - return - - # Guard 6: keyword match (case-insensitive, first match wins) - text_lower = (text or '').lower() - matched_template = None - for keyword, template in BOT_KEYWORDS.items(): - if keyword in text_lower: - matched_template = template - break - - if matched_template is None: - return - - # Build path string: "path(N); A>B" or "path(0)" - path_str = self._format_path(path_len, path_hashes) - - # Build reply - snr_str = f"{snr:.1f}" if snr is not None else "?" - reply = matched_template.format( - bot=BOT_NAME, - sender=sender or "?", - snr=snr_str, - path=path_str, - ) - - # Update cooldown timestamp - self._bot_last_reply = now - - # Queue as internal command — picked up by _process_commands - self.shared.put_command({ - 'action': 'send_message', - 'channel': BOT_CHANNEL, - 'text': reply, - '_bot': True, - }) - - debug_print(f"BOT: queued reply to '{sender}': {reply}") - - def _format_path( - self, path_len: int, path_hashes: Optional[List[str]], - ) -> str: - """Format path info as ``path(N); 8D>A8`` or ``path(0)``. - - Shows raw 1-byte hashes in uppercase hex. - """ - if not path_len: - return "path(0)" - - if not path_hashes: - return f"path({path_len})" - - hop_names = [h.upper() for h in path_hashes if h and len(h) >= 2] - - if hop_names: - return f"path({path_len}); {'>'.join(hop_names)}" - return f"path({path_len})" - - # ------------------------------------------------------------------ - # Event callbacks - # ------------------------------------------------------------------ - - def _on_rx_log(self, event) -> None: - """Callback for RX log data — the single source of truth. - - Decodes the raw LoRa frame via ``meshcoredecoder``. For - GroupText packets this yields message_hash, path_hashes, - sender, text and channel_idx — all from **one** frame. - - The decoded message is added to SharedData directly. The - message_hash is recorded so that the duplicate - ``CHANNEL_MSG_RECV`` event is suppressed. - """ - payload = event.payload - - # Always add to the RX log display - self.shared.add_rx_log({ - 'time': datetime.now().strftime('%H:%M:%S'), - 'snr': payload.get('snr', 0), - 'rssi': payload.get('rssi', 0), - 'payload_type': payload.get('payload_type', '?'), - 'hops': payload.get('path_len', 0), - }) - - # Decode the raw packet - payload_hex = payload.get('payload', '') - if not payload_hex: - return - - decoded = self._decoder.decode(payload_hex) - if decoded is None: - return - - # Only process decrypted GroupText packets as messages - if (decoded.payload_type == PayloadType.GroupText - and decoded.is_decrypted): - # Mark as seen so CHANNEL_MSG_RECV is suppressed - self._mark_seen(decoded.message_hash) - self._mark_content_seen( - decoded.sender, decoded.channel_idx, decoded.text, - ) - - # Look up sender pubkey from contact name - sender_pubkey = '' - if decoded.sender: - match = self.shared.get_contact_by_name(decoded.sender) - if match: - sender_pubkey, _contact = match - - # Extract SNR from the RX_LOG event - snr = payload.get('snr') - if snr is not None: - try: - snr = float(snr) - except (ValueError, TypeError): - snr = None - - self.shared.add_message({ - 'time': datetime.now().strftime('%H:%M:%S'), - 'sender': decoded.sender, - 'text': decoded.text, - 'channel': decoded.channel_idx, - 'direction': 'in', - 'snr': snr, - 'path_len': decoded.path_length, - 'sender_pubkey': sender_pubkey, - 'path_hashes': decoded.path_hashes, - 'message_hash': decoded.message_hash, - }) - - debug_print( - f"RX_LOG → message: hash={decoded.message_hash}, " - f"sender={decoded.sender!r}, " - f"ch={decoded.channel_idx}, " - f"path={decoded.path_hashes}" - ) - - # BOT: check for keyword and queue reply - self._bot_check_and_queue( - sender=decoded.sender, - text=decoded.text, - channel_idx=decoded.channel_idx, - snr=snr, - path_len=decoded.path_length, - path_hashes=decoded.path_hashes, - ) - - def _on_channel_msg(self, event) -> None: - """Callback for channel messages — fallback only. - - If the same packet was already decoded from ``RX_LOG_DATA`` - (checked via ``message_hash``), this event is suppressed. - - Otherwise — e.g. when the channel key is missing or decryption - failed — this adds the message without path data. - """ - payload = event.payload - - debug_print(f"Channel msg payload keys: {list(payload.keys())}") - debug_print(f"Channel msg payload: {payload}") - - # --- Check for duplicate via message_hash --- - msg_hash = payload.get('message_hash', '') - if msg_hash and self._is_seen(msg_hash): - debug_print( - f"Channel msg suppressed (hash match): " - f"hash={msg_hash}" - ) - return - - # --- Extract sender name from text field --- - # Channel text format: "SenderName: message body" - raw_text = payload.get('text', '') - sender = '' - msg_text = raw_text - - if ': ' in raw_text: - name_part, body_part = raw_text.split(': ', 1) - sender = name_part.strip() - msg_text = body_part - elif raw_text: - msg_text = raw_text - - # --- Check for duplicate via content --- - ch_idx = payload.get('channel_idx') - if self._is_content_seen(sender, ch_idx, msg_text): - debug_print( - f"Channel msg suppressed (content match): " - f"sender={sender!r}, ch={ch_idx}, text={msg_text[:30]!r}" - ) - return - - debug_print( - f"Channel msg (fallback): sender={sender!r}, " - f"text={msg_text[:40]!r}" - ) - - # --- Look up sender contact by name to obtain pubkey --- - sender_pubkey = '' - if sender: - match = self.shared.get_contact_by_name(sender) - if match: - sender_pubkey, _contact = match - - # Extract SNR - msg_snr = payload.get('SNR') or payload.get('snr') - if msg_snr is not None: - try: - msg_snr = float(msg_snr) - except (ValueError, TypeError): - msg_snr = None - - self.shared.add_message({ - 'time': datetime.now().strftime('%H:%M:%S'), - 'sender': sender, - 'text': msg_text, - 'channel': payload.get('channel_idx'), - 'direction': 'in', - 'snr': msg_snr, - 'path_len': payload.get('path_len', 0), - 'sender_pubkey': sender_pubkey, - 'path_hashes': [], # No path data from companion event - 'message_hash': msg_hash, - }) - - # BOT: check for keyword and queue reply (fallback path) - self._bot_check_and_queue( - sender=sender, - text=msg_text, - channel_idx=payload.get('channel_idx'), - snr=msg_snr, - path_len=payload.get('path_len', 0), - ) - - def _on_contact_msg(self, event) -> None: - """Callback for received DMs; resolves sender name via pubkey.""" - payload = event.payload - pubkey = payload.get('pubkey_prefix', '') - sender = '' - - debug_print(f"DM payload keys: {list(payload.keys())}") - debug_print(f"DM payload: {payload}") - - if pubkey: - sender = self.shared.get_contact_name_by_prefix(pubkey) - - if not sender: - sender = pubkey[:8] if pubkey else '' - - self.shared.add_message({ - 'time': datetime.now().strftime('%H:%M:%S'), - 'sender': sender, - 'text': payload.get('text', ''), - 'channel': None, - 'direction': 'in', - 'snr': payload.get('SNR') or payload.get('snr'), - 'path_len': payload.get('path_len', 0), - 'sender_pubkey': pubkey, - 'path_hashes': [], # DMs use out_path from contact record - }) - - debug_print( - f"DM received from {sender}: " - f"{payload.get('text', '')[:30]}" - ) diff --git a/meshcore-gui/meshcore_gui/config.py b/meshcore-gui/meshcore_gui/config.py deleted file mode 100644 index cb045f7..0000000 --- a/meshcore-gui/meshcore_gui/config.py +++ /dev/null @@ -1,81 +0,0 @@ -""" -Configuration and shared constants for MeshCore GUI. - -Contains: - - Debug flag and debug_print helper - - Channel configuration - - Contact type mappings - -The DEBUG flag defaults to False and can be activated at startup -with the ``--debug-on`` command-line option. -""" - -from typing import Dict, List - - -# ============================================================================== -# DEBUG -# ============================================================================== - -DEBUG = False - - -def debug_print(msg: str) -> None: - """ - Print debug message if DEBUG mode is enabled. - - Args: - msg: The message to print - """ - if DEBUG: - print(f"DEBUG: {msg}") - - -# ============================================================================== -# CHANNELS -# ============================================================================== - -# Hardcoded channels configuration. -# Determine your channels with meshcli: -# meshcli -d -# > get_channels -# Output: 0: Public [...], 1: #test [...], etc. -CHANNELS_CONFIG: List[Dict] = [ - {'idx': 0, 'name': 'Public'}, - {'idx': 1, 'name': '#test'}, - {'idx': 2, 'name': '#zwolle'}, - {'idx': 3, 'name': 'RahanSom'}, -] - - -# ============================================================================== -# CONTACT TYPE MAPPINGS -# ============================================================================== - -TYPE_ICONS: Dict[int, str] = {0: "○", 1: "📱", 2: "📡", 3: "🏠"} -TYPE_NAMES: Dict[int, str] = {0: "-", 1: "CLI", 2: "REP", 3: "ROOM"} -TYPE_LABELS: Dict[int, str] = {0: "-", 1: "Companion", 2: "Repeater", 3: "Room Server"} - - -# ============================================================================== -# BOT -# ============================================================================== - -# Channel index the bot listens on (must match CHANNELS_CONFIG). -BOT_CHANNEL: int = 1 # #test - -# Display name prepended to every bot reply. -BOT_NAME: str = "Zwolle Bot" - -# Minimum seconds between two bot replies (prevents reply-storms). -BOT_COOLDOWN_SECONDS: float = 5.0 - -# Keyword → reply template mapping. -# Available variables: {bot}, {sender}, {snr}, {path} -# The bot checks whether the incoming message text *contains* the keyword -# (case-insensitive). First match wins. -BOT_KEYWORDS: Dict[str, str] = { - 'test': '{bot}: {sender}, rcvd | SNR {snr} | {path}', - 'ping': '{bot}: Pong!', - 'help': '{bot}: test, ping, help', -} diff --git a/meshcore-gui/meshcore_gui/main_page.py b/meshcore-gui/meshcore_gui/main_page.py deleted file mode 100644 index 8341715..0000000 --- a/meshcore-gui/meshcore_gui/main_page.py +++ /dev/null @@ -1,440 +0,0 @@ -""" -Main dashboard page for MeshCore GUI. - -Contains the three-column layout with device info, contacts, map, -messaging, filters and RX log. The 500 ms update timer lives here. -""" - -from typing import Dict, List - -import logging - -from nicegui import ui - -from meshcore_gui.config import TYPE_ICONS, TYPE_NAMES -from meshcore_gui.protocols import SharedDataReader - - -# Suppress the harmless "Client has been deleted" warning that NiceGUI -# emits when a browser tab is refreshed while a ui.timer is active. -class _DeletedClientFilter(logging.Filter): - def filter(self, record: logging.LogRecord) -> bool: - return 'Client has been deleted' not in record.getMessage() - -logging.getLogger('nicegui').addFilter(_DeletedClientFilter()) - - -class DashboardPage: - """ - Main dashboard rendered at ``/``. - - Args: - shared: SharedDataReader for data access and command dispatch - """ - - def __init__(self, shared: SharedDataReader) -> None: - self._shared = shared - - # UI element references - self._status_label = None - self._device_label = None - self._channel_select = None - self._channels_filter_container = None - self._channel_filters: Dict = {} - self._bot_checkbox = None - self._contacts_container = None - self._map_widget = None - self._messages_container = None - self._rxlog_table = None - self._msg_input = None - - # Map markers tracking - self._markers: List = [] - - # Channel data for message display - self._last_channels: List[Dict] = [] - - # Local first-render flag — each new browser tab gets its own - # DashboardPage instance and must render device/contacts once. - self._initialized: bool = False - - # ------------------------------------------------------------------ - # Public - # ------------------------------------------------------------------ - - def render(self) -> None: - """Build the complete dashboard layout and start the timer.""" - # Reset per-tab state — same DashboardPage instance is reused - # across browser reconnects, so force a fresh first-render. - self._initialized = False - self._markers.clear() - self._channel_filters = {} - self._last_channels = [] - - ui.dark_mode(False) - - # Header - with ui.header().classes('bg-blue-600 text-white'): - ui.label('🔗 MeshCore').classes('text-xl font-bold') - ui.space() - self._status_label = ui.label('Starting...').classes('text-sm') - - # Three columns - with ui.row().classes('w-full h-full gap-2 p-2'): - with ui.column().classes('w-64 gap-2'): - self._render_device_panel() - self._render_contacts_panel() - - with ui.column().classes('flex-grow gap-2'): - self._render_map_panel() - self._render_input_panel() - self._render_channels_filter() - self._render_messages_panel() - - with ui.column().classes('w-64 gap-2'): - self._render_actions_panel() - self._render_rxlog_panel() - - # 500 ms update timer - ui.timer(0.5, self._update_ui) - - # ------------------------------------------------------------------ - # Panel builders - # ------------------------------------------------------------------ - - def _render_device_panel(self) -> None: - with ui.card().classes('w-full'): - ui.label('📡 Device').classes('font-bold text-gray-600') - self._device_label = ui.label('Connecting...').classes( - 'text-sm whitespace-pre-line' - ) - - def _render_contacts_panel(self) -> None: - with ui.card().classes('w-full'): - ui.label('👥 Contacts').classes('font-bold text-gray-600') - self._contacts_container = ui.column().classes( - 'w-full gap-1 max-h-96 overflow-y-auto' - ) - - def _render_map_panel(self) -> None: - with ui.card().classes('w-full'): - self._map_widget = ui.leaflet( - center=(52.5, 6.0), zoom=9 - ).classes('w-full h-72') - - def _render_input_panel(self) -> None: - with ui.card().classes('w-full'): - with ui.row().classes('w-full items-center gap-2'): - self._msg_input = ui.input( - placeholder='Message...' - ).classes('flex-grow') - - self._channel_select = ui.select( - options={0: '[0] Public'}, value=0 - ).classes('w-32') - - ui.button( - 'Send', on_click=self._send_message - ).classes('bg-blue-500 text-white') - - def _render_channels_filter(self) -> None: - with ui.card().classes('w-full'): - with ui.row().classes('w-full items-center gap-4 justify-center'): - ui.label('📻 Filter:').classes('text-sm text-gray-600') - self._channels_filter_container = ui.row().classes('gap-4') - - def _render_messages_panel(self) -> None: - with ui.card().classes('w-full'): - ui.label('💬 Messages').classes('font-bold text-gray-600') - self._messages_container = ui.column().classes( - 'w-full h-40 overflow-y-auto gap-0 text-sm font-mono ' - 'bg-gray-50 p-2 rounded' - ) - - def _render_actions_panel(self) -> None: - with ui.card().classes('w-full'): - ui.label('⚡ Actions').classes('font-bold text-gray-600') - with ui.row().classes('gap-2'): - ui.button('🔄 Refresh', on_click=self._cmd_refresh) - ui.button('📢 Advert', on_click=self._cmd_send_advert) - - def _render_rxlog_panel(self) -> None: - with ui.card().classes('w-full'): - ui.label('📊 RX Log').classes('font-bold text-gray-600') - self._rxlog_table = ui.table( - columns=[ - {'name': 'time', 'label': 'Time', 'field': 'time'}, - {'name': 'snr', 'label': 'SNR', 'field': 'snr'}, - {'name': 'type', 'label': 'Type', 'field': 'type'}, - ], - rows=[], - ).props('dense flat').classes('text-xs max-h-48 overflow-y-auto') - - # ------------------------------------------------------------------ - # Timer-driven UI update - # ------------------------------------------------------------------ - - def _update_ui(self) -> None: - """Periodic UI refresh — called every 500 ms.""" - try: - if not self._status_label or not self._device_label: - return - - data = self._shared.get_snapshot() - is_first = not self._initialized - - self._status_label.text = data['status'] - - if data['device_updated'] or is_first: - self._update_device_info(data) - if data['channels_updated'] or is_first: - self._update_channels(data) - if data['contacts_updated'] or is_first: - self._update_contacts(data) - if data['contacts'] and ( - data['contacts_updated'] or not self._markers or is_first - ): - self._update_map(data) - - self._refresh_messages(data) - - if data['rxlog_updated'] and self._rxlog_table: - self._update_rxlog(data) - - self._shared.clear_update_flags() - - if is_first and data['channels'] and data['contacts']: - self._initialized = True - self._shared.mark_gui_initialized() - - except Exception as e: - err = str(e).lower() - if "deleted" not in err and "client" not in err: - print(f"GUI update error: {e}") - - # ------------------------------------------------------------------ - # Data → UI updaters - # ------------------------------------------------------------------ - - def _update_device_info(self, data: Dict) -> None: - lines = [] - if data['name']: - lines.append(f"📡 {data['name']}") - if data['public_key']: - lines.append(f"🔑 {data['public_key'][:16]}...") - if data['radio_freq']: - lines.append(f"📻 {data['radio_freq']:.3f} MHz") - lines.append(f"⚙️ SF{data['radio_sf']} / {data['radio_bw']} kHz") - if data['tx_power']: - lines.append(f"⚡ TX: {data['tx_power']} dBm") - if data['adv_lat'] and data['adv_lon']: - lines.append(f"📍 {data['adv_lat']:.4f}, {data['adv_lon']:.4f}") - if data['firmware_version']: - lines.append(f"🏷️ {data['firmware_version']}") - self._device_label.text = "\n".join(lines) if lines else "Loading..." - - def _update_channels(self, data: Dict) -> None: - if not self._channels_filter_container or not data['channels']: - return - - self._channels_filter_container.clear() - self._channel_filters = {} - - with self._channels_filter_container: - # BOT toggle — controls auto-reply, not display filtering - self._bot_checkbox = ui.checkbox( - '🤖 BOT', - value=data.get('bot_enabled', False), - on_change=lambda e: self._shared.set_bot_enabled(e.value), - ) - - # Visual separator - ui.label('│').classes('text-gray-300') - - # Display filters: DM + channels - cb_dm = ui.checkbox('DM', value=True) - self._channel_filters['DM'] = cb_dm - for ch in data['channels']: - cb = ui.checkbox(f"[{ch['idx']}] {ch['name']}", value=True) - self._channel_filters[ch['idx']] = cb - - self._last_channels = data['channels'] - - if self._channel_select and data['channels']: - opts = { - ch['idx']: f"[{ch['idx']}] {ch['name']}" - for ch in data['channels'] - } - self._channel_select.options = opts - if self._channel_select.value not in opts: - self._channel_select.value = list(opts.keys())[0] - self._channel_select.update() - - def _update_contacts(self, data: Dict) -> None: - if not self._contacts_container: - return - - self._contacts_container.clear() - - with self._contacts_container: - for key, contact in data['contacts'].items(): - ctype = contact.get('type', 0) - icon = TYPE_ICONS.get(ctype, '○') - name = contact.get('adv_name', key[:12]) - type_name = TYPE_NAMES.get(ctype, '-') - lat = contact.get('adv_lat', 0) - lon = contact.get('adv_lon', 0) - has_loc = lat != 0 or lon != 0 - - tooltip = ( - f"{name}\nType: {type_name}\n" - f"Key: {key[:16]}...\nClick to send DM" - ) - if has_loc: - tooltip += f"\nLat: {lat:.4f}\nLon: {lon:.4f}" - - with ui.row().classes( - 'w-full items-center gap-2 p-1 ' - 'hover:bg-gray-100 rounded cursor-pointer' - ).on('click', lambda e, k=key, n=name: self._open_dm_dialog(k, n)): - ui.label(icon).classes('text-sm') - ui.label(name[:15]).classes( - 'text-sm flex-grow truncate' - ).tooltip(tooltip) - ui.label(type_name).classes('text-xs text-gray-500') - if has_loc: - ui.label('📍').classes('text-xs') - - def _update_map(self, data: Dict) -> None: - if not self._map_widget: - return - - for marker in self._markers: - try: - self._map_widget.remove_layer(marker) - except Exception: - pass - self._markers.clear() - - if data['adv_lat'] and data['adv_lon']: - m = self._map_widget.marker( - latlng=(data['adv_lat'], data['adv_lon']) - ) - self._markers.append(m) - self._map_widget.set_center((data['adv_lat'], data['adv_lon'])) - - for key, contact in data['contacts'].items(): - lat = contact.get('adv_lat', 0) - lon = contact.get('adv_lon', 0) - if lat != 0 or lon != 0: - m = self._map_widget.marker(latlng=(lat, lon)) - self._markers.append(m) - - def _update_rxlog(self, data: Dict) -> None: - rows = [ - { - 'time': e['time'], - 'snr': f"{e['snr']:.1f}", - 'type': e['payload_type'], - } - for e in data['rx_log'][:20] - ] - self._rxlog_table.rows = rows - self._rxlog_table.update() - - def _refresh_messages(self, data: Dict) -> None: - if not self._messages_container: - return - - channel_names = {ch['idx']: ch['name'] for ch in self._last_channels} - - filtered = [] - for orig_idx, msg in enumerate(data['messages']): - ch = msg['channel'] - if ch is None: - if self._channel_filters.get('DM') and not self._channel_filters['DM'].value: - continue - else: - if ch in self._channel_filters and not self._channel_filters[ch].value: - continue - filtered.append((orig_idx, msg)) - - self._messages_container.clear() - - with self._messages_container: - for orig_idx, msg in reversed(filtered[-50:]): - direction = '→' if msg['direction'] == 'out' else '←' - ch = msg['channel'] - - ch_label = ( - f"[{channel_names.get(ch, f'ch{ch}')}]" - if ch is not None - else '[DM]' - ) - - sender = msg.get('sender', '') - path_len = msg.get('path_len', 0) - has_path = bool(msg.get('path_hashes')) - if msg['direction'] == 'in' and path_len > 0: - hop_tag = f' [{path_len}h{"✓" if has_path else ""}]' - else: - hop_tag = '' - - if sender: - line = f"{msg['time']} {direction} {ch_label}{hop_tag} {sender}: {msg['text']}" - else: - line = f"{msg['time']} {direction} {ch_label}{hop_tag} {msg['text']}" - - ui.label(line).classes( - 'text-xs leading-tight cursor-pointer ' - 'hover:bg-blue-50 rounded px-1' - ).on('click', lambda e, i=orig_idx: ui.navigate.to( - f'/route/{i}', new_tab=True - )) - - # ------------------------------------------------------------------ - # DM dialog - # ------------------------------------------------------------------ - - def _open_dm_dialog(self, pubkey: str, contact_name: str) -> None: - with ui.dialog() as dialog, ui.card().classes('w-96'): - ui.label(f'💬 DM to {contact_name}').classes('font-bold text-lg') - msg_input = ui.input(placeholder='Type your message...').classes('w-full') - - with ui.row().classes('w-full justify-end gap-2 mt-4'): - ui.button('Cancel', on_click=dialog.close).props('flat') - - def send_dm(): - text = msg_input.value - if text: - self._shared.put_command({ - 'action': 'send_dm', - 'pubkey': pubkey, - 'text': text, - 'contact_name': contact_name, - }) - dialog.close() - - ui.button('Send', on_click=send_dm).classes('bg-blue-500 text-white') - dialog.open() - - # ------------------------------------------------------------------ - # Command helpers - # ------------------------------------------------------------------ - - def _send_message(self) -> None: - text = self._msg_input.value - channel = self._channel_select.value - if text: - self._shared.put_command({ - 'action': 'send_message', - 'channel': channel, - 'text': text, - }) - self._msg_input.value = '' - - def _cmd_send_advert(self) -> None: - self._shared.put_command({'action': 'send_advert'}) - - def _cmd_refresh(self) -> None: - self._shared.put_command({'action': 'refresh'}) diff --git a/meshcore-gui/meshcore_gui/route_pagex.py b/meshcore-gui/meshcore_gui/route_pagex.py deleted file mode 100644 index 85a9afd..0000000 --- a/meshcore-gui/meshcore_gui/route_pagex.py +++ /dev/null @@ -1,306 +0,0 @@ -""" -Route visualization page for MeshCore GUI. - -Standalone NiceGUI page that opens in a new browser tab when a user -clicks on a message. Shows a Leaflet map with the message route, -a hop count summary, and a details table. -""" - -from typing import Dict - -from nicegui import ui - -from meshcore_gui.config import TYPE_LABELS, debug_print -from meshcore_gui.route_builder import RouteBuilder -from meshcore_gui.protocols import SharedDataReadAndLookup - - -class RoutePage: - """ - Route visualization page rendered at ``/route/{msg_index}``. - - Args: - shared: SharedDataReadAndLookup for data access and contact lookups - """ - - def __init__(self, shared: SharedDataReadAndLookup) -> None: - self._shared = shared - self._builder = RouteBuilder(shared) - - # ------------------------------------------------------------------ - # Public - # ------------------------------------------------------------------ - - def render(self, msg_index: int) -> None: - """ - Render the route page for a specific message. - - Args: - msg_index: Index into SharedData.messages list - """ - data = self._shared.get_snapshot() - - # Validate - if msg_index < 0 or msg_index >= len(data['messages']): - ui.label('❌ Message not found').classes('text-xl p-8') - return - - msg = data['messages'][msg_index] - route = self._builder.build(msg, data) - - sender = msg.get('sender', 'Unknown') - ui.page_title(f'Route — {sender}') - ui.dark_mode(False) - - # Header - with ui.header().classes('bg-blue-600 text-white'): - ui.label('🗺️ MeshCore Route').classes('text-xl font-bold') - - with ui.column().classes('w-full max-w-4xl mx-auto p-4 gap-4'): - self._render_message_info(msg) - self._render_hop_summary(msg, route) - self._render_map(data, route) - self._render_route_table(msg, data, route) - - # ------------------------------------------------------------------ - # Private — sub-sections - # ------------------------------------------------------------------ - - @staticmethod - def _render_message_info(msg: Dict) -> None: - """Message header with sender name and text.""" - sender = msg.get('sender', 'Unknown') - direction = '→ Sent' if msg['direction'] == 'out' else '← Received' - ui.label(f'Message Route — {sender} ({direction})').classes('font-bold text-lg') - ui.label( - f"{msg['time']} {sender}: " - f"{msg['text'][:120]}" - ).classes('text-sm text-gray-600') - - @staticmethod - def _render_hop_summary(msg: Dict, route: Dict) -> None: - """Hop count banner with SNR and path source.""" - msg_path_len = route['msg_path_len'] - resolved_hops = len(route['path_nodes']) - path_source = route.get('path_source', 'none') - expected_repeaters = max(msg_path_len - 1, 0) - - with ui.card().classes('w-full'): - with ui.row().classes('items-center gap-4'): - if msg['direction'] == 'in': - if msg_path_len == 0: - ui.label('📡 Direct (0 hops)').classes( - 'text-lg font-bold text-green-600' - ) - else: - hop_text = '1 hop' if msg_path_len == 1 else f'{msg_path_len} hops' - ui.label(f'📡 {hop_text}').classes( - 'text-lg font-bold text-blue-600' - ) - else: - ui.label('📡 Outgoing message').classes( - 'text-lg font-bold text-gray-600' - ) - - if route['snr'] is not None: - ui.label( - f'📶 SNR: {route["snr"]:.1f} dB' - ).classes('text-sm text-gray-600') - - # Resolution status - if expected_repeaters > 0 and resolved_hops > 0: - source_label = ( - 'from received packet' - if path_source == 'rx_log' - else 'from stored contact route' - ) - rpt = 'repeater' if expected_repeaters == 1 else 'repeaters' - ui.label( - f'✅ {resolved_hops} of {expected_repeaters} ' - f'{rpt} identified ' - f'({source_label})' - ).classes('text-xs text-gray-500 mt-1') - elif msg_path_len > 0 and resolved_hops == 0: - ui.label( - f'ℹ️ {msg_path_len} ' - f'hop{"s" if msg_path_len != 1 else ""} — ' - f'repeater identities not resolved' - ).classes('text-xs text-gray-500 mt-1') - - @staticmethod - def _render_map(data: Dict, route: Dict) -> None: - """Leaflet map with route markers and polylines. - - Lines are only drawn between nodes that are **adjacent** in the - route and both have GPS coordinates. A node without coordinates - breaks the line so that no false connections are shown. - """ - with ui.card().classes('w-full'): - if not route['has_locations']: - ui.label( - '📍 No location data available for map display' - ).classes('text-gray-500 italic p-4') - return - - center_lat = data['adv_lat'] or 52.5 - center_lon = data['adv_lon'] or 6.0 - - route_map = ui.leaflet( - center=(center_lat, center_lon), zoom=10 - ).classes('w-full h-96') - - # --- Build ordered list of positions (or None) --- - ordered = [] - - # Sender - if route['sender']: - s = route['sender'] - if s['lat'] or s['lon']: - ordered.append((s['lat'], s['lon'])) - else: - ordered.append(None) - else: - ordered.append(None) - - # Repeaters - for node in route['path_nodes']: - if node['lat'] or node['lon']: - ordered.append((node['lat'], node['lon'])) - else: - ordered.append(None) - - # Own position (receiver) - if data['adv_lat'] or data['adv_lon']: - ordered.append((data['adv_lat'], data['adv_lon'])) - else: - ordered.append(None) - - # --- Place markers for all nodes with coordinates --- - all_points = [p for p in ordered if p is not None] - for lat, lon in all_points: - route_map.marker(latlng=(lat, lon)) - - # --- Draw line between all located nodes (skip unknowns) --- - # Nodes without coordinates are simply skipped so the line - # connects sender → known repeaters → receiver without gaps. - if len(all_points) >= 2: - route_map.generic_layer( - name='polyline', - args=[all_points, {'color': '#2563eb', 'weight': 3}], - ) - - # Center map on all located nodes - if all_points: - lats = [p[0] for p in all_points] - lons = [p[1] for p in all_points] - route_map.set_center( - (sum(lats) / len(lats), sum(lons) / len(lons)) - ) - - @staticmethod - def _render_route_table(msg: Dict, data: Dict, route: Dict) -> None: - """Route details table with sender, hops and receiver.""" - msg_path_len = route['msg_path_len'] - resolved_hops = len(route['path_nodes']) - path_source = route.get('path_source', 'none') - - with ui.card().classes('w-full'): - ui.label('📋 Route Details').classes('font-bold text-gray-600') - - rows = [] - - # Sender - if route['sender']: - s = route['sender'] - has_loc = s['lat'] != 0 or s['lon'] != 0 - rows.append({ - 'hop': 'Start', - 'name': s['name'], - 'hash': s.get('pubkey', '')[:2].upper() if s.get('pubkey') else '-', - 'type': TYPE_LABELS.get(s['type'], '-'), - 'location': f"{s['lat']:.4f}, {s['lon']:.4f}" if has_loc else '-', - 'role': '📱 Sender', - }) - else: - sender_pubkey = msg.get('sender_pubkey', '') - rows.append({ - 'hop': 'Start', - 'name': msg.get('sender', 'Unknown'), - 'hash': sender_pubkey[:2].upper() if sender_pubkey else '-', - 'type': '-', - 'location': '-', - 'role': '📱 Sender', - }) - - # Resolved repeaters (from RX_LOG or out_path) - for i, node in enumerate(route['path_nodes']): - has_loc = node['lat'] != 0 or node['lon'] != 0 - rows.append({ - 'hop': str(i + 1), - 'name': node['name'], - 'hash': node.get('pubkey', '')[:2].upper() if node.get('pubkey') else '-', - 'type': TYPE_LABELS.get(node['type'], '-'), - 'location': f"{node['lat']:.4f}, {node['lon']:.4f}" if has_loc else '-', - 'role': '📡 Repeater', - }) - - # Placeholder rows when no path data was resolved - if not route['path_nodes'] and msg_path_len > 0: - for i in range(msg_path_len): - rows.append({ - 'hop': str(i + 1), - 'name': '-', - 'hash': '-', - 'type': '-', - 'location': '-', - 'role': '📡 Repeater', - }) - - # Own position - self_has_loc = data['adv_lat'] != 0 or data['adv_lon'] != 0 - rows.append({ - 'hop': 'End', - 'name': data['name'] or 'Me', - 'hash': '-', - 'type': 'Companion', - 'location': f"{data['adv_lat']:.4f}, {data['adv_lon']:.4f}" if self_has_loc else '-', - 'role': '📱 Receiver' if msg['direction'] == 'in' else '📱 Sender', - }) - - ui.table( - columns=[ - {'name': 'hop', 'label': 'Hop', 'field': 'hop', 'align': 'center'}, - {'name': 'role', 'label': 'Role', 'field': 'role'}, - {'name': 'name', 'label': 'Name', 'field': 'name'}, - {'name': 'hash', 'label': 'ID', 'field': 'hash', 'align': 'center'}, - {'name': 'type', 'label': 'Type', 'field': 'type'}, - {'name': 'location', 'label': 'Location', 'field': 'location'}, - ], - rows=rows, - ).props('dense flat bordered').classes('w-full') - - # Footnote based on path_source - if msg_path_len == 0 and msg['direction'] == 'in': - ui.label( - 'ℹ️ Direct message — no intermediate hops.' - ).classes('text-xs text-gray-400 italic mt-2') - elif path_source == 'rx_log': - ui.label( - 'ℹ️ Path extracted from received LoRa packet (RX_LOG). ' - 'Each ID is the first byte of a node\'s public key.' - ).classes('text-xs text-gray-400 italic mt-2') - elif path_source == 'contact_out_path': - ui.label( - 'ℹ️ Path from sender\'s stored contact route (out_path). ' - 'Last known route, not necessarily this message\'s path.' - ).classes('text-xs text-gray-400 italic mt-2') - elif msg_path_len > 0 and resolved_hops == 0: - ui.label( - 'ℹ️ Repeater identities could not be resolved. ' - 'RX_LOG correlation may have missed the raw packet, ' - 'and sender has no stored out_path.' - ).classes('text-xs text-gray-400 italic mt-2') - elif msg['direction'] == 'out': - ui.label( - 'ℹ️ Hop information is only available for received messages.' - ).classes('text-xs text-gray-400 italic mt-2') diff --git a/meshcore-gui/meshcore_gui.py b/meshcore_gui.py similarity index 90% rename from meshcore-gui/meshcore_gui.py rename to meshcore_gui.py index c8b9d04..7ba8f4f 100644 --- a/meshcore-gui/meshcore_gui.py +++ b/meshcore_gui.py @@ -11,7 +11,7 @@ Usage: python meshcore_gui.py --debug-on Author: PE1HVH - Version: 4.0 + Version: 5.0 SPDX-License-Identifier: MIT Copyright: (c) 2026 PE1HVH """ @@ -20,7 +20,7 @@ import sys from nicegui import ui -# Allow overriding DEBUG and CHANNELS_CONFIG before anything imports them +# Allow overriding DEBUG before anything imports it import meshcore_gui.config as config try: @@ -30,10 +30,10 @@ except ImportError: print("Install with: pip install meshcore") sys.exit(1) -from meshcore_gui.ble_worker import BLEWorker -from meshcore_gui.main_page import DashboardPage -from meshcore_gui.route_page import RoutePage -from meshcore_gui.shared_data import SharedData +from meshcore_gui.ble.worker import BLEWorker +from meshcore_gui.core.shared_data import SharedData +from meshcore_gui.gui.dashboard import DashboardPage +from meshcore_gui.gui.route_page import RoutePage # Global instances (needed by NiceGUI page decorators) diff --git a/meshcore_gui.zip b/meshcore_gui.zip new file mode 100644 index 0000000000000000000000000000000000000000..93723b3ea4d5e459f7dfd5a4392e26c65ce3931f GIT binary patch literal 60134 zcmb5W19W9ww)cHv+o{-gDz+-NZ5x%OV%xTD+qO}$ZKIN}s{8hR`s?@YzV|(2tbH)f zm~*ea*ZQyDTyu^sCkXVS*+lT);zySyV*2a!zhPDpIx~9(N^vX(b00`>A z0P}w|XIB^i5a=-o0Pwei+}}(=0w4jUZhx7Yg#iF?{?*h!+oiK}`TdYDMXy7*-$N8yIZ=*9m6ep{&n^$|aR_K-2HR9Pgbz}~9f z1E^AsiwhvL^T6hhVVDH{K6LfWA=A^iE=Y&DEaU@2gr>}Iq)|b+Y=MuLA}kQ=uOlBa z$cfS65yN6Ep;(&1jIJ0jCZk|Djv|-#t+FCC&q3&5#@+^%_YHo#x&m}bO*ld%z`Qr; z1>I}#ynocn1xri&*Q1RHCM+^G+6h3iNp#vP9@z66KAm5`tS)b7-VK-~e}@Mt z;^6soFJ;mI8ZHeoO%g4Z2t+oxq}OOhECx8UH!{0C{Y`WH91Z! zV_G3T%tKq$k}oy7$EX4U^w=i{{A0V7zjTR86?0i?BSJ5@W%uSGsvbhivxG+qekA

9T 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 literal 0 HcmV?d00001 diff --git a/meshcore-gui/meshcore_gui/__init__.py b/meshcore_gui/__init__.py similarity index 88% rename from meshcore-gui/meshcore_gui/__init__.py rename to meshcore_gui/__init__.py index 9a4e646..c12da55 100644 --- a/meshcore-gui/meshcore_gui/__init__.py +++ b/meshcore_gui/__init__.py @@ -5,4 +5,4 @@ A graphical user interface for MeshCore mesh network devices, communicating via Bluetooth Low Energy (BLE). """ -__version__ = "4.0" +__version__ = "5.0" diff --git a/meshcore-gui/meshcore_gui/meshcore_gui.py b/meshcore_gui/__main__.py similarity index 88% rename from meshcore-gui/meshcore_gui/meshcore_gui.py rename to meshcore_gui/__main__.py index c8b9d04..3bdbdbd 100644 --- a/meshcore-gui/meshcore_gui/meshcore_gui.py +++ b/meshcore_gui/__main__.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -MeshCore GUI - Threaded BLE Edition +MeshCore GUI — Threaded BLE Edition ==================================== Entry point. Parses arguments, wires up the components, registers @@ -9,9 +9,10 @@ NiceGUI pages and starts the server. Usage: python meshcore_gui.py python meshcore_gui.py --debug-on + python -m meshcore_gui Author: PE1HVH - Version: 4.0 + Version: 5.0 SPDX-License-Identifier: MIT Copyright: (c) 2026 PE1HVH """ @@ -20,7 +21,7 @@ import sys from nicegui import ui -# Allow overriding DEBUG and CHANNELS_CONFIG before anything imports them +# Allow overriding DEBUG before anything imports it import meshcore_gui.config as config try: @@ -30,10 +31,10 @@ except ImportError: print("Install with: pip install meshcore") sys.exit(1) -from meshcore_gui.ble_worker import BLEWorker -from meshcore_gui.main_page import DashboardPage -from meshcore_gui.route_page import RoutePage -from meshcore_gui.shared_data import SharedData +from meshcore_gui.ble.worker import BLEWorker +from meshcore_gui.core.shared_data import SharedData +from meshcore_gui.gui.dashboard import DashboardPage +from meshcore_gui.gui.route_page import RoutePage # Global instances (needed by NiceGUI page decorators) diff --git a/meshcore_gui/ble/__init__.py b/meshcore_gui/ble/__init__.py new file mode 100644 index 0000000..ea6f296 --- /dev/null +++ b/meshcore_gui/ble/__init__.py @@ -0,0 +1,3 @@ +""" +BLE infrastructure layer — device connection, commands and events. +""" diff --git a/meshcore_gui/ble/commands.py b/meshcore_gui/ble/commands.py new file mode 100644 index 0000000..edee6ac --- /dev/null +++ b/meshcore_gui/ble/commands.py @@ -0,0 +1,113 @@ +""" +BLE command handlers for MeshCore GUI. + +Extracted from ``BLEWorker`` so that each command is an isolated unit +of work. New commands can be registered without modifying existing +code (Open/Closed Principle). +""" + +from datetime import datetime +from typing import Dict, Optional + +from meshcore import MeshCore + +from meshcore_gui.config import debug_print +from meshcore_gui.core.models import Message +from meshcore_gui.core.protocols import SharedDataWriter + + +class CommandHandler: + """Dispatches and executes commands sent from the GUI. + + Args: + mc: Connected MeshCore instance. + shared: SharedDataWriter for storing results. + """ + + def __init__(self, mc: MeshCore, shared: SharedDataWriter) -> None: + self._mc = mc + self._shared = shared + + # Handler registry — add new commands here (OCP) + self._handlers: Dict[str, object] = { + 'send_message': self._cmd_send_message, + 'send_dm': self._cmd_send_dm, + 'send_advert': self._cmd_send_advert, + 'refresh': self._cmd_refresh, + } + + async def process_all(self) -> None: + """Drain the command queue and dispatch each command.""" + while True: + cmd = self._shared.get_next_command() + if cmd is None: + break + await self._dispatch(cmd) + + async def _dispatch(self, cmd: Dict) -> None: + action = cmd.get('action') + handler = self._handlers.get(action) + if handler: + await handler(cmd) + else: + debug_print(f"Unknown command action: {action}") + + # ------------------------------------------------------------------ + # Individual command handlers + # ------------------------------------------------------------------ + + async def _cmd_send_message(self, cmd: Dict) -> None: + channel = cmd.get('channel', 0) + text = cmd.get('text', '') + is_bot = cmd.get('_bot', False) + if text: + await self._mc.commands.send_chan_msg(channel, text) + if not is_bot: + self._shared.add_message(Message( + time=datetime.now().strftime('%H:%M:%S'), + sender='Me', + text=text, + channel=channel, + direction='out', + )) + debug_print( + f"{'BOT' if is_bot else 'Sent'} message to " + f"channel {channel}: {text[:30]}" + ) + + async def _cmd_send_dm(self, cmd: Dict) -> None: + pubkey = cmd.get('pubkey', '') + text = cmd.get('text', '') + contact_name = cmd.get('contact_name', pubkey[:8]) + if text and pubkey: + await self._mc.commands.send_msg(pubkey, text) + self._shared.add_message(Message( + time=datetime.now().strftime('%H:%M:%S'), + sender='Me', + text=text, + channel=None, + direction='out', + sender_pubkey=pubkey, + )) + debug_print(f"Sent DM to {contact_name}: {text[:30]}") + + async def _cmd_send_advert(self, cmd: Dict) -> None: + await self._mc.commands.send_advert(flood=True) + self._shared.set_status("📢 Advert sent") + debug_print("Advert sent") + + async def _cmd_refresh(self, cmd: Dict) -> None: + debug_print("Refresh requested") + # Delegate to the worker's _load_data via a callback + if self._load_data_callback: + await self._load_data_callback() + + # ------------------------------------------------------------------ + # Callback for refresh (set by BLEWorker after construction) + # ------------------------------------------------------------------ + + _load_data_callback = None + + def set_load_data_callback(self, callback) -> None: + """Register the worker's ``_load_data`` coroutine for refresh.""" + self._load_data_callback = callback diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py new file mode 100644 index 0000000..b15c2e1 --- /dev/null +++ b/meshcore_gui/ble/events.py @@ -0,0 +1,216 @@ +""" +BLE event callbacks for MeshCore GUI. + +Handles ``CHANNEL_MSG_RECV``, ``CONTACT_MSG_RECV`` and ``RX_LOG_DATA`` +events from the MeshCore library. Extracted from ``BLEWorker`` so the +worker only deals with connection lifecycle. +""" + +from datetime import datetime +from typing import Dict, Optional + +from meshcore_gui.config import debug_print +from meshcore_gui.core.models import Message, RxLogEntry +from meshcore_gui.core.protocols import SharedDataWriter +from meshcore_gui.ble.packet_decoder import PacketDecoder, PayloadType +from meshcore_gui.services.bot import MeshBot +from meshcore_gui.services.dedup import DualDeduplicator + + +class EventHandler: + """Processes BLE events and writes results to shared data. + + Args: + shared: SharedDataWriter for storing messages and RX log. + decoder: PacketDecoder for raw LoRa packet decryption. + dedup: DualDeduplicator for message deduplication. + bot: MeshBot for auto-reply logic. + """ + + def __init__( + self, + shared: SharedDataWriter, + decoder: PacketDecoder, + dedup: DualDeduplicator, + bot: MeshBot, + ) -> None: + self._shared = shared + self._decoder = decoder + self._dedup = dedup + self._bot = bot + + # ------------------------------------------------------------------ + # RX_LOG_DATA — the single source of truth for path info + # ------------------------------------------------------------------ + + def on_rx_log(self, event) -> None: + """Handle RX log data events.""" + payload = event.payload + + self._shared.add_rx_log(RxLogEntry( + time=datetime.now().strftime('%H:%M:%S'), + snr=payload.get('snr', 0), + rssi=payload.get('rssi', 0), + payload_type=payload.get('payload_type', '?'), + hops=payload.get('path_len', 0), + )) + + payload_hex = payload.get('payload', '') + if not payload_hex: + return + + decoded = self._decoder.decode(payload_hex) + if decoded is None: + return + + if decoded.payload_type == PayloadType.GroupText and decoded.is_decrypted: + self._dedup.mark_hash(decoded.message_hash) + self._dedup.mark_content( + decoded.sender, decoded.channel_idx, decoded.text, + ) + + sender_pubkey = '' + if decoded.sender: + match = self._shared.get_contact_by_name(decoded.sender) + if match: + sender_pubkey, _contact = match + + snr = self._extract_snr(payload) + + self._shared.add_message(Message( + time=datetime.now().strftime('%H:%M:%S'), + sender=decoded.sender, + text=decoded.text, + channel=decoded.channel_idx, + direction='in', + snr=snr, + path_len=decoded.path_length, + sender_pubkey=sender_pubkey, + path_hashes=decoded.path_hashes, + message_hash=decoded.message_hash, + )) + + debug_print( + f"RX_LOG → message: hash={decoded.message_hash}, " + f"sender={decoded.sender!r}, ch={decoded.channel_idx}, " + f"path={decoded.path_hashes}" + ) + + self._bot.check_and_reply( + sender=decoded.sender, + text=decoded.text, + channel_idx=decoded.channel_idx, + snr=snr, + path_len=decoded.path_length, + path_hashes=decoded.path_hashes, + ) + + # ------------------------------------------------------------------ + # CHANNEL_MSG_RECV — fallback when RX_LOG decode missed it + # ------------------------------------------------------------------ + + def on_channel_msg(self, event) -> None: + """Handle channel message events.""" + payload = event.payload + + debug_print(f"Channel msg payload keys: {list(payload.keys())}") + + # Dedup via hash + msg_hash = payload.get('message_hash', '') + if msg_hash and self._dedup.is_hash_seen(msg_hash): + debug_print(f"Channel msg suppressed (hash): {msg_hash}") + return + + # Parse sender from "SenderName: message body" format + raw_text = payload.get('text', '') + sender, msg_text = '', raw_text + if ': ' in raw_text: + name_part, body_part = raw_text.split(': ', 1) + sender = name_part.strip() + msg_text = body_part + elif raw_text: + msg_text = raw_text + + # Dedup via content + ch_idx = payload.get('channel_idx') + if self._dedup.is_content_seen(sender, ch_idx, msg_text): + debug_print(f"Channel msg suppressed (content): {sender!r}") + return + + debug_print( + f"Channel msg (fallback): sender={sender!r}, " + f"text={msg_text[:40]!r}" + ) + + sender_pubkey = '' + if sender: + match = self._shared.get_contact_by_name(sender) + if match: + sender_pubkey, _contact = match + + snr = self._extract_snr(payload) + + self._shared.add_message(Message( + time=datetime.now().strftime('%H:%M:%S'), + sender=sender, + text=msg_text, + channel=ch_idx, + direction='in', + snr=snr, + path_len=payload.get('path_len', 0), + sender_pubkey=sender_pubkey, + path_hashes=[], + message_hash=msg_hash, + )) + + self._bot.check_and_reply( + sender=sender, + text=msg_text, + channel_idx=ch_idx, + snr=snr, + path_len=payload.get('path_len', 0), + ) + + # ------------------------------------------------------------------ + # CONTACT_MSG_RECV — DMs + # ------------------------------------------------------------------ + + def on_contact_msg(self, event) -> None: + """Handle direct message events.""" + payload = event.payload + pubkey = payload.get('pubkey_prefix', '') + + debug_print(f"DM payload keys: {list(payload.keys())}") + + sender = '' + if pubkey: + sender = self._shared.get_contact_name_by_prefix(pubkey) + if not sender: + sender = pubkey[:8] if pubkey else '' + + self._shared.add_message(Message( + time=datetime.now().strftime('%H:%M:%S'), + sender=sender, + text=payload.get('text', ''), + channel=None, + direction='in', + snr=self._extract_snr(payload), + path_len=payload.get('path_len', 0), + sender_pubkey=pubkey, + )) + debug_print(f"DM received from {sender}: {payload.get('text', '')[:30]}") + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _extract_snr(payload: Dict) -> Optional[float]: + """Extract SNR from a payload dict (handles 'SNR' and 'snr' keys).""" + raw = payload.get('SNR') or payload.get('snr') + if raw is not None: + try: + return float(raw) + except (ValueError, TypeError): + pass + return None diff --git a/meshcore-gui/meshcore_gui/packet_parser.py b/meshcore_gui/ble/packet_decoder.py similarity index 100% rename from meshcore-gui/meshcore_gui/packet_parser.py rename to meshcore_gui/ble/packet_decoder.py diff --git a/meshcore_gui/ble/worker.py b/meshcore_gui/ble/worker.py new file mode 100644 index 0000000..4336cdd --- /dev/null +++ b/meshcore_gui/ble/worker.py @@ -0,0 +1,199 @@ +""" +BLE communication worker for MeshCore GUI. + +Runs in a separate thread with its own asyncio event loop. Connects +to the MeshCore device, wires up collaborators, and runs the command +processing loop. + +Responsibilities deliberately kept narrow (SRP): + - Thread lifecycle and asyncio loop + - BLE connection and initial data loading + - Wiring CommandHandler and EventHandler + +Command execution → :mod:`meshcore_gui.ble.commands` +Event handling → :mod:`meshcore_gui.ble.events` +Packet decoding → :mod:`meshcore_gui.ble.packet_decoder` +Bot logic → :mod:`meshcore_gui.services.bot` +Deduplication → :mod:`meshcore_gui.services.dedup` +""" + +import asyncio +import threading +from typing import Optional + +from meshcore import MeshCore, EventType + +from meshcore_gui.config import CHANNELS_CONFIG, debug_print +from meshcore_gui.core.protocols import SharedDataWriter +from meshcore_gui.ble.commands import CommandHandler +from meshcore_gui.ble.events import EventHandler +from meshcore_gui.ble.packet_decoder import PacketDecoder +from meshcore_gui.services.bot import BotConfig, MeshBot +from meshcore_gui.services.dedup import DualDeduplicator + + +class BLEWorker: + """BLE communication worker that runs in a separate thread. + + Args: + address: BLE MAC address (e.g. ``"literal:AA:BB:CC:DD:EE:FF"``). + shared: SharedDataWriter for thread-safe communication. + """ + + def __init__(self, address: str, shared: SharedDataWriter) -> None: + self.address = address + self.shared = shared + self.mc: Optional[MeshCore] = None + self.running = True + + # Collaborators (created eagerly, wired after connection) + self._decoder = PacketDecoder() + self._dedup = DualDeduplicator(max_size=200) + self._bot = MeshBot( + config=BotConfig(), + command_sink=shared.put_command, + enabled_check=shared.is_bot_enabled, + ) + + # ------------------------------------------------------------------ + # Thread lifecycle + # ------------------------------------------------------------------ + + def start(self) -> None: + """Start the worker in a new daemon thread.""" + thread = threading.Thread(target=self._run, daemon=True) + thread.start() + debug_print("BLE worker thread started") + + def _run(self) -> None: + asyncio.run(self._async_main()) + + async def _async_main(self) -> None: + await self._connect() + if self.mc: + while self.running: + await self._cmd_handler.process_all() + await asyncio.sleep(0.1) + + # ------------------------------------------------------------------ + # Connection + # ------------------------------------------------------------------ + + async def _connect(self) -> None: + self.shared.set_status(f"🔄 Connecting to {self.address}...") + try: + print(f"BLE: Connecting to {self.address}...") + self.mc = await MeshCore.create_ble(self.address) + print("BLE: Connected!") + + await asyncio.sleep(1) + + # Wire collaborators now that mc is available + self._evt_handler = EventHandler( + shared=self.shared, + decoder=self._decoder, + dedup=self._dedup, + bot=self._bot, + ) + self._cmd_handler = CommandHandler(mc=self.mc, shared=self.shared) + self._cmd_handler.set_load_data_callback(self._load_data) + + # Subscribe to events + self.mc.subscribe(EventType.CHANNEL_MSG_RECV, self._evt_handler.on_channel_msg) + self.mc.subscribe(EventType.CONTACT_MSG_RECV, self._evt_handler.on_contact_msg) + self.mc.subscribe(EventType.RX_LOG_DATA, self._evt_handler.on_rx_log) + + await self._load_data() + await self._load_channel_keys() + await self.mc.start_auto_message_fetching() + + self.shared.set_connected(True) + self.shared.set_status("✅ Connected") + print("BLE: Ready!") + + except Exception as e: + print(f"BLE: Connection error: {e}") + self.shared.set_status(f"❌ {e}") + + # ------------------------------------------------------------------ + # Initial data loading + # ------------------------------------------------------------------ + + async def _load_data(self) -> None: + """Load device info, channels and contacts.""" + # send_appstart (retries) + self.shared.set_status("🔄 Device info...") + for i in range(5): + debug_print(f"send_appstart attempt {i + 1}") + r = await self.mc.commands.send_appstart() + if r.type != EventType.ERROR: + print(f"BLE: send_appstart OK: {r.payload.get('name')}") + self.shared.update_from_appstart(r.payload) + break + await asyncio.sleep(0.3) + + # send_device_query (retries) + for i in range(5): + debug_print(f"send_device_query attempt {i + 1}") + r = await self.mc.commands.send_device_query() + if r.type != EventType.ERROR: + print(f"BLE: send_device_query OK: {r.payload.get('ver')}") + self.shared.update_from_device_query(r.payload) + break + await asyncio.sleep(0.3) + + # Channels (hardcoded — BLE get_channel is unreliable) + self.shared.set_status("🔄 Channels...") + self.shared.set_channels(CHANNELS_CONFIG) + print(f"BLE: Channels loaded: {[c['name'] for c in CHANNELS_CONFIG]}") + + # Contacts + self.shared.set_status("🔄 Contacts...") + r = await self.mc.commands.get_contacts() + if r.type != EventType.ERROR: + self.shared.set_contacts(r.payload) + print(f"BLE: Contacts loaded: {len(r.payload)} contacts") + + async def _load_channel_keys(self) -> None: + """Load channel decryption keys from device or derive from name. + + Channels that cannot be confirmed on the device are logged with + a warning. Sending and receiving on unconfirmed channels will + likely fail because the device does not know about them. + """ + self.shared.set_status("🔄 Channel keys...") + confirmed: list[str] = [] + missing: list[str] = [] + + for ch in CHANNELS_CONFIG: + idx, name = ch['idx'], ch['name'] + loaded = False + + for attempt in range(3): + try: + r = await self.mc.commands.get_channel(idx) + if r.type != EventType.ERROR: + secret = r.payload.get('channel_secret') + if secret and isinstance(secret, bytes) and len(secret) >= 16: + self._decoder.add_channel_key(idx, secret[:16]) + print(f"BLE: ✅ Channel [{idx}] '{name}' — key loaded from device") + confirmed.append(f"[{idx}] {name}") + loaded = True + break + except Exception as exc: + debug_print(f"get_channel({idx}) attempt {attempt + 1} error: {exc}") + await asyncio.sleep(0.3) + + if not loaded: + self._decoder.add_channel_key_from_name(idx, name) + missing.append(f"[{idx}] {name}") + print(f"BLE: ⚠️ Channel [{idx}] '{name}' — NOT found on device (key derived from name)") + + if missing: + print(f"BLE: ⚠️ Channels not confirmed on device: {', '.join(missing)}") + print(f"BLE: ⚠️ Sending/receiving on these channels may not work.") + print(f"BLE: ⚠️ Check your device config with: meshcli -d → get_channels") + + print(f"BLE: PacketDecoder ready — has_keys={self._decoder.has_keys}") + print(f"BLE: Confirmed: {', '.join(confirmed) if confirmed else 'none'}") + print(f"BLE: Unconfirmed: {', '.join(missing) if missing else 'none'}") diff --git a/meshcore_gui/config.py b/meshcore_gui/config.py new file mode 100644 index 0000000..a88ae42 --- /dev/null +++ b/meshcore_gui/config.py @@ -0,0 +1,43 @@ +""" +Application configuration for MeshCore GUI. + +Contains only global runtime settings and the channel table. +Bot configuration lives in :mod:`meshcore_gui.services.bot`. +UI display constants live in :mod:`meshcore_gui.gui.constants`. + +The ``DEBUG`` flag defaults to False and can be activated at startup +with the ``--debug-on`` command-line option. +""" + +from typing import Dict, List + + +# ============================================================================== +# DEBUG +# ============================================================================== + +DEBUG: bool = False + + +def debug_print(msg: str) -> None: + """Print a debug message when ``DEBUG`` is enabled.""" + if DEBUG: + print(f"DEBUG: {msg}") + + +# ============================================================================== +# CHANNELS +# ============================================================================== + +# Hardcoded channels configuration. +# Determine your channels with meshcli: +# meshcli -d +# > get_channels +# Output: 0: Public [...], 1: #test [...], etc. +CHANNELS_CONFIG: List[Dict] = [ + {'idx': 0, 'name': 'Public'}, + {'idx': 1, 'name': '#test'}, + {'idx': 2, 'name': '#zwolle'}, + {'idx': 3, 'name': 'RahanSom'}, + {'idx': 4, 'name': '#bot'}, +] diff --git a/meshcore_gui/core/__init__.py b/meshcore_gui/core/__init__.py new file mode 100644 index 0000000..23b6c09 --- /dev/null +++ b/meshcore_gui/core/__init__.py @@ -0,0 +1,16 @@ +""" +Core domain layer — models, protocols and shared data store. + +Re-exports the most commonly used names so consumers can write:: + + from meshcore_gui.core import SharedData, Message, RxLogEntry +""" + +from meshcore_gui.core.models import ( # noqa: F401 + Contact, + DeviceInfo, + Message, + RouteNode, + RxLogEntry, +) +from meshcore_gui.core.shared_data import SharedData # noqa: F401 diff --git a/meshcore_gui/core/models.py b/meshcore_gui/core/models.py new file mode 100644 index 0000000..0d1a3e5 --- /dev/null +++ b/meshcore_gui/core/models.py @@ -0,0 +1,174 @@ +""" +Domain model for MeshCore GUI. + +Typed dataclasses that replace untyped Dict objects throughout the +codebase. Each class represents a core domain concept. All classes +are immutable-friendly (frozen is not used because SharedData mutates +collections, but fields are not reassigned after construction). + +Migration note +~~~~~~~~~~~~~~ +``SharedData.get_snapshot()`` still returns a plain dict for backward +compatibility with the NiceGUI timer loop. Inside that dict, however, +``messages`` and ``rx_log`` are now lists of dataclass instances. +UI code can access attributes directly (``msg.sender``) or fall back +to ``dataclasses.asdict(msg)`` if a plain dict is needed. +""" + +from dataclasses import dataclass, field +from typing import List, Optional + + +# --------------------------------------------------------------------------- +# Message +# --------------------------------------------------------------------------- + +@dataclass +class Message: + """A channel message or direct message (DM). + + Attributes: + time: Formatted timestamp (HH:MM:SS). + sender: Display name of the sender. + text: Message body. + channel: Channel index, or ``None`` for a DM. + direction: ``'in'`` for received, ``'out'`` for sent. + snr: Signal-to-noise ratio (dB), if available. + path_len: Hop count from the LoRa frame header. + sender_pubkey: Full public key of the sender (hex string). + path_hashes: List of 2-char hex strings, one per repeater. + message_hash: Deterministic packet identifier (hex string). + """ + + time: str + sender: str + text: str + channel: Optional[int] + direction: str + snr: Optional[float] = None + path_len: int = 0 + sender_pubkey: str = "" + path_hashes: List[str] = field(default_factory=list) + message_hash: str = "" + + +# --------------------------------------------------------------------------- +# Contact +# --------------------------------------------------------------------------- + +@dataclass +class Contact: + """A known mesh network node. + + Attributes: + pubkey: Full public key (hex string). + adv_name: Advertised display name. + type: Node type (0=unknown, 1=CLI, 2=REP, 3=ROOM). + adv_lat: Advertised latitude (0.0 if unknown). + adv_lon: Advertised longitude (0.0 if unknown). + out_path: Hex string of stored route (2 hex chars per hop). + out_path_len: Number of hops in ``out_path``. + """ + + pubkey: str + adv_name: str = "" + type: int = 0 + adv_lat: float = 0.0 + adv_lon: float = 0.0 + out_path: str = "" + out_path_len: int = 0 + + @staticmethod + def from_dict(pubkey: str, d: dict) -> "Contact": + """Create a Contact from a meshcore contacts dict entry.""" + return Contact( + pubkey=pubkey, + adv_name=d.get("adv_name", ""), + type=d.get("type", 0), + adv_lat=d.get("adv_lat", 0.0), + adv_lon=d.get("adv_lon", 0.0), + out_path=d.get("out_path", ""), + out_path_len=d.get("out_path_len", 0), + ) + + +# --------------------------------------------------------------------------- +# DeviceInfo +# --------------------------------------------------------------------------- + +@dataclass +class DeviceInfo: + """Radio device identification and configuration. + + Attributes: + name: Device display name. + public_key: Device public key (hex string). + radio_freq: Radio frequency in MHz. + radio_sf: LoRa spreading factor. + radio_bw: Bandwidth in kHz. + tx_power: Transmit power in dBm. + adv_lat: Advertised latitude. + adv_lon: Advertised longitude. + firmware_version: Firmware version string. + """ + + name: str = "" + public_key: str = "" + radio_freq: float = 0.0 + radio_sf: int = 0 + radio_bw: float = 0.0 + tx_power: int = 0 + adv_lat: float = 0.0 + adv_lon: float = 0.0 + firmware_version: str = "" + + +# --------------------------------------------------------------------------- +# RxLogEntry +# --------------------------------------------------------------------------- + +@dataclass +class RxLogEntry: + """A single RX log entry from the radio. + + Attributes: + time: Formatted timestamp (HH:MM:SS). + snr: Signal-to-noise ratio (dB). + rssi: Received signal strength (dBm). + payload_type: Packet type identifier. + hops: Number of hops (path_len from frame header). + """ + + time: str + snr: float = 0.0 + rssi: float = 0.0 + payload_type: str = "?" + hops: int = 0 + + +# --------------------------------------------------------------------------- +# RouteNode +# --------------------------------------------------------------------------- + +@dataclass +class RouteNode: + """A node in a message route (sender, repeater or receiver). + + Attributes: + name: Display name (or ``'-'`` if unknown). + lat: Latitude (0.0 if unknown). + lon: Longitude (0.0 if unknown). + type: Node type (0=unknown, 1=CLI, 2=REP, 3=ROOM). + pubkey: Public key or 2-char hash (hex string). + """ + + name: str + lat: float = 0.0 + lon: float = 0.0 + type: int = 0 + pubkey: str = "" + + @property + def has_location(self) -> bool: + """True if the node has GPS coordinates.""" + return self.lat != 0 or self.lon != 0 diff --git a/meshcore-gui/meshcore_gui/protocols.py b/meshcore_gui/core/protocols.py similarity index 79% rename from meshcore-gui/meshcore_gui/protocols.py rename to meshcore_gui/core/protocols.py index a95364c..27768eb 100644 --- a/meshcore-gui/meshcore_gui/protocols.py +++ b/meshcore_gui/core/protocols.py @@ -9,10 +9,29 @@ and the Dependency Inversion Principle (DIP). Consumers depend on these protocols rather than on the concrete SharedData class, which makes the contracts explicit and enables testing with lightweight stubs. + +v4.1 changes +~~~~~~~~~~~~~ +- Added ``CommandSink`` protocol for bot and command dispatch. +- ``SharedDataWriter.add_message`` now accepts a ``Message`` dataclass. +- ``SharedDataWriter.add_rx_log`` now accepts an ``RxLogEntry`` dataclass. """ from typing import Dict, List, Optional, Protocol, runtime_checkable +from meshcore_gui.core.models import Message, RxLogEntry + + +# ---------------------------------------------------------------------- +# CommandSink — used by MeshBot and GUI pages +# ---------------------------------------------------------------------- + +@runtime_checkable +class CommandSink(Protocol): + """Enqueue commands for the BLE worker.""" + + def put_command(self, cmd: Dict) -> None: ... + # ---------------------------------------------------------------------- # Writer — used by BLEWorker @@ -33,8 +52,8 @@ class SharedDataWriter(Protocol): def set_connected(self, connected: bool) -> None: ... def set_contacts(self, contacts_dict: Dict) -> None: ... def set_channels(self, channels: List[Dict]) -> None: ... - def add_message(self, msg: Dict) -> None: ... - def add_rx_log(self, entry: Dict) -> None: ... + def add_message(self, msg: Message) -> None: ... + def add_rx_log(self, entry: RxLogEntry) -> None: ... def get_next_command(self) -> Optional[Dict]: ... def get_contact_name_by_prefix(self, pubkey_prefix: str) -> str: ... def get_contact_by_name(self, name: str) -> Optional[tuple]: ... @@ -43,7 +62,7 @@ class SharedDataWriter(Protocol): # ---------------------------------------------------------------------- -# Reader — used by DashboardPage and RoutePage +# Reader — used by DashboardPage # ---------------------------------------------------------------------- @runtime_checkable @@ -70,9 +89,7 @@ class ContactLookup(Protocol): """Contact lookup interface used by RouteBuilder. RouteBuilder needs to resolve public key prefixes and names - to contact records. Path hashes are always available in the - message dict (decoded from the raw packet), so no archive - lookup is needed. + to contact records. """ def get_contact_by_prefix(self, pubkey_prefix: str) -> Optional[Dict]: ... diff --git a/meshcore-gui/meshcore_gui/shared_data.py b/meshcore_gui/core/shared_data.py similarity index 60% rename from meshcore-gui/meshcore_gui/shared_data.py rename to meshcore_gui/core/shared_data.py index 7e81e43..500f374 100644 --- a/meshcore-gui/meshcore_gui/shared_data.py +++ b/meshcore_gui/core/shared_data.py @@ -5,67 +5,46 @@ SharedData is the central data store shared between the BLE worker thread and the GUI main thread. All access goes through methods that acquire a threading.Lock so both threads can safely read and write. -Single-source architecture -~~~~~~~~~~~~~~~~~~~~~~~~~~ -Path data (repeater hashes) is embedded in each message dict at creation -time — decoded from the raw LoRa packet via ``meshcoredecoder``. There -is no temporal archive or deferred matching. +v4.1 changes +~~~~~~~~~~~~~ +- ``messages`` is now ``List[Message]`` (was ``List[Dict]``). +- ``rx_log`` is now ``List[RxLogEntry]`` (was ``List[Dict]``). +- ``DeviceInfo`` dataclass replaces loose scalar fields. +- ``get_snapshot()`` returns typed objects; UI code accesses attributes + directly (``msg.sender``) instead of dict keys (``msg['sender']``). """ import queue import threading +from dataclasses import asdict from typing import Dict, List, Optional, Tuple from meshcore_gui.config import debug_print +from meshcore_gui.core.models import DeviceInfo, Message, RxLogEntry class SharedData: """ Thread-safe container for shared data between BLE worker and GUI. - Attributes: - lock: Threading lock for thread-safe access - name: Device name - public_key: Device public key - radio_freq: Radio frequency in MHz - radio_sf: Spreading factor - radio_bw: Bandwidth in kHz - tx_power: Transmit power in dBm - adv_lat: Advertised latitude - adv_lon: Advertised longitude - firmware_version: Firmware version string - connected: Whether device is connected - status: Status text for UI - contacts: Dict of contacts {key: {adv_name, type, lat, lon, …}} - channels: List of channels [{idx, name}, …] - messages: List of messages - rx_log: List of RX log entries + Implements all four Protocol interfaces defined in ``protocols.py``. """ def __init__(self) -> None: - """Initialize SharedData with empty values and flags set to True.""" self.lock = threading.Lock() - # Device info - self.name: str = "" - self.public_key: str = "" - self.radio_freq: float = 0.0 - self.radio_sf: int = 0 - self.radio_bw: float = 0.0 - self.tx_power: int = 0 - self.adv_lat: float = 0.0 - self.adv_lon: float = 0.0 - self.firmware_version: str = "" + # Device info (typed) + self.device = DeviceInfo() # Connection status self.connected: bool = False self.status: str = "Starting..." - # Data collections + # Data collections (typed) self.contacts: Dict = {} self.channels: List[Dict] = [] - self.messages: List[Dict] = [] - self.rx_log: List[Dict] = [] + self.messages: List[Message] = [] + self.rx_log: List[RxLogEntry] = [] # Command queue (GUI → BLE) self.cmd_queue: queue.Queue = queue.Queue() @@ -89,35 +68,36 @@ class SharedData: def update_from_appstart(self, payload: Dict) -> None: """Update device info from send_appstart response.""" with self.lock: - self.name = payload.get('name', self.name) - self.public_key = payload.get('public_key', self.public_key) - self.radio_freq = payload.get('radio_freq', self.radio_freq) - self.radio_sf = payload.get('radio_sf', self.radio_sf) - self.radio_bw = payload.get('radio_bw', self.radio_bw) - self.tx_power = payload.get('tx_power', self.tx_power) - self.adv_lat = payload.get('adv_lat', self.adv_lat) - self.adv_lon = payload.get('adv_lon', self.adv_lon) + d = self.device + d.name = payload.get('name', d.name) + d.public_key = payload.get('public_key', d.public_key) + d.radio_freq = payload.get('radio_freq', d.radio_freq) + d.radio_sf = payload.get('radio_sf', d.radio_sf) + d.radio_bw = payload.get('radio_bw', d.radio_bw) + d.tx_power = payload.get('tx_power', d.tx_power) + d.adv_lat = payload.get('adv_lat', d.adv_lat) + d.adv_lon = payload.get('adv_lon', d.adv_lon) self.device_updated = True - debug_print(f"Device info updated: {self.name}") + debug_print(f"Device info updated: {d.name}") def update_from_device_query(self, payload: Dict) -> None: """Update firmware version from send_device_query response.""" with self.lock: - self.firmware_version = payload.get('ver', self.firmware_version) + self.device.firmware_version = payload.get( + 'ver', self.device.firmware_version, + ) self.device_updated = True - debug_print(f"Firmware version: {self.firmware_version}") + debug_print(f"Firmware version: {self.device.firmware_version}") # ------------------------------------------------------------------ # Status # ------------------------------------------------------------------ def set_status(self, status: str) -> None: - """Update status text.""" with self.lock: self.status = status def set_connected(self, connected: bool) -> None: - """Update connection status.""" with self.lock: self.connected = connected @@ -126,13 +106,11 @@ class SharedData: # ------------------------------------------------------------------ def set_bot_enabled(self, enabled: bool) -> None: - """Toggle the BOT on or off.""" with self.lock: self.bot_enabled = enabled debug_print(f"BOT {'enabled' if enabled else 'disabled'}") def is_bot_enabled(self) -> bool: - """Return whether the BOT is currently enabled.""" with self.lock: return self.bot_enabled @@ -141,16 +119,9 @@ class SharedData: # ------------------------------------------------------------------ def put_command(self, cmd: Dict) -> None: - """Enqueue a command for the BLE worker.""" self.cmd_queue.put(cmd) def get_next_command(self) -> Optional[Dict]: - """ - Dequeue the next command, or return None if the queue is empty. - - Returns: - Command dictionary, or None. - """ try: return self.cmd_queue.get_nowait() except queue.Empty: @@ -161,39 +132,29 @@ class SharedData: # ------------------------------------------------------------------ def set_contacts(self, contacts_dict: Dict) -> None: - """Replace the contacts dictionary.""" with self.lock: self.contacts = contacts_dict.copy() self.contacts_updated = True debug_print(f"Contacts updated: {len(self.contacts)} contacts") def set_channels(self, channels: List[Dict]) -> None: - """Replace the channels list.""" with self.lock: self.channels = channels.copy() self.channels_updated = True debug_print(f"Channels updated: {[c['name'] for c in channels]}") - def add_message(self, msg: Dict) -> None: - """ - Add a message to the messages list (max 100). - - Args: - msg: Message dict with keys: time, sender, text, channel, - direction, path_len, path_hashes, message_hash, - sender_pubkey, snr - """ + def add_message(self, msg: Message) -> None: + """Add a Message to the store (max 100).""" with self.lock: self.messages.append(msg) if len(self.messages) > 100: self.messages.pop(0) debug_print( - f"Message added: {msg.get('sender', '?')}: " - f"{msg.get('text', '')[:30]}" + f"Message added: {msg.sender}: {msg.text[:30]}" ) - def add_rx_log(self, entry: Dict) -> None: - """Add an RX log entry (max 50, newest first).""" + def add_rx_log(self, entry: RxLogEntry) -> None: + """Add an RxLogEntry (max 50, newest first).""" with self.lock: self.rx_log.insert(0, entry) if len(self.rx_log) > 50: @@ -205,24 +166,34 @@ class SharedData: # ------------------------------------------------------------------ def get_snapshot(self) -> Dict: - """Create a complete snapshot of all data for the GUI.""" + """Create a complete snapshot of all data for the GUI. + + Returns a plain dict with typed objects inside. The + ``messages`` and ``rx_log`` values are lists of dataclass + instances (not dicts). + """ with self.lock: + d = self.device return { - 'name': self.name, - 'public_key': self.public_key, - 'radio_freq': self.radio_freq, - 'radio_sf': self.radio_sf, - 'radio_bw': self.radio_bw, - 'tx_power': self.tx_power, - 'adv_lat': self.adv_lat, - 'adv_lon': self.adv_lon, - 'firmware_version': self.firmware_version, + # DeviceInfo fields (flat for backward compat) + 'name': d.name, + 'public_key': d.public_key, + 'radio_freq': d.radio_freq, + 'radio_sf': d.radio_sf, + 'radio_bw': d.radio_bw, + 'tx_power': d.tx_power, + 'adv_lat': d.adv_lat, + 'adv_lon': d.adv_lon, + 'firmware_version': d.firmware_version, + # Status 'connected': self.connected, 'status': self.status, + # Collections (typed copies) 'contacts': self.contacts.copy(), 'channels': self.channels.copy(), 'messages': self.messages.copy(), 'rx_log': self.rx_log.copy(), + # Flags 'device_updated': self.device_updated, 'contacts_updated': self.contacts_updated, 'channels_updated': self.channels_updated, @@ -232,7 +203,6 @@ class SharedData: } def clear_update_flags(self) -> None: - """Reset all update flags to False.""" with self.lock: self.device_updated = False self.contacts_updated = False @@ -240,7 +210,6 @@ class SharedData: self.rxlog_updated = False def mark_gui_initialized(self) -> None: - """Mark that the GUI has completed its first render.""" with self.lock: self.gui_initialized = True debug_print("GUI marked as initialized") @@ -250,18 +219,8 @@ class SharedData: # ------------------------------------------------------------------ def get_contact_by_prefix(self, pubkey_prefix: str) -> Optional[Dict]: - """ - Look up a contact by public key prefix. - - Used by route visualization to resolve pubkey prefixes (from - messages and out_path) to full contact records. - - Returns: - Copy of the contact dictionary, or None if not found. - """ if not pubkey_prefix: return None - with self.lock: for key, contact in self.contacts.items(): if key.startswith(pubkey_prefix) or pubkey_prefix.startswith(key): @@ -269,60 +228,34 @@ class SharedData: return None def get_contact_name_by_prefix(self, pubkey_prefix: str) -> str: - """ - Look up a contact name by public key prefix. - - Returns: - The contact's adv_name, or the first 8 chars of the prefix - if not found, or empty string if prefix is empty. - """ if not pubkey_prefix: return "" - with self.lock: for key, contact in self.contacts.items(): if key.startswith(pubkey_prefix): name = contact.get('adv_name', '') if name: return name - return pubkey_prefix[:8] - # ------------------------------------------------------------------ - # Contact lookup by name - # ------------------------------------------------------------------ - def get_contact_by_name(self, name: str) -> Optional[Tuple[str, Dict]]: - """ - Look up a contact by advertised name. - - Tries in order: exact match → case-insensitive → startswith - (either direction, to handle truncated names). - - Returns: - ``(pubkey, contact_dict)`` tuple, or ``None`` if no match. - """ if not name: return None - with self.lock: # Strategy 1: exact match for key, contact in self.contacts.items(): if contact.get('adv_name', '') == name: return (key, contact.copy()) - # Strategy 2: case-insensitive name_lower = name.lower() for key, contact in self.contacts.items(): if contact.get('adv_name', '').lower() == name_lower: return (key, contact.copy()) - - # Strategy 3: one name starts with the other + # Strategy 3: prefix match for key, contact in self.contacts.items(): adv = contact.get('adv_name', '') if not adv: continue if name.startswith(adv) or adv.startswith(name): return (key, contact.copy()) - return None diff --git a/meshcore_gui/gui/__init__.py b/meshcore_gui/gui/__init__.py new file mode 100644 index 0000000..b0da0df --- /dev/null +++ b/meshcore_gui/gui/__init__.py @@ -0,0 +1,3 @@ +""" +Presentation layer — NiceGUI pages and panels. +""" diff --git a/meshcore_gui/gui/constants.py b/meshcore_gui/gui/constants.py new file mode 100644 index 0000000..b42e92c --- /dev/null +++ b/meshcore_gui/gui/constants.py @@ -0,0 +1,11 @@ +""" +Display constants for the GUI layer. + +Contact type → icon/name/label mappings used by multiple panels. +""" + +from typing import Dict + +TYPE_ICONS: Dict[int, str] = {0: "○", 1: "📱", 2: "📡", 3: "🏠"} +TYPE_NAMES: Dict[int, str] = {0: "-", 1: "CLI", 2: "REP", 3: "ROOM"} +TYPE_LABELS: Dict[int, str] = {0: "-", 1: "Companion", 2: "Repeater", 3: "Room Server"} diff --git a/meshcore_gui/gui/dashboard.py b/meshcore_gui/gui/dashboard.py new file mode 100644 index 0000000..5a03c47 --- /dev/null +++ b/meshcore_gui/gui/dashboard.py @@ -0,0 +1,165 @@ +""" +Main dashboard page for MeshCore GUI. + +Thin orchestrator that owns the layout and the 500 ms update timer. +All visual content is delegated to individual panel classes in +:mod:`meshcore_gui.gui.panels`. +""" + +import logging + +from nicegui import ui + +from meshcore_gui.core.protocols import SharedDataReader +from meshcore_gui.gui.panels import ( + ActionsPanel, + ContactsPanel, + DevicePanel, + FilterPanel, + InputPanel, + MapPanel, + MessagesPanel, + RxLogPanel, +) + + +# Suppress the harmless "Client has been deleted" warning that NiceGUI +# emits when a browser tab is refreshed while a ui.timer is active. +class _DeletedClientFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + return 'Client has been deleted' not in record.getMessage() + +logging.getLogger('nicegui').addFilter(_DeletedClientFilter()) + + +class DashboardPage: + """Main dashboard rendered at ``/``. + + Args: + shared: SharedDataReader for data access and command dispatch. + """ + + def __init__(self, shared: SharedDataReader) -> None: + self._shared = shared + + # Panels (created fresh on each render) + self._device: DevicePanel | None = None + self._contacts: ContactsPanel | None = None + self._map: MapPanel | None = None + self._input: InputPanel | None = None + self._filter: FilterPanel | None = None + self._messages: MessagesPanel | None = None + self._actions: ActionsPanel | None = None + self._rxlog: RxLogPanel | None = None + + # Header status label + self._status_label = None + + # Local first-render flag + self._initialized: bool = False + + # ------------------------------------------------------------------ + # Public + # ------------------------------------------------------------------ + + def render(self) -> None: + """Build the complete dashboard layout and start the timer.""" + self._initialized = False + + # Create panel instances + put_cmd = self._shared.put_command + self._device = DevicePanel() + self._contacts = ContactsPanel(put_cmd) + self._map = MapPanel() + self._input = InputPanel(put_cmd) + self._filter = FilterPanel(self._shared.set_bot_enabled) + self._messages = MessagesPanel() + self._actions = ActionsPanel(put_cmd) + self._rxlog = RxLogPanel() + + ui.dark_mode(False) + + # Header + with ui.header().classes('bg-blue-600 text-white'): + ui.label('🔗 MeshCore').classes('text-xl font-bold') + ui.space() + self._status_label = ui.label('Starting...').classes('text-sm') + + # Three-column layout + with ui.row().classes('w-full h-full gap-2 p-2'): + # Left column + with ui.column().classes('w-64 gap-2'): + self._device.render() + self._contacts.render() + + # Centre column + with ui.column().classes('flex-grow gap-2'): + self._map.render() + self._input.render() + self._filter.render() + self._messages.render() + + # Right column + with ui.column().classes('w-64 gap-2'): + self._actions.render() + self._rxlog.render() + + # Start update timer + ui.timer(0.5, self._update_ui) + + # ------------------------------------------------------------------ + # Timer-driven UI update + # ------------------------------------------------------------------ + + def _update_ui(self) -> None: + try: + if not self._status_label: + return + + data = self._shared.get_snapshot() + is_first = not self._initialized + + # Always update status + self._status_label.text = data['status'] + + # Device info + if data['device_updated'] or is_first: + self._device.update(data) + + # Channels → filter checkboxes + input dropdown + if data['channels_updated'] or is_first: + self._filter.update(data) + self._input.update_channel_options(data['channels']) + + # Contacts + if data['contacts_updated'] or is_first: + self._contacts.update(data) + + # Map + if data['contacts'] and ( + data['contacts_updated'] or not self._map.has_markers or is_first + ): + self._map.update(data) + + # Messages (always — for live filter changes) + self._messages.update( + data, + self._filter.channel_filters, + self._filter.last_channels, + ) + + # RX Log + if data['rxlog_updated']: + self._rxlog.update(data) + + # Clear flags and mark initialised + self._shared.clear_update_flags() + + if is_first and data['channels'] and data['contacts']: + self._initialized = True + self._shared.mark_gui_initialized() + + except Exception as e: + err = str(e).lower() + if "deleted" not in err and "client" not in err: + print(f"GUI update error: {e}") diff --git a/meshcore_gui/gui/panels/__init__.py b/meshcore_gui/gui/panels/__init__.py new file mode 100644 index 0000000..8070286 --- /dev/null +++ b/meshcore_gui/gui/panels/__init__.py @@ -0,0 +1,16 @@ +""" +Individual dashboard panels — each panel is a single-responsibility class. + +Re-exports all panels for convenient importing:: + + from meshcore_gui.gui.panels import DevicePanel, ContactsPanel, ... +""" + +from meshcore_gui.gui.panels.device_panel import DevicePanel # noqa: F401 +from meshcore_gui.gui.panels.contacts_panel import ContactsPanel # noqa: F401 +from meshcore_gui.gui.panels.map_panel import MapPanel # noqa: F401 +from meshcore_gui.gui.panels.input_panel import InputPanel # noqa: F401 +from meshcore_gui.gui.panels.filter_panel import FilterPanel # noqa: F401 +from meshcore_gui.gui.panels.messages_panel import MessagesPanel # noqa: F401 +from meshcore_gui.gui.panels.actions_panel import ActionsPanel # noqa: F401 +from meshcore_gui.gui.panels.rxlog_panel import RxLogPanel # noqa: F401 diff --git a/meshcore_gui/gui/panels/actions_panel.py b/meshcore_gui/gui/panels/actions_panel.py new file mode 100644 index 0000000..b8c65d3 --- /dev/null +++ b/meshcore_gui/gui/panels/actions_panel.py @@ -0,0 +1,29 @@ +"""Actions panel — refresh and advertise buttons.""" + +from typing import Callable, Dict + +from nicegui import ui + + +class ActionsPanel: + """Action buttons in the right column. + + Args: + put_command: Callable to enqueue a command dict for the BLE worker. + """ + + def __init__(self, put_command: Callable[[Dict], None]) -> None: + self._put_command = put_command + + def render(self) -> None: + with ui.card().classes('w-full'): + ui.label('⚡ Actions').classes('font-bold text-gray-600') + with ui.row().classes('gap-2'): + ui.button('🔄 Refresh', on_click=self._refresh) + ui.button('📢 Advert', on_click=self._advert) + + def _refresh(self) -> None: + self._put_command({'action': 'refresh'}) + + def _advert(self) -> None: + self._put_command({'action': 'send_advert'}) diff --git a/meshcore_gui/gui/panels/contacts_panel.py b/meshcore_gui/gui/panels/contacts_panel.py new file mode 100644 index 0000000..da8bfd7 --- /dev/null +++ b/meshcore_gui/gui/panels/contacts_panel.py @@ -0,0 +1,87 @@ +"""Contacts panel — list of known mesh nodes with click-to-DM.""" + +from typing import Callable, Dict + +from nicegui import ui + +from meshcore_gui.gui.constants import TYPE_ICONS, TYPE_NAMES + + +class ContactsPanel: + """Displays contacts in the left column. Click opens a DM dialog. + + Args: + put_command: Callable to enqueue a command dict for the BLE worker. + """ + + def __init__(self, put_command: Callable[[Dict], None]) -> None: + self._put_command = put_command + self._container = None + + def render(self) -> None: + with ui.card().classes('w-full'): + ui.label('👥 Contacts').classes('font-bold text-gray-600') + self._container = ui.column().classes( + 'w-full gap-1 max-h-96 overflow-y-auto' + ) + + def update(self, data: Dict) -> None: + if not self._container: + return + + self._container.clear() + + with self._container: + for key, contact in data['contacts'].items(): + ctype = contact.get('type', 0) + icon = TYPE_ICONS.get(ctype, '○') + name = contact.get('adv_name', key[:12]) + type_name = TYPE_NAMES.get(ctype, '-') + lat = contact.get('adv_lat', 0) + lon = contact.get('adv_lon', 0) + has_loc = lat != 0 or lon != 0 + + tooltip = ( + f"{name}\nType: {type_name}\n" + f"Key: {key[:16]}...\nClick to send DM" + ) + if has_loc: + tooltip += f"\nLat: {lat:.4f}\nLon: {lon:.4f}" + + with ui.row().classes( + 'w-full items-center gap-2 p-1 ' + 'hover:bg-gray-100 rounded cursor-pointer' + ).on('click', lambda e, k=key, n=name: self._open_dm_dialog(k, n)): + ui.label(icon).classes('text-sm') + ui.label(name[:15]).classes( + 'text-sm flex-grow truncate' + ).tooltip(tooltip) + ui.label(type_name).classes('text-xs text-gray-500') + if has_loc: + ui.label('📍').classes('text-xs') + + # ------------------------------------------------------------------ + # DM dialog + # ------------------------------------------------------------------ + + def _open_dm_dialog(self, pubkey: str, contact_name: str) -> None: + with ui.dialog() as dialog, ui.card().classes('w-96'): + ui.label(f'💬 DM to {contact_name}').classes('font-bold text-lg') + msg_input = ui.input(placeholder='Type your message...').classes('w-full') + + with ui.row().classes('w-full justify-end gap-2 mt-4'): + ui.button('Cancel', on_click=dialog.close).props('flat') + + def send_dm(): + text = msg_input.value + if text: + self._put_command({ + 'action': 'send_dm', + 'pubkey': pubkey, + 'text': text, + 'contact_name': contact_name, + }) + dialog.close() + + ui.button('Send', on_click=send_dm).classes('bg-blue-500 text-white') + dialog.open() diff --git a/meshcore_gui/gui/panels/device_panel.py b/meshcore_gui/gui/panels/device_panel.py new file mode 100644 index 0000000..a873e0f --- /dev/null +++ b/meshcore_gui/gui/panels/device_panel.py @@ -0,0 +1,40 @@ +"""Device information panel — radio name, frequency, location, firmware.""" + +from typing import Dict + +from nicegui import ui + + +class DevicePanel: + """Displays device info in the left column.""" + + def __init__(self) -> None: + self._label = None + + def render(self) -> None: + with ui.card().classes('w-full'): + ui.label('📡 Device').classes('font-bold text-gray-600') + self._label = ui.label('Connecting...').classes( + 'text-sm whitespace-pre-line' + ) + + def update(self, data: Dict) -> None: + if not self._label: + return + + lines = [] + if data['name']: + lines.append(f"📡 {data['name']}") + if data['public_key']: + lines.append(f"🔑 {data['public_key'][:16]}...") + if data['radio_freq']: + lines.append(f"📻 {data['radio_freq']:.3f} MHz") + lines.append(f"⚙️ SF{data['radio_sf']} / {data['radio_bw']} kHz") + if data['tx_power']: + lines.append(f"⚡ TX: {data['tx_power']} dBm") + if data['adv_lat'] and data['adv_lon']: + lines.append(f"📍 {data['adv_lat']:.4f}, {data['adv_lon']:.4f}") + if data['firmware_version']: + lines.append(f"🏷️ {data['firmware_version']}") + + self._label.text = "\n".join(lines) if lines else "Loading..." diff --git a/meshcore_gui/gui/panels/filter_panel.py b/meshcore_gui/gui/panels/filter_panel.py new file mode 100644 index 0000000..5901f6c --- /dev/null +++ b/meshcore_gui/gui/panels/filter_panel.py @@ -0,0 +1,61 @@ +"""Filter panel — channel filter checkboxes and bot toggle.""" + +from typing import Callable, Dict, List + +from nicegui import ui + + +class FilterPanel: + """Channel filter checkboxes and bot on/off toggle. + + Args: + set_bot_enabled: Callable to toggle the bot in SharedData. + """ + + def __init__(self, set_bot_enabled: Callable[[bool], None]) -> None: + self._set_bot_enabled = set_bot_enabled + self._container = None + self._bot_checkbox = None + self._channel_filters: Dict = {} + self._last_channels: List[Dict] = [] + + @property + def channel_filters(self) -> Dict: + """Current filter checkboxes (key: channel idx or ``'DM'``).""" + return self._channel_filters + + @property + def last_channels(self) -> List[Dict]: + """Channel list from the most recent update.""" + return self._last_channels + + def render(self) -> None: + with ui.card().classes('w-full'): + with ui.row().classes('w-full items-center gap-4 justify-center'): + ui.label('📻 Filter:').classes('text-sm text-gray-600') + self._container = ui.row().classes('gap-4') + + def update(self, data: Dict) -> None: + """Rebuild checkboxes when channel data changes.""" + if not self._container or not data['channels']: + return + + self._container.clear() + self._channel_filters = {} + + with self._container: + self._bot_checkbox = ui.checkbox( + '🤖 BOT', + value=data.get('bot_enabled', False), + on_change=lambda e: self._set_bot_enabled(e.value), + ) + ui.label('│').classes('text-gray-300') + + cb_dm = ui.checkbox('DM', value=True) + self._channel_filters['DM'] = cb_dm + + for ch in data['channels']: + cb = ui.checkbox(f"[{ch['idx']}] {ch['name']}", value=True) + self._channel_filters[ch['idx']] = cb + + self._last_channels = data['channels'] diff --git a/meshcore_gui/gui/panels/input_panel.py b/meshcore_gui/gui/panels/input_panel.py new file mode 100644 index 0000000..2a96230 --- /dev/null +++ b/meshcore_gui/gui/panels/input_panel.py @@ -0,0 +1,59 @@ +"""Input panel — message input field, channel selector and send button.""" + +from typing import Callable, Dict, List + +from nicegui import ui + + +class InputPanel: + """Message composition panel in the centre column. + + Args: + put_command: Callable to enqueue a command dict for the BLE worker. + """ + + def __init__(self, put_command: Callable[[Dict], None]) -> None: + self._put_command = put_command + self._msg_input = None + self._channel_select = None + + @property + def channel_select(self): + """Expose channel_select so FilterPanel can update its options.""" + return self._channel_select + + def render(self) -> None: + with ui.card().classes('w-full'): + with ui.row().classes('w-full items-center gap-2'): + self._msg_input = ui.input( + placeholder='Message...' + ).classes('flex-grow') + + self._channel_select = ui.select( + options={0: '[0] Public'}, value=0 + ).classes('w-32') + + ui.button( + 'Send', on_click=self._send_message + ).classes('bg-blue-500 text-white') + + def update_channel_options(self, channels: List[Dict]) -> None: + """Update the channel dropdown options.""" + if not self._channel_select or not channels: + return + opts = {ch['idx']: f"[{ch['idx']}] {ch['name']}" for ch in channels} + self._channel_select.options = opts + if self._channel_select.value not in opts: + self._channel_select.value = list(opts.keys())[0] + self._channel_select.update() + + def _send_message(self) -> None: + text = self._msg_input.value + channel = self._channel_select.value + if text: + self._put_command({ + 'action': 'send_message', + 'channel': channel, + 'text': text, + }) + self._msg_input.value = '' diff --git a/meshcore_gui/gui/panels/map_panel.py b/meshcore_gui/gui/panels/map_panel.py new file mode 100644 index 0000000..bce2471 --- /dev/null +++ b/meshcore_gui/gui/panels/map_panel.py @@ -0,0 +1,49 @@ +"""Map panel — Leaflet map with own position and contact markers.""" + +from typing import Dict, List + +from nicegui import ui + + +class MapPanel: + """Interactive Leaflet map in the centre column.""" + + def __init__(self) -> None: + self._map = None + self._markers: List = [] + + @property + def has_markers(self) -> bool: + return bool(self._markers) + + def render(self) -> None: + with ui.card().classes('w-full'): + self._map = ui.leaflet( + center=(52.5, 6.0), zoom=9 + ).classes('w-full h-72') + + def update(self, data: Dict) -> None: + if not self._map: + return + + # Remove old markers + for marker in self._markers: + try: + self._map.remove_layer(marker) + except Exception: + pass + self._markers.clear() + + # Own position + if data['adv_lat'] and data['adv_lon']: + m = self._map.marker(latlng=(data['adv_lat'], data['adv_lon'])) + self._markers.append(m) + self._map.set_center((data['adv_lat'], data['adv_lon'])) + + # Contact markers + for key, contact in data['contacts'].items(): + lat = contact.get('adv_lat', 0) + lon = contact.get('adv_lon', 0) + if lat != 0 or lon != 0: + m = self._map.marker(latlng=(lat, lon)) + self._markers.append(m) diff --git a/meshcore_gui/gui/panels/messages_panel.py b/meshcore_gui/gui/panels/messages_panel.py new file mode 100644 index 0000000..e44bb69 --- /dev/null +++ b/meshcore_gui/gui/panels/messages_panel.py @@ -0,0 +1,89 @@ +"""Messages panel — filtered message display with route navigation.""" + +from typing import Dict, List + +from nicegui import ui + +from meshcore_gui.core.models import Message + + +class MessagesPanel: + """Displays filtered messages in the centre column. + + Messages are filtered based on channel checkboxes managed by + :class:`~meshcore_gui.gui.panels.filter_panel.FilterPanel`. + """ + + def __init__(self) -> None: + self._container = None + + def render(self) -> None: + with ui.card().classes('w-full'): + ui.label('💬 Messages').classes('font-bold text-gray-600') + self._container = ui.column().classes( + 'w-full h-40 overflow-y-auto gap-0 text-sm font-mono ' + 'bg-gray-50 p-2 rounded' + ) + + def update( + self, + data: Dict, + channel_filters: Dict, + last_channels: List[Dict], + ) -> None: + """Refresh messages applying current filter state. + + Args: + data: Snapshot dict from SharedData. + channel_filters: ``{channel_idx: checkbox, 'DM': checkbox}`` + from FilterPanel. + last_channels: Channel list from FilterPanel. + """ + if not self._container: + return + + channel_names = {ch['idx']: ch['name'] for ch in last_channels} + messages: List[Message] = data['messages'] + + # Apply filters + filtered = [] + for orig_idx, msg in enumerate(messages): + if msg.channel is None: + if channel_filters.get('DM') and not channel_filters['DM'].value: + continue + else: + if msg.channel in channel_filters and not channel_filters[msg.channel].value: + continue + filtered.append((orig_idx, msg)) + + # Rebuild + self._container.clear() + + with self._container: + for orig_idx, msg in reversed(filtered[-50:]): + direction = '→' if msg.direction == 'out' else '←' + + ch_label = ( + f"[{channel_names.get(msg.channel, f'ch{msg.channel}')}]" + if msg.channel is not None + else '[DM]' + ) + + path_len = msg.path_len + has_path = bool(msg.path_hashes) + if msg.direction == 'in' and path_len > 0: + hop_tag = f' [{path_len}h{"✓" if has_path else ""}]' + else: + hop_tag = '' + + if msg.sender: + line = f"{msg.time} {direction} {ch_label}{hop_tag} {msg.sender}: {msg.text}" + else: + line = f"{msg.time} {direction} {ch_label}{hop_tag} {msg.text}" + + ui.label(line).classes( + 'text-xs leading-tight cursor-pointer ' + 'hover:bg-blue-50 rounded px-1' + ).on('click', lambda e, i=orig_idx: ui.navigate.to( + f'/route/{i}', new_tab=True + )) diff --git a/meshcore_gui/gui/panels/rxlog_panel.py b/meshcore_gui/gui/panels/rxlog_panel.py new file mode 100644 index 0000000..9e74e63 --- /dev/null +++ b/meshcore_gui/gui/panels/rxlog_panel.py @@ -0,0 +1,41 @@ +"""RX log panel — table of recently received packets.""" + +from typing import Dict, List + +from nicegui import ui + +from meshcore_gui.core.models import RxLogEntry + + +class RxLogPanel: + """RX log table in the right column.""" + + def __init__(self) -> None: + self._table = None + + def render(self) -> None: + with ui.card().classes('w-full'): + ui.label('📊 RX Log').classes('font-bold text-gray-600') + self._table = ui.table( + columns=[ + {'name': 'time', 'label': 'Time', 'field': 'time'}, + {'name': 'snr', 'label': 'SNR', 'field': 'snr'}, + {'name': 'type', 'label': 'Type', 'field': 'type'}, + ], + rows=[], + ).props('dense flat').classes('text-xs max-h-48 overflow-y-auto') + + def update(self, data: Dict) -> None: + if not self._table: + return + entries: List[RxLogEntry] = data['rx_log'][:20] + rows = [ + { + 'time': e.time, + 'snr': f"{e.snr:.1f}", + 'type': e.payload_type, + } + for e in entries + ] + self._table.rows = rows + self._table.update() diff --git a/meshcore-gui/meshcore_gui/route_page.py b/meshcore_gui/gui/route_page.py similarity index 62% rename from meshcore-gui/meshcore_gui/route_page.py rename to meshcore_gui/gui/route_page.py index d5ae211..3be5e89 100644 --- a/meshcore-gui/meshcore_gui/route_page.py +++ b/meshcore_gui/gui/route_page.py @@ -4,15 +4,22 @@ Route visualization page for MeshCore GUI. Standalone NiceGUI page that opens in a new browser tab when a user clicks on a message. Shows a Leaflet map with the message route, a hop count summary, and a details table. + +v4.1 changes +~~~~~~~~~~~~~ +- Uses :class:`~meshcore_gui.models.Message` and + :class:`~meshcore_gui.models.RouteNode` instead of plain dicts. """ -from typing import Dict +from typing import Dict, List from nicegui import ui -from meshcore_gui.config import TYPE_LABELS, debug_print -from meshcore_gui.route_builder import RouteBuilder -from meshcore_gui.protocols import SharedDataReadAndLookup +from meshcore_gui.gui.constants import TYPE_LABELS +from meshcore_gui.config import debug_print +from meshcore_gui.core.models import Message, RouteNode +from meshcore_gui.services.route_builder import RouteBuilder +from meshcore_gui.core.protocols import SharedDataReadAndLookup class RoutePage: @@ -32,27 +39,19 @@ class RoutePage: # ------------------------------------------------------------------ def render(self, msg_index: int) -> None: - """ - Render the route page for a specific message. - - Args: - msg_index: Index into SharedData.messages list - """ data = self._shared.get_snapshot() + messages: List[Message] = data['messages'] - # Validate - if msg_index < 0 or msg_index >= len(data['messages']): + if msg_index < 0 or msg_index >= len(messages): ui.label('❌ Message not found').classes('text-xl p-8') return - msg = data['messages'][msg_index] + msg = messages[msg_index] route = self._builder.build(msg, data) - sender = msg.get('sender', 'Unknown') - ui.page_title(f'Route — {sender}') + ui.page_title(f'Route — {msg.sender or "Unknown"}') ui.dark_mode(False) - # Header with ui.header().classes('bg-blue-600 text-white'): ui.label('🗺️ MeshCore Route').classes('text-xl font-bold') @@ -68,27 +67,25 @@ class RoutePage: # ------------------------------------------------------------------ @staticmethod - def _render_message_info(msg: Dict) -> None: - """Message header with sender name and text.""" - sender = msg.get('sender', 'Unknown') - direction = '→ Sent' if msg['direction'] == 'out' else '← Received' + def _render_message_info(msg: Message) -> None: + sender = msg.sender or 'Unknown' + direction = '→ Sent' if msg.direction == 'out' else '← Received' ui.label(f'Message Route — {sender} ({direction})').classes('font-bold text-lg') ui.label( - f"{msg['time']} {sender}: " - f"{msg['text'][:120]}" + f"{msg.time} {sender}: {msg.text[:120]}" ).classes('text-sm text-gray-600') @staticmethod - def _render_hop_summary(msg: Dict, route: Dict) -> None: - """Hop count banner with SNR and path source.""" + def _render_hop_summary(msg: Message, route: Dict) -> None: msg_path_len = route['msg_path_len'] - resolved_hops = len(route['path_nodes']) + path_nodes: List[RouteNode] = route['path_nodes'] + resolved_hops = len(path_nodes) path_source = route.get('path_source', 'none') expected_repeaters = max(msg_path_len - 1, 0) with ui.card().classes('w-full'): with ui.row().classes('items-center gap-4'): - if msg['direction'] == 'in': + if msg.direction == 'in': if msg_path_len == 0: ui.label('📡 Direct (0 hops)').classes( 'text-lg font-bold text-green-600' @@ -108,7 +105,6 @@ class RoutePage: f'📶 SNR: {route["snr"]:.1f} dB' ).classes('text-sm text-gray-600') - # Resolution status if expected_repeaters > 0 and resolved_hops > 0: source_label = ( 'from received packet' @@ -118,8 +114,7 @@ class RoutePage: rpt = 'repeater' if expected_repeaters == 1 else 'repeaters' ui.label( f'✅ {resolved_hops} of {expected_repeaters} ' - f'{rpt} identified ' - f'({source_label})' + f'{rpt} identified ({source_label})' ).classes('text-xs text-gray-500 mt-1') elif msg_path_len > 0 and resolved_hops == 0: ui.label( @@ -130,12 +125,7 @@ class RoutePage: @staticmethod def _render_map(data: Dict, route: Dict) -> None: - """Leaflet map with route markers and polylines. - - Lines are only drawn between nodes that are **adjacent** in the - route and both have GPS coordinates. A node without coordinates - breaks the line so that no false connections are shown. - """ + """Leaflet map with route markers and polylines.""" with ui.card().classes('w-full'): if not route['has_locations']: ui.label( @@ -150,47 +140,34 @@ class RoutePage: center=(center_lat, center_lon), zoom=10 ).classes('w-full h-96') - # --- Build ordered list of positions (or None) --- + # Build ordered list of positions (or None) ordered = [] - # Sender - if route['sender']: - s = route['sender'] - if s['lat'] or s['lon']: - ordered.append((s['lat'], s['lon'])) - else: - ordered.append(None) + sender: RouteNode = route['sender'] + if sender: + ordered.append((sender.lat, sender.lon) if sender.has_location else None) else: ordered.append(None) - # Repeaters for node in route['path_nodes']: - if node['lat'] or node['lon']: - ordered.append((node['lat'], node['lon'])) - else: - ordered.append(None) + ordered.append((node.lat, node.lon) if node.has_location else None) - # Own position (receiver) - if data['adv_lat'] or data['adv_lon']: - ordered.append((data['adv_lat'], data['adv_lon'])) + self_node: RouteNode = route['self_node'] + if self_node.has_location: + ordered.append((self_node.lat, self_node.lon)) else: ordered.append(None) - # --- Place markers for all nodes with coordinates --- all_points = [p for p in ordered if p is not None] for lat, lon in all_points: route_map.marker(latlng=(lat, lon)) - # --- Draw line between all located nodes (skip unknowns) --- - # Nodes without coordinates are simply skipped so the line - # connects sender → known repeaters → receiver without gaps. if len(all_points) >= 2: route_map.generic_layer( name='polyline', args=[all_points, {'color': '#2563eb', 'weight': 3}], ) - # Center map on all located nodes if all_points: lats = [p[0] for p in all_points] lons = [p[1] for p in all_points] @@ -199,10 +176,10 @@ class RoutePage: ) @staticmethod - def _render_route_table(msg: Dict, data: Dict, route: Dict) -> None: - """Route details table with sender, hops and receiver.""" + def _render_route_table(msg: Message, data: Dict, route: Dict) -> None: msg_path_len = route['msg_path_len'] - resolved_hops = len(route['path_nodes']) + path_nodes: List[RouteNode] = route['path_nodes'] + resolved_hops = len(path_nodes) path_source = route.get('path_source', 'none') with ui.card().classes('w-full'): @@ -211,61 +188,55 @@ class RoutePage: rows = [] # Sender - if route['sender']: - s = route['sender'] - has_loc = s['lat'] != 0 or s['lon'] != 0 + sender: RouteNode = route['sender'] + if sender: rows.append({ 'hop': 'Start', - 'name': s['name'], - 'hash': s.get('pubkey', '')[:2].upper() if s.get('pubkey') else '-', - 'type': TYPE_LABELS.get(s['type'], '-'), - 'location': f"{s['lat']:.4f}, {s['lon']:.4f}" if has_loc else '-', + 'name': sender.name, + 'hash': sender.pubkey[:2].upper() if sender.pubkey else '-', + 'type': TYPE_LABELS.get(sender.type, '-'), + 'location': f"{sender.lat:.4f}, {sender.lon:.4f}" if sender.has_location else '-', 'role': '📱 Sender', }) else: - sender_pubkey = msg.get('sender_pubkey', '') rows.append({ 'hop': 'Start', - 'name': msg.get('sender', 'Unknown'), - 'hash': sender_pubkey[:2].upper() if sender_pubkey else '-', + 'name': msg.sender or 'Unknown', + 'hash': msg.sender_pubkey[:2].upper() if msg.sender_pubkey else '-', 'type': '-', 'location': '-', 'role': '📱 Sender', }) - # Resolved repeaters (from RX_LOG or out_path) - for i, node in enumerate(route['path_nodes']): - has_loc = node['lat'] != 0 or node['lon'] != 0 + # Repeaters + for i, node in enumerate(path_nodes): rows.append({ 'hop': str(i + 1), - 'name': node['name'], - 'hash': node.get('pubkey', '')[:2].upper() if node.get('pubkey') else '-', - 'type': TYPE_LABELS.get(node['type'], '-'), - 'location': f"{node['lat']:.4f}, {node['lon']:.4f}" if has_loc else '-', + 'name': node.name, + 'hash': node.pubkey[:2].upper() if node.pubkey else '-', + 'type': TYPE_LABELS.get(node.type, '-'), + 'location': f"{node.lat:.4f}, {node.lon:.4f}" if node.has_location else '-', 'role': '📡 Repeater', }) - # Placeholder rows when no path data was resolved - if not route['path_nodes'] and msg_path_len > 0: + # Placeholder rows + if not path_nodes and msg_path_len > 0: for i in range(msg_path_len): rows.append({ 'hop': str(i + 1), - 'name': '-', - 'hash': '-', - 'type': '-', - 'location': '-', - 'role': '📡 Repeater', + 'name': '-', 'hash': '-', 'type': '-', + 'location': '-', 'role': '📡 Repeater', }) # Own position - self_has_loc = data['adv_lat'] != 0 or data['adv_lon'] != 0 + self_node: RouteNode = route['self_node'] rows.append({ 'hop': 'End', - 'name': data['name'] or 'Me', + 'name': self_node.name, 'hash': '-', 'type': 'Companion', - 'location': f"{data['adv_lat']:.4f}, {data['adv_lon']:.4f}" if self_has_loc else '-', - 'role': '📱 Receiver' if msg['direction'] == 'in' else '📱 Sender', + 'location': f"{self_node.lat:.4f}, {self_node.lon:.4f}" if self_node.has_location else '-', + 'role': '📱 Receiver' if msg.direction == 'in' else '📱 Sender', }) ui.table( @@ -280,8 +251,8 @@ class RoutePage: rows=rows, ).props('dense flat bordered').classes('w-full') - # Footnote based on path_source - if msg_path_len == 0 and msg['direction'] == 'in': + # Footnotes + if msg_path_len == 0 and msg.direction == 'in': ui.label( 'ℹ️ Direct message — no intermediate hops.' ).classes('text-xs text-gray-400 italic mt-2') @@ -297,32 +268,25 @@ class RoutePage: ).classes('text-xs text-gray-400 italic mt-2') elif msg_path_len > 0 and resolved_hops == 0: ui.label( - 'ℹ️ Repeater identities could not be resolved. ' - 'RX_LOG correlation may have missed the raw packet, ' - 'and sender has no stored out_path.' + 'ℹ️ Repeater identities could not be resolved.' ).classes('text-xs text-gray-400 italic mt-2') - elif msg['direction'] == 'out': + elif msg.direction == 'out': ui.label( 'ℹ️ Hop information is only available for received messages.' ).classes('text-xs text-gray-400 italic mt-2') def _render_send_panel( - self, msg: Dict, route: Dict, data: Dict, + self, msg: Message, route: Dict, data: Dict, ) -> None: """Send widget pre-filled with route acknowledgement message.""" - sender = msg.get('sender', 'Unknown') - path_len = route['msg_path_len'] - path_hashes = msg.get('path_hashes', []) + path_hashes = msg.path_hashes - # Build pre-filled message: - # @SenderName Received in Zwolle path(3); B8>7B>F5 - parts = [f"@[{sender}] Received in Zwolle path({path_len})"] + parts = [f"@[{msg.sender or 'Unknown'}] Received in Zwolle path({msg.path_len})"] if path_hashes: path_str = '>'.join(h.upper() for h in path_hashes) parts.append(f"; {path_str}") prefilled = ''.join(parts) - # Channel options ch_options = { ch['idx']: f"[{ch['idx']}] {ch['name']}" for ch in data['channels'] @@ -332,13 +296,8 @@ class RoutePage: with ui.card().classes('w-full'): ui.label('📤 Reply').classes('font-bold text-gray-600') with ui.row().classes('w-full items-center gap-2'): - msg_input = ui.input( - value=prefilled, - ).classes('flex-grow') - - ch_select = ui.select( - options=ch_options, value=default_ch, - ).classes('w-32') + msg_input = ui.input(value=prefilled).classes('flex-grow') + ch_select = ui.select(options=ch_options, value=default_ch).classes('w-32') def send(inp=msg_input, sel=ch_select): text = inp.value @@ -350,6 +309,4 @@ class RoutePage: }) inp.value = '' - ui.button( - 'Send', on_click=send, - ).classes('bg-blue-500 text-white') + ui.button('Send', on_click=send).classes('bg-blue-500 text-white') diff --git a/meshcore_gui/services/__init__.py b/meshcore_gui/services/__init__.py new file mode 100644 index 0000000..f5fa647 --- /dev/null +++ b/meshcore_gui/services/__init__.py @@ -0,0 +1,3 @@ +""" +Business logic services — bot, deduplication and route building. +""" diff --git a/meshcore_gui/services/bot.py b/meshcore_gui/services/bot.py new file mode 100644 index 0000000..96bb11e --- /dev/null +++ b/meshcore_gui/services/bot.py @@ -0,0 +1,195 @@ +""" +Keyword-triggered auto-reply bot for MeshCore GUI. + +Extracted from BLEWorker to satisfy the Single Responsibility Principle. +The bot listens on a configured channel and replies to messages that +contain recognised keywords. + +Open/Closed +~~~~~~~~~~~ +New keywords are added via ``BotConfig.keywords`` (data) without +modifying the ``MeshBot`` class (code). Custom matching strategies +can be implemented by subclassing and overriding ``_match_keyword``. +""" + +import time +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional + +from meshcore_gui.config import debug_print + + +# ============================================================================== +# Bot defaults (previously in config.py) +# ============================================================================== + +# Channel indices the bot listens on (must match CHANNELS_CONFIG). +BOT_CHANNELS: frozenset = frozenset({1, 4}) # #test, #bot + +# Display name prepended to every bot reply. +BOT_NAME: str = "Zwolle Bot" + +# Minimum seconds between two bot replies (prevents reply-storms). +BOT_COOLDOWN_SECONDS: float = 5.0 + +# Keyword → reply template mapping. +# Available variables: {bot}, {sender}, {snr}, {path} +# The bot checks whether the incoming message text *contains* the keyword +# (case-insensitive). First match wins. +BOT_KEYWORDS: Dict[str, str] = { + 'test': '{bot}: {sender}, rcvd | SNR {snr} | {path}', + 'ping': '{bot}: Pong!', + 'help': '{bot}: test, ping, help', +} + + +@dataclass +class BotConfig: + """Configuration for :class:`MeshBot`. + + Attributes: + channels: Channel indices to listen on. + name: Display name prepended to replies. + cooldown_seconds: Minimum seconds between replies. + keywords: Keyword → reply template mapping. + """ + + channels: frozenset = field(default_factory=lambda: frozenset(BOT_CHANNELS)) + name: str = BOT_NAME + cooldown_seconds: float = BOT_COOLDOWN_SECONDS + keywords: Dict[str, str] = field(default_factory=lambda: dict(BOT_KEYWORDS)) + + +class MeshBot: + """Keyword-triggered auto-reply bot. + + The bot checks incoming messages against a set of keyword → template + pairs. When a keyword is found (case-insensitive substring match, + first match wins), the template is expanded and queued as a channel + message via *command_sink*. + + Args: + config: Bot configuration. + command_sink: Callable that enqueues a command dict for the + BLE worker (typically ``shared.put_command``). + enabled_check: Callable that returns ``True`` when the bot is + enabled (typically ``shared.is_bot_enabled``). + """ + + def __init__( + self, + config: BotConfig, + command_sink: Callable[[Dict], None], + enabled_check: Callable[[], bool], + ) -> None: + self._config = config + self._sink = command_sink + self._enabled = enabled_check + self._last_reply: float = 0.0 + + def check_and_reply( + self, + sender: str, + text: str, + channel_idx: Optional[int], + snr: Optional[float], + path_len: int, + path_hashes: Optional[List[str]] = None, + ) -> None: + """Evaluate an incoming message and queue a reply if appropriate. + + Guards (in order): + 1. Bot is enabled (checkbox in GUI). + 2. Message is on the configured channel. + 3. Sender is not the bot itself. + 4. Sender name does not end with ``'Bot'`` (prevent loops). + 5. Cooldown period has elapsed. + 6. Message text contains a recognised keyword. + """ + # Guard 1: enabled? + if not self._enabled(): + return + + # Guard 2: correct channel? + if channel_idx not in self._config.channels: + return + + # Guard 3: own messages? + if sender == "Me" or (text and text.startswith(self._config.name)): + return + + # Guard 4: other bots? + if sender and sender.rstrip().lower().endswith("bot"): + debug_print(f"BOT: skipping message from other bot '{sender}'") + return + + # Guard 5: cooldown? + now = time.time() + if now - self._last_reply < self._config.cooldown_seconds: + debug_print("BOT: cooldown active, skipping") + return + + # Guard 6: keyword match + template = self._match_keyword(text) + if template is None: + return + + # Build reply + path_str = self._format_path(path_len, path_hashes) + snr_str = f"{snr:.1f}" if snr is not None else "?" + reply = template.format( + bot=self._config.name, + sender=sender or "?", + snr=snr_str, + path=path_str, + ) + + self._last_reply = now + + self._sink({ + "action": "send_message", + "channel": channel_idx, + "text": reply, + "_bot": True, + }) + debug_print(f"BOT: queued reply to '{sender}': {reply}") + + # ------------------------------------------------------------------ + # Extension point (OCP) + # ------------------------------------------------------------------ + + def _match_keyword(self, text: str) -> Optional[str]: + """Return the reply template for the first matching keyword. + + Override this method for custom matching strategies (regex, + exact match, priority ordering, etc.). + + Returns: + Template string, or ``None`` if no keyword matched. + """ + text_lower = (text or "").lower() + for keyword, template in self._config.keywords.items(): + if keyword in text_lower: + return template + return None + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _format_path( + path_len: int, + path_hashes: Optional[List[str]], + ) -> str: + """Format path info as ``path(N); 8D>A8`` or ``path(0)``.""" + if not path_len: + return "path(0)" + + if not path_hashes: + return f"path({path_len})" + + hop_names = [h.upper() for h in path_hashes if h and len(h) >= 2] + if hop_names: + return f"path({path_len}); {'>'.join(hop_names)}" + return f"path({path_len})" diff --git a/meshcore_gui/services/dedup.py b/meshcore_gui/services/dedup.py new file mode 100644 index 0000000..b6ee460 --- /dev/null +++ b/meshcore_gui/services/dedup.py @@ -0,0 +1,108 @@ +""" +Message deduplication for MeshCore GUI. + +Extracted from BLEWorker to satisfy the Single Responsibility Principle. +Provides bounded-size deduplication via message hash and content keys. + +Two strategies are used because the two event sources carry different +identifiers: + +1. **Hash-based** — ``RX_LOG_DATA`` events produce a deterministic + ``message_hash``. When ``CHANNEL_MSG_RECV`` arrives for the same + packet, it is suppressed. + +2. **Content-based** — ``CHANNEL_MSG_RECV`` events do *not* include + ``message_hash``, so a composite key of ``channel:sender:text`` is + used as a fallback. + +Both stores are bounded to prevent unbounded memory growth. +""" + +from collections import OrderedDict + + +class MessageDeduplicator: + """Bounded-size message deduplication store. + + Uses an :class:`OrderedDict` as an LRU-style bounded set. + Oldest entries are evicted when the store exceeds ``max_size``. + + Args: + max_size: Maximum number of keys to retain. 200 is generous + for the typical message rate of a mesh network. + """ + + def __init__(self, max_size: int = 200) -> None: + self._max = max_size + self._seen: OrderedDict[str, None] = OrderedDict() + + def is_seen(self, key: str) -> bool: + """Check if a key has already been recorded.""" + return key in self._seen + + def mark(self, key: str) -> None: + """Record a key. Evicts the oldest entry if at capacity.""" + if key in self._seen: + # Move to end (most recent) + self._seen.move_to_end(key) + return + self._seen[key] = None + while len(self._seen) > self._max: + self._seen.popitem(last=False) + + def clear(self) -> None: + """Remove all recorded keys.""" + self._seen.clear() + + def __len__(self) -> int: + return len(self._seen) + + +class DualDeduplicator: + """Combined hash-based and content-based deduplication. + + Wraps two :class:`MessageDeduplicator` instances — one for + message hashes and one for content keys — behind a single + interface. + + Args: + max_size: Maximum entries per store. + """ + + def __init__(self, max_size: int = 200) -> None: + self._by_hash = MessageDeduplicator(max_size) + self._by_content = MessageDeduplicator(max_size) + + # -- Hash-based -- + + def mark_hash(self, message_hash: str) -> None: + """Record a message hash as processed.""" + if message_hash: + self._by_hash.mark(message_hash) + + def is_hash_seen(self, message_hash: str) -> bool: + """Check if a message hash has already been processed.""" + return bool(message_hash) and self._by_hash.is_seen(message_hash) + + # -- Content-based -- + + def mark_content(self, sender: str, channel, text: str) -> None: + """Record a content key as processed.""" + key = self._content_key(sender, channel, text) + self._by_content.mark(key) + + def is_content_seen(self, sender: str, channel, text: str) -> bool: + """Check if a content key has already been processed.""" + key = self._content_key(sender, channel, text) + return self._by_content.is_seen(key) + + # -- Bulk -- + + def clear(self) -> None: + """Clear both stores.""" + self._by_hash.clear() + self._by_content.clear() + + @staticmethod + def _content_key(sender: str, channel, text: str) -> str: + return f"{channel}:{sender}:{text}" diff --git a/meshcore-gui/meshcore_gui/route_builder.py b/meshcore_gui/services/route_builder.py similarity index 50% rename from meshcore-gui/meshcore_gui/route_builder.py rename to meshcore_gui/services/route_builder.py index fd07f02..67e36c7 100644 --- a/meshcore-gui/meshcore_gui/route_builder.py +++ b/meshcore_gui/services/route_builder.py @@ -5,28 +5,18 @@ Pure data logic — no UI code. Given a message and a data snapshot, this module constructs a route dictionary that describes the path the message has taken through the mesh network (sender → repeaters → receiver). -Path data sources (in priority order): - -1. **path_hashes** (from the message) — decoded from the raw LoRa - packet by ``meshcoredecoder`` via ``RX_LOG_DATA``. Each entry is a - 2-char hex string representing the first byte of a repeater's public - key. Always available when the packet was successfully decrypted - (single-source architecture). - -2. **out_path** (from the sender's contact record) — hex string where - each byte (2 hex chars) is the first byte of a repeater's public - key. Only available for known contacts with a stored route. This - is the *last known* route to/from that contact, not necessarily the - route of *this* message. - -3. **path_len only** — hop count from the message frame. Always - available for received messages but contains no repeater identities. +v4.1 changes +~~~~~~~~~~~~~ +- ``build()`` now accepts a :class:`~meshcore_gui.models.Message` + dataclass instead of a plain dict. +- Route nodes returned as :class:`~meshcore_gui.models.RouteNode`. """ from typing import Dict, List, Optional from meshcore_gui.config import debug_print -from meshcore_gui.protocols import ContactLookup +from meshcore_gui.core.models import Message, RouteNode +from meshcore_gui.core.protocols import ContactLookup class RouteBuilder: @@ -42,20 +32,19 @@ class RouteBuilder: def __init__(self, shared: ContactLookup) -> None: self._shared = shared - def build(self, msg: Dict, data: Dict) -> Dict: + def build(self, msg: Message, data: Dict) -> Dict: """ Build route data for a single message. Args: - msg: Message dict (must contain 'sender_pubkey', may contain - 'path_len', 'snr' and 'path_hashes') - data: Snapshot dictionary from SharedData.get_snapshot() + msg: Message dataclass instance. + data: Snapshot dictionary from SharedData.get_snapshot(). Returns: Dictionary with keys: - sender: {name, lat, lon, type, pubkey} or None - self_node: {name, lat, lon} - path_nodes: [{name, lat, lon, type, pubkey}, …] + sender: RouteNode or None + self_node: RouteNode + path_nodes: List[RouteNode] snr: float or None msg_path_len: int — hop count from the message itself has_locations: bool — True if any node has GPS coords @@ -63,20 +52,20 @@ class RouteBuilder: """ result: Dict = { 'sender': None, - 'self_node': { - 'name': data['name'] or 'Me', - 'lat': data['adv_lat'], - 'lon': data['adv_lon'], - }, + 'self_node': RouteNode( + name=data['name'] or 'Me', + lat=data['adv_lat'], + lon=data['adv_lon'], + ), 'path_nodes': [], - 'snr': msg.get('snr'), - 'msg_path_len': msg.get('path_len', 0), + 'snr': msg.snr, + 'msg_path_len': msg.path_len, 'has_locations': False, 'path_source': 'none', } # Look up sender in contacts - pubkey = msg.get('sender_pubkey', '') + pubkey = msg.sender_pubkey contact: Optional[Dict] = None debug_print( @@ -91,50 +80,37 @@ class RouteBuilder: f"{'FOUND ' + contact.get('adv_name', '?') if contact else 'NOT FOUND'}" ) if contact: - result['sender'] = { - 'name': contact.get('adv_name', pubkey[:8]), - 'lat': contact.get('adv_lat', 0), - 'lon': contact.get('adv_lon', 0), - 'type': contact.get('type', 0), - 'pubkey': pubkey, - } - debug_print( - f"Route build: sender hash will be " - f"{pubkey[:2].upper()!r}" + result['sender'] = RouteNode( + name=contact.get('adv_name', pubkey[:8]), + lat=contact.get('adv_lat', 0), + lon=contact.get('adv_lon', 0), + type=contact.get('type', 0), + pubkey=pubkey, ) else: # Deferred sender lookup: try fuzzy name match - # Use sender_full (untruncated) if available, fall back to sender - sender_name = msg.get('sender_full') or msg.get('sender', '') + sender_name = msg.sender if sender_name: match = self._shared.get_contact_by_name(sender_name) if match: pubkey, contact_data = match contact = contact_data - result['sender'] = { - 'name': contact_data.get('adv_name', pubkey[:8]), - 'lat': contact_data.get('adv_lat', 0), - 'lon': contact_data.get('adv_lon', 0), - 'type': contact_data.get('type', 0), - 'pubkey': pubkey, - } + result['sender'] = RouteNode( + name=contact_data.get('adv_name', pubkey[:8]), + lat=contact_data.get('adv_lat', 0), + lon=contact_data.get('adv_lon', 0), + type=contact_data.get('type', 0), + pubkey=pubkey, + ) debug_print( f"Route build: deferred name lookup " - f"'{sender_name}' → pubkey={pubkey[:16]!r}, " - f"hash={pubkey[:2].upper()!r}" + f"'{sender_name}' → pubkey={pubkey[:16]!r}" ) - else: - debug_print( - f"Route build: deferred name lookup " - f"'{sender_name}' → NOT FOUND" - ) - else: - debug_print("Route build: sender_pubkey is EMPTY, no name → hash will be '-'") # --- Resolve path nodes (priority order) --- - # Priority 1: path_hashes from RX_LOG decode (single-source) - rx_hashes = msg.get('path_hashes', []) + # Priority 1: path_hashes from RX_LOG decode + rx_hashes = msg.path_hashes if rx_hashes: result['path_nodes'] = self._resolve_hashes( @@ -165,15 +141,12 @@ class RouteBuilder: result['path_source'] = 'contact_out_path' # Determine if any node has GPS coordinates - all_points = [result['self_node']] + all_nodes: List[RouteNode] = [result['self_node']] if result['sender']: - all_points.append(result['sender']) - all_points.extend(result['path_nodes']) + all_nodes.append(result['sender']) + all_nodes.extend(result['path_nodes']) - result['has_locations'] = any( - p.get('lat', 0) != 0 or p.get('lon', 0) != 0 - for p in all_points - ) + result['has_locations'] = any(n.has_location for n in all_nodes) return result @@ -185,18 +158,9 @@ class RouteBuilder: def _resolve_hashes( hashes: List[str], contacts: Dict, - ) -> List[Dict]: - """ - Resolve a list of 1-byte path hashes into hop node dicts. - - Args: - hashes: List of 2-char hex strings (e.g. ["8d", "a8"]) - contacts: Contacts dictionary from snapshot - - Returns: - List of hop node dicts. - """ - nodes: List[Dict] = [] + ) -> List[RouteNode]: + """Resolve a list of 1-byte path hashes into RouteNode objects.""" + nodes: List[RouteNode] = [] for hop_hash in hashes: if not hop_hash or len(hop_hash) < 2: @@ -207,21 +171,18 @@ class RouteBuilder: ) if hop_contact: - nodes.append({ - 'name': hop_contact.get('adv_name', f'0x{hop_hash}'), - 'lat': hop_contact.get('adv_lat', 0), - 'lon': hop_contact.get('adv_lon', 0), - 'type': hop_contact.get('type', 0), - 'pubkey': hop_hash, - }) + nodes.append(RouteNode( + name=hop_contact.get('adv_name', f'0x{hop_hash}'), + lat=hop_contact.get('adv_lat', 0), + lon=hop_contact.get('adv_lon', 0), + type=hop_contact.get('type', 0), + pubkey=hop_hash, + )) else: - nodes.append({ - 'name': '-', - 'lat': 0, - 'lon': 0, - 'type': 0, - 'pubkey': hop_hash, - }) + nodes.append(RouteNode( + name='-', + pubkey=hop_hash, + )) return nodes @@ -230,18 +191,10 @@ class RouteBuilder: out_path: str, out_path_len: int, contacts: Dict, - ) -> List[Dict]: - """ - Parse out_path hex string into a list of hop nodes. - - Each byte (2 hex chars) in out_path is the first byte of a - repeater's public key. - - Returns: - List of hop node dicts. - """ + ) -> List[RouteNode]: + """Parse out_path hex string into a list of RouteNode objects.""" hashes: List[str] = [] - hop_hex_len = 2 # 1 byte = 2 hex chars + hop_hex_len = 2 for i in range(0, min(len(out_path), out_path_len * 2), hop_hex_len): hop_hash = out_path[i:i + hop_hex_len] @@ -254,12 +207,6 @@ class RouteBuilder: def _find_contact_by_pubkey_hash( hash_hex: str, contacts: Dict, ) -> Optional[Dict]: - """ - Find a contact whose pubkey starts with the given 1-byte hash. - - Note: with only 256 possible values, collisions are possible - when there are many contacts. Returns the first match. - """ hash_hex = hash_hex.lower() for pubkey, contact in contacts.items(): if pubkey.lower().startswith(hash_hex):