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
+128
View File
@@ -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).
+105
View File
@@ -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
+119
View File
@@ -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,
}
),
)
+20
View File
@@ -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
+426
View File
@@ -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"
}
+118
View File
@@ -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()
+31
View File
@@ -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)"
}
}
}
}
}