Skip to content
Open
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
49 changes: 45 additions & 4 deletions custom_components/pyscript/decorators/event.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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, "<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)
Expand All @@ -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)

Expand Down
25 changes: 24 additions & 1 deletion docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading