Files
claudetools/.claude/skills/unifi-wifi/scripts/survey-collect.sh
Howard Enos 1836bfd34d sync: auto-sync from HOWARD-HOME at 2026-06-21 12:25:00
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-06-21 12:25:00
2026-06-21 12:25:45 -07:00

131 lines
8.2 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"
# Mandatory skill error logging (skill-creator rule): log GENUINE functional failures only.
logerr(){ bash "$REPO/.claude/scripts/log-skill-error.sh" "unifi-wifi/survey-collect" "$1" --context "${2:-}" >/dev/null 2>&1 || true; }
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"; logerr "UOS controller login failed (HTTP $code)" "host=$HOST:$PORT site=$SITEARG"; 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 channels measured -- ALL channels; * = DFS)")
print("NOTE: DFS channels are often the cleanest (consumer gear avoids them). Do NOT pre-filter them out;")
print(" run survey-report.py for the fleet rollup + a data-driven palette, then channel-plan --channels.\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 "?"
cleanest=sorted(rows, key=lambda r:r[0])[:3] # ALL channels (DFS + non-DFS), * marks DFS
clean=", ".join(f"ch{c}{'*' if c in DFS else ''}({bz}%)" for bz,c,_,_ in cleanest)
print(f" {b}GHz in-use {iu} | cleanest (all): {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."