diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index e045ece12ba6ce..264e2f9d440c85 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import ItemsView +from collections.abc import ItemsView, Mapping from typing import Any import voluptuous as vol @@ -101,30 +101,18 @@ async def async_attach_trigger( job = HassJob(action, f"event trigger {trigger_info}") @callback - def filter_event(event: Event) -> bool: + def filter_event(event_data: Mapping[str, Any]) -> bool: """Filter events.""" try: # Check that the event data and context match the configured # schema if one was provided if event_data_items: # Fast path for simple items comparison - if not (event.data.items() >= event_data_items): + if not (event_data.items() >= event_data_items): return False elif event_data_schema: # Slow path for schema validation - 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)) + event_data_schema(event_data) except vol.Invalid: # If event doesn't match, skip event return False @@ -133,6 +121,22 @@ def filter_event(event: Event) -> bool: @callback def handle_event(event: Event) -> None: """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( job, { @@ -146,9 +150,10 @@ def handle_event(event: Event) -> None: event.context, ) + event_filter = filter_event if event_data_items or event_data_schema else None removes = [ 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 ] diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py index 1f9fa2fad0fa10..a9b86c4bf8f5c4 100644 --- a/homeassistant/components/mqtt_statestream/__init__.py +++ b/homeassistant/components/mqtt_statestream/__init__.py @@ -1,7 +1,9 @@ """Publish simple item state changes via MQTT.""" +from collections.abc import Mapping import json import logging +from typing import Any import voluptuous as vol @@ -90,9 +92,9 @@ async def _state_publisher(evt: Event) -> None: @callback def _ha_started(hass: HomeAssistant) -> None: @callback - def _event_filter(evt: Event) -> bool: - entity_id: str = evt.data["entity_id"] - new_state: State | None = evt.data["new_state"] + def _event_filter(event_data: Mapping[str, Any]) -> bool: + entity_id: str = event_data["entity_id"] + new_state: State | None = event_data["new_state"] if new_state is None: return False if not publish_filter(entity_id): diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 3b2273f5033b8d..8aa3251641b932 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping import logging from typing import Any, Self @@ -248,11 +248,11 @@ async def async_load(self) -> None: ) @callback - def _entity_registry_filter(self, event: Event) -> bool: + def _entity_registry_filter(self, event_data: Mapping[str, Any]) -> bool: """Filter entity registry events.""" return ( - event.data["action"] == "remove" - and split_entity_id(event.data[ATTR_ENTITY_ID])[0] == "device_tracker" + event_data["action"] == "remove" + and split_entity_id(event_data[ATTR_ENTITY_ID])[0] == "device_tracker" ) async def _entity_registry_updated(self, event: Event) -> None: diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 6755e9c5c9bbb5..cc1e0e1d5ecd2e 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -321,7 +321,7 @@ def to_native(self, validate_entity_id: bool = True) -> Event | None: EventOrigin(self.origin) if self.origin 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, ) except JSON_DECODE_EXCEPTIONS: diff --git a/homeassistant/components/recorder/entity_registry.py b/homeassistant/components/recorder/entity_registry.py index 89e6864cb06617..5bf1856316ac46 100644 --- a/homeassistant/components/recorder/entity_registry.py +++ b/homeassistant/components/recorder/entity_registry.py @@ -1,6 +1,8 @@ """Recorder entity registry helper.""" +from collections.abc import Mapping import logging +from typing import Any from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -29,9 +31,9 @@ def _async_entity_id_changed(event: Event) -> None: ) @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.""" - 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 def _setup_entity_registry_event_handler(hass: HomeAssistant) -> None: diff --git a/homeassistant/components/tasmota/device_automation.py b/homeassistant/components/tasmota/device_automation.py index 98c7d1355c3211..2fdcad45c81e52 100644 --- a/homeassistant/components/tasmota/device_automation.py +++ b/homeassistant/components/tasmota/device_automation.py @@ -1,5 +1,8 @@ """Provides device automations for Tasmota.""" +from collections.abc import Mapping +from typing import Any + from hatasmota.const import AUTOMATION_TYPE_TRIGGER from hatasmota.models import DiscoveryHashType from hatasmota.trigger import TasmotaTrigger @@ -27,9 +30,9 @@ async def async_device_removed(event: Event) -> None: await async_remove_automations(hass, event.data["device_id"]) @callback - def _async_device_removed_filter(event: Event) -> bool: + def _async_device_removed_filter(event_data: Mapping[str, Any]) -> bool: """Filter device registry events.""" - return event.data["action"] == "remove" + return event_data["action"] == "remove" async def async_discover( tasmota_automation: TasmotaTrigger, discovery_hash: DiscoveryHashType diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py index aed8e6de740e2f..9acc04f6879433 100644 --- a/homeassistant/components/voip/devices.py +++ b/homeassistant/components/voip/devices.py @@ -97,7 +97,7 @@ def async_device_removed(ev: Event) -> None: self.hass.bus.async_listen( dr.EVENT_DEVICE_REGISTRY_UPDATED, async_device_removed, - callback(lambda ev: ev.data.get("action") == "remove"), + callback(lambda event_data: event_data.get("action") == "remove"), ) ) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b73c7b25e4118e..91986f053fd161 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2519,16 +2519,16 @@ async def _handle_reload(self, _now: Any) -> None: @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. Only handle changes to "disabled_by". If "disabled_by" was CONFIG_ENTRY, reload is not needed. """ if ( - event.data["action"] != "update" - or "disabled_by" not in event.data["changes"] - or event.data["changes"]["disabled_by"] + event_data["action"] != "update" + or "disabled_by" not in event_data["changes"] + or event_data["changes"]["disabled_by"] is entity_registry.RegistryEntryDisabler.CONFIG_ENTRY ): return False diff --git a/homeassistant/core.py b/homeassistant/core.py index a2c14814c99ad3..9e040b82b98d5c 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -67,6 +67,7 @@ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, + EVENT_LOGGING_CHANGED, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_STATE_CHANGED, @@ -1215,24 +1216,24 @@ def __init__( event_type: str, data: _DataT | None = None, origin: EventOrigin = EventOrigin.local, - time_fired: datetime.datetime | None = None, + time_fired_timestamp: float | None = None, context: Context | None = None, ) -> None: """Initialize a new event.""" self.event_type = event_type self.data: _DataT = data or {} # type: ignore[assignment] 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: - context = Context(id=ulid_at_time(self.time_fired.timestamp())) + context = Context(id=ulid_at_time(self.time_fired_timestamp)) self.context = context if not context.origin_event: context.origin_event = self @cached_property - def time_fired_timestamp(self) -> float: + def time_fired(self) -> datetime.datetime: """Return time fired as a timestamp.""" - return self.time_fired.timestamp() + return dt_util.utc_from_timestamp(self.time_fired_timestamp) @cached_property def _as_dict(self) -> dict[str, Any]: @@ -1282,18 +1283,22 @@ def json_fragment(self) -> json_fragment: def __repr__(self) -> str: """Return the representation.""" - if self.data: - return ( - f"" - ) + return _event_repr(self.event_type, self.origin, self.data) + - return f"" +def _event_repr( + event_type: str, origin: EventOrigin, data: Mapping[str, Any] | None +) -> str: + """Return the representation.""" + if data: + return f"" + + return f"" _FilterableJobType = tuple[ 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 ] @@ -1325,7 +1330,7 @@ def __repr__(self) -> str: class EventBus: """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: """Initialize a new event bus.""" @@ -1333,6 +1338,15 @@ def __init__(self, hass: HomeAssistant) -> None: self._match_all_listeners: list[_FilterableJobType[Any]] = [] self._listeners[MATCH_ALL] = self._match_all_listeners 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 def async_listeners(self) -> dict[str, int]: @@ -1366,7 +1380,7 @@ def async_fire( event_data: Mapping[str, Any] | None = None, origin: EventOrigin = EventOrigin.local, context: Context | None = None, - time_fired: datetime.datetime | None = None, + time_fired: float | None = None, ) -> None: """Fire an event. @@ -1376,30 +1390,57 @@ def async_fire( raise MaxLengthExceeded( 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, []) - match_all_listeners = self._match_all_listeners - - event = Event(event_type, event_data, origin, time_fired, context) + @callback + 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. - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Bus:Handling %s", event) + This method must be run in the event loop. + """ - if not listeners and not match_all_listeners: - return + if self._debug: + _LOGGER.debug( + "Bus:Handling %s", _event_repr(event_type, origin, event_data) + ) + listeners = self._listeners.get(event_type) # EVENT_HOMEASSISTANT_CLOSE should not be sent to MATCH_ALL listeners 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: if event_filter is not None: try: - if not event_filter(event): + if event_data is None or not event_filter(event_data): continue except Exception: # pylint: disable=broad-except _LOGGER.exception("Error in event filter") continue + + if not event: + event = Event( + event_type, + event_data, + origin, + time_fired, + context, + ) + if run_immediately: try: self._hass.async_run_hass_job(job, event) @@ -1433,7 +1474,7 @@ def async_listen( self, event_type: str, 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, ) -> CALLBACK_TYPE: """Listen for all events or events of a specific type. @@ -1952,7 +1993,7 @@ def async_remove(self, entity_id: str, context: Context | None = None) -> bool: return False old_state.expire() - self._bus.async_fire( + self._bus._async_fire( # pylint: disable=protected-access EVENT_STATE_CHANGED, {"entity_id": entity_id, "old_state": old_state, "new_state": None}, context=context, @@ -2047,32 +2088,35 @@ def async_set( same_attr = old_state.attributes == attributes 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: return if context is 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 TYPE_CHECKING: + assert timestamp is not None context = Context(id=ulid_at_time(timestamp)) - else: - now = dt_util.utcnow() if same_attr: if TYPE_CHECKING: assert old_state is not None attributes = old_state.attributes + # This is intentionally called with positional only arguments for performance + # reasons state = State( entity_id, new_state, @@ -2086,11 +2130,11 @@ def async_set( if old_state is not None: old_state.expire() self._states[entity_id] = state - self._bus.async_fire( + self._bus._async_fire( # pylint: disable=protected-access EVENT_STATE_CHANGED, {"entity_id": entity_id, "old_state": old_state, "new_state": state}, context=context, - time_fired=now, + time_fired=timestamp, ) @@ -2429,7 +2473,7 @@ async def async_call( domain, service, processed_data, context, return_response ) - self._hass.bus.async_fire( + self._hass.bus._async_fire( # pylint: disable=protected-access EVENT_CALL_SERVICE, { ATTR_DOMAIN: domain, diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 7b65a53d34c19f..fc535bed610122 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -314,10 +314,11 @@ def _async_setup_cleanup(self) -> None: @callback def _removed_from_registry_filter( - event: fr.EventFloorRegistryUpdated | lr.EventLabelRegistryUpdated, + event_data: fr.EventFloorRegistryUpdatedData + | lr.EventLabelRegistryUpdatedData, ) -> bool: """Filter all except for the item removed from registry events.""" - return event.data["action"] == "remove" + return event_data["action"] == "remove" @callback def _handle_floor_registry_update(event: fr.EventFloorRegistryUpdated) -> None: diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 2f4210349190f8..e31c372c18e9ac 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1145,10 +1145,10 @@ def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None: @callback def _label_removed_from_registry_filter( - event: lr.EventLabelRegistryUpdated, + event_data: lr.EventLabelRegistryUpdatedData, ) -> bool: """Filter all except for the remove action from label registry events.""" - return event.data["action"] == "remove" + return event_data["action"] == "remove" @callback def _handle_label_registry_update(event: lr.EventLabelRegistryUpdated) -> None: @@ -1178,12 +1178,12 @@ def _async_entity_registry_changed(event: Event) -> None: debounced_cleanup.async_schedule_call() @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.""" if ( - event.data["action"] == "update" - and "device_id" not in event.data["changes"] - ) or event.data["action"] == "create": + event_data["action"] == "update" + and "device_id" not in event_data["changes"] + ) or event_data["action"] == "create": return False return True diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 637734c16ae3fd..5946542b394c44 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1431,10 +1431,11 @@ def _async_setup_cleanup(hass: HomeAssistant, registry: EntityRegistry) -> None: @callback def _removed_from_registry_filter( - event: lr.EventLabelRegistryUpdated | cr.EventCategoryRegistryUpdated, + event_data: lr.EventLabelRegistryUpdatedData + | cr.EventCategoryRegistryUpdatedData, ) -> bool: """Filter all except for the remove action from registry events.""" - return event.data["action"] == "remove" + return event_data["action"] == "remove" @callback 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.""" @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.""" - return bool(event.data["action"] == "remove") + return bool(event_data["action"] == "remove") @callback def cleanup_restored_states(event: Event) -> None: diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 44fc2356c83c82..dd9c038340e1a3 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -109,7 +109,7 @@ class _KeyedEventTracker(Generic[_TypedDictT]): [ HomeAssistant, dict[str, list[HassJob[[Event[_TypedDictT]], Any]]], - Event[_TypedDictT], + _TypedDictT, ], bool, ] @@ -237,11 +237,11 @@ def async_track_state_change( job = HassJob(action, f"track state change {entity_ids} {from_state} {to_state}") @callback - def state_change_filter(event: Event[EventStateChangedData]) -> bool: + def state_change_filter(event_data: EventStateChangedData) -> bool: """Handle specific state changes.""" if from_state is not 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 if not match_from_state(old_state_str): @@ -249,7 +249,7 @@ def state_change_filter(event: Event[EventStateChangedData]) -> bool: if to_state is not 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 if not match_to_state(new_state_str): @@ -270,7 +270,7 @@ def state_change_dispatcher(event: Event[EventStateChangedData]) -> None: @callback def state_change_listener(event: Event[EventStateChangedData]) -> None: """Handle specific state changes.""" - if not state_change_filter(event): + if not state_change_filter(event.data): return state_change_dispatcher(event) @@ -341,10 +341,10 @@ def _async_dispatch_entity_id_event( def _async_state_change_filter( hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event[EventStateChangedData]], Any]]], - event: Event[EventStateChangedData], + event_data: EventStateChangedData, ) -> bool: """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( @@ -473,10 +473,10 @@ def _async_dispatch_old_entity_id_or_entity_id_event( def _async_entity_registry_updated_filter( hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event[EventEntityRegistryUpdatedData]], Any]]], - event: Event[EventEntityRegistryUpdatedData], + event_data: EventEntityRegistryUpdatedData, ) -> bool: """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( @@ -512,10 +512,10 @@ def async_track_entity_registry_updated_event( def _async_device_registry_updated_filter( hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event[EventDeviceRegistryUpdatedData]], Any]]], - event: Event[EventDeviceRegistryUpdatedData], + event_data: EventDeviceRegistryUpdatedData, ) -> bool: """Filter device registry updates by device_id.""" - return event.data["device_id"] in callbacks + return event_data["device_id"] in callbacks @callback @@ -585,12 +585,12 @@ def _async_dispatch_domain_event( def _async_domain_added_filter( hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event[EventStateChangedData]], Any]]], - event: Event[EventStateChangedData], + event_data: EventStateChangedData, ) -> bool: """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 - 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( hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event[EventStateChangedData]], Any]]], - event: Event[EventStateChangedData], + event_data: EventStateChangedData, ) -> bool: """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 - or split_entity_id(event.data["entity_id"])[0] in callbacks + or split_entity_id(event_data["entity_id"])[0] in callbacks ) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 841226ac5842cb..acc4f146e8bc57 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -492,11 +492,11 @@ def async_setup(hass: HomeAssistant) -> None: hass.data[TRANSLATION_FLATTEN_CACHE] = cache @callback - def _async_load_translations_filter(event: Event) -> bool: + def _async_load_translations_filter(event_data: Mapping[str, Any]) -> bool: """Filter out unwanted events.""" nonlocal current_language if ( - new_language := event.data.get("language") + new_language := event_data.get("language") ) and new_language != current_language: current_language = new_language return True diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index c4c47f06418e72..07f3d06f4cc050 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -97,7 +97,7 @@ async def fire_events_with_filter(hass): events_to_fire = 10**6 @core.callback - def event_filter(event): + def event_filter(event_data): """Filter event.""" return False diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 522e7bd94acb04..97aa79512f270a 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -603,9 +603,9 @@ async def _matched_event(event: Event[Any]) -> None: await when_setup() @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.""" - return event.data[ATTR_COMPONENT] == component + return event_data[ATTR_COMPONENT] == component listeners.append( hass.bus.async_listen( diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 26c25bbb71f5a0..262fb48af4df89 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -98,7 +98,7 @@ def test_repr() -> None: EVENT_STATE_CHANGED, {"entity_id": "sensor.temperature", "old_state": None, "new_state": state}, 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(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.state == "" 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: @@ -247,7 +247,10 @@ async def test_process_timestamp_to_utc_isoformat() -> None: async def test_event_to_db_model() -> None: """Test we can round trip Event conversion.""" 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) dialect = SupportedDialect.MYSQL diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index bab28f0f90c89b..4f2eb4c758599f 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -78,13 +78,13 @@ async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmp_path: Path) - "new_state": mock_state, }, EventOrigin.local, - time_fired=now, + time_fired_timestamp=now.timestamp(), ) custom_event = Event( "custom_event", {"entity_id": "sensor.custom"}, EventOrigin.local, - time_fired=now, + time_fired_timestamp=now.timestamp(), ) number_of_migrations = 5 @@ -242,13 +242,13 @@ async def test_migrate_can_resume_entity_id_post_migration( "new_state": mock_state, }, EventOrigin.local, - time_fired=now, + time_fired_timestamp=now.timestamp(), ) custom_event = Event( "custom_event", {"entity_id": "sensor.custom"}, EventOrigin.local, - time_fired=now, + time_fired_timestamp=now.timestamp(), ) number_of_migrations = 5 diff --git a/tests/test_core.py b/tests/test_core.py index 0db2ba562ee74b..5b385700b00dfd 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -836,18 +836,23 @@ def test_event_eq() -> None: data = {"some": "attr"} context = ha.Context() 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() -def test_event_time_fired_timestamp() -> None: - """Test time_fired_timestamp.""" +def test_event_time() -> None: + """Test time_fired and time_fired_timestamp.""" now = dt_util.utcnow() - event = ha.Event("some_type", {"some": "attr"}, time_fired=now) - assert event.time_fired_timestamp == now.timestamp() + event = ha.Event( + "some_type", {"some": "attr"}, time_fired_timestamp=now.timestamp() + ) assert event.time_fired_timestamp == now.timestamp() + assert event.time_fired == now def test_event_json_fragment() -> None: @@ -856,7 +861,10 @@ def test_event_json_fragment() -> None: data = {"some": "attr"} context = ha.Context() 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 @@ -898,7 +906,7 @@ def test_event_as_dict() -> None: now = dt_util.utcnow() 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 = { "event_type": event_type, "data": data, @@ -1108,9 +1116,9 @@ def listener(event): calls.append(event) @ha.callback - def filter(event): + def filter(event_data): """Mock filter.""" - return not event.data["filtered"] + return not event_data["filtered"] unsub = hass.bus.async_listen("test", listener, event_filter=filter) @@ -3152,3 +3160,63 @@ async def _test(): "https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job" " for replacement options" ) 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()