mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-06-22 19:15:00 +02:00
Merge pull request #148 from agessaman/feat/companion-modes
feat: improve repeater TX mode functionality so companion tenants can TX while in monitor mode
This commit is contained in:
@@ -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
|
||||
|
||||
+14
-9
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+62
-1
@@ -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
|
||||
# ===================================================================
|
||||
|
||||
Reference in New Issue
Block a user