From cb99d9f941744492582980fd5b9abc0373c38079 Mon Sep 17 00:00:00 2001 From: Hank Mueller Date: Tue, 1 Sep 2026 15:37:23 +0000 Subject: [PATCH] Add Celebright holiday-lights integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01F6B8b3iYNv6QUftK2FDfYb --- .gitignore | 38 ++ LICENSE | 21 + Makefile | 46 +++ README.md | 217 ++++++++++ celebright_controller.py | 276 +++++++++++++ custom_components/celebright/README.md | 128 ++++++ custom_components/celebright/__init__.py | 105 +++++ custom_components/celebright/config_flow.py | 119 ++++++ custom_components/celebright/const.py | 20 + custom_components/celebright/controller.py | 426 ++++++++++++++++++++ custom_components/celebright/manifest.json | 12 + custom_components/celebright/select.py | 118 ++++++ custom_components/celebright/strings.json | 31 ++ docs/PROTOCOL.md | 114 ++++++ hacs.json | 6 + requirements.txt | 1 + 16 files changed, 1678 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 celebright_controller.py create mode 100644 custom_components/celebright/README.md create mode 100644 custom_components/celebright/__init__.py create mode 100644 custom_components/celebright/config_flow.py create mode 100644 custom_components/celebright/const.py create mode 100644 custom_components/celebright/controller.py create mode 100644 custom_components/celebright/manifest.json create mode 100644 custom_components/celebright/select.py create mode 100644 custom_components/celebright/strings.json create mode 100644 docs/PROTOCOL.md create mode 100644 hacs.json create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38052cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ + +# Virtual Environment +.venv/ +venv/ +ENV/ +env/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Project specific +*.pcapng +*.pcap + +# Claude +.claude/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1c2d3a0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 fritzlab + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c7b1d07 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +.PHONY: deploy clean help setup + +# Configuration — override on the command line or in your environment, e.g. +# make deploy REMOTE_HOST=homeassistant.local +REMOTE_HOST ?= homeassistant.local +REMOTE_PATH ?= /homeassistant/custom_components +REMOTE_USER ?= root +LOCAL_PATH = custom_components/celebright + +# Default target +help: + @echo "Celebright Home Assistant Integration - Makefile" + @echo "" + @echo "Available targets:" + @echo " make deploy - Deploy integration to Home Assistant (set REMOTE_HOST)" + @echo " make setup - Ensure remote directory exists with correct permissions" + @echo " make clean - Remove Python cache files" + @echo " make help - Show this help message" + @echo "" + @echo "Override REMOTE_HOST / REMOTE_PATH / REMOTE_USER for your own host." + +# Ensure remote directory exists with correct permissions +setup: + @echo "Setting up remote directory on $(REMOTE_HOST)..." + @ssh $(REMOTE_HOST) "sudo mkdir -p $(REMOTE_PATH)/celebright && sudo chown -R $(REMOTE_USER):$(REMOTE_USER) $(REMOTE_PATH)/celebright" + @echo "Setup complete!" + +# Deploy integration to Home Assistant +deploy: setup + @echo "Deploying Celebright integration to $(REMOTE_HOST)..." + rsync -avz --delete \ + --exclude='__pycache__' \ + --exclude='*.pyc' \ + --exclude='.DS_Store' \ + --exclude='*.swp' \ + $(LOCAL_PATH)/ $(REMOTE_HOST):$(REMOTE_PATH)/celebright/ + @echo "" + @echo "Deployment complete!" + @echo "Remember to restart Home Assistant to load the changes." + +# Clean Python cache files +clean: + @echo "Cleaning Python cache files..." + find $(LOCAL_PATH) -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true + find $(LOCAL_PATH) -type f -name '*.pyc' -delete 2>/dev/null || true + @echo "Clean complete!" diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ae820b --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +# Celebright Controller + +Python-based controller for Celebright holiday lights systems. +This implementation is based on reverse-engineering the network protocol from packet capture analysis. + +## Project Components + +This repository contains two main components: + +1. **Home Assistant Integration** (`custom_components/celebright/`) — the maintained, **firmware-v2** custom component. This is the supported implementation. +2. **Standalone Python Controller** (`celebright_controller.py`) — the original **v1** reverse-engineering reference/demo. It does **not** work against firmware 2.x (the `getPresetsAndEventsPaginated`/`loadPreset` topics it uses were removed); kept for historical reference. + +> **Full protocol & operations reference: [`docs/PROTOCOL.md`](docs/PROTOCOL.md)** — v2 WebSocket topics, device behaviors, deploy, and access gotchas. + +## Features + +### Standalone Controller + +- **Discovery**: Automatically discovers Celebright controllers on the local network via UDP broadcast +- **WebSocket Communication**: Establishes WebSocket connection for real-time control +- **Scene Management**: Lists and selects available lighting scenes/presets +- **Power Control**: Turn off lights and disable schedules + +### Home Assistant Integration + +- **Automatic Discovery**: Finds controllers on your network automatically +- **Select Entity**: Drop-down selector for choosing presets +- **Native Integration**: Full Home Assistant device integration +- **Automation Support**: Use in automations, scripts, and scenes + +## Requirements + +- Python 3.7+ +- `websockets` library + +## Installation + +```bash +pip install -r requirements.txt +``` + +## Usage + +### Basic Usage + +Run the main script to execute a complete demo sequence: + +```bash +python3 celebright_controller.py +``` + +This will: +1. Discover the Celebright controller on your network +2. Establish a WebSocket connection +3. List all available scenes +4. Load a couple of example scenes +5. Turn off the system +6. Disconnect + +### Using as a Library + +```python +import asyncio +from celebright_controller import CelebrightController + +async def control_lights(): + controller = CelebrightController() + + # Discover controller + device_info = controller.discover_controller() + if not device_info: + print("Controller not found") + return + + # Connect + await controller.connect() + + # Get available presets + presets = await controller.get_presets() + + # Load a specific preset + if presets: + await controller.load_preset(presets[0]['preset_uuid']) + + # Turn off + await controller.turn_off() + + # Disconnect + await controller.disconnect() + +asyncio.run(control_lights()) +``` + +### Home Assistant Integration + +For detailed Home Assistant integration instructions, see [custom_components/celebright/README.md](custom_components/celebright/README.md). + +#### Quick Start + +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 + +#### Deployment via Makefile + +The project includes a Makefile for easy deployment to Home Assistant: + +```bash +make deploy +``` + +This will: +- Set up the remote directory with correct permissions +- Deploy the integration to your Home Assistant instance +- Remind you to restart Home Assistant + +Configure the Makefile variables for your environment: +- `REMOTE_HOST`: Your Home Assistant hostname or IP +- `REMOTE_PATH`: Path to Home Assistant's custom_components directory +- `REMOTE_USER`: SSH user for deployment + +## Protocol Details + +> The current **firmware-v2** protocol is documented in full in +> [`docs/PROTOCOL.md`](docs/PROTOCOL.md). The sections below describe the +> **original v1** protocol the standalone controller targets, retained for +> historical reference. + +Based on packet capture analysis of `celebright-full.pcapng`: + +### Discovery Process +- **Client sends**: UDP broadcast to port 49999 (discovery request with empty payload) +- **Client listens**: On port 51234 for responses +- **Controller responds**: From port 49999 to client's port 51234 with JSON payload containing: + - Model name + - Device ID + - Firmware version + - Number of LEDs + - LED configuration + +### WebSocket Communication +- **Endpoint**: `ws://:80/ws` +- **Message Format**: JSON with `topic` and `message` fields + +```json +{ + "topic": "command_name", + "message": { + // command parameters + } +} +``` + +### Available Commands + +| Topic | Description | Parameters | +|-------|-------------|------------| +| `getPresetsAndEventsPaginated` | Get list of available scenes | None | +| `loadPreset` | Load/activate a specific scene | `presetUuid`: UUID of preset | +| `setTurnOffAndDisableSchedule` | Turn off lights and disable schedule | None | +| `getSystemState` | Get current system state | None | + +## Example Output + +``` +============================================================ +STEP 1: DISCOVERY +============================================================ +[Discovery] Listening for Celebright controller on UDP port 51234... +[Discovery] Found controller at 192.168.1.50 +[Discovery] Model: CLC-03 +[Discovery] Device ID: 123456789 +[Discovery] Firmware: 1.41 +[Discovery] Number of LEDs: 296 + +============================================================ +STEP 2: WEBSOCKET CONNECTION +============================================================ +[Connect] Connecting to ws://192.168.1.50:80/ws... +[Connect] WebSocket connection established + +============================================================ +STEP 3: LIST AVAILABLE SCENES +============================================================ +[Presets] Requesting preset list... +[Receive] Response topic: presetsPage +[Presets] Found 12 presets: + 1. Halloween Twinkle - Orange, Purple and Green twinkle + UUID: 3956e504-aafa-4f1c-8ad7-b00fb2381baa + 2. Warm White Solid - Warm White lights + UUID: 6d2bdc6e-b015-4903-b8e5-5f7fc28c156a + ... +``` + +## Network Requirements + +- The controller must be on the same local network +- UDP port 49999 must be accessible for sending discovery broadcasts +- UDP port 51234 must be accessible for receiving discovery responses +- TCP port 80 must be accessible for WebSocket communication + +## Limitations + +- The discovery process requires the ability to send UDP broadcasts on the local network +- Broadcast discovery may not work across network segments or VLANs without proper routing +- If broadcast discovery fails, you may need to specify the controller IP directly (feature could be added) +- The script currently only handles the basic protocol commands observed in the packet capture + +## License + +MIT — see [LICENSE](LICENSE). + +This is an unofficial, reverse-engineered implementation based on local network +traffic analysis of the vendor device. It is not affiliated with or endorsed by +Celebright. Use at your own risk. diff --git a/celebright_controller.py b/celebright_controller.py new file mode 100644 index 0000000..764e126 --- /dev/null +++ b/celebright_controller.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +""" +Celebright Holiday Lights Controller +Based on packet capture analysis of the Celebright protocol. +""" +import questionary +from questionary import Style, Choice +import socket +import json +import asyncio +import logging +import websockets +from typing import Optional, Dict, List, Self +import netifaces +from dataclasses import dataclass + +DISCOVERY_PORT = 49999 +DISCOVERY_TIMEOUT = 5.0 +DISCOVERY_MESSAGE = b'App Broadcast Message' +WEBSOCKET_ENDPOINT = "/ws" + +log = logging.getLogger("CelebrightController") + + +@dataclass(frozen=True) +class CelebrightPreset: + preset_uuid: str + preset_name: str + preset_desc: str + preset_mode: int + brightness: int + web_id: int + md5: str + controller: "CelebrightController" + + async def select(self) -> None: + """Select/load this preset on the controller.""" + await self.controller.load_preset(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. """ + broadcast_addrs = list() + for iface in netifaces.interfaces(): + addrs = netifaces.ifaddresses(iface) + if netifaces.AF_INET in addrs: + for addr in addrs[netifaces.AF_INET]: + if 'broadcast' in addr: + broadcast_addrs.append(addr['broadcast']) + broadcast_addrs = sorted(set(broadcast_addrs)) + log.debug("Discovered System Broadcast addresses: %s", broadcast_addrs) + return broadcast_addrs + + @classmethod + def _discover_controller(cls, bcast: str) -> Self | None: + """ + Discover Celebright controller on the network by sending UDP broadcast. + + Args: + broadcast_addr: Broadcast address to use (default: 255.255.255.255) + + Returns: + A list of dictionary containing device information + """ + + log.debug("Discovering Celebright controller on: %s", bcast) + + # Create socket for sending broadcast + soc = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + soc.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + soc.bind(('', DISCOVERY_PORT)) + + # Send broadcast discovery request ("App Broadcast Message" payload) + soc.sendto(DISCOVERY_MESSAGE, (bcast, DISCOVERY_PORT)) + log.debug(f"Sent Discovery Broadcast: {bcast}:{DISCOVERY_PORT}") + + try: + # Wait for response from controller + while True: + try: + data, addr = soc.recvfrom(4096) + log.debug(f"Received response from {addr}: {data}") + if data: + data = json.loads(data.decode('utf-8')) + if "model_name" in data: + log.info("Discovered Celebright Controller at %s", + addr[0]) + return cls(address=addr[0], **data) + except json.JSONDecodeError: + log.debug("Failed to decode JSON from response: %s", data) + + except socket.timeout: + log.debug(f"Controller Timeout on %s", bcast) + except Exception as e: + log.warning(f"Unhandled Discovery Error: {e}") + + finally: + soc.close() + + @classmethod + def _discover_controllers(cls) -> List[Self]: + """ + Discover Celebright controllers on the network by sending UDP broadcast. + + Returns: + A list of dictionary containing device information + """ + + controllers = list() + for address in cls._discover_broadcast_addresses(): + result = cls._discover_controller(address) + if result: + controllers.append(result) + + return controllers + + @classmethod + def from_discovery(cls) -> list[Self]: + """ + Factory method to create CelebrightController instances by + discovering controllers on the network. + """ + return cls._discover_controllers() + + async def connect(self) -> None: + """ + Establish WebSocket connection to the controller. + """ + ws_url = f"ws://{self.address}{WEBSOCKET_ENDPOINT}" + log.debug(f"establishing websocket to {ws_url}...") + + self._ws = await websockets.connect(ws_url) + log.info("websocket connection established: %s", ws_url) + + async def send_command(self, topic: str, message: Dict = None) -> None: + """ + Send a command to the controller via WebSocket. + + Args: + topic: Command topic (e.g., "getPresetsAndEventsPaginated") + message: Command parameters dictionary (default: {}) + """ + if not self._ws: + await self.connect() + + if message is None: + message = {} + + command = {"topic": topic, "message": message} + + log.debug(f"sending to '%s': %s", topic, message) + await self._ws.send(json.dumps(command)) + + async def receive_response(self) -> Optional[Dict]: + """ + Receive a response from the controller. + """ + if not self._ws: + await self.connect() + + response = await self._ws.recv() + data = json.loads(response) + log.debug(f"Received Response topic: {data.get('topic')}") + return data + + async def get_presets(self) -> list[CelebrightPreset]: + """ + Get list of available scene presets. + + Returns: + List of preset dictionaries, or None if error + """ + log.debug("Requesting preset list...") + await self.send_command("getPresetsAndEventsPaginated") + presets = list() + response = await self.receive_response() + if response and response.get('topic') == 'presetsPage': + for preset in response.get('message', {}).get('presets', []): + presets.append(CelebrightPreset(**preset, controller=self)) + return presets + + async def load_preset(self, preset: CelebrightPreset) -> None: + """ + Load/activate a specific scene preset. + + Args: + preset_uuid: UUID of the preset to load + """ + log.info("Loading preset: %s", preset.preset_name) + await self.send_command( + "loadPreset", + {"presetUuid": preset.preset_uuid} + ) + log.info("Setting 12-hour sleep timer...") + await self.send_command( + "setSleepTimer", + {"timerMinutes": 720} + ) + + async def turn_off(self) -> None: + """Turn off the lights and disable schedule.""" + log.info("Turning off lights and disabling schedule...") + await self.send_command("setTurnOffAndDisableSchedule") + + async def disconnect(self) -> None: + """Close the WebSocket connection.""" + if self._ws: + log.debug("Closing WebSocket connection...") + await self._ws.close() + self._ws = None + log.debug("WebSocket connection closed.") + + async def interact(self): + """Interactive menu for selecting presets or turning off lights.""" + custom_style = Style([('selected', 'fg:ansicyan bold')]) + presets = await self.get_presets() + choices = [ + Choice(title=preset.preset_name, value=preset) + for preset in presets + ] + choices.append( + Choice(title="Turn Off Lights", value="__TURN_OFF__") + ) + choices.append( + Choice(title="Exit", value="__EXIT__") + ) + + while True: + selected = await questionary.select( + "Select a preset or action:", + choices=choices, + style=custom_style, + use_indicator=True, + use_shortcuts=False + ).ask_async() + + if selected is None: + break + if selected == "__TURN_OFF__": + await self.turn_off() + return + elif selected == "__EXIT__": + return + else: + preset = selected + await preset.select() + + +async def main(): + """Main function demonstrating the Celebright controller usage.""" + controllers = CelebrightController.from_discovery() + controller = controllers[0] + await controller.interact() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/custom_components/celebright/README.md b/custom_components/celebright/README.md new file mode 100644 index 0000000..6060df5 --- /dev/null +++ b/custom_components/celebright/README.md @@ -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). diff --git a/custom_components/celebright/__init__.py b/custom_components/celebright/__init__.py new file mode 100644 index 0000000..0eeb45c --- /dev/null +++ b/custom_components/celebright/__init__.py @@ -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 diff --git a/custom_components/celebright/config_flow.py b/custom_components/celebright/config_flow.py new file mode 100644 index 0000000..af900f0 --- /dev/null +++ b/custom_components/celebright/config_flow.py @@ -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, + } + ), + ) diff --git a/custom_components/celebright/const.py b/custom_components/celebright/const.py new file mode 100644 index 0000000..a3b9dbe --- /dev/null +++ b/custom_components/celebright/const.py @@ -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 \ No newline at end of file diff --git a/custom_components/celebright/controller.py b/custom_components/celebright/controller.py new file mode 100644 index 0000000..94564d7 --- /dev/null +++ b/custom_components/celebright/controller.py @@ -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.") diff --git a/custom_components/celebright/manifest.json b/custom_components/celebright/manifest.json new file mode 100644 index 0000000..b2cdef8 --- /dev/null +++ b/custom_components/celebright/manifest.json @@ -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" +} diff --git a/custom_components/celebright/select.py b/custom_components/celebright/select.py new file mode 100644 index 0000000..4bf2521 --- /dev/null +++ b/custom_components/celebright/select.py @@ -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() diff --git a/custom_components/celebright/strings.json b/custom_components/celebright/strings.json new file mode 100644 index 0000000..f1f8a53 --- /dev/null +++ b/custom_components/celebright/strings.json @@ -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)" + } + } + } + } +} diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..1544361 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,114 @@ +# Celebright protocol & operations reference + +Canonical technical reference for the Celebright controller, reverse-engineered +from device WebSocket captures and live probing. The maintained implementation +is the Home Assistant integration in [`custom_components/celebright/`](../custom_components/celebright). + +## Device + +| Field | Value (reference unit) | +|---|---| +| Model | `CLC-03` | +| Hardware | `hwVer 4` | +| Firmware | `fwVer 2.04` (protocol **v2**) | +| LAN address | device DHCP/static IP on your LAN (HTTP/WS on port 80) | +| HTTP/WS port | `80` (config portal at `/`, WebSocket at `/ws`) | +| Discovery | UDP broadcast port `49999`, payload `App Broadcast Message` | + +## Firmware versions + +Firmware **2.x renamed the v1 "presets" concept to "savedScenes"** and bumped +the wire protocol. v2 replies are tagged `"v":2`. The v1 topics +(`getPresetsAndEventsPaginated`, `loadPreset`, response `presetsPage`) are gone; +sending them now returns `logDeviceError "Unrecognized topic [X]"`. The +standalone `celebright_controller.py` is the original v1 reverse-engineering +reference and does **not** work against 2.x — the integration is v2. + +## WebSocket protocol (v2) + +`ws:///ws`, JSON frames `{"topic": , "message": }`. Negotiates +`permessage-deflate`. Client→server frames are masked (standard WS); the device's +own frames are unmasked and tagged `"v":2`. + +### Topics + +| Request topic | `message` | Response topic | Notes | +|---|---|---|---| +| `getSystemState` | `{}` | `systemState` | **Always answered** (lights on or off). | +| `getSavedScenesAndEventsPaginated` | `{}` | `savedScenesPage` | **Only served while idle** — see gating below. | +| `getZones` | `{"v":2}` | `systemZones` | Light zones / per-light map. | +| `getInfo` | `{}` | `getInfoResponse` | Device info (model, fw, IP, RSSI, storage). | +| `loadSavedScene` | `{"savedSceneUuid": }` | `systemState` | Activate a scene. | +| `setTurnOffAndDisableSchedule` | `{}` | `systemState` | Turn off + disable schedule. | +| unknown | — | `logDeviceError` | `"Unrecognized topic [X] No action taken"`. | + +### `systemState` message + +```json +{"userDisplay": 1, "scheduleEnabled": 0, "sleepTimer": 223, + "activeSavedScene": "", "currentScene": [ ... ], + "md5": "8400de4ae50038cee347364b840e6328"} +``` + +- `userDisplay` 0 = off, 1 = a scene is showing. +- `activeSavedScene` = uuid of the showing scene (absent when off). +- `md5` is a **library-level** hash — constant across on/off, changes when the + saved-scene set changes. Use it to invalidate a cached scene list. +- `loadSavedScene` sets a default `sleepTimer` (~minutes) itself; no separate + sleep-timer call is needed. + +### `savedScenesPage` message + +```json +{"savedScenes": [ + {"uuid": "...", "name": "Starry Night", "desc": "...", "md5": "...", + "displays": [ {"uuid": "...", "zones": ["..."], "lookType": 2, + "lookData": { ... }} ]} + ], "offset": 0, "limit": 10, "total": 7} +``` + +`displays`/`lookData` (patterns, palettes) are opaque to the integration — it +only needs `uuid` + `name`. + +## Device behaviors that shape the integration + +- **Scene library is gated on idle.** `getSavedScenesAndEventsPaginated` returns + `savedScenesPage` immediately when the lights are **off**, but returns + **nothing** (silent, not an error) while a scene is actively rendering. A + local-only client therefore cannot fetch the library on demand while lights + are on. The vendor app sidesteps this by reading the library from the + encrypted cloud relay. +- **One WebSocket client at a time.** Overlapping connections (e.g. a leftover + test client) make a fresh connection's reads return nothing — close the old + one and let the slot free before reconnecting. +- **Unknown topics don't close the socket** — they emit `logDeviceError`, so a + read loop waiting for a specific reply must give up on a timeout, not hang. + +### How the integration copes + +- Availability + current scene are driven by `getSystemState` (always answered), + so the entry stays `loaded` even with lights on. +- The scene library is fetched **best-effort and cached**, re-fetched only when + the `systemState` `md5` changes. A failed fetch keeps the cached list instead + of failing the coordinator update. +- All WS connect/recv/send calls are wrapped in `asyncio.wait_for` + (`WS_CONNECT_TIMEOUT`/`WS_RECV_TIMEOUT`/`WS_SEND_TIMEOUT` in `const.py`) so a + silent device cannot blow past Home Assistant's 60s setup deadline. + +## Deploy + +Copy `custom_components/celebright/` into your Home Assistant +`config/custom_components/` (or use HACS as a custom repository), then restart +Home Assistant. The on-disk copy is not git-managed — after updating the files +call `homeassistant.restart`; a config-entry reload does **not** re-import changed +Python. Verify the entry reaches `loaded` and the preset `select` entity lists the +scenes. A `make deploy` target is provided for rsync-over-SSH deployment; set +`REMOTE_HOST` to your Home Assistant host. + +## Re-deriving the protocol from a capture + +A `.pcapng` of the vendor app talking to the device (port 80) yields the wire +protocol. The cloud relay is encrypted and not capturable, so only the local +device exchange is visible. Parse the capture per TCP connection; **client→server +frames are WS-masked** (XOR the 4-byte key) to read the request topics, while the +device's responses are plaintext. diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..224f655 --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "Celebright", + "content_in_root": false, + "render_readme": true, + "homeassistant": "2024.6.0" +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..31b5e2f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +websockets>=12.0