sync: auto-sync from HOWARD-HOME at 2026-06-15 23:43:51

Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-06-15 23:43:51
This commit is contained in:
2026-06-15 23:44:00 -07:00
parent e5f07afd90
commit a62bd65be7
4 changed files with 84 additions and 2 deletions

View File

@@ -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 `<site> [ap-ssh-vault-path]`, These read each AP directly (controller hides this data). All take `<site> [ap-ssh-vault-path]`,
use sshpass-or-SSH_ASKPASS auth, and must run in the **foreground**: use sshpass-or-SSH_ASKPASS auth, and must run in the **foreground**:
```bash ```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/<site>-nbr.json bash .claude/skills/unifi-wifi/scripts/neighbor-collect.sh cascades
NEIGHBOR_JSON=.claude/tmp/<site>-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] 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) # 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] bash .claude/skills/unifi-wifi/scripts/survey-collect.sh cascades [vault-path]

View File

@@ -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}" HOST="${UOS_HOST:-172.16.3.29}"; PORT="${UOS_HTTPS_PORT:-11443}"
SITEARG="${1:?usage: neighbor-collect.sh <site-name|id> [ap-ssh-vault-path] [snr_min]}" SITEARG="${1:?usage: neighbor-collect.sh <site-name|id> [ap-ssh-vault-path] [snr_min]}"
VP="${2:-clients/cascades-tucson/unifi-ap-ssh}"; SNR_MIN="${3:-20}" 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 TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
# --- controller creds + login (RW admin reads fine) --- # --- controller creds + login (RW admin reads fine) ---
@@ -97,10 +99,11 @@ done < "$TMP/aps.tsv"
echo "" >&2 echo "" >&2
# --- parse + map + emit adjacency matrix + redundancy summary --- # --- 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 import json,re,sys
raw=open(sys.argv[1],encoding='utf-8',errors='replace').read().splitlines() 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]) 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'} 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[(src, band)] = {neighbor_name: best_snr}; presence[(src,band)] = set(names) from ess_ap_list
edges={}; presence={}; cur=None; mode=None; band=None 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) --") 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]: for cnt,ap,strong in sorted(redund,reverse=True)[:12]:
print(f" {ap}: {cnt} strong ({', '.join(strong[:4])}{'...' if len(strong)>4 else ''})") 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 PY
echo "" echo ""
echo "[next] feed the redundancy list to optimize-radios.sh; validate per-zone with watch-ap.sh before any --apply." echo "[next] feed the redundancy list to optimize-radios.sh; validate per-zone with watch-ap.sh before any --apply."

View File

@@ -29,10 +29,35 @@ if [[ "$arg" =~ ^[0-9a-f]{24}$ ]]; then SITE="$arg"; else
[ -n "$SITE" ] || { echo "[ERROR] no site matching '$arg'"; exit 1; } [ -n "$SITE" ] || { echo "[ERROR] no site matching '$arg'"; exit 1; }
fi 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 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] 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 <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need' cat <<JS | bash "$UOS" 2>&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 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 "nameA<tab>nameB" bidirectional strong-overlap pairs, or ""
var ace=db.getSiblingDB('ace'),st=db.getSiblingDB('ace_stat'); var ace=db.getSiblingDB('ace'),st=db.getSiblingDB('ace_stat');
var since=new Date().getTime()-DAYS*86400000; var since=new Date().getTime()-DAYS*86400000;
// identity + zone // 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; 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 // greedy capacity-aware disable
var aps=Object.keys(prof), active={}; aps.forEach(function(a){active[a]=true;}); 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) var projCu={}; aps.forEach(function(a){projCu[a]=prof[a].cu;}); // projected utilization (grows as we shift load)

View File

@@ -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 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). 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=<path>` env now also emits machine-readable adjacency
`{ap:{band:{nbr:snr}}}` (74 APs, ~21KB).
- optimize-radios.sh: `NEIGHBOR_JSON=<path>` 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).