mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-11 03:12:51 +02:00
Refactor Mesh CLI handler: rename RepeaterCLI to MeshCLI, initialization with identity type and region support, and update command handling for room servers.
This commit is contained in:
@@ -0,0 +1,578 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MeshCLI:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: str,
|
||||
config: Dict[str, Any],
|
||||
save_config_callback: Callable,
|
||||
identity_type: str = "repeater",
|
||||
enable_regions: bool = True,
|
||||
send_advert_callback: Optional[Callable] = None
|
||||
):
|
||||
|
||||
self.config_path = Path(config_path)
|
||||
self.config = config
|
||||
self.save_config = save_config_callback
|
||||
self.identity_type = identity_type
|
||||
self.enable_regions = enable_regions
|
||||
self.send_advert_callback = send_advert_callback
|
||||
|
||||
# Get repeater config shortcut
|
||||
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] == '|':
|
||||
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()
|
||||
elif command == "advert":
|
||||
return self._cmd_advert()
|
||||
elif command.startswith("clock"):
|
||||
return self._cmd_clock(command)
|
||||
elif command.startswith("time "):
|
||||
return self._cmd_time(command)
|
||||
elif command == "start ota":
|
||||
return "Error: OTA not supported in Python repeater"
|
||||
elif command.startswith("password "):
|
||||
return self._cmd_password(command)
|
||||
elif command == "clear stats":
|
||||
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."""
|
||||
logger.warning("Reboot command received - not implemented (use systemctl restart)")
|
||||
return "Error: Use systemctl restart pymc-repeater"
|
||||
|
||||
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:
|
||||
# Call the async callback synchronously (will be awaited by caller if needed)
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Schedule the coroutine and don't wait for it
|
||||
asyncio.create_task(self.send_advert_callback())
|
||||
logger.info("Advert send scheduled")
|
||||
return "OK - Advert sent"
|
||||
else:
|
||||
# Run synchronously if no loop
|
||||
result = loop.run_until_complete(self.send_advert_callback())
|
||||
if result:
|
||||
return "OK - Advert sent"
|
||||
else:
|
||||
return "Error: Failed to send advert"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send 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":
|
||||
# Clock sync happens automatically via sender_timestamp in protocol
|
||||
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
|
||||
|
||||
# Save config
|
||||
try:
|
||||
self.save_config()
|
||||
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')
|
||||
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)
|
||||
return f"> {af}"
|
||||
|
||||
elif param == "name":
|
||||
name = self.repeater_config.get('name', 'Unknown')
|
||||
return f"> {name}"
|
||||
|
||||
elif param == "repeat":
|
||||
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)
|
||||
return f"> {lat}"
|
||||
|
||||
elif param == "lon":
|
||||
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)
|
||||
# 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_mhz = freq_hz / 1_000_000.0
|
||||
return f"> {freq_mhz}"
|
||||
|
||||
elif param == "tx":
|
||||
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"
|
||||
|
||||
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', '')
|
||||
return f"> {guest_pw}"
|
||||
|
||||
elif param == "allow.read.only":
|
||||
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)
|
||||
return f"> {interval}"
|
||||
|
||||
elif param == "flood.advert.interval":
|
||||
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)
|
||||
return f"> {max_flood}"
|
||||
|
||||
elif param == "rxdelay":
|
||||
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)
|
||||
return f"> {delay}"
|
||||
|
||||
elif param == "direct.txdelay":
|
||||
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)
|
||||
return f"> {acks}"
|
||||
|
||||
elif param == "int.thresh":
|
||||
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)
|
||||
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.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "name":
|
||||
self.repeater_config['name'] = value
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "repeat":
|
||||
disabled = value.lower() == "off"
|
||||
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.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "lon":
|
||||
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])
|
||||
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)
|
||||
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)
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "guest.password":
|
||||
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"
|
||||
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.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.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.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.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.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.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "multi.acks":
|
||||
self.repeater_config['multi_acks'] = int(value)
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "int.thresh":
|
||||
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.save_config()
|
||||
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":
|
||||
return "Error: Region commands not implemented"
|
||||
elif subcommand in ("allowf", "denyf", "get", "home", "put", "remove"):
|
||||
return "Error: Region commands not implemented"
|
||||
else:
|
||||
return "Err - ??"
|
||||
|
||||
# ==================== Neighbor Commands ====================
|
||||
|
||||
def _cmd_neighbors(self) -> str:
|
||||
"""List neighbors."""
|
||||
# TODO: Get neighbors from routing table
|
||||
return "Error: Not yet implemented"
|
||||
|
||||
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"
|
||||
if not (7.0 <= bw <= 500.0):
|
||||
return "Error: invalid bandwidth"
|
||||
if not (5 <= sf <= 12):
|
||||
return "Error: invalid spreading factor"
|
||||
if not (5 <= cr <= 8):
|
||||
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":
|
||||
# TODO: Enable logging
|
||||
return "Error: Not yet implemented"
|
||||
elif command == "log stop":
|
||||
# TODO: Disable logging
|
||||
return "Error: Not yet implemented"
|
||||
elif command == "log erase":
|
||||
# TODO: Clear log file
|
||||
return "Error: Not yet implemented"
|
||||
elif command == "log":
|
||||
return "Error: Use journalctl to view logs"
|
||||
else:
|
||||
return "Unknown log command"
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Repeater CLI Handler
|
||||
Handles administrative commands sent to the repeater via TXT_MSG packets.
|
||||
Mesh CLI Handler
|
||||
Handles administrative commands sent to repeaters and room servers via TXT_MSG packets.
|
||||
Only users with admin permissions (via ACL) can execute these commands.
|
||||
"""
|
||||
|
||||
@@ -13,14 +13,21 @@ import time
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RepeaterCLI:
|
||||
class MeshCLI:
|
||||
"""
|
||||
CLI command handler for repeater administration.
|
||||
CLI command handler for mesh node administration (repeaters and room servers).
|
||||
Commands follow the format: XX|command params
|
||||
where XX is an optional sequence number that gets echoed in the reply.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: str, config: Dict[str, Any], save_config_callback: Callable):
|
||||
def __init__(
|
||||
self,
|
||||
config_path: str,
|
||||
config: Dict[str, Any],
|
||||
save_config_callback: Callable,
|
||||
identity_type: str = "repeater",
|
||||
enable_regions: bool = True
|
||||
):
|
||||
"""
|
||||
Initialize the CLI handler.
|
||||
|
||||
@@ -28,10 +35,14 @@ class RepeaterCLI:
|
||||
config_path: Path to the config.yaml file
|
||||
config: Current configuration dictionary
|
||||
save_config_callback: Callback to save config changes
|
||||
identity_type: Type of identity ('repeater' or 'room_server')
|
||||
enable_regions: Whether to enable region commands (only for repeaters)
|
||||
"""
|
||||
self.config_path = Path(config_path)
|
||||
self.config = config
|
||||
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', {})
|
||||
@@ -108,9 +119,12 @@ class RepeaterCLI:
|
||||
elif command == "get acl":
|
||||
return "Error: Use 'get acl' via serial console only"
|
||||
|
||||
# Region commands
|
||||
# Region commands (repeaters only)
|
||||
elif command.startswith("region"):
|
||||
return self._cmd_region(command)
|
||||
if self.enable_regions:
|
||||
return self._cmd_region(command)
|
||||
else:
|
||||
return "Error: Region commands not available for room servers"
|
||||
|
||||
# Neighbor commands
|
||||
elif command == "neighbors":
|
||||
@@ -199,8 +213,9 @@ class RepeaterCLI:
|
||||
|
||||
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')
|
||||
return f"pyMC_Repeater v{version}"
|
||||
return f"pyMC_{role} v{version}"
|
||||
|
||||
# ==================== Get Commands ====================
|
||||
|
||||
@@ -254,7 +269,8 @@ class RepeaterCLI:
|
||||
return "Error: Not yet implemented"
|
||||
|
||||
elif param == "role":
|
||||
return "> repeater"
|
||||
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', '')
|
||||
@@ -569,3 +585,7 @@ class RepeaterCLI:
|
||||
return "Error: Use journalctl to view logs"
|
||||
else:
|
||||
return "Unknown log command"
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
RepeaterCLI = MeshCLI
|
||||
|
||||
@@ -79,7 +79,11 @@ class RoomServer:
|
||||
sqlite_handler,
|
||||
packet_injector,
|
||||
acl,
|
||||
max_posts: int = 32
|
||||
max_posts: int = 32,
|
||||
config_path: str = None,
|
||||
config: dict = None,
|
||||
save_config_callback = None,
|
||||
send_advert_callback = None
|
||||
):
|
||||
|
||||
self.room_hash = room_hash
|
||||
@@ -89,6 +93,67 @@ class RoomServer:
|
||||
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")
|
||||
return False
|
||||
|
||||
try:
|
||||
from pymc_core.protocol import PacketBuilder
|
||||
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_settings = {}
|
||||
for rs in room_config:
|
||||
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)
|
||||
|
||||
flags = ADVERT_FLAG_IS_ROOM_SERVER | ADVERT_FLAG_HAS_NAME
|
||||
|
||||
packet = PacketBuilder.create_advert(
|
||||
local_identity=local_identity,
|
||||
name=node_name,
|
||||
lat=latitude,
|
||||
lon=longitude,
|
||||
feature1=0,
|
||||
feature2=0,
|
||||
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})")
|
||||
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 save_config_callback:
|
||||
from .mesh_cli import MeshCLI
|
||||
self.cli = MeshCLI(
|
||||
config_path,
|
||||
config,
|
||||
save_config_callback,
|
||||
identity_type="room_server",
|
||||
enable_regions=False, # Room servers don't support region commands
|
||||
send_advert_callback=send_room_advert
|
||||
)
|
||||
logger.info(f"Room '{room_name}': Initialized CLI handler")
|
||||
|
||||
# Enforce hard limit (match C++ MAX_UNSYNCED_POSTS)
|
||||
if max_posts > MAX_UNSYNCED_POSTS:
|
||||
logger.warning(
|
||||
|
||||
@@ -11,7 +11,7 @@ import logging
|
||||
import struct
|
||||
|
||||
from pymc_core.node.handlers.text import TextMessageHandler
|
||||
from .repeater_cli import RepeaterCLI
|
||||
from .mesh_cli import MeshCLI
|
||||
from .room_server import RoomServer
|
||||
|
||||
logger = logging.getLogger("TextHelper")
|
||||
@@ -21,13 +21,14 @@ class TextHelper:
|
||||
|
||||
def __init__(self, identity_manager, packet_injector=None, acl_dict=None, log_fn=None,
|
||||
config_path: str = None, config: dict = None, save_config_callback=None,
|
||||
sqlite_handler=None):
|
||||
sqlite_handler=None, send_advert_callback=None):
|
||||
|
||||
self.identity_manager = identity_manager
|
||||
self.packet_injector = packet_injector
|
||||
self.log_fn = log_fn or logger.info
|
||||
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 = {}
|
||||
@@ -38,10 +39,22 @@ class TextHelper:
|
||||
# Track repeater identity for CLI commands
|
||||
self.repeater_hash = None
|
||||
|
||||
# Store config for later use
|
||||
self.config_path = config_path
|
||||
self.config = config
|
||||
self.save_config_callback = save_config_callback
|
||||
|
||||
# Initialize CLI handler if config provided
|
||||
self.cli = None
|
||||
if config_path and config and save_config_callback:
|
||||
self.cli = RepeaterCLI(config_path, config, save_config_callback)
|
||||
self.cli = MeshCLI(
|
||||
config_path,
|
||||
config,
|
||||
save_config_callback,
|
||||
identity_type="repeater",
|
||||
enable_regions=True,
|
||||
send_advert_callback=send_advert_callback
|
||||
)
|
||||
logger.info("Initialized CLI handler for repeater commands")
|
||||
|
||||
def register_identity(
|
||||
@@ -109,7 +122,10 @@ class TextHelper:
|
||||
sqlite_handler=self.sqlite_handler,
|
||||
packet_injector=self.packet_injector,
|
||||
acl=identity_acl,
|
||||
max_posts=max_posts
|
||||
max_posts=max_posts,
|
||||
config_path=self.config_path,
|
||||
config=self.config,
|
||||
save_config_callback=self.save_config_callback
|
||||
)
|
||||
|
||||
self.room_servers[hash_byte] = room_server
|
||||
@@ -252,14 +268,52 @@ class TextHelper:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store room message: {e}", exc_info=True)
|
||||
|
||||
# Room messages don't need further processing
|
||||
# Check if this is a CLI command to the room server
|
||||
if self._is_cli_command(message_text):
|
||||
room_server = self.room_servers.get(dest_hash)
|
||||
if room_server and room_server.cli:
|
||||
try:
|
||||
# Check admin permission
|
||||
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)")
|
||||
return
|
||||
|
||||
# Get sender's full pubkey
|
||||
identity_acl = self.acl_dict.get(dest_hash)
|
||||
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
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# Room messages (non-CLI) don't need further processing
|
||||
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(src_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:
|
||||
@@ -347,14 +401,18 @@ class TextHelper:
|
||||
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 (bit 0x02)."""
|
||||
# Get the repeater's ACL
|
||||
repeater_acl = self.acl_dict.get(self.repeater_hash)
|
||||
if not repeater_acl:
|
||||
"""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 = repeater_acl.get_all_clients()
|
||||
clients = identity_acl.get_all_clients()
|
||||
for client_info in clients:
|
||||
pubkey = client_info.id.get_public_key()
|
||||
if pubkey[0] == src_hash:
|
||||
|
||||
@@ -196,6 +196,7 @@ class RepeaterDaemon:
|
||||
config=self.config, # For CLI to read/modify settings
|
||||
save_config_callback=lambda: self._save_config(getattr(self, 'config_path', '/tmp/config.yaml')), # For CLI to persist changes
|
||||
sqlite_handler=self.repeater_handler.storage.sqlite_handler if self.repeater_handler and self.repeater_handler.storage else None, # For room server database
|
||||
send_advert_callback=self.send_advert, # For CLI advert command
|
||||
)
|
||||
|
||||
# Register default repeater identity for text messages
|
||||
|
||||
Reference in New Issue
Block a user