Files
claudetools/.claude/skills/unifi-wifi/scripts/coverage-thin.sh
Howard Enos 3374483cd6 unifi-wifi: coverage-thin mesh-awareness — never disable wireless-mesh APs or their parents
Howard caught a real hazard: coverage-thin was mesh-blind. At Cascades, 2nd Floor Atrium is the
wireless-mesh PARENT for CC Bridge + salon (backhaul ch36/5GHz), and 206 U7 Pro carries 108. The tool
had listed 2nd Floor Atrium / CC Bridge / 206 as 2.4 disable targets. Although the backhaul is 5GHz
(so a 2.4-radio disable wouldn't drop it), touching infra APs that feed others is needless risk.

Fix: fetch live uplink topology (stat/device); build the mesh set = wireless-uplink APs UNION their
parents; exclude them from disable (kept as coverers if their 2.4 is on); print MESH-PROTECTED line.
Falls back with a clear WARNING if no controller cred. Cascades now auto-excludes 108/206/2nd Atrium/
CC Bridge/salon; resilient plan 34->33. Also verified SSIDs are not AP-pinned (broadcasting_aps off),
so no client is orphaned by a radio disable.

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

171 lines
11 KiB
Bash

#!/usr/bin/env bash
# coverage-thin.sh — "which 2.4 radios can we turn OFF?" When 2.4 is over-deployed (every AP blasting
# 2.4, huge co-channel contention, high airtime) you don't need every 2.4 radio on to cover the floor.
# This computes a COVERAGE-REDUNDANCY (dominating-set) plan on the AP-to-AP 2.4 SNR matrix: disable the
# radios whose area is still covered by a nearby ACTIVE 2.4 neighbor, maximizing airtime/interference
# removed without opening a 2.4 coverage hole.
#
# Why this exists alongside optimize-radios.sh: optimize uses band-AGNOSTIC physical adjacency (good for
# "are APs near each other"), so it can count an AP whose 2.4 is DISABLED as a "coverer" via its 5/6 GHz.
# For the 2.4-coverage question that's wrong (a disabled 2.4 radio covers no 2.4 clients). coverage-thin
# uses the 2.4 SNR layer specifically and only ever counts neighbors whose 2.4 radio stays ON.
#
# REQUIRES the SNR matrix: NBR=.claude/tmp/<site>-nbr.json
# NBR_JSON=$NBR bash .../neighbor-collect.sh <site>
# NEIGHBOR_JSON=$NBR bash .../coverage-thin.sh <site> [days=14]
# Read-only (Mongo plane). Tunables (env): COVER_SNR=28 (min AP-to-AP 2.4 SNR to count as coverage),
# MINCOV=1 (active 2.4 coverers a disabled AP must retain; flags <2 as low-resilience),
# ZPCT=50 (max % of a zone's 2.4 radios off), CLIENT_CAP=12 (max projected avg clients on a coverer).
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: coverage-thin.sh <site> [days=14] (NEIGHBOR_JSON=<matrix> required)}"; DAYS="${2:-14}"
NJ="${NEIGHBOR_JSON:-}"; [ -n "$NJ" ] && [ -f "$NJ" ] || { echo "[ERROR] NEIGHBOR_JSON=<matrix.json> required (run neighbor-collect.sh with NBR_JSON=...)"; 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: $SITEARG"; exit 1; }
echo "[INFO] coverage-thin site=$SITE window=${DAYS}d matrix=$NJ"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
# ---- MESH SAFETY: wireless-mesh APs depend on a parent's backhaul; never disable a mesh AP or a mesh
# parent (even 2.4-only changes risk the children). Fetch live uplink topology (controller) -> exclude. ----
MESH="$TMP/mesh.txt"; : > "$MESH"
CU="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.username 2>/dev/null || true)"
CP="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.password 2>/dev/null || true)"
if [ -n "$CU" ] && [ -n "$CP" ]; then
base="https://$HOST:$PORT"; CJ="$TMP/cj"
curl -sk -c "$CJ" -o /dev/null -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")" 2>/dev/null
SHORT="$(curl -sk -b "$CJ" "$base/proxy/network/api/self/sites" | python -c "import sys,json;[print(s['name']) for s in json.load(sys.stdin).get('data',[]) if s.get('_id')=='$SITE']" 2>/dev/null)"
curl -sk -b "$CJ" "$base/proxy/network/api/s/${SHORT:-$SITEARG}/stat/device" -o "$TMP/dev.json" 2>/dev/null
python - "$TMP/dev.json" "$MESH" <<'PY' 2>/dev/null || true
import sys,json
try: dev=json.load(open(sys.argv[1])).get('data',[])
except Exception: dev=[]
aps=[d for d in dev if d.get('type')=='uap']; bymac={d.get('mac'):(d.get('name') or d.get('mac')) for d in aps}
mesh=set()
for a in aps:
up=a.get('uplink') or {}
if up.get('type')=='wireless':
mesh.add(a.get('name')) # the wireless child
if up.get('uplink_mac') in bymac: mesh.add(bymac[up['uplink_mac']]) # its parent
open(sys.argv[2],'w',newline='\n').write('\n'.join(sorted(x for x in mesh if x)))
PY
[ -s "$MESH" ] && echo "[INFO] mesh APs auto-excluded from disable: $(tr '\n' ' ' < "$MESH")" || echo "[INFO] no wireless-mesh APs detected (all wired)."
else
echo "[WARNING] no controller cred -> CANNOT detect mesh topology; mesh APs are NOT auto-excluded. Verify manually before disabling!"
fi
# ---- per-AP 2.4 state + airtime/clients (Mongo) -> TSV ----
cat <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need' > "$TMP/ap.tsv"
var ace=db.getSiblingDB('ace'), st=db.getSiblingDB('ace_stat'), SITE="$SITE";
var since=new Date().getTime()-$DAYS*86400000;
var name={},zone={},chan={},state={};
ace.device.find({site_id:SITE,type:'uap'},{mac:1,name:1,radio_table:1}).forEach(function(a){
var nm=a.name||a.mac; name[a.mac]=nm;
var fz=String(nm).match(/(\d)(?:st|nd|rd|th)\s*floor/i), rm=String(nm).match(/\b(\d)\d{2}\b/);
zone[a.mac]=fz?('Floor '+fz[1]):(rm?('Floor '+rm[1]):'misc');
(a.radio_table||[]).forEach(function(r){ if(r.radio=='ng'){ chan[a.mac]=r.channel; state[a.mac]=r.tx_power_mode||'auto'; }});
});
var prof={};
st.stat_hourly.find({o:'ap',site_id:SITE,time:{\$gte:since}}).forEach(function(d){
var ap=d.ap; if(!ap||name[ap]===undefined)return;
var cu=d['ng-cu_total'],intf=d['ng-cu_interf'],sta=d['ng-num_sta'],retr=d['ng-tx_retries'],att=d['ng-wifi_tx_attempts'];
if(cu==null&&intf==null)return;
if(!prof[ap])prof[ap]={cu:0,intf:0,sta:0,pk:0,retr:0,att:0,n:0};
var p=prof[ap]; p.cu+=(cu||0);p.intf+=(intf||0);p.sta+=(sta||0);p.pk=Math.max(p.pk,sta||0);p.retr+=(retr||0);p.att+=(att||0);p.n++;
});
Object.keys(name).forEach(function(m){ var p=prof[m]||{cu:0,intf:0,sta:0,pk:0,retr:0,att:0,n:0};
var cu=p.n?p.cu/p.n:0, intf=p.n?p.intf/p.n:0, sta=p.n?p.sta/p.n:0, retr=p.att>0?Math.min(100,100*p.retr/p.att):0;
print("ROW\t"+name[m]+"\t"+zone[m]+"\t"+(chan[m]||'?')+"\t"+(state[m]||'none')+"\t"+cu.toFixed(0)+"\t"+intf.toFixed(0)+"\t"+retr.toFixed(0)+"\t"+sta.toFixed(2)+"\t"+p.pk);
});
JS
# ---- greedy coverage-thinning on the 2.4 SNR layer ----
COVER_SNR="${COVER_SNR:-28}"; MINCOV="${MINCOV:-1}"; ZPCT="${ZPCT:-50}"; CLIENT_CAP="${CLIENT_CAP:-12}"
python - "$TMP/ap.tsv" "$NJ" "$COVER_SNR" "$MINCOV" "$ZPCT" "$CLIENT_CAP" "$MESH" <<'PY'
import sys,json
MESH=set()
try: MESH={l.strip() for l in open(sys.argv[7],encoding='utf-8') if l.strip()}
except Exception: pass
ap={}
for ln in open(sys.argv[1],encoding='utf-8',errors='replace'):
if not ln.startswith('ROW\t'): continue
_,nm,z,ch,stt,cu,intf,retr,sta,pk=ln.rstrip('\n').split('\t')
ap[nm]={'zone':z,'ch':ch,'st':stt,'cu':float(cu),'intf':float(intf),'retr':float(retr),'avg':float(sta),'pk':int(pk)}
M=json.load(open(sys.argv[2])); COVER=float(sys.argv[3]); MINCOV=int(sys.argv[4]); ZPCT=float(sys.argv[5]); CCAP=float(sys.argv[6])
def lay(a): return (M.get(a) or {}).get('2.4') or {}
def snr(a,b):
x=lay(a).get(b); y=lay(b).get(a); vals=[v for v in (x,y) if isinstance(v,(int,float))]
return min(vals) if vals else None # both-ways when available; min = conservative
# nodes = APs with an ng radio that is currently ON (disabled radios neither candidates nor coverers)
nodes=[a for a in ap if ap[a]['st']!='disabled' and ap[a]['st']!='none']
adj={a:set(b for b in nodes if b!=a and (snr(a,b) is not None and snr(a,b)>=COVER)) for a in nodes}
on={a:True for a in nodes}
proj={a:ap[a]['avg'] for a in nodes}
zoneTot={}; zoneOff={}
for a in ap:
if ap[a]['st']=='none': continue
z=ap[a]['zone']; zoneTot[z]=zoneTot.get(z,0)+1
if ap[a]['st']=='disabled': zoneOff[z]=zoneOff.get(z,0)+1
def coverers(a): return [b for b in adj[a] if on[b]]
# order: disable the biggest air hog / lowest value first
order=sorted(nodes, key=lambda a:-(ap[a]['intf']+0.5*ap[a]['retr']-2*ap[a]['avg']))
disabled=[]
changed=True
while changed:
changed=False
for a in order:
if not on[a]: continue
if a in MESH: continue # never disable a mesh AP/parent (backhaul risk)
cov=coverers(a)
if len(cov)<max(1,MINCOV): continue # area must stay covered by active 2.4
# disabling 'a' must not strand an already-OFF neighbor that relied on it
bad=False
for b in adj[a]:
if not on[b] and not [c for c in adj[b] if on[c] and c!=a]: bad=True; break
if bad: continue
z=ap[a]['zone']
if (zoneOff.get(z,0)+1)/max(1,zoneTot.get(z,1)) > ZPCT/100: continue # don't over-thin a zone
absorber=min(cov,key=lambda c:proj[c])
if proj[absorber]+ap[a]['avg']>CCAP: continue # don't overload the coverer
on[a]=False; zoneOff[z]=zoneOff.get(z,0)+1; proj[absorber]+=ap[a]['avg']
disabled.append(a); changed=True
# report
keep=[a for a in nodes if on[a]]
intf_removed=sum(ap[a]['intf'] for a in disabled)
def pad(s,w): s=str(s); return s+' '*max(0,w-len(s))
print(f"\n==== 2.4 COVERAGE-THINNING PLAN (active 2.4 radios={len(nodes)}) DISABLE={len(disabled)} KEEP={len(keep)} ====")
print(f"SNR>={COVER:.0f} = 'covers same area'; only ACTIVE-2.4 neighbors count; <{ '2' if MINCOV<2 else MINCOV} coverers flagged.")
if MESH: print(f"MESH-PROTECTED (never disabled - wireless backhaul): {', '.join(sorted(MESH))}")
print("")
from collections import defaultdict
byz=defaultdict(list)
for a in disabled: byz[ap[a]['zone']].append(a)
for z in sorted(byz):
print(f" [{z}] (zone now {zoneOff.get(z,0)}/{zoneTot.get(z,0)} 2.4 radios off)")
for a in sorted(byz[z], key=lambda x:-ap[x]['intf']):
cov=[b for b in adj[a] if on[b]]
cs=", ".join(f"{b}(SNR {int(snr(a,b))})" for b in sorted(cov,key=lambda b:-(snr(a,b) or 0))[:3])
flag=" [LOW-RESILIENCE: 1 coverer]" if len(cov)<2 else ""
print(f" {a}: ch{ap[a]['ch']} cu={ap[a]['cu']:.0f}% interf={ap[a]['intf']:.0f}% clients(avg/pk)={ap[a]['avg']:.1f}/{ap[a]['pk']} retr={ap[a]['retr']:.0f}%")
print(f" -> stays covered on 2.4 by: {cs}{flag}")
# co-channel contention before/after on the KEPT set
def chans(setlist):
c=defaultdict(int)
for a in setlist:
if ap[a]['ch'] not in ('?','none'): c[ap[a]['ch']]+=1
return dict(sorted(c.items(), key=lambda x:-x[1]))
print(f"\nco-channel 2.4 radios BEFORE: {chans(nodes)}")
print(f"co-channel 2.4 radios AFTER : {chans(keep)}")
print(f"\nESTIMATE: ~{intf_removed:.0f} interference-airtime points removed from the air ({len(disabled)} fewer contending 2.4 radios).")
print("APPLY -- PER AP (do NOT use --zone disable; that turns off the WHOLE floor, not this subset).")
print("One at a time; after each, confirm a kept neighbor took its clients + no dead spot, then continue:")
for a in disabled:
print(f" bash .claude/skills/unifi-wifi/scripts/apply-radio.sh <site> ng disable --ap \"{a}\" --apply # {ap[a]['zone']}")
print("[rollback] re-enable any AP: apply-radio.sh <site> ng enable --ap \"<name>\" --apply (rollback json in .claude/tmp).")
print("[note] coverage proven by AP-to-AP 2.4 SNR (APs hearing each other), a proxy for client-level overlap.")
print(" Tip: power-down the busy/high-client members instead of disabling; disable the low-client redundant ones.")
PY