diff --git a/custom_components/pyscript/decorators/event.py b/custom_components/pyscript/decorators/event.py index 82e422c..7fafa39 100644 --- a/custom_components/pyscript/decorators/event.py +++ b/custom_components/pyscript/decorators/event.py @@ -1,18 +1,22 @@ """Event decorator.""" import logging +from typing import Any import voluptuous as vol -from homeassistant.core import CALLBACK_TYPE, Event +from homeassistant.core import CALLBACK_TYPE, Event, callback from ..decorator_abc import DispatchData, TriggerDecorator -from .base import ExpressionDecorator +from .base import AutoKwargsDecorator, ExpressionDecorator _LOGGER = logging.getLogger(__name__) +# No builtins: an event_filter expression may only reference event data keys. +_FILTER_GLOBALS = {"__builtins__": {}} -class EventTriggerDecorator(TriggerDecorator, ExpressionDecorator): + +class EventTriggerDecorator(TriggerDecorator, ExpressionDecorator, AutoKwargsDecorator): """Implementation for @event_trigger.""" name = "event_trigger" @@ -22,14 +26,38 @@ class EventTriggerDecorator(TriggerDecorator, ExpressionDecorator): vol.Length(min=1, max=2, msg="needs at least one argument"), ) ) + kwargs_schema = vol.Schema({vol.Optional("event_filter"): str}) + + event_filter: str | None remove_listener_callback: CALLBACK_TYPE | None = None + _filter_code: Any = None async def validate(self) -> None: """Validate the event trigger.""" await super().validate() if len(self.args) == 2: self.create_expression(self.args[1]) + if self.event_filter: + try: + self._filter_code = compile(self.event_filter, "", "eval") + except SyntaxError as err: + raise TypeError( + f"function '{self.dm.func_name}' defined in " + f"{self.dm.ast_ctx.get_global_ctx_name()}: decorator @{self.name} " + f"event_filter {err.msg!r} is not a valid expression" + ) from err + + @callback + def _bus_event_filter(self, event_data: Any) -> bool: + """Evaluate the event_filter expression natively at the event bus.""" + if self._filter_code is None: + return True + try: + return bool(eval(self._filter_code, _FILTER_GLOBALS, event_data)) # noqa: S307 + except Exception as exc: + _LOGGER.error("event_trigger %s event_filter %r raised %s", self.args[0], self.event_filter, exc) + return False async def _event_callback(self, event: Event) -> None: _LOGGER.debug("Event trigger received: %s %s", type(event), event) @@ -48,7 +76,20 @@ async def _event_callback(self, event: Event) -> None: async def start(self) -> None: """Start the event trigger.""" await super().start() - self.remove_listener_callback = self.dm.hass.bus.async_listen(self.args[0], self._event_callback) + # Only register the bus filter when an event_filter is given; HA then + # rejects filter-mandatory events (e.g. EVENT_STATE_REPORTED) instead of + # firehosing every event of that type. + event_filter = self._bus_event_filter if self._filter_code is not None else None + try: + self.remove_listener_callback = self.dm.hass.bus.async_listen( + self.args[0], self._event_callback, event_filter=event_filter + ) + except Exception as exc: + # Keep sibling triggers alive; surface the error and leave this one inert. + self.remove_listener_callback = None + _LOGGER.error("Event trigger failed to start for event %s: %s", self.args[0], exc) + await self.dm.handle_exception(exc) + return _LOGGER.debug("Event trigger started for event: %s", self.args[0]) _LOGGER.debug("Remove listener: %s", self.remove_listener_callback) diff --git a/docs/reference.rst b/docs/reference.rst index 3b7c587..4d3533e 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -755,7 +755,7 @@ trigger will occur 1 hour after the 1:01am trigger. .. code:: python - @event_trigger(event_type, str_expr=None, kwargs=None) + @event_trigger(event_type, str_expr=None, event_filter=None, kwargs=None) ``@event_trigger`` triggers on the given ``event_type``. Multiple ``@event_trigger`` decorators can be applied to a single function if you want to trigger the same function with different event @@ -772,6 +772,29 @@ parameters sent with the event, together with these two variables: Note, unlike state variables, the event data values are not forced to be strings, so typically that data has its native type. +An optional ``event_filter`` keyword argument can be set to an expression string that is evaluated +at the Home Assistant event bus *before* the event is delivered to your function. Unlike +``str_expr`` (which is evaluated by the pyscript engine just before the function runs), the +``event_filter`` expression is evaluated natively and synchronously on the bus, so it can only +reference keys of the event data (no pyscript state variables, functions, or builtins are in +scope). Use it as a fast pre-filter to avoid waking the trigger for high-volume events, and use +``str_expr`` when you need full pyscript-style matching. If the expression raises (for example a +referenced key is missing) the event is skipped and the error is logged. + +Some events require a bus filter: Home Assistant rejects a plain ``async_listen`` for high-volume +events such as ``EVENT_STATE_REPORTED``. For those events you must supply an ``event_filter``; +without one the trigger fails to start and the error is logged. This deliberately prevents +accidentally subscribing to every event of such a high-volume type. To intentionally receive them +all, pass a filter that always matches, e.g. ``event_filter="True"``. Example: + +.. code:: python + + from homeassistant.const import EVENT_STATE_REPORTED + + @event_trigger(EVENT_STATE_REPORTED, event_filter="entity_id == 'sensor.kitchen_temp'") + def on_kitchen_temp_reported(new_state=None, **kwargs): + log.info(f"kitchen temp reported: {new_state.state}") + When the ``@event_trigger`` occurs, those same variables are passed as keyword arguments to the function in case it needs them. Additional keyword parameters can be specified by setting the optional ``kwargs`` argument to a ``dict`` with the keywords and values.