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]