From 9358bf4199c31753e1787fb1a0e3be9a93eb4570 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sat, 25 Apr 2026 14:25:29 -0700 Subject: [PATCH] Misc. bug fixes around prefixing and channel message receipt --- app/tcp_proxy/encoder.py | 2 +- app/tcp_proxy/protocol.py | 2 +- app/tcp_proxy/session.py | 73 ++++++++++---- tests/test_tcp_proxy_session.py | 169 ++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 19 deletions(-) diff --git a/app/tcp_proxy/encoder.py b/app/tcp_proxy/encoder.py index d46dab3..de4fe45 100644 --- a/app/tcp_proxy/encoder.py +++ b/app/tcp_proxy/encoder.py @@ -59,7 +59,7 @@ def build_contact( if direct_path_len >= 0 and direct_path_hash_mode >= 0: out.append(encode_path_byte(direct_path_len, direct_path_hash_mode)) else: - out.append(0xFF) # flood + out.append(0xFF) # no route known path_bytes = bytes.fromhex(direct_path) if direct_path else b"" out.extend(pad(path_bytes, 64)) diff --git a/app/tcp_proxy/protocol.py b/app/tcp_proxy/protocol.py index 1143f45..cdd6845 100644 --- a/app/tcp_proxy/protocol.py +++ b/app/tcp_proxy/protocol.py @@ -131,7 +131,7 @@ def pad(data: bytes, length: int) -> bytes: def encode_path_byte(hop_count: int, hash_mode: int) -> int: """Encode hop count + hash mode into a single packed byte. - Returns ``0xFF`` (flood) when either value is negative. + Returns ``0xFF`` (direct / non-flood) when either value is negative. """ if hop_count < 0 or hash_mode < 0: return 0xFF diff --git a/app/tcp_proxy/session.py b/app/tcp_proxy/session.py index fcb3dc9..0b80ff0 100644 --- a/app/tcp_proxy/session.py +++ b/app/tcp_proxy/session.py @@ -64,6 +64,7 @@ from .protocol import ( FrameParser, build_error, build_ok, + encode_path_byte, frame_response, pad, ) @@ -368,26 +369,25 @@ class ProxySession: def _parse_destination_and_text(self, remaining: bytes) -> tuple[str | None, str | None]: """Resolve destination key + text from the combined buffer. - Tries 32-byte full key first (always accepted — _do_send_dm resolves - from the repository), then falls back to 6-byte prefix matched against - the cached contacts list. + The standard companion protocol sends a 6-byte pubkey prefix at the + start of ``remaining``, so we try prefix resolution first. Only when + prefix lookup fails do we attempt a 32-byte full-key parse (used by + ``meshcore_py`` ``send_msg_with_retry``). """ - # Try 32-byte full key first (send_msg_with_retry sends full keys) - if len(remaining) > 32: - candidate = remaining[:32].hex() - # Accept any well-formed 64-char hex key — _do_send_dm will - # resolve it from the repository, not just our favorites cache. - if len(candidate) == 64: - return candidate, remaining[32:].decode("utf-8", "ignore") - - # Fall back to 6-byte prefix (send_msg default) — can only resolve - # against our cached contacts since we need an unambiguous match. + # Standard path: 6-byte prefix — resolve against cached contacts. if len(remaining) > 6: prefix = remaining[:6].hex() matches = [c["public_key"] for c in self.contacts if c["public_key"].startswith(prefix)] if len(matches) == 1: return matches[0], remaining[6:].decode("utf-8", "ignore") + # Extended path: 32-byte full key (send_msg_with_retry sends full + # keys). _do_send_dm resolves from the repository, not just our + # favorites cache. + if len(remaining) > 32: + candidate = remaining[:32].hex() + return candidate, remaining[32:].decode("utf-8", "ignore") + return None, None # ── SEND_CHANNEL_TXT_MSG ───────────────────────────────────────── @@ -553,6 +553,43 @@ class ProxySession: self.key_to_idx[k] = i logger.debug("Pre-loaded %d favorite channel(s)", len(self.channel_slots)) + # ── Broadcast event helpers ──────────────────────────────────────── + + @staticmethod + def _extract_path_meta(data: dict[str, Any]) -> tuple[int, int]: + """Extract (snr_byte, path_len_byte) from a broadcast message dict. + + Returns the SNR as ``int8(snr * 4)`` and path_len as the companion- + protocol packed byte ``(hash_mode << 6) | hop_count``. When no path + data is available, returns ``(0, 0)`` — 0 hops at 1-byte hash mode, + which is the safest "we don't know" default for flood messages. + """ + paths = data.get("paths") or [] + first = paths[0] if paths else None + + # SNR — V3 field, signed int8 encoded as snr * 4 + snr_raw = (first.get("snr") if first else None) or 0.0 + snr_byte = max(-128, min(127, int(snr_raw * 4))) & 0xFF + + if first is None: + return snr_byte, 0 # no path info → 0 hops + + hop_count = first.get("path_len") + path_hex: str = first.get("path") or "" + if hop_count is None: + # Legacy: infer 1-byte hops from hex length + hop_count = len(path_hex) // 2 + + # Determine hash mode from path hex length and hop count + if hop_count > 0 and path_hex: + path_byte_len = len(path_hex) // 2 + hash_size = path_byte_len // hop_count if hop_count else 1 + hash_mode = max(0, hash_size - 1) # 1-byte → 0, 2 → 1, 3 → 2 + else: + hash_mode = 0 + + return snr_byte, encode_path_byte(hop_count, hash_mode) + # ── Broadcast event handlers (called by server.dispatch_event) ── async def _push_contact_from_db(self, public_key: str) -> None: @@ -589,12 +626,13 @@ class ProxySession: text = data.get("text") or "" ts = int(data.get("sender_timestamp") or time.time()) + snr_byte, path_byte = self._extract_path_meta(data) frame = bytearray([RESP_CONTACT_MSG_RECV_V3]) - frame.append(0) # SNR + frame.append(snr_byte) frame.extend(b"\x00\x00") # reserved frame.extend(bytes.fromhex(sender_key[:12])) # 6-byte prefix - frame.append(0xFF) # flood + frame.append(path_byte) frame.append(0) # txt_type frame.extend(struct.pack("26 chars) must resolve correctly.""" + session, sent = _make_session() + session.contacts = [{"public_key": EXAMPLE_KEY}] + + prefix = bytes.fromhex(EXAMPLE_KEY[:12]) + long_text = b"A" * 50 # well over 26 chars + cmd = ( + bytes([CMD_SEND_TXT_MSG, 0, 0]) + + int(time.time()).to_bytes(4, "little") + + prefix + + long_text + ) + + with patch.object(session, "_do_send_dm", new_callable=AsyncMock) as mock_send: + await session._cmd_send_dm(cmd) + + payloads = _extract_payloads(sent) + assert payloads[0][0] == RESP_MSG_SENT # not ERR + + mock_send.assert_called_once() + call_key, call_text = mock_send.call_args[0] + assert call_key == EXAMPLE_KEY + assert call_text == "A" * 50 + class TestSendChannel: @pytest.mark.asyncio @@ -414,6 +443,78 @@ class TestSyncNext: assert len(session._msg_queue) == 0 +class TestExtractPathMeta: + """Tests for _extract_path_meta static helper.""" + + def test_no_paths(self): + snr, path_byte = ProxySession._extract_path_meta({"paths": None}) + assert snr == 0 + assert path_byte == 0 # 0 hops, mode 0 + + def test_empty_paths_list(self): + snr, path_byte = ProxySession._extract_path_meta({"paths": []}) + assert snr == 0 + assert path_byte == 0 + + def test_one_byte_hops(self): + """2 hops at 1-byte hash mode → path_byte = (0 << 6) | 2 = 0x02.""" + snr, path_byte = ProxySession._extract_path_meta( + { + "paths": [{"path": "aabb", "path_len": 2, "snr": None, "rssi": None}], + } + ) + assert path_byte == encode_path_byte(2, 0) + assert path_byte == 0x02 + + def test_two_byte_hops(self): + """3 hops at 2-byte hash mode → path_byte = (1 << 6) | 3 = 0x43.""" + snr, path_byte = ProxySession._extract_path_meta( + { + "paths": [{"path": "aabbccddee11", "path_len": 3, "snr": None, "rssi": None}], + } + ) + assert path_byte == encode_path_byte(3, 1) + assert path_byte == 0x43 + + def test_three_byte_hops(self): + """1 hop at 3-byte hash mode → path_byte = (2 << 6) | 1 = 0x81.""" + snr, path_byte = ProxySession._extract_path_meta( + { + "paths": [{"path": "aabbcc", "path_len": 1, "snr": None, "rssi": None}], + } + ) + assert path_byte == encode_path_byte(1, 2) + assert path_byte == 0x81 + + def test_snr_encoded(self): + """SNR is encoded as int8(snr * 4).""" + snr, _ = ProxySession._extract_path_meta( + { + "paths": [{"path": "aa", "path_len": 1, "snr": -5.25, "rssi": -100}], + } + ) + assert snr == (-21) & 0xFF # -5.25 * 4 = -21 → unsigned byte + + def test_zero_hops_empty_path(self): + """0 hops, empty path → path_byte 0.""" + snr, path_byte = ProxySession._extract_path_meta( + { + "paths": [{"path": "", "path_len": 0, "snr": None, "rssi": None}], + } + ) + assert path_byte == 0 + + def test_legacy_no_path_len(self): + """path_len=None falls back to inferring from hex length (1-byte hops).""" + snr, path_byte = ProxySession._extract_path_meta( + { + "paths": [{"path": "aabb", "path_len": None, "snr": None, "rssi": None}], + } + ) + # Inferred: 2 hops, path is 2 bytes → 1-byte hash → mode 0 + assert path_byte == encode_path_byte(2, 0) + + class TestEventHandlers: @pytest.mark.asyncio async def test_priv_message_queued(self): @@ -431,6 +532,28 @@ class TestEventHandlers: payloads = _extract_payloads(sent) assert payloads[0][0] == PUSH_MSG_WAITING + @pytest.mark.asyncio + async def test_priv_message_path_encoding(self): + """DM frame encodes path_len byte from message path data.""" + session, sent = _make_session() + data = { + "type": "PRIV", + "outgoing": False, + "conversation_key": EXAMPLE_KEY, + "text": "hi", + "sender_timestamp": 1700000000, + "paths": [{"path": "aabb", "path_len": 2, "snr": 3.0, "rssi": -80}], + } + await session.on_event_message(data) + + frame = session._msg_queue[0] + assert frame[0] == RESP_CONTACT_MSG_RECV_V3 + snr_byte = frame[1] + assert snr_byte == 12 # 3.0 * 4 + # path_len byte is at offset 10 (after: type, snr, 2 reserved, 6 prefix) + path_byte = frame[10] + assert path_byte == encode_path_byte(2, 0) # 2 hops, 1-byte hash + @pytest.mark.asyncio async def test_chan_message_queued(self): session, sent = _make_session() @@ -448,6 +571,52 @@ class TestEventHandlers: assert len(session._msg_queue) == 1 + @pytest.mark.asyncio + async def test_chan_message_path_encoding(self): + """Channel frame encodes path_len byte correctly instead of 0xFF.""" + session, sent = _make_session() + key = "cc" * 16 + session.key_to_idx = {key: 0} + + data = { + "type": "CHAN", + "outgoing": False, + "conversation_key": key, + "text": "hello", + "sender_timestamp": 1700000000, + "paths": [{"path": "aabbccdd", "path_len": 2, "snr": -2.5, "rssi": -90}], + } + await session.on_event_message(data) + + frame = session._msg_queue[0] + assert frame[0] == RESP_CHANNEL_MSG_RECV_V3 + snr_byte = frame[1] + assert snr_byte == (-10) & 0xFF # -2.5 * 4 + # path_len byte is at offset 5 (after: type, snr, 2 reserved, channel_idx) + path_byte = frame[5] + assert path_byte == encode_path_byte(2, 1) # 2 hops, 2-byte hash + assert path_byte != 0xFF # Must NOT be the old wrong value + + @pytest.mark.asyncio + async def test_chan_message_no_paths_defaults_zero(self): + """Channel message with no path data uses 0 (not 0xFF).""" + session, sent = _make_session() + key = "cc" * 16 + session.key_to_idx = {key: 0} + + data = { + "type": "CHAN", + "outgoing": False, + "conversation_key": key, + "text": "hello", + "sender_timestamp": 1700000000, + } + await session.on_event_message(data) + + frame = session._msg_queue[0] + path_byte = frame[5] + assert path_byte == 0 # 0 hops, not 0xFF + @pytest.mark.asyncio async def test_outgoing_message_ignored(self): session, sent = _make_session()