From dccd3818200537fd1a0d17465499e0b7032968a9 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Wed, 17 Jun 2026 08:53:36 -0700 Subject: [PATCH] unifi-wifi: validate connector RF analysis vs UOS (Cascades) - macs[] fix + --site passthrough Validated the cloud-connector analysis against a KNOWN entity (Cascades, normally UOS-Mongo). The connector reaches the self-hosted "UOS Server" host; Cascades is its site `va6iba3v`. Two fixes from the validation: - rf-analyze.py: pass macs:[] to /stat/report/*.ap. The UniFi report endpoint returns only a small DEFAULT subset otherwise -- Cascades came back as 10 of 77 APs until the MAC list was supplied. Now profiles all 75 (uaps with 2.4 radios), matching the UOS path. - model-rank.sh / optimize-radios.sh: --console now accepts --site (internal short name from /api/self/sites) for multi-site controllers like the UOS Server (Cascades = va6iba3v). Result lines up with the known UOS-Mongo figures: 75 APs, 2.4GHz util 65-90% / interf 53-78% / ~1 client each, all power-down, 0 disables (roam graph absent via connector -> same coverage-safe degradation; disables still need NEIGHBOR_JSON). Apples-to-apples confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../unifi-wifi/references/site-manager-api.md | 13 +++++++++++++ .claude/skills/unifi-wifi/scripts/model-rank.sh | 10 ++++++++-- .../skills/unifi-wifi/scripts/optimize-radios.sh | 10 ++++++++-- .claude/skills/unifi-wifi/scripts/rf-analyze.py | 4 +++- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.claude/skills/unifi-wifi/references/site-manager-api.md b/.claude/skills/unifi-wifi/references/site-manager-api.md index d09d9f59..2dc6639b 100644 --- a/.claude/skills/unifi-wifi/references/site-manager-api.md +++ b/.claude/skills/unifi-wifi/references/site-manager-api.md @@ -84,6 +84,19 @@ Roam graph is usually EMPTY on small/stationary sites (no `/stat/event` roam log ranks by airtime pressure only; optimize-radios returns power-down candidates and 0 disables (coverage-safe). For disables, supply `NEIGHBOR_JSON` (AP-to-AP SNR from neighbor-collect) as on UOS. +**Multi-site controllers** (e.g. the self-hosted "UOS Server" host runs 47 sites): pass `--site ` +(the internal short name from `/api/self/sites`, e.g. Cascades = `va6iba3v`). Default is `default`. +```bash +bash .../model-rank.sh --console "UOS Server" --site va6iba3v 7 ng # Cascades 2.4GHz +``` +**Gotcha (handled in rf-analyze.py):** the internal `/stat/report/*.ap` endpoint returns only a +small DEFAULT subset of APs unless you POST `macs:[]` (Cascades returned 10 of 77 +until the MAC list was supplied). The analyzer fetches the uap macs from `/stat/device` and passes them. + +**Validation (2026-06-17):** Cascades via the connector matched the UOS-Mongo path - 75 APs profiled, +2.4GHz util 65-90% / interf 53-78% / ~1 client each, all power-down, 0 disables. Apples-to-apples (same +self-hosted controller + site). Connector lacks the roam graph, so disables need `NEIGHBOR_JSON`. + ## Gotchas - Direct WAN SSH/HTTPS to a standalone UDM is usually firewalled (Brooklyn 67.1.139.219: 22/443/8443 filtered) - the connector is the remote path, not direct. diff --git a/.claude/skills/unifi-wifi/scripts/model-rank.sh b/.claude/skills/unifi-wifi/scripts/model-rank.sh index b3396d1d..65577178 100644 --- a/.claude/skills/unifi-wifi/scripts/model-rank.sh +++ b/.claude/skills/unifi-wifi/scripts/model-rank.sh @@ -14,11 +14,17 @@ REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" # Cloud connector path (non-UOS console): `model-rank.sh --console "" [days] [band]` routes to # rf-analyze.py (Site Manager API; see references/site-manager-api.md). The UOS Mongo path below is unchanged. if [ "${1:-}" = "--console" ]; then - CONSOLE="${2:?--console needs a console name}"; CDAYS="${3:-7}"; CBAND="${4:-ng}" + shift; CONSOLE=""; CSITE="default"; CPOS=() + while [ $# -gt 0 ]; do case "$1" in + --site) CSITE="${2:-default}"; shift 2 2>/dev/null || shift;; + *) if [ -z "$CONSOLE" ]; then CONSOLE="$1"; else CPOS+=("$1"); fi; shift;; + esac; done + [ -n "$CONSOLE" ] || { echo "usage: model-rank.sh --console [--site ] [days] [band]"; exit 2; } + CDAYS="${CPOS[0]:-7}"; CBAND="${CPOS[1]:-ng}" CKEY="$(bash "$REPO/.claude/scripts/vault.sh" get-field services/unifi-site-manager credentials.api_key 2>/dev/null | tr -d '\r\n')" [ -n "$CKEY" ] || { echo "[ERROR] no UniFi Site Manager key (vault services/unifi-site-manager)"; exit 1; } CPY="$(command -v py 2>/dev/null || command -v python 2>/dev/null || command -v python3)" - exec env UNIFI_SM_KEY="$CKEY" "$CPY" "$REPO/.claude/skills/unifi-wifi/scripts/rf-analyze.py" rank --console "$CONSOLE" --days "$CDAYS" --band "$CBAND" + exec env UNIFI_SM_KEY="$CKEY" "$CPY" "$REPO/.claude/skills/unifi-wifi/scripts/rf-analyze.py" rank --console "$CONSOLE" --days "$CDAYS" --band "$CBAND" --site "$CSITE" fi UOS="$REPO/.claude/scripts/uos-mongo.sh" arg="${1:?usage: model-rank.sh [days] [band] | model-rank.sh --console [days] [band]}"; DAYS="${2:-7}"; BAND="${3:-ng}" diff --git a/.claude/skills/unifi-wifi/scripts/optimize-radios.sh b/.claude/skills/unifi-wifi/scripts/optimize-radios.sh index 371e4486..3c86e7a5 100644 --- a/.claude/skills/unifi-wifi/scripts/optimize-radios.sh +++ b/.claude/skills/unifi-wifi/scripts/optimize-radios.sh @@ -24,11 +24,17 @@ REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" # rf-analyze.py (Site Manager API; see references/site-manager-api.md). UOS Mongo path below is unchanged. # NEIGHBOR_JSON / ROAM_MIN / CAP / ZONE_DISABLE_PCT / REDUN_* env vars pass through to the analyzer. if [ "${1:-}" = "--console" ]; then - CONSOLE="${2:?--console needs a console name}"; CDAYS="${3:-14}"; CBAND="${4:-ng}" + shift; CONSOLE=""; CSITE="default"; CPOS=() + while [ $# -gt 0 ]; do case "$1" in + --site) CSITE="${2:-default}"; shift 2 2>/dev/null || shift;; + *) if [ -z "$CONSOLE" ]; then CONSOLE="$1"; else CPOS+=("$1"); fi; shift;; + esac; done + [ -n "$CONSOLE" ] || { echo "usage: optimize-radios.sh --console [--site ] [days] [band]"; exit 2; } + CDAYS="${CPOS[0]:-14}"; CBAND="${CPOS[1]:-ng}" CKEY="$(bash "$REPO/.claude/scripts/vault.sh" get-field services/unifi-site-manager credentials.api_key 2>/dev/null | tr -d '\r\n')" [ -n "$CKEY" ] || { echo "[ERROR] no UniFi Site Manager key (vault services/unifi-site-manager)"; exit 1; } CPY="$(command -v py 2>/dev/null || command -v python 2>/dev/null || command -v python3)" - exec env UNIFI_SM_KEY="$CKEY" "$CPY" "$REPO/.claude/skills/unifi-wifi/scripts/rf-analyze.py" optimize --console "$CONSOLE" --days "$CDAYS" --band "$CBAND" + exec env UNIFI_SM_KEY="$CKEY" "$CPY" "$REPO/.claude/skills/unifi-wifi/scripts/rf-analyze.py" optimize --console "$CONSOLE" --days "$CDAYS" --band "$CBAND" --site "$CSITE" fi UOS="$REPO/.claude/scripts/uos-mongo.sh" arg="${1:?usage: optimize-radios.sh [days] [band] | optimize-radios.sh --console [days] [band]}"; DAYS="${2:-14}"; BAND="${3:-ng}" diff --git a/.claude/skills/unifi-wifi/scripts/rf-analyze.py b/.claude/skills/unifi-wifi/scripts/rf-analyze.py index 3390b6c0..6bed4809 100644 --- a/.claude/skills/unifi-wifi/scripts/rf-analyze.py +++ b/.claude/skills/unifi-wifi/scripts/rf-analyze.py @@ -71,7 +71,9 @@ import time as _t end = int(_t.time() * 1000); start = end - DAYS * 86400000 attrs = ['time', 'ap'] + ['%s-%s' % (BAND, f) for f in ('cu_total', 'cu_self_rx', 'cu_self_tx', 'cu_interf', 'num_sta', 'tx_retries', 'wifi_tx_attempts', 'satisfaction')] -rep = api(CB + '/stat/report/hourly.ap', 'POST', {'attrs': attrs, 'start': start, 'end': end}) +# IMPORTANT: pass macs[] explicitly -- the UniFi report endpoint returns only a small default +# subset of APs otherwise (Cascades returned 10 of 77 until the MAC list was supplied). +rep = api(CB + '/stat/report/hourly.ap', 'POST', {'attrs': attrs, 'start': start, 'end': end, 'macs': list(name.keys())}) rows = (rep or {}).get('data', []) prof = {} for d in rows: