From 252db09145f0f5b3cf734163f4e01dc8548f09a9 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 28 Feb 2026 19:53:47 +0300 Subject: [PATCH] Add HAOS scene preset buttons and smooth tutorial scrolling Expose scene presets as button entities in the HA integration under a dedicated "Scenes" device. Each button activates its scene via the API. The coordinator now fetches scene presets alongside other data, and the integration reloads when the scene list changes. Also animate tutorial autoscroll with smooth behavior and wait for scrollend before positioning the spotlight overlay. Co-Authored-By: Claude Opus 4.6 --- .../wled_screen_controller/__init__.py | 29 ++++++- .../wled_screen_controller/button.py | 75 +++++++++++++++++ .../wled_screen_controller/coordinator.py | 45 ++++++++-- .../wled_screen_controller/strings.json | 5 ++ .../translations/en.json | 5 ++ .../translations/ru.json | 5 ++ .../static/js/features/tutorials.js | 82 ++++++++++++------- 7 files changed, 208 insertions(+), 38 deletions(-) create mode 100644 custom_components/wled_screen_controller/button.py diff --git a/custom_components/wled_screen_controller/__init__.py b/custom_components/wled_screen_controller/__init__.py index 324a346..64f5940 100644 --- a/custom_components/wled_screen_controller/__init__.py +++ b/custom_components/wled_screen_controller/__init__.py @@ -28,6 +28,7 @@ from .ws_manager import KeyColorsWebSocketManager _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ + Platform.BUTTON, Platform.SWITCH, Platform.SENSOR, Platform.NUMBER, @@ -79,6 +80,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) 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=f"{server_name} Scenes", + manufacturer=server_name, + model="Scene Presets", + configuration_url=server_url, + ) + current_identifiers.add(scenes_identifier) + # Remove devices for targets that no longer exist for device_entry in dr.async_entries_for_config_entry( device_registry, entry.entry_id @@ -95,10 +110,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: DATA_EVENT_LISTENER: event_listener, } - # Track target IDs to detect changes + # Track target and scene IDs to detect changes initial_target_ids = set( coordinator.data.get("targets", {}).keys() if coordinator.data else [] ) + initial_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.""" @@ -117,10 +135,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: else: hass.async_create_task(ws_manager.stop_listening(target_id)) - # Reload if target list changed + # Reload if target or scene list changed current_ids = set(targets.keys()) - if current_ids != initial_target_ids: - _LOGGER.info("Target list changed, reloading integration") + 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: + _LOGGER.info("Target or scene list changed, reloading integration") hass.async_create_task( hass.config_entries.async_reload(entry.entry_id) ) diff --git a/custom_components/wled_screen_controller/button.py b/custom_components/wled_screen_controller/button.py new file mode 100644 index 0000000..2c3758e --- /dev/null +++ b/custom_components/wled_screen_controller/button.py @@ -0,0 +1,75 @@ +"""Button platform for LED Screen Controller — scene preset activation.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.button import ButtonEntity +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 +from .coordinator import WLEDScreenControllerCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up scene preset buttons.""" + data = hass.data[DOMAIN][entry.entry_id] + coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR] + + entities = [] + if coordinator.data: + for preset in coordinator.data.get("scene_presets", []): + entities.append( + SceneActivateButton(coordinator, preset, entry.entry_id) + ) + + async_add_entities(entities) + + +class SceneActivateButton(CoordinatorEntity, ButtonEntity): + """Button that activates a scene preset.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: WLEDScreenControllerCoordinator, + preset: dict[str, Any], + entry_id: str, + ) -> None: + """Initialize the button.""" + super().__init__(coordinator) + self._preset_id = preset["id"] + self._entry_id = entry_id + self._attr_unique_id = f"{entry_id}_scene_{preset['id']}" + self._attr_translation_key = "activate_scene" + self._attr_translation_placeholders = {"scene_name": preset["name"]} + self._attr_icon = "mdi:palette" + + @property + def device_info(self) -> dict[str, Any]: + """Return device information — all scene buttons belong to the Scenes device.""" + return {"identifiers": {(DOMAIN, f"{self._entry_id}_scenes")}} + + @property + def available(self) -> bool: + """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", []) + ) + + async def async_press(self) -> None: + """Activate the scene preset.""" + await self.coordinator.activate_scene(self._preset_id) diff --git a/custom_components/wled_screen_controller/coordinator.py b/custom_components/wled_screen_controller/coordinator.py index 86ab1d7..8d85e11 100644 --- a/custom_components/wled_screen_controller/coordinator.py +++ b/custom_components/wled_screen_controller/coordinator.py @@ -107,11 +107,14 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): target_id, data = r targets_data[target_id] = data - # 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(), + # Fetch devices, CSS sources, value sources, and scene presets in parallel + devices_data, css_sources, value_sources, scene_presets = ( + await asyncio.gather( + self._fetch_devices(), + self._fetch_css_sources(), + self._fetch_value_sources(), + self._fetch_scene_presets(), + ) ) return { @@ -119,6 +122,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): "devices": devices_data, "css_sources": css_sources, "value_sources": value_sources, + "scene_presets": scene_presets, "server_version": self.server_version, } @@ -317,6 +321,37 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator): _LOGGER.warning("Failed to fetch value sources: %s", err) return [] + async def _fetch_scene_presets(self) -> list[dict[str, Any]]: + """Fetch all scene presets.""" + try: + async with self.session.get( + f"{self.server_url}/api/v1/scene-presets", + headers=self._auth_headers, + timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT), + ) as resp: + resp.raise_for_status() + data = await resp.json() + return data.get("presets", []) + except Exception as err: + _LOGGER.warning("Failed to fetch scene presets: %s", err) + return [] + + async def activate_scene(self, preset_id: str) -> None: + """Activate a scene preset.""" + 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), + ) as resp: + if resp.status != 200: + body = await resp.text() + _LOGGER.error( + "Failed to activate scene %s: %s %s", + preset_id, resp.status, body, + ) + resp.raise_for_status() + await self.async_request_refresh() + async def update_target(self, target_id: str, **kwargs: Any) -> None: """Update a picture target's fields.""" async with self.session.put( diff --git a/custom_components/wled_screen_controller/strings.json b/custom_components/wled_screen_controller/strings.json index 15a43f4..1e7268e 100644 --- a/custom_components/wled_screen_controller/strings.json +++ b/custom_components/wled_screen_controller/strings.json @@ -26,6 +26,11 @@ } }, "entity": { + "button": { + "activate_scene": { + "name": "{scene_name}" + } + }, "switch": { "processing": { "name": "Processing" diff --git a/custom_components/wled_screen_controller/translations/en.json b/custom_components/wled_screen_controller/translations/en.json index 3f9bc8e..a1feef3 100644 --- a/custom_components/wled_screen_controller/translations/en.json +++ b/custom_components/wled_screen_controller/translations/en.json @@ -26,6 +26,11 @@ } }, "entity": { + "button": { + "activate_scene": { + "name": "{scene_name}" + } + }, "switch": { "processing": { "name": "Processing" diff --git a/custom_components/wled_screen_controller/translations/ru.json b/custom_components/wled_screen_controller/translations/ru.json index dec1cf2..d470efa 100644 --- a/custom_components/wled_screen_controller/translations/ru.json +++ b/custom_components/wled_screen_controller/translations/ru.json @@ -26,6 +26,11 @@ } }, "entity": { + "button": { + "activate_scene": { + "name": "{scene_name}" + } + }, "switch": { "processing": { "name": "Обработка" diff --git a/server/src/wled_controller/static/js/features/tutorials.js b/server/src/wled_controller/static/js/features/tutorials.js index 587ba09..578b656 100644 --- a/server/src/wled_controller/static/js/features/tutorials.js +++ b/server/src/wled_controller/static/js/features/tutorials.js @@ -226,35 +226,7 @@ export function tutorialPrev() { } } -function showTutorialStep(index, direction = 1) { - if (!activeTutorial) return; - activeTutorial.step = index; - const step = activeTutorial.steps[index]; - const overlay = activeTutorial.overlay; - const isFixed = activeTutorial.mode === 'fixed'; - - document.querySelectorAll('.tutorial-target').forEach(el => { - el.classList.remove('tutorial-target'); - el.style.zIndex = ''; - }); - - const target = activeTutorial.resolveTarget(step); - if (!target) { - // Auto-skip hidden/missing targets in the current direction - const next = index + direction; - if (next >= 0 && next < activeTutorial.steps.length) showTutorialStep(next, direction); - else closeTutorial(); - return; - } - target.classList.add('tutorial-target'); - if (isFixed) target.style.zIndex = '10001'; - - // Scroll target into view if off-screen - const preRect = target.getBoundingClientRect(); - if (preRect.bottom > window.innerHeight || preRect.top < 0) { - target.scrollIntoView({ behavior: 'instant', block: 'center' }); - } - +function _positionSpotlight(target, overlay, step, index, isFixed) { const targetRect = target.getBoundingClientRect(); const pad = 6; let x, y, w, h; @@ -306,6 +278,58 @@ function showTutorialStep(index, direction = 1) { } } +/** Wait for smooth scroll to finish, then resolve. */ +function _waitForScrollEnd(scrollTarget) { + return new Promise(resolve => { + let timer = setTimeout(resolve, 500); // fallback + const onEnd = () => { + clearTimeout(timer); + scrollTarget.removeEventListener('scrollend', onEnd); + resolve(); + }; + scrollTarget.addEventListener('scrollend', onEnd, { once: true }); + }); +} + +function showTutorialStep(index, direction = 1) { + if (!activeTutorial) return; + activeTutorial.step = index; + const step = activeTutorial.steps[index]; + const overlay = activeTutorial.overlay; + const isFixed = activeTutorial.mode === 'fixed'; + + document.querySelectorAll('.tutorial-target').forEach(el => { + el.classList.remove('tutorial-target'); + el.style.zIndex = ''; + }); + + const target = activeTutorial.resolveTarget(step); + if (!target) { + // Auto-skip hidden/missing targets in the current direction + const next = index + direction; + if (next >= 0 && next < activeTutorial.steps.length) showTutorialStep(next, direction); + else closeTutorial(); + return; + } + target.classList.add('tutorial-target'); + if (isFixed) target.style.zIndex = '10001'; + + // Scroll target into view if off-screen (smooth animation) + const preRect = target.getBoundingClientRect(); + const needsScroll = preRect.bottom > window.innerHeight || preRect.top < 0; + + if (needsScroll) { + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + _waitForScrollEnd(isFixed ? document.scrollingElement || document.documentElement : activeTutorial.container).then(() => { + if (activeTutorial && activeTutorial.step === index) { + _positionSpotlight(target, overlay, step, index, isFixed); + } + }); + } else { + _positionSpotlight(target, overlay, step, index, isFixed); + } +} + function positionTutorialTooltip(tooltip, sx, sy, sw, sh, preferred, isFixed) { const gap = 12; const tooltipW = 260;