diff --git a/repeater/handler_helpers/discovery.py b/repeater/handler_helpers/discovery.py index 8153747..fab9d76 100644 --- a/repeater/handler_helpers/discovery.py +++ b/repeater/handler_helpers/discovery.py @@ -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}") diff --git a/tests/test_handler_helpers_trace_discovery_login.py b/tests/test_handler_helpers_trace_discovery_login.py index 54d21fe..d286d7d 100644 --- a/tests/test_handler_helpers_trace_discovery_login.py +++ b/tests/test_handler_helpers_trace_discovery_login.py @@ -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)