From 1879db9f8f77137d4a46f9c9580a66633eb67cc8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Jul 2024 04:45:39 -0500 Subject: [PATCH] Revert to using call_soon for event triggers and state changed event trackers (#122735) --- .../homeassistant/triggers/event.py | 3 +- homeassistant/helpers/event.py | 12 ++- tests/components/automation/test_init.py | 1 + .../components/deconz/test_device_trigger.py | 1 + .../components/history/test_websocket_api.py | 88 ++++++++++++++++++- .../components/logbook/test_websocket_api.py | 80 ++++++++++++++++- .../components/universal/test_media_player.py | 1 + .../components/websocket_api/test_commands.py | 52 +++++++++++ tests/components/zha/test_device_trigger.py | 1 + 9 files changed, 233 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index 0a15585586e..98363de1f8d 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -154,7 +154,8 @@ async def async_attach_trigger( # If event doesn't match, skip event return - hass.async_run_hass_job( + hass.loop.call_soon( + hass.async_run_hass_job, job, { "trigger": { diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 0c77809079e..207dd024b6a 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -328,6 +328,16 @@ def async_track_state_change_event( return _async_track_state_change_event(hass, entity_ids, action, job_type) +@callback +def _async_dispatch_entity_id_event_soon( + hass: HomeAssistant, + callbacks: dict[str, list[HassJob[[Event[_StateEventDataT]], Any]]], + event: Event[_StateEventDataT], +) -> None: + """Dispatch to listeners soon to ensure one event loop runs before dispatch.""" + hass.loop.call_soon(_async_dispatch_entity_id_event, hass, callbacks, event) + + @callback def _async_dispatch_entity_id_event( hass: HomeAssistant, @@ -361,7 +371,7 @@ def _async_state_filter( _KEYED_TRACK_STATE_CHANGE = _KeyedEventTracker( key=_TRACK_STATE_CHANGE_DATA, event_type=EVENT_STATE_CHANGED, - dispatcher_callable=_async_dispatch_entity_id_event, + dispatcher_callable=_async_dispatch_entity_id_event_soon, filter_callable=_async_state_filter, ) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 8bac0c15db9..d8078984630 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -3229,6 +3229,7 @@ async def test_two_automations_call_restart_script_same_time( hass.states.async_set("binary_sensor.presence", "on") await hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 2 cancel() diff --git a/tests/components/deconz/test_device_trigger.py b/tests/components/deconz/test_device_trigger.py index 211ce14b8dc..6f74db0b82c 100644 --- a/tests/components/deconz/test_device_trigger.py +++ b/tests/components/deconz/test_device_trigger.py @@ -343,6 +343,7 @@ async def test_functional_device_trigger( assert len(hass.states.async_entity_ids(AUTOMATION_DOMAIN)) == 1 await sensor_ws_data({"state": {"buttonevent": 1002}}) + await hass.async_block_till_done() assert len(service_calls) == 1 assert service_calls[0].data["some"] == "test_trigger_button_press" diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index e5c33d0e7af..717840c6b05 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -2,7 +2,7 @@ import asyncio from datetime import timedelta -from unittest.mock import patch +from unittest.mock import ANY, patch from freezegun import freeze_time import pytest @@ -10,8 +10,9 @@ import pytest from homeassistant.components import history from homeassistant.components.history import websocket_api from homeassistant.components.recorder import Recorder -from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -2072,3 +2073,84 @@ async def test_history_stream_historical_only_with_start_time_state_past( "id": 1, "type": "event", } + + +async def test_history_stream_live_chained_events( + hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator +) -> None: + """Test history stream with history with a chained event.""" + now = dt_util.utcnow() + await async_setup_component(hass, "history", {}) + + await async_wait_recording_done(hass) + hass.states.async_set("binary_sensor.is_light", STATE_OFF) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/stream", + "entity_ids": ["binary_sensor.is_light"], + "start_time": now.isoformat(), + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": False, + "minimal_response": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 1 + assert response["type"] == "result" + + response = await client.receive_json() + + assert response == { + "event": { + "end_time": ANY, + "start_time": ANY, + "states": { + "binary_sensor.is_light": [ + { + "a": {}, + "lu": ANY, + "s": STATE_OFF, + }, + ], + }, + }, + "id": 1, + "type": "event", + } + + await async_recorder_block_till_done(hass) + + @callback + def auto_off_listener(event): + hass.states.async_set("binary_sensor.is_light", STATE_OFF) + + async_track_state_change_event(hass, ["binary_sensor.is_light"], auto_off_listener) + + hass.states.async_set("binary_sensor.is_light", STATE_ON) + + response = await client.receive_json() + assert response == { + "event": { + "states": { + "binary_sensor.is_light": [ + { + "lu": ANY, + "s": STATE_ON, + "a": {}, + }, + { + "lu": ANY, + "s": STATE_OFF, + "a": {}, + }, + ], + }, + }, + "id": 1, + "type": "event", + } diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index ac653737614..9b1a6bb44cc 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Callable from datetime import timedelta +from typing import Any from unittest.mock import ANY, patch from freezegun import freeze_time @@ -31,9 +32,10 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import Event, HomeAssistant, State +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -2965,3 +2967,79 @@ async def test_subscribe_all_entities_are_continuous_with_device( assert listeners_without_writes( hass.bus.async_listeners() ) == listeners_without_writes(init_listeners) + + +@pytest.mark.parametrize("params", [{"entity_ids": ["binary_sensor.is_light"]}, {}]) +async def test_live_stream_with_changed_state_change( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + params: dict[str, Any], +) -> None: + """Test the live logbook stream with chained events.""" + config = {recorder.CONF_COMMIT_INTERVAL: 0.5} + await async_setup_recorder_instance(hass, config) + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook") + ] + ) + + hass.states.async_set("binary_sensor.is_light", "ignored") + hass.states.async_set("binary_sensor.is_light", "init") + await async_wait_recording_done(hass) + + @callback + def auto_off_listener(event): + hass.states.async_set("binary_sensor.is_light", STATE_OFF) + + async_track_state_change_event(hass, ["binary_sensor.is_light"], auto_off_listener) + + websocket_client = await hass_ws_client() + init_listeners = hass.bus.async_listeners() + await websocket_client.send_json( + { + "id": 7, + "type": "logbook/event_stream", + "start_time": now.isoformat(), + **params, + } + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + await hass.async_block_till_done() + hass.states.async_set("binary_sensor.is_light", STATE_ON) + + recieved_rows = [] + while len(recieved_rows) < 3: + msg = await asyncio.wait_for(websocket_client.receive_json(), 2.5) + assert msg["id"] == 7 + assert msg["type"] == "event" + recieved_rows.extend(msg["event"]["events"]) + + # Make sure we get rows back in order + assert recieved_rows == [ + {"entity_id": "binary_sensor.is_light", "state": "init", "when": ANY}, + {"entity_id": "binary_sensor.is_light", "state": "on", "when": ANY}, + {"entity_id": "binary_sensor.is_light", "state": "off", "when": ANY}, + ] + + await websocket_client.send_json( + {"id": 8, "type": "unsubscribe_events", "subscription": 7} + ) + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + + assert msg["id"] == 8 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Check our listener got unsubscribed + assert listeners_without_writes( + hass.bus.async_listeners() + ) == listeners_without_writes(init_listeners) diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 527675a2208..187b62a93a1 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -1280,6 +1280,7 @@ async def test_master_state_with_template(hass: HomeAssistant) -> None: context = Context() hass.states.async_set("input_boolean.test", STATE_ON, context=context) await hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get("media_player.tv").state == STATE_OFF assert events[0].context == context diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 276a383d9e9..10a9c4876b9 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -24,6 +24,7 @@ from homeassistant.core import Context, HomeAssistant, State, SupportsResponse, from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component from homeassistant.util.json import json_loads @@ -2814,3 +2815,54 @@ async def test_integration_descriptions( assert response["success"] assert response["result"] + + +async def test_subscribe_entities_chained_state_change( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, +) -> None: + """Test chaining state changed events. + + Ensure the websocket sends the off state after + the on state. + """ + + @callback + def auto_off_listener(event): + hass.states.async_set("light.permitted", "off") + + async_track_state_change_event(hass, ["light.permitted"], auto_off_listener) + + await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + + data = await websocket_client.receive_str() + msg = json_loads(data) + assert msg["id"] == 7 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + data = await websocket_client.receive_str() + msg = json_loads(data) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"] == {"a": {}} + + hass.states.async_set("light.permitted", "on") + data = await websocket_client.receive_str() + msg = json_loads(data) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"] == { + "a": {"light.permitted": {"a": {}, "c": ANY, "lc": ANY, "s": "on"}} + } + data = await websocket_client.receive_str() + msg = json_loads(data) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"] == { + "c": {"light.permitted": {"+": {"c": ANY, "lc": ANY, "s": "off"}}} + } + + await websocket_client.close() + await hass.async_block_till_done() diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 24883dfc336..09b2d155547 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -306,6 +306,7 @@ async def test_device_offline_fires( assert zha_device.available is True zha_device.available = False zha_device.emit_zha_event({"device_event_type": "device_offline"}) + await hass.async_block_till_done() assert len(service_calls) == 1 assert service_calls[0].data["message"] == "service called"