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:
Lloyd
2026-03-16 10:06:34 +00:00
committed by GitHub
8 changed files with 106 additions and 27 deletions
+4
View File
@@ -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
View File
@@ -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(
+4 -5
View File
@@ -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)
+4 -5
View File
@@ -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)
+5
View File
@@ -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
+3 -3
View File
@@ -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
+10 -4
View File
@@ -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
View File
@@ -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
# ===================================================================