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:
2026-06-16 15:08:35 -07:00
parent 7e2f49020a
commit e1031ae91a
10 changed files with 19 additions and 19 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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")});

View File

@@ -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

View File

@@ -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

View File

@@ -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."

View File

@@ -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.");

View File

@@ -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'))

View File

@@ -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:

View File

@@ -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."