Files
pyMC_Repeater/tests/test_engine.py
T
agessaman f91a30afa1 docs(engine): state TX delay factor semantics at startup
The TX delay factors changed from absolute seconds to firmware-matching
airtime multipliers (random delay in [0, 5 * airtime * factor], per
MeshCore getRetransmitDelay), but deployed configs tuned under the old
seconds interpretation get no signal on upgrade. Log the interpretation
and both factors at startup, and fix the stale wording in
config.yaml.example and the web API docstring. Defaults are unchanged
(flood 1.0, direct 0.5; firmware ships 0.5/0.3).
2026-07-15 22:02:18 -07:00

2441 lines
95 KiB
Python

"""
tests for openhop-repeater engine.py — RepeaterHandler.
Covers: flood_forward, direct_forward, process_packet, duplicate detection,
mark_seen, validate_packet, packet scoring, TX delay, cache management,
airtime duty-cycle, TX mode (forward/monitor/no_tx), and config reloading.
"""
import asyncio
import base64
import logging
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openhop_core.protocol import Packet, PacketBuilder
from openhop_core.protocol.constants import (
MAX_PATH_SIZE,
PAYLOAD_TYPE_ACK,
PAYLOAD_TYPE_MULTIPART,
PH_ROUTE_MASK,
PH_TYPE_SHIFT,
ROUTE_TYPE_DIRECT,
ROUTE_TYPE_FLOOD,
ROUTE_TYPE_TRANSPORT_DIRECT,
ROUTE_TYPE_TRANSPORT_FLOOD,
)
from openhop_core.protocol.packet_utils import PathUtils
# ---------------------------------------------------------------------------
# Helpers — build minimal config / mocks needed by RepeaterHandler.__init__
# ---------------------------------------------------------------------------
LOCAL_HASH = 0xAB # repeater's own 1-byte path hash
# MULTIPART is not forwarded generically — it has its own MeshCore branch
# (forward_multipart_direct), covered by TestForwardMultipartDirect.
_GENERIC_FORWARD_PAYLOAD_TYPES = [pt for pt in range(16) if pt != PAYLOAD_TYPE_MULTIPART]
def _make_config(**overrides) -> dict:
"""Return a minimal valid config dict for RepeaterHandler."""
cfg = {
"repeater": {
"mode": "forward",
"cache_ttl": 3600,
"use_score_for_tx": False,
"score_threshold": 0.3,
"send_advert_interval_hours": 0, # off in tests
"node_name": "test-node",
},
"mesh": {
"unscoped_flood_allow": True,
"loop_detect": "off",
},
"delays": {
"tx_delay_factor": 1.0,
"direct_tx_delay_factor": 0.5,
},
"duty_cycle": {
"max_airtime_per_minute": 3600,
"enforcement_enabled": True,
},
"radio": {
"spreading_factor": 8,
"bandwidth": 125000,
"coding_rate": 8,
"preamble_length": 17,
},
}
# Merge overrides
for key, val in overrides.items():
if isinstance(val, dict) and key in cfg:
cfg[key].update(val)
else:
cfg[key] = val
return cfg
def _make_radio():
"""Return a mock radio with sensible defaults."""
radio = MagicMock()
radio.spreading_factor = 8
radio.bandwidth = 125000
radio.coding_rate = 8
radio.preamble_length = 17
radio.frequency = 915000000
radio.tx_power = 14
return radio
def _make_dispatcher(radio=None):
"""Return a mock dispatcher with a radio and local_identity."""
dispatcher = MagicMock()
dispatcher.radio = radio or _make_radio()
dispatcher.local_identity = MagicMock()
dispatcher.send_packet = AsyncMock()
return dispatcher
def _make_handler_with_hash(local_hash_bytes: bytes):
"""Create a RepeaterHandler whose local path hash is exactly local_hash_bytes."""
with (
patch("repeater.engine.StorageCollector"),
patch("repeater.engine.RepeaterHandler._start_background_tasks"),
):
from repeater.engine import RepeaterHandler
return RepeaterHandler(
_make_config(),
_make_dispatcher(),
local_hash_bytes[0],
local_hash_bytes=local_hash_bytes,
)
@pytest.fixture()
def handler():
"""Create a RepeaterHandler with mocked external dependencies."""
return _make_handler_with_hash(bytes([LOCAL_HASH]))
def _make_flood_packet(
payload: bytes = b"\x01\x02\x03\x04", path: bytes = b"", payload_type: int = 0x01
) -> Packet:
"""Build a FLOOD-routed packet."""
pkt = Packet()
# header: route=FLOOD(0x01), payload_type shifted, version=0
pkt.header = ROUTE_TYPE_FLOOD | (payload_type << PH_TYPE_SHIFT)
pkt.payload = bytearray(payload)
pkt.payload_len = len(payload)
pkt.path = bytearray(path)
pkt.path_len = len(path)
return pkt
def _make_direct_packet(
payload: bytes = b"\x01\x02\x03\x04", path: bytes = None, payload_type: int = 0x01
) -> Packet:
"""Build a DIRECT-routed packet with path[0] == LOCAL_HASH by default."""
if path is None:
path = bytes([LOCAL_HASH, 0xCC, 0xDD])
pkt = Packet()
pkt.header = ROUTE_TYPE_DIRECT | (payload_type << PH_TYPE_SHIFT)
pkt.payload = bytearray(payload)
pkt.payload_len = len(payload)
pkt.path = bytearray(path)
pkt.path_len = len(path)
return pkt
def _make_transport_flood_packet(
payload: bytes = b"\x01\x02\x03\x04",
path: bytes = b"",
payload_type: int = 0x01,
transport_codes=(0x1234, 0x5678),
) -> Packet:
"""Build a TRANSPORT_FLOOD-routed packet."""
pkt = Packet()
pkt.header = ROUTE_TYPE_TRANSPORT_FLOOD | (payload_type << PH_TYPE_SHIFT)
pkt.payload = bytearray(payload)
pkt.payload_len = len(payload)
pkt.path = bytearray(path)
pkt.path_len = len(path)
pkt.transport_codes = list(transport_codes)
return pkt
def _make_transport_direct_packet(
payload: bytes = b"\x01\x02\x03\x04",
path: bytes = None,
payload_type: int = 0x01,
transport_codes=(0x1234, 0x5678),
) -> Packet:
"""Build a TRANSPORT_DIRECT-routed packet with path[0] == LOCAL_HASH."""
if path is None:
path = bytes([LOCAL_HASH, 0xCC])
pkt = Packet()
pkt.header = ROUTE_TYPE_TRANSPORT_DIRECT | (payload_type << PH_TYPE_SHIFT)
pkt.payload = bytearray(payload)
pkt.payload_len = len(payload)
pkt.path = bytearray(path)
pkt.path_len = len(path)
pkt.transport_codes = list(transport_codes)
return pkt
# ===================================================================
# 1. flood_forward
# ===================================================================
class TestFloodForward:
"""flood_forward: validation, duplicate suppression, path append."""
def test_valid_flood_returns_packet(self, handler):
pkt = _make_flood_packet()
result = handler.flood_forward(pkt)
assert result is not None
assert result is pkt # mutated in-place
def test_local_hash_appended_to_path(self, handler):
pkt = _make_flood_packet(path=b"\x11\x22")
result = handler.flood_forward(pkt)
assert result.path[-1] == LOCAL_HASH
assert list(result.path) == [0x11, 0x22, LOCAL_HASH]
assert result.path_len == 3
def test_empty_path_gets_local_hash(self, handler):
pkt = _make_flood_packet(path=b"")
result = handler.flood_forward(pkt)
assert list(result.path) == [LOCAL_HASH]
assert result.path_len == 1
def test_duplicate_flood_dropped(self, handler):
pkt = _make_flood_packet()
handler.flood_forward(pkt)
pkt2 = _make_flood_packet() # same payload → same hash
result = handler.flood_forward(pkt2)
assert result is None
assert pkt2.drop_reason == "Duplicate"
def test_empty_payload_rejected(self, handler):
pkt = _make_flood_packet(payload=b"")
pkt.payload_len = 0
result = handler.flood_forward(pkt)
assert result is None
assert "Empty payload" in pkt.drop_reason
def test_none_payload_rejected(self, handler):
pkt = _make_flood_packet()
pkt.payload = None
result = handler.flood_forward(pkt)
assert result is None
def test_flood_at_max_hops_rejected(self, handler):
"""A one-byte flood path at its 63-hop encoding limit cannot grow."""
pkt = _make_flood_packet(path=bytes(range(MAX_PATH_SIZE - 1)))
result = handler.flood_forward(pkt)
assert result is None
assert "cannot append" in pkt.drop_reason
def test_do_not_retransmit_dropped(self, handler):
pkt = _make_flood_packet()
pkt.mark_do_not_retransmit()
result = handler.flood_forward(pkt)
assert result is None
assert "do not retransmit" in pkt.drop_reason.lower()
def test_unscoped_flood_deny_plain_flood(self, handler):
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_flood_packet()
# When unscoped_flood_allow=False, flood_forward should fail on a packet type without a transport code defined
result = handler.flood_forward(pkt)
assert result is None
def test_hash_computed_before_path_append(self, handler):
"""mark_seen must use the pre-append hash so duplicate detection works
when another node sends the same packet with or without our hash."""
pkt1 = _make_flood_packet(payload=b"\xaa\xbb")
hash_before = pkt1.calculate_packet_hash().hex().upper()
handler.flood_forward(pkt1)
# The hash stored in seen_packets should be the PRE-append hash
assert hash_before in handler.seen_packets
def test_path_len_updated_after_append(self, handler):
pkt = _make_flood_packet(path=b"\x11")
handler.flood_forward(pkt)
assert pkt.path_len == len(pkt.path) == 2
def test_different_payloads_not_duplicate(self, handler):
pkt1 = _make_flood_packet(payload=b"\x01")
pkt2 = _make_flood_packet(payload=b"\x02")
r1 = handler.flood_forward(pkt1)
r2 = handler.flood_forward(pkt2)
assert r1 is not None
assert r2 is not None
def test_path_none_is_handled(self, handler):
pkt = _make_flood_packet()
pkt.path = None
pkt.path_len = 0
result = handler.flood_forward(pkt)
assert result is not None
assert list(result.path) == [LOCAL_HASH]
# ===================================================================
# 2. direct_forward
# ===================================================================
class TestDirectForward:
"""direct_forward: next-hop check, path consumption, duplicate suppression."""
def test_valid_direct_returns_packet(self, handler):
pkt = _make_direct_packet()
result = handler.direct_forward(pkt)
assert result is not None
def test_path_consumed(self, handler):
pkt = _make_direct_packet(path=bytes([LOCAL_HASH, 0xCC, 0xDD]))
result = handler.direct_forward(pkt)
assert list(result.path) == [0xCC, 0xDD]
assert result.path_len == 2
def test_single_hop_path_consumed(self, handler):
"""Single hop to us: we strip and return packet with empty path (forward so it can reach destination)."""
pkt = _make_direct_packet(path=bytes([LOCAL_HASH]))
result = handler.direct_forward(pkt)
assert result is not None
assert list(result.path) == []
assert result.path_len == 0
def test_wrong_next_hop_dropped(self, handler):
pkt = _make_direct_packet(path=bytes([0xFF, 0xCC]))
result = handler.direct_forward(pkt)
assert result is None
assert "not for us" in pkt.drop_reason
def test_empty_path_dropped(self, handler):
pkt = _make_direct_packet(path=b"")
pkt.path_len = 0
result = handler.direct_forward(pkt)
assert result is None
assert "no path" in pkt.drop_reason
def test_none_path_dropped(self, handler):
pkt = Packet()
pkt.header = ROUTE_TYPE_DIRECT | (0x01 << PH_TYPE_SHIFT)
pkt.payload = bytearray(b"\x01\x02")
pkt.payload_len = 2
pkt.path = None
pkt.path_len = 0
result = handler.direct_forward(pkt)
assert result is None
def test_duplicate_direct_dropped(self, handler):
pkt = _make_direct_packet()
handler.direct_forward(pkt)
pkt2 = _make_direct_packet() # same payload → same hash
result = handler.direct_forward(pkt2)
assert result is None
assert pkt2.drop_reason == "Duplicate"
def test_hash_computed_before_path_consume(self, handler):
"""mark_seen hash must match the packet as received, before path[0] removal."""
pkt = _make_direct_packet(path=bytes([LOCAL_HASH, 0xCC]))
hash_before = pkt.calculate_packet_hash().hex().upper()
handler.direct_forward(pkt)
assert hash_before in handler.seen_packets
def test_path_len_updated_after_consume(self, handler):
pkt = _make_direct_packet(path=bytes([LOCAL_HASH, 0xCC, 0xDD]))
handler.direct_forward(pkt)
assert pkt.path_len == len(pkt.path) == 2
# ===================================================================
# 2b. forward_multipart_direct — MeshCore forwardMultipartDirect parity
# ===================================================================
def _make_multipart_ack_packet(
crc: bytes = b"\x4d\xab\xaf\x95",
remaining: int = 2,
path: bytes = None,
route: int = ROUTE_TYPE_DIRECT,
embedded_type: int = PAYLOAD_TYPE_ACK,
) -> Packet:
"""Build a MULTIPART wrapper: [remaining<<4 | embedded_type] followed by a CRC."""
if path is None:
path = bytes([LOCAL_HASH, 0xCC])
pkt = Packet()
pkt.header = route | (PAYLOAD_TYPE_MULTIPART << PH_TYPE_SHIFT)
pkt.payload = bytearray(bytes([(remaining << 4) | embedded_type]) + crc)
pkt.payload_len = len(pkt.payload)
pkt.path = bytearray(path)
pkt.path_len = len(path)
return pkt
class TestForwardMultipartDirect:
"""forward_multipart_direct mirrors MeshCore: regenerate the embedded ACK,
drop every other multipart case."""
def test_intermediate_multipart_ack_regenerates_plain_direct_ack(self, handler):
pkt = _make_multipart_ack_packet(remaining=2, path=bytes([LOCAL_HASH, 0xCC]))
result = handler.forward_multipart_direct(pkt)
assert result is not None
ack, delay = result
# Wrapper byte stripped, forced to a plain DIRECT ACK.
assert ack.get_payload_type() == PAYLOAD_TYPE_ACK
assert ack.get_route_type() == ROUTE_TYPE_DIRECT
assert bytes(ack.payload) == b"\x4d\xab\xaf\x95"
# This node removed from the path.
assert list(ack.path) == [0xCC]
assert ack.path_len == PathUtils.encode_path_len(1, 1)
# (remaining + 1) * 300 ms fragment spacing.
assert delay == pytest.approx(0.9)
def test_delay_scales_with_remaining_fragments(self, handler):
pkt = _make_multipart_ack_packet(remaining=0, path=bytes([LOCAL_HASH, 0xCC]))
_, delay = handler.forward_multipart_direct(pkt)
assert delay == pytest.approx(0.3)
def test_transport_direct_multipart_ack_regenerates_plain_direct_ack(self, handler):
pkt = _make_multipart_ack_packet(crc=b"\x11\x22\x33\x44", route=ROUTE_TYPE_TRANSPORT_DIRECT)
ack, _ = handler.forward_multipart_direct(pkt)
# MeshCore forces ROUTE_TYPE_DIRECT on the regenerated ACK.
assert ack.get_route_type() == ROUTE_TYPE_DIRECT
assert bytes(ack.payload) == b"\x11\x22\x33\x44"
def test_flood_multipart_not_forwarded(self, handler):
pkt = _make_multipart_ack_packet(route=ROUTE_TYPE_FLOOD)
result = handler.forward_multipart_direct(pkt)
assert result is None
assert "not direct-routed" in pkt.drop_reason
def test_final_hop_multipart_not_forwarded(self, handler):
pkt = _make_multipart_ack_packet(path=b"")
pkt.path_len = 0
result = handler.forward_multipart_direct(pkt)
assert result is None
assert "no path" in pkt.drop_reason
def test_wrong_next_hop_multipart_dropped(self, handler):
pkt = _make_multipart_ack_packet(path=bytes([0xFF, 0xCC]))
result = handler.forward_multipart_direct(pkt)
assert result is None
assert "not for us" in pkt.drop_reason
def test_non_ack_embedded_type_not_forwarded(self, handler):
pkt = _make_multipart_ack_packet(embedded_type=0x02) # PAYLOAD_TYPE_TXT_MSG
result = handler.forward_multipart_direct(pkt)
assert result is None
assert "unsupported embedded type" in pkt.drop_reason
def test_short_multipart_ack_not_forwarded(self, handler):
pkt = _make_multipart_ack_packet(crc=b"\x01\x02\x03") # only 4-byte payload
result = handler.forward_multipart_direct(pkt)
assert result is None
assert "unsupported embedded type" in pkt.drop_reason
def test_duplicate_multipart_ack_dropped(self, handler):
handler.forward_multipart_direct(_make_multipart_ack_packet())
pkt2 = _make_multipart_ack_packet() # same CRC + path → same regenerated ACK
result = handler.forward_multipart_direct(pkt2)
assert result is None
assert pkt2.drop_reason == "Duplicate"
def test_dedup_key_is_regenerated_ack_before_pop(self, handler):
"""The seen key is the plain-ACK form with the full path, matching
MeshCore's hasSeen()-before-removeSelfFromPath() order."""
pkt = _make_multipart_ack_packet(path=bytes([LOCAL_HASH, 0xCC]))
expected = Packet()
expected.header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_ACK << PH_TYPE_SHIFT)
expected.payload = bytearray(b"\x4d\xab\xaf\x95")
expected.payload_len = 4
expected.path = bytearray([LOCAL_HASH, 0xCC])
expected.path_len = 2
key = expected.calculate_packet_hash().hex().upper()
handler.forward_multipart_direct(pkt)
assert key in handler.seen_packets
def test_marked_do_not_retransmit_multipart_dropped(self, handler):
pkt = _make_multipart_ack_packet()
pkt.mark_do_not_retransmit()
result = handler.forward_multipart_direct(pkt)
assert result is None
def test_process_packet_dispatches_multipart(self, handler):
pkt = _make_multipart_ack_packet(remaining=1, path=bytes([LOCAL_HASH, 0xCC]))
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
ack, delay = result
assert ack.get_payload_type() == PAYLOAD_TYPE_ACK
assert delay == pytest.approx(0.6)
# Literal MeshCore-format frames (Packet::writeTo layout: header, optional 4-byte
# transport codes, encoded path_len, path bytes, payload). A multipart-ACK
# payload is `[remaining<<4 | PAYLOAD_TYPE_ACK]` + 4-byte CRC; forwardMultipartDirect
# strips that wrapper byte and re-emits a plain DIRECT ACK with this node removed
# from the path. These are independent wire fixtures — do NOT rebuild them through
# Packet.write_to()/PathUtils. remaining=2 in every case → (2+1)*300ms = 0.9s.
FIRMWARE_MULTIPART_ACK_VECTORS = (
# header 0x2A = ROUTE_DIRECT | MULTIPART<<2; path_len 0x02 = 1-byte/2-hop;
# path AB CC (AB = us); payload 23=remaining2|ACK + CRC 95AFAB4D.
# out: header 0x0E = ACK<<2 | ROUTE_DIRECT; path_len 0x01; path CC; CRC.
pytest.param(
bytes([0xAB]),
bytes.fromhex("2A02ABCC234DABAF95"),
bytes.fromhex("0E01CC4DABAF95"),
id="direct-1-byte-2-hop",
),
# 2-byte path width: path_len 0x42 = 2-byte/2-hop; path ABCD (us) + 1122.
pytest.param(
bytes([0xAB, 0xCD, 0xEF]),
bytes.fromhex("2A42ABCD1122234DABAF95"),
bytes.fromhex("0E4111224DABAF95"),
id="direct-2-byte-2-hop",
),
# header 0x2B = ROUTE_TRANSPORT_DIRECT | MULTIPART<<2; transport codes
# 34127856; the regenerated ACK is forced to plain DIRECT (codes dropped).
pytest.param(
bytes([0xAB]),
bytes.fromhex("2B3412785602ABCC234DABAF95"),
bytes.fromhex("0E01CC4DABAF95"),
id="transport-direct-1-byte-2-hop",
),
)
@pytest.mark.parametrize("local_hash_bytes, wire_in, wire_out", FIRMWARE_MULTIPART_ACK_VECTORS)
def test_forward_multipart_matches_firmware_wire_vectors(local_hash_bytes, wire_in, wire_out):
"""Parse a firmware-format multipart frame and assert the exact relayed ACK bytes."""
handler = _make_handler_with_hash(local_hash_bytes)
pkt = Packet()
pkt.read_from(wire_in)
result = handler.forward_multipart_direct(pkt)
assert result is not None
ack, delay = result
assert ack.write_to() == wire_out
assert delay == pytest.approx(0.9)
# ===================================================================
# 3. process_packet — route dispatch
# ===================================================================
class TestProcessPacket:
"""process_packet routes to flood_forward or direct_forward."""
def test_flood_route_dispatched(self, handler):
pkt = _make_flood_packet()
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, delay = result
# Flood appends local hash
assert fwd_pkt.path[-1] == LOCAL_HASH
def test_direct_route_dispatched(self, handler):
pkt = _make_direct_packet()
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, delay = result
# Direct consumed first hop
assert LOCAL_HASH not in fwd_pkt.path
def test_transport_flood_dispatched(self, handler):
pkt = _make_transport_flood_packet()
with patch.object(handler, "_check_transport_codes", return_value=(True, "")):
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, _ = result
assert fwd_pkt.path[-1] == LOCAL_HASH
def test_transport_direct_dispatched(self, handler):
pkt = _make_transport_direct_packet()
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
def test_unknown_route_type_dropped(self, handler):
"""A header with route bits = 0x03 is TRANSPORT_DIRECT which IS handled.
We hackily create a truly unknown route by patching both masks."""
pkt = _make_flood_packet()
# All 4 route types are handled. We can verify the fallback by
# ensuring flood forward fails, e.g. empty payload → returns None.
pkt.payload = None
result = handler.process_packet(pkt, snr=0.0)
assert result is None
def test_returns_tuple_with_delay(self, handler):
pkt = _make_flood_packet()
result = handler.process_packet(pkt, snr=5.0)
assert isinstance(result, tuple)
assert len(result) == 2
fwd_pkt, delay = result
assert isinstance(delay, float)
assert delay >= 0.0
def test_flood_forward_failure_returns_none(self, handler):
pkt = _make_flood_packet(payload=b"")
pkt.payload_len = 0
result = handler.process_packet(pkt, snr=0.0)
assert result is None
def test_direct_forward_failure_returns_none(self, handler):
pkt = _make_direct_packet(path=bytes([0xFF])) # wrong hop
result = handler.process_packet(pkt, snr=0.0)
assert result is None
# ===================================================================
# 4. is_duplicate / mark_seen / cache management
# ===================================================================
class TestDuplicateDetection:
"""Duplicate tracking, TTL clean-up, and cache eviction."""
def test_unseen_packet_not_duplicate(self, handler):
pkt = _make_flood_packet()
assert handler.is_duplicate(pkt) is False
def test_after_mark_seen_is_duplicate(self, handler):
pkt = _make_flood_packet()
handler.mark_seen(pkt)
assert handler.is_duplicate(pkt) is True
def test_different_packets_independent(self, handler):
pkt1 = _make_flood_packet(payload=b"\x01")
pkt2 = _make_flood_packet(payload=b"\x02")
handler.mark_seen(pkt1)
assert handler.is_duplicate(pkt1) is True
assert handler.is_duplicate(pkt2) is False
def test_cache_eviction_at_max_size(self, handler):
handler.max_cache_size = 5
packets = [_make_flood_packet(payload=bytes([i])) for i in range(6)]
for p in packets:
handler.mark_seen(p)
# Oldest (packets[0]) should have been evicted
assert handler.is_duplicate(packets[0]) is False
assert handler.is_duplicate(packets[5]) is True
def test_cleanup_cache_removes_expired(self, handler):
pkt = _make_flood_packet()
handler.mark_seen(pkt)
pkt_hash = pkt.calculate_packet_hash().hex().upper()
# Manually expire it
handler.seen_packets[pkt_hash] = time.time() - handler.cache_ttl - 1
handler.cleanup_cache()
assert handler.is_duplicate(pkt) is False
def test_cleanup_cache_keeps_fresh(self, handler):
pkt = _make_flood_packet()
handler.mark_seen(pkt)
handler.cleanup_cache()
assert handler.is_duplicate(pkt) is True
def test_mark_seen_stores_hex_upper_key(self, handler):
pkt = _make_flood_packet()
handler.mark_seen(pkt)
for key in handler.seen_packets:
assert key == key.upper()
# ===================================================================
# 5. validate_packet
# ===================================================================
class TestValidatePacket:
"""validate_packet: empty payload, oversized path."""
def test_valid_packet(self, handler):
pkt = _make_flood_packet()
valid, reason = handler.validate_packet(pkt)
assert valid is True
assert reason == ""
def test_empty_payload_fails(self, handler):
pkt = _make_flood_packet(payload=b"")
pkt.payload = None
valid, reason = handler.validate_packet(pkt)
assert valid is False
assert "Empty" in reason
def test_maximum_valid_path_passes_validation(self, handler):
"""A 32-hop, two-byte path occupies all 64 valid path bytes."""
pkt = _make_flood_packet(path=bytes(range(MAX_PATH_SIZE)))
pkt.path_len = PathUtils.encode_path_len(2, 32)
valid, reason = handler.validate_packet(pkt)
assert valid is True
assert reason == ""
def test_path_one_below_max_passes(self, handler):
pkt = _make_flood_packet(path=bytes(range(MAX_PATH_SIZE - 1)))
valid, reason = handler.validate_packet(pkt)
assert valid is True
def test_reserved_four_byte_path_width_fails(self, handler):
pkt = _make_flood_packet(path=bytes([LOCAL_HASH] * 4))
pkt.path_len = (3 << 6) | 1
valid, reason = handler.validate_packet(pkt)
assert valid is False
assert "reserved" in reason.lower()
def test_none_packet(self, handler):
valid, reason = handler.validate_packet(None)
assert valid is False
# ===================================================================
# 6. calculate_packet_score — static method
# ===================================================================
class TestPacketScore:
"""Score: SNR thresholds, collision penalty, clamping."""
def test_below_threshold_returns_zero(self):
from repeater.engine import RepeaterHandler
# SF8 threshold is -10.0
score = RepeaterHandler.calculate_packet_score(snr=-15.0, packet_len=50, spreading_factor=8)
assert score == 0.0
def test_at_threshold_returns_zero(self):
from repeater.engine import RepeaterHandler
score = RepeaterHandler.calculate_packet_score(snr=-10.0, packet_len=50, spreading_factor=8)
assert score == 0.0
def test_above_threshold_positive(self):
from repeater.engine import RepeaterHandler
score = RepeaterHandler.calculate_packet_score(snr=0.0, packet_len=50, spreading_factor=8)
assert score > 0.0
def test_high_snr_high_score(self):
from repeater.engine import RepeaterHandler
score = RepeaterHandler.calculate_packet_score(snr=10.0, packet_len=10, spreading_factor=8)
assert score > 0.5
def test_long_packet_collision_penalty(self):
from repeater.engine import RepeaterHandler
short = RepeaterHandler.calculate_packet_score(snr=5.0, packet_len=10, spreading_factor=8)
long_ = RepeaterHandler.calculate_packet_score(snr=5.0, packet_len=250, spreading_factor=8)
assert short > long_
def test_score_clamped_to_0_1(self):
from repeater.engine import RepeaterHandler
score = RepeaterHandler.calculate_packet_score(snr=50.0, packet_len=1, spreading_factor=8)
assert 0.0 <= score <= 1.0
def test_sf_below_7_returns_zero(self):
from repeater.engine import RepeaterHandler
score = RepeaterHandler.calculate_packet_score(snr=10.0, packet_len=50, spreading_factor=6)
assert score == 0.0
def test_each_sf_has_different_threshold(self):
from repeater.engine import RepeaterHandler
scores = {}
for sf in (7, 8, 9, 10, 11, 12):
scores[sf] = RepeaterHandler.calculate_packet_score(
snr=-5.0, packet_len=50, spreading_factor=sf
)
# Higher SF → lower threshold → better reception at same SNR
# At SNR=-5, SF7 (threshold -7.5) should be worse than SF12 (threshold -20)
assert scores[12] > scores[7]
# ===================================================================
# 7. _calculate_tx_delay
# ===================================================================
class TestTxDelay:
"""TX delay: route-aware random window, score adjustment, cap."""
def test_flood_delay_non_negative(self, handler):
pkt = _make_flood_packet()
delay = handler._calculate_tx_delay(pkt, snr=0.0)
assert delay >= 0.0
def test_flood_delay_capped_at_5s(self, handler):
handler.tx_delay_factor = 1000.0 # extreme multiplier
pkt = _make_flood_packet()
delay = handler._calculate_tx_delay(pkt, snr=0.0)
assert delay <= 5.0
@pytest.mark.parametrize(
"builder, factor_attr",
[
(_make_flood_packet, "tx_delay_factor"),
(_make_transport_flood_packet, "tx_delay_factor"),
(_make_direct_packet, "direct_tx_delay_factor"),
(_make_transport_direct_packet, "direct_tx_delay_factor"),
],
)
def test_route_variants_use_random_window(self, handler, builder, factor_attr):
setattr(handler, factor_attr, 1.23)
pkt = builder()
expected = handler.airtime_mgr.calculate_airtime(pkt.get_raw_length()) * 1.23 * 5 / 1000.0
with patch("repeater.engine.secrets.randbelow", return_value=5000):
delay = handler._calculate_tx_delay(pkt, snr=0.0)
assert delay == pytest.approx(expected, rel=0.001, abs=0.001)
@pytest.mark.parametrize(
"builder, factor_attr",
[
(_make_flood_packet, "tx_delay_factor"),
(_make_transport_flood_packet, "tx_delay_factor"),
(_make_direct_packet, "direct_tx_delay_factor"),
(_make_transport_direct_packet, "direct_tx_delay_factor"),
],
)
def test_route_variants_can_delay_to_zero(self, handler, builder, factor_attr):
setattr(handler, factor_attr, 1.23)
pkt = builder()
with patch("repeater.engine.secrets.randbelow", return_value=0):
delay = handler._calculate_tx_delay(pkt, snr=0.0)
assert delay == 0.0
def test_score_adjustment_reduces_delay(self, handler):
handler.use_score_for_tx = True
pkt = _make_flood_packet(payload=b"\x01" * 50)
# High SNR → high score → shorter delay
delays = []
for _ in range(50):
d = handler._calculate_tx_delay(pkt, snr=10.0)
delays.append(d)
avg_high_snr = sum(delays) / len(delays)
# Low SNR → low score → longer delay
delays_low = []
for _ in range(50):
d = handler._calculate_tx_delay(pkt, snr=-5.0)
delays_low.append(d)
avg_low_snr = sum(delays_low) / len(delays_low)
# Using statistical comparison — average high-SNR delay should be lower
# (Both use random, but multiplier differs)
# This is non-deterministic; we just check score path doesn't crash
assert avg_high_snr >= 0.0
assert avg_low_snr >= 0.0
def test_zero_tx_delay_factor(self, handler):
handler.tx_delay_factor = 0.0
pkt = _make_flood_packet()
delay = handler._calculate_tx_delay(pkt, snr=0.0)
assert delay == 0.0
def test_startup_log_states_delay_factor_semantics(self, caplog):
with caplog.at_level(logging.INFO, logger="RepeaterHandler"):
_make_handler_with_hash(bytes([LOCAL_HASH]))
assert any(
"TX delay factors" in record.message and "airtime multipliers" in record.message
for record in caplog.records
)
def test_transport_direct_uses_random_window(self, handler):
handler.direct_tx_delay_factor = 0.77
pkt = _make_transport_direct_packet()
expected = handler.airtime_mgr.calculate_airtime(pkt.get_raw_length()) * 0.77 * 5 / 1000.0
with patch("repeater.engine.secrets.randbelow", return_value=5000):
delay = handler._calculate_tx_delay(pkt, snr=0.0)
assert delay == pytest.approx(expected, rel=0.001, abs=0.001)
# ===================================================================
# 8. Hash stability through forwarding operations
# ===================================================================
class TestHashStabilityThroughForwarding:
"""Verify hash is computed on original packet (before path mutation)."""
def test_flood_hash_unchanged_after_forward(self, handler):
pkt = _make_flood_packet(payload=b"\xde\xad")
hash_before = pkt.calculate_packet_hash().hex().upper()
handler.flood_forward(pkt)
# The hash stored should be the pre-modification hash
assert hash_before in handler.seen_packets
def test_direct_hash_unchanged_after_forward(self, handler):
pkt = _make_direct_packet(payload=b"\xbe\xef", path=bytes([LOCAL_HASH, 0xCC]))
hash_before = pkt.calculate_packet_hash().hex().upper()
handler.direct_forward(pkt)
assert hash_before in handler.seen_packets
def test_flood_second_identical_detected_as_duplicate(self, handler):
"""Two identical packets with the same payload (but path not yet modified)
should be correctly detected as duplicates."""
p1 = _make_flood_packet(payload=b"\xca\xfe")
p2 = _make_flood_packet(payload=b"\xca\xfe")
handler.flood_forward(p1)
result = handler.flood_forward(p2)
assert result is None
def test_direct_second_identical_detected_as_duplicate(self, handler):
p1 = _make_direct_packet(payload=b"\xca\xfe", path=bytes([LOCAL_HASH, 0x11]))
p2 = _make_direct_packet(payload=b"\xca\xfe", path=bytes([LOCAL_HASH, 0x11]))
handler.direct_forward(p1)
result = handler.direct_forward(p2)
assert result is None
# ===================================================================
# 9. unscoped flood policy
# ===================================================================
class TestUnscopedFloodPolicy:
"""unscoped_flood_allow=False blocks plain flood, transport checked."""
def test_flood_blocked_by_policy(self, handler):
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_flood_packet()
result = handler.flood_forward(pkt)
assert result is None
def test_direct_unaffected_by_flood_policy(self, handler):
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_direct_packet()
result = handler.direct_forward(pkt)
assert result is not None # direct is not blocked by flood policy
def test_transport_flood_unaffected_by_unscoped_policy(self, handler):
# unscoped_flood_allow controls only plain FLOOD packets.
# Transport floods are validated via _check_transport_codes regardless.
# With a configured scope that allows, transport flood passes even when
# unscoped traffic is denied — the two settings are fully independent.
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_transport_flood_packet()
with patch.object(handler, "_check_transport_codes", return_value=(True, "")):
result = handler.flood_forward(pkt)
assert result is not None # transport flood passes; unscoped=False did not block it
def test_transport_flood_denied_with_no_keys(self, handler):
# Scope Not Configured = denied, regardless of unscoped_flood_allow.
pkt = _make_transport_flood_packet()
result = handler.flood_forward(pkt) # no mocking — real _check_transport_codes
assert result is None # denied because no transport keys configured
class TestFloodLoopDetection:
"""MeshCore-style loop detection for flood forwarding."""
def test_loop_detect_off_allows_looped_path(self, handler):
handler.config["mesh"]["loop_detect"] = "off"
handler.reload_runtime_config()
pkt = _make_flood_packet(path=bytes([LOCAL_HASH, 0x11, LOCAL_HASH]))
result = handler.flood_forward(pkt)
assert result is not None
def test_loop_detect_minimal_drops_at_four(self, handler):
handler.config["mesh"]["loop_detect"] = "minimal"
handler.reload_runtime_config()
pkt = _make_flood_packet(path=bytes([LOCAL_HASH, LOCAL_HASH, LOCAL_HASH, LOCAL_HASH]))
result = handler.flood_forward(pkt)
assert result is None
assert "loop detected" in (pkt.drop_reason or "").lower()
def test_loop_detect_minimal_allows_below_threshold(self, handler):
handler.config["mesh"]["loop_detect"] = "minimal"
handler.reload_runtime_config()
pkt = _make_flood_packet(path=bytes([LOCAL_HASH, LOCAL_HASH, LOCAL_HASH]))
result = handler.flood_forward(pkt)
assert result is not None
def test_loop_detect_moderate_drops_at_two(self, handler):
handler.config["mesh"]["loop_detect"] = "moderate"
handler.reload_runtime_config()
pkt = _make_flood_packet(path=bytes([LOCAL_HASH, 0x22, LOCAL_HASH]))
result = handler.flood_forward(pkt)
assert result is None
def test_loop_detect_strict_drops_at_one(self, handler):
handler.config["mesh"]["loop_detect"] = "strict"
handler.reload_runtime_config()
pkt = _make_flood_packet(path=bytes([0x33, LOCAL_HASH, 0x44]))
result = handler.flood_forward(pkt)
assert result is None
@pytest.mark.parametrize(
"loop_detect, hash_size, threshold",
[
("minimal", 1, 4),
("minimal", 2, 2),
("minimal", 3, 1),
("moderate", 1, 2),
("moderate", 2, 1),
("moderate", 3, 1),
("strict", 1, 1),
("strict", 2, 1),
("strict", 3, 1),
],
)
def test_loop_threshold_depends_on_hash_width(self, handler, loop_detect, hash_size, threshold):
handler.config["mesh"]["loop_detect"] = loop_detect
handler.reload_runtime_config()
handler.local_hash_bytes = bytes([LOCAL_HASH, 0xCD, 0xEF])
local_hash = handler.local_hash_bytes[:hash_size]
below_payload = bytes([hash_size, threshold, 0x01])
below_path = local_hash * max(threshold - 1, 0)
below_pkt = _make_flood_packet(path=below_path, payload=below_payload)
below_pkt.path_len = PathUtils.encode_path_len(hash_size, max(threshold - 1, 0))
below_result = handler.flood_forward(below_pkt)
assert below_result is not None
at_payload = bytes([hash_size, threshold, 0x02])
at_path = local_hash * threshold
at_pkt = _make_flood_packet(path=at_path, payload=at_payload)
at_pkt.path_len = PathUtils.encode_path_len(hash_size, threshold)
at_result = handler.flood_forward(at_pkt)
assert at_result is None
assert "loop" in (at_pkt.drop_reason or "").lower()
# ===================================================================
# 10. Airtime / duty-cycle integration
# ===================================================================
class TestAirtimeIntegration:
"""Airtime calculation and duty-cycle enforcement."""
def test_airtime_positive(self, handler):
airtime = handler.airtime_mgr.calculate_airtime(50)
assert airtime > 0.0
def test_can_transmit_fresh(self, handler):
can_tx, wait = handler.airtime_mgr.can_transmit(100.0)
assert can_tx is True
assert wait == 0.0
def test_cannot_transmit_after_exhaustion(self, handler):
# Fill up the budget
handler.airtime_mgr.record_tx(handler.airtime_mgr.max_airtime_per_minute)
can_tx, wait = handler.airtime_mgr.can_transmit(1.0)
assert can_tx is False
assert wait > 0.0
def test_duty_cycle_disabled(self, handler):
handler.config["duty_cycle"]["enforcement_enabled"] = False
handler.airtime_mgr.config = handler.config
handler.airtime_mgr.record_tx(999999)
can_tx, wait = handler.airtime_mgr.can_transmit(999999)
assert can_tx is True
def test_airtime_increases_with_packet_size(self, handler):
short = handler.airtime_mgr.calculate_airtime(10)
long_ = handler.airtime_mgr.calculate_airtime(200)
assert long_ > short
# ===================================================================
# 11. Config reload
# ===================================================================
class TestConfigReload:
"""reload_runtime_config updates in-memory state."""
def test_tx_delay_factor_reloaded(self, handler):
handler.config["delays"]["tx_delay_factor"] = 3.14
handler.reload_runtime_config()
assert handler.tx_delay_factor == 3.14
def test_direct_tx_delay_factor_reloaded(self, handler):
handler.config["delays"]["direct_tx_delay_factor"] = 2.5
handler.reload_runtime_config()
assert handler.direct_tx_delay_factor == 2.5
def test_use_score_for_tx_reloaded(self, handler):
handler.config["repeater"]["use_score_for_tx"] = True
handler.reload_runtime_config()
assert handler.use_score_for_tx is True
def test_cache_ttl_reloaded(self, handler):
handler.config["repeater"]["cache_ttl"] = 120
handler.reload_runtime_config()
assert handler.cache_ttl == 120
# ===================================================================
# 12. _get_drop_reason
# ===================================================================
class TestGetDropReason:
"""_get_drop_reason: determine why a packet was not forwarded."""
def test_duplicate_reason(self, handler):
pkt = _make_flood_packet()
handler.mark_seen(pkt)
reason = handler._get_drop_reason(pkt)
assert reason == "Duplicate"
def test_empty_payload_reason(self, handler):
pkt = _make_flood_packet(payload=b"")
pkt.payload_len = 0
pkt.payload = bytearray()
reason = handler._get_drop_reason(pkt)
assert "Empty" in reason
def test_path_too_long_reason(self, handler):
pkt = _make_flood_packet(path=bytes(range(MAX_PATH_SIZE + 2)))
pkt.path_len = PathUtils.encode_path_len(2, 33)
reason = handler._get_drop_reason(pkt)
assert "Path too long" in reason
def test_flood_policy_reason(self, handler):
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_flood_packet()
reason = handler._get_drop_reason(pkt)
assert "flood" in reason.lower()
def test_direct_not_for_us_reason(self, handler):
pkt = _make_direct_packet(path=bytes([0xFF, 0xCC]))
reason = handler._get_drop_reason(pkt)
assert "not for us" in reason
def test_direct_no_path_reason(self, handler):
pkt = _make_direct_packet(path=b"")
pkt.path_len = 0
reason = handler._get_drop_reason(pkt)
assert "no path" in reason
# ===================================================================
# 13. Transport route forwarding
# ===================================================================
class TestTransportForwarding:
"""TRANSPORT_FLOOD and TRANSPORT_DIRECT: packet routing through process_packet."""
def test_transport_flood_appends_path(self, handler):
pkt = _make_transport_flood_packet(path=b"\x11")
with patch.object(handler, "_check_transport_codes", return_value=(True, "")):
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, _ = result
assert fwd_pkt.path[-1] == LOCAL_HASH
assert len(fwd_pkt.path) == 2
def test_transport_direct_consumes_path(self, handler):
pkt = _make_transport_direct_packet(path=bytes([LOCAL_HASH, 0xCC]))
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, _ = result
assert list(fwd_pkt.path) == [0xCC]
def test_transport_codes_preserved_after_flood(self, handler):
pkt = _make_transport_flood_packet(transport_codes=(0xAAAA, 0xBBBB))
with patch.object(handler, "_check_transport_codes", return_value=(True, "")):
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, _ = result
assert fwd_pkt.transport_codes == [0xAAAA, 0xBBBB]
def test_transport_codes_preserved_after_direct(self, handler):
pkt = _make_transport_direct_packet(transport_codes=(0x1111, 0x2222))
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, _ = result
assert fwd_pkt.transport_codes == [0x1111, 0x2222]
# ===================================================================
# 14. Statistics tracking
# ===================================================================
class TestStatistics:
"""RX/TX/dropped counters and recent_packets list."""
def test_initial_counters_zero(self, handler):
assert handler.rx_count == 0
assert handler.forwarded_count == 0
assert handler.dropped_count == 0
def test_get_stats_returns_dict(self, handler):
with patch.object(handler, "storage", None):
stats = handler.get_stats()
assert "rx_count" in stats
assert "forwarded_count" in stats
assert "dropped_count" in stats
assert "local_hash" in stats
assert "uptime_seconds" in stats
def test_get_stats_local_hash_format(self, handler):
with patch.object(handler, "storage", None):
stats = handler.get_stats()
assert stats["local_hash"] == f"0x{LOCAL_HASH:02x}"
# ===================================================================
# 15. Edge cases and regression tests
# ===================================================================
class TestEdgeCases:
"""Miscellaneous edge cases and regressions."""
def test_path_as_list_converted_to_bytearray(self, handler):
"""flood_forward should handle path being a list (not bytearray)."""
pkt = _make_flood_packet()
pkt.path = [0x11, 0x22]
pkt.path_len = 2
result = handler.flood_forward(pkt)
assert result is not None
assert isinstance(result.path, bytearray)
assert list(result.path) == [0x11, 0x22, LOCAL_HASH]
def test_flood_forward_idempotent_on_second_call(self, handler):
"""Calling flood_forward again with the SAME packet object should
detect as duplicate (the first call already mark_seen'd it)."""
pkt = _make_flood_packet(payload=b"\xff" * 10)
r1 = handler.flood_forward(pkt)
assert r1 is not None
# Now pkt has local_hash appended, but hash was computed pre-append.
# A new packet with same original payload should be duplicate.
pkt2 = _make_flood_packet(payload=b"\xff" * 10)
r2 = handler.flood_forward(pkt2)
assert r2 is None
def test_large_payload_still_forwarded(self, handler):
pkt = _make_flood_packet(payload=bytes(range(256)) * 4)
result = handler.flood_forward(pkt)
assert result is not None
def test_payload_type_encoding_in_header(self, handler):
"""Verify header construction encodes payload_type correctly."""
for pt in range(16):
pkt = _make_flood_packet(payload=bytes([pt, 0xFF]), payload_type=pt)
assert pkt.get_payload_type() == pt
result = handler.flood_forward(pkt)
assert result is not None
def test_many_distinct_packets_all_forwarded(self, handler):
"""100 unique packets should all be forwarded (no false duplicates)."""
for i in range(100):
pkt = _make_flood_packet(payload=i.to_bytes(4, "big"))
result = handler.flood_forward(pkt)
assert result is not None, f"Packet {i} incorrectly detected as duplicate"
def test_cache_eviction_order_is_fifo(self, handler):
handler.max_cache_size = 3
pkts = [_make_flood_packet(payload=bytes([i])) for i in range(4)]
for p in pkts:
handler.mark_seen(p)
# First one evicted
assert handler.is_duplicate(pkts[0]) is False
# Last three still present
for p in pkts[1:]:
assert handler.is_duplicate(p) is True
def test_do_not_retransmit_with_custom_drop_reason(self, handler):
pkt = _make_flood_packet()
pkt.mark_do_not_retransmit()
pkt.drop_reason = "Custom reason"
result = handler.flood_forward(pkt)
assert result is None
# Custom reason should be preserved (not overwritten)
assert pkt.drop_reason == "Custom reason"
def test_monitor_mode_skips_processing(self, handler):
"""In monitor mode, process_packet is not called at all in __call__,
but process_packet itself doesn't check mode — that's done in __call__.
We test that process_packet works irrespective of mode."""
handler.config["repeater"]["mode"] = "monitor"
pkt = _make_flood_packet()
# process_packet doesn't check mode — should still work
result = handler.process_packet(pkt, snr=0.0)
assert result is not None
# ===================================================================
# 15b. TX mode: forward, monitor, no_tx
# ===================================================================
@pytest.mark.asyncio
class TestTxMode:
"""forward = repeat on; monitor = no repeat, local TX allowed; no_tx = all TX off."""
async def test_forward_mode_calls_process_packet_for_rx(self, handler):
"""In forward mode, a received packet (not local) triggers process_packet."""
handler.config["repeater"]["mode"] = "forward"
pkt = _make_flood_packet()
with patch.object(handler, "process_packet", wraps=handler.process_packet) as m:
await handler(pkt, {"snr": 0.0, "rssi": -80}, local_transmission=False)
m.assert_called_once()
async def test_monitor_mode_does_not_call_process_packet_for_rx(self, handler):
"""In monitor mode, a received packet does not trigger process_packet."""
handler.config["repeater"]["mode"] = "monitor"
pkt = _make_flood_packet()
with patch.object(handler, "process_packet") as m:
await handler(pkt, {"snr": 0.0, "rssi": -80}, local_transmission=False)
m.assert_not_called()
async def test_no_tx_mode_does_not_call_process_packet_for_rx(self, handler):
"""In no_tx mode, a received packet does not trigger process_packet."""
handler.config["repeater"]["mode"] = "no_tx"
pkt = _make_flood_packet()
with patch.object(handler, "process_packet") as m:
await handler(pkt, {"snr": 0.0, "rssi": -80}, local_transmission=False)
m.assert_not_called()
async def test_monitor_mode_allows_local_tx(self, handler):
"""In monitor mode, local_transmission=True still schedules send_packet."""
handler.config["repeater"]["mode"] = "monitor"
pkt = _make_flood_packet()
with patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock):
await handler(pkt, {"snr": 0.0, "rssi": -80}, local_transmission=True)
await asyncio.sleep(0) # flush scheduled task
handler.dispatcher.send_packet.assert_called_once()
async def test_no_tx_mode_blocks_local_tx(self, handler):
"""In no_tx mode, local_transmission=True does not schedule send_packet."""
handler.config["repeater"]["mode"] = "no_tx"
pkt = _make_flood_packet()
with patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock):
await handler(pkt, {"snr": 0.0, "rssi": -80}, local_transmission=True)
await asyncio.sleep(0)
handler.dispatcher.send_packet.assert_not_called()
async def test_forward_mode_allows_local_tx(self, handler):
"""In forward mode, local_transmission=True schedules send_packet."""
handler.config["repeater"]["mode"] = "forward"
pkt = _make_flood_packet()
with patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock):
await handler(pkt, {"snr": 0.0, "rssi": -80}, local_transmission=True)
await asyncio.sleep(0)
handler.dispatcher.send_packet.assert_called_once()
async def test_local_tx_exception_does_not_underflow_forwarded_count(self, handler):
"""TX exceptions must not decrement forwarded_count before any increment happened."""
handler.config["repeater"]["mode"] = "forward"
pkt = _make_flood_packet()
handler.forwarded_count = 0
async def _boom():
raise RuntimeError("simulated tx failure")
with patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock):
with patch.object(
handler,
"schedule_retransmit",
new=AsyncMock(return_value=asyncio.create_task(_boom())),
):
with pytest.raises(RuntimeError, match="simulated tx failure"):
await handler(pkt, {"snr": 0.0, "rssi": -80}, local_transmission=True)
assert handler.forwarded_count == 0
async def test_rx_tx_failure_increments_dropped_count_and_preserves_accounting(self, handler):
"""A graceful TX failure should count as dropped and keep counters balanced."""
handler.config["repeater"]["mode"] = "forward"
pkt = _make_flood_packet()
handler.rx_count = 0
handler.forwarded_count = 0
handler.dropped_count = 0
async def _tx_false():
return False
with patch.object(
handler,
"schedule_retransmit",
new=AsyncMock(return_value=asyncio.create_task(_tx_false())),
):
transmitted = await handler(pkt, {"snr": 0.0, "rssi": -80}, local_transmission=False)
assert transmitted is False
assert handler.rx_count == 1
assert handler.forwarded_count == 0
assert handler.dropped_count == 1
assert handler.rx_count == handler.forwarded_count + handler.dropped_count
# ===================================================================
# 16. Airtime calculation correctness
# ===================================================================
class TestAirtimeCalculation:
"""Semtech LoRa airtime formula validation."""
def test_known_airtime_sf7_125khz(self, handler):
"""SF7, 125kHz, CR4/5, 10-byte payload — well-known reference value."""
mgr = handler.airtime_mgr
# Override to known settings
at = mgr.calculate_airtime(
10, spreading_factor=7, bandwidth_hz=125000, coding_rate=5, preamble_len=8
)
# Semtech calculator: ~36ms for these params
assert 30.0 < at < 50.0
def test_sf12_much_slower_than_sf7(self, handler):
mgr = handler.airtime_mgr
at7 = mgr.calculate_airtime(50, spreading_factor=7)
at12 = mgr.calculate_airtime(50, spreading_factor=12)
# SF12 is roughly 32x slower than SF7 per symbol
assert at12 > at7 * 10
def test_zero_payload_still_has_preamble(self, handler):
mgr = handler.airtime_mgr
at = mgr.calculate_airtime(0)
assert at > 0.0
# ===================================================================
# 17. Curated good-packet and bad-packet arrays
# ===================================================================
# ---- 20 GOOD packets: all should be forwarded by process_packet ----
GOOD_PACKETS = [
# (id, description, builder)
(
"good_flood_minimal",
"Flood, 1-byte payload, empty path",
lambda: _make_flood_packet(payload=b"\x01"),
),
(
"good_flood_typical",
"Flood, 10-byte payload, 2-hop path",
lambda: _make_flood_packet(payload=bytes(range(10)), path=b"\x11\x22"),
),
(
"good_flood_max_payload_type",
"Flood, payload_type=15 (max 4-bit)",
lambda: _make_flood_packet(payload=b"\xaa\xbb", payload_type=15),
),
(
"good_flood_payload_type_0",
"Flood, payload_type=0 (plain text)",
lambda: _make_flood_packet(payload=b"\x01\x02\x03", payload_type=0),
),
(
"good_flood_long_payload",
"Flood, 200-byte payload",
lambda: _make_flood_packet(payload=bytes(range(200))),
),
(
"good_flood_single_byte_path",
"Flood, path has 1 prior hop",
lambda: _make_flood_packet(payload=b"\xde\xad", path=b"\x42"),
),
(
"good_flood_binary_payload",
"Flood, all-zero payload",
lambda: _make_flood_packet(payload=b"\x00" * 16),
),
(
"good_flood_high_entropy",
"Flood, high-entropy random-looking payload",
lambda: _make_flood_packet(payload=bytes(i ^ 0xA5 for i in range(64))),
),
(
"good_flood_advert_type",
"Flood, payload_type=4 (ADVERT)",
lambda: _make_flood_packet(payload=b"\xab\x01\x02\x03", payload_type=4),
),
(
"good_direct_minimal",
"Direct, 1-byte payload, single hop to us (forward with empty path)",
lambda: _make_direct_packet(payload=b"\x01", path=bytes([LOCAL_HASH])),
),
(
"good_direct_multihop",
"Direct, 3-hop remaining path (us + 2 more)",
lambda: _make_direct_packet(payload=b"\xca\xfe", path=bytes([LOCAL_HASH, 0x11, 0x22])),
),
(
"good_direct_long_payload",
"Direct, 150-byte payload",
lambda: _make_direct_packet(payload=bytes(range(150)), path=bytes([LOCAL_HASH, 0xBB])),
),
(
"good_direct_type_2",
"Direct, payload_type=2 (ACK)",
lambda: _make_direct_packet(payload=b"\x01\x02", path=bytes([LOCAL_HASH]), payload_type=2),
),
(
"good_direct_long_remaining_path",
"Direct, 10 hops remaining after us",
lambda: _make_direct_packet(
payload=b"\xff\xee", path=bytes([LOCAL_HASH] + list(range(10)))
),
),
(
"good_transport_direct_basic",
"Transport direct, basic hop to us",
lambda: _make_transport_direct_packet(payload=b"\x01\x02"),
),
(
"good_transport_direct_long_path",
"Transport direct, 5 remaining hops",
lambda: _make_transport_direct_packet(
payload=b"\xde\xad\xbe\xef", path=bytes([LOCAL_HASH, 0x11, 0x22, 0x33, 0x44])
),
),
]
# ---- 19 BAD packets: all should be dropped / return None ----
BAD_PACKETS = [
# (id, description, builder)
(
"bad_empty_payload",
"Empty bytearray payload",
lambda: _make_flood_packet(payload=b""),
"Empty payload",
),
(
"bad_none_payload",
"payload = None",
lambda: (lambda p: (setattr(p, "payload", None), p)[-1])(_make_flood_packet()),
"Empty payload",
),
(
"bad_flood_path_near_max",
"Flood, path = MAX_PATH_SIZE - 1 (63 hops; path_len encodes 0-63, cannot append)",
lambda: _make_flood_packet(payload=b"\xff", path=bytes(range(MAX_PATH_SIZE - 1))),
"cannot append",
),
(
"bad_path_over_max",
"Path exceeds MAX_PATH_SIZE",
lambda: _make_flood_packet(payload=b"\x01", path=bytes(range(MAX_PATH_SIZE + 5))),
"Path length",
),
(
"bad_do_not_retransmit",
"Marked do-not-retransmit",
lambda: (lambda p: (p.mark_do_not_retransmit(), p)[-1])(_make_flood_packet()),
"do not retransmit",
),
(
"bad_direct_wrong_hop",
"Direct packet, path[0] != LOCAL_HASH",
lambda: _make_direct_packet(path=bytes([0xFF, 0xCC])),
"not for us",
),
(
"bad_direct_empty_path",
"Direct packet with empty path",
lambda: _make_direct_packet(path=b""),
"no path",
),
(
"bad_direct_none_path",
"Direct packet with path = None",
lambda: (lambda p: (setattr(p, "path", None), setattr(p, "path_len", 0), p)[-1])(
_make_direct_packet()
),
"no path",
),
(
"bad_flood_policy_off",
"Plain flood when unscoped_flood_allow=False (needs config override)",
lambda: _make_flood_packet(payload=b"\x01\x02"),
"unscoped flood",
),
(
"bad_transport_flood_no_keys",
"Transport flood with no configured transport keys — always denied",
lambda: _make_transport_flood_packet(payload=b"\x01\x02"),
"transport",
),
(
"bad_direct_empty_payload",
"Direct with empty payload (now caught by validate_packet)",
lambda: (
lambda p: (setattr(p, "payload", bytearray()), setattr(p, "payload_len", 0), p)[-1]
)(_make_direct_packet(path=bytes([LOCAL_HASH]))),
"Empty payload",
),
(
"bad_flood_zero_len_payload",
"Flood with payload_len forced to 0",
lambda: (
lambda p: (setattr(p, "payload_len", 0), setattr(p, "payload", bytearray()), p)[-1]
)(_make_flood_packet(payload=b"\x01")),
"Empty payload",
),
(
"bad_direct_only_wrong_hops",
"Direct path of all 0xFF bytes (none match LOCAL_HASH)",
lambda: _make_direct_packet(path=bytes([0xFF, 0xFE, 0xFD])),
"not for us",
),
(
"bad_transport_direct_wrong_hop",
"Transport direct with wrong first hop",
lambda: _make_transport_direct_packet(path=bytes([0x01, 0x02])),
"not for us",
),
(
"bad_transport_direct_empty_path",
"Transport direct with empty path",
lambda: _make_transport_direct_packet(path=b""),
"no path",
),
(
"bad_transport_direct_none_path",
"Transport direct with path = None",
lambda: (lambda p: (setattr(p, "path", None), setattr(p, "path_len", 0), p)[-1])(
_make_transport_direct_packet()
),
"no path",
),
(
"bad_flood_payload_255_zeros",
"Flood with payload = bytearray(0) (empty)",
lambda: (
lambda p: (setattr(p, "payload", bytearray()), setattr(p, "payload_len", 0), p)[-1]
)(_make_flood_packet()),
"Empty payload",
),
(
"bad_direct_none_payload",
"Direct with None payload (now caught by validate_packet)",
lambda: (lambda p: (setattr(p, "payload", None), p)[-1])(
_make_direct_packet(path=bytes([LOCAL_HASH]))
),
"Empty payload",
),
(
"bad_flood_do_not_retransmit_custom",
"Flood, do-not-retransmit with custom drop reason",
lambda: (
lambda p: (p.mark_do_not_retransmit(), setattr(p, "drop_reason", "Advert consumed"), p)[
-1
]
)(_make_flood_packet(payload=b"\xab")),
"Advert consumed",
),
(
"bad_direct_do_not_retransmit",
"Direct, marked do-not-retransmit (now caught by direct_forward)",
lambda: (lambda p: (p.mark_do_not_retransmit(), p)[-1])(
_make_direct_packet(payload=b"\x99", path=bytes([LOCAL_HASH, 0x11]))
),
"do not retransmit",
),
]
# Pytest ids for readable output
_good_ids = [g[0] for g in GOOD_PACKETS]
_bad_ids = [b[0] for b in BAD_PACKETS]
class TestGoodPacketArray:
"""All 20 good packets should be forwarded successfully."""
@pytest.mark.parametrize(
"name, desc, builder",
GOOD_PACKETS,
ids=_good_ids,
)
def test_process_packet_forwards(self, handler, name, desc, builder):
pkt = builder()
result = handler.process_packet(pkt, snr=5.0)
assert result is not None, f"[{name}] {desc} — unexpectedly dropped"
fwd_pkt, delay = result
assert delay >= 0.0
@pytest.mark.parametrize(
"name, desc, builder",
GOOD_PACKETS,
ids=_good_ids,
)
def test_good_packet_not_duplicate_on_first_see(self, handler, name, desc, builder):
pkt = builder()
assert handler.is_duplicate(pkt) is False, f"[{name}] falsely flagged as duplicate"
@pytest.mark.parametrize(
"name, desc, builder",
GOOD_PACKETS,
ids=_good_ids,
)
def test_good_packet_path_modified(self, handler, name, desc, builder):
pkt = builder()
route = pkt.header & PH_ROUTE_MASK
original_path = list(pkt.path) if pkt.path else []
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, _ = result
if route in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD):
assert fwd_pkt.path[-1] == LOCAL_HASH, f"[{name}] local hash not appended"
assert len(fwd_pkt.path) == len(original_path) + 1
else:
# Direct: first hop consumed
assert len(fwd_pkt.path) == len(original_path) - 1
class TestBadPacketArray:
"""All 20 bad packets should be dropped by the engine."""
@pytest.mark.parametrize(
"name, desc, builder, expected_reason",
BAD_PACKETS,
ids=_bad_ids,
)
def test_process_packet_drops(self, handler, name, desc, builder, expected_reason):
# Two entries need unscoped_flood_allow=False
if "policy_off" in name:
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = builder()
result = handler.process_packet(pkt, snr=5.0)
assert result is None, f"[{name}] {desc} — should have been dropped"
@pytest.mark.parametrize(
"name, desc, builder, expected_reason",
BAD_PACKETS,
ids=_bad_ids,
)
def test_drop_reason_set(self, handler, name, desc, builder, expected_reason):
if "policy_off" in name:
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = builder()
handler.process_packet(pkt, snr=5.0)
assert pkt.drop_reason is not None, f"[{name}] drop_reason not set"
assert expected_reason.lower() in pkt.drop_reason.lower(), (
f"[{name}] expected '{expected_reason}' in drop_reason, got '{pkt.drop_reason}'"
)
@pytest.mark.parametrize(
"name, desc, builder, expected_reason",
BAD_PACKETS,
ids=_bad_ids,
)
def test_bad_packet_not_marked_seen(self, handler, name, desc, builder, expected_reason):
"""Dropped packets must NOT pollute the seen cache."""
if "policy_off" in name:
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = builder()
handler.process_packet(pkt, snr=5.0)
# Should not be in seen_packets (except do-not-retransmit and policy
# packets which fail AFTER validation but BEFORE mark_seen)
# Actually none of these should be marked seen — the engine only
# calls mark_seen on packets that pass all checks.
pkt_hash = pkt.calculate_packet_hash().hex().upper() if pkt.payload else None
if pkt_hash:
assert pkt_hash not in handler.seen_packets, (
f"[{name}] bad packet was incorrectly added to seen cache"
)
class TestRecordPacketOnlyTrace:
"""record_packet_only must not log TRACE: TraceHelper owns trace path; packet.path is SNR."""
def test_record_packet_only_skips_trace(self, handler):
storage = handler.storage
storage.record_packet.reset_mock()
pkt = PacketBuilder.create_trace(tag=1, auth_code=2, flags=0, path=[0xAB, 0xCD])
n_before = len(handler.recent_packets)
handler.record_packet_only(pkt, {"rssi": -80, "snr": 10.0})
storage.record_packet.assert_not_called()
assert len(handler.recent_packets) == n_before
# ===================================================================
# 18. Real packet injection through __call__
# ===================================================================
def _inject_from_wire(pkt: Packet) -> Packet:
"""Serialize and deserialize a packet to simulate a real RF wire packet."""
wire = pkt.write_to()
injected = Packet()
injected.read_from(wire)
return injected
@pytest.mark.asyncio
class TestPacketInjectionRouting:
"""Inject real serialized packets through __call__ and assert routing outcomes."""
@staticmethod
def _prepare_fast_tx(handler):
handler.airtime_mgr.calculate_airtime = MagicMock(return_value=20.0)
handler.airtime_mgr.can_transmit = MagicMock(return_value=(True, 0.0))
handler.airtime_mgr.record_tx = MagicMock()
handler.airtime_mgr.record_rx = MagicMock()
async def test_injected_flood_forwards_and_appends_path(self, handler):
self._prepare_fast_tx(handler)
pkt = _inject_from_wire(_make_flood_packet(payload=b"\x10\x20\x30", path=b"\x11"))
with (
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt, {"snr": 7.0, "rssi": -70}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
sent_pkt = handler.dispatcher.send_packet.call_args.args[0]
assert bytes(sent_pkt.path) == bytes([0x11, LOCAL_HASH])
assert handler.rx_count == 1
assert handler.forwarded_count == 1
assert handler.dropped_count == 0
async def test_injected_direct_forwards_and_consumes_hop(self, handler):
self._prepare_fast_tx(handler)
pkt = _inject_from_wire(
_make_direct_packet(payload=b"\xaa\xbb", path=bytes([LOCAL_HASH, 0x44, 0x55]))
)
with (
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt, {"snr": 3.0, "rssi": -82}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
sent_pkt = handler.dispatcher.send_packet.call_args.args[0]
assert bytes(sent_pkt.path) == b"\x44\x55"
async def test_injected_multipart_ack_forwards_regenerated_plain_ack(self, handler):
"""An intermediate hop relays a multipart ACK as a plain DIRECT ACK off the wire."""
self._prepare_fast_tx(handler)
pkt = _inject_from_wire(
_make_multipart_ack_packet(
crc=b"\x4d\xab\xaf\x95", remaining=2, path=bytes([LOCAL_HASH, 0x44])
)
)
with patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock):
await handler(pkt, {"snr": 3.0, "rssi": -82}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
sent_pkt = handler.dispatcher.send_packet.call_args.args[0]
assert sent_pkt.get_payload_type() == PAYLOAD_TYPE_ACK
assert sent_pkt.get_route_type() == ROUTE_TYPE_DIRECT
assert bytes(sent_pkt.payload) == b"\x4d\xab\xaf\x95"
assert bytes(sent_pkt.path) == b"\x44"
async def test_injected_multipart_ack_flood_is_dropped(self, handler):
"""MeshCore never flood-routes multipart; an intermediate hop drops it."""
self._prepare_fast_tx(handler)
pkt = _inject_from_wire(_make_multipart_ack_packet(route=ROUTE_TYPE_FLOOD, path=b"\x11"))
with patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock):
await handler(pkt, {"snr": 3.0, "rssi": -82}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 0
async def test_direct_for_other_node_is_dropped(self, handler):
pkt = _inject_from_wire(_make_direct_packet(payload=b"\xaa\xbb", path=b"\xfe\x44"))
metadata = {"snr": 2.0, "rssi": -90}
with patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock):
await handler(pkt, metadata, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 0
assert handler.dropped_count == 1
assert "not for us" in (handler.recent_packets[-1]["drop_reason"] or "")
assert "not for us" in ((metadata.get("_repeater_drop_reason") or "").lower())
async def test_duplicate_wire_packet_not_retransmitted(self, handler):
self._prepare_fast_tx(handler)
incoming = _make_flood_packet(payload=b"\x99\x88\x77", path=b"\x01")
pkt1 = _inject_from_wire(incoming)
pkt2 = _inject_from_wire(incoming)
with (
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt1, {"snr": 6.0, "rssi": -75}, local_transmission=False)
await handler(pkt2, {"snr": 5.5, "rssi": -76}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
assert handler.dropped_count == 1
assert handler.flood_dup_count == 1
original = handler.recent_packets[-1]
assert "duplicates" in original
assert len(original["duplicates"]) == 1
assert original["duplicates"][0]["drop_reason"] == "Duplicate"
async def test_transport_flood_injection_honors_transport_key_decision(self, handler):
pkt = _inject_from_wire(_make_transport_flood_packet(payload=b"\x01\x02\x03\x04", path=b""))
with (
patch.object(handler, "_check_transport_codes", return_value=(False, "denied")),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt, {"snr": 0.0, "rssi": -92}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 0
assert "transport" in (handler.recent_packets[-1]["drop_reason"] or "").lower()
async def test_local_tx_then_rf_echo_is_duplicate(self, handler):
self._prepare_fast_tx(handler)
local_pkt = _make_flood_packet(payload=b"\x0a\x0b\x0c", path=b"")
with (
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(local_pkt, {"snr": 0.0, "rssi": -50}, local_transmission=True)
rf_echo = _inject_from_wire(_make_flood_packet(payload=b"\x0a\x0b\x0c", path=b""))
await handler(rf_echo, {"snr": 0.0, "rssi": -70}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
assert handler.dropped_count == 1
assert handler.flood_dup_count == 1
original = handler.recent_packets[-1]
assert "duplicates" in original
assert len(original["duplicates"]) == 1
assert original["duplicates"][0]["drop_reason"] == "Duplicate"
@pytest.mark.parametrize("payload_type", _GENERIC_FORWARD_PAYLOAD_TYPES)
async def test_all_payload_types_flood_injection_forwards(self, handler, payload_type):
self._prepare_fast_tx(handler)
pkt = _inject_from_wire(
_make_flood_packet(
payload=bytes([payload_type, 0xA5, 0x5A]),
path=b"\x11",
payload_type=payload_type,
)
)
with (
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt, {"snr": 4.0, "rssi": -78}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
sent_pkt = handler.dispatcher.send_packet.call_args.args[0]
assert sent_pkt.get_payload_type() == payload_type
assert sent_pkt.path[-1] == LOCAL_HASH
@pytest.mark.parametrize("payload_type", _GENERIC_FORWARD_PAYLOAD_TYPES)
async def test_all_payload_types_direct_injection_forwards(self, handler, payload_type):
self._prepare_fast_tx(handler)
pkt = _inject_from_wire(
_make_direct_packet(
payload=bytes([payload_type, 0x01, 0x02]),
path=bytes([LOCAL_HASH, 0x44, 0x55]),
payload_type=payload_type,
)
)
with (
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt, {"snr": 2.5, "rssi": -84}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
sent_pkt = handler.dispatcher.send_packet.call_args.args[0]
assert sent_pkt.get_payload_type() == payload_type
assert bytes(sent_pkt.path) == b"\x44\x55"
@pytest.mark.parametrize("payload_type", _GENERIC_FORWARD_PAYLOAD_TYPES)
async def test_all_payload_types_transport_flood_injection_forwards(
self, handler, payload_type
):
self._prepare_fast_tx(handler)
pkt = _inject_from_wire(
_make_transport_flood_packet(
payload=bytes([payload_type, 0x33, 0x44]),
path=b"",
payload_type=payload_type,
transport_codes=(0x1111, 0x2222),
)
)
with (
patch.object(handler, "_check_transport_codes", return_value=(True, "")),
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt, {"snr": 1.0, "rssi": -88}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
sent_pkt = handler.dispatcher.send_packet.call_args.args[0]
assert sent_pkt.get_payload_type() == payload_type
assert sent_pkt.transport_codes == [0x1111, 0x2222]
@pytest.mark.parametrize("payload_type", _GENERIC_FORWARD_PAYLOAD_TYPES)
async def test_all_payload_types_transport_direct_injection_forwards(
self, handler, payload_type
):
self._prepare_fast_tx(handler)
pkt = _inject_from_wire(
_make_transport_direct_packet(
payload=bytes([payload_type, 0x66, 0x77]),
path=bytes([LOCAL_HASH, 0x22]),
payload_type=payload_type,
transport_codes=(0x3333, 0x4444),
)
)
with (
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt, {"snr": 3.0, "rssi": -83}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
sent_pkt = handler.dispatcher.send_packet.call_args.args[0]
assert sent_pkt.get_payload_type() == payload_type
assert bytes(sent_pkt.path) == b"\x22"
assert sent_pkt.transport_codes == [0x3333, 0x4444]
# ===================================================================
# 19. Missed branch coverage (background/transport helpers)
# ===================================================================
class TestMissedEngineBranches:
"""Target previously untested helper/lifecycle branches in RepeaterHandler."""
def test_check_transport_codes_accepts_matching_key_and_uses_cache(self, handler):
key_raw = b"0123456789ABCDEF"
key_b64 = base64.b64encode(key_raw).decode("ascii")
handler.storage.get_transport_keys.return_value = [
{
"id": 7,
"name": "primary",
"transport_key": key_b64,
"flood_policy": "allow",
}
]
pkt = _make_transport_flood_packet(payload=b"\x01\x02", path=b"")
pkt.transport_codes = [0xCAFE, 0xBEEF]
with patch("openhop_core.protocol.transport_keys.calc_transport_code", return_value=0xCAFE):
allowed_1, reason_1 = handler._check_transport_codes(pkt)
allowed_2, reason_2 = handler._check_transport_codes(pkt)
assert allowed_1 is True and reason_1 == ""
assert allowed_2 is True and reason_2 == ""
assert handler.storage.get_transport_keys.call_count == 1
assert handler.storage.update_transport_key.call_count == 2
def test_record_duplicate_groups_under_original(self, handler):
pkt = _make_flood_packet(payload=b"\x12\x34")
original_hash = pkt.calculate_packet_hash().hex().upper()[:16]
original_record = {
"timestamp": time.time(),
"packet_hash": original_hash,
"transmitted": True,
}
handler._append_recent_packet(original_record)
handler.record_duplicate(pkt, rssi=-90, snr=1.5)
assert handler.flood_dup_count == 1
assert "duplicates" in original_record
assert len(original_record["duplicates"]) == 1
assert original_record["duplicates"][0]["drop_reason"] == "Duplicate"
@pytest.mark.asyncio
async def test_record_crc_errors_async_records_positive_delta(self, handler):
handler.dispatcher.radio.crc_error_count = 9
handler._last_crc_error_count = 4
await handler._record_crc_errors_async()
handler.storage.record_crc_errors.assert_called_once_with(5)
assert handler._last_crc_error_count == 9
@pytest.mark.asyncio
async def test_record_noise_floor_async_caches_and_persists(self, handler):
with patch.object(handler, "get_noise_floor", return_value=-117.5):
await handler._record_noise_floor_async()
assert handler._cached_noise_floor == -117.5
handler.storage.record_noise_floor.assert_called_once_with(-117.5)
@pytest.mark.asyncio
async def test_send_periodic_advert_async_success_and_failure(self, handler):
handler.send_advert_func = AsyncMock(side_effect=[True, False])
await handler._send_periodic_advert_async()
await handler._send_periodic_advert_async()
assert handler.send_advert_func.await_count == 2
def test_cleanup_cancels_background_task_and_closes_storage(self, handler):
fake_task = MagicMock()
fake_task.done.return_value = False
handler._background_task = fake_task
handler.cleanup()
fake_task.cancel.assert_called_once()
handler.storage.close.assert_called_once()
# ===================================================================
# 20. Transmission and Background Lifecycle Branches
# ===================================================================
class TestEngineTransmissionAndBackgroundLifecycle:
"""Cover duty-cycle outcomes, packet-record robustness, and background timer lifecycle."""
@pytest.mark.asyncio
async def test_local_tx_defers_when_duty_cycle_blocked(self, handler):
pkt = _make_flood_packet(payload=b"\x21\x22")
handler.airtime_mgr.calculate_airtime = MagicMock(return_value=120.0)
handler.airtime_mgr.can_transmit = MagicMock(return_value=(False, 2.0))
with patch.object(handler, "_calculate_tx_delay", return_value=0.5):
loop = asyncio.get_running_loop()
completed = loop.create_future()
completed.set_result(True)
async def _fake_schedule(packet, delay, airtime_ms, local_transmission=False):
packet._tx_metadata = {
"lbt_attempts": 2,
"lbt_backoff_delays_ms": [10, 20],
"lbt_channel_busy": True,
}
return completed
handler.schedule_retransmit = AsyncMock(side_effect=_fake_schedule)
await handler(pkt, {"snr": 0.0, "rssi": -50}, local_transmission=True)
handler.schedule_retransmit.assert_awaited_once()
args = handler.schedule_retransmit.await_args.args
assert args[0] is pkt
assert args[1] == pytest.approx(2.5) # original delay + duty-cycle wait
assert args[2] == 120.0
assert handler.forwarded_count == 1
assert handler.dropped_count == 0
assert handler.recent_packets[-1]["lbt_attempts"] == 2
@pytest.mark.asyncio
async def test_local_deferred_tx_failure_decrements_forwarded_counter(self, handler):
pkt = _make_flood_packet(payload=b"\x23\x24")
handler.airtime_mgr.calculate_airtime = MagicMock(return_value=55.0)
handler.airtime_mgr.can_transmit = MagicMock(return_value=(False, 1.0))
loop = asyncio.get_running_loop()
failing = loop.create_future()
failing.set_exception(RuntimeError("deferred tx failed"))
handler.schedule_retransmit = AsyncMock(return_value=failing)
with patch.object(handler, "_calculate_tx_delay", return_value=0.2):
with pytest.raises(RuntimeError, match="deferred tx failed"):
await handler(pkt, {"snr": 0.0, "rssi": -52}, local_transmission=True)
assert handler.forwarded_count == 0
@pytest.mark.asyncio
async def test_non_local_drop_when_duty_cycle_blocked(self, handler):
pkt = _make_flood_packet(payload=b"\x31\x32")
handler.airtime_mgr.calculate_airtime = MagicMock(return_value=80.0)
handler.airtime_mgr.can_transmit = MagicMock(return_value=(False, 1.25))
handler.process_packet = MagicMock(return_value=(pkt, 0.1))
handler.schedule_retransmit = AsyncMock()
await handler(pkt, {"snr": 5.0, "rssi": -75}, local_transmission=False)
handler.schedule_retransmit.assert_not_awaited()
assert handler.dropped_count == 1
assert handler.forwarded_count == 0
assert handler.recent_packets[-1]["drop_reason"] == "Duty cycle limit"
@pytest.mark.asyncio
async def test_tx_failure_rolls_back_forwarded_counter(self, handler):
pkt = _make_flood_packet(payload=b"\x41\x42")
handler.airtime_mgr.calculate_airtime = MagicMock(return_value=40.0)
handler.airtime_mgr.can_transmit = MagicMock(return_value=(True, 0.0))
loop = asyncio.get_running_loop()
failing = loop.create_future()
failing.set_exception(RuntimeError("radio busy"))
handler.schedule_retransmit = AsyncMock(return_value=failing)
with patch.object(handler, "_calculate_tx_delay", return_value=0.0):
with pytest.raises(RuntimeError, match="radio busy"):
await handler(pkt, {"snr": 0.0, "rssi": -40}, local_transmission=True)
# Incremented before scheduling, decremented on failure path.
assert handler.forwarded_count == 0
def test_record_packet_only_missing_header_and_storage_failure(self, handler):
pkt = _make_flood_packet(payload=b"\x51\x52")
n_before = len(handler.recent_packets)
pkt.header = None
handler.record_packet_only(pkt, {"rssi": -70, "snr": 2.0})
assert len(handler.recent_packets) == n_before
pkt.header = ROUTE_TYPE_FLOOD | (0x01 << PH_TYPE_SHIFT)
handler.storage.record_packet.side_effect = RuntimeError("db down")
handler.record_packet_only(pkt, {"rssi": -70, "snr": 2.0})
# Storage failure should not append to recent list.
assert len(handler.recent_packets) == n_before
def test_log_trace_record_updates_counters_even_if_storage_fails(self, handler):
base_rx = handler.rx_count
base_fwd = handler.forwarded_count
base_drop = handler.dropped_count
handler.storage.record_packet.side_effect = RuntimeError("write fail")
handler.log_trace_record({"packet_hash": "ABC123", "transmitted": True})
handler.log_trace_record({"packet_hash": "DEF456", "transmitted": False})
assert handler.rx_count == base_rx + 2
assert handler.forwarded_count == base_fwd + 1
assert handler.dropped_count == base_drop + 1
def test_record_duplicate_direct_route_updates_duplicate_counters(self, handler):
pkt = _make_direct_packet(payload=b"\x61\x62", path=bytes([LOCAL_HASH, 0xAA]))
handler.record_duplicate(pkt, rssi=-88, snr=1.2)
assert handler.recv_direct_count == 1
assert handler.direct_dup_count == 1
def test_start_background_tasks_only_starts_once(self, handler):
marker_task = MagicMock(name="bg_task")
def _fake_create_task(coro):
coro.close()
return marker_task
with patch("repeater.engine.asyncio.create_task", side_effect=_fake_create_task) as mk_task:
handler._background_task = None
handler._start_background_tasks()
handler._start_background_tasks()
mk_task.assert_called_once()
assert handler._background_task is marker_task
@pytest.mark.asyncio
async def test_background_timer_loop_runs_tasks_and_handles_cancel(self, handler):
handler.last_noise_measurement = 0
handler.noise_floor_interval = 1
handler.send_advert_interval_hours = 1
handler.send_advert_func = AsyncMock()
handler.last_advert_time = 0
handler.last_cache_cleanup = 0
handler.last_db_cleanup = 0
handler.cleanup_cache = MagicMock()
handler._record_noise_floor_async = AsyncMock()
handler._record_crc_errors_async = AsyncMock()
handler._send_periodic_advert_async = AsyncMock()
with (
patch("repeater.engine.time.time", return_value=100000.0),
patch(
"repeater.engine.asyncio.sleep",
new_callable=AsyncMock,
side_effect=asyncio.CancelledError,
),
):
with pytest.raises(asyncio.CancelledError):
await handler._background_timer_loop()
handler._record_noise_floor_async.assert_awaited_once()
handler._record_crc_errors_async.assert_awaited_once()
handler._send_periodic_advert_async.assert_awaited_once()
handler.cleanup_cache.assert_called_once()
handler.storage.cleanup_old_data.assert_called_once()
@pytest.mark.asyncio
async def test_background_timer_loop_continues_when_db_cleanup_fails(self, handler):
handler.last_noise_measurement = 0
handler.noise_floor_interval = 999999
handler.send_advert_interval_hours = 0
handler.last_cache_cleanup = 0
handler.last_db_cleanup = 0
handler.cleanup_cache = MagicMock()
handler._record_noise_floor_async = AsyncMock()
handler._record_crc_errors_async = AsyncMock()
handler.storage.cleanup_old_data.side_effect = RuntimeError("cleanup error")
with (
patch("repeater.engine.time.time", return_value=100000.0),
patch(
"repeater.engine.asyncio.sleep",
new_callable=AsyncMock,
side_effect=asyncio.CancelledError,
),
):
with pytest.raises(asyncio.CancelledError):
await handler._background_timer_loop()
handler.storage.cleanup_old_data.assert_called_once()
@pytest.mark.asyncio
async def test_background_timer_loop_exception_restarts_task(self, handler):
handler._record_noise_floor_async = AsyncMock(side_effect=RuntimeError("boom"))
handler.last_noise_measurement = 0
handler.noise_floor_interval = 1
created = {}
def _fake_create_task(coro):
created["called"] = True
# Avoid leaking an un-awaited coroutine in the test process.
coro.close()
return "restarted-task"
with (
patch("repeater.engine.time.time", return_value=100000.0),
patch(
"repeater.engine.asyncio.sleep", new_callable=AsyncMock, return_value=None
) as sleep_mock,
patch("repeater.engine.asyncio.create_task", side_effect=_fake_create_task),
):
await handler._background_timer_loop()
sleep_mock.assert_awaited_once_with(30)
assert created.get("called") is True
assert handler._background_task == "restarted-task"
@pytest.mark.asyncio
async def test_record_noise_floor_handles_none_and_exceptions(self, handler):
with patch.object(handler, "get_noise_floor", return_value=None):
await handler._record_noise_floor_async()
handler.storage.record_noise_floor.assert_not_called()
with patch.object(handler, "get_noise_floor", side_effect=RuntimeError("noise fail")):
await handler._record_noise_floor_async()
@pytest.mark.asyncio
async def test_record_crc_errors_returns_without_storage_and_handles_storage_exception(
self, handler
):
# No storage configured: should return early.
handler.storage = None
await handler._record_crc_errors_async()
# Restore storage and force write error on positive delta.
handler.storage = MagicMock()
handler._last_crc_error_count = 1
handler.dispatcher.radio.crc_error_count = 3
handler.storage.record_crc_errors.side_effect = RuntimeError("crc write fail")
await handler._record_crc_errors_async()
@pytest.mark.asyncio
async def test_send_periodic_advert_handles_missing_handler_and_handler_exception(
self, handler
):
handler.send_advert_func = None
await handler._send_periodic_advert_async()
handler.send_advert_func = AsyncMock(side_effect=RuntimeError("advert fail"))
await handler._send_periodic_advert_async()
class TestEngineRecordAndCleanupHelpers:
"""Cover helper fallbacks that protect UI visibility and in-memory index integrity."""
def test_record_duplicate_appends_when_original_not_found(self, handler):
# Keep recent non-empty but ensure duplicate hash is not indexed.
handler._append_recent_packet({"packet_hash": "OTHERHASH", "transmitted": True})
pkt = _make_flood_packet(payload=b"\x71\x72")
handler.record_duplicate(pkt, rssi=-85, snr=1.0)
assert handler.recent_packets[-1]["drop_reason"] == "Duplicate"
assert (
handler.recent_packets[-1]["packet_hash"]
== pkt.calculate_packet_hash().hex().upper()[:16]
)
def test_record_duplicate_appends_when_recent_packets_empty(self, handler):
handler.recent_packets.clear()
handler._recent_hash_index.clear()
pkt = _make_flood_packet(payload=b"\x73\x74")
handler.record_duplicate(pkt, rssi=-82, snr=1.1)
assert len(handler.recent_packets) == 1
assert handler.recent_packets[0]["drop_reason"] == "Duplicate"
def test_record_duplicate_route_zero_maps_to_flood_counters(self, handler):
pkt = _make_flood_packet(payload=b"\x75\x76")
# Route nibble 0 is parsed as FLOOD in current protocol constants.
pkt.header = 0x00 << PH_TYPE_SHIFT
handler.record_duplicate(pkt, rssi=-90, snr=0.5)
assert handler.flood_dup_count == 1
assert handler.direct_dup_count == 0
def test_append_recent_packet_eviction_removes_matching_index_entry(self, handler):
handler.max_recent_packets = 1
old = {"packet_hash": "OLDHASH"}
handler.recent_packets.append(old)
handler._recent_hash_index["OLDHASH"] = old
handler._append_recent_packet({"packet_hash": "NEWHASH"})
assert "OLDHASH" not in handler._recent_hash_index
assert handler.recent_packets[-1]["packet_hash"] == "NEWHASH"
assert handler._recent_hash_index["NEWHASH"] is handler.recent_packets[-1]
def test_append_recent_packet_without_hash_skips_index_update(self, handler):
base_index = dict(handler._recent_hash_index)
handler._append_recent_packet({"timestamp": time.time()})
assert dict(handler._recent_hash_index) == base_index
def test_cleanup_handles_storage_close_exception(self, handler):
fake_task = MagicMock()
fake_task.done.return_value = False
handler._background_task = fake_task
handler.storage.close.side_effect = RuntimeError("close failed")
# cleanup should swallow close errors and not raise.
handler.cleanup()
fake_task.cancel.assert_called_once()