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:
@@ -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).
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user