Files
celebright/custom_components/celebright/select.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

119 lines
4.1 KiB
Python

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