Created toggle for hotspot in admin settings. User needs sudo privileges.

This commit is contained in:
Roland 2022-02-04 09:41:08 -07:00
parent bc22fb6a6c
commit f27e9fdf63
No known key found for this signature in database
GPG key ID: 9D6DBD3ACF11AB93
8 changed files with 398 additions and 6 deletions

View file

@ -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,
});
}
}
}

View file

@ -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 (
<>
<Checkbox toggle
label='Toggle the WiFi Hotspot. Only supported on Raspberry Pi.'
disabled={on === null}
defaultIndeterminate={on === null}
checked={on === true}
onChange={(e, data) => setHotspot(data.checked)}
/>
</>
);
}
class Settings extends React.Component {
@ -78,6 +94,12 @@ class Settings extends React.Component {
return (
<Container fluid>
<WROLModeMessage content='Settings are disabled because WROL Mode is enabled.'/>
<p>
<HotspotToggle/>
</p>
<Divider/>
<p>
The global settings for your server.
</p>

View file

@ -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};
}

90
wrolpi/admin.py Normal file
View file

@ -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

View file

@ -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

View file

@ -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,
},
}

View file

@ -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):

158
wrolpi/test/test_admin.py Normal file
View file

@ -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