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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
)
|
||||
|
||||
75
custom_components/wled_screen_controller/button.py
Normal file
75
custom_components/wled_screen_controller/button.py
Normal file
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"activate_scene": {
|
||||
"name": "{scene_name}"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"processing": {
|
||||
"name": "Processing"
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"activate_scene": {
|
||||
"name": "{scene_name}"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"processing": {
|
||||
"name": "Processing"
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"activate_scene": {
|
||||
"name": "{scene_name}"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"processing": {
|
||||
"name": "Обработка"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user