Skip to content

Commit 964a43e

Browse files
authored
Accomodate more interfaces in network status (#1902)
Resolves tiny-pilot/tinypilot-pro#1500 This change introduces a new process for displaying the network status of different interfaces. Instead of hardcoding the interface names (`eth0` and `wlan0`), we instead query the device for a list of network interface names backed by hardware before determining their status. This change also adjusts the API's JSON response at `/network/status` to a list of of network interface statuses with a new `name` field. Additionally, this also changes the user-facing naming of Ethernet interfaces to LAN. i.e., instead of `Ethernet` a user will see `LAN1`. <a data-ca-tag href="https://codeapprove.com/pr/tiny-pilot/tinypilot/1902"><img src="https://codeapprove.com/external/github-tag-allbg.png" alt="Review on CodeApprove" /></a>
1 parent ed7e0c2 commit 964a43e

File tree

5 files changed

+158
-81
lines changed

5 files changed

+158
-81
lines changed

app/api.py

Lines changed: 42 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -207,55 +207,59 @@ def network_status():
207207
"""Returns the current status of the available network interfaces.
208208
209209
Returns:
210-
On success, a JSON data structure with the following properties:
211-
ethernet: object
212-
wifi: object
213-
The object contains the following fields:
214-
isConnected: bool
215-
ipAddress: string or null
216-
macAddress: string or null
210+
On success, a JSON data structure with the following:
211+
interfaces: array of objects, where each object represents a network
212+
interface with the following properties:
213+
name: string
214+
isConnected: boolean
215+
ipAddress: string or null
216+
macAddress: string or null
217217
218218
Example:
219219
{
220-
"ethernet": {
221-
"isConnected": true,
222-
"ipAddress": "192.168.2.41",
223-
"macAddress": "e4-5f-01-98-65-03"
224-
},
225-
"wifi": {
226-
"isConnected": false,
227-
"ipAddress": null,
228-
"macAddress": null
229-
}
220+
"interfaces": [
221+
{
222+
"name": "eth0",
223+
"isConnected": true,
224+
"ipAddress": "192.168.2.41",
225+
"macAddress": "e4-5f-01-98-65-03"
226+
},
227+
{
228+
"name": "wlan0",
229+
"isConnected": false,
230+
"ipAddress": null,
231+
"macAddress": null
232+
}
233+
]
230234
}
231235
"""
232236
# In dev mode, return dummy data because attempting to read the actual
233237
# settings will fail in most non-Raspberry Pi OS environments.
234238
if flask.current_app.debug:
235239
return json_response.success({
236-
'ethernet': {
237-
'isConnected': True,
238-
'ipAddress': '192.168.2.8',
239-
'macAddress': '00-b0-d0-63-c2-26',
240-
},
241-
'wifi': {
242-
'isConnected': False,
243-
'ipAddress': None,
244-
'macAddress': None,
245-
},
240+
'interfaces': [
241+
{
242+
'name': 'eth0',
243+
'isConnected': True,
244+
'ipAddress': '192.168.2.41',
245+
'macAddress': 'e4-5f-01-98-65-03',
246+
},
247+
{
248+
'name': 'wlan0',
249+
'isConnected': False,
250+
'ipAddress': None,
251+
'macAddress': None,
252+
},
253+
],
246254
})
247-
ethernet, wifi = network.determine_network_status()
255+
network_interfaces = network.determine_network_status()
248256
return json_response.success({
249-
'ethernet': {
250-
'isConnected': ethernet.is_connected,
251-
'ipAddress': ethernet.ip_address,
252-
'macAddress': ethernet.mac_address,
253-
},
254-
'wifi': {
255-
'isConnected': wifi.is_connected,
256-
'ipAddress': wifi.ip_address,
257-
'macAddress': wifi.mac_address,
258-
},
257+
'interfaces': [{
258+
'name': interface.name,
259+
'isConnected': interface.is_connected,
260+
'ipAddress': interface.ip_address,
261+
'macAddress': interface.mac_address,
262+
} for interface in network_interfaces]
259263
})
260264

261265

app/network.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import logging
44
import re
55
import subprocess
6+
from pathlib import Path
67

78
logger = logging.getLogger(__name__)
89

910
_WIFI_COUNTRY_PATTERN = re.compile(r'^\s*country=(.+)$')
1011
_WIFI_SSID_PATTERN = re.compile(r'^\s*ssid="(.+)"$')
12+
_INTERFACES_DIR = '/sys/class/net'
1113

1214

1315
class Error(Exception):
@@ -20,6 +22,7 @@ class NetworkError(Error):
2022

2123
@dataclasses.dataclass
2224
class InterfaceStatus:
25+
name: str
2326
is_connected: bool
2427
ip_address: str # May be `None` if interface is disabled.
2528
mac_address: str # May be `None` if interface is disabled.
@@ -32,13 +35,42 @@ class WifiSettings:
3235
psk: str # Optional.
3336

3437

38+
def get_network_interfaces():
39+
"""Get a list of physical network interface names.
40+
41+
Excludes loopback and virtual interfaces. A device is considered "physical"
42+
if /sys/class/net/<ifname>/device exists (i.e., it’s backed by hardware).
43+
44+
Returns:
45+
A list of interface names for all available physical network interfaces.
46+
"""
47+
sys_net_path = Path(_INTERFACES_DIR)
48+
if not sys_net_path.is_dir():
49+
logger.debug('%s is not available', str(_INTERFACES_DIR))
50+
return []
51+
52+
interface_names = []
53+
for iface_path in sys_net_path.iterdir():
54+
# We know we don't want the loopback interface.
55+
if iface_path.name == 'lo':
56+
continue
57+
# If /sys/class/net/<ifname>/device exists, the interface appears
58+
# to be hardware.
59+
if (iface_path / 'device').exists():
60+
interface_names.append(iface_path.name)
61+
62+
return sorted(interface_names)
63+
64+
3565
def determine_network_status():
3666
"""Checks the connectivity of the network interfaces.
3767
3868
Returns:
39-
A tuple of InterfaceStatus objects for the Ethernet and WiFi interface.
69+
A list of InterfaceStatus objects for all available Ethernet and Wi-Fi
70+
network interfaces.
4071
"""
41-
return inspect_interface('eth0'), inspect_interface('wlan0')
72+
interfaces = get_network_interfaces()
73+
return [inspect_interface(iface) for iface in interfaces]
4274

4375

4476
def inspect_interface(interface_name):
@@ -66,7 +98,7 @@ def inspect_interface(interface_name):
6698
Returns:
6799
InterfaceStatus object
68100
"""
69-
status = InterfaceStatus(False, None, None)
101+
status = InterfaceStatus(interface_name, False, None, None)
70102

71103
try:
72104
ip_cmd_out_raw = subprocess.check_output([

app/network_test.py

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import subprocess
2+
import tempfile
23
import unittest
4+
from pathlib import Path
35
from unittest import mock
46

57
import network
@@ -13,23 +15,23 @@ class InspectInterfaceTest(unittest.TestCase):
1315
def test_treats_empty_response_as_inactive_interface(self, mock_cmd):
1416
mock_cmd.return_value = ''
1517
self.assertEqual(
16-
network.InterfaceStatus(False, None, None),
18+
network.InterfaceStatus('eth0', False, None, None),
1719
network.inspect_interface('eth0'),
1820
)
1921

2022
@mock.patch.object(subprocess, 'check_output')
2123
def test_treats_empty_array_as_inactive_interface(self, mock_cmd):
2224
mock_cmd.return_value = '[]'
2325
self.assertEqual(
24-
network.InterfaceStatus(False, None, None),
26+
network.InterfaceStatus('eth0', False, None, None),
2527
network.inspect_interface('eth0'),
2628
)
2729

2830
@mock.patch.object(subprocess, 'check_output')
2931
def test_treats_emtpy_object_as_inactive_interface(self, mock_cmd):
3032
mock_cmd.return_value = '[{}]'
3133
self.assertEqual(
32-
network.InterfaceStatus(False, None, None),
34+
network.InterfaceStatus('eth0', False, None, None),
3335
network.inspect_interface('eth0'),
3436
)
3537

@@ -38,7 +40,7 @@ def test_disregards_command_failure(self, mock_cmd):
3840
mock_cmd.side_effect = mock.Mock(
3941
side_effect=subprocess.CalledProcessError(returncode=1, cmd='ip'))
4042
self.assertEqual(
41-
network.InterfaceStatus(False, None, None),
43+
network.InterfaceStatus('eth0', False, None, None),
4244
network.inspect_interface('eth0'),
4345
)
4446

@@ -48,7 +50,7 @@ def test_parses_operstate_down_as_not_connected(self, mock_cmd):
4850
[{"operstate":"DOWN"}]
4951
"""
5052
self.assertEqual(
51-
network.InterfaceStatus(False, None, None),
53+
network.InterfaceStatus('eth0', False, None, None),
5254
network.inspect_interface('eth0'),
5355
)
5456

@@ -58,7 +60,7 @@ def test_parses_operstate_up_as_connected(self, mock_cmd):
5860
[{"operstate":"UP"}]
5961
"""
6062
self.assertEqual(
61-
network.InterfaceStatus(True, None, None),
63+
network.InterfaceStatus('eth0', True, None, None),
6264
network.inspect_interface('eth0'),
6365
)
6466

@@ -68,7 +70,7 @@ def test_parses_mac_address(self, mock_cmd):
6870
[{"address":"00-b0-d0-63-c2-26"}]
6971
"""
7072
self.assertEqual(
71-
network.InterfaceStatus(False, None, '00-b0-d0-63-c2-26'),
73+
network.InterfaceStatus('eth0', False, None, '00-b0-d0-63-c2-26'),
7274
network.inspect_interface('eth0'),
7375
)
7476

@@ -78,7 +80,7 @@ def test_normalizes_mac_address_to_use_dashes(self, mock_cmd):
7880
[{"address":"00:b0:d0:63:c2:26"}]
7981
"""
8082
self.assertEqual(
81-
network.InterfaceStatus(False, None, '00-b0-d0-63-c2-26'),
83+
network.InterfaceStatus('eth0', False, None, '00-b0-d0-63-c2-26'),
8284
network.inspect_interface('eth0'),
8385
)
8486

@@ -88,7 +90,7 @@ def test_parses_ip_address(self, mock_cmd):
8890
[{"addr_info":[{"family":"inet","local":"192.168.2.5"}]}]
8991
"""
9092
self.assertEqual(
91-
network.InterfaceStatus(False, '192.168.2.5', None),
93+
network.InterfaceStatus('eth0', False, '192.168.2.5', None),
9294
network.inspect_interface('eth0'),
9395
)
9496

@@ -98,7 +100,7 @@ def test_disregards_other_families_such_as_ipv6(self, mock_cmd):
98100
[{"addr_info":[{"family":"inet6","local":"::ffff:c0a8:205"}]}]
99101
"""
100102
self.assertEqual(
101-
network.InterfaceStatus(False, None, None),
103+
network.InterfaceStatus('eth0', False, None, None),
102104
network.inspect_interface('eth0'),
103105
)
104106

@@ -112,14 +114,58 @@ def test_parses_all_data(self, mock_cmd):
112114
}]
113115
"""
114116
self.assertEqual(
115-
network.InterfaceStatus(True, '192.168.2.5', '00-b0-d0-63-c2-26'),
117+
network.InterfaceStatus('eth0', True, '192.168.2.5',
118+
'00-b0-d0-63-c2-26'),
116119
network.inspect_interface('eth0'),
117120
)
118121

119122
@mock.patch.object(subprocess, 'check_output')
120123
def test_disregards_invalid_json(self, mock_cmd):
121124
mock_cmd.return_value = '[{"address'
122125
self.assertEqual(
123-
network.InterfaceStatus(False, None, None),
126+
network.InterfaceStatus('eth0', False, None, None),
124127
network.inspect_interface('eth0'),
125128
)
129+
130+
131+
class GetNetworkInterfacesTest(unittest.TestCase):
132+
133+
def test_returns_empty_list_when_path_is_not_a_directory(self):
134+
with tempfile.NamedTemporaryFile() as mock_file:
135+
with mock.patch.object(network, '_INTERFACES_DIR', mock_file.name):
136+
self.assertEqual([], network.get_network_interfaces())
137+
138+
def test_returns_empty_list_when_path_does_not_exist(self):
139+
with tempfile.TemporaryDirectory() as mock_dir:
140+
with mock.patch.object(network, '_INTERFACES_DIR',
141+
f'{mock_dir}/path/does/not/exist'):
142+
self.assertEqual([], network.get_network_interfaces())
143+
144+
def test_returns_empty_list_when_directory_has_no_interfaces(self):
145+
with tempfile.TemporaryDirectory() as mock_dir:
146+
with mock.patch.object(network, '_INTERFACES_DIR', mock_dir):
147+
self.assertEqual([], network.get_network_interfaces())
148+
149+
def test_excludes_loopback_and_virtual_interfaces(self):
150+
with tempfile.TemporaryDirectory() as mock_dir:
151+
mock_net_interfaces_dir = Path(mock_dir)
152+
# Physical interfaces (with 'device').
153+
(mock_net_interfaces_dir / 'eth0' / 'device').mkdir(parents=True)
154+
(mock_net_interfaces_dir / 'wlan0' / 'device').mkdir(parents=True)
155+
# Loopback (no 'device' in the path).
156+
(mock_net_interfaces_dir / 'lo').mkdir()
157+
# Some virtual interface (no 'device' in the path).
158+
(mock_net_interfaces_dir / 'veth0').mkdir()
159+
with mock.patch.object(network, '_INTERFACES_DIR', mock_dir):
160+
self.assertEqual(['eth0', 'wlan0'],
161+
network.get_network_interfaces())
162+
163+
def test_returns_sorted_interface_names(self):
164+
with tempfile.TemporaryDirectory() as mock_dir:
165+
mock_net_interfaces_dir = Path(mock_dir)
166+
# Create in unsorted order.
167+
(mock_net_interfaces_dir / 'wlan0' / 'device').mkdir(parents=True)
168+
(mock_net_interfaces_dir / 'eth0' / 'device').mkdir(parents=True)
169+
with mock.patch.object(network, '_INTERFACES_DIR', mock_dir):
170+
self.assertEqual(['eth0', 'wlan0'],
171+
network.get_network_interfaces())

app/static/js/controllers.js

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -203,21 +203,11 @@ export async function getNetworkStatus() {
203203
})
204204
.then(processJsonResponse)
205205
.then((response) => {
206-
["ethernet", "wifi"].forEach((field) => {
207-
// eslint-disable-next-line no-prototype-builtins
208-
if (!response.hasOwnProperty(field)) {
209-
throw new ControllerError(`Missing expected ${field} field`);
210-
}
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-
});
219-
});
220-
return response;
206+
// eslint-disable-next-line no-prototype-builtins
207+
if (!response.hasOwnProperty("interfaces")) {
208+
throw new ControllerError("Missing expected interfaces field");
209+
}
210+
return response.interfaces;
221211
});
222212
}
223213

0 commit comments

Comments
 (0)