1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-08 07:15:09 +01:00

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>
This commit is contained in:
Erik Montnemery
2026-06-30 10:27:31 +02:00
committed by GitHub
parent 3bcdb621ec
commit de6b679e6e
9 changed files with 257 additions and 39 deletions
+12 -15
View File
@@ -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,
)
@@ -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
@@ -126,7 +126,6 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
DOMAIN,
self.name,
self.logger.log,
start_event is not None,
)
async def _handle_triggered_with_script(
+75 -11
View File
@@ -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
+10 -3
View File
@@ -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,
)
+3 -2
View File
@@ -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")
@@ -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."""
+31
View File
@@ -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:
+78
View File
@@ -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."""