From 843d59a2d616ed34429239d9b29b7369a76060f5 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Fri, 5 Jun 2026 10:42:07 +0200 Subject: [PATCH] fix(channels): refresh per-send channel secret to keep echo correlation working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel indices on the device can shift after the user deletes a channel — subsequent slots compact down by one — but mc-webui only ran _load_channel_secrets() once at startup, so the in-memory cache mapped channel_idx to whichever secret was there at boot. Once the indices moved, expected_payloads for sent channel messages were encrypted with the wrong key, so legitimate repeater echoes always fell into the 'doesn't match expected candidates' branch and never got linked to the originating send. send_channel_message now calls _refresh_channel_secret(idx) before building the candidate list: one extra get_channel(idx) round-trip that fetches the current secret straight from the firmware, updates the in-memory cache + DB if they had drifted, and is used for the pkt_payload computation. If the slot is empty, the stale cache entry is dropped. Also bump the set_param timeout for path_hash_mode and custom_var to 20s — the meshcore lib has a 15s internal timeout, so the previous 5s outer wrapper raised a bare concurrent.futures.TimeoutError with empty str(e) before the device's ERROR event could surface. The exception handler now logs the exception type as well so future empty-string errors are still diagnosable, and stores the event.payload (not the never-defined event.data) when capturing the sent message's pkt_payload field. --- app/device_manager.py | 63 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/app/device_manager.py b/app/device_manager.py index 4952fe9..dbe3e79 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -447,6 +447,48 @@ class DeviceManager: except Exception as e: logger.error(f"Failed to load channel secrets: {e}") + def _refresh_channel_secret(self, channel_idx: int) -> Optional[str]: + """Re-read a single channel from the device and update local caches. + + Returns the current secret (hex string) for use in pkt_payload + computation. Deleting a channel on the device shifts later indices + down, so the boot-time _channel_secrets cache can map this idx to + a stale or removed channel; re-querying guarantees we encrypt the + echo-correlation candidates with the right key. + """ + if not self.is_connected: + return self._channel_secrets.get(channel_idx) + try: + info = self.get_channel_info(channel_idx) + except Exception as e: + logger.warning(f"_refresh_channel_secret({channel_idx}) failed: {e}") + return self._channel_secrets.get(channel_idx) + if not info: + # idx is empty on the device — drop any stale cache entry + old = self._channel_secrets.pop(channel_idx, None) + if old is not None: + logger.info(f"Channel idx {channel_idx} no longer exists on device — cleared stale cache") + try: + self.db.delete_channel(channel_idx) + except Exception: + pass + return None + secret = info.get('secret') or '' + name = info.get('name') or '' + if not (isinstance(secret, str) and len(secret) == 32 and secret != '0' * 32): + # Channel slot exists but has no usable secret (e.g. Public) + return None + old = self._channel_secrets.get(channel_idx) + if old != secret: + self._channel_secrets[channel_idx] = secret + logger.info(f"Channel idx {channel_idx} ('{name}') secret refreshed (cache was stale)") + try: + if name: + self.db.upsert_channel(channel_idx, name, secret) + except Exception as e: + logger.debug(f"DB upsert for channel {channel_idx} failed: {e}") + return secret + async def _subscribe_events(self): """Subscribe to all relevant device events.""" from meshcore.events import EventType @@ -1416,13 +1458,16 @@ class DeviceManager: content=text, timestamp=ts, is_own=True, - pkt_payload=getattr(event, 'data', {}).get('pkt_payload') if event else None, + pkt_payload=getattr(event, 'payload', {}).get('pkt_payload') if event else None, ) # Pre-compute expected pkt_payloads for echo correlation. # We try ts±3s to account for clock drift between host and firmware. + # Refresh channel secret from the device first: deleting a channel + # shifts subsequent indices down, so the cache loaded at startup + # may map this idx to a different (or removed) channel. + secret = self._refresh_channel_secret(channel_idx) expected_payloads = set() - secret = self._channel_secrets.get(channel_idx) if secret and self.device_name: full_text = f"{self.device_name}: {text}" for dt in range(-3, 4): @@ -3190,17 +3235,23 @@ class DeviceManager: self.execute(self.mc.commands.set_multi_acks(enabled), timeout=5) return {'success': True, 'message': f'Multi acks: {enabled}'} elif param == 'path_hash_mode': - self.execute(self.mc.commands.set_path_hash_mode(int(value)), timeout=5) + # Lib's internal default_timeout is 15s; give the outer wrapper + # enough headroom so we don't surface a bare TimeoutError when + # the device just needs a moment to acknowledge. + self.execute(self.mc.commands.set_path_hash_mode(int(value)), timeout=20) return {'success': True, 'message': f'Path hash mode set to: {value}'} elif param == 'help': return {'success': True, 'help': 'set'} else: # Try as custom variable - self.execute(self.mc.commands.set_custom_var(param, value), timeout=5) + self.execute(self.mc.commands.set_custom_var(param, value), timeout=20) return {'success': True, 'message': f'Custom var {param} set to: {value}'} except Exception as e: - logger.error(f"set_param failed: {e}") - return {'success': False, 'error': str(e)} + # TimeoutError on the outer asyncio future has an empty str(e); + # surface the type so the log isn't blank. + err_msg = str(e) or type(e).__name__ + logger.error(f"set_param({param}={value}) failed: {err_msg}") + return {'success': False, 'error': err_msg} def node_discover(self, type_filter: str = None) -> Dict: """Discover nodes on the mesh."""