From 60b763df0cbc5d0ce55852c065f11084e2a919ae Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Tue, 16 Jun 2026 00:40:12 -0700 Subject: [PATCH] sync: auto-sync from HOWARD-HOME at 2026-06-16 00:40:03 Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-06-16 00:40:03 --- .claude/skills/unifi-wifi/SKILL.md | 19 ++++ .../skills/unifi-wifi/references/ROADMAP.md | 12 +- .../skills/unifi-wifi/scripts/apply-wlan.sh | 106 ++++++++++++++++++ .../skills/unifi-wifi/scripts/dfs-check.sh | 2 +- .../unifi-wifi/scripts/neighbor-collect.sh | 2 +- .claude/skills/unifi-wifi/scripts/sites.sh | 52 +++++++++ .../unifi-wifi/scripts/survey-collect.sh | 2 +- .claude/skills/unifi-wifi/scripts/watch-ap.sh | 2 +- ...026-06-15-howard-cascades-wifi-rf-audit.md | 23 ++++ 9 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 .claude/skills/unifi-wifi/scripts/apply-wlan.sh create mode 100644 .claude/skills/unifi-wifi/scripts/sites.sh diff --git a/.claude/skills/unifi-wifi/SKILL.md b/.claude/skills/unifi-wifi/SKILL.md index c6b5f9a..1b924df 100644 --- a/.claude/skills/unifi-wifi/SKILL.md +++ b/.claude/skills/unifi-wifi/SKILL.md @@ -10,6 +10,15 @@ performance + stability for connected devices in congested environments by analy 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 — 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//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. + ## 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 @@ -133,6 +142,16 @@ disable IS implemented** = `tx_power_mode:"disabled"` (confirmed via UI-toggle + — 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 +bash .../apply-wlan.sh minrate ng|na auto|off| [--wlan NAME] [--apply] # kill 1-11Mbps: minrate ng 12 +bash .../apply-wlan.sh steer on|off [--wlan NAME] [--apply] # 5GHz roaming-assistant +``` +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 → diff --git a/.claude/skills/unifi-wifi/references/ROADMAP.md b/.claude/skills/unifi-wifi/references/ROADMAP.md index 27ac9b2..e31cc13 100644 --- a/.claude/skills/unifi-wifi/references/ROADMAP.md +++ b/.claude/skills/unifi-wifi/references/ROADMAP.md @@ -26,17 +26,19 @@ side, multi-client enablement, and non-WiFi scope. Build/validate new apply acti 2026-06-16 via UI-toggle + device-JSON diff); enable = `auto`. Validated full cycle on a 0-client 6 GHz radio. Use `--ap ` to target one AP (the right scope for disables). Still highest-risk (coverage holes) — gate + validate per AP with watch-ap; pair with optimize-radios' disable list. -- [ ] **min data rates** (kill 1–11 Mbps; 2.4 floor 12/24) and **band-steering / 6 GHz steer** — these - live in **`wlanconf`** (WLAN object), NOT `radio_table`; they affect every AP on the WLAN. Separate - apply path (`apply-wlan.sh`), more blast radius — design carefully. +- [x] **min data rates** (kill 1–11 Mbps; 2.4 floor 12/24) — DONE in `apply-wlan.sh` + (`minrate ng|na auto|off|`). Requires `minrate_setting_preference=manual` (handled). Validated + on a 0-client WLAN. **band-steering / 6 GHz steer**: partial — `steer on|off` toggles the 5 GHz + `roaming_assistant`; classic prefer-5G band-steering field not yet located (per-AP-group?) — TODO. - [ ] **channel-plan apply** — feed `survey-collect` cleanest-channel output into a per-AP channel set. ## B. Multi-client enablement (use on any client we manage) - [ ] Per-client AP device-auth cred: vault `clients//unifi-ap-ssh`, pass as the script arg (only Cascades exists today). Keys vaulted per-client as needed. - [ ] Per-client L3 reach to APs (site VPN / route) for the AP-side collectors (Cascades split-tunnel done). -- [ ] Controller-only degraded mode is already usable (audit/live-stats/model-rank need no AP reach) — - document it so a client with no VPN still gets the bulk of the value. +- [x] Controller-only mode documented + discoverable — `scripts/sites.sh` lists all sites + AP-cred + readiness; controller-side scripts validated on other clients (Glabman, Sonoran Glass). AP-side + scripts print the exact vault command when a client's cred is missing (and note controller-side works). ## C. Non-WiFi UniFi (currently WIP / out of scope) - [ ] **Switch/PoE collector** — port up/down, PoE budget + per-port draw, errors, **uplink negotiated diff --git a/.claude/skills/unifi-wifi/scripts/apply-wlan.sh b/.claude/skills/unifi-wifi/scripts/apply-wlan.sh new file mode 100644 index 0000000..17109ef --- /dev/null +++ b/.claude/skills/unifi-wifi/scripts/apply-wlan.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# apply-wlan.sh — WLAN-level (wlanconf) config changes that aren't on the radio: minimum data rates +# (and, extensibly, band-steering / roaming-assistant). These live on the WLAN object, so a change +# affects EVERY AP broadcasting that WLAN — bigger blast radius than apply-radio; target with --wlan. +# +# DRY-RUN by default; --apply gated behind infrastructure/uos-server-network-api-rw. Same controller +# REST write path as apply-radio (login -> GET/modify/PUT rest/wlanconf/), rollback auto-saved. +# +# Actions: +# minrate off| -> minrate__enabled (+ minrate__data_rate_kbps) +# kill 1-11Mbps legacy basic rates: set 2.4 floor to 12 or 24. e.g. minrate ng 12 +# steer -> roaming_assistant_na_enabled (5GHz client steering / "roaming assistant") +# +# Usage: +# bash .../apply-wlan.sh minrate ng 12 [--wlan "CSCNet"] [--apply] +# bash .../apply-wlan.sh steer on --wlan "CSC ENT" --apply +set -uo pipefail +REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" +UOS="$REPO/.claude/scripts/uos-mongo.sh"; VAULT="$REPO/.claude/scripts/vault.sh" +SITEARG="${1:?usage: apply-wlan.sh ... [--wlan NAME] [--apply]}" +ACT="${2:?action: minrate|steer}"; shift 2 +WLAN=""; APPLY=0 +# action-specific positional args +case "$ACT" in + minrate) RBAND="${1:?minrate }"; RVAL="${2:?minrate }"; shift 2 + case "$RBAND" in ng|na) ;; *) echo "minrate band must be ng|na"; exit 1;; esac + # NOTE: a manual min rate is ONLY honored when minrate_setting_preference=manual (else the + # controller auto-manages and ignores the kbps). 'auto' hands it back to the controller. + if [ "$RVAL" = auto ]; then FIELDS="{\"minrate_setting_preference\":\"auto\"}"; + elif [ "$RVAL" = off ]; then FIELDS="{\"minrate_setting_preference\":\"manual\",\"minrate_${RBAND}_enabled\":false}"; + elif [[ "$RVAL" =~ ^[0-9]+$ ]]; then FIELDS="{\"minrate_setting_preference\":\"manual\",\"minrate_${RBAND}_enabled\":true,\"minrate_${RBAND}_data_rate_kbps\":$((RVAL*1000))}"; + else echo "minrate value: auto|off|"; exit 1; fi ;; + steer) SVAL="${1:?steer }"; shift + case "$SVAL" in on) FIELDS="{\"roaming_assistant_na_enabled\":true}";; off) FIELDS="{\"roaming_assistant_na_enabled\":false}";; *) echo "steer: on|off"; exit 1;; esac ;; + *) echo "action must be minrate|steer"; exit 1;; +esac +while [ $# -gt 0 ]; do case "$1" in --wlan) WLAN="$2"; shift 2;; --apply) APPLY=1; shift;; *) shift;; esac; done + +if [[ "$SITEARG" =~ ^[0-9a-f]{24}$ ]]; then SITE="$SITEARG"; else + SITE="$(bash "$UOS" --sites 2>/dev/null | grep -vi 'pq.html' | grep -i "$SITEARG" | awk '{print $1}' | head -1)"; fi +[ -n "$SITE" ] || { echo "[ERROR] site not found"; exit 1; } +echo "[INFO] site=$SITE set $FIELDS${WLAN:+ wlan='$WLAN'} mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)" +[ -z "$WLAN" ] && echo "[WARNING] no --wlan filter: this targets ALL WLANs at the site (incl. Guest). Consider --wlan." + +# ---- DRY-RUN preview against current wlanconf (Mongo) ---- +cat <&1 | grep -viE 'pq.html|post-quantum|store now|server may need' +var SITE='$SITE',FIELDS=$FIELDS,WLAN='$WLAN'; +var n=0,skip=0; +db.wlanconf.find({site_id:SITE}).forEach(function(w){ + if(WLAN && w.name!==WLAN) return; + var change=false,roll={}; + for(var f in FIELDS){ roll[f]=(w[f]!==undefined?w[f]:null); if(String(w[f])!==String(FIELDS[f])) change=true; } + if(!change){ skip++; return; } + n++; print("CHANGE wlan='"+w.name+"' "+JSON.stringify(roll)+" -> "+JSON.stringify(FIELDS)); +}); +print("\nSUMMARY: "+n+" WLAN(s) would change, "+skip+" already at target."); +JS + +if [ "$APPLY" != "1" ]; then + echo; echo "[dry-run] no changes made. Add --apply to write (needs the RW admin vaulted)." + exit 0 +fi + +# ---- WRITE PATH (controller REST: login -> GET/modify/PUT rest/wlanconf/, rollback) ---- +RWP="infrastructure/uos-server-network-api-rw" +export RW_U="$(bash "$VAULT" get-field "$RWP" credentials.username 2>/dev/null || true)" +export RW_P="$(bash "$VAULT" get-field "$RWP" credentials.password 2>/dev/null || true)" +if [ -z "$RW_U" ] || [ -z "$RW_P" ]; then + echo "[BLOCKED] --apply needs the RW controller admin vaulted at: $RWP"; exit 2; fi +export AW_SITE="$SITE" AW_FIELDS="$FIELDS" AW_WLAN="$WLAN" REPO +python - <<'PY' +import os,sys,json,ssl,urllib.request,http.cookiejar +H="172.16.3.29";PORT=11443;base=f"https://{H}:{PORT}" +ctx=ssl.create_default_context();ctx.check_hostname=False;ctx.verify_mode=ssl.CERT_NONE +cj=http.cookiejar.CookieJar();op=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj),urllib.request.HTTPSHandler(context=ctx)) +def call(method,path,body=None,csrf=None,want_headers=False): + data=json.dumps(body).encode() if body is not None else None + r=urllib.request.Request(base+path,data=data,method=method); r.add_header('Content-Type','application/json') + if csrf:r.add_header('X-CSRF-Token',csrf) + resp=op.open(r,timeout=20);hdr=resp.headers;txt=resp.read().decode('utf-8','replace') + return (txt,hdr) if want_headers else txt +try:_,hd=call('POST','/api/auth/login',{'username':os.environ['RW_U'],'password':os.environ['RW_P']},want_headers=True) +except Exception as e:print("[ERROR] login failed:",e);sys.exit(1) +csrf=hd.get('X-CSRF-Token') or hd.get('X-Updated-Csrf-Token') +sites=json.loads(call('GET','/proxy/network/api/self/sites')).get('data',[]) +short=next((s['name'] for s in sites if s.get('_id')==os.environ['AW_SITE']),None) +if not short:print("[ERROR] site resolve failed");sys.exit(1) +fields=json.loads(os.environ['AW_FIELDS']);wlan=os.environ['AW_WLAN'] +wl=json.loads(call('GET',f'/proxy/network/api/s/{short}/rest/wlanconf')).get('data',[]) +roll=[];done=0;fail=0 +for w in wl: + if wlan and w.get('name')!=wlan:continue + if all(str(w.get(f))==str(v) for f,v in fields.items()):continue + old={f:w.get(f) for f in fields} + for f,v in fields.items():w[f]=v + try: + call('PUT',f"/proxy/network/api/s/{short}/rest/wlanconf/{w['_id']}",w,csrf=csrf) + roll.append({'id':w['_id'],'name':w.get('name'),'old':old});done+=1;print(f" [ok] wlan '{w.get('name')}' -> {fields}") + except Exception as e: + fail+=1;print(f" [FAIL] wlan '{w.get('name')}': {e}") +rp=os.path.join(os.environ.get('REPO','.'),'.claude','tmp',f"apply-wlan-rollback-{short}.json") +try: + os.makedirs(os.path.dirname(rp),exist_ok=True);open(rp,'w').write(json.dumps(roll,indent=1)) + print(f"\n[APPLY] {done} changed, {fail} failed. Rollback saved: {rp}") +except Exception as e:print("[APPLY] done; rollback save failed:",e) +PY diff --git a/.claude/skills/unifi-wifi/scripts/dfs-check.sh b/.claude/skills/unifi-wifi/scripts/dfs-check.sh index 56b509d..9192cdb 100644 --- a/.claude/skills/unifi-wifi/scripts/dfs-check.sh +++ b/.claude/skills/unifi-wifi/scripts/dfs-check.sh @@ -53,7 +53,7 @@ PY AU="$(bash "$VAULT" get-field "$VP" credentials.username 2>/dev/null)" AP_PW="$(bash "$VAULT" get-field "$VP" credentials.password 2>/dev/null)"; export AP_PW -[ -n "$AU" ] && [ -n "$AP_PW" ] || { echo "[ERROR] no AP device-auth cred at vault:$VP"; exit 1; } +[ -n "$AU" ] && [ -n "$AP_PW" ] || { echo "[BLOCKED] no AP device-auth cred at vault:$VP"; echo " Controller-side scripts (audit-site/live-stats/model-rank/optimize-radios/apply-radio) still work for this site."; echo " For AP-side collectors, vault this client's cred then re-run:"; echo " bash .claude/skills/vault/scripts/vault-helper.sh new $VP --kind generic --name ' UniFi AP device-auth SSH' --tag unifi --set username= --set password="; exit 2; } SSH_OPTS=(-o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \ -o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1) if command -v sshpass >/dev/null 2>&1; then diff --git a/.claude/skills/unifi-wifi/scripts/neighbor-collect.sh b/.claude/skills/unifi-wifi/scripts/neighbor-collect.sh index 5b8571c..9fac190 100644 --- a/.claude/skills/unifi-wifi/scripts/neighbor-collect.sh +++ b/.claude/skills/unifi-wifi/scripts/neighbor-collect.sh @@ -70,7 +70,7 @@ PY # --- AP SSH auth: sshpass if present, else SSH_ASKPASS fallback --- AU="$(bash "$VAULT" get-field "$VP" credentials.username 2>/dev/null)" AP_PW="$(bash "$VAULT" get-field "$VP" credentials.password 2>/dev/null)"; export AP_PW -[ -n "$AU" ] && [ -n "$AP_PW" ] || { echo "[ERROR] no AP device-auth cred at vault:$VP"; exit 1; } +[ -n "$AU" ] && [ -n "$AP_PW" ] || { echo "[BLOCKED] no AP device-auth cred at vault:$VP"; echo " Controller-side scripts (audit-site/live-stats/model-rank/optimize-radios/apply-radio) still work for this site."; echo " For AP-side collectors, vault this client's cred then re-run:"; echo " bash .claude/skills/vault/scripts/vault-helper.sh new $VP --kind generic --name ' UniFi AP device-auth SSH' --tag unifi --set username= --set password="; exit 2; } SSH_OPTS=(-o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \ -o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1) # NOTE: /dev/null)}" +[ -n "$VROOT" ] || VROOT="$REPO/../vault" + +echo "[INFO] UniFi sites on the UOS controller (controller-side scripts work on ALL of these now):" +cat <<'JS' | bash "$UOS" 2>&1 | grep -viE 'WARNING|post-quantum|store now|upgraded|openssh' +var sites={}; +db.site.find({},{name:1,desc:1}).forEach(function(s){ sites[s._id.str]={name:(s.desc||s.name),short:s.name,ap:0,sw:0,gw:0}; }); +db.device.find({},{site_id:1,type:1}).forEach(function(d){ var s=sites[d.site_id]; if(!s)return; + if(d.type=='uap')s.ap++; else if(d.type=='usw')s.sw++; else if(d.type=='ugw'||d.type=='uxg'||d.type=='udm')s.gw++; }); +function pad(v,n){var s=String(v);while(s.length"); +JS + +# AP-side readiness: which clients have an AP device-auth cred vaulted (clients//unifi-ap-ssh) +echo "" +echo "[INFO] AP-side collectors (neighbor/survey/dfs/watch-ap) need a per-client AP device-auth cred." +echo " Vaulted AP creds found (clients/*/unifi-ap-ssh):" +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) + [ -n "$found" ] && echo "$found" || echo " (none yet — only the controller-side scripts will work until you vault one)" +else + echo " (vault not found at $VROOT — set VAULT_ROOT)" +fi +cat <<'EOF' + To enable AP-side collectors for a new client: + 1) get that site's Device Authentication user/pass (UniFi OS -> Settings -> System -> + Device Authentication, per site) + L3 reach to its AP mgmt VLAN (site VPN/route). + 2) bash .claude/skills/vault/scripts/vault-helper.sh new clients//unifi-ap-ssh \ + --kind generic --name ' UniFi AP device-auth SSH' --tag unifi \ + --set username= --set password= + 3) pass it as the script's vault-path arg, e.g.: + bash .../neighbor-collect.sh clients//unifi-ap-ssh +EOF diff --git a/.claude/skills/unifi-wifi/scripts/survey-collect.sh b/.claude/skills/unifi-wifi/scripts/survey-collect.sh index 9f4a382..5dbcd72 100644 --- a/.claude/skills/unifi-wifi/scripts/survey-collect.sh +++ b/.claude/skills/unifi-wifi/scripts/survey-collect.sh @@ -49,7 +49,7 @@ PY # --- AP SSH auth (sshpass or SSH_ASKPASS fallback) --- AU="$(bash "$VAULT" get-field "$VP" credentials.username 2>/dev/null)" AP_PW="$(bash "$VAULT" get-field "$VP" credentials.password 2>/dev/null)"; export AP_PW -[ -n "$AU" ] && [ -n "$AP_PW" ] || { echo "[ERROR] no AP device-auth cred at vault:$VP"; exit 1; } +[ -n "$AU" ] && [ -n "$AP_PW" ] || { echo "[BLOCKED] no AP device-auth cred at vault:$VP"; echo " Controller-side scripts (audit-site/live-stats/model-rank/optimize-radios/apply-radio) still work for this site."; echo " For AP-side collectors, vault this client's cred then re-run:"; echo " bash .claude/skills/vault/scripts/vault-helper.sh new $VP --kind generic --name ' UniFi AP device-auth SSH' --tag unifi --set username= --set password="; exit 2; } SSH_OPTS=(-o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \ -o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1) if command -v sshpass >/dev/null 2>&1; then diff --git a/.claude/skills/unifi-wifi/scripts/watch-ap.sh b/.claude/skills/unifi-wifi/scripts/watch-ap.sh index e91eb01..c0b88a0 100644 --- a/.claude/skills/unifi-wifi/scripts/watch-ap.sh +++ b/.claude/skills/unifi-wifi/scripts/watch-ap.sh @@ -19,7 +19,7 @@ VAULT="$REPO/.claude/scripts/vault.sh" AP="${1:?usage: watch-ap.sh [interval] [vault-path]}"; INT="${2:-2}"; VP="${3:-clients/cascades-tucson/unifi-ap-ssh}" U="$(bash "$VAULT" get-field "$VP" credentials.username 2>/dev/null)" P="$(bash "$VAULT" get-field "$VP" credentials.password 2>/dev/null)" -[ -n "$U" ] && [ -n "$P" ] || { echo "[ERROR] no device-auth cred at vault:$VP"; exit 1; } +[ -n "$U" ] && [ -n "$P" ] || { echo "[BLOCKED] no AP device-auth cred at vault:$VP"; echo " Vault this client's cred: bash .claude/skills/vault/scripts/vault-helper.sh new $VP --kind generic --name ' UniFi AP device-auth SSH' --tag unifi --set username= --set password="; exit 2; } # Auth method: sshpass if available, else SSH_ASKPASS fallback (no sshpass needed). SSH_OPTS=(-o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \ diff --git a/clients/cascades-tucson/session-logs/2026-06/2026-06-15-howard-cascades-wifi-rf-audit.md b/clients/cascades-tucson/session-logs/2026-06/2026-06-15-howard-cascades-wifi-rf-audit.md index 40a2e88..cb5be25 100644 --- a/clients/cascades-tucson/session-logs/2026-06/2026-06-15-howard-cascades-wifi-rf-audit.md +++ b/clients/cascades-tucson/session-logs/2026-06/2026-06-15-howard-cascades-wifi-rf-audit.md @@ -378,3 +378,26 @@ disable/enable take no value. VALIDATED full enable->disable->enable cycle on 62 SKILL.md + ROADMAP.md updated (disable now done). apply-side now covers all radio_table knobs; only wlanconf-level (min-data-rate, band-steering) remains -> future apply-wlan.sh. Coord: 6aac1298-> this: msg sent. + +--- + +## Update: 2026-06-16 00:40 PT — multi-client enablement + WLAN-level apply (wlanconf); both validated + +MULTI-CLIENT (B): NEW scripts/sites.sh lists all ~49 UOS sites (APs/SW/GW) + per-client AP-cred +readiness — the entry point for other clients. Controller-side scripts validated on **other clients** +(Glabman 6 APs, Sonoran Glass 5 APs) with zero setup. AP-side scripts now print the exact vault +command when a client's clients//unifi-ap-ssh is missing (controller-side still works). SKILL.md +Multi-client section added. (Per-client AP creds vaulted as clients come online.) + +WLAN-LEVEL APPLY (A2): NEW scripts/apply-wlan.sh (wlanconf, gated like apply-radio, --wlan filter): + minrate ng|na auto|off| (kill 1-11Mbps: minrate ng 12) + steer on|off (5GHz roaming_assistant) +GOTCHA found+fixed: manual min rate only applies when minrate_setting_preference=manual (else controller +auto-manages + silently reverts — first PUT looked [ok] but value stayed 1000). Script now sets the +preference; 'minrate ... auto' restores controller management. WRITE PATH VALIDATED on a 0-client WLAN +(Green Valley Computer Club): minrate ng 12 -> persisted (pref=manual, 12000) -> restored to auto. + +apply-radio (radio_table: power all-states/width/channel/minrssi/disable/enable + --ap/--zone) and +apply-wlan (wlanconf: minrate/steer) now cover the WiFi apply surface, all gated + rollback + validated +on 0-client sandboxes. ROADMAP updated. Coord: this msg. Remaining: classic band-steering field, per-client +creds/VPN, switches/gateway collectors (C).