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