From 717fc8b2f022c7f1fc41de18cc6db6963327cad4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2020 08:57:09 -0500 Subject: [PATCH 01/10] Implement template rate_limit directive --- homeassistant/const.py | 4 + homeassistant/core.py | 2 +- homeassistant/helpers/event.py | 24 ++- homeassistant/helpers/ratelimit.py | 96 ++++++++++ homeassistant/helpers/template.py | 28 ++- tests/helpers/test_event.py | 292 +++++++++++++++++++++++++++++ tests/helpers/test_ratelimit.py | 105 +++++++++++ tests/helpers/test_template.py | 17 +- 8 files changed, 561 insertions(+), 7 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 f845bc2bd0f0c..e6da01668c489 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -623,3 +623,7 @@ # 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 eb584b22b49bc..82fbe1be2b638 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -538,7 +538,7 @@ def __init__( 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 b396ebb1d9190..52a43fca3ffac 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -27,6 +27,7 @@ EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL, + MAX_TIME_TRACKING_ERROR, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) @@ -40,6 +41,7 @@ ) 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.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 @@ def __init__( 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 @@ def async_remove(self) -> None: """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 @@ def _event_triggers_template(self, template: Template, event: Event) -> bool: 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 @@ def _refresh(self, event: Optional[Event]) -> None: 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 0000000000000..45946462d43ec --- /dev/null +++ b/homeassistant/helpers/ratelimit.py @@ -0,0 +1,96 @@ +"""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 key in self._rate_limit_timers: + self._rate_limit_timers.pop(key).cancel() + + @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 not rate_limit 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 6261f7b225737..414cce8da52da 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -198,10 +198,11 @@ def __init__(self, template): 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.""" @@ -478,6 +479,28 @@ def __repr__(self) -> str: 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) -> Optional[timedelta]: + """Handle a call to the class.""" + delta = timedelta(*args, **kwargs) + + render_info = self._hass.data.get(_RENDER_INFO) + if render_info is not None: + render_info.rate_limit = delta + + return delta + + def __repr__(self) -> str: + """Representation of a RateLimit.""" + return "