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
This commit is contained in:
2026-06-16 00:03:21 -07:00
parent a62bd65be7
commit 35a0cca2c6
4 changed files with 162 additions and 59 deletions

View File

@@ -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/<id>` 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/<id>` 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 <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]
```
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.

View File

@@ -0,0 +1,60 @@
# unifi-wifi — roadmap
Status as of 2026-06-16. The skill is **fleet-generic** (every script takes `<site>`; 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 111 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/<x>/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; `</dev/null` in `while read` loops; **foreground**.
- Windows text-mode file writes add CRLF → strip `\r` after bash `read`.

View File

@@ -1,63 +1,79 @@
#!/usr/bin/env bash
# apply-radio.sh — compute (and, when enabled, apply) a radio config change across a set of APs.
# DRY-RUN BY DEFAULT: prints the exact per-AP before->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/<site>/rest/device/<id>) 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 <site> <band ng|na|6e> power <low|medium|high|auto|<dBm>> [--zone "Floor 3"]
# (--apply to actually write — refused until infrastructure/uos-server-network-api-rw is vaulted)
# bash .../apply-radio.sh <site> <band ng|na|6e> <action> <value> [--zone "Floor 3"] [--apply]
# Actions / values (all are radio_table fields):
# power low|medium|high|auto|<dBm> -> tx_power_mode (+ tx_power if a dBm number)
# width 20|40|80|160 -> ht (channel width)
# channel <number>|auto -> channel
# minrssi off|on|-<NN> -> 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 <site> <band> power <val> [--zone Z] [--apply]}"; BAND="${2:?band ng|na|6e}"; ACT="${3:?action: power}"; VAL="${4:?value: low|medium|high|auto|<dBm>}"
SITEARG="${1:?usage: apply-radio.sh <site> <band> <action> <value> [--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|<dBm>"; 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|<dBm>"; 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: <number>|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|-<NN>"; 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 <<JS | bash "$UOS" 2>&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 <<JS | bash "$UOS" 2>&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/<site>/rest/device/<id>):");
print(" { \"radio_table\": [ { \"radio\":\""+BAND+"\", \"tx_power_mode\":\""+MODE+"\""+(DBM?(", \"tx_power\":"+DBM):"")+" , ...other fields unchanged... } ] }");
print("REST PUT /proxy/network/api/s/<site>/rest/device/<id> 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 <<EOF
[BLOCKED] --apply needs a read-WRITE controller admin vaulted at: $RWP
UniFi OS authenticates against unifi-core, so this admin must be created in the UI (it cannot be
minted from Mongo). Howard has full controller access — this is his to create (~1 min):
1) UniFi OS -> 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=<u> --set password=<pw>
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=<u> --set password=<pw>
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}")

View File

@@ -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|<dBm> -> tx_power_mode (+tx_power)
width 20|40|80|160 -> ht
channel <number>|auto -> channel
minrssi off|on|-<NN> -> 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).