Skip to content

Commit

Permalink
WiFi dialog: add backend endpoints and logic (#1812)
Browse files Browse the repository at this point in the history
Related #131, part (c).
Stacked on #1811.

This PR adds the backend logic and endpoints that we need for the WiFi
dialog.

- There are endpoints for reading, writing, and deleting the WiFi
configuration. There is also one endpoint for checking the status of the
network connectivity, which allows us to display warnings or status
indicators in the frontend.
- As [mentioned in the code
comment](https://github.com/tiny-pilot/tinypilot/pull/1812/files#diff-43d8494a595e7d1f838bc01d5f2c6b18eebf61ca63bc3bbf2837dbcb27a8e109R62-R63),
we cannot read the `wpa_supplicant.conf` file directly due to file
ownership. Therefore, we use the [new `print-marker-sections` privileged
script](#1811) from the
previous PR.
- The scripts for enabling and disabling are executed in a “fire and
forget” manner, otherwise it may happen that the frontend requests hang
due to e.g. a change/interruption in the network (which would result in
an eternal loading spinner). As we only write a config file and trigger
service/daemon restarts, we wouldn’t get direct feedback about errors
anyway, but we’d have to poll for the resulting status, which can take
quite long, though.
- Note that we also can’t really verify the WiFi credentials, because
the desired WiFi might not be reachable at the time where the user wants
to enter the settings (e.g., because they want to enter the config ahead
of time).
- The validation of the incoming request data is relatively important,
because we will show the error messages in the frontend for input
validation. So we need to make sure that we detect all relevant
potential issues. The validation errors would be user-facing.

This PR can probably be tested best via the [subsequent frontend
PR](#1813) – that one isn’t
100% code-complete yet, but it’s pretty close in terms of functionality.
<a data-ca-tag
href="https://codeapprove.com/pr/tiny-pilot/tinypilot/1812"><img
src="https://codeapprove.com/external/github-tag-allbg.png" alt="Review
on CodeApprove" /></a>

---------

Co-authored-by: Jan Heuermann <[email protected]>
  • Loading branch information
jotaen4tinypilot and jotaen authored Jul 5, 2024
1 parent 01ac3f3 commit 2159320
Show file tree
Hide file tree
Showing 7 changed files with 442 additions and 0 deletions.
88 changes: 88 additions & 0 deletions app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import hostname
import json_response
import local_system
import network
import request_parsers.errors
import request_parsers.hostname
import request_parsers.network
import request_parsers.paste
import request_parsers.video_settings
import update.launcher
Expand Down Expand Up @@ -199,6 +201,92 @@ def hostname_set():
return json_response.error(e), 500


@api_blueprint.route('/network/status', methods=['GET'])
def network_status():
"""Returns the current network status (i.e., which interfaces are active).
Returns:
On success, a JSON data structure with the following properties:
ethernet: bool.
wifi: bool
Example:
{
"ethernet": true,
"wifi": false
}
"""
status = network.status()
return json_response.success({
'ethernet': status.ethernet,
'wifi': status.wifi,
})


@api_blueprint.route('/network/settings/wifi', methods=['GET'])
def network_wifi_get():
"""Returns the current WiFi settings, if present.
Returns:
On success, a JSON data structure with the following properties:
countryCode: string.
ssid: string.
Example:
{
"countryCode": "US",
"ssid": "my-network"
}
Returns an error object on failure.
"""
wifi_settings = network.determine_wifi_settings()
return json_response.success({
'countryCode': wifi_settings.country_code,
'ssid': wifi_settings.ssid,
})


@api_blueprint.route('/network/settings/wifi', methods=['PUT'])
def network_wifi_enable():
"""Enables a wireless network connection.
Expects a JSON data structure in the request body that contains a country
code, an SSID, and optionally a password; all as strings. Example:
{
"countryCode": "US",
"ssid": "my-network",
"psk": "sup3r-s3cr3t!"
}
Returns:
Empty response on success, error object otherwise.
"""
try:
wifi_settings = request_parsers.network.parse_wifi_settings(
flask.request)
network.enable_wifi(wifi_settings)
return json_response.success()
except request_parsers.errors.Error as e:
return json_response.error(e), 400
except network.Error as e:
return json_response.error(e), 500


@api_blueprint.route('/network/settings/wifi', methods=['DELETE'])
def network_wifi_disable():
"""Disables the WiFi network connection.
Returns:
Empty response on success, error object otherwise.
"""
try:
network.disable_wifi()
return json_response.success()
except network.Error as e:
return json_response.error(e), 500


@api_blueprint.route('/status', methods=['GET'])
def status_get():
"""Checks the status of TinyPilot.
Expand Down
128 changes: 128 additions & 0 deletions app/network.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import dataclasses
import re
import subprocess

_WIFI_COUNTRY_PATTERN = re.compile(r'^\s*country=(.+)$')
_WIFI_SSID_PATTERN = re.compile(r'^\s*ssid="(.+)"$')


class Error(Exception):
pass


class NetworkError(Error):
pass


@dataclasses.dataclass
class NetworkStatus:
ethernet: bool
wifi: bool


@dataclasses.dataclass
class WifiSettings:
country_code: str
ssid: str
psk: str # Optional.


def status():
"""Checks the connectivity of the network interfaces.
Returns:
NetworkStatus
"""
network_status = NetworkStatus(False, False)
try:
with open('/sys/class/net/eth0/operstate', encoding='utf-8') as file:
eth0 = file.read().strip()
network_status.ethernet = eth0 == 'up'
except OSError:
pass # We treat this as if the interface was down altogether.
try:
with open('/sys/class/net/wlan0/operstate', encoding='utf-8') as file:
wlan0 = file.read().strip()
network_status.wifi = wlan0 == 'up'
except OSError:
pass # We treat this as if the interface was down altogether.
return network_status


def determine_wifi_settings():
"""Determines the current WiFi settings (if set).
Returns:
WifiSettings: if the `ssid` and `country_code` attributes are `None`,
there is no WiFi configuration present. The `psk` property is
always `None` for security reasons.
"""
try:
# We cannot read the wpa_supplicant.conf file directly, because it is
# owned by the root user.
config_lines = subprocess.check_output([
'sudo', '/opt/tinypilot-privileged/scripts/print-marker-sections',
'/etc/wpa_supplicant/wpa_supplicant.conf'
],
stderr=subprocess.STDOUT,
universal_newlines=True)
except subprocess.CalledProcessError as e:
raise NetworkError(str(e.output).strip()) from e

wifi = WifiSettings(None, None, None)
for line in config_lines.splitlines():
match_country = _WIFI_COUNTRY_PATTERN.search(line.strip())
if match_country:
wifi.country_code = match_country.group(1)
continue
match_ssid = _WIFI_SSID_PATTERN.search(line.strip())
if match_ssid:
wifi.ssid = match_ssid.group(1)
continue
return wifi


def enable_wifi(wifi_settings):
"""Enables a wireless network connection.
Note: The function is executed in a "fire and forget" manner, to prevent
the HTTP request from failing erratically due to a network interruption.
Args:
wifi_settings: The new, desired settings (of type WifiSettings)
Raises:
NetworkError
"""
args = [
'sudo', '/opt/tinypilot-privileged/scripts/enable-wifi', '--country',
wifi_settings.country_code, '--ssid', wifi_settings.ssid
]
if wifi_settings.psk:
args.extend(['--psk', wifi_settings.psk])
try:
# Ignore pylint since we're not managing the child process.
# pylint: disable=consider-using-with
subprocess.Popen(args)
except subprocess.CalledProcessError as e:
raise NetworkError(str(e.output).strip()) from e


def disable_wifi():
"""Removes the WiFi settings and disables the wireless connection.
Note: The function is executed in a "fire and forget" manner, to prevent
the HTTP request from failing erratically due to a network interruption.
Raises:
NetworkError
"""
try:
# Ignore pylint since we're not managing the child process.
# pylint: disable=consider-using-with
subprocess.Popen([
'sudo',
'/opt/tinypilot-privileged/scripts/disable-wifi',
])
except subprocess.CalledProcessError as e:
raise NetworkError(str(e.output).strip()) from e
4 changes: 4 additions & 0 deletions app/request_parsers/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ class InvalidHostnameError(Error):
code = 'INVALID_HOSTNAME'


class InvalidWifiSettings(Error):
code = 'INVALID_WIFI_SETTINGS'


class InvalidVideoSettingError(Error):
pass

Expand Down
48 changes: 48 additions & 0 deletions app/request_parsers/network.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import network
from request_parsers import errors
from request_parsers import json


def parse_wifi_settings(request):
"""Parses WiFi settings from the request.
Returns:
WifiSettings
Raises:
InvalidWifiSettings
"""
# pylint: disable=unbalanced-tuple-unpacking
(
country_code,
ssid,
psk,
) = json.parse_json_body(request,
required_fields=['countryCode', 'ssid', 'psk'])

if not isinstance(country_code, str):
raise errors.InvalidWifiSettings('The country code must be a string.')
if len(country_code) != 2:
# The ISO 3166-1 alpha-2 standard theoretically allows any 2-digit
# combination, so we don’t have to eagerly restrict this.
raise errors.InvalidWifiSettings(
'The country code must consist of 2 characters.')
if not country_code.isalpha():
raise errors.InvalidWifiSettings(
'The country code must only contain letters.')

if not isinstance(ssid, str):
raise errors.InvalidWifiSettings('The SSID must be a string.')
if len(ssid) == 0:
raise errors.InvalidWifiSettings('The SSID cannot be empty.')

if psk is not None:
if not isinstance(psk, str):
raise errors.InvalidWifiSettings('The password must be a string.')
if len(psk) < 8 or len(psk) > 63:
# Note: this constraint is imposed by the WPA2 standard. We need
# to enforce this to prevent underlying commands from failing.
raise errors.InvalidWifiSettings(
'The password must consist of 8-63 characters.')

return network.WifiSettings(country_code.upper(), ssid, psk)
Loading

0 comments on commit 2159320

Please sign in to comment.