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
427 lines
17 KiB
Python
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.")
|