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;