Files
ledgrab-haos-integration/custom_components/ledgrab/__init__.py
T
alexei.dolgolyov 705616f8b0 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).
2026-06-08 15:59:42 +03:00

250 lines
9.0 KiB
Python

"""The LED Screen Controller integration."""
from __future__ import annotations
import logging
from datetime import timedelta
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
DOMAIN,
CONF_SERVER_NAME,
CONF_SERVER_URL,
CONF_API_KEY,
DEFAULT_SCAN_INTERVAL,
TARGET_TYPE_HA_LIGHT,
DATA_COORDINATOR,
DATA_EVENT_LISTENER,
)
from .coordinator import LedGrabCoordinator
from .event_listener import EventStreamListener
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.LIGHT,
Platform.SWITCH,
Platform.SENSOR,
Platform.NUMBER,
Platform.SELECT,
Platform.UPDATE,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up LED Screen Controller from a config entry."""
server_name = entry.data.get(CONF_SERVER_NAME, "LED Screen Controller")
server_url = entry.data[CONF_SERVER_URL]
api_key = entry.data[CONF_API_KEY]
session = async_get_clientsession(hass)
coordinator = LedGrabCoordinator(
hass,
session,
server_url,
api_key,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
)
await coordinator.async_config_entry_first_refresh()
event_listener = EventStreamListener(hass, server_url, api_key, coordinator)
await event_listener.start()
# Create device entries for each target and remove stale ones
device_registry = dr.async_get(hass)
current_identifiers: set[tuple[str, str]] = set()
# Server device — owns the system telemetry sensors (CPU, RAM, GPU, etc.)
server_identifier = (DOMAIN, f"{entry.entry_id}_server")
sw_version = (
coordinator.server_version
if coordinator.server_version and coordinator.server_version != "unknown"
else None
)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={server_identifier},
name=server_name,
manufacturer="LedGrab",
model="Server",
sw_version=sw_version,
configuration_url=server_url,
)
current_identifiers.add(server_identifier)
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
target_type = info.get("target_type", "led")
if target_type == TARGET_TYPE_HA_LIGHT:
model = "HA Light Target"
else:
model = "LED Target"
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, target_id)},
name=info.get("name", target_id),
manufacturer=server_name,
model=model,
configuration_url=server_url,
)
current_identifiers.add((DOMAIN, target_id))
# Create a single "Scenes" device for scene preset buttons
scenes_identifier = (DOMAIN, f"{entry.entry_id}_scenes")
scene_presets = coordinator.data.get("scene_presets", []) if coordinator.data else []
if scene_presets:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={scenes_identifier},
name="Scenes",
manufacturer=server_name,
model="Scene Presets",
configuration_url=server_url,
)
current_identifiers.add(scenes_identifier)
# One device per scene playlist — groups its switch + stats sensors.
scene_playlists = (
coordinator.data.get("scene_playlists", []) if coordinator.data else []
)
for playlist in scene_playlists:
playlist_identifier = (DOMAIN, playlist["id"])
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={playlist_identifier},
name=playlist.get("name", playlist["id"]),
manufacturer=server_name,
model="Scene Playlist",
configuration_url=server_url,
)
current_identifiers.add(playlist_identifier)
# One device per sync clock — groups its switch/number/button/sensor.
sync_clocks = coordinator.data.get("sync_clocks", []) if coordinator.data else []
for clock in sync_clocks:
clock_identifier = (DOMAIN, clock["id"])
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={clock_identifier},
name=clock.get("name", clock["id"]),
manufacturer=server_name,
model="Sync Clock",
configuration_url=server_url,
)
current_identifiers.add(clock_identifier)
# 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, {})
hass.data[DOMAIN][entry.entry_id] = {
DATA_COORDINATOR: coordinator,
DATA_EVENT_LISTENER: event_listener,
}
# Track target, scene, playlist, and sync-clock IDs to detect changes
known_target_ids = set(coordinator.data.get("targets", {}).keys() if coordinator.data else [])
known_scene_ids = set(
p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else [])
)
known_playlist_ids = set(
p["id"] for p in (coordinator.data.get("scene_playlists", []) if coordinator.data else [])
)
known_clock_ids = set(
c["id"] for c in (coordinator.data.get("sync_clocks", []) if coordinator.data else [])
)
def _on_coordinator_update() -> None:
"""Detect target/scene/playlist/clock list changes and trigger reload."""
nonlocal known_target_ids, known_scene_ids, known_playlist_ids, known_clock_ids
if not coordinator.data:
return
targets = coordinator.data.get("targets", {})
# Reload if target, scene, playlist, or sync-clock list changed
current_ids = set(targets.keys())
current_scene_ids = set(p["id"] for p in coordinator.data.get("scene_presets", []))
current_playlist_ids = set(p["id"] for p in coordinator.data.get("scene_playlists", []))
current_clock_ids = set(c["id"] for c in coordinator.data.get("sync_clocks", []))
if (
current_ids != known_target_ids
or current_scene_ids != known_scene_ids
or current_playlist_ids != known_playlist_ids
or current_clock_ids != known_clock_ids
):
known_target_ids = current_ids
known_scene_ids = current_scene_ids
known_playlist_ids = current_playlist_ids
known_clock_ids = current_clock_ids
_LOGGER.info(
"Target, scene, playlist, or sync-clock list changed, reloading integration"
)
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
coordinator.async_add_listener(_on_coordinator_update)
# Register set_leds service (once across all entries)
async def handle_set_leds(call) -> None:
"""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 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)
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(
DOMAIN,
"set_leds",
handle_set_leds,
schema=vol.Schema(
{
vol.Required("source_id"): str,
vol.Required("segments"): list,
}
),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
entry_data = hass.data[DOMAIN][entry.entry_id]
await entry_data[DATA_EVENT_LISTENER].shutdown()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
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