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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user