Files
mc-webui/tests/test_echo_correlation.py
T
MarekWoandClaude Fable 5.1 0c94bf0ee5 feat(chat): show the region of a channel message and shorten its route line
The meta line under an incoming channel message used to print the first
route inline ("Route (3): 142C→1122→271C→EE6C"), which with 2- or 3-byte
hashes and a few hops crowded everything else off a phone screen. It now
names only the per-hop hash size ("Route (3): 2 bytes") and stays the tap
target for the routes popup, which lists every route as before.

The line also ends with "Region: <name>" when the packet was flood-scoped
to a region this instance knows. The region name never travels: a
TRANSPORT_FLOOD packet carries a 16-bit transport code (HMAC of the scope
key over the payload) that _on_rx_log_data used to discard. Echoes now
keep those 4 header bytes in a new echoes.transport_codes column, and the
API recomputes the code for every configured region (Settings → Channels)
to find the one that reproduces it; own messages fall back to their
raw_packet snapshot. calc_transport_code() is shared with the raw-resend
packet builder, so both sides derive the stamp from one place.

Verified against on-air packets from the test server on #mcwebui: pl,
pl-ma and pl-kra each resolved to exactly one configured region.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:58:01 +02:00

308 lines
13 KiB
Python

"""
Unit tests for sent-message echo correlation.
A sent channel message learns its packet hash (and therefore its route badge)
only by hearing its own echo come back from a repeater. These tests cover the
matching rules that decide whether an incoming echo belongs to one of our
recent sends.
Run: python -m pytest tests/test_echo_correlation.py -v
"""
import pytest
pytest.importorskip('meshcore', reason='app.device_manager needs the meshcore lib')
import asyncio # noqa: E402
from types import SimpleNamespace # noqa: E402
from app.device_manager import ( # noqa: E402
DeviceManager,
_build_grp_txt_raw_packet,
_compute_pkt_payload,
_payload_from_raw_packet,
transport_codes_from_raw_packet,
)
from app.meshcore.regions import PAYLOAD_TYPE_GRP_TXT, calc_transport_code # noqa: E402
SECRET = '5c' * 32
SCOPE_KEY = 'a7' * 32
class FakeDB:
"""Records the writes _process_echo makes, nothing else."""
def __init__(self):
self.echoes = []
self.payloads = {} # msg_id -> pkt_payload
self.raw_packets = {} # msg_id -> raw_packet hex
def insert_echo(self, pkt_payload, **kwargs):
self.echoes.append(dict(pkt_payload=pkt_payload, **kwargs))
def update_message_pkt_payload(self, msg_id, pkt_payload):
self.payloads[msg_id] = pkt_payload
def update_message_raw_packet(self, msg_id, raw_packet):
self.raw_packets[msg_id] = raw_packet
def get_channel_scope(self, channel_idx):
return None
@pytest.fixture
def dm():
manager = DeviceManager(config=None, db=FakeDB())
manager._channel_secrets = {0: SECRET, 7: SECRET}
return manager
def payload_for(sender_timestamp, text):
return _compute_pkt_payload(SECRET, sender_timestamp, 0, text)
def backdate(manager, seconds):
"""Age the most recently registered pending send."""
manager._pending_echoes[-1]['timestamp'] -= seconds
# ================================================================
# Multiple sends in flight
# ================================================================
class TestMultiplePendingSends:
def test_resend_does_not_steal_another_sends_echo(self, dm):
"""Regression (msg #71179, 2026-08-07): a resend of an older message
fired 33 ms before this message's echo came back. With a single
correlation slot the echo was filed as foreign traffic and the message
never got a route badge."""
fresh = payload_for(1000, 'MarWoj: fresh send')
older = payload_for(500, 'MarWoj: older message')
dm._register_pending_echo(msg_id=71179, channel_idx=7,
expected_payloads={fresh}, guess_pkt_payload=fresh)
dm._register_pending_echo(msg_id=71177, channel_idx=7, pkt_payload=older,
expected_payloads={older}, guess_pkt_payload=older)
dm._process_echo(fresh, path='d103df', snr=12.0, hash_size=3)
assert dm.db.payloads == {71179: fresh}
assert dm.db.echoes[-1]['direction'] == 'sent'
assert dm.db.raw_packets == {} # ts+0 guess was right, no rebuild
def test_back_to_back_sends_each_get_their_own_echo(self, dm):
first = payload_for(1000, 'MarWoj: one')
second = payload_for(1030, 'MarWoj: two')
dm._register_pending_echo(msg_id=1, channel_idx=0,
expected_payloads={first}, guess_pkt_payload=first)
dm._register_pending_echo(msg_id=2, channel_idx=0,
expected_payloads={second}, guess_pkt_payload=second)
dm._process_echo(second, path='5e34e8', hash_size=3)
dm._process_echo(first, path='d103df', hash_size=3)
assert dm.db.payloads == {1: first, 2: second}
assert [e['direction'] for e in dm.db.echoes] == ['sent', 'sent']
def test_second_repeater_echo_is_still_ours(self, dm):
payload = payload_for(1000, 'MarWoj: two repeaters heard this')
dm._register_pending_echo(msg_id=5, channel_idx=0,
expected_payloads={payload}, guess_pkt_payload=payload)
dm._process_echo(payload, path='d103df', hash_size=3)
dm._process_echo(payload, path='5e34e8', hash_size=3)
assert [e['direction'] for e in dm.db.echoes] == ['sent', 'sent']
assert len(dm.db.payloads) == 1 # written once, not on every echo
def test_foreign_payload_stays_incoming(self, dm):
mine = payload_for(1000, 'MarWoj: mine')
theirs = payload_for(1000, 'Kolargol: theirs')
dm._register_pending_echo(msg_id=1, channel_idx=0,
expected_payloads={mine}, guess_pkt_payload=mine)
dm._process_echo(theirs, path='e7615e34', hash_size=2)
assert dm.db.payloads == {}
assert dm.db.echoes[-1]['direction'] == 'incoming'
def test_one_pending_entry_per_message(self, dm):
payload = payload_for(1000, 'MarWoj: resent twice')
for _ in range(3):
dm._register_pending_echo(msg_id=9, channel_idx=0, pkt_payload=payload,
expected_payloads={payload},
guess_pkt_payload=payload)
assert len(dm._pending_echoes) == 1
def test_pending_list_is_capped(self, dm):
for i in range(DeviceManager._MAX_PENDING_ECHOES + 10):
dm._register_pending_echo(msg_id=i, channel_idx=0,
expected_payloads={payload_for(i, f'msg {i}')})
assert len(dm._pending_echoes) == DeviceManager._MAX_PENDING_ECHOES
# ================================================================
# Correlation windows
# ================================================================
class TestCorrelationWindow:
def test_late_echo_still_correlates(self, dm):
"""Observed on prod: repeats arriving 62 s and 200 s after the send."""
payload = payload_for(1000, 'MarWoj: slow repeater')
dm._register_pending_echo(msg_id=1, channel_idx=0,
expected_payloads={payload}, guess_pkt_payload=payload)
backdate(dm, 200)
dm._process_echo(payload, path='ee6ccf', hash_size=3)
assert dm.db.payloads == {1: payload}
def test_echo_past_ttl_is_dropped(self, dm):
payload = payload_for(1000, 'MarWoj: ancient')
dm._register_pending_echo(msg_id=1, channel_idx=0,
expected_payloads={payload}, guess_pkt_payload=payload)
backdate(dm, DeviceManager._ECHO_MATCH_TTL + 1)
dm._process_echo(payload, path='d103df', hash_size=3)
assert dm.db.payloads == {}
assert dm.db.echoes[-1]['direction'] == 'incoming'
assert dm._pending_echoes == []
class TestChannelHashFallback:
"""Loose matching for the no-secret edge case: same channel-hash byte,
short window only, because it can grab a foreign message."""
def test_matches_within_short_window(self, dm):
payload = payload_for(1000, 'MarWoj: no secret at send time')
dm._register_pending_echo(msg_id=1, channel_idx=0) # no expected_payloads
dm._process_echo(payload, path='d103df', hash_size=3)
assert dm.db.payloads == {1: payload}
assert dm.db.echoes[-1]['direction'] == 'sent'
def test_not_used_after_60s(self, dm):
payload = payload_for(1000, 'MarWoj: too late for a loose match')
dm._register_pending_echo(msg_id=1, channel_idx=0)
backdate(dm, DeviceManager._ECHO_FALLBACK_TTL + 1)
dm._process_echo(payload, path='d103df', hash_size=3)
assert dm.db.payloads == {}
assert dm.db.echoes[-1]['direction'] == 'incoming'
def test_other_channel_hash_is_not_ours(self, dm):
dm._channel_secrets = {0: SECRET}
other = _compute_pkt_payload(SCOPE_KEY, 1000, 0, 'Someone: other channel')
dm._register_pending_echo(msg_id=1, channel_idx=0)
dm._process_echo(other, path='d103df', hash_size=3)
assert dm.db.payloads == {}
assert dm.db.echoes[-1]['direction'] == 'incoming'
# ================================================================
# Recovering the payload from a raw_packet snapshot (resend path)
# ================================================================
class TestPayloadFromRawPacket:
def test_round_trip_flood(self):
payload = payload_for(1000, 'MarWoj: plain flood')
raw = _build_grp_txt_raw_packet(payload, scope_key_hex=None, path_hash_size=3)
assert _payload_from_raw_packet(raw) == payload
def test_round_trip_transport_flood(self):
payload = payload_for(1000, 'MarWoj: region-scoped')
raw = _build_grp_txt_raw_packet(payload, scope_key_hex=SCOPE_KEY, path_hash_size=2)
assert _payload_from_raw_packet(raw) == payload
def test_accepts_bytes(self):
payload = payload_for(1000, 'MarWoj: bytes in')
raw = _build_grp_txt_raw_packet(payload, path_hash_size=1)
assert _payload_from_raw_packet(bytes.fromhex(raw)) == payload
@pytest.mark.parametrize('bad', [None, '', '15'])
def test_returns_none_when_unparsable(self, bad):
assert _payload_from_raw_packet(bad) is None
def test_resend_recovers_the_badge_of_an_uncorrelated_message(self, dm):
"""A message that missed its echo has no stored pkt_payload. The resend
arms correlation from the raw_packet snapshot instead, so the next echo
writes the payload onto the message row and the badge appears."""
payload = payload_for(1000, 'MarWoj: badge-less until resent')
raw = _build_grp_txt_raw_packet(payload, scope_key_hex=None, path_hash_size=3)
recovered = _payload_from_raw_packet(raw)
dm._register_pending_echo(msg_id=71179, channel_idx=7, pkt_payload=None,
expected_payloads={recovered},
guess_pkt_payload=recovered)
dm._process_echo(payload, path='d103df', snr=12.0, hash_size=3)
assert dm.db.payloads == {71179: payload}
assert dm.db.echoes[-1]['direction'] == 'sent'
# ================================================================
# Transport codes — the packet's region stamp, kept with the echo
# ================================================================
class TestTransportCodesFromRawPacket:
def test_scoped_packet_yields_its_codes(self):
payload = payload_for(1000, 'MarWoj: region-scoped')
raw = _build_grp_txt_raw_packet(payload, scope_key_hex=SCOPE_KEY, path_hash_size=2)
codes = transport_codes_from_raw_packet(raw)
assert codes == raw[2:10]
expected = calc_transport_code(bytes.fromhex(SCOPE_KEY), PAYLOAD_TYPE_GRP_TXT,
bytes.fromhex(payload)).hex()
assert codes == expected + '0000' # transport_codes[1] is always 0
def test_unscoped_packet_has_none(self):
payload = payload_for(1000, 'MarWoj: plain flood')
raw = _build_grp_txt_raw_packet(payload, scope_key_hex=None, path_hash_size=1)
assert transport_codes_from_raw_packet(raw) is None
@pytest.mark.parametrize('bad', [None, '', '14', '14aabb', 'zz'])
def test_returns_none_when_unparsable(self, bad):
assert transport_codes_from_raw_packet(bad) is None
class TestEchoKeepsTransportCodes:
def test_stored_on_the_echo_row(self, dm):
payload = payload_for(1000, 'MarWoj: stamped')
dm._process_echo(payload, path='d103df', hash_size=3, transport_codes='1a2b0000')
assert dm.db.echoes[-1]['transport_codes'] == '1a2b0000'
def test_unscoped_echo_stores_none(self, dm):
payload = payload_for(1000, 'MarWoj: unstamped')
dm._process_echo(payload, path='d103df', hash_size=3)
assert dm.db.echoes[-1]['transport_codes'] is None
def test_rx_log_frame_codes_reach_the_echo(self, dm):
"""The RX-log frame is the packet as heard on air: header, 4 bytes of
transport codes, path_len, the path a repeater appended, payload."""
payload = payload_for(1000, 'MarWoj: over the air')
raw = _build_grp_txt_raw_packet(payload, scope_key_hex=SCOPE_KEY, path_hash_size=1)
# one 1-byte hop (0xAB) added by the repeater that relayed it to us
heard = raw[:10] + '01' + 'ab' + raw[12:]
event = SimpleNamespace(payload={'payload': heard, 'snr': 7.5, 'rssi': -90})
asyncio.run(dm._on_rx_log_data(event))
echo = dm.db.echoes[-1]
assert echo['pkt_payload'] == payload
assert echo['path'] == 'ab'
assert echo['hash_size'] == 1
assert echo['transport_codes'] == raw[2:10]
assert echo['snr'] == 7.5
def test_rx_log_unscoped_frame_has_no_codes(self, dm):
payload = payload_for(1000, 'MarWoj: plain on air')
raw = _build_grp_txt_raw_packet(payload, scope_key_hex=None, path_hash_size=1)
event = SimpleNamespace(payload={'payload': raw, 'snr': 1.0, 'rssi': -100})
asyncio.run(dm._on_rx_log_data(event))
assert dm.db.echoes[-1]['transport_codes'] is None