fix(worker): poll device message queue when notification is missed

WHAT
Add MSG_POLL_INTERVAL (config.py, default 30s) and
SerialWorker._poll_pending_messages(), which drains the device
message queue via get_msg() until NO_MORE_MSGS, independently of
the messages_waiting event.

WHY
meshcore 2.3.9.1 fetches messages only in response to
messages_waiting. When that event is missed the radio ACKs the
packet but no message event ever reaches the application, so the
message is neither archived nor displayed. Node resets do not
help: the trigger goes missing, not the queue.

NOTES
start_auto_message_fetching() stays active; the poll only picks up
what the event path leaves behind. Fetched messages dispatch as
ordinary message events, so dedup and archiving are unchanged.
Room Server history timestamps remain a separate open issue.
This commit is contained in:
pe1hvh committed 2026-08-31 10:17:55 +02:00
1 parent 4b6d3a2d78
commit 39e9d81161
3 files changed
+135 -1

No files matched your search

+61
View File
@@ -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
+60
View File
@@ -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:
+14 -1
View File
@@ -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)
# ==============================================================================