mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
505 lines
15 KiB
Python
505 lines
15 KiB
Python
"""
|
|
Admin operations for WROLPi Controller.
|
|
|
|
Migrated from wrolpi/admin.py - provides system control functions.
|
|
"""
|
|
|
|
import asyncio
|
|
import enum
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from controller.lib.config import is_docker_mode, get_config_value
|
|
|
|
# Constants - use controller's own config instead of wrolpi module
|
|
DEFAULT_CPU_FREQUENCY = get_config_value('throttle.default_governor', 'ondemand')
|
|
|
|
|
|
class HotspotStatus(enum.Enum):
|
|
"""Hotspot status enum matching wrolpi/admin.py"""
|
|
disconnected = enum.auto() # Radio is on, but Hotspot is not connected.
|
|
unavailable = enum.auto() # Radio is off.
|
|
connected = enum.auto() # Radio is on, Hotspot is on.
|
|
unknown = enum.auto() # Unknown status. Hotspot may not be supported.
|
|
in_use = enum.auto() # Wi-Fi device is in use for a network connection.
|
|
|
|
|
|
class GovernorStatus(enum.Enum):
|
|
"""CPU governor status enum matching wrolpi/admin.py"""
|
|
ondemand = enum.auto()
|
|
powersave = enum.auto()
|
|
unknown = enum.auto()
|
|
|
|
|
|
def get_current_ssid(interface: str = None) -> str | None:
|
|
"""
|
|
Returns the name of the SSID that is currently connected.
|
|
Returns None if Hotspot is active, or no Wi-Fi network is being used.
|
|
"""
|
|
if interface is None:
|
|
interface = get_config_value('hotspot.device', 'wlan0')
|
|
try:
|
|
result = subprocess.run(
|
|
['iwgetid', interface, '--raw'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
if result.returncode == 0:
|
|
ssid = result.stdout.strip()
|
|
return ssid if ssid else None
|
|
elif result.returncode == 255:
|
|
# Hotspot is in use
|
|
return None
|
|
else:
|
|
return None
|
|
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
return None
|
|
|
|
|
|
def get_hotspot_status() -> HotspotStatus:
|
|
"""
|
|
Get current hotspot status matching wrolpi/admin.py logic.
|
|
|
|
Returns:
|
|
HotspotStatus enum value
|
|
"""
|
|
if is_docker_mode():
|
|
return HotspotStatus.unknown
|
|
|
|
device = get_config_value('hotspot.device', 'wlan0')
|
|
|
|
# Check if device is connected to a Wi-Fi network (not hotspot)
|
|
if get_current_ssid(device):
|
|
return HotspotStatus.in_use
|
|
|
|
# Check nmcli status
|
|
try:
|
|
result = subprocess.run(
|
|
["nmcli"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
if result.returncode != 0:
|
|
return HotspotStatus.unknown
|
|
|
|
output = result.stdout.strip()
|
|
for line in output.splitlines():
|
|
if line.startswith(f'{device}: connected'):
|
|
return HotspotStatus.connected
|
|
elif line.startswith(f'{device}: disconnected'):
|
|
return HotspotStatus.disconnected
|
|
elif line.startswith(f'{device}: unavailable'):
|
|
return HotspotStatus.unavailable
|
|
|
|
return HotspotStatus.unknown
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return HotspotStatus.unknown
|
|
except FileNotFoundError:
|
|
return HotspotStatus.unknown
|
|
except subprocess.SubprocessError:
|
|
return HotspotStatus.unknown
|
|
|
|
|
|
def get_hotspot_status_dict() -> dict:
|
|
"""
|
|
Get hotspot status as a dict for API responses.
|
|
|
|
Returns dict matching HotspotStatusResponse schema:
|
|
enabled: bool - Whether hotspot is currently enabled
|
|
available: bool - Whether hotspot functionality is available
|
|
reason: Optional[str] - Reason if unavailable
|
|
ssid: Optional[str] - Hotspot SSID when enabled
|
|
device: Optional[str] - WiFi device name
|
|
"""
|
|
device = get_config_value('hotspot.device', 'wlan0')
|
|
status = get_hotspot_status()
|
|
|
|
# Map enum status to enabled/available flags
|
|
enabled = status == HotspotStatus.connected
|
|
available = status not in (HotspotStatus.unknown, HotspotStatus.unavailable)
|
|
|
|
reason = None
|
|
if status == HotspotStatus.unknown:
|
|
reason = "Hotspot not supported or nmcli unavailable"
|
|
elif status == HotspotStatus.unavailable:
|
|
reason = "WiFi radio is off"
|
|
elif status == HotspotStatus.in_use:
|
|
reason = "WiFi device is connected to a network"
|
|
|
|
return {
|
|
"enabled": enabled,
|
|
"available": available,
|
|
"reason": reason,
|
|
"ssid": get_config_value('hotspot.ssid', 'WROLPi') if enabled else None,
|
|
"device": device,
|
|
}
|
|
|
|
|
|
def enable_hotspot() -> dict:
|
|
"""
|
|
Enable the WiFi hotspot.
|
|
|
|
Returns:
|
|
dict with success status and message
|
|
"""
|
|
if is_docker_mode():
|
|
return {"success": False, "error": "Not available in Docker mode"}
|
|
|
|
device = get_config_value('hotspot.device', 'wlan0')
|
|
ssid = get_config_value('hotspot.ssid', 'WROLPi')
|
|
password = get_config_value('hotspot.password', 'wrolpi hotspot')
|
|
|
|
try:
|
|
# First ensure radio is on
|
|
subprocess.run(
|
|
["nmcli", "radio", "wifi", "on"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
|
|
# Create hotspot using NetworkManager
|
|
result = subprocess.run(
|
|
[
|
|
"nmcli", "device", "wifi", "hotspot",
|
|
"ifname", device,
|
|
"ssid", ssid,
|
|
"password", password,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
return {"success": True, "ssid": ssid, "device": device}
|
|
else:
|
|
return {"success": False, "error": result.stderr.strip() or "Unknown error"}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {"success": False, "error": "Timeout enabling hotspot"}
|
|
except FileNotFoundError:
|
|
return {"success": False, "error": "nmcli not found"}
|
|
except subprocess.SubprocessError as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def disable_hotspot() -> dict:
|
|
"""
|
|
Disable the WiFi hotspot.
|
|
|
|
Returns:
|
|
dict with success status
|
|
"""
|
|
if is_docker_mode():
|
|
return {"success": False, "error": "Not available in Docker mode"}
|
|
|
|
try:
|
|
# Turn off the radio to disconnect hotspot
|
|
result = subprocess.run(
|
|
["nmcli", "radio", "wifi", "off"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
|
|
# Success even if already off
|
|
return {"success": True}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {"success": False, "error": "Timeout disabling hotspot"}
|
|
except FileNotFoundError:
|
|
return {"success": False, "error": "nmcli not found"}
|
|
except subprocess.SubprocessError as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
GOVERNOR_MAP = {
|
|
'ondemand': GovernorStatus.ondemand,
|
|
'powersave': GovernorStatus.powersave,
|
|
}
|
|
|
|
|
|
def get_throttle_status() -> GovernorStatus:
|
|
"""
|
|
Get CPU throttle (governor) status matching wrolpi/admin.py logic.
|
|
|
|
Returns:
|
|
GovernorStatus enum value
|
|
"""
|
|
if is_docker_mode():
|
|
return GovernorStatus.unknown
|
|
|
|
governor_path = Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
|
|
|
|
if not governor_path.exists():
|
|
return GovernorStatus.unknown
|
|
|
|
try:
|
|
current = governor_path.read_text().strip()
|
|
return GOVERNOR_MAP.get(current, GovernorStatus.unknown)
|
|
except IOError:
|
|
return GovernorStatus.unknown
|
|
|
|
|
|
def get_throttle_status_dict() -> dict:
|
|
"""
|
|
Get throttle status as a dict for API responses.
|
|
|
|
Returns dict matching ThrottleStatusResponse schema:
|
|
enabled: bool - Whether CPU throttle (powersave) is enabled
|
|
available: bool - Whether throttle control is available
|
|
reason: Optional[str] - Reason if unavailable
|
|
governor: Optional[str] - Current CPU governor
|
|
available_governors: Optional[list[str]] - Available governors
|
|
"""
|
|
if is_docker_mode():
|
|
return {
|
|
"enabled": False,
|
|
"available": False,
|
|
"reason": "Docker mode",
|
|
"governor": None,
|
|
"available_governors": None,
|
|
}
|
|
|
|
governor_path = Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
|
|
available_path = Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors")
|
|
|
|
if not governor_path.exists():
|
|
return {
|
|
"enabled": False,
|
|
"available": False,
|
|
"reason": "cpufreq not available",
|
|
"governor": None,
|
|
"available_governors": None,
|
|
}
|
|
|
|
try:
|
|
current = governor_path.read_text().strip()
|
|
available_governors = available_path.read_text().strip().split() if available_path.exists() else []
|
|
|
|
return {
|
|
"enabled": current == "powersave",
|
|
"available": True,
|
|
"reason": None,
|
|
"governor": current,
|
|
"available_governors": available_governors,
|
|
}
|
|
except IOError as e:
|
|
return {
|
|
"enabled": False,
|
|
"available": False,
|
|
"reason": str(e),
|
|
"governor": None,
|
|
"available_governors": None,
|
|
}
|
|
|
|
|
|
def enable_throttle() -> dict:
|
|
"""
|
|
Enable CPU throttle (set powersave governor).
|
|
|
|
Returns:
|
|
dict with success status
|
|
"""
|
|
if is_docker_mode():
|
|
return {"success": False, "error": "Not available in Docker mode"}
|
|
|
|
try:
|
|
# Try cpufreq-set first
|
|
result = subprocess.run(
|
|
["cpufreq-set", "-g", "powersave"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
return {"success": True, "governor": "powersave"}
|
|
else:
|
|
return {"success": False, "error": result.stderr.strip() or "Unknown error"}
|
|
|
|
except FileNotFoundError:
|
|
# Try direct write to sysfs
|
|
try:
|
|
for cpu_path in Path("/sys/devices/system/cpu/").glob("cpu[0-9]*/cpufreq/scaling_governor"):
|
|
cpu_path.write_text("powersave")
|
|
return {"success": True, "governor": "powersave"}
|
|
except IOError as e:
|
|
return {"success": False, "error": str(e)}
|
|
except subprocess.TimeoutExpired:
|
|
return {"success": False, "error": "Timeout enabling throttle"}
|
|
except subprocess.SubprocessError as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def disable_throttle() -> dict:
|
|
"""
|
|
Disable CPU throttle (set ondemand or performance governor).
|
|
|
|
Returns:
|
|
dict with success status
|
|
"""
|
|
if is_docker_mode():
|
|
return {"success": False, "error": "Not available in Docker mode"}
|
|
|
|
default_governor = DEFAULT_CPU_FREQUENCY
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["cpufreq-set", "-g", default_governor],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
return {"success": True, "governor": default_governor}
|
|
else:
|
|
return {"success": False, "error": result.stderr.strip() or "Unknown error"}
|
|
|
|
except FileNotFoundError:
|
|
# Try direct write to sysfs
|
|
try:
|
|
for cpu_path in Path("/sys/devices/system/cpu/").glob("cpu[0-9]*/cpufreq/scaling_governor"):
|
|
cpu_path.write_text(default_governor)
|
|
return {"success": True, "governor": default_governor}
|
|
except IOError as e:
|
|
return {"success": False, "error": str(e)}
|
|
except subprocess.TimeoutExpired:
|
|
return {"success": False, "error": "Timeout disabling throttle"}
|
|
except subprocess.SubprocessError as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def shutdown_system(delay: int = 10) -> dict:
|
|
"""
|
|
Shut down the system after a delay.
|
|
|
|
Args:
|
|
delay: Seconds to wait before shutdown (default 10). Allows API to return response.
|
|
|
|
Returns:
|
|
dict with success status
|
|
"""
|
|
import shutil
|
|
|
|
if is_docker_mode():
|
|
return {"success": False, "error": "Cannot shutdown from Docker container"}
|
|
|
|
# Check if shutdown command exists before starting thread
|
|
if not shutil.which("shutdown"):
|
|
return {"success": False, "error": "shutdown command not found"}
|
|
|
|
def _delayed_shutdown():
|
|
import time
|
|
time.sleep(delay)
|
|
subprocess.Popen(["shutdown", "-h", "now"])
|
|
|
|
try:
|
|
import threading
|
|
thread = threading.Thread(target=_delayed_shutdown, daemon=True)
|
|
thread.start()
|
|
return {"success": True, "message": f"System shutting down in {delay} seconds"}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def reboot_system(delay: int = 10) -> dict:
|
|
"""
|
|
Reboot the system after a delay.
|
|
|
|
Args:
|
|
delay: Seconds to wait before reboot (default 10). Allows API to return response.
|
|
|
|
Returns:
|
|
dict with success status
|
|
"""
|
|
import shutil
|
|
|
|
if is_docker_mode():
|
|
return {"success": False, "error": "Cannot reboot from Docker container"}
|
|
|
|
# Check if reboot command exists before starting thread
|
|
if not shutil.which("reboot"):
|
|
return {"success": False, "error": "reboot command not found"}
|
|
|
|
def _delayed_reboot():
|
|
import time
|
|
time.sleep(delay)
|
|
subprocess.Popen(["reboot"])
|
|
|
|
try:
|
|
import threading
|
|
thread = threading.Thread(target=_delayed_reboot, daemon=True)
|
|
thread.start()
|
|
return {"success": True, "message": f"System rebooting in {delay} seconds"}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
async def restart_all_services() -> dict:
|
|
"""
|
|
Restart all WROLPi services including Controller itself.
|
|
|
|
Controller restarts itself LAST so it can report success for other services.
|
|
|
|
Returns:
|
|
dict with results for each service
|
|
"""
|
|
if is_docker_mode():
|
|
# In Docker mode, we can't use systemctl
|
|
return {
|
|
"success": False,
|
|
"error": "Service restart via systemctl not available in Docker mode",
|
|
}
|
|
|
|
services_to_restart = [
|
|
"wrolpi-api",
|
|
"wrolpi-app",
|
|
"wrolpi-kiwix",
|
|
"wrolpi-help",
|
|
"renderd",
|
|
"apache2",
|
|
# Controller restarts itself last (scheduled after response)
|
|
]
|
|
|
|
results = {}
|
|
|
|
for service in services_to_restart:
|
|
try:
|
|
result = subprocess.run(
|
|
["systemctl", "restart", service],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
results[service] = {
|
|
"success": result.returncode == 0,
|
|
"error": result.stderr.strip() if result.returncode != 0 else None,
|
|
}
|
|
except subprocess.TimeoutExpired:
|
|
results[service] = {"success": False, "error": "Timeout"}
|
|
except FileNotFoundError:
|
|
results[service] = {"success": False, "error": "systemctl not found"}
|
|
except Exception as e:
|
|
results[service] = {"success": False, "error": str(e)}
|
|
|
|
# Schedule Controller self-restart after response is sent
|
|
loop = asyncio.get_event_loop()
|
|
loop.call_later(1.0, _restart_self)
|
|
|
|
results["wrolpi-controller"] = {"success": True, "pending": True}
|
|
return {"success": True, "services": results}
|
|
|
|
|
|
def _restart_self():
|
|
"""Restart the Controller service. Called after response is sent."""
|
|
try:
|
|
subprocess.Popen(["systemctl", "restart", "wrolpi-controller"])
|
|
except Exception:
|
|
# If we can't restart ourselves, not much we can do
|
|
pass
|