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 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 23:33:25 +03:00
parent 7a4d7149a6
commit 847ac38d8a
6 changed files with 266 additions and 158 deletions

View File

@@ -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, {})

View File

@@ -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(

View File

@@ -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)

View File

@@ -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

View File

@@ -53,9 +53,12 @@
"name": "Brightness"
}
},
"light": {
"light": {
"name": "Light"
"select": {
"color_strip_source": {
"name": "Color Strip Source"
},
"brightness_source": {
"name": "Brightness Source"
}
}
}