From f27e9fdf6300f296aab6e95130cf5bd386b16632 Mon Sep 17 00:00:00 2001 From: Roland Date: Fri, 4 Feb 2022 09:41:08 -0700 Subject: [PATCH 1/7] Created toggle for hotspot in admin settings. User needs sudo privileges. --- app/src/api.js | 40 ++++++++- app/src/components/Admin.js | 22 +++++ app/src/hooks/customHooks.js | 35 +++++++- wrolpi/admin.py | 90 ++++++++++++++++++++ wrolpi/common.py | 16 +++- wrolpi/errors.py | 11 ++- wrolpi/root_api.py | 32 ++++++- wrolpi/test/test_admin.py | 158 +++++++++++++++++++++++++++++++++++ 8 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 wrolpi/admin.py create mode 100644 wrolpi/test/test_admin.py diff --git a/app/src/api.js b/app/src/api.js index 0cf521b2..c7b84a1a 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -34,7 +34,8 @@ async function apiCall(url, method, body, ms = 10000) { // await the response or error. let response = await timeoutPromise(ms, promise); let copy = response.clone(); - if (response.status === 403 && (await copy.json())['code'] === 17) { + let code = (await copy.json())['code']; + if (response.status === 403 && code === 17) { toast({ type: 'error', title: 'WROL Mode is enabled!', @@ -423,3 +424,40 @@ export async function getVersion() { let data = await response.json(); return data['version']; } + +export async function getDevices() { + let response = await apiGet(`${API_URI}/hotspot`); + if (response.status === 200) { + let {devices} = await response.json(); + return devices; + } 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; + } + } + return null; +} + +export async function setHotspot(on) { + let response; + if (on) { + response = await apiPost(`${API_URI}/hotspot/on`); + } else { + response = await apiPost(`${API_URI}/hotspot/off`); + } + if (response.status === 204) { + return null; + } else { + let code = (await response.json())['code']; + if (code === 34) { + toast({ + type: 'error', + title: 'Unsupported!', + description: 'Hotspot is only supported on a Raspberry Pi!', + time: 5000, + }); + } + } +} diff --git a/app/src/components/Admin.js b/app/src/components/Admin.js index 811c10f2..584dda4b 100644 --- a/app/src/components/Admin.js +++ b/app/src/components/Admin.js @@ -24,6 +24,22 @@ import { import TimezoneSelect from 'react-timezone-select'; import {PageContainer, secondsToDate, secondsToFrequency, TabLinks, textEllipsis, WROLModeMessage} from "./Common"; import {Route} from "react-router-dom"; +import {useHotspot} from "../hooks/customHooks"; + +function HotspotToggle() { + let {on, setHotspot} = useHotspot(); + return ( + <> + setHotspot(data.checked)} + /> + + ); +} class Settings extends React.Component { @@ -78,6 +94,12 @@ class Settings extends React.Component { return ( + +

+ +

+ +

The global settings for your server.

diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 3e22f71c..51f52017 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -1,5 +1,14 @@ import {useEffect, useState} from "react"; -import {fetchDomains, filesSearch, getFiles, getVersion, searchArchives, searchVideos} from "../api"; +import { + fetchDomains, + filesSearch, + getDevices, + getFiles, + getVersion, + searchArchives, + searchVideos, + setHotspot +} from "../api"; import {useHistory} from "react-router-dom"; export const useSearchParam = (key, defaultValue = null) => { @@ -178,3 +187,27 @@ export const useBrowseFiles = () => { return {browseFiles, openFolders, setOpenFolders, fetchFiles}; } + +export const useHotspot = () => { + const [on, setOn] = useState(null); + + const fetchDevices = async () => { + const devices = await getDevices(); + if (devices && devices['wlan0']) { + let wlan0 = devices['wlan0']; + setOn(wlan0['connection'] === 'connected'); + } + } + + useEffect(() => { + fetchDevices(); + }); + + const localSetHotspot = async (on) => { + setOn(null); + setHotspot(on); + fetchDevices(); + } + + return {on, setOn, setHotspot: localSetHotspot}; +} diff --git a/wrolpi/admin.py b/wrolpi/admin.py new file mode 100644 index 00000000..4dbeb4c5 --- /dev/null +++ b/wrolpi/admin.py @@ -0,0 +1,90 @@ +import pathlib +import re +import subprocess + +from wrolpi.common import logger +from wrolpi.vars import PYTEST + +logger = logger.getChild(__name__) + +# Location on an RPi +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!!!') + +DEVICE_NAMES = ('wlan0', 'eth0') + +DEVICE_MATCH = re.compile(r'^(.+?): (connected|unavailable).*$') +MAC_MATCH = re.compile(r'.+?(\w+?) \(.+?\), ([0-9A-F:]{17}).*') +INET_MATCH = re.compile(r'.+?(inet[46]) ([a-f0-9\./:]+).*') + + +def parse_nmcli_status(status: bytes) -> dict: + status = status.decode() + + parsed = dict() + device = dict() + device_name = None + for line in status.splitlines(): + if match := DEVICE_MATCH.match(line): + device_name, connection = match.groups() + if device_name not in DEVICE_NAMES: + # We don't care about this device. + device_name = None + continue + device['connection'] = connection + elif match := MAC_MATCH.match(line): + kind, mac = match.groups() + device['kind'] = kind + device['mac'] = mac + elif match := INET_MATCH.match(line): + net, ip = match.groups() + if net == 'inet4': + device[net] = ip + elif net in device: + device[net].append(ip) + else: + device[net] = [ip, ] + elif line == '' and device_name: + # Completed this device block + parsed[device_name] = device + device = dict() + device_name = None + + return parsed + + +def hotspot_status(): + cmd = (NMCLI_BIN,) + output = subprocess.check_output(cmd, stderr=subprocess.PIPE, timeout=10) + + parsed = parse_nmcli_status(output) + + devices = dict() + for device_name, status in parsed.items(): + devices[device_name] = dict( + kind=status.get('kind'), + inet4=status.get('inet4'), + inet6=status.get('inet6'), + connection=status.get('connection'), + ) + return devices + + +def hotspot_on(): + cmd = (SUDO_BIN, NMCLI_BIN, 'radio', 'wifi', 'on') + code = subprocess.check_call(cmd) + if code == 0: + return True + return False + + +def hotspot_off(): + cmd = (SUDO_BIN, NMCLI_BIN, 'radio', 'wifi', 'off') + code = subprocess.check_call(cmd) + if code == 0: + return True + return False diff --git a/wrolpi/common.py b/wrolpi/common.py index 21808a30..df217dd6 100644 --- a/wrolpi/common.py +++ b/wrolpi/common.py @@ -27,8 +27,9 @@ from sqlalchemy import types from sqlalchemy.ext.compiler import compiles from sqlalchemy.ext.declarative import declarative_base -from wrolpi.errors import WROLModeEnabled -from wrolpi.vars import CONFIG_PATH, EXAMPLE_CONFIG_PATH, PUBLIC_HOST, PUBLIC_PORT, PROJECT_DIR, PYTEST, MODULES_DIR +from wrolpi.errors import WROLModeEnabled, NativeOnly +from wrolpi.vars import CONFIG_PATH, EXAMPLE_CONFIG_PATH, PUBLIC_HOST, PUBLIC_PORT, PROJECT_DIR, PYTEST, MODULES_DIR, \ + DOCKERIZED logger = logging.getLogger() ch = logging.StreamHandler() @@ -655,3 +656,14 @@ INVALID_FILE_CHARS = re.compile(r'[/<>:\|"\\\?\*]') def escape_file_name(name: str) -> str: """Replace any invalid characters in a file name with "_".""" return INVALID_FILE_CHARS.sub('', name) + + +def native_only(func: callable): + """Wraps a function. Raises NativeOnly if run while Dockerized.""" + @wraps(func) + def wrapped(*a, **kw): + if DOCKERIZED: + raise NativeOnly('Only supported on a Raspberry Pi') + return func(*a, **kw) + + return wrapped diff --git a/wrolpi/errors.py b/wrolpi/errors.py index bccb667f..0d5eff62 100644 --- a/wrolpi/errors.py +++ b/wrolpi/errors.py @@ -140,6 +140,10 @@ class InvalidFile(APIError): pass +class NativeOnly(APIError): + pass + + error_codes = iter(range(1, 1000)) API_ERRORS = { @@ -307,5 +311,10 @@ API_ERRORS = { 'code': next(error_codes), 'message': 'File does not exist or is a directory', 'status': HTTPStatus.BAD_REQUEST, - } + }, + NativeOnly: { + 'code': next(error_codes), + 'message': 'This functionality is only supported outside a docker container', + 'status': HTTPStatus.BAD_REQUEST, + }, } diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 2f94f392..bc4647db 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -14,8 +14,9 @@ from sanic.response import HTTPResponse from sanic_ext import validate from sanic_ext.extensions.openapi import openapi +from wrolpi import admin from wrolpi.common import set_sanic_url_parts, logger, get_config, wrol_mode_enabled, save_settings_config, \ - Base, get_media_directory, wrol_mode_check + Base, get_media_directory, wrol_mode_check, native_only from wrolpi.dates import set_timezone from wrolpi.downloader import download_manager from wrolpi.errors import WROLModeEnabled, InvalidTimezone, API_ERRORS, APIError, ValidationError @@ -210,6 +211,35 @@ async def get_downloaders(_: Request): return json_response(ret) +@root_api.get('/hotspot') +@native_only +@openapi.description('Get the status of the hotspot') +async def hotspot(_: Request): + devices = admin.hotspot_status() + ret = dict(devices=devices) + return json_response(ret) + + +@root_api.post('/hotspot/on') +@openapi.description('Turn on the hotspot') +@native_only +async def hotspot_on(_: Request): + result = admin.hotspot_off() + if result: + return response.empty() + return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR) + + +@root_api.post('/hotspot/off') +@openapi.description('Turn off the hotspot') +@native_only +async def hotspot_(_: Request): + result = admin.hotspot_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 new file mode 100644 index 00000000..29e02011 --- /dev/null +++ b/wrolpi/test/test_admin.py @@ -0,0 +1,158 @@ +from pathlib import PosixPath +from unittest import mock + +from wrolpi import admin + +nmcli_status_example1 = b'''wlan0: connected to Hotspot + "Broadcom BCM43438 combo and Bluetooth Low Energy" + wifi (brcmfmac), AB:CD:EF:F1:23:45, hw, mtu 1500 + inet4 192.168.0.1/24 + route4 192.168.0.0/24 + route4 0.0.0.0/0 + inet6 fe80::5a1b:7312:ab3:80e7/64 + route6 fe80::/64 + +eth0: connected to Wired connection 1 + "eth0" + ethernet (bcmgenet), AB:CD:EF:F1:23:45, hw, mtu 1500 + ip4 default + inet4 10.0.0.6/24 + route4 0.0.0.0/0 + route4 10.0.0.0/24 + inet6 fd57:d3d7:5699:4a24:b1ec:31b8:1f7a:fa13/64 + inet6 fe80::611b:154f:8022:c8ca/64 + route6 fdbd:1f76:f9a5::/64 + route6 fd57:d3d7:5699:4a24::/64 + route6 fe80::/64 + +p2p-dev-wlan0: disconnected + "p2p-dev-wlan0" + wifi-p2p, hw + +lo: unmanaged + "lo" + loopback (unknown), 00:00:00:00:00:00, sw, mtu 65536 + +DNS configuration: + servers: 10.0.0.1 + interface: eth0 + +Use "nmcli device show" to get complete information about known devices and +"nmcli connection show" to get an overview on active connection profiles. + +Consult nmcli(1) and nmcli-examples(7) manual pages for complete usage details. +''' + +nmcli_status_example2 = b'''eth0: connected to Wired connection 1 + "eth0" + ethernet (bcmgenet), AB:CD:EF:F1:23:45, hw, mtu 1500 + ip4 default + inet4 10.0.0.6/24 + route4 0.0.0.0/0 + route4 10.0.0.0/24 + inet6 fd57:d3d7:5699:4a24:b1ec:31b8:1f7a:fa13/64 + inet6 fe80::611b:154f:8022:c8ca/64 + route6 fdbd:1f76:f9a5::/64 + route6 fd57:d3d7:5699:4a24::/64 + route6 fe80::/64 + +wlan0: unavailable + "Broadcom BCM43438 combo and Bluetooth Low Energy" + wifi (brcmfmac), AB:CD:EF:F1:23:45, sw disabled, hw, mtu 1500 + +p2p-dev-wlan0: unavailable + "p2p-dev-wlan0" + wifi-p2p, sw disabled, hw + +lo: unmanaged + "lo" + loopback (unknown), 00:00:00:00:00:00, sw, mtu 65536 + +DNS configuration: + servers: 10.0.0.1 + interface: eth0 + +Use "nmcli device show" to get complete information about known devices and +"nmcli connection show" to get an overview on active connection profiles. + +Consult nmcli(1) and nmcli-examples(7) manual pages for complete usage details. +''' + + +def test_parse_nmcli_status(): + result = admin.parse_nmcli_status(nmcli_status_example1) + assert result == { + 'wlan0': { + 'connection': 'connected', + 'kind': 'wifi', + 'mac': 'AB:CD:EF:F1:23:45', + 'inet4': '192.168.0.1/24', + 'inet6': ['fe80::5a1b:7312:ab3:80e7/64'], + }, + 'eth0': { + 'connection': 'connected', + 'kind': 'ethernet', + 'mac': 'AB:CD:EF:F1:23:45', + 'inet4': '10.0.0.6/24', + 'inet6': ['fd57:d3d7:5699:4a24:b1ec:31b8:1f7a:fa13/64', 'fe80::611b:154f:8022:c8ca/64'], + }, + } + + result = admin.parse_nmcli_status(nmcli_status_example2) + assert result == { + 'wlan0': { + 'connection': 'unavailable', + 'kind': 'wifi', + 'mac': 'AB:CD:EF:F1:23:45', + }, + 'eth0': { + 'connection': 'connected', + 'kind': 'ethernet', + 'mac': 'AB:CD:EF:F1:23:45', + 'inet4': '10.0.0.6/24', + 'inet6': ['fd57:d3d7:5699:4a24:b1ec:31b8:1f7a:fa13/64', 'fe80::611b:154f:8022:c8ca/64'], + }, + } + + +def test_hotspot_status(): + with mock.patch('wrolpi.admin.subprocess') as mock_subprocess: + mock_subprocess.check_output.return_value = nmcli_status_example1 + assert admin.hotspot_status() == { + 'wlan0': { + 'connection': 'connected', + 'kind': 'wifi', + 'inet4': '192.168.0.1/24', + 'inet6': ['fe80::5a1b:7312:ab3:80e7/64'], + }, + 'eth0': { + 'connection': 'connected', + 'kind': 'ethernet', + 'inet4': '10.0.0.6/24', + 'inet6': ['fd57:d3d7:5699:4a24:b1ec:31b8:1f7a:fa13/64', 'fe80::611b:154f:8022:c8ca/64'], + } + } + + +def test_hotspot_on(): + with mock.patch('wrolpi.admin.subprocess') as mock_subprocess: + mock_subprocess.check_call.return_value = 0 + assert admin.hotspot_on() is True + mock_subprocess.check_call.assert_called_once_with( + (PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'on') + ) + + mock_subprocess.check_call.return_value = 1 + assert admin.hotspot_on() is False + + +def test_hotspot_off(): + with mock.patch('wrolpi.admin.subprocess') as mock_subprocess: + mock_subprocess.check_call.return_value = 0 + assert admin.hotspot_off() is True + mock_subprocess.check_call.assert_called_once_with( + (PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'off') + ) + + mock_subprocess.check_call.return_value = 1 + assert admin.hotspot_off() is False From f9b98efa8b3398866298c63705ef91a9d63f1eec Mon Sep 17 00:00:00 2001 From: Roland Date: Fri, 4 Feb 2022 10:37:59 -0700 Subject: [PATCH 2/7] Removing /etc/hosts because this does not work in docker. --- docker/archive/Dockerfile | 3 --- 1 file changed, 3 deletions(-) diff --git a/docker/archive/Dockerfile b/docker/archive/Dockerfile index 5370355f..e393fc18 100644 --- a/docker/archive/Dockerfile +++ b/docker/archive/Dockerfile @@ -17,7 +17,4 @@ COPY docker/archive /app # Install Readability too RUN npm install -g 'git+https://github.com/pirate/readability-extractor' -# Block ads using /etc/hosts -RUN wget https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts -O /etc/hosts - ENTRYPOINT [ "python3", "/app/main.py"] From 37bd4f7481d1a9462745d99fefc665e3bbd487db Mon Sep 17 00:00:00 2001 From: Roland Date: Fri, 4 Feb 2022 10:52:33 -0700 Subject: [PATCH 3/7] Downloader is optional. --- wrolpi/schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wrolpi/schema.py b/wrolpi/schema.py index 3c41ed6f..2a471686 100644 --- a/wrolpi/schema.py +++ b/wrolpi/schema.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List +from typing import List, Optional @dataclass @@ -52,7 +52,7 @@ class EventsResponse: @dataclass class DownloadRequest: urls: str - downloader: str + downloader: Optional[str] = None @dataclass From 2ed3ef9d00d20de4cc2dda5c982353ea80f684cc Mon Sep 17 00:00:00 2001 From: Roland Date: Fri, 4 Feb 2022 11:03:44 -0700 Subject: [PATCH 4/7] Upgrading yt-dlp. --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d204d37d..12a12dc3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,4 +19,4 @@ SQLAlchemy==1.3.22 srt==3.5.1 websockets==10.1 webvtt-py==0.4.6 -yt-dlp==2022.1.21 +yt-dlp==2022.2.4 From d935da95f298fa01d605bd58df34b9f77eb285b9 Mon Sep 17 00:00:00 2001 From: Roland Date: Fri, 4 Feb 2022 13:55:13 -0700 Subject: [PATCH 5/7] Changing download enable/disable button into a toggle switch. --- app/src/api.js | 16 +++++- app/src/components/Admin.js | 97 +++++++----------------------------- app/src/components/Common.js | 24 ++++++++- app/src/hooks/customHooks.js | 36 +++++++++++-- 4 files changed, 87 insertions(+), 86 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index c7b84a1a..b28831f5 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -33,6 +33,11 @@ async function apiCall(url, method, body, ms = 10000) { try { // await the response or error. let response = await timeoutPromise(ms, promise); + if (200 <= response.status < 300) { + // Request was successful. + return response; + } + // Request encountered an error. let copy = response.clone(); let code = (await copy.json())['code']; if (response.status === 403 && code === 17) { @@ -360,17 +365,24 @@ export async function killDownload(download_id) { export async function killDownloads() { let response = await apiPost(`${API_URI}/download/kill`); + console.log('killDownloads', response); return response; } export async function startDownloads() { let response = await apiPost(`${API_URI}/download/enable`); + console.log('startDownloads', response); return response; } export async function getDownloaders() { - let response = await apiGet(`${API_URI}/downloaders`); - return await response.json(); + try { + let response = await apiGet(`${API_URI}/downloaders`); + return await response.json(); + } catch (e) { + console.log('getDownloaders', e); + return {}; + } } const replaceFileDatetimes = (files) => { diff --git a/app/src/components/Admin.js b/app/src/components/Admin.js index 584dda4b..3ff10020 100644 --- a/app/src/components/Admin.js +++ b/app/src/components/Admin.js @@ -11,35 +11,19 @@ import { Placeholder, Table } from "semantic-ui-react"; -import { - getConfig, - getDownloaders, - getDownloads, - killDownload, - killDownloads, - postDownload, - saveConfig, - startDownloads -} from "../api"; +import {getConfig, getDownloads, killDownload, postDownload, saveConfig} from "../api"; import TimezoneSelect from 'react-timezone-select'; -import {PageContainer, secondsToDate, secondsToFrequency, TabLinks, textEllipsis, WROLModeMessage} from "./Common"; +import { + DisableDownloadsToggle, + HotspotToggle, + PageContainer, + secondsToDate, + secondsToFrequency, + TabLinks, + textEllipsis, + WROLModeMessage +} from "./Common"; import {Route} from "react-router-dom"; -import {useHotspot} from "../hooks/customHooks"; - -function HotspotToggle() { - let {on, setHotspot} = useHotspot(); - return ( - <> - setHotspot(data.checked)} - /> - - ); -} class Settings extends React.Component { @@ -95,9 +79,7 @@ class Settings extends React.Component { -

- -

+

@@ -327,17 +309,6 @@ class Downloads extends React.Component { async componentDidMount() { await this.fetchDownloads(); - await this.fetchStatus(); - } - - killDownloads = async () => { - await killDownloads(); - this.setState({stopOpen: false}, this.fetchStatus); - } - - startDownloads = async () => { - await startDownloads(); - this.setState({stopOpen: false}, this.fetchStatus); } closeStop = () => { @@ -357,11 +328,6 @@ class Downloads extends React.Component { }); } - fetchStatus = async () => { - let data = await getDownloaders(); - this.setState({disabled: data['manager_disabled']}); - } - render() { let tablePlaceholder = ( @@ -400,7 +366,9 @@ class Downloads extends React.Component { onceTable = ( {stoppableHeader} - {this.state.once_downloads.map((i) => )} + {this.state.once_downloads.map((i) => + + )}
); } @@ -412,46 +380,15 @@ class Downloads extends React.Component { recurringTable = ( {nonStoppableHeader} - {this.state.recurring_downloads.map((i) => )} + {this.state.recurring_downloads.map((i) => )}
); } - let {stopOpen} = this.state; - let allButton = (<> - - - - ); - if (this.state.disabled === true) { - allButton = (<> - - - ); - } - return (

- {allButton} +
Downloads
{onceTable} diff --git a/app/src/components/Common.js b/app/src/components/Common.js index cd6f2e8a..5316c209 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -1,10 +1,11 @@ import React, {useEffect, useState} from "react"; -import {Card, Container, Form, Icon, Image, Input, Menu, Pagination, Responsive} from 'semantic-ui-react'; +import {Card, Checkbox, Container, Form, Icon, Image, Input, Menu, Pagination, Responsive} from 'semantic-ui-react'; import {Link, NavLink} from "react-router-dom"; 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"; export const API_URI = `http://${window.location.host}/api`; export const VIDEOS_API = `${API_URI}/videos`; @@ -643,3 +644,24 @@ export function CardGroupCentered(props) { ) } + +export function HotspotToggle() { + let {on, setHotspot} = useHotspot(); + return setHotspot(data.checked)} + />; +} + +export function DisableDownloadsToggle() { + let {on, setDownloads} = useDownloaders(); + return setDownloads(data.checked)} + />; +} diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 51f52017..06917b25 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -3,11 +3,14 @@ import { fetchDomains, filesSearch, getDevices, + getDownloaders, getFiles, getVersion, + killDownloads, searchArchives, searchVideos, - setHotspot + setHotspot, + startDownloads } from "../api"; import {useHistory} from "react-router-dom"; @@ -205,9 +208,36 @@ export const useHotspot = () => { const localSetHotspot = async (on) => { setOn(null); - setHotspot(on); - fetchDevices(); + await setHotspot(on); + await fetchDevices(); } return {on, setOn, setHotspot: localSetHotspot}; } + +export const useDownloaders = () => { + const [on, setOn] = useState(null); + const [downloaders, setDownloaders] = useState(null); + + const fetchDownloaders = async () => { + let data = await getDownloaders(); + setDownloaders(data['downloaders']); + setOn(!data['manager_disabled']); + } + + useEffect(() => { + fetchDownloaders(); + }, []); + + const localSetDownloads = async (on) => { + setOn(null); + if (on) { + await startDownloads(); + } else { + await killDownloads(); + } + await fetchDownloaders(); + } + + return {on, setOn, downloaders, setDownloads: localSetDownloads}; +} From 41e89843bf4442c25e2ddfa0ffcdd85190a980b4 Mon Sep 17 00:00:00 2001 From: Roland Date: Fri, 4 Feb 2022 16:05:19 -0700 Subject: [PATCH 6/7] Hotspot can be toggled in UI. Tested on RPi. --- app/src/api.js | 20 +++- app/src/hooks/customHooks.js | 18 ++-- wrolpi/admin.py | 101 +++++++----------- wrolpi/root_api.py | 14 ++- wrolpi/test/test_admin.py | 191 +++++++++-------------------------- 5 files changed, 121 insertions(+), 223 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index b28831f5..142dc703 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -437,16 +437,23 @@ export async function getVersion() { return data['version']; } -export async function getDevices() { +export async function getHotspotStatus() { let response = await apiGet(`${API_URI}/hotspot`); if (response.status === 200) { - let {devices} = await response.json(); - return devices; + 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 Hotspot status", + description: 'Failed to get Hotspot status. Check client and server logs.', + time: 5000, + }); } } return null; @@ -470,6 +477,13 @@ export async function setHotspot(on) { description: 'Hotspot is only supported on a Raspberry Pi!', time: 5000, }); + } else { + toast({ + type: 'error', + title: 'Error!', + description: 'Could not modify hotspot. See server logs.', + time: 5000, + }); } } } diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 06917b25..50760fa9 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -2,7 +2,7 @@ import {useEffect, useState} from "react"; import { fetchDomains, filesSearch, - getDevices, + getHotspotStatus, getDownloaders, getFiles, getVersion, @@ -194,22 +194,20 @@ export const useBrowseFiles = () => { export const useHotspot = () => { const [on, setOn] = useState(null); - const fetchDevices = async () => { - const devices = await getDevices(); - if (devices && devices['wlan0']) { - let wlan0 = devices['wlan0']; - setOn(wlan0['connection'] === 'connected'); - } + const fetchHotspotStatus = async () => { + const status = await getHotspotStatus(); + // Wi-Fi can be: connected, disconnected, unavailable. + setOn(status === 'connected'); } useEffect(() => { - fetchDevices(); - }); + fetchHotspotStatus(); + }, []); const localSetHotspot = async (on) => { setOn(null); await setHotspot(on); - await fetchDevices(); + await fetchHotspotStatus(); } return {on, setOn, setHotspot: localSetHotspot}; diff --git a/wrolpi/admin.py b/wrolpi/admin.py index 4dbeb4c5..4119ae15 100644 --- a/wrolpi/admin.py +++ b/wrolpi/admin.py @@ -1,5 +1,5 @@ +import enum import pathlib -import re import subprocess from wrolpi.common import logger @@ -15,76 +15,53 @@ NMCLI_BIN = pathlib.Path('/usr/bin/nmcli') if not NMCLI_BIN.is_file() and not PYTEST: logger.error('COULD NOT FIND nmcli!!!') -DEVICE_NAMES = ('wlan0', 'eth0') -DEVICE_MATCH = re.compile(r'^(.+?): (connected|unavailable).*$') -MAC_MATCH = re.compile(r'.+?(\w+?) \(.+?\), ([0-9A-F:]{17}).*') -INET_MATCH = re.compile(r'.+?(inet[46]) ([a-f0-9\./:]+).*') +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. -def parse_nmcli_status(status: bytes) -> dict: - status = status.decode() - - parsed = dict() - device = dict() - device_name = None - for line in status.splitlines(): - if match := DEVICE_MATCH.match(line): - device_name, connection = match.groups() - if device_name not in DEVICE_NAMES: - # We don't care about this device. - device_name = None - continue - device['connection'] = connection - elif match := MAC_MATCH.match(line): - kind, mac = match.groups() - device['kind'] = kind - device['mac'] = mac - elif match := INET_MATCH.match(line): - net, ip = match.groups() - if net == 'inet4': - device[net] = ip - elif net in device: - device[net].append(ip) - else: - device[net] = [ip, ] - elif line == '' and device_name: - # Completed this device block - parsed[device_name] = device - device = dict() - device_name = None - - return parsed - - -def hotspot_status(): +def hotspot_status() -> HotspotStatus: cmd = (NMCLI_BIN,) - output = subprocess.check_output(cmd, stderr=subprocess.PIPE, timeout=10) + output = subprocess.check_output(cmd).decode().strip() - parsed = parse_nmcli_status(output) + for line in output.splitlines(): + if line.startswith('wlan0: connected'): + return HotspotStatus.connected + elif line.startswith('wlan0: disconnected'): + return HotspotStatus.disconnected + elif line.startswith('wlan0: unavailable'): + return HotspotStatus.unavailable - devices = dict() - for device_name, status in parsed.items(): - devices[device_name] = dict( - kind=status.get('kind'), - inet4=status.get('inet4'), - inet6=status.get('inet6'), - connection=status.get('connection'), - ) - return devices + raise Exception(f'Unknown status! {output=}') -def hotspot_on(): - cmd = (SUDO_BIN, NMCLI_BIN, 'radio', 'wifi', 'on') - code = subprocess.check_call(cmd) - if code == 0: +def enable_hotspot(): + """ + Turn the wlan0 interface into a hotspot. If wlan0 is already running, replace that with a hotspot. + """ + status = hotspot_status() + logger.warning(f'Hotspot status: {status}') + + if status == HotspotStatus.connected: + disable_hotspot() + return enable_hotspot() + 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', 'wlan0', + 'ssid', 'WROLPi', 'password', 'wrolpi hotspot') + subprocess.check_call(cmd) return True - return False + elif status == HotspotStatus.unavailable: + # Radio is not on, turn it on. + cmd = (SUDO_BIN, NMCLI_BIN, 'radio', 'wifi', 'on') + subprocess.check_call(cmd) + return enable_hotspot() -def hotspot_off(): +def disable_hotspot(): + """Turn off the wlan0 interface.""" cmd = (SUDO_BIN, NMCLI_BIN, 'radio', 'wifi', 'off') - code = subprocess.check_call(cmd) - if code == 0: - return True - return False + subprocess.check_call(cmd) + return True diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index bc4647db..5e30fcc2 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -15,6 +15,7 @@ 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 @@ -215,8 +216,11 @@ async def get_downloaders(_: Request): @native_only @openapi.description('Get the status of the hotspot') async def hotspot(_: Request): - devices = admin.hotspot_status() - ret = dict(devices=devices) + status = admin.hotspot_status() + # Get the name of the enum. + status = status.name + + ret = dict(status=status) return json_response(ret) @@ -224,7 +228,7 @@ async def hotspot(_: Request): @openapi.description('Turn on the hotspot') @native_only async def hotspot_on(_: Request): - result = admin.hotspot_off() + result = admin.enable_hotspot() if result: return response.empty() return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR) @@ -233,8 +237,8 @@ async def hotspot_on(_: Request): @root_api.post('/hotspot/off') @openapi.description('Turn off the hotspot') @native_only -async def hotspot_(_: Request): - result = admin.hotspot_off() +async def hotspot_off(_: Request): + result = admin.disable_hotspot() if result: return response.empty() return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR) diff --git a/wrolpi/test/test_admin.py b/wrolpi/test/test_admin.py index 29e02011..b5430ef4 100644 --- a/wrolpi/test/test_admin.py +++ b/wrolpi/test/test_admin.py @@ -1,158 +1,63 @@ from pathlib import PosixPath from unittest import mock +from unittest.mock import call from wrolpi import admin - -nmcli_status_example1 = b'''wlan0: connected to Hotspot - "Broadcom BCM43438 combo and Bluetooth Low Energy" - wifi (brcmfmac), AB:CD:EF:F1:23:45, hw, mtu 1500 - inet4 192.168.0.1/24 - route4 192.168.0.0/24 - route4 0.0.0.0/0 - inet6 fe80::5a1b:7312:ab3:80e7/64 - route6 fe80::/64 - -eth0: connected to Wired connection 1 - "eth0" - ethernet (bcmgenet), AB:CD:EF:F1:23:45, hw, mtu 1500 - ip4 default - inet4 10.0.0.6/24 - route4 0.0.0.0/0 - route4 10.0.0.0/24 - inet6 fd57:d3d7:5699:4a24:b1ec:31b8:1f7a:fa13/64 - inet6 fe80::611b:154f:8022:c8ca/64 - route6 fdbd:1f76:f9a5::/64 - route6 fd57:d3d7:5699:4a24::/64 - route6 fe80::/64 - -p2p-dev-wlan0: disconnected - "p2p-dev-wlan0" - wifi-p2p, hw - -lo: unmanaged - "lo" - loopback (unknown), 00:00:00:00:00:00, sw, mtu 65536 - -DNS configuration: - servers: 10.0.0.1 - interface: eth0 - -Use "nmcli device show" to get complete information about known devices and -"nmcli connection show" to get an overview on active connection profiles. - -Consult nmcli(1) and nmcli-examples(7) manual pages for complete usage details. -''' - -nmcli_status_example2 = b'''eth0: connected to Wired connection 1 - "eth0" - ethernet (bcmgenet), AB:CD:EF:F1:23:45, hw, mtu 1500 - ip4 default - inet4 10.0.0.6/24 - route4 0.0.0.0/0 - route4 10.0.0.0/24 - inet6 fd57:d3d7:5699:4a24:b1ec:31b8:1f7a:fa13/64 - inet6 fe80::611b:154f:8022:c8ca/64 - route6 fdbd:1f76:f9a5::/64 - route6 fd57:d3d7:5699:4a24::/64 - route6 fe80::/64 - -wlan0: unavailable - "Broadcom BCM43438 combo and Bluetooth Low Energy" - wifi (brcmfmac), AB:CD:EF:F1:23:45, sw disabled, hw, mtu 1500 - -p2p-dev-wlan0: unavailable - "p2p-dev-wlan0" - wifi-p2p, sw disabled, hw - -lo: unmanaged - "lo" - loopback (unknown), 00:00:00:00:00:00, sw, mtu 65536 - -DNS configuration: - servers: 10.0.0.1 - interface: eth0 - -Use "nmcli device show" to get complete information about known devices and -"nmcli connection show" to get an overview on active connection profiles. - -Consult nmcli(1) and nmcli-examples(7) manual pages for complete usage details. -''' - - -def test_parse_nmcli_status(): - result = admin.parse_nmcli_status(nmcli_status_example1) - assert result == { - 'wlan0': { - 'connection': 'connected', - 'kind': 'wifi', - 'mac': 'AB:CD:EF:F1:23:45', - 'inet4': '192.168.0.1/24', - 'inet6': ['fe80::5a1b:7312:ab3:80e7/64'], - }, - 'eth0': { - 'connection': 'connected', - 'kind': 'ethernet', - 'mac': 'AB:CD:EF:F1:23:45', - 'inet4': '10.0.0.6/24', - 'inet6': ['fd57:d3d7:5699:4a24:b1ec:31b8:1f7a:fa13/64', 'fe80::611b:154f:8022:c8ca/64'], - }, - } - - result = admin.parse_nmcli_status(nmcli_status_example2) - assert result == { - 'wlan0': { - 'connection': 'unavailable', - 'kind': 'wifi', - 'mac': 'AB:CD:EF:F1:23:45', - }, - 'eth0': { - 'connection': 'connected', - 'kind': 'ethernet', - 'mac': 'AB:CD:EF:F1:23:45', - 'inet4': '10.0.0.6/24', - 'inet6': ['fd57:d3d7:5699:4a24:b1ec:31b8:1f7a:fa13/64', 'fe80::611b:154f:8022:c8ca/64'], - }, - } +from wrolpi.admin import HotspotStatus def test_hotspot_status(): with mock.patch('wrolpi.admin.subprocess') as mock_subprocess: - mock_subprocess.check_output.return_value = nmcli_status_example1 - assert admin.hotspot_status() == { - 'wlan0': { - 'connection': 'connected', - 'kind': 'wifi', - 'inet4': '192.168.0.1/24', - 'inet6': ['fe80::5a1b:7312:ab3:80e7/64'], - }, - 'eth0': { - 'connection': 'connected', - 'kind': 'ethernet', - 'inet4': '10.0.0.6/24', - 'inet6': ['fd57:d3d7:5699:4a24:b1ec:31b8:1f7a:fa13/64', 'fe80::611b:154f:8022:c8ca/64'], - } - } + 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_hotspot_on(): - with mock.patch('wrolpi.admin.subprocess') as mock_subprocess: - mock_subprocess.check_call.return_value = 0 - assert admin.hotspot_on() is True +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_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'), 'radio', 'wifi', 'on') + (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_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'))] ) - mock_subprocess.check_call.return_value = 1 - assert admin.hotspot_on() is False - -def test_hotspot_off(): - with mock.patch('wrolpi.admin.subprocess') as mock_subprocess: - mock_subprocess.check_call.return_value = 0 - assert admin.hotspot_off() is True - mock_subprocess.check_call.assert_called_once_with( - (PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'off') +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_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'))] ) - - mock_subprocess.check_call.return_value = 1 - assert admin.hotspot_off() is False From 703f916c5381b75db692911df0af6ab7f602da77 Mon Sep 17 00:00:00 2001 From: Roland Date: Fri, 4 Feb 2022 16:18:11 -0700 Subject: [PATCH 7/7] Preserving null output status to represent unsupported device. --- app/src/components/Common.js | 3 +-- app/src/hooks/customHooks.js | 14 +++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 5316c209..29ced8b3 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -648,8 +648,7 @@ export function CardGroupCentered(props) { export function HotspotToggle() { let {on, setHotspot} = useHotspot(); return setHotspot(data.checked)} diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 50760fa9..b6cbdc2f 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -2,9 +2,9 @@ import {useEffect, useState} from "react"; import { fetchDomains, filesSearch, - getHotspotStatus, getDownloaders, getFiles, + getHotspotStatus, getVersion, killDownloads, searchArchives, @@ -196,8 +196,16 @@ export const useHotspot = () => { const fetchHotspotStatus = async () => { const status = await getHotspotStatus(); - // Wi-Fi can be: connected, disconnected, unavailable. - setOn(status === 'connected'); + if (status === 'connected') { + // Hotspot is on. + setOn(true); + } else if (status === 'disconnected' || status === 'unavailable') { + // Hotspot can be turned on. + setOn(false); + } else { + // Hotspot is not supported. API is probably running in a Docker container. + setOn(null); + } } useEffect(() => {