From de6b679e6e68bbf2aeebb309befe6e4f9b16f9b1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Jun 2026 10:27:31 +0200 Subject: [PATCH] Rewrite homeassistant started trigger (#175160) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/automation/__init__.py | 27 +++--- .../homeassistant/triggers/homeassistant.py | 24 ++++-- .../components/template/coordinator.py | 1 - homeassistant/core.py | 86 ++++++++++++++++--- homeassistant/helpers/trigger.py | 13 ++- tests/components/automation/test_init.py | 5 +- .../triggers/test_homeassistant.py | 31 ++++++- tests/helpers/test_trigger.py | 31 +++++++ tests/test_core.py | 78 +++++++++++++++++ 9 files changed, 257 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 9599a3fc8485..9442c55571e8 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -27,7 +27,6 @@ from homeassistant.const import ( # noqa: F401 CONF_PATH, CONF_TRIGGERS, CONF_VARIABLES, - EVENT_HOMEASSISTANT_STARTED, SERVICE_RELOAD, SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -38,7 +37,7 @@ from homeassistant.core import ( CALLBACK_TYPE, Context, CoreState, - Event, + HassJob, HomeAssistant, ServiceCall, callback, @@ -830,13 +829,13 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): if self._condition is not None: self._condition.async_unload() - async def _async_enable_automation(self, event: Event) -> None: - """Start automation on startup.""" + async def _async_enable_automation(self) -> None: + """Arm the automation's triggers on startup.""" # Don't do anything if no longer enabled or already attached if not self._is_enabled or self._async_detach_triggers is not None: return - self._async_detach_triggers = await self._async_attach_triggers(True) + self._async_detach_triggers = await self._async_attach_triggers() self.async_write_ha_state() async def _async_enable(self) -> None: @@ -851,13 +850,14 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): self._is_enabled = True # HomeAssistant is starting up if self.hass.state is not CoreState.not_running: - self._async_detach_triggers = await self._async_attach_triggers(False) + self._async_detach_triggers = await self._async_attach_triggers() return - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, - self._async_enable_automation, - ) + # Arm the triggers in a startup job, which runs after all listeners to + # EVENT_HOMEASSISTANT_START have run but before EVENT_HOMEASSISTANT_STARTED + # has fired. This ensures automations do not fire during startup, but + # triggers listening for the started event are armed in time to catch it. + self.hass.async_add_startup_job(HassJob(self._async_enable_automation)) async def _async_disable(self, stop_actions: bool = DEFAULT_STOP_ACTIONS) -> None: """Disable the automation entity. @@ -942,9 +942,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): script_execution_set("not_triggered") - async def _async_attach_triggers( - self, home_assistant_start: bool - ) -> Callable[[], None] | None: + async def _async_attach_triggers(self) -> Callable[[], None] | None: """Set up the triggers.""" this = None if state := self.hass.states.get(self.entity_id): @@ -968,8 +966,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): DOMAIN, str(self.name), self._log_callback, - home_assistant_start, - variables, + variables=variables, did_not_trigger=self._handle_not_triggered, ) diff --git a/homeassistant/components/homeassistant/triggers/homeassistant.py b/homeassistant/components/homeassistant/triggers/homeassistant.py index 025ca661ac25..4f96b25d281e 100644 --- a/homeassistant/components/homeassistant/triggers/homeassistant.py +++ b/homeassistant/components/homeassistant/triggers/homeassistant.py @@ -2,8 +2,8 @@ import voluptuous as vol -from homeassistant.const import CONF_EVENT, CONF_PLATFORM -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant +from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -45,9 +45,12 @@ async def async_attach_trigger( }, ) - # Automation are enabled while hass is starting up, fire right away - # Check state because a config reload shouldn't trigger it. - if trigger_info["home_assistant_start"]: + unsub: CALLBACK_TYPE | None = None + + @callback + def hass_started(_: Event) -> None: + nonlocal unsub + unsub = None hass.async_run_hass_job( job, { @@ -60,4 +63,13 @@ async def async_attach_trigger( }, ) - return lambda: None + # Only fires if armed before EVENT_HOMEASSISTANT_STARTED; if hass is already + # started, the trigger doesn't fire. + unsub = hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, hass_started) + + @callback + def remove() -> None: + if unsub is not None: + unsub() + + return remove diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index 9d7970953a21..6a78aed9ed13 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -126,7 +126,6 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): DOMAIN, self.name, self.logger.log, - start_event is not None, ) async def _handle_triggered_with_script( diff --git a/homeassistant/core.py b/homeassistant/core.py index 2f56495fd631..5bc5f94c85e3 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -156,6 +156,8 @@ class EventStateReportedData(EventStateEventData): # How long to wait until things that run on startup have to finish. TIMEOUT_EVENT_START = 15 +# How long to wait until startup jobs have to finish. +TIMEOUT_STARTUP_JOBS = 15 EVENTS_EXCLUDED_FROM_MATCH_ALL = { @@ -416,6 +418,7 @@ class HomeAssistant: self.timeout: TimeoutManager = TimeoutManager() self._stop_future: concurrent.futures.Future[None] | None = None self._shutdown_jobs: list[HassJobWithArgs] = [] + self._startup_jobs: list[HassJobWithArgs] = [] self.import_executor = InterruptibleThreadPoolExecutor( max_workers=1, thread_name_prefix="ImportExecutor" ) @@ -503,6 +506,20 @@ class HomeAssistant: """ _LOGGER.info("Starting Home Assistant %s", __version__) + def _log_startup_blocked(tasks: set[asyncio.Future[Any]]) -> None: + """Log when startup is blocked by tasks.""" + _LOGGER.warning( + ( + "Something is blocking Home Assistant from wrapping up the start up" + " phase. We're going to continue anyway. Please report the" + " following info at" + " https://github.com/home-assistant/core/issues: %s" + " The system is waiting for tasks: %s" + ), + ", ".join(self.config.components), + tasks, + ) + self.set_state(CoreState.starting) self.bus.async_fire_internal(EVENT_CORE_CONFIG_UPDATE) self.bus.async_fire_internal(EVENT_HOMEASSISTANT_START) @@ -515,17 +532,23 @@ class HomeAssistant: ) if pending: - _LOGGER.warning( - ( - "Something is blocking Home Assistant from wrapping up the start up" - " phase. We're going to continue anyway. Please report the" - " following info at" - " https://github.com/home-assistant/core/issues: %s" - " The system is waiting for tasks: %s" - ), - ", ".join(self.config.components), - self._tasks, - ) + _log_startup_blocked(self._tasks) + + # Run startup jobs + tasks: list[asyncio.Future[Any]] = [] + for job in self._startup_jobs: + task_or_none = self.async_run_hass_job(job.job, *job.args) + if task_or_none is None: + continue + tasks.append(task_or_none) + self._startup_jobs.clear() + if not tasks: + pending = None + else: + _done, pending = await asyncio.wait(tasks, timeout=TIMEOUT_STARTUP_JOBS) + + if pending: + _log_startup_blocked(pending) if self.state is not CoreState.starting: _LOGGER.warning( @@ -1032,6 +1055,9 @@ class HomeAssistant: ) -> CALLBACK_TYPE: """Add a HassJob which will be executed on shutdown. + The job will be called (and awaited if it returns a coroutine) before firing + of event EVENT_HOMEASSISTANT_STOP when Home Assistant is shutting down. + This method must be run in the event loop. hassjob: HassJob @@ -1048,6 +1074,44 @@ class HomeAssistant: return remove_job + @overload + @callback + def async_add_startup_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any]], *args: Any + ) -> CALLBACK_TYPE: ... + + @overload + @callback + def async_add_startup_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any + ) -> CALLBACK_TYPE: ... + + @callback + def async_add_startup_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any + ) -> CALLBACK_TYPE: + """Add a HassJob which will be executed on startup. + + The job will be called (and awaited if it returns a coroutine) before firing + of event EVENT_HOMEASSISTANT_STARTED when Home Assistant is starting. + + This method must be run in the event loop. + + hassjob: HassJob + args: parameters for method to call. + + Returns function to remove the job. + """ + job_with_args = HassJobWithArgs(hassjob, args) + self._startup_jobs.append(job_with_args) + + @callback + def remove_job() -> None: + if job_with_args in self._startup_jobs: + self._startup_jobs.remove(job_with_args) + + return remove_job + def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" if self.state is CoreState.not_running: # just ignore diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index bac1f7deebb5..2059575b8b1e 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -79,6 +79,7 @@ from .automation import ( move_options_fields_to_top_level, ) from .event import async_call_later +from .frame import report_usage from .integration_platform import async_process_integration_platforms from .selector import ( NumericThresholdMode, @@ -1350,7 +1351,6 @@ class TriggerInfo(TypedDict): domain: str name: str - home_assistant_start: bool variables: TemplateVarsType trigger_data: TriggerData @@ -1671,7 +1671,7 @@ async def async_initialize_triggers( domain: str, name: str, log_cb: Callable, - home_assistant_start: bool = False, + home_assistant_start: bool | UndefinedType = UNDEFINED, variables: TemplateVarsType = None, *, did_not_trigger: TriggerNotTriggeredAction | None = None, @@ -1682,6 +1682,14 @@ async def async_initialize_triggers( invoked - for new-style triggers that support it - when a trigger evaluates a relevant change but reports it did not fire. Old-style triggers ignore it. """ + if home_assistant_start is not UNDEFINED: + report_usage( + "passes `home_assistant_start` to `async_initialize_triggers`, which is " + "deprecated and will be removed in Home Assistant 2027.8; the parameter " + "no longer has any effect", + breaks_in_ha_version="2027.8.0", + ) + triggers: list[asyncio.Task[CALLBACK_TYPE]] = [] for idx, conf in enumerate(trigger_config): # Skip triggers that are not enabled @@ -1705,7 +1713,6 @@ async def async_initialize_triggers( info = TriggerInfo( domain=domain, name=name, - home_assistant_start=home_assistant_start, variables=variables, trigger_data=trigger_data, ) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index a3e06f3c353c..938af58b0f64 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -23,7 +23,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, CONF_ID, - EVENT_HOMEASSISTANT_STARTED, SERVICE_RELOAD, SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -1683,7 +1682,9 @@ async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(calls) == 0 - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + # Triggers are armed by a startup job which runs during async_start, before + # EVENT_HOMEASSISTANT_STARTED is fired. + await hass.async_start() await hass.async_block_till_done() assert automation.is_on(hass, "automation.hello") diff --git a/tests/components/homeassistant/triggers/test_homeassistant.py b/tests/components/homeassistant/triggers/test_homeassistant.py index 9c552a0324b4..c686f8273052 100644 --- a/tests/components/homeassistant/triggers/test_homeassistant.py +++ b/tests/components/homeassistant/triggers/test_homeassistant.py @@ -29,7 +29,9 @@ from tests.common import async_mock_service ) @pytest.mark.usefixtures("mock_hass_config") async def test_if_fires_on_hass_start( - hass: HomeAssistant, hass_config: ConfigType + hass: HomeAssistant, + hass_config: ConfigType, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the firing when Home Assistant starts.""" calls = async_mock_service(hass, "test", "automation") @@ -52,6 +54,33 @@ async def test_if_fires_on_hass_start( assert len(calls) == 1 assert calls[0].data["id"] == 0 + # Detaching the trigger after it fired must not re-invoke the stale once + # listener's remove callback. + assert "Unable to remove unknown job listener" not in caplog.text + + +async def test_if_not_fires_when_set_up_after_start(hass: HomeAssistant) -> None: + """Test the start trigger stays silent when armed after Home Assistant started.""" + calls = async_mock_service(hass, "test", "automation") + assert hass.state is CoreState.running + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "hello", + "trigger": {"platform": "homeassistant", "event": "start"}, + "action": {"service": "test.automation"}, + } + }, + ) + assert automation.is_on(hass, "automation.hello") + await hass.async_block_till_done() + + # EVENT_HOMEASSISTANT_STARTED has already fired, so the trigger must not fire. + assert len(calls) == 0 + async def test_if_fires_on_hass_shutdown(hass: HomeAssistant) -> None: """Test the firing when Home Assistant shuts down.""" diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 6746f70b69a4..351ad9dc4761 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -3856,6 +3856,37 @@ async def _arm_off_to_on_trigger( ) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_async_initialize_triggers_home_assistant_start_deprecated( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test passing the deprecated home_assistant_start parameter is reported.""" + log = logging.getLogger(__name__) + + @callback + def action(run_variables: dict[str, Any], context: Context | None = None) -> None: + pass + + # The parameter no longer has any effect; passing it is logged, not raised. + assert ( + await async_initialize_triggers( + hass, + [], + action, + "test", + "test", + log.log, + home_assistant_start=True, + ) + is None + ) + assert ( + "passes `home_assistant_start` to `async_initialize_triggers`, which is " + "deprecated and will be removed in Home Assistant 2027.8" in caplog.text + ) + + def _set_or_remove_state( hass: HomeAssistant, entity_id: str, state: str | None ) -> None: diff --git a/tests/test_core.py b/tests/test_core.py index 54a383c55ead..c393b5f50209 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3211,6 +3211,84 @@ async def test_cancel_shutdown_job(hass: HomeAssistant) -> None: assert not evt.is_set() +async def test_shutdown_job_runs_before_stop(hass: HomeAssistant) -> None: + """Test shutdown jobs run before EVENT_HOMEASSISTANT_STOP is fired.""" + order: list[str] = [] + + @callback + def stop_listener(event: ha.Event) -> None: + order.append("stop_listener") + + async def shutdown_func() -> None: + order.append("shutdown_job") + assert hass.state is CoreState.running + assert "stop_listener" not in order + + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, stop_listener) + hass.async_add_shutdown_job(HassJob(shutdown_func, "shutdown_job")) + + await hass.async_stop() + assert order == ["shutdown_job", "stop_listener"] + + +async def test_startup_job(hass: HomeAssistant) -> None: + """Test async_add_startup_job.""" + evt = asyncio.Event() + + async def startup_func() -> None: + # Sleep to ensure core is waiting for the task to finish + await asyncio.sleep(0.01) + evt.set() + + job = HassJob(startup_func, "startup_job") + hass.async_add_startup_job(job) + await hass.async_start() + assert evt.is_set() + + +async def test_cancel_startup_job(hass: HomeAssistant) -> None: + """Test cancelling a job added to async_add_startup_job.""" + evt = asyncio.Event() + + async def startup_func() -> None: + evt.set() + + job = HassJob(startup_func, "startup_job") + cancel = hass.async_add_startup_job(job) + cancel() + await hass.async_start() + assert not evt.is_set() + + +async def test_startup_job_runs_after_start_before_started(hass: HomeAssistant) -> None: + """Test startup jobs run after START listeners finish and before STARTED is fired.""" + order: list[str] = [] + + async def start_listener(event: ha.Event) -> None: + # Yield control to prove startup jobs wait for in-flight START listeners. + await asyncio.sleep(0.01) + order.append("start_listener") + + @callback + def started_listener(event: ha.Event) -> None: + order.append("started_listener") + + async def startup_func() -> None: + order.append("startup_job") + assert hass.state is CoreState.starting + assert "started_listener" not in order + + hass.bus.async_listen(EVENT_HOMEASSISTANT_START, start_listener) + hass.bus.async_listen(EVENT_HOMEASSISTANT_STARTED, started_listener) + hass.async_add_startup_job(HassJob(startup_func, "startup_job")) + + hass.set_state(CoreState.not_running) + await hass.async_start() + await hass.async_block_till_done() + + assert order == ["start_listener", "startup_job", "started_listener"] + + def test_one_time_listener_repr(hass: HomeAssistant) -> None: """Test one time listener repr."""