No description
Find a file
Dominik Westner d8fffb0b60 Fix radio switch: use the real toggle endpoint, not rpc/app
wifi.set_disabled's parameter contract turned out to be unsafe to reverse
engineer by probing (rejects "radioN" strings; the one accepted input format
executed for real and caused a full wifi reload across every band). Instead,
captured the real web UI's own "Enable" toggle via the network tab: it POSTs
to wireless/config/action_ssid_disable_toggle?iface=<ssid-iface> with a CSRF
token, flipping the primary SSID's own disabled flag rather than the radio
device's. Verified end-to-end live (off, confirmed off, on, confirmed on)
through the actual async code path, with a concurrency fix (the old read
call re-acquired an already-held lock) and redaction of the WPA key that
conf.uci_get_all responses include, so it never lands in debug logs.
2026-07-10 00:24:29 +02:00
translations Flatten repo to install as custom_components/cudy_ap directly 2026-07-09 23:54:47 +02:00
.gitignore Add Cudy AP11000 Home Assistant integration 2026-07-09 23:36:20 +02:00
__init__.py Flatten repo to install as custom_components/cudy_ap directly 2026-07-09 23:54:47 +02:00
api.py Fix radio switch: use the real toggle endpoint, not rpc/app 2026-07-10 00:24:29 +02:00
config_flow.py Flatten repo to install as custom_components/cudy_ap directly 2026-07-09 23:54:47 +02:00
const.py Fix radio switch: use the real toggle endpoint, not rpc/app 2026-07-10 00:24:29 +02:00
coordinator.py Flatten repo to install as custom_components/cudy_ap directly 2026-07-09 23:54:47 +02:00
cudy_probe.py Fix radio switch: use the real toggle endpoint, not rpc/app 2026-07-10 00:24:29 +02:00
device_tracker.py Flatten repo to install as custom_components/cudy_ap directly 2026-07-09 23:54:47 +02:00
manifest.json Flatten repo to install as custom_components/cudy_ap directly 2026-07-09 23:54:47 +02:00
README.md Fix radio switch: use the real toggle endpoint, not rpc/app 2026-07-10 00:24:29 +02:00
sensor.py Flatten repo to install as custom_components/cudy_ap directly 2026-07-09 23:54:47 +02:00
strings.json Flatten repo to install as custom_components/cudy_ap directly 2026-07-09 23:54:47 +02:00
switch.py Flatten repo to install as custom_components/cudy_ap directly 2026-07-09 23:54:47 +02:00

Cudy AP11000 → Home Assistant

Local, cloud-free integration for the Cudy AP11000 (WiFi 7 access point). It logs into the AP's LuCI web interface and talks to the same JSON-RPC channel the mobile app uses, so Home Assistant gets:

  • a device_tracker per client, mesh-wide (see "How it works" below),
  • optional per-client RSSI sensors,
  • a switch per radio band (2.4 / 5 / 6 GHz) to turn WiFi off, e.g. for a night schedule.

No ubus, no MQTT certificates, no cloud, no root/UART required — everything here has been verified live against a real AP11000 (firmware 2.5.13).

Contents

This repo's root is the cudy_ap custom component (manifest.json, __init__.py, etc. live at the top level, not nested under custom_components/) — that's deliberate, see Install below. cudy_probe.py is a standalone, dependency-free test/diagnostic script, useful to sanity-check connectivity/credentials outside of Home Assistant.

Install

Clone straight into custom_components/, naming the target dir cudy_ap:

cd <home-assistant-config>/custom_components
git clone https://forgejo.nikwest.de/nikwest/cudy-ha.git cudy_ap

Then:

  1. Restart Home Assistant.
  2. Settings → Devices & Services → Add Integration → "Cudy AP".
  3. Enter host (IP), username (admin), and the webUI password.

To update later: no manual copy/delete needed — just

cd <home-assistant-config>/custom_components/cudy_ap
git pull

and restart Home Assistant.

One config entry is likely enough for all three APs — see "mesh-wide" below. If you do want per-AP granularity (e.g. to know exactly which physical unit a client is on), add all three; each is its own entry.

(HACS isn't an option here — it only supports repositories hosted on GitHub.)

Entities

  • device_tracker.* — one per client MAC ever seen. home while connected, not_home when it drops off. Attributes: band, ssid, rssi, online_seconds, apid/apname (which mesh AP it's actually on), internet allowed, vpn.
  • sensor.* RSSI — signal strength per WiFi client (dBm). Disabled by default (there can be many clients); enable the ones you care about.
  • sensor.*_connected_clients — total client count, with a perband breakdown attribute.
  • switch.*_radio — one per band (2.4/5/6 GHz). Turning it off disables that band's primary SSID (which, with one SSID per band, takes the whole band down — every client on it disconnects) — a real, disruptive action, so it's only ever triggered by you or an automation, never automatically. Verified end-to-end live: toggled off, confirmed off, toggled back on, confirmed on, through the actual integration code path.

Options

Poll interval is configurable (default 60 s, min 30 s) via the integration's Configure dialog.

How it works

Login (reverseengineered from sysauth.js):

GET  /cgi-bin/luci/                 → scrape hidden _csrf + salt
POST /cgi-bin/luci/admin/get_token  → fresh token
luci_password = sha256( sha256(password + salt) + token )
POST /cgi-bin/luci/  (form)         → sets `sysauth` session cookie

Note: this AP serves the unauthenticated login page with HTTP 403, not 200 — that's normal for this firmware, not a lockout. Browsers/curl just show the HTML body and nobody notices the status code; the code here never treats a status code alone as fatal.

Client list and radio control both go through the mobile app's JSON-RPC channel, /cgi-bin/luci/rpc/app (backed by apprpc/*.lua in the firmware). The app additionally wraps its payloads in AES, but that's optional — plain JSON works fine over the authenticated session cookie:

POST /cgi-bin/luci/rpc/app
  {"method": "devices.get_devlist", "params": {}}
  → {"result": [ {macaddr, ipaddr, hostname, ssid, iface, apid, apname,
                   rssi, online, internet, vpn, channel, bw, mode, ...}, ... ]}

rssi is reported as a positive magnitude (e.g. 53, not -53) — the integration normalizes it to negative dBm.

Radio (band) on/off is not an rpc/app call. The obvious candidate, apprpc/wifi.lua's set_disabled(radio, disabled), couldn't be pinned down safely by probing: it rejects "radio0"-style UCI section names (which conf.uci_get_all happily accepts for reads), and the one non-rejected input tried live executed for real and triggered a full wifi reload across every band. Rather than keep guessing against a live network, the real web UI's own "Enable" toggle was clicked once (and immediately reverted) while watching the network tab:

Read:  POST rpc/app {"method": "conf.uci_get_all", "params": ["wireless", "wlan00"]}
       → {"result": {"disabled": "1", ...}}   (key ABSENT, not "0", when on)
Write: POST /cgi-bin/luci/admin/network/wireless/config/action_ssid_disable_toggle?iface=wlan00
       body: token=<from the config page's hidden field>&timeclock=&cbi.submit=1
       (sysauth cookie auth, not rpc/app/JSON — and it's a **toggle**, not a
       set-to-value call, so current state is read first)

This flips disabled on the wifi-iface (SSID) section — wlan00/ wlan10/wlan20 for the primary SSID of radio0/1/2 — not the wifi-device section. With one SSID per band that has the same effect as disabling the whole radio; verified live end-to-end (toggled off, confirmed off via read-back, toggled back on, confirmed on).

conf.uci_get_all takes positional params (a JSON array), not a named object.

Mesh-wide client list

apid/apname identify which physical mesh AP a client is actually attached to ("000000000000" = the AP you're talking to). One AP's devlist already includes clients connected to the other two mesh units too — verified live (a client whose apid pointed at a different AP's MAC still showed up). Interface → band mapping: wlan0* = 2.4 GHz, wlan1* = 5 GHz, wlan2* = 6 GHz, eth* = wired.