From 1222a39867fcde86c329c141a0e0490199f31c8b Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 7 Feb 2022 13:37:58 -0700 Subject: [PATCH] CPU can be throttled in UI. --- app/src/api.js | 51 +++++++++++++++++++++++++++++++++ app/src/components/Admin.js | 3 ++ app/src/components/Common.js | 16 +++++++++-- app/src/hooks/customHooks.js | 29 +++++++++++++++++++ wrolpi/admin.py | 55 +++++++++++++++++++++++++++++++++++- wrolpi/root_api.py | 33 +++++++++++++++++++++- wrolpi/test/test_admin.py | 16 +++++++++++ wrolpi/vars.py | 1 + 8 files changed, 199 insertions(+), 5 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 142dc703..b03346e4 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -487,3 +487,54 @@ export async function setHotspot(on) { } } } + +export async function getThrottleStatus() { + let response = await apiGet(`${API_URI}/throttle`); + if (response.status === 200) { + let {status} = await response.json(); + return status; + } else { + let code = (await response.json())['code']; + if (response.status === 400 && code === 34) { + // Must not be running on a RPi. Don't warn the user. + return null; + } else { + toast({ + type: 'error', + title: "Can't get throttle status", + description: 'Failed to get CPU Throttle status. Check client and server logs.', + time: 5000, + }); + } + } + return null; +} + +export async function setThrottle(on) { + let response; + if (on) { + response = await apiPost(`${API_URI}/throttle/on`); + } else { + response = await apiPost(`${API_URI}/throttle/off`); + } + if (response.status === 204) { + return null; + } else { + let code = (await response.json())['code']; + if (code === 34) { + toast({ + type: 'error', + title: 'Unsupported!', + description: 'Throttle is only supported on a Raspberry Pi!', + time: 5000, + }); + } else { + toast({ + type: 'error', + title: 'Error!', + description: 'Could not modify throttle. See server logs.', + time: 5000, + }); + } + } +} diff --git a/app/src/components/Admin.js b/app/src/components/Admin.js index 3ff10020..8efbcc9a 100644 --- a/app/src/components/Admin.js +++ b/app/src/components/Admin.js @@ -21,6 +21,7 @@ import { secondsToFrequency, TabLinks, textEllipsis, + ThrottleToggle, WROLModeMessage } from "./Common"; import {Route} from "react-router-dom"; @@ -80,6 +81,8 @@ class Settings extends React.Component { +
+

diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 29ced8b3..54cd25bc 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -5,7 +5,7 @@ import Button from "semantic-ui-react/dist/commonjs/elements/Button"; import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; import Message from "semantic-ui-react/dist/commonjs/collections/Message"; import {getConfig} from "../api"; -import {useDownloaders, useHotspot} from "../hooks/customHooks"; +import {useDownloaders, useHotspot, useThrottle} from "../hooks/customHooks"; export const API_URI = `http://${window.location.host}/api`; export const VIDEOS_API = `${API_URI}/videos`; @@ -645,9 +645,9 @@ export function CardGroupCentered(props) { ) } -export function HotspotToggle() { +export function HotspotToggle(props) { let {on, setHotspot} = useHotspot(); - return ; } +export function ThrottleToggle(props) { + let {on, setThrottle} = useThrottle(); + return setThrottle(data.checked)} + />; +} + export function DisableDownloadsToggle() { let {on, setDownloads} = useDownloaders(); return { return {on, setOn, downloaders, setDownloads: localSetDownloads}; } + +export const useThrottle = () => { + const [on, setOn] = useState(null); + + const fetchThrottleStatus = async () => { + const status = await getThrottleStatus(); + if (status === 'powersave') { + setOn(true); + } else if (status === 'ondemand') { + setOn(false); + } else { + setOn(null); + } + } + + useEffect(() => { + fetchThrottleStatus(); + }, []); + + const localSetThrottle = async (on) => { + setOn(null); + await setThrottle(on); + await fetchThrottleStatus(); + } + + return {on, setOn, setThrottle: localSetThrottle}; +} diff --git a/wrolpi/admin.py b/wrolpi/admin.py index 4119ae15..14232bbf 100644 --- a/wrolpi/admin.py +++ b/wrolpi/admin.py @@ -3,7 +3,7 @@ import pathlib import subprocess from wrolpi.common import logger -from wrolpi.vars import PYTEST +from wrolpi.vars import PYTEST, DEFAULT_CPU_FREQUENCY logger = logger.getChild(__name__) @@ -11,10 +11,21 @@ logger = logger.getChild(__name__) SUDO_BIN = pathlib.Path('/usr/bin/sudo') if not SUDO_BIN.is_file() and not PYTEST: logger.error('COULD NOT FIND sudo!!!') + NMCLI_BIN = pathlib.Path('/usr/bin/nmcli') if not NMCLI_BIN.is_file() and not PYTEST: logger.error('COULD NOT FIND nmcli!!!') +CPUFREQ_INFO_BIN = pathlib.Path('/usr/bin/cpufreq-info') +if not CPUFREQ_INFO_BIN.is_file() and not PYTEST: + logger.error('COULD NOT FIND cpufreq-info!!!') + +CPUFREQ_SET_BIN = pathlib.Path('/usr/bin/cpufreq-set') +if not CPUFREQ_SET_BIN.is_file() and not PYTEST: + logger.error('COULD NOT FIND cpufreq-set!!!') + +POWER_SAVE_FREQ = 'powersave' # noqa + class HotspotStatus(enum.Enum): disconnected = enum.auto() # Radio is on, but Hotspot is not connected. @@ -65,3 +76,45 @@ def disable_hotspot(): cmd = (SUDO_BIN, NMCLI_BIN, 'radio', 'wifi', 'off') subprocess.check_call(cmd) return True + + +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 = { + 'The governor "ondemand" may decide ': GovernorStatus.ondemand, + 'The governor "powersave" may decide ': GovernorStatus.powersave, +} + + +def throttle_status() -> GovernorStatus: + cmd = (CPUFREQ_INFO_BIN,) + output = subprocess.check_output(cmd) + 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: + cmd = (SUDO_BIN, CPUFREQ_SET_BIN, '-g', POWER_SAVE_FREQ) + subprocess.check_call(cmd) + return True + + +def throttle_cpu_off() -> bool: + cmd = (SUDO_BIN, CPUFREQ_SET_BIN, '-g', DEFAULT_CPU_FREQUENCY) + subprocess.check_call(cmd) + return True diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 5e30fcc2..48987ff6 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -15,7 +15,6 @@ from sanic_ext import validate from sanic_ext.extensions.openapi import openapi from wrolpi import admin -from wrolpi.admin import HotspotStatus from wrolpi.common import set_sanic_url_parts, logger, get_config, wrol_mode_enabled, save_settings_config, \ Base, get_media_directory, wrol_mode_check, native_only from wrolpi.dates import set_timezone @@ -244,6 +243,38 @@ async def hotspot_off(_: Request): return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR) +@root_api.get('/throttle') +@openapi.description('Get status of CPU throttling') +@native_only +async def throttle_status(_: Request): + status = admin.throttle_status() + # Get the name of the enum. + status = status.name + + ret = dict(status=status) + return json_response(ret) + + +@root_api.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) + + +@root_api.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) + + class CustomJSONEncoder(json.JSONEncoder): def default(self, obj): diff --git a/wrolpi/test/test_admin.py b/wrolpi/test/test_admin.py index b5430ef4..9055e2ae 100644 --- a/wrolpi/test/test_admin.py +++ b/wrolpi/test/test_admin.py @@ -61,3 +61,19 @@ def test_enable_hotspot_connected(): call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname', 'wlan0', 'ssid', 'WROLPi', 'password', 'wrolpi hotspot'))] ) + + +def test_throttle_on(): + with mock.patch('wrolpi.admin.subprocess') as mock_subprocess: + 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: + assert admin.throttle_cpu_off() + mock_subprocess.check_call.assert_called_once_with( + (PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/cpufreq-set'), '-g', 'ondemand') + ) diff --git a/wrolpi/vars.py b/wrolpi/vars.py index c810884b..4905aba9 100644 --- a/wrolpi/vars.py +++ b/wrolpi/vars.py @@ -19,6 +19,7 @@ LAST_MODIFIED_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT' DATE_FORMAT = '%Y-%M-%d' DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S' DATETIME_FORMAT_MS = '%Y-%m-%d %H:%M:%S.%f' +DEFAULT_CPU_FREQUENCY = 'ondemand' DEFAULT_FILE_PERMISSIONS = 0o644