Avoid creating unneeded Context and Event objects when firing events (#113798)

* Avoid creating unneeded Context and Event objects when firing events

* Add test

---------

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Erik Montnemery 2024-03-20 09:40:06 +01:00 committed by GitHub
parent 638020f168
commit d31124d5d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 257 additions and 128 deletions

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import ItemsView from collections.abc import ItemsView, Mapping
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
@ -101,30 +101,18 @@ async def async_attach_trigger(
job = HassJob(action, f"event trigger {trigger_info}") job = HassJob(action, f"event trigger {trigger_info}")
@callback @callback
def filter_event(event: Event) -> bool: def filter_event(event_data: Mapping[str, Any]) -> bool:
"""Filter events.""" """Filter events."""
try: try:
# Check that the event data and context match the configured # Check that the event data and context match the configured
# schema if one was provided # schema if one was provided
if event_data_items: if event_data_items:
# Fast path for simple items comparison # Fast path for simple items comparison
if not (event.data.items() >= event_data_items): if not (event_data.items() >= event_data_items):
return False return False
elif event_data_schema: elif event_data_schema:
# Slow path for schema validation # Slow path for schema validation
event_data_schema(event.data) event_data_schema(event_data)
if event_context_items:
# Fast path for simple items comparison
# This is safe because we do not mutate the event context
# pylint: disable-next=protected-access
if not (event.context._as_dict.items() >= event_context_items):
return False
elif event_context_schema:
# Slow path for schema validation
# This is safe because we make a copy of the event context
# pylint: disable-next=protected-access
event_context_schema(dict(event.context._as_dict))
except vol.Invalid: except vol.Invalid:
# If event doesn't match, skip event # If event doesn't match, skip event
return False return False
@ -133,6 +121,22 @@ async def async_attach_trigger(
@callback @callback
def handle_event(event: Event) -> None: def handle_event(event: Event) -> None:
"""Listen for events and calls the action when data matches.""" """Listen for events and calls the action when data matches."""
if event_context_items:
# Fast path for simple items comparison
# This is safe because we do not mutate the event context
# pylint: disable-next=protected-access
if not (event.context._as_dict.items() >= event_context_items):
return
elif event_context_schema:
try:
# Slow path for schema validation
# This is safe because we make a copy of the event context
# pylint: disable-next=protected-access
event_context_schema(dict(event.context._as_dict))
except vol.Invalid:
# If event doesn't match, skip event
return
hass.async_run_hass_job( hass.async_run_hass_job(
job, job,
{ {
@ -146,9 +150,10 @@ async def async_attach_trigger(
event.context, event.context,
) )
event_filter = filter_event if event_data_items or event_data_schema else None
removes = [ removes = [
hass.bus.async_listen( hass.bus.async_listen(
event_type, handle_event, event_filter=filter_event, run_immediately=True event_type, handle_event, event_filter=event_filter, run_immediately=True
) )
for event_type in event_types for event_type in event_types
] ]

View File

@ -1,7 +1,9 @@
"""Publish simple item state changes via MQTT.""" """Publish simple item state changes via MQTT."""
from collections.abc import Mapping
import json import json
import logging import logging
from typing import Any
import voluptuous as vol import voluptuous as vol
@ -90,9 +92,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@callback @callback
def _ha_started(hass: HomeAssistant) -> None: def _ha_started(hass: HomeAssistant) -> None:
@callback @callback
def _event_filter(evt: Event) -> bool: def _event_filter(event_data: Mapping[str, Any]) -> bool:
entity_id: str = evt.data["entity_id"] entity_id: str = event_data["entity_id"]
new_state: State | None = evt.data["new_state"] new_state: State | None = event_data["new_state"]
if new_state is None: if new_state is None:
return False return False
if not publish_filter(entity_id): if not publish_filter(entity_id):

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable, Mapping
import logging import logging
from typing import Any, Self from typing import Any, Self
@ -248,11 +248,11 @@ class PersonStorageCollection(collection.DictStorageCollection):
) )
@callback @callback
def _entity_registry_filter(self, event: Event) -> bool: def _entity_registry_filter(self, event_data: Mapping[str, Any]) -> bool:
"""Filter entity registry events.""" """Filter entity registry events."""
return ( return (
event.data["action"] == "remove" event_data["action"] == "remove"
and split_entity_id(event.data[ATTR_ENTITY_ID])[0] == "device_tracker" and split_entity_id(event_data[ATTR_ENTITY_ID])[0] == "device_tracker"
) )
async def _entity_registry_updated(self, event: Event) -> None: async def _entity_registry_updated(self, event: Event) -> None:

View File

@ -321,7 +321,7 @@ class Events(Base):
EventOrigin(self.origin) EventOrigin(self.origin)
if self.origin if self.origin
else EVENT_ORIGIN_ORDER[self.origin_idx or 0], else EVENT_ORIGIN_ORDER[self.origin_idx or 0],
dt_util.utc_from_timestamp(self.time_fired_ts or 0), self.time_fired_ts or 0,
context=context, context=context,
) )
except JSON_DECODE_EXCEPTIONS: except JSON_DECODE_EXCEPTIONS:

View File

@ -1,6 +1,8 @@
"""Recorder entity registry helper.""" """Recorder entity registry helper."""
from collections.abc import Mapping
import logging import logging
from typing import Any
from homeassistant.core import Event, HomeAssistant, callback from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
@ -29,9 +31,9 @@ def async_setup(hass: HomeAssistant) -> None:
) )
@callback @callback
def entity_registry_changed_filter(event: Event) -> bool: def entity_registry_changed_filter(event_data: Mapping[str, Any]) -> bool:
"""Handle entity_id changed filter.""" """Handle entity_id changed filter."""
return event.data["action"] == "update" and "old_entity_id" in event.data return event_data["action"] == "update" and "old_entity_id" in event_data
@callback @callback
def _setup_entity_registry_event_handler(hass: HomeAssistant) -> None: def _setup_entity_registry_event_handler(hass: HomeAssistant) -> None:

View File

@ -1,5 +1,8 @@
"""Provides device automations for Tasmota.""" """Provides device automations for Tasmota."""
from collections.abc import Mapping
from typing import Any
from hatasmota.const import AUTOMATION_TYPE_TRIGGER from hatasmota.const import AUTOMATION_TYPE_TRIGGER
from hatasmota.models import DiscoveryHashType from hatasmota.models import DiscoveryHashType
from hatasmota.trigger import TasmotaTrigger from hatasmota.trigger import TasmotaTrigger
@ -27,9 +30,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N
await async_remove_automations(hass, event.data["device_id"]) await async_remove_automations(hass, event.data["device_id"])
@callback @callback
def _async_device_removed_filter(event: Event) -> bool: def _async_device_removed_filter(event_data: Mapping[str, Any]) -> bool:
"""Filter device registry events.""" """Filter device registry events."""
return event.data["action"] == "remove" return event_data["action"] == "remove"
async def async_discover( async def async_discover(
tasmota_automation: TasmotaTrigger, discovery_hash: DiscoveryHashType tasmota_automation: TasmotaTrigger, discovery_hash: DiscoveryHashType

View File

@ -97,7 +97,7 @@ class VoIPDevices:
self.hass.bus.async_listen( self.hass.bus.async_listen(
dr.EVENT_DEVICE_REGISTRY_UPDATED, dr.EVENT_DEVICE_REGISTRY_UPDATED,
async_device_removed, async_device_removed,
callback(lambda ev: ev.data.get("action") == "remove"), callback(lambda event_data: event_data.get("action") == "remove"),
) )
) )

View File

@ -2519,16 +2519,16 @@ class EntityRegistryDisabledHandler:
@callback @callback
def _handle_entry_updated_filter(event: Event) -> bool: def _handle_entry_updated_filter(event_data: Mapping[str, Any]) -> bool:
"""Handle entity registry entry update filter. """Handle entity registry entry update filter.
Only handle changes to "disabled_by". Only handle changes to "disabled_by".
If "disabled_by" was CONFIG_ENTRY, reload is not needed. If "disabled_by" was CONFIG_ENTRY, reload is not needed.
""" """
if ( if (
event.data["action"] != "update" event_data["action"] != "update"
or "disabled_by" not in event.data["changes"] or "disabled_by" not in event_data["changes"]
or event.data["changes"]["disabled_by"] or event_data["changes"]["disabled_by"]
is entity_registry.RegistryEntryDisabler.CONFIG_ENTRY is entity_registry.RegistryEntryDisabler.CONFIG_ENTRY
): ):
return False return False

View File

@ -67,6 +67,7 @@ from .const import (
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
EVENT_LOGGING_CHANGED,
EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REGISTERED,
EVENT_SERVICE_REMOVED, EVENT_SERVICE_REMOVED,
EVENT_STATE_CHANGED, EVENT_STATE_CHANGED,
@ -1215,24 +1216,24 @@ class Event(Generic[_DataT]):
event_type: str, event_type: str,
data: _DataT | None = None, data: _DataT | None = None,
origin: EventOrigin = EventOrigin.local, origin: EventOrigin = EventOrigin.local,
time_fired: datetime.datetime | None = None, time_fired_timestamp: float | None = None,
context: Context | None = None, context: Context | None = None,
) -> None: ) -> None:
"""Initialize a new event.""" """Initialize a new event."""
self.event_type = event_type self.event_type = event_type
self.data: _DataT = data or {} # type: ignore[assignment] self.data: _DataT = data or {} # type: ignore[assignment]
self.origin = origin self.origin = origin
self.time_fired = time_fired or dt_util.utcnow() self.time_fired_timestamp = time_fired_timestamp or time.time()
if not context: if not context:
context = Context(id=ulid_at_time(self.time_fired.timestamp())) context = Context(id=ulid_at_time(self.time_fired_timestamp))
self.context = context self.context = context
if not context.origin_event: if not context.origin_event:
context.origin_event = self context.origin_event = self
@cached_property @cached_property
def time_fired_timestamp(self) -> float: def time_fired(self) -> datetime.datetime:
"""Return time fired as a timestamp.""" """Return time fired as a timestamp."""
return self.time_fired.timestamp() return dt_util.utc_from_timestamp(self.time_fired_timestamp)
@cached_property @cached_property
def _as_dict(self) -> dict[str, Any]: def _as_dict(self) -> dict[str, Any]:
@ -1282,18 +1283,22 @@ class Event(Generic[_DataT]):
def __repr__(self) -> str: def __repr__(self) -> str:
"""Return the representation.""" """Return the representation."""
if self.data: return _event_repr(self.event_type, self.origin, self.data)
return (
f"<Event {self.event_type}[{str(self.origin)[0]}]:"
f" {util.repr_helper(self.data)}>"
)
return f"<Event {self.event_type}[{str(self.origin)[0]}]>"
def _event_repr(
event_type: str, origin: EventOrigin, data: Mapping[str, Any] | None
) -> str:
"""Return the representation."""
if data:
return f"<Event {event_type}[{str(origin)[0]}]: {util.repr_helper(data)}>"
return f"<Event {event_type}[{str(origin)[0]}]>"
_FilterableJobType = tuple[ _FilterableJobType = tuple[
HassJob[[Event[_DataT]], Coroutine[Any, Any, None] | None], # job HassJob[[Event[_DataT]], Coroutine[Any, Any, None] | None], # job
Callable[[Event[_DataT]], bool] | None, # event_filter Callable[[_DataT], bool] | None, # event_filter
bool, # run_immediately bool, # run_immediately
] ]
@ -1325,7 +1330,7 @@ class _OneTimeListener:
class EventBus: class EventBus:
"""Allow the firing of and listening for events.""" """Allow the firing of and listening for events."""
__slots__ = ("_listeners", "_match_all_listeners", "_hass") __slots__ = ("_debug", "_hass", "_listeners", "_match_all_listeners")
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
"""Initialize a new event bus.""" """Initialize a new event bus."""
@ -1333,6 +1338,15 @@ class EventBus:
self._match_all_listeners: list[_FilterableJobType[Any]] = [] self._match_all_listeners: list[_FilterableJobType[Any]] = []
self._listeners[MATCH_ALL] = self._match_all_listeners self._listeners[MATCH_ALL] = self._match_all_listeners
self._hass = hass self._hass = hass
self._async_logging_changed()
self.async_listen(
EVENT_LOGGING_CHANGED, self._async_logging_changed, run_immediately=True
)
@callback
def _async_logging_changed(self, event: Event | None = None) -> None:
"""Handle logging change."""
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
@callback @callback
def async_listeners(self) -> dict[str, int]: def async_listeners(self) -> dict[str, int]:
@ -1366,7 +1380,7 @@ class EventBus:
event_data: Mapping[str, Any] | None = None, event_data: Mapping[str, Any] | None = None,
origin: EventOrigin = EventOrigin.local, origin: EventOrigin = EventOrigin.local,
context: Context | None = None, context: Context | None = None,
time_fired: datetime.datetime | None = None, time_fired: float | None = None,
) -> None: ) -> None:
"""Fire an event. """Fire an event.
@ -1376,30 +1390,57 @@ class EventBus:
raise MaxLengthExceeded( raise MaxLengthExceeded(
event_type, "event_type", MAX_LENGTH_EVENT_EVENT_TYPE event_type, "event_type", MAX_LENGTH_EVENT_EVENT_TYPE
) )
return self._async_fire(event_type, event_data, origin, context, time_fired)
listeners = self._listeners.get(event_type, []) @callback
match_all_listeners = self._match_all_listeners def _async_fire(
self,
event_type: str,
event_data: Mapping[str, Any] | None = None,
origin: EventOrigin = EventOrigin.local,
context: Context | None = None,
time_fired: float | None = None,
) -> None:
"""Fire an event.
event = Event(event_type, event_data, origin, time_fired, context) This method must be run in the event loop.
"""
if _LOGGER.isEnabledFor(logging.DEBUG): if self._debug:
_LOGGER.debug("Bus:Handling %s", event) _LOGGER.debug(
"Bus:Handling %s", _event_repr(event_type, origin, event_data)
if not listeners and not match_all_listeners: )
return
listeners = self._listeners.get(event_type)
# EVENT_HOMEASSISTANT_CLOSE should not be sent to MATCH_ALL listeners # EVENT_HOMEASSISTANT_CLOSE should not be sent to MATCH_ALL listeners
if event_type != EVENT_HOMEASSISTANT_CLOSE: if event_type != EVENT_HOMEASSISTANT_CLOSE:
listeners = match_all_listeners + listeners if listeners:
listeners = self._match_all_listeners + listeners
else:
listeners = self._match_all_listeners.copy()
if not listeners:
return
event: Event | None = None
for job, event_filter, run_immediately in listeners: for job, event_filter, run_immediately in listeners:
if event_filter is not None: if event_filter is not None:
try: try:
if not event_filter(event): if event_data is None or not event_filter(event_data):
continue continue
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in event filter") _LOGGER.exception("Error in event filter")
continue continue
if not event:
event = Event(
event_type,
event_data,
origin,
time_fired,
context,
)
if run_immediately: if run_immediately:
try: try:
self._hass.async_run_hass_job(job, event) self._hass.async_run_hass_job(job, event)
@ -1433,7 +1474,7 @@ class EventBus:
self, self,
event_type: str, event_type: str,
listener: Callable[[Event[_DataT]], Coroutine[Any, Any, None] | None], listener: Callable[[Event[_DataT]], Coroutine[Any, Any, None] | None],
event_filter: Callable[[Event[_DataT]], bool] | None = None, event_filter: Callable[[_DataT], bool] | None = None,
run_immediately: bool = False, run_immediately: bool = False,
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Listen for all events or events of a specific type. """Listen for all events or events of a specific type.
@ -1952,7 +1993,7 @@ class StateMachine:
return False return False
old_state.expire() old_state.expire()
self._bus.async_fire( self._bus._async_fire( # pylint: disable=protected-access
EVENT_STATE_CHANGED, EVENT_STATE_CHANGED,
{"entity_id": entity_id, "old_state": old_state, "new_state": None}, {"entity_id": entity_id, "old_state": old_state, "new_state": None},
context=context, context=context,
@ -2047,32 +2088,35 @@ class StateMachine:
same_attr = old_state.attributes == attributes same_attr = old_state.attributes == attributes
last_changed = old_state.last_changed if same_state else None last_changed = old_state.last_changed if same_state else None
# It is much faster to convert a timestamp to a utc datetime object
# than converting a utc datetime object to a timestamp since cpython
# does not have a fast path for handling the UTC timezone and has to do
# multiple local timezone conversions.
#
# from_timestamp implementation:
# https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L2936
#
# timestamp implementation:
# https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L6387
# https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L6323
timestamp = time.time()
now = dt_util.utc_from_timestamp(timestamp)
if same_state and same_attr: if same_state and same_attr:
return return
if context is None: if context is None:
# It is much faster to convert a timestamp to a utc datetime object if TYPE_CHECKING:
# than converting a utc datetime object to a timestamp since cpython assert timestamp is not None
# does not have a fast path for handling the UTC timezone and has to do
# multiple local timezone conversions.
#
# from_timestamp implementation:
# https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L2936
#
# timestamp implementation:
# https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L6387
# https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L6323
timestamp = time.time()
now = dt_util.utc_from_timestamp(timestamp)
context = Context(id=ulid_at_time(timestamp)) context = Context(id=ulid_at_time(timestamp))
else:
now = dt_util.utcnow()
if same_attr: if same_attr:
if TYPE_CHECKING: if TYPE_CHECKING:
assert old_state is not None assert old_state is not None
attributes = old_state.attributes attributes = old_state.attributes
# This is intentionally called with positional only arguments for performance
# reasons
state = State( state = State(
entity_id, entity_id,
new_state, new_state,
@ -2086,11 +2130,11 @@ class StateMachine:
if old_state is not None: if old_state is not None:
old_state.expire() old_state.expire()
self._states[entity_id] = state self._states[entity_id] = state
self._bus.async_fire( self._bus._async_fire( # pylint: disable=protected-access
EVENT_STATE_CHANGED, EVENT_STATE_CHANGED,
{"entity_id": entity_id, "old_state": old_state, "new_state": state}, {"entity_id": entity_id, "old_state": old_state, "new_state": state},
context=context, context=context,
time_fired=now, time_fired=timestamp,
) )
@ -2429,7 +2473,7 @@ class ServiceRegistry:
domain, service, processed_data, context, return_response domain, service, processed_data, context, return_response
) )
self._hass.bus.async_fire( self._hass.bus._async_fire( # pylint: disable=protected-access
EVENT_CALL_SERVICE, EVENT_CALL_SERVICE,
{ {
ATTR_DOMAIN: domain, ATTR_DOMAIN: domain,

View File

@ -314,10 +314,11 @@ class AreaRegistry(BaseRegistry):
@callback @callback
def _removed_from_registry_filter( def _removed_from_registry_filter(
event: fr.EventFloorRegistryUpdated | lr.EventLabelRegistryUpdated, event_data: fr.EventFloorRegistryUpdatedData
| lr.EventLabelRegistryUpdatedData,
) -> bool: ) -> bool:
"""Filter all except for the item removed from registry events.""" """Filter all except for the item removed from registry events."""
return event.data["action"] == "remove" return event_data["action"] == "remove"
@callback @callback
def _handle_floor_registry_update(event: fr.EventFloorRegistryUpdated) -> None: def _handle_floor_registry_update(event: fr.EventFloorRegistryUpdated) -> None:

View File

@ -1145,10 +1145,10 @@ def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None:
@callback @callback
def _label_removed_from_registry_filter( def _label_removed_from_registry_filter(
event: lr.EventLabelRegistryUpdated, event_data: lr.EventLabelRegistryUpdatedData,
) -> bool: ) -> bool:
"""Filter all except for the remove action from label registry events.""" """Filter all except for the remove action from label registry events."""
return event.data["action"] == "remove" return event_data["action"] == "remove"
@callback @callback
def _handle_label_registry_update(event: lr.EventLabelRegistryUpdated) -> None: def _handle_label_registry_update(event: lr.EventLabelRegistryUpdated) -> None:
@ -1178,12 +1178,12 @@ def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None:
debounced_cleanup.async_schedule_call() debounced_cleanup.async_schedule_call()
@callback @callback
def entity_registry_changed_filter(event: Event) -> bool: def entity_registry_changed_filter(event_data: Mapping[str, Any]) -> bool:
"""Handle entity updated or removed filter.""" """Handle entity updated or removed filter."""
if ( if (
event.data["action"] == "update" event_data["action"] == "update"
and "device_id" not in event.data["changes"] and "device_id" not in event_data["changes"]
) or event.data["action"] == "create": ) or event_data["action"] == "create":
return False return False
return True return True

View File

@ -1431,10 +1431,11 @@ def _async_setup_cleanup(hass: HomeAssistant, registry: EntityRegistry) -> None:
@callback @callback
def _removed_from_registry_filter( def _removed_from_registry_filter(
event: lr.EventLabelRegistryUpdated | cr.EventCategoryRegistryUpdated, event_data: lr.EventLabelRegistryUpdatedData
| cr.EventCategoryRegistryUpdatedData,
) -> bool: ) -> bool:
"""Filter all except for the remove action from registry events.""" """Filter all except for the remove action from registry events."""
return event.data["action"] == "remove" return event_data["action"] == "remove"
@callback @callback
def _handle_label_registry_update(event: lr.EventLabelRegistryUpdated) -> None: def _handle_label_registry_update(event: lr.EventLabelRegistryUpdated) -> None:
@ -1488,9 +1489,9 @@ def _async_setup_entity_restore(hass: HomeAssistant, registry: EntityRegistry) -
"""Set up the entity restore mechanism.""" """Set up the entity restore mechanism."""
@callback @callback
def cleanup_restored_states_filter(event: Event) -> bool: def cleanup_restored_states_filter(event_data: Mapping[str, Any]) -> bool:
"""Clean up restored states filter.""" """Clean up restored states filter."""
return bool(event.data["action"] == "remove") return bool(event_data["action"] == "remove")
@callback @callback
def cleanup_restored_states(event: Event) -> None: def cleanup_restored_states(event: Event) -> None:

View File

@ -109,7 +109,7 @@ class _KeyedEventTracker(Generic[_TypedDictT]):
[ [
HomeAssistant, HomeAssistant,
dict[str, list[HassJob[[Event[_TypedDictT]], Any]]], dict[str, list[HassJob[[Event[_TypedDictT]], Any]]],
Event[_TypedDictT], _TypedDictT,
], ],
bool, bool,
] ]
@ -237,11 +237,11 @@ def async_track_state_change(
job = HassJob(action, f"track state change {entity_ids} {from_state} {to_state}") job = HassJob(action, f"track state change {entity_ids} {from_state} {to_state}")
@callback @callback
def state_change_filter(event: Event[EventStateChangedData]) -> bool: def state_change_filter(event_data: EventStateChangedData) -> bool:
"""Handle specific state changes.""" """Handle specific state changes."""
if from_state is not None: if from_state is not None:
old_state_str: str | None = None old_state_str: str | None = None
if (old_state := event.data["old_state"]) is not None: if (old_state := event_data["old_state"]) is not None:
old_state_str = old_state.state old_state_str = old_state.state
if not match_from_state(old_state_str): if not match_from_state(old_state_str):
@ -249,7 +249,7 @@ def async_track_state_change(
if to_state is not None: if to_state is not None:
new_state_str: str | None = None new_state_str: str | None = None
if (new_state := event.data["new_state"]) is not None: if (new_state := event_data["new_state"]) is not None:
new_state_str = new_state.state new_state_str = new_state.state
if not match_to_state(new_state_str): if not match_to_state(new_state_str):
@ -270,7 +270,7 @@ def async_track_state_change(
@callback @callback
def state_change_listener(event: Event[EventStateChangedData]) -> None: def state_change_listener(event: Event[EventStateChangedData]) -> None:
"""Handle specific state changes.""" """Handle specific state changes."""
if not state_change_filter(event): if not state_change_filter(event.data):
return return
state_change_dispatcher(event) state_change_dispatcher(event)
@ -341,10 +341,10 @@ def _async_dispatch_entity_id_event(
def _async_state_change_filter( def _async_state_change_filter(
hass: HomeAssistant, hass: HomeAssistant,
callbacks: dict[str, list[HassJob[[Event[EventStateChangedData]], Any]]], callbacks: dict[str, list[HassJob[[Event[EventStateChangedData]], Any]]],
event: Event[EventStateChangedData], event_data: EventStateChangedData,
) -> bool: ) -> bool:
"""Filter state changes by entity_id.""" """Filter state changes by entity_id."""
return event.data["entity_id"] in callbacks return event_data["entity_id"] in callbacks
_KEYED_TRACK_STATE_CHANGE = _KeyedEventTracker( _KEYED_TRACK_STATE_CHANGE = _KeyedEventTracker(
@ -473,10 +473,10 @@ def _async_dispatch_old_entity_id_or_entity_id_event(
def _async_entity_registry_updated_filter( def _async_entity_registry_updated_filter(
hass: HomeAssistant, hass: HomeAssistant,
callbacks: dict[str, list[HassJob[[Event[EventEntityRegistryUpdatedData]], Any]]], callbacks: dict[str, list[HassJob[[Event[EventEntityRegistryUpdatedData]], Any]]],
event: Event[EventEntityRegistryUpdatedData], event_data: EventEntityRegistryUpdatedData,
) -> bool: ) -> bool:
"""Filter entity registry updates by entity_id.""" """Filter entity registry updates by entity_id."""
return event.data.get("old_entity_id", event.data["entity_id"]) in callbacks return event_data.get("old_entity_id", event_data["entity_id"]) in callbacks
_KEYED_TRACK_ENTITY_REGISTRY_UPDATED = _KeyedEventTracker( _KEYED_TRACK_ENTITY_REGISTRY_UPDATED = _KeyedEventTracker(
@ -512,10 +512,10 @@ def async_track_entity_registry_updated_event(
def _async_device_registry_updated_filter( def _async_device_registry_updated_filter(
hass: HomeAssistant, hass: HomeAssistant,
callbacks: dict[str, list[HassJob[[Event[EventDeviceRegistryUpdatedData]], Any]]], callbacks: dict[str, list[HassJob[[Event[EventDeviceRegistryUpdatedData]], Any]]],
event: Event[EventDeviceRegistryUpdatedData], event_data: EventDeviceRegistryUpdatedData,
) -> bool: ) -> bool:
"""Filter device registry updates by device_id.""" """Filter device registry updates by device_id."""
return event.data["device_id"] in callbacks return event_data["device_id"] in callbacks
@callback @callback
@ -585,12 +585,12 @@ def _async_dispatch_domain_event(
def _async_domain_added_filter( def _async_domain_added_filter(
hass: HomeAssistant, hass: HomeAssistant,
callbacks: dict[str, list[HassJob[[Event[EventStateChangedData]], Any]]], callbacks: dict[str, list[HassJob[[Event[EventStateChangedData]], Any]]],
event: Event[EventStateChangedData], event_data: EventStateChangedData,
) -> bool: ) -> bool:
"""Filter state changes by entity_id.""" """Filter state changes by entity_id."""
return event.data["old_state"] is None and ( return event_data["old_state"] is None and (
MATCH_ALL in callbacks MATCH_ALL in callbacks
or split_entity_id(event.data["entity_id"])[0] in callbacks or split_entity_id(event_data["entity_id"])[0] in callbacks
) )
@ -634,12 +634,12 @@ def _async_track_state_added_domain(
def _async_domain_removed_filter( def _async_domain_removed_filter(
hass: HomeAssistant, hass: HomeAssistant,
callbacks: dict[str, list[HassJob[[Event[EventStateChangedData]], Any]]], callbacks: dict[str, list[HassJob[[Event[EventStateChangedData]], Any]]],
event: Event[EventStateChangedData], event_data: EventStateChangedData,
) -> bool: ) -> bool:
"""Filter state changes by entity_id.""" """Filter state changes by entity_id."""
return event.data["new_state"] is None and ( return event_data["new_state"] is None and (
MATCH_ALL in callbacks MATCH_ALL in callbacks
or split_entity_id(event.data["entity_id"])[0] in callbacks or split_entity_id(event_data["entity_id"])[0] in callbacks
) )

View File

@ -492,11 +492,11 @@ def async_setup(hass: HomeAssistant) -> None:
hass.data[TRANSLATION_FLATTEN_CACHE] = cache hass.data[TRANSLATION_FLATTEN_CACHE] = cache
@callback @callback
def _async_load_translations_filter(event: Event) -> bool: def _async_load_translations_filter(event_data: Mapping[str, Any]) -> bool:
"""Filter out unwanted events.""" """Filter out unwanted events."""
nonlocal current_language nonlocal current_language
if ( if (
new_language := event.data.get("language") new_language := event_data.get("language")
) and new_language != current_language: ) and new_language != current_language:
current_language = new_language current_language = new_language
return True return True

View File

@ -97,7 +97,7 @@ async def fire_events_with_filter(hass):
events_to_fire = 10**6 events_to_fire = 10**6
@core.callback @core.callback
def event_filter(event): def event_filter(event_data):
"""Filter event.""" """Filter event."""
return False return False

View File

@ -603,9 +603,9 @@ def _async_when_setup(
await when_setup() await when_setup()
@callback @callback
def _async_is_component_filter(event: Event[EventComponentLoaded]) -> bool: def _async_is_component_filter(event_data: EventComponentLoaded) -> bool:
"""Check if the event is for the component.""" """Check if the event is for the component."""
return event.data[ATTR_COMPONENT] == component return event_data[ATTR_COMPONENT] == component
listeners.append( listeners.append(
hass.bus.async_listen( hass.bus.async_listen(

View File

@ -98,7 +98,7 @@ def test_repr() -> None:
EVENT_STATE_CHANGED, EVENT_STATE_CHANGED,
{"entity_id": "sensor.temperature", "old_state": None, "new_state": state}, {"entity_id": "sensor.temperature", "old_state": None, "new_state": state},
context=state.context, context=state.context,
time_fired=fixed_time, time_fired_timestamp=fixed_time.timestamp(),
) )
assert "2016-07-09 11:00:00+00:00" in repr(States.from_event(event)) assert "2016-07-09 11:00:00+00:00" in repr(States.from_event(event))
assert "2016-07-09 11:00:00+00:00" in repr(Events.from_event(event)) assert "2016-07-09 11:00:00+00:00" in repr(Events.from_event(event))
@ -164,7 +164,7 @@ def test_from_event_to_delete_state() -> None:
assert db_state.entity_id == "sensor.temperature" assert db_state.entity_id == "sensor.temperature"
assert db_state.state == "" assert db_state.state == ""
assert db_state.last_changed_ts is None assert db_state.last_changed_ts is None
assert db_state.last_updated_ts == event.time_fired.timestamp() assert db_state.last_updated_ts == pytest.approx(event.time_fired.timestamp())
def test_states_from_native_invalid_entity_id() -> None: def test_states_from_native_invalid_entity_id() -> None:
@ -247,7 +247,10 @@ async def test_process_timestamp_to_utc_isoformat() -> None:
async def test_event_to_db_model() -> None: async def test_event_to_db_model() -> None:
"""Test we can round trip Event conversion.""" """Test we can round trip Event conversion."""
event = ha.Event( event = ha.Event(
"state_changed", {"some": "attr"}, ha.EventOrigin.local, dt_util.utcnow() "state_changed",
{"some": "attr"},
ha.EventOrigin.local,
dt_util.utcnow().timestamp(),
) )
db_event = Events.from_event(event) db_event = Events.from_event(event)
dialect = SupportedDialect.MYSQL dialect = SupportedDialect.MYSQL

View File

@ -78,13 +78,13 @@ async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmp_path: Path) -
"new_state": mock_state, "new_state": mock_state,
}, },
EventOrigin.local, EventOrigin.local,
time_fired=now, time_fired_timestamp=now.timestamp(),
) )
custom_event = Event( custom_event = Event(
"custom_event", "custom_event",
{"entity_id": "sensor.custom"}, {"entity_id": "sensor.custom"},
EventOrigin.local, EventOrigin.local,
time_fired=now, time_fired_timestamp=now.timestamp(),
) )
number_of_migrations = 5 number_of_migrations = 5
@ -242,13 +242,13 @@ async def test_migrate_can_resume_entity_id_post_migration(
"new_state": mock_state, "new_state": mock_state,
}, },
EventOrigin.local, EventOrigin.local,
time_fired=now, time_fired_timestamp=now.timestamp(),
) )
custom_event = Event( custom_event = Event(
"custom_event", "custom_event",
{"entity_id": "sensor.custom"}, {"entity_id": "sensor.custom"},
EventOrigin.local, EventOrigin.local,
time_fired=now, time_fired_timestamp=now.timestamp(),
) )
number_of_migrations = 5 number_of_migrations = 5

View File

@ -836,18 +836,23 @@ def test_event_eq() -> None:
data = {"some": "attr"} data = {"some": "attr"}
context = ha.Context() context = ha.Context()
event1, event2 = ( event1, event2 = (
ha.Event("some_type", data, time_fired=now, context=context) for _ in range(2) ha.Event(
"some_type", data, time_fired_timestamp=now.timestamp(), context=context
)
for _ in range(2)
) )
assert event1.as_dict() == event2.as_dict() assert event1.as_dict() == event2.as_dict()
def test_event_time_fired_timestamp() -> None: def test_event_time() -> None:
"""Test time_fired_timestamp.""" """Test time_fired and time_fired_timestamp."""
now = dt_util.utcnow() now = dt_util.utcnow()
event = ha.Event("some_type", {"some": "attr"}, time_fired=now) event = ha.Event(
assert event.time_fired_timestamp == now.timestamp() "some_type", {"some": "attr"}, time_fired_timestamp=now.timestamp()
)
assert event.time_fired_timestamp == now.timestamp() assert event.time_fired_timestamp == now.timestamp()
assert event.time_fired == now
def test_event_json_fragment() -> None: def test_event_json_fragment() -> None:
@ -856,7 +861,10 @@ def test_event_json_fragment() -> None:
data = {"some": "attr"} data = {"some": "attr"}
context = ha.Context() context = ha.Context()
event1, event2 = ( event1, event2 = (
ha.Event("some_type", data, time_fired=now, context=context) for _ in range(2) ha.Event(
"some_type", data, time_fired_timestamp=now.timestamp(), context=context
)
for _ in range(2)
) )
# We are testing that the JSON fragments are the same when as_dict is called # We are testing that the JSON fragments are the same when as_dict is called
@ -898,7 +906,7 @@ def test_event_as_dict() -> None:
now = dt_util.utcnow() now = dt_util.utcnow()
data = {"some": "attr"} data = {"some": "attr"}
event = ha.Event(event_type, data, ha.EventOrigin.local, now) event = ha.Event(event_type, data, ha.EventOrigin.local, now.timestamp())
expected = { expected = {
"event_type": event_type, "event_type": event_type,
"data": data, "data": data,
@ -1108,9 +1116,9 @@ async def test_eventbus_filtered_listener(hass: HomeAssistant) -> None:
calls.append(event) calls.append(event)
@ha.callback @ha.callback
def filter(event): def filter(event_data):
"""Mock filter.""" """Mock filter."""
return not event.data["filtered"] return not event_data["filtered"]
unsub = hass.bus.async_listen("test", listener, event_filter=filter) unsub = hass.bus.async_listen("test", listener, event_filter=filter)
@ -3152,3 +3160,63 @@ async def test_async_add_job_deprecated(
"https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job" "https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job"
" for replacement options" " for replacement options"
) in caplog.text ) in caplog.text
async def test_eventbus_lazy_object_creation(hass: HomeAssistant) -> None:
"""Test we don't create unneeded objects when firing events."""
calls = []
@ha.callback
def listener(event):
"""Mock listener."""
calls.append(event)
@ha.callback
def filter(event_data):
"""Mock filter."""
return not event_data["filtered"]
unsub = hass.bus.async_listen("test_1", listener, event_filter=filter)
# Test lazy creation of Event objects
with patch("homeassistant.core.Event") as mock_event:
# Fire an event which is filtered out by its listener
hass.bus.async_fire("test_1", {"filtered": True})
await hass.async_block_till_done()
mock_event.assert_not_called()
assert len(calls) == 0
# Fire an event which has no listener
hass.bus.async_fire("test_2")
await hass.async_block_till_done()
mock_event.assert_not_called()
assert len(calls) == 0
# Fire an event which is not filtered out by its listener
hass.bus.async_fire("test_1", {"filtered": False})
await hass.async_block_till_done()
mock_event.assert_called_once()
assert len(calls) == 1
calls = []
# Test lazy creation of Context objects
with patch("homeassistant.core.Context") as mock_context:
# Fire an event which is filtered out by its listener
hass.bus.async_fire("test_1", {"filtered": True})
await hass.async_block_till_done()
mock_context.assert_not_called()
assert len(calls) == 0
# Fire an event which has no listener
hass.bus.async_fire("test_2")
await hass.async_block_till_done()
mock_context.assert_not_called()
assert len(calls) == 0
# Fire an event which is not filtered out by its listener
hass.bus.async_fire("test_1", {"filtered": False})
await hass.async_block_till_done()
mock_context.assert_called_once()
assert len(calls) == 1
unsub()