feat: truth-first get_ap (dual radio + enterprise) and derived pineap mode

get_ap now reports radio0, radio1 and enterprise APs separately from UCI
(never a stored/cached branch), with radio channel fallback. Mode is
derived from live enabled/collect state instead of showing 'unknown';
device truth wins over stale stored presets.
This commit is contained in:
2026-08-18 19:25:06 -05:00
parent 740348067a
commit 38ef4e8d0a
4 changed files with 123 additions and 81 deletions
@@ -2493,78 +2493,104 @@ def _uci_section(section):
return _uci_values(section)
def _ap_iface_dict(cfg, radio_cfg, pool=None):
"""Normalize one wifi-iface UCI dict into the API shape used by the UI."""
channel = cfg.get('channel') or ''
try:
channel = int(channel)
except (TypeError, ValueError):
channel = None
if channel is None:
# An AP without its own channel broadcasts on the radio's channel.
try:
channel = int((radio_cfg or {}).get('channel') or '')
except (TypeError, ValueError):
channel = None
encryption = cfg.get('encryption') or ''
if encryption.startswith('psk2'):
encryption = 'psk2'
elif encryption.startswith('sae'):
encryption = 'sae'
elif encryption.startswith('owe'):
encryption = 'owe'
elif encryption.startswith('wpa2'):
encryption = 'wpa2'
elif encryption.startswith('wpa3'):
encryption = 'wpa3'
return {
'enabled': cfg.get('disabled') == '0',
'ssid': cfg.get('ssid') or '',
'hidden': cfg.get('hidden') == '1',
'channel': channel,
'country': (radio_cfg or {}).get('country') or '',
'enctype': encryption,
'passphrase': cfg.get('key') or '',
'bssid': cfg.get('macaddr') or '',
'target': (pool or {}).get('target') or None,
}
def _radio_dict(name):
cfg = _uci_wifi_iface(name) or {}
return {
'band': {'2g': BAND_2G, '5g': BAND_5G, '6g': BAND_6G}.get(
cfg.get('band'), None),
'channel': cfg.get('channel') or 'auto',
'htmode': cfg.get('htmode') or '',
'country': cfg.get('country') or '',
'disabled': cfg.get('disabled') == '1',
}
def _iface_live(name):
return os.path.exists('/sys/class/net/%s' % name)
def h_pineap_wifi_get_ap(ctx):
global _last_reconcile
def _iface_state(open_name, wpa_name, radio_name):
open_cfg = _uci_wifi_iface(open_name)
wpa_cfg = _uci_wifi_iface(wpa_name)
radio_cfg = _uci_wifi_iface(radio_name)
def _chan(cfg):
channel = cfg.get('channel') or ''
try:
return int(channel)
except (TypeError, ValueError):
return None
radio_channel = _chan(radio_cfg)
open_channel = _chan(open_cfg) or radio_channel
wpa_channel = _chan(wpa_cfg) or radio_channel
encryption = wpa_cfg.get('encryption') or ''
if encryption.startswith('psk2'):
encryption = 'psk2'
elif encryption.startswith('sae'):
encryption = 'sae'
elif encryption.startswith('owe'):
encryption = 'owe'
return open_cfg, wpa_cfg, radio_cfg, open_channel, wpa_channel, encryption
if _uci_wifi_iface('wlan1open') or _uci_wifi_iface('wlan1wpa'):
open_cfg, wpa_cfg, radio_cfg, open_channel, wpa_channel, encryption = _iface_state(
'wlan1open', 'wlan1wpa', 'radio1')
else:
open_cfg, wpa_cfg, radio_cfg, open_channel, wpa_channel, encryption = _iface_state(
'wlan0open', 'wlan0wpa', 'radio0')
pool = _uci_section('pineapd.@ssidpool[0]')
radio0_cfg = _uci_wifi_iface('radio0')
radio1_cfg = _uci_wifi_iface('radio1')
open_cfg = _uci_wifi_iface('wlan0open')
wpa_cfg = _uci_wifi_iface('wlan0wpa')
r1_open_cfg = _uci_wifi_iface('wlan1open')
r1_wpa_cfg = _uci_wifi_iface('wlan1wpa')
ent_cfg = _uci_wifi_iface('wlan0ent')
status, data = daemon_sock_call('GET', '/api/pineap/hostapd/get_config')
host = data if status == 200 and isinstance(data, dict) else {}
status2, data2 = daemon_sock_call('GET', '/api/pineap/get_config')
pinecfg = data2 if status2 == 200 and isinstance(data2, dict) else {}
pool = _uci_section('pineapd.@ssidpool[0]')
radio1 = _uci_wifi_iface('radio1')
for name in ('wlan1open', 'wlan1wpa'):
cfg = _uci_wifi_iface(name)
if cfg and cfg.get('disabled') != '1' and not os.path.exists('/sys/class/net/%s' % name):
if cfg and cfg.get('disabled') != '1' and not _iface_live(name):
if time.time() - _last_reconcile > 30:
_last_reconcile = time.time()
device_run(['wifi', 'reload'])
break
ent = _ap_iface_dict(ent_cfg, radio0_cfg)
return 200, {
'open': {
'enabled': open_cfg.get('disabled') == '0',
'ssid': open_cfg.get('ssid') or '',
'bssid': open_cfg.get('macaddr') or '',
'target': pool.get('target') or None,
'hidden': open_cfg.get('hidden') == '1',
'channel': open_channel,
'country': radio_cfg.get('country') or '',
'open': _ap_iface_dict(open_cfg, radio0_cfg, pool),
'wpa': _ap_iface_dict(wpa_cfg, radio0_cfg),
'radio1_open': _ap_iface_dict(r1_open_cfg, radio1_cfg),
'radio1_wpa': _ap_iface_dict(r1_wpa_cfg, radio1_cfg),
'enterprise': {
'enabled': ent.get('enabled', False),
'ssid': ent.get('ssid', ''),
'enctype': ent.get('enctype') or 'wpa2',
'passphrase': ent.get('passphrase', ''),
'hidden': ent.get('hidden', False),
'channel': ent.get('channel'),
'live': _iface_live('wlan0ent'),
},
'wpa': {
'ssid': wpa_cfg.get('ssid') or '',
'passphrase': wpa_cfg.get('key') or '',
'enctype': encryption,
'hidden': wpa_cfg.get('hidden') == '1',
'enabled': wpa_cfg.get('disabled') == '0',
'channel': wpa_channel,
},
'enterprise': {'enabled': not host.get('pineape_disabled', True)},
'pool': {'disabled': None, 'collecting': bool(pinecfg.get('autossidpool'))},
'radio1': {
'band': {'2g': BAND_2G, '5g': BAND_5G, '6g': BAND_6G}.get(
(radio1 or {}).get('band'), BAND_5G),
'channel': (radio1 or {}).get('channel') or 'auto',
'htmode': (radio1 or {}).get('htmode') or 'VHT80',
'country': (radio1 or {}).get('country') or '',
'radios': {
'radio0': _radio_dict('radio0'),
'radio1': _radio_dict('radio1'),
},
'pineape': {
'enabled': not host.get('pineape_disabled', True),
'auth_pass': bool(host.get('pineape_auth_pass')),
},
}
@@ -2840,7 +2866,7 @@ def update_pineap_state(mode=None, **flags):
def h_pineap_mode_get(ctx):
state = load_pineap_state()
mode = state.get('mode')
stored = state.get('mode')
_, config = daemon_sock_call('GET', '/api/pineap/get_config')
_, hostapd = daemon_sock_call('GET', '/api/pineap/hostapd/get_config')
collect = config.get('autossidpool') if isinstance(config, dict) else None
@@ -2848,18 +2874,21 @@ def h_pineap_mode_get(ctx):
if isinstance(hostapd, dict) and 'pineap_disabled' in hostapd:
enabled = not bool(hostapd['pineap_disabled'])
# On the Pager, the Mimic/PineAP switch is the response engine itself:
# Passive intentionally leaves it disabled, while Active enables it.
# Treat only a real mismatch with that preset (or disabled collection) as
# a custom/Advanced setup.
expected_enabled = {'passive': False, 'active': True}.get(mode)
engine_mismatch = (enabled is not None and expected_enabled is not None
and enabled != expected_enabled)
if mode in ('passive', 'active') and (collect is False or engine_mismatch):
# The mode is DERIVED from the live device state, never invented.
if enabled is not None and collect is not None:
live = 'passive' if enabled is False else ('active' if collect else 'advanced')
if stored == 'advanced':
mode = 'advanced'
elif stored == live:
mode = stored
else:
mode = live
state = update_pineap_state(mode=live, enabled=enabled, collect=collect,
karma=bool(enabled))
elif stored in ('passive', 'active', 'advanced'):
mode = stored
else:
mode = 'advanced'
state = update_pineap_state(mode='advanced', collect=collect)
elif mode not in ('passive', 'active', 'advanced'):
mode = 'advanced' if collect is False or enabled is False else 'unknown'
result = dict(state)
result['mode'] = mode