mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
Removing WROLPi admin functionality that is now in Controller.
This commit is contained in:
parent
7183cf5e85
commit
78251b067e
15 changed files with 159 additions and 1332 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -140,3 +140,4 @@ app/cypress/videos
|
|||
|
||||
.claude
|
||||
*.md
|
||||
/.mcp.json
|
||||
|
|
|
|||
|
|
@ -1640,21 +1640,31 @@ export async function deleteOutdatedZims() {
|
|||
}
|
||||
|
||||
export async function postShutdown() {
|
||||
const response = await apiPost(`${API_URI}/shutdown`);
|
||||
if (response.status !== 204) {
|
||||
// Return the error.
|
||||
return await response.json();
|
||||
// Call Controller directly for system shutdown
|
||||
const {shutdownSystem} = await import('./api/controller');
|
||||
try {
|
||||
const result = await shutdownSystem();
|
||||
if (result.success) {
|
||||
return null; // Success
|
||||
}
|
||||
return {code: 'SHUTDOWN_FAILED', error: result.error};
|
||||
} catch (e) {
|
||||
return {code: 'SHUTDOWN_FAILED', error: e.message};
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function postRestart() {
|
||||
const response = await apiPost(`${API_URI}/restart`);
|
||||
if (response.status !== 204) {
|
||||
// Return the error.
|
||||
return await response.json();
|
||||
// Call Controller directly for system reboot
|
||||
const {rebootSystem} = await import('./api/controller');
|
||||
try {
|
||||
const result = await rebootSystem();
|
||||
if (result.success) {
|
||||
return null; // Success
|
||||
}
|
||||
return {code: 'SHUTDOWN_FAILED', error: result.error};
|
||||
} catch (e) {
|
||||
return {code: 'SHUTDOWN_FAILED', error: e.message};
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function postVideoFileFormat(video_file_format) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import enum
|
|||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from controller.lib.config import get_config, is_docker_mode
|
||||
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):
|
||||
|
|
@ -28,11 +31,13 @@ class GovernorStatus(enum.Enum):
|
|||
unknown = enum.auto()
|
||||
|
||||
|
||||
def get_current_ssid(interface: str = 'wlan0') -> str | None:
|
||||
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'],
|
||||
|
|
@ -62,9 +67,7 @@ def get_hotspot_status() -> HotspotStatus:
|
|||
if is_docker_mode():
|
||||
return HotspotStatus.unknown
|
||||
|
||||
config = get_config()
|
||||
hotspot_config = config.get("hotspot", {})
|
||||
device = hotspot_config.get("device", "wlan0")
|
||||
device = get_config_value('hotspot.device', 'wlan0')
|
||||
|
||||
# Check if device is connected to a Wi-Fi network (not hotspot)
|
||||
if get_current_ssid(device):
|
||||
|
|
@ -111,9 +114,7 @@ def get_hotspot_status_dict() -> dict:
|
|||
ssid: Optional[str] - Hotspot SSID when enabled
|
||||
device: Optional[str] - WiFi device name
|
||||
"""
|
||||
config = get_config()
|
||||
hotspot_config = config.get("hotspot", {})
|
||||
device = hotspot_config.get("device", "wlan0")
|
||||
device = get_config_value('hotspot.device', 'wlan0')
|
||||
status = get_hotspot_status()
|
||||
|
||||
# Map enum status to enabled/available flags
|
||||
|
|
@ -132,7 +133,7 @@ def get_hotspot_status_dict() -> dict:
|
|||
"enabled": enabled,
|
||||
"available": available,
|
||||
"reason": reason,
|
||||
"ssid": hotspot_config.get("ssid", "WROLPi") if enabled else None,
|
||||
"ssid": get_config_value('hotspot.ssid', 'WROLPi') if enabled else None,
|
||||
"device": device,
|
||||
}
|
||||
|
||||
|
|
@ -147,12 +148,9 @@ def enable_hotspot() -> dict:
|
|||
if is_docker_mode():
|
||||
return {"success": False, "error": "Not available in Docker mode"}
|
||||
|
||||
config = get_config()
|
||||
hotspot_config = config.get("hotspot", {})
|
||||
|
||||
device = hotspot_config.get("device", "wlan0")
|
||||
ssid = hotspot_config.get("ssid", "WROLPi")
|
||||
password = hotspot_config.get("password", "wrolpi hotspot")
|
||||
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
|
||||
|
|
@ -348,8 +346,7 @@ def disable_throttle() -> dict:
|
|||
if is_docker_mode():
|
||||
return {"success": False, "error": "Not available in Docker mode"}
|
||||
|
||||
config = get_config()
|
||||
default_governor = config.get("throttle", {}).get("default_governor", "ondemand")
|
||||
default_governor = DEFAULT_CPU_FREQUENCY
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
|
|
@ -378,40 +375,68 @@ def disable_throttle() -> dict:
|
|||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def shutdown_system() -> dict:
|
||||
def shutdown_system(delay: int = 10) -> dict:
|
||||
"""
|
||||
Shut down the system.
|
||||
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"}
|
||||
|
||||
try:
|
||||
subprocess.Popen(["shutdown", "-h", "now"])
|
||||
return {"success": True, "message": "System shutting down"}
|
||||
except FileNotFoundError:
|
||||
# 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() -> dict:
|
||||
def reboot_system(delay: int = 10) -> dict:
|
||||
"""
|
||||
Reboot the system.
|
||||
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"}
|
||||
|
||||
try:
|
||||
subprocess.Popen(["reboot"])
|
||||
return {"success": True, "message": "System rebooting"}
|
||||
except FileNotFoundError:
|
||||
# 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)}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ pyyaml>=6.0
|
|||
|
||||
# Docker management (optional, for Docker mode)
|
||||
docker>=6.0.0
|
||||
aiohttp==3.12.14
|
||||
|
||||
# Testing
|
||||
pytest>=7.0.0
|
||||
|
|
|
|||
|
|
@ -66,11 +66,38 @@ class TestEnableHotspot:
|
|||
def test_handles_nmcli_not_found(self):
|
||||
"""Should handle nmcli not being available."""
|
||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||
with mock.patch("controller.lib.admin.get_config", return_value={}):
|
||||
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
|
||||
result = enable_hotspot()
|
||||
assert result["success"] is False
|
||||
assert "nmcli" in result.get("error", "").lower()
|
||||
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
|
||||
result = enable_hotspot()
|
||||
assert result["success"] is False
|
||||
assert "nmcli" in result.get("error", "").lower()
|
||||
|
||||
def test_uses_config_values(self):
|
||||
"""Should use get_config_value for device, ssid, and password."""
|
||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||
# Mock get_config_value to return custom values
|
||||
def mock_get_config(key, default=None):
|
||||
config_map = {
|
||||
'hotspot.device': 'wlan1',
|
||||
'hotspot.ssid': 'TestSSID',
|
||||
'hotspot.password': 'testpassword123',
|
||||
}
|
||||
return config_map.get(key, default)
|
||||
|
||||
with mock.patch("controller.lib.admin.get_config_value", side_effect=mock_get_config):
|
||||
mock_run = mock.Mock()
|
||||
mock_run.returncode = 0
|
||||
with mock.patch("subprocess.run", return_value=mock_run) as mock_subprocess:
|
||||
enable_hotspot()
|
||||
|
||||
# Verify the hotspot was created with config values
|
||||
calls = mock_subprocess.call_args_list
|
||||
# Find the hotspot creation call (nmcli device wifi hotspot)
|
||||
hotspot_call = [c for c in calls if len(c[0][0]) > 2 and 'hotspot' in c[0][0]]
|
||||
if hotspot_call:
|
||||
cmd_args = hotspot_call[0][0][0]
|
||||
assert "wlan1" in cmd_args
|
||||
assert "TestSSID" in cmd_args
|
||||
assert "testpassword123" in cmd_args
|
||||
|
||||
|
||||
class TestDisableHotspot:
|
||||
|
|
@ -165,19 +192,25 @@ class TestShutdownSystem:
|
|||
def test_handles_shutdown_not_found(self):
|
||||
"""Should handle shutdown command not being available."""
|
||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||
with mock.patch("subprocess.Popen", side_effect=FileNotFoundError()):
|
||||
with mock.patch("shutil.which", return_value=None):
|
||||
result = shutdown_system()
|
||||
assert result["success"] is False
|
||||
assert "not found" in result.get("error", "").lower()
|
||||
|
||||
def test_calls_shutdown_command(self):
|
||||
"""Should call shutdown command with correct args."""
|
||||
"""Should call shutdown command with correct args after delay."""
|
||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||
mock_popen = mock.Mock()
|
||||
with mock.patch("subprocess.Popen", mock_popen):
|
||||
result = shutdown_system()
|
||||
mock_popen.assert_called_once_with(["shutdown", "-h", "now"])
|
||||
assert result["success"] is True
|
||||
with mock.patch("shutil.which", return_value="/sbin/shutdown"):
|
||||
mock_popen = mock.Mock()
|
||||
with mock.patch("subprocess.Popen", mock_popen):
|
||||
# Use delay=0 for testing to avoid waiting
|
||||
result = shutdown_system(delay=0)
|
||||
assert result["success"] is True
|
||||
assert "shutting down" in result["message"].lower()
|
||||
# Give thread time to execute
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
mock_popen.assert_called_once_with(["shutdown", "-h", "now"])
|
||||
|
||||
|
||||
class TestRebootSystem:
|
||||
|
|
@ -193,19 +226,25 @@ class TestRebootSystem:
|
|||
def test_handles_reboot_not_found(self):
|
||||
"""Should handle reboot command not being available."""
|
||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||
with mock.patch("subprocess.Popen", side_effect=FileNotFoundError()):
|
||||
with mock.patch("shutil.which", return_value=None):
|
||||
result = reboot_system()
|
||||
assert result["success"] is False
|
||||
assert "not found" in result.get("error", "").lower()
|
||||
|
||||
def test_calls_reboot_command(self):
|
||||
"""Should call reboot command."""
|
||||
"""Should call reboot command after delay."""
|
||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||
mock_popen = mock.Mock()
|
||||
with mock.patch("subprocess.Popen", mock_popen):
|
||||
result = reboot_system()
|
||||
mock_popen.assert_called_once_with(["reboot"])
|
||||
assert result["success"] is True
|
||||
with mock.patch("shutil.which", return_value="/sbin/reboot"):
|
||||
mock_popen = mock.Mock()
|
||||
with mock.patch("subprocess.Popen", mock_popen):
|
||||
# Use delay=0 for testing to avoid waiting
|
||||
result = reboot_system(delay=0)
|
||||
assert result["success"] is True
|
||||
assert "rebooting" in result["message"].lower()
|
||||
# Give thread time to execute
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
mock_popen.assert_called_once_with(["reboot"])
|
||||
|
||||
|
||||
class TestRestartAllServices:
|
||||
|
|
|
|||
22
main.py
22
main.py
|
|
@ -12,7 +12,7 @@ from sanic.signals import Event
|
|||
from modules.inventory import init_inventory
|
||||
from modules.inventory.common import import_inventories_config
|
||||
from modules.videos.lib import import_channels_config, get_videos_downloader_config
|
||||
from wrolpi import flags, admin
|
||||
from wrolpi import flags
|
||||
from modules.archive.lib import import_domains_config
|
||||
from wrolpi import root_api # noqa
|
||||
from wrolpi import tags
|
||||
|
|
@ -284,25 +284,7 @@ async def start_single_tasks(app: Sanic):
|
|||
# Set all downloads to new.
|
||||
download_manager.retry_downloads()
|
||||
|
||||
# Hotspot/throttle are not supported in Docker containers.
|
||||
if not DOCKERIZED:
|
||||
if get_wrolpi_config().hotspot_on_startup:
|
||||
logger.info('Starting hotspot...')
|
||||
try:
|
||||
admin.enable_hotspot()
|
||||
except Exception as e:
|
||||
logger.error('Failed to enable hotspot', exc_info=e)
|
||||
else:
|
||||
logger.info('Hotspot on startup is disabled.')
|
||||
|
||||
if get_wrolpi_config().throttle_on_startup:
|
||||
logger.info('Throttling CPU...')
|
||||
try:
|
||||
admin.throttle_cpu_on()
|
||||
except Exception as e:
|
||||
logger.error('Failed to throttle CPU', exc_info=e)
|
||||
else:
|
||||
logger.info('CPU throttle on startup is disabled')
|
||||
# Hotspot/throttle on startup are now handled by the Controller service.
|
||||
|
||||
if get_wrolpi_config().download_on_startup and not wrol_mode_enabled():
|
||||
# Only start downloading when prerequisites have been met.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,2 @@
|
|||
import wrolpi.files.api # noqa Import from files so the blueprint is attached.
|
||||
import wrolpi.scrape_downloader # noqa
|
||||
import wrolpi.status # noqa
|
||||
|
|
|
|||
225
wrolpi/admin.py
225
wrolpi/admin.py
|
|
@ -1,225 +0,0 @@
|
|||
import asyncio
|
||||
import enum
|
||||
import subprocess
|
||||
|
||||
from wrolpi.cmd import NMCLI_BIN, CPUFREQ_INFO_BIN, SUDO_BIN, CPUFREQ_SET_BIN, run_command
|
||||
from wrolpi.common import logger, WROLPI_CONFIG, get_warn_once, background_task
|
||||
from wrolpi.errors import ShutdownFailed
|
||||
from wrolpi.events import Events
|
||||
from wrolpi.vars import DEFAULT_CPU_FREQUENCY, DOCKERIZED
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
POWER_SAVE_FREQ = 'powersave' # noqa
|
||||
|
||||
nmcli_warn_once = get_warn_once('Unable to use nmcli', logger)
|
||||
iwgetid_warn_once = get_warn_once('Unable to use iwgetid', logger)
|
||||
|
||||
|
||||
class HotspotStatus(enum.Enum):
|
||||
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 on this hardware.
|
||||
in_use = enum.auto() # WI-FI device is in use, and cannot be used for Hotspot.
|
||||
|
||||
|
||||
def hotspot_status() -> HotspotStatus:
|
||||
if NMCLI_BIN is None:
|
||||
return HotspotStatus.unknown
|
||||
|
||||
device = WROLPI_CONFIG.hotspot_device
|
||||
|
||||
if get_current_ssid(device):
|
||||
# Hotspot device is currently connected to a WI-FI network.
|
||||
return HotspotStatus.in_use
|
||||
|
||||
cmd = (NMCLI_BIN,)
|
||||
try:
|
||||
output = subprocess.check_output(cmd, stderr=subprocess.PIPE).decode().strip()
|
||||
except Exception as e:
|
||||
nmcli_warn_once(e)
|
||||
return HotspotStatus.unknown
|
||||
|
||||
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
|
||||
|
||||
|
||||
def get_current_ssid(interface='wlan0') -> str | None:
|
||||
"""Returns the name of the SSID that is currently connected. Returns None if Hotspot, or no WI-FI network is being
|
||||
used."""
|
||||
try:
|
||||
# Run the iwgetid command and capture the output
|
||||
cmd = ('iwgetid', interface, '--raw')
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
|
||||
# Check if the command was successful
|
||||
if result.returncode == 0:
|
||||
ssid = result.stdout.strip()
|
||||
return ssid
|
||||
elif result.returncode == 255:
|
||||
# Hotspot is in use.
|
||||
return None
|
||||
else:
|
||||
logger.error(f'Failed to get current ssid: {result.stderr}')
|
||||
return None
|
||||
except FileNotFoundError as e:
|
||||
iwgetid_warn_once(e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error('Failed to get current ssid', exc_info=e)
|
||||
return None
|
||||
|
||||
|
||||
def enable_hotspot(depth: int = 10, overwrite: bool = False):
|
||||
"""Turn the Wi-Fi interface into a hotspot.
|
||||
|
||||
If Wi-Fi is already running, replace that with a hotspot.
|
||||
"""
|
||||
if depth < 0:
|
||||
raise RuntimeError(f'Failed to start hotspot!')
|
||||
|
||||
status = hotspot_status()
|
||||
logger.info(f'Hotspot status: {status}')
|
||||
|
||||
if overwrite is False and (current_ssid := get_current_ssid(WROLPI_CONFIG.hotspot_device)):
|
||||
raise RuntimeError(f'Refusing to start hotspot because Wi-Fi device is already being used for {current_ssid}.')
|
||||
|
||||
if status == HotspotStatus.connected:
|
||||
logger.info('Hotspot already connected. Rebooting hotspot.')
|
||||
disable_hotspot()
|
||||
return enable_hotspot(depth=depth - 1)
|
||||
elif status == HotspotStatus.in_use:
|
||||
logger.info('Wifi already in use. Rebooting hotspot.')
|
||||
disable_hotspot()
|
||||
return enable_hotspot(depth=depth - 1)
|
||||
elif status == HotspotStatus.disconnected:
|
||||
# Radio is on, but not connected. Good, turn it into a hotspot.
|
||||
cmd = (SUDO_BIN, NMCLI_BIN, 'device', 'wifi', 'hotspot', 'ifname', WROLPI_CONFIG.hotspot_device,
|
||||
'ssid', WROLPI_CONFIG.hotspot_ssid, 'password', WROLPI_CONFIG.hotspot_password)
|
||||
subprocess.check_call(cmd)
|
||||
logger.info('Enabling hotspot successful')
|
||||
return True
|
||||
elif status == HotspotStatus.unavailable:
|
||||
# Radio is not on, turn it on.
|
||||
cmd = (SUDO_BIN, NMCLI_BIN, 'radio', 'wifi', 'on')
|
||||
subprocess.check_call(cmd)
|
||||
logger.info('Turned on hotspot radio.')
|
||||
return enable_hotspot(depth=depth - 1)
|
||||
elif status == HotspotStatus.unknown:
|
||||
logger.error('Cannot enable hotspot with unknown status!')
|
||||
|
||||
|
||||
def disable_hotspot():
|
||||
"""Turn off the Wi-Fi interface."""
|
||||
cmd = (SUDO_BIN, NMCLI_BIN, 'radio', 'wifi', 'off')
|
||||
try:
|
||||
subprocess.check_call(cmd)
|
||||
logger.info('Disabling hotspot successful')
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error('Failed to disable hotspot', exc_info=e)
|
||||
return False
|
||||
|
||||
|
||||
class GovernorStatus(enum.Enum):
|
||||
ondemand = enum.auto()
|
||||
powersave = enum.auto()
|
||||
unknown = enum.auto()
|
||||
# These are unused by WROLPi.
|
||||
# performance = enum.auto()
|
||||
# schedutil = enum.auto()
|
||||
# userspace = enum.auto()
|
||||
# conservative = enum.auto()
|
||||
|
||||
|
||||
GOVERNOR_MAP = {
|
||||
'governor "ondemand"': GovernorStatus.ondemand,
|
||||
'governor "powersave"': GovernorStatus.powersave,
|
||||
}
|
||||
|
||||
|
||||
def throttle_status() -> GovernorStatus:
|
||||
if not CPUFREQ_INFO_BIN:
|
||||
return GovernorStatus.unknown
|
||||
|
||||
cmd = (CPUFREQ_INFO_BIN,)
|
||||
try:
|
||||
output = subprocess.check_output(cmd)
|
||||
except FileNotFoundError as e:
|
||||
if not DOCKERIZED:
|
||||
logger.debug(f'Could not get CPU throttle info', exc_info=e)
|
||||
return GovernorStatus.unknown
|
||||
output = output.decode().strip()
|
||||
|
||||
for line in output.splitlines():
|
||||
for governor, status in GOVERNOR_MAP.items():
|
||||
if governor in line:
|
||||
return status
|
||||
|
||||
return GovernorStatus.unknown
|
||||
|
||||
|
||||
def throttle_cpu_on() -> bool:
|
||||
"""Call cpufreq-set to throttle CPU."""
|
||||
try:
|
||||
cmd = (SUDO_BIN, CPUFREQ_SET_BIN, '-g', POWER_SAVE_FREQ)
|
||||
subprocess.check_call(cmd)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
if not DOCKERIZED:
|
||||
logger.error('Could not enable CPU throttle', exc_info=True)
|
||||
|
||||
|
||||
def throttle_cpu_off() -> bool:
|
||||
"""Call cpufreq-set to un-throttle CPU."""
|
||||
try:
|
||||
cmd = (SUDO_BIN, CPUFREQ_SET_BIN, '-g', DEFAULT_CPU_FREQUENCY)
|
||||
subprocess.check_call(cmd)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
if not DOCKERIZED:
|
||||
logger.error('Could not disable CPU throttle', exc_info=True)
|
||||
|
||||
|
||||
async def shutdown(reboot: bool = False, delay: int = 10):
|
||||
"""Shutdown or reboot the WROLPi. Schedules an asyncio sleep to delay the shutdown.
|
||||
|
||||
@raise ShutdownFailed: If not possible, or if sudo command failed."""
|
||||
if DOCKERIZED:
|
||||
raise ShutdownFailed('Cannot Reboot because we are dockerized')
|
||||
|
||||
cmd = (SUDO_BIN, 'shutdown', '--reboot', '0') if reboot else (SUDO_BIN, 'shutdown', '0')
|
||||
|
||||
async def _():
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
try:
|
||||
# Call shutdown (or reboot) with no delay now that we have slept.
|
||||
result = await run_command(cmd)
|
||||
except subprocess.CalledProcessError as e:
|
||||
if reboot:
|
||||
Events.send_shutdown_failed('Reboot failed')
|
||||
else:
|
||||
Events.send_shutdown_failed('Shutdown failed')
|
||||
raise ShutdownFailed() from e
|
||||
|
||||
if result.return_code != 0:
|
||||
if reboot:
|
||||
Events.send_shutdown_failed('Reboot failed')
|
||||
else:
|
||||
Events.send_shutdown_failed('Shutdown failed')
|
||||
raise ShutdownFailed(f'Got return code {result.return_code}')
|
||||
|
||||
background_task(_())
|
||||
if reboot:
|
||||
Events.send_shutdown(f'Reboot in {delay} seconds')
|
||||
else:
|
||||
Events.send_shutdown(f'Shutdown in {delay} seconds')
|
||||
|
|
@ -818,8 +818,3 @@ def mock_downloader_download_file():
|
|||
yield set_contents
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def start_status_worker():
|
||||
"""Enable the status worker for testing."""
|
||||
with mock.patch('wrolpi.status.DISABLE_STATUS_WORKER', False):
|
||||
yield
|
||||
|
|
|
|||
|
|
@ -17,9 +17,8 @@ from modules.map.api import map_bp
|
|||
from modules.otp.api import otp_bp
|
||||
from modules.videos.api import videos_bp
|
||||
from modules.zim.api import zim_bp
|
||||
from wrolpi import admin, flags, schema, dates
|
||||
from wrolpi import flags, schema, dates
|
||||
from wrolpi import tags
|
||||
from wrolpi.admin import HotspotStatus
|
||||
from wrolpi.api_utils import json_response, api_app
|
||||
from wrolpi.collections.api import collection_bp
|
||||
from wrolpi.common import logger, get_wrolpi_config, wrol_mode_enabled, get_media_directory, \
|
||||
|
|
@ -28,8 +27,7 @@ from wrolpi.common import logger, get_wrolpi_config, wrol_mode_enabled, get_medi
|
|||
from wrolpi.config_api import config_bp
|
||||
from wrolpi.dates import now
|
||||
from wrolpi.downloader import download_manager
|
||||
from wrolpi.errors import WROLModeEnabled, HotspotError, HotspotPasswordTooShort, InvalidConfig, \
|
||||
ValidationError
|
||||
from wrolpi.errors import WROLModeEnabled, InvalidConfig, ValidationError
|
||||
from wrolpi.events import get_events, Events
|
||||
from wrolpi.files import files_bp
|
||||
from wrolpi.files.lib import get_file_statistics, search_file_suggestion_count
|
||||
|
|
@ -161,7 +159,7 @@ def get_settings(_: Request):
|
|||
'hotspot_on_startup': wrolpi_config.hotspot_on_startup,
|
||||
'hotspot_password': wrolpi_config.hotspot_password,
|
||||
'hotspot_ssid': wrolpi_config.hotspot_ssid,
|
||||
'hotspot_status': admin.hotspot_status().name,
|
||||
# Hotspot/throttle status now from Controller (/controller/api/hotspot/status, /controller/api/throttle/status)
|
||||
'check_for_upgrades': wrolpi_config.check_for_upgrades,
|
||||
'ignore_outdated_zims': wrolpi_config.ignore_outdated_zims,
|
||||
'ignored_directories': ignored_directories,
|
||||
|
|
@ -170,7 +168,6 @@ def get_settings(_: Request):
|
|||
'nav_color': wrolpi_config.nav_color,
|
||||
'media_directory': str(get_media_directory()), # Convert to string to avoid conversion to relative.
|
||||
'throttle_on_startup': wrolpi_config.throttle_on_startup,
|
||||
'throttle_status': admin.throttle_status().name,
|
||||
'version': __version__,
|
||||
'videos_destination': wrolpi_config.videos_destination,
|
||||
'wrol_mode': wrolpi_config.wrol_mode,
|
||||
|
|
@ -200,7 +197,7 @@ async def update_settings(_: Request, body: schema.SettingsRequest):
|
|||
return response.empty()
|
||||
|
||||
if body.hotspot_password and len(body.hotspot_password) < 8:
|
||||
raise HotspotPasswordTooShort()
|
||||
raise InvalidConfig('Hotspot password must be at least 8 characters')
|
||||
|
||||
# Remove any keys with None values, then save the config.
|
||||
new_config = {k: v for k, v in body.__dict__.items() if v is not None}
|
||||
|
|
@ -235,21 +232,9 @@ async def update_settings(_: Request, body: schema.SettingsRequest):
|
|||
# Persist log level to config
|
||||
wrolpi_config.log_level = log_level_int_to_name(log_level)
|
||||
|
||||
old_password = wrolpi_config.hotspot_password
|
||||
# Save config settings (hotspot control is now through Controller endpoints)
|
||||
wrolpi_config.update(new_config)
|
||||
|
||||
# If the password was changed, we need to restart the hotspot.
|
||||
password_changed = (new_password := new_config.get('hotspot_password')) and old_password != new_password
|
||||
|
||||
if body.hotspot_status is True or (password_changed and admin.hotspot_status() == HotspotStatus.connected):
|
||||
# Turn on Hotspot
|
||||
if admin.enable_hotspot() is False:
|
||||
raise HotspotError('Could not turn on hotspot')
|
||||
elif body.hotspot_status is False:
|
||||
# Turn off Hotspot
|
||||
if admin.disable_hotspot() is False:
|
||||
raise HotspotError('Could not turn off hotspot')
|
||||
|
||||
return response.empty()
|
||||
|
||||
|
||||
|
|
@ -425,46 +410,6 @@ async def get_downloaders(_: Request):
|
|||
return json_response(ret)
|
||||
|
||||
|
||||
@api_bp.post('/hotspot/on')
|
||||
@openapi.description('Turn on the hotspot')
|
||||
@native_only
|
||||
async def hotspot_on(_: Request):
|
||||
result = admin.enable_hotspot(overwrite=True)
|
||||
if result:
|
||||
return response.empty()
|
||||
return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_bp.post('/hotspot/off')
|
||||
@openapi.description('Turn off the hotspot')
|
||||
@native_only
|
||||
async def hotspot_off(_: Request):
|
||||
result = admin.disable_hotspot()
|
||||
if result:
|
||||
return response.empty()
|
||||
return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_bp.post('/throttle/on')
|
||||
@openapi.description('Turn on CPU throttling')
|
||||
@native_only
|
||||
async def throttle_on(_: Request):
|
||||
result = admin.throttle_cpu_on()
|
||||
if result:
|
||||
return response.empty()
|
||||
return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_bp.post('/throttle/off')
|
||||
@openapi.description('Turn off CPU throttling')
|
||||
@native_only
|
||||
async def throttle_off(_: Request):
|
||||
result = admin.throttle_cpu_off()
|
||||
if result:
|
||||
return response.empty()
|
||||
return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_bp.get('/status')
|
||||
@openapi.definition(
|
||||
description='Get the status of CPU/load/etc.',
|
||||
|
|
@ -491,13 +436,11 @@ async def get_status(request: Request):
|
|||
dockerized=DOCKERIZED,
|
||||
downloads=downloads,
|
||||
flags=flags.get_flags(),
|
||||
hotspot_ssid=admin.get_current_ssid(get_wrolpi_config().hotspot_device),
|
||||
hotspot_status=admin.hotspot_status().name,
|
||||
# Hotspot and throttle status now come from Controller (/controller/api/stats)
|
||||
is_rpi4=IS_RPI4,
|
||||
is_rpi5=IS_RPI5,
|
||||
is_rpi=IS_RPI,
|
||||
sanic_workers=sanic_workers,
|
||||
throttle_status=admin.throttle_status().name,
|
||||
version=__version__,
|
||||
wrol_mode=wrol_mode_enabled(),
|
||||
# Include all stats from status worker (includes upgrade info and git_branch).
|
||||
|
|
@ -612,22 +555,6 @@ async def post_vin_number_decoder(_: Request, body: schema.VINDecoderRequest):
|
|||
return json_response(dict(vin=vin))
|
||||
|
||||
|
||||
@api_bp.post('/restart')
|
||||
@openapi.definition(description='Restart the system')
|
||||
@native_only
|
||||
async def post_restart(_: Request):
|
||||
await admin.shutdown(reboot=True)
|
||||
return response.empty(HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
@api_bp.post('/shutdown')
|
||||
@openapi.definition(description='Shutdown the system')
|
||||
@native_only
|
||||
async def post_shutdown(_: Request):
|
||||
await admin.shutdown()
|
||||
return response.empty(HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
@api_bp.get('/upgrade/check')
|
||||
@openapi.definition(description='Check for available WROLPi updates')
|
||||
@native_only
|
||||
|
|
|
|||
650
wrolpi/status.py
650
wrolpi/status.py
|
|
@ -1,650 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import statistics
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
import jc
|
||||
|
||||
from wrolpi.api_utils import api_app, perpetual_signal
|
||||
from wrolpi.cmd import which, run_command
|
||||
from wrolpi.common import logger, get_warn_once, unique_by_predicate, partition, TRACE_LEVEL
|
||||
from wrolpi.dates import now
|
||||
from wrolpi.vars import PYTEST, DOCKERIZED
|
||||
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
logger.warning('Unable to import psutil!')
|
||||
psutil = None
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
UPTIME_BIN = which('uptime', '/usr/bin/uptime')
|
||||
|
||||
warn_once = get_warn_once('Unable to use psutil', logger)
|
||||
warn_cpu_once = get_warn_once('Cannot read CPU stats', logger)
|
||||
warn_cpu_frequency_once = get_warn_once('Cannot read CPU frequency paths', logger)
|
||||
warn_cpu_temperature_once = get_warn_once('Cannot read CPU Temperature stats', logger)
|
||||
warn_iostat_once = get_warn_once('Cannot get iostat stats', logger)
|
||||
status_worker_warn_once = get_warn_once('Status worker encountered error', logger)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemLoad:
|
||||
minute_1: Decimal = 0
|
||||
minute_5: Decimal = 0
|
||||
minute_15: Decimal = 0
|
||||
|
||||
def __json__(self) -> dict:
|
||||
return dict(
|
||||
minute_1=str(round(self.minute_1, 2)),
|
||||
minute_5=str(round(self.minute_5, 2)),
|
||||
minute_15=str(round(self.minute_15, 2)),
|
||||
)
|
||||
|
||||
|
||||
LOAD_REGEX = re.compile(r'.+?load average: (.+?), (.+?), (.*)')
|
||||
LOAD_AVG_FILE = pathlib.Path('/proc/loadavg')
|
||||
|
||||
|
||||
async def get_load_stats() -> SystemLoad:
|
||||
"""Read loadavg file and return the 1, 5, and 15 minute system loads."""
|
||||
if LOAD_AVG_FILE.exists():
|
||||
# Read load information from proc file.
|
||||
# Example: 0.99 1.06 1.02 1/2305 43879
|
||||
loadavg = LOAD_AVG_FILE.read_text()
|
||||
load_1, load_5, load_15, *_ = loadavg.split(' ')
|
||||
else:
|
||||
load_1, load_5, load_15 = os.getloadavg()
|
||||
load = SystemLoad(Decimal(load_1), Decimal(load_5), Decimal(load_15))
|
||||
return load
|
||||
|
||||
|
||||
@dataclass
|
||||
class IostatInfo:
|
||||
percent_idle: float = None
|
||||
percent_iowait: float = None
|
||||
percent_nice: float = None
|
||||
percent_steal: float = None
|
||||
percent_system: float = None
|
||||
percent_user: float = None
|
||||
|
||||
def __json__(self) -> dict:
|
||||
return dict(
|
||||
percent_idle=self.percent_idle,
|
||||
percent_iowait=self.percent_iowait,
|
||||
percent_nice=self.percent_nice,
|
||||
percent_steal=self.percent_steal,
|
||||
percent_system=self.percent_system,
|
||||
percent_user=self.percent_user,
|
||||
)
|
||||
|
||||
|
||||
async def get_iostat_stats() -> IostatInfo:
|
||||
try:
|
||||
result = await run_command(
|
||||
('iostat', '3', '2'), # 3 second interval to gather more data, get 2 count.
|
||||
log_command=False,
|
||||
)
|
||||
if result.stdout and result.return_code == 0:
|
||||
iostat_stats = jc.parse('iostat', result.stdout.decode(), quiet=True)
|
||||
if iostat_stats:
|
||||
cpu_statuses, _ = partition(lambda i: i['type'] == 'cpu', iostat_stats)
|
||||
_, cpu_stats = cpu_statuses # Use the second status because the first is a lie.
|
||||
|
||||
if __debug__ and logger.isEnabledFor(TRACE_LEVEL):
|
||||
logger.trace(
|
||||
f'get_iostat_stats'
|
||||
f' percent_iowait={cpu_stats["percent_iowait"]}'
|
||||
f' percent_idle={cpu_stats["percent_idle"]}')
|
||||
# Return only the CPU stats.
|
||||
return IostatInfo(
|
||||
percent_idle=cpu_stats['percent_idle'],
|
||||
percent_iowait=cpu_stats['percent_iowait'],
|
||||
percent_nice=cpu_stats['percent_nice'],
|
||||
percent_steal=cpu_stats['percent_steal'],
|
||||
percent_system=cpu_stats['percent_system'],
|
||||
percent_user=cpu_stats['percent_user'],
|
||||
)
|
||||
except Exception as e:
|
||||
if __debug__ and logger.isEnabledFor(TRACE_LEVEL):
|
||||
logger.trace(f'get_iostat_stats failed: {e}')
|
||||
warn_iostat_once(e)
|
||||
|
||||
return IostatInfo()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessInfo:
|
||||
pid: int = None
|
||||
percent_cpu: int = None
|
||||
percent_mem: int = None
|
||||
command: str = None
|
||||
|
||||
def __json__(self) -> dict:
|
||||
return dict(
|
||||
pid=self.pid,
|
||||
percent_cpu=self.percent_cpu,
|
||||
percent_mem=self.percent_mem,
|
||||
command=self.command,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CPUInfo:
|
||||
cores: int = None
|
||||
cur_frequency: int = None
|
||||
max_frequency: int = None
|
||||
min_frequency: int = None
|
||||
percent: int = None
|
||||
temperature: Optional[int] = None
|
||||
high_temperature: Optional[float] = None
|
||||
critical_temperature: Optional[float] = None
|
||||
|
||||
def __json__(self) -> dict:
|
||||
return dict(
|
||||
cores=self.cores,
|
||||
cur_frequency=self.cur_frequency,
|
||||
max_frequency=self.max_frequency,
|
||||
min_frequency=self.min_frequency,
|
||||
percent=self.percent,
|
||||
temperature=self.temperature,
|
||||
high_temperature=self.high_temperature,
|
||||
critical_temperature=self.critical_temperature,
|
||||
)
|
||||
|
||||
|
||||
MIN_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_min_freq')
|
||||
MAX_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq')
|
||||
CUR_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq')
|
||||
TEMPERATURE_PATH = Path('/sys/class/thermal/thermal_zone0/temp')
|
||||
TOP_REGEX = re.compile(r'^%Cpu\(s\):\s+(\d+\.\d+)', re.MULTILINE)
|
||||
|
||||
CORE_COUNT = multiprocessing.cpu_count()
|
||||
|
||||
|
||||
async def get_cpu_stats() -> CPUInfo:
|
||||
"""Get core count, max freq, min freq, current freq, cpu temperature."""
|
||||
percent = int(psutil.cpu_percent())
|
||||
|
||||
min_frequency = max_frequency = cur_frequency = None
|
||||
try:
|
||||
min_frequency = int(MIN_FREQUENCY_PATH.read_text())
|
||||
max_frequency = int(MAX_FREQUENCY_PATH.read_text())
|
||||
cur_frequency = int(CUR_FREQUENCY_PATH.read_text())
|
||||
except Exception as e:
|
||||
warn_cpu_frequency_once(e)
|
||||
|
||||
# Get temperature using psutil.
|
||||
temperature = high_temperature = critical_temperature = None
|
||||
try:
|
||||
temp = psutil.sensors_temperatures()
|
||||
name = 'coretemp' if 'coretemp' in temp else list(temp.keys())[0]
|
||||
# Temperatures may be None. Get average temperatures from CPU because one core may be hot.
|
||||
temperatures = temp.get(name)
|
||||
temperature = temperature or statistics.median([i.current or 0 for i in temperatures])
|
||||
high_temperature = statistics.median([i.high or 0 for i in temperatures])
|
||||
critical_temperature = statistics.median([i.critical or 0 for i in temperatures])
|
||||
|
||||
# Temperatures may not exist.
|
||||
high_temperature = high_temperature or 60
|
||||
critical_temperature = critical_temperature or 95
|
||||
|
||||
if high_temperature and high_temperature == critical_temperature:
|
||||
# Display yellow warning before red warning.
|
||||
high_temperature = critical_temperature - 25
|
||||
except Exception as e:
|
||||
warn_cpu_temperature_once(e)
|
||||
|
||||
info = CPUInfo(
|
||||
cores=CORE_COUNT,
|
||||
critical_temperature=int(critical_temperature) if critical_temperature else None,
|
||||
# Current frequency is between minimum and maximum.
|
||||
cur_frequency=cur_frequency - min_frequency if cur_frequency else None,
|
||||
high_temperature=int(high_temperature) if high_temperature else None,
|
||||
max_frequency=max_frequency,
|
||||
min_frequency=min_frequency,
|
||||
percent=percent,
|
||||
temperature=int(temperature) if temperature else None,
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
PS_CMD = ('ps', 'aux', '--sort=-%cpu', '--cols=512')
|
||||
IGNORED_PROCESS_COMMANDS = {' '.join(PS_CMD), '<defunct>'}
|
||||
|
||||
|
||||
async def get_processes_stats() -> List[ProcessInfo]:
|
||||
result = await run_command(PS_CMD, log_command=False)
|
||||
# Do not include `ps` in the top processes.
|
||||
ps_pid = result.pid
|
||||
|
||||
ps_info = jc.parse('ps', result.stdout.decode(), quiet=True)
|
||||
processes = sorted(ps_info, key=lambda i: i['cpu_percent'], reverse=True)
|
||||
processes = [i for i in processes if i['pid'] != ps_pid
|
||||
and i['cpu_percent'] > 1
|
||||
and not any(j in i['command'] for j in IGNORED_PROCESS_COMMANDS)]
|
||||
processes = unique_by_predicate(processes, lambda i: i['command'][:30])
|
||||
processes = processes[:10]
|
||||
processes_info = [ProcessInfo(
|
||||
pid=int(i['pid']),
|
||||
percent_cpu=int(i['cpu_percent']),
|
||||
percent_mem=int(i['mem_percent']),
|
||||
command=i['command'],
|
||||
) for i in processes]
|
||||
|
||||
return processes_info
|
||||
|
||||
|
||||
MEMORY_TOTAL = re.compile(r'^MemTotal:\s+(\d+)\s+kB$', re.MULTILINE)
|
||||
MEMORY_FREE = re.compile(r'^MemFree:\s+(\d+)\s+kB$', re.MULTILINE)
|
||||
MEMORY_CACHED = re.compile(r'^Cached:\s+(\d+)\s+kB$', re.MULTILINE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryStats:
|
||||
total: int = None
|
||||
used: int = None
|
||||
free: int = None
|
||||
cached: int = None
|
||||
|
||||
def __json__(self) -> dict:
|
||||
return dict(
|
||||
total=self.total,
|
||||
used=self.used,
|
||||
free=self.free,
|
||||
cached=self.cached,
|
||||
)
|
||||
|
||||
|
||||
def get_memory_stats_psutil():
|
||||
mem = psutil.virtual_memory()
|
||||
mem = MemoryStats(
|
||||
total=mem.total,
|
||||
used=mem.used,
|
||||
free=mem.free,
|
||||
cached=mem.cached,
|
||||
)
|
||||
return mem
|
||||
|
||||
|
||||
async def get_memory_stats():
|
||||
try:
|
||||
return get_memory_stats_psutil()
|
||||
except Exception as e:
|
||||
warn_once(e)
|
||||
|
||||
meminfo = pathlib.Path('/proc/meminfo')
|
||||
if meminfo.is_file() and (contents := meminfo.read_text()):
|
||||
total = int(i[0]) if (i := MEMORY_TOTAL.findall(contents)) else None
|
||||
free = int(i[0]) if (i := MEMORY_FREE.findall(contents)) else None
|
||||
cached = int(i[0]) if (i := MEMORY_CACHED.findall(contents)) else None
|
||||
mem = MemoryStats(
|
||||
total=int(total * 1024) if total else None,
|
||||
free=int(free * 1024) if free else None,
|
||||
cached=int(cached * 1024) if cached else None,
|
||||
used=int(total - free) if total and free else None,
|
||||
)
|
||||
return mem
|
||||
|
||||
return MemoryStats()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DriveInfo:
|
||||
mount: str = None
|
||||
percent: int = 0
|
||||
size: int = 0
|
||||
used: int = 0
|
||||
|
||||
def __json__(self) -> dict:
|
||||
return dict(
|
||||
mount=self.mount,
|
||||
percent=self.percent,
|
||||
size=self.size,
|
||||
used=self.used,
|
||||
)
|
||||
|
||||
|
||||
DRIVE_REGEX = re.compile(r'^.+?' # filesystem
|
||||
r'\s+(\d+)K' # size
|
||||
r'\s+(\d+)K' # used
|
||||
r'\s+(\d+)K' # available
|
||||
r'\s+(\d+)%' # use %
|
||||
r'\s+(.*)', # mount
|
||||
re.MULTILINE)
|
||||
IGNORED_DRIVES = ['/boot', '/etc']
|
||||
VALID_FORMATS = {'btrfs', 'ext4', 'ext3', 'ext2', 'vfat', 'exfat', 'apfs'}
|
||||
|
||||
|
||||
def get_drives_info_psutil() -> List[DriveInfo]:
|
||||
info = {}
|
||||
|
||||
disks = psutil.disk_partitions()
|
||||
for disk in disks:
|
||||
if any(disk.mountpoint.startswith(i) for i in IGNORED_DRIVES):
|
||||
continue
|
||||
if disk.fstype not in VALID_FORMATS:
|
||||
continue
|
||||
if disk.device not in info:
|
||||
# Only use the first partition.
|
||||
usage = psutil.disk_usage(disk.mountpoint)
|
||||
info[disk.device] = DriveInfo(
|
||||
mount=disk.mountpoint,
|
||||
percent=int(usage.percent),
|
||||
size=int(usage.total),
|
||||
used=int(usage.used),
|
||||
)
|
||||
|
||||
info = sorted(info.values(), key=lambda i: i.mount)
|
||||
return info
|
||||
|
||||
|
||||
async def get_drives_stats() -> List[DriveInfo]:
|
||||
try:
|
||||
return get_drives_info_psutil()
|
||||
except Exception as e:
|
||||
warn_once(e)
|
||||
|
||||
# Fallback to using `df` to fetch disk information.
|
||||
proc = await asyncio.subprocess.create_subprocess_shell(
|
||||
'df --type btrfs --type ext4 --type ext3 --type ext2 --type vfat -Bk',
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
drives = []
|
||||
for size, used, _, percent, mount in DRIVE_REGEX.findall(stdout.decode()):
|
||||
mount = mount.strip()
|
||||
if any(mount.startswith(i) for i in IGNORED_DRIVES):
|
||||
continue
|
||||
|
||||
size = int(size) * 1024
|
||||
used = int(used) * 1024
|
||||
|
||||
drives.append(DriveInfo(
|
||||
mount=mount,
|
||||
percent=int(percent),
|
||||
size=size,
|
||||
used=used,
|
||||
))
|
||||
|
||||
drives = sorted(drives, key=lambda i: i.mount)
|
||||
return drives
|
||||
|
||||
|
||||
@dataclass
|
||||
class NICBandwidthInfo:
|
||||
bytes_recv: int = None
|
||||
bytes_sent: int = None
|
||||
elapsed: int = None
|
||||
name: str = None
|
||||
speed: int = None
|
||||
now: float = None
|
||||
|
||||
def __json__(self) -> dict:
|
||||
return dict(
|
||||
bytes_recv=self.bytes_recv,
|
||||
bytes_sent=self.bytes_sent,
|
||||
elapsed=self.elapsed,
|
||||
name=self.name,
|
||||
speed=self.speed,
|
||||
)
|
||||
|
||||
|
||||
IGNORED_NIC_NAMES = {
|
||||
'lo',
|
||||
'veth',
|
||||
'tun',
|
||||
'docker',
|
||||
'br-',
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiskBandwidthInfo:
|
||||
bytes_recv: int = None
|
||||
bytes_sent: int = None
|
||||
bytes_read_ps: int = None
|
||||
bytes_write_ps: int = None
|
||||
elapsed: int = None
|
||||
name: str = None
|
||||
now: float = None
|
||||
max_read_ps: int = None
|
||||
max_write_ps: int = None
|
||||
|
||||
def __json__(self) -> dict:
|
||||
return dict(
|
||||
bytes_recv=self.bytes_recv,
|
||||
bytes_sent=self.bytes_sent,
|
||||
bytes_read_ps=self.bytes_read_ps,
|
||||
bytes_write_ps=self.bytes_write_ps,
|
||||
elapsed=self.elapsed,
|
||||
name=self.name,
|
||||
now=self.now,
|
||||
max_read_ps=self.max_read_ps,
|
||||
max_write_ps=self.max_write_ps,
|
||||
)
|
||||
|
||||
|
||||
IGNORED_DISK_NAMES = (
|
||||
'loop',
|
||||
'ram',
|
||||
)
|
||||
|
||||
|
||||
def _calculate_bytes_per_second(old_stats: dict, new_stats: dict) -> Dict[str, int]:
|
||||
"""Calculate the bytes-per-second between the oldest and newest counter."""
|
||||
try:
|
||||
# NICs
|
||||
old_now, old_recv, old_sent = old_stats['now'], old_stats['bytes_recv'], old_stats['bytes_sent']
|
||||
new_now, new_recv, new_sent = new_stats['now'], new_stats['bytes_recv'], new_stats['bytes_sent']
|
||||
except KeyError:
|
||||
# Disks
|
||||
old_now, old_recv, old_sent = old_stats['now'], old_stats['bytes_read'], old_stats['bytes_write']
|
||||
new_now, new_recv, new_sent = new_stats['now'], new_stats['bytes_read'], new_stats['bytes_write']
|
||||
elapsed = int(new_now - old_now)
|
||||
if elapsed == 0:
|
||||
return dict(bytes_recv_ps=0, bytes_sent_ps=0, elapsed=0)
|
||||
bytes_recv_ps = int((new_recv - old_recv) // elapsed)
|
||||
bytes_sent_ps = int((new_sent - old_sent) // elapsed)
|
||||
return dict(bytes_recv_ps=bytes_recv_ps, bytes_sent_ps=bytes_sent_ps, elapsed=elapsed)
|
||||
|
||||
|
||||
def _get_nic_counters() -> dict:
|
||||
"""
|
||||
Get the instant network statistics for the provided NIC.
|
||||
"""
|
||||
timestamp = now().timestamp()
|
||||
counters = dict()
|
||||
if_stats = psutil.net_if_stats()
|
||||
for name, counter in sorted(psutil.net_io_counters(pernic=True, nowrap=True).items(), key=lambda i: i[0]):
|
||||
if name in IGNORED_NIC_NAMES:
|
||||
continue
|
||||
stats = if_stats[name]
|
||||
counters[name] = {
|
||||
'name': name,
|
||||
'now': timestamp,
|
||||
'bytes_recv': counter.bytes_recv,
|
||||
'bytes_sent': counter.bytes_sent,
|
||||
'speed': int(stats.speed),
|
||||
}
|
||||
return counters
|
||||
|
||||
|
||||
async def get_nic_counters(shared_status):
|
||||
new_nic_stats = _get_nic_counters()
|
||||
old_nic_stats = shared_status['nic_bandwidth_stats']
|
||||
nic_bandwidth_stats = dict()
|
||||
for name, old_stats in old_nic_stats.items():
|
||||
new_stats = new_nic_stats[name]
|
||||
new_stats.update(_calculate_bytes_per_second(old_stats, new_stats))
|
||||
nic_bandwidth_stats[name] = new_stats
|
||||
return nic_bandwidth_stats
|
||||
|
||||
|
||||
def _get_disk_counters() -> dict:
|
||||
"""
|
||||
Get the instant disk statistics for the all disks.
|
||||
"""
|
||||
timestamp = now().timestamp()
|
||||
counters = dict()
|
||||
|
||||
for name, counter in sorted(psutil.disk_io_counters(perdisk=True).items(), key=lambda i: i[0]):
|
||||
if any(name.startswith(i) for i in counters):
|
||||
# Report only the first disk. Do not report its partitions. Disks are sorted to make this work!
|
||||
# i.e. report "sda" but not "sda1" or "sda2".
|
||||
continue
|
||||
if any(name.startswith(i) for i in IGNORED_DISK_NAMES):
|
||||
continue
|
||||
counters[name] = {
|
||||
'name': name,
|
||||
'now': timestamp,
|
||||
'bytes_read': counter.read_bytes,
|
||||
'bytes_write': counter.write_bytes,
|
||||
}
|
||||
|
||||
return counters
|
||||
|
||||
|
||||
async def get_disk_counters(shared_status):
|
||||
"""Get stats about bandwidth per disk."""
|
||||
new_disk_stats = _get_disk_counters()
|
||||
old_disk_stats = shared_status['disk_bandwidth_stats']
|
||||
disk_bandwidth_stats = dict()
|
||||
for name, old_stats in old_disk_stats.items():
|
||||
new_stats = new_disk_stats[name]
|
||||
bps = _calculate_bytes_per_second(old_stats, new_stats)
|
||||
new_stats.update(dict(
|
||||
elapsed=bps['elapsed'],
|
||||
bytes_read_ps=bps['bytes_recv_ps'],
|
||||
bytes_write_ps=bps['bytes_sent_ps'],
|
||||
max_read_ps=max(bps['bytes_recv_ps'], old_stats['max_read_ps']),
|
||||
max_write_ps=max(bps['bytes_sent_ps'], old_stats['max_write_ps']),
|
||||
))
|
||||
disk_bandwidth_stats[name] = new_stats
|
||||
return disk_bandwidth_stats
|
||||
|
||||
|
||||
@dataclass
|
||||
class PowerStats:
|
||||
under_voltage: bool = False
|
||||
over_current: bool = False
|
||||
|
||||
def __json__(self) -> dict:
|
||||
return dict(
|
||||
under_voltage=self.under_voltage,
|
||||
over_current=self.over_current,
|
||||
)
|
||||
|
||||
|
||||
async def get_power_stats() -> PowerStats:
|
||||
logger.trace(f'get_power_stats called')
|
||||
|
||||
try:
|
||||
cmd = ('dmesg',)
|
||||
result = await run_command(cmd, log_command=False)
|
||||
if result.stdout and result.return_code == 0:
|
||||
under_voltage = b' Undervoltage detected' in result.stdout
|
||||
over_current = b' over-current change ' in result.stdout
|
||||
return PowerStats(
|
||||
under_voltage=under_voltage,
|
||||
over_current=over_current,
|
||||
)
|
||||
except Exception as e:
|
||||
warn_iostat_once(e)
|
||||
|
||||
return PowerStats()
|
||||
|
||||
|
||||
DISABLE_STATUS_WORKER = bool(PYTEST) # Do not run status worker while testing, unless explicitly needed.
|
||||
|
||||
|
||||
# Disable status_worker in favor of status from Controller.
|
||||
# @perpetual_signal(sleep=5)
|
||||
async def status_worker(count: int = None, sleep_time: int = 5):
|
||||
"""A background process which will gather historical data about system statistics."""
|
||||
shared_status = api_app.shared_ctx.status
|
||||
|
||||
if PYTEST and DISABLE_STATUS_WORKER:
|
||||
# Tests are running, status worker has not been enabled by `start_status_worker` fixture.
|
||||
return
|
||||
|
||||
if count is not None and count <= 0:
|
||||
logger.debug('Status worker ran out of count')
|
||||
return dict(**shared_status)
|
||||
|
||||
load_stats = None
|
||||
try:
|
||||
# Update global `status` dict with stats that are gathered instantly.
|
||||
cpu_stats, load_stats, drives_stats, memory_stats, processes_stats, iostat_stats, power_stats = \
|
||||
await asyncio.gather(
|
||||
asyncio.create_task(get_cpu_stats(), name='get_cpu_stats'),
|
||||
asyncio.create_task(get_load_stats(), name='get_load_stats'),
|
||||
asyncio.create_task(get_drives_stats(), name='get_drives_stats'),
|
||||
asyncio.create_task(get_memory_stats(), name='get_memory_stats'),
|
||||
asyncio.create_task(get_processes_stats(), name='get_processes_stats'),
|
||||
asyncio.create_task(get_iostat_stats(), name='get_iostat_stats'),
|
||||
asyncio.create_task(get_power_stats(), name='get_power_stats'),
|
||||
)
|
||||
from wrolpi.upgrade import get_current_branch
|
||||
shared_status.update({
|
||||
'cpu_stats': cpu_stats.__json__(),
|
||||
'load_stats': load_stats.__json__(),
|
||||
'drives_stats': [i.__json__() for i in drives_stats],
|
||||
'processes_stats': [i.__json__() for i in processes_stats],
|
||||
'memory_stats': memory_stats.__json__(),
|
||||
'last_status': now().isoformat(),
|
||||
'iostat_stats': iostat_stats.__json__(),
|
||||
'power_stats': power_stats.__json__(),
|
||||
'git_branch': get_current_branch() if not DOCKERIZED else None,
|
||||
})
|
||||
|
||||
if 'disk_bandwidth_stats' not in shared_status:
|
||||
# Worker has just been started, fill it with some starting bandwidth data.
|
||||
disk_info = _get_disk_counters()
|
||||
for name in disk_info.keys():
|
||||
disk_info[name].update({'max_read_ps': 500_000, 'max_write_ps': 500_000})
|
||||
shared_status.update({
|
||||
'nic_bandwidth_stats': _get_nic_counters(),
|
||||
'disk_bandwidth_stats': disk_info,
|
||||
})
|
||||
# Break out and call the `finally` code below.
|
||||
return
|
||||
|
||||
# Calculate the difference between the previous status worker's bandwidth count and now.
|
||||
shared_status.update({
|
||||
'nic_bandwidth_stats': await get_nic_counters(shared_status),
|
||||
'disk_bandwidth_stats': await get_disk_counters(shared_status),
|
||||
})
|
||||
except Exception as e:
|
||||
status_worker_warn_once(e)
|
||||
raise
|
||||
finally:
|
||||
# Always sleep so this does not become a busy loop with errors.
|
||||
if load_stats and load_stats.minute_1 and load_stats.minute_1 > multiprocessing.cpu_count():
|
||||
# System is stressed, slow down status updates.
|
||||
await asyncio.sleep((sleep_time * float(load_stats.minute_1)) / multiprocessing.cpu_count())
|
||||
|
||||
if count:
|
||||
# Running in testing, or __main__.
|
||||
return await status_worker(count - 1, sleep_time)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from wrolpi.contexts import attach_shared_contexts
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
attach_shared_contexts(api_app)
|
||||
loop.run_until_complete(status_worker(2, sleep_time=2))
|
||||
print(json.dumps(dict(api_app.shared_ctx.status), indent=2))
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
from pathlib import PosixPath
|
||||
from unittest import mock
|
||||
from unittest.mock import call
|
||||
|
||||
from wrolpi import admin
|
||||
from wrolpi.admin import HotspotStatus
|
||||
from wrolpi.common import WROLPI_CONFIG
|
||||
from wrolpi.test.common import skip_circleci, skip_macos
|
||||
|
||||
|
||||
@skip_circleci
|
||||
@skip_macos
|
||||
def test_hotspot_status():
|
||||
with mock.patch('wrolpi.admin.subprocess') as mock_subprocess:
|
||||
mock_subprocess.check_output.return_value = b'wlan0: unavailable'
|
||||
assert admin.hotspot_status() == HotspotStatus.unavailable
|
||||
mock_subprocess.check_output.return_value = b'wlan0: disconnected'
|
||||
assert admin.hotspot_status() == HotspotStatus.disconnected
|
||||
mock_subprocess.check_output.return_value = b'wlan0: connected'
|
||||
assert admin.hotspot_status() == HotspotStatus.connected
|
||||
|
||||
|
||||
def test_enable_hotspot_disconnected():
|
||||
"""Enable hotspot with wifi on."""
|
||||
with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \
|
||||
mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
||||
mock.patch('wrolpi.admin.NMCLI_BIN', PosixPath('/usr/bin/nmcli')):
|
||||
mock_hotspot_status.return_value = HotspotStatus.disconnected
|
||||
mock_subprocess.check_output.return_value = 0
|
||||
assert admin.enable_hotspot() is True
|
||||
mock_subprocess.check_call.assert_called_once_with(
|
||||
(PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname', 'wlan0',
|
||||
'ssid', 'WROLPi', 'password', 'wrolpi hotspot'))
|
||||
|
||||
|
||||
def test_enable_hotspot_unavailable():
|
||||
"""Enable hotspot with wifi off."""
|
||||
with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \
|
||||
mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
||||
mock.patch('wrolpi.admin.NMCLI_BIN', PosixPath('/usr/bin/nmcli')):
|
||||
mock_hotspot_status.side_effect = [HotspotStatus.unavailable, HotspotStatus.disconnected]
|
||||
mock_subprocess.check_output.return_value = 0
|
||||
assert admin.enable_hotspot() is True
|
||||
mock_subprocess.check_call.assert_has_calls(
|
||||
[
|
||||
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'on')),
|
||||
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname',
|
||||
'wlan0', 'ssid', 'WROLPi', 'password', 'wrolpi hotspot'))]
|
||||
)
|
||||
|
||||
|
||||
def test_enable_hotspot_connected():
|
||||
"""Enable hotspot with the hotspot alaready enabled."""
|
||||
with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \
|
||||
mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
||||
mock.patch('wrolpi.admin.NMCLI_BIN', PosixPath('/usr/bin/nmcli')):
|
||||
mock_hotspot_status.side_effect = [
|
||||
HotspotStatus.connected,
|
||||
HotspotStatus.unavailable,
|
||||
HotspotStatus.disconnected,
|
||||
]
|
||||
mock_subprocess.check_output.return_value = 0
|
||||
assert admin.enable_hotspot() is True
|
||||
mock_subprocess.check_call.assert_has_calls(
|
||||
[
|
||||
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'off')),
|
||||
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'on')),
|
||||
call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname',
|
||||
'wlan0', 'ssid', 'WROLPi', 'password', 'wrolpi hotspot'))]
|
||||
)
|
||||
|
||||
|
||||
def test_change_hotspot(test_wrolpi_config, async_client):
|
||||
"""The hotspot can be configured."""
|
||||
with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \
|
||||
mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
||||
mock.patch('wrolpi.admin.NMCLI_BIN', PosixPath('/usr/bin/nmcli')):
|
||||
mock_subprocess.check_output.return_value = 0
|
||||
mock_hotspot_status.return_value = HotspotStatus.disconnected
|
||||
assert admin.enable_hotspot() is True
|
||||
mock_subprocess.check_call.assert_called_once_with(
|
||||
(PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname', 'wlan0',
|
||||
'ssid', 'WROLPi', 'password', 'wrolpi hotspot'))
|
||||
|
||||
WROLPI_CONFIG.hotspot_ssid = 'New SSID'
|
||||
WROLPI_CONFIG.hotspot_password = '$uper Secure *#'
|
||||
with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \
|
||||
mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
||||
mock.patch('wrolpi.admin.NMCLI_BIN', PosixPath('/usr/bin/nmcli')):
|
||||
mock_subprocess.check_output.return_value = 0
|
||||
mock_hotspot_status.return_value = HotspotStatus.disconnected
|
||||
assert admin.enable_hotspot() is True
|
||||
mock_subprocess.check_call.assert_called_once_with(
|
||||
(PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname', 'wlan0',
|
||||
'ssid', 'New SSID', 'password', '$uper Secure *#'))
|
||||
|
||||
|
||||
def test_throttle_on():
|
||||
with mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
||||
mock.patch('wrolpi.admin.CPUFREQ_SET_BIN', PosixPath('/usr/bin/cpufreq-set')):
|
||||
assert admin.throttle_cpu_on()
|
||||
mock_subprocess.check_call.assert_called_once_with(
|
||||
(PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/cpufreq-set'), '-g', 'powersave')
|
||||
)
|
||||
|
||||
|
||||
def test_throttle_off():
|
||||
with mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
||||
mock.patch('wrolpi.admin.CPUFREQ_SET_BIN', PosixPath('/usr/bin/cpufreq-set')):
|
||||
assert admin.throttle_cpu_off()
|
||||
mock_subprocess.check_call.assert_called_once_with(
|
||||
(PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/cpufreq-set'), '-g', 'ondemand')
|
||||
)
|
||||
|
||||
|
||||
@skip_circleci
|
||||
@skip_macos
|
||||
def test_hotspot_device(async_client):
|
||||
"""Changing the hotspot device changes what device is turned on."""
|
||||
from wrolpi.common import WROLPI_CONFIG
|
||||
WROLPI_CONFIG.hotspot_device = 'wlp2s0'
|
||||
|
||||
with mock.patch('wrolpi.admin.subprocess') as mock_subprocess:
|
||||
mock_subprocess.check_output.return_value = b'wlp2s0: connected'
|
||||
assert admin.hotspot_status() == HotspotStatus.connected
|
||||
|
|
@ -6,12 +6,11 @@ import pytest
|
|||
from mock import mock
|
||||
from sanic import Request
|
||||
|
||||
from wrolpi.admin import HotspotStatus
|
||||
from wrolpi.api_utils import json_error_handler
|
||||
from wrolpi.common import get_wrolpi_config
|
||||
from wrolpi.downloader import Download, get_download_manager_config
|
||||
from wrolpi.errors import ValidationError, SearchEmpty
|
||||
from wrolpi.test.common import skip_circleci, assert_dict_contains, skip_macos
|
||||
from wrolpi.test.common import skip_circleci, assert_dict_contains
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -52,11 +51,8 @@ async def test_valid_regex(test_session, async_client):
|
|||
@pytest.mark.asyncio
|
||||
async def test_get_settings(test_session, async_client):
|
||||
get_wrolpi_config().ignored_directories = list()
|
||||
|
||||
with mock.patch('wrolpi.admin.CPUFREQ_INFO_BIN', '/usr/bin/cpufreq-info'), \
|
||||
mock.patch('wrolpi.admin.NMCLI_BIN', '/usr/bin/nmcli'):
|
||||
request, response = await async_client.get('/api/settings')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
request, response = await async_client.get('/api/settings')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -208,83 +204,25 @@ async def test_echo(async_client):
|
|||
@pytest.mark.asyncio
|
||||
async def test_hotspot_settings(test_session, async_client, test_wrolpi_config):
|
||||
"""
|
||||
The User can toggle the Hotspot via /settings. The Hotspot can be automatically started on startup.
|
||||
The User can configure hotspot settings (ssid, password, device) via /settings.
|
||||
Hotspot control (on/off) is now done through Controller endpoints.
|
||||
"""
|
||||
config = get_wrolpi_config()
|
||||
assert config.hotspot_on_startup is True
|
||||
|
||||
with mock.patch('wrolpi.root_api.admin') as mock_admin:
|
||||
# Turning on the hotspot succeeds.
|
||||
mock_admin.enable_hotspot.return_value = True
|
||||
request, response = await async_client.patch('/api/settings', content=json.dumps({'hotspot_status': True}))
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.json
|
||||
mock_admin.enable_hotspot.assert_called_once()
|
||||
mock_admin.reset_mock()
|
||||
# Hotspot password can be changed.
|
||||
content = {'hotspot_password': 'new password', 'hotspot_ssid': 'new ssid'}
|
||||
request, response = await async_client.patch('/api/settings', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
assert config.hotspot_password == 'new password'
|
||||
assert config.hotspot_ssid == 'new ssid'
|
||||
|
||||
# Turning on the hotspot fails.
|
||||
mock_admin.enable_hotspot.return_value = False
|
||||
request, response = await async_client.patch('/api/settings', content=json.dumps({'hotspot_status': True}))
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, response.json
|
||||
assert response.json['code'] == 'HOTSPOT_ERROR'
|
||||
mock_admin.enable_hotspot.assert_called_once()
|
||||
|
||||
# Turning off the hotspot succeeds.
|
||||
mock_admin.disable_hotspot.return_value = True
|
||||
request, response = await async_client.patch('/api/settings', content=json.dumps({'hotspot_status': False}))
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.json
|
||||
mock_admin.disable_hotspot.assert_called_once()
|
||||
mock_admin.reset_mock()
|
||||
|
||||
# Turning off the hotspot succeeds.
|
||||
mock_admin.disable_hotspot.return_value = False
|
||||
request, response = await async_client.patch('/api/settings', content=json.dumps({'hotspot_status': False}))
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, response.json
|
||||
assert response.json['code'] == 'HOTSPOT_ERROR'
|
||||
mock_admin.disable_hotspot.assert_called_once()
|
||||
|
||||
mock_admin.enable_hotspot.reset_mock()
|
||||
mock_admin.enable_hotspot.return_value = True
|
||||
mock_admin.hotspot_status.return_value = HotspotStatus.connected
|
||||
|
||||
# Hotspot password can be changed.
|
||||
mock_admin.disable_hotspot.return_value = True
|
||||
content = {'hotspot_password': 'new password', 'hotspot_ssid': 'new ssid'}
|
||||
request, response = await async_client.patch('/api/settings', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
assert config.hotspot_password == 'new password'
|
||||
assert config.hotspot_ssid == 'new ssid'
|
||||
# Changing the password restarts the hotspot.
|
||||
mock_admin.enable_hotspot.assert_called_once()
|
||||
|
||||
# Hotspot password must be at least 8 characters.
|
||||
mock_admin.disable_hotspot.return_value = True
|
||||
content = {'hotspot_password': '1234567', 'hotspot_ssid': 'new ssid'}
|
||||
request, response = await async_client.patch('/api/settings', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json == {'code': 'HOTSPOT_PASSWORD_TOO_SHORT',
|
||||
'error': 'Bad Request',
|
||||
'message': 'Hotspot password must be at least 8 characters'
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@skip_macos
|
||||
@skip_circleci
|
||||
async def test_throttle_toggle(test_session, async_client, test_wrolpi_config):
|
||||
get_wrolpi_config().ignored_directories = list()
|
||||
|
||||
with mock.patch('wrolpi.admin.subprocess') as mock_subprocess, \
|
||||
mock.patch('wrolpi.admin.CPUFREQ_INFO_BIN', "this value isn't even used"):
|
||||
mock_subprocess.check_output.side_effect = [
|
||||
b'wlan0: unavailable',
|
||||
b'The governor "ondemand" may decide ',
|
||||
]
|
||||
request, response = await async_client.get('/api/settings')
|
||||
|
||||
# Throttle is off by default.
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['throttle_on_startup'] is False
|
||||
assert response.json['throttle_status'] == 'ondemand'
|
||||
# Hotspot password must be at least 8 characters.
|
||||
content = {'hotspot_password': '1234567', 'hotspot_ssid': 'new ssid'}
|
||||
request, response = await async_client.patch('/api/settings', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json['code'] == 'INVALID_CONFIG'
|
||||
assert '8 characters' in response.json['error']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -413,8 +351,6 @@ async def test_get_status(async_client, test_session):
|
|||
assert isinstance(response.json.get('load_stats'), dict), 'load_stats should be a dict'
|
||||
assert isinstance(response.json.get('drives_stats'), list), 'drive_stats should be a dict'
|
||||
assert isinstance(response.json.get('downloads'), dict), 'downloads should be a dict'
|
||||
assert isinstance(response.json.get('hotspot_status'), str), 'hotspot_status should be a str'
|
||||
assert isinstance(response.json.get('throttle_status'), str), 'throttle_status should be a str'
|
||||
assert isinstance(response.json.get('version'), str), 'version should be a str'
|
||||
assert isinstance(response.json.get('memory_stats'), dict), 'memory_stats should be a dict'
|
||||
assert isinstance(response.json.get('flags'), dict), 'flags should be a dict'
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
from decimal import Decimal
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from wrolpi import status
|
||||
from wrolpi.test.common import skip_circleci, skip_macos
|
||||
from wrolpi.vars import IS_MACOS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_load(async_client):
|
||||
"""Test reading load information from /proc/loadavg."""
|
||||
load = await status.get_load_stats()
|
||||
assert isinstance(load, status.SystemLoad)
|
||||
|
||||
assert isinstance(load.minute_1, Decimal) and load.minute_1 >= 0
|
||||
assert isinstance(load.minute_5, Decimal) and load.minute_5 >= 0
|
||||
assert isinstance(load.minute_15, Decimal) and load.minute_15 >= 0
|
||||
|
||||
|
||||
@skip_circleci
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cpu_stats(async_client):
|
||||
"""Minimum CPU info testing because this will fail in docker, etc."""
|
||||
info = await status.get_cpu_stats()
|
||||
assert isinstance(info, status.CPUInfo)
|
||||
assert isinstance(info.percent, int)
|
||||
if not IS_MACOS:
|
||||
assert isinstance(info.temperature, int)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@skip_macos
|
||||
async def test_iostat_stats(async_client):
|
||||
info = await status.get_iostat_stats()
|
||||
assert isinstance(info, status.IostatInfo)
|
||||
assert isinstance(info.percent_idle, float)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_power_stats(async_client):
|
||||
info = await status.get_power_stats()
|
||||
assert isinstance(info, status.PowerStats)
|
||||
assert info.under_voltage is False
|
||||
assert info.over_current is False
|
||||
|
||||
|
||||
@skip_circleci
|
||||
@skip_macos
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_drives_stats(async_client):
|
||||
"""Minimum drives info testing because this will fail in docker, etc."""
|
||||
info = await status.get_drives_stats()
|
||||
assert isinstance(info, list)
|
||||
assert len(info) >= 1
|
||||
assert isinstance(info[0], status.DriveInfo)
|
||||
|
||||
with mock.patch('wrolpi.status.get_drives_info_psutil') as mock_get_drives_stats_psutil:
|
||||
# Fallback to subprocess when psutil is not available.
|
||||
mock_get_drives_stats_psutil.side_effect = Exception('testing no psutil')
|
||||
info = await status.get_drives_stats()
|
||||
assert isinstance(info, list)
|
||||
assert len(info) >= 1
|
||||
assert isinstance(info[0], status.DriveInfo)
|
||||
|
||||
|
||||
@skip_circleci
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_worker(async_client, start_status_worker):
|
||||
"""Status worker calls itself perpetually, but can be limited during testing."""
|
||||
from wrolpi.api_utils import api_app
|
||||
assert async_client.sanic_app.shared_ctx.status['cpu_stats'] == dict()
|
||||
|
||||
# Status worker fills out stats as it goes along.
|
||||
await status.status_worker(count=2)
|
||||
assert api_app.shared_ctx.status['nic_bandwidth_stats']
|
||||
assert 'bytes_recv' in list(api_app.shared_ctx.status['nic_bandwidth_stats'].values())[0]
|
||||
assert api_app.shared_ctx.status['disk_bandwidth_stats']
|
||||
assert 'bytes_read' in list(api_app.shared_ctx.status['disk_bandwidth_stats'].values())[0]
|
||||
|
||||
assert isinstance(api_app.shared_ctx.status.get('cpu_stats'), dict)
|
||||
assert isinstance(api_app.shared_ctx.status.get('load_stats'), dict)
|
||||
assert isinstance(api_app.shared_ctx.status.get('drives_stats'), list)
|
||||
assert isinstance(api_app.shared_ctx.status.get('processes_stats'), list)
|
||||
assert isinstance(api_app.shared_ctx.status.get('memory_stats'), dict)
|
||||
assert isinstance(api_app.shared_ctx.status.get('iostat_stats'), dict)
|
||||
assert isinstance(api_app.shared_ctx.status.get('power_stats'), dict)
|
||||
Loading…
Add table
Add a link
Reference in a new issue