115 lines
5.3 KiB
Markdown
115 lines
5.3 KiB
Markdown
# Celebright protocol & operations reference
|
|||
|
|
|
||
|
|
Canonical technical reference for the Celebright controller, reverse-engineered
|
||
|
|
from device WebSocket captures and live probing. The maintained implementation
|
||
|
|
is the Home Assistant integration in [`custom_components/celebright/`](../custom_components/celebright).
|
||
|
|
|
||
|
|
## Device
|
||
|
|
|
||
|
|
| Field | Value (reference unit) |
|
||
|
|
|---|---|
|
||
|
|
| Model | `CLC-03` |
|
||
|
|
| Hardware | `hwVer 4` |
|
||
|
|
| Firmware | `fwVer 2.04` (protocol **v2**) |
|
||
|
|
| LAN address | device DHCP/static IP on your LAN (HTTP/WS on port 80) |
|
||
|
|
| HTTP/WS port | `80` (config portal at `/`, WebSocket at `/ws`) |
|
||
|
|
| Discovery | UDP broadcast port `49999`, payload `App Broadcast Message` |
|
||
|
|
|
||
|
|
## Firmware versions
|
||
|
|
|
||
|
|
Firmware **2.x renamed the v1 "presets" concept to "savedScenes"** and bumped
|
||
|
|
the wire protocol. v2 replies are tagged `"v":2`. The v1 topics
|
||
|
|
(`getPresetsAndEventsPaginated`, `loadPreset`, response `presetsPage`) are gone;
|
||
|
|
sending them now returns `logDeviceError "Unrecognized topic [X]"`. The
|
||
|
|
standalone `celebright_controller.py` is the original v1 reverse-engineering
|
||
|
|
reference and does **not** work against 2.x — the integration is v2.
|
||
|
|
|
||
|
|
## WebSocket protocol (v2)
|
||
|
|
|
||
|
|
`ws://<device>/ws`, JSON frames `{"topic": <str>, "message": <obj>}`. Negotiates
|
||
|
|
`permessage-deflate`. Client→server frames are masked (standard WS); the device's
|
||
|
|
own frames are unmasked and tagged `"v":2`.
|
||
|
|
|
||
|
|
### Topics
|
||
|
|
|
||
|
|
| Request topic | `message` | Response topic | Notes |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `getSystemState` | `{}` | `systemState` | **Always answered** (lights on or off). |
|
||
|
|
| `getSavedScenesAndEventsPaginated` | `{}` | `savedScenesPage` | **Only served while idle** — see gating below. |
|
||
|
|
| `getZones` | `{"v":2}` | `systemZones` | Light zones / per-light map. |
|
||
|
|
| `getInfo` | `{}` | `getInfoResponse` | Device info (model, fw, IP, RSSI, storage). |
|
||
|
|
| `loadSavedScene` | `{"savedSceneUuid": <uuid>}` | `systemState` | Activate a scene. |
|
||
|
|
| `setTurnOffAndDisableSchedule` | `{}` | `systemState` | Turn off + disable schedule. |
|
||
|
|
| unknown | — | `logDeviceError` | `"Unrecognized topic [X] No action taken"`. |
|
||
|
|
|
||
|
|
### `systemState` message
|
||
|
|
|
||
|
|
```json
|
||
|
|
{"userDisplay": 1, "scheduleEnabled": 0, "sleepTimer": 223,
|
||
|
|
"activeSavedScene": "<uuid>", "currentScene": [ ... ],
|
||
|
|
"md5": "8400de4ae50038cee347364b840e6328"}
|
||
|
|
```
|
||
|
|
|
||
|
|
- `userDisplay` 0 = off, 1 = a scene is showing.
|
||
|
|
- `activeSavedScene` = uuid of the showing scene (absent when off).
|
||
|
|
- `md5` is a **library-level** hash — constant across on/off, changes when the
|
||
|
|
saved-scene set changes. Use it to invalidate a cached scene list.
|
||
|
|
- `loadSavedScene` sets a default `sleepTimer` (~minutes) itself; no separate
|
||
|
|
sleep-timer call is needed.
|
||
|
|
|
||
|
|
### `savedScenesPage` message
|
||
|
|
|
||
|
|
```json
|
||
|
|
{"savedScenes": [
|
||
|
|
{"uuid": "...", "name": "Starry Night", "desc": "...", "md5": "...",
|
||
|
|
"displays": [ {"uuid": "...", "zones": ["..."], "lookType": 2,
|
||
|
|
"lookData": { ... }} ]}
|
||
|
|
], "offset": 0, "limit": 10, "total": 7}
|
||
|
|
```
|
||
|
|
|
||
|
|
`displays`/`lookData` (patterns, palettes) are opaque to the integration — it
|
||
|
|
only needs `uuid` + `name`.
|
||
|
|
|
||
|
|
## Device behaviors that shape the integration
|
||
|
|
|
||
|
|
- **Scene library is gated on idle.** `getSavedScenesAndEventsPaginated` returns
|
||
|
|
`savedScenesPage` immediately when the lights are **off**, but returns
|
||
|
|
**nothing** (silent, not an error) while a scene is actively rendering. A
|
||
|
|
local-only client therefore cannot fetch the library on demand while lights
|
||
|
|
are on. The vendor app sidesteps this by reading the library from the
|
||
|
|
encrypted cloud relay.
|
||
|
|
- **One WebSocket client at a time.** Overlapping connections (e.g. a leftover
|
||
|
|
test client) make a fresh connection's reads return nothing — close the old
|
||
|
|
one and let the slot free before reconnecting.
|
||
|
|
- **Unknown topics don't close the socket** — they emit `logDeviceError`, so a
|
||
|
|
read loop waiting for a specific reply must give up on a timeout, not hang.
|
||
|
|
|
||
|
|
### How the integration copes
|
||
|
|
|
||
|
|
- Availability + current scene are driven by `getSystemState` (always answered),
|
||
|
|
so the entry stays `loaded` even with lights on.
|
||
|
|
- The scene library is fetched **best-effort and cached**, re-fetched only when
|
||
|
|
the `systemState` `md5` changes. A failed fetch keeps the cached list instead
|
||
|
|
of failing the coordinator update.
|
||
|
|
- All WS connect/recv/send calls are wrapped in `asyncio.wait_for`
|
||
|
|
(`WS_CONNECT_TIMEOUT`/`WS_RECV_TIMEOUT`/`WS_SEND_TIMEOUT` in `const.py`) so a
|
||
|
|
silent device cannot blow past Home Assistant's 60s setup deadline.
|
||
|
|
|
||
|
|
## Deploy
|
||
|
|
|
||
|
|
Copy `custom_components/celebright/` into your Home Assistant
|
||
|
|
`config/custom_components/` (or use HACS as a custom repository), then restart
|
||
|
|
Home Assistant. The on-disk copy is not git-managed — after updating the files
|
||
|
|
call `homeassistant.restart`; a config-entry reload does **not** re-import changed
|
||
|
|
Python. Verify the entry reaches `loaded` and the preset `select` entity lists the
|
||
|
|
scenes. A `make deploy` target is provided for rsync-over-SSH deployment; set
|
||
|
|
`REMOTE_HOST` to your Home Assistant host.
|
||
|
|
|
||
|
|
## Re-deriving the protocol from a capture
|
||
|
|
|
||
|
|
A `.pcapng` of the vendor app talking to the device (port 80) yields the wire
|
||
|
|
protocol. The cloud relay is encrypted and not capturable, so only the local
|
||
|
|
device exchange is visible. Parse the capture per TCP connection; **client→server
|
||
|
|
frames are WS-masked** (XOR the 4-byte key) to read the request topics, while the
|
||
|
|
device's responses are plaintext.
|