diff --git a/custom_components/wled_screen_controller/__init__.py b/custom_components/wled_screen_controller/__init__.py index 65a651e..f956370 100644 --- a/custom_components/wled_screen_controller/__init__.py +++ b/custom_components/wled_screen_controller/__init__.py @@ -95,7 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model="Scene Presets", configuration_url=server_url, ) - current_identifiers.add(scenes_identifier) + current_identifiers.add(scenes_identifier) # Remove devices for targets that no longer exist for device_entry in dr.async_entries_for_config_entry( @@ -114,15 +114,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } # Track target and scene IDs to detect changes - initial_target_ids = set( + known_target_ids = set( coordinator.data.get("targets", {}).keys() if coordinator.data else [] ) - initial_scene_ids = set( + known_scene_ids = set( p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else []) ) def _on_coordinator_update() -> None: """Manage WS connections and detect target list changes.""" + nonlocal known_target_ids, known_scene_ids + if not coordinator.data: return @@ -134,16 +136,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: state = target_data.get("state") or {} if info.get("target_type") == TARGET_TYPE_KEY_COLORS: if state.get("processing"): - hass.async_create_task(ws_manager.start_listening(target_id)) + if target_id not in ws_manager._connections: + hass.async_create_task(ws_manager.start_listening(target_id)) else: - hass.async_create_task(ws_manager.stop_listening(target_id)) + if target_id in ws_manager._connections: + hass.async_create_task(ws_manager.stop_listening(target_id)) # Reload if target or scene list changed current_ids = set(targets.keys()) current_scene_ids = set( p["id"] for p in coordinator.data.get("scene_presets", []) ) - if current_ids != initial_target_ids or current_scene_ids != initial_scene_ids: + if current_ids != known_target_ids or current_scene_ids != known_scene_ids: + known_target_ids = current_ids + known_scene_ids = current_scene_ids _LOGGER.info("Target or scene list changed, reloading integration") hass.async_create_task( hass.config_entries.async_reload(entry.entry_id) @@ -156,11 +162,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle the set_leds service call.""" source_id = call.data["source_id"] segments = call.data["segments"] + # Route to the coordinator that owns this source for entry_data in hass.data[DOMAIN].values(): coord = entry_data.get(DATA_COORDINATOR) - if coord: + if not coord or not coord.data: + continue + source_ids = { + s["id"] for s in coord.data.get("css_sources", []) + } + if source_id in source_ids: await coord.push_segments(source_id, segments) - break + return + _LOGGER.error("No server found with source_id %s", source_id) if not hass.services.has_service(DOMAIN, "set_leds"): hass.services.async_register( @@ -188,5 +201,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + # Unregister service if no entries remain + if not hass.data[DOMAIN]: + hass.services.async_remove(DOMAIN, "set_leds") return unload_ok diff --git a/custom_components/wled_screen_controller/button.py b/custom_components/wled_screen_controller/button.py index 2c3758e..e597cfd 100644 --- a/custom_components/wled_screen_controller/button.py +++ b/custom_components/wled_screen_controller/button.py @@ -65,10 +65,9 @@ class SceneActivateButton(CoordinatorEntity, ButtonEntity): """Return if entity is available.""" if not self.coordinator.data: return False - return any( - p["id"] == self._preset_id - for p in self.coordinator.data.get("scene_presets", []) - ) + return self._preset_id in { + p["id"] for p in self.coordinator.data.get("scene_presets", []) + } async def async_press(self) -> None: """Activate the scene preset.""" diff --git a/custom_components/wled_screen_controller/coordinator.py b/custom_components/wled_screen_controller/coordinator.py index c52afca..5f25eb4 100644 --- a/custom_components/wled_screen_controller/coordinator.py +++ b/custom_components/wled_screen_controller/coordinator.py @@ -37,7 +37,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): self.api_key = api_key self.server_version = "unknown" self._auth_headers = {"Authorization": f"Bearer {api_key}"} - self._pattern_cache: dict[str, list[dict]] = {} + self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT) super().__init__( hass, @@ -85,7 +85,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): kc_settings = target.get("key_colors_settings") or {} template_id = kc_settings.get("pattern_template_id", "") if template_id: - result["rectangles"] = await self._get_rectangles( + result["rectangles"] = await self._fetch_rectangles( template_id ) else: @@ -136,7 +136,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): try: async with self.session.get( f"{self.server_url}/health", - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: resp.raise_for_status() data = await resp.json() @@ -150,7 +150,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): async with self.session.get( f"{self.server_url}/api/v1/output-targets", headers=self._auth_headers, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: resp.raise_for_status() data = await resp.json() @@ -161,7 +161,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): async with self.session.get( f"{self.server_url}/api/v1/output-targets/{target_id}/state", headers=self._auth_headers, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: resp.raise_for_status() return await resp.json() @@ -171,27 +171,22 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): async with self.session.get( f"{self.server_url}/api/v1/output-targets/{target_id}/metrics", headers=self._auth_headers, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: resp.raise_for_status() return await resp.json() - async def _get_rectangles(self, template_id: str) -> list[dict]: - """Get rectangles for a pattern template, using cache.""" - if template_id in self._pattern_cache: - return self._pattern_cache[template_id] - + async def _fetch_rectangles(self, template_id: str) -> list[dict]: + """Fetch rectangles for a pattern template (no cache — always fresh).""" try: async with self.session.get( f"{self.server_url}/api/v1/pattern-templates/{template_id}", headers=self._auth_headers, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: resp.raise_for_status() data = await resp.json() - rectangles = data.get("rectangles", []) - self._pattern_cache[template_id] = rectangles - return rectangles + return data.get("rectangles", []) except Exception as err: _LOGGER.warning( "Failed to fetch pattern template %s: %s", template_id, err @@ -204,7 +199,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): async with self.session.get( f"{self.server_url}/api/v1/devices", headers=self._auth_headers, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: resp.raise_for_status() data = await resp.json() @@ -213,18 +208,16 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): _LOGGER.warning("Failed to fetch devices: %s", err) return {} - devices_data: dict[str, dict[str, Any]] = {} - - for device in devices: + # Fetch brightness for all capable devices in parallel + async def fetch_device_entry(device: dict) -> tuple[str, dict[str, Any]]: device_id = device["id"] entry: dict[str, Any] = {"info": device, "brightness": None} - if "brightness_control" in (device.get("capabilities") or []): try: async with self.session.get( f"{self.server_url}/api/v1/devices/{device_id}/brightness", headers=self._auth_headers, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: if resp.status == 200: bri_data = await resp.json() @@ -234,7 +227,19 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): "Failed to fetch brightness for device %s: %s", device_id, err, ) + return device_id, entry + results = await asyncio.gather( + *(fetch_device_entry(d) for d in devices), + return_exceptions=True, + ) + + devices_data: dict[str, dict[str, Any]] = {} + for r in results: + if isinstance(r, Exception): + _LOGGER.warning("Device fetch failed: %s", r) + continue + device_id, entry = r devices_data[device_id] = entry return devices_data @@ -245,7 +250,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): f"{self.server_url}/api/v1/devices/{device_id}/brightness", headers={**self._auth_headers, "Content-Type": "application/json"}, json={"brightness": brightness}, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: if resp.status != 200: body = await resp.text() @@ -262,7 +267,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): f"{self.server_url}/api/v1/devices/{device_id}/color", headers={**self._auth_headers, "Content-Type": "application/json"}, json={"color": color}, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: if resp.status != 200: body = await resp.text() @@ -280,7 +285,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): f"{self.server_url}/api/v1/output-targets/{target_id}", headers={**self._auth_headers, "Content-Type": "application/json"}, json={"key_colors_settings": {"brightness": brightness_float}}, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: if resp.status != 200: body = await resp.text() @@ -297,7 +302,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): async with self.session.get( f"{self.server_url}/api/v1/color-strip-sources", headers=self._auth_headers, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: resp.raise_for_status() data = await resp.json() @@ -312,7 +317,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): async with self.session.get( f"{self.server_url}/api/v1/value-sources", headers=self._auth_headers, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: resp.raise_for_status() data = await resp.json() @@ -327,7 +332,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): async with self.session.get( f"{self.server_url}/api/v1/scene-presets", headers=self._auth_headers, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: resp.raise_for_status() data = await resp.json() @@ -342,7 +347,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors", headers={**self._auth_headers, "Content-Type": "application/json"}, json={"colors": colors}, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: if resp.status not in (200, 204): body = await resp.text() @@ -358,7 +363,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors", headers={**self._auth_headers, "Content-Type": "application/json"}, json={"segments": segments}, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: if resp.status not in (200, 204): body = await resp.text() @@ -373,7 +378,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): async with self.session.post( f"{self.server_url}/api/v1/scene-presets/{preset_id}/activate", headers=self._auth_headers, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: if resp.status != 200: body = await resp.text() @@ -390,7 +395,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): f"{self.server_url}/api/v1/color-strip-sources/{source_id}", headers={**self._auth_headers, "Content-Type": "application/json"}, json=kwargs, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: if resp.status != 200: body = await resp.text() @@ -398,14 +403,15 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): "Failed to update source %s: %s %s", source_id, resp.status, body, ) + resp.raise_for_status() async def update_target(self, target_id: str, **kwargs: Any) -> None: - """Update a output target's fields.""" + """Update an output target's fields.""" async with self.session.put( f"{self.server_url}/api/v1/output-targets/{target_id}", headers={**self._auth_headers, "Content-Type": "application/json"}, json=kwargs, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: if resp.status != 200: body = await resp.text() @@ -421,7 +427,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): async with self.session.post( f"{self.server_url}/api/v1/output-targets/{target_id}/start", headers=self._auth_headers, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: if resp.status == 409: _LOGGER.debug("Target %s already processing", target_id) @@ -439,7 +445,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): async with self.session.post( f"{self.server_url}/api/v1/output-targets/{target_id}/stop", headers=self._auth_headers, - timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + timeout=self._timeout, ) as resp: if resp.status == 409: _LOGGER.debug("Target %s already stopped", target_id) diff --git a/custom_components/wled_screen_controller/light.py b/custom_components/wled_screen_controller/light.py index 4881460..943a014 100644 --- a/custom_components/wled_screen_controller/light.py +++ b/custom_components/wled_screen_controller/light.py @@ -63,9 +63,13 @@ class ApiInputLight(CoordinatorEntity, LightEntity): self._entry_id = entry_id self._attr_unique_id = f"{self._source_id}_light" - # Local state — not derived from coordinator data - self._is_on: bool = False - self._rgb_color: tuple[int, int, int] = (255, 255, 255) + # Restore state from fallback_color + fallback = self._get_fallback_color() + is_off = fallback == [0, 0, 0] + self._is_on: bool = not is_off + self._rgb_color: tuple[int, int, int] = ( + (255, 255, 255) if is_off else tuple(fallback) # type: ignore[arg-type] + ) self._brightness: int = 255 @property diff --git a/custom_components/wled_screen_controller/select.py b/custom_components/wled_screen_controller/select.py index 288c027..70c756b 100644 --- a/custom_components/wled_screen_controller/select.py +++ b/custom_components/wled_screen_controller/select.py @@ -96,7 +96,7 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity): 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) + source_id = self._name_to_id_map().get(option) if source_id is None: _LOGGER.error("CSS source not found: %s", option) return @@ -104,12 +104,9 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity): self._target_id, color_strip_source_id=source_id ) - def _name_to_id(self, name: str) -> str | None: + def _name_to_id_map(self) -> dict[str, str]: sources = (self.coordinator.data or {}).get("css_sources") or [] - for s in sources: - if s["name"] == name: - return s["id"] - return None + return {s["name"]: s["id"] for s in sources} class BrightnessSourceSelect(CoordinatorEntity, SelectEntity): @@ -167,17 +164,14 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity): if option == NONE_OPTION: source_id = "" else: - source_id = self._name_to_id(option) + name_map = { + s["name"]: s["id"] + for s in (self.coordinator.data or {}).get("value_sources") or [] + } + source_id = name_map.get(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/translations/en.json b/custom_components/wled_screen_controller/translations/en.json index a1feef3..afe74b6 100644 --- a/custom_components/wled_screen_controller/translations/en.json +++ b/custom_components/wled_screen_controller/translations/en.json @@ -31,6 +31,11 @@ "name": "{scene_name}" } }, + "light": { + "api_input_light": { + "name": "Light" + } + }, "switch": { "processing": { "name": "Processing" @@ -58,9 +63,12 @@ "name": "Brightness" } }, - "light": { - "light": { - "name": "Light" + "select": { + "color_strip_source": { + "name": "Color Strip Source" + }, + "brightness_source": { + "name": "Brightness Source" } } } diff --git a/custom_components/wled_screen_controller/translations/ru.json b/custom_components/wled_screen_controller/translations/ru.json index d470efa..30f9761 100644 --- a/custom_components/wled_screen_controller/translations/ru.json +++ b/custom_components/wled_screen_controller/translations/ru.json @@ -31,6 +31,11 @@ "name": "{scene_name}" } }, + "light": { + "api_input_light": { + "name": "Подсветка" + } + }, "switch": { "processing": { "name": "Обработка" @@ -58,9 +63,12 @@ "name": "Яркость" } }, - "light": { - "light": { - "name": "Подсветка" + "select": { + "color_strip_source": { + "name": "Источник цветовой полосы" + }, + "brightness_source": { + "name": "Источник яркости" } } }