mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-08 14:25:10 +02:00
Misc. bug fixes around prefixing and channel message receipt
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user