HA integration: fix reload loop, parallel device fetch, WS guards, translations

- Fix indentation bug causing scenes device to not register
- Use nonlocal tracking to prevent infinite reload loops on target/scene changes
- Guard WS start/stop to avoid redundant connections
- Parallel device brightness fetching via asyncio.gather
- Route set_leds service to correct coordinator by source ID
- Remove stale pattern cache, reuse single timeout object
- Fix translations structure for light/select entities
- Unregister service when last config entry unloaded

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 11:21:46 +03:00
parent 122e95545c
commit 968046d96b
7 changed files with 105 additions and 70 deletions

View File

@@ -114,15 +114,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
} }
# Track target and scene IDs to detect changes # 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 [] 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 []) p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else [])
) )
def _on_coordinator_update() -> None: def _on_coordinator_update() -> None:
"""Manage WS connections and detect target list changes.""" """Manage WS connections and detect target list changes."""
nonlocal known_target_ids, known_scene_ids
if not coordinator.data: if not coordinator.data:
return return
@@ -134,8 +136,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
state = target_data.get("state") or {} state = target_data.get("state") or {}
if info.get("target_type") == TARGET_TYPE_KEY_COLORS: if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
if state.get("processing"): if state.get("processing"):
if target_id not in ws_manager._connections:
hass.async_create_task(ws_manager.start_listening(target_id)) hass.async_create_task(ws_manager.start_listening(target_id))
else: else:
if target_id in ws_manager._connections:
hass.async_create_task(ws_manager.stop_listening(target_id)) hass.async_create_task(ws_manager.stop_listening(target_id))
# Reload if target or scene list changed # Reload if target or scene list changed
@@ -143,7 +147,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
current_scene_ids = set( current_scene_ids = set(
p["id"] for p in coordinator.data.get("scene_presets", []) 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") _LOGGER.info("Target or scene list changed, reloading integration")
hass.async_create_task( hass.async_create_task(
hass.config_entries.async_reload(entry.entry_id) 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.""" """Handle the set_leds service call."""
source_id = call.data["source_id"] source_id = call.data["source_id"]
segments = call.data["segments"] segments = call.data["segments"]
# Route to the coordinator that owns this source
for entry_data in hass.data[DOMAIN].values(): for entry_data in hass.data[DOMAIN].values():
coord = entry_data.get(DATA_COORDINATOR) 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) 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"): if not hass.services.has_service(DOMAIN, "set_leds"):
hass.services.async_register( hass.services.async_register(
@@ -188,5 +201,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok: if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) 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 return unload_ok

View File

@@ -65,10 +65,9 @@ class SceneActivateButton(CoordinatorEntity, ButtonEntity):
"""Return if entity is available.""" """Return if entity is available."""
if not self.coordinator.data: if not self.coordinator.data:
return False return False
return any( return self._preset_id in {
p["id"] == self._preset_id p["id"] for p in self.coordinator.data.get("scene_presets", [])
for p in self.coordinator.data.get("scene_presets", []) }
)
async def async_press(self) -> None: async def async_press(self) -> None:
"""Activate the scene preset.""" """Activate the scene preset."""

View File

@@ -37,7 +37,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
self.api_key = api_key self.api_key = api_key
self.server_version = "unknown" self.server_version = "unknown"
self._auth_headers = {"Authorization": f"Bearer {api_key}"} self._auth_headers = {"Authorization": f"Bearer {api_key}"}
self._pattern_cache: dict[str, list[dict]] = {} self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
super().__init__( super().__init__(
hass, hass,
@@ -85,7 +85,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
kc_settings = target.get("key_colors_settings") or {} kc_settings = target.get("key_colors_settings") or {}
template_id = kc_settings.get("pattern_template_id", "") template_id = kc_settings.get("pattern_template_id", "")
if template_id: if template_id:
result["rectangles"] = await self._get_rectangles( result["rectangles"] = await self._fetch_rectangles(
template_id template_id
) )
else: else:
@@ -136,7 +136,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
try: try:
async with self.session.get( async with self.session.get(
f"{self.server_url}/health", f"{self.server_url}/health",
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
data = await resp.json() data = await resp.json()
@@ -150,7 +150,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/output-targets", f"{self.server_url}/api/v1/output-targets",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
data = await resp.json() data = await resp.json()
@@ -161,7 +161,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/state", f"{self.server_url}/api/v1/output-targets/{target_id}/state",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
return await resp.json() return await resp.json()
@@ -171,27 +171,22 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/metrics", f"{self.server_url}/api/v1/output-targets/{target_id}/metrics",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
return await resp.json() return await resp.json()
async def _get_rectangles(self, template_id: str) -> list[dict]: async def _fetch_rectangles(self, template_id: str) -> list[dict]:
"""Get rectangles for a pattern template, using cache.""" """Fetch rectangles for a pattern template (no cache — always fresh)."""
if template_id in self._pattern_cache:
return self._pattern_cache[template_id]
try: try:
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/pattern-templates/{template_id}", f"{self.server_url}/api/v1/pattern-templates/{template_id}",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
data = await resp.json() data = await resp.json()
rectangles = data.get("rectangles", []) return data.get("rectangles", [])
self._pattern_cache[template_id] = rectangles
return rectangles
except Exception as err: except Exception as err:
_LOGGER.warning( _LOGGER.warning(
"Failed to fetch pattern template %s: %s", template_id, err "Failed to fetch pattern template %s: %s", template_id, err
@@ -204,7 +199,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/devices", f"{self.server_url}/api/v1/devices",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
data = await resp.json() data = await resp.json()
@@ -213,18 +208,16 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
_LOGGER.warning("Failed to fetch devices: %s", err) _LOGGER.warning("Failed to fetch devices: %s", err)
return {} return {}
devices_data: dict[str, dict[str, Any]] = {} # Fetch brightness for all capable devices in parallel
async def fetch_device_entry(device: dict) -> tuple[str, dict[str, Any]]:
for device in devices:
device_id = device["id"] device_id = device["id"]
entry: dict[str, Any] = {"info": device, "brightness": None} entry: dict[str, Any] = {"info": device, "brightness": None}
if "brightness_control" in (device.get("capabilities") or []): if "brightness_control" in (device.get("capabilities") or []):
try: try:
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/devices/{device_id}/brightness", f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status == 200: if resp.status == 200:
bri_data = await resp.json() bri_data = await resp.json()
@@ -234,7 +227,19 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
"Failed to fetch brightness for device %s: %s", "Failed to fetch brightness for device %s: %s",
device_id, err, 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 devices_data[device_id] = entry
return devices_data return devices_data
@@ -245,7 +250,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/devices/{device_id}/brightness", f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers={**self._auth_headers, "Content-Type": "application/json"}, headers={**self._auth_headers, "Content-Type": "application/json"},
json={"brightness": brightness}, json={"brightness": brightness},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() body = await resp.text()
@@ -262,7 +267,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/devices/{device_id}/color", f"{self.server_url}/api/v1/devices/{device_id}/color",
headers={**self._auth_headers, "Content-Type": "application/json"}, headers={**self._auth_headers, "Content-Type": "application/json"},
json={"color": color}, json={"color": color},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() body = await resp.text()
@@ -280,7 +285,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/output-targets/{target_id}", f"{self.server_url}/api/v1/output-targets/{target_id}",
headers={**self._auth_headers, "Content-Type": "application/json"}, headers={**self._auth_headers, "Content-Type": "application/json"},
json={"key_colors_settings": {"brightness": brightness_float}}, json={"key_colors_settings": {"brightness": brightness_float}},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() body = await resp.text()
@@ -297,7 +302,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/color-strip-sources", f"{self.server_url}/api/v1/color-strip-sources",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
data = await resp.json() data = await resp.json()
@@ -312,7 +317,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/value-sources", f"{self.server_url}/api/v1/value-sources",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
data = await resp.json() data = await resp.json()
@@ -327,7 +332,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get( async with self.session.get(
f"{self.server_url}/api/v1/scene-presets", f"{self.server_url}/api/v1/scene-presets",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
resp.raise_for_status() resp.raise_for_status()
data = await resp.json() data = await resp.json()
@@ -342,7 +347,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors", f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"}, headers={**self._auth_headers, "Content-Type": "application/json"},
json={"colors": colors}, json={"colors": colors},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status not in (200, 204): if resp.status not in (200, 204):
body = await resp.text() body = await resp.text()
@@ -358,7 +363,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors", f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"}, headers={**self._auth_headers, "Content-Type": "application/json"},
json={"segments": segments}, json={"segments": segments},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status not in (200, 204): if resp.status not in (200, 204):
body = await resp.text() body = await resp.text()
@@ -373,7 +378,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.post( async with self.session.post(
f"{self.server_url}/api/v1/scene-presets/{preset_id}/activate", f"{self.server_url}/api/v1/scene-presets/{preset_id}/activate",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() body = await resp.text()
@@ -390,7 +395,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/color-strip-sources/{source_id}", f"{self.server_url}/api/v1/color-strip-sources/{source_id}",
headers={**self._auth_headers, "Content-Type": "application/json"}, headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs, json=kwargs,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() body = await resp.text()
@@ -398,14 +403,15 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
"Failed to update source %s: %s %s", "Failed to update source %s: %s %s",
source_id, resp.status, body, source_id, resp.status, body,
) )
resp.raise_for_status()
async def update_target(self, target_id: str, **kwargs: Any) -> None: 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( async with self.session.put(
f"{self.server_url}/api/v1/output-targets/{target_id}", f"{self.server_url}/api/v1/output-targets/{target_id}",
headers={**self._auth_headers, "Content-Type": "application/json"}, headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs, json=kwargs,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() body = await resp.text()
@@ -421,7 +427,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.post( async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/start", f"{self.server_url}/api/v1/output-targets/{target_id}/start",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status == 409: if resp.status == 409:
_LOGGER.debug("Target %s already processing", target_id) _LOGGER.debug("Target %s already processing", target_id)
@@ -439,7 +445,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.post( async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/stop", f"{self.server_url}/api/v1/output-targets/{target_id}/stop",
headers=self._auth_headers, headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), timeout=self._timeout,
) as resp: ) as resp:
if resp.status == 409: if resp.status == 409:
_LOGGER.debug("Target %s already stopped", target_id) _LOGGER.debug("Target %s already stopped", target_id)

View File

@@ -63,9 +63,13 @@ class ApiInputLight(CoordinatorEntity, LightEntity):
self._entry_id = entry_id self._entry_id = entry_id
self._attr_unique_id = f"{self._source_id}_light" self._attr_unique_id = f"{self._source_id}_light"
# Local state — not derived from coordinator data # Restore state from fallback_color
self._is_on: bool = False fallback = self._get_fallback_color()
self._rgb_color: tuple[int, int, int] = (255, 255, 255) 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 self._brightness: int = 255
@property @property

View File

@@ -96,7 +96,7 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity):
return self._target_id in self.coordinator.data.get("targets", {}) return self._target_id in self.coordinator.data.get("targets", {})
async def async_select_option(self, option: str) -> None: 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: if source_id is None:
_LOGGER.error("CSS source not found: %s", option) _LOGGER.error("CSS source not found: %s", option)
return return
@@ -104,12 +104,9 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity):
self._target_id, color_strip_source_id=source_id 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 [] sources = (self.coordinator.data or {}).get("css_sources") or []
for s in sources: return {s["name"]: s["id"] for s in sources}
if s["name"] == name:
return s["id"]
return None
class BrightnessSourceSelect(CoordinatorEntity, SelectEntity): class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
@@ -167,17 +164,14 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
if option == NONE_OPTION: if option == NONE_OPTION:
source_id = "" source_id = ""
else: 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: if source_id is None:
_LOGGER.error("Value source not found: %s", option) _LOGGER.error("Value source not found: %s", option)
return return
await self.coordinator.update_target( await self.coordinator.update_target(
self._target_id, brightness_value_source_id=source_id 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

@@ -31,6 +31,11 @@
"name": "{scene_name}" "name": "{scene_name}"
} }
}, },
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": { "switch": {
"processing": { "processing": {
"name": "Processing" "name": "Processing"
@@ -58,9 +63,12 @@
"name": "Brightness" "name": "Brightness"
} }
}, },
"light": { "select": {
"light": { "color_strip_source": {
"name": "Light" "name": "Color Strip Source"
},
"brightness_source": {
"name": "Brightness Source"
} }
} }
} }

View File

@@ -31,6 +31,11 @@
"name": "{scene_name}" "name": "{scene_name}"
} }
}, },
"light": {
"api_input_light": {
"name": "Подсветка"
}
},
"switch": { "switch": {
"processing": { "processing": {
"name": "Обработка" "name": "Обработка"
@@ -58,9 +63,12 @@
"name": "Яркость" "name": "Яркость"
} }
}, },
"light": { "select": {
"light": { "color_strip_source": {
"name": "Подсветка" "name": "Источник цветовой полосы"
},
"brightness_source": {
"name": "Источник яркости"
} }
} }
} }