diff --git a/homeassistant/const.py b/homeassistant/const.py index 6099a28a7c8..9fac02a60b6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -627,3 +627,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 "