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:
Hank Mueller
2026-09-01 15:37:23 +00:00
co-authored by Claude Opus 5
commit cb99d9f941
16 changed files with 1678 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
# Virtual Environment
.venv/
venv/
ENV/
env/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Project specific
*.pcapng
*.pcap
# Claude
.claude/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 fritzlab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+46
View File
@@ -0,0 +1,46 @@
.PHONY: deploy clean help setup
# Configuration — override on the command line or in your environment, e.g.
# make deploy REMOTE_HOST=homeassistant.local
REMOTE_HOST ?= homeassistant.local
REMOTE_PATH ?= /homeassistant/custom_components
REMOTE_USER ?= root
LOCAL_PATH = custom_components/celebright
# Default target
help:
@echo "Celebright Home Assistant Integration - Makefile"
@echo ""
@echo "Available targets:"
@echo " make deploy - Deploy integration to Home Assistant (set REMOTE_HOST)"
@echo " make setup - Ensure remote directory exists with correct permissions"
@echo " make clean - Remove Python cache files"
@echo " make help - Show this help message"
@echo ""
@echo "Override REMOTE_HOST / REMOTE_PATH / REMOTE_USER for your own host."
# Ensure remote directory exists with correct permissions
setup:
@echo "Setting up remote directory on $(REMOTE_HOST)..."
@ssh $(REMOTE_HOST) "sudo mkdir -p $(REMOTE_PATH)/celebright && sudo chown -R $(REMOTE_USER):$(REMOTE_USER) $(REMOTE_PATH)/celebright"
@echo "Setup complete!"
# Deploy integration to Home Assistant
deploy: setup
@echo "Deploying Celebright integration to $(REMOTE_HOST)..."
rsync -avz --delete \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='.DS_Store' \
--exclude='*.swp' \
$(LOCAL_PATH)/ $(REMOTE_HOST):$(REMOTE_PATH)/celebright/
@echo ""
@echo "Deployment complete!"
@echo "Remember to restart Home Assistant to load the changes."
# Clean Python cache files
clean:
@echo "Cleaning Python cache files..."
find $(LOCAL_PATH) -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find $(LOCAL_PATH) -type f -name '*.pyc' -delete 2>/dev/null || true
@echo "Clean complete!"
+217
View File
@@ -0,0 +1,217 @@
# Celebright Controller
Python-based controller for Celebright holiday lights systems.
This implementation is based on reverse-engineering the network protocol from packet capture analysis.
## Project Components
This repository contains two main components:
1. **Home Assistant Integration** (`custom_components/celebright/`) — the maintained, **firmware-v2** custom component. This is the supported implementation.
2. **Standalone Python Controller** (`celebright_controller.py`) — the original **v1** reverse-engineering reference/demo. It does **not** work against firmware 2.x (the `getPresetsAndEventsPaginated`/`loadPreset` topics it uses were removed); kept for historical reference.
> **Full protocol & operations reference: [`docs/PROTOCOL.md`](docs/PROTOCOL.md)** — v2 WebSocket topics, device behaviors, deploy, and access gotchas.
## Features
### Standalone Controller
- **Discovery**: Automatically discovers Celebright controllers on the local network via UDP broadcast
- **WebSocket Communication**: Establishes WebSocket connection for real-time control
- **Scene Management**: Lists and selects available lighting scenes/presets
- **Power Control**: Turn off lights and disable schedules
### Home Assistant Integration
- **Automatic Discovery**: Finds controllers on your network automatically
- **Select Entity**: Drop-down selector for choosing presets
- **Native Integration**: Full Home Assistant device integration
- **Automation Support**: Use in automations, scripts, and scenes
## Requirements
- Python 3.7+
- `websockets` library
## Installation
```bash
pip install -r requirements.txt
```
## Usage
### Basic Usage
Run the main script to execute a complete demo sequence:
```bash
python3 celebright_controller.py
```
This will:
1. Discover the Celebright controller on your network
2. Establish a WebSocket connection
3. List all available scenes
4. Load a couple of example scenes
5. Turn off the system
6. Disconnect
### Using as a Library
```python
import asyncio
from celebright_controller import CelebrightController
async def control_lights():
controller = CelebrightController()
# Discover controller
device_info = controller.discover_controller()
if not device_info:
print("Controller not found")
return
# Connect
await controller.connect()
# Get available presets
presets = await controller.get_presets()
# Load a specific preset
if presets:
await controller.load_preset(presets[0]['preset_uuid'])
# Turn off
await controller.turn_off()
# Disconnect
await controller.disconnect()
asyncio.run(control_lights())
```
### Home Assistant Integration
For detailed Home Assistant integration instructions, see [custom_components/celebright/README.md](custom_components/celebright/README.md).
#### Quick Start
1. Copy the `custom_components/celebright` folder to your Home Assistant's `custom_components` directory
2. Restart Home Assistant
3. Go to Settings > Devices & Services > Add Integration
4. Search for "Celebright"
5. Follow the configuration flow
#### Deployment via Makefile
The project includes a Makefile for easy deployment to Home Assistant:
```bash
make deploy
```
This will:
- Set up the remote directory with correct permissions
- Deploy the integration to your Home Assistant instance
- Remind you to restart Home Assistant
Configure the Makefile variables for your environment:
- `REMOTE_HOST`: Your Home Assistant hostname or IP
- `REMOTE_PATH`: Path to Home Assistant's custom_components directory
- `REMOTE_USER`: SSH user for deployment
## Protocol Details
> The current **firmware-v2** protocol is documented in full in
> [`docs/PROTOCOL.md`](docs/PROTOCOL.md). The sections below describe the
> **original v1** protocol the standalone controller targets, retained for
> historical reference.
Based on packet capture analysis of `celebright-full.pcapng`:
### Discovery Process
- **Client sends**: UDP broadcast to port 49999 (discovery request with empty payload)
- **Client listens**: On port 51234 for responses
- **Controller responds**: From port 49999 to client's port 51234 with JSON payload containing:
- Model name
- Device ID
- Firmware version
- Number of LEDs
- LED configuration
### WebSocket Communication
- **Endpoint**: `ws://<device_ip>:80/ws`
- **Message Format**: JSON with `topic` and `message` fields
```json
{
"topic": "command_name",
"message": {
// command parameters
}
}
```
### Available Commands
| Topic | Description | Parameters |
|-------|-------------|------------|
| `getPresetsAndEventsPaginated` | Get list of available scenes | None |
| `loadPreset` | Load/activate a specific scene | `presetUuid`: UUID of preset |
| `setTurnOffAndDisableSchedule` | Turn off lights and disable schedule | None |
| `getSystemState` | Get current system state | None |
## Example Output
```
============================================================
STEP 1: DISCOVERY
============================================================
[Discovery] Listening for Celebright controller on UDP port 51234...
[Discovery] Found controller at 192.168.1.50
[Discovery] Model: CLC-03
[Discovery] Device ID: 123456789
[Discovery] Firmware: 1.41
[Discovery] Number of LEDs: 296
============================================================
STEP 2: WEBSOCKET CONNECTION
============================================================
[Connect] Connecting to ws://192.168.1.50:80/ws...
[Connect] WebSocket connection established
============================================================
STEP 3: LIST AVAILABLE SCENES
============================================================
[Presets] Requesting preset list...
[Receive] Response topic: presetsPage
[Presets] Found 12 presets:
1. Halloween Twinkle - Orange, Purple and Green twinkle
UUID: 3956e504-aafa-4f1c-8ad7-b00fb2381baa
2. Warm White Solid - Warm White lights
UUID: 6d2bdc6e-b015-4903-b8e5-5f7fc28c156a
...
```
## Network Requirements
- The controller must be on the same local network
- UDP port 49999 must be accessible for sending discovery broadcasts
- UDP port 51234 must be accessible for receiving discovery responses
- TCP port 80 must be accessible for WebSocket communication
## Limitations
- The discovery process requires the ability to send UDP broadcasts on the local network
- Broadcast discovery may not work across network segments or VLANs without proper routing
- If broadcast discovery fails, you may need to specify the controller IP directly (feature could be added)
- The script currently only handles the basic protocol commands observed in the packet capture
## License
MIT — see [LICENSE](LICENSE).
This is an unofficial, reverse-engineered implementation based on local network
traffic analysis of the vendor device. It is not affiliated with or endorsed by
Celebright. Use at your own risk.
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""
Celebright Holiday Lights Controller
Based on packet capture analysis of the Celebright protocol.
"""
import questionary
from questionary import Style, Choice
import socket
import json
import asyncio
import logging
import websockets
from typing import Optional, Dict, List, Self
import netifaces
from dataclasses import dataclass
DISCOVERY_PORT = 49999
DISCOVERY_TIMEOUT = 5.0
DISCOVERY_MESSAGE = b'App Broadcast Message'
WEBSOCKET_ENDPOINT = "/ws"
log = logging.getLogger("CelebrightController")
@dataclass(frozen=True)
class CelebrightPreset:
preset_uuid: str
preset_name: str
preset_desc: str
preset_mode: int
brightness: int
web_id: int
md5: str
controller: "CelebrightController"
async def select(self) -> None:
"""Select/load this preset on the controller."""
await self.controller.load_preset(self)
@dataclass
class CelebrightController:
"""Controller for Celebright holiday lights system."""
address: str
model_name: str
device_id: str
firmware: str
hardware: int
num_leds: int
location_id: int
timezoneId: int
led_strings: list[int]
isRGBW: bool
colorOrder: str
RGBW_type: str
bulbType: str
_ws: Optional[websockets.ClientConnection] = None
@classmethod
def _discover_broadcast_addresses(cls) -> list[str]:
""" Get list of broadcast addresses for all network interfaces. """
broadcast_addrs = list()
for iface in netifaces.interfaces():
addrs = netifaces.ifaddresses(iface)
if netifaces.AF_INET in addrs:
for addr in addrs[netifaces.AF_INET]:
if 'broadcast' in addr:
broadcast_addrs.append(addr['broadcast'])
broadcast_addrs = sorted(set(broadcast_addrs))
log.debug("Discovered System Broadcast addresses: %s", broadcast_addrs)
return broadcast_addrs
@classmethod
def _discover_controller(cls, bcast: str) -> Self | None:
"""
Discover Celebright controller on the network by sending UDP broadcast.
Args:
broadcast_addr: Broadcast address to use (default: 255.255.255.255)
Returns:
A list of dictionary containing device information
"""
log.debug("Discovering Celebright controller on: %s", bcast)
# Create socket for sending broadcast
soc = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
soc.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
soc.bind(('', DISCOVERY_PORT))
# Send broadcast discovery request ("App Broadcast Message" payload)
soc.sendto(DISCOVERY_MESSAGE, (bcast, DISCOVERY_PORT))
log.debug(f"Sent Discovery Broadcast: {bcast}:{DISCOVERY_PORT}")
try:
# Wait for response from controller
while True:
try:
data, addr = soc.recvfrom(4096)
log.debug(f"Received response from {addr}: {data}")
if data:
data = json.loads(data.decode('utf-8'))
if "model_name" in data:
log.info("Discovered Celebright Controller at %s",
addr[0])
return cls(address=addr[0], **data)
except json.JSONDecodeError:
log.debug("Failed to decode JSON from response: %s", data)
except socket.timeout:
log.debug(f"Controller Timeout on %s", bcast)
except Exception as e:
log.warning(f"Unhandled Discovery Error: {e}")
finally:
soc.close()
@classmethod
def _discover_controllers(cls) -> List[Self]:
"""
Discover Celebright controllers on the network by sending UDP broadcast.
Returns:
A list of dictionary containing device information
"""
controllers = list()
for address in cls._discover_broadcast_addresses():
result = cls._discover_controller(address)
if result:
controllers.append(result)
return controllers
@classmethod
def from_discovery(cls) -> list[Self]:
"""
Factory method to create CelebrightController instances by
discovering controllers on the network.
"""
return cls._discover_controllers()
async def connect(self) -> None:
"""
Establish WebSocket connection to the controller.
"""
ws_url = f"ws://{self.address}{WEBSOCKET_ENDPOINT}"
log.debug(f"establishing websocket to {ws_url}...")
self._ws = await websockets.connect(ws_url)
log.info("websocket connection established: %s", ws_url)
async def send_command(self, topic: str, message: Dict = None) -> None:
"""
Send a command to the controller via WebSocket.
Args:
topic: Command topic (e.g., "getPresetsAndEventsPaginated")
message: Command parameters dictionary (default: {})
"""
if not self._ws:
await self.connect()
if message is None:
message = {}
command = {"topic": topic, "message": message}
log.debug(f"sending to '%s': %s", topic, message)
await self._ws.send(json.dumps(command))
async def receive_response(self) -> Optional[Dict]:
"""
Receive a response from the controller.
"""
if not self._ws:
await self.connect()
response = await self._ws.recv()
data = json.loads(response)
log.debug(f"Received Response topic: {data.get('topic')}")
return data
async def get_presets(self) -> list[CelebrightPreset]:
"""
Get list of available scene presets.
Returns:
List of preset dictionaries, or None if error
"""
log.debug("Requesting preset list...")
await self.send_command("getPresetsAndEventsPaginated")
presets = list()
response = await self.receive_response()
if response and response.get('topic') == 'presetsPage':
for preset in response.get('message', {}).get('presets', []):
presets.append(CelebrightPreset(**preset, controller=self))
return presets
async def load_preset(self, preset: CelebrightPreset) -> None:
"""
Load/activate a specific scene preset.
Args:
preset_uuid: UUID of the preset to load
"""
log.info("Loading preset: %s", preset.preset_name)
await self.send_command(
"loadPreset",
{"presetUuid": preset.preset_uuid}
)
log.info("Setting 12-hour sleep timer...")
await self.send_command(
"setSleepTimer",
{"timerMinutes": 720}
)
async def turn_off(self) -> None:
"""Turn off the lights and disable schedule."""
log.info("Turning off lights and disabling schedule...")
await self.send_command("setTurnOffAndDisableSchedule")
async def disconnect(self) -> None:
"""Close the WebSocket connection."""
if self._ws:
log.debug("Closing WebSocket connection...")
await self._ws.close()
self._ws = None
log.debug("WebSocket connection closed.")
async def interact(self):
"""Interactive menu for selecting presets or turning off lights."""
custom_style = Style([('selected', 'fg:ansicyan bold')])
presets = await self.get_presets()
choices = [
Choice(title=preset.preset_name, value=preset)
for preset in presets
]
choices.append(
Choice(title="Turn Off Lights", value="__TURN_OFF__")
)
choices.append(
Choice(title="Exit", value="__EXIT__")
)
while True:
selected = await questionary.select(
"Select a preset or action:",
choices=choices,
style=custom_style,
use_indicator=True,
use_shortcuts=False
).ask_async()
if selected is None:
break
if selected == "__TURN_OFF__":
await self.turn_off()
return
elif selected == "__EXIT__":
return
else:
preset = selected
await preset.select()
async def main():
"""Main function demonstrating the Celebright controller usage."""
controllers = CelebrightController.from_discovery()
controller = controllers[0]
await controller.interact()
if __name__ == "__main__":
asyncio.run(main())
+128
View File
@@ -0,0 +1,128 @@
# Celebright Home Assistant Integration
This is a custom Home Assistant integration for Celebright holiday light controllers.
## Features
- Automatic discovery of Celebright controllers on your local network
- Drop-down selector entity to choose from available presets
- Option to turn off lights
- Configurable update interval for preset polling (default: 600 seconds / 10 minutes)
- Native Home Assistant device integration
## Installation
### Manual Installation
1. Copy the `custom_components/celebright` folder to your Home Assistant's `custom_components` directory
2. Restart Home Assistant
3. Go to Settings > Devices & Services > Add Integration
4. Search for "Celebright"
5. Follow the configuration flow to select your discovered controller
### HACS Installation
This integration is not yet available in HACS. Use manual installation for now.
## Configuration
### Initial Setup
1. Ensure your Celebright controller is powered on and connected to the same network as Home Assistant
2. Add the integration via the UI (Settings > Devices & Services > Add Integration)
3. The integration will automatically discover controllers on your network
4. Select your controller from the list
### Options
You can configure the following options by clicking "Configure" on the integration card:
- **Update Interval**: How often (in seconds) to poll the controller for updated preset lists. Default is 600 seconds (10 minutes).
## Usage
Once configured, the integration creates a Select entity for your controller:
- **Entity Name**: `select.celebright_[model]_preset`
- **Options**: All presets available on your controller, plus a "Turn Off" option
You can use this entity in:
- Automations
- Scripts
- Dashboards (as a dropdown selector)
- Scenes
### Example Automation
```yaml
automation:
- alias: "Set holiday lights at sunset"
trigger:
- platform: sun
event: sunset
action:
- service: select.select_option
target:
entity_id: select.celebright_controller_preset
data:
option: "Rainbow Chase"
```
### Example Dashboard Card
```yaml
type: entities
entities:
- entity: select.celebright_controller_preset
```
## Technical Details
### Architecture
- **Discovery**: Uses UDP broadcast on port 49999 to discover controllers
- **Communication**: WebSocket connection for sending commands
- **Polling**: Periodically fetches available presets (configurable interval)
- **Platform**: Select entity for preset selection
### API
The integration uses the Celebright firmware **v2** WebSocket API:
- `getSystemState`: Current state (active scene, schedule). Always answered, so
this drives availability and the currently-selected scene.
- `getSavedScenesAndEventsPaginated``savedScenesPage`: Fetch the saved-scene
library. The controller only serves this **while idle** — with a scene
actively rendering it returns nothing, so the library is fetched best-effort
and cached (refreshed when the system `md5` changes).
- `loadSavedScene` (`savedSceneUuid`): Activate a specific scene.
- `setTurnOffAndDisableSchedule`: Turn off lights.
> Firmware 2.x renamed the v1 "presets" (`getPresetsAndEventsPaginated`,
> `loadPreset`) to "savedScenes". This integration targets v2.
## Troubleshooting
### Controller Not Found
- Ensure the controller is on the same network as Home Assistant
- Check that UDP broadcast traffic is not blocked by firewalls
- Verify the controller is powered on and responding to the mobile app
### Presets Not Updating
- Check the update interval in the integration options
- Try reloading the integration
- Check Home Assistant logs for errors
### Connection Issues
- The integration will automatically reconnect if the WebSocket connection is lost
- Check that port 80 (WebSocket) is accessible on the controller
## Support
For issues, feature requests, or contributions, please visit the [project repository](https://code.fritzlab.net/homeassistant/celebright).
## License
MIT — see [LICENSE](../../LICENSE).
+105
View File
@@ -0,0 +1,105 @@
"""The Celebright integration."""
from __future__ import annotations
import logging
from datetime import timedelta
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL, DOMAIN
from .controller import CelebrightController
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SELECT]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Celebright from a config entry."""
hass.data.setdefault(DOMAIN, {})
# Check if this entry is already set up
if entry.entry_id in hass.data[DOMAIN]:
_LOGGER.warning("Entry %s is already set up, skipping", entry.entry_id)
return True
host = entry.data[CONF_HOST]
update_interval = entry.options.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL)
# Create controller instance
controller = CelebrightController(
address=host,
model_name=entry.data.get("model_name", "Unknown"),
device_id=entry.data.get("device_id", ""),
firmware=entry.data.get("firmware", ""),
hardware=entry.data.get("hardware", 0),
num_leds=entry.data.get("num_leds", 0),
location_id=entry.data.get("location_id", 0),
timezoneId=entry.data.get("timezoneId", 0),
led_strings=entry.data.get("led_strings", []),
isRGBW=entry.data.get("isRGBW", False),
colorOrder=entry.data.get("colorOrder", ""),
RGBW_type=entry.data.get("RGBW_type", ""),
bulbType=entry.data.get("bulbType", ""),
)
async def async_update_data():
"""Fetch data from the controller.
Availability is driven by ``getSystemState`` (always answered). The
scene library is only served while the controller is idle, so it is
fetched best-effort and cached, refreshed only when the system md5
changes. A failed library fetch never fails the update — we keep the
cached scenes so the entry stays loaded with the lights on.
"""
try:
await controller.connect()
state = await controller.get_system_state()
cache = coordinator.data or {}
scenes = cache.get("scenes") or []
scenes_md5 = cache.get("scenes_md5")
current_md5 = state.get("md5")
if not scenes or current_md5 != scenes_md5:
fetched = await controller.get_saved_scenes()
if fetched is not None:
scenes = fetched
scenes_md5 = current_md5
return {"scenes": scenes, "scenes_md5": scenes_md5, "state": state}
except Exception as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=DOMAIN,
update_method=async_update_data,
update_interval=timedelta(seconds=update_interval),
)
# Fetch initial data
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = {
"controller": controller,
"coordinator": coordinator,
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
controller = hass.data[DOMAIN][entry.entry_id]["controller"]
await controller.disconnect()
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
+119
View File
@@ -0,0 +1,119 @@
"""Config flow for Celebright integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv
from .const import CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL, DOMAIN
from .controller import CelebrightController
_LOGGER = logging.getLogger(__name__)
class CelebrightConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Celebright."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered_devices: list[CelebrightController] = []
self._selected_device: CelebrightController | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step - manual IP configuration."""
errors = {}
if user_input is not None:
host = user_input[CONF_HOST]
_LOGGER.info("Attempting to connect to controller at: %s", host)
try:
# Try to connect to the controller
controller = await CelebrightController.from_host(host)
_LOGGER.info("Successfully connected to controller at %s", host)
await self.async_set_unique_id(controller.device_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"Celebright Controller ({host})",
data={
CONF_HOST: controller.address,
"model_name": controller.model_name,
"device_id": controller.device_id,
"firmware": controller.firmware,
"hardware": controller.hardware,
"num_leds": controller.num_leds,
"location_id": controller.location_id,
"timezoneId": controller.timezoneId,
"led_strings": controller.led_strings,
"isRGBW": controller.isRGBW,
"colorOrder": controller.colorOrder,
"RGBW_type": controller.RGBW_type,
"bulbType": controller.bulbType,
},
)
except Exception as err:
_LOGGER.error("Failed to connect to %s: %s", host, err)
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
}
),
errors=errors,
description_placeholders={
"info": "Enter the IP address of your Celebright controller (e.g., 192.168.1.100)"
},
)
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> CelebrightOptionsFlowHandler:
"""Get the options flow for this handler."""
return CelebrightOptionsFlowHandler(config_entry)
class CelebrightOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for Celebright."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_UPDATE_INTERVAL,
default=self.config_entry.options.get(
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
),
): cv.positive_int,
}
),
)
+20
View File
@@ -0,0 +1,20 @@
"""Constants for the Celebright integration."""
DOMAIN = "celebright"
# Configuration
CONF_UPDATE_INTERVAL = "update_interval"
DEFAULT_UPDATE_INTERVAL = 600 # seconds (10 minutes)
# Discovery
DISCOVERY_PORT = 49999
DISCOVERY_TIMEOUT = 5.0
DISCOVERY_MESSAGE = b'App Broadcast Message'
WEBSOCKET_ENDPOINT = "/ws"
# WebSocket timeouts (seconds). Without these the integration hangs
# indefinitely if the device accepts the upgrade then stops responding,
# blowing past HA's 60s setup deadline and leaving the entry in setup_error.
WS_CONNECT_TIMEOUT = 8.0
WS_RECV_TIMEOUT = 8.0
WS_SEND_TIMEOUT = 5.0
+426
View File
@@ -0,0 +1,426 @@
"""Celebright Controller for Home Assistant."""
from __future__ import annotations
import json
import logging
import socket
from dataclasses import dataclass
from typing import Optional
import asyncio
import websockets
from .const import (
DISCOVERY_MESSAGE,
DISCOVERY_PORT,
WEBSOCKET_ENDPOINT,
WS_CONNECT_TIMEOUT,
WS_RECV_TIMEOUT,
WS_SEND_TIMEOUT,
)
try:
import netifaces
except ImportError:
netifaces = None
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True)
class CelebrightScene:
"""Represents a Celebright saved scene (firmware v2 protocol).
Firmware 2.x renamed the old "presets" to "savedScenes". A scene is keyed
by ``uuid`` and carries a human ``name``; ``displays`` holds the per-zone
look data and is opaque to this integration.
"""
uuid: str
name: str
desc: str = ""
md5: str = ""
displays: tuple = ()
controller: "CelebrightController" = None
@classmethod
def from_dict(cls, data: dict, controller: "CelebrightController") -> "CelebrightScene":
"""Build a scene from a savedScenesPage entry, ignoring unknown keys."""
return cls(
uuid=data["uuid"],
name=data.get("name") or data["uuid"],
desc=data.get("desc", ""),
md5=data.get("md5", ""),
displays=tuple(data.get("displays", []) or ()),
controller=controller,
)
async def select(self) -> None:
"""Select/load this scene on the controller."""
await self.controller.load_scene(self)
@dataclass
class CelebrightController:
"""Controller for Celebright holiday lights system."""
address: str
model_name: str
device_id: str
firmware: str
hardware: int
num_leds: int
location_id: int
timezoneId: int
led_strings: list[int]
isRGBW: bool
colorOrder: str
RGBW_type: str
bulbType: str
_ws: Optional[websockets.ClientConnection] = None
@classmethod
def _discover_broadcast_addresses(cls) -> list[str]:
"""Get list of broadcast addresses for all network interfaces."""
if netifaces is None:
_LOGGER.warning("netifaces not available, using default broadcast address")
return ["255.255.255.255"]
_LOGGER.info("Starting network interface discovery...")
broadcast_addrs = []
interfaces = netifaces.interfaces()
_LOGGER.info("Found %d network interfaces: %s", len(interfaces), interfaces)
for iface in interfaces:
addrs = netifaces.ifaddresses(iface)
_LOGGER.debug("Interface %s addresses: %s", iface, addrs)
if netifaces.AF_INET in addrs:
for addr in addrs[netifaces.AF_INET]:
if "broadcast" in addr:
broadcast_addrs.append(addr["broadcast"])
_LOGGER.info("Found broadcast address %s on interface %s", addr["broadcast"], iface)
broadcast_addrs = sorted(set(broadcast_addrs))
_LOGGER.info("Final broadcast addresses to try: %s", broadcast_addrs)
return broadcast_addrs
@classmethod
def _discover_controller(cls, bcast: str) -> CelebrightController | None:
"""Discover Celebright controller on the network by sending UDP broadcast."""
_LOGGER.info("=== Starting discovery on broadcast address: %s ===", bcast)
try:
# Create socket for sending broadcast
_LOGGER.debug("Creating UDP socket...")
soc = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
soc.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
soc.settimeout(5.0)
_LOGGER.debug("Binding to port %s...", DISCOVERY_PORT)
soc.bind(("", DISCOVERY_PORT))
_LOGGER.debug("Socket bound successfully")
# Send broadcast discovery request
_LOGGER.info("Sending discovery message '%s' to %s:%s",
DISCOVERY_MESSAGE.decode(), bcast, DISCOVERY_PORT)
soc.sendto(DISCOVERY_MESSAGE, (bcast, DISCOVERY_PORT))
_LOGGER.debug("Discovery broadcast sent, waiting for responses...")
# Wait for response from controller
response_count = 0
while True:
try:
data, addr = soc.recvfrom(4096)
response_count += 1
_LOGGER.info("Received response #%d from %s", response_count, addr)
_LOGGER.debug("Response data: %s", data)
if data:
try:
data_dict = json.loads(data.decode("utf-8"))
_LOGGER.debug("Parsed JSON: %s", data_dict)
if "model_name" in data_dict:
_LOGGER.info("✓ Valid Celebright Controller found at %s: %s",
addr[0], data_dict.get("model_name"))
return cls(address=addr[0], **data_dict)
else:
_LOGGER.warning("Response missing 'model_name' field: %s", data_dict)
except json.JSONDecodeError as e:
_LOGGER.warning("Failed to decode JSON from %s: %s", addr, e)
_LOGGER.debug("Raw data: %s", data)
except socket.timeout:
break
except socket.timeout:
_LOGGER.info("Discovery timeout on %s (received %d responses)", bcast, response_count)
except OSError as err:
_LOGGER.error("Socket error on %s: %s", bcast, err)
except Exception as err:
_LOGGER.exception("Unexpected error during discovery on %s: %s", bcast, err)
finally:
try:
soc.close()
_LOGGER.debug("Socket closed")
except:
pass
_LOGGER.info("=== No controller found on %s ===", bcast)
return None
@classmethod
def _discover_controllers(cls) -> list[CelebrightController]:
"""Discover Celebright controllers on the network."""
_LOGGER.info("==================================================")
_LOGGER.info("Starting Celebright controller discovery process")
_LOGGER.info("==================================================")
controllers = []
broadcast_addresses = cls._discover_broadcast_addresses()
if not broadcast_addresses:
_LOGGER.error("No broadcast addresses available for discovery!")
return controllers
_LOGGER.info("Trying discovery on %d broadcast address(es)", len(broadcast_addresses))
for idx, address in enumerate(broadcast_addresses, 1):
_LOGGER.info("Attempt %d/%d: Trying broadcast address %s",
idx, len(broadcast_addresses), address)
result = cls._discover_controller(address)
if result:
controllers.append(result)
_LOGGER.info("Controller added to list. Total found: %d", len(controllers))
_LOGGER.info("==================================================")
_LOGGER.info("Discovery complete. Found %d controller(s)", len(controllers))
_LOGGER.info("==================================================")
return controllers
@classmethod
def from_discovery(cls) -> list[CelebrightController]:
"""Factory method to create CelebrightController instances by discovering controllers."""
return cls._discover_controllers()
@classmethod
async def from_host(cls, host: str) -> CelebrightController:
"""
Manually connect to a controller by IP address.
Args:
host: IP address of the controller
Returns:
CelebrightController instance
Raises:
Exception if connection fails
"""
_LOGGER.info("Attempting manual connection to %s", host)
# Try to connect via WebSocket to verify the host
ws_url = f"ws://{host}{WEBSOCKET_ENDPOINT}"
try:
_LOGGER.debug("Testing WebSocket connection to %s", ws_url)
ws = await asyncio.wait_for(
websockets.connect(ws_url), timeout=WS_CONNECT_TIMEOUT
)
# Probe with getSystemState: a firmware-v2 topic the controller
# always answers (on or off), unlike the scene library which it
# only serves while idle. Confirms we are talking to a Celebright.
command = {"topic": "getSystemState", "message": {}}
await ws.send(json.dumps(command))
# Wait for the matching systemState reply
for _ in range(5):
response = await asyncio.wait_for(ws.recv(), timeout=WS_RECV_TIMEOUT)
data = json.loads(response)
if data.get("topic") == "systemState":
break
_LOGGER.debug("Received response from controller: %s", data)
await ws.close()
# Create controller with minimal info (we don't have full device info from manual connection)
_LOGGER.info("Successfully connected to controller at %s", host)
return cls(
address=host,
model_name="Celebright Controller",
device_id=host.replace(".", "_"),
firmware="unknown",
hardware=0,
num_leds=0,
location_id=0,
timezoneId=0,
led_strings=[],
isRGBW=False,
colorOrder="",
RGBW_type="",
bulbType="",
)
except asyncio.TimeoutError:
_LOGGER.error("Timeout connecting to %s", host)
raise Exception(f"Timeout connecting to {host}")
except Exception as err:
_LOGGER.error("Failed to connect to %s: %s", host, err)
raise Exception(f"Failed to connect to {host}: {err}") from err
async def connect(self) -> None:
"""Establish WebSocket connection to the controller."""
# Check if connection is still open
if self._ws is not None:
try:
# Try to ping to see if connection is alive
pong = await self._ws.ping()
await asyncio.wait_for(pong, timeout=1.0)
_LOGGER.debug("WebSocket connection is still alive")
return
except Exception:
# Connection is dead, close it
try:
await self._ws.close()
except Exception:
pass
self._ws = None
ws_url = f"ws://{self.address}{WEBSOCKET_ENDPOINT}"
_LOGGER.debug("Establishing websocket to %s...", ws_url)
try:
self._ws = await asyncio.wait_for(
websockets.connect(ws_url), timeout=WS_CONNECT_TIMEOUT
)
except asyncio.TimeoutError as err:
self._ws = None
raise TimeoutError(
f"Timed out after {WS_CONNECT_TIMEOUT}s connecting to {ws_url}"
) from err
_LOGGER.info("Websocket connection established: %s", ws_url)
async def send_command(self, topic: str, message: dict | None = None) -> None:
"""Send a command to the controller via WebSocket."""
if not self._ws:
await self.connect()
if message is None:
message = {}
command = {"topic": topic, "message": message}
_LOGGER.debug("Sending to '%s': %s", topic, message)
try:
await asyncio.wait_for(
self._ws.send(json.dumps(command)), timeout=WS_SEND_TIMEOUT
)
except (websockets.exceptions.ConnectionClosedError,
websockets.exceptions.ConnectionClosedOK) as err:
_LOGGER.warning("WebSocket connection closed, reconnecting: %s", err)
self._ws = None
await self.connect()
await asyncio.wait_for(
self._ws.send(json.dumps(command)), timeout=WS_SEND_TIMEOUT
)
async def receive_response(self) -> dict | None:
"""Receive a response from the controller."""
if not self._ws:
await self.connect()
try:
_LOGGER.debug("Waiting for response from WebSocket...")
response = await asyncio.wait_for(self._ws.recv(), timeout=WS_RECV_TIMEOUT)
_LOGGER.debug("Received raw response: %s", response[:200] if len(response) > 200 else response)
data = json.loads(response)
_LOGGER.debug("Parsed response topic: %s", data.get("topic"))
return data
except (websockets.exceptions.ConnectionClosedError,
websockets.exceptions.ConnectionClosedOK) as err:
_LOGGER.warning("WebSocket connection closed while receiving: %s", err)
self._ws = None
await self.connect()
_LOGGER.debug("Retrying receive after reconnection...")
response = await asyncio.wait_for(self._ws.recv(), timeout=WS_RECV_TIMEOUT)
_LOGGER.debug("Received raw response (retry): %s", response[:200] if len(response) > 200 else response)
data = json.loads(response)
_LOGGER.debug("Parsed response topic (retry): %s", data.get("topic"))
return data
async def _read_until(self, topic: str, max_attempts: int = 10) -> dict | None:
"""Read responses until one matches ``topic``.
Returns None if the controller stops sending (recv times out) before
the expected reply arrives. Firmware v2 emits ``logDeviceError`` for
unknown topics and silence while it is busy rendering a scene, so
callers must tolerate a None here rather than hang.
"""
for attempt in range(max_attempts):
try:
response = await self.receive_response()
except (asyncio.TimeoutError, TimeoutError):
_LOGGER.debug("Timed out waiting for '%s' (attempt %d)", topic, attempt + 1)
return None
if not response:
return None
got = response.get("topic")
if got == topic:
return response
if got == "logDeviceError":
_LOGGER.warning("Controller rejected request awaiting '%s': %s",
topic, response.get("message"))
_LOGGER.debug("Got '%s', still waiting for '%s'", got, topic)
return None
async def get_system_state(self) -> dict:
"""Fetch current system state.
``getSystemState`` is answered whether the lights are on or off, so it
is the reliable signal for availability and the active scene.
"""
await self.send_command("getSystemState")
response = await self._read_until("systemState")
return response.get("message", {}) if response else {}
async def get_saved_scenes(self) -> list[CelebrightScene] | None:
"""Fetch the saved-scene library, or None if it is unavailable now.
The controller only serves the library (``savedScenesPage``) while it
is idle; with a scene actively rendering it returns nothing. Returning
None lets the coordinator keep its cached list instead of failing.
"""
_LOGGER.info("=== REFRESHING SCENES from %s ===", self.address)
await self.send_command("getSavedScenesAndEventsPaginated")
response = await self._read_until("savedScenesPage")
if not response:
_LOGGER.info("Scene library not served right now (controller busy/off-list)")
return None
raw = response.get("message", {}).get("savedScenes", [])
scenes: list[CelebrightScene] = []
for entry in raw:
if not entry.get("uuid"):
continue
scenes.append(CelebrightScene.from_dict(entry, self))
_LOGGER.info("=== SCENE REFRESH COMPLETE: %d scenes ===", len(scenes))
return scenes
async def load_scene(self, scene: CelebrightScene) -> None:
"""Load/activate a specific saved scene."""
_LOGGER.info("Loading scene: %s (%s)", scene.name, scene.uuid)
await self.send_command("loadSavedScene", {"savedSceneUuid": scene.uuid})
async def turn_off(self) -> None:
"""Turn off the lights and disable schedule."""
_LOGGER.info("Turning off lights and disabling schedule...")
await self.send_command("setTurnOffAndDisableSchedule")
async def disconnect(self) -> None:
"""Close the WebSocket connection."""
if self._ws:
_LOGGER.debug("Closing WebSocket connection...")
await self._ws.close()
self._ws = None
_LOGGER.debug("WebSocket connection closed.")
@@ -0,0 +1,12 @@
{
"domain": "celebright",
"name": "Celebright",
"codeowners": ["@fritzlab"],
"config_flow": true,
"documentation": "https://code.fritzlab.net/homeassistant/celebright",
"issue_tracker": "https://code.fritzlab.net/homeassistant/celebright/issues",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["websockets>=12.0", "netifaces>=0.11.0"],
"version": "2.0.0"
}
+118
View File
@@ -0,0 +1,118 @@
"""Select platform for Celebright integration."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .controller import CelebrightController
_LOGGER = logging.getLogger(__name__)
# Special option for turning off lights
OPTION_OFF = "Turn Off"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Celebright select platform."""
controller = hass.data[DOMAIN][config_entry.entry_id]["controller"]
coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"]
async_add_entities([CelebrightPresetSelect(coordinator, controller, config_entry)])
class CelebrightPresetSelect(CoordinatorEntity, SelectEntity):
"""Representation of a Celebright preset selector."""
def __init__(
self,
coordinator,
controller: CelebrightController,
config_entry: ConfigEntry,
) -> None:
"""Initialize the select entity."""
super().__init__(coordinator)
self._controller = controller
self._config_entry = config_entry
self._attr_name = f"{controller.model_name} Preset"
self._attr_unique_id = f"{controller.device_id}_preset_select"
self._attr_icon = "mdi:lightbulb-group"
@property
def _scenes(self) -> list:
"""Return the cached scene list from coordinator data."""
data = self.coordinator.data or {}
return data.get("scenes") or []
@property
def _state(self) -> dict:
"""Return the latest system state from coordinator data."""
data = self.coordinator.data or {}
return data.get("state") or {}
@property
def device_info(self):
"""Return device information about this Celebright controller."""
return {
"identifiers": {(DOMAIN, self._controller.device_id)},
"name": f"Celebright {self._controller.model_name}",
"manufacturer": "Celebright",
"model": self._controller.model_name,
"sw_version": self._controller.firmware,
}
@property
def options(self) -> list[str]:
"""Return the available scene names plus the off option."""
return [scene.name for scene in self._scenes] + [OPTION_OFF]
@property
def current_option(self) -> str | None:
"""Return the active scene, derived from live system state."""
state = self._state
if not state.get("userDisplay") or not state.get("activeSavedScene"):
return OPTION_OFF
active_uuid = state.get("activeSavedScene")
for scene in self._scenes:
if scene.uuid == active_uuid:
return scene.name
# A scene is active but not in our (possibly stale) cache.
return None
async def async_select_option(self, option: str) -> None:
"""Change the selected scene."""
_LOGGER.debug("Selecting option: %s", option)
try:
if option == OPTION_OFF:
await self._controller.turn_off()
else:
scene = next((s for s in self._scenes if s.name == option), None)
if scene is None:
_LOGGER.error("Scene not found: %s", option)
return
await self._controller.load_scene(scene)
# Reflect the new device state (active scene) in current_option.
await self.coordinator.async_request_refresh()
except Exception as err:
_LOGGER.exception("Error selecting scene: %s", err)
raise
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
# Refresh scenes when entity is added
await self.coordinator.async_request_refresh()
+31
View File
@@ -0,0 +1,31 @@
{
"config": {
"step": {
"user": {
"title": "Configure Celebright Controller",
"description": "Enter the IP address of your Celebright controller (e.g., 192.168.1.100)",
"data": {
"host": "IP Address"
}
}
},
"error": {
"cannot_connect": "Failed to connect to the controller. Please verify the IP address and ensure the controller is powered on and accessible.",
"no_devices_found": "No Celebright controllers found on the network. Please ensure your controller is powered on and connected to the same network."
},
"abort": {
"already_configured": "This controller is already configured"
}
},
"options": {
"step": {
"init": {
"title": "Celebright Options",
"description": "Configure update interval for preset polling",
"data": {
"update_interval": "Update interval (seconds)"
}
}
}
}
}
+114
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Celebright",
"content_in_root": false,
"render_readme": true,
"homeassistant": "2024.6.0"
}
+1
View File
@@ -0,0 +1 @@
websockets>=12.0