mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-10 10:52:53 +02:00
feat: implement randomized response jitter in DiscoveryHelper to prevent packet collisions
- Added a default upper bound for randomized pre-send jitter in discovery responses to avoid collisions when multiple repeaters respond simultaneously. - Introduced a new parameter `response_jitter_ms` in the `DiscoveryHelper` constructor to configure the jitter. - Updated the `_send_packet_async` method to apply the jitter before sending responses. - Added tests to verify the correct application of jitter and ensure functionality when jitter is disabled.
This commit is contained in:
@@ -7,11 +7,21 @@ allowing other nodes to discover repeaters on the mesh network.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from pymc_core.node.handlers.control import ControlHandler
|
||||
|
||||
logger = logging.getLogger("DiscoveryHelper")
|
||||
|
||||
# Default upper bound (ms) for the randomized pre-send jitter applied to node
|
||||
# discovery responses. A node-discover request is a broadcast that every
|
||||
# in-range repeater answers at once, so without jitter they all transmit at the
|
||||
# same engine-scheduled instant and collide. Mirrors the firmware, which spreads
|
||||
# these replies deliberately (MyMesh.cpp:797, sendZeroHop with
|
||||
# getRetransmitDelay*4). Safe to be generous: the requester's discovery window is
|
||||
# 60s (firmware pending_discover_until = futureMillis(60000)).
|
||||
DEFAULT_DISCOVERY_RESPONSE_JITTER_MS = 2000
|
||||
|
||||
|
||||
class DiscoveryHelper:
|
||||
"""Helper class for processing discovery requests in the repeater."""
|
||||
@@ -23,6 +33,7 @@ class DiscoveryHelper:
|
||||
node_type: int = 2,
|
||||
log_fn=None,
|
||||
debug_log_fn=None,
|
||||
response_jitter_ms: int = DEFAULT_DISCOVERY_RESPONSE_JITTER_MS,
|
||||
):
|
||||
"""
|
||||
Initialize the discovery helper.
|
||||
@@ -34,10 +45,14 @@ class DiscoveryHelper:
|
||||
log_fn: Optional logging function for ControlHandler
|
||||
debug_log_fn: Optional logging for verbose ControlHandler messages (e.g. callback
|
||||
presence). Pass logger.debug to avoid INFO noise when forwarding to companions.
|
||||
response_jitter_ms: Upper bound (ms) for the randomized delay added before
|
||||
transmitting a discovery response, to avoid multiple repeaters colliding
|
||||
when answering the same broadcast. Set to 0 to disable (e.g. in tests).
|
||||
"""
|
||||
self.local_identity = local_identity
|
||||
self.packet_injector = packet_injector # Function to inject packets into router
|
||||
self.node_type = node_type
|
||||
self.response_jitter_ms = max(0, int(response_jitter_ms))
|
||||
|
||||
# Create ControlHandler internally as a parsing utility
|
||||
self.control_handler = ControlHandler(
|
||||
@@ -147,6 +162,18 @@ class DiscoveryHelper:
|
||||
tag: The tag for logging purposes
|
||||
"""
|
||||
try:
|
||||
# Randomized pre-send jitter so multiple repeaters answering the same
|
||||
# zero-hop discovery broadcast don't transmit at the same engine-scheduled
|
||||
# instant and collide (the engine's DIRECT delay is fixed, not random).
|
||||
# Mirrors firmware MyMesh.cpp:797. Uses secrets like the engine's TX jitter.
|
||||
if self.response_jitter_ms > 0:
|
||||
jitter_s = secrets.randbelow(self.response_jitter_ms + 1) / 1000.0
|
||||
if jitter_s > 0:
|
||||
logger.debug(
|
||||
f"Discovery response jitter {jitter_s * 1000:.0f}ms for tag 0x{tag:08X}"
|
||||
)
|
||||
await asyncio.sleep(jitter_s)
|
||||
|
||||
success = await self.packet_injector(packet, wait_for_ack=False)
|
||||
if success:
|
||||
logger.info(f"Response sent for tag 0x{tag:08X}")
|
||||
|
||||
@@ -207,7 +207,10 @@ def test_discovery_request_without_identity_does_not_send():
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_send_packet_async_success_failure_and_exception():
|
||||
injector = AsyncMock(side_effect=[True, False, RuntimeError("send fail")])
|
||||
helper = DiscoveryHelper(local_identity=FakeIdentity(0x42), packet_injector=injector)
|
||||
# jitter disabled so the test doesn't sleep
|
||||
helper = DiscoveryHelper(
|
||||
local_identity=FakeIdentity(0x42), packet_injector=injector, response_jitter_ms=0
|
||||
)
|
||||
|
||||
await helper._send_packet_async(packet=object(), tag=0x11)
|
||||
await helper._send_packet_async(packet=object(), tag=0x12)
|
||||
@@ -216,6 +219,41 @@ async def test_discovery_send_packet_async_success_failure_and_exception():
|
||||
assert injector.await_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_response_applies_bounded_jitter_before_send():
|
||||
injector = AsyncMock(return_value=True)
|
||||
helper = DiscoveryHelper(
|
||||
local_identity=FakeIdentity(0x42), packet_injector=injector, response_jitter_ms=2000
|
||||
)
|
||||
|
||||
slept = []
|
||||
|
||||
async def fake_sleep(secs):
|
||||
slept.append(secs)
|
||||
|
||||
with patch("repeater.handler_helpers.discovery.asyncio.sleep", side_effect=fake_sleep):
|
||||
await helper._send_packet_async(packet=object(), tag=0x55)
|
||||
|
||||
# Jitter applied exactly once, bounded to [0, 2.0]s, before the injection.
|
||||
assert len(slept) == 1
|
||||
assert 0.0 <= slept[0] <= 2.0
|
||||
injector.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_response_jitter_disabled_does_not_sleep():
|
||||
injector = AsyncMock(return_value=True)
|
||||
helper = DiscoveryHelper(
|
||||
local_identity=FakeIdentity(0x42), packet_injector=injector, response_jitter_ms=0
|
||||
)
|
||||
|
||||
with patch("repeater.handler_helpers.discovery.asyncio.sleep") as sleep_mock:
|
||||
await helper._send_packet_async(packet=object(), tag=0x56)
|
||||
|
||||
sleep_mock.assert_not_called()
|
||||
injector.assert_awaited_once()
|
||||
|
||||
|
||||
def test_discovery_send_response_without_injector_is_safe():
|
||||
helper = DiscoveryHelper(local_identity=FakeIdentity(0x42), packet_injector=None)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user