mirror of
https://github.com/pe1hvh/meshcore-gui.git
synced 2026-05-05 04:52:28 +02:00
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:
129
CHANGELOG.md
129
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from typing import Any, Dict, List
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
VERSION: str = "1.17.1"
|
||||
VERSION: str = "1.19.0"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
BIN
meshcore_guiDelChannel.zip
Normal file
Binary file not shown.
BIN
meshcore_guiEmptyPathHashes.zip
Normal file
BIN
meshcore_guiEmptyPathHashes.zip
Normal file
Binary file not shown.
Reference in New Issue
Block a user