From 847ac38d8a18f9321628de97c28bcc916e9b5bc6 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 25 Feb 2026 23:33:25 +0300 Subject: [PATCH] Replace HAOS light entity with select entities, add zero-brightness optimization - Remove light.py platform (static color control via HA light entity) - Add select.py with CSS Source and Brightness Source dropdowns for LED targets - Coordinator now fetches color-strip-sources and value-sources lists - Add generic update_target() method for partial target updates - Clean up stale device registry entries on integration reload - Skip frame sends when effective brightness is ~0 (suppresses unnecessary UDP/HTTP traffic while LEDs are dark) Co-Authored-By: Claude Opus 4.6 --- .../wled_screen_controller/__init__.py | 14 +- .../wled_screen_controller/coordinator.py | 57 +++++- .../wled_screen_controller/light.py | 151 --------------- .../wled_screen_controller/select.py | 183 ++++++++++++++++++ .../wled_screen_controller/strings.json | 9 +- .../core/processing/wled_target_processor.py | 10 + 6 files changed, 266 insertions(+), 158 deletions(-) delete mode 100644 custom_components/wled_screen_controller/light.py create mode 100644 custom_components/wled_screen_controller/select.py diff --git a/custom_components/wled_screen_controller/__init__.py b/custom_components/wled_screen_controller/__init__.py index 6f8fe63..324a346 100644 --- a/custom_components/wled_screen_controller/__init__.py +++ b/custom_components/wled_screen_controller/__init__.py @@ -31,7 +31,7 @@ PLATFORMS: list[Platform] = [ Platform.SWITCH, Platform.SENSOR, Platform.NUMBER, - Platform.LIGHT, + Platform.SELECT, ] @@ -57,8 +57,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: event_listener = EventStreamListener(hass, server_url, api_key, coordinator) await event_listener.start() - # Create device entries for each target + # Create device entries for each target and remove stale ones device_registry = dr.async_get(hass) + current_identifiers: set[tuple[str, str]] = set() if coordinator.data and "targets" in coordinator.data: for target_id, target_data in coordinator.data["targets"].items(): info = target_data["info"] @@ -76,6 +77,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model=model, configuration_url=server_url, ) + current_identifiers.add((DOMAIN, target_id)) + + # Remove devices for targets that no longer exist + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + if not device_entry.identifiers & current_identifiers: + _LOGGER.info("Removing stale device: %s", device_entry.name) + device_registry.async_remove_device(device_entry.id) # Store data hass.data.setdefault(DOMAIN, {}) diff --git a/custom_components/wled_screen_controller/coordinator.py b/custom_components/wled_screen_controller/coordinator.py index 32a37f2..86ab1d7 100644 --- a/custom_components/wled_screen_controller/coordinator.py +++ b/custom_components/wled_screen_controller/coordinator.py @@ -107,12 +107,18 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): target_id, data = r targets_data[target_id] = data - # Fetch devices with capabilities and brightness - devices_data = await self._fetch_devices() + # Fetch devices, CSS sources, and value sources in parallel + devices_data, css_sources, value_sources = await asyncio.gather( + self._fetch_devices(), + self._fetch_css_sources(), + self._fetch_value_sources(), + ) return { "targets": targets_data, "devices": devices_data, + "css_sources": css_sources, + "value_sources": value_sources, "server_version": self.server_version, } @@ -281,6 +287,53 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): resp.raise_for_status() await self.async_request_refresh() + async def _fetch_css_sources(self) -> list[dict[str, Any]]: + """Fetch all color strip sources.""" + try: + async with self.session.get( + f"{self.server_url}/api/v1/color-strip-sources", + headers=self._auth_headers, + timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + ) as resp: + resp.raise_for_status() + data = await resp.json() + return data.get("sources", []) + except Exception as err: + _LOGGER.warning("Failed to fetch CSS sources: %s", err) + return [] + + async def _fetch_value_sources(self) -> list[dict[str, Any]]: + """Fetch all value sources.""" + try: + async with self.session.get( + f"{self.server_url}/api/v1/value-sources", + headers=self._auth_headers, + timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + ) as resp: + resp.raise_for_status() + data = await resp.json() + return data.get("sources", []) + except Exception as err: + _LOGGER.warning("Failed to fetch value sources: %s", err) + return [] + + async def update_target(self, target_id: str, **kwargs: Any) -> None: + """Update a picture target's fields.""" + async with self.session.put( + f"{self.server_url}/api/v1/picture-targets/{target_id}", + headers={**self._auth_headers, "Content-Type": "application/json"}, + json=kwargs, + timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + ) as resp: + if resp.status != 200: + body = await resp.text() + _LOGGER.error( + "Failed to update target %s: %s %s", + target_id, resp.status, body, + ) + resp.raise_for_status() + await self.async_request_refresh() + async def start_processing(self, target_id: str) -> None: """Start processing for a target.""" async with self.session.post( diff --git a/custom_components/wled_screen_controller/light.py b/custom_components/wled_screen_controller/light.py deleted file mode 100644 index 10bdb53..0000000 --- a/custom_components/wled_screen_controller/light.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Light platform for LED Screen Controller (static color + brightness).""" -from __future__ import annotations - -import logging -from typing import Any - -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - ATTR_RGB_COLOR, - ColorMode, - LightEntity, -) -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, DATA_COORDINATOR, TARGET_TYPE_KEY_COLORS -from .coordinator import WLEDScreenControllerCoordinator - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up LED Screen Controller light entities.""" - data = hass.data[DOMAIN][entry.entry_id] - coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR] - - entities = [] - if coordinator.data and "targets" in coordinator.data: - devices = coordinator.data.get("devices") or {} - - for target_id, target_data in coordinator.data["targets"].items(): - info = target_data["info"] - - # Only LED targets (skip KC targets) - if info.get("target_type") == TARGET_TYPE_KEY_COLORS: - continue - - device_id = info.get("device_id", "") - if not device_id: - continue - - device_data = devices.get(device_id) - if not device_data: - continue - - capabilities = device_data.get("info", {}).get("capabilities") or [] - - # Light entity requires BOTH brightness_control AND static_color - if "brightness_control" in capabilities and "static_color" in capabilities: - entities.append( - WLEDScreenControllerLight( - coordinator, target_id, device_id, entry.entry_id, - ) - ) - - async_add_entities(entities) - - -class WLEDScreenControllerLight(CoordinatorEntity, LightEntity): - """Light entity for an LED device with brightness and static color.""" - - _attr_has_entity_name = True - _attr_color_mode = ColorMode.RGB - _attr_supported_color_modes = {ColorMode.RGB} - - def __init__( - self, - coordinator: WLEDScreenControllerCoordinator, - target_id: str, - device_id: str, - entry_id: str, - ) -> None: - """Initialize the light entity.""" - super().__init__(coordinator) - self._target_id = target_id - self._device_id = device_id - self._entry_id = entry_id - self._attr_unique_id = f"{target_id}_light" - self._attr_translation_key = "light" - - @property - def device_info(self) -> dict[str, Any]: - """Return device information.""" - return {"identifiers": {(DOMAIN, self._target_id)}} - - @property - def is_on(self) -> bool | None: - """Return True if static_color is set (not null).""" - device_data = self._get_device_data() - if not device_data: - return None - static_color = device_data.get("info", {}).get("static_color") - return static_color is not None - - @property - def brightness(self) -> int | None: - """Return the brightness (0-255).""" - device_data = self._get_device_data() - if not device_data: - return None - return device_data.get("brightness") - - @property - def rgb_color(self) -> tuple[int, int, int] | None: - """Return the RGB color tuple.""" - device_data = self._get_device_data() - if not device_data: - return None - static_color = device_data.get("info", {}).get("static_color") - if static_color is not None and len(static_color) == 3: - return tuple(static_color) - return None - - @property - def available(self) -> bool: - """Return if entity is available.""" - if not self.coordinator.data: - return False - targets = self.coordinator.data.get("targets", {}) - devices = self.coordinator.data.get("devices", {}) - return self._target_id in targets and self._device_id in devices - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the light on (set static color and/or brightness).""" - if ATTR_BRIGHTNESS in kwargs: - await self.coordinator.set_brightness( - self._device_id, int(kwargs[ATTR_BRIGHTNESS]) - ) - - if ATTR_RGB_COLOR in kwargs: - r, g, b = kwargs[ATTR_RGB_COLOR] - await self.coordinator.set_color(self._device_id, [r, g, b]) - elif not self.is_on: - # Turning on without specifying color: default to white - await self.coordinator.set_color(self._device_id, [255, 255, 255]) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the light off (clear static color).""" - await self.coordinator.set_color(self._device_id, None) - - def _get_device_data(self) -> dict[str, Any] | None: - """Get device data from coordinator.""" - if not self.coordinator.data: - return None - return self.coordinator.data.get("devices", {}).get(self._device_id) diff --git a/custom_components/wled_screen_controller/select.py b/custom_components/wled_screen_controller/select.py new file mode 100644 index 0000000..288c027 --- /dev/null +++ b/custom_components/wled_screen_controller/select.py @@ -0,0 +1,183 @@ +"""Select platform for LED Screen Controller (CSS source & brightness source).""" +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, DATA_COORDINATOR, TARGET_TYPE_KEY_COLORS +from .coordinator import WLEDScreenControllerCoordinator + +_LOGGER = logging.getLogger(__name__) + +NONE_OPTION = "— None —" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up LED Screen Controller select entities.""" + data = hass.data[DOMAIN][entry.entry_id] + coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR] + + entities: list[SelectEntity] = [] + if coordinator.data and "targets" in coordinator.data: + for target_id, target_data in coordinator.data["targets"].items(): + info = target_data["info"] + + # Only LED targets + if info.get("target_type") == TARGET_TYPE_KEY_COLORS: + continue + + entities.append( + CSSSourceSelect(coordinator, target_id, entry.entry_id) + ) + entities.append( + BrightnessSourceSelect(coordinator, target_id, entry.entry_id) + ) + + async_add_entities(entities) + + +class CSSSourceSelect(CoordinatorEntity, SelectEntity): + """Select entity for choosing a color strip source for an LED target.""" + + _attr_has_entity_name = True + _attr_icon = "mdi:palette" + + def __init__( + self, + coordinator: WLEDScreenControllerCoordinator, + target_id: str, + entry_id: str, + ) -> None: + super().__init__(coordinator) + self._target_id = target_id + self._entry_id = entry_id + self._attr_unique_id = f"{target_id}_css_source" + self._attr_translation_key = "color_strip_source" + + @property + def device_info(self) -> dict[str, Any]: + return {"identifiers": {(DOMAIN, self._target_id)}} + + @property + def options(self) -> list[str]: + if not self.coordinator.data: + return [] + sources = self.coordinator.data.get("css_sources") or [] + return [s["name"] for s in sources] + + @property + def current_option(self) -> str | None: + if not self.coordinator.data: + return None + target_data = self.coordinator.data.get("targets", {}).get(self._target_id) + if not target_data: + return None + current_id = target_data["info"].get("color_strip_source_id", "") + sources = self.coordinator.data.get("css_sources") or [] + for s in sources: + if s["id"] == current_id: + return s["name"] + return None + + @property + def available(self) -> bool: + if not self.coordinator.data: + return False + return self._target_id in self.coordinator.data.get("targets", {}) + + async def async_select_option(self, option: str) -> None: + source_id = self._name_to_id(option) + if source_id is None: + _LOGGER.error("CSS source not found: %s", option) + return + await self.coordinator.update_target( + self._target_id, color_strip_source_id=source_id + ) + + def _name_to_id(self, name: str) -> str | None: + sources = (self.coordinator.data or {}).get("css_sources") or [] + for s in sources: + if s["name"] == name: + return s["id"] + return None + + +class BrightnessSourceSelect(CoordinatorEntity, SelectEntity): + """Select entity for choosing a brightness value source for an LED target.""" + + _attr_has_entity_name = True + _attr_icon = "mdi:brightness-auto" + + def __init__( + self, + coordinator: WLEDScreenControllerCoordinator, + target_id: str, + entry_id: str, + ) -> None: + super().__init__(coordinator) + self._target_id = target_id + self._entry_id = entry_id + self._attr_unique_id = f"{target_id}_brightness_source" + self._attr_translation_key = "brightness_source" + + @property + def device_info(self) -> dict[str, Any]: + return {"identifiers": {(DOMAIN, self._target_id)}} + + @property + def options(self) -> list[str]: + if not self.coordinator.data: + return [NONE_OPTION] + sources = self.coordinator.data.get("value_sources") or [] + return [NONE_OPTION] + [s["name"] for s in sources] + + @property + def current_option(self) -> str | None: + if not self.coordinator.data: + return None + target_data = self.coordinator.data.get("targets", {}).get(self._target_id) + if not target_data: + return None + current_id = target_data["info"].get("brightness_value_source_id", "") + if not current_id: + return NONE_OPTION + sources = self.coordinator.data.get("value_sources") or [] + for s in sources: + if s["id"] == current_id: + return s["name"] + return NONE_OPTION + + @property + def available(self) -> bool: + if not self.coordinator.data: + return False + return self._target_id in self.coordinator.data.get("targets", {}) + + async def async_select_option(self, option: str) -> None: + if option == NONE_OPTION: + source_id = "" + else: + source_id = self._name_to_id(option) + if source_id is None: + _LOGGER.error("Value source not found: %s", option) + return + await self.coordinator.update_target( + self._target_id, brightness_value_source_id=source_id + ) + + def _name_to_id(self, name: str) -> str | None: + sources = (self.coordinator.data or {}).get("value_sources") or [] + for s in sources: + if s["name"] == name: + return s["id"] + return None diff --git a/custom_components/wled_screen_controller/strings.json b/custom_components/wled_screen_controller/strings.json index 3f9bc8e..15a43f4 100644 --- a/custom_components/wled_screen_controller/strings.json +++ b/custom_components/wled_screen_controller/strings.json @@ -53,9 +53,12 @@ "name": "Brightness" } }, - "light": { - "light": { - "name": "Light" + "select": { + "color_strip_source": { + "name": "Color Strip Source" + }, + "brightness_source": { + "name": "Brightness Source" } } } diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 956bd1d..5c733e3 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -577,6 +577,16 @@ class WledTargetProcessor(TargetProcessor): cur_brightness = _effective_brightness(device_info) + # Zero-brightness suppression: if output is black and + # the last sent frame was also black, skip sending. + if cur_brightness <= 1 and _prev_brightness <= 1 and has_any_frame: + self._metrics.frames_skipped += 1 + while send_timestamps and send_timestamps[0] < loop_start - 1.0: + send_timestamps.popleft() + self._metrics.fps_current = len(send_timestamps) + await asyncio.sleep(SKIP_REPOLL) + continue + if frame is prev_frame_ref and cur_brightness == _prev_brightness: # Same frame + same brightness — keepalive or skip if self._needs_keepalive and has_any_frame and (loop_start - last_send_time) >= keepalive_interval: