added CLI

This commit is contained in:
Lloyd
2025-12-29 14:37:54 +00:00
parent c91b06c7b0
commit 98b425f444
15 changed files with 4687 additions and 4127 deletions
+25
View File
@@ -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..."
+221
View File
@@ -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
View File
@@ -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()
+53 -26
View File
@@ -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:
+9 -2
View File
@@ -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."""
+3 -3
View File
@@ -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,
+5 -5
View File
@@ -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
View File
@@ -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
+46
View File
@@ -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)}"
+93 -73
View File
@@ -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
+2 -2
View File
@@ -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>