Add Celebright holiday-lights integration

Home Assistant custom integration for Celebright (CLC-03) holiday-lights
controllers, plus a standalone reference client. Local-only, reverse-engineered
from the device's WebSocket protocol — talks directly to the controller on the
LAN with no vendor cloud.

- select entity exposes the device's saved scenes plus an off option
- firmware-v2 protocol (savedScenes); getSystemState drives availability and
  the active scene, scene library fetched best-effort and cached
- WS connect/recv/send wrapped in asyncio timeouts so a silent device can't
  blow past HA's setup deadline
- config flow prompts for the controller IP; no credentials involved
- docs/PROTOCOL.md: full v2 WebSocket protocol and device behaviors

Migrated from the private dfritz/celebright with history dropped and internal
site references removed. MIT-licensed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6B8b3iYNv6QUftK2FDfYb
This commit is contained in:
Hank Mueller
2026-09-01 15:37:23 +00:00
co-authored by Claude Opus 5
commit cb99d9f941
16 changed files with 1678 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""
Celebright Holiday Lights Controller
Based on packet capture analysis of the Celebright protocol.
"""
import questionary
from questionary import Style, Choice
import socket
import json
import asyncio
import logging
import websockets
from typing import Optional, Dict, List, Self
import netifaces
from dataclasses import dataclass
DISCOVERY_PORT = 49999
DISCOVERY_TIMEOUT = 5.0
DISCOVERY_MESSAGE = b'App Broadcast Message'
WEBSOCKET_ENDPOINT = "/ws"
log = logging.getLogger("CelebrightController")
@dataclass(frozen=True)
class CelebrightPreset:
preset_uuid: str
preset_name: str
preset_desc: str
preset_mode: int
brightness: int
web_id: int
md5: str
controller: "CelebrightController"
async def select(self) -> None:
"""Select/load this preset on the controller."""
await self.controller.load_preset(self)
@dataclass
class CelebrightController:
"""Controller for Celebright holiday lights system."""
address: str
model_name: str
device_id: str
firmware: str
hardware: int
num_leds: int
location_id: int
timezoneId: int
led_strings: list[int]
isRGBW: bool
colorOrder: str
RGBW_type: str
bulbType: str
_ws: Optional[websockets.ClientConnection] = None
@classmethod
def _discover_broadcast_addresses(cls) -> list[str]:
""" Get list of broadcast addresses for all network interfaces. """
broadcast_addrs = list()
for iface in netifaces.interfaces():
addrs = netifaces.ifaddresses(iface)
if netifaces.AF_INET in addrs:
for addr in addrs[netifaces.AF_INET]:
if 'broadcast' in addr:
broadcast_addrs.append(addr['broadcast'])
broadcast_addrs = sorted(set(broadcast_addrs))
log.debug("Discovered System Broadcast addresses: %s", broadcast_addrs)
return broadcast_addrs
@classmethod
def _discover_controller(cls, bcast: str) -> Self | None:
"""
Discover Celebright controller on the network by sending UDP broadcast.
Args:
broadcast_addr: Broadcast address to use (default: 255.255.255.255)
Returns:
A list of dictionary containing device information
"""
log.debug("Discovering Celebright controller on: %s", bcast)
# Create socket for sending broadcast
soc = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
soc.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
soc.bind(('', DISCOVERY_PORT))
# Send broadcast discovery request ("App Broadcast Message" payload)
soc.sendto(DISCOVERY_MESSAGE, (bcast, DISCOVERY_PORT))
log.debug(f"Sent Discovery Broadcast: {bcast}:{DISCOVERY_PORT}")
try:
# Wait for response from controller
while True:
try:
data, addr = soc.recvfrom(4096)
log.debug(f"Received response from {addr}: {data}")
if data:
data = json.loads(data.decode('utf-8'))
if "model_name" in data:
log.info("Discovered Celebright Controller at %s",
addr[0])
return cls(address=addr[0], **data)
except json.JSONDecodeError:
log.debug("Failed to decode JSON from response: %s", data)
except socket.timeout:
log.debug(f"Controller Timeout on %s", bcast)
except Exception as e:
log.warning(f"Unhandled Discovery Error: {e}")
finally:
soc.close()
@classmethod
def _discover_controllers(cls) -> List[Self]:
"""
Discover Celebright controllers on the network by sending UDP broadcast.
Returns:
A list of dictionary containing device information
"""
controllers = list()
for address in cls._discover_broadcast_addresses():
result = cls._discover_controller(address)
if result:
controllers.append(result)
return controllers
@classmethod
def from_discovery(cls) -> list[Self]:
"""
Factory method to create CelebrightController instances by
discovering controllers on the network.
"""
return cls._discover_controllers()
async def connect(self) -> None:
"""
Establish WebSocket connection to the controller.
"""
ws_url = f"ws://{self.address}{WEBSOCKET_ENDPOINT}"
log.debug(f"establishing websocket to {ws_url}...")
self._ws = await websockets.connect(ws_url)
log.info("websocket connection established: %s", ws_url)
async def send_command(self, topic: str, message: Dict = None) -> None:
"""
Send a command to the controller via WebSocket.
Args:
topic: Command topic (e.g., "getPresetsAndEventsPaginated")
message: Command parameters dictionary (default: {})
"""
if not self._ws:
await self.connect()
if message is None:
message = {}
command = {"topic": topic, "message": message}
log.debug(f"sending to '%s': %s", topic, message)
await self._ws.send(json.dumps(command))
async def receive_response(self) -> Optional[Dict]:
"""
Receive a response from the controller.
"""
if not self._ws:
await self.connect()
response = await self._ws.recv()
data = json.loads(response)
log.debug(f"Received Response topic: {data.get('topic')}")
return data
async def get_presets(self) -> list[CelebrightPreset]:
"""
Get list of available scene presets.
Returns:
List of preset dictionaries, or None if error
"""
log.debug("Requesting preset list...")
await self.send_command("getPresetsAndEventsPaginated")
presets = list()
response = await self.receive_response()
if response and response.get('topic') == 'presetsPage':
for preset in response.get('message', {}).get('presets', []):
presets.append(CelebrightPreset(**preset, controller=self))
return presets
async def load_preset(self, preset: CelebrightPreset) -> None:
"""
Load/activate a specific scene preset.
Args:
preset_uuid: UUID of the preset to load
"""
log.info("Loading preset: %s", preset.preset_name)
await self.send_command(
"loadPreset",
{"presetUuid": preset.preset_uuid}
)
log.info("Setting 12-hour sleep timer...")
await self.send_command(
"setSleepTimer",
{"timerMinutes": 720}
)
async def turn_off(self) -> None:
"""Turn off the lights and disable schedule."""
log.info("Turning off lights and disabling schedule...")
await self.send_command("setTurnOffAndDisableSchedule")
async def disconnect(self) -> None:
"""Close the WebSocket connection."""
if self._ws:
log.debug("Closing WebSocket connection...")
await self._ws.close()
self._ws = None
log.debug("WebSocket connection closed.")
async def interact(self):
"""Interactive menu for selecting presets or turning off lights."""
custom_style = Style([('selected', 'fg:ansicyan bold')])
presets = await self.get_presets()
choices = [
Choice(title=preset.preset_name, value=preset)
for preset in presets
]
choices.append(
Choice(title="Turn Off Lights", value="__TURN_OFF__")
)
choices.append(
Choice(title="Exit", value="__EXIT__")
)
while True:
selected = await questionary.select(
"Select a preset or action:",
choices=choices,
style=custom_style,
use_indicator=True,
use_shortcuts=False
).ask_async()
if selected is None:
break
if selected == "__TURN_OFF__":
await self.turn_off()
return
elif selected == "__EXIT__":
return
else:
preset = selected
await preset.select()
async def main():
"""Main function demonstrating the Celebright controller usage."""
controllers = CelebrightController.from_discovery()
controller = controllers[0]
await controller.interact()
if __name__ == "__main__":
asyncio.run(main())