diff --git a/CHANGELOG.md b/CHANGELOG.md index b4192f3..f94ff4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,135 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver --- +## [1.19.0] - 2026-04-06 + +### FIXED +- **Wrong channel attribution for hashtag channels** (`ble/packet_decoder.py`): + `ChannelCrypto.calculate_channel_hash()` produces a different channel identifier + than what the MeshCore firmware embeds in packets, causing `_hash_to_idx` to + return `None` for all hashtag-channel messages. As a result, `on_channel_msg` + fell back to the `channel_idx` from the `CHANNEL_MSG_RECV` event — which was + itself incorrect — and messages sent on `#mc-radar` appeared in `#weather` and + vice versa. + Fix: brute-force channel resolution. When the hash lookup returns `None`, every + registered key is tried individually via a single-key keystore. The first key + that produces a valid decryption determines the channel index. The result is + cached in `_hash_to_idx` for O(1) resolution on all subsequent packets for that + channel. + +- **Cross-channel message duplication** (`ble/events.py`): + A second copy of the same physical packet was stored under a different channel + index because two independent dedup guards both failed simultaneously: (1) the + `message_hash` from `meshcoredecoder` differed from the one in the + `CHANNEL_MSG_RECV` event payload (two separate libraries), breaking hash-based + dedup; (2) the `channel_idx` resolved by `PacketDecoder` differed from the + `channel_idx` reported by the event, breaking content-based dedup. + Fix: when `on_rx_log` successfully stores a message, it now also marks a + channel-agnostic sentinel key (`'*'`) in `DualDeduplicator`. `on_channel_msg` + checks this sentinel before storing and suppresses the message if set, regardless + of hash or channel_idx differences between the two systems. + +- **Empty `path_hashes` on hashtag channels** (`ble/events.py`): + `_path_cache` is keyed by the `message_hash` from `meshcoredecoder`, but + `on_channel_msg` performed the lookup with the `message_hash` from `meshcore` + (different value), causing `_path_cache.pop()` to always return `[]`. + Fix: secondary `_path_cache_by_content` keyed by `"sender:text[:100]"` as + fallback. Path hashes are now recovered even when the two hash values disagree. + +- **Stale channel key in cache after `del_channel` reindex** (`ble/commands.py`, + `services/cache.py`): After moving a channel slot from `old_idx` to `new_idx`, + the cache entry for `old_idx` was never removed. On the next startup, both the + old and the new index had the same secret, causing the same channel to appear + twice in the cache and the last channel not to be displayed. + Fix: new `DeviceCache.remove_channel_key(idx)` method; called after each slot + move in `_cmd_del_channel`. Added `asyncio.sleep(0.5)` before re-discovery to + let the device commit all slot changes before `_discover_channels` reads them. + +### ADDED +- **Channel Move / Reindex** (`gui/panels/channel_panel.py`, `ble/commands.py`, + `gui/dashboard.py`): New mode `↕️ Move / Reindex` in the Channel Manager dialog. + The user selects a source channel from a dropdown and a target index from the + number field. A `↕` button appears inline next to `🗑` for each channel in both + the Messages and Archive submenus. + `_cmd_move_channel` reads the channel secret from `DeviceCache` (or fetches it + directly from the device as fallback), writes to the new slot, clears the old + slot, and updates both cache entries atomically before triggering re-discovery. + +### CHANGED +- `gui/panels/channel_panel.py`: Dialog title changed from `📡 Add Channel` to + `📡 Channel Manager`; submit button renamed from `Add Channel` to `Confirm`. +- `gui/dashboard.py`: `_make_channel_sub_item()` extended with `on_move` callback + and inline `↕` button; both Messages and Archive submenus pass the callback. +- `config.py`: version bump `1.18.1 → 1.19.0`. + +### IMPACT +- `ble/packet_decoder.py`: `_secret_to_idx` dict added; `_resolve_channel_by_brute_force()` + helper added; fallback invoked in `decode()` when hash lookup fails. O(n_channels) + cost on first packet per channel; O(1) thereafter. +- `ble/events.py`: `_path_cache_by_content` dict added; sentinel mark added in + `on_rx_log`; sentinel check and content-key path fallback added in `on_channel_msg`. +- `ble/commands.py`: `move_channel` registered in handler dict; `_cmd_move_channel()` + added; `_cmd_del_channel()` calls `remove_channel_key()` and includes settle delay. +- `services/cache.py`: `remove_channel_key(idx)` added — no-op when key absent. +- `gui/panels/channel_panel.py`: `_move_section`, `_move_select` widgets added; + `open()` accepts `mode` and `preselect_idx` parameters; `_submit_move()` added. +- `gui/dashboard.py`: `_make_channel_sub_item()` signature extended with `on_move`. + +--- + +## [1.18.1] - 2026-04-05 + +### FIXED +- **Drawer width** (`gui/dashboard.py`): increased from 300 px to 360 px so that + longer channel names such as `[19] #radio-zend-amateurs` fit without truncation. +- **`del_channel` library fallback** (`ble/commands.py`): `_cmd_del_channel` now + catches `AttributeError` when `mc.commands.del_channel()` is not available in + the installed pymeshcore version and falls back to overwriting the slot with an + empty name via `set_channel(idx, '', None)`, which the firmware treats as removal. +- **Delete confirmation dialog** (`gui/dashboard.py`): clicking 🗑 now opens an + "Are you sure?" dialog (Cancel / Delete) before dispatching the `del_channel` + command, preventing accidental removals. + +### CHANGED +- `config.py`: version bump `1.18.0 → 1.18.1`. + +--- + +## [1.18.0] - 2026-04-05 + +### ADDED +- **Channel delete button** (`gui/dashboard.py`): each channel entry in the + MESSAGES and ARCHIVE submenus now shows an inline 🗑 delete button next to + the channel name. Clicking it queues a `del_channel` command for the BLE + worker without requiring any additional confirmation dialog. +- **`del_channel` command handler** (`ble/commands.py`): new + `_cmd_del_channel()` async method that deletes the target channel slot via + `mc.commands.del_channel(idx)` and then re-indexes all higher-numbered + channels by one position using `set_channel` + `del_channel`. Secrets for + private channels are read from the `DeviceCache` so no key material is lost + during renumbering. A full channel re-discovery is triggered afterwards via + `_load_data_callback()`. + +### CHANGED +- **Drawer width** (`gui/dashboard.py`): left navigation panel widened from + 260 px to 300 px (min-width 200 px → 220 px) for better readability of + channel names. +- `config.py`: version bump `1.17.1 → 1.18.0`. + +### IMPACT +- `gui/dashboard.py`: new static method `_make_channel_sub_item()`; + `_update_submenus()` uses it instead of `_make_sub_btn()` for channel rows. + All existing submenu logic (ALL, DM, + Add Channel, rooms) is unchanged. +- `ble/commands.py`: one new handler registered; no existing handlers touched. + +### RATIONALE +- Users need a quick way to remove channels directly from the navigation menu + without opening a separate dialog. +- Re-indexing keeps the channel list compact (no sparse gaps) which matches + MeshCore firmware expectations and the visual convention of sequential indices. + +--- + ## [1.17.1] - 2026-04-04 ### FIXED diff --git a/README.md b/README.md index 8cfa45e..6d6f337 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Under the hood it uses `meshcore` as the protocol layer, `meshcoredecoder` for r - **Dynamic Channel Discovery** — Channels are automatically discovered from the device at startup via probing, eliminating the need to manually configure `CHANNELS_CONFIG` -- **Add Channel** — Add hashtag or private channels directly from the GUI via the `+ Add Channel` button in the Messages submenu. New private channels generate a shareable QR code and hex key for distribution to other users +- **Add / Delete Channel** — Add hashtag or private channels directly from the GUI via the `+ Add Channel` button in the Messages submenu. New private channels generate a shareable QR code and hex key for distribution to other users. Each channel entry shows a 🗑 delete button; removing a channel automatically re-indexes higher slots to keep the list compact - **Public REST API** — Read-only JSON endpoints (`/api/v1/stats`, `/api/v1/nodes`, `/api/v1/messages`, `/api/v1/channels`) for external consumers such as statistics dashboards. Private channel messages are unconditionally excluded; no authentication required - **Keyword Bot** — Built-in auto-reply bot that responds to configurable keywords on selected channels, with cooldown and loop prevention - **Packet Decoding** — Raw LoRa packets from RX log are decoded and decrypted using channel keys, providing message hashes, path hashes and hop data @@ -321,6 +321,10 @@ Open the **Messages** section in the left drawer and click **+ Add Channel** a After clicking **Add Channel**, the device is updated and channel discovery re-runs automatically. A new private channel shows a scannable QR code (`meshcore://channel/add?name=…&secret=…`) that the official MeshCore app can read directly. +#### Deleting channels from the GUI + +Each channel row in the **Messages** and **Archive** submenus has an inline 🗑 delete button. Clicking it removes the channel slot from the device and automatically re-indexes any higher-numbered channels downward by one position, keeping the list gapless. Private channel keys for renumbered slots are read from the local cache, so no key material is lost. + ### 6.4. Start the GUI See [7. Starting the Application](#7-starting-the-application) below for all startup methods. diff --git a/meshcore_gui/ble/commands.py b/meshcore_gui/ble/commands.py index 92a8d0e..b3774b2 100644 --- a/meshcore_gui/ble/commands.py +++ b/meshcore_gui/ble/commands.py @@ -52,6 +52,8 @@ class CommandHandler: 'send_room_msg': self._cmd_send_room_msg, 'load_room_history': self._cmd_load_room_history, 'add_channel': self._cmd_add_channel, + 'del_channel': self._cmd_del_channel, + 'move_channel': self._cmd_move_channel, } async def process_all(self) -> None: @@ -602,6 +604,233 @@ class CommandHandler: self._shared.set_status(f'⚠️ Add channel error: {exc}') debug_print(f'add_channel exception: {exc}') + async def _cmd_del_channel(self, cmd: Dict) -> None: + """Delete a channel slot on the MeshCore device and re-index higher slots. + + After deleting index N, all channels with index > N are moved down + by one to close any gap in the channel list. + + Expected command dict:: + + { + 'action': 'del_channel', + 'idx': int, # channel slot to delete (1-99) + 'channels': List[Dict], # snapshot of current channel list + } + + Re-indexing uses secrets from the DeviceCache so private channel + keys are preserved when slots are renumbered. + """ + idx: int = int(cmd.get('idx', 0)) + channels: List[Dict] = cmd.get('channels', []) + + if not idx: + debug_print('del_channel: no index provided, skipping') + return + + # Retrieve cached secrets for re-indexing (JSON keys stored as str) + cache_keys: dict = {} + if self._cache: + cache_keys = self._cache.get_channel_keys() + + async def _clear_slot(slot: int) -> bool: + """Clear a device channel slot. + + Tries ``del_channel`` first; falls back to overwriting with an + empty name when the pymeshcore library does not expose that + command yet. Returns ``True`` on success. + """ + try: + r = await self._mc.commands.del_channel(slot) + if r is not None and r.type == EventType.ERROR: + debug_print(f'del_channel: _clear_slot ERROR for [{slot}]') + return False + return True + except AttributeError: + # pymeshcore does not expose del_channel; clear by writing + # an empty-name slot which the firmware treats as removed. + debug_print( + f'del_channel: del_channel() not in library, ' + f'falling back to set_channel("", None) for [{slot}]' + ) + try: + await self._mc.commands.set_channel(slot, '', None) + return True + except Exception as fb_exc: + debug_print(f'del_channel: fallback clear failed [{slot}]: {fb_exc}') + return False + + try: + # Step 1: clear the target slot on the device + ok = await _clear_slot(idx) + if not ok: + self._shared.set_status(f"⚠️ Failed to delete channel [{idx}]") + return + + debug_print(f'del_channel: cleared slot [{idx}]') + + # Step 2: re-index all channels above the deleted slot + higher = sorted( + [ch for ch in channels if int(ch.get('idx', 0)) > idx], + key=lambda c: int(c['idx']), + ) + + for ch in higher: + old_idx: int = int(ch['idx']) + new_idx: int = old_idx - 1 + name: str = ch.get('name', '') + + # JSON stores channel-key indices as strings + raw_hex: str = ( + cache_keys.get(str(old_idx), '') + or cache_keys.get(old_idx, '') # type: ignore[call-overload] + ) + secret_bytes: Optional[bytes] = None + if raw_hex: + try: + secret_bytes = bytes.fromhex(raw_hex) + except ValueError: + debug_print(f'del_channel: bad cached secret for [{old_idx}]') + + try: + # Move channel to its new (lower) index + r2 = await self._mc.commands.set_channel(new_idx, name, secret_bytes) + if r2 is not None and r2.type == EventType.ERROR: + debug_print( + f'del_channel: re-index ERROR [{old_idx}] -> [{new_idx}]' + ) + continue + + # Persist new key mapping in cache and remove stale old entry + if self._cache and raw_hex: + self._cache.set_channel_key(new_idx, raw_hex) + self._cache.remove_channel_key(old_idx) + + # Clear the now-vacated original slot + await _clear_slot(old_idx) + debug_print( + f'del_channel: moved [{old_idx}] -> [{new_idx}] ({name})' + ) + + except Exception as exc: + debug_print(f'del_channel: re-index exception [{old_idx}]: {exc}') + + self._shared.set_status(f"🗑️ Channel [{idx}] deleted") + + # Small settle delay: the device needs a moment to commit all + # slot changes before re-discovery reads them back. Without + # this, _discover_channels may see channels at both the old and + # new indices, producing duplicate entries in the channel list. + await asyncio.sleep(0.5) + + # Trigger a full channel re-discovery so the GUI is in sync + if self._load_data_callback: + await self._load_data_callback() + + except Exception as exc: + self._shared.set_status(f'⚠️ Delete channel error: {exc}') + debug_print(f'del_channel exception: {exc}') + + async def _cmd_move_channel(self, cmd: Dict) -> None: + """Move a channel slot to a different index on the MeshCore device. + + Reads the channel secret from the DeviceCache (or fetches it from + the device as fallback), writes it to the new index, and clears + the old slot. Both cache entries are updated atomically. + + Expected command dict:: + + { + 'action': 'move_channel', + 'old_idx': int, # current channel slot + 'new_idx': int, # target channel slot + 'name': str, # channel name (from channel list) + } + """ + old_idx: int = int(cmd.get('old_idx', 0)) + new_idx: int = int(cmd.get('new_idx', 0)) + name: str = (cmd.get('name') or '').strip() + + if not name or old_idx == new_idx: + debug_print( + f'move_channel: invalid args old={old_idx} new={new_idx} name={name!r}' + ) + return + + # Resolve secret — prefer cache, fall back to device query + cache_keys: dict = self._cache.get_channel_keys() if self._cache else {} + raw_hex: str = ( + cache_keys.get(str(old_idx), '') + or cache_keys.get(old_idx, '') # type: ignore[call-overload] + ) + secret_bytes: Optional[bytes] = None + + if raw_hex: + try: + secret_bytes = bytes.fromhex(raw_hex) + except ValueError: + debug_print(f'move_channel: bad cached secret for [{old_idx}]') + raw_hex = '' + + if not secret_bytes: + # Fetch secret directly from the device + debug_print( + f'move_channel: no cached key for [{old_idx}], fetching from device' + ) + try: + r = await self._mc.commands.get_channel(old_idx) + if r is not None and r.type != EventType.ERROR: + secret = r.payload.get('channel_secret') + if secret and isinstance(secret, bytes) and len(secret) >= 16: + secret_bytes = secret[:16] + raw_hex = secret_bytes.hex() + elif secret and isinstance(secret, str) and len(secret) >= 32: + try: + secret_bytes = bytes.fromhex(secret)[:16] + raw_hex = secret_bytes.hex() + except ValueError: + pass + except Exception as exc: + debug_print(f'move_channel: get_channel({old_idx}) failed: {exc}') + + try: + # Write channel to new slot + r2 = await self._mc.commands.set_channel(new_idx, name, secret_bytes) + if r2 is not None and r2.type == EventType.ERROR: + self._shared.set_status( + f"\u26a0\ufe0f Failed to move channel [{old_idx}] to [{new_idx}]" + ) + debug_print(f'move_channel: set_channel({new_idx}) ERROR') + return + + # Clear old slot + try: + await self._mc.commands.del_channel(old_idx) + except AttributeError: + await self._mc.commands.set_channel(old_idx, '', None) + + # Update cache: write new index, remove stale old index + if self._cache: + if raw_hex: + self._cache.set_channel_key(new_idx, raw_hex) + self._cache.remove_channel_key(old_idx) + + self._shared.set_status( + f"\u2705 Channel [{old_idx}] \'{name}\' moved to [{new_idx}]" + ) + debug_print(f'move_channel: [{old_idx}] -> [{new_idx}] ({name})') + + # Let device settle before re-discovery + await asyncio.sleep(0.5) + if self._load_data_callback: + await self._load_data_callback() + + except Exception as exc: + self._shared.set_status(f'\u26a0\ufe0f Move channel error: {exc}') + debug_print(f'move_channel exception: {exc}') + + + # ------------------------------------------------------------------ # Callback for refresh (set by SerialWorker after construction) # ------------------------------------------------------------------ diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py index dcb9a32..bf2b8e2 100644 --- a/meshcore_gui/ble/events.py +++ b/meshcore_gui/ble/events.py @@ -60,6 +60,12 @@ class EventHandler: # CHANNEL_MSG_RECV event does not provide. self._path_cache: Dict[str, list] = {} + # Secondary path cache keyed by "sender:text[:100]". + # Fallback for on_channel_msg when the message_hash computed by + # meshcoredecoder differs from the one in CHANNEL_MSG_RECV + # (two independent libraries may disagree on the hash format). + self._path_cache_by_content: Dict[str, list] = {} + # ------------------------------------------------------------------ # Helpers — resolve names at receive time # ------------------------------------------------------------------ @@ -147,13 +153,25 @@ class EventHandler: if decoded.sender: rx_sender = decoded.sender - # Cache path_hashes for correlation with on_channel_msg + # Cache path_hashes for correlation with on_channel_msg. + # Primary key: message_hash (fastest lookup). if decoded.path_hashes and message_hash: self._path_cache[message_hash] = decoded.path_hashes # Evict oldest entries if cache is too large if len(self._path_cache) > self._PATH_CACHE_MAX: oldest = next(iter(self._path_cache)) del self._path_cache[oldest] + + # Secondary key: "sender:text[:100]" (fallback when the + # message_hash from meshcore and meshcoredecoder disagree). + # Only populated for decrypted GroupText packets so we have + # reliable sender + text to form the key. + if decoded.path_hashes and decoded.is_decrypted and decoded.sender: + ck = f"{decoded.sender}:{decoded.text[:100]}" + self._path_cache_by_content[ck] = decoded.path_hashes + if len(self._path_cache_by_content) > self._PATH_CACHE_MAX: + oldest_ck = next(iter(self._path_cache_by_content)) + del self._path_cache_by_content[oldest_ck] # Process decoded message if it's a group text if decoded.payload_type == PayloadType.GroupText and decoded.is_decrypted: @@ -174,6 +192,11 @@ class EventHandler: self._dedup.mark_content( decoded.sender, decoded.channel_idx, decoded.text, ) + # Channel-agnostic sentinel: prevents on_channel_msg from + # storing a duplicate even if its channel_idx or + # message_hash differ from what PacketDecoder resolved. + # Uses sentinel '*' so it cannot clash with real int indices. + self._dedup.mark_content(decoded.sender, '*', decoded.text) sender_pubkey = '' if decoded.sender: @@ -278,6 +301,16 @@ class EventHandler: """Handle channel message events.""" payload = event.payload + # DIAGNOSTIC — remove after root cause confirmed. + # If two lines appear for one incoming packet (different ch_idx), + # the meshcore library fires CHANNEL_MSG_RECV once per subscribed + # channel, which is the confirmed cause of cross-channel duplicates. + debug_print( + f"CHANNEL_MSG_RECV: ch_idx={payload.get('channel_idx')!r}, " + f"msg_hash={str(payload.get('message_hash', ''))[:12]!r}, " + f"text={str(payload.get('text', ''))[:30]!r}" + ) + debug_print(f"Channel msg payload keys: {list(payload.keys())}") # Dedup via hash @@ -296,6 +329,18 @@ class EventHandler: elif raw_text: msg_text = raw_text + # Channel-agnostic guard: on_rx_log already stored this message + # (possibly under a different channel_idx due to index-scheme + # differences between meshcoredecoder and the meshcore library). + # The '*' sentinel is set by on_rx_log only when it successfully + # stored the message, so this guard never fires for the deferred + # case (channel_idx unresolved in on_rx_log). + if sender and self._dedup.is_content_seen(sender, '*', msg_text): + debug_print( + f"Channel msg suppressed (rxlog stored, channel-agnostic): {sender!r}" + ) + return + # Dedup via content ch_idx = payload.get('channel_idx') if self._dedup.is_content_seen(sender, ch_idx, msg_text): @@ -322,7 +367,21 @@ class EventHandler: # Recover path_hashes from RX_LOG cache (CHANNEL_MSG_RECV # does not carry them, but the preceding RX_LOG decode does). + # Primary lookup: by message_hash. path_hashes = self._path_cache.pop(msg_hash, []) if msg_hash else [] + + # Fallback lookup: by "sender:text[:100]" content key. + # Handles the case where meshcoredecoder and the meshcore library + # compute different message_hash values for the same packet. + if not path_hashes and sender and msg_text: + ck = f"{sender}:{msg_text[:100]}" + path_hashes = self._path_cache_by_content.pop(ck, []) + if path_hashes: + debug_print( + f"on_channel_msg: path_hashes recovered via content key: " + f"{path_hashes}" + ) + path_names = self._resolve_path_names(path_hashes) self._shared.add_message(Message.incoming( diff --git a/meshcore_gui/ble/packet_decoder.py b/meshcore_gui/ble/packet_decoder.py index aa489e8..44580bd 100644 --- a/meshcore_gui/ble/packet_decoder.py +++ b/meshcore_gui/ble/packet_decoder.py @@ -8,6 +8,20 @@ and sender name. No correlation with CHANNEL_MSG_RECV events is needed. +Channel attribution +~~~~~~~~~~~~~~~~~~~ +The channel a message belongs to is determined by **which registered key +successfully decrypts the payload** — not by any channel index or the +``channel_hash`` embedded in the packet header. The firmware-embedded +``channel_hash`` is ignored for attribution because ``ChannelCrypto`` +and the firmware may compute it differently, making hash-based lookup +unreliable. + +Decryption is therefore attempted per-key (one ``MeshCoreDecoder.decode`` +call per registered channel). For a typical deployment with fewer than +ten channels this cost is negligible, and the correct channel is always +identified deterministically. + Channel decryption keys are loaded at startup (fetched from the device via ``get_channel()`` or derived from the channel name as fallback). """ @@ -17,7 +31,6 @@ 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 @@ -45,7 +58,7 @@ class DecodedPacket: 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). + channel_idx: Channel index (GroupText only, resolved by key match). timestamp: Message timestamp (GroupText only). is_decrypted: True if payload was successfully decrypted. """ @@ -68,7 +81,13 @@ class DecodedPacket: # --------------------------------------------------------------------------- class PacketDecoder: - """Decode raw LoRa packets with channel-key decryption. + """Decode raw LoRa packets with per-key channel attribution. + + Channel attribution is done by key matching: the registered secret that + successfully decrypts a GroupText packet identifies its channel. This + avoids relying on the ``channel_hash`` mechanism, which requires the + MeshCore firmware and ``meshcoredecoder`` to compute identical hashes — + a dependency that cannot always be guaranteed. Usage:: @@ -78,14 +97,12 @@ class PacketDecoder: result = decoder.decode(payload_hex) if result and result.is_decrypted: - print(result.sender, result.text, result.path_hashes) + print(result.sender, result.text, result.channel_idx) """ 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] = {} + # secret_hex → channel_idx (primary channel attribution map) + self._secret_to_idx: Dict[str, int] = {} # ------------------------------------------------------------------ # Key management @@ -105,14 +122,10 @@ class PacketDecoder: source: Label for debug output (e.g. "device", "cache"). """ 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 + self._secret_to_idx[secret_hex] = channel_idx debug_print( - f"PacketDecoder: key for ch{channel_idx} " - f"(hash={ch_hash}, from {source})" + f"PacketDecoder: key registered for ch{channel_idx} " + f"(source={source}, secret={secret_hex[:8]}…)" ) def add_channel_key_from_name( @@ -133,7 +146,7 @@ class PacketDecoder: @property def has_keys(self) -> bool: """True if at least one channel key has been registered.""" - return self._options is not None + return bool(self._secret_to_idx) # ------------------------------------------------------------------ # Decode @@ -142,6 +155,17 @@ class PacketDecoder: def decode(self, payload_hex: str) -> Optional[DecodedPacket]: """Decode a raw LoRa packet hex string. + Two-phase approach: + + 1. Decode packet **structure** without any key: extracts + ``message_hash``, ``payload_type``, ``path_length`` and + ``path_hashes``. These fields are in the unencrypted header. + + 2. For GroupText packets, attempt decryption with each registered + key individually. The key that produces a valid decryption + **is** the channel identifier — ``channel_idx`` is set directly + from the matching key's registration. + Args: payload_hex: Hex string from the RX_LOG_DATA event's ``payload`` field. @@ -153,14 +177,15 @@ class PacketDecoder: if not payload_hex: return None + # ── Phase 1: structural decode (no key required) ────────────── try: - packet = MeshCoreDecoder.decode(payload_hex, self._options) + packet = MeshCoreDecoder.decode(payload_hex, None) except Exception as exc: - debug_print(f"PacketDecoder: decode error: {exc}") + debug_print(f"PacketDecoder: structural decode error: {exc}") return None if not packet.is_valid: - debug_print(f"PacketDecoder: invalid: {packet.errors}") + debug_print(f"PacketDecoder: invalid packet: {packet.errors}") return None result = DecodedPacket( @@ -170,32 +195,44 @@ class PacketDecoder: 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 + # ── Phase 2: per-key decryption (GroupText only) ────────────── + if packet.payload_type == PayloadType.GroupText and self._secret_to_idx: + for secret_hex, idx in self._secret_to_idx.items(): + try: + ks = MeshCoreKeyStore() + ks.add_channel_secrets([secret_hex]) + opts = DecryptionOptions(key_store=ks) + dec_pkt = MeshCoreDecoder.decode(payload_hex, opts) + if not dec_pkt.is_valid: + continue + dec_payload = dec_pkt.payload.get("decoded") + if dec_payload and dec_payload.decrypted: + d = dec_payload.decrypted + result.sender = d.get("sender", "") or "" + result.text = d.get("message", "") or "" + result.timestamp = d.get("timestamp", 0) + result.channel_idx = idx + result.is_decrypted = True + debug_print( + f"PacketDecoder: GroupText OK — " + f"hash={result.message_hash}, " + f"sender={result.sender!r}, " + f"ch={result.channel_idx} (key-matched), " + f"path={result.path_hashes}, " + f"text={result.text[:40]!r}" + ) + break + except Exception as exc: + debug_print( + f"PacketDecoder: key for ch{idx} error: {exc}" + ) + continue - # 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: + if not result.is_decrypted: debug_print( f"PacketDecoder: GroupText NOT decrypted " - f"(hash={result.message_hash})" + f"(hash={result.message_hash}, " + f"{len(self._secret_to_idx)} keys tried)" ) return result @@ -203,14 +240,3 @@ class PacketDecoder: def get_payload_type_text(self, payload_type: PayloadType) -> str: """Get human-friendly name for a PayloadType enum value.""" return get_payload_type_name(payload_type) - - # ------------------------------------------------------------------ - # 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/config.py b/meshcore_gui/config.py index 8ab28de..a33f37a 100644 --- a/meshcore_gui/config.py +++ b/meshcore_gui/config.py @@ -25,7 +25,7 @@ from typing import Any, Dict, List # ============================================================================== -VERSION: str = "1.17.1" +VERSION: str = "1.19.0" # ============================================================================== diff --git a/meshcore_gui/gui/dashboard.py b/meshcore_gui/gui/dashboard.py index 3033656..7f0878d 100644 --- a/meshcore_gui/gui/dashboard.py +++ b/meshcore_gui/gui/dashboard.py @@ -206,7 +206,7 @@ body.body--light .domca-drawer .q-item { color: #3d6380 !important; } body, .q-layout, .q-page { min-width: 0 !important; } -.q-drawer { max-width: 80vw !important; width: 260px !important; min-width: 200px !important; } +.q-drawer { max-width: 85vw !important; width: 360px !important; min-width: 240px !important; } /* ── Mobile optimisations ── */ @media (max-width: 640px) { @@ -329,6 +329,11 @@ class DashboardPage: # Channel add dialog panel self._channel_panel: ChannelPanel | None = None + # Channel delete confirmation dialog + self._confirm_delete_dialog = None + self._confirm_delete_label = None + self._pending_delete_cmd: dict | None = None + # Header status label self._status_label = None @@ -384,6 +389,22 @@ class DashboardPage: self._channel_panel = ChannelPanel(put_cmd) self._channel_panel.render() + # ── Channel delete confirmation dialog ──────────────────── + self._confirm_delete_dialog = ui.dialog() + with self._confirm_delete_dialog: + with ui.card().classes('w-full').style('min-width: 280px; max-width: 380px'): + ui.label('🗑️ Delete Channel').classes('font-bold text-gray-600 text-base') + self._confirm_delete_label = ui.label('').classes('text-sm text-gray-500') + with ui.row().classes('gap-2 justify-end w-full'): + ui.button( + 'Cancel', + on_click=lambda: self._confirm_delete_dialog.close(), + ).props('flat no-caps') + ui.button( + 'Delete', + on_click=self._on_delete_confirmed, + ).props('unelevated color=negative no-caps') + # Inject DOMCA theme (fonts + CSS variables) ui.add_head_html(_DOMCA_HEAD) @@ -586,6 +607,43 @@ class DashboardPage: 'w-full justify-start domca-sub-btn' ).style(_SUB_BTN_STYLE) + @staticmethod + def _make_channel_sub_item(label: str, on_click, on_delete, on_move) -> None: + """Create a channel submenu item with inline move and delete buttons. + + Renders a full-width row containing a navigation button (flex-1), + a compact move button (↕) and a compact delete button (🗑). + + Args: + label: Button label text (e.g. '[1] #localmesh'). + on_click: Callback for navigating to the channel. + on_delete: Callback for deleting the channel. + on_move: Callback for opening the move/reindex dialog. + """ + with ui.row().classes('w-full gap-0 items-center').style('padding: 0'): + ui.button( + label, + on_click=on_click, + ).props('flat no-caps align=left').classes( + 'flex-1 justify-start domca-sub-btn' + ).style(_SUB_BTN_STYLE) + ui.button( + '↕', + on_click=on_move, + ).props('flat dense no-caps').classes( + 'domca-sub-btn' + ).style( + "font-size: 0.7rem; opacity: 0.45; min-width: 1.6rem; padding: 0.1rem 0.3rem" + ) + ui.button( + '🗑', + on_click=on_delete, + ).props('flat dense no-caps').classes( + 'domca-sub-btn' + ).style( + "font-size: 0.7rem; opacity: 0.45; min-width: 1.6rem; padding: 0.1rem 0.3rem" + ) + # ------------------------------------------------------------------ # Dynamic submenu updates (layout — called from _update_ui) # ------------------------------------------------------------------ @@ -616,9 +674,16 @@ class DashboardPage: for ch in channels: idx = ch['idx'] name = ch['name'] - self._make_sub_btn( + self._make_channel_sub_item( f"[{idx}] {name}", - lambda i=idx: self._navigate_panel('messages', channel=i), + on_click=lambda i=idx: self._navigate_panel('messages', channel=i), + on_delete=lambda i=idx, n=name, chs=channels: ( + self._open_delete_confirm(i, n, chs) + ), + on_move=lambda i=idx: ( + self._channel_panel.open(mode='move', preselect_idx=i) + if self._channel_panel else None + ), ) self._make_sub_btn( '+ Add Channel', @@ -638,9 +703,16 @@ class DashboardPage: for ch in channels: idx = ch['idx'] name = ch['name'] - self._make_sub_btn( + self._make_channel_sub_item( f"[{idx}] {name}", - lambda n=name: self._navigate_panel('archive', channel=n), + on_click=lambda n=name: self._navigate_panel('archive', channel=n), + on_delete=lambda i=idx, n=name, chs=channels: ( + self._open_delete_confirm(i, n, chs) + ), + on_move=lambda i=idx: ( + self._channel_panel.open(mode='move', preselect_idx=i) + if self._channel_panel else None + ), ) # ── Room submenus ── @@ -799,6 +871,42 @@ class DashboardPage: if self._room_server: self._room_server.add_room(pubkey, name, password) + # ------------------------------------------------------------------ + # Channel delete confirmation (dialog + dispatch) + # ------------------------------------------------------------------ + + def _open_delete_confirm(self, idx: int, name: str, channels: list) -> None: + """Open the delete confirmation dialog for a channel. + + Stores the pending command so ``_on_delete_confirmed`` can dispatch + it without needing to capture mutable closure state. + + Args: + idx: Channel index to delete. + name: Channel name (shown in the dialog label). + channels: Current channel list snapshot passed to the handler. + """ + self._pending_delete_cmd = { + 'action': 'del_channel', + 'idx': idx, + 'channels': list(channels), + } + if self._confirm_delete_label: + self._confirm_delete_label.text = ( + f'Remove channel [{idx}] "{name}" from the device? ' + 'Higher-numbered channels will be re-indexed automatically.' + ) + if self._confirm_delete_dialog: + self._confirm_delete_dialog.open() + + def _on_delete_confirmed(self) -> None: + """Dispatch the pending delete command and close the dialog.""" + if self._confirm_delete_dialog: + self._confirm_delete_dialog.close() + if self._pending_delete_cmd is not None: + self._shared.put_command(self._pending_delete_cmd) + self._pending_delete_cmd = None + # ------------------------------------------------------------------ # Timer-driven UI update # ------------------------------------------------------------------ diff --git a/meshcore_gui/gui/panels/channel_panel.py b/meshcore_gui/gui/panels/channel_panel.py index 065d189..f25211a 100644 --- a/meshcore_gui/gui/panels/channel_panel.py +++ b/meshcore_gui/gui/panels/channel_panel.py @@ -1,8 +1,8 @@ """ -Channel panel — dialog for adding hashtag and private channels. +Channel panel — dialog for adding, moving and managing channels. -Triggered by the ``+ Add Channel`` button in the Messages submenu. -Three modes are supported: +Triggered by the ``+ Add Channel`` button or the ``↕`` move button in +the Messages submenu. Four modes are supported: Hashtag Name must start with ``#``. The channel key is derived automatically @@ -18,6 +18,11 @@ Private — Existing (join) Used when another user has shared a private channel key. The user pastes the 32-character hex key and the dialog writes it to the device verbatim. No key export is offered. + +Move / Reindex + Select an existing channel and assign it a different slot index. + The secret is read from the DeviceCache by the command handler; the + user only needs to pick a source channel and a target index. """ from typing import Callable, Dict, List, Optional @@ -32,7 +37,7 @@ from meshcore_gui.services.channel_service import ( class ChannelPanel: - """NiceGUI dialog for adding a channel to the connected MeshCore device. + """NiceGUI dialog for adding or moving a channel on the MeshCore device. Args: put_command: Callable to enqueue a command dict for the BLE worker. @@ -60,6 +65,10 @@ class ChannelPanel: self._qr_label: Optional[ui.label] = None self._qr_image: Optional[ui.image] = None + # Move-mode widgets + self._move_section: Optional[ui.column] = None + self._move_select: Optional[ui.select] = None + # Transient state self._generated_secret: Optional[bytes] = None @@ -80,7 +89,7 @@ class ChannelPanel: with ui.card().classes('w-full').style( 'min-width: 340px; max-width: 440px; gap: 0.6rem' ): - ui.label('📡 Add Channel').classes('font-bold text-gray-600 text-base') + ui.label('📡 Channel Manager').classes('font-bold text-gray-600 text-base') # ── Mode selection ────────────────────────────────── self._mode_radio = ui.radio( @@ -88,6 +97,7 @@ class ChannelPanel: 'hashtag': '# Hashtag channel', 'private_new': '🔒 Private – New', 'private_existing': '🔒 Private – Existing (join)', + 'move': '↕️ Move / Reindex', }, value='hashtag', on_change=self._on_mode_change, @@ -103,13 +113,13 @@ class ChannelPanel: format='%d', ).classes('w-full') - # ── Channel name ───────────────────────────────────── + # ── Channel name (add modes) ───────────────────────── self._name_input = ui.input( label='Channel name', placeholder='e.g. #localmesh', ).classes('w-full') - # ── Hashtag info label (hashtag mode only) ─────────── + # ── Hashtag info label ─────────────────────────────── self._hashtag_info = ui.label( '🔑 Key is derived automatically from the name. ' 'Anyone who knows the name can join.' @@ -123,7 +133,6 @@ class ChannelPanel: placeholder='e.g. 8b3387e9c5cdea6ac9e5edbaa115cd72', ).classes('w-full') - # Generate + copy row (private-new only) self._generate_row = ui.row().classes('gap-2 items-center') with self._generate_row: ui.button( @@ -135,6 +144,19 @@ class ChannelPanel: on_click=self._copy_key, ).props('flat dense no-caps') + # ── Move section ───────────────────────────────────── + self._move_section = ui.column().classes('w-full gap-1') + with self._move_section: + self._move_select = ui.select( + options={}, + label='Channel to move', + ).classes('w-full') + ui.label( + 'The channel will be written to the new index and ' + 'removed from its current slot. The secret is ' + 'retrieved automatically from the cache.' + ).classes('text-xs text-gray-500') + # ── Action buttons ─────────────────────────────────── with ui.row().classes('gap-2 justify-end w-full'): ui.button( @@ -142,7 +164,7 @@ class ChannelPanel: on_click=self._close, ).props('flat no-caps') ui.button( - 'Add Channel', + 'Confirm', on_click=self._submit, ).props('unelevated color=primary no-caps') @@ -163,21 +185,29 @@ class ChannelPanel: self._qr_section.set_visibility(False) def update(self, data: Dict) -> None: - """Update the next-available channel index from the live channel list. + """Update the channel list from the live data snapshot. Called every 500 ms from the dashboard update cycle. Stores the - current channel list so ``open()`` can pre-fill a sensible index. + current channel list so ``open()`` can pre-fill sensible defaults + and populate the move-mode selector. Args: data: SharedData snapshot dict containing the ``channels`` list. """ self._channels = data.get('channels', []) - def open(self) -> None: - """Open the dialog and reset the form to a clean state.""" + def open(self, mode: str = 'hashtag', preselect_idx: Optional[int] = None) -> None: + """Open the dialog in the given mode. + + Args: + mode: One of ``'hashtag'``, ``'private_new'``, + ``'private_existing'``, or ``'move'``. + preselect_idx: When mode is ``'move'``, pre-select this channel + index in the source selector. + """ if self._dialog is None: return - self._reset_form() + self._reset_form(mode=mode, preselect_idx=preselect_idx) self._dialog.open() # ------------------------------------------------------------------ @@ -189,12 +219,16 @@ class ChannelPanel: if self._dialog: self._dialog.close() - def _reset_form(self) -> None: - """Reset all fields to their defaults and hide the QR section.""" + def _reset_form( + self, + mode: str = 'hashtag', + preselect_idx: Optional[int] = None, + ) -> None: + """Reset all fields to clean state and apply the given mode.""" self._generated_secret = None if self._mode_radio: - self._mode_radio.value = 'hashtag' + self._mode_radio.value = mode if self._name_input: self._name_input.value = '' if self._secret_input: @@ -207,14 +241,34 @@ class ChannelPanel: self._qr_label.text = '' # Pre-fill next available index - if self._channels: + if self._channels and mode != 'move': next_idx = min(max(ch['idx'] for ch in self._channels) + 1, 99) else: next_idx = 1 if self._idx_input: self._idx_input.value = next_idx - self._apply_visibility('hashtag') + # Populate move-mode selector + self._refresh_move_options(preselect_idx) + self._apply_visibility(mode) + + def _refresh_move_options(self, preselect_idx: Optional[int] = None) -> None: + """Rebuild the source-channel selector for move mode.""" + if not self._move_select: + return + # Skip index 0 (Public) — slot 0 cannot be moved + opts = { + ch['idx']: f"[{ch['idx']}] {ch['name']}" + for ch in self._channels + if ch['idx'] != 0 + } + self._move_select.options = opts + if opts: + if preselect_idx is not None and preselect_idx in opts: + self._move_select.value = preselect_idx + else: + self._move_select.value = next(iter(opts)) + self._move_select.update() def _on_mode_change(self, event=None) -> None: """React to mode-radio change — update field visibility.""" @@ -224,6 +278,8 @@ class ChannelPanel: self._secret_input.value = '' if self._qr_section: self._qr_section.set_visibility(False) + if mode == 'move': + self._refresh_move_options() self._apply_visibility(mode) def _apply_visibility(self, mode: str) -> None: @@ -231,6 +287,7 @@ class ChannelPanel: is_hashtag = mode == 'hashtag' is_private = mode in ('private_new', 'private_existing') is_private_new = mode == 'private_new' + is_move = mode == 'move' if self._hashtag_info: self._hashtag_info.set_visibility(is_hashtag) @@ -240,9 +297,18 @@ class ChannelPanel: self._generate_row.set_visibility(is_private_new) if self._copy_btn: self._copy_btn.set_visibility(is_private_new) - - # Adjust name placeholder to hint correct input format if self._name_input: + self._name_input.set_visibility(not is_move) + if self._move_section: + self._move_section.set_visibility(is_move) + + # Adjust index label contextually + if self._idx_input: + label = 'Target index (1 – 99)' if is_move else 'Channel index (1 – 99)' + self._idx_input.props(f'label="{label}"') + + # Adjust name placeholder + if self._name_input and not is_move: placeholder = 'e.g. #localmesh' if is_hashtag else 'e.g. TeamName' self._name_input.props(f'placeholder="{placeholder}"') @@ -268,12 +334,17 @@ class ChannelPanel: ui.notify('Generate a key first', type='warning', timeout=2000) def _submit(self) -> None: - """Validate form inputs and queue the ``add_channel`` command.""" + """Validate form inputs and queue the appropriate command.""" mode = self._mode_radio.value if self._mode_radio else 'hashtag' + + if mode == 'move': + self._submit_move() + return + + # ── Add modes ──────────────────────────────────────────────── name = (self._name_input.value or '').strip() if self._name_input else '' idx = int(self._idx_input.value or 1) if self._idx_input else 1 - # ── Validation ────────────────────────────────────────────── if not name: ui.notify('Channel name is required', type='warning', timeout=3000) return @@ -309,11 +380,9 @@ class ChannelPanel: secret_hex = raw else: - # Hashtag: library derives the key; pass empty string so the - # command handler passes secret=None to set_channel(). + # Hashtag: library derives the key secret_hex = '' - # ── Queue command ──────────────────────────────────────────── self._put_command({ 'action': 'add_channel', 'idx': idx, @@ -323,14 +392,52 @@ class ChannelPanel: ui.notify(f"Adding [{idx}] {name}…", type='info', timeout=2500) - # ── QR code for new private channels ───────────────────────── if mode == 'private_new' and self._generated_secret: qr_data = generate_qr_base64(name, self._generated_secret) if qr_data and self._qr_image and self._qr_label and self._qr_section: self._qr_image.source = qr_data self._qr_label.text = f'Share key for "{name}"' self._qr_section.set_visibility(True) - # Keep dialog open so the user can scan / copy the key return self._close() + + def _submit_move(self) -> None: + """Validate and queue a move_channel command.""" + if not self._move_select or not self._move_select.options: + ui.notify('No movable channels available', type='warning', timeout=3000) + return + + old_idx = self._move_select.value + new_idx = int(self._idx_input.value or 1) if self._idx_input else 1 + + if old_idx is None: + ui.notify('Select a channel to move', type='warning', timeout=3000) + return + + if old_idx == new_idx: + ui.notify('Source and target index are the same', type='warning', timeout=3000) + return + + # Resolve name from channel list + name = next( + (ch['name'] for ch in self._channels if ch['idx'] == old_idx), + '', + ) + if not name: + ui.notify('Could not resolve channel name', type='warning', timeout=3000) + return + + self._put_command({ + 'action': 'move_channel', + 'old_idx': old_idx, + 'new_idx': new_idx, + 'name': name, + }) + + ui.notify( + f"Moving [{old_idx}] {name} → [{new_idx}]…", + type='info', + timeout=2500, + ) + self._close() diff --git a/meshcore_gui/services/cache.py b/meshcore_gui/services/cache.py index 08d7a32..dbde3a7 100644 --- a/meshcore_gui/services/cache.py +++ b/meshcore_gui/services/cache.py @@ -153,6 +153,20 @@ class DeviceCache: self._data["channel_keys"] = keys self.save() + def remove_channel_key(self, channel_idx: int) -> None: + """Remove a channel key from the cache and persist. + + No-op if the index is not present. + + Args: + channel_idx: Channel slot index whose key should be removed. + """ + keys = self._data.get("channel_keys", {}) + removed = keys.pop(str(channel_idx), None) + if removed is not None: + self._data["channel_keys"] = keys + self.save() + # ------------------------------------------------------------------ # Contacts (merge strategy) # ------------------------------------------------------------------ diff --git a/meshcore_gui/static/icon-512.png b/meshcore_gui/static/icon-512.png index 8b84688..e69de29 100644 Binary files a/meshcore_gui/static/icon-512.png and b/meshcore_gui/static/icon-512.png differ diff --git a/meshcore_guiDelChannel.zip b/meshcore_guiDelChannel.zip new file mode 100644 index 0000000..56843fe Binary files /dev/null and b/meshcore_guiDelChannel.zip differ diff --git a/meshcore_guiEmptyPathHashes.zip b/meshcore_guiEmptyPathHashes.zip new file mode 100644 index 0000000..d4b0118 Binary files /dev/null and b/meshcore_guiEmptyPathHashes.zip differ