unifi-wifi: skill health pass — fix optimize-radios stray REPL echo + ASCII-clean all output
Full verification of the skill against Cascades (live): - All 19 scripts syntax-clean. - Controller-side read-only validated live: sites, audit-site, switch-audit, live-stats, model-rank, optimize-radios, monitor-run, gw-audit. Dry-run apply paths validated: apply-radio, apply-wlan, client-control, device-control. AP-side mechanism validated: SSH auth + /proc/ui_neighbor read on a sample AP; full neighbor-collect (74-AP SNR sweep) -> channel-plan end-to-end produced a 1/6/11 plan. Fixes: - optimize-radios.sh: the `for(k in prof)` loop's numeric completion value was REPL-echoed by the legacy mongo shell (stray "94.56..." line in output). Terminated the loop body with `void 0` to suppress it. - ASCII-clean printed output (CLAUDE.md no-non-ASCII): replaced em-dashes / Unicode arrows / § that reached stdout and rendered as `?`/mojibake on the Windows console, across optimize-radios, neighbor-collect, survey-collect, dfs-check, audit-site, sites, monitor-run, apply-radio, apply-wlan, pfsense-backend. (Comment-only non-ASCII left as-is; never printed.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -77,7 +77,7 @@ print("REST PUT /proxy/network/api/s/<site>/rest/device/<id> body: { radio_table
|
|||||||
JS
|
JS
|
||||||
|
|
||||||
if [ "$APPLY" != "1" ]; then
|
if [ "$APPLY" != "1" ]; then
|
||||||
echo; echo "[dry-run] no changes made. Add --apply to write (needs the RW admin vaulted — see below)."
|
echo; echo "[dry-run] no changes made. Add --apply to write (needs the RW admin vaulted - see below)."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ case "$ACT" in
|
|||||||
rrm) SVAL="${1:?rrm <on|off>}"; shift # 802.11k neighbor reports (roaming assist)
|
rrm) SVAL="${1:?rrm <on|off>}"; shift # 802.11k neighbor reports (roaming assist)
|
||||||
case "$SVAL" in on) FIELDS="{\"rrm_enabled\":true}";; off) FIELDS="{\"rrm_enabled\":false}";; *) echo "rrm: on|off"; exit 1;; esac ;;
|
case "$SVAL" in on) FIELDS="{\"rrm_enabled\":true}";; off) FIELDS="{\"rrm_enabled\":false}";; *) echo "rrm: on|off"; exit 1;; esac ;;
|
||||||
ftroam) SVAL="${1:?ftroam <on|off>}"; shift # 802.11r fast roaming
|
ftroam) SVAL="${1:?ftroam <on|off>}"; shift # 802.11r fast roaming
|
||||||
case "$SVAL" in on) FIELDS="{\"fast_roaming_enabled\":true}"; echo "[WARNING] 802.11r can break legacy/medical/IoT clients — test on a scoped SSID first.";; off) FIELDS="{\"fast_roaming_enabled\":false}";; *) echo "ftroam: on|off"; exit 1;; esac ;;
|
case "$SVAL" in on) FIELDS="{\"fast_roaming_enabled\":true}"; echo "[WARNING] 802.11r can break legacy/medical/IoT clients - test on a scoped SSID first.";; off) FIELDS="{\"fast_roaming_enabled\":false}";; *) echo "ftroam: on|off"; exit 1;; esac ;;
|
||||||
isolation) SVAL="${1:?isolation <on|off>}"; shift # L2 client isolation
|
isolation) SVAL="${1:?isolation <on|off>}"; shift # L2 client isolation
|
||||||
case "$SVAL" in on) FIELDS="{\"l2_isolation\":true}";; off) FIELDS="{\"l2_isolation\":false}";; *) echo "isolation: on|off"; exit 1;; esac ;;
|
case "$SVAL" in on) FIELDS="{\"l2_isolation\":true}";; off) FIELDS="{\"l2_isolation\":false}";; *) echo "isolation: on|off"; exit 1;; esac ;;
|
||||||
hidessid) SVAL="${1:?hidessid <on|off>}"; shift
|
hidessid) SVAL="${1:?hidessid <on|off>}"; shift
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ print(" 2.4GHz widths: "+JSON.stringify(ng_w)+" (want only 20)");
|
|||||||
print(" 2.4GHz power: "+JSON.stringify(ng_pwr)+" (want low/medium/custom in density)");
|
print(" 2.4GHz power: "+JSON.stringify(ng_pwr)+" (want low/medium/custom in density)");
|
||||||
print(" 2.4GHz min_rssi OFF on "+ngOffRssi+" radios");
|
print(" 2.4GHz min_rssi OFF on "+ngOffRssi+" radios");
|
||||||
print(" 5GHz radios: "+na_used+" widths: "+JSON.stringify(na_w)+" (want 40 in density, not 80/160)");
|
print(" 5GHz radios: "+na_used+" widths: "+JSON.stringify(na_w)+" (want 40 in density, not 80/160)");
|
||||||
print(" 6GHz radios active: "+sixe_used+" (steer 6E-capable clients here — usually the clean band)");
|
print(" 6GHz radios active: "+sixe_used+" (steer 6E-capable clients here - usually the clean band)");
|
||||||
print("\n==== NEIGHBOR-DENSITY MAP (rogue = co-channel interference) ====");
|
print("\n==== NEIGHBOR-DENSITY MAP (rogue = co-channel interference) ====");
|
||||||
print(" 2.4GHz:");
|
print(" 2.4GHz:");
|
||||||
db.rogue.aggregate([{\$match:{site_id:SITE,band:'ng'}},{\$group:{_id:'\$channel',n:{\$sum:1}}},{\$sort:{n:-1}},{\$limit:6}]).forEach(function(d){print(" ch"+d._id+": "+d.n+" neighbor BSSIDs")});
|
db.rogue.aggregate([{\$match:{site_id:SITE,band:'ng'}},{\$group:{_id:'\$channel',n:{\$sum:1}}},{\$sort:{n:-1}},{\$limit:6}]).forEach(function(d){print(" ch"+d._id+": "+d.n+" neighbor BSSIDs")});
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ while IFS=$'\t' read -r name ip ch dfs; do
|
|||||||
done < "$TMP/aps.tsv"; echo "" >&2
|
done < "$TMP/aps.tsv"; echo "" >&2
|
||||||
echo ""
|
echo ""
|
||||||
if [ "$hits" -eq 0 ]; then
|
if [ "$hits" -eq 0 ]; then
|
||||||
echo "[OK] No real radar/DFS events found in any AP's dmesg → DFS appears low-risk at this site."
|
echo "[OK] No real radar/DFS events found in any AP's dmesg -> DFS appears low-risk at this site."
|
||||||
echo " (dmesg is bounded; re-run periodically. Absence over long uptime = strong signal DFS is usable.)"
|
echo " (dmesg is bounded; re-run periodically. Absence over long uptime = strong signal DFS is usable.)"
|
||||||
else
|
else
|
||||||
echo "[WARNING] $hits AP(s) logged radar/DFS events → DFS is being hit; prefer non-DFS (UNII-1 36-48 + UNII-3 149-165)."
|
echo "[WARNING] $hits AP(s) logged radar/DFS events -> DFS is being hit; prefer non-DFS (UNII-1 36-48 + UNII-3 149-165)."
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ sweep_one(){ # $1 = site id, $2 = display name
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [ "$ARG" = all ]; then
|
if [ "$ARG" = all ]; then
|
||||||
echo "[INFO] fleet health sweep — $(date '+%Y-%m-%d %H:%M') — all UOS sites (controller-side, read-only)"
|
echo "[INFO] fleet health sweep - $(date '+%Y-%m-%d %H:%M') - all UOS sites (controller-side, read-only)"
|
||||||
bash "$UOS" --sites 2>/dev/null | clean | grep -E '^[0-9a-f]{24}' | while read -r sid rest; do
|
bash "$UOS" --sites 2>/dev/null | clean | grep -E '^[0-9a-f]{24}' | while read -r sid rest; do
|
||||||
sweep_one "$sid" "$rest"
|
sweep_one "$sid" "$rest"
|
||||||
done
|
done
|
||||||
@@ -39,6 +39,6 @@ else
|
|||||||
if [[ "$ARG" =~ ^[0-9a-f]{24}$ ]]; then SID="$ARG"; NM=""; else
|
if [[ "$ARG" =~ ^[0-9a-f]{24}$ ]]; then SID="$ARG"; NM=""; else
|
||||||
line="$(bash "$UOS" --sites 2>/dev/null | clean | grep -i "$ARG" | head -1)"; SID="${line%% *}"; NM="${line#* }"; fi
|
line="$(bash "$UOS" --sites 2>/dev/null | clean | grep -i "$ARG" | head -1)"; SID="${line%% *}"; NM="${line#* }"; fi
|
||||||
[ -n "$SID" ] || { echo "[ERROR] site not found: $ARG"; exit 1; }
|
[ -n "$SID" ] || { echo "[ERROR] site not found: $ARG"; exit 1; }
|
||||||
echo "[INFO] health sweep — $(date '+%Y-%m-%d %H:%M') — $NM"
|
echo "[INFO] health sweep - $(date '+%Y-%m-%d %H:%M') - $NM"
|
||||||
sweep_one "$SID" "$NM"
|
sweep_one "$SID" "$NM"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ if OUTJSON!='NONE':
|
|||||||
for (src,b),nbrs in edges.items():
|
for (src,b),nbrs in edges.items():
|
||||||
adj.setdefault(src,{})[b]=nbrs
|
adj.setdefault(src,{})[b]=nbrs
|
||||||
json.dump(adj, open(OUTJSON,'w'))
|
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")
|
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."
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ st.stat_hourly.find({o:'ap',site_id:SITE,time:{\$gte:since}}).forEach(function(d
|
|||||||
});
|
});
|
||||||
// tx_retries is a COUNT, not a %, so normalize by tx attempts; satisfaction averaged over its own samples.
|
// tx_retries is a COUNT, not a %, so normalize by tx attempts; satisfaction averaged over its own samples.
|
||||||
for(var k in prof){var p=prof[k];['cu','intf','self','sta'].forEach(function(f){p[f]/=p.n;});
|
for(var k in prof){var p=prof[k];['cu','intf','self','sta'].forEach(function(f){p[f]/=p.n;});
|
||||||
p.retrPct=p.att>0?Math.min(100,100*p.retr/p.att):0; p.sat=p.satN>0?p.sat/p.satN:100;}
|
p.retrPct=p.att>0?Math.min(100,100*p.retr/p.att):0; p.sat=p.satN>0?p.sat/p.satN:100; void 0;} // void 0: stop the mongo REPL echoing this for-loop's numeric completion value
|
||||||
// directional roam edges + RSSI
|
// directional roam edges + RSSI
|
||||||
var dir={}; // "A>B"->count ; rs["A>B"]->[rssi to B]
|
var dir={}; // "A>B"->count ; rs["A>B"]->[rssi to B]
|
||||||
var rs={};
|
var rs={};
|
||||||
@@ -153,9 +153,9 @@ var save=0;disabled.forEach(function(d){save+=prof[d.ap].intf;});
|
|||||||
print("Phase A: POWER-DOWN the busy/thrashing radios to ~Low (smaller cells cut mutual cu_interf for the whole zone). Do this FIRST.");
|
print("Phase A: POWER-DOWN the busy/thrashing radios to ~Low (smaller cells cut mutual cu_interf for the whole zone). Do this FIRST.");
|
||||||
var pz=byZone(powerdown,function(a){return a;});
|
var pz=byZone(powerdown,function(a){return a;});
|
||||||
Object.keys(pz).sort().forEach(function(z){print(" ["+z+"] "+pz[z].length);pz[z].slice(0,6).forEach(function(a){print(" "+fmt(a));}); if(pz[z].length>6)print(" ...(+"+(pz[z].length-6)+")");});
|
Object.keys(pz).sort().forEach(function(z){print(" ["+z+"] "+pz[z].length);pz[z].slice(0,6).forEach(function(a){print(" "+fmt(a));}); if(pz[z].length>6)print(" ...(+"+(pz[z].length-6)+")");});
|
||||||
print("\nPhase B: re-measure (live-stats.sh) after power-down settles — cu_interf should drop, headroom appears.");
|
print("\nPhase B: re-measure (live-stats.sh) after power-down settles - cu_interf should drop, headroom appears.");
|
||||||
print("\nPhase C: DISABLE these only-when-redundant radios (each has >="+REDUN+" bidirectional good neighbors WITH headroom to absorb its load). Est. interference airtime removed: "+save.toFixed(0)+".");
|
print("\nPhase C: DISABLE these only-when-redundant radios (each has >="+REDUN+" bidirectional good neighbors WITH headroom to absorb its load). Est. interference airtime removed: "+save.toFixed(0)+".");
|
||||||
if(!disabled.length) print(" (none clear the capacity+coverage bar yet — expected when every neighbor is saturated; revisit after Phase A.)");
|
if(!disabled.length) print(" (none clear the capacity+coverage bar yet - expected when every neighbor is saturated; revisit after Phase A.)");
|
||||||
var dz=byZone(disabled,function(d){return d.ap;});
|
var dz=byZone(disabled,function(d){return d.ap;});
|
||||||
Object.keys(dz).sort().forEach(function(z){print(" ["+z+"]");dz[z].forEach(function(d){print(" "+fmt(d.ap)+" -> covered by: "+d.cover.slice(0,3).join(', '));});});
|
Object.keys(dz).sort().forEach(function(z){print(" ["+z+"]");dz[z].forEach(function(d){print(" "+fmt(d.ap)+" -> covered by: "+d.cover.slice(0,3).join(', '));});});
|
||||||
print("\nKEEP: "+keep.length+" radios (isolated-essential or already efficient). Apply per ZONE; never >"+ZPCT+"% disabled/zone; validate before+after.");
|
print("\nKEEP: "+keep.length+" radios (isolated-essential or already efficient). Apply per ZONE; never >"+ZPCT+"% disabled/zone; validate before+after.");
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ while [ $# -gt 0 ]; do case "$1" in
|
|||||||
*) POS+=("$1"); shift;; esac; done
|
*) POS+=("$1"); shift;; esac; done
|
||||||
|
|
||||||
setup_msg(){ cat <<EOF
|
setup_msg(){ cat <<EOF
|
||||||
[SETUP] pfSense REST API backend — one-time per client:
|
[SETUP] pfSense REST API backend - one-time per client:
|
||||||
1) pfSense GUI -> System > Package Manager > Available Packages: install 'RESTAPI'
|
1) pfSense GUI -> System > Package Manager > Available Packages: install 'RESTAPI'
|
||||||
2) System > REST API > Settings: enable; Auth Method = API Key
|
2) System > REST API > Settings: enable; Auth Method = API Key
|
||||||
3) System > REST API > Keys: create a key (READ-ONLY for audit; a write-capable key for pf-/fw-/block-ips)
|
3) System > REST API > Keys: create a key (READ-ONLY for audit; a write-capable key for pf-/fw-/block-ips)
|
||||||
@@ -42,7 +42,7 @@ setup_msg(){ cat <<EOF
|
|||||||
bash $REPO/.claude/skills/vault/scripts/vault-helper.sh new $VP \\
|
bash $REPO/.claude/skills/vault/scripts/vault-helper.sh new $VP \\
|
||||||
--kind generic --name '<Client> pfSense REST API' --tag pfsense \\
|
--kind generic --name '<Client> pfSense REST API' --tag pfsense \\
|
||||||
--set url=https://<pfsense> --set apikey=<key>
|
--set url=https://<pfsense> --set apikey=<key>
|
||||||
(No package / can't install? SSH 'easyrule' + config.xml fallback is the planned alt backend — ROADMAP §E.)
|
(No package / can't install? SSH 'easyrule' + config.xml fallback is the planned alt backend - ROADMAP section E.)
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
[ "$ACT" = "setup" ] && { setup_msg; exit 0; }
|
[ "$ACT" = "setup" ] && { setup_msg; exit 0; }
|
||||||
@@ -77,7 +77,7 @@ def err(e):
|
|||||||
try: body=e.read().decode('utf-8','replace')[:300]
|
try: body=e.read().decode('utf-8','replace')[:300]
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
print(f"[FAIL] HTTP {getattr(e,'code','?')}: {body or e}")
|
print(f"[FAIL] HTTP {getattr(e,'code','?')}: {body or e}")
|
||||||
print("[hint] if 404/endpoint-not-found, the installed REST API version uses a different path — "
|
print("[hint] if 404/endpoint-not-found, the installed REST API version uses a different path - "
|
||||||
"check System > REST API > Documentation and adjust pfsense-backend.sh.")
|
"check System > REST API > Documentation and adjust pfsense-backend.sh.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
def save_rollback(tag,obj):
|
def save_rollback(tag,obj):
|
||||||
@@ -119,7 +119,7 @@ try:
|
|||||||
s=data(call('GET',ep))
|
s=data(call('GET',ep))
|
||||||
if isinstance(s,dict): print(" "+", ".join(f"{k}={s.get(k)}" for k in list(s)[:8])); break
|
if isinstance(s,dict): print(" "+", ".join(f"{k}={s.get(k)}" for k in list(s)[:8])); break
|
||||||
except urllib.error.HTTPError: continue
|
except urllib.error.HTTPError: continue
|
||||||
else: print(" (system endpoint not found — verify path)")
|
else: print(" (system endpoint not found - verify path)")
|
||||||
print("\n== DHCP scopes (pool pressure) ==")
|
print("\n== DHCP scopes (pool pressure) ==")
|
||||||
try:
|
try:
|
||||||
ds=data(call('GET','/services/dhcp_server'))
|
ds=data(call('GET','/services/dhcp_server'))
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ echo "[INFO] AP-side collectors (neighbor/survey/dfs/watch-ap) need a per-client
|
|||||||
echo " Vaulted AP creds found (clients/*/unifi-ap-ssh):"
|
echo " Vaulted AP creds found (clients/*/unifi-ap-ssh):"
|
||||||
if [ -d "$VROOT" ]; then
|
if [ -d "$VROOT" ]; then
|
||||||
found=$(find "$VROOT/clients" -name 'unifi-ap-ssh.sops.yaml' 2>/dev/null | sed -E 's#.*/clients/([^/]+)/.*# [ready] clients/\1/unifi-ap-ssh#' | sort)
|
found=$(find "$VROOT/clients" -name 'unifi-ap-ssh.sops.yaml' 2>/dev/null | sed -E 's#.*/clients/([^/]+)/.*# [ready] clients/\1/unifi-ap-ssh#' | sort)
|
||||||
[ -n "$found" ] && echo "$found" || echo " (none yet — only the controller-side scripts will work until you vault one)"
|
[ -n "$found" ] && echo "$found" || echo " (none yet - only the controller-side scripts will work until you vault one)"
|
||||||
else
|
else
|
||||||
echo " (vault not found at $VROOT — set VAULT_ROOT)"
|
echo " (vault not found at $VROOT - set VAULT_ROOT)"
|
||||||
fi
|
fi
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
To enable AP-side collectors for a new client:
|
To enable AP-side collectors for a new client:
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ for ln in open(sys.argv[1],encoding='utf-8',errors='replace'):
|
|||||||
elif 'channel active time' in ln: m=re.search(r'(\d+) ms',ln); rec['act']=int(m.group(1)) if m else 0
|
elif 'channel active time' in ln: m=re.search(r'(\d+) ms',ln); rec['act']=int(m.group(1)) if m else 0
|
||||||
elif 'channel busy time' in ln: m=re.search(r'(\d+) ms',ln); rec['busy']=int(m.group(1)) if m else 0
|
elif 'channel busy time' in ln: m=re.search(r'(\d+) ms',ln); rec['busy']=int(m.group(1)) if m else 0
|
||||||
flush()
|
flush()
|
||||||
print(f"\n==== MEASURED RF OCCUPANCY — cleanest channels per AP ({len(data)} APs) ====")
|
print(f"\n==== MEASURED RF OCCUPANCY - cleanest channels per AP ({len(data)} APs) ====")
|
||||||
print("(in-use channel busy%, then 3 lowest-busy NON-DFS channels measured; * = DFS)\n")
|
print("(in-use channel busy%, then 3 lowest-busy NON-DFS channels measured; * = DFS)\n")
|
||||||
for ap in sorted(data):
|
for ap in sorted(data):
|
||||||
print(f"{ap}:")
|
print(f"{ap}:")
|
||||||
@@ -120,7 +120,7 @@ if OUT!='NONE':
|
|||||||
for busy,c,inuse,noise in data[ap][b]:
|
for busy,c,inuse,noise in data[ap][b]:
|
||||||
j.setdefault(ap,{}).setdefault(b,{})[str(c)]=busy # busy% per channel
|
j.setdefault(ap,{}).setdefault(b,{})[str(c)]=busy # busy% per channel
|
||||||
json.dump(j,open(OUT,'w'))
|
json.dump(j,open(OUT,'w'))
|
||||||
print(f"\n[INFO] wrote survey JSON -> {OUT} ({len(j)} APs) — feed to channel-plan.sh via SURVEY_JSON")
|
print(f"\n[INFO] wrote survey JSON -> {OUT} ({len(j)} APs) - feed to channel-plan.sh via SURVEY_JSON")
|
||||||
PY
|
PY
|
||||||
echo ""
|
echo ""
|
||||||
echo "[next] use the cleanest-channel data for a manual 1/6/11 (2.4) + non-DFS (5GHz) plan; apply via apply-radio.sh per zone."
|
echo "[next] use the cleanest-channel data for a manual 1/6/11 (2.4) + non-DFS (5GHz) plan; apply via apply-radio.sh per zone."
|
||||||
|
|||||||
Reference in New Issue
Block a user