Add Celebright holiday-lights integration
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
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user