diff --git a/.claude/skills/unifi-wifi/SKILL.md b/.claude/skills/unifi-wifi/SKILL.md index edd60ca..7443395 100644 --- a/.claude/skills/unifi-wifi/SKILL.md +++ b/.claude/skills/unifi-wifi/SKILL.md @@ -81,7 +81,11 @@ controller knows and making prioritized, validated changes. Built for any site; These read each AP directly (controller hides this data). All take ` [ap-ssh-vault-path]`, use sshpass-or-SSH_ASKPASS auth, and must run in the **foreground**: ```bash -# AP-to-AP SNR neighbor matrix (/proc/ui_neighbor) -> redundancy = data-backed disable candidates +# AP-to-AP SNR neighbor matrix (/proc/ui_neighbor) -> redundancy = data-backed disable candidates. +# Set NBR_JSON to also emit a machine-readable adjacency, then feed it to optimize-radios.sh: +NBR_JSON=.claude/tmp/-nbr.json bash .claude/skills/unifi-wifi/scripts/neighbor-collect.sh cascades +NEIGHBOR_JSON=.claude/tmp/-nbr.json bash .claude/skills/unifi-wifi/scripts/optimize-radios.sh cascades 14 ng +# ^ optimize-radios uses the measured SNR overlap (not the sparse roam graph) -> real DISABLE list. bash .claude/skills/unifi-wifi/scripts/neighbor-collect.sh cascades [vault-path] [snr_min=20] # measured per-channel busy%/noise per AP -> cleanest-channel plan (iw survey dump) bash .claude/skills/unifi-wifi/scripts/survey-collect.sh cascades [vault-path] diff --git a/.claude/skills/unifi-wifi/scripts/neighbor-collect.sh b/.claude/skills/unifi-wifi/scripts/neighbor-collect.sh index acce1ba..5b8571c 100644 --- a/.claude/skills/unifi-wifi/scripts/neighbor-collect.sh +++ b/.claude/skills/unifi-wifi/scripts/neighbor-collect.sh @@ -26,6 +26,8 @@ VAULT="$REPO/.claude/scripts/vault.sh"; UOS="$REPO/.claude/scripts/uos-mongo.sh" HOST="${UOS_HOST:-172.16.3.29}"; PORT="${UOS_HTTPS_PORT:-11443}" SITEARG="${1:?usage: neighbor-collect.sh [ap-ssh-vault-path] [snr_min]}" VP="${2:-clients/cascades-tucson/unifi-ap-ssh}"; SNR_MIN="${3:-20}" +NBR_JSON="${NBR_JSON:-}" # if set, also write a machine-readable adjacency {ap:{band:{nbr:snr}}} here + # (consumed by optimize-radios.sh via NEIGHBOR_JSON for data-backed disables) TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT # --- controller creds + login (RW admin reads fine) --- @@ -97,10 +99,11 @@ done < "$TMP/aps.tsv" echo "" >&2 # --- parse + map + emit adjacency matrix + redundancy summary --- -python - "$RAW" "$TMP/macmap.json" "$TMP/bssmap.json" "$SNR_MIN" <<'PY' +python - "$RAW" "$TMP/macmap.json" "$TMP/bssmap.json" "$SNR_MIN" "${NBR_JSON:-NONE}" <<'PY' import json,re,sys raw=open(sys.argv[1],encoding='utf-8',errors='replace').read().splitlines() macmap=json.load(open(sys.argv[2])); bssmap=json.load(open(sys.argv[3])); SNR_MIN=int(sys.argv[4]) +OUTJSON=sys.argv[5] if len(sys.argv)>5 else 'NONE' BN={'0':'2.4','1':'5','2':'6'}; EB={'2.4ghz':'2.4','5ghz':'5','6ghz':'6'} # edges[(src, band)] = {neighbor_name: best_snr}; presence[(src,band)] = set(names) from ess_ap_list edges={}; presence={}; cur=None; mode=None; band=None @@ -153,6 +156,14 @@ for b in ('2.4','5','6'): print(f"\n-- {b}GHz: {len(redund)}/{len(rows)} APs have >=2 strong neighbors (disable/power-down candidates) --") for cnt,ap,strong in sorted(redund,reverse=True)[:12]: print(f" {ap}: {cnt} strong ({', '.join(strong[:4])}{'...' if len(strong)>4 else ''})") + +# machine-readable adjacency for optimize-radios.sh: {ap_name: {band: {neighbor_name: snr}}} +if OUTJSON!='NONE': + adj={} + for (src,b),nbrs in edges.items(): + adj.setdefault(src,{})[b]=nbrs + json.dump(adj, open(OUTJSON,'w')) + print(f"\n[INFO] wrote adjacency JSON -> {OUTJSON} ({len(adj)} APs) — feed to optimize-radios.sh via NEIGHBOR_JSON") PY echo "" echo "[next] feed the redundancy list to optimize-radios.sh; validate per-zone with watch-ap.sh before any --apply." diff --git a/.claude/skills/unifi-wifi/scripts/optimize-radios.sh b/.claude/skills/unifi-wifi/scripts/optimize-radios.sh index e94f8f4..6eee683 100644 --- a/.claude/skills/unifi-wifi/scripts/optimize-radios.sh +++ b/.claude/skills/unifi-wifi/scripts/optimize-radios.sh @@ -29,10 +29,35 @@ if [[ "$arg" =~ ^[0-9a-f]{24}$ ]]; then SITE="$arg"; else [ -n "$SITE" ] || { echo "[ERROR] no site matching '$arg'"; exit 1; } fi case "$BAND" in ng) RSSI_OK=-68; REDUN=$REDUN_NG;; na) RSSI_OK=-72; REDUN=$REDUN_OTHER;; 6e) RSSI_OK=-75; REDUN=$REDUN_OTHER;; *) echo "band must be ng|na|6e"; exit 1;; esac +# Coverage-adjacency source: the measured AP-to-AP SNR matrix from neighbor-collect.sh (NEIGHBOR_JSON) +# is preferred — it's dense + accurate. The roam graph is the fallback but is sparse in sites with +# stationary clients (e.g. senior living), which is why disables came back 0 there. NBR_SNR_MIN = the +# SNR (dB) at which a managed-AP neighbor counts as real overlap (neighbor-collect default 20). +NBR_SNR_MIN="${NBR_SNR_MIN:-20}"; STRONG_RAW=""; ADJ_SRC="roam-graph (sparse w/ stationary clients)" +if [ -n "${NEIGHBOR_JSON:-}" ] && [ -f "${NEIGHBOR_JSON:-}" ]; then + # Precompute bidirectional strong-overlap NAME pairs HERE (not inside mongo JS — a large embedded + # object literal crashes the old mongo shell). Emit a compact ';'-joined, tab-internal flat string. + STRONG_RAW="$(python - "$NEIGHBOR_JSON" "$NBR_SNR_MIN" <<'PY' +import json,sys +nbr=json.load(open(sys.argv[1])); MIN=int(sys.argv[2]) +def ms(a,b): # max SNR a->b over any band + o=nbr.get(a,{}); return max([o.get(bd,{}).get(b,-1) for bd in ('2.4','5','6')]+[-1]) +aps=list(nbr); out=[] +for i,a in enumerate(aps): + for b in aps[i+1:]: + if ms(a,b)>=MIN and ms(b,a)>=MIN: # bidirectional overlap + out.append(a.replace('"','').replace('\\','')+'\t'+b.replace('"','').replace('\\','')) +print(';'.join(out)) +PY +)" + ADJ_SRC="neighbor SNR matrix /proc/ui_neighbor (SNR>=$NBR_SNR_MIN)" +fi echo "[INFO] site=$SITE band=$BAND window=${DAYS}d rssi>=$RSSI_OK roam>=$ROAM_MIN cap=${CAP}% need_neighbors=$REDUN zone_cap=${ZPCT}%" +echo "[INFO] coverage adjacency source: $ADJ_SRC" cat <&1 | grep -viE 'pq.html|post-quantum|store now|server may need' var SITE='$SITE',DAYS=$DAYS,BAND='$BAND',RSSI_OK=$RSSI_OK,ROAM_MIN=$ROAM_MIN,CAP=$CAP,ZPCT=$ZPCT,REDUN=$REDUN; +var STRONG_RAW="$STRONG_RAW"; // ';'-joined "nameAnameB" bidirectional strong-overlap pairs, or "" var ace=db.getSiblingDB('ace'),st=db.getSiblingDB('ace_stat'); var since=new Date().getTime()-DAYS*86400000; // identity + zone @@ -73,6 +98,18 @@ Object.keys(prof).forEach(function(A){ strong[A]={}; if(Math.min(ab,ba)>=ROAM_MIN && p25((rs[A+'>'+B]||[]).concat(rs[B+'>'+A]||[]))>=RSSI_OK) strong[A][B]=1; }); }); +// PREFERRED: rebuild adjacency from the measured AP-to-AP SNR matrix (neighbor-collect / /proc/ui_neighbor) +// when provided — denser + accurate. Overlap is bidirectional: A hears B AND B hears A, both >= NBR_SNR_MIN +// on any band (physical overlap is band-independent). This is what makes DISABLE decisions data-backed in +// sites where the roam graph is too sparse (stationary clients). +if(STRONG_RAW){ + var nm2mac={}; for(var m in name){ nm2mac[name[m]]=m; } + Object.keys(prof).forEach(function(A){ strong[A]={}; }); + STRONG_RAW.split(';').forEach(function(pr){ if(!pr)return; var t=pr.split('\t'); + var A=nm2mac[t[0]], B=nm2mac[t[1]]; + if(A&&B&&prof[A]&&prof[B]){ strong[A][B]=1; strong[B][A]=1; } // bidirectional overlap proven by SNR + }); +} // greedy capacity-aware disable var aps=Object.keys(prof), active={}; aps.forEach(function(a){active[a]=true;}); var projCu={}; aps.forEach(function(a){projCu[a]=prof[a].cu;}); // projected utilization (grows as we shift load) diff --git a/clients/cascades-tucson/session-logs/2026-06/2026-06-15-howard-cascades-wifi-rf-audit.md b/clients/cascades-tucson/session-logs/2026-06/2026-06-15-howard-cascades-wifi-rf-audit.md index eee4b39..e66d814 100644 --- a/clients/cascades-tucson/session-logs/2026-06/2026-06-15-howard-cascades-wifi-rf-audit.md +++ b/clients/cascades-tucson/session-logs/2026-06/2026-06-15-howard-cascades-wifi-rf-audit.md @@ -301,3 +301,33 @@ and fails ("No such file or directory") on Windows. Use `python - "$TMP/x" <<'PY Coord: collectors+status announce c3ccaa07. Next: wire neighbor-collect redundancy into optimize-radios.sh; Floor-4 2.4 power-down pilot (still nothing applied to live radios). + +--- + +## Update: 00:05 PT (06-16) — wired neighbor SNR matrix into optimize-radios.sh (disables now data-backed) + +Closed the last follow-up: the AP-to-AP SNR matrix now DRIVES optimize-radios.sh disable decisions. +- neighbor-collect.sh: `NBR_JSON=` env now also emits machine-readable adjacency + `{ap:{band:{nbr:snr}}}` (74 APs, ~21KB). +- optimize-radios.sh: `NEIGHBOR_JSON=` env now uses the measured bidirectional SNR overlap + (A hears B AND B hears A, SNR>=NBR_SNR_MIN default 20, any band) as the coverage-adjacency source, + replacing the sparse roam graph. Roam graph remains the fallback when NEIGHBOR_JSON unset. + +Workflow: + NBR_JSON=.claude/tmp/cascades-nbr.json bash .../neighbor-collect.sh cascades + NEIGHBOR_JSON=.claude/tmp/cascades-nbr.json bash .../optimize-radios.sh cascades 14 ng + +VALIDATED on Cascades 2.4GHz: roam-graph -> POWER-DOWN 74 / DISABLE 0; neighbor-SNR -> POWER-DOWN 65 +/ **DISABLE 9** / KEEP 1, est. 621% interference-airtime removed, each disable listing its covering +neighbors (127->128; 229->128; 248->348; 330->128; 445->347/348/247; 428->128; 622->505/615/517; +Kitchen->Memcare TV room; salon->128). The disable capability we concluded was impossible is now +data-backed for any site with a neighbor-collect run. + +IMPL NOTE (bug fixed): injecting the full matrix as a JS object literal CRASHED the old mongo shell +(SpiderMonkey GC-during-compile on a ~21KB literal). Fix: precompute bidirectional strong NAME-pairs +in python, inject a compact ';'-joined flat STRING, parse in JS. (Pattern for any large data -> +mongo-shell injection: flat string, not object literal.) + +Still gated: apply-radio.sh has NO disable action (power/channel/width only); disables remain a +reviewed, per-zone, live-validated MANUAL step. Coord: wire announce 68cae757. +Remaining open: Floor-4 2.4 power-down pilot (still nothing applied to live radios).