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