Files
claudetools/.claude/skills/unifi-wifi/scripts/survey-collect.sh
Howard Enos 412c0eff31 unifi-wifi: skill health pass — fix optimize-radios stray REPL echo + ASCII-clean all output
Full verification of the skill against Cascades (live):
- All 19 scripts syntax-clean.
- Controller-side read-only validated live: sites, audit-site, switch-audit, live-stats, model-rank,
  optimize-radios, monitor-run, gw-audit. Dry-run apply paths validated: apply-radio, apply-wlan,
  client-control, device-control. AP-side mechanism validated: SSH auth + /proc/ui_neighbor read on a
  sample AP; full neighbor-collect (74-AP SNR sweep) -> channel-plan end-to-end produced a 1/6/11 plan.

Fixes:
- optimize-radios.sh: the `for(k in prof)` loop's numeric completion value was REPL-echoed by the legacy
  mongo shell (stray "94.56..." line in output). Terminated the loop body with `void 0` to suppress it.
- ASCII-clean printed output (CLAUDE.md no-non-ASCII): replaced em-dashes / Unicode arrows / § that
  reached stdout and rendered as `?`/mojibake on the Windows console, across optimize-radios,
  neighbor-collect, survey-collect, dfs-check, audit-site, sites, monitor-run, apply-radio, apply-wlan,
  pfsense-backend. (Comment-only non-ASCII left as-is; never printed.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:08:35 -07:00

127 lines
7.6 KiB
Bash

#!/usr/bin/env bash
# survey-collect.sh — measured per-channel RF occupancy (busy% + noise) for every AP in a site.
#
# Reads `iw dev <radio> survey dump` from each AP (NON-DISRUPTIVE — the AP's background scanning
# already populated it). Per AP, per band, reports the in-use channel's busy% and the cleanest
# available channels by measured airtime — the data-driven input for a manual channel plan
# (vs. inferring from the foreign-neighbor `rogue` map). Pairs with neighbor-collect.sh (overlap)
# and audit-site.sh (config). Works for any UOS site; /proc + iw exist on every UniFi AP.
#
# Non-disruptive. Needs: controller cred (infrastructure/uos-server-network-api-rw, for the AP
# name/ip list) + per-site AP device-auth SSH cred + L3 reach to the AP mgmt VLAN (site VPN).
# AP SSH uses sshpass if present, else SSH_ASKPASS fallback. RUN IN FOREGROUND (a detached
# background process can't spawn the askpass helper).
#
# Usage: bash .claude/skills/unifi-wifi/scripts/survey-collect.sh <site-name|id> [ap-ssh-vault-path]
set -uo pipefail
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
VAULT="$REPO/.claude/scripts/vault.sh"
HOST="${UOS_HOST:-172.16.3.29}"; PORT="${UOS_HTTPS_PORT:-11443}"
SITEARG="${1:?usage: survey-collect.sh <site-name|id> [ap-ssh-vault-path]}"
VP="${2:-clients/cascades-tucson/unifi-ap-ssh}"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
# --- controller login + AP (name,ip) list ---
CU="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.username 2>/dev/null)"
CP="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.password 2>/dev/null)"
[ -n "$CU" ] && [ -n "$CP" ] || { echo "[ERROR] no controller cred (infrastructure/uos-server-network-api-rw)"; exit 1; }
base="https://$HOST:$PORT"; CJ="$TMP/cj"
code=$(curl -sk -c "$CJ" -o /dev/null -w '%{http_code}' -X POST "$base/api/auth/login" -H 'Content-Type: application/json' \
--data-binary "$(python -c 'import json,sys;print(json.dumps({"username":sys.argv[1],"password":sys.argv[2]}))' "$CU" "$CP")")
[ "$code" = "200" ] || { echo "[ERROR] controller login HTTP $code"; exit 1; }
SHORT="$(curl -sk -b "$CJ" "$base/proxy/network/api/self/sites" | python -c "
import sys,json; d=json.load(sys.stdin).get('data',[]); q='''$SITEARG'''.lower()
for s in d:
if s.get('_id')=='''$SITEARG''' or s.get('name')=='''$SITEARG''' or q in (s.get('desc','').lower()): print(s.get('name')); break
")"; [ -n "$SHORT" ] || SHORT="$SITEARG"
echo "[INFO] site=$SHORT"
# NOTE: pass temp paths as ARGV (MSYS translates POSIX->Windows for the python.exe); embedding
# $TMP inside a `python -c` string is NOT translated and fails on Windows.
curl -sk -b "$CJ" "$base/proxy/network/api/s/$SHORT/stat/device" -o "$TMP/dev.json"
python - "$TMP/dev.json" "$TMP/aps.tsv" <<'PY'
import sys,json
d=json.load(open(sys.argv[1]))
aps=[(a.get('name') or a.get('mac'),a.get('ip')) for a in d.get('data',[]) if a.get('type')=='uap' and a.get('state')==1 and a.get('ip')]
open(sys.argv[2],'w',newline='\n').write('\n'.join(f"{n}\t{i}" for n,i in aps))
print(f"[INFO] {len(aps)} online APs")
PY
# --- AP SSH auth (sshpass or SSH_ASKPASS fallback) ---
AU="$(bash "$VAULT" get-field "$VP" credentials.username 2>/dev/null)"
AP_PW="$(bash "$VAULT" get-field "$VP" credentials.password 2>/dev/null)"; export AP_PW
[ -n "$AU" ] && [ -n "$AP_PW" ] || { echo "[BLOCKED] no AP device-auth cred at vault:$VP"; echo " Controller-side scripts (audit-site/live-stats/model-rank/optimize-radios/apply-radio) still work for this site."; echo " For AP-side collectors, vault this client's cred then re-run:"; echo " bash .claude/skills/vault/scripts/vault-helper.sh new $VP --kind generic --name '<Client> UniFi AP device-auth SSH' --tag unifi --set username=<u> --set password=<pw>"; exit 2; }
SSH_OPTS=(-o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \
-o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1)
if command -v sshpass >/dev/null 2>&1; then
ap_ssh() { SSHPASS="$AP_PW" sshpass -e ssh "${SSH_OPTS[@]}" "$@" </dev/null; }
else
ASKP="$TMP/askpass.sh"; printf '#!/bin/sh\nprintf "%%s\\n" "$AP_PW"\n' > "$ASKP"; chmod +x "$ASKP"
ap_ssh() { SSH_ASKPASS="$ASKP" SSH_ASKPASS_REQUIRE=force DISPLAY="${DISPLAY:-:0}" ssh "${SSH_OPTS[@]}" "$@" </dev/null; }
fi
# --- harvest survey dump per AP ---
RAW="$TMP/raw.txt"; n=0; ok=0; tot=$(wc -l < "$TMP/aps.tsv")
while IFS=$'\t' read -r name ip; do
ip="${ip%$'\r'}"; name="${name%$'\r'}"; [ -z "$ip" ] && continue; n=$((n+1))
echo "###AP $name" >> "$RAW"
out=""; t=0 # retry per AP (transient VPN flaps); capture-to-var avoids partial appends
while [ $t -lt 3 ]; do if out="$(ap_ssh "$AU@$ip" 'for r in wifi0 wifi1 wifi2 ath0 ath1; do iw dev $r survey dump 2>/dev/null; done' 2>/dev/null)" && [ -n "$out" ]; then break; fi; t=$((t+1)); sleep 2; done
if [ -n "$out" ]; then printf '%s\n' "$out" >> "$RAW"; ok=$((ok+1)); else echo "@@UNREACHABLE" >> "$RAW"; fi
printf '\r[INFO] surveyed %d/%d (ok %d) ' "$n" "$tot" "$ok" >&2
done < "$TMP/aps.tsv"; echo "" >&2
# --- parse + report cleanest channels per AP per band ---
# Set SURVEY_JSON=<path> to also emit machine-readable {ap:{band:{channel:busy%}}} (for channel-plan.sh).
python - "$RAW" "${SURVEY_JSON:-NONE}" <<'PY'
import sys,re,json
def band(f):
f=int(f)
if 2400<f<2500: return '2.4'
if 5150<f<5895: return '5'
if 5925<f<7125: return '6'
return '?'
def ch(f):
f=int(f)
if 2400<f<2500: return (f-2407)//5
if f>=5000: return (f-5000)//5
return f
DFS=set(range(52,145))
cur=None; rec={}; data={} # data[ap][band]=list of (busy%,ch,inuse,noise)
def flush():
if cur and rec.get('f') and rec.get('act',0)>0:
b=band(rec['f']); c=ch(rec['f']); busy=round(100*rec.get('busy',0)/rec['act'])
data.setdefault(cur,{}).setdefault(b,[]).append((busy,c,rec.get('inuse',False),rec.get('noise','?')))
for ln in open(sys.argv[1],encoding='utf-8',errors='replace'):
ln=ln.rstrip()
if ln.startswith('###AP'): flush(); rec={}; cur=ln.split('\t')[1] if '\t' in ln else ln[6:]; continue
if ln.startswith('@@UNREACHABLE'): continue
if 'frequency:' in ln: flush(); m=re.search(r'(\d+) MHz',ln); rec={'f':m.group(1) if m else None,'inuse':'in use' in ln}
elif 'noise:' in ln: m=re.search(r'(-?\d+) dBm',ln); rec['noise']=m.group(1) if m else '?'
elif 'channel active time' in ln: m=re.search(r'(\d+) ms',ln); rec['act']=int(m.group(1)) if m else 0
elif 'channel busy time' in ln: m=re.search(r'(\d+) ms',ln); rec['busy']=int(m.group(1)) if m else 0
flush()
print(f"\n==== MEASURED RF OCCUPANCY - cleanest channels per AP ({len(data)} APs) ====")
print("(in-use channel busy%, then 3 lowest-busy NON-DFS channels measured; * = DFS)\n")
for ap in sorted(data):
print(f"{ap}:")
for b in ('2.4','5','6'):
rows=data[ap].get(b,[])
if not rows: continue
inuse=[r for r in rows if r[2]]
iu=f"ch{inuse[0][1]}={inuse[0][0]}%" if inuse else "?"
nondfs=sorted([r for r in rows if r[1] not in DFS], key=lambda r:r[0])[:3]
clean=", ".join(f"ch{c}({bz}%)" for bz,c,_,_ in nondfs)
print(f" {b}GHz in-use {iu} | cleanest non-DFS: {clean}")
OUT=sys.argv[2] if len(sys.argv)>2 else 'NONE'
if OUT!='NONE':
j={}
for ap in data:
for b in data[ap]:
for busy,c,inuse,noise in data[ap][b]:
j.setdefault(ap,{}).setdefault(b,{})[str(c)]=busy # busy% per channel
json.dump(j,open(OUT,'w'))
print(f"\n[INFO] wrote survey JSON -> {OUT} ({len(j)} APs) - feed to channel-plan.sh via SURVEY_JSON")
PY
echo ""
echo "[next] use the cleanest-channel data for a manual 1/6/11 (2.4) + non-DFS (5GHz) plan; apply via apply-radio.sh per zone."