Files
celebright/custom_components/celebright/controller.py
T
Hank MuellerandClaude Opus 5 cb99d9f941 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
2026-09-01 15:37:23 +00:00

427 lines
17 KiB
Python

"""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.")