Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ If your configuration has a different location for scenes you can change the loc
Some attributes such as light brightness will be rounded off. Therefore, to assess whether the scene is active a tolerance will be applied. The default tolerance of 1 will work for rounding errors of ±1. If this does not work for your setup consider increasing this value.

### Restore on deactivation
You can set up Stateful Scenes to restore the state of the entities when you want to turn off a scene. This can also be configured per Stateful Scene by going to the device page.
You can set up Stateful Scenes to restore the state of the entities when you want to turn off a scene. This can also be configured per Stateful Scene by going to the device page. Some complex scenes might not be able to restore the state of all the entities and may benefit from configuring an opposing 'off' scene as described below.

### Transition time
Furthermore, you can specify the default transition time for applying scenes. This will gradually change the lights of a scene to the specified state. It does need to be supported by your lights.
Expand All @@ -59,7 +59,15 @@ Note that while all entity states are supported only some entity attributes are


## Scene configurations
For each scene you can specify the individual transition time and whether to restore on deactivation by changing the variables on the scene's device page.
For each scene you can specify:

- The debounce time
- Whether to ignore stateful scene changes when the underlying scene is unavailable
- Specify an opposing 'off' scene that is activated when the stateful scene is deactivated
(when Restore is off)
- Restore the previous state on deactivation by changing the variables on the scene's device page.
- The scene tolerance for the stateful scene to be active
- The individual transition time

## External Scenes
> Note this is an EXPERIMENTAL feature and may not work correctly for your setup. I have tested it with scenes configured in Zigbee2MQTT which works, but I do not have access to a Hue hub which therefore may not work correctly. If you are experiencing issues, please let me know or open a pull request with the improvements.
Expand Down
60 changes: 48 additions & 12 deletions custom_components/stateful_scenes/StatefulScenes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import asyncio
import logging

from typing import Any

from homeassistant.core import Event, EventStateChangedData, HomeAssistant
from homeassistant.helpers.template import area_id, area_name

Expand All @@ -16,6 +18,7 @@
CONF_SCENE_LEARN,
CONF_SCENE_NAME,
CONF_SCENE_NUMBER_TOLERANCE,
DEFAULT_OFF_SCENE_ENTITY_ID,
StatefulScenesYamlInvalid,
)
from .helpers import (
Expand Down Expand Up @@ -43,27 +46,25 @@ class Hub:
def __init__(
self,
hass: HomeAssistant,
scene_confs: dict,
scene_confs: dict[str, Any],
number_tolerance: int = 1,
) -> None:
"""Initialize the Hub class.

Args:
hass (HomeAssistant): Home Assistant instance
scene_confs (str): Scene configurations from the scene file
external_scenes (list): List of external scenes
scene_confs (dict[str, Any]): Scene configurations from the scene file
number_tolerance (int): Tolerance for comparing numbers

Raises:
StatefulScenesYamlNotFound: If the yaml file is not found
StatefulScenesYamlInvalid: If the yaml file is invalid

"""
self.scene_confs = scene_confs
self.number_tolerance = number_tolerance
self.hass = hass
self.scenes = []
self.scene_confs = []
self.scenes: list[Scene] = []
self.scene_confs: list[dict[str, Any]] = []

for scene_conf in scene_confs:
if not self.validate_scene(scene_conf):
Expand Down Expand Up @@ -163,26 +164,33 @@ def prepare_external_scene(self, entity_id, entities) -> dict:
"entities": entities,
}

def get_available_scenes(self) -> list[str]:
"""Get list of all scenes from the hub."""
scene_entities: list[str] = [scene.entity_id for scene in self.scenes]
return scene_entities



class Scene:
"""State scene class."""

def __init__(self, hass: HomeAssistant, scene_conf: dict) -> None:
"""Initialize."""
self.hass = hass
self.name = scene_conf[CONF_SCENE_NAME]
self._entity_id = scene_conf[CONF_SCENE_ENTITY_ID]
self.name: str = scene_conf[CONF_SCENE_NAME]
self._entity_id: str = scene_conf[CONF_SCENE_ENTITY_ID]
self._number_tolerance = scene_conf[CONF_SCENE_NUMBER_TOLERANCE]
self._id = scene_conf[CONF_SCENE_ID]
self.area_id = scene_conf[CONF_SCENE_AREA]
self._area_id: str = scene_conf[CONF_SCENE_AREA]
self.learn = scene_conf[CONF_SCENE_LEARN]
self.entities = scene_conf[CONF_SCENE_ENTITIES]
self.icon = scene_conf[CONF_SCENE_ICON]
self._is_on = None
self._transition_time = 0.0
self._transition_time: float = 0.0
self._restore_on_deactivate = True
self._debounce_time: float = 0
self._ignore_unavailable = False
self._off_scene_entity_id = None

self.callback = None
self.callback_funcs = {}
Expand All @@ -196,18 +204,28 @@ def __init__(self, hass: HomeAssistant, scene_conf: dict) -> None:
if self._entity_id is None:
self._entity_id = get_entity_id_from_id(self.hass, self._id)

@property
def entity_id(self) -> str:
"""Return the entity_id of the scene."""
return self._entity_id

@property
def is_on(self):
"""Return true if the scene is on."""
return self._is_on

@property
def id(self):
def id(self) -> str:
"""Return the id of the scene."""
if self.learn:
return self._id + "_learned" # avoids non-unique id during testing
return self._id

@property
def area_id(self) -> str:
"""Return the area_id of the scene."""
return self._area_id

def turn_on(self):
"""Turn on the scene."""
if self._entity_id is None:
Expand All @@ -227,12 +245,30 @@ def turn_on(self):
)
self._is_on = True

@property
def off_scene_entity_id(self) -> str | None:
"""Return the entity_id of the off scene."""
return self._off_scene_entity_id

def set_off_scene(self, entity_id: str | None) -> None:
"""Set the off scene entity_id."""
self._off_scene_entity_id = entity_id
if entity_id:
self._restore_on_deactivate = False

def turn_off(self):
"""Turn off all entities in the scene."""
if not self._is_on: # already off
return

if self.restore_on_deactivate:
if self._off_scene_entity_id:
self.hass.services.call(
domain="scene",
service="turn_on",
target={"entity_id": self._off_scene_entity_id},
service_data={"transition": self._transition_time},
)
elif self.restore_on_deactivate:
self.restore()
else:
self.hass.services.call(
Expand Down
4 changes: 2 additions & 2 deletions custom_components/stateful_scenes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@
from .StatefulScenes import Hub, Scene

PLATFORMS: list[Platform] = [
Platform.SWITCH,
Platform.NUMBER,
Platform.SELECT,
Platform.SWITCH,
]


# https://developers.home-assistant.io/docs/config_entries_index/#setting-up-an-entry
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
Expand Down
1 change: 1 addition & 0 deletions custom_components/stateful_scenes/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
DEFAULT_DEBOUNCE_TIME = 0.0
DEFAULT_IGNORE_UNAVAILABLE = False
DEFAULT_ENABLE_DISCOVERY = True
DEFAULT_OFF_SCENE_ENTITY_ID: str = "None"

DEBOUNCE_MIN = 0
DEBOUNCE_MAX = 300
Expand Down
182 changes: 182 additions & 0 deletions custom_components/stateful_scenes/select.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""Select platform for Stateful Scenes to provide 'off' scene select."""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any, Protocol, Set, cast

from homeassistant.helpers.entity_registry import ReadOnlyDict

if TYPE_CHECKING:
# mypy cannot workout _cache Protocol with attrs
from propcache import cached_property as under_cached_property
else:
from propcache import under_cached_property

from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import (
Event,
EventStateChangedData,
HomeAssistant,
State,
callback,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event

from .const import DEFAULT_OFF_SCENE_ENTITY_ID, DEVICE_INFO_MANUFACTURER, DOMAIN
from .StatefulScenes import Hub, Scene

_LOGGER = logging.getLogger(__name__)


class StateProtocol(Protocol):
"""Protocol for State with known attributes type."""

@property
def attributes(self) -> SceneStateAttributes: ... # noqa: D102

class SceneStateAttributes(ReadOnlyDict[str, Any]):
"""Scene state attributes."""

friendly_name: str
icon: str | None
area_id: str | None
entity_id: list[str]

async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Stateful Scenes select."""
data = hass.data[DOMAIN][config_entry.entry_id]
entities: list[StatefulSceneOffSelect] = []

if isinstance(data, Hub):
entities.extend(StatefulSceneOffSelect(scene, data) for scene in data.scenes)
elif isinstance(data, Scene):
entities.append(StatefulSceneOffSelect(data, None))

async_add_entities(entities)


class StatefulSceneOffSelect(SelectEntity):
"""Representation of a Stateful Scene select entity."""

def __init__(self, scene: Scene, hub: Hub | None) -> None:
"""Initialize the select entity."""
self._entity_id_map: dict[str, str] = {DEFAULT_OFF_SCENE_ENTITY_ID: DEFAULT_OFF_SCENE_ENTITY_ID}
self._attr_options = list(self._entity_id_map.keys())
self._attr_current_option = list(self._entity_id_map.keys())[0]
self._attr_options_ordered = False # Preserve our ordering
super().__init__()
self._scene = scene
self._hub = hub
self.unique_id = f"{scene.id}_off_scene"
self._attr_name = f"{scene.name} Off Scene"
self._cache: dict[str, bool | DeviceInfo] = {}
self._attr_entity_category = EntityCategory.CONFIG
self._restore_on_deactivate_state: str | None = None

def _get_available_off_scenes(self) -> list[tuple[str, str]]:
"""Get list of available scenes with friendly names."""
scenes: list[tuple[str, str]] = [(DEFAULT_OFF_SCENE_ENTITY_ID, DEFAULT_OFF_SCENE_ENTITY_ID)]

if self._hub:
for opt in self._hub.get_available_scenes():
if opt != self._scene.entity_id:
scene_entity = cast(StateProtocol | None, self._scene.hass.states.get(opt))
if scene_entity:
friendly_name = scene_entity.attributes.get("friendly_name", opt)
scenes.append((opt, friendly_name))
else:
# Stand-alone case
hub_scenes: set[str] = set(self._hub.get_available_scenes()) if self._hub else set()
states: list[State] = self._scene.hass.states.async_all("scene")
for state in states:
if state.entity_id != self._scene.entity_id and state.entity_id not in hub_scenes:
scene_entity = cast(StateProtocol | None, self._scene.hass.states.get(state.entity_id))
if scene_entity:
friendly_name = scene_entity.attributes.get("friendly_name", state.entity_id)
scenes.append((state.entity_id, friendly_name))

# Sort scenes by friendly name
scenes.sort(key=lambda x: x[1].lower())
return scenes

@property
def available(self) -> bool: # type: ignore[incompatible-override] # Need UI to update
"""Return entity is available based on restore state toggle state."""
return self._restore_on_deactivate_state == "off"

@callback
def async_update_restore_state(
self, event: Event[EventStateChangedData] | None = None
) -> None:
"""Sync with the restore state toggle."""
if event:
new_state = event.data.get("new_state")
if new_state and hasattr(new_state, "state"):
self._restore_on_deactivate_state = str(new_state.state)
entity_id: str | None = event.data.get("entity_id")
_LOGGER.debug(
"Restore on Deactivate state for %s: %s",
entity_id,
self._restore_on_deactivate_state,
)

scenes = self._get_available_off_scenes()
self._entity_id_map = {friendly_name: entity_id for entity_id, friendly_name in scenes}
self._attr_options = [friendly_name for _, friendly_name in scenes]
self.async_write_ha_state()
else:
_LOGGER.warning("Event is None, callback not triggered")

async def async_added_to_hass(self) -> None:
"""Sync 'off' scene select availability with 'Resotre on Deactivate' state."""
restore_entity_id = (
f"switch.{self._scene.name.lower().replace(' ', '_')}_restore_on_deactivate"
)
_LOGGER.debug("Setting up state change listener for %s", restore_entity_id)
async_track_state_change_event(
self.hass, [restore_entity_id], self.async_update_restore_state
)
state = self.hass.states.get(restore_entity_id)
if state:
self._restore_on_deactivate_state = state.state
_LOGGER.debug(
"Initial Restore on Deactivate state for %s: %s",
restore_entity_id,
self._restore_on_deactivate_state,
)

@under_cached_property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return DeviceInfo(
# In this integration's platform files following perhaps should be...
# identifiers={(DOMAIN, self._scene.id)},
# but the integration would break changing one file alone
identifiers={(self._scene.id,)},
name=self._scene.name,
manufacturer=DEVICE_INFO_MANUFACTURER,
suggested_area=self._scene.area_id,
)

def select_option(self, option: str) -> None:
"""Update the current selected option."""
entity_id = self._entity_id_map[option]
if entity_id == DEFAULT_OFF_SCENE_ENTITY_ID:
self._scene.set_off_scene(None)
else:
self._scene.set_off_scene(entity_id)
self._attr_current_option = option

@property
def options(self) -> list[str]: # type: ignore[incompatible-override] # Need UI to update
"""Return the list of available options."""
return self._attr_options
Loading