release: Mark VIII 1.1

Wireless client mode (connect to WiFi as client):
- settings/wifi/client API: state, scan, connect, disconnect, route
- Internet Connection topbar dialog and functional Settings > Networking card
- routing toggle syncing UCI flag and daemon state
- fix trailing-slash hash routes (View not available)
- hidden-SSID filtering, encryption classification (Open/WPA2/WPA3/mixed)
- tests for client state, scan parsing, connect/disconnect, routing
This commit is contained in:
2026-08-17 22:28:41 -05:00
parent 2bf39ecb9d
commit 5f6dc5bcdb
10 changed files with 719 additions and 13 deletions
@@ -1963,11 +1963,32 @@ def _proxy_json(method, path, body=None):
return 200, (data if isinstance(data, dict) else {'ok': True})
def _payload_detail(data):
if isinstance(data, dict):
text = data.get('error') or data.get('detail')
if isinstance(text, str):
return text
return json.dumps(data)
if isinstance(data, bytes):
data = data.decode('utf-8', 'replace')
if isinstance(data, str):
try:
parsed = json.loads(data)
except Exception:
return data
if isinstance(parsed, dict):
text = parsed.get('error') or parsed.get('detail')
if isinstance(text, str):
return text
return data
return str(data) if data is not None else ''
def _payload_daemon(method, path, body=None):
status, data = daemon_call(method, path, body=body, token=current_token(), timeout=45)
if status != 200:
return (502 if status == 0 else status), {
'error': 'Pager payload service failed', 'detail': data}
'error': 'Pager payload service failed', 'detail': _payload_detail(data)}
if not isinstance(data, (dict, list)):
return 502, {'error': 'Pager payload service returned an invalid response'}
return 200, data
@@ -2413,6 +2434,184 @@ def h_settings_management_wifi(ctx):
}
WIFI_CLIENT_ENCRYPTIONS = {
'none': 'none', 'open': 'none',
'wpa2': 'psk2', 'psk2': 'psk2',
'wpa3': 'sae', 'sae': 'sae',
'wpa2wpa3': 'sae-mixed', 'sae-mixed': 'sae-mixed'
}
def _freq_to_channel(freq):
if not freq:
return None
if freq < 2484:
return int((freq - 2412) / 5 + 1)
if freq == 2484:
return 14
return int((freq - 5000) / 5)
def _wifi_client_state():
cfg = _uci_wifi_iface('wlan0cli')
state = {
'enabled': cfg.get('disabled', '1') != '1',
'connected': False,
'ssid': cfg.get('ssid') or '',
'connected_ssid': '',
'ip': '',
'signal': None,
'freq': None,
'routed': cfg.get('routed') == '1',
'has_password': bool(cfg.get('key'))
}
rc, out, err = device_run(['iw', 'dev', 'wlan0cli', 'link'], timeout=10)
for line in out.splitlines():
line = line.strip()
if line.startswith('Connected to'):
state['connected'] = True
elif line.startswith('SSID:'):
state['connected_ssid'] = line.split(':', 1)[1].strip().strip('"')
elif line.startswith('signal:'):
try:
state['signal'] = int(float(line.split(':', 1)[1].split()[0]))
except (ValueError, IndexError):
state['signal'] = None
elif line.startswith('freq:'):
try:
state['freq'] = int(float(line.split(':', 1)[1].split()[0]))
except (ValueError, IndexError):
state['freq'] = None
if not state['connected']:
state['connected_ssid'] = ''
_, addr_out, _ = device_run(['ip', '-4', 'addr', 'show', 'dev', 'wlan0cli'], timeout=10)
for line in addr_out.splitlines():
m = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', line)
if m:
state['ip'] = m.group(1)
break
return state
def h_settings_wifi_client(ctx):
return 200, _wifi_client_state()
def _parse_wifi_scan(out):
networks = []
for block in out.split('BSS '):
block = block.strip()
if not block:
continue
m = re.match(r'([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})\(on', block)
bss = m.group(1).upper() if m else ''
freq = None
m = re.search(r'freq:\s*([\d.]+)', block)
if m:
try:
freq = int(float(m.group(1)))
except ValueError:
freq = None
signal = None
m = re.search(r'signal:\s*(-?\d+(?:\.\d+)?)', block)
if m:
try:
signal = int(float(m.group(1)))
except ValueError:
signal = None
ssid = ''
m = re.search(r'SSID:\s*([^\n]*)', block)
if m:
ssid = m.group(1).strip().strip('"')
if (not ssid or all(ord(c) < 32 or c == '\ufffd' for c in ssid)
or re.match(r'^(\\x[0-9A-Fa-f]{2})+$', ssid)):
ssid = ''
if not ssid:
continue
auth = ''
m = re.search(r'Authentication suites:\s*([^\n]+)', block)
if m:
auth = m.group(1).strip()
if 'RSN:' in block and 'WPA:' in block:
encryption = 'WPA/WPA2'
elif 'RSN:' in block:
if 'SAE' in auth and 'PSK' in auth:
encryption = 'WPA2/WPA3'
elif 'SAE' in auth:
encryption = 'WPA3'
else:
encryption = 'WPA2'
elif 'WPA:' in block:
encryption = 'WPA'
else:
encryption = 'Open'
networks.append({'bssid': bss, 'ssid': ssid, 'freq': freq,
'channel': _freq_to_channel(freq), 'signal': signal,
'encryption': encryption})
by_ssid = {}
for net in networks:
key = net['ssid'] or net['bssid']
current = by_ssid.get(key)
if current is None or (net['signal'] or -200) > (current['signal'] or -200):
by_ssid[key] = net
return sorted(by_ssid.values(),
key=lambda n: n['signal'] if n['signal'] is not None else -200,
reverse=True)
def h_settings_wifi_client_scan(ctx):
rc, out, err = device_run(['iw', 'dev', 'wlan0', 'scan'], timeout=25)
if rc != 0:
return 502, {'error': err or out or 'scan failed'}
return 200, {'networks': _parse_wifi_scan(out)}
def h_settings_wifi_client_connect(ctx):
body = ctx.body or {}
ssid = (body.get('ssid') or '').strip()
if not ssid:
return 400, {'error': 'SSID is required'}
encryption = (body.get('encryption') or 'wpa2').strip().lower()
if encryption not in WIFI_CLIENT_ENCRYPTIONS:
return 400, {'error': 'unsupported encryption type'}
enc = WIFI_CLIENT_ENCRYPTIONS[encryption]
password = body.get('password') or ''
if enc != 'none' and len(password) < 8:
return 400, {'error': 'password must be at least 8 characters'}
routed = '1' if body.get('routed') else '0'
device_run(['uci', 'set', 'wireless.wlan0cli.ssid=%s' % ssid])
device_run(['uci', 'set', 'wireless.wlan0cli.encryption=%s' % enc])
device_run(['uci', 'set', 'wireless.wlan0cli.disabled=0'])
device_run(['uci', 'set', 'wireless.wlan0cli.routed=%s' % routed])
if enc == 'none':
device_run(['uci', 'delete', 'wireless.wlan0cli.key'])
else:
device_run(['uci', 'set', 'wireless.wlan0cli.key=%s' % password])
device_run(['uci', 'commit', 'wireless'])
daemon_sock_call('PUT', '/api/settings/wifi/set_client_route', {'routed': body.get('routed') or False})
rc, out, err = device_run(['wifi', 'reload'], timeout=45)
if rc != 0:
return 502, {'error': err or out or 'wireless reload failed'}
return 200, _wifi_client_state()
def h_settings_wifi_client_disconnect(ctx):
device_run(['uci', 'set', 'wireless.wlan0cli.disabled=1'])
device_run(['uci', 'commit', 'wireless'])
rc, out, err = device_run(['wifi', 'reload'], timeout=45)
if rc != 0:
return 502, {'error': err or out or 'wireless reload failed'}
return 200, _wifi_client_state()
def h_settings_wifi_client_route(ctx):
routed = bool((ctx.body or {}).get('routed'))
device_run(['uci', 'set', 'wireless.wlan0cli.routed=%d' % (1 if routed else 0)])
device_run(['uci', 'commit', 'wireless'])
daemon_sock_call('PUT', '/api/settings/wifi/set_client_route', {'routed': routed})
return 200, _wifi_client_state()
PAGER_LED_COLORS = ('red', 'green', 'blue', 'yellow', 'cyan', 'magenta', 'white')
@@ -2565,6 +2764,11 @@ ROUTER.add('GET', r'/api/settings/usb', h_settings_usb)
ROUTER.add('GET', r'/api/settings/network', h_settings_network)
ROUTER.add('GET', r'/api/settings/wifi/management', h_settings_management_wifi)
ROUTER.add('POST', r'/api/settings/wifi/management', h_settings_management_wifi)
ROUTER.add('GET', r'/api/settings/wifi/client', h_settings_wifi_client)
ROUTER.add('POST', r'/api/settings/wifi/client/scan', h_settings_wifi_client_scan)
ROUTER.add('POST', r'/api/settings/wifi/client/connect', h_settings_wifi_client_connect)
ROUTER.add('POST', r'/api/settings/wifi/client/disconnect', h_settings_wifi_client_disconnect)
ROUTER.add('POST', r'/api/settings/wifi/client/route', h_settings_wifi_client_route)
ROUTER.add('GET', r'/api/settings/hardware', h_settings_hardware)
ROUTER.add('POST', r'/api/settings/hardware', h_settings_hardware)
ROUTER.add('GET', r'/api/settings/advanced', h_settings_advanced)