diff --git a/meshcore-gui/meshcore_gui.py b/meshcore-gui/meshcore_gui.py index 8f113b7..c8b9d04 100644 --- a/meshcore-gui/meshcore_gui.py +++ b/meshcore-gui/meshcore_gui.py @@ -11,7 +11,7 @@ Usage: python meshcore_gui.py --debug-on Author: PE1HVH - Version: 3.2 + Version: 4.0 SPDX-License-Identifier: MIT Copyright: (c) 2026 PE1HVH """ diff --git a/meshcore-gui/meshcore_gui/CHANGELOG_v4.0.md b/meshcore-gui/meshcore_gui/CHANGELOG_v4.0.md new file mode 100644 index 0000000..3346d83 --- /dev/null +++ b/meshcore-gui/meshcore_gui/CHANGELOG_v4.0.md @@ -0,0 +1,108 @@ +# MeshCore GUI v4.0 — Changelog + +## Probleem dat is opgelost + +Bij het opstarten van de GUI bufferde het systeem RX_LOG-pakketten die tijdens de +initialisatiefase binnenkwamen (send_appstart, send_device_query, get_contacts). +Deze pakketten werden later foutief gekoppeld aan het eerste echte channel message, +wat resulteerde in onmogelijke route-informatie (bijv. "22 of 7 repeaters identified"). + +Daarnaast gebruikte de oude code SNR-matching om RX_LOG-entries te koppelen aan +channel messages. Dit werkte niet betrouwbaar omdat het companion protocol en +RX_LOG verschillende SNR-waarden rapporteren voor hetzelfde fysieke pakket +(bijv. companion=13.5, RX_LOG=14.0). + +## Drie defensieve lagen + +### Laag 1: Startup buffer clearing (`ble_worker.py:126-135`) + +Na `start_auto_message_fetching()` worden de RX path buffer én het SharedData +archive geleegd. Alle pakketten die tijdens de init-fase zijn binnengekomen +worden weggegooid voordat het eerste echte channel message kan arriveren. + +```python +await self.mc.start_auto_message_fetching() +self._rx_path_buffer.clear() +self.shared.clear_rx_archive() +``` + +### Laag 2: Path_len sanity checks (alle matching paden) + +In elke matching-methode wordt gecontroleerd of het aantal hashes in een RX_LOG +entry niet wild afwijkt van het door het companion protocol gerapporteerde hop +count. De margin is configureerbaar via `PATH_LEN_SANITY_MARGIN = 5`. + +Aanwezig in: +- Forward matching (`ble_worker.py:366`) +- Retroactive matching (`ble_worker.py:304`) +- Archive matching (`shared_data.py:354`) + +Voorbeeld: een 7-hop bericht accepteert maximaal 12 hashes (7 + 5). +Een entry met 22 hashes wordt geweigerd. + +### Laag 3: Display-time guard (`route_page.py:89`) + +Laatste vangnet bij het renderen van de route-pagina. Als het aantal resolved +hops meer is dan 2× de path_len, wordt de match als false positive beschouwd +en verworpen. + +```python +if msg_path_len > 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/__init__.py b/meshcore-gui/meshcore_gui/__init__.py index 69a3ae8..9a4e646 100644 --- a/meshcore-gui/meshcore_gui/__init__.py +++ b/meshcore-gui/meshcore_gui/__init__.py @@ -5,4 +5,4 @@ A graphical user interface for MeshCore mesh network devices, communicating via Bluetooth Low Energy (BLE). """ -__version__ = "3.1" +__version__ = "4.0" diff --git a/meshcore-gui/meshcore_gui/ble_worker.py b/meshcore-gui/meshcore_gui/ble_worker.py index b5b4796..7e12ca2 100644 --- a/meshcore-gui/meshcore_gui/ble_worker.py +++ b/meshcore-gui/meshcore_gui/ble_worker.py @@ -4,19 +4,50 @@ 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 from datetime import datetime -from typing import Dict, Optional +from typing import Dict, List, Optional, Set from meshcore import MeshCore, EventType from meshcore_gui.config import 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. @@ -34,6 +65,22 @@ class BLEWorker: self.mc: Optional[MeshCore] = None self.running = True + # Packet decoder (channel keys loaded at startup) + self._decoder = PacketDecoder() + + # 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 # ------------------------------------------------------------------ @@ -77,6 +124,7 @@ class BLEWorker: 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) @@ -127,6 +175,95 @@ class BLEWorker: 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 # ------------------------------------------------------------------ @@ -159,6 +296,7 @@ class BLEWorker: 'channel': channel, 'direction': 'out', 'sender_pubkey': '', + 'path_hashes': [], }) debug_print(f"Sent message to channel {channel}: {text[:30]}") @@ -181,6 +319,7 @@ class BLEWorker: 'channel': None, 'direction': 'out', 'sender_pubkey': pubkey, + 'path_hashes': [], }) debug_print(f"Sent DM to {contact_name}: {text[:30]}") @@ -193,23 +332,157 @@ class BLEWorker: # Event callbacks # ------------------------------------------------------------------ - def _on_channel_msg(self, event) -> None: - """Callback for received channel messages.""" + 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}" + ) + + 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 - sender = payload.get('sender_name') or payload.get('sender') or '' 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[:15] if sender else '', - 'text': payload.get('text', ''), + 'sender': sender, + 'text': msg_text, 'channel': payload.get('channel_idx'), 'direction': 'in', - 'snr': payload.get('SNR') or payload.get('snr'), + 'snr': msg_snr, 'path_len': payload.get('path_len', 0), - 'sender_pubkey': payload.get('sender', ''), + 'sender_pubkey': sender_pubkey, + 'path_hashes': [], # No path data from companion event + 'message_hash': msg_hash, }) def _on_contact_msg(self, event) -> None: @@ -229,24 +502,17 @@ class BLEWorker: self.shared.add_message({ 'time': datetime.now().strftime('%H:%M:%S'), - 'sender': sender[:15] if sender else '', + '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}: {payload.get('text', '')[:30]}") - - def _on_rx_log(self, event) -> None: - """Callback for RX log data.""" - payload = event.payload - 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), - }) + debug_print( + f"DM received from {sender}: " + f"{payload.get('text', '')[:30]}" + ) diff --git a/meshcore-gui/meshcore_gui/main_page.py b/meshcore-gui/meshcore_gui/main_page.py index 94a64d7..3945714 100644 --- a/meshcore-gui/meshcore_gui/main_page.py +++ b/meshcore-gui/meshcore_gui/main_page.py @@ -314,7 +314,7 @@ class DashboardPage: channel_names = {ch['idx']: ch['name'] for ch in self._last_channels} filtered = [] - for msg in data['messages']: + 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: @@ -322,12 +322,12 @@ class DashboardPage: else: if ch in self._channel_filters and not self._channel_filters[ch].value: continue - filtered.append(msg) + filtered.append((orig_idx, msg)) self._messages_container.clear() with self._messages_container: - for msg in reversed(filtered[-50:]): + for orig_idx, msg in reversed(filtered[-50:]): direction = '→' if msg['direction'] == 'out' else '←' ch = msg['channel'] @@ -339,18 +339,21 @@ class DashboardPage: sender = msg.get('sender', '') path_len = msg.get('path_len', 0) - hop_tag = f' [{path_len}h]' if msg['direction'] == 'in' and path_len > 0 else '' + 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']}" - msg_idx = len(filtered) - 1 - filtered[::-1].index(msg) ui.label(line).classes( 'text-xs leading-tight cursor-pointer ' 'hover:bg-blue-50 rounded px-1' - ).on('click', lambda e, i=msg_idx: ui.navigate.to( + ).on('click', lambda e, i=orig_idx: ui.navigate.to( f'/route/{i}', new_tab=True )) diff --git a/meshcore-gui/meshcore_gui/meshcore_gui.py b/meshcore-gui/meshcore_gui/meshcore_gui.py new file mode 100644 index 0000000..c8b9d04 --- /dev/null +++ b/meshcore-gui/meshcore_gui/meshcore_gui.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +MeshCore GUI - Threaded BLE Edition +==================================== + +Entry point. Parses arguments, wires up the components, registers +NiceGUI pages and starts the server. + +Usage: + python meshcore_gui.py + python meshcore_gui.py --debug-on + + Author: PE1HVH + Version: 4.0 + SPDX-License-Identifier: MIT + Copyright: (c) 2026 PE1HVH +""" + +import sys + +from nicegui import ui + +# Allow overriding DEBUG and CHANNELS_CONFIG before anything imports them +import meshcore_gui.config as config + +try: + from meshcore import MeshCore, EventType # noqa: F401 — availability check +except ImportError: + print("ERROR: meshcore library not found") + 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 + + +# Global instances (needed by NiceGUI page decorators) +_shared = None +_dashboard = None +_route_page = None + + +@ui.page('/') +def _page_dashboard(): + """NiceGUI page handler — main dashboard.""" + if _dashboard: + _dashboard.render() + + +@ui.page('/route/{msg_index}') +def _page_route(msg_index: int): + """NiceGUI page handler — route visualization (new tab).""" + if _route_page: + _route_page.render(msg_index) + + +def main(): + """ + Main entry point. + + Parses CLI arguments, initialises all components and starts the + NiceGUI server. + """ + global _shared, _dashboard, _route_page + + # Parse arguments + args = [a for a in sys.argv[1:] if not a.startswith('--')] + flags = [a for a in sys.argv[1:] if a.startswith('--')] + + if not args: + print("MeshCore GUI - Threaded BLE Edition") + print("=" * 40) + print("Usage: python meshcore_gui.py [--debug-on]") + print("Example: python meshcore_gui.py literal:AA:BB:CC:DD:EE:FF") + print(" python meshcore_gui.py literal:AA:BB:CC:DD:EE:FF --debug-on") + print() + print("Options:") + print(" --debug-on Enable verbose debug logging") + print() + print("Tip: Use 'bluetoothctl scan on' to find devices") + sys.exit(1) + + ble_address = args[0] + + # Apply --debug-on flag + if '--debug-on' in flags: + config.DEBUG = True + + # Startup banner + print("=" * 50) + print("MeshCore GUI - Threaded BLE Edition") + print("=" * 50) + print(f"Device: {ble_address}") + print(f"Debug mode: {'ON' if config.DEBUG else 'OFF'}") + print("=" * 50) + + # Assemble components + _shared = SharedData() + _dashboard = DashboardPage(_shared) + _route_page = RoutePage(_shared) + + # Start BLE worker in background thread + worker = BLEWorker(ble_address, _shared) + worker.start() + + # Start NiceGUI server (blocks) + ui.run(title='MeshCore', port=8080, reload=False) + + +if __name__ == "__main__": + main() diff --git a/meshcore-gui/meshcore_gui/packet_parser.py b/meshcore-gui/meshcore_gui/packet_parser.py new file mode 100644 index 0000000..fc81712 --- /dev/null +++ b/meshcore-gui/meshcore_gui/packet_parser.py @@ -0,0 +1,206 @@ +""" +Packet decoder for MeshCore GUI — single-source approach. + +Wraps ``meshcoredecoder`` to decode raw LoRa packets from RX_LOG_DATA +events. A single raw packet contains **everything**: message_hash, +path hashes, hop count, and (with channel keys) the decrypted text +and sender name. + +No correlation with CHANNEL_MSG_RECV events is needed. + +Channel decryption keys are loaded at startup (fetched from the device +via ``get_channel()`` or derived from the channel name as fallback). +""" + +from dataclasses import dataclass, field +from hashlib import sha256 +from typing import Dict, List, Optional + +from meshcoredecoder import MeshCoreDecoder +from meshcoredecoder.crypto.channel_crypto import ChannelCrypto +from meshcoredecoder.crypto.key_manager import MeshCoreKeyStore +from meshcoredecoder.types.crypto import DecryptionOptions +from meshcoredecoder.types.enums import PayloadType + +from meshcore_gui.config import debug_print + + +# Re-export so other modules don't need to import meshcoredecoder +__all__ = ["PacketDecoder", "DecodedPacket", "PayloadType"] + + +# --------------------------------------------------------------------------- +# Decoded result +# --------------------------------------------------------------------------- + +@dataclass +class DecodedPacket: + """All data extracted from a single raw LoRa packet. + + Attributes: + message_hash: Deterministic packet identifier (hex string). + payload_type: Enum (GroupText, Advert, Ack, …). + path_length: Number of repeater hashes in the path. + path_hashes: 2-char hex strings, one per repeater. + sender: Sender name (GroupText only, after decryption). + text: Message body (GroupText only, after decryption). + channel_idx: Channel index (GroupText only, via hash→idx map). + timestamp: Message timestamp (GroupText only). + is_decrypted: True if payload was successfully decrypted. + """ + + message_hash: str + payload_type: PayloadType + path_length: int + path_hashes: List[str] = field(default_factory=list) + + # GroupText-specific (populated after successful decryption) + sender: str = "" + text: str = "" + channel_idx: Optional[int] = None + timestamp: int = 0 + is_decrypted: bool = False + + +# --------------------------------------------------------------------------- +# Decoder +# --------------------------------------------------------------------------- + +class PacketDecoder: + """Decode raw LoRa packets with channel-key decryption. + + Usage:: + + decoder = PacketDecoder() + decoder.add_channel_key(0, secret_bytes) # from device + decoder.add_channel_key_from_name(1, "#test") # fallback + + result = decoder.decode(payload_hex) + if result and result.is_decrypted: + print(result.sender, result.text, result.path_hashes) + """ + + def __init__(self) -> None: + self._key_store = MeshCoreKeyStore() + self._options: Optional[DecryptionOptions] = None + # channel_hash (2-char lower hex) → channel_idx + self._hash_to_idx: Dict[str, int] = {} + + # ------------------------------------------------------------------ + # Key management + # ------------------------------------------------------------------ + + def add_channel_key(self, channel_idx: int, secret_bytes: bytes) -> None: + """Register a channel decryption key (16 raw bytes from device). + + Args: + channel_idx: Channel index (0-based). + secret_bytes: 16-byte channel secret from ``get_channel()``. + """ + secret_hex = secret_bytes.hex() + self._key_store.add_channel_secrets([secret_hex]) + self._rebuild_options() + + ch_hash = ChannelCrypto.calculate_channel_hash(secret_hex).lower() + self._hash_to_idx[ch_hash] = channel_idx + debug_print( + f"PacketDecoder: key for ch{channel_idx} " + f"(hash={ch_hash}, from device)" + ) + + def add_channel_key_from_name( + self, channel_idx: int, channel_name: str, + ) -> None: + """Derive a channel key from the channel name (fallback). + + MeshCore derives channel secrets as + ``SHA-256(name.encode('utf-8'))[:16]``. + + Args: + channel_idx: Channel index (0-based). + channel_name: Channel name string (e.g. ``"#test"``). + """ + secret_bytes = sha256(channel_name.encode("utf-8")).digest()[:16] + self.add_channel_key(channel_idx, secret_bytes) + debug_print( + f"PacketDecoder: key for ch{channel_idx} " + f"(derived from '{channel_name}')" + ) + + @property + def has_keys(self) -> bool: + """True if at least one channel key has been registered.""" + return self._options is not None + + # ------------------------------------------------------------------ + # Decode + # ------------------------------------------------------------------ + + def decode(self, payload_hex: str) -> Optional[DecodedPacket]: + """Decode a raw LoRa packet hex string. + + Args: + payload_hex: Hex string from the RX_LOG_DATA event's + ``payload`` field. + + Returns: + :class:`DecodedPacket` on success, ``None`` if the data + is invalid or too short. + """ + if not payload_hex: + return None + + try: + packet = MeshCoreDecoder.decode(payload_hex, self._options) + except Exception as exc: + debug_print(f"PacketDecoder: decode error: {exc}") + return None + + if not packet.is_valid: + debug_print(f"PacketDecoder: invalid: {packet.errors}") + return None + + result = DecodedPacket( + message_hash=packet.message_hash, + payload_type=packet.payload_type, + path_length=packet.path_length, + path_hashes=list(packet.path) if packet.path else [], + ) + + # --- GroupText decryption --- + if packet.payload_type == PayloadType.GroupText: + decoded_payload = packet.payload.get("decoded") + if decoded_payload and decoded_payload.decrypted: + d = decoded_payload.decrypted + result.sender = d.get("sender", "") or "" + result.text = d.get("message", "") or "" + result.timestamp = d.get("timestamp", 0) + result.is_decrypted = True + + # Resolve channel_hash → channel_idx + ch_hash = decoded_payload.channel_hash.lower() + result.channel_idx = self._hash_to_idx.get(ch_hash) + + debug_print( + f"PacketDecoder: GroupText OK — " + f"hash={result.message_hash}, " + f"sender={result.sender!r}, " + f"ch={result.channel_idx}, " + f"path={result.path_hashes}, " + f"text={result.text[:40]!r}" + ) + else: + debug_print( + f"PacketDecoder: GroupText NOT decrypted " + f"(hash={result.message_hash})" + ) + + return result + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _rebuild_options(self) -> None: + """Recreate DecryptionOptions after a key change.""" + self._options = DecryptionOptions(key_store=self._key_store) diff --git a/meshcore-gui/meshcore_gui/protocols.py b/meshcore-gui/meshcore_gui/protocols.py index 71e97a0..87b1573 100644 --- a/meshcore-gui/meshcore_gui/protocols.py +++ b/meshcore-gui/meshcore_gui/protocols.py @@ -37,6 +37,7 @@ class SharedDataWriter(Protocol): def add_rx_log(self, entry: Dict) -> 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]: ... # ---------------------------------------------------------------------- @@ -65,11 +66,14 @@ class SharedDataReader(Protocol): class ContactLookup(Protocol): """Contact lookup interface used by RouteBuilder. - RouteBuilder only needs to resolve public key prefixes to - contact records. + 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. """ def get_contact_by_prefix(self, pubkey_prefix: str) -> Optional[Dict]: ... + def get_contact_by_name(self, name: str) -> Optional[tuple]: ... # ---------------------------------------------------------------------- diff --git a/meshcore-gui/meshcore_gui/route_builder.py b/meshcore-gui/meshcore_gui/route_builder.py index 0156637..fd07f02 100644 --- a/meshcore-gui/meshcore_gui/route_builder.py +++ b/meshcore-gui/meshcore_gui/route_builder.py @@ -5,15 +5,22 @@ 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). -The route information comes from two sources: +Path data sources (in priority order): -1. **path_len** (from the message itself) — number of hops the message - traveled. Always available for received messages. +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 when the sender is a known contact with a stored - route. + 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. """ from typing import Dict, List, Optional @@ -41,7 +48,7 @@ class RouteBuilder: Args: msg: Message dict (must contain 'sender_pubkey', may contain - 'path_len' and 'snr') + 'path_len', 'snr' and 'path_hashes') data: Snapshot dictionary from SharedData.get_snapshot() Returns: @@ -52,6 +59,7 @@ class RouteBuilder: snr: float or None msg_path_len: int — hop count from the message itself has_locations: bool — True if any node has GPS coords + path_source: str — 'rx_log', 'contact_out_path' or 'none' """ result: Dict = { 'sender': None, @@ -64,12 +72,24 @@ class RouteBuilder: 'snr': msg.get('snr'), 'msg_path_len': msg.get('path_len', 0), 'has_locations': False, + 'path_source': 'none', } # Look up sender in contacts pubkey = msg.get('sender_pubkey', '') + contact: Optional[Dict] = None + + debug_print( + f"Route build: sender_pubkey={pubkey!r} " + f"(len={len(pubkey)}, first2={pubkey[:2]!r})" + ) + if pubkey: contact = self._shared.get_contact_by_prefix(pubkey) + debug_print( + f"Route build: contact lookup " + f"{'FOUND ' + contact.get('adv_name', '?') if contact else 'NOT FOUND'}" + ) if contact: result['sender'] = { 'name': contact.get('adv_name', pubkey[:8]), @@ -78,21 +98,71 @@ class RouteBuilder: 'type': contact.get('type', 0), 'pubkey': pubkey, } - - # Parse out_path for intermediate hops - out_path = contact.get('out_path', '') - out_path_len = contact.get('out_path_len', 0) - debug_print( - f"Route: sender={contact.get('adv_name')}, " - f"out_path={out_path!r}, out_path_len={out_path_len}, " - f"msg_path_len={result['msg_path_len']}" + f"Route build: sender hash will be " + f"{pubkey[:2].upper()!r}" ) - - if out_path and out_path_len and out_path_len > 0: - result['path_nodes'] = self._parse_out_path( - out_path, out_path_len, data['contacts'] + 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', '') + 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, + } + debug_print( + f"Route build: deferred name lookup " + f"'{sender_name}' → pubkey={pubkey[:16]!r}, " + f"hash={pubkey[:2].upper()!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', []) + + if rx_hashes: + result['path_nodes'] = self._resolve_hashes( + rx_hashes, data['contacts'], + ) + result['path_source'] = 'rx_log' + + debug_print( + f"Route from RX_LOG: {len(rx_hashes)} hashes → " + f"{len(result['path_nodes'])} nodes" + ) + + # Priority 2: out_path from sender's contact record + elif contact: + out_path = contact.get('out_path', '') + out_path_len = contact.get('out_path_len', 0) + + debug_print( + f"Route: sender={contact.get('adv_name')}, " + f"out_path={out_path!r}, out_path_len={out_path_len}, " + f"msg_path_len={result['msg_path_len']}" + ) + + if out_path and out_path_len and out_path_len > 0: + result['path_nodes'] = self._parse_out_path( + out_path, out_path_len, data['contacts'], + ) + result['path_source'] = 'contact_out_path' # Determine if any node has GPS coordinates all_points = [result['self_node']] @@ -111,6 +181,50 @@ class RouteBuilder: # Helpers # ------------------------------------------------------------------ + @staticmethod + 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] = [] + + for hop_hash in hashes: + if not hop_hash or len(hop_hash) < 2: + continue + + hop_contact = RouteBuilder._find_contact_by_pubkey_hash( + hop_hash, contacts, + ) + + 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, + }) + else: + nodes.append({ + 'name': '-', + 'lat': 0, + 'lon': 0, + 'type': 0, + 'pubkey': hop_hash, + }) + + return nodes + @staticmethod def _parse_out_path( out_path: str, @@ -126,40 +240,19 @@ class RouteBuilder: Returns: List of hop node dicts. """ - nodes: List[Dict] = [] + hashes: List[str] = [] hop_hex_len = 2 # 1 byte = 2 hex chars 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] - if not hop_hash or len(hop_hash) < 2: - continue + if hop_hash and len(hop_hash) == 2: + hashes.append(hop_hash) - hop_contact = RouteBuilder._find_contact_by_pubkey_hash( - hop_hash, contacts - ) - - 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, - }) - else: - nodes.append({ - 'name': f'Unknown (0x{hop_hash})', - 'lat': 0, - 'lon': 0, - 'type': 0, - 'pubkey': hop_hash, - }) - - return nodes + return RouteBuilder._resolve_hashes(hashes, contacts) @staticmethod def _find_contact_by_pubkey_hash( - hash_hex: str, contacts: Dict + hash_hex: str, contacts: Dict, ) -> Optional[Dict]: """ Find a contact whose pubkey starts with the given 1-byte hash. diff --git a/meshcore-gui/meshcore_gui/route_page.py b/meshcore-gui/meshcore_gui/route_page.py index 9740cb5..85a9afd 100644 --- a/meshcore-gui/meshcore_gui/route_page.py +++ b/meshcore-gui/meshcore_gui/route_page.py @@ -10,7 +10,7 @@ from typing import Dict from nicegui import ui -from meshcore_gui.config import TYPE_LABELS +from meshcore_gui.config import TYPE_LABELS, debug_print from meshcore_gui.route_builder import RouteBuilder from meshcore_gui.protocols import SharedDataReadAndLookup @@ -48,6 +48,8 @@ class RoutePage: 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 @@ -66,19 +68,22 @@ class RoutePage: @staticmethod def _render_message_info(msg: Dict) -> None: - """Message header with direction and text.""" + """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 — {direction}').classes('font-bold text-lg') + ui.label(f'Message Route — {sender} ({direction})').classes('font-bold text-lg') ui.label( - f"{msg['time']} {msg.get('sender', '')}: " + 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.""" + """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'): @@ -103,22 +108,33 @@ class RoutePage: ).classes('text-sm text-gray-600') # Resolution status - if msg_path_len > 0 and resolved_hops > 0: + 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 {msg_path_len} ' - f'repeater{"s" if msg_path_len != 1 else ""} identified' + 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 ' - f'(not in out_path or not in contacts)' + 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 polyline.""" + """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( @@ -133,35 +149,50 @@ class RoutePage: center=(center_lat, center_lon), zoom=10 ).classes('w-full h-96') - path_points = [] + # --- Build ordered list of positions (or None) --- + ordered = [] # Sender - if route['sender'] and (route['sender']['lat'] or route['sender']['lon']): - lat, lon = route['sender']['lat'], route['sender']['lon'] - route_map.marker(latlng=(lat, lon)) - path_points.append((lat, lon)) + 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']: - lat, lon = node['lat'], node['lon'] - route_map.marker(latlng=(lat, lon)) - path_points.append((lat, lon)) + ordered.append((node['lat'], node['lon'])) + else: + ordered.append(None) - # Own position + # Own position (receiver) if data['adv_lat'] or data['adv_lon']: - route_map.marker(latlng=(data['adv_lat'], data['adv_lon'])) - path_points.append((data['adv_lat'], data['adv_lon'])) + ordered.append((data['adv_lat'], data['adv_lon'])) + else: + ordered.append(None) - # Polyline - if len(path_points) >= 2: + # --- 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=[path_points], - options={'color': '#2563eb', 'weight': 3}, + args=[all_points, {'color': '#2563eb', 'weight': 3}], ) - lats = [p[0] for p in path_points] - lons = [p[1] for p in path_points] + + # 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)) ) @@ -171,6 +202,7 @@ class RoutePage: """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') @@ -184,36 +216,41 @@ class RoutePage: 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 + # 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 for unresolved hops - if msg_path_len > resolved_hops: - for i in range(resolved_hops, msg_path_len): + # 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': '(unknown repeater)', + 'name': '-', + 'hash': '-', 'type': '-', 'location': '-', 'role': '📡 Repeater', @@ -224,6 +261,7 @@ class RoutePage: 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', @@ -234,23 +272,33 @@ class RoutePage: {'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 + # 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( - "ℹ️ The repeater identities could not be resolved. " - "This happens when the sender's out_path is empty " - "(e.g. channel messages) or the repeaters are not in " - "your contacts list." + 'ℹ️ 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( diff --git a/meshcore-gui/meshcore_gui/route_pagex.py b/meshcore-gui/meshcore_gui/route_pagex.py new file mode 100644 index 0000000..85a9afd --- /dev/null +++ b/meshcore-gui/meshcore_gui/route_pagex.py @@ -0,0 +1,306 @@ +""" +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/shared_data.py b/meshcore-gui/meshcore_gui/shared_data.py index 1a1a861..1ce0ca9 100644 --- a/meshcore-gui/meshcore_gui/shared_data.py +++ b/meshcore-gui/meshcore_gui/shared_data.py @@ -4,11 +4,17 @@ Thread-safe shared data container for MeshCore GUI. 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. """ import queue import threading -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple from meshcore_gui.config import debug_print @@ -155,8 +161,9 @@ class SharedData: Add a message to the messages list (max 100). Args: - msg: Message dict with time, sender, text, channel, - direction, path_len, snr, sender_pubkey + msg: Message dict with keys: time, sender, text, channel, + direction, path_len, path_hashes, message_hash, + sender_pubkey, snr """ with self.lock: self.messages.append(msg) @@ -261,3 +268,42 @@ class SharedData: 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 + 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