From ba23899c312bc9ee12585fa603c64c0d182f9b01 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 25 Aug 2026 09:28:19 -0700 Subject: [PATCH] fix(correlation): tie channel messages to their own RF packet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit {packet_hash} came back empty on a channel, and so did {path} and {connection_info}. message.routing_info is only attached when the RF row is correlated, and a channel message can never correlate: MeshCore's CHAN event carries neither raw_hex nor a pubkey prefix, so every prefix strategy in find_recent_rf_data is skipped and the message lands on the most-recent-packet fallback, which #80 rightly refuses to take a route from. The fallback is in fact almost always the right packet—the firmware emits the RF log row and the decoded CHAN event for one reception back to back—but "almost always" is a guess, and #80 is what guessing costs. The CHAN payload does restate three things the RF row records independently: payload type, path length and SNR. Requiring all three to agree, on a row no older than the correlation window, turns the fallback into a checked hypothesis and earns a real match kind rather than a fallback tag. SNR carries the weight: it is one reception's measured value, quantised to 0.25 dB, so an unrelated packet agreeing on all three is improbable rather than merely unlikely. Across the 55 channel messages in my logs the immediately preceding RF row agreed on all three every time, always within a second, while a window-wide search on the same fields was ambiguous for half of them—so this verifies the ordering the firmware already gives us instead of searching for a match. Because these messages now correlate, they also satisfy the '*' gate by the primary route, resolve their scope properly when flood_scopes is regional, and contribute their path to the mesh graph, which they never did before. --- CHANGELOG.md | 14 ++++++ modules/message_handler.py | 69 ++++++++++++++++++++++++++++ tests/test_message_handler.py | 86 +++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1523190..0ca4f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ semantic versioning. ### Fixed +- Channel messages are now tied to their own RF packet, so `{packet_hash}`, `{path}` + and `{connection_info}` resolve on a channel instead of coming back empty or + `Unknown`. MeshCore's CHAN event carries neither `raw_hex` nor a pubkey prefix, so + none of the prefix strategies in `find_recent_rf_data` can fire and every channel + message fell through to the most-recent-packet fallback, which #80 correctly + refuses to attribute a route from. The decoded payload does restate three things + the RF row records independently—payload type, path length and SNR—so the fallback + can now be checked instead of assumed. SNR is the discriminating field: it is one + reception's measured value, quantised to 0.25 dB. Across 55 channel messages in my + logs the immediately preceding RF row agreed on all three every time, and a row + that disagrees, or that is older than the correlation window, stays a fallback. + Side effect worth knowing: verified channel messages now contribute their path to + the mesh graph, which they never did before. + - `flood_scopes = *` no longer drops every channel message. Requiring RF data correlated to the message before `*` could authorize a reply looked reasonable, but MeshCore's CHAN payload carries neither `raw_hex` nor a pubkey prefix, so a diff --git a/modules/message_handler.py b/modules/message_handler.py index 9719427..34ca080 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -32,6 +32,10 @@ RF_MATCH_KEY = "_rf_match" RF_MATCH_EXACT = "exact" RF_MATCH_PUBKEY = "pubkey" RF_MATCH_PARTIAL = "partial" +# Verified against the decoded message payload's own fields rather than a packet +# prefix. Channel messages have no prefix to match on, so this is the only positive +# correlation available to them (see _rf_data_matches_chan_payload). +RF_MATCH_PAYLOAD = "payload" RF_MATCH_FALLBACK = "fallback" @@ -1534,8 +1538,73 @@ class MessageHandler: scope_eligible_only=scope_eligible_only, ) + # A channel message has no packet prefix or pubkey to match on, so everything + # above lands on the most-recent-packet fallback. The decoded payload carries + # its own copies of fields the RF row also has, though, so the fallback can be + # checked rather than assumed. + if recent_rf_data is not None and not rf_data_is_correlated(recent_rf_data): + if self._rf_data_matches_chan_payload(recent_rf_data, payload): + recent_rf_data = {**recent_rf_data, RF_MATCH_KEY: RF_MATCH_PAYLOAD} + self.logger.debug( + "Verified RF row %s against the channel payload (GRP_TXT, path_len=%s, " + "SNR=%s); treating it as this message's packet", + (recent_rf_data.get("packet_prefix") or "?")[:16], + payload.get("path_len"), + payload.get("SNR"), + ) + return recent_rf_data + def _rf_data_matches_chan_payload( + self, rf_data: dict[str, Any] | None, payload: dict[str, Any] | None + ) -> bool: + """True when a cached RF row is confirmed to be this channel message's packet. + + MeshCore's CHAN event carries neither raw_hex nor a pubkey prefix, so the + prefix strategies in find_recent_rf_data cannot fire and every channel + message falls through to the most recent packet. That fallback is normally + right — the firmware emits the RF log row and the decoded CHAN event for the + same reception back to back — but "normally right" is not evidence, and #80 + is what happens when a guess is treated as one. + + The CHAN payload does, however, restate three things the RF row records + independently: payload type, path length and SNR. Requiring all three to + agree on a row heard within the correlation window turns the fallback into + a checked hypothesis. SNR is the discriminating one: it is the measured + value of a single reception, quantised to 0.25 dB, so an unrelated packet + matching all three is improbable rather than merely unlikely. + """ + if not rf_data or not payload: + return False + + if rf_data.get("payload_type_int") != self._grp_txt_payload_type_int(): + return False + + # Bound the age: the RF row and its CHAN event arrive together, so an old row + # that happens to agree is a coincidence, not this message. + timestamp = rf_data.get("timestamp") + if not isinstance(timestamp, (int, float)): + return False + if time.time() - timestamp > self.message_timeout: + return False + + payload_path_len = payload.get("path_len") + rf_path_len = (rf_data.get("routing_info") or {}).get("path_length") + if payload_path_len is None or rf_path_len is None: + return False + if int(payload_path_len) != int(rf_path_len): + return False + + payload_snr = payload.get("SNR") + rf_snr = rf_data.get("snr") + if payload_snr is None or rf_snr is None: + return False + try: + # Tolerance covers float representation only; SNR is quantised to 0.25 dB. + return abs(float(payload_snr) - float(rf_snr)) < 1e-6 + except (TypeError, ValueError): + return False + def find_recent_rf_data( self, correlation_key: str | None = None, diff --git a/tests/test_message_handler.py b/tests/test_message_handler.py index 7998dee..cf56b8b 100644 --- a/tests/test_message_handler.py +++ b/tests/test_message_handler.py @@ -1,5 +1,6 @@ """Tests for MessageHandler pure logic (no network, no meshcore device).""" +import asyncio import configparser import time from unittest.mock import AsyncMock, Mock, patch @@ -11,6 +12,7 @@ from modules.message_handler import ( RF_MATCH_FALLBACK, RF_MATCH_KEY, RF_MATCH_PARTIAL, + RF_MATCH_PAYLOAD, RF_MATCH_PUBKEY, MessageHandler, rf_data_is_correlated, @@ -2340,3 +2342,87 @@ class TestGlobalFloodAuthorization: rf = {"route_type_int": RouteType.TRANSPORT_FLOOD.value, RF_MATCH_KEY: RF_MATCH_EXACT} assert handler._is_confirmed_global_flood(rf, scoped_traffic_in_window=False) is False + + +class TestChannelPayloadCorrelation: + """MeshCore's CHAN event has no packet prefix or pubkey, so a channel message can + only be tied to its packet by checking the RF row against fields the decoded + payload restates: payload type, path length and SNR.""" + + CHAN = {"type": "CHAN", "SNR": 0.0, "channel_idx": 1, "path_len": 0, "text": "x: test"} + + @staticmethod + def _row(**over): + row = { + "timestamp": time.time(), + "packet_prefix": "00001540b45a2077b6bdbff6f59ca5af", + "pubkey_prefix": None, + "snr": 0.0, + "rssi": 0, + "raw_hex": "00001540b45a2077", + "payload_type_int": 5, # GRP_TXT + "packet_hash": "A1B2C3D4E5F60789", + "routing_info": {"path_length": 0, "packet_hash": "A1B2C3D4E5F60789"}, + } + row.update(over) + return row + + def test_matching_row_is_confirmed(self, handler): + assert handler._rf_data_matches_chan_payload(self._row(), self.CHAN) is True + + def test_wrong_payload_type_is_rejected(self, handler): + assert handler._rf_data_matches_chan_payload( + self._row(payload_type_int=4), self.CHAN + ) is False + + def test_path_length_mismatch_is_rejected(self, handler): + row = self._row(routing_info={"path_length": 2}) + assert handler._rf_data_matches_chan_payload(row, self.CHAN) is False + + def test_snr_mismatch_is_rejected(self, handler): + """SNR is the discriminating field: it is one reception's measured value.""" + assert handler._rf_data_matches_chan_payload(self._row(snr=-7.5), self.CHAN) is False + + def test_stale_row_is_rejected(self, handler): + """The RF row and its CHAN event arrive together; an old row that happens to + agree is a coincidence, not this message.""" + handler.message_timeout = 10.0 + row = self._row(timestamp=time.time() - 60) + assert handler._rf_data_matches_chan_payload(row, self.CHAN) is False + + def test_missing_fields_are_rejected(self, handler): + assert handler._rf_data_matches_chan_payload(self._row(snr=None), self.CHAN) is False + assert handler._rf_data_matches_chan_payload(self._row(routing_info={}), self.CHAN) is False + assert handler._rf_data_matches_chan_payload(self._row(timestamp=None), self.CHAN) is False + assert handler._rf_data_matches_chan_payload(self._row(), {}) is False + assert handler._rf_data_matches_chan_payload(None, self.CHAN) is False + assert handler._rf_data_matches_chan_payload(self._row(), {"SNR": 0.0}) is False + + def _correlate(self, handler, rows): + handler.rf_data_timeout = 15.0 + handler.message_timeout = 10.0 + handler.enhanced_correlation = False + handler.recent_rf_data = list(rows) + return asyncio.run( + handler._correlate_channel_message_rf_data( + None, "", self.CHAN, scope_eligible_only=False, extended_timeout=30.0 + ) + ) + + def test_fallback_is_promoted_when_the_payload_confirms_it(self, handler): + result = self._correlate(handler, [self._row()]) + assert result[RF_MATCH_KEY] == RF_MATCH_PAYLOAD + assert rf_data_is_correlated(result) is True + assert result["routing_info"]["packet_hash"] == "A1B2C3D4E5F60789" + + def test_fallback_stays_a_fallback_when_the_payload_disagrees(self, handler): + result = self._correlate(handler, [self._row(snr=-7.5)]) + assert result[RF_MATCH_KEY] == RF_MATCH_FALLBACK + assert rf_data_is_correlated(result) is False + + def test_promotion_does_not_mutate_the_cache(self, handler): + row = self._row() + result = self._correlate(handler, [row]) + assert result[RF_MATCH_KEY] == RF_MATCH_PAYLOAD + assert RF_MATCH_KEY not in row + assert RF_MATCH_KEY not in handler.recent_rf_data[0]