Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve BlueOS wifi service #2785

Draft
wants to merge 14 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bootstrap/startup.json.default
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"bind": "/sys/",
"mode": "rw"
},
"/var/run/wpa_supplicant/wlan0": {
"bind": "/var/run/wpa_supplicant/wlan0",
"/var/run/wpa_supplicant": {
"bind": "/var/run/wpa_supplicant",
"mode": "rw"
},
"/tmp/wpa_playground": {
Expand Down
4 changes: 2 additions & 2 deletions core/frontend/src/components/wifi/WifiUpdater.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default Vue.extend({
callPeriodically(this.fetchSavedNetworks, 5000)
callPeriodically(this.fetchNetworkStatus, 5000)
callPeriodically(this.fetchHotspotStatus, 10000)
callPeriodically(this.fetchAvailableNetworks, 20000)
callPeriodically(this.fetchAvailableNetworks, 6000)
callPeriodically(this.fetchHotspotCredentials, 10000)
},
methods: {
Expand Down Expand Up @@ -101,7 +101,7 @@ export default Vue.extend({
await back_axios({
method: 'get',
url: `${wifi.API_URL}/scan`,
timeout: 20000,
timeout: 5000,
})
.then((response) => {
const saved_networks_ssids = wifi.saved_networks?.map((network: SavedNetwork) => network.ssid)
Expand Down
46 changes: 11 additions & 35 deletions core/services/wifi/WifiManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ def connect(self, path: Any) -> None:
except Exception:
logger.exception("Could not load previous hotspot settings.")

asyncio.run(self.wpa.send_command_autoscan("periodic:10"))
asyncio.run(self.wpa.send_command_scan_interval(10))

@staticmethod
def __decode_escaped(data: bytes) -> str:
"""Decode escaped byte array
Expand Down Expand Up @@ -128,42 +131,15 @@ def hotspot(self) -> HotspotManager:

async def get_wifi_available(self) -> List[ScannedWifiNetwork]:
"""Get a dict from the wifi signals available"""
try:
await self.wpa.send_command_scan(timeout=30)
data = await self.wpa.send_command_scan_results()
networks_list = WifiManager.__dict_from_table(data)
return [ScannedWifiNetwork(**network) for network in networks_list]

async def perform_new_scan() -> None:
try:
# We store the scan task here so that new scan requests that happen in the interval
# where this one is running can check when it has finished.
# Otherwise, it could happen that a new scan is initiated before the ones waiting know
# the previous one has finished, making them stay on the loop unnecessarily.
self._scan_task = asyncio.create_task(self.wpa.send_command_scan(timeout=30))
await self._scan_task
data = await self.wpa.send_command_scan_results()
networks_list = WifiManager.__dict_from_table(data)
self._updated_scan_results = [ScannedWifiNetwork(**network) for network in networks_list]
self._time_last_scan = time.time()
except Exception as error:
if self._scan_task is not None:
self._scan_task.cancel()
self._updated_scan_results = None
raise FetchError("Failed to fetch wifi list.") from error

# Performs a new scan only if more than 30 seconds passed since last scan
if time.time() - self._time_last_scan < 30:
return self._updated_scan_results or []
# Performs a new scan only if it's the first one or the last one is already done
# In case there's one running already, wait for it to finish and use its result
if self._scan_task is None or self._scan_task.done():
await perform_new_scan()
else:
awaited_scan_task_name = self._scan_task.get_name()
while self._scan_task.get_name() == awaited_scan_task_name and not self._scan_task.done():
logger.info(f"Waiting for {awaited_scan_task_name} results.")
await asyncio.sleep(0.5)

if self._updated_scan_results is None:
raise FetchError("Failed to fetch wifi list.")

return self._updated_scan_results
except Exception as error:
logger.error(f"Failed to fetch wifi list: {error}")
raise FetchError("Failed to fetch wifi list.") from error

async def get_saved_wifi_network(self) -> List[SavedWifiNetwork]:
"""Get a list of saved wifi networks"""
Expand Down
26 changes: 24 additions & 2 deletions core/services/wifi/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import asyncio
import logging
import os
import stat
import sys
from pathlib import Path
from typing import Any, List, Optional

from aiocache import cached
from commonwealth.utils.apis import (
GenericErrorHandlingRoute,
PrettyJSONResponse,
Expand Down Expand Up @@ -55,6 +57,7 @@ async def network_status() -> Any:

@app.get("/scan", response_model=List[ScannedWifiNetwork], summary="Retrieve available wifi networks.")
@version(1, 0)
@cached(ttl=15, namespace="scan")
async def scan() -> Any:
logger.info("Trying to perform network scan.")
try:
Expand Down Expand Up @@ -231,10 +234,29 @@ def get_hotspot_credentials() -> Any:
socket_name = args.socket_name
else:
logger.info("Connecting via default socket.")
available_sockets = os.listdir(wpa_socket_folder)

def is_socket(file_path: str) -> bool:
try:
mode = os.stat(file_path).st_mode
return stat.S_ISSOCK(mode)
except Exception as error:
logger.warning(f"Could not check if '{file_path}' is a socket: {error}")
return False

# We are going to sort and get the latest file, since this in theory will be an external interface
# added by the user
entries = os.scandir(wpa_socket_folder)
available_sockets = sorted(
[
entry.path
for entry in entries
if entry.name.startswith(("wlan", "wifi", "wlp")) and is_socket(entry.path)
]
)
if not available_sockets:
raise RuntimeError("No wifi sockets available.")
socket_name = available_sockets[0]
socket_name = available_sockets[-1]
logger.info(f"Going to use {socket_name} file")
WLAN_SOCKET = os.path.join(wpa_socket_folder, socket_name)
wifi_manager.connect(WLAN_SOCKET)
except Exception as socket_connection_error:
Expand Down
1 change: 1 addition & 0 deletions core/services/wifi/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
license="MIT",
py_modules=[],
install_requires=[
"aiocache == 0.12.2",
"aiofiles == 0.6.0",
"commonwealth == 0.1.0",
"fastapi == 0.105.0",
Expand Down
43 changes: 42 additions & 1 deletion core/services/wifi/wpa_supplicant.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import socket
import time
from pathlib import Path
from threading import Lock
from typing import Optional, Tuple, Union

from loguru import logger
Expand All @@ -17,6 +18,7 @@ class WPASupplicant:

def __init__(self) -> None:
self.sock: Optional[socket.socket] = None
self.lock = Lock()

def __del__(self) -> None:
if self.sock:
Expand Down Expand Up @@ -56,12 +58,15 @@ async def send_command(self, command: str, timeout: float) -> bytes:
timeout {float} -- Maximum time (in seconds) allowed for receiving an answer before raising a BusyError
"""
assert self.sock, "No socket assigned to WPA Supplicant"
# pylint: disable=consider-using-with
self.lock.acquire()

timeout_start = time.time()
while time.time() - timeout_start < timeout:
try:
self.sock.send(command.encode("utf-8"))
data, _ = self.sock.recvfrom(self.BUFFER_SIZE)
logger.debug(data)
except Exception as error:
# Oh my, something is wrong!
# For now, let us report the error but not without recreating the socket
Expand All @@ -72,16 +77,25 @@ async def send_command(self, command: str, timeout: float) -> bytes:
self.run(self.target)
except Exception as inner_error:
logger.error(f"Failed to send command and failed to recreate wpa socket: {inner_error}")
self.lock.release()
raise SockCommError(error_message) from error

if b"FAIL-BUSY" in data:
logger.info(f"Busy during {command} operation. Trying again...")
await asyncio.sleep(0.1)
await asyncio.sleep(1)
continue
if b"CTRL-EVENT-SCAN-STARTED" in data:
logger.info(f"Scan started during {command} operation. Waiting for results...")
await asyncio.sleep(1)
continue
break
else:
self.lock.release()
raise BusyError(f"{command} operation took more than specified timeout ({timeout}). Cancelling.")

if self.lock.locked():
self.lock.release()

if data == b"FAIL":
raise WPAOperationFail(f"WPA operation {command} failed.")

Expand Down Expand Up @@ -235,6 +249,33 @@ async def send_command_disconnect(self, timeout: float = 1) -> bytes:
"""
return await self.send_command("DISCONNECT", timeout)

async def send_command_autoscan(self, parameter: str, timeout: float = 1) -> bytes:
"""Send message: AUTOSCAN

Automatic scan
This is an optional set of parameters for automatic scanning
within an interface in following format:
autoscan=<autoscan module name>:<module parameters>
autoscan is like bgscan but on disconnected or inactive state.
For instance, on exponential module parameters would be <base>:<limit>
autoscan=exponential:3:300
Which means a delay between scans on a base exponential of 3,
up to the limit of 300 seconds (3, 9, 27 ... 300)
For periodic module, parameters would be <fixed interval>
autoscan=periodic:30
So a delay of 30 seconds will be applied between each scan.
Note: If sched_scan_plans are configured and supported by the driver,
autoscan is ignored.
"""
return await self.send_command(f"AUTOSCAN {parameter}", timeout)

async def send_command_scan_interval(self, seconds: int, timeout: float = 1) -> bytes:
"""Send message: SCAN_INTERVAL

scan interval in seconds.
"""
return await self.send_command(f"SCAN_INTERVAL {seconds}", timeout)

async def send_command_scan(self, timeout: float = 1) -> bytes:
"""Send message: SCAN

Expand Down
2 changes: 1 addition & 1 deletion core/start-blueos-core
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ PRIORITY_SERVICES=(
SERVICES=(
# This services are not prioritized because they are not fundamental for the vehicle to work
'kraken',0,"nice -19 $SERVICES_PATH/kraken/main.py"
'wifi',0,"nice -19 $SERVICES_PATH/wifi/main.py --socket wlan0"
'wifi',0,"nice -19 $SERVICES_PATH/wifi/main.py"
# This services are not as important as the others
'beacon',250,"$SERVICES_PATH/beacon/main.py"
'bridget',0,"nice -19 $RUN_AS_REGULAR_USER $SERVICES_PATH/bridget/main.py"
Expand Down
11 changes: 6 additions & 5 deletions core/tools/blueos_startup_update/blueos_startup_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,17 @@
DELTA_JSON = {
"core": {
"binds": {
"/run/udev": {"bind": "/run/udev", "mode": "ro"},
"/etc/blueos": {"bind": "/etc/blueos", "mode": "rw"},
"/etc/machine-id": {"bind": "/etc/machine-id", "mode": "ro"},
"/etc/dhcpcd.conf": {"bind": "/etc/dhcpcd.conf", "mode": "rw"},
"/usr/blueos/userdata": {"bind": "/usr/blueos/userdata", "mode": "rw"},
"/usr/blueos/extensions": {"bind": "/usr/blueos/extensions", "mode": "rw"},
"/usr/blueos/bin": {"bind": "/usr/blueos/bin", "mode": "rw"},
"/etc/machine-id": {"bind": "/etc/machine-id", "mode": "ro"},
"/etc/resolv.conf.host": {"bind": "/etc/resolv.conf.host", "mode": "ro"},
"/home/pi/.ssh": {"bind": "/home/pi/.ssh", "mode": "rw"},
"/run/udev": {"bind": "/run/udev", "mode": "ro"},
"/sys/": {"bind": "/sys/", "mode": "rw"},
"/usr/blueos/bin": {"bind": "/usr/blueos/bin", "mode": "rw"},
"/usr/blueos/extensions": {"bind": "/usr/blueos/extensions", "mode": "rw"},
"/usr/blueos/userdata": {"bind": "/usr/blueos/userdata", "mode": "rw"},
"/var/run/wpa_supplicant": {"bind": "/var/run/wpa_supplicant", "mode": "rw"},
}
}
}
Expand Down