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>
127 lines
7.6 KiB
Bash
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."
|