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>
171 lines
11 KiB
Bash
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
|