Skip to content

Commit 69519d8

Browse files
Determine network status based on ip command (#1827)
Related #1814. This PR refactors the backend logic of how we determine the connection status of the network interfaces. It extends the response structure so that we will be able to show IP address and MAC address in a separate network dialog. The current WiFi dialog also uses this endpoint, so there needs to be a small adjustment to accommodate the new structure there. Notes: - I renamed the `network.status()` method to `network.determine_network_status()`, to reflect that it’s now actively running a command. In order to avoid code duplication, `network.determine_network_status()` is actually just a convenience wrapper around `network.inspect_interface()`, which also encapsulates the technical interface names `eth0` and `wlan0`. I also renamed and restructured the `NetworkStatus` class to `InterfaceStatus`. - I found the JSON output structure of the `ip` command a bit hard to predict, as I saw numerous invariants of which fields were present during testing, which I wasn’t able to reproduce. I also couldn’t find reliable documentation for it. I therefore made the code inside the `network.inspect_interface()` method very defensive and failure-tolerant, to avoid producing erratic errors. I thought in the end it would be more user-friendly to show a network interface as down, even if that’s a false positive/negative, as opposed to potentially showing a lot of errors and risking to give the feature an overall brittle impression. - For consistency [with the Wake on LAN feature](https://github.com/tiny-pilot/tinypilot-pro/blob/a2ee01de5d97af4f81b3bb60e2af3f61abb8ed63/app/request_parsers/wake_on_lan.py#L14), I normalized the MAC address to use dashes as delimiter. <a data-ca-tag href="https://codeapprove.com/pr/tiny-pilot/tinypilot/1827"><img src="https://codeapprove.com/external/github-tag-allbg.png" alt="Review on CodeApprove" /></a> --------- Co-authored-by: Jan Heuermann <[email protected]>
1 parent 6b029a4 commit 69519d8

File tree

5 files changed

+249
-26
lines changed

5 files changed

+249
-26
lines changed

app/api.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -203,23 +203,58 @@ def hostname_set():
203203

204204
@api_blueprint.route('/network/status', methods=['GET'])
205205
def network_status():
206-
"""Returns the current network status (i.e., which interfaces are active).
206+
"""Returns the current status of the available network interfaces.
207207
208208
Returns:
209209
On success, a JSON data structure with the following properties:
210-
ethernet: bool.
211-
wifi: bool
210+
ethernet: object
211+
wifi: object
212+
The object contains the following fields:
213+
isConnected: bool
214+
ipAddress: string or null
215+
macAddress: string or null
212216
213217
Example:
214218
{
215-
"ethernet": true,
216-
"wifi": false
219+
"ethernet": {
220+
"isConnected": true,
221+
"ipAddress": "192.168.2.41",
222+
"macAddress": "e4-5f-01-98-65-03"
223+
},
224+
"wifi": {
225+
"isConnected": false,
226+
"ipAddress": null,
227+
"macAddress": null
228+
}
217229
}
218230
"""
219-
status = network.status()
231+
# In dev mode, return dummy data because attempting to read the actual
232+
# settings will fail in most non-Raspberry Pi OS environments.
233+
if flask.current_app.debug:
234+
return json_response.success({
235+
'ethernet': {
236+
'isConnected': True,
237+
'ipAddress': '192.168.2.8',
238+
'macAddress': '00-b0-d0-63-c2-26',
239+
},
240+
'wifi': {
241+
'isConnected': False,
242+
'ipAddress': None,
243+
'macAddress': None,
244+
},
245+
})
246+
ethernet, wifi = network.determine_network_status()
220247
return json_response.success({
221-
'ethernet': status.ethernet,
222-
'wifi': status.wifi,
248+
'ethernet': {
249+
'isConnected': ethernet.is_connected,
250+
'ipAddress': ethernet.ip_address,
251+
'macAddress': ethernet.mac_address,
252+
},
253+
'wifi': {
254+
'isConnected': wifi.is_connected,
255+
'ipAddress': wifi.ip_address,
256+
'macAddress': wifi.mac_address,
257+
},
223258
})
224259

225260

app/network.py

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import dataclasses
2+
import json
3+
import logging
24
import re
35
import subprocess
46

7+
logger = logging.getLogger(__name__)
8+
59
_WIFI_COUNTRY_PATTERN = re.compile(r'^\s*country=(.+)$')
610
_WIFI_SSID_PATTERN = re.compile(r'^\s*ssid="(.+)"$')
711

@@ -15,9 +19,10 @@ class NetworkError(Error):
1519

1620

1721
@dataclasses.dataclass
18-
class NetworkStatus:
19-
ethernet: bool
20-
wifi: bool
22+
class InterfaceStatus:
23+
is_connected: bool
24+
ip_address: str # May be `None` if interface is disabled.
25+
mac_address: str # May be `None` if interface is disabled.
2126

2227

2328
@dataclasses.dataclass
@@ -27,26 +32,76 @@ class WifiSettings:
2732
psk: str # Optional.
2833

2934

30-
def status():
35+
def determine_network_status():
3136
"""Checks the connectivity of the network interfaces.
3237
3338
Returns:
34-
NetworkStatus
39+
A tuple of InterfaceStatus objects for the Ethernet and WiFi interface.
40+
"""
41+
return inspect_interface('eth0'), inspect_interface('wlan0')
42+
43+
44+
def inspect_interface(interface_name):
45+
"""Gathers information about a network interface.
46+
47+
This method relies on the JSON output of the `ip` command. If the interface
48+
is available, the JSON structure is an array containing an object, which
49+
looks like the following (extra properties omitted for brevity):
50+
[{
51+
"operstate": "UP",
52+
"address": "e4:5f:01:98:65:05",
53+
"addr_info": [{"family":"inet", "local":"192.168.12.86"}]
54+
}]
55+
Note that `addr_info` might be empty, e.g. if `operstate` is `DOWN`;
56+
it also might contain additional families, such as `inet6` (IPv6).
57+
58+
In general, we don’t have too much trust in the consistency of the JSON
59+
structure, as there is no reliable documentation for it. We try to handle
60+
and parse the output in a defensive and graceful way, to maximize
61+
robustness and avoid producing erratic failures.
62+
63+
Args:
64+
interface_name: the technical interface name as string, e.g. `eth0`.
65+
66+
Returns:
67+
InterfaceStatus object
3568
"""
36-
network_status = NetworkStatus(False, False)
69+
status = InterfaceStatus(False, None, None)
70+
3771
try:
38-
with open('/sys/class/net/eth0/operstate', encoding='utf-8') as file:
39-
eth0 = file.read().strip()
40-
network_status.ethernet = eth0 == 'up'
41-
except OSError:
42-
pass # We treat this as if the interface was down altogether.
72+
ip_cmd_out_raw = subprocess.check_output([
73+
'ip',
74+
'-json',
75+
'address',
76+
'show',
77+
interface_name,
78+
],
79+
stderr=subprocess.STDOUT,
80+
universal_newlines=True)
81+
except subprocess.CalledProcessError as e:
82+
logger.error('Failed to run `ip` command: %s', str(e))
83+
return status
84+
4385
try:
44-
with open('/sys/class/net/wlan0/operstate', encoding='utf-8') as file:
45-
wlan0 = file.read().strip()
46-
network_status.wifi = wlan0 == 'up'
47-
except OSError:
48-
pass # We treat this as if the interface was down altogether.
49-
return network_status
86+
json_output = json.loads(ip_cmd_out_raw)
87+
except json.decoder.JSONDecodeError as e:
88+
logger.error('Failed to parse JSON output of `ip` command: %s', str(e))
89+
return status
90+
91+
if len(json_output) == 0:
92+
return status
93+
data = json_output[0]
94+
95+
if 'operstate' in data:
96+
status.is_connected = data['operstate'] == 'UP'
97+
if 'address' in data:
98+
status.mac_address = data['address'].replace(':', '-')
99+
if 'addr_info' in data:
100+
status.ip_address = next((addr_info['local']
101+
for addr_info in data['addr_info']
102+
if addr_info['family'] == 'inet'), None)
103+
104+
return status
50105

51106

52107
def determine_wifi_settings():

app/network_test.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import subprocess
2+
import unittest
3+
from unittest import mock
4+
5+
import network
6+
7+
8+
# This test checks the various potential JSON output values that the underlying
9+
# `ip` command may return.
10+
class InspectInterfaceTest(unittest.TestCase):
11+
12+
@mock.patch.object(subprocess, 'check_output')
13+
def test_treats_empty_response_as_inactive_interface(self, mock_cmd):
14+
mock_cmd.return_value = ''
15+
self.assertEqual(
16+
network.InterfaceStatus(False, None, None),
17+
network.inspect_interface('eth0'),
18+
)
19+
20+
@mock.patch.object(subprocess, 'check_output')
21+
def test_treats_empty_array_as_inactive_interface(self, mock_cmd):
22+
mock_cmd.return_value = '[]'
23+
self.assertEqual(
24+
network.InterfaceStatus(False, None, None),
25+
network.inspect_interface('eth0'),
26+
)
27+
28+
@mock.patch.object(subprocess, 'check_output')
29+
def test_treats_emtpy_object_as_inactive_interface(self, mock_cmd):
30+
mock_cmd.return_value = '[{}]'
31+
self.assertEqual(
32+
network.InterfaceStatus(False, None, None),
33+
network.inspect_interface('eth0'),
34+
)
35+
36+
@mock.patch.object(subprocess, 'check_output')
37+
def test_disregards_command_failure(self, mock_cmd):
38+
mock_cmd.side_effect = mock.Mock(
39+
side_effect=subprocess.CalledProcessError(returncode=1, cmd='ip'))
40+
self.assertEqual(
41+
network.InterfaceStatus(False, None, None),
42+
network.inspect_interface('eth0'),
43+
)
44+
45+
@mock.patch.object(subprocess, 'check_output')
46+
def test_parses_operstate_down_as_not_connected(self, mock_cmd):
47+
mock_cmd.return_value = """
48+
[{"operstate":"DOWN"}]
49+
"""
50+
self.assertEqual(
51+
network.InterfaceStatus(False, None, None),
52+
network.inspect_interface('eth0'),
53+
)
54+
55+
@mock.patch.object(subprocess, 'check_output')
56+
def test_parses_operstate_up_as_connected(self, mock_cmd):
57+
mock_cmd.return_value = """
58+
[{"operstate":"UP"}]
59+
"""
60+
self.assertEqual(
61+
network.InterfaceStatus(True, None, None),
62+
network.inspect_interface('eth0'),
63+
)
64+
65+
@mock.patch.object(subprocess, 'check_output')
66+
def test_parses_mac_address(self, mock_cmd):
67+
mock_cmd.return_value = """
68+
[{"address":"00-b0-d0-63-c2-26"}]
69+
"""
70+
self.assertEqual(
71+
network.InterfaceStatus(False, None, '00-b0-d0-63-c2-26'),
72+
network.inspect_interface('eth0'),
73+
)
74+
75+
@mock.patch.object(subprocess, 'check_output')
76+
def test_normalizes_mac_address_to_use_dashes(self, mock_cmd):
77+
mock_cmd.return_value = """
78+
[{"address":"00:b0:d0:63:c2:26"}]
79+
"""
80+
self.assertEqual(
81+
network.InterfaceStatus(False, None, '00-b0-d0-63-c2-26'),
82+
network.inspect_interface('eth0'),
83+
)
84+
85+
@mock.patch.object(subprocess, 'check_output')
86+
def test_parses_ip_address(self, mock_cmd):
87+
mock_cmd.return_value = """
88+
[{"addr_info":[{"family":"inet","local":"192.168.2.5"}]}]
89+
"""
90+
self.assertEqual(
91+
network.InterfaceStatus(False, '192.168.2.5', None),
92+
network.inspect_interface('eth0'),
93+
)
94+
95+
@mock.patch.object(subprocess, 'check_output')
96+
def test_disregards_other_families_such_as_ipv6(self, mock_cmd):
97+
mock_cmd.return_value = """
98+
[{"addr_info":[{"family":"inet6","local":"::ffff:c0a8:205"}]}]
99+
"""
100+
self.assertEqual(
101+
network.InterfaceStatus(False, None, None),
102+
network.inspect_interface('eth0'),
103+
)
104+
105+
@mock.patch.object(subprocess, 'check_output')
106+
def test_parses_all_data(self, mock_cmd):
107+
mock_cmd.return_value = """
108+
[{
109+
"operstate":"UP",
110+
"address":"00-b0-d0-63-c2-26",
111+
"addr_info":[{"family":"inet","local":"192.168.2.5"}]
112+
}]
113+
"""
114+
self.assertEqual(
115+
network.InterfaceStatus(True, '192.168.2.5', '00-b0-d0-63-c2-26'),
116+
network.inspect_interface('eth0'),
117+
)
118+
119+
@mock.patch.object(subprocess, 'check_output')
120+
def test_disregards_invalid_json(self, mock_cmd):
121+
mock_cmd.return_value = '[{"address'
122+
self.assertEqual(
123+
network.InterfaceStatus(False, None, None),
124+
network.inspect_interface('eth0'),
125+
)

app/static/js/controllers.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,14 @@ export async function getNetworkStatus() {
208208
if (!response.hasOwnProperty(field)) {
209209
throw new ControllerError(`Missing expected ${field} field`);
210210
}
211+
["isConnected", "ipAddress", "macAddress"].forEach((property) => {
212+
// eslint-disable-next-line no-prototype-builtins
213+
if (!response[field].hasOwnProperty(property)) {
214+
throw new ControllerError(
215+
`Missing expected ${field}.${property} field`
216+
);
217+
}
218+
});
211219
});
212220
return response;
213221
});

app/templates/custom-elements/wifi-dialog.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ <h3>Wi-Fi Credentials Removed</h3>
272272

273273
this._elements.noEthernetWarning.hide();
274274
this._elements.inputError.hide();
275-
if (!networkStatus.ethernet) {
275+
if (!networkStatus.ethernet.isConnected) {
276276
this._elements.noEthernetWarning.show();
277277
}
278278

0 commit comments

Comments
 (0)