Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add debounce time setting #87

Merged
merged 6 commits into from
May 25, 2024
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ You can set up Stateful Scenes to restore the state of the entities when you wan
### 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.

### 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.

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.

### Supported attributes
Note that while all entity states are supported only some entity attributes are supported at the moment. For the entities listed in the table the state is supported as well as the attributes in the table. Please open an issue, if you want support for other entity attributes.

Expand Down
88 changes: 67 additions & 21 deletions custom_components/stateful_scenes/StatefulScenes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Stateful Scenes for Home Assistant."""

import asyncio
import logging

import yaml
Expand Down Expand Up @@ -30,6 +31,7 @@ def get_entity_id_from_id(hass: HomeAssistant, id: str) -> str:
return entity_id
return None


def get_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> str:
"""Get scene id from entity_id."""
er = entity_registry.async_get(hass)
Expand All @@ -42,6 +44,7 @@ def get_name_from_entity_id(hass: HomeAssistant, entity_id: str) -> str:
name = er.async_get(entity_id).original_name
return name if name is not None else entity_id


def get_icon_from_entity_id(hass: HomeAssistant, entity_id: str) -> str:
"""Get scene icon from entity_id."""
er = entity_registry.async_get(hass)
Expand Down Expand Up @@ -135,15 +138,21 @@ def validate_scene(self, scene_conf: dict) -> None:
"""

if "entities" not in scene_conf:
raise StatefulScenesYamlInvalid("Scene is missing entities: " + scene_conf["name"])
raise StatefulScenesYamlInvalid(
"Scene is missing entities: " + scene_conf["name"]
)

if "id" not in scene_conf:
raise StatefulScenesYamlInvalid("Scene is missing id: " + scene_conf["name"])
raise StatefulScenesYamlInvalid(
"Scene is missing id: " + scene_conf["name"]
)

for entity_id, scene_attributes in scene_conf["entities"].items():
if "state" not in scene_attributes:
raise StatefulScenesYamlInvalid(
"Scene is missing state for entity " + entity_id + scene_conf["name"]
"Scene is missing state for entity "
+ entity_id
+ scene_conf["name"]
)

return True
Expand Down Expand Up @@ -177,7 +186,9 @@ def extract_scene_configuration(self, scene_conf: dict) -> dict:
return {
"name": scene_conf["name"],
"id": scene_conf.get("id", entity_id),
"icon": scene_conf.get("icon", get_icon_from_entity_id(self.hass, entity_id)),
"icon": scene_conf.get(
"icon", get_icon_from_entity_id(self.hass, entity_id)
),
"entity_id": entity_id,
"area": area_id(self.hass, entity_id),
"learn": scene_conf.get("learn", False),
Expand Down Expand Up @@ -216,6 +227,7 @@ def __init__(
self._is_on = None
self._transition_time = None
self._restore_on_deactivate = True
self._debounce_time: float = 0

self.callback = None
self.schedule_update = None
Expand Down Expand Up @@ -243,7 +255,9 @@ def id(self):
def turn_on(self):
"""Turn on the scene."""
if self._entity_id is None:
raise StatefulScenesYamlInvalid("Cannot find entity_id for: " + self.name + self._entity_id)
raise StatefulScenesYamlInvalid(
"Cannot find entity_id for: " + self.name + self._entity_id
)

self.hass.services.call(
domain="scene",
Expand Down Expand Up @@ -274,6 +288,15 @@ def set_transition_time(self, transition_time):
"""Set the transition time."""
self._transition_time = transition_time

@property
def debounce_time(self) -> float:
"""Get the debounce time."""
return self._debounce_time

def set_debounce_time(self, debounce_time: float):
"""Set the debounce time."""
self._debounce_time = debounce_time or 0.0

@property
def restore_on_deactivate(self) -> bool:
"""Get the restore on deactivate flag."""
Expand All @@ -296,30 +319,49 @@ def unregister_callback(self):
self.callback()
self.callback = None

def update_callback(self, entity_id, old_state, new_state):
async def update_callback(self, entity_id, old_state, new_state):
"""Update the scene when a tracked entity changes state."""
self.check_state(entity_id, new_state)
self.store_entity_state(entity_id, old_state)
self._is_on = all(self.states.values())
self.schedule_update(True)
self.store_entity_state(entity_id, self.states[entity_id])
if self.is_interesting_update(old_state, new_state):
await asyncio.sleep(self.debounce_time)
self.schedule_update(True)

def is_interesting_update(self, old_state, new_state):
"""Check if the state change is interesting."""
if old_state is None:
return True
if not self.compare_values(old_state.state, new_state.state):
return True

if new_state.domain in ATTRIBUTES_TO_CHECK:
entity_attrs = new_state.attributes
old_entity_attrs = old_state.attributes
for attribute in ATTRIBUTES_TO_CHECK.get(new_state.domain):
if attribute not in old_entity_attrs or attribute not in entity_attrs:
continue

if not self.compare_values(
old_entity_attrs[attribute], entity_attrs[attribute]
):
return True
return False

def check_state(self, entity_id, new_state):
"""Check the state of the scene."""
if new_state is None:
_LOGGER.warning("Entity not found: %(entity_id)s", entity_id=entity_id)
self.states[entity_id] = False
_LOGGER.warning(f"Entity not found: {entity_id}")
return False

# Check state
if not self.compare_values(self.entities[entity_id]["state"], new_state.state):
_LOGGER.debug(
"[%s] state not matching: %s: %s != %s.",
"[%s] state not matching: %s: wanted=%s got=%s.",
self.name,
entity_id,
self.entities[entity_id]["state"],
new_state.state,
)
self.states[entity_id] = False
return
return False

# Check attributes
if new_state.domain in ATTRIBUTES_TO_CHECK:
Expand All @@ -334,23 +376,27 @@ def check_state(self, entity_id, new_state):
self.entities[entity_id][attribute], entity_attrs[attribute]
):
_LOGGER.debug(
"[%s] attribute not matching: %s %s: %s %s.",
"[%s] attribute not matching: %s %s: wanted=%s got=%s.",
self.name,
entity_id,
attribute,
self.entities[entity_id][attribute],
entity_attrs[attribute],
)
self.states[entity_id] = False
return

self.states[entity_id] = True
return False
_LOGGER.debug(
"[%s] Found match after %s updated",
self.name,
entity_id,
)
return True

def check_all_states(self):
"""Check the state of the scene."""
for entity_id in self.entities:
state = self.hass.states.get(entity_id)
self.check_state(entity_id, state)
self.states[entity_id] = self.check_state(entity_id, state)
self._is_on = all(self.states.values())

def store_entity_state(self, entity_id, state):
"""Store the state of an entity."""
Expand Down
12 changes: 12 additions & 0 deletions custom_components/stateful_scenes/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
)

from .const import (
CONF_DEBOUNCE_TIME,
DEBOUNCE_MAX,
DEBOUNCE_MIN,
DEBOUNCE_STEP,
DEFAULT_DEBOUNCE_TIME,
TOLERANCE_MIN,
TOLERANCE_MAX,
TOLERANCE_STEP,
Expand Down Expand Up @@ -103,6 +108,13 @@ async def async_step_user(
min=TRANSITION_MIN, max=TRANSITION_MAX, step=TRANSITION_STEP
)
),
vol.Optional(
CONF_DEBOUNCE_TIME, default=DEFAULT_DEBOUNCE_TIME
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=DEBOUNCE_MIN, max=DEBOUNCE_MAX, step=DEBOUNCE_STEP
)
),
}
),
errors=errors,
Expand Down
6 changes: 6 additions & 0 deletions custom_components/stateful_scenes/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@
CONF_EXTERNAL_SCENES = "external_scenes"
CONF_EXTERNAL_SCENE_ACTIVE = "external_scene_active"
CONF_EXTERNAL_SCENES_LIST = "external_scenes_list"
CONF_DEBOUNCE_TIME = "debounce_time"

DEFAULT_SCENE_PATH = "scenes.yaml"
DEFAULT_NUMBER_TOLERANCE = 1
DEFAULT_RESTORE_STATES_ON_DEACTIVATE = False
DEFAULT_TRANSITION_TIME = 1
DEFAULT_EXTERNAL_SCENES = []
DEFAULT_EXTERNAL_SCENE_ACTIVE = False
DEFAULT_DEBOUNCE_TIME = 0.0

DEBOUNCE_MIN = 0
DEBOUNCE_MAX = 300
DEBOUNCE_STEP = 0.1

TOLERANCE_MIN = 0
TOLERANCE_MAX = 10
Expand Down
77 changes: 74 additions & 3 deletions custom_components/stateful_scenes/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from . import StatefulScenes
from .const import CONF_TRANSITION_TIME, DEVICE_INFO_MANUFACTURER, DOMAIN
from .const import (
CONF_TRANSITION_TIME,
CONF_DEBOUNCE_TIME,
DEBOUNCE_MAX,
DEBOUNCE_MIN,
DEBOUNCE_STEP,
DEVICE_INFO_MANUFACTURER,
DOMAIN,
)

_LOGGER = logging.getLogger(__name__)

Expand All @@ -36,9 +44,11 @@ async def async_setup_entry(
TransitionNumber(scene, entry.data.get(CONF_TRANSITION_TIME))
for scene in hub.scenes
]
debounce_entities = [
DebounceTime(scene, entry.data.get(CONF_DEBOUNCE_TIME)) for scene in hub.scenes
]


add_entities(stateful_scene_number)
add_entities(stateful_scene_number + debounce_entities)

return True

Expand Down Expand Up @@ -104,3 +114,64 @@ async def async_added_to_hass(self) -> None:
def native_value(self) -> float:
"""Return the entity value to represent the entity state."""
return self._scene.transition_time


class DebounceTime(RestoreNumber):
"""Time to wait after activating a scene switch until evaluating if the scene is still active."""

_attr_native_max_value = DEBOUNCE_MAX
_attr_native_min_value = DEBOUNCE_MIN
_attr_native_step = DEBOUNCE_STEP
_attr_native_unit_of_measurement = "seconds"
_attr_name = "Debounce Time"
_attr_entity_category = EntityCategory.CONFIG

def __init__(self, scene: StatefulScenes.Scene, debounce_time: float = 0) -> None:
"""Initialize."""
self._scene = scene
self._name = f"{scene.name} Debounce Time"
self._attr_unique_id = f"{scene.id}_debounce_time"

_LOGGER.debug(
"Setting initial debounce time for %s to %s",
scene.name,
debounce_time,
)
self._scene.set_debounce_time(debounce_time)

@property
def name(self) -> str:
"""Return the display name of this light."""
return self._name

@property
def device_info(self) -> DeviceInfo | None:
"""Return the device info."""
return DeviceInfo(
identifiers={(self._scene.id,)},
name=self._scene.name,
manufacturer=DEVICE_INFO_MANUFACTURER,
)

def set_native_value(self, value: float) -> None:
"""Update the current value."""
self._scene.set_debounce_time(value)

async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()
if (last_state := await self.async_get_last_state()) and (
last_number_data := await self.async_get_last_number_data()
):
if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
_LOGGER.debug(
"Restoring debounce time for %s to %s",
self._scene.name,
last_number_data.native_value,
)
self._scene.set_debounce_time(last_number_data.native_value)

@property
def native_value(self) -> float:
"""Return the entity value to represent the entity state."""
return self._scene.debounce_time
1 change: 1 addition & 0 deletions custom_components/stateful_scenes/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ def update(self) -> None:

This is the only method that should fetch new data for Home Assistant.
"""
self._scene.check_all_states()
self._is_on = self._scene.is_on

def register_callback(self) -> None:
Expand Down
3 changes: 2 additions & 1 deletion custom_components/stateful_scenes/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"scene_path": "Scene path",
"number_tolerance": "Rounding tolerance",
"restore_states_on_deactivate": "Restore states on deactivation",
"transition_time": "Transition time"
"transition_time": "Transition time",
"debounce_time": "Debounce time"
}
},
"select_external_scenes": {
Expand Down