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