mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
Created toggle for hotspot in admin settings. User needs sudo privileges.
This commit is contained in:
parent
bc22fb6a6c
commit
f27e9fdf63
8 changed files with 398 additions and 6 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
90
wrolpi/admin.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
158
wrolpi/test/test_admin.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue