Files
claudetools/.claude/skills/unifi-wifi/scripts/channel-plan.sh
Howard Enos 48592bd16b sync: auto-sync from HOWARD-HOME at 2026-06-16 07:26:57
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-06-16 07:26:57
2026-06-16 07:27:06 -07:00

108 lines
6.7 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
shift 2; while [ $# -gt 0 ]; do case "$1" in --apply) APPLY=1; shift;; *) 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" 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]
CH = [1,6,11] if band=='ng' else [36,40,44,48,149,153,157,161] # non-DFS only (radar-safe)
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
for a in order:
best=None;bestcost=1e9
for ch in CH:
coll=sum(1 for n in adj[a] if plan.get(n)==ch) # strong neighbors already on this channel
cost = coll*1000 + (busy(a,ch) if band=='na' else 0) # ng: pure separation; na: separation + measured busy
if cost<bestcost: bestcost=cost; best=ch
plan[a]=best
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) ====")
print(f"allowed channels: {CH} (non-DFS only)")
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