diff --git a/CHANGELOG.md b/CHANGELOG.md index aca8f4a..27340a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,67 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver --- +## [1.22.6] - 2026-08-31 — Queued messages no longer stall when the device notification is missed + +### Fixed +- 🛠 **Incoming messages received by the radio never reached the + application** (`ble/worker.py`): the companion radio received the packet + and ACKed it, but no `CONTACT_MSG_RECV` event followed and the message + was never archived or displayed. `MeshCore.start_auto_message_fetching()` + (meshcore 2.3.9.1) is purely event-driven: `_fetch_messages_loop()` runs + only when the device emits `messages_waiting`. There is no periodic + fallback, so a missed notification leaves the message queued on the + device indefinitely. Diagnosis confirmed the gap: `RX_LOG_DATA` showed a + DIRECT `TEXT_MSG` arriving at -55 dBm followed by an outgoing ACK, while + no `get_msg()` command was sent over the serial link for minutes and no + traceback was logged — the fetch loop had never been started rather than + having crashed. Restarting the node does not help, because the trigger, + not the queue, is what goes missing. + +### Added +- `MSG_POLL_INTERVAL` in `config.py` (default 30 s, `0` disables) — + interval for the safety-net poll of the device message queue. +- `SerialWorker._poll_pending_messages()` — drains the queue by calling + `get_msg()` until the device reports `NO_MORE_MSGS`, independently of + the `messages_waiting` event. + +### Changed +- `_main_loop()` runs the message poll alongside the existing periodic + tasks (contact refresh, key retry, cleanup), following the same + interval-guarded pattern. + +### Impact +- Messages that the notification path misses are delayed by at most one + poll interval instead of being lost. `start_auto_message_fetching()` + remains active, so the event path still delivers immediately when it + works; the poll only picks up what it leaves behind. +- Fetched messages are dispatched by the library as ordinary message + events, so existing handlers, deduplication and archiving apply + unchanged — messages already delivered via the event path are not + stored twice. +- No change to the archive JSON schema and no change to any public + method signature. + +### Rationale +- A fixed-interval poll is the smallest change that closes the gap + without reimplementing message retrieval or forking the library. The + interval is a config constant so it can be tuned per deployment, or + switched off entirely once the underlying notification proves reliable. + +### Known limitation +- Room Server history messages still carry their **receive** time rather + than their original send time: `EventHandler.on_contact_msg()` calls + `Message.incoming()` without `time=` / `date=`, so a message replayed + from server history is dated at the moment it arrives. Confirmed + against the archive, where a message sent on 2026-08-27 is stored as + `2026-08-31 07:10:57`. Not fixed here: whether the + `CONTACT_MSG_RECV` payload carries a usable original timestamp cannot + be determined until room traffic flows again, and guessing at the field + would be a fix without a confirmed root cause. + +--- + + ## [1.22.5] - 2026-08-31 — Failed channel discovery no longer erases the channel list ### Fixed diff --git a/meshcore_gui/ble/worker.py b/meshcore_gui/ble/worker.py index fd0852e..d8fe267 100644 --- a/meshcore_gui/ble/worker.py +++ b/meshcore_gui/ble/worker.py @@ -44,6 +44,7 @@ from meshcore_gui.config import ( CHANNEL_DISCOVERY_ABORT_THRESHOLD, CONTACT_REFRESH_SECONDS, MAX_CHANNELS, + MSG_POLL_INTERVAL, RECONNECT_BASE_DELAY, RECONNECT_MAX_RETRIES, debug_data, @@ -206,6 +207,7 @@ class _BaseWorker(abc.ABC): last_contact_refresh = time.time() last_key_retry = time.time() last_cleanup = time.time() + last_msg_poll = time.time() while self.running and not self._disconnected: try: @@ -232,6 +234,10 @@ class _BaseWorker(abc.ABC): await self._cleanup_old_data() last_cleanup = now + if MSG_POLL_INTERVAL > 0 and now - last_msg_poll > MSG_POLL_INTERVAL: + await self._poll_pending_messages() + last_msg_poll = now + await asyncio.sleep(0.1) async def _handle_reconnect(self) -> bool: @@ -803,6 +809,60 @@ class _BaseWorker(abc.ABC): except Exception as exc: debug_print(f"Periodic contact refresh failed: {exc}") + async def _poll_pending_messages(self) -> None: + """Drain the device message queue as a safety net. + + ``MeshCore.start_auto_message_fetching()`` only fetches messages + when the radio emits a ``messages_waiting`` event. If that event + is missed the queue is never drained: the radio receives and ACKs + the packet, but no message event reaches the application. + + This poll calls ``get_msg()`` directly on a fixed interval and + keeps reading until the device reports ``NO_MORE_MSGS``. Each + fetched message is dispatched by the library as a normal + ``CONTACT_MSG_RECV`` / ``CHANNEL_MSG_RECV`` event, so the existing + handlers and deduplication apply unchanged and messages already + delivered via the event path are not stored twice. + + Errors are logged and end the poll; the next interval retries. + Disconnects are left to the main loop, which inspects command + errors for transport keywords. + """ + if not self.mc: + return + + fetched = 0 + try: + while self.running and not self._disconnected: + result = await self.mc.commands.get_msg() + + if result is None: + debug_print("Message poll: get_msg returned None, stopping") + break + + if result.type == EventType.NO_MORE_MSGS: + break + + if result.type == EventType.ERROR: + debug_print("Message poll: device returned ERROR, stopping") + break + + fetched += 1 + + # Yield between reads so the device is not overwhelmed and + # the event loop stays responsive. + await asyncio.sleep(0.1) + + except Exception as exc: + debug_print(f"Message poll failed: {exc}") + return + + if fetched: + debug_print( + f"Message poll: drained {fetched} queued message(s) " + f"that the messages_waiting event did not deliver" + ) + async def _cleanup_old_data(self) -> None: try: if self.shared.archive: diff --git a/meshcore_gui/config.py b/meshcore_gui/config.py index 78ad65f..450537e 100644 --- a/meshcore_gui/config.py +++ b/meshcore_gui/config.py @@ -25,7 +25,7 @@ from typing import Any, Dict, List # ============================================================================== -VERSION: str = "1.22.5" +VERSION: str = "1.22.6" # ============================================================================== @@ -389,6 +389,19 @@ RECONNECT_BASE_DELAY: float = 5.0 # only present in cache are kept so offline nodes are preserved). CONTACT_REFRESH_SECONDS: float = 300.0 # 5 minutes +# Interval in seconds between safety-net polls of the device message queue. +# +# The meshcore library fetches messages only in response to a +# ``messages_waiting`` event from the companion radio. When that event is +# missed, incoming messages stay queued on the device indefinitely: the +# radio receives and ACKs the packet, but no ``CONTACT_MSG_RECV`` / +# ``CHANNEL_MSG_RECV`` event ever reaches the application. +# +# This poll drains the queue on a fixed interval regardless of the event, +# so a missed notification delays a message instead of losing it. Set to +# ``0`` to disable the poll and rely on the event alone. +MSG_POLL_INTERVAL: float = 30.0 + # ============================================================================== # EXTERNAL LINKS (drawer menu) # ==============================================================================