fix: recon env-check verifies runtime wlan0mon hopping (2.4GHz starvation)

The band UCI config looked correct but pineapd was not hopping wlan0mon at
runtime, so 2.4GHz recon results were entirely absent. env_check now parses
_pineap INTERFACE LIST and warns when wlan0mon hop is off; /api/recon/status
exposes wlan0_hopping and the recon scan bar surfaces it.
This commit is contained in:
2026-08-19 10:29:50 -05:00
parent 904843307e
commit 5eb81b90f1
3 changed files with 75 additions and 2 deletions
@@ -1359,6 +1359,7 @@ def h_recon_status(ctx):
'scanning': scanning, 'scan_remaining': remaining, 'stale': stale,
'hopper_online': _hopper_online(),
'wlan0_pinned': _wlan0_pinned(),
'wlan0_hopping': _wlan0_hopping(),
'history_reset': _recon_history_reset()}
@@ -3716,6 +3717,40 @@ def _wlan0_pinned():
return False
def _pineap_interfaces():
"""Runtime pineapd interface table from ``_pineap INTERFACE LIST``:
{name: {'channels': int, 'bands': str, 'hop': str, 'pkts': int}}."""
rc, out, err = _pineap('INTERFACE', 'LIST', timeout=15)
if rc != 0:
return {}
result = {}
for line in (out or '').splitlines():
parts = line.split()
if len(parts) < 7 or not parts[0].startswith('wlan'):
continue
result[parts[0]] = {
'channels': int(parts[1]) if parts[1].isdigit() else None,
'bands': parts[2],
'type': parts[3],
'hop': parts[4],
'chan': parts[5],
'pkts': int(parts[6]) if parts[6].isdigit() else None,
}
return result
def _wlan0_hopping():
"""Whether pineapd is actually hopping wlan0mon at runtime. The band UCI
config can be correct while runtime hopping is off, which starves 2.4GHz
recon results entirely."""
ifaces = _pineap_interfaces()
mon = ifaces.get('wlan0mon')
if not mon:
return None
hop = mon.get('hop')
return bool(hop) and hop not in ('0', '', 'none', 'false')
def env_check():
"""Full environment pass, run at service startup and via
``server.py --env-check``. Auto-fixes everything fixable, re-verifies, and
@@ -3774,6 +3809,16 @@ def env_check():
else:
_env_step(report, 'pass', 'no radio0 AP pins wlan0mon')
hopping = _wlan0_hopping()
if hopping is False:
_env_step(report, 'warn', '2.4GHz recon starved: wlan0mon hopping is off at '
'runtime (INTERFACE LIST hop=0)')
elif hopping is None:
_env_step(report, 'warn', 'could not read pineapd interface state '
'(INTERFACE LIST failed)')
else:
_env_step(report, 'pass', 'wlan0mon hopping on (2.4GHz scanning active)')
ENV_CHECK_STATE['report'] = report
ENV_CHECK_STATE['overall'] = _env_overall(report)
ENV_CHECK_STATE['updated'] = time.time()
@@ -1462,7 +1462,7 @@ views.recon = (root) => {
compare: [], history: {}, mapBand: null,
archive: null, archives: [], scanRemaining: null,
hopperOnline: null, historyReset: false, scanErr: null,
wlan0Pinned: false };
wlan0Pinned: false, wlan0Hopping: null };
const cols = reconLoadCols();
// ---- title cards (stat cards with optional mini charts) ----
@@ -1661,9 +1661,10 @@ views.recon = (root) => {
if (state.hopperOnline === false) bits.push('Hopper radio offline — fewer networks seen');
if (state.historyReset) bits.push('History reset — previous scans archived (see Previous Scans)');
if (state.wlan0Pinned) bits.push('2.4GHz under-sampled — OpenAP/Evil WPA holds wlan0mon');
if (state.wlan0Hopping === false) bits.push('2.4GHz starved — wlan0mon hopping is off');
if (state.scanErr) bits.push(state.scanErr);
scanStatus.textContent = bits.join(' · ');
scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset || state.wlan0Pinned || !!state.scanErr);
scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset || state.wlan0Pinned || state.wlan0Hopping === false || !!state.scanErr);
}
scanToggle.addEventListener('change', () => {
if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; }
@@ -2526,6 +2527,7 @@ views.recon = (root) => {
state.hopperOnline = r.data.hopper_online;
state.historyReset = !!r.data.history_reset;
state.wlan0Pinned = !!r.data.wlan0_pinned;
state.wlan0Hopping = r.data.wlan0_hopping;
if (!pendingScan) scanToggle.checked = scanning;
renderScanBar();
if (wasScanning !== scanning) restartPoll();