From f987812fe5729b1b4e3f00ab0bdeb513a7172d4c Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Wed, 17 Jun 2026 08:43:09 -0700 Subject: [PATCH] unifi-wifi: model-rank + optimize-radios run on cloud-connector data (non-UOS consoles) Both analyses now accept `--console ""` and run against the UniFi cloud connector instead of the UOS Mongo server, so RF airtime tuning works on standalone/non-UOS consoles (e.g. Brooklyn/Skybar). The UOS Mongo path is unchanged. - New shared analyzer scripts/rf-analyze.py: pulls per-AP/band airtime history via the connector POST /stat/report/hourly.ap (SAME schema as ace_stat.stat_hourly) + /stat/device for names/zones, derives cu_interf = cu_total - cu_self_rx - cu_self_tx, and runs the SAME model-rank ranking and optimize-radios greedy power-down/disable logic (ported faithfully). - Roam graph (/stat/event) is usually empty on small/stationary sites -> graceful degrade: model-rank ranks by airtime pressure; optimize-radios returns power-down candidates + 0 disables (coverage-safe). NEIGHBOR_JSON (SNR matrix) still enables disables, as on UOS. - model-rank.sh / optimize-radios.sh: added the `--console` route (resolves the key from vault services/unifi-site-manager, execs rf-analyze.py). Validated on Brooklyn/Skybar: 2.4GHz saturated (Yoga AP cu 63%/interf 55%), 5GHz idle (1-5%) - the expected pain-band split. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../unifi-wifi/references/site-manager-api.md | 12 + .../skills/unifi-wifi/scripts/model-rank.sh | 11 +- .../unifi-wifi/scripts/optimize-radios.sh | 12 +- .../skills/unifi-wifi/scripts/rf-analyze.py | 236 ++++++++++++++++++ 4 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/unifi-wifi/scripts/rf-analyze.py diff --git a/.claude/skills/unifi-wifi/references/site-manager-api.md b/.claude/skills/unifi-wifi/references/site-manager-api.md index 77d31d14..d09d9f59 100644 --- a/.claude/skills/unifi-wifi/references/site-manager-api.md +++ b/.claude/skills/unifi-wifi/references/site-manager-api.md @@ -72,6 +72,18 @@ bash $S net brooklyn clients # DEEP per-client RSSI/signal/satisfaction bash $S net brooklyn raw /integration/v1/sites # proxy any /proxy/network/... path ``` +## Analysis on non-UOS consoles (model-rank / optimize-radios) +The existing airtime-reduction analyses run against cloud-connector data via a `--console` flag +(UOS-Mongo path unchanged). They pull `/stat/report/hourly.ap` (same schema as `stat_hourly`) +through the connector and run the SAME logic via `scripts/rf-analyze.py`: +```bash +bash .claude/skills/unifi-wifi/scripts/model-rank.sh --console "Brooklyn/Skybar" 7 ng +bash .claude/skills/unifi-wifi/scripts/optimize-radios.sh --console "Brooklyn/Skybar" 14 ng +``` +Roam graph is usually EMPTY on small/stationary sites (no `/stat/event` roam log) -> model-rank +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. + ## 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 2c0a7c4a..b3396d1d 100644 --- a/.claude/skills/unifi-wifi/scripts/model-rank.sh +++ b/.claude/skills/unifi-wifi/scripts/model-rank.sh @@ -11,8 +11,17 @@ # Usage: bash .claude/skills/unifi-wifi/scripts/model-rank.sh [days=7] [band=ng|na|6e|all] set -euo pipefail 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}" + 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" +fi UOS="$REPO/.claude/scripts/uos-mongo.sh" -arg="${1:?usage: model-rank.sh [days] [band]}"; DAYS="${2:-7}"; BAND="${3:-ng}" +arg="${1:?usage: model-rank.sh [days] [band] | model-rank.sh --console [days] [band]}"; DAYS="${2:-7}"; BAND="${3:-ng}" if [[ "$arg" =~ ^[0-9a-f]{24}$ ]]; then SITE="$arg"; else SITE="$(bash "$UOS" --sites 2>/dev/null | grep -vi 'pq.html' | grep -i "$arg" | awk '{print $1}' | head -1)" [ -n "$SITE" ] || { echo "[ERROR] no site matching '$arg'"; exit 1; } diff --git a/.claude/skills/unifi-wifi/scripts/optimize-radios.sh b/.claude/skills/unifi-wifi/scripts/optimize-radios.sh index 6307cf7f..371e4486 100644 --- a/.claude/skills/unifi-wifi/scripts/optimize-radios.sh +++ b/.claude/skills/unifi-wifi/scripts/optimize-radios.sh @@ -20,8 +20,18 @@ # env: ROAM_MIN(4) CAP(85) ZONE_DISABLE_PCT(40) REDUN_NG(2) REDUN_OTHER(1) set -euo pipefail REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" +# Cloud connector path (non-UOS console): `optimize-radios.sh --console "" [days] [band]` routes to +# 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}" + 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" +fi UOS="$REPO/.claude/scripts/uos-mongo.sh" -arg="${1:?usage: optimize-radios.sh [days] [band]}"; DAYS="${2:-14}"; BAND="${3:-ng}" +arg="${1:?usage: optimize-radios.sh [days] [band] | optimize-radios.sh --console [days] [band]}"; DAYS="${2:-14}"; BAND="${3:-ng}" ROAM_MIN="${ROAM_MIN:-4}"; CAP="${CAP:-85}"; ZPCT="${ZONE_DISABLE_PCT:-40}" REDUN_NG="${REDUN_NG:-2}"; REDUN_OTHER="${REDUN_OTHER:-1}" if [[ "$arg" =~ ^[0-9a-f]{24}$ ]]; then SITE="$arg"; else diff --git a/.claude/skills/unifi-wifi/scripts/rf-analyze.py b/.claude/skills/unifi-wifi/scripts/rf-analyze.py new file mode 100644 index 00000000..3390b6c0 --- /dev/null +++ b/.claude/skills/unifi-wifi/scripts/rf-analyze.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +# rf-analyze.py -- run the unifi-wifi airtime-reduction analyses against the UniFi cloud +# CONNECTOR (api.ui.com) for NON-UOS consoles. Same logic as the Mongo-fed model-rank.sh / +# optimize-radios.sh, but the data comes from the console's LOCAL Network API proxied through +# the cloud connector (see references/site-manager-api.md): +# - per-AP/band airtime history: POST /stat/report/hourly.ap (SAME schema as ace_stat.stat_hourly) +# - identity/zone: GET /stat/device (type=uap, name -> floor/zone) +# - roam graph (optional): POST /stat/event (often empty on small/stationary sites -> +# degrades to airtime-pressure ranking / power-down-only planning) +# +# Usage (normally invoked by model-rank.sh/optimize-radios.sh with --console): +# UNIFI_SM_KEY= rf-analyze.py rank|optimize --console "" [--days N] [--band ng|na|6e] [--site default] +# env (optimize): ROAM_MIN(4) CAP(85) ZONE_DISABLE_PCT(40) REDUN_NG(2) REDUN_OTHER(1) +import json, os, sys, math, re, urllib.request, urllib.error + +KEY = os.environ.get('UNIFI_SM_KEY', '') +BASE = 'https://api.ui.com' + +def api(path, method='GET', body=None, quiet=False): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(BASE + path, data=data, method=method, + headers={'X-API-KEY': KEY, 'Accept': 'application/json', 'Content-Type': 'application/json'}) + try: + return json.loads(urllib.request.urlopen(req, timeout=90).read()) + except urllib.error.HTTPError as e: + if not quiet: sys.stderr.write('[rf-analyze] %s %s -> HTTP %s\n' % (method, path, e.code)) + return None + except Exception as e: + if not quiet: sys.stderr.write('[rf-analyze] %s %s -> %s\n' % (method, path, e)) + return None + +def die(m): sys.stderr.write('[ERROR] %s\n' % m); sys.exit(1) + +# ---- args ---- +if len(sys.argv) < 2 or sys.argv[1] not in ('rank', 'optimize'): + die('usage: rf-analyze.py rank|optimize --console [--days N] [--band ng|na|6e] [--site default]') +MODE = sys.argv[1] +opt = {'--console': None, '--days': '7' if MODE == 'rank' else '14', '--band': 'ng', '--site': 'default'} +a = sys.argv[2:]; i = 0 +while i < len(a): + if a[i] in opt: opt[a[i]] = a[i+1]; i += 2 + else: i += 1 +CONSOLE = opt['--console'] or die('--console required') +DAYS = int(opt['--days']); BAND = opt['--band']; SITE = opt['--site'] +if BAND not in ('ng', 'na', '6e'): die('band must be ng|na|6e') +if not KEY: die('no UNIFI_SM_KEY (the .sh wrapper resolves it from vault services/unifi-site-manager)') + +# ---- resolve console -> hostId ---- +hosts = (api('/v1/hosts') or {}).get('data', []) +HID = CNAME = None +for h in hosts: + nm = ((h.get('reportedState') or {}).get('name')) or '' + if h.get('id', '').lower() == CONSOLE.lower() or CONSOLE.lower() in nm.lower(): + HID, CNAME = h['id'], nm; break +if not HID: die("no console matching '%s' in the Site Manager fleet" % CONSOLE) +CB = '/v1/connector/consoles/%s/proxy/network/api/s/%s' % (HID, SITE) + +# ---- identity + zone from /stat/device ---- +dev = (api(CB + '/stat/device') or {}).get('data', []) +name, zone = {}, {} +for d in dev: + if d.get('type') != 'uap': continue + n = d.get('name') or d.get('mac'); name[d['mac']] = n + fz = re.search(r'(\d)(?:st|nd|rd|th)\s*floor', str(n), re.I) + rm = re.search(r'\b(\d)\d{2}\b', str(n)) + zone[d['mac']] = ('Floor ' + fz.group(1)) if fz else (('Floor ' + rm.group(1)) if rm else 'misc') +if not name: die('no APs (type=uap) on console %s site %s' % (CNAME, SITE)) + +# ---- airtime history from /stat/report/hourly.ap (same schema as ace_stat.stat_hourly) ---- +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}) +rows = (rep or {}).get('data', []) +prof = {} +for d in rows: + ap = d.get('ap') + if not ap or ap not in name: continue + cu = d.get(BAND + '-cu_total'); + if cu is None: continue + self_ = (d.get(BAND + '-cu_self_rx') or 0) + (d.get(BAND + '-cu_self_tx') or 0) + intf = d.get(BAND + '-cu_interf') + if intf is None: intf = max(0.0, (cu or 0) - self_) # derive when not reported + sta = d.get(BAND + '-num_sta') or 0 + retr = d.get(BAND + '-tx_retries') or 0; att = d.get(BAND + '-wifi_tx_attempts') or 0 + sat = d.get(BAND + '-satisfaction') + p = prof.setdefault(ap, {'cu': 0, 'intf': 0, 'self': 0, 'sta': 0, 'staPk': 0, 'retr': 0, 'att': 0, 'sat': 0, 'satN': 0, 'n': 0}) + p['cu'] += cu or 0; p['intf'] += intf; p['self'] += self_; p['sta'] += sta; p['staPk'] = max(p['staPk'], sta) + p['retr'] += retr; p['att'] += att + if isinstance(sat, (int, float)): p['sat'] += sat; p['satN'] += 1 + p['n'] += 1 +for k, p in prof.items(): + for f in ('cu', 'intf', 'self', 'sta'): p[f] /= p['n'] + p['retrPct'] = min(100.0, 100.0 * p['retr'] / p['att']) if p['att'] > 0 else 0.0 + p['sat'] = p['sat'] / p['satN'] if p['satN'] else 100.0 + +# ---- roam graph (optional; often empty on small/stationary sites) ---- +roam = {}; dir_ = {}; rss = {} +ev = api(CB + '/stat/event', 'POST', {'_limit': 3000, 'within': DAYS * 24}, quiet=True) +for e in ((ev or {}).get('data', []) if ev else []): + if 'Roam' not in str(e.get('key', '')): continue + A = e.get('ap_from') or e.get('ap'); B = e.get('ap_to') or e.get('ap') + if A: roam[A] = roam.get(A, 0) + 1 + if B: roam[B] = roam.get(B, 0) + 1 + if A and B and A != B: dir_[A + '>' + B] = dir_.get(A + '>' + B, 0) + 1 +HAVE_ROAM = bool(roam) + +bn = {'ng': '2.4', 'na': '5', '6e': '6'}[BAND] +print('[INFO] console=%s site=%s band=%s(%sGHz) window=%dd APs profiled=%d roam-graph=%s (source: cloud connector)' + % (CNAME, SITE, BAND, bn, DAYS, len(prof), 'yes' if HAVE_ROAM else 'EMPTY -> airtime-only')) +if not prof: die('no airtime history for band %s (try another band, or the APs may be idle)' % BAND) + +# ============================== RANK (model-rank port) ============================== +if MODE == 'rank': + rows_out = [] + for ap, p in prof.items(): + rm = roam.get(ap, 0) + # with roam: (cu+intf)*log(1+roam)/(1+sta); without: airtime pressure / load (roam-neutral) + score = (p['cu'] + p['intf']) * (math.log(1 + rm) if HAVE_ROAM else 1.0) / (1 + p['sta']) + rows_out.append((ap, name.get(ap, ap), p['cu'], p['intf'], p['sta'], rm, score)) + rows_out.sort(key=lambda r: -r[6]) + print('\n==== band=%s top airtime-reduction candidates (disable/power-down) ====' % BAND) + print(' rank AP cu% interf% ~clients roams score hint') + for i, r in enumerate(rows_out[:15]): + ap, nm, cu, intf, sta, rm, sc = r + hint = ('DISABLE (redundant, low load)' if (rm > 50 and sta < 3) + else 'POWER-DOWN (busy)' if (cu + intf > 40) else 'review') + print(' %d\t%-24s %.0f\t%.0f\t%.1f\t%d\t%.1f\t%s' % (i+1, nm[:24], cu, intf, sta, rm, sc, hint)) + print(' (APs profiled on %s: %d)' % (BAND, len(rows_out))) + note = 'score = (cu_total + cu_interf) * log(1+roams) / (clients+1)' + if not HAVE_ROAM: + note = ('score = (cu_total + cu_interf) / (clients+1) [ROAM GRAPH EMPTY on this console -- ' + 'ranking by airtime pressure only; no redundancy weighting]') + print('\n[note] v1 heuristic: %s. High = busy+interfered (and clients have somewhere to roam, if roam known) ' + '= safe to shrink/disable. Validate per-zone before applying.' % note) + sys.exit(0) + +# ============================== OPTIMIZE (optimize-radios port) ============================== +ROAM_MIN = int(os.environ.get('ROAM_MIN', '4')); CAP = float(os.environ.get('CAP', '85')) +ZPCT = float(os.environ.get('ZONE_DISABLE_PCT', '40')) +REDUN = int(os.environ.get('REDUN_NG', '2')) if BAND == 'ng' else int(os.environ.get('REDUN_OTHER', '1')) +RSSI_OK = {'ng': -68, 'na': -72, '6e': -75}[BAND] + +# strong bidirectional neighbors (from roam dir edges). No roam + no neighbor matrix -> empty -> no disables. +strong = {a: {} for a in prof} +for A in prof: + for B in prof: + if A == B: continue + if min(dir_.get(A + '>' + B, 0), dir_.get(B + '>' + A, 0)) >= ROAM_MIN: + strong[A][B] = 1 +# optional measured SNR matrix (neighbor-collect) via env NEIGHBOR_JSON +nj = os.environ.get('NEIGHBOR_JSON', '') +if nj and os.path.isfile(nj): + nbr = json.load(open(nj)); MIN = int(os.environ.get('NBR_SNR_MIN', '20')) + def ms(x, y): o = nbr.get(x, {}); return max([o.get(b, {}).get(y, -1) for b in ('2.4', '5', '6')] + [-1]) + nm2mac = {v: k for k, v in name.items()} + strong = {a: {} for a in prof} + aps_n = list(nbr) + for x in aps_n: + for y in aps_n: + if x == y: continue + if ms(x, y) >= MIN and ms(y, x) >= MIN: + A, B = nm2mac.get(x), nm2mac.get(y) + if A in prof and B in prof: strong[A][B] = 1; strong[B][A] = 1 + +aps = list(prof); active = {a: True for a in aps}; projCu = {a: prof[a]['cu'] for a in aps} +zoneTot = {}; zoneDis = {} +for a in aps: zoneTot[zone[a]] = zoneTot.get(zone[a], 0) + 1 +def activeStrong(a): return [n for n in strong.get(a, {}) if active.get(n)] +def absorbers(a): return [n for n in activeStrong(a) if projCu[n] + prof[a]['self'] <= CAP] +def soleNeighborOf(a): + for x in aps: + if not active.get(x) or x == a or not strong.get(x, {}).get(a): continue + if sum(1 for n in strong.get(x, {}) if n != a and active.get(n)) == 0: return True + return False +disabled = [] +go = True +while go: + go = False; best = None; bestBen = -1 + for a in aps: + if not active[a]: continue + p = prof[a] + if len(activeStrong(a)) < REDUN: continue + if len(absorbers(a)) < 1: continue + if soleNeighborOf(a): continue + if (zoneDis.get(zone[a], 0) + 1) > math.floor(zoneTot[zone[a]] * ZPCT / 100): continue + ben = p['intf'] + 0.5 * p['retrPct'] + if ben > bestBen: bestBen = ben; best = a + if best: + ab = absorbers(best); active[best] = False; zoneDis[zone[best]] = zoneDis.get(zone[best], 0) + 1 + share = prof[best]['self'] / len(ab) + for n in ab: projCu[n] += share + disabled.append({'ap': best, 'cover': [name[n] for n in ab]}); go = True + +powerdown = []; keep = [] +for a in aps: + if not active[a]: continue + p = prof[a] + if p['n'] < 3 or (p['cu'] < 5 and p['intf'] < 5): keep.append((a, 'idle/off')); continue + if p['cu'] >= 40 or p['intf'] >= 35 or p['retrPct'] >= 15 or p['sat'] < 80: powerdown.append(a) + else: keep.append((a, 'low-util')) + +def fmt(a): + p = prof[a] + return '%s cu=%.0f%% interf=%.0f%% self=%.0f%% clients(avg/pk)=%.1f/%.0f retr=%.0f%% sat=%.0f' % ( + name[a], p['cu'], p['intf'], p['self'], p['sta'], p['staPk'], p['retrPct'], p['sat']) +def byZone(items, kf): + z = {} + for x in items: z.setdefault(zone[kf(x)], []).append(x) + return z + +print('[INFO] band=%s rssi>=%d roam>=%d cap=%.0f%% need_neighbors=%d zone_cap=%.0f%% adjacency=%s' + % (BAND, RSSI_OK, ROAM_MIN, CAP, REDUN, ZPCT, + ('neighbor SNR matrix' if nj else ('roam graph' if HAVE_ROAM else 'NONE -> power-down only')))) +print('==== PLAN band=%s (APs=%d) POWER-DOWN(first)=%d DISABLE(after)=%d KEEP=%d ====' + % (BAND, len(aps), len(powerdown), len(disabled), len(keep))) +print('Phase A: POWER-DOWN the busy/thrashing radios to ~Low (smaller cells cut mutual cu_interf zone-wide). Do FIRST.') +pz = byZone(powerdown, lambda a: a) +for z in sorted(pz): + print(' [%s] %d' % (z, len(pz[z]))) + for a in pz[z][:6]: print(' ' + fmt(a)) + if len(pz[z]) > 6: print(' ...(+%d)' % (len(pz[z]) - 6)) +print('\nPhase B: re-measure after power-down settles (cu_interf should drop, headroom appears).') +save = sum(prof[d['ap']]['intf'] for d in disabled) +print('\nPhase C: DISABLE only-when-redundant radios (each has >=%d bidirectional good neighbors WITH headroom). ' + 'Est. interference airtime removed: %.0f.' % (REDUN, save)) +if not disabled: + why = 'roam graph empty + no neighbor SNR matrix -> no proven overlap' if not HAVE_ROAM and not nj else 'none clear the capacity+coverage bar yet' + print(' (none -- %s. Power-down is still coverage-safe; for disables, supply NEIGHBOR_JSON from neighbor-collect.)' % why) +dz = byZone(disabled, lambda d: d['ap']) +for z in sorted(dz): + print(' [%s]' % z) + for d in dz[z]: print(' ' + fmt(d['ap']) + ' -> covered by: ' + ', '.join(d['cover'][:3])) +print('\nKEEP: %d radios (isolated-essential or already efficient). Apply per ZONE; never >%.0f%% disabled/zone; validate before+after.' + % (len(keep), ZPCT))