---
name: unifi-wifi
description: "Analyze and tune UniFi WiFi in dense/congested environments: audit AP/radio config + neighbor interference from the UOS controller, flag issues, recommend prioritized changes. Triggers: unifi wifi tuning, RF/airtime/channel analysis, 2.4GHz congestion, sticky clients."
---

# UniFi WiFi tuning (UOS sites)

Data-driven WiFi tuning for UniFi sites on the **UOS Server** (`172.16.3.29`). Goal: solid
performance + stability for connected devices in congested environments by analyzing what the
controller knows and making prioritized, validated changes. Built for any site; **Cascades**
(77 APs, ~550 clients, brutal 2.4GHz) is the reference hard case.

## Multi-client (any UOS site)
**`scripts/sites.sh`** lists all ~49 sites (APs/switches/gateways) + per-client AP-cred readiness + a
**site→gateway map** (each site classified UniFi-gateway vs pfSense/third-party, and which
`clients/*/pfsense-firewall` SSH creds are vaulted with host:port) — the entry point for pointing the
skill at another client. **Controller-side** scripts (audit-site,
live-stats, model-rank, optimize-radios, apply-radio) work on ANY site with **zero per-client setup**
(shared controller creds) — pass the site name/desc/id. **AP-side** collectors (neighbor/survey/dfs/
watch-ap) additionally need that client's `clients/<slug>/unifi-ap-ssh` vaulted + L3 reach (site VPN);
if missing they print the exact vault command and exit (controller-side still works). Default AP-cred
path is Cascades — override with the script's vault-path arg per client.
**Onboarding a new client** (AP-side collectors, pfSense gateway control, read-only controller cred):
copy-paste procedures in **`references/onboarding.md`**.

## Status (2026-06-15)
- **[WORKING] WiFi monitoring + RF tuning** — complete data-gathering for any UOS site/client:
  config + interference (`audit-site`), live per-AP + per-client (`live-stats`), airtime history
  + roam graph (`model-rank`/`optimize-radios`), measured per-channel occupancy (`survey-collect`),
  empirical DFS radar history (`dfs-check`), the **AP-to-AP SNR neighbor matrix** from
  `/proc/ui_neighbor` (`neighbor-collect`), per-AP live stream (`watch-ap`), and gated config apply
  (`apply-radio`). All site-parameterized → works on every UniFi site we monitor.
- **[WORKING] Switch / PoE audit** — `scripts/switch-audit.sh <site> [--all-ports]`: per-switch PoE
  budget, port up/total, and flags (UNDERSPEED gig-port-at-10/100, rate-based DROPS, ERRORS, PoE
  faults, PoE-budget pressure). Controller-side, any site. (Found ~25 100M-linked gig ports at Cascades.)
- **[WORKING] Gateway / WAN / internet + site health** — `scripts/gw-audit.sh <site>`: WAN status/IP/
  uplink, internet latency/drops/speedtest, gateway CPU/mem/uptime, and the adoption/health rollup
  (APs/switches adopted vs disconnected vs pending, client counts, firmware-upgradable) with flags.
  Handles third-party-firewall sites (num_gw=0). Controller-side, any site.
- **[WORKING] Gateway router actions — port-forwards + WAN firewall** — `scripts/gw-control.sh <site>`:
  list/disable/enable/delete/re-scope port-forwards (`pf-*`), toggle WAN firewall rules (`fw-disable`/
  `fw-enable`), and drop attacker IPs at the edge (`block-ips`). The write companion to gw-audit; closes
  an internet-facing exposure (e.g. a brute-forced PPTP). Gated/DRY-RUN, rollback saved. Controller-side.
- **[WORKING] Scheduled fleet monitoring** — `scripts/monitor-run.sh <site|all>`: controller-side
  read-only health digest per site (gateway/WAN flags + switch/PoE flags + WiFi config flag count),
  cron-friendly for ongoing monitoring of every client. AP-side collectors now **retry per-AP** (3x)
  to ride out transient VPN flaps without wasting a sweep.
- **[WIP] Client DHCP/DNS policy, deeper VPN (server) config, adoption *remediation* depth** — port-forward
  + WAN firewall is now covered (gw-control); remaining gateway config (VPN server stand-up, DHCP/DNS) is future.
- **[WORKING] pfSense gateway compatibility layer via SSH** — `scripts/pfsense-ssh.sh <slug> <verb>`.
  DECISION (Mike 2026-06-16): **no RESTAPI package needed** — VPN + SSH shell reads the same data and makes
  changes. Cred = `clients/<slug>/pfsense-firewall` (host + admin user/pass), system OpenSSH via askpass.
  **Cred path (Mike 2026-06-21, option A):** the 1st arg is a **full vault path** if it contains `/`
  (any infra-vaulted device, e.g. `pfsense-ssh.sh infrastructure/pfsense-firewall audit`), else a client
  slug -> `clients/<slug>/pfsense-firewall`. A path that resolves to no cred fails loud (`[ERROR] no cred
  at vault:<path>`). gw-audit/gw-control's `--pfsense` inherits the same convention.
  SSH port: `--port N` flag > optional vault `port`/`credentials.port` field > default 22 (e.g. the ACG
  office box on 2248 — store `port: 2248` in its vault entry). Validated live on Cascades (pfSense Plus 25.07).
  - **Reads (no gate):** `audit` (WAN/DHCP/states/DNS/NIC health), `dhcp` (pool pressure), `pf-list`
    (NAT port-forwards), `fw-list` (filter rules), `showblock [--if wan]` (active easyrule blocks),
    `run "<cmd>"` (arbitrary; incl. changes — operator-gated, no dry-run).
  - **Writes (DRY-RUN default; add `--apply` to commit — `write_config` + `filter_configure`):**
    `fw-disable|fw-enable <tracker|descr>`, `block-ips|unblock <ip[,ip,...]> [--if wan]` (easyrule) — both
    **live-validated**; `pf-disable|pf-enable|pf-delete <tracker|descr>`, `pf-set-ports <dst> [<local>]`,
    `pf-set-src <cidr|any>` (port-forwards + associated filter rule) — **live-validated 2026-06-21** (full
    create→list→set-ports→set-src→enable→disable→delete cycle on a temp source-locked forward on Cascades;
    `pf-delete` removes the associated filter rule too). Filter rules match by `tracker` (the `id` field is
    empty on 25.07) or exact `descr`; NAT rules have no `tracker` → match by `descr` or index. Each write backs
    up `config.xml` first; writes drive `scripts/pfsense-gwc.php`.
  - **Dispatch + auto-select:** `gw-audit.sh`/`gw-control.sh` route the SAME verbs to this SSH backend three
    ways: (1) explicit `--pfsense <slug-or-vault-path>`; (2) the site arg is itself a client slug with a vaulted
    cred; (3) **auto-select** — the resolved UOS site is bound in `references/site-gateways.tsv`, so no
    `--pfsense` is needed (`gw-control Cascades fw-list` just works). Manage the map with
    `scripts/gateway-map.sh` (`lookup`/`list`/`validate`/`suggest`); `suggest` lists no-UniFi-gateway sites
    not yet bound + vaulted creds not yet referenced. The REST `pfsense-backend.sh`
    (`clients/<slug>/pfsense-api`) remains a **dormant fallback** only. Design/verb-map + pfSense PHP gotchas:
    `references/ROADMAP.md` §E.
- **Per-client requirement:** `watch-ap`/`neighbor-collect`/`survey-collect`/`dfs-check` default the
  AP device-auth SSH cred to `clients/cascades-tucson/unifi-ap-ssh`; for another client, vault its
  own `clients/<x>/unifi-ap-ssh` and pass it as the script's vault-path arg.

## First, load context
- **[references/data-access.md](references/data-access.md)** — what data the UOS exposes and how
  to read it (the two planes: Mongo config/interference now, live Network API later).
- **[references/methodology.md](references/methodology.md)** — the prioritized tuning playbook
  (synthesized from a multi-model pass + live recon). Read before recommending any change.
- **[references/interference-model.md](references/interference-model.md)** — design for the
  AP-interference / airtime-reduction model (which radios to disable / power down), incl. the data
  feasibility (needs Plane 2 + a collector — the signals aren't in Mongo).

## What it does (current = Plane 1, read-only)
1. **Audit a site** — config + interference, no live plane needed:
   ```bash
   bash .claude/skills/unifi-wifi/scripts/audit-site.sh <site-name|site_id>
   bash .claude/skills/unifi-wifi/scripts/audit-site.sh cascades
   ```
   Outputs 2.4/5/6 config summary, the per-channel neighbor-density (interference) map, and flagged
   issues (2.4 over-provisioning, 40/80/160MHz width, off-1/6/11 channels, min-RSSI off, high power).
2. **Rank airtime-reduction candidates** — which radios to disable / power down, from real history
   (`ace_stat`: per-AP airtime `cu_total`/`cu_interf`/`num_sta` + the `wifi_connectivity_event` roam
   graph). Works for any band:
   ```bash
   bash .claude/skills/unifi-wifi/scripts/model-rank.sh <site> [days=7] [band=ng|na|6e|all]
   ```
   (Cascades 2.4: 75 radios at 74–94% utilization, 61–81% interference, ~1 client each → disable/power-down.)
2b. **Radio usage history** — is a radio actually USED (so you can tell disable-safe from power-down)?
   Per-AP time-avg concurrent users + peak station count over the retained window (`stat_daily`, ~77d):
   ```bash
   bash .claude/skills/unifi-wifi/scripts/radio-usage.sh <site> [band=ng|na|6e] [days=30]
   NEIGHBOR_JSON=<nbr.json> bash .../radio-usage.sh <site> ng 77   # adds a safe-to-disable shortlist
   bash .../radio-usage.sh <site> ng --ap "<AP name>"             # per-DEVICE: who's on this AP's 2.4 + steerable?
   ```
   The `--ap` mode lists the devices on one AP's band (live clients + recent assoc events) and tags each
   **steerable** (dual-band, from events; or a randomized MAC = modern phone/laptop) vs **legacy/IoT**
   (fixed vendor OUI, 2.4-only) — so you know whether the fix is band-steering or whether a 2.4-only device
   forces the radio to stay. (Needs controller cred for the live BSSID map; assoc-event retention is ~1 day.)
   Key distinction: `avg~0 but peak>0` = NOT unused (takes bursts → POWER-DOWN); only `peak==0` over the
   window = genuinely unused (disable-safe). With the SNR matrix it cross-checks low-use APs against
   overlapping neighbors w/ headroom. (Cascades 2.4: only 1 never-used radio — the offline AP 108; every
   other radio takes real bursts → confirms power-down, not disable. optimize-radios does the conflict-free pick.)
3. **Optimize (coverage-safe plan)** — which radios to **power-down** (do first) vs **disable**
   (after), per band, without opening coverage holes or capacity cascades:
   ```bash
   bash .claude/skills/unifi-wifi/scripts/optimize-radios.sh <site> [days=14] [band=ng|na|6e]
   ```
   Coverage model = the **roam graph** (materials-aware: clients can't roam through Cascades' steel
   hallway walls, so the model never calls cross-wall APs redundant). Multi-model hardened
   (bidirectional roams, load-shift simulation, 40%/zone cap, stepwise). On Cascades 2.4 it
   recommends power-down on 74/75 radios (safe big win) and 0 disables until the live RF table proves
   redundancy — see interference-model.md.
3b. **Coverage-thin 2.4 (which radios to turn OFF)** — when 2.4 is over-deployed (every AP blasting 2.4,
   heavy co-channel contention), compute a coverage-redundancy (dominating-set) DISABLE plan on the
   **2.4 SNR layer** so each disabled AP stays covered by a nearby **ACTIVE-2.4** neighbor:
   ```bash
   NBR=.claude/tmp/<site>-nbr.json; NBR_JSON=$NBR bash .../neighbor-collect.sh <site>
   NEIGHBOR_JSON=$NBR bash .../coverage-thin.sh <site> [days=14]      # MINCOV=2 for the resilient subset
   ```
   Unlike optimize-radios (band-AGNOSTIC physical adjacency → can wrongly count an ng-DISABLED AP as a
   coverer via its 5/6 GHz), coverage-thin uses the 2.4 layer and only counts neighbors whose 2.4 stays
   ON; flags single-coverer disables, caps per-zone, halves co-channel count. Cascades: ~34 of 76 2.4
   radios disable-able with ≥2 active coverers each (ch6 28→13, ch11/ch1 →13).
4. **Validate live (Plane 2)** — current cu_total/satisfaction/per-AP RF, before+after a change, and
   the AP-to-AP RF-neighbor table that unlocks confident disables:
   ```bash
   bash .claude/skills/unifi-wifi/scripts/live-stats.sh <site> [--clients]
   ```
   Needs a read-only controller admin vaulted at `infrastructure/uos-server-network-api` (the script
   prints the one-time provisioning steps if it's missing).
   - **Per-AP live watch (no controller lag)** — SSH straight into an AP and stream channel-busy% +
     noise + clients/retries every ~2s, to watch a targeted change land in real time:
     ```bash
     bash .claude/skills/unifi-wifi/scripts/watch-ap.sh <ap-ip> [interval]
     ```
     Uses `mca-dump` + `iw survey` on the AP. Device-auth cred vaulted (`clients/cascades-tucson/unifi-ap-ssh`).
     Needs L3 reach to the AP (at Cascades: bring up the site VPN; APs are on 192.168.2.x/3.x) + local `sshpass`.
5. **Interpret** the flags against `methodology.md` (fix order: prune 2.4 -> shrink cells/power ->
   min data rates -> manual 1/6/11 plan -> min-RSSI + roaming -> steer to 6GHz).
3. **Recommend** a prioritized, per-zone change plan. Roll out per zone, not site-wide at once.

### AP-side collectors (direct AP SSH over the site VPN — non-disruptive, foreground)
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**:
```bash
# 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]
# 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]
# empirical DFS radar-event history per AP (dmesg) -> is DFS safe at this site?
bash .claude/skills/unifi-wifi/scripts/dfs-check.sh cascades [vault-path]
```
The neighbor matrix is the breakthrough: UniFi exposes managed-AP-to-managed-AP visibility
**nowhere** in the controller API/DB (all filter our own APs), but each AP keeps it in
`/proc/ui_neighbor/{ess_ap_list,ssid/*}`, populated non-disruptively by background RRM scanning.

Ad-hoc Mongo queries: `.claude/scripts/uos-mongo.sh` (recipes in data-access.md). Access is the
vaulted dedicated key `infrastructure/uos-server-ssh-key` (works from any fleet machine).

## The two data planes (know which you're using)
- **Plane 1 — Mongo `ace`** (available now, read-only via SSH): radio config (`radio_table`), the
  `rogue` neighbor-interference map, `channelplan`, AP/client inventory. Enough for the full config
  audit + channel/interference plan — covers the 2.4GHz "first problem".
- **Plane 2 — live Network API** (`stat/device`, `stat/sta`; NOT yet wired): live channel
  utilization (`cu_total`), per-client RSSI/SNR/retries, AP satisfaction. Needed to **validate**
  changes (before/after) and find the worst APs by live airtime. Wiring it needs a dedicated
  read-only UniFi admin or integration API key on `.29`, vaulted as
  `infrastructure/uos-server-network-api`. See data-access.md "Plane 2".
- **Plane 3 - UniFi cloud (Site Manager API + connector; WIRED 2026-06-17):** reaches ANY of the
  ~36 ACG consoles remotely with NO UOS server and NO LAN/VPN, via `api.ui.com` + the account key
  (vault `services/unifi-site-manager`). Tier 1 = fleet inventory/health/WAN-ISP; the CONNECTOR
  (`/v1/connector/consoles/<id>/proxy/network/api/s/<site>/stat/{device,sta}`) delivers the SAME
  `cu_total`/RSSI/satisfaction depth as Plane 1+2 (= UOS PARITY) for non-UOS / standalone UDMs.
  Backend: `scripts/gw-sitemanager.sh` (`fleet|devices|sites|isp|net`); catalog:
  `references/site-manager-api.md`. Use this for consoles NOT adopted into the UOS server.

## Applying changes — IMPORTANT boundary
**Error logging (mandatory):** on a GENUINE functional failure (controller/SSH login or connect fail,
unexpected REST/pfSense error, php fatal), the scripts log it via `.claude/scripts/log-skill-error.sh`
before surfacing — do NOT log handled conditions (missing cred, bad args, site-not-found, "no results").
The live scripts (`pfsense-ssh.sh`, `gw-control.sh`, `gw-audit.sh`) self-log; if you add a new script,
do the same at its failure branches.

Config changes are automated across many APs (no per-AP UI clicking) via the controller REST API
(`PUT .../rest/device/<id>` radio_table) — **`scripts/apply-radio.sh`**. Actions (all radio_table):
```bash
bash .../apply-radio.sh <site> <ng|na|6e> power   low|medium|high|auto|<dBm>  [--zone Z] [--apply]
bash .../apply-radio.sh <site> <ng|na|6e> width   20|40|80|160                [--zone Z] [--apply]
bash .../apply-radio.sh <site> <ng|na|6e> channel <number>|auto               [--zone Z] [--apply]
bash .../apply-radio.sh <site> <ng|na|6e> minrssi off|on|-<NN>                [--zone Z] [--apply]
bash .../apply-radio.sh <site> <ng|na|6e> disable      # radio OFF (tx_power_mode=disabled)  [--ap NAME] [--apply]
bash .../apply-radio.sh <site> <ng|na|6e> enable       # radio ON  (tx_power_mode=auto)      [--ap NAME] [--apply]
```
`--ap "<name>"` targets a single AP (the right scope for disables — execute optimize-radios' disable
list one AP at a time: `optimize-radios ... ` -> for each, `apply-radio <site> ng disable --ap <name> --apply`).
Dry-run (default) prints per-AP before->after + rollback values + the REST payload. **Writes are GATED
OFF** until (1) `infrastructure/uos-server-network-api-rw` is vaulted (the root SSH key is the data
plane, NOT an API write session) and (2) `--apply` is passed. Even then: rollback is auto-saved to
`.claude/tmp/apply-rollback-*.json`, go **one `--zone` at a time**, validate live with `watch-ap.sh`
**before and after**, and never auto channel-optimize in ultra-dense sites. WRITE PATH VALIDATED
2026-06-16 (apply->verify->revert + full disable/enable cycle on 0-client 6 GHz radios). **Radio
disable IS implemented** = `tx_power_mode:"disabled"` (confirmed via UI-toggle + device-JSON diff);
`enable` sets it back to `auto`. **min-data-rate / band-steering** live in `wlanconf` (not radio_table)
— separate future apply path (see references/ROADMAP.md).
Get explicit go before any write. Full roadmap: **references/ROADMAP.md**.

**WLAN-level knobs — `scripts/apply-wlan.sh`** (wlanconf, not radio_table; affects every AP on the WLAN
— target with `--wlan`). Same gated REST path (`rest/wlanconf`), dry-run default, rollback saved:
```bash
# tuning
apply-wlan.sh <site> minrate ng|na auto|off|<Mbps>   [--wlan N] [--apply]  # kill 1-11Mbps: minrate ng 12
apply-wlan.sh <site> dtim ng|na|6e <1-255>           [--wlan N] [--apply]  # DTIM (power-save/mcast)
apply-wlan.sh <site> mcast|bcfilter on|off           [--wlan N] [--apply]  # multicast-enhance / broadcast-filter
# steering / roaming
apply-wlan.sh <site> bandsteer on|off                [--wlan N] [--apply]  # no2ghz_oui: 5GHz-capable off 2.4
apply-wlan.sh <site> bands both|5g|5g6e|6e|all       [--wlan N] [--apply]  # wlan_bands: force SSID onto bands
apply-wlan.sh <site> steer|bsstm|rrm|ftroam on|off   [--wlan N] [--apply]  # roam-assist / 802.11v / 802.11k / 802.11r
# access / security
apply-wlan.sh <site> wlan on|off                     [--wlan N] [--apply]  # enable/disable the SSID
apply-wlan.sh <site> isolation|hidessid on|off       [--wlan N] [--apply]
apply-wlan.sh <site> macfilter off|allow|deny <macs> [--wlan N] [--apply]  # per-WLAN MAC allow/deny
apply-wlan.sh <site> aps <ap1,ap2|all>               [--wlan N] [--apply]  # broadcasting_aps: restrict SSID to APs
```
**"Lock a device to an AP"** (UniFi has no native per-client AP pin): use `aps` to put an SSID on only
the chosen AP(s) ± `macfilter` to admit only that device — that constrains it to those APs. Band steering:
no classic `bandsteering_mode`; replacements are `bandsteer`/`bands`/`bsstm`. (802.11r `ftroam` warns — risky for IoT.)

**Client controls — `scripts/client-control.sh`** (operational; controller-side, gated):
```bash
client-control.sh <site> block|unblock|kick <mac> [--apply]   # ban a MAC / un-ban / force-reconnect
```
**Device / adoption remediation — `scripts/device-control.sh`** (pairs with gw-audit flags; gated):
```bash
device-control.sh <site> adopt|restart|locate|unlocate|upgrade <mac> [--apply]
device-control.sh <site> poe-cycle <ap-name|mac> [--apply]   # RECOVER a hung AP: power-cycle its PoE switch port
```
**SAFETY:** `provision`/force-provision is **removed** — it took AP 445 fully **offline** on a U7-Pro
(2026-06-16), requiring a physical port power-cycle. To recover a hung/offline AP, use **`poe-cycle`**
(remote PoE port power-cycle of the AP's uplink port); to push config cleanly use `restart` or let the
controller converge.

**Gateway router actions — `scripts/gw-control.sh`** (port-forwards + WAN firewall; the write side of
gw-audit; gated/DRY-RUN, rollback saved):
```bash
gw-control.sh <site> pf-list                          # list port-forwards (id/on/proto/ports/dst/src)
gw-control.sh <site> pf-set-ports <name|id> <dst> [fwd]   # change forwarded ports (drop one, e.g. 1723)
gw-control.sh <site> pf-disable|pf-enable|pf-delete <name|id>
gw-control.sh <site> pf-set-src <name|id> <cidr|any>  # restrict a forward to a known source
gw-control.sh <site> fw-list                          # list firewall rules
gw-control.sh <site> fw-disable|fw-enable <name|id>   # toggle a WAN rule (e.g. a "GRE" accept)
gw-control.sh <site> block-ips <ip[,ip,...]> [--group N]  # WAN address-group + WAN_IN drop rule
# pfSense sites (no UniFi gw): the same verbs auto-route to scripts/pfsense-ssh.sh (SSH backend) when a
# clients/<slug>/pfsense-firewall cred is vaulted (or pass --pfsense <slug>). pfSense fw verbs match a rule by
# `tracker` or exact `descr` (not the UniFi name/id), e.g.: gw-control.sh <slug> fw-disable "Block Guest to LAN"
```
Closing an internet-facing PPTP usually = `pf-set-ports VPN 80,443` (drop tcp 1723) **+** `fw-disable GRE`
(PPTP needs both the 1723 forward and the GRE WAN_IN rule). Reads via Mongo (no cred); writes via the RW
admin REST (`rest/portforward|firewallrule|firewallgroup`). `block-ips` clones an existing WAN_IN rule's
schema for firmware compatibility — verify the new rule's precedence in the UI. Dry-run validated 2026-06-16
on Grabb & Durando (USG-3P): identified the live `VPN` forward (80,443,1723→.200) + `GRE` WAN_IN accept.
**Channel data-gathering — the MANDATORY scan-first workflow (do this BEFORE any channel change).**
Choosing channels without the measured scan is how you pick the *congested* channels. Order:
```bash
# 1. AP-to-AP SNR neighbor matrix (who-hears-who = the co-channel graph)
NBR_JSON=.claude/tmp/<site>-nbr.json neighbor-collect.sh <site>
# 2. measured per-channel busy%/noise for EVERY AP (iw survey) -> SURVEY_JSON
SURVEY_JSON=.claude/tmp/<site>-survey.json survey-collect.sh <site>     # be patient: run to 74/74, not partial
# 3. FLEET congestion analysis -> the data that makes the channel choice (incl. DFS-vs-non-DFS)
python scripts/survey-report.py .claude/tmp/<site>-survey.json na       # per-channel busy% + suggested clean palette
```
`survey-report.py` is the **decision-driver**: it rolls the survey up into a per-channel / per-band-group
measured busy% table, ranks cleanest↔dirtiest, and prints a suggested clean 40MHz palette to feed
channel-plan via `--channels`. **DFS channels are usually the cleanest** (consumer gear avoids them) — do
NOT assume non-DFS; let this report decide (then weigh the radar-vacate tradeoff via `dfs-check.sh`).

**Channel plan — `scripts/channel-plan.sh`** (computes + applies a co-channel-minimizing plan):
```bash
NEIGHBOR_JSON=...nbr.json SURVEY_JSON=...survey.json \
  channel-plan.sh <site> ng|na [--channels 52,60,100,108,116,124,132,140] [--dfs ok|avoid|only] [--apply]
```
ng graph-colors 1/6/11. **na palette is DATA-DRIVEN, not a hardcoded non-DFS list** (that bias picked the
congested 149/157 at Cascades): pass `--channels` from survey-report, or `--dfs only|avoid`, else the
default considers ALL 40MHz primaries and the measured busy% chooses. Cost = co-channel collision (dominant)
+ load-balance + measured busy%; a local-search pass drives strong co-channel pairs to **0**. Reports
before/after. `survey-collect.sh` emits its JSON via `SURVEY_JSON=<path>`.
**LESSON (Cascades 2026-06-19):** a blind non-DFS reshuffle moved APs onto the 2 dirtiest channels (149=12%,
157=28% busy) and did nothing; the data-driven clean-DFS plan (52/60/100/108/116/124/132/140, all ≤3% busy,
0 co-channel) **halved 5GHz retry (8.7→3.8)**. Always: scan → survey-report → channel-plan --channels → apply → validate.
GOTCHA (handled): a manual min rate is only honored when `minrate_setting_preference=manual` — the
script sets it; `minrate ... auto` hands rate management back to the controller. Write path validated
2026-06-16 on a 0-client WLAN (Green Valley Computer Club) — apply->verify->restore.

## Roadmap
- **Phase 1 (done):** config + interference audit, flags, methodology. Read-only.
- **Phase 2:** wire the live Network API (Plane 2) for `cu_total`/satisfaction/per-client RF →
  before/after validation + "worst APs by airtime" ranking.
- **Phase 3:** assisted change application (per-zone, API-driven, with live before/after gating).

## Notes
- The methodology is independent-model guidance + config-plane recon — **validate against live
  stats before trusting any single recommendation**, and roll out per zone.
- Multi-site: pass any site name/id; `uos-mongo.sh --sites` lists them. Cascades = `685f39068e65331c46ef6dd2`.
