diff --git a/config.yaml.example b/config.yaml.example index 68f0539..5e13fc3 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -6,6 +6,10 @@ repeater: # Node name for logging and identification node_name: "mesh-repeater-01" + # TX mode: forward | monitor | no_tx (default: forward) + # forward = repeat on; monitor = no repeat but companions/tenants can send; no_tx = all TX off + # mode: forward + # Geographic location (optional) # Latitude in decimal degrees (-90 to 90) latitude: 0.0 diff --git a/repeater/engine.py b/repeater/engine.py index dc6d413..0657f1e 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -154,9 +154,12 @@ class RepeaterHandler(BaseHandler): route_type = packet.header & PH_ROUTE_MASK - # Check if we're in monitor mode (receive only, no forwarding) + # TX mode: forward (repeat on), monitor (no repeat, tenants can TX), no_tx (all TX off) mode = self.config.get("repeater", {}).get("mode", "forward") - monitor_mode = mode == "monitor" + if mode not in ("forward", "monitor", "no_tx"): + mode = "forward" + allow_forward = mode == "forward" + allow_local_tx = mode != "no_tx" logger.debug( f"RX packet: header=0x{packet.header:02x}, payload_len={len(packet.payload or b'')}, " @@ -179,16 +182,16 @@ class RepeaterHandler(BaseHandler): original_path_hashes = packet.get_path_hashes_hex() path_hash_size = packet.get_path_hash_size() - # Process for forwarding (skip if in monitor mode or if this is a local transmission) + # Process for forwarding (skip if repeat disabled or if this is a local transmission) result = ( None - if (monitor_mode or local_transmission) + if (not allow_forward or local_transmission) else self.process_packet(processed_packet, snr) ) forwarded_path_hashes = None - # For local transmissions, create a direct transmission result - if local_transmission and not monitor_mode: + # For local transmissions, create a direct transmission result (if local TX allowed) + if local_transmission and allow_local_tx: # Mark local packet as seen to prevent duplicate processing when received back self.mark_seen(packet) # Calculate transmission delay for local packets @@ -285,9 +288,11 @@ class RepeaterHandler(BaseHandler): ) else: self.dropped_count += 1 - # Determine drop reason from process_packet result - if monitor_mode: - drop_reason = "Monitor mode" + # Determine drop reason + if local_transmission and not allow_local_tx: + drop_reason = "No TX mode" + elif not allow_forward: + drop_reason = "Repeat disabled" else: # Check if packet has a specific drop reason set by handlers drop_reason = processed_packet.drop_reason or self._get_drop_reason( diff --git a/repeater/handler_helpers/mesh_cli.py b/repeater/handler_helpers/mesh_cli.py index a05bf81..78f1c0d 100644 --- a/repeater/handler_helpers/mesh_cli.py +++ b/repeater/handler_helpers/mesh_cli.py @@ -236,8 +236,8 @@ class MeshCLI: return f"> {name}" elif param == "repeat": - disabled = self.repeater_config.get("disable_forward", False) - return f"> {'off' if disabled else 'on'}" + mode = self.repeater_config.get("mode", "forward") + return f"> {'on' if mode == 'forward' else 'off'}" elif param == "lat": lat = self.repeater_config.get("latitude", 0.0) @@ -353,11 +353,10 @@ class MeshCLI: return "OK" elif key == "repeat": - disabled = value.lower() == "off" - self.repeater_config["disable_forward"] = disabled + self.repeater_config["mode"] = "forward" if value.lower() == "on" else "monitor" saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(["repeater"]) - return f"OK - repeat is now {'OFF' if disabled else 'ON'}" + return f"OK - repeat is now {'ON' if self.repeater_config['mode'] == 'forward' else 'OFF'}" elif key == "lat": self.repeater_config["latitude"] = float(value) diff --git a/repeater/handler_helpers/repeater_cli.py b/repeater/handler_helpers/repeater_cli.py index d2c7492..58e9e3d 100644 --- a/repeater/handler_helpers/repeater_cli.py +++ b/repeater/handler_helpers/repeater_cli.py @@ -242,8 +242,8 @@ class MeshCLI: return f"> {name}" elif param == "repeat": - disabled = self.repeater_config.get("disable_forward", False) - return f"> {'off' if disabled else 'on'}" + mode = self.repeater_config.get("mode", "forward") + return f"> {'on' if mode == 'forward' else 'off'}" elif param == "lat": lat = self.repeater_config.get("latitude", 0.0) @@ -350,10 +350,9 @@ class MeshCLI: return "OK" elif key == "repeat": - disabled = value.lower() == "off" - self.repeater_config["disable_forward"] = disabled + self.repeater_config["mode"] = "forward" if value.lower() == "on" else "monitor" self.save_config() - return f"OK - repeat is now {'OFF' if disabled else 'ON'}" + return f"OK - repeat is now {'ON' if self.repeater_config['mode'] == 'forward' else 'OFF'}" elif key == "lat": self.repeater_config["latitude"] = float(value) diff --git a/repeater/main.py b/repeater/main.py index 83ae8ec..a1a3650 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -906,6 +906,11 @@ class RepeaterDaemon: logger.error("Cannot send advert: dispatcher or identity not initialized") return False + mode = self.config.get("repeater", {}).get("mode", "forward") + if mode == "no_tx": + logger.debug("Adverts disabled in no_tx mode") + return False + try: from pymc_core.protocol import PacketBuilder from pymc_core.protocol.constants import ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_REPEATER diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index ac884f8..9e88edb 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -44,7 +44,7 @@ logger = logging.getLogger("HTTPServer") # Repeater Control # POST /api/send_advert - Send repeater advertisement -# POST /api/set_mode {"mode": "forward|monitor"} - Set repeater mode +# POST /api/set_mode {"mode": "forward|monitor|no_tx"} - Set repeater mode # POST /api/set_duty_cycle {"enabled": true|false} - Enable/disable duty cycle # POST /api/update_duty_cycle_config {"enabled": true, "on_time": 300, "off_time": 60} - Update duty cycle config # POST /api/update_radio_config - Update radio configuration @@ -655,8 +655,8 @@ class APIEndpoints: self._require_post() data = cherrypy.request.json new_mode = data.get("mode", "forward") - if new_mode not in ["forward", "monitor"]: - return self._error("Invalid mode. Must be 'forward' or 'monitor'") + if new_mode not in ["forward", "monitor", "no_tx"]: + return self._error("Invalid mode. Must be 'forward', 'monitor', or 'no_tx'") if "repeater" not in self.config: self.config["repeater"] = {} self.config["repeater"]["mode"] = new_mode diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index 9cfdc0e..1160378 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -414,7 +414,9 @@ paths: post: tags: [System] summary: Set repeater mode - description: Switch between forward and monitor modes + description: | + Set TX mode. forward = repeat on; monitor = no repeat but companions/tenants can send; + no_tx = all transmission disabled (receive-only). requestBody: required: true content: @@ -425,10 +427,11 @@ paths: properties: mode: type: string - enum: [forward, monitor] + enum: [forward, monitor, no_tx] description: | - - forward: Active forwarding mode (default) - - monitor: Passive monitoring only + - forward: Repeat received packets; allow all local TX (default) + - monitor: Do not repeat; allow local TX (companions, adverts, etc.) + - no_tx: Do not repeat; no local TX (receive-only) example: forward examples: forward: @@ -437,6 +440,9 @@ paths: monitor: value: mode: monitor + no_tx: + value: + mode: no_tx responses: '200': description: Mode changed diff --git a/tests/test_engine.py b/tests/test_engine.py index a4be9b4..b35b031 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -3,8 +3,9 @@ tests for pyMC_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, and config reloading. +airtime duty-cycle, TX mode (forward/monitor/no_tx), and config reloading. """ +import asyncio import copy import math import time @@ -972,6 +973,66 @@ class TestEdgeCases: 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() + + # =================================================================== # 16. Airtime calculation correctness # ===================================================================