# 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:///ws`, JSON frames `{"topic": , "message": }`. 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": }` | `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": "", "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.