mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-09 10:23:01 +02:00
refactor: companion FrameServer and related (substantive only, no Black)
Reapply refactor from ce8381a (replace monolithic FrameServer with thin pymc_core subclass, re-export constants, SQLite persistence hooks) while preserving pre-refactor whitespace where patch applied cleanly. Remaining files match refactor commit exactly. Diff vs ce8381a is whitespace-only. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,11 +1,19 @@
|
||||
"""Handler helper modules for pyMC Repeater."""
|
||||
|
||||
from .trace import TraceHelper
|
||||
from .discovery import DiscoveryHelper
|
||||
from .advert import AdvertHelper
|
||||
from .discovery import DiscoveryHelper
|
||||
from .login import LoginHelper
|
||||
from .text import TextHelper
|
||||
from .path import PathHelper
|
||||
from .protocol_request import ProtocolRequestHelper
|
||||
from .text import TextHelper
|
||||
from .trace import TraceHelper
|
||||
|
||||
__all__ = ["TraceHelper", "DiscoveryHelper", "AdvertHelper", "LoginHelper", "TextHelper", "PathHelper", "ProtocolRequestHelper"]
|
||||
__all__ = [
|
||||
"TraceHelper",
|
||||
"DiscoveryHelper",
|
||||
"AdvertHelper",
|
||||
"LoginHelper",
|
||||
"TextHelper",
|
||||
"PathHelper",
|
||||
"ProtocolRequestHelper",
|
||||
]
|
||||
|
||||
@@ -58,7 +58,7 @@ class ACL:
|
||||
sync_since: int = None,
|
||||
target_identity_hash: int = None,
|
||||
target_identity_name: str = None,
|
||||
target_identity_config: dict = None
|
||||
target_identity_config: dict = None,
|
||||
) -> tuple[bool, int]:
|
||||
|
||||
target_identity_config = target_identity_config or {}
|
||||
@@ -79,9 +79,11 @@ class ACL:
|
||||
# Empty strings are treated as "not set"
|
||||
admin_pwd = identity_settings.get("admin_password") or None
|
||||
guest_pwd = identity_settings.get("guest_password") or None
|
||||
|
||||
|
||||
if not admin_pwd and not guest_pwd:
|
||||
logger.error(f"Room server '{target_identity_name}' has no passwords configured! Set admin_password and/or guest_password in settings.")
|
||||
logger.error(
|
||||
f"Room server '{target_identity_name}' has no passwords configured! Set admin_password and/or guest_password in settings."
|
||||
)
|
||||
return False, 0
|
||||
else:
|
||||
# Repeater uses global passwords from its own security section
|
||||
@@ -91,10 +93,12 @@ class ACL:
|
||||
f"Repeater passwords - admin: {'SET' if admin_pwd else 'NONE'}, "
|
||||
f"guest: {'SET' if guest_pwd else 'NONE'}"
|
||||
)
|
||||
|
||||
|
||||
if target_identity_name:
|
||||
logger.debug(f"Authenticating for identity '{target_identity_name}' (room_server={is_room_server})")
|
||||
|
||||
logger.debug(
|
||||
f"Authenticating for identity '{target_identity_name}' (room_server={is_room_server})"
|
||||
)
|
||||
|
||||
pub_key = client_identity.get_public_key()[:PUB_KEY_SIZE]
|
||||
|
||||
if not password:
|
||||
@@ -111,8 +115,12 @@ class ACL:
|
||||
|
||||
permissions = 0
|
||||
logger.debug(f"Comparing password (len={len(password)}) against admin/guest")
|
||||
logger.debug(f"Admin pwd len={len(admin_pwd) if admin_pwd else 0}, Guest pwd len={len(guest_pwd) if guest_pwd else 0}")
|
||||
logger.debug(f"Password comparison: '{password}' vs admin='{admin_pwd[:4]}...' ({len(admin_pwd)} chars)")
|
||||
logger.debug(
|
||||
f"Admin pwd len={len(admin_pwd) if admin_pwd else 0}, Guest pwd len={len(guest_pwd) if guest_pwd else 0}"
|
||||
)
|
||||
logger.debug(
|
||||
f"Password comparison: '{password}' vs admin='{admin_pwd[:4]}...' ({len(admin_pwd)} chars)"
|
||||
)
|
||||
if admin_pwd and password == admin_pwd:
|
||||
permissions = PERM_ACL_ADMIN
|
||||
logger.info(f"Admin password validated for '{target_identity_name or 'unknown'}'")
|
||||
|
||||
@@ -70,11 +70,12 @@ class AdvertHelper:
|
||||
if pubkey == local_pubkey:
|
||||
logger.debug("Ignoring own advert in neighbor tracking")
|
||||
return
|
||||
|
||||
|
||||
# Get route type from packet header
|
||||
from pymc_core.protocol.constants import PH_ROUTE_MASK
|
||||
|
||||
route_type = packet.header & PH_ROUTE_MASK
|
||||
|
||||
|
||||
# Check if this is a new neighbor
|
||||
current_time = time.time()
|
||||
if pubkey not in self._known_neighbors:
|
||||
|
||||
@@ -7,6 +7,7 @@ allowing other nodes to discover repeaters on the mesh network.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from pymc_core.node.handlers.control import ControlHandler
|
||||
|
||||
logger = logging.getLogger("DiscoveryHelper")
|
||||
|
||||
@@ -22,9 +22,11 @@ class LoginHelper:
|
||||
self.handlers = {}
|
||||
self.acls = {} # Per-identity ACLs keyed by hash_byte
|
||||
|
||||
def register_identity(self, name: str, identity, identity_type: str = "room_server", config: dict = None):
|
||||
def register_identity(
|
||||
self, name: str, identity, identity_type: str = "room_server", config: dict = None
|
||||
):
|
||||
config = config or {}
|
||||
|
||||
|
||||
hash_byte = identity.get_public_key()[0]
|
||||
|
||||
# Create ACL for this identity
|
||||
@@ -79,9 +81,11 @@ class LoginHelper:
|
||||
|
||||
self.acls[hash_byte] = identity_acl
|
||||
logger.info(f"Created ACL for {identity_type} '{name}': hash=0x{hash_byte:02X}")
|
||||
|
||||
|
||||
# Create auth callback that uses this identity's ACL
|
||||
def auth_callback_with_context(client_identity, shared_secret, password, timestamp, sync_since=None):
|
||||
def auth_callback_with_context(
|
||||
client_identity, shared_secret, password, timestamp, sync_since=None
|
||||
):
|
||||
return identity_acl.authenticate_client(
|
||||
client_identity=client_identity,
|
||||
shared_secret=shared_secret,
|
||||
@@ -90,9 +94,9 @@ class LoginHelper:
|
||||
sync_since=sync_since,
|
||||
target_identity_hash=hash_byte,
|
||||
target_identity_name=name,
|
||||
target_identity_config=config
|
||||
target_identity_config=config,
|
||||
)
|
||||
|
||||
|
||||
handler = LoginServerHandler(
|
||||
local_identity=identity,
|
||||
log_fn=self.log_fn,
|
||||
@@ -103,11 +107,9 @@ class LoginHelper:
|
||||
handler.set_send_packet_callback(self._send_packet_with_delay)
|
||||
|
||||
self.handlers[hash_byte] = handler
|
||||
|
||||
|
||||
logger.info(f"Registered {identity_type} '{name}' login handler: hash=0x{hash_byte:02X}")
|
||||
|
||||
|
||||
|
||||
async def process_login_packet(self, packet):
|
||||
|
||||
try:
|
||||
@@ -123,9 +125,11 @@ class LoginHelper:
|
||||
packet.mark_do_not_retransmit()
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"No login handler registered for hash 0x{dest_hash:02X}, allowing forward")
|
||||
logger.debug(
|
||||
f"No login handler registered for hash 0x{dest_hash:02X}, allowing forward"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing login packet: {e}")
|
||||
return False
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -10,15 +11,15 @@ logger = logging.getLogger(__name__)
|
||||
class MeshCLI:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: str,
|
||||
config: Dict[str, Any],
|
||||
self,
|
||||
config_path: str,
|
||||
config: Dict[str, Any],
|
||||
config_manager, # ConfigManager instance for save & live updates
|
||||
identity_type: str = "repeater",
|
||||
enable_regions: bool = True,
|
||||
send_advert_callback: Optional[Callable] = None,
|
||||
identity = None,
|
||||
storage_handler = None
|
||||
identity=None,
|
||||
storage_handler=None,
|
||||
):
|
||||
|
||||
self.config_path = Path(config_path)
|
||||
@@ -29,39 +30,39 @@ class MeshCLI:
|
||||
self.send_advert_callback = send_advert_callback
|
||||
self.identity = identity
|
||||
self.storage_handler = storage_handler
|
||||
|
||||
|
||||
# Get repeater config shortcut
|
||||
self.repeater_config = config.get('repeater', {})
|
||||
|
||||
self.repeater_config = config.get("repeater", {})
|
||||
|
||||
def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str:
|
||||
|
||||
# Check admin permission first
|
||||
if not is_admin:
|
||||
return "Error: Admin permission required"
|
||||
|
||||
|
||||
logger.debug(f"handle_command received: '{command}' (len={len(command)})")
|
||||
|
||||
|
||||
# Extract optional sequence prefix (XX|)
|
||||
prefix = ""
|
||||
if len(command) > 4 and command[2] == '|':
|
||||
if len(command) > 4 and command[2] == "|":
|
||||
prefix = command[:3]
|
||||
command = command[3:]
|
||||
logger.debug(f"Extracted prefix: '{prefix}', remaining command: '{command}'")
|
||||
|
||||
|
||||
# Strip leading/trailing whitespace
|
||||
command = command.strip()
|
||||
logger.debug(f"After strip: '{command}'")
|
||||
|
||||
|
||||
# Route to appropriate handler
|
||||
reply = self._route_command(command)
|
||||
|
||||
|
||||
# Add prefix back to reply if present
|
||||
if prefix:
|
||||
return prefix + reply
|
||||
return reply
|
||||
|
||||
|
||||
def _route_command(self, command: str) -> str:
|
||||
|
||||
|
||||
# System commands
|
||||
if command == "reboot":
|
||||
return self._cmd_reboot()
|
||||
@@ -79,97 +80,98 @@ class MeshCLI:
|
||||
return self._cmd_clear_stats()
|
||||
elif command == "ver":
|
||||
return self._cmd_version()
|
||||
|
||||
|
||||
# Get commands
|
||||
elif command.startswith("get "):
|
||||
return self._cmd_get(command[4:])
|
||||
|
||||
|
||||
# Set commands
|
||||
elif command.startswith("set "):
|
||||
return self._cmd_set(command[4:])
|
||||
|
||||
|
||||
# ACL commands
|
||||
elif command.startswith("setperm "):
|
||||
return self._cmd_setperm(command)
|
||||
elif command == "get acl":
|
||||
return "Error: Use 'get acl' via serial console only"
|
||||
|
||||
|
||||
# Region commands (repeaters only)
|
||||
elif command.startswith("region"):
|
||||
if self.enable_regions:
|
||||
return self._cmd_region(command)
|
||||
else:
|
||||
return "Error: Region commands not available for room servers"
|
||||
|
||||
|
||||
# Neighbor commands
|
||||
elif command == "neighbors":
|
||||
return self._cmd_neighbors()
|
||||
elif command.startswith("neighbor.remove "):
|
||||
return self._cmd_neighbor_remove(command)
|
||||
|
||||
|
||||
# Temporary radio params
|
||||
elif command.startswith("tempradio "):
|
||||
return self._cmd_tempradio(command)
|
||||
|
||||
|
||||
# Sensor commands
|
||||
elif command.startswith("sensor "):
|
||||
return "Error: Sensor commands not implemented in Python repeater"
|
||||
|
||||
|
||||
# GPS commands
|
||||
elif command.startswith("gps"):
|
||||
return "Error: GPS commands not implemented in Python repeater"
|
||||
|
||||
|
||||
# Logging commands
|
||||
elif command.startswith("log "):
|
||||
return self._cmd_log(command)
|
||||
|
||||
|
||||
# Statistics commands
|
||||
elif command.startswith("stats-"):
|
||||
return "Error: Stats commands not fully implemented yet"
|
||||
|
||||
|
||||
else:
|
||||
return "Unknown command"
|
||||
|
||||
|
||||
# ==================== System Commands ====================
|
||||
|
||||
|
||||
def _cmd_reboot(self) -> str:
|
||||
"""Reboot the repeater process."""
|
||||
from repeater.service_utils import restart_service
|
||||
|
||||
|
||||
logger.warning("Reboot command received via mesh CLI")
|
||||
success, message = restart_service()
|
||||
|
||||
|
||||
if success:
|
||||
return f"OK - {message}"
|
||||
else:
|
||||
return f"Error: {message}"
|
||||
|
||||
|
||||
def _cmd_advert(self) -> str:
|
||||
"""Send self advertisement."""
|
||||
if not self.send_advert_callback:
|
||||
logger.warning("Advert command received but no callback configured")
|
||||
return "Error: Advert functionality not configured"
|
||||
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
|
||||
async def delayed_advert():
|
||||
"""Delay advert to let CLI response send first (matches C++ 1500ms delay)."""
|
||||
await asyncio.sleep(1.5)
|
||||
await self.send_advert_callback()
|
||||
|
||||
|
||||
asyncio.create_task(delayed_advert())
|
||||
logger.info("Advert scheduled for sending (1.5s delay)")
|
||||
return "OK - Advert sent"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule advert: {e}", exc_info=True)
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def _cmd_clock(self, command: str) -> str:
|
||||
"""Handle clock commands."""
|
||||
if command == "clock":
|
||||
# Display current time
|
||||
import datetime
|
||||
|
||||
dt = datetime.datetime.utcnow()
|
||||
return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC"
|
||||
elif command == "clock sync":
|
||||
@@ -177,94 +179,94 @@ class MeshCLI:
|
||||
return "OK - clock sync not needed (system time used)"
|
||||
else:
|
||||
return "Unknown clock command"
|
||||
|
||||
|
||||
def _cmd_time(self, command: str) -> str:
|
||||
"""Set time - not supported in Python (use system time)."""
|
||||
return "Error: Time setting not supported (system time is used)"
|
||||
|
||||
|
||||
def _cmd_password(self, command: str) -> str:
|
||||
"""Change admin password."""
|
||||
new_password = command[9:].strip()
|
||||
|
||||
|
||||
if not new_password:
|
||||
return "Error: Password cannot be empty"
|
||||
|
||||
|
||||
# Update security config
|
||||
if 'security' not in self.config:
|
||||
self.config['security'] = {}
|
||||
|
||||
self.config['security']['password'] = new_password
|
||||
|
||||
if "security" not in self.config:
|
||||
self.config["security"] = {}
|
||||
|
||||
self.config["security"]["password"] = new_password
|
||||
|
||||
# Save config and live update
|
||||
try:
|
||||
saved, err = self.config_manager.save_to_file()
|
||||
if not saved:
|
||||
logger.error(f"Failed to save password: {err}")
|
||||
return f"Error: Failed to save config: {err}"
|
||||
self.config_manager.live_update_daemon(['security'])
|
||||
self.config_manager.live_update_daemon(["security"])
|
||||
return f"password now: {new_password}"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save password: {e}")
|
||||
return "Error: Failed to save password"
|
||||
|
||||
|
||||
def _cmd_clear_stats(self) -> str:
|
||||
"""Clear statistics."""
|
||||
# TODO: Implement stats clearing
|
||||
return "Error: Not yet implemented"
|
||||
|
||||
|
||||
def _cmd_version(self) -> str:
|
||||
"""Get version information."""
|
||||
role = "room_server" if self.identity_type == "room_server" else "repeater"
|
||||
version = self.config.get('version', '1.0.0')
|
||||
version = self.config.get("version", "1.0.0")
|
||||
return f"pyMC_{role} v{version}"
|
||||
|
||||
|
||||
# ==================== Get Commands ====================
|
||||
|
||||
|
||||
def _cmd_get(self, param: str) -> str:
|
||||
"""Handle get commands."""
|
||||
param = param.strip()
|
||||
logger.debug(f"_cmd_get called with param: '{param}' (len={len(param)})")
|
||||
|
||||
|
||||
if param == "af":
|
||||
af = self.repeater_config.get('airtime_factor', 1.0)
|
||||
af = self.repeater_config.get("airtime_factor", 1.0)
|
||||
return f"> {af}"
|
||||
|
||||
|
||||
elif param == "name":
|
||||
name = self.repeater_config.get('name', 'Unknown')
|
||||
name = self.repeater_config.get("name", "Unknown")
|
||||
return f"> {name}"
|
||||
|
||||
|
||||
elif param == "repeat":
|
||||
disabled = self.repeater_config.get('disable_forward', False)
|
||||
disabled = self.repeater_config.get("disable_forward", False)
|
||||
return f"> {'off' if disabled else 'on'}"
|
||||
|
||||
|
||||
elif param == "lat":
|
||||
lat = self.repeater_config.get('latitude', 0.0)
|
||||
lat = self.repeater_config.get("latitude", 0.0)
|
||||
return f"> {lat}"
|
||||
|
||||
|
||||
elif param == "lon":
|
||||
lon = self.repeater_config.get('longitude', 0.0)
|
||||
lon = self.repeater_config.get("longitude", 0.0)
|
||||
return f"> {lon}"
|
||||
|
||||
|
||||
elif param == "radio":
|
||||
radio = self.config.get('radio', {})
|
||||
freq_hz = radio.get('frequency', 915000000)
|
||||
bw_hz = radio.get('bandwidth', 125000)
|
||||
sf = radio.get('spreading_factor', 7)
|
||||
cr = radio.get('coding_rate', 5)
|
||||
radio = self.config.get("radio", {})
|
||||
freq_hz = radio.get("frequency", 915000000)
|
||||
bw_hz = radio.get("bandwidth", 125000)
|
||||
sf = radio.get("spreading_factor", 7)
|
||||
cr = radio.get("coding_rate", 5)
|
||||
# Convert Hz to MHz for freq, Hz to kHz for bandwidth (match C++ ftoa output)
|
||||
freq_mhz = freq_hz / 1_000_000.0
|
||||
bw_khz = bw_hz / 1_000.0
|
||||
return f"> {freq_mhz},{bw_khz},{sf},{cr}"
|
||||
|
||||
|
||||
elif param == "freq":
|
||||
freq_hz = self.config.get('radio', {}).get('frequency', 915000000)
|
||||
freq_hz = self.config.get("radio", {}).get("frequency", 915000000)
|
||||
freq_mhz = freq_hz / 1_000_000.0
|
||||
return f"> {freq_mhz}"
|
||||
|
||||
|
||||
elif param == "tx":
|
||||
power = self.config.get('radio', {}).get('tx_power', 20)
|
||||
power = self.config.get("radio", {}).get("tx_power", 20)
|
||||
return f"> {power}"
|
||||
|
||||
|
||||
elif param == "public.key":
|
||||
if not self.identity:
|
||||
return "Error: Identity not available"
|
||||
@@ -275,263 +277,263 @@ class MeshCLI:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get public key: {e}")
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
elif param == "role":
|
||||
role = "room_server" if self.identity_type == "room_server" else "repeater"
|
||||
return f"> {role}"
|
||||
|
||||
|
||||
elif param == "guest.password":
|
||||
guest_pw = self.config.get('security', {}).get('guest_password', '')
|
||||
guest_pw = self.config.get("security", {}).get("guest_password", "")
|
||||
return f"> {guest_pw}"
|
||||
|
||||
|
||||
elif param == "allow.read.only":
|
||||
allow = self.config.get('security', {}).get('allow_read_only', False)
|
||||
allow = self.config.get("security", {}).get("allow_read_only", False)
|
||||
return f"> {'on' if allow else 'off'}"
|
||||
|
||||
|
||||
elif param == "advert.interval":
|
||||
interval = self.repeater_config.get('advert_interval_minutes', 120)
|
||||
interval = self.repeater_config.get("advert_interval_minutes", 120)
|
||||
return f"> {interval}"
|
||||
|
||||
|
||||
elif param == "flood.advert.interval":
|
||||
interval = self.repeater_config.get('flood_advert_interval_hours', 24)
|
||||
interval = self.repeater_config.get("flood_advert_interval_hours", 24)
|
||||
return f"> {interval}"
|
||||
|
||||
|
||||
elif param == "flood.max":
|
||||
max_flood = self.repeater_config.get('max_flood_hops', 3)
|
||||
max_flood = self.repeater_config.get("max_flood_hops", 3)
|
||||
return f"> {max_flood}"
|
||||
|
||||
|
||||
elif param == "rxdelay":
|
||||
delay = self.repeater_config.get('rx_delay_base', 0.0)
|
||||
delay = self.repeater_config.get("rx_delay_base", 0.0)
|
||||
return f"> {delay}"
|
||||
|
||||
|
||||
elif param == "txdelay":
|
||||
delay = self.repeater_config.get('tx_delay_factor', 1.0)
|
||||
delay = self.repeater_config.get("tx_delay_factor", 1.0)
|
||||
return f"> {delay}"
|
||||
|
||||
|
||||
elif param == "direct.txdelay":
|
||||
delay = self.repeater_config.get('direct_tx_delay_factor', 0.5)
|
||||
delay = self.repeater_config.get("direct_tx_delay_factor", 0.5)
|
||||
return f"> {delay}"
|
||||
|
||||
|
||||
elif param == "multi.acks":
|
||||
acks = self.repeater_config.get('multi_acks', 0)
|
||||
acks = self.repeater_config.get("multi_acks", 0)
|
||||
return f"> {acks}"
|
||||
|
||||
|
||||
elif param == "int.thresh":
|
||||
thresh = self.repeater_config.get('interference_threshold', -120)
|
||||
thresh = self.repeater_config.get("interference_threshold", -120)
|
||||
return f"> {thresh}"
|
||||
|
||||
|
||||
elif param == "agc.reset.interval":
|
||||
interval = self.repeater_config.get('agc_reset_interval', 0)
|
||||
interval = self.repeater_config.get("agc_reset_interval", 0)
|
||||
return f"> {interval}"
|
||||
|
||||
|
||||
else:
|
||||
return f"??: {param}"
|
||||
|
||||
|
||||
# ==================== Set Commands ====================
|
||||
|
||||
|
||||
def _cmd_set(self, param: str) -> str:
|
||||
"""Handle set commands."""
|
||||
parts = param.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
return "Error: Missing value"
|
||||
|
||||
|
||||
key, value = parts[0], parts[1]
|
||||
|
||||
|
||||
try:
|
||||
if key == "af":
|
||||
self.repeater_config['airtime_factor'] = float(value)
|
||||
self.repeater_config["airtime_factor"] = float(value)
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "name":
|
||||
self.repeater_config['node_name'] = value
|
||||
self.repeater_config["node_name"] = value
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "repeat":
|
||||
disabled = value.lower() == "off"
|
||||
self.repeater_config['disable_forward'] = disabled
|
||||
self.repeater_config["disable_forward"] = disabled
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
return f"OK - repeat is now {'OFF' if disabled else 'ON'}"
|
||||
|
||||
|
||||
elif key == "lat":
|
||||
self.repeater_config['latitude'] = float(value)
|
||||
self.repeater_config["latitude"] = float(value)
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "lon":
|
||||
self.repeater_config['longitude'] = float(value)
|
||||
self.repeater_config["longitude"] = float(value)
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "radio":
|
||||
# Format: freq bw sf cr
|
||||
radio_parts = value.split()
|
||||
if len(radio_parts) != 4:
|
||||
return "Error: Expected freq bw sf cr"
|
||||
|
||||
if 'radio' not in self.config:
|
||||
self.config['radio'] = {}
|
||||
|
||||
self.config['radio']['frequency'] = float(radio_parts[0])
|
||||
self.config['radio']['bandwidth'] = float(radio_parts[1])
|
||||
self.config['radio']['spreading_factor'] = int(radio_parts[2])
|
||||
self.config['radio']['coding_rate'] = int(radio_parts[3])
|
||||
|
||||
if "radio" not in self.config:
|
||||
self.config["radio"] = {}
|
||||
|
||||
self.config["radio"]["frequency"] = float(radio_parts[0])
|
||||
self.config["radio"]["bandwidth"] = float(radio_parts[1])
|
||||
self.config["radio"]["spreading_factor"] = int(radio_parts[2])
|
||||
self.config["radio"]["coding_rate"] = int(radio_parts[3])
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['radio'])
|
||||
self.config_manager.live_update_daemon(["radio"])
|
||||
return "OK - restart repeater to apply"
|
||||
|
||||
|
||||
elif key == "freq":
|
||||
if 'radio' not in self.config:
|
||||
self.config['radio'] = {}
|
||||
self.config['radio']['frequency'] = float(value)
|
||||
if "radio" not in self.config:
|
||||
self.config["radio"] = {}
|
||||
self.config["radio"]["frequency"] = float(value)
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['radio'])
|
||||
self.config_manager.live_update_daemon(["radio"])
|
||||
return "OK - restart repeater to apply"
|
||||
|
||||
|
||||
elif key == "tx":
|
||||
if 'radio' not in self.config:
|
||||
self.config['radio'] = {}
|
||||
self.config['radio']['tx_power'] = int(value)
|
||||
if "radio" not in self.config:
|
||||
self.config["radio"] = {}
|
||||
self.config["radio"]["tx_power"] = int(value)
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['radio'])
|
||||
self.config_manager.live_update_daemon(["radio"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "guest.password":
|
||||
if 'security' not in self.config:
|
||||
self.config['security'] = {}
|
||||
self.config['security']['guest_password'] = value
|
||||
if "security" not in self.config:
|
||||
self.config["security"] = {}
|
||||
self.config["security"]["guest_password"] = value
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['security'])
|
||||
self.config_manager.live_update_daemon(["security"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "allow.read.only":
|
||||
if 'security' not in self.config:
|
||||
self.config['security'] = {}
|
||||
self.config['security']['allow_read_only'] = value.lower() == "on"
|
||||
if "security" not in self.config:
|
||||
self.config["security"] = {}
|
||||
self.config["security"]["allow_read_only"] = value.lower() == "on"
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['security'])
|
||||
self.config_manager.live_update_daemon(["security"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "advert.interval":
|
||||
mins = int(value)
|
||||
if mins > 0 and (mins < 60 or mins > 240):
|
||||
return "Error: interval range is 60-240 minutes"
|
||||
self.repeater_config['advert_interval_minutes'] = mins
|
||||
self.repeater_config["advert_interval_minutes"] = mins
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "flood.advert.interval":
|
||||
hours = int(value)
|
||||
if (hours > 0 and hours < 3) or hours > 48:
|
||||
return "Error: interval range is 3-48 hours"
|
||||
self.repeater_config['flood_advert_interval_hours'] = hours
|
||||
self.repeater_config["flood_advert_interval_hours"] = hours
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "flood.max":
|
||||
max_val = int(value)
|
||||
if max_val > 64:
|
||||
return "Error: max 64"
|
||||
self.repeater_config['max_flood_hops'] = max_val
|
||||
self.repeater_config["max_flood_hops"] = max_val
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "rxdelay":
|
||||
delay = float(value)
|
||||
if delay < 0:
|
||||
return "Error: cannot be negative"
|
||||
self.repeater_config['rx_delay_base'] = delay
|
||||
self.repeater_config["rx_delay_base"] = delay
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater', 'delays'])
|
||||
self.config_manager.live_update_daemon(["repeater", "delays"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "txdelay":
|
||||
delay = float(value)
|
||||
if delay < 0:
|
||||
return "Error: cannot be negative"
|
||||
self.repeater_config['tx_delay_factor'] = delay
|
||||
self.repeater_config["tx_delay_factor"] = delay
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater', 'delays'])
|
||||
self.config_manager.live_update_daemon(["repeater", "delays"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "direct.txdelay":
|
||||
delay = float(value)
|
||||
if delay < 0:
|
||||
return "Error: cannot be negative"
|
||||
self.repeater_config['direct_tx_delay_factor'] = delay
|
||||
self.repeater_config["direct_tx_delay_factor"] = delay
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater', 'delays'])
|
||||
self.config_manager.live_update_daemon(["repeater", "delays"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "multi.acks":
|
||||
self.repeater_config['multi_acks'] = int(value)
|
||||
self.repeater_config["multi_acks"] = int(value)
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "int.thresh":
|
||||
self.repeater_config['interference_threshold'] = int(value)
|
||||
self.repeater_config["interference_threshold"] = int(value)
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "agc.reset.interval":
|
||||
interval = int(value)
|
||||
# Round to nearest multiple of 4
|
||||
rounded = (interval // 4) * 4
|
||||
self.repeater_config['agc_reset_interval'] = rounded
|
||||
self.repeater_config["agc_reset_interval"] = rounded
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
return f"OK - interval rounded to {rounded}"
|
||||
|
||||
|
||||
else:
|
||||
return f"unknown config: {key}"
|
||||
|
||||
|
||||
except ValueError as e:
|
||||
return f"Error: invalid value - {e}"
|
||||
except Exception as e:
|
||||
logger.error(f"Set command error: {e}")
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# ==================== ACL Commands ====================
|
||||
|
||||
|
||||
def _cmd_setperm(self, command: str) -> str:
|
||||
"""Set permissions for a public key."""
|
||||
# Format: setperm {pubkey-hex} {permissions-int}
|
||||
parts = command[8:].split()
|
||||
if len(parts) < 2:
|
||||
return "Err - bad params"
|
||||
|
||||
|
||||
pubkey_hex = parts[0]
|
||||
try:
|
||||
permissions = int(parts[1])
|
||||
except ValueError:
|
||||
return "Err - invalid permissions"
|
||||
|
||||
|
||||
# TODO: Apply permissions via ACL
|
||||
logger.info(f"setperm command: {pubkey_hex} -> {permissions}")
|
||||
return "Error: Not yet implemented - use config file"
|
||||
|
||||
|
||||
# ==================== Region Commands ====================
|
||||
|
||||
|
||||
def _cmd_region(self, command: str) -> str:
|
||||
"""Handle region commands."""
|
||||
parts = command.split()
|
||||
|
||||
|
||||
if len(parts) == 1:
|
||||
return "Error: Region commands not implemented in Python repeater"
|
||||
|
||||
|
||||
subcommand = parts[1]
|
||||
|
||||
|
||||
if subcommand == "load":
|
||||
return "Error: Region commands not implemented"
|
||||
elif subcommand == "save":
|
||||
@@ -540,80 +542,82 @@ class MeshCLI:
|
||||
return "Error: Region commands not implemented"
|
||||
else:
|
||||
return "Err - ??"
|
||||
|
||||
|
||||
# ==================== Neighbor Commands ====================
|
||||
|
||||
|
||||
def _cmd_neighbors(self) -> str:
|
||||
"""List neighbors."""
|
||||
if not self.storage_handler:
|
||||
return "Error: Storage not available"
|
||||
|
||||
|
||||
try:
|
||||
neighbors = self.storage_handler.get_neighbors()
|
||||
|
||||
|
||||
if not neighbors:
|
||||
return "No neighbors discovered yet"
|
||||
|
||||
|
||||
# Filter to only show repeaters and zero hop nodes
|
||||
filtered_neighbors = {
|
||||
pubkey: info for pubkey, info in neighbors.items()
|
||||
if info.get('is_repeater', False) or info.get('zero_hop', False)
|
||||
pubkey: info
|
||||
for pubkey, info in neighbors.items()
|
||||
if info.get("is_repeater", False) or info.get("zero_hop", False)
|
||||
}
|
||||
|
||||
|
||||
if not filtered_neighbors:
|
||||
return "No repeaters or zero hop neighbors discovered yet"
|
||||
|
||||
|
||||
# Format output similar to C++ version
|
||||
# Format: "<pubkey_prefix> heard Xs ago"
|
||||
import time
|
||||
|
||||
current_time = int(time.time())
|
||||
|
||||
|
||||
lines = []
|
||||
for pubkey, info in filtered_neighbors.items():
|
||||
last_seen = info.get('last_seen', 0)
|
||||
last_seen = info.get("last_seen", 0)
|
||||
seconds_ago = int(current_time - last_seen)
|
||||
|
||||
|
||||
# Get first 4 bytes of pubkey as hex (match C++ format)
|
||||
pubkey_short = pubkey[:8] if len(pubkey) >= 8 else pubkey
|
||||
snr = info.get('snr', 0) or 0
|
||||
|
||||
snr = info.get("snr", 0) or 0
|
||||
|
||||
# Format: <4byte_hex>:<seconds_ago>:<snr> (matches C++ format)
|
||||
lines.append(f"{pubkey_short}:{seconds_ago}:{int(snr)}")
|
||||
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list neighbors: {e}", exc_info=True)
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def _cmd_neighbor_remove(self, command: str) -> str:
|
||||
"""Remove a neighbor."""
|
||||
pubkey_hex = command[16:].strip()
|
||||
|
||||
|
||||
if not pubkey_hex:
|
||||
return "ERR: Missing pubkey"
|
||||
|
||||
|
||||
# TODO: Remove neighbor from routing table
|
||||
logger.info(f"neighbor.remove: {pubkey_hex}")
|
||||
return "Error: Not yet implemented"
|
||||
|
||||
|
||||
# ==================== Temporary Radio Commands ====================
|
||||
|
||||
|
||||
def _cmd_tempradio(self, command: str) -> str:
|
||||
"""Apply temporary radio parameters."""
|
||||
# Format: tempradio {freq} {bw} {sf} {cr} {timeout_mins}
|
||||
parts = command[10:].split()
|
||||
|
||||
|
||||
if len(parts) < 5:
|
||||
return "Error: Expected freq bw sf cr timeout_mins"
|
||||
|
||||
|
||||
try:
|
||||
freq = float(parts[0])
|
||||
bw = float(parts[1])
|
||||
sf = int(parts[2])
|
||||
cr = int(parts[3])
|
||||
timeout_mins = int(parts[4])
|
||||
|
||||
|
||||
# Validate
|
||||
if not (300.0 <= freq <= 2500.0):
|
||||
return "Error: invalid frequency"
|
||||
@@ -625,16 +629,16 @@ class MeshCLI:
|
||||
return "Error: invalid coding rate"
|
||||
if timeout_mins <= 0:
|
||||
return "Error: invalid timeout"
|
||||
|
||||
|
||||
# TODO: Apply temporary radio parameters
|
||||
logger.info(f"tempradio: {freq}MHz {bw}kHz SF{sf} CR4/{cr} for {timeout_mins}min")
|
||||
return "Error: Not yet implemented"
|
||||
|
||||
|
||||
except ValueError:
|
||||
return "Error, invalid params"
|
||||
|
||||
|
||||
# ==================== Logging Commands ====================
|
||||
|
||||
|
||||
def _cmd_log(self, command: str) -> str:
|
||||
"""Handle log commands."""
|
||||
if command == "log start":
|
||||
|
||||
@@ -13,20 +13,20 @@ class PathHelper:
|
||||
async def process_path_packet(self, packet):
|
||||
|
||||
from pymc_core.protocol.crypto import CryptoUtils
|
||||
|
||||
|
||||
try:
|
||||
if len(packet.payload) < 2:
|
||||
return False
|
||||
|
||||
|
||||
dest_hash = packet.payload[0]
|
||||
src_hash = packet.payload[1]
|
||||
|
||||
|
||||
# Get the ACL for this destination identity
|
||||
identity_acl = self.acl_dict.get(dest_hash)
|
||||
if not identity_acl:
|
||||
logger.debug(f"No ACL for dest 0x{dest_hash:02X}, allowing forward")
|
||||
return False
|
||||
|
||||
|
||||
# Find the client by source hash
|
||||
client = None
|
||||
for client_info in identity_acl.get_all_clients():
|
||||
@@ -34,57 +34,59 @@ class PathHelper:
|
||||
if pubkey[0] == src_hash:
|
||||
client = client_info
|
||||
break
|
||||
|
||||
|
||||
if not client:
|
||||
logger.debug(f"PATH packet from unknown client 0x{src_hash:02X}, allowing forward")
|
||||
return False
|
||||
|
||||
|
||||
# Get shared secret for decryption
|
||||
shared_secret = client.shared_secret
|
||||
if not shared_secret or len(shared_secret) == 0:
|
||||
logger.debug(f"No shared secret for client 0x{src_hash:02X}, cannot decrypt PATH")
|
||||
return False
|
||||
|
||||
|
||||
# Decrypt the PATH packet payload
|
||||
# Payload format: dest_hash(1) + src_hash(1) + mac(2) + encrypted_data
|
||||
if len(packet.payload) < 4:
|
||||
logger.debug(f"PATH packet too short: {len(packet.payload)} bytes")
|
||||
return False
|
||||
|
||||
|
||||
mac_and_data = packet.payload[2:] # Skip dest_hash and src_hash
|
||||
aes_key = shared_secret[:16]
|
||||
decrypted = CryptoUtils.mac_then_decrypt(aes_key, shared_secret, mac_and_data)
|
||||
|
||||
|
||||
if not decrypted:
|
||||
logger.debug(f"Failed to decrypt PATH packet from 0x{src_hash:02X}")
|
||||
return False
|
||||
|
||||
|
||||
# Parse decrypted PATH data
|
||||
# Format: path_len(1) + path[path_len] + extra_type(1) + extra[...]
|
||||
if len(decrypted) < 1:
|
||||
logger.debug(f"Decrypted PATH data too short")
|
||||
return False
|
||||
|
||||
|
||||
path_len = decrypted[0]
|
||||
if len(decrypted) < 1 + path_len:
|
||||
logger.debug(f"PATH data truncated: need {1 + path_len} bytes, got {len(decrypted)}")
|
||||
logger.debug(
|
||||
f"PATH data truncated: need {1 + path_len} bytes, got {len(decrypted)}"
|
||||
)
|
||||
return False
|
||||
|
||||
path_data = decrypted[1:1 + path_len]
|
||||
|
||||
|
||||
path_data = decrypted[1 : 1 + path_len]
|
||||
|
||||
# Update client's out_path (same as C++ memcpy)
|
||||
client.out_path = bytearray(path_data)
|
||||
client.out_path_len = path_len
|
||||
client.last_activity = int(time.time())
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Updated out_path for client 0x{src_hash:02X} -> 0x{dest_hash:02X}: "
|
||||
f"path_len={path_len}, path={[hex(b) for b in path_data]}"
|
||||
)
|
||||
|
||||
|
||||
# Don't mark as do_not_retransmit - let it forward normally
|
||||
return False
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing PATH packet: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
@@ -10,12 +10,12 @@ import struct
|
||||
import time
|
||||
|
||||
from pymc_core.node.handlers.protocol_request import (
|
||||
ProtocolRequestHandler,
|
||||
REQ_TYPE_GET_STATUS,
|
||||
REQ_TYPE_GET_TELEMETRY_DATA,
|
||||
REQ_TYPE_GET_ACCESS_LIST,
|
||||
REQ_TYPE_GET_NEIGHBOURS,
|
||||
SERVER_RESPONSE_DELAY_MS
|
||||
REQ_TYPE_GET_STATUS,
|
||||
REQ_TYPE_GET_TELEMETRY_DATA,
|
||||
SERVER_RESPONSE_DELAY_MS,
|
||||
ProtocolRequestHandler,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ProtocolRequestHelper")
|
||||
@@ -23,8 +23,16 @@ logger = logging.getLogger("ProtocolRequestHelper")
|
||||
|
||||
class ProtocolRequestHelper:
|
||||
"""Provides repeater-specific protocol request handlers."""
|
||||
|
||||
def __init__(self, identity_manager, packet_injector=None, acl_dict=None, radio=None, engine=None, neighbor_tracker=None):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identity_manager,
|
||||
packet_injector=None,
|
||||
acl_dict=None,
|
||||
radio=None,
|
||||
engine=None,
|
||||
neighbor_tracker=None,
|
||||
):
|
||||
|
||||
self.identity_manager = identity_manager
|
||||
self.packet_injector = packet_injector
|
||||
@@ -71,9 +79,10 @@ class ProtocolRequestHelper:
|
||||
}
|
||||
|
||||
logger.info(f"Registered protocol request handler for '{name}': hash=0x{hash_byte:02X}")
|
||||
|
||||
|
||||
def _create_acl_contacts_wrapper(self, acl):
|
||||
"""Create contacts wrapper from ACL."""
|
||||
|
||||
class ACLContactsWrapper:
|
||||
def __init__(self, identity_acl):
|
||||
self._acl = identity_acl
|
||||
@@ -138,22 +147,32 @@ class ProtocolRequestHelper:
|
||||
# uint32_t n_direct_dups;
|
||||
# uint32_t n_flood_dups;
|
||||
# uint32_t total_rx_air_time_secs;
|
||||
|
||||
|
||||
# Get stats from radio/engine
|
||||
noise_floor = int(self.radio.get_noise_floor() * 1.0) if self.radio else -120
|
||||
last_rssi = int(self.radio.last_rssi) if self.radio and hasattr(self.radio, 'last_rssi') else -120
|
||||
last_snr = int((self.radio.last_snr * 4.0) if self.radio and hasattr(self.radio, 'last_snr') else 0)
|
||||
|
||||
last_rssi = (
|
||||
int(self.radio.last_rssi) if self.radio and hasattr(self.radio, "last_rssi") else -120
|
||||
)
|
||||
last_snr = int(
|
||||
(self.radio.last_snr * 4.0) if self.radio and hasattr(self.radio, "last_snr") else 0
|
||||
)
|
||||
|
||||
# Get packet counts
|
||||
n_packets_recv = self.radio.packets_received if self.radio and hasattr(self.radio, 'packets_received') else 0
|
||||
n_packets_sent = self.radio.packets_sent if self.radio and hasattr(self.radio, 'packets_sent') else 0
|
||||
|
||||
n_packets_recv = (
|
||||
self.radio.packets_received
|
||||
if self.radio and hasattr(self.radio, "packets_received")
|
||||
else 0
|
||||
)
|
||||
n_packets_sent = (
|
||||
self.radio.packets_sent if self.radio and hasattr(self.radio, "packets_sent") else 0
|
||||
)
|
||||
|
||||
# Get airtime stats
|
||||
total_air_time_secs = 0
|
||||
total_rx_air_time_secs = 0
|
||||
if self.engine and hasattr(self.engine, 'airtime_manager'):
|
||||
if self.engine and hasattr(self.engine, "airtime_manager"):
|
||||
total_air_time_secs = int(self.engine.airtime_manager.total_tx_airtime_ms / 1000)
|
||||
|
||||
|
||||
# Get routing stats
|
||||
n_sent_flood = 0
|
||||
n_sent_direct = 0
|
||||
@@ -161,18 +180,18 @@ class ProtocolRequestHelper:
|
||||
n_recv_direct = 0
|
||||
n_direct_dups = 0
|
||||
n_flood_dups = 0
|
||||
|
||||
|
||||
if self.engine:
|
||||
n_sent_flood = getattr(self.engine, 'sent_flood_count', 0)
|
||||
n_sent_direct = getattr(self.engine, 'sent_direct_count', 0)
|
||||
n_recv_flood = getattr(self.engine, 'recv_flood_count', 0)
|
||||
n_recv_direct = getattr(self.engine, 'recv_direct_count', 0)
|
||||
n_direct_dups = getattr(self.engine, 'direct_dup_count', 0)
|
||||
n_flood_dups = getattr(self.engine, 'flood_dup_count', 0)
|
||||
|
||||
n_sent_flood = getattr(self.engine, "sent_flood_count", 0)
|
||||
n_sent_direct = getattr(self.engine, "sent_direct_count", 0)
|
||||
n_recv_flood = getattr(self.engine, "recv_flood_count", 0)
|
||||
n_recv_direct = getattr(self.engine, "recv_direct_count", 0)
|
||||
n_direct_dups = getattr(self.engine, "direct_dup_count", 0)
|
||||
n_flood_dups = getattr(self.engine, "flood_dup_count", 0)
|
||||
|
||||
# Pack struct (little-endian)
|
||||
stats = struct.pack(
|
||||
'<HHhhIIIIIIIIIhIII',
|
||||
"<HHhhIIIIIIIIIhIII",
|
||||
0, # batt_milli_volts (not available on Pi)
|
||||
0, # curr_tx_queue_len (TODO)
|
||||
noise_floor,
|
||||
|
||||
@@ -5,10 +5,11 @@ Only users with admin permissions (via ACL) can execute these commands.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,10 +24,10 @@ class MeshCLI:
|
||||
def __init__(
|
||||
self,
|
||||
config_path: str,
|
||||
config: Dict[str, Any],
|
||||
config: Dict[str, Any],
|
||||
save_config_callback: Callable,
|
||||
identity_type: str = "repeater",
|
||||
enable_regions: bool = True
|
||||
enable_regions: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize the CLI handler.
|
||||
@@ -43,10 +44,10 @@ class MeshCLI:
|
||||
self.save_config = save_config_callback
|
||||
self.identity_type = identity_type
|
||||
self.enable_regions = enable_regions
|
||||
|
||||
|
||||
# Get repeater config shortcut
|
||||
self.repeater_config = config.get('repeater', {})
|
||||
|
||||
self.repeater_config = config.get("repeater", {})
|
||||
|
||||
def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str:
|
||||
"""
|
||||
Handle an incoming command from a client.
|
||||
@@ -64,10 +65,10 @@ class MeshCLI:
|
||||
return "Error: Admin permission required"
|
||||
|
||||
logger.debug(f"handle_command received: '{command}' (len={len(command)})")
|
||||
|
||||
|
||||
# Extract optional sequence prefix (XX|)
|
||||
prefix = ""
|
||||
if len(command) > 4 and command[2] == '|':
|
||||
if len(command) > 4 and command[2] == "|":
|
||||
prefix = command[:3]
|
||||
command = command[3:]
|
||||
logger.debug(f"Extracted prefix: '{prefix}', remaining command: '{command}'")
|
||||
@@ -180,6 +181,7 @@ class MeshCLI:
|
||||
if command == "clock":
|
||||
# Display current time
|
||||
import datetime
|
||||
|
||||
dt = datetime.datetime.utcnow()
|
||||
return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC"
|
||||
elif command == "clock sync":
|
||||
@@ -198,13 +200,13 @@ class MeshCLI:
|
||||
|
||||
if not new_password:
|
||||
return "Error: Password cannot be empty"
|
||||
|
||||
|
||||
# Update security config
|
||||
if 'security' not in self.config:
|
||||
self.config['security'] = {}
|
||||
|
||||
self.config['security']['password'] = new_password
|
||||
|
||||
if "security" not in self.config:
|
||||
self.config["security"] = {}
|
||||
|
||||
self.config["security"]["password"] = new_password
|
||||
|
||||
# Save config
|
||||
try:
|
||||
self.save_config()
|
||||
@@ -221,56 +223,56 @@ class MeshCLI:
|
||||
def _cmd_version(self) -> str:
|
||||
"""Get version information."""
|
||||
role = "room_server" if self.identity_type == "room_server" else "repeater"
|
||||
version = self.config.get('version', '1.0.0')
|
||||
version = self.config.get("version", "1.0.0")
|
||||
return f"pyMC_{role} v{version}"
|
||||
|
||||
|
||||
# ==================== Get Commands ====================
|
||||
|
||||
def _cmd_get(self, param: str) -> str:
|
||||
"""Handle get commands."""
|
||||
param = param.strip()
|
||||
logger.debug(f"_cmd_get called with param: '{param}' (len={len(param)})")
|
||||
|
||||
|
||||
if param == "af":
|
||||
af = self.repeater_config.get('airtime_factor', 1.0)
|
||||
af = self.repeater_config.get("airtime_factor", 1.0)
|
||||
return f"> {af}"
|
||||
|
||||
|
||||
elif param == "name":
|
||||
name = self.repeater_config.get('name', 'Unknown')
|
||||
name = self.repeater_config.get("name", "Unknown")
|
||||
return f"> {name}"
|
||||
|
||||
|
||||
elif param == "repeat":
|
||||
disabled = self.repeater_config.get('disable_forward', False)
|
||||
disabled = self.repeater_config.get("disable_forward", False)
|
||||
return f"> {'off' if disabled else 'on'}"
|
||||
|
||||
|
||||
elif param == "lat":
|
||||
lat = self.repeater_config.get('latitude', 0.0)
|
||||
lat = self.repeater_config.get("latitude", 0.0)
|
||||
return f"> {lat}"
|
||||
|
||||
|
||||
elif param == "lon":
|
||||
lon = self.repeater_config.get('longitude', 0.0)
|
||||
lon = self.repeater_config.get("longitude", 0.0)
|
||||
return f"> {lon}"
|
||||
|
||||
|
||||
elif param == "radio":
|
||||
radio = self.config.get('radio', {})
|
||||
freq_hz = radio.get('frequency', 915000000)
|
||||
bw_hz = radio.get('bandwidth', 125000)
|
||||
sf = radio.get('spreading_factor', 7)
|
||||
cr = radio.get('coding_rate', 5)
|
||||
radio = self.config.get("radio", {})
|
||||
freq_hz = radio.get("frequency", 915000000)
|
||||
bw_hz = radio.get("bandwidth", 125000)
|
||||
sf = radio.get("spreading_factor", 7)
|
||||
cr = radio.get("coding_rate", 5)
|
||||
# Convert Hz to MHz for freq, Hz to kHz for bandwidth (match C++ ftoa output)
|
||||
freq_mhz = freq_hz / 1_000_000.0
|
||||
bw_khz = bw_hz / 1_000.0
|
||||
return f"> {freq_mhz},{bw_khz},{sf},{cr}"
|
||||
|
||||
|
||||
elif param == "freq":
|
||||
freq_hz = self.config.get('radio', {}).get('frequency', 915000000)
|
||||
freq_hz = self.config.get("radio", {}).get("frequency", 915000000)
|
||||
freq_mhz = freq_hz / 1_000_000.0
|
||||
return f"> {freq_mhz}"
|
||||
|
||||
|
||||
elif param == "tx":
|
||||
power = self.config.get('radio', {}).get('tx_power', 20)
|
||||
power = self.config.get("radio", {}).get("tx_power", 20)
|
||||
return f"> {power}"
|
||||
|
||||
|
||||
elif param == "public.key":
|
||||
# TODO: Get from identity
|
||||
return "Error: Not yet implemented"
|
||||
@@ -278,51 +280,51 @@ class MeshCLI:
|
||||
elif param == "role":
|
||||
role = "room_server" if self.identity_type == "room_server" else "repeater"
|
||||
return f"> {role}"
|
||||
|
||||
|
||||
elif param == "guest.password":
|
||||
guest_pw = self.config.get('security', {}).get('guest_password', '')
|
||||
guest_pw = self.config.get("security", {}).get("guest_password", "")
|
||||
return f"> {guest_pw}"
|
||||
|
||||
|
||||
elif param == "allow.read.only":
|
||||
allow = self.config.get('security', {}).get('allow_read_only', False)
|
||||
allow = self.config.get("security", {}).get("allow_read_only", False)
|
||||
return f"> {'on' if allow else 'off'}"
|
||||
|
||||
|
||||
elif param == "advert.interval":
|
||||
interval = self.repeater_config.get('advert_interval_minutes', 120)
|
||||
interval = self.repeater_config.get("advert_interval_minutes", 120)
|
||||
return f"> {interval}"
|
||||
|
||||
|
||||
elif param == "flood.advert.interval":
|
||||
interval = self.repeater_config.get('flood_advert_interval_hours', 24)
|
||||
interval = self.repeater_config.get("flood_advert_interval_hours", 24)
|
||||
return f"> {interval}"
|
||||
|
||||
|
||||
elif param == "flood.max":
|
||||
max_flood = self.repeater_config.get('max_flood_hops', 3)
|
||||
max_flood = self.repeater_config.get("max_flood_hops", 3)
|
||||
return f"> {max_flood}"
|
||||
|
||||
|
||||
elif param == "rxdelay":
|
||||
delay = self.repeater_config.get('rx_delay_base', 0.0)
|
||||
delay = self.repeater_config.get("rx_delay_base", 0.0)
|
||||
return f"> {delay}"
|
||||
|
||||
|
||||
elif param == "txdelay":
|
||||
delay = self.repeater_config.get('tx_delay_factor', 1.0)
|
||||
delay = self.repeater_config.get("tx_delay_factor", 1.0)
|
||||
return f"> {delay}"
|
||||
|
||||
|
||||
elif param == "direct.txdelay":
|
||||
delay = self.repeater_config.get('direct_tx_delay_factor', 0.5)
|
||||
delay = self.repeater_config.get("direct_tx_delay_factor", 0.5)
|
||||
return f"> {delay}"
|
||||
|
||||
|
||||
elif param == "multi.acks":
|
||||
acks = self.repeater_config.get('multi_acks', 0)
|
||||
acks = self.repeater_config.get("multi_acks", 0)
|
||||
return f"> {acks}"
|
||||
|
||||
|
||||
elif param == "int.thresh":
|
||||
thresh = self.repeater_config.get('interference_threshold', -120)
|
||||
thresh = self.repeater_config.get("interference_threshold", -120)
|
||||
return f"> {thresh}"
|
||||
|
||||
|
||||
elif param == "agc.reset.interval":
|
||||
interval = self.repeater_config.get('agc_reset_interval', 0)
|
||||
interval = self.repeater_config.get("agc_reset_interval", 0)
|
||||
return f"> {interval}"
|
||||
|
||||
|
||||
else:
|
||||
return f"??: {param}"
|
||||
|
||||
@@ -335,144 +337,144 @@ class MeshCLI:
|
||||
return "Error: Missing value"
|
||||
|
||||
key, value = parts[0], parts[1]
|
||||
|
||||
|
||||
try:
|
||||
if key == "af":
|
||||
self.repeater_config['airtime_factor'] = float(value)
|
||||
self.repeater_config["airtime_factor"] = float(value)
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "name":
|
||||
self.repeater_config['name'] = value
|
||||
self.repeater_config["name"] = value
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "repeat":
|
||||
disabled = value.lower() == "off"
|
||||
self.repeater_config['disable_forward'] = disabled
|
||||
self.repeater_config["disable_forward"] = disabled
|
||||
self.save_config()
|
||||
return f"OK - repeat is now {'OFF' if disabled else 'ON'}"
|
||||
|
||||
|
||||
elif key == "lat":
|
||||
self.repeater_config['latitude'] = float(value)
|
||||
self.repeater_config["latitude"] = float(value)
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "lon":
|
||||
self.repeater_config['longitude'] = float(value)
|
||||
self.repeater_config["longitude"] = float(value)
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "radio":
|
||||
# Format: freq bw sf cr
|
||||
radio_parts = value.split()
|
||||
if len(radio_parts) != 4:
|
||||
return "Error: Expected freq bw sf cr"
|
||||
|
||||
if 'radio' not in self.config:
|
||||
self.config['radio'] = {}
|
||||
|
||||
self.config['radio']['frequency'] = float(radio_parts[0])
|
||||
self.config['radio']['bandwidth'] = float(radio_parts[1])
|
||||
self.config['radio']['spreading_factor'] = int(radio_parts[2])
|
||||
self.config['radio']['coding_rate'] = int(radio_parts[3])
|
||||
|
||||
if "radio" not in self.config:
|
||||
self.config["radio"] = {}
|
||||
|
||||
self.config["radio"]["frequency"] = float(radio_parts[0])
|
||||
self.config["radio"]["bandwidth"] = float(radio_parts[1])
|
||||
self.config["radio"]["spreading_factor"] = int(radio_parts[2])
|
||||
self.config["radio"]["coding_rate"] = int(radio_parts[3])
|
||||
self.save_config()
|
||||
return "OK - restart repeater to apply"
|
||||
|
||||
|
||||
elif key == "freq":
|
||||
if 'radio' not in self.config:
|
||||
self.config['radio'] = {}
|
||||
self.config['radio']['frequency'] = float(value)
|
||||
if "radio" not in self.config:
|
||||
self.config["radio"] = {}
|
||||
self.config["radio"]["frequency"] = float(value)
|
||||
self.save_config()
|
||||
return "OK - restart repeater to apply"
|
||||
|
||||
|
||||
elif key == "tx":
|
||||
if 'radio' not in self.config:
|
||||
self.config['radio'] = {}
|
||||
self.config['radio']['tx_power'] = int(value)
|
||||
if "radio" not in self.config:
|
||||
self.config["radio"] = {}
|
||||
self.config["radio"]["tx_power"] = int(value)
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "guest.password":
|
||||
if 'security' not in self.config:
|
||||
self.config['security'] = {}
|
||||
self.config['security']['guest_password'] = value
|
||||
if "security" not in self.config:
|
||||
self.config["security"] = {}
|
||||
self.config["security"]["guest_password"] = value
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "allow.read.only":
|
||||
if 'security' not in self.config:
|
||||
self.config['security'] = {}
|
||||
self.config['security']['allow_read_only'] = value.lower() == "on"
|
||||
if "security" not in self.config:
|
||||
self.config["security"] = {}
|
||||
self.config["security"]["allow_read_only"] = value.lower() == "on"
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "advert.interval":
|
||||
mins = int(value)
|
||||
if mins > 0 and (mins < 60 or mins > 240):
|
||||
return "Error: interval range is 60-240 minutes"
|
||||
self.repeater_config['advert_interval_minutes'] = mins
|
||||
self.repeater_config["advert_interval_minutes"] = mins
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "flood.advert.interval":
|
||||
hours = int(value)
|
||||
if (hours > 0 and hours < 3) or hours > 48:
|
||||
return "Error: interval range is 3-48 hours"
|
||||
self.repeater_config['flood_advert_interval_hours'] = hours
|
||||
self.repeater_config["flood_advert_interval_hours"] = hours
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "flood.max":
|
||||
max_val = int(value)
|
||||
if max_val > 64:
|
||||
return "Error: max 64"
|
||||
self.repeater_config['max_flood_hops'] = max_val
|
||||
self.repeater_config["max_flood_hops"] = max_val
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "rxdelay":
|
||||
delay = float(value)
|
||||
if delay < 0:
|
||||
return "Error: cannot be negative"
|
||||
self.repeater_config['rx_delay_base'] = delay
|
||||
self.repeater_config["rx_delay_base"] = delay
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "txdelay":
|
||||
delay = float(value)
|
||||
if delay < 0:
|
||||
return "Error: cannot be negative"
|
||||
self.repeater_config['tx_delay_factor'] = delay
|
||||
self.repeater_config["tx_delay_factor"] = delay
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "direct.txdelay":
|
||||
delay = float(value)
|
||||
if delay < 0:
|
||||
return "Error: cannot be negative"
|
||||
self.repeater_config['direct_tx_delay_factor'] = delay
|
||||
self.repeater_config["direct_tx_delay_factor"] = delay
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "multi.acks":
|
||||
self.repeater_config['multi_acks'] = int(value)
|
||||
self.repeater_config["multi_acks"] = int(value)
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "int.thresh":
|
||||
self.repeater_config['interference_threshold'] = int(value)
|
||||
self.repeater_config["interference_threshold"] = int(value)
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
|
||||
elif key == "agc.reset.interval":
|
||||
interval = int(value)
|
||||
# Round to nearest multiple of 4
|
||||
rounded = (interval // 4) * 4
|
||||
self.repeater_config['agc_reset_interval'] = rounded
|
||||
self.repeater_config["agc_reset_interval"] = rounded
|
||||
self.save_config()
|
||||
return f"OK - interval rounded to {rounded}"
|
||||
|
||||
|
||||
else:
|
||||
return f"unknown config: {key}"
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Dict
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pymc_core.protocol import PacketBuilder, CryptoUtils
|
||||
from pymc_core.protocol import CryptoUtils, PacketBuilder
|
||||
from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG
|
||||
|
||||
logger = logging.getLogger("RoomServer")
|
||||
@@ -51,7 +51,7 @@ class GlobalRateLimiter:
|
||||
self.min_gap = min_gap_seconds # Minimum gap between consecutive messages
|
||||
self.lock = asyncio.Lock() # Only one transmission at a time
|
||||
self.last_release_time = 0
|
||||
|
||||
|
||||
async def acquire(self):
|
||||
|
||||
async with self.lock:
|
||||
@@ -64,7 +64,7 @@ class GlobalRateLimiter:
|
||||
await asyncio.sleep(wait_time)
|
||||
# Lock is now held - caller can transmit
|
||||
# Will be released when context exits
|
||||
|
||||
|
||||
def release(self):
|
||||
self.last_release_time = time.time()
|
||||
|
||||
@@ -82,43 +82,48 @@ class RoomServer:
|
||||
max_posts: int = 32,
|
||||
config_path: str = None,
|
||||
config: dict = None,
|
||||
config_manager = None,
|
||||
send_advert_callback = None
|
||||
config_manager=None,
|
||||
send_advert_callback=None,
|
||||
):
|
||||
|
||||
|
||||
self.room_hash = room_hash
|
||||
self.room_name = room_name
|
||||
self.local_identity = local_identity
|
||||
self.db = sqlite_handler
|
||||
self.packet_injector = packet_injector
|
||||
self.acl = acl
|
||||
|
||||
|
||||
# Create send_advert callback for this room server
|
||||
async def send_room_advert():
|
||||
"""Send advertisement for this specific room server."""
|
||||
if not packet_injector or not local_identity:
|
||||
logger.error(f"Room '{room_name}': Cannot send advert - missing injector or identity")
|
||||
logger.error(
|
||||
f"Room '{room_name}': Cannot send advert - missing injector or identity"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
from pymc_core.protocol import PacketBuilder
|
||||
from pymc_core.protocol.constants import ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_ROOM_SERVER
|
||||
|
||||
from pymc_core.protocol.constants import (
|
||||
ADVERT_FLAG_HAS_NAME,
|
||||
ADVERT_FLAG_IS_ROOM_SERVER,
|
||||
)
|
||||
|
||||
# Get room config
|
||||
room_config = config.get('identities', {}).get('room_servers', [])
|
||||
room_config = config.get("identities", {}).get("room_servers", [])
|
||||
room_settings = {}
|
||||
for rs in room_config:
|
||||
if rs.get('name') == room_name:
|
||||
room_settings = rs.get('settings', {})
|
||||
if rs.get("name") == room_name:
|
||||
room_settings = rs.get("settings", {})
|
||||
break
|
||||
|
||||
|
||||
# Use room-specific name and location
|
||||
node_name = room_settings.get('room_name', room_name)
|
||||
latitude = room_settings.get('latitude', 0.0)
|
||||
longitude = room_settings.get('longitude', 0.0)
|
||||
|
||||
node_name = room_settings.get("room_name", room_name)
|
||||
latitude = room_settings.get("latitude", 0.0)
|
||||
longitude = room_settings.get("longitude", 0.0)
|
||||
|
||||
flags = ADVERT_FLAG_IS_ROOM_SERVER | ADVERT_FLAG_HAS_NAME
|
||||
|
||||
|
||||
packet = PacketBuilder.create_advert(
|
||||
local_identity=local_identity,
|
||||
name=node_name,
|
||||
@@ -129,21 +134,24 @@ class RoomServer:
|
||||
flags=flags,
|
||||
route_type="flood",
|
||||
)
|
||||
|
||||
|
||||
# Send via packet injector
|
||||
await packet_injector(packet, wait_for_ack=False)
|
||||
|
||||
logger.info(f"Room '{room_name}': Sent flood advert '{node_name}' at ({latitude:.6f}, {longitude:.6f})")
|
||||
|
||||
logger.info(
|
||||
f"Room '{room_name}': Sent flood advert '{node_name}' at ({latitude:.6f}, {longitude:.6f})"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Room '{room_name}': Failed to send advert: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
# Initialize CLI handler for room server commands
|
||||
self.cli = None
|
||||
if config_path and config and config_manager:
|
||||
from .mesh_cli import MeshCLI
|
||||
|
||||
self.cli = MeshCLI(
|
||||
config_path,
|
||||
config,
|
||||
@@ -152,10 +160,10 @@ class RoomServer:
|
||||
enable_regions=False, # Room servers don't support region commands
|
||||
send_advert_callback=send_room_advert,
|
||||
identity=local_identity,
|
||||
storage_handler=sqlite_handler
|
||||
storage_handler=sqlite_handler,
|
||||
)
|
||||
logger.info(f"Room '{room_name}': Initialized CLI handler with identity and storage")
|
||||
|
||||
|
||||
# Enforce hard limit (match C++ MAX_UNSYNCED_POSTS)
|
||||
if max_posts > MAX_UNSYNCED_POSTS:
|
||||
logger.warning(
|
||||
@@ -164,45 +172,45 @@ class RoomServer:
|
||||
)
|
||||
max_posts = MAX_UNSYNCED_POSTS
|
||||
self.max_posts = max_posts
|
||||
|
||||
|
||||
# Round-robin state
|
||||
self.next_client_idx = 0
|
||||
self.next_push_time = 0
|
||||
|
||||
|
||||
# Cleanup tracking
|
||||
self.last_cleanup_time = time.time()
|
||||
self.cleanup_interval = 600 # Cleanup every 10 minutes
|
||||
|
||||
|
||||
# Safety and monitoring
|
||||
self.client_post_times = {} # Track last N post times per client for rate limiting
|
||||
self.consecutive_sync_errors = 0 # Circuit breaker counter
|
||||
self.last_eviction_check = time.time()
|
||||
self.eviction_check_interval = 300 # Check every 5 minutes
|
||||
|
||||
|
||||
# Initialize global rate limiter (singleton)
|
||||
global _global_push_limiter
|
||||
if _global_push_limiter is None:
|
||||
_global_push_limiter = GlobalRateLimiter(GLOBAL_MIN_GAP_BETWEEN_MESSAGES)
|
||||
self.global_limiter = _global_push_limiter
|
||||
|
||||
|
||||
# Background task handle
|
||||
self._sync_task = None
|
||||
self._running = False
|
||||
|
||||
|
||||
logger.info(
|
||||
f"RoomServer initialized: name='{room_name}', "
|
||||
f"hash=0x{room_hash:02X}, max_posts={max_posts}"
|
||||
)
|
||||
|
||||
|
||||
async def start(self):
|
||||
if self._running:
|
||||
logger.warning(f"Room '{self.room_name}' sync loop already running")
|
||||
return
|
||||
|
||||
|
||||
self._running = True
|
||||
self._sync_task = asyncio.create_task(self._sync_loop())
|
||||
logger.info(f"Room '{self.room_name}' sync loop started")
|
||||
|
||||
|
||||
async def stop(self):
|
||||
self._running = False
|
||||
if self._sync_task:
|
||||
@@ -212,14 +220,14 @@ class RoomServer:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info(f"Room '{self.room_name}' sync loop stopped")
|
||||
|
||||
|
||||
async def add_post(
|
||||
self,
|
||||
client_pubkey: bytes,
|
||||
message_text: str,
|
||||
sender_timestamp: int,
|
||||
txt_type: int = TXT_TYPE_PLAIN,
|
||||
allow_server_author: bool = False
|
||||
allow_server_author: bool = False,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
@@ -230,20 +238,19 @@ class RoomServer:
|
||||
f"exceeds max length ({len(message_text)} > {MAX_MESSAGE_LENGTH}), truncating"
|
||||
)
|
||||
message_text = message_text[:MAX_MESSAGE_LENGTH]
|
||||
|
||||
|
||||
# SAFETY: Rate limit per client
|
||||
client_key = client_pubkey.hex()
|
||||
now = time.time()
|
||||
|
||||
|
||||
if client_key not in self.client_post_times:
|
||||
self.client_post_times[client_key] = []
|
||||
|
||||
|
||||
# Remove timestamps older than 1 minute
|
||||
self.client_post_times[client_key] = [
|
||||
t for t in self.client_post_times[client_key]
|
||||
if now - t < 60
|
||||
t for t in self.client_post_times[client_key] if now - t < 60
|
||||
]
|
||||
|
||||
|
||||
# Check rate limit
|
||||
if len(self.client_post_times[client_key]) >= MAX_POSTS_PER_CLIENT_PER_MINUTE:
|
||||
logger.warning(
|
||||
@@ -251,13 +258,13 @@ class RoomServer:
|
||||
f"exceeded rate limit ({MAX_POSTS_PER_CLIENT_PER_MINUTE} posts/min), dropping message"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# Record this post time
|
||||
self.client_post_times[client_key].append(now)
|
||||
|
||||
|
||||
# Use our RTC time for post_timestamp
|
||||
post_timestamp = time.time()
|
||||
|
||||
|
||||
# Store to database
|
||||
msg_id = self.db.insert_room_message(
|
||||
room_hash=f"0x{self.room_hash:02X}",
|
||||
@@ -265,22 +272,22 @@ class RoomServer:
|
||||
message_text=message_text,
|
||||
post_timestamp=post_timestamp,
|
||||
sender_timestamp=sender_timestamp,
|
||||
txt_type=txt_type
|
||||
txt_type=txt_type,
|
||||
)
|
||||
|
||||
|
||||
if msg_id:
|
||||
logger.info(
|
||||
f"Room '{self.room_name}': New post #{msg_id} from "
|
||||
f"{client_pubkey[:4].hex()}: {message_text[:50]}"
|
||||
)
|
||||
|
||||
|
||||
# Log authenticated clients count for debugging distribution
|
||||
all_clients = self.acl.get_all_clients()
|
||||
logger.info(
|
||||
f"Room '{self.room_name}': Message stored, will distribute to "
|
||||
f"{len(all_clients)} authenticated client(s)"
|
||||
)
|
||||
|
||||
|
||||
# Update client's sync_since to this message's timestamp
|
||||
# This prevents the author from receiving their own message back
|
||||
# Also update activity timestamp (they're clearly active if posting)
|
||||
@@ -292,43 +299,43 @@ class RoomServer:
|
||||
room_hash=f"0x{self.room_hash:02X}",
|
||||
client_pubkey=client_pubkey.hex(),
|
||||
sync_since=post_timestamp, # Don't send this message back to author
|
||||
last_activity=time.time()
|
||||
last_activity=time.time(),
|
||||
)
|
||||
|
||||
|
||||
# Trigger push notification
|
||||
self.next_push_time = time.time() + (PUSH_NOTIFY_DELAY_MS / 1000.0)
|
||||
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to store message to database")
|
||||
return False
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding post: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def push_post_to_client(self, client_info, post: Dict) -> bool:
|
||||
|
||||
|
||||
try:
|
||||
# SAFETY: Global transmission lock - only ONE message on radio at a time
|
||||
# This is critical because LoRa is serial (0.5-9s airtime per message)
|
||||
await self.global_limiter.acquire()
|
||||
|
||||
|
||||
# SAFETY: Check client failure backoff
|
||||
sync_state = self.db.get_client_sync(
|
||||
room_hash=f"0x{self.room_hash:02X}",
|
||||
client_pubkey=client_info.id.get_public_key().hex()
|
||||
client_pubkey=client_info.id.get_public_key().hex(),
|
||||
)
|
||||
|
||||
|
||||
if sync_state:
|
||||
failures = sync_state.get('push_failures', 0)
|
||||
failures = sync_state.get("push_failures", 0)
|
||||
if failures > 0:
|
||||
# Apply exponential backoff
|
||||
backoff_idx = min(failures, len(RETRY_BACKOFF_SCHEDULE) - 1)
|
||||
backoff_delay = RETRY_BACKOFF_SCHEDULE[backoff_idx]
|
||||
last_failure_time = sync_state.get('updated_at', 0)
|
||||
last_failure_time = sync_state.get("updated_at", 0)
|
||||
time_since_failure = time.time() - last_failure_time
|
||||
|
||||
|
||||
if time_since_failure < backoff_delay:
|
||||
wait_time = backoff_delay - time_since_failure
|
||||
logger.debug(
|
||||
@@ -336,33 +343,30 @@ class RoomServer:
|
||||
f"in backoff (failure {failures}), waiting {wait_time:.0f}s"
|
||||
)
|
||||
return False # Skip this client for now
|
||||
|
||||
|
||||
# Build message payload
|
||||
timestamp = int(time.time())
|
||||
flags = (TXT_TYPE_SIGNED_PLAIN << 2) # Include author prefix
|
||||
|
||||
flags = TXT_TYPE_SIGNED_PLAIN << 2 # Include author prefix
|
||||
|
||||
# Author prefix (first 4 bytes of pubkey)
|
||||
author_pubkey = bytes.fromhex(post['author_pubkey'])
|
||||
author_pubkey = bytes.fromhex(post["author_pubkey"])
|
||||
author_prefix = author_pubkey[:4]
|
||||
|
||||
|
||||
# Plaintext: timestamp(4) + flags(1) + author_prefix(4) + text
|
||||
message_bytes = post['message_text'].encode('utf-8')
|
||||
message_bytes = post["message_text"].encode("utf-8")
|
||||
plaintext = (
|
||||
timestamp.to_bytes(4, 'little') +
|
||||
bytes([flags]) +
|
||||
author_prefix +
|
||||
message_bytes
|
||||
timestamp.to_bytes(4, "little") + bytes([flags]) + author_prefix + message_bytes
|
||||
)
|
||||
|
||||
|
||||
# Calculate expected ACK (same algorithm as pymc_core)
|
||||
attempt = 0
|
||||
pack_data = PacketBuilder._pack_timestamp_data(timestamp, attempt, message_bytes)
|
||||
ack_hash = CryptoUtils.sha256(pack_data + client_info.id.get_public_key())[:4]
|
||||
expected_ack_crc = int.from_bytes(ack_hash, 'little')
|
||||
|
||||
expected_ack_crc = int.from_bytes(ack_hash, "little")
|
||||
|
||||
# Determine routing based on stored out_path
|
||||
route_type = "flood" if client_info.out_path_len < 0 else "direct"
|
||||
|
||||
|
||||
# Create datagram
|
||||
packet = PacketBuilder.create_datagram(
|
||||
ptype=PAYLOAD_TYPE_TXT_MSG,
|
||||
@@ -370,41 +374,42 @@ class RoomServer:
|
||||
local_identity=self.local_identity,
|
||||
secret=client_info.shared_secret,
|
||||
plaintext=plaintext,
|
||||
route_type=route_type
|
||||
route_type=route_type,
|
||||
)
|
||||
|
||||
|
||||
# Add stored path for direct routing
|
||||
if route_type == "direct" and len(client_info.out_path) > 0:
|
||||
packet.path = bytearray(client_info.out_path[:client_info.out_path_len])
|
||||
packet.path = bytearray(client_info.out_path[: client_info.out_path_len])
|
||||
packet.path_len = client_info.out_path_len
|
||||
|
||||
|
||||
# Calculate ACK timeout
|
||||
if route_type == "flood":
|
||||
ack_timeout = PUSH_ACK_TIMEOUT_FLOOD_MS / 1000.0
|
||||
else:
|
||||
path_len = client_info.out_path_len if client_info.out_path_len >= 0 else 0
|
||||
ack_timeout = (PUSH_TIMEOUT_BASE_MS + PUSH_ACK_TIMEOUT_FACTOR_MS * (path_len + 1)) / 1000.0
|
||||
|
||||
ack_timeout = (
|
||||
PUSH_TIMEOUT_BASE_MS + PUSH_ACK_TIMEOUT_FACTOR_MS * (path_len + 1)
|
||||
) / 1000.0
|
||||
|
||||
# Update client sync state with pending ACK
|
||||
self.db.upsert_client_sync(
|
||||
room_hash=f"0x{self.room_hash:02X}",
|
||||
client_pubkey=client_info.id.get_public_key().hex(),
|
||||
pending_ack_crc=expected_ack_crc,
|
||||
push_post_timestamp=post['post_timestamp'],
|
||||
ack_timeout_time=time.time() + ack_timeout
|
||||
push_post_timestamp=post["post_timestamp"],
|
||||
ack_timeout_time=time.time() + ack_timeout,
|
||||
)
|
||||
# Send packet (dispatcher will track ACK automatically)
|
||||
# This blocks for the entire transmission duration (0.5-9 seconds)
|
||||
success = await self.packet_injector(packet, wait_for_ack=True)
|
||||
|
||||
|
||||
# SAFETY: Release transmission lock AFTER send completes
|
||||
self.global_limiter.release()
|
||||
|
||||
|
||||
if success:
|
||||
# ACK received! Update sync state
|
||||
await self._handle_ack_received(
|
||||
client_info.id.get_public_key(),
|
||||
post['post_timestamp']
|
||||
client_info.id.get_public_key(), post["post_timestamp"]
|
||||
)
|
||||
logger.info(
|
||||
f"Room '{self.room_name}': Pushed post to "
|
||||
@@ -417,13 +422,13 @@ class RoomServer:
|
||||
f"Room '{self.room_name}': Push to "
|
||||
f"0x{client_info.id.get_public_key()[0]:02X} timed out"
|
||||
)
|
||||
|
||||
|
||||
return success
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error pushing post to client: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def _handle_ack_received(self, client_pubkey: bytes, post_timestamp: float):
|
||||
|
||||
try:
|
||||
@@ -434,29 +439,28 @@ class RoomServer:
|
||||
sync_since=post_timestamp,
|
||||
pending_ack_crc=0,
|
||||
push_failures=0,
|
||||
last_activity=time.time()
|
||||
last_activity=time.time(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling ACK received: {e}")
|
||||
|
||||
|
||||
async def _handle_ack_timeout(self, client_pubkey: bytes):
|
||||
try:
|
||||
# Get current sync state
|
||||
sync_state = self.db.get_client_sync(
|
||||
room_hash=f"0x{self.room_hash:02X}",
|
||||
client_pubkey=client_pubkey.hex()
|
||||
room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey.hex()
|
||||
)
|
||||
|
||||
|
||||
if sync_state:
|
||||
# Increment failure counter, clear pending_ack
|
||||
failures = sync_state.get('push_failures', 0) + 1
|
||||
failures = sync_state.get("push_failures", 0) + 1
|
||||
self.db.upsert_client_sync(
|
||||
room_hash=f"0x{self.room_hash:02X}",
|
||||
client_pubkey=client_pubkey.hex(),
|
||||
push_failures=failures,
|
||||
pending_ack_crc=0
|
||||
pending_ack_crc=0,
|
||||
)
|
||||
|
||||
|
||||
if failures >= 3:
|
||||
logger.warning(
|
||||
f"Room '{self.room_name}': Client 0x{client_pubkey[0]:02X} "
|
||||
@@ -464,86 +468,86 @@ class RoomServer:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling ACK timeout: {e}")
|
||||
|
||||
|
||||
def get_unsynced_count(self, client_pubkey: bytes) -> int:
|
||||
try:
|
||||
# Get client's sync state
|
||||
sync_state = self.db.get_client_sync(
|
||||
room_hash=f"0x{self.room_hash:02X}",
|
||||
client_pubkey=client_pubkey.hex()
|
||||
room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey.hex()
|
||||
)
|
||||
|
||||
sync_since = sync_state['sync_since'] if sync_state else 0
|
||||
|
||||
|
||||
sync_since = sync_state["sync_since"] if sync_state else 0
|
||||
|
||||
return self.db.get_unsynced_count(
|
||||
room_hash=f"0x{self.room_hash:02X}",
|
||||
client_pubkey=client_pubkey.hex(),
|
||||
sync_since=sync_since
|
||||
sync_since=sync_since,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting unsynced count: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
async def _evict_failed_clients(self):
|
||||
try:
|
||||
now = time.time()
|
||||
all_sync_states = self.db.get_all_room_clients(f"0x{self.room_hash:02X}")
|
||||
|
||||
|
||||
for sync_state in all_sync_states:
|
||||
client_pubkey_hex = sync_state['client_pubkey']
|
||||
push_failures = sync_state.get('push_failures', 0)
|
||||
last_activity = sync_state.get('last_activity', 0)
|
||||
|
||||
client_pubkey_hex = sync_state["client_pubkey"]
|
||||
push_failures = sync_state.get("push_failures", 0)
|
||||
last_activity = sync_state.get("last_activity", 0)
|
||||
|
||||
# Skip already-evicted clients (marked with last_activity=0)
|
||||
if last_activity == 0:
|
||||
continue
|
||||
|
||||
|
||||
evict = False
|
||||
reason = ""
|
||||
|
||||
|
||||
# Check max failures
|
||||
if push_failures >= MAX_PUSH_FAILURES:
|
||||
evict = True
|
||||
reason = f"max failures ({push_failures})"
|
||||
|
||||
|
||||
# Check inactivity timeout
|
||||
elif now - last_activity > INACTIVE_CLIENT_TIMEOUT:
|
||||
evict = True
|
||||
reason = f"inactive for {(now - last_activity) / 60:.0f} minutes"
|
||||
|
||||
|
||||
if evict:
|
||||
# Remove from database
|
||||
self.db.upsert_client_sync(
|
||||
room_hash=f"0x{self.room_hash:02X}",
|
||||
client_pubkey=client_pubkey_hex,
|
||||
last_activity=0 # Mark as evicted
|
||||
last_activity=0, # Mark as evicted
|
||||
)
|
||||
|
||||
|
||||
# Remove from ACL
|
||||
client_pubkey = bytes.fromhex(client_pubkey_hex)
|
||||
self.acl.remove_client(client_pubkey)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Room '{self.room_name}': Evicted client "
|
||||
f"0x{client_pubkey[0]:02X} ({reason})"
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error evicting failed clients: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def _sync_loop(self):
|
||||
|
||||
# SAFETY: Stagger room startup to prevent thundering herd
|
||||
import random
|
||||
|
||||
startup_delay = random.uniform(0, 5) # 0-5 second random delay
|
||||
await asyncio.sleep(startup_delay)
|
||||
|
||||
|
||||
logger.info(f"Room '{self.room_name}' sync loop starting (delayed {startup_delay:.1f}s)")
|
||||
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.sleep(SYNC_PUSH_INTERVAL_MS / 1000.0)
|
||||
|
||||
|
||||
# SAFETY: Circuit breaker - stop if too many consecutive errors
|
||||
if self.consecutive_sync_errors >= MAX_CONSECUTIVE_SYNC_ERRORS:
|
||||
logger.error(
|
||||
@@ -553,21 +557,21 @@ class RoomServer:
|
||||
await asyncio.sleep(DB_ERROR_RETRY_DELAY)
|
||||
self.consecutive_sync_errors = 0 # Reset after pause
|
||||
continue
|
||||
|
||||
|
||||
# SAFETY: Periodic eviction check (every 5 minutes)
|
||||
if time.time() - self.last_eviction_check > self.eviction_check_interval:
|
||||
await self._evict_failed_clients()
|
||||
self.last_eviction_check = time.time()
|
||||
|
||||
|
||||
# Periodic cleanup check (every 10 minutes)
|
||||
if time.time() - self.last_cleanup_time > self.cleanup_interval:
|
||||
await self._cleanup_old_messages()
|
||||
self.last_cleanup_time = time.time()
|
||||
|
||||
|
||||
# Check if it's time to push
|
||||
if time.time() < self.next_push_time:
|
||||
continue
|
||||
|
||||
|
||||
# Get all clients for this room
|
||||
all_clients = self.acl.get_all_clients()
|
||||
if not all_clients:
|
||||
@@ -575,60 +579,66 @@ class RoomServer:
|
||||
# to avoid log spam when room is idle
|
||||
self.next_push_time = time.time() + 1.0 # Check again in 1 second
|
||||
continue
|
||||
|
||||
|
||||
# SAFETY: Limit number of clients
|
||||
if len(all_clients) > MAX_CLIENTS_PER_ROOM:
|
||||
logger.warning(
|
||||
f"Room '{self.room_name}': Too many clients ({len(all_clients)} > {MAX_CLIENTS_PER_ROOM})"
|
||||
)
|
||||
all_clients = all_clients[:MAX_CLIENTS_PER_ROOM]
|
||||
|
||||
|
||||
# Check for ACK timeouts first
|
||||
await self._check_ack_timeouts()
|
||||
|
||||
|
||||
# Track how many clients we've checked in this iteration
|
||||
clients_checked = 0
|
||||
max_checks = len(all_clients)
|
||||
|
||||
|
||||
# Round-robin: find next active client
|
||||
while clients_checked < max_checks:
|
||||
# Get next client
|
||||
if self.next_client_idx >= len(all_clients):
|
||||
self.next_client_idx = 0
|
||||
|
||||
|
||||
client = all_clients[self.next_client_idx]
|
||||
self.next_client_idx = (self.next_client_idx + 1) % len(all_clients)
|
||||
clients_checked += 1
|
||||
|
||||
|
||||
# Get client sync state
|
||||
sync_state = self.db.get_client_sync(
|
||||
room_hash=f"0x{self.room_hash:02X}",
|
||||
client_pubkey=client.id.get_public_key().hex()
|
||||
client_pubkey=client.id.get_public_key().hex(),
|
||||
)
|
||||
|
||||
|
||||
# Skip if already waiting for ACK, evicted, or max failures
|
||||
if sync_state:
|
||||
pending_ack = sync_state.get('pending_ack_crc', 0)
|
||||
last_activity = sync_state.get('last_activity', 0)
|
||||
push_failures = sync_state.get('push_failures', 0)
|
||||
|
||||
pending_ack = sync_state.get("pending_ack_crc", 0)
|
||||
last_activity = sync_state.get("last_activity", 0)
|
||||
push_failures = sync_state.get("push_failures", 0)
|
||||
|
||||
if pending_ack != 0:
|
||||
logger.debug(f"Skipping client 0x{client.id.get_public_key()[0]:02X} (waiting for ACK)")
|
||||
logger.debug(
|
||||
f"Skipping client 0x{client.id.get_public_key()[0]:02X} (waiting for ACK)"
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
if last_activity == 0:
|
||||
logger.debug(f"Skipping client 0x{client.id.get_public_key()[0]:02X} (evicted)")
|
||||
logger.debug(
|
||||
f"Skipping client 0x{client.id.get_public_key()[0]:02X} (evicted)"
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
if push_failures >= 3:
|
||||
logger.debug(f"Skipping client 0x{client.id.get_public_key()[0]:02X} (max failures)")
|
||||
logger.debug(
|
||||
f"Skipping client 0x{client.id.get_public_key()[0]:02X} (max failures)"
|
||||
)
|
||||
continue
|
||||
|
||||
sync_since = sync_state.get('sync_since', 0)
|
||||
|
||||
sync_since = sync_state.get("sync_since", 0)
|
||||
else:
|
||||
# Initialize sync state for new client
|
||||
# Use sync_since from ACL client (sent during login) if available
|
||||
sync_since = client.sync_since if hasattr(client, 'sync_since') else 0
|
||||
sync_since = client.sync_since if hasattr(client, "sync_since") else 0
|
||||
logger.info(
|
||||
f"Room '{self.room_name}': Initializing client "
|
||||
f"0x{client.id.get_public_key()[0]:02X} with sync_since={sync_since}"
|
||||
@@ -637,17 +647,17 @@ class RoomServer:
|
||||
room_hash=f"0x{self.room_hash:02X}",
|
||||
client_pubkey=client.id.get_public_key().hex(),
|
||||
sync_since=sync_since,
|
||||
last_activity=time.time()
|
||||
last_activity=time.time(),
|
||||
)
|
||||
|
||||
|
||||
# Find next unsynced message for this client
|
||||
unsynced = self.db.get_unsynced_messages(
|
||||
room_hash=f"0x{self.room_hash:02X}",
|
||||
client_pubkey=client.id.get_public_key().hex(),
|
||||
sync_since=sync_since,
|
||||
limit=1
|
||||
limit=1,
|
||||
)
|
||||
|
||||
|
||||
if unsynced:
|
||||
post = unsynced[0]
|
||||
logger.debug(
|
||||
@@ -656,7 +666,7 @@ class RoomServer:
|
||||
)
|
||||
# Check if enough time has passed since post creation
|
||||
now = time.time()
|
||||
if now >= post['post_timestamp'] + POST_SYNC_DELAY_SECS:
|
||||
if now >= post["post_timestamp"] + POST_SYNC_DELAY_SECS:
|
||||
# Push this post
|
||||
await self.push_post_to_client(client, post)
|
||||
self.next_push_time = time.time() + (SYNC_PUSH_INTERVAL_MS / 1000.0)
|
||||
@@ -668,15 +678,15 @@ class RoomServer:
|
||||
else:
|
||||
# No unsynced posts for this client, try next client
|
||||
continue
|
||||
|
||||
|
||||
# If we checked all clients and none were active/ready
|
||||
if clients_checked >= max_checks:
|
||||
# All clients skipped or no messages - wait longer before next check
|
||||
self.next_push_time = time.time() + 5.0 # Wait 5 seconds
|
||||
|
||||
|
||||
# SAFETY: Reset error counter on successful iteration
|
||||
self.consecutive_sync_errors = 0
|
||||
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -684,35 +694,34 @@ class RoomServer:
|
||||
self.consecutive_sync_errors += 1
|
||||
logger.error(
|
||||
f"Room '{self.room_name}': Sync loop error #{self.consecutive_sync_errors}: {e}",
|
||||
exc_info=True
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
# SAFETY: Back off on errors
|
||||
backoff = min(self.consecutive_sync_errors, 10) # Cap at 10 seconds
|
||||
await asyncio.sleep(backoff)
|
||||
|
||||
|
||||
logger.info(f"Room '{self.room_name}' sync loop stopped")
|
||||
|
||||
|
||||
async def _check_ack_timeouts(self):
|
||||
try:
|
||||
now = time.time()
|
||||
all_sync_states = self.db.get_all_room_clients(f"0x{self.room_hash:02X}")
|
||||
|
||||
|
||||
for sync_state in all_sync_states:
|
||||
if sync_state['pending_ack_crc'] != 0:
|
||||
timeout_time = sync_state.get('ack_timeout_time', 0)
|
||||
if sync_state["pending_ack_crc"] != 0:
|
||||
timeout_time = sync_state.get("ack_timeout_time", 0)
|
||||
if now >= timeout_time:
|
||||
# ACK timeout
|
||||
client_pubkey = bytes.fromhex(sync_state['client_pubkey'])
|
||||
client_pubkey = bytes.fromhex(sync_state["client_pubkey"])
|
||||
await self._handle_ack_timeout(client_pubkey)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking ACK timeouts: {e}")
|
||||
|
||||
|
||||
async def _cleanup_old_messages(self):
|
||||
try:
|
||||
deleted = self.db.cleanup_old_messages(
|
||||
room_hash=f"0x{self.room_hash:02X}",
|
||||
keep_count=self.max_posts
|
||||
room_hash=f"0x{self.room_hash:02X}", keep_count=self.max_posts
|
||||
)
|
||||
if deleted > 0:
|
||||
logger.info(f"Room '{self.room_name}': Cleaned up {deleted} old messages")
|
||||
|
||||
+179
-140
@@ -12,6 +12,7 @@ import struct
|
||||
import time
|
||||
|
||||
from pymc_core.node.handlers.text import TextMessageHandler
|
||||
|
||||
from .mesh_cli import MeshCLI
|
||||
from .room_server import RoomServer
|
||||
|
||||
@@ -24,9 +25,18 @@ TXT_TYPE_CLI_DATA = 0x01
|
||||
|
||||
class TextHelper:
|
||||
|
||||
def __init__(self, identity_manager, packet_injector=None, acl_dict=None, log_fn=None,
|
||||
config_path: str = None, config: dict = None, config_manager=None,
|
||||
sqlite_handler=None, send_advert_callback=None):
|
||||
def __init__(
|
||||
self,
|
||||
identity_manager,
|
||||
packet_injector=None,
|
||||
acl_dict=None,
|
||||
log_fn=None,
|
||||
config_path: str = None,
|
||||
config: dict = None,
|
||||
config_manager=None,
|
||||
sqlite_handler=None,
|
||||
send_advert_callback=None,
|
||||
):
|
||||
|
||||
self.identity_manager = identity_manager
|
||||
self.packet_injector = packet_injector
|
||||
@@ -34,47 +44,43 @@ class TextHelper:
|
||||
self.acl_dict = acl_dict or {} # Per-identity ACLs keyed by hash_byte
|
||||
self.sqlite_handler = sqlite_handler # For room server database operations
|
||||
self.send_advert_callback = send_advert_callback # Callback to send repeater advert
|
||||
|
||||
|
||||
# Dictionary of handlers keyed by dest_hash
|
||||
self.handlers = {}
|
||||
|
||||
|
||||
# Dictionary of room servers keyed by dest_hash
|
||||
self.room_servers = {}
|
||||
|
||||
|
||||
# Track repeater identity for CLI commands
|
||||
self.repeater_hash = None
|
||||
|
||||
|
||||
# Store config for later use
|
||||
self.config_path = config_path
|
||||
self.config = config
|
||||
self.config_manager = config_manager
|
||||
|
||||
|
||||
# Store for later CLI initialization (needs identity and storage)
|
||||
self.config_path = config_path
|
||||
self.config = config
|
||||
|
||||
|
||||
# Initialize CLI handler later when repeater identity is registered
|
||||
self.cli = None
|
||||
|
||||
def register_identity(
|
||||
self,
|
||||
name: str,
|
||||
identity,
|
||||
identity_type: str = "room_server",
|
||||
radio_config=None
|
||||
self, name: str, identity, identity_type: str = "room_server", radio_config=None
|
||||
):
|
||||
|
||||
hash_byte = identity.get_public_key()[0]
|
||||
|
||||
|
||||
# Get ACL for this identity
|
||||
identity_acl = self.acl_dict.get(hash_byte)
|
||||
if not identity_acl:
|
||||
logger.warning(f"Cannot register identity '{name}': no ACL for hash 0x{hash_byte:02X}")
|
||||
return
|
||||
|
||||
|
||||
# Create a contacts wrapper from this identity's ACL
|
||||
acl_contacts = self._create_acl_contacts_wrapper(identity_acl)
|
||||
|
||||
|
||||
# Create TextMessageHandler for this identity
|
||||
handler = TextMessageHandler(
|
||||
local_identity=identity,
|
||||
@@ -83,7 +89,7 @@ class TextHelper:
|
||||
send_packet_fn=self._send_packet,
|
||||
radio_config=radio_config,
|
||||
)
|
||||
|
||||
|
||||
# Register by dest hash
|
||||
hash_byte = identity.get_public_key()[0]
|
||||
self.handlers[hash_byte] = {
|
||||
@@ -92,12 +98,12 @@ class TextHelper:
|
||||
"name": name,
|
||||
"type": identity_type,
|
||||
}
|
||||
|
||||
|
||||
# Track repeater identity for CLI commands
|
||||
if identity_type == "repeater":
|
||||
self.repeater_hash = hash_byte
|
||||
logger.info(f"Set repeater hash for CLI: 0x{hash_byte:02X}")
|
||||
|
||||
|
||||
# Initialize CLI handler now that we have the repeater identity
|
||||
if self.config_path and self.config and self.config_manager:
|
||||
self.cli = MeshCLI(
|
||||
@@ -108,18 +114,20 @@ class TextHelper:
|
||||
enable_regions=True,
|
||||
send_advert_callback=self.send_advert_callback,
|
||||
identity=identity,
|
||||
storage_handler=self.sqlite_handler
|
||||
storage_handler=self.sqlite_handler,
|
||||
)
|
||||
logger.info("Initialized CLI handler for repeater commands with identity and storage")
|
||||
|
||||
logger.info(
|
||||
"Initialized CLI handler for repeater commands with identity and storage"
|
||||
)
|
||||
|
||||
# Create RoomServer instance for room_server identities
|
||||
if identity_type == "room_server" and self.sqlite_handler:
|
||||
try:
|
||||
from .room_server import MAX_UNSYNCED_POSTS
|
||||
|
||||
|
||||
room_config = radio_config or {}
|
||||
max_posts = room_config.get('max_posts', MAX_UNSYNCED_POSTS)
|
||||
|
||||
max_posts = room_config.get("max_posts", MAX_UNSYNCED_POSTS)
|
||||
|
||||
# Enforce hard limit
|
||||
if max_posts > MAX_UNSYNCED_POSTS:
|
||||
logger.warning(
|
||||
@@ -127,7 +135,7 @@ class TextHelper:
|
||||
f"of {MAX_UNSYNCED_POSTS}, capping to {MAX_UNSYNCED_POSTS}"
|
||||
)
|
||||
max_posts = MAX_UNSYNCED_POSTS
|
||||
|
||||
|
||||
room_server = RoomServer(
|
||||
room_hash=hash_byte,
|
||||
room_name=name,
|
||||
@@ -138,31 +146,29 @@ class TextHelper:
|
||||
max_posts=max_posts,
|
||||
config_path=self.config_path,
|
||||
config=self.config,
|
||||
config_manager=self.config_manager
|
||||
config_manager=self.config_manager,
|
||||
)
|
||||
|
||||
|
||||
self.room_servers[hash_byte] = room_server
|
||||
|
||||
|
||||
# Start sync loop
|
||||
asyncio.create_task(room_server.start())
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Registered room server '{name}': hash=0x{hash_byte:02X}, "
|
||||
f"max_posts={max_posts}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create room server '{name}': {e}", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
f"Registered {identity_type} '{name}' text handler: hash=0x{hash_byte:02X}"
|
||||
)
|
||||
|
||||
|
||||
logger.info(f"Registered {identity_type} '{name}' text handler: hash=0x{hash_byte:02X}")
|
||||
|
||||
def _create_acl_contacts_wrapper(self, acl):
|
||||
|
||||
class ACLContactsWrapper:
|
||||
def __init__(self, identity_acl):
|
||||
self._acl = identity_acl
|
||||
|
||||
|
||||
@property
|
||||
def contacts(self):
|
||||
contact_list = []
|
||||
@@ -172,10 +178,10 @@ class TextHelper:
|
||||
def __init__(self, client):
|
||||
self.public_key = client.id.get_public_key().hex()
|
||||
self.name = f"client_{self.public_key[:8]}"
|
||||
|
||||
|
||||
contact_list.append(ContactProxy(client_info))
|
||||
return contact_list
|
||||
|
||||
|
||||
return ACLContactsWrapper(acl)
|
||||
|
||||
async def process_text_packet(self, packet):
|
||||
@@ -183,20 +189,20 @@ class TextHelper:
|
||||
try:
|
||||
if len(packet.payload) < 2:
|
||||
return False
|
||||
|
||||
|
||||
dest_hash = packet.payload[0]
|
||||
src_hash = packet.payload[1]
|
||||
|
||||
|
||||
handler_info = self.handlers.get(dest_hash)
|
||||
if handler_info:
|
||||
logger.debug(
|
||||
f"Routing text message to '{handler_info['name']}': "
|
||||
f"dest=0x{dest_hash:02X}, src=0x{src_hash:02X}"
|
||||
)
|
||||
|
||||
|
||||
# Let handler decrypt the message first
|
||||
await handler_info["handler"](packet)
|
||||
|
||||
|
||||
# Call placeholder for custom processing
|
||||
await self._on_message_received(
|
||||
identity_name=handler_info["name"],
|
||||
@@ -205,16 +211,14 @@ class TextHelper:
|
||||
dest_hash=dest_hash,
|
||||
src_hash=src_hash,
|
||||
)
|
||||
|
||||
|
||||
# Mark packet as handled
|
||||
packet.mark_do_not_retransmit()
|
||||
return True
|
||||
else:
|
||||
logger.debug(
|
||||
f"No text handler for hash 0x{dest_hash:02X}, allowing forward"
|
||||
)
|
||||
logger.debug(f"No text handler for hash 0x{dest_hash:02X}, allowing forward")
|
||||
return False
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing text packet: {e}")
|
||||
return False
|
||||
@@ -230,128 +234,137 @@ class TextHelper:
|
||||
|
||||
# Placeholder - can be overridden or callback can be added
|
||||
logger.debug(
|
||||
f"Message received for {identity_type} '{identity_name}' "
|
||||
f"from 0x{src_hash:02X}"
|
||||
f"Message received for {identity_type} '{identity_name}' " f"from 0x{src_hash:02X}"
|
||||
)
|
||||
|
||||
|
||||
# Extract decrypted message if available
|
||||
if hasattr(packet, "decrypted") and packet.decrypted:
|
||||
message_text = packet.decrypted.get("text", "<unknown>")
|
||||
|
||||
|
||||
# Clean message text - remove null bytes and trailing whitespace
|
||||
message_text = message_text.rstrip('\x00').rstrip()
|
||||
|
||||
logger.info(
|
||||
f"[{identity_type}:{identity_name}] Message: {message_text}"
|
||||
)
|
||||
|
||||
message_text = message_text.rstrip("\x00").rstrip()
|
||||
|
||||
logger.info(f"[{identity_type}:{identity_name}] Message: {message_text}")
|
||||
|
||||
# Handle room server messages
|
||||
if identity_type == "room_server" and dest_hash in self.room_servers:
|
||||
room_server = self.room_servers[dest_hash]
|
||||
|
||||
|
||||
# Check if this is a CLI command FIRST (before storing as post)
|
||||
if self._is_cli_command(message_text):
|
||||
# Handle CLI command - do NOT store as post
|
||||
if room_server and room_server.cli:
|
||||
try:
|
||||
# Check admin permission
|
||||
is_admin = self._check_admin_permission_for_identity(src_hash, dest_hash)
|
||||
|
||||
is_admin = self._check_admin_permission_for_identity(
|
||||
src_hash, dest_hash
|
||||
)
|
||||
|
||||
if not is_admin:
|
||||
logger.warning(f"Room '{identity_name}': CLI command denied from 0x{src_hash:02X} (not admin)")
|
||||
logger.warning(
|
||||
f"Room '{identity_name}': CLI command denied from 0x{src_hash:02X} (not admin)"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
# Get sender's full pubkey
|
||||
identity_acl = self.acl_dict.get(dest_hash)
|
||||
sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Default
|
||||
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
|
||||
if identity_acl:
|
||||
for client_info in identity_acl.get_all_clients():
|
||||
if client_info.id.get_public_key()[0] == src_hash:
|
||||
sender_pubkey = client_info.id.get_public_key()
|
||||
break
|
||||
|
||||
|
||||
# Handle CLI command
|
||||
reply = room_server.cli.handle_command(
|
||||
sender_pubkey=sender_pubkey,
|
||||
command=message_text,
|
||||
is_admin=is_admin
|
||||
sender_pubkey=sender_pubkey, command=message_text, is_admin=is_admin
|
||||
)
|
||||
|
||||
logger.info(f"Room '{identity_name}': CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}")
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Room '{identity_name}': CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}"
|
||||
)
|
||||
|
||||
# Send reply back to sender
|
||||
handler_info = self.handlers.get(dest_hash)
|
||||
if handler_info:
|
||||
await self._send_cli_reply(packet, reply, handler_info)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing room server CLI command: {e}", exc_info=True)
|
||||
|
||||
logger.error(
|
||||
f"Error processing room server CLI command: {e}", exc_info=True
|
||||
)
|
||||
|
||||
# CLI command handled, don't store as post
|
||||
return
|
||||
|
||||
|
||||
# NOT a CLI command - store as regular room post
|
||||
try:
|
||||
# Get sender's full pubkey
|
||||
identity_acl = self.acl_dict.get(dest_hash)
|
||||
sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Default
|
||||
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
|
||||
if identity_acl:
|
||||
for client_info in identity_acl.get_all_clients():
|
||||
if client_info.id.get_public_key()[0] == src_hash:
|
||||
sender_pubkey = client_info.id.get_public_key()
|
||||
break
|
||||
|
||||
|
||||
# Store message as post
|
||||
sender_timestamp = int(time.time())
|
||||
success = await room_server.add_post(
|
||||
client_pubkey=sender_pubkey,
|
||||
message_text=message_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
txt_type=TXT_TYPE_PLAIN
|
||||
txt_type=TXT_TYPE_PLAIN,
|
||||
)
|
||||
|
||||
|
||||
if success:
|
||||
logger.info(f"Room '{identity_name}': New post from {sender_pubkey[:4].hex()}: {message_text[:50]}")
|
||||
|
||||
logger.info(
|
||||
f"Room '{identity_name}': New post from {sender_pubkey[:4].hex()}: {message_text[:50]}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing room post: {e}", exc_info=True)
|
||||
|
||||
|
||||
return
|
||||
|
||||
|
||||
# Check if this is a CLI command to the repeater (AFTER decryption)
|
||||
if dest_hash == self.repeater_hash and self.cli and self._is_cli_command(message_text):
|
||||
try:
|
||||
# Check admin permission
|
||||
is_admin = self._check_admin_permission_for_identity(src_hash, self.repeater_hash)
|
||||
|
||||
is_admin = self._check_admin_permission_for_identity(
|
||||
src_hash, self.repeater_hash
|
||||
)
|
||||
|
||||
# If not admin, log and return without sending reply
|
||||
if not is_admin:
|
||||
logger.warning(f"CLI command denied from 0x{src_hash:02X} (not admin): {message_text[:50]}")
|
||||
logger.warning(
|
||||
f"CLI command denied from 0x{src_hash:02X} (not admin): {message_text[:50]}"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
# Get client for full public key
|
||||
repeater_acl = self.acl_dict.get(self.repeater_hash)
|
||||
sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Default
|
||||
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
|
||||
if repeater_acl:
|
||||
for client_info in repeater_acl.get_all_clients():
|
||||
if client_info.id.get_public_key()[0] == src_hash:
|
||||
sender_pubkey = client_info.id.get_public_key()
|
||||
break
|
||||
|
||||
|
||||
# Handle CLI command
|
||||
reply = self.cli.handle_command(
|
||||
sender_pubkey=sender_pubkey,
|
||||
command=message_text,
|
||||
is_admin=is_admin
|
||||
sender_pubkey=sender_pubkey, command=message_text, is_admin=is_admin
|
||||
)
|
||||
|
||||
logger.info(f"CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}")
|
||||
|
||||
|
||||
logger.info(
|
||||
f"CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}"
|
||||
)
|
||||
|
||||
# Send reply back to sender
|
||||
handler_info = self.handlers.get(dest_hash)
|
||||
if handler_info:
|
||||
await self._send_cli_reply(packet, reply, handler_info)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing CLI command: {e}", exc_info=True)
|
||||
|
||||
@@ -381,7 +394,7 @@ class TextHelper:
|
||||
}
|
||||
for hash_byte, info in self.handlers.items()
|
||||
]
|
||||
|
||||
|
||||
async def cleanup(self):
|
||||
"""Cleanup room servers and handlers."""
|
||||
# Stop all room server sync loops
|
||||
@@ -390,52 +403,68 @@ class TextHelper:
|
||||
await room_server.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping room server: {e}")
|
||||
|
||||
|
||||
logger.info("TextHelper cleanup complete")
|
||||
|
||||
|
||||
def _is_cli_command(self, message: str) -> bool:
|
||||
"""Check if message looks like a CLI command."""
|
||||
# Strip optional sequence prefix (XX|)
|
||||
if len(message) > 4 and message[2] == '|':
|
||||
if len(message) > 4 and message[2] == "|":
|
||||
message = message[3:].strip()
|
||||
|
||||
|
||||
# Check for known command prefixes
|
||||
command_prefixes = [
|
||||
"get ", "set ", "reboot", "advert", "clock", "time ",
|
||||
"password ", "clear ", "ver", "board", "neighbors", "neighbor.",
|
||||
"tempradio ", "setperm ", "region", "sensor ", "gps", "log ",
|
||||
"stats-", "start ota"
|
||||
"get ",
|
||||
"set ",
|
||||
"reboot",
|
||||
"advert",
|
||||
"clock",
|
||||
"time ",
|
||||
"password ",
|
||||
"clear ",
|
||||
"ver",
|
||||
"board",
|
||||
"neighbors",
|
||||
"neighbor.",
|
||||
"tempradio ",
|
||||
"setperm ",
|
||||
"region",
|
||||
"sensor ",
|
||||
"gps",
|
||||
"log ",
|
||||
"stats-",
|
||||
"start ota",
|
||||
]
|
||||
|
||||
|
||||
return any(message.startswith(prefix) for prefix in command_prefixes)
|
||||
|
||||
|
||||
def _check_admin_permission(self, src_hash: int) -> bool:
|
||||
"""Check if sender has admin permissions for repeater (legacy method)."""
|
||||
return self._check_admin_permission_for_identity(src_hash, self.repeater_hash)
|
||||
|
||||
|
||||
def _check_admin_permission_for_identity(self, src_hash: int, identity_hash: int) -> bool:
|
||||
"""Check if sender has admin permissions (bit 0x02) for a specific identity."""
|
||||
# Get the identity's ACL
|
||||
identity_acl = self.acl_dict.get(identity_hash)
|
||||
if not identity_acl:
|
||||
return False
|
||||
|
||||
|
||||
# Get client by hash byte
|
||||
clients = identity_acl.get_all_clients()
|
||||
for client_info in clients:
|
||||
pubkey = client_info.id.get_public_key()
|
||||
if pubkey[0] == src_hash:
|
||||
# Check admin bit (0x02 = PERM_ACL_ADMIN)
|
||||
permissions = getattr(client_info, 'permissions', 0)
|
||||
permissions = getattr(client_info, "permissions", 0)
|
||||
PERM_ACL_ADMIN = 0x02
|
||||
return (permissions & 0x02) == PERM_ACL_ADMIN
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def _send_cli_reply(self, original_packet, reply_text: str, handler_info: dict):
|
||||
"""
|
||||
Send CLI reply back to sender using TXT_MSG datagram.
|
||||
|
||||
|
||||
Follows the C++ pattern (lines 603-609 in MyMesh.cpp):
|
||||
- Creates TXT_MSG datagram with TXT_TYPE_CLI_DATA flag
|
||||
- Encrypts with shared secret from ACL client
|
||||
@@ -443,77 +472,87 @@ class TextHelper:
|
||||
* if out_path_len < 0: sendFlood()
|
||||
* else: sendDirect() with stored out_path
|
||||
"""
|
||||
from pymc_core.protocol import PacketBuilder, Identity
|
||||
from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG
|
||||
import time
|
||||
|
||||
|
||||
from pymc_core.protocol import Identity, PacketBuilder
|
||||
from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG
|
||||
|
||||
try:
|
||||
src_hash = original_packet.payload[1]
|
||||
dest_hash = original_packet.payload[0]
|
||||
|
||||
|
||||
incoming_route = original_packet.get_route_type()
|
||||
logger.debug(f"CLI reply: original packet dest=0x{dest_hash:02X}, src=0x{src_hash:02X}, incoming_route={incoming_route}")
|
||||
|
||||
logger.debug(
|
||||
f"CLI reply: original packet dest=0x{dest_hash:02X}, src=0x{src_hash:02X}, incoming_route={incoming_route}"
|
||||
)
|
||||
|
||||
# Find the client in the DESTINATION identity's ACL (not always repeater!)
|
||||
# dest_hash is the identity that received the command (repeater OR room server)
|
||||
identity_acl = self.acl_dict.get(dest_hash)
|
||||
if not identity_acl:
|
||||
logger.error(f"No ACL found for identity 0x{dest_hash:02X} for CLI reply")
|
||||
return
|
||||
|
||||
|
||||
client = None
|
||||
for client_info in identity_acl.get_all_clients():
|
||||
pubkey = client_info.id.get_public_key()
|
||||
if pubkey[0] == src_hash:
|
||||
client = client_info
|
||||
break
|
||||
|
||||
|
||||
if not client:
|
||||
logger.error(f"Client 0x{src_hash:02X} not found in identity 0x{dest_hash:02X} ACL for CLI reply")
|
||||
logger.error(
|
||||
f"Client 0x{src_hash:02X} not found in identity 0x{dest_hash:02X} ACL for CLI reply"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
# Get shared secret from client
|
||||
shared_secret = client.shared_secret
|
||||
if not shared_secret or len(shared_secret) == 0:
|
||||
logger.error(f"No shared secret for client 0x{src_hash:02X}")
|
||||
return
|
||||
|
||||
|
||||
# Build reply packet payload
|
||||
# Format: timestamp(4) + flags(1) + reply_text
|
||||
timestamp = int(time.time())
|
||||
TXT_TYPE_CLI_DATA = 0x01
|
||||
flags = (TXT_TYPE_CLI_DATA << 2) # Upper 6 bits are txt_type
|
||||
|
||||
reply_bytes = reply_text.encode('utf-8')
|
||||
plaintext = timestamp.to_bytes(4, 'little') + bytes([flags]) + reply_bytes
|
||||
|
||||
flags = TXT_TYPE_CLI_DATA << 2 # Upper 6 bits are txt_type
|
||||
|
||||
reply_bytes = reply_text.encode("utf-8")
|
||||
plaintext = timestamp.to_bytes(4, "little") + bytes([flags]) + reply_bytes
|
||||
|
||||
# Decide routing based on client->out_path_len (C++ pattern)
|
||||
# out_path is populated by PATH packets, NOT from incoming text message route
|
||||
route_type = "flood" if client.out_path_len < 0 else "direct"
|
||||
logger.debug(f"CLI reply: client.out_path_len={client.out_path_len}, using route_type={route_type}")
|
||||
|
||||
logger.debug(
|
||||
f"CLI reply: client.out_path_len={client.out_path_len}, using route_type={route_type}"
|
||||
)
|
||||
|
||||
reply_packet = PacketBuilder.create_datagram(
|
||||
ptype=PAYLOAD_TYPE_TXT_MSG,
|
||||
dest=client.id,
|
||||
local_identity=handler_info["identity"],
|
||||
secret=shared_secret,
|
||||
plaintext=plaintext,
|
||||
route_type=route_type
|
||||
route_type=route_type,
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Add path for direct routing if available from PATH packets
|
||||
if client.out_path_len >= 0 and len(client.out_path) > 0:
|
||||
reply_packet.path = bytearray(client.out_path[:client.out_path_len])
|
||||
reply_packet.path = bytearray(client.out_path[: client.out_path_len])
|
||||
reply_packet.path_len = client.out_path_len
|
||||
logger.debug(f"CLI reply: Added stored out_path - path_len={reply_packet.path_len}, path={[hex(b) for b in reply_packet.path]}")
|
||||
|
||||
logger.debug(
|
||||
f"CLI reply: Added stored out_path - path_len={reply_packet.path_len}, path={[hex(b) for b in reply_packet.path]}"
|
||||
)
|
||||
|
||||
# Send with delay (CLI_REPLY_DELAY_MILLIS = 600ms in C++)
|
||||
CLI_REPLY_DELAY_MS = 600
|
||||
await asyncio.sleep(CLI_REPLY_DELAY_MS / 1000.0)
|
||||
|
||||
|
||||
await self._send_packet(reply_packet, wait_for_ack=False)
|
||||
logger.info(f"CLI reply sent to 0x{src_hash:02X} via {route_type.upper()}: {reply_text[:50]}")
|
||||
|
||||
logger.info(
|
||||
f"CLI reply sent to 0x{src_hash:02X} via {route_type.upper()}: {reply_text[:50]}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending CLI reply: {e}", exc_info=True)
|
||||
|
||||
@@ -9,7 +9,7 @@ of packets through the mesh network.
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, Any
|
||||
from typing import Any, Dict
|
||||
|
||||
from pymc_core.hardware.signal_utils import snr_register_to_db
|
||||
from pymc_core.node.handlers.trace import TraceHandler
|
||||
@@ -34,10 +34,12 @@ class TraceHelper:
|
||||
self.local_hash = local_hash
|
||||
self.repeater_handler = repeater_handler
|
||||
self.packet_injector = packet_injector # Function to inject packets into router
|
||||
|
||||
|
||||
# Ping callback system - track pending ping requests by tag
|
||||
self.pending_pings = {} # {tag: {'event': asyncio.Event(), 'result': dict, 'target': int, 'sent_at': float}}
|
||||
|
||||
self.pending_pings = (
|
||||
{}
|
||||
) # {tag: {'event': asyncio.Event(), 'result': dict, 'target': int, 'sent_at': float}}
|
||||
|
||||
# Optional: when trace reaches final node, call this (packet, parsed_data) to push 0x89 to companions
|
||||
self.on_trace_complete = None # async (packet, parsed_data) -> None
|
||||
|
||||
@@ -63,9 +65,7 @@ class TraceHelper:
|
||||
parsed_data = self.trace_handler._parse_trace_payload(packet.payload)
|
||||
|
||||
if not parsed_data.get("valid", False):
|
||||
logger.warning(
|
||||
f"Invalid trace packet: {parsed_data.get('error', 'Unknown error')}"
|
||||
)
|
||||
logger.warning(f"Invalid trace packet: {parsed_data.get('error', 'Unknown error')}")
|
||||
return
|
||||
|
||||
trace_path = parsed_data["trace_path"]
|
||||
@@ -76,14 +76,14 @@ class TraceHelper:
|
||||
if trace_tag in self.pending_pings:
|
||||
ping_info = self.pending_pings[trace_tag]
|
||||
# Store response data
|
||||
ping_info['result'] = {
|
||||
'path': trace_path,
|
||||
'snr': packet.get_snr(),
|
||||
'rssi': getattr(packet, "rssi", 0),
|
||||
'received_at': time.time()
|
||||
ping_info["result"] = {
|
||||
"path": trace_path,
|
||||
"snr": packet.get_snr(),
|
||||
"rssi": getattr(packet, "rssi", 0),
|
||||
"received_at": time.time(),
|
||||
}
|
||||
# Signal the waiting coroutine
|
||||
ping_info['event'].set()
|
||||
ping_info["event"].set()
|
||||
logger.info(f"Ping response received for tag {trace_tag}")
|
||||
|
||||
# Record the trace packet for dashboard/statistics
|
||||
@@ -149,27 +149,37 @@ class TraceHelper:
|
||||
|
||||
# Add detailed SNR info if we have the corresponding hash
|
||||
if i < len(trace_path):
|
||||
path_snr_details.append({
|
||||
"hash": f"{trace_path[i]:02X}",
|
||||
"snr_raw": snr_val,
|
||||
"snr_db": snr_db
|
||||
})
|
||||
path_snr_details.append(
|
||||
{"hash": f"{trace_path[i]:02X}", "snr_raw": snr_val, "snr_db": snr_db}
|
||||
)
|
||||
|
||||
return {
|
||||
"timestamp": time.time(),
|
||||
"header": f"0x{packet.header:02X}" if hasattr(packet, "header") and packet.header is not None else None,
|
||||
"payload": packet.payload.hex() if hasattr(packet, "payload") and packet.payload else None,
|
||||
"payload_length": len(packet.payload) if hasattr(packet, "payload") and packet.payload else 0,
|
||||
"header": (
|
||||
f"0x{packet.header:02X}"
|
||||
if hasattr(packet, "header") and packet.header is not None
|
||||
else None
|
||||
),
|
||||
"payload": (
|
||||
packet.payload.hex() if hasattr(packet, "payload") and packet.payload else None
|
||||
),
|
||||
"payload_length": (
|
||||
len(packet.payload) if hasattr(packet, "payload") and packet.payload else 0
|
||||
),
|
||||
"type": packet.get_payload_type(), # 0x09 for trace
|
||||
"route": packet.get_route_type(), # Should be direct (1)
|
||||
"route": packet.get_route_type(), # Should be direct (1)
|
||||
"length": len(packet.payload or b""),
|
||||
"rssi": getattr(packet, "rssi", 0),
|
||||
"snr": getattr(packet, "snr", 0.0),
|
||||
"score": self.repeater_handler.calculate_packet_score(
|
||||
getattr(packet, "snr", 0.0),
|
||||
len(packet.payload or b""),
|
||||
self.repeater_handler.radio_config.get("spreading_factor", 8)
|
||||
) if self.repeater_handler else 0.0,
|
||||
"score": (
|
||||
self.repeater_handler.calculate_packet_score(
|
||||
getattr(packet, "snr", 0.0),
|
||||
len(packet.payload or b""),
|
||||
self.repeater_handler.radio_config.get("spreading_factor", 8),
|
||||
)
|
||||
if self.repeater_handler
|
||||
else 0.0
|
||||
),
|
||||
"tx_delay_ms": 0,
|
||||
"transmitted": False,
|
||||
"is_duplicate": False,
|
||||
@@ -226,21 +236,24 @@ class TraceHelper:
|
||||
True if the packet should be forwarded, False otherwise
|
||||
"""
|
||||
# Use the exact logic from the original working code
|
||||
return (packet.path_len < trace_path_len and
|
||||
len(trace_path) > packet.path_len and
|
||||
trace_path[packet.path_len] == self.local_hash and
|
||||
self.repeater_handler and not self.repeater_handler.is_duplicate(packet))
|
||||
return (
|
||||
packet.path_len < trace_path_len
|
||||
and len(trace_path) > packet.path_len
|
||||
and trace_path[packet.path_len] == self.local_hash
|
||||
and self.repeater_handler
|
||||
and not self.repeater_handler.is_duplicate(packet)
|
||||
)
|
||||
|
||||
async def _forward_trace_packet(self, packet, trace_path_len: int) -> None:
|
||||
"""
|
||||
Forward a trace packet by appending SNR and sending via injection.
|
||||
|
||||
|
||||
Args:
|
||||
packet: The trace packet to forward
|
||||
trace_path_len: The length of the trace path
|
||||
"""
|
||||
# Update the packet record to show it will be transmitted
|
||||
if self.repeater_handler and hasattr(self.repeater_handler, 'recent_packets'):
|
||||
if self.repeater_handler and hasattr(self.repeater_handler, "recent_packets"):
|
||||
packet_hash = packet.calculate_packet_hash().hex().upper()[:16]
|
||||
for record in reversed(self.repeater_handler.recent_packets):
|
||||
if record.get("packet_hash") == packet_hash:
|
||||
@@ -293,41 +306,44 @@ class TraceHelper:
|
||||
elif len(trace_path) <= packet.path_len:
|
||||
logger.info("Path index out of bounds")
|
||||
elif trace_path[packet.path_len] != self.local_hash:
|
||||
expected_hash = trace_path[packet.path_len] if packet.path_len < len(trace_path) else None
|
||||
expected_hash = (
|
||||
trace_path[packet.path_len] if packet.path_len < len(trace_path) else None
|
||||
)
|
||||
logger.info(f"Not our turn (next hop: 0x{expected_hash:02x})")
|
||||
elif self.repeater_handler and self.repeater_handler.is_duplicate(packet):
|
||||
logger.info("Duplicate packet, ignoring")
|
||||
|
||||
def register_ping(self, tag: int, target_hash: int) -> asyncio.Event:
|
||||
"""Register a ping request and return an event to wait on.
|
||||
|
||||
|
||||
Args:
|
||||
tag: The unique trace tag for this ping
|
||||
target_hash: The hash of the target node
|
||||
|
||||
|
||||
Returns:
|
||||
asyncio.Event that will be set when response is received
|
||||
"""
|
||||
event = asyncio.Event()
|
||||
self.pending_pings[tag] = {
|
||||
'event': event,
|
||||
'result': None,
|
||||
'target': target_hash,
|
||||
'sent_at': time.time()
|
||||
"event": event,
|
||||
"result": None,
|
||||
"target": target_hash,
|
||||
"sent_at": time.time(),
|
||||
}
|
||||
logger.debug(f"Registered ping with tag {tag} for target 0x{target_hash:02x}")
|
||||
return event
|
||||
|
||||
def cleanup_stale_pings(self, max_age_seconds: int = 30):
|
||||
"""Remove pending pings older than max_age_seconds.
|
||||
|
||||
|
||||
Args:
|
||||
max_age_seconds: Maximum age in seconds before a ping is considered stale
|
||||
"""
|
||||
current_time = time.time()
|
||||
stale_tags = [
|
||||
tag for tag, info in self.pending_pings.items()
|
||||
if current_time - info['sent_at'] > max_age_seconds
|
||||
tag
|
||||
for tag, info in self.pending_pings.items()
|
||||
if current_time - info["sent_at"] > max_age_seconds
|
||||
]
|
||||
for tag in stale_tags:
|
||||
self.pending_pings.pop(tag)
|
||||
|
||||
Reference in New Issue
Block a user