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:
+38
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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!"
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Celebright",
|
||||
"content_in_root": false,
|
||||
"render_readme": true,
|
||||
"homeassistant": "2024.6.0"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
websockets>=12.0
|
||||
Reference in New Issue
Block a user