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

WiFi dialog: add dialog to the UI #1813

Merged
merged 56 commits into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from 52 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
42d06e0
Set up scripts for enabling/disabling WiFi network interface
jotaen Jun 13, 2024
d02832d
Fix bash style
jotaen Jun 13, 2024
9bbc056
Remove redundant entry
jotaen Jun 13, 2024
e245459
Add commentary
jotaen Jun 13, 2024
7637133
Read WiFi settings from CLI args
jotaen Jun 17, 2024
517af0e
Add mock scripts
jotaen Jun 17, 2024
3cf6e38
Parse CLI arguments
jotaen Jun 20, 2024
e0602d4
Allow to join open networks (no PSK)
jotaen Jun 26, 2024
7995bd4
Fix declaration of optional flags in help output
jotaen Jun 27, 2024
d6fdade
Clarify purpose of script
jotaen Jun 27, 2024
dece804
Validate length of —country flag
jotaen Jun 27, 2024
49c18cd
Simplify procedure for writing to file
jotaen Jun 27, 2024
0933a3a
Reference real scripts in comment
jotaen Jun 27, 2024
c4feb1f
Add script for printing marker section content
jotaen Jul 1, 2024
c80a86c
Add mock script
jotaen Jul 1, 2024
3937305
Add tests
jotaen Jul 1, 2024
565a7c9
Add backend endpoints and logic
jotaen Jul 1, 2024
6794c58
Add WiFi dialog (WIP)
jotaen Jul 1, 2024
e9f1f01
Fix docstring
jotaen Jul 1, 2024
810f91c
Merge branch 'wifi-dialog/2-print-marker' into wifi-dialog/3-backend
jotaen Jul 1, 2024
766b4eb
Add comment
jotaen Jul 1, 2024
e01f296
Merge branch 'wifi-dialog/3-backend' into wifi-dialog/4-frontend
jotaen Jul 1, 2024
e9dcb52
Nullify emtpy PSK
jotaen Jul 1, 2024
f9352bc
Phrasing of error message
jotaen Jul 2, 2024
e03c777
Direct to —help flag
jotaen Jul 2, 2024
25591db
Prepend >&2
jotaen Jul 2, 2024
b631631
Fix tests
jotaen Jul 2, 2024
20109f2
Merge branch 'wifi-dialog/2-print-marker' into wifi-dialog/3-backend
jotaen Jul 2, 2024
78e1db3
Merge branch 'wifi-dialog/3-backend' into wifi-dialog/4-frontend
jotaen Jul 2, 2024
210fbc4
Formatting
jotaen Jul 2, 2024
e3a3bcb
Clean ups
jotaen Jul 2, 2024
232e446
Add missing `assertRaises`
jotaen Jul 3, 2024
7ee0a82
Rephrase error message
jotaen Jul 3, 2024
e72f888
Remove unused variabel
jotaen Jul 3, 2024
1e3f474
Omit unused return
jotaen Jul 3, 2024
c813305
Declare arg type in docstring
jotaen Jul 3, 2024
b093d97
Use `splitlines` instead of `split(‘\n’)`
jotaen Jul 3, 2024
0470c5f
Simplify camelcase
jotaen Jul 3, 2024
49bbaeb
Use more explicit HTTP paths
jotaen Jul 3, 2024
9ae036c
Merge branch 'wifi-dialog/3-backend' into wifi-dialog/4-frontend
jotaen Jul 3, 2024
567560d
Adjust paths
jotaen Jul 3, 2024
ac397d6
Ignore pylint warning
jotaen Jul 3, 2024
cd42056
Merge branch 'wifi-dialog/3-backend' into wifi-dialog/4-frontend
jotaen Jul 3, 2024
5143d67
Naming, commentary
jotaen Jul 3, 2024
41ed741
Fix
jotaen Jul 3, 2024
c1e92f7
Use `Wi-Fi` spelling
jotaen Jul 4, 2024
c113678
Phrasing of intro text
jotaen Jul 4, 2024
774f81c
Input field label/placeholder
jotaen Jul 4, 2024
c046544
Phrasing of “no-ethernet” warning
jotaen Jul 4, 2024
2ccccea
Guard initialization with a try/catch
jotaen Jul 4, 2024
974ed2e
Simplify refreshButtons
jotaen Jul 4, 2024
f08ae0a
Use Promise.all
jotaen Jul 4, 2024
fe0184b
Adjust title/button wording
jotaen Jul 5, 2024
a0fc9be
Add success state
jotaen Jul 5, 2024
8b239c6
Merge branch 'master' into wifi-dialog/4-frontend
jotaen Jul 5, 2024
a53bf0c
Wording of success state
jotaen Jul 16, 2024
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
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