Skip to content

Commit

Permalink
squash commiots for restore off state, remove white space from blank
Browse files Browse the repository at this point in the history
line, update readme

Fix restore off state during HA restart

Remove white space from blank lines - Ruff

g This is a combination of 2 commits.

Update the readme to clarify that debounce time
occurs after the transition time elapses.

remove unused import
  • Loading branch information
cayossarian committed Dec 29, 2024
1 parent abdc670 commit e48986e
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 28 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ Some attributes such as light brightness will be rounded off. Therefore, to asse
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.
Furthermore, you can specify the default transition time for applying scenes. This will gradually change the lights of a scene to the specified state. Transition time does need to be supported by your lights.

### Debounce time

After activating a scene by turning on a stateful scene switch, entities may need some time to achieve their desired states. When first turned on, the scene state switch will be assumed to be 'on'; the debounce time setting controls how long this integration will wait after observing a member entity state update event before reevaluating the entity state to determine if the scene is still active. If you're having issues with scenes immediately deactivating/reactivating, consider increasing this debounce time.
After activating a scene by turning on a stateful scene switch, entities may need some time to achieve their desired states after the transition time elapses. When first turned on, the scene state switch will be assumed to be 'on'; the debounce time setting controls how long this integration will wait after observing a member entity state update event before reevaluating the entity state to determine if the scene is still active. If you're having issues with scenes immediately deactivating/reactivating, consider increasing this debounce time.

This setting is measured in seconds, but sub-second values (e.g '0.1' for 100ms delay) can be provided such that the delay is not perceptible to humans viewing a dashboard, for example.

Expand All @@ -61,13 +61,13 @@ Note that while all entity states are supported only some entity attributes are
## Scene configurations
For each scene you can specify:

- The debounce time
- The debounce time which is applied after the transition time has elapsed
- 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
- 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
19 changes: 11 additions & 8 deletions custom_components/stateful_scenes/StatefulScenes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import logging
from typing import Any

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

Expand Down Expand Up @@ -190,9 +190,11 @@ def turn_on(self):
self.store_entity_state(entity_id)

asyncio.run_coroutine_threadsafe(
self._scene_evaluation_timer.async_start(self.async_timer_evaluate_scene_state),
self.hass.loop
).result()
self._scene_evaluation_timer.async_start(
self.async_timer_evaluate_scene_state
),
self.hass.loop,
).result()

self.hass.services.call(
domain="scene",
Expand Down Expand Up @@ -228,8 +230,10 @@ def turn_off(self):
)
elif self.restore_on_deactivate:
asyncio.run_coroutine_threadsafe(
self._scene_evaluation_timer.async_start(self.async_timer_evaluate_scene_state),
self.hass.loop
self._scene_evaluation_timer.async_start(
self.async_timer_evaluate_scene_state
),
self.hass.loop,
).result()
self.restore()
else:
Expand Down Expand Up @@ -315,8 +319,7 @@ def update_callback(self, event: Event[EventStateChangedData]):
if self.is_interesting_update(old_state, new_state):
if not self._scene_evaluation_timer.is_active():
asyncio.run_coroutine_threadsafe(
self.async_evaluate_scene_state(),
self.hass.loop
self.async_evaluate_scene_state(), self.hass.loop
).result()

async def async_evaluate_scene_state(self):
Expand Down
76 changes: 60 additions & 16 deletions custom_components/stateful_scenes/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
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 homeassistant.helpers.restore_state import RestoreEntity


from .const import (
DEFAULT_OFF_SCENE_ENTITY_ID,
Expand All @@ -36,6 +38,7 @@

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
Expand All @@ -53,12 +56,14 @@ async def async_setup_entry(
async_add_entities(entities)


class StatefulSceneOffSelect(SelectEntity):
class StatefulSceneOffSelect(SelectEntity, RestoreEntity):
"""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._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
Expand All @@ -73,32 +78,46 @@ def __init__(self, scene: Scene, hub: Hub | 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)]
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:
hub_scene = cast(SceneStateProtocol | None, self._hub.get_scene(opt))
hub_scene = cast(
SceneStateProtocol | None, self._hub.get_scene(opt)
)
if hub_scene:
friendly_name = hub_scene.attributes.get("friendly_name", opt)
scenes.append((opt, friendly_name))
else:
# Stand-alone case, filter out internal scenes and current scene
hub_scenes: set[str] = set(self._hub.get_available_scenes()) if self._hub else set()
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(SceneStateProtocol | None, self._scene.hass.states.get(state.entity_id))
if (
state.entity_id != self._scene.entity_id
and state.entity_id not in hub_scenes
):
scene_entity = cast(
SceneStateProtocol | None,
self._scene.hass.states.get(state.entity_id),
)
if scene_entity:
friendly_name = scene_entity.attributes.get("friendly_name", state.entity_id)
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
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"

Expand All @@ -113,35 +132,60 @@ def async_update_restore_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",
"Select 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._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")
_LOGGER.warning("Select 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 last state and set up tracking."""
await super().async_added_to_hass()

# First set up available options
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]

# Restore previous selection if available
if last_state := await self.async_get_last_state():
if last_state.state in self._attr_options:
self._attr_current_option = last_state.state
self._scene.set_off_scene(self._entity_id_map[last_state.state])
_LOGGER.debug(
"Restored off scene selection for %s to %s",
self._scene.name,
last_state.state,
)

# Set up restore state tracking
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:

# Get initial restore state
if state := self.hass.states.get(restore_entity_id):
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,
)
await self.async_update_restore_state(None)

@under_cached_property
def device_info(self) -> DeviceInfo:
Expand All @@ -166,6 +210,6 @@ def select_option(self, option: str) -> None:
self._attr_current_option = option

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

0 comments on commit e48986e

Please sign in to comment.