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
119 lines
4.1 KiB
Python
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()
|