mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-10 10:52:53 +02:00
added CLI
This commit is contained in:
@@ -244,6 +244,19 @@ install_repeater() {
|
||||
mkdir -p /var/lib/pymc_repeater/.config/pymc_repeater
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" /var/lib/pymc_repeater/.config
|
||||
|
||||
# Configure polkit for passwordless service restart
|
||||
mkdir -p /etc/polkit-1/rules.d
|
||||
cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF'
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
action.lookup("unit") == "pymc-repeater.service" &&
|
||||
subject.user == "repeater") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
EOF
|
||||
chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules
|
||||
|
||||
echo "75"; echo "# Starting service..."
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
|
||||
@@ -376,6 +389,18 @@ upgrade_repeater() {
|
||||
# Pre-create the .config directory that the service will need
|
||||
mkdir -p /var/lib/pymc_repeater/.config/pymc_repeater 2>/dev/null || true
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" /var/lib/pymc_repeater/.config 2>/dev/null || true
|
||||
# Configure polkit for passwordless service restart
|
||||
mkdir -p /etc/polkit-1/rules.d
|
||||
cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF'
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
action.lookup("unit") == "pymc-repeater.service" &&
|
||||
subject.user == "repeater") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
EOF
|
||||
chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules
|
||||
echo " ✓ Permissions updated"
|
||||
|
||||
echo "[7/9] Reloading systemd..."
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import logging
|
||||
import os
|
||||
import yaml
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
logger = logging.getLogger("ConfigManager")
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""Manages configuration persistence and live updates to the daemon."""
|
||||
|
||||
def __init__(self, config_path: str, config: dict, daemon_instance=None):
|
||||
"""
|
||||
Initialize ConfigManager.
|
||||
|
||||
Args:
|
||||
config_path: Path to the YAML config file
|
||||
config: Reference to the config dictionary
|
||||
daemon_instance: Optional reference to the daemon for live updates
|
||||
"""
|
||||
self.config_path = config_path
|
||||
self.config = config
|
||||
self.daemon = daemon_instance
|
||||
|
||||
def save_to_file(self) -> bool:
|
||||
"""
|
||||
Save current config to YAML file.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
||||
with open(self.config_path, 'w') as f:
|
||||
# Use safe_dump with explicit width to prevent line wrapping
|
||||
# Setting width to a very large number prevents truncation of long strings like identity keys
|
||||
yaml.safe_dump(
|
||||
self.config,
|
||||
f,
|
||||
default_flow_style=False,
|
||||
indent=2,
|
||||
width=1000000, # Very large width to prevent any line wrapping
|
||||
sort_keys=False,
|
||||
allow_unicode=True
|
||||
)
|
||||
logger.info(f"Configuration saved to {self.config_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save config to {self.config_path}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def live_update_daemon(self, sections: Optional[List[str]] = None) -> bool:
|
||||
"""
|
||||
Apply configuration changes to the running daemon's in-memory config.
|
||||
|
||||
Args:
|
||||
sections: List of config sections to update (e.g., ['repeater', 'delays']).
|
||||
If None, updates all common sections.
|
||||
|
||||
Returns:
|
||||
True if live update was successful, False otherwise
|
||||
"""
|
||||
if not self.daemon or not hasattr(self.daemon, 'config'):
|
||||
logger.warning("Daemon not available for live update")
|
||||
return False
|
||||
|
||||
try:
|
||||
daemon_config = self.daemon.config
|
||||
|
||||
# Default sections to update if not specified
|
||||
if sections is None:
|
||||
sections = ['repeater', 'delays', 'radio', 'acl', 'identities']
|
||||
|
||||
# Update each section
|
||||
for section in sections:
|
||||
if section in self.config:
|
||||
if section not in daemon_config:
|
||||
daemon_config[section] = {}
|
||||
|
||||
# Deep copy the section to avoid reference issues
|
||||
if isinstance(self.config[section], dict):
|
||||
daemon_config[section].update(self.config[section])
|
||||
else:
|
||||
daemon_config[section] = self.config[section]
|
||||
|
||||
logger.debug(f"Live updated daemon config section: {section}")
|
||||
|
||||
logger.info(f"Live updated daemon config sections: {', '.join(sections)}")
|
||||
|
||||
# Also reload runtime config in RepeaterHandler if delays or repeater sections changed
|
||||
if self.daemon and hasattr(self.daemon, 'repeater_handler'):
|
||||
if any(s in ['delays', 'repeater'] for s in sections):
|
||||
if hasattr(self.daemon.repeater_handler, 'reload_runtime_config'):
|
||||
self.daemon.repeater_handler.reload_runtime_config()
|
||||
logger.info("Reloaded RepeaterHandler runtime config")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to live update daemon config: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def update_and_save(self,
|
||||
updates: Dict[str, Any],
|
||||
live_update: bool = True,
|
||||
live_update_sections: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Apply updates to config, save to file, and optionally live update daemon.
|
||||
|
||||
This is the main method that should be used by both mesh_cli and api_endpoints.
|
||||
|
||||
Args:
|
||||
updates: Dictionary of config updates in nested format.
|
||||
Example: {"repeater": {"node_name": "NewName"}, "delays": {"tx_delay_factor": 1.5}}
|
||||
live_update: Whether to apply changes to running daemon immediately
|
||||
live_update_sections: Specific sections to live update. If None, auto-detects from updates.
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
- success: bool - Whether operation succeeded
|
||||
- saved: bool - Whether config was saved to file
|
||||
- live_updated: bool - Whether daemon was live updated
|
||||
- error: str (optional) - Error message if failed
|
||||
"""
|
||||
result = {
|
||||
"success": False,
|
||||
"saved": False,
|
||||
"live_updated": False
|
||||
}
|
||||
|
||||
try:
|
||||
# Apply updates to config
|
||||
for section, values in updates.items():
|
||||
if section not in self.config:
|
||||
self.config[section] = {}
|
||||
|
||||
if isinstance(values, dict):
|
||||
self.config[section].update(values)
|
||||
else:
|
||||
self.config[section] = values
|
||||
|
||||
# Save to file
|
||||
result["saved"] = self.save_to_file()
|
||||
|
||||
if not result["saved"]:
|
||||
result["error"] = "Failed to save config to file"
|
||||
return result
|
||||
|
||||
# Live update daemon if requested
|
||||
if live_update:
|
||||
# Auto-detect sections if not specified
|
||||
if live_update_sections is None:
|
||||
live_update_sections = list(updates.keys())
|
||||
|
||||
result["live_updated"] = self.live_update_daemon(live_update_sections)
|
||||
|
||||
result["success"] = result["saved"]
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in update_and_save: {e}", exc_info=True)
|
||||
result["error"] = str(e)
|
||||
return result
|
||||
|
||||
def update_nested(self, path: str, value: Any, live_update: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
Update a nested config value using dot notation.
|
||||
|
||||
Convenience method for simple updates like "repeater.node_name" = "NewName"
|
||||
|
||||
Args:
|
||||
path: Dot-separated path to config value (e.g., "repeater.node_name")
|
||||
value: Value to set
|
||||
live_update: Whether to apply changes to running daemon
|
||||
|
||||
Returns:
|
||||
Result dict from update_and_save
|
||||
"""
|
||||
parts = path.split('.')
|
||||
|
||||
if len(parts) == 1:
|
||||
# Top-level key
|
||||
updates = {parts[0]: value}
|
||||
elif len(parts) == 2:
|
||||
# Nested one level (most common case)
|
||||
updates = {parts[0]: {parts[1]: value}}
|
||||
else:
|
||||
# Build nested dict for deeper paths
|
||||
updates = {}
|
||||
current = updates
|
||||
for i, part in enumerate(parts[:-1]):
|
||||
if i == 0:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
else:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
current[parts[-1]] = value
|
||||
|
||||
# Determine which section to live update
|
||||
section = parts[0]
|
||||
|
||||
return self.update_and_save(
|
||||
updates=updates,
|
||||
live_update=live_update,
|
||||
live_update_sections=[section] if live_update else None
|
||||
)
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get status information about the ConfigManager.
|
||||
|
||||
Returns:
|
||||
Dict with config file path, existence, daemon availability
|
||||
"""
|
||||
return {
|
||||
"config_path": self.config_path,
|
||||
"config_exists": os.path.exists(self.config_path),
|
||||
"daemon_available": self.daemon is not None and hasattr(self.daemon, 'config'),
|
||||
"config_sections": list(self.config.keys()) if self.config else []
|
||||
}
|
||||
+25
-12
@@ -710,23 +710,15 @@ class RepeaterHandler(BaseHandler):
|
||||
"node_name": repeater_config.get("node_name", "Unknown"),
|
||||
"repeater": {
|
||||
"mode": repeater_config.get("mode", "forward"),
|
||||
"use_score_for_tx": self.use_score_for_tx,
|
||||
"score_threshold": self.score_threshold,
|
||||
"send_advert_interval_hours": self.send_advert_interval_hours,
|
||||
"use_score_for_tx": repeater_config.get("use_score_for_tx", False),
|
||||
"score_threshold": repeater_config.get("score_threshold", 0.3),
|
||||
"send_advert_interval_hours": repeater_config.get("send_advert_interval_hours", 10),
|
||||
"latitude": repeater_config.get("latitude", 0.0),
|
||||
"longitude": repeater_config.get("longitude", 0.0),
|
||||
# PYMC_CONSOLE_STATS_PATCH - MeshCore CLI parity
|
||||
"max_flood_hops": repeater_config.get("max_flood_hops", 3),
|
||||
"advert_interval_minutes": repeater_config.get("advert_interval_minutes", 120),
|
||||
},
|
||||
"radio": {
|
||||
"frequency": self.radio_config.get("frequency", 0),
|
||||
"tx_power": self.radio_config.get("tx_power", 0),
|
||||
"bandwidth": self.radio_config.get("bandwidth", 0),
|
||||
"spreading_factor": self.radio_config.get("spreading_factor", 0),
|
||||
"coding_rate": self.radio_config.get("coding_rate", 0),
|
||||
"preamble_length": self.radio_config.get("preamble_length", 0),
|
||||
},
|
||||
"radio": self.config.get("radio", {}), # Read from live config, not cached radio_config
|
||||
"duty_cycle": {
|
||||
"max_airtime_percent": max_duty_cycle_percent,
|
||||
"enforcement_enabled": duty_cycle_config.get("enforcement_enabled", True),
|
||||
@@ -807,6 +799,27 @@ class RepeaterHandler(BaseHandler):
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending periodic advert: {e}")
|
||||
|
||||
def reload_runtime_config(self):
|
||||
"""Reload runtime configuration from self.config (called after live config updates)."""
|
||||
try:
|
||||
# Refresh delay factors
|
||||
self.tx_delay_factor = self.config.get("delays", {}).get("tx_delay_factor", 1.0)
|
||||
self.direct_tx_delay_factor = self.config.get("delays", {}).get("direct_tx_delay_factor", 0.5)
|
||||
|
||||
# Refresh repeater settings
|
||||
repeater_config = self.config.get("repeater", {})
|
||||
self.use_score_for_tx = repeater_config.get("use_score_for_tx", False)
|
||||
self.score_threshold = repeater_config.get("score_threshold", 0.3)
|
||||
self.send_advert_interval_hours = repeater_config.get("send_advert_interval_hours", 10)
|
||||
self.cache_ttl = repeater_config.get("cache_ttl", 60)
|
||||
|
||||
# Note: Radio config changes require restart as they affect hardware
|
||||
# Note: Airtime manager has its own config reference that gets updated
|
||||
|
||||
logger.info("Runtime configuration reloaded successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error reloading runtime config: {e}")
|
||||
|
||||
def cleanup(self):
|
||||
if self._background_task and not self._background_task.done():
|
||||
self._background_task.cancel()
|
||||
|
||||
@@ -13,7 +13,7 @@ class MeshCLI:
|
||||
self,
|
||||
config_path: str,
|
||||
config: Dict[str, Any],
|
||||
save_config_callback: Callable,
|
||||
config_manager, # ConfigManager instance for save & live updates
|
||||
identity_type: str = "repeater",
|
||||
enable_regions: bool = True,
|
||||
send_advert_callback: Optional[Callable] = None,
|
||||
@@ -23,7 +23,7 @@ class MeshCLI:
|
||||
|
||||
self.config_path = Path(config_path)
|
||||
self.config = config
|
||||
self.save_config = save_config_callback
|
||||
self.config_manager = config_manager
|
||||
self.identity_type = identity_type
|
||||
self.enable_regions = enable_regions
|
||||
self.send_advert_callback = send_advert_callback
|
||||
@@ -134,8 +134,15 @@ class MeshCLI:
|
||||
|
||||
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"
|
||||
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."""
|
||||
@@ -188,9 +195,10 @@ class MeshCLI:
|
||||
|
||||
self.config['security']['password'] = new_password
|
||||
|
||||
# Save config
|
||||
# Save config and live update
|
||||
try:
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
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}")
|
||||
@@ -329,28 +337,33 @@ class MeshCLI:
|
||||
try:
|
||||
if key == "af":
|
||||
self.repeater_config['airtime_factor'] = float(value)
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
return "OK"
|
||||
|
||||
elif key == "name":
|
||||
self.repeater_config['name'] = value
|
||||
self.save_config()
|
||||
self.repeater_config['node_name'] = value
|
||||
self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
return "OK"
|
||||
|
||||
elif key == "repeat":
|
||||
disabled = value.lower() == "off"
|
||||
self.repeater_config['disable_forward'] = disabled
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
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.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
return "OK"
|
||||
|
||||
elif key == "lon":
|
||||
self.repeater_config['longitude'] = float(value)
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
return "OK"
|
||||
|
||||
elif key == "radio":
|
||||
@@ -366,35 +379,40 @@ class MeshCLI:
|
||||
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()
|
||||
self.config_manager.save_to_file()
|
||||
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)
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
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)
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
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
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
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"
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['security'])
|
||||
return "OK"
|
||||
|
||||
elif key == "advert.interval":
|
||||
@@ -402,7 +420,8 @@ class MeshCLI:
|
||||
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()
|
||||
self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
return "OK"
|
||||
|
||||
elif key == "flood.advert.interval":
|
||||
@@ -410,7 +429,8 @@ class MeshCLI:
|
||||
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()
|
||||
self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
return "OK"
|
||||
|
||||
elif key == "flood.max":
|
||||
@@ -418,7 +438,8 @@ class MeshCLI:
|
||||
if max_val > 64:
|
||||
return "Error: max 64"
|
||||
self.repeater_config['max_flood_hops'] = max_val
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
return "OK"
|
||||
|
||||
elif key == "rxdelay":
|
||||
@@ -426,7 +447,8 @@ class MeshCLI:
|
||||
if delay < 0:
|
||||
return "Error: cannot be negative"
|
||||
self.repeater_config['rx_delay_base'] = delay
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater', 'delays'])
|
||||
return "OK"
|
||||
|
||||
elif key == "txdelay":
|
||||
@@ -434,7 +456,8 @@ class MeshCLI:
|
||||
if delay < 0:
|
||||
return "Error: cannot be negative"
|
||||
self.repeater_config['tx_delay_factor'] = delay
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater', 'delays'])
|
||||
return "OK"
|
||||
|
||||
elif key == "direct.txdelay":
|
||||
@@ -442,17 +465,20 @@ class MeshCLI:
|
||||
if delay < 0:
|
||||
return "Error: cannot be negative"
|
||||
self.repeater_config['direct_tx_delay_factor'] = delay
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater', 'delays'])
|
||||
return "OK"
|
||||
|
||||
elif key == "multi.acks":
|
||||
self.repeater_config['multi_acks'] = int(value)
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
return "OK"
|
||||
|
||||
elif key == "int.thresh":
|
||||
self.repeater_config['interference_threshold'] = int(value)
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
return "OK"
|
||||
|
||||
elif key == "agc.reset.interval":
|
||||
@@ -460,7 +486,8 @@ class MeshCLI:
|
||||
# Round to nearest multiple of 4
|
||||
rounded = (interval // 4) * 4
|
||||
self.repeater_config['agc_reset_interval'] = rounded
|
||||
self.save_config()
|
||||
self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(['repeater'])
|
||||
return f"OK - interval rounded to {rounded}"
|
||||
|
||||
else:
|
||||
|
||||
@@ -159,8 +159,15 @@ class MeshCLI:
|
||||
|
||||
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"
|
||||
from repeater.service_utils import restart_service
|
||||
|
||||
logger.warning("Reboot command received via repeater CLI")
|
||||
success, message = restart_service()
|
||||
|
||||
if success:
|
||||
return f"OK - {message}"
|
||||
else:
|
||||
return f"Error: {message}"
|
||||
|
||||
def _cmd_advert(self) -> str:
|
||||
"""Send self advertisement."""
|
||||
|
||||
@@ -82,7 +82,7 @@ class RoomServer:
|
||||
max_posts: int = 32,
|
||||
config_path: str = None,
|
||||
config: dict = None,
|
||||
save_config_callback = None,
|
||||
config_manager = None,
|
||||
send_advert_callback = None
|
||||
):
|
||||
|
||||
@@ -142,12 +142,12 @@ class RoomServer:
|
||||
|
||||
# Initialize CLI handler for room server commands
|
||||
self.cli = None
|
||||
if config_path and config and save_config_callback:
|
||||
if config_path and config and config_manager:
|
||||
from .mesh_cli import MeshCLI
|
||||
self.cli = MeshCLI(
|
||||
config_path,
|
||||
config,
|
||||
save_config_callback,
|
||||
config_manager,
|
||||
identity_type="room_server",
|
||||
enable_regions=False, # Room servers don't support region commands
|
||||
send_advert_callback=send_room_advert,
|
||||
|
||||
@@ -25,7 +25,7 @@ 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, save_config_callback=None,
|
||||
config_path: str = None, config: dict = None, config_manager=None,
|
||||
sqlite_handler=None, send_advert_callback=None):
|
||||
|
||||
self.identity_manager = identity_manager
|
||||
@@ -47,7 +47,7 @@ class TextHelper:
|
||||
# Store config for later use
|
||||
self.config_path = config_path
|
||||
self.config = config
|
||||
self.save_config_callback = save_config_callback
|
||||
self.config_manager = config_manager
|
||||
|
||||
# Store for later CLI initialization (needs identity and storage)
|
||||
self.config_path = config_path
|
||||
@@ -99,11 +99,11 @@ class TextHelper:
|
||||
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.save_config_callback:
|
||||
if self.config_path and self.config and self.config_manager:
|
||||
self.cli = MeshCLI(
|
||||
self.config_path,
|
||||
self.config,
|
||||
self.save_config_callback,
|
||||
self.config_manager,
|
||||
identity_type="repeater",
|
||||
enable_regions=True,
|
||||
send_advert_callback=self.send_advert_callback,
|
||||
@@ -138,7 +138,7 @@ class TextHelper:
|
||||
max_posts=max_posts,
|
||||
config_path=self.config_path,
|
||||
config=self.config,
|
||||
save_config_callback=self.save_config_callback
|
||||
config_manager=self.config_manager
|
||||
)
|
||||
|
||||
self.room_servers[hash_byte] = room_server
|
||||
|
||||
+11
-12
@@ -4,6 +4,7 @@ import os
|
||||
import sys
|
||||
|
||||
from repeater.config import get_radio_for_board, load_config
|
||||
from repeater.config_manager import ConfigManager
|
||||
from repeater.engine import RepeaterHandler
|
||||
from repeater.web.http_server import HTTPStatsServer, _log_buffer
|
||||
from repeater.handler_helpers import TraceHelper, DiscoveryHelper, AdvertHelper, LoginHelper, TextHelper, PathHelper, ProtocolRequestHelper
|
||||
@@ -24,6 +25,7 @@ class RepeaterDaemon:
|
||||
self.local_hash = None
|
||||
self.local_identity = None
|
||||
self.identity_manager = None
|
||||
self.config_manager = None
|
||||
self.http_server = None
|
||||
self.trace_helper = None
|
||||
self.advert_helper = None
|
||||
@@ -186,6 +188,14 @@ class RepeaterDaemon:
|
||||
|
||||
logger.info("Login processing helper initialized")
|
||||
|
||||
# Initialize ConfigManager for centralized config management
|
||||
self.config_manager = ConfigManager(
|
||||
config_path=getattr(self, 'config_path', '/etc/pymc_repeater/config.yaml'),
|
||||
config=self.config,
|
||||
daemon_instance=self
|
||||
)
|
||||
logger.info("Config manager initialized")
|
||||
|
||||
# Initialize text message helper with per-identity ACLs
|
||||
self.text_helper = TextHelper(
|
||||
identity_manager=self.identity_manager,
|
||||
@@ -194,7 +204,7 @@ class RepeaterDaemon:
|
||||
log_fn=logger.info,
|
||||
config_path=getattr(self, 'config_path', None), # For CLI to save changes
|
||||
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
|
||||
config_manager=self.config_manager, # New centralized config manager
|
||||
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
|
||||
)
|
||||
@@ -245,17 +255,6 @@ class RepeaterDaemon:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize dispatcher: {e}")
|
||||
raise
|
||||
|
||||
def _save_config(self, config_path: str):
|
||||
"""Save configuration to file (called by CLI when settings change)."""
|
||||
import yaml
|
||||
try:
|
||||
with open(config_path, 'w') as f:
|
||||
yaml.dump(self.config, f, default_flow_style=False)
|
||||
logger.info(f"Configuration saved to {config_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save config: {e}")
|
||||
raise
|
||||
|
||||
async def _load_additional_identities(self):
|
||||
from pymc_core import LocalIdentity
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Service management utilities for pyMC Repeater.
|
||||
Provides functions for service control operations like restart.
|
||||
"""
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Tuple
|
||||
|
||||
logger = logging.getLogger("ServiceUtils")
|
||||
|
||||
|
||||
def restart_service() -> Tuple[bool, str]:
|
||||
"""
|
||||
Restart the pymc-repeater service via systemctl.
|
||||
|
||||
Uses polkit for authentication (requires proper polkit rules configured).
|
||||
NoNewPrivileges systemd flag prevents sudo from working.
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: (success, message)
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['systemctl', 'restart', 'pymc-repeater'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info("Service restart command executed successfully")
|
||||
return True, "Service restart initiated"
|
||||
else:
|
||||
error_msg = result.stderr or "Unknown error"
|
||||
logger.error(f"Service restart failed: {error_msg}")
|
||||
return False, f"Restart failed: {error_msg}"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Service restart command timed out (service may be restarting)")
|
||||
return True, "Service restart initiated (timeout - likely restarting)"
|
||||
except FileNotFoundError:
|
||||
logger.error("systemctl not found")
|
||||
return False, "systemctl not available"
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing restart command: {e}")
|
||||
return False, f"Restart command failed: {str(e)}"
|
||||
@@ -38,6 +38,7 @@ logger = logging.getLogger("HTTPServer")
|
||||
# POST /api/send_advert
|
||||
# POST /api/set_mode {"mode": "forward|monitor"}
|
||||
# POST /api/set_duty_cycle {"enabled": true|false}
|
||||
# POST /api/restart_service
|
||||
|
||||
# CAD Calibration
|
||||
# POST /api/cad_calibration_start {"samples": 8, "delay": 100}
|
||||
@@ -88,6 +89,14 @@ class APIEndpoints:
|
||||
self.daemon_instance = daemon_instance
|
||||
self._config_path = config_path or '/etc/pymc_repeater/config.yaml'
|
||||
self.cad_calibration = CADCalibrationEngine(daemon_instance, event_loop)
|
||||
|
||||
# Initialize ConfigManager for centralized config management
|
||||
from repeater.config_manager import ConfigManager
|
||||
self.config_manager = ConfigManager(
|
||||
config_path=self._config_path,
|
||||
config=self.config,
|
||||
daemon_instance=daemon_instance
|
||||
)
|
||||
|
||||
def _is_cors_enabled(self):
|
||||
return self.config.get("web", {}).get("cors_enabled", False)
|
||||
@@ -262,6 +271,35 @@ class APIEndpoints:
|
||||
logger.error(f"Error setting duty cycle: {e}", exc_info=True)
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@cherrypy.tools.json_in()
|
||||
def restart_service(self):
|
||||
"""Restart the pymc-repeater service via systemctl."""
|
||||
# Enable CORS for this endpoint only if configured
|
||||
self._set_cors_headers()
|
||||
|
||||
if cherrypy.request.method == "OPTIONS":
|
||||
return ""
|
||||
|
||||
try:
|
||||
self._require_post()
|
||||
from repeater.service_utils import restart_service as do_restart
|
||||
|
||||
logger.warning("Service restart requested via API")
|
||||
success, message = do_restart()
|
||||
|
||||
if success:
|
||||
return {"success": True, "message": message}
|
||||
else:
|
||||
return self._error(message)
|
||||
|
||||
except cherrypy.HTTPError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error in restart_service endpoint: {e}", exc_info=True)
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def logs(self):
|
||||
@@ -599,7 +637,7 @@ class APIEndpoints:
|
||||
self.config["radio"]["cad"]["min_threshold"] = min_val
|
||||
|
||||
config_path = getattr(self, '_config_path', '/etc/pymc_repeater/config.yaml')
|
||||
self._save_config_to_file(config_path)
|
||||
self.config_manager.save_to_file()
|
||||
|
||||
logger.info(f"Saved CAD settings to config: peak={peak}, min={min_val}, rate={detection_rate:.1f}%")
|
||||
return {
|
||||
@@ -623,6 +661,10 @@ class APIEndpoints:
|
||||
POST /api/update_radio_config
|
||||
Body: {
|
||||
"tx_power": 22, # TX power in dBm (2-30)
|
||||
"frequency": 869618000, # Frequency in Hz (100-1000 MHz)
|
||||
"bandwidth": 62500, # Bandwidth in Hz (valid: 7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500 kHz)
|
||||
"spreading_factor": 8, # Spreading factor (5-12)
|
||||
"coding_rate": 8, # Coding rate (5-8 for 4/5 to 4/8)
|
||||
"tx_delay_factor": 1.0, # TX delay factor (0.0-5.0)
|
||||
"direct_tx_delay_factor": 0.5, # Direct TX delay (0.0-5.0)
|
||||
"rx_delay_base": 0.0, # RX delay base (>= 0)
|
||||
@@ -634,6 +676,8 @@ class APIEndpoints:
|
||||
"advert_interval_minutes": 120 # Local advert interval (0 or 1-10080)
|
||||
}
|
||||
|
||||
Note: Radio hardware changes (frequency, bandwidth, SF, CR) require restart to apply.
|
||||
|
||||
Returns: {"success": true, "data": {"applied": [...], "live_update": true}}
|
||||
"""
|
||||
# Enable CORS for this endpoint only if configured
|
||||
@@ -664,6 +708,39 @@ class APIEndpoints:
|
||||
self.config["radio"]["tx_power"] = power
|
||||
applied.append(f"power={power}dBm")
|
||||
|
||||
# Update frequency (in Hz)
|
||||
if "frequency" in data:
|
||||
freq = float(data["frequency"])
|
||||
if freq < 100_000_000 or freq > 1_000_000_000:
|
||||
return self._error("Frequency must be 100-1000 MHz")
|
||||
self.config["radio"]["frequency"] = freq
|
||||
applied.append(f"freq={freq/1_000_000:.3f}MHz")
|
||||
|
||||
# Update bandwidth (in Hz)
|
||||
if "bandwidth" in data:
|
||||
bw = int(float(data["bandwidth"]))
|
||||
valid_bw = [7800, 10400, 15600, 20800, 31250, 41700, 62500, 125000, 250000, 500000]
|
||||
if bw not in valid_bw:
|
||||
return self._error(f"Bandwidth must be one of {[b/1000 for b in valid_bw]} kHz")
|
||||
self.config["radio"]["bandwidth"] = bw
|
||||
applied.append(f"bw={bw/1000}kHz")
|
||||
|
||||
# Update spreading factor
|
||||
if "spreading_factor" in data:
|
||||
sf = int(data["spreading_factor"])
|
||||
if sf < 5 or sf > 12:
|
||||
return self._error("Spreading factor must be 5-12")
|
||||
self.config["radio"]["spreading_factor"] = sf
|
||||
applied.append(f"sf={sf}")
|
||||
|
||||
# Update coding rate
|
||||
if "coding_rate" in data:
|
||||
cr = int(data["coding_rate"])
|
||||
if cr < 5 or cr > 8:
|
||||
return self._error("Coding rate must be 5-8 (for 4/5 to 4/8)")
|
||||
self.config["radio"]["coding_rate"] = cr
|
||||
applied.append(f"cr=4/{cr}")
|
||||
|
||||
# Update TX delay factor
|
||||
if "tx_delay_factor" in data:
|
||||
tdf = float(data["tx_delay_factor"])
|
||||
@@ -739,44 +816,21 @@ class APIEndpoints:
|
||||
if not applied:
|
||||
return self._error("No valid settings provided")
|
||||
|
||||
# Save to config file
|
||||
config_path = getattr(self, '_config_path', '/etc/pymc_repeater/config.yaml')
|
||||
self._save_config_to_file(config_path)
|
||||
|
||||
# Live update: Also update daemon's in-memory config for immediate effect
|
||||
live_updated = False
|
||||
if self.daemon_instance and hasattr(self.daemon_instance, 'config'):
|
||||
try:
|
||||
daemon_config = self.daemon_instance.config
|
||||
|
||||
# Update repeater section in daemon config
|
||||
if 'repeater' not in daemon_config:
|
||||
daemon_config['repeater'] = {}
|
||||
for key in ['node_name', 'latitude', 'longitude', 'max_flood_hops',
|
||||
'advert_interval_minutes', 'send_advert_interval_hours']:
|
||||
if key in self.config.get('repeater', {}):
|
||||
daemon_config['repeater'][key] = self.config['repeater'][key]
|
||||
|
||||
# Update delays section in daemon config
|
||||
if 'delays' not in daemon_config:
|
||||
daemon_config['delays'] = {}
|
||||
for key in ['tx_delay_factor', 'direct_tx_delay_factor', 'rx_delay_base']:
|
||||
if key in self.config.get('delays', {}):
|
||||
daemon_config['delays'][key] = self.config['delays'][key]
|
||||
|
||||
live_updated = True
|
||||
logger.info("Live updated daemon config")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not live update daemon config: {e}")
|
||||
# Save to config file and live update daemon in one operation
|
||||
result = self.config_manager.update_and_save(
|
||||
updates={}, # Updates already applied to self.config above
|
||||
live_update=True,
|
||||
live_update_sections=['repeater', 'delays', 'radio']
|
||||
)
|
||||
|
||||
logger.info(f"Radio config updated: {', '.join(applied)}")
|
||||
|
||||
return self._success({
|
||||
"applied": applied,
|
||||
"persisted": True,
|
||||
"live_update": live_updated,
|
||||
"restart_required": not live_updated,
|
||||
"message": "Settings applied immediately." if live_updated else "Settings saved. Restart service to apply changes."
|
||||
"persisted": result.get("saved", False),
|
||||
"live_update": result.get("live_updated", False),
|
||||
"restart_required": not result.get("live_updated", False),
|
||||
"message": "Settings applied immediately." if result.get("live_updated") else "Settings saved. Restart service to apply changes."
|
||||
})
|
||||
|
||||
except cherrypy.HTTPError:
|
||||
@@ -785,28 +839,6 @@ class APIEndpoints:
|
||||
logger.error(f"Error updating radio config: {e}")
|
||||
return self._error(str(e))
|
||||
|
||||
def _save_config_to_file(self, config_path):
|
||||
try:
|
||||
import yaml
|
||||
import os
|
||||
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
||||
with open(config_path, 'w') as f:
|
||||
# Use safe_dump with explicit width to prevent line wrapping
|
||||
# Setting width to a very large number prevents truncation of long strings like identity keys
|
||||
yaml.safe_dump(
|
||||
self.config,
|
||||
f,
|
||||
default_flow_style=False,
|
||||
indent=2,
|
||||
width=1000000, # Very large width to prevent any line wrapping
|
||||
sort_keys=False,
|
||||
allow_unicode=True
|
||||
)
|
||||
logger.info(f"Configuration saved to {config_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save config to {config_path}: {e}")
|
||||
raise
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def noise_floor_history(self, hours: int = 24):
|
||||
@@ -1106,9 +1138,9 @@ class APIEndpoints:
|
||||
|
||||
logger.info(f"Using config path for global flood policy: {config_path}")
|
||||
|
||||
# Update the configuration file using the same method as CAD
|
||||
# Update the configuration file using ConfigManager
|
||||
try:
|
||||
self._save_config_to_file(config_path)
|
||||
self.config_manager.save_to_file()
|
||||
logger.info(f"Updated running config and saved global flood policy to file: {'allow' if global_flood_allow else 'deny'}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save global flood policy to file: {e}")
|
||||
@@ -1376,11 +1408,7 @@ class APIEndpoints:
|
||||
self.config["identities"]["room_servers"] = room_servers
|
||||
|
||||
# Save to file
|
||||
config_path = getattr(self, '_config_path', '/etc/pymc_repeater/config.yaml')
|
||||
if self.daemon_instance and hasattr(self.daemon_instance, 'config_path'):
|
||||
config_path = self.daemon_instance.config_path
|
||||
|
||||
self._save_config_to_file(config_path)
|
||||
self.config_manager.save_to_file()
|
||||
|
||||
logger.info(f"Created new identity: {name} (type: {identity_type}){' with auto-generated key' if key_was_generated else ''}")
|
||||
|
||||
@@ -1527,11 +1555,7 @@ class APIEndpoints:
|
||||
room_servers[identity_index] = identity
|
||||
self.config["identities"]["room_servers"] = room_servers
|
||||
|
||||
config_path = getattr(self, '_config_path', '/etc/pymc_repeater/config.yaml')
|
||||
if self.daemon_instance and hasattr(self.daemon_instance, 'config_path'):
|
||||
config_path = self.daemon_instance.config_path
|
||||
|
||||
self._save_config_to_file(config_path)
|
||||
self.config_manager.save_to_file()
|
||||
|
||||
logger.info(f"Updated identity: {name}")
|
||||
|
||||
@@ -1628,11 +1652,7 @@ class APIEndpoints:
|
||||
# Update config
|
||||
self.config["identities"]["room_servers"] = room_servers
|
||||
|
||||
config_path = getattr(self, '_config_path', '/etc/pymc_repeater/config.yaml')
|
||||
if self.daemon_instance and hasattr(self.daemon_instance, 'config_path'):
|
||||
config_path = self.daemon_instance.config_path
|
||||
|
||||
self._save_config_to_file(config_path)
|
||||
self.config_manager.save_to_file()
|
||||
|
||||
logger.info(f"Deleted identity: {name}")
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -8,8 +8,8 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-Bn7AOH36.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-jXjQ2-1g.css">
|
||||
<script type="module" crossorigin src="/assets/index-ViklARR2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DiNHIYsR.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user