From 1222a39867fcde86c329c141a0e0490199f31c8b Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 7 Feb 2022 13:37:58 -0700 Subject: [PATCH 1/5] 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 From e3c640eae010af57dcebab4ea679703c94fe1861 Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 7 Feb 2022 13:54:44 -0700 Subject: [PATCH 2/5] Modify sudo such that wrolpi user can run two commands without password. --- install.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/install.sh b/install.sh index 17d15b3a..57259850 100755 --- a/install.sh +++ b/install.sh @@ -84,6 +84,13 @@ cp /opt/wrolpi/50x.html /var/www/50x.html grep wrolpi /etc/passwd || useradd -md /home/wrolpi wrolpi -s "$(command -v bash)" chown -R wrolpi:wrolpi /opt/wrolpi +# Give WROLPi user a few privileged commands via sudo without password. +cat > /etc/sudoers.d/90-wrolpi << 'EOF' +%wrolpi ALL=(ALL) NOPASSWD:/usr/bin/nmcli,/usr/bin/cpufreq-set +EOF +# Verify this new file is valid. +visudo -c -f /etc/sudoers.d/90-wrolpi + # Create the media directory. This should be mounted by the maintainer. [ -d /media/wrolpi ] || mkdir /media/wrolpi chown -R wrolpi:wrolpi /media/wrolpi From 227d33ba78e3f517876593da11d25e2e4d1aba8e Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 7 Feb 2022 13:56:15 -0700 Subject: [PATCH 3/5] The group has access, not the user. --- install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 57259850..7b53f13f 100755 --- a/install.sh +++ b/install.sh @@ -84,7 +84,7 @@ cp /opt/wrolpi/50x.html /var/www/50x.html grep wrolpi /etc/passwd || useradd -md /home/wrolpi wrolpi -s "$(command -v bash)" chown -R wrolpi:wrolpi /opt/wrolpi -# Give WROLPi user a few privileged commands via sudo without password. +# Give WROLPi group a few privileged commands via sudo without password. cat > /etc/sudoers.d/90-wrolpi << 'EOF' %wrolpi ALL=(ALL) NOPASSWD:/usr/bin/nmcli,/usr/bin/cpufreq-set EOF From 58562f07b54ef26ce832c02f0443e9def0d5b4e5 Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 7 Feb 2022 14:00:40 -0700 Subject: [PATCH 4/5] Restricting access to new sudoers file. --- install.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/install.sh b/install.sh index 7b53f13f..5fcff407 100755 --- a/install.sh +++ b/install.sh @@ -88,6 +88,7 @@ chown -R wrolpi:wrolpi /opt/wrolpi cat > /etc/sudoers.d/90-wrolpi << 'EOF' %wrolpi ALL=(ALL) NOPASSWD:/usr/bin/nmcli,/usr/bin/cpufreq-set EOF +chmod 660 /etc/sudoers.d/90-wrolpi # Verify this new file is valid. visudo -c -f /etc/sudoers.d/90-wrolpi From 22a6d24c27dd06f563d50b1945cd23f602aaa097 Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 7 Feb 2022 14:48:53 -0700 Subject: [PATCH 5/5] Removing hotspot script in favor of UI toggle. --- install.sh | 5 +--- scripts/hotspot.sh | 61 ---------------------------------------------- 2 files changed, 1 insertion(+), 65 deletions(-) delete mode 100644 scripts/hotspot.sh diff --git a/install.sh b/install.sh index 5fcff407..b20c97ee 100755 --- a/install.sh +++ b/install.sh @@ -114,12 +114,9 @@ sudo -u postgres psql -c '\l' | grep wrolpi || ( sudo -u postgres psql -c "alter user wrolpi password 'wrolpi'" && sudo -u postgres createdb -O wrolpi wrolpi ) -# Init/upgrade the WROLPi database. +# Initialize/upgrade the WROLPi database. (cd /opt/wrolpi && /usr/bin/python3 /opt/wrolpi/main.py db upgrade) -# Configure the hotspot. -bash /opt/wrolpi/scripts/hotspot.sh - set +x ip=$(hostname -i | cut -d' ' -f1) diff --git a/scripts/hotspot.sh b/scripts/hotspot.sh deleted file mode 100644 index 9db51272..00000000 --- a/scripts/hotspot.sh +++ /dev/null @@ -1,61 +0,0 @@ -#! /usr/bin/env bash -# Configure the WROLPi hotspot. -set -x -set -e - -apt install -y network-manager - -cat >/etc/netplan/50-cloud-init.yaml <<'EOF' -# This file is generated from information provided by the datasource. Changes -# to it will not persist across an instance reboot. To disable cloud-init's -# network configuration capabilities, write a file -# /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg with the following: -# network: {config: disabled} -network: - version: 2 - renderer: NetworkManager -EOF - -cat >/etc/cloud/cloud.cfg.d/99-disable-network-config.cfg <<'EOF' -network: {config: disabled} -EOF - -netplan generate -netplan apply - -# Initialize the Hotspot config, then overwrite it. -nmcli con add type wifi ifname wlan0 con-name Hotspot autoconnect yes ssid WROLPi -cat >/etc/NetworkManager/system-connections/Hotspot.nmconnection <<'EOF' -[connection] -id=Hotspot -uuid=b72ae32e-877c-4559-aaa1-e41a7a9d7bd5 -type=wifi -interface-name=wlan0 -permissions= -timestamp=1635780305 - -[wifi] -band=bg -mac-address-blacklist= -mode=ap -seen-bssids=DC:A6:32:C7:01:EE; -ssid=WROLPi - -[wifi-security] -key-mgmt=wpa-psk -psk=wrolpi hotspot - -[ipv4] -address1=192.168.0.1/24,192.168.0.1 -dns-search= -method=shared - -[ipv6] -addr-gen-mode=stable-privacy -dns-search= -method=auto - -[proxy] -EOF - -systemctl restart NetworkManager.service