From 1c534ea027b4b5b31d9affcab3a250d2cf425967 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 30 Sep 2020 21:24:57 +0200 Subject: [PATCH 01/82] Bumped version to 0.116.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f845bc2bd0f..a641b43e9fb 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 116 -PATCH_VERSION = "0.dev0" +PATCH_VERSION = "0b0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 1) From b9931aabe72adc47c9aa1a0d69379b26ff37339d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Oct 2020 03:19:20 -0500 Subject: [PATCH 02/82] Seperate state change tracking from async_track_template_result into async_track_state_change_filtered (#40803) --- homeassistant/helpers/event.py | 334 +++++++++++++++++++++------------ tests/helpers/test_event.py | 138 ++++++++++++++ 2 files changed, 349 insertions(+), 123 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 9af781b7abd..b396ebb1d91 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -61,13 +61,27 @@ TRACK_STATE_REMOVED_DOMAIN_LISTENER = "track_state_removed_domain_listener" TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS = "track_entity_registry_updated_callbacks" TRACK_ENTITY_REGISTRY_UPDATED_LISTENER = "track_entity_registry_updated_listener" -_TEMPLATE_ALL_LISTENER = "all" -_TEMPLATE_DOMAINS_LISTENER = "domains" -_TEMPLATE_ENTITIES_LISTENER = "entities" +_ALL_LISTENER = "all" +_DOMAINS_LISTENER = "domains" +_ENTITIES_LISTENER = "entities" _LOGGER = logging.getLogger(__name__) +@dataclass +class TrackStates: + """Class for keeping track of states being tracked. + + all_states: All states on the system are being tracked + entities: Entities to track + domains: Domains to track + """ + + all_states: bool + entities: Set + domains: Set + + @dataclass class TrackTemplate: """Class for keeping track of a template with variables. @@ -452,6 +466,158 @@ def _async_string_to_lower_list(instr: Union[str, Iterable[str]]) -> List[str]: return [mstr.lower() for mstr in instr] +class _TrackStateChangeFiltered: + """Handle removal / refresh of tracker.""" + + def __init__( + self, + hass: HomeAssistant, + track_states: TrackStates, + action: Callable[[Event], Any], + ): + """Handle removal / refresh of tracker init.""" + self.hass = hass + self._action = action + self._listeners: Dict[str, Callable] = {} + self._last_track_states: TrackStates = track_states + + @callback + def async_setup(self) -> None: + """Create listeners to track states.""" + track_states = self._last_track_states + + if ( + not track_states.all_states + and not track_states.domains + and not track_states.entities + ): + return + + if track_states.all_states: + self._setup_all_listener() + return + + self._setup_domains_listener(track_states.domains) + self._setup_entities_listener(track_states.domains, track_states.entities) + + @property + def listeners(self) -> Dict: + """State changes that will cause a re-render.""" + track_states = self._last_track_states + return { + _ALL_LISTENER: track_states.all_states, + _ENTITIES_LISTENER: track_states.entities, + _DOMAINS_LISTENER: track_states.domains, + } + + @callback + def async_update_listeners(self, new_track_states: TrackStates) -> None: + """Update the listeners based on the new TrackStates.""" + last_track_states = self._last_track_states + self._last_track_states = new_track_states + + had_all_listener = last_track_states.all_states + + if new_track_states.all_states: + if had_all_listener: + return + self._cancel_listener(_DOMAINS_LISTENER) + self._cancel_listener(_ENTITIES_LISTENER) + self._setup_all_listener() + return + + if had_all_listener: + self._cancel_listener(_ALL_LISTENER) + + domains_changed = new_track_states.domains != last_track_states.domains + + if had_all_listener or domains_changed: + domains_changed = True + self._cancel_listener(_DOMAINS_LISTENER) + self._setup_domains_listener(new_track_states.domains) + + if ( + had_all_listener + or domains_changed + or new_track_states.entities != last_track_states.entities + ): + self._cancel_listener(_ENTITIES_LISTENER) + self._setup_entities_listener( + new_track_states.domains, new_track_states.entities + ) + + @callback + def async_remove(self) -> None: + """Cancel the listeners.""" + for key in list(self._listeners): + self._listeners.pop(key)() + + @callback + def _cancel_listener(self, listener_name: str) -> None: + if listener_name not in self._listeners: + return + + self._listeners.pop(listener_name)() + + @callback + def _setup_entities_listener(self, domains: Set, entities: Set) -> None: + if domains: + entities = entities.copy() + entities.update(self.hass.states.async_entity_ids(domains)) + + # Entities has changed to none + if not entities: + return + + self._listeners[_ENTITIES_LISTENER] = async_track_state_change_event( + self.hass, entities, self._action + ) + + @callback + def _setup_domains_listener(self, domains: Set) -> None: + if not domains: + return + + self._listeners[_DOMAINS_LISTENER] = async_track_state_added_domain( + self.hass, domains, self._action + ) + + @callback + def _setup_all_listener(self) -> None: + self._listeners[_ALL_LISTENER] = self.hass.bus.async_listen( + EVENT_STATE_CHANGED, self._action + ) + + +@callback +@bind_hass +def async_track_state_change_filtered( + hass: HomeAssistant, + track_states: TrackStates, + action: Callable[[Event], Any], +) -> _TrackStateChangeFiltered: + """Track state changes with a TrackStates filter that can be updated. + + Parameters + ---------- + hass + Home assistant object. + track_states + A TrackStates data class. + action + Callable to call with results. + + Returns + ------- + Object used to update the listeners (async_update_listeners) with a new TrackStates or + cancel the tracking (async_remove). + + """ + tracker = _TrackStateChangeFiltered(hass, track_states, action) + tracker.async_setup() + return tracker + + @callback @bind_hass def async_track_template( @@ -557,12 +723,9 @@ class _TrackTemplateResultInfo: track_template_.template.hass = hass self._track_templates = track_templates - self._listeners: Dict[str, Callable] = {} - self._last_result: Dict[Template, Union[str, TemplateError]] = {} self._info: Dict[Template, RenderInfo] = {} - self._last_domains: Set = set() - self._last_entities: Set = set() + self._track_state_changes: Optional[_TrackStateChangeFiltered] = None def async_setup(self, raise_on_template_error: bool) -> None: """Activation of template tracking.""" @@ -580,7 +743,9 @@ class _TrackTemplateResultInfo: exc_info=self._info[template].exception, ) - self._create_listeners() + self._track_state_changes = async_track_state_change_filtered( + self.hass, _render_infos_to_track_states(self._info.values()), self._refresh + ) _LOGGER.debug( "Template group %s listens for %s", self._track_templates, @@ -590,123 +755,14 @@ class _TrackTemplateResultInfo: @property def listeners(self) -> Dict: """State changes that will cause a re-render.""" - return { - "all": _TEMPLATE_ALL_LISTENER in self._listeners, - "entities": self._last_entities, - "domains": self._last_domains, - } - - @property - def _needs_all_listener(self) -> bool: - for info in self._info.values(): - # Tracking all states - if info.all_states or info.all_states_lifecycle: - return True - - # Previous call had an exception - # so we do not know which states - # to track - if info.exception: - return True - - return False - - @property - def _all_templates_are_static(self) -> bool: - for info in self._info.values(): - if not info.is_static: - return False - - return True - - @callback - def _create_listeners(self) -> None: - if self._all_templates_are_static: - return - - if self._needs_all_listener: - self._setup_all_listener() - return - - self._last_entities, self._last_domains = _entities_domains_from_info( - self._info.values() - ) - self._setup_domains_listener(self._last_domains) - self._setup_entities_listener(self._last_domains, self._last_entities) - - @callback - def _cancel_listener(self, listener_name: str) -> None: - if listener_name not in self._listeners: - return - - self._listeners.pop(listener_name)() - - @callback - def _update_listeners(self) -> None: - had_all_listener = _TEMPLATE_ALL_LISTENER in self._listeners - - if self._needs_all_listener: - if had_all_listener: - return - self._last_domains = set() - self._last_entities = set() - self._cancel_listener(_TEMPLATE_DOMAINS_LISTENER) - self._cancel_listener(_TEMPLATE_ENTITIES_LISTENER) - self._setup_all_listener() - return - - if had_all_listener: - self._cancel_listener(_TEMPLATE_ALL_LISTENER) - - entities, domains = _entities_domains_from_info(self._info.values()) - domains_changed = domains != self._last_domains - - if had_all_listener or domains_changed: - domains_changed = True - self._cancel_listener(_TEMPLATE_DOMAINS_LISTENER) - self._setup_domains_listener(domains) - - if had_all_listener or domains_changed or entities != self._last_entities: - self._cancel_listener(_TEMPLATE_ENTITIES_LISTENER) - self._setup_entities_listener(domains, entities) - - self._last_domains = domains - self._last_entities = entities - - @callback - def _setup_entities_listener(self, domains: Set, entities: Set) -> None: - if domains: - entities = entities.copy() - entities.update(self.hass.states.async_entity_ids(domains)) - - # Entities has changed to none - if not entities: - return - - self._listeners[_TEMPLATE_ENTITIES_LISTENER] = async_track_state_change_event( - self.hass, entities, self._refresh - ) - - @callback - def _setup_domains_listener(self, domains: Set) -> None: - if not domains: - return - - self._listeners[_TEMPLATE_DOMAINS_LISTENER] = async_track_state_added_domain( - self.hass, domains, self._refresh - ) - - @callback - def _setup_all_listener(self) -> None: - self._listeners[_TEMPLATE_ALL_LISTENER] = self.hass.bus.async_listen( - EVENT_STATE_CHANGED, self._refresh - ) + assert self._track_state_changes + return self._track_state_changes.listeners @callback def async_remove(self) -> None: """Cancel the listener.""" - for key in list(self._listeners): - self._listeners.pop(key)() + assert self._track_state_changes + self._track_state_changes.async_remove() @callback def async_refresh(self) -> None: @@ -765,7 +821,10 @@ class _TrackTemplateResultInfo: updates.append(TrackTemplateResult(template, last_result, result)) if info_changed: - self._update_listeners() + assert self._track_state_changes + self._track_state_changes.async_update_listeners( + _render_infos_to_track_states(self._info.values()), + ) _LOGGER.debug( "Template group %s listens for %s", self._track_templates, @@ -1229,7 +1288,10 @@ def process_state_match( return lambda state: state in parameter_set -def _entities_domains_from_info(render_infos: Iterable[RenderInfo]) -> Tuple[Set, Set]: +@callback +def _entities_domains_from_render_infos( + render_infos: Iterable[RenderInfo], +) -> Tuple[Set, Set]: """Combine from multiple RenderInfo.""" entities = set() domains = set() @@ -1242,3 +1304,29 @@ def _entities_domains_from_info(render_infos: Iterable[RenderInfo]) -> Tuple[Set if render_info.domains_lifecycle: domains.update(render_info.domains_lifecycle) return entities, domains + + +@callback +def _render_infos_needs_all_listener(render_infos: Iterable[RenderInfo]) -> bool: + """Determine if an all listener is needed from RenderInfo.""" + for render_info in render_infos: + # Tracking all states + if render_info.all_states or render_info.all_states_lifecycle: + return True + + # Previous call had an exception + # so we do not know which states + # to track + if render_info.exception: + return True + + return False + + +@callback +def _render_infos_to_track_states(render_infos: Iterable[RenderInfo]) -> TrackStates: + """Create a TrackStates dataclass from the latest RenderInfo.""" + if _render_infos_needs_all_listener(render_infos): + return TrackStates(True, set(), set()) + + return TrackStates(False, *_entities_domains_from_render_infos(render_infos)) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 8bdf9cb891c..887917fa74c 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -14,6 +14,7 @@ from homeassistant.core import callback from homeassistant.exceptions import TemplateError from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.event import ( + TrackStates, TrackTemplate, TrackTemplateResult, async_call_later, @@ -23,6 +24,7 @@ from homeassistant.helpers.event import ( async_track_state_added_domain, async_track_state_change, async_track_state_change_event, + async_track_state_change_filtered, async_track_state_removed_domain, async_track_sunrise, async_track_sunset, @@ -255,6 +257,142 @@ async def test_track_state_change(hass): assert len(wildercard_runs) == 6 +async def test_async_track_state_change_filtered(hass): + """Test async_track_state_change_filtered.""" + single_entity_id_tracker = [] + multiple_entity_id_tracker = [] + + @ha.callback + def single_run_callback(event): + old_state = event.data.get("old_state") + new_state = event.data.get("new_state") + + single_entity_id_tracker.append((old_state, new_state)) + + @ha.callback + def multiple_run_callback(event): + old_state = event.data.get("old_state") + new_state = event.data.get("new_state") + + multiple_entity_id_tracker.append((old_state, new_state)) + + @ha.callback + def callback_that_throws(event): + raise ValueError + + track_single = async_track_state_change_filtered( + hass, TrackStates(False, {"light.bowl"}, None), single_run_callback + ) + assert track_single.listeners == { + "all": False, + "domains": None, + "entities": {"light.bowl"}, + } + + track_multi = async_track_state_change_filtered( + hass, TrackStates(False, {"light.bowl"}, {"switch"}), multiple_run_callback + ) + assert track_multi.listeners == { + "all": False, + "domains": {"switch"}, + "entities": {"light.bowl"}, + } + + track_throws = async_track_state_change_filtered( + hass, TrackStates(False, {"light.bowl"}, {"switch"}), callback_that_throws + ) + assert track_throws.listeners == { + "all": False, + "domains": {"switch"}, + "entities": {"light.bowl"}, + } + + # Adding state to state machine + hass.states.async_set("light.Bowl", "on") + await hass.async_block_till_done() + assert len(single_entity_id_tracker) == 1 + assert single_entity_id_tracker[-1][0] is None + assert single_entity_id_tracker[-1][1] is not None + assert len(multiple_entity_id_tracker) == 1 + assert multiple_entity_id_tracker[-1][0] is None + assert multiple_entity_id_tracker[-1][1] is not None + + # Set same state should not trigger a state change/listener + hass.states.async_set("light.Bowl", "on") + await hass.async_block_till_done() + assert len(single_entity_id_tracker) == 1 + assert len(multiple_entity_id_tracker) == 1 + + # State change off -> on + hass.states.async_set("light.Bowl", "off") + await hass.async_block_till_done() + assert len(single_entity_id_tracker) == 2 + assert len(multiple_entity_id_tracker) == 2 + + # State change off -> off + hass.states.async_set("light.Bowl", "off", {"some_attr": 1}) + await hass.async_block_till_done() + assert len(single_entity_id_tracker) == 3 + assert len(multiple_entity_id_tracker) == 3 + + # State change off -> on + hass.states.async_set("light.Bowl", "on") + await hass.async_block_till_done() + assert len(single_entity_id_tracker) == 4 + assert len(multiple_entity_id_tracker) == 4 + + hass.states.async_remove("light.bowl") + await hass.async_block_till_done() + assert len(single_entity_id_tracker) == 5 + assert single_entity_id_tracker[-1][0] is not None + assert single_entity_id_tracker[-1][1] is None + assert len(multiple_entity_id_tracker) == 5 + assert multiple_entity_id_tracker[-1][0] is not None + assert multiple_entity_id_tracker[-1][1] is None + + # Set state for different entity id + hass.states.async_set("switch.kitchen", "on") + await hass.async_block_till_done() + assert len(single_entity_id_tracker) == 5 + assert len(multiple_entity_id_tracker) == 6 + + track_single.async_remove() + # Ensure unsubing the listener works + hass.states.async_set("light.Bowl", "off") + await hass.async_block_till_done() + assert len(single_entity_id_tracker) == 5 + assert len(multiple_entity_id_tracker) == 7 + + assert track_multi.listeners == { + "all": False, + "domains": {"switch"}, + "entities": {"light.bowl"}, + } + track_multi.async_update_listeners(TrackStates(False, {"light.bowl"}, None)) + assert track_multi.listeners == { + "all": False, + "domains": None, + "entities": {"light.bowl"}, + } + hass.states.async_set("light.Bowl", "on") + await hass.async_block_till_done() + assert len(multiple_entity_id_tracker) == 8 + hass.states.async_set("switch.kitchen", "off") + await hass.async_block_till_done() + assert len(multiple_entity_id_tracker) == 8 + + track_multi.async_update_listeners(TrackStates(True, None, None)) + hass.states.async_set("switch.kitchen", "off") + await hass.async_block_till_done() + assert len(multiple_entity_id_tracker) == 8 + hass.states.async_set("switch.any", "off") + await hass.async_block_till_done() + assert len(multiple_entity_id_tracker) == 9 + + track_multi.async_remove() + track_throws.async_remove() + + async def test_async_track_state_change_event(hass): """Test async_track_state_change_event.""" single_entity_id_tracker = [] From 0902caa7e4e1070342e7c7dd6ff1b95690e8e04d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Oct 2020 14:39:44 -0500 Subject: [PATCH 03/82] Implement template rate_limit directive (#40667) --- homeassistant/const.py | 4 + homeassistant/core.py | 2 +- homeassistant/helpers/event.py | 24 +- homeassistant/helpers/ratelimit.py | 97 +++++++ homeassistant/helpers/template.py | 44 ++- tests/components/template/test_cover.py | 1 + tests/components/template/test_sensor.py | 16 +- tests/helpers/test_event.py | 341 ++++++++++++++++++++++- tests/helpers/test_ratelimit.py | 108 +++++++ tests/helpers/test_template.py | 50 +++- 10 files changed, 669 insertions(+), 18 deletions(-) create mode 100644 homeassistant/helpers/ratelimit.py create mode 100644 tests/helpers/test_ratelimit.py diff --git a/homeassistant/const.py b/homeassistant/const.py index a641b43e9fb..98186047459 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -623,3 +623,7 @@ CLOUD_NEVER_EXPOSED_ENTITIES = ["group.all_locks"] # The ID of the Home Assistant Cast App CAST_APP_ID_HOMEASSISTANT = "B12CE3CA" + +# The tracker error allow when converting +# loop time to human readable time +MAX_TIME_TRACKING_ERROR = 0.001 diff --git a/homeassistant/core.py b/homeassistant/core.py index eb584b22b49..82fbe1be2b6 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -538,7 +538,7 @@ class Event: event_type: str, data: Optional[Dict[str, Any]] = None, origin: EventOrigin = EventOrigin.local, - time_fired: Optional[int] = None, + time_fired: Optional[datetime.datetime] = None, context: Optional[Context] = None, ) -> None: """Initialize a new event.""" diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index b396ebb1d91..52a43fca3ff 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -27,6 +27,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL, + MAX_TIME_TRACKING_ERROR, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) @@ -40,6 +41,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import TemplateError from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED +from homeassistant.helpers.ratelimit import KeyedRateLimit from homeassistant.helpers.sun import get_astral_event_next from homeassistant.helpers.template import RenderInfo, Template, result_as_boolean from homeassistant.helpers.typing import TemplateVarsType @@ -47,8 +49,6 @@ from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe -MAX_TIME_TRACKING_ERROR = 0.001 - TRACK_STATE_CHANGE_CALLBACKS = "track_state_change_callbacks" TRACK_STATE_CHANGE_LISTENER = "track_state_change_listener" @@ -88,10 +88,12 @@ class TrackTemplate: The template is template to calculate. The variables are variables to pass to the template. + The rate_limit is a rate limit on how often the template is re-rendered. """ template: Template variables: TemplateVarsType + rate_limit: Optional[timedelta] = None @dataclass @@ -724,6 +726,8 @@ class _TrackTemplateResultInfo: self._track_templates = track_templates self._last_result: Dict[Template, Union[str, TemplateError]] = {} + + self._rate_limit = KeyedRateLimit(hass) self._info: Dict[Template, RenderInfo] = {} self._track_state_changes: Optional[_TrackStateChangeFiltered] = None @@ -763,6 +767,7 @@ class _TrackTemplateResultInfo: """Cancel the listener.""" assert self._track_state_changes self._track_state_changes.async_remove() + self._rate_limit.async_remove() @callback def async_refresh(self) -> None: @@ -784,11 +789,23 @@ class _TrackTemplateResultInfo: def _refresh(self, event: Optional[Event]) -> None: updates = [] info_changed = False + now = dt_util.utcnow() for track_template_ in self._track_templates: template = track_template_.template if event: - if not self._event_triggers_template(template, event): + if not self._rate_limit.async_has_timer( + template + ) and not self._event_triggers_template(template, event): + continue + + if self._rate_limit.async_schedule_action( + template, + self._info[template].rate_limit or track_template_.rate_limit, + now, + self._refresh, + event, + ): continue _LOGGER.debug( @@ -797,6 +814,7 @@ class _TrackTemplateResultInfo: event, ) + self._rate_limit.async_triggered(template, now) self._info[template] = template.async_render_to_info( track_template_.variables ) diff --git a/homeassistant/helpers/ratelimit.py b/homeassistant/helpers/ratelimit.py new file mode 100644 index 00000000000..422ebdf2eee --- /dev/null +++ b/homeassistant/helpers/ratelimit.py @@ -0,0 +1,97 @@ +"""Ratelimit helper.""" +import asyncio +from datetime import datetime, timedelta +import logging +from typing import Any, Callable, Dict, Hashable, Optional + +from homeassistant.const import MAX_TIME_TRACKING_ERROR +from homeassistant.core import HomeAssistant, callback +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + + +class KeyedRateLimit: + """Class to track rate limits.""" + + def __init__( + self, + hass: HomeAssistant, + ): + """Initialize ratelimit tracker.""" + self.hass = hass + self._last_triggered: Dict[Hashable, datetime] = {} + self._rate_limit_timers: Dict[Hashable, asyncio.TimerHandle] = {} + + @callback + def async_has_timer(self, key: Hashable) -> bool: + """Check if a rate limit timer is running.""" + return key in self._rate_limit_timers + + @callback + def async_triggered(self, key: Hashable, now: Optional[datetime] = None) -> None: + """Call when the action we are tracking was triggered.""" + self.async_cancel_timer(key) + self._last_triggered[key] = now or dt_util.utcnow() + + @callback + def async_cancel_timer(self, key: Hashable) -> None: + """Cancel a rate limit time that will call the action.""" + if not self.async_has_timer(key): + return + + self._rate_limit_timers.pop(key).cancel() + + @callback + def async_remove(self) -> None: + """Remove all timers.""" + for timer in self._rate_limit_timers.values(): + timer.cancel() + self._rate_limit_timers.clear() + + @callback + def async_schedule_action( + self, + key: Hashable, + rate_limit: Optional[timedelta], + now: datetime, + action: Callable, + *args: Any, + ) -> Optional[datetime]: + """Check rate limits and schedule an action if we hit the limit. + + If the rate limit is hit: + Schedules the action for when the rate limit expires + if there are no pending timers. The action must + be called in async. + + Returns the time the rate limit will expire + + If the rate limit is not hit: + + Return None + """ + if rate_limit is None or key not in self._last_triggered: + return None + + next_call_time = self._last_triggered[key] + rate_limit + + if next_call_time <= now: + self.async_cancel_timer(key) + return None + + _LOGGER.debug( + "Reached rate limit of %s for %s and deferred action until %s", + rate_limit, + key, + next_call_time, + ) + + if key not in self._rate_limit_timers: + self._rate_limit_timers[key] = self.hass.loop.call_later( + (next_call_time - now).total_seconds() + MAX_TIME_TRACKING_ERROR, + action, + *args, + ) + + return next_call_time diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 6261f7b2257..b877e0b0e12 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -72,6 +72,8 @@ _COLLECTABLE_STATE_ATTRIBUTES = { "name", } +DEFAULT_RATE_LIMIT = timedelta(seconds=1) + @bind_hass def attach(hass: HomeAssistantType, obj: Any) -> None: @@ -198,10 +200,11 @@ class RenderInfo: self.domains = set() self.domains_lifecycle = set() self.entities = set() + self.rate_limit = None def __repr__(self) -> str: """Representation of RenderInfo.""" - return f"" + return f"" def _filter_domains_and_entities(self, entity_id: str) -> bool: """Template should re-render if the entity state changes when we match specific domains or entities.""" @@ -221,16 +224,24 @@ class RenderInfo: def _freeze_static(self) -> None: self.is_static = True - self.entities = frozenset(self.entities) - self.domains = frozenset(self.domains) - self.domains_lifecycle = frozenset(self.domains_lifecycle) + self._freeze_sets() self.all_states = False - def _freeze(self) -> None: + def _freeze_sets(self) -> None: self.entities = frozenset(self.entities) self.domains = frozenset(self.domains) self.domains_lifecycle = frozenset(self.domains_lifecycle) + def _freeze(self) -> None: + self._freeze_sets() + + if self.rate_limit is None and ( + self.domains or self.domains_lifecycle or self.all_states or self.exception + ): + # If the template accesses all states or an entire + # domain, and no rate limit is set, we use the default. + self.rate_limit = DEFAULT_RATE_LIMIT + if self.exception: return @@ -478,6 +489,26 @@ class Template: return 'Template("' + self.template + '")' +class RateLimit: + """Class to control update rate limits.""" + + def __init__(self, hass: HomeAssistantType): + """Initialize rate limit.""" + self._hass = hass + + def __call__(self, *args: Any, **kwargs: Any) -> str: + """Handle a call to the class.""" + render_info = self._hass.data.get(_RENDER_INFO) + if render_info is not None: + render_info.rate_limit = timedelta(*args, **kwargs) + + return "" + + def __repr__(self) -> str: + """Representation of a RateLimit.""" + return "