Removing WROLPi admin functionality that is now in Controller.
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled

This commit is contained in:
Roland 2025-12-19 17:40:43 -07:00
parent 7183cf5e85
commit 78251b067e
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
15 changed files with 159 additions and 1332 deletions

1
.gitignore vendored
View file

@ -140,3 +140,4 @@ app/cypress/videos
.claude
*.md
/.mcp.json

View file

@ -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) {

View file

@ -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)}

View file

@ -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

View file

@ -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
View file

@ -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.

View file

@ -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

View file

@ -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')

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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'

View file

@ -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)