Hotspot can be toggled in UI. Tested on RPi.

This commit is contained in:
Roland 2022-02-04 16:05:19 -07:00
parent d935da95f2
commit 41e89843bf
No known key found for this signature in database
GPG key ID: 9D6DBD3ACF11AB93
5 changed files with 121 additions and 223 deletions

View file

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

View file

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

View file

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

View file

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

View file

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