Misc. bug fixes around prefixing and channel message receipt

This commit is contained in:
Jack Kingsman
2026-04-25 14:25:29 -07:00
parent c31779f1a9
commit 9358bf4199
4 changed files with 227 additions and 19 deletions
+1 -1
View File
@@ -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))
+1 -1
View File
@@ -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
+56 -17
View File
@@ -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("<I", ts))
frame.extend(text.encode("utf-8"))
@@ -610,12 +648,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_CHANNEL_MSG_RECV_V3])
frame.append(0) # SNR
frame.append(snr_byte)
frame.extend(b"\x00\x00") # reserved
frame.append(idx)
frame.append(0xFF) # flood
frame.append(path_byte)
frame.append(0) # txt_type
frame.extend(struct.pack("<I", ts))
frame.extend(text.encode("utf-8"))
+169
View File
@@ -24,7 +24,9 @@ from app.tcp_proxy.protocol import (
PROXY_FW_VER,
PUSH_MSG_WAITING,
RESP_BATTERY,
RESP_CHANNEL_MSG_RECV_V3,
RESP_CONTACT_END,
RESP_CONTACT_MSG_RECV_V3,
RESP_CONTACT_START,
RESP_CURRENT_TIME,
RESP_DEVICE_INFO,
@@ -33,6 +35,7 @@ from app.tcp_proxy.protocol import (
RESP_NO_MORE_MSGS,
RESP_OK,
RESP_SELF_INFO,
encode_path_byte,
)
from app.tcp_proxy.session import ProxySession
@@ -330,6 +333,32 @@ class TestSendDm:
ack_from_push = payloads[1][1:5]
assert ack_from_sent == ack_from_push
@pytest.mark.asyncio
async def test_long_text_with_prefix(self):
"""6-byte prefix + long text (>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()