- Python 100%
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. |
||
|---|---|---|
| translations | ||
| .gitignore | ||
| __init__.py | ||
| api.py | ||
| config_flow.py | ||
| const.py | ||
| coordinator.py | ||
| cudy_probe.py | ||
| device_tracker.py | ||
| manifest.json | ||
| README.md | ||
| sensor.py | ||
| strings.json | ||
| switch.py | ||
Cudy AP11000 → Home Assistant
Local, cloud-free integration for the Cudy AP11000 (Wi‑Fi 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_trackerper 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 Wi‑Fi 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:
- Restart Home Assistant.
- Settings → Devices & Services → Add Integration → "Cudy AP".
- Enter host (IP), username (
admin), and the web‑UI 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.homewhile connected,not_homewhen 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 Wi‑Fi client (dBm). Disabled by default (there can be many clients); enable the ones you care about.sensor.*_connected_clients— total client count, with a per‑band 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 (reverse‑engineered 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.