unifi-wifi: radio-usage.sh --ap mode — per-device 2.4 history + steerable-vs-legacy tagging

Adds `radio-usage.sh <site> <band> --ap "<AP name>"`: lists the devices on one AP's band by merging
live clients (stat/sta) with recent association events (wifi_connectivity_event, band-aware), enriched
from ace.user identity. Tags each device steerable vs legacy:
  - from events: DUAL (also seen on 5/6 GHz -> steerable) vs NG-ONLY (2.4-only -> legacy/IoT)
  - fallback when no event in the (short ~1d) retention window: randomized MAC = modern phone/laptop
    (likely 5G/steerable) vs fixed vendor OUI = likely IoT/legacy.
Decision value: steerable -> fix via band-steering/min-RSSI; a legacy/IoT device present argues AGAINST
disabling that 2.4 radio. Needs controller cred for the live BSSID (vap_table) map; honest about the
short event retention. Validated live on Cascades (347, Dining Room).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 15:51:13 -07:00
parent 659c3a2bda
commit 80384a79f4
2 changed files with 107 additions and 4 deletions

View File

@@ -87,7 +87,12 @@ path is Cascades — override with the script's vault-path arg per client.
```bash ```bash
bash .claude/skills/unifi-wifi/scripts/radio-usage.sh <site> [band=ng|na|6e] [days=30] bash .claude/skills/unifi-wifi/scripts/radio-usage.sh <site> [band=ng|na|6e] [days=30]
NEIGHBOR_JSON=<nbr.json> bash .../radio-usage.sh <site> ng 77 # adds a safe-to-disable shortlist NEIGHBOR_JSON=<nbr.json> bash .../radio-usage.sh <site> ng 77 # adds a safe-to-disable shortlist
bash .../radio-usage.sh <site> ng --ap "<AP name>" # per-DEVICE: who's on this AP's 2.4 + steerable?
``` ```
The `--ap` mode lists the devices on one AP's band (live clients + recent assoc events) and tags each
**steerable** (dual-band, from events; or a randomized MAC = modern phone/laptop) vs **legacy/IoT**
(fixed vendor OUI, 2.4-only) — so you know whether the fix is band-steering or whether a 2.4-only device
forces the radio to stay. (Needs controller cred for the live BSSID map; assoc-event retention is ~1 day.)
Key distinction: `avg~0 but peak>0` = NOT unused (takes bursts → POWER-DOWN); only `peak==0` over the Key distinction: `avg~0 but peak>0` = NOT unused (takes bursts → POWER-DOWN); only `peak==0` over the
window = genuinely unused (disable-safe). With the SNR matrix it cross-checks low-use APs against window = genuinely unused (disable-safe). With the SNR matrix it cross-checks low-use APs against
overlapping neighbors w/ headroom. (Cascades 2.4: only 1 never-used radio — the offline AP 108; every overlapping neighbors w/ headroom. (Cascades 2.4: only 1 never-used radio — the offline AP 108; every

View File

@@ -20,16 +20,114 @@
# LOW_AVG=0.5 NBR_SNR_MIN=30 HEADROOM=4 thresholds (avg-users "low", strong-neighbor SNR, absorb cap) # LOW_AVG=0.5 NBR_SNR_MIN=30 HEADROOM=4 thresholds (avg-users "low", strong-neighbor SNR, absorb cap)
set -uo pipefail set -uo pipefail
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
UOS="$REPO/.claude/scripts/uos-mongo.sh" UOS="$REPO/.claude/scripts/uos-mongo.sh"; VAULT="$REPO/.claude/scripts/vault.sh"
SITEARG="${1:?usage: radio-usage.sh <site> [band=ng|na|6e] [days=30]}" HOST="${UOS_HOST:-172.16.3.29}"; PORT="${UOS_HTTPS_PORT:-11443}"
BAND="${2:-ng}"; DAYS="${3:-30}" SITEARG="${1:?usage: radio-usage.sh <site> [band=ng|na|6e] [days=30] [--ap <name>]}"; 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 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 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 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; } [ -n "$SITE" ] || { echo "[ERROR] site not found: $SITEARG"; exit 1; }
echo "[INFO] radio-usage site=$SITE band=$BAND window=${DAYS}d"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
# ============ DEVICE mode: --ap <name> -> which devices used this AP's <band> (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 <<JS | bash "$UOS" 2>&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<TAB>... ---- # ---- Mongo: per-AP avg(time-avg users) + peak(station snapshot) over the window. Emits ROW<TAB>... ----
cat <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need' > "$TMP/rows.txt" cat <<JS | bash "$UOS" 2>&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 ace=db.getSiblingDB('ace'), st=db.getSiblingDB('ace_stat');