From 35a0cca2c620e963a1c3fabf23cb4d8d40f70419 Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Tue, 16 Jun 2026 00:03:21 -0700 Subject: [PATCH] sync: auto-sync from HOWARD-HOME at 2026-06-16 00:03:10 Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-06-16 00:03:10 --- .claude/skills/unifi-wifi/SKILL.md | 25 +++-- .../skills/unifi-wifi/references/ROADMAP.md | 60 ++++++++++ .../skills/unifi-wifi/scripts/apply-radio.sh | 106 ++++++++++-------- ...026-06-15-howard-cascades-wifi-rf-audit.md | 30 +++++ 4 files changed, 162 insertions(+), 59 deletions(-) create mode 100644 .claude/skills/unifi-wifi/references/ROADMAP.md diff --git a/.claude/skills/unifi-wifi/SKILL.md b/.claude/skills/unifi-wifi/SKILL.md index 7443395..67262f7 100644 --- a/.claude/skills/unifi-wifi/SKILL.md +++ b/.claude/skills/unifi-wifi/SKILL.md @@ -110,18 +110,23 @@ vaulted dedicated key `infrastructure/uos-server-ssh-key` (works from any fleet `infrastructure/uos-server-network-api`. See data-access.md "Plane 2". ## Applying changes — IMPORTANT boundary -Config changes CAN be automated across many APs (no per-AP UI clicking) via the controller REST API -(`PUT .../rest/device/` radio_table) — **`scripts/apply-radio.sh`** does this: +Config changes are automated across many APs (no per-AP UI clicking) via the controller REST API +(`PUT .../rest/device/` radio_table) — **`scripts/apply-radio.sh`**. Actions (all radio_table): ```bash -bash .claude/skills/unifi-wifi/scripts/apply-radio.sh cascades ng power low [--zone "Floor 3"] # DRY-RUN preview +bash .../apply-radio.sh power low|medium|high|auto| [--zone Z] [--apply] +bash .../apply-radio.sh width 20|40|80|160 [--zone Z] [--apply] +bash .../apply-radio.sh channel |auto [--zone Z] [--apply] +bash .../apply-radio.sh minrssi off|on|- [--zone Z] [--apply] ``` -Dry-run prints per-AP before->after + rollback values + the exact REST payload. **Writes are GATED -OFF** until (1) a read-WRITE controller admin is vaulted (`infrastructure/uos-server-network-api-rw`; -the root SSH key is the data plane, NOT an API write session) and (2) `--apply` is passed — and even -then must: capture old values, go **one `--zone` at a time**, validate live `cu_total`/busy% with -`watch-ap.sh` **before and after**, and never run nightly/auto channel optimization in ultra-dense -sites. `disable` is intentionally NOT in apply-radio.sh yet — it needs the RF-neighbor table to prove -redundancy first (see interference-model.md). Get explicit go before enabling any write. +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 on 0-client 6 GHz radios). **`disable` a radio is NOT implemented** +— there is no `radio_table` enable field; the mechanism is unconfirmed (see references/ROADMAP.md A). +**min-data-rate / band-steering** live in `wlanconf` (not radio_table) — separate future apply path. +Get explicit go before any write. Full roadmap: **references/ROADMAP.md**. ## Roadmap - **Phase 1 (done):** config + interference audit, flags, methodology. Read-only. diff --git a/.claude/skills/unifi-wifi/references/ROADMAP.md b/.claude/skills/unifi-wifi/references/ROADMAP.md new file mode 100644 index 0000000..cd0d527 --- /dev/null +++ b/.claude/skills/unifi-wifi/references/ROADMAP.md @@ -0,0 +1,60 @@ +# unifi-wifi — roadmap + +Status as of 2026-06-16. The skill is **fleet-generic** (every script takes ``; works on any +of the ~49 UOS sites) and **WiFi data-gathering is complete**. Remaining work is the apply/action +side, multi-client enablement, and non-WiFi scope. Build/validate new apply actions against +**zero-client radios** (6 GHz is ~0-client fleetwide) — change → verify → revert, no disruption. + +## Done (WiFi monitoring + analysis) +- `audit-site.sh` — config + foreign-interference flags (Plane 1, Mongo). +- `live-stats.sh` — live per-AP utilization/satisfaction/retry% + per-client RSSI/retry%/reason (Plane 2). +- `model-rank.sh` / `optimize-radios.sh` — airtime-reduction + coverage-safe plan. optimize-radios + consumes the SNR matrix (`NEIGHBOR_JSON`) for **data-backed disables** (roam graph fallback). +- `neighbor-collect.sh` — AP-to-AP **SNR matrix** from `/proc/ui_neighbor` (+ `NBR_JSON` adjacency emit). +- `survey-collect.sh` — measured per-channel busy%/noise → cleanest-channel input. +- `dfs-check.sh` — empirical DFS radar history (dmesg) → is DFS safe at this site. +- `watch-ap.sh` — live per-AP RF stream (mca-dump + iw survey). +- `apply-radio.sh` — gated config apply. Currently: **power**, **width (ht)**, **channel**, **min-RSSI** + (all `radio_table` PUTs). DRY-RUN default; `--apply` needs `infrastructure/uos-server-network-api-rw`. + +## A. Apply side — make WiFi changes fully executable +- [x] apply-radio: power (tx_power_mode/tx_power) +- [x] apply-radio: width (ht 20/40/80/160) +- [x] apply-radio: channel (manual channel assignment) +- [x] apply-radio: min-RSSI (min_rssi_enabled + min_rssi) +- [ ] **apply-radio: disable a radio** — NO `enabled` field in `radio_table`; the disable mechanism is + unconfirmed (likely an AP-level/`vap` setting or a field that only appears once set). Discover by + toggling a radio in the UI and diffing the device JSON before/after, then implement. Highest-risk + action (coverage holes) — keep gated + per-zone + on-site validation. +- [ ] **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. +- [ ] **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. + +## C. Non-WiFi UniFi (currently WIP / out of scope) +- [ ] **Switch/PoE collector** — port up/down, PoE budget + per-port draw, errors, **uplink negotiated + speed** (the FastEthernet-uplink issue is still not scriptable). +- [ ] **Gateway/WAN/firewall + adoption** — WAN health/failover, pending-adoption devices. +- The access layer already reaches these (`uos-mongo.sh` = whole `ace` DB; controller API + device SSH); + they just need dedicated scripts. Consider a sibling `unifi` skill if scope grows. + +## D. Robustness / ops +- [ ] **VPN-flap resilience** in the AP-side loops (resume/retry so a mid-run tunnel drop doesn't waste + a 4-min sweep). Background runs can't spawn the SSH_ASKPASS helper — must run foreground. +- [ ] **Scheduling** — periodic `dfs-check` + neighbor/survey refresh (DFS is time-varying). +- [ ] Vault read-only `infrastructure/uos-server-network-api` (least-privilege; RW does double duty now). + +## Cross-platform notes (baked into the scripts; keep for any new ones) +- Pass temp paths to python via **ARGV** (MSYS translates POSIX→Windows for python.exe); `$TMP` inside + a `python -c` string is NOT translated → fails on Windows. +- Do NOT inject large data as a JS **object literal** into the mongo shell (~21 KB crashes SpiderMonkey); + precompute + inject a compact flat **string**. +- AP-side SSH: `sshpass` or `SSH_ASKPASS` fallback; `after, the REST payload that would be sent, -# and the captured old values for rollback. Writes are GATED and off until a read-WRITE controller -# admin is vaulted AND --apply is passed (production safety; this is a live facility). +# apply-radio.sh — compute (and, when enabled, apply) a radio_table config change across a set of APs. +# DRY-RUN BY DEFAULT: prints the exact per-AP before->after, the REST payload, and captured rollback +# values. Writes are GATED off until a read-WRITE controller admin is vaulted AND --apply is passed +# (production safety; live facility). Roll out per --zone, validate with watch-ap.sh before+after. # # WHY API, not SSH/Mongo: UniFi config is controller-authoritative. The supported write path is the # controller REST API (PUT /proxy/network/api/s//rest/device/) with an ADMIN SESSION. The -# vaulted root SSH key is the data plane (reads/AP-watch), NOT an API write credential. Editing Mongo -# + forcing a re-provision is possible with root but unsupported/fragile -- don't, on a live site. +# root SSH key is the data plane (reads/AP-watch), NOT an API write credential. # # Usage: -# bash .../apply-radio.sh power > [--zone "Floor 3"] -# (--apply to actually write — refused until infrastructure/uos-server-network-api-rw is vaulted) +# bash .../apply-radio.sh [--zone "Floor 3"] [--apply] +# Actions / values (all are radio_table fields): +# power low|medium|high|auto| -> tx_power_mode (+ tx_power if a dBm number) +# width 20|40|80|160 -> ht (channel width) +# channel |auto -> channel +# minrssi off|on|- -> min_rssi_enabled (+ min_rssi if a dBm number) +# (disable a radio is NOT here — no radio_table enable field; see references/ROADMAP.md) # Examples: -# apply-radio.sh cascades ng power low # preview: drop all 2.4 radios to Low -# apply-radio.sh cascades ng power low --zone "Floor 3"# preview: just Floor 3 +# apply-radio.sh cascades na width 40 # preview: 5GHz -> 40MHz everywhere +# apply-radio.sh cascades na width 40 --zone "Floor 4" --apply +# apply-radio.sh cascades ng minrssi -76 --apply # enable 2.4 min-RSSI -76 +# apply-radio.sh cascades 6e power low --zone "Floor 6" set -euo 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-radio.sh power [--zone Z] [--apply]}"; BAND="${2:?band ng|na|6e}"; ACT="${3:?action: power}"; VAL="${4:?value: low|medium|high|auto|}" +SITEARG="${1:?usage: apply-radio.sh [--zone Z] [--apply]}" +BAND="${2:?band ng|na|6e}"; ACT="${3:?action: power|width|channel|minrssi}"; VAL="${4:?value}" ZONE=""; APPLY=0; shift 4 || true while [ $# -gt 0 ]; do case "$1" in --zone) ZONE="$2"; shift 2;; --apply) APPLY=1; shift;; *) shift;; esac; done case "$BAND" in ng|na|6e) ;; *) echo "band must be ng|na|6e"; exit 1;; esac -[ "$ACT" = "power" ] || { echo "[ERROR] only 'power' is implemented (disable needs the RF-neighbor table; see interference-model.md)"; exit 1; } -# value -> tx_power_mode (+ tx_power if a dBm number) -if [[ "$VAL" =~ ^[0-9]+$ ]]; then MODE="custom"; DBM="$VAL"; else MODE="$VAL"; DBM=""; fi -case "$MODE" in low|medium|high|auto|custom) ;; *) echo "value must be low|medium|high|auto|"; exit 1;; esac + +# action+value -> the radio_table fields to set (compact JSON, used by both the preview JS and apply python) +case "$ACT" in + power) + if [[ "$VAL" =~ ^-?[0-9]+$ ]]; then FIELDS="{\"tx_power_mode\":\"custom\",\"tx_power\":$VAL}"; + else case "$VAL" in low|medium|high|auto) FIELDS="{\"tx_power_mode\":\"$VAL\"}";; *) echo "power: low|medium|high|auto|"; exit 1;; esac; fi ;; + width) case "$VAL" in 20|40|80|160) FIELDS="{\"ht\":$VAL}";; *) echo "width: 20|40|80|160"; exit 1;; esac ;; + channel) if [[ "$VAL" =~ ^[0-9]+$ ]]; then FIELDS="{\"channel\":$VAL}"; elif [ "$VAL" = auto ]; then FIELDS="{\"channel\":\"auto\"}"; else echo "channel: |auto"; exit 1; fi ;; + minrssi) case "$VAL" in + off) FIELDS="{\"min_rssi_enabled\":false}";; + on) FIELDS="{\"min_rssi_enabled\":true}";; + -[0-9]*) FIELDS="{\"min_rssi_enabled\":true,\"min_rssi\":$VAL}";; + *) echo "minrssi: off|on|-"; exit 1;; esac ;; + *) echo "action must be power|width|channel|minrssi"; exit 1;; +esac + 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 band=$BAND -> tx_power_mode=$MODE${DBM:+ tx_power=${DBM}dBm}${ZONE:+ zone='$ZONE'} mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)" +echo "[INFO] site=$SITE band=$BAND set $FIELDS${ZONE:+ zone='$ZONE'} mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)" -# compute the change set from current config (Mongo) -cat <&1 | grep -viE 'pq.html|post-quantum|store now|server may need' | tee /tmp/apply_preview.$$ -var SITE='$SITE',BAND='$BAND',MODE='$MODE',DBM='$DBM',ZONE='$ZONE'; +# ---- DRY-RUN preview: compare target fields vs current radio_table (Mongo) ---- +cat <&1 | grep -viE 'pq.html|post-quantum|store now|server may need' +var SITE='$SITE',BAND='$BAND',FIELDS=$FIELDS; function zoneOf(n){var fz=String(n||'').match(/(\d)(?:st|nd|rd|th)\s*floor/i),rm=String(n||'').match(/\b(\d)\d{2}\b/);return fz?('Floor '+fz[1]):(rm?('Floor '+rm[1]):'misc');} var n=0,skip=0; db.device.find({site_id:SITE,type:'uap'},{name:1,radio_table:1}).forEach(function(a){ - if(ZONE && zoneOf(a.name)!==ZONE) return; + if('$ZONE' && zoneOf(a.name)!=='$ZONE') return; (a.radio_table||[]).forEach(function(r){ if(r.radio!==BAND) return; - var curMode=r.tx_power_mode, curDbm=(r.tx_power!=null?r.tx_power:''); - if(curMode===MODE && (MODE!=='custom' || String(curDbm)===DBM)){ skip++; return; } // already at target - n++; - print("CHANGE "+(a.name||a._id)+" ["+BAND+"] tx_power_mode: "+curMode+(curMode=='custom'?('('+curDbm+')'):'')+" -> "+MODE+(DBM?('('+DBM+')'):'') - +" (rollback: mode="+curMode+(curDbm!==''?(' dbm='+curDbm):'')+")"); + var change=false,roll={}; + for(var f in FIELDS){ roll[f]=(r[f]!==undefined?r[f]:null); if(String(r[f])!==String(FIELDS[f])) change=true; } + if(!change){ skip++; return; } + n++; print("CHANGE "+(a.name||a._id)+" ["+BAND+"] "+JSON.stringify(roll)+" -> "+JSON.stringify(FIELDS)); }); }); print("\nSUMMARY: "+n+" radios would change, "+skip+" already at target."); -print("REST payload per AP (what --apply WOULD PUT to /proxy/network/api/s//rest/device/):"); -print(" { \"radio_table\": [ { \"radio\":\""+BAND+"\", \"tx_power_mode\":\""+MODE+"\""+(DBM?(", \"tx_power\":"+DBM):"")+" , ...other fields unchanged... } ] }"); +print("REST PUT /proxy/network/api/s//rest/device/ body: { radio_table:[ {radio:'"+BAND+"', ...current..., "+Object.keys(FIELDS).map(function(k){return k+':'+JSON.stringify(FIELDS[k]);}).join(', ')+"} ] }"); JS 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 fi -# ----- WRITE PATH (login -> per-AP GET/modify/PUT via the controller REST API, with rollback) ----- +# ---- WRITE PATH (controller REST: login -> per-AP GET/modify/PUT, with 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)" @@ -65,18 +81,14 @@ if [ -z "$RW_U" ] || [ -z "$RW_P" ]; then cat < Settings -> Admins -> Add Admin (Full Management / site Admin; set username + password). - 2) bash .claude/skills/vault/scripts/vault-helper.sh new $RWP --kind generic \\ - --name 'UOS Network API (read-write admin)' --tag unifi --set username= --set password= - 3) Re-run this command with --apply. -Once vaulted, this works for the whole fleet (incl. HOWARD-HOME). +Create in UniFi OS -> Settings -> Admins (Full Management), then: + bash .claude/skills/vault/scripts/vault-helper.sh new $RWP --kind generic \\ + --name 'UOS Network API (read-write admin)' --tag unifi --set username= --set password= EOF exit 2 fi -export AR_SITE="$SITE" AR_BAND="$BAND" AR_MODE="$MODE" AR_DBM="$DBM" AR_ZONE="$ZONE" +export AR_SITE="$SITE" AR_BAND="$BAND" AR_FIELDS="$FIELDS" AR_ZONE="$ZONE" REPO python - <<'PY' import os,sys,json,ssl,urllib.request,http.cookiejar H="172.16.3.29";PORT=11443;base=f"https://{H}:{PORT}" @@ -84,20 +96,17 @@ ctx=ssl.create_default_context();ctx.check_hostname=False;ctx.verify_mode=ssl.CE 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') + 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 -# login + CSRF 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') -# resolve short site name 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['AR_SITE']),None) if not short:print("[ERROR] site resolve failed");sys.exit(1) -band=os.environ['AR_BAND'];mode=os.environ['AR_MODE'];dbm=os.environ['AR_DBM'];zone=os.environ['AR_ZONE'] +band=os.environ['AR_BAND'];zone=os.environ['AR_ZONE'];fields=json.loads(os.environ['AR_FIELDS']) def zof(n): import re;n=n or '' m=re.search(r'(\d)(?:st|nd|rd|th)\s*floor',n,re.I) or re.search(r'\b(\d)\d{2}\b',n) @@ -110,18 +119,17 @@ for d in devs: rt=d.get('radio_table') or [];changed=False;old=[] for r in rt: if r.get('radio')!=band:continue - old.append({'tx_power_mode':r.get('tx_power_mode'),'tx_power':r.get('tx_power')}) - if r.get('tx_power_mode')==mode and (mode!='custom' or str(r.get('tx_power'))==dbm):continue - r['tx_power_mode']=mode - if dbm:r['tx_power']=int(dbm) + old.append({f:r.get(f) for f in fields}) + if all(str(r.get(f))==str(v) for f,v in fields.items()):continue + for f,v in fields.items():r[f]=v changed=True if not changed:continue try: call('PUT',f"/proxy/network/api/s/{short}/rest/device/{d['_id']}",{'radio_table':rt},csrf=csrf) - roll.append({'id':d['_id'],'name':d.get('name'),'old':old});done+=1;print(f" [ok] {d.get('name')} -> {band} {mode}{('('+dbm+')') if dbm else ''}") + roll.append({'id':d['_id'],'name':d.get('name'),'old':old});done+=1;print(f" [ok] {d.get('name')} -> {band} {fields}") except Exception as e: fail+=1;print(f" [FAIL] {d.get('name')}: {e}") -rp=os.path.join(os.environ.get('REPO','.'),'.claude','tmp',f"apply-rollback-{short}-{band}.json") +rp=os.path.join(os.environ.get('REPO','.'),'.claude','tmp',f"apply-rollback-{short}-{band}-{'-'.join(fields)}.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}") 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 e66d814..823d315 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 @@ -331,3 +331,33 @@ mongo-shell injection: flat string, not object literal.) Still gated: apply-radio.sh has NO disable action (power/channel/width only); disables remain a reviewed, per-zone, live-validated MANUAL step. Coord: wire announce 68cae757. Remaining open: Floor-4 2.4 power-down pilot (still nothing applied to live radios). + +--- + +## Update: 2026-06-16 00:03 PT — skill-build focus: apply-radio generalized + write path validated + ROADMAP + +Per Howard: pause the Cascades production fix; build the unifi-wifi skill into a fully working tool +for any client. Used Cascades **0-client radios as the safe sandbox** (6 GHz is ~0-client fleetwide; +no whole-AP had 0 clients). + +ADDED references/ROADMAP.md — full A/B/C/D plan: (A) apply side, (B) multi-client enablement, +(C) switches/gateway WIP, (D) robustness. Cross-platform gotchas captured for future scripts. + +GENERALIZED apply-radio.sh from power-only to 4 radio_table actions (same gated REST mechanism, +field-generic via a compact FIELDS JSON shared by the dry-run preview and the apply python; rollback +auto-saved): + power low|medium|high|auto| -> tx_power_mode (+tx_power) + width 20|40|80|160 -> ht + channel |auto -> channel + minrssi off|on|- -> min_rssi_enabled (+min_rssi) + +**WRITE PATH VALIDATED** end-to-end (first real --apply): on Floor 6 0-client 6e radios (608, 622) +applied ht 160->80, controller confirmed ht=80, then reverted to 160 (state restored). Dry-run +previews for width/minrssi verified correct (incl. 615 U6-Pro correctly has no 6e radio). + +radio_table field names confirmed: channel, ht (width), tx_power_mode/tx_power, min_rssi + +min_rssi_enabled. NO radio enable/disable field -> DISABLE not implemented (ROADMAP A; discover via +UI-toggle + device-JSON diff). min-data-rate/band-steering are wlanconf-level -> separate path. + +SKILL.md apply section updated with all 4 actions + the validated-write-path note. Coord: 6aac1298. +Next per roadmap: A (disable mechanism, wlanconf knobs, channel-plan apply) then B (per-client creds/VPN).