#!/usr/bin/env bash # radio-usage.sh — per-AP client-USAGE history for a band, from accumulated controller stats # (ace_stat.stat_daily, ~77d retention). Answers "is this radio actually used?" so you can tell a # truly-idle radio (safe to DISABLE) apart from a lightly-but-genuinely-used one (POWER-DOWN, not off). # # Two metrics per AP, because they say different things: # avg = time-averaged concurrent users (-num_sta_avg) — how busy the radio is on average # peak = max station snapshot (-num_sta) — did ANY device ever land here # A radio with avg~0 but peak>0 is NOT unused: it takes occasional bursts (legacy/IoT/roamers). Disabling # it strands those clients onto a farther AP. Only peak==0 over the window = genuinely unused. # # With NEIGHBOR_JSON= (from neighbor-collect.sh NBR_JSON=...), cross the low-usage APs against the # AP-to-AP SNR matrix to emit a DEFENSIBLE safe-to-disable shortlist: a low-usage AP whose strong # overlapping neighbor has the headroom to absorb its rare clients. This is the data-backed version of # the optimize-radios DISABLE gate, driven by real client history instead of the roam graph. # # Read-only. Mongo plane (no controller cred), like model-rank/optimize-radios. # Usage: bash radio-usage.sh [band=ng|na|6e] [days=30] # NEIGHBOR_JSON= also print the safe-to-disable shortlist # LOW_AVG=0.5 NBR_SNR_MIN=30 HEADROOM=4 thresholds (avg-users "low", strong-neighbor SNR, absorb cap) 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: radio-usage.sh [band=ng|na|6e] [days=30] [--ap ]}"; shift BAND="ng"; DAYS="30"; APNAME=""; POS=() while [ $# -gt 0 ]; do case "$1" in --ap) APNAME="${2:?--ap needs an AP name}"; shift 2;; *) POS+=("$1"); shift;; esac; done [ ${#POS[@]} -ge 1 ] && BAND="${POS[0]}"; [ ${#POS[@]} -ge 2 ] && DAYS="${POS[1]}" case "$BAND" in ng|na|6e) ;; *) echo "[ERROR] band must be ng|na|6e"; exit 1;; esac 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; } TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT # ============ DEVICE mode: --ap -> which devices used this AP's (recent) + capability ============ if [ -n "$APNAME" ]; then echo "[INFO] radio-usage DEVICE history: site=$SITE band=$BAND AP='$APNAME'" case "$BAND" in ng) BTOK=BAND_NG;; na) BTOK=BAND_NA;; 6e) BTOK=BAND_6E;; esac 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 "[BLOCKED] --ap needs controller cred at infrastructure/uos-server-network-api-rw (for the live BSSID map + current clients)"; exit 2; } 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"; exit 1; } 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']")" [ -n "$SHORT" ] || SHORT="$SITEARG" curl -sk -b "$CJ" "$base/proxy/network/api/s/$SHORT/stat/device" -o "$TMP/dev.json" curl -sk -b "$CJ" "$base/proxy/network/api/s/$SHORT/stat/sta" -o "$TMP/sta.json" RES="$(python - "$TMP/dev.json" "$TMP/sta.json" "$APNAME" "$BAND" "$TMP/bssids.txt" "$TMP/live.tsv" <<'PY' import sys,json dev=json.load(open(sys.argv[1])).get('data',[]); sta=json.load(open(sys.argv[2])).get('data',[]) apname=sys.argv[3].lower(); band=sys.argv[4] ap=next((a for a in dev if a.get('type')=='uap' and (a.get('name','').lower()==apname)),None) if not ap: print("ERR not-found"); sys.exit(0) apmac=ap.get('mac') bss=[v.get('bssid','').lower() for v in (ap.get('vap_table') or []) if v.get('radio')==band and v.get('bssid')] open(sys.argv[5],'w',newline='\n').write('\n'.join(bss)) live=[s for s in sta if s.get('ap_mac')==apmac and s.get('radio')==band] open(sys.argv[6],'w',newline='\n').write('\n'.join(f"{s.get('mac')}\t{s.get('signal')}\t{s.get('channel')}\t{(s.get('oui') or '')}" for s in live)) print(f"OK {apmac} bssids={len(bss)} live={len(live)}") PY )" echo " [live] $RES" case "$RES" in ERR*) echo "[ERROR] AP '$APNAME' not found at this site (check exact name from audit-site/live-stats)"; exit 1;; esac BSSID_JS="$(python -c "import sys;print(','.join('\"%s\"'%b for b in open(sys.argv[1]).read().split()))" "$TMP/bssids.txt")" # Plane 1: band-aware association events (retention ~1 day) + cross-band capability + ace.user identity cat <&1 | grep -viE 'pq.html|post-quantum|store now|server may need' > "$TMP/hist.tsv" var ace=db.getSiblingDB('ace'), st=db.getSiblingDB('ace_stat'), SITE="$SITE"; var bset={}; [$BSSID_JS].forEach(function(b){bset[String(b).toLowerCase()]=1;}); var hist={}; st.wifi_connectivity_event.find({site_id:SITE,'to_endpoint.band':"$BTOK"}).forEach(function(e){ var b=((e.to_endpoint&&e.to_endpoint.mac)||'').toLowerCase(); if(!bset[b])return; var m=e.client_mac; if(!m)return; if(!hist[m])hist[m]={n:0,last:0}; hist[m].n++; if(e.time>hist[m].last)hist[m].last=e.time; }); var macs=Object.keys(hist), dual={}; if(macs.length){ st.wifi_connectivity_event.find({site_id:SITE,client_mac:{\$in:macs}, \$or:[{'to_endpoint.band':{\$in:['BAND_NA','BAND_6E']}},{'from_endpoint.band':{\$in:['BAND_NA','BAND_6E']}}]},{client_mac:1}).forEach(function(e){dual[e.client_mac]=1;}); } macs.forEach(function(m){ var u=ace.user.findOne({site_id:SITE,mac:m})||{}; print("DEV\t"+m+"\t"+new Date(hist[m].last).toISOString()+"\t"+hist[m].n+"\t"+(dual[m]?'DUAL':'NG-ONLY')+"\t"+((u.hostname||'').replace(/\t/g,' '))+"\t"+(u.oui||'')+"\t"+(u.last_seen?new Date(u.last_seen*1000).toISOString().slice(0,10):'')); }); JS python - "$TMP/hist.tsv" "$TMP/live.tsv" "$APNAME" "$BAND" <<'PY' import sys hist=[] for ln in open(sys.argv[1],encoding='utf-8',errors='replace'): if not ln.startswith('DEV\t'): continue p=ln.rstrip('\n').split('\t') while len(p)<8: p.append('') hist.append({'mac':p[1],'last':p[2],'n':int(p[3] or 0),'cap':p[4],'host':p[5],'oui':p[6],'seen':p[7]}) live={} for ln in open(sys.argv[2],encoding='utf-8',errors='replace'): q=ln.rstrip('\n').split('\t') if len(q)>=2 and q[0]: live[q[0]]={'sig':q[1],'ch':q[2] if len(q)>2 else '','oui':q[3] if len(q)>3 else ''} ap,band=sys.argv[3],sys.argv[4] # union: anything in events + anything live-on-now (covers clients present but with no assoc event in window) allmacs={h['mac'] for h in hist} | set(live) def pad(s,w): s=str(s); return s+' '*max(0,w-len(s)) print(f"\n==== Devices on '{ap}' {band} ==== (assoc events retained ~1 day; 'on now' from live)") print(pad("MAC",19)+pad("name/oui",22)+pad("assoc",7)+pad("last_assoc(UTC)",22)+pad("on_now",9)+"capability") def randomized(mac): try: return (int(mac.split(':')[0],16) & 0x02)!=0 # locally-administered bit = randomized (modern phones/laptops) except: return False byh={h['mac']:h for h in hist} steer=legacy=0 for m in sorted(allmacs, key=lambda x:(-(byh.get(x,{}).get('n',0)))): h=byh.get(m,{}); l=live.get(m) nm=(h.get('host') or h.get('oui') or (l or {}).get('oui') or '')[:21] cap=h.get('cap','') if cap=='DUAL': captag='5G-capable (event) -> STEERABLE'; steer+=1 elif cap=='NG-ONLY': captag='2.4-only (event) -> legacy/IoT?'; legacy+=1 elif randomized(m): captag='randomized MAC -> modern, likely 5G/STEERABLE'; steer+=1 else: v=(h.get('oui') or (l or {}).get('oui') or m[:8]); captag=f'fixed-OUI {v} -> likely IoT/legacy'; legacy+=1 onnow = (l['sig']+'dBm') if l else '-' print(pad(m,19)+pad(nm,22)+pad(h.get('n','-'),7)+pad(h.get('last','-'),22)+pad(onnow,9)+captag) print(f"\nSUMMARY: {len(allmacs)} device(s) on {band} for '{ap}' | likely-steerable={steer} likely-legacy/IoT={legacy} on-now={len(live)}") print(" capability = DUAL/NG-ONLY from assoc events when present, else inferred from MAC (randomized=modern, fixed-OUI=IoT).") print(" -> STEERABLE: fix via band-steering / min-RSSI (apply-wlan bandsteer, apply-radio minrssi), not the radio.") print(" -> legacy/IoT must keep a 2.4 radio in range -> argues AGAINST disabling this radio if any are present.") print(" [retention] connectivity events are short-lived here; run during/after busy hours for a fuller event-based read.") PY exit 0 fi # ============ AGGREGATE mode (default) ============ echo "[INFO] radio-usage site=$SITE band=$BAND window=${DAYS}d" # ---- Mongo: per-AP avg(time-avg users) + peak(station snapshot) over the window. Emits ROW... ---- cat <&1 | grep -viE 'pq.html|post-quantum|store now|server may need' > "$TMP/rows.txt" var ace=db.getSiblingDB('ace'), st=db.getSiblingDB('ace_stat'); var SITE="$SITE", BAND="$BAND", DAYS=$DAYS, since=new Date().getTime()-DAYS*86400000; var name={}, rname={}, rstate={}; ace.device.find({site_id:SITE,type:'uap'},{mac:1,name:1,radio_table:1}).forEach(function(a){ name[a.mac]=a.name||a.mac; (a.radio_table||[]).forEach(function(r){ if(r.radio==BAND){ rname[a.mac]=r.name; rstate[a.mac]=r.tx_power_mode||'auto'; }}); }); var agg={}; st.stat_daily.find({o:'ap',site_id:SITE,time:{\$gte:since}}).forEach(function(d){ var ap=d.ap; if(!ap||name[ap]===undefined)return; var nm=rname[ap]; if(!nm)return; var av=d[nm+'-num_sta_avg'], pk=d[BAND+'-num_sta']; if(!agg[ap])agg[ap]={s:0,sn:0,pk:0,days:0,n:0}; var a=agg[ap]; if(av!=null){a.s+=av;a.sn++;} if(pk!=null){if(pk>a.pk)a.pk=pk; if(pk>0)a.days++;} a.n++; }); Object.keys(agg).forEach(function(m){ var a=agg[m]; var avg=a.sn? (a.s/a.sn):-1; print("ROW\t"+name[m]+"\t"+avg.toFixed(3)+"\t"+a.pk+"\t"+a.days+"\t"+a.n+"\t"+(rstate[m]||'?')); }); JS # ---- Python: format report + (optional) neighbor-matrix safe-to-disable shortlist ---- LOW_AVG="${LOW_AVG:-0.5}"; NBR_SNR_MIN="${NBR_SNR_MIN:-30}"; HEADROOM="${HEADROOM:-4}" python - "$TMP/rows.txt" "${NEIGHBOR_JSON:-NONE}" "$LOW_AVG" "$NBR_SNR_MIN" "$HEADROOM" "$BAND" <<'PY' import sys,json rows=[] for ln in open(sys.argv[1],encoding='utf-8',errors='replace'): if not ln.startswith('ROW\t'): continue _,ap,avg,pk,days,n,st=ln.rstrip('\n').split('\t') rows.append({'ap':ap,'avg':float(avg),'pk':int(pk),'days':int(days),'n':int(n),'st':st}) if not rows: print("[WARN] no daily stats in window (retention may be shorter)."); sys.exit(0) NJ,LOW,SNR,HEAD,BAND=sys.argv[2],float(sys.argv[3]),float(sys.argv[4]),float(sys.argv[5]),sys.argv[6] rows.sort(key=lambda r:(r['avg'],r['pk'])) idle=[r for r in rows if r['pk']==0] low=[r for r in rows if r['pk']>0 and 0<=r['avg'] genuinely disable-safe; " f"{len(low)} low-avg(<{LOW}) but with real peaks -> POWER-DOWN, not disable (bursts land here).") if idle: print(" never-used: "+", ".join(f"{r['ap']}({r['st']})" for r in idle)) if NJ!='NONE': try: M=json.load(open(NJ)) except Exception as e: print(f"\n[WARN] could not read NEIGHBOR_JSON {NJ}: {e}"); sys.exit(0) # union strong neighbors across bands (physical overlap is band-independent) avgby={r['ap']:r for r in rows} def strong_nbrs(ap): out={} for b,nb in (M.get(ap) or {}).items(): for nm,snr in nb.items(): try: s=float(snr) except: continue if s>=SNR: out[nm]=max(out.get(nm,-999),s) return out print(f"\n==== SAFE-TO-DISABLE SHORTLIST (low-use AP + strong neighbor w/ headroom; SNR>={SNR:.0f}) ====") found=0 for r in low+idle: if r['st']=='disabled': continue nbrs=strong_nbrs(r['ap']) # an absorber = active strong neighbor whose avg + this AP's avg stays under HEADROOM absorbers=[(nm,avgby[nm]['avg']) for nm in nbrs if nm in avgby and avgby[nm]['st']!='disabled' and (avgby[nm]['avg']+max(r['avg'],0))<=HEAD] if absorbers: found+=1 ab=", ".join(f"{nm}(avg {a:.2f})" for nm,a in sorted(absorbers,key=lambda x:x[1])[:3]) print(f" {r['ap']} avg={r['avg']:.2f} peak={r['pk']} -> covered by: {ab}") if not found: print(" (none clear the bar — every low-use radio's strong neighbors lack headroom, or no SNR overlap.") print(" That CONFIRMS the conservative call: power-down, don't disable.)") print(f"\n[note] shortlist = candidates, not orders. Many are each other's absorbers (mutual coverage),") print(f" so only a NON-CONFLICTING SUBSET can be disabled — once you disable one, its neighbors are") print(f" no longer spare. optimize-radios.sh does the greedy conflict-free selection (with the same") print(f" SNR matrix via NEIGHBOR_JSON); use this list to understand WHY. Disable one at a time, watch") print(f" the neighbor absorb the load (live-stats/watch-ap) before+after. Rare bursts WILL reassociate.") PY echo "" echo "[tip] add the SNR matrix for the disable shortlist:" echo " NBR=\$(mktemp -u).json; NBR_JSON=\$NBR bash .../neighbor-collect.sh $SITEARG; NEIGHBOR_JSON=\$NBR bash .../radio-usage.sh $SITEARG $BAND $DAYS"