diff --git a/.gitignore b/.gitignore index d4b7562f..26d47206 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,4 @@ app/cypress/videos .claude *.md +/.mcp.json diff --git a/.mcp.json b/.mcp.json.example similarity index 100% rename from .mcp.json rename to .mcp.json.example diff --git a/app/src/api.js b/app/src/api.js index b1a0f126..fa8fbdce 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -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) { diff --git a/controller/lib/admin.py b/controller/lib/admin.py index 9f487b31..62ffbb12 100644 --- a/controller/lib/admin.py +++ b/controller/lib/admin.py @@ -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)} diff --git a/controller/requirements.txt b/controller/requirements.txt index 6240703e..e82969e6 100644 --- a/controller/requirements.txt +++ b/controller/requirements.txt @@ -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 diff --git a/controller/test/test_admin.py b/controller/test/test_admin.py index e007b528..ffff9308 100644 --- a/controller/test/test_admin.py +++ b/controller/test/test_admin.py @@ -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: diff --git a/main.py b/main.py index 355535d6..9bb082fc 100755 --- a/main.py +++ b/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. diff --git a/wrolpi/__init__.py b/wrolpi/__init__.py index 587e215d..eff35163 100644 --- a/wrolpi/__init__.py +++ b/wrolpi/__init__.py @@ -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 diff --git a/wrolpi/admin.py b/wrolpi/admin.py deleted file mode 100644 index aae0b6d6..00000000 --- a/wrolpi/admin.py +++ /dev/null @@ -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') diff --git a/wrolpi/conftest.py b/wrolpi/conftest.py index cf60a2b3..5ea52591 100644 --- a/wrolpi/conftest.py +++ b/wrolpi/conftest.py @@ -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 diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 34a1690b..c819176b 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -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 diff --git a/wrolpi/status.py b/wrolpi/status.py deleted file mode 100755 index e7f80c4d..00000000 --- a/wrolpi/status.py +++ /dev/null @@ -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), ''} - - -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)) diff --git a/wrolpi/test/test_admin.py b/wrolpi/test/test_admin.py deleted file mode 100644 index 699a6f6c..00000000 --- a/wrolpi/test/test_admin.py +++ /dev/null @@ -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 diff --git a/wrolpi/test/test_root_api.py b/wrolpi/test/test_root_api.py index ab3c1123..82e87832 100644 --- a/wrolpi/test/test_root_api.py +++ b/wrolpi/test/test_root_api.py @@ -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' diff --git a/wrolpi/test/test_status.py b/wrolpi/test/test_status.py deleted file mode 100644 index 7f21affb..00000000 --- a/wrolpi/test/test_status.py +++ /dev/null @@ -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)