Files
claudetools/.claude/skills/unifi-wifi/scripts/channel-plan.sh
Howard Enos fb835fe756 unifi-wifi: data-driven channel selection — add survey-report, kill non-DFS bias
Codifies the scan-first/data-driven workflow proven on Cascades (where the baked-in
non-DFS bias picked the congested channels and a data-driven DFS plan halved 5GHz retry):

- NEW survey-report.py: rolls survey-collect JSON into the fleet per-channel/per-band-group
  measured busy% table + cleanest/dirtiest ranking + a suggested clean 40MHz palette. The
  decision-driver that was missing (we built it by hand).
- channel-plan.sh: na palette is now DATA-DRIVEN, not hardcoded non-DFS. Adds --channels
  (explicit palette) + --dfs ok|avoid|only; default considers ALL 40MHz primaries and lets
  measured busy% choose. Adds load-balancing + a local-search pass -> strong co-channel to 0.
- survey-collect.sh: per-AP "cleanest" report no longer pre-filters out DFS (DFS is usually
  cleanest here); marks DFS with *, points at survey-report.
- SKILL.md: documents the mandatory scan -> survey-report -> channel-plan --channels -> apply
  -> validate order + the Cascades lesson.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 05:00:47 -07:00

138 lines
8.4 KiB
Bash

#!/usr/bin/env bash
# channel-plan.sh — compute (and optionally apply) a per-AP channel plan that minimizes co-channel
# overlap, using the measured AP-to-AP neighbor matrix (neighbor-collect) + per-channel busy% (survey).
# ng (2.4): greedy graph-coloring into 1/6/11 so strong neighbors differ.
# na (5GHz): per AP pick the NON-DFS channel with lowest cost = measured busy% + neighbor-collision
# penalty (avoid DFS near military/airport radar; spreads APs apart).
# DRY-RUN default; --apply writes per-AP via the controller REST radio_table (gated, rollback saved).
# Inputs: NEIGHBOR_JSON (from neighbor-collect, required) + SURVEY_JSON (from survey-collect, for na).
#
# Usage:
# NEIGHBOR_JSON=.claude/tmp/<site>-nbr.json SURVEY_JSON=.claude/tmp/<site>-survey.json \
# bash .../channel-plan.sh <site> ng|na [--apply]
set -uo pipefail
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
UOS="$REPO/.claude/scripts/uos-mongo.sh"; VAULT="$REPO/.claude/scripts/vault.sh"
HOST="${UOS_HOST:-172.16.3.29}"; PORT="${UOS_HTTPS_PORT:-11443}"
SITEARG="${1:?usage: channel-plan.sh <site> <ng|na> [--apply]}"; BAND="${2:?band ng|na}"; APPLY=0
CHANS=""; DFSPOL=""
shift 2; while [ $# -gt 0 ]; do case "$1" in
--apply) APPLY=1; shift;;
--channels) CHANS="${2:-}"; shift 2;; # explicit palette, e.g. 52,60,100,108,116,124,132,140
--dfs) DFSPOL="${2:-}"; shift 2;; # ok|avoid|only — data-driven policy (default: ok = all)
*) shift;; esac; done
case "$BAND" in ng|na) ;; *) echo "band must be ng|na (channel planning is for 2.4/5GHz)"; exit 1;; esac
NEIGHBOR_JSON="${NEIGHBOR_JSON:-}"; SURVEY_JSON="${SURVEY_JSON:-}"; SNR_MIN="${NBR_SNR_MIN:-20}"
[ -n "$NEIGHBOR_JSON" ] && [ -f "$NEIGHBOR_JSON" ] || { echo "[ERROR] NEIGHBOR_JSON required (run neighbor-collect.sh with NBR_JSON=...)"; exit 1; }
U="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.username 2>/dev/null)"
P="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.password 2>/dev/null)"
[ -n "$U" ] && [ -n "$P" ] || { echo "[ERROR] no controller cred"; exit 1; }
if [[ "$SITEARG" =~ ^[0-9a-f]{24}$ ]]; then SITE="$SITEARG"; else
SITE="$(bash "$UOS" --sites 2>/dev/null | grep -vi 'pq.html' | grep -i "$SITEARG" | awk '{print $1}' | head -1)"; fi
[ -n "$SITE" ] || { echo "[ERROR] site not found"; exit 1; }
echo "[INFO] channel-plan site=$SITE band=$BAND mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN) (neighbor=$NEIGHBOR_JSON survey=${SURVEY_JSON:-none})"
export CP_SITE="$SITE" CP_BAND="$BAND" CP_APPLY="$APPLY" CP_NBR="$NEIGHBOR_JSON" CP_SURVEY="$SURVEY_JSON" CP_SNR="$SNR_MIN" CP_CHANS="$CHANS" CP_DFS="$DFSPOL" RW_U="$U" RW_P="$P" REPO
python - <<'PY'
import os,sys,json,ssl,urllib.request,http.cookiejar
band=os.environ['CP_BAND']; apply=os.environ['CP_APPLY']=='1'; SNR=int(os.environ['CP_SNR'])
nbr=json.load(open(os.environ['CP_NBR']))
survey=json.load(open(os.environ['CP_SURVEY'])) if os.environ.get('CP_SURVEY') and os.path.exists(os.environ['CP_SURVEY']) else {}
sb={'ng':'2.4','na':'5'}[band]
# Palette: data-driven. ng is always 1/6/11. na: --channels overrides; else --dfs policy decides.
# Default (na) is NOT a hardcoded non-DFS list anymore -- that baked-in bias picked the congested
# channels at Cascades. Run survey-report.py first and pass its suggested palette via --channels.
_chans=os.environ.get('CP_CHANS','').strip()
_dfs=os.environ.get('CP_DFS','').strip().lower()
NONDFS_NA=[36,40,44,48,149,153,157,161]; DFS_NA=[52,60,100,108,116,124,132,140]; ALL_NA=sorted(NONDFS_NA+DFS_NA)
if band=='ng':
CH=[1,6,11]
elif _chans:
CH=[int(x) for x in _chans.replace(' ','').split(',') if x]
elif _dfs=='only': CH=DFS_NA
elif _dfs=='avoid': CH=NONDFS_NA
else: CH=ALL_NA # 'ok'/default: consider ALL 40MHz primaries; measured busy% chooses
H="172.16.3.29";PORT=11443;base=f"https://{H}:{PORT}"
ctx=ssl.create_default_context();ctx.check_hostname=False;ctx.verify_mode=ssl.CERT_NONE
cj=http.cookiejar.CookieJar();op=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj),urllib.request.HTTPSHandler(context=ctx))
def call(m,p,b=None,csrf=None,wh=False):
d=json.dumps(b).encode() if b is not None else None
r=urllib.request.Request(base+p,data=d,method=m);r.add_header('Content-Type','application/json')
if csrf:r.add_header('X-CSRF-Token',csrf)
x=op.open(r,timeout=20);return (x.read().decode('utf-8','replace'),x.headers) if wh else x.read().decode('utf-8','replace')
_,hd=call('POST','/api/auth/login',{'username':os.environ['RW_U'],'password':os.environ['RW_P']},wh=True)
csrf=hd.get('X-CSRF-Token') or hd.get('X-Updated-Csrf-Token')
sites=json.loads(call('GET','/proxy/network/api/self/sites')).get('data',[])
short=next((s['name'] for s in sites if s.get('_id')==os.environ['CP_SITE']),None)
devs=json.loads(call('GET',f'/proxy/network/api/s/{short}/stat/device')).get('data',[])
# current channel per AP (by name) + name->(mac,_id,radio_table)
cur={}; meta={}
for d in devs:
if d.get('type')!='uap' or d.get('state')!=1: continue
nm=d.get('name') or d.get('mac'); meta[nm]=d
for r in d.get('radio_table_stats',[]):
if r.get('radio')==band:
try: cur[nm]=int(r.get('channel'))
except: cur[nm]=None
# adjacency: strong bidirectional neighbors (union over bands), by name
def msnr(a,b):
o=nbr.get(a,{});
return max([o.get(x,{}).get(b,-1) for x in ('2.4','5','6')]+[-1])
aps=[a for a in cur if a in nbr]
adj={a:set() for a in aps}
for a in aps:
for b in aps:
if a!=b and msnr(a,b)>=SNR and msnr(b,a)>=SNR: adj[a].add(b)
order=sorted(aps,key=lambda a:-len(adj[a])) # most-constrained first
plan={}
def busy(ap,ch):
try: return survey.get(ap,{}).get(sb,{}).get(str(ch),50)
except: return 50
from collections import Counter
def cost(a,ch,pl):
coll=sum(1 for n in adj[a] if pl.get(n)==ch) # strong-neighbor co-channel (dominant)
load=Counter(pl.values()).get(ch,0) # balance APs across the palette
bz=busy(a,ch) if band=='na' else 0 # measured congestion (na)
return coll*1000 + load*10 + bz
# greedy: most-constrained first
for a in order:
plan[a]=min(CH,key=lambda ch:cost(a,ch,plan))
# local-search: repeatedly move the worst-collision AP to its best channel until stable
for _ in range(8000):
worst=max(aps,key=lambda a:sum(1 for n in adj[a] if plan.get(n)==plan.get(a)))
cur_c=sum(1 for n in adj[worst] if plan.get(n)==plan.get(worst))
if cur_c==0: break
alt=min(CH,key=lambda ch:cost(worst,ch,plan))
if sum(1 for n in adj[worst] if plan.get(n)==alt) < cur_c: plan[worst]=alt
else: break
changes=[(a,cur[a],plan[a]) for a in sorted(plan) if cur.get(a)!=plan[a]]
print(f"\n==== CHANNEL PLAN band={band} ({len(aps)} APs, {len(changes)} would change) ====")
_pal='explicit --channels' if _chans else (_dfs+' policy' if _dfs else 'data-driven (all 40MHz primaries; busy% chooses)')
print(f"allowed channels: {CH} [{_pal}]")
if band=='na' and not _chans:
print(" TIP: run survey-report.py on the SURVEY_JSON first; pass its suggested clean palette via --channels.")
for a,c0,c1 in changes[:60]:
print(f" {a:<22} {str(c0):>4} -> {c1} (strong-neighbor adj={len(adj[a])})")
if len(changes)>60: print(f" ...(+{len(changes)-60} more)")
# collision check after plan
post=sum(1 for a in aps for n in adj[a] if plan.get(n)==plan.get(a))//2
pre =sum(1 for a in aps for n in adj[a] if cur.get(n)==cur.get(a) and cur.get(a))//2
print(f"\nstrong-neighbor co-channel pairs: before={pre} after={post}")
if not apply:
print("\n[dry-run] no changes. Add --apply to write per-AP channels (gated, rollback saved).")
sys.exit(0)
# APPLY: per changed AP, set radio_table channel
roll=[];done=0;fail=0
for a,c0,c1 in changes:
d=meta[a]; rt=d.get('radio_table') or []
for r in rt:
if r.get('radio')==band: r['channel']=c1
try:
call('PUT',f"/proxy/network/api/s/{short}/rest/device/{d['_id']}",{'radio_table':rt},csrf=csrf)
roll.append({'name':a,'band':band,'old':c0}); done+=1; print(f" [ok] {a} {band} -> ch{c1}")
except Exception as e: fail+=1; print(f" [FAIL] {a}: {e}")
rp=os.path.join(os.environ.get('REPO','.'),'.claude','tmp',f"chanplan-rollback-{short}-{band}.json")
os.makedirs(os.path.dirname(rp),exist_ok=True); open(rp,'w').write(json.dumps(roll,indent=1))
print(f"\n[APPLY] {done} changed, {fail} failed. Rollback: {rp}")
print("[validate] re-run survey-collect / watch-ap after settle; roll out per-zone in practice.")
PY