fix: channel attribution, dedup, cache bugs + channel edit/move (v1.19.0)

fix(packet_decoder): brute-force channel resolution when hash lookup fails

ChannelCrypto.calculate_channel_hash() and the MeshCore firmware compute
different channel identifiers for the same secret, causing _hash_to_idx to
return None for all hashtag-channel messages. Fallback: try each registered
key individually via a single-key keystore. First valid decryption wins.
Result cached in _hash_to_idx for O(1) resolution on subsequent packets.

fix(events): channel-agnostic sentinel prevents cross-channel duplicates

on_rx_log now marks '*' in DualDeduplicator after storing a message.
on_channel_msg checks this sentinel and suppresses storage regardless of
channel_idx or message_hash differences between the two library systems.
Fixes #mc-radar messages appearing in #weather and vice versa.

fix(events): secondary path-cache keyed by content for hash-mismatch

_path_cache keyed by meshcoredecoder hash; CHANNEL_MSG_RECV carries meshcore
hash (different value). Added _path_cache_by_content ("sender:text[:100]")
as fallback so path_hashes are recovered when the two hashes disagree.

fix(commands,cache): remove stale channel key after del_channel reindex

Cache entry for old_idx not removed after slot move, causing same channel
to appear twice. New DeviceCache.remove_channel_key(idx) called after each
move. asyncio.sleep(0.5) added before re-discovery to let device settle.

feat(channel_panel,commands,dashboard): channel edit — move/reindex support

First channel-edit capability in the GUI. New '↕️ Move / Reindex' mode in
Channel Manager dialog. Source channel selected from dropdown, target index
in number field. ↕ button inline with 🗑 in Messages and Archive submenus.
_cmd_move_channel reads secret from cache or device, writes new slot, clears
old slot, updates cache atomically, triggers re-discovery with settle delay.
This commit is contained in:
pe1hvh
2026-04-06 10:23:24 +02:00
parent 8080bcc203
commit 4a58a95cf0
12 changed files with 765 additions and 89 deletions

View File

@@ -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

View File

@@ -120,7 +120,7 @@ Under the hood it uses `meshcore` as the protocol layer, `meshcoredecoder` for r
<!-- ADDED: Room Server feature (v5.7.0) -->
- **Dynamic Channel Discovery** — Channels are automatically discovered from the device at startup via probing, eliminating the need to manually configure `CHANNELS_CONFIG`
<!-- ADDED: Dynamic channel discovery (v5.7.0) -->
- **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.

View File

@@ -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)
# ------------------------------------------------------------------

View File

@@ -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(

View File

@@ -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)

View File

@@ -25,7 +25,7 @@ from typing import Any, Dict, List
# ==============================================================================
VERSION: str = "1.17.1"
VERSION: str = "1.19.0"
# ==============================================================================

View File

@@ -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
# ------------------------------------------------------------------

View File

@@ -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()

View File

@@ -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)
# ------------------------------------------------------------------

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 0 B

BIN
meshcore_guiDelChannel.zip Normal file

Binary file not shown.

Binary file not shown.