feat(playlists): expose scene playlists as Home Assistant entities

One device per scene playlist (model "Scene Playlist"), each with:
- a switch (start/stop; the server cycles one playlist at a time, so starting one stops any other)
- a "Current Scene" sensor that resolves the active preset name from scene_presets while this playlist is cycling
- an "Items" diagnostic sensor (configured scene count; no state_class)

The coordinator reads scene_playlists plus the flat playlist_state from the /api/v1/snapshot payload and gains start_playlist()/stop_playlist(); __init__ registers and prunes per-playlist devices and reloads on playlist-id changes; the event listener also refreshes on the playlist_state_changed WS event. Shared device/lookup/running-state plumbing lives in a new LedGrabPlaylistEntity base (entity.py) used by both the switch and the sensors. Adds en/ru translation keys.

Note: this also lands the in-progress coordinator migration from the per-request fan-out to the single /api/v1/snapshot poll that was already present in the working tree.

Requires the led-grab server /api/v1/snapshot to emit scene_playlists + playlist_state (companion server change, tracked separately).
This commit is contained in:
2026-06-08 15:59:08 +03:00
parent a666d9eb9c
commit 705616f8b0
9 changed files with 404 additions and 289 deletions
+96
View File
@@ -29,6 +29,7 @@ from .const import (
DATA_COORDINATOR,
)
from .coordinator import LedGrabCoordinator
from .entity import LedGrabPlaylistEntity
_LOGGER = logging.getLogger(__name__)
@@ -59,6 +60,14 @@ async def async_setup_entry(
for clock in coordinator.data.get("sync_clocks", []):
entities.append(SyncClockElapsedSensor(coordinator, clock["id"], entry.entry_id))
for playlist in coordinator.data.get("scene_playlists", []):
entities.append(
PlaylistCurrentSceneSensor(coordinator, playlist["id"], entry.entry_id)
)
entities.append(
PlaylistItemsSensor(coordinator, playlist["id"], entry.entry_id)
)
entities.extend(_build_server_sensors(coordinator, entry.entry_id))
async_add_entities(entities)
@@ -268,6 +277,93 @@ class SyncClockElapsedSensor(CoordinatorEntity, SensorEntity):
return None
class _PlaylistSensorBase(LedGrabPlaylistEntity, SensorEntity):
"""Sensor attached to a scene-playlist device.
Device/availability/lookup plumbing lives in :class:`LedGrabPlaylistEntity`
so it stays in sync with the playlist switch.
"""
class PlaylistCurrentSceneSensor(_PlaylistSensorBase):
"""Name of the scene preset this playlist is currently holding.
Only this playlist's value populates while it is the active (cycling) one;
every other playlist's sensor reads ``None`` (idle). The value advances as
the engine steps to the next item.
"""
_attr_icon = "mdi:playlist-star"
def __init__(
self,
coordinator: LedGrabCoordinator,
playlist_id: str,
entry_id: str,
) -> None:
super().__init__(coordinator, playlist_id, entry_id)
self._attr_unique_id = f"{playlist_id}_current_scene"
self._attr_translation_key = "playlist_current_scene"
@property
def native_value(self) -> str | None:
state = self._running_state()
if not state:
return None
preset_id = state.get("current_preset_id")
if not preset_id:
return None
return self._resolve_preset_name(preset_id)
@property
def extra_state_attributes(self) -> dict[str, Any]:
state = self._running_state()
if not state:
return {}
return {
"current_preset_id": state.get("current_preset_id"),
"current_index": state.get("current_index"),
"item_count": state.get("item_count"),
}
def _resolve_preset_name(self, preset_id: str) -> str:
"""Map a scene-preset id to its display name (fall back to the id)."""
if self.coordinator.data:
for preset in self.coordinator.data.get("scene_presets", []):
if preset.get("id") == preset_id:
return preset.get("name", preset_id)
return preset_id
class PlaylistItemsSensor(_PlaylistSensorBase):
"""Number of scene presets queued in a playlist (static config stat).
No ``state_class`` — this is a configuration cardinality that only changes
when the playlist is edited, so it shouldn't be enrolled in long-term
statistics.
"""
_attr_icon = "mdi:playlist-music"
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
coordinator: LedGrabCoordinator,
playlist_id: str,
entry_id: str,
) -> None:
super().__init__(coordinator, playlist_id, entry_id)
self._attr_unique_id = f"{playlist_id}_items"
self._attr_translation_key = "playlist_items"
@property
def native_value(self) -> int | None:
playlist = self._get_playlist()
if playlist is None:
return None
return len(playlist.get("items") or [])
class HALightMappedLightsSensor(CoordinatorEntity, SensorEntity):
"""Sensor showing the number of mapped HA lights for an HA Light target."""