Home Assistant custom integration for Celebright (CLC-03) holiday-lights controllers, plus a standalone reference client. Local-only, reverse-engineered from the device's WebSocket protocol — talks directly to the controller on the LAN with no vendor cloud. - select entity exposes the device's saved scenes plus an off option - firmware-v2 protocol (savedScenes); getSystemState drives availability and the active scene, scene library fetched best-effort and cached - WS connect/recv/send wrapped in asyncio timeouts so a silent device can't blow past HA's setup deadline - config flow prompts for the controller IP; no credentials involved - docs/PROTOCOL.md: full v2 WebSocket protocol and device behaviors Migrated from the private dfritz/celebright with history dropped and internal site references removed. MIT-licensed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6B8b3iYNv6QUftK2FDfYb
5.3 KiB
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/.
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
{"userDisplay": 1, "scheduleEnabled": 0, "sleepTimer": 223,
"activeSavedScene": "<uuid>", "currentScene": [ ... ],
"md5": "8400de4ae50038cee347364b840e6328"}
userDisplay0 = off, 1 = a scene is showing.activeSavedScene= uuid of the showing scene (absent when off).md5is a library-level hash — constant across on/off, changes when the saved-scene set changes. Use it to invalidate a cached scene list.loadSavedScenesets a defaultsleepTimer(~minutes) itself; no separate sleep-timer call is needed.
savedScenesPage message
{"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.
getSavedScenesAndEventsPaginatedreturnssavedScenesPageimmediately 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 staysloadedeven with lights on. - The scene library is fetched best-effort and cached, re-fetched only when
the
systemStatemd5changes. 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_TIMEOUTinconst.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.