From a9a9e1f199e936f4528a2c96e7cb09afe27516d1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 10 Mar 2021 23:42:13 +0100 Subject: [PATCH] Tweak automation tracing (#47721) --- .../components/automation/__init__.py | 224 +---- homeassistant/components/automation/trace.py | 206 +++++ .../components/automation/websocket_api.py | 229 +++++ homeassistant/components/config/automation.py | 224 ----- homeassistant/helpers/script.py | 2 +- .../automation/test_websocket_api.py | 803 ++++++++++++++++++ tests/components/config/test_automation.py | 772 +---------------- 7 files changed, 1248 insertions(+), 1212 deletions(-) create mode 100644 homeassistant/components/automation/trace.py create mode 100644 homeassistant/components/automation/websocket_api.py create mode 100644 tests/components/automation/test_websocket_api.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 7e1352afc2f..50b9fc43bf5 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -1,21 +1,6 @@ """Allow to set up simple automation rules via the config file.""" -from collections import OrderedDict -from contextlib import contextmanager -import datetime as dt -from itertools import count import logging -from typing import ( - Any, - Awaitable, - Callable, - Deque, - Dict, - List, - Optional, - Set, - Union, - cast, -) +from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Union, cast import voluptuous as vol from voluptuous.humanize import humanize_error @@ -68,18 +53,13 @@ from homeassistant.helpers.script import ( ) from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.service import async_register_admin_service -from homeassistant.helpers.trace import ( - TraceElement, - trace_get, - trace_id_set, - trace_path, -) +from homeassistant.helpers.trace import trace_get, trace_path from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.helpers.typing import TemplateVarsType from homeassistant.loader import bind_hass -from homeassistant.util import dt as dt_util from homeassistant.util.dt import parse_datetime +from . import websocket_api from .config import AutomationConfig, async_validate_config_item # Not used except by packages to check config structure @@ -94,6 +74,7 @@ from .const import ( LOGGER, ) from .helpers import async_get_blueprints +from .trace import DATA_AUTOMATION_TRACE, trace_automation # mypy: allow-untyped-calls, allow-untyped-defs # mypy: no-check-untyped-defs, no-warn-return-any @@ -113,9 +94,6 @@ ATTR_SOURCE = "source" ATTR_VARIABLES = "variables" SERVICE_TRIGGER = "trigger" -DATA_AUTOMATION_TRACE = "automation_trace" -STORED_TRACES = 5 # Stored traces per automation - _LOGGER = logging.getLogger(__name__) AutomationActionType = Callable[[HomeAssistant, TemplateVarsType], Awaitable[None]] @@ -194,9 +172,12 @@ def devices_in_automation(hass: HomeAssistant, entity_id: str) -> List[str]: async def async_setup(hass, config): """Set up all automations.""" + # Local import to avoid circular import hass.data[DOMAIN] = component = EntityComponent(LOGGER, DOMAIN, hass) hass.data.setdefault(DATA_AUTOMATION_TRACE, {}) + websocket_api.async_setup(hass) + # To register the automation blueprints async_get_blueprints(hass) @@ -243,167 +224,6 @@ async def async_setup(hass, config): return True -class AutomationTrace: - """Container for automation trace.""" - - _run_ids = count(0) - - def __init__( - self, - unique_id: Optional[str], - config: Dict[str, Any], - trigger: Dict[str, Any], - context: Context, - ): - """Container for automation trace.""" - self._action_trace: Optional[Dict[str, Deque[TraceElement]]] = None - self._condition_trace: Optional[Dict[str, Deque[TraceElement]]] = None - self._config: Dict[str, Any] = config - self._context: Context = context - self._error: Optional[Exception] = None - self._state: str = "running" - self.run_id: str = str(next(self._run_ids)) - self._timestamp_finish: Optional[dt.datetime] = None - self._timestamp_start: dt.datetime = dt_util.utcnow() - self._trigger: Dict[str, Any] = trigger - self._unique_id: Optional[str] = unique_id - self._variables: Optional[Dict[str, Any]] = None - - def set_action_trace(self, trace: Dict[str, Deque[TraceElement]]) -> None: - """Set action trace.""" - self._action_trace = trace - - def set_condition_trace(self, trace: Dict[str, Deque[TraceElement]]) -> None: - """Set condition trace.""" - self._condition_trace = trace - - def set_error(self, ex: Exception) -> None: - """Set error.""" - self._error = ex - - def set_variables(self, variables: Dict[str, Any]) -> None: - """Set variables.""" - self._variables = variables - - def finished(self) -> None: - """Set finish time.""" - self._timestamp_finish = dt_util.utcnow() - self._state = "stopped" - - def as_dict(self) -> Dict[str, Any]: - """Return dictionary version of this AutomationTrace.""" - - action_traces = {} - condition_traces = {} - if self._action_trace: - for key, trace_list in self._action_trace.items(): - action_traces[key] = [item.as_dict() for item in trace_list] - - if self._condition_trace: - for key, trace_list in self._condition_trace.items(): - condition_traces[key] = [item.as_dict() for item in trace_list] - - result = { - "action_trace": action_traces, - "condition_trace": condition_traces, - "config": self._config, - "context": self._context, - "run_id": self.run_id, - "state": self._state, - "timestamp": { - "start": self._timestamp_start, - "finish": self._timestamp_finish, - }, - "trigger": self._trigger, - "unique_id": self._unique_id, - "variables": self._variables, - } - if self._error is not None: - result["error"] = str(self._error) - return result - - def as_short_dict(self) -> Dict[str, Any]: - """Return a brief dictionary version of this AutomationTrace.""" - - last_action = None - last_condition = None - - if self._action_trace: - last_action = list(self._action_trace.keys())[-1] - if self._condition_trace: - last_condition = list(self._condition_trace.keys())[-1] - - result = { - "last_action": last_action, - "last_condition": last_condition, - "run_id": self.run_id, - "state": self._state, - "timestamp": { - "start": self._timestamp_start, - "finish": self._timestamp_finish, - }, - "trigger": self._trigger.get("description"), - "unique_id": self._unique_id, - } - if self._error is not None: - result["error"] = str(self._error) - if last_action is not None: - result["last_action"] = last_action - result["last_condition"] = last_condition - - return result - - -class LimitedSizeDict(OrderedDict): - """OrderedDict limited in size.""" - - def __init__(self, *args, **kwds): - """Initialize OrderedDict limited in size.""" - self.size_limit = kwds.pop("size_limit", None) - OrderedDict.__init__(self, *args, **kwds) - self._check_size_limit() - - def __setitem__(self, key, value): - """Set item and check dict size.""" - OrderedDict.__setitem__(self, key, value) - self._check_size_limit() - - def _check_size_limit(self): - """Check dict size and evict items in FIFO order if needed.""" - if self.size_limit is not None: - while len(self) > self.size_limit: - self.popitem(last=False) - - -@contextmanager -def trace_automation(hass, unique_id, config, trigger, context): - """Trace action execution of automation with automation_id.""" - automation_trace = AutomationTrace(unique_id, config, trigger, context) - trace_id_set((unique_id, automation_trace.run_id)) - - if unique_id: - automation_traces = hass.data[DATA_AUTOMATION_TRACE] - if unique_id not in automation_traces: - automation_traces[unique_id] = LimitedSizeDict(size_limit=STORED_TRACES) - automation_traces[unique_id][automation_trace.run_id] = automation_trace - - try: - yield automation_trace - except Exception as ex: # pylint: disable=broad-except - if unique_id: - automation_trace.set_error(ex) - raise ex - finally: - if unique_id: - automation_trace.finished() - _LOGGER.debug( - "Automation finished. Summary:\n\ttrigger: %s\n\tcondition: %s\n\taction: %s", - automation_trace._trigger, # pylint: disable=protected-access - automation_trace._condition_trace, # pylint: disable=protected-access - automation_trace._action_trace, # pylint: disable=protected-access - ) - - class AutomationEntity(ToggleEntity, RestoreEntity): """Entity to show status of entity.""" @@ -570,9 +390,8 @@ class AutomationEntity(ToggleEntity, RestoreEntity): reason = f' by {run_variables["trigger"]["description"]}' self._logger.debug("Automation triggered%s", reason) - trigger = run_variables["trigger"] if "trigger" in run_variables else None with trace_automation( - self.hass, self.unique_id, self._raw_config, trigger, context + self.hass, self.unique_id, self._raw_config, context ) as automation_trace: if self._variables: try: @@ -891,30 +710,3 @@ def _trigger_extract_entities(trigger_conf: dict) -> List[str]: return ["sun.sun"] return [] - - -@callback -def get_debug_traces_for_automation(hass, automation_id, summary=False): - """Return a serializable list of debug traces for an automation.""" - traces = [] - - for trace in hass.data[DATA_AUTOMATION_TRACE].get(automation_id, {}).values(): - if summary: - traces.append(trace.as_short_dict()) - else: - traces.append(trace.as_dict()) - - return traces - - -@callback -def get_debug_traces(hass, summary=False): - """Return a serializable list of debug traces.""" - traces = {} - - for automation_id in hass.data[DATA_AUTOMATION_TRACE]: - traces[automation_id] = get_debug_traces_for_automation( - hass, automation_id, summary - ) - - return traces diff --git a/homeassistant/components/automation/trace.py b/homeassistant/components/automation/trace.py new file mode 100644 index 00000000000..ebd981f2541 --- /dev/null +++ b/homeassistant/components/automation/trace.py @@ -0,0 +1,206 @@ +"""Trace support for automation.""" +from collections import OrderedDict +from contextlib import contextmanager +import datetime as dt +from itertools import count +import logging +from typing import Any, Awaitable, Callable, Deque, Dict, Optional + +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.helpers.trace import TraceElement, trace_id_set +from homeassistant.helpers.typing import TemplateVarsType +from homeassistant.util import dt as dt_util + +DATA_AUTOMATION_TRACE = "automation_trace" +STORED_TRACES = 5 # Stored traces per automation + +_LOGGER = logging.getLogger(__name__) +AutomationActionType = Callable[[HomeAssistant, TemplateVarsType], Awaitable[None]] + +# mypy: allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs, no-warn-return-any + + +class AutomationTrace: + """Container for automation trace.""" + + _run_ids = count(0) + + def __init__( + self, + unique_id: Optional[str], + config: Dict[str, Any], + context: Context, + ): + """Container for automation trace.""" + self._action_trace: Optional[Dict[str, Deque[TraceElement]]] = None + self._condition_trace: Optional[Dict[str, Deque[TraceElement]]] = None + self._config: Dict[str, Any] = config + self._context: Context = context + self._error: Optional[Exception] = None + self._state: str = "running" + self.run_id: str = str(next(self._run_ids)) + self._timestamp_finish: Optional[dt.datetime] = None + self._timestamp_start: dt.datetime = dt_util.utcnow() + self._unique_id: Optional[str] = unique_id + self._variables: Optional[Dict[str, Any]] = None + + def set_action_trace(self, trace: Dict[str, Deque[TraceElement]]) -> None: + """Set action trace.""" + self._action_trace = trace + + def set_condition_trace(self, trace: Dict[str, Deque[TraceElement]]) -> None: + """Set condition trace.""" + self._condition_trace = trace + + def set_error(self, ex: Exception) -> None: + """Set error.""" + self._error = ex + + def set_variables(self, variables: Dict[str, Any]) -> None: + """Set variables.""" + self._variables = variables + + def finished(self) -> None: + """Set finish time.""" + self._timestamp_finish = dt_util.utcnow() + self._state = "stopped" + + def as_dict(self) -> Dict[str, Any]: + """Return dictionary version of this AutomationTrace.""" + + result = self.as_short_dict() + + action_traces = {} + condition_traces = {} + if self._action_trace: + for key, trace_list in self._action_trace.items(): + action_traces[key] = [item.as_dict() for item in trace_list] + + if self._condition_trace: + for key, trace_list in self._condition_trace.items(): + condition_traces[key] = [item.as_dict() for item in trace_list] + + result.update( + { + "action_trace": action_traces, + "condition_trace": condition_traces, + "config": self._config, + "context": self._context, + "variables": self._variables, + } + ) + if self._error is not None: + result["error"] = str(self._error) + return result + + def as_short_dict(self) -> Dict[str, Any]: + """Return a brief dictionary version of this AutomationTrace.""" + + last_action = None + last_condition = None + trigger = None + + if self._action_trace: + last_action = list(self._action_trace)[-1] + if self._condition_trace: + last_condition = list(self._condition_trace)[-1] + if self._variables: + trigger = self._variables.get("trigger", {}).get("description") + + result = { + "last_action": last_action, + "last_condition": last_condition, + "run_id": self.run_id, + "state": self._state, + "timestamp": { + "start": self._timestamp_start, + "finish": self._timestamp_finish, + }, + "trigger": trigger, + "unique_id": self._unique_id, + } + if self._error is not None: + result["error"] = str(self._error) + if last_action is not None: + result["last_action"] = last_action + result["last_condition"] = last_condition + + return result + + +class LimitedSizeDict(OrderedDict): + """OrderedDict limited in size.""" + + def __init__(self, *args, **kwds): + """Initialize OrderedDict limited in size.""" + self.size_limit = kwds.pop("size_limit", None) + OrderedDict.__init__(self, *args, **kwds) + self._check_size_limit() + + def __setitem__(self, key, value): + """Set item and check dict size.""" + OrderedDict.__setitem__(self, key, value) + self._check_size_limit() + + def _check_size_limit(self): + """Check dict size and evict items in FIFO order if needed.""" + if self.size_limit is not None: + while len(self) > self.size_limit: + self.popitem(last=False) + + +@contextmanager +def trace_automation(hass, unique_id, config, context): + """Trace action execution of automation with automation_id.""" + automation_trace = AutomationTrace(unique_id, config, context) + trace_id_set((unique_id, automation_trace.run_id)) + + if unique_id: + automation_traces = hass.data[DATA_AUTOMATION_TRACE] + if unique_id not in automation_traces: + automation_traces[unique_id] = LimitedSizeDict(size_limit=STORED_TRACES) + automation_traces[unique_id][automation_trace.run_id] = automation_trace + + try: + yield automation_trace + except Exception as ex: # pylint: disable=broad-except + if unique_id: + automation_trace.set_error(ex) + raise ex + finally: + if unique_id: + automation_trace.finished() + + +@callback +def get_debug_trace(hass, automation_id, run_id): + """Return a serializable debug trace.""" + return hass.data[DATA_AUTOMATION_TRACE][automation_id][run_id] + + +@callback +def get_debug_traces_for_automation(hass, automation_id, summary=False): + """Return a serializable list of debug traces for an automation.""" + traces = [] + + for trace in hass.data[DATA_AUTOMATION_TRACE].get(automation_id, {}).values(): + if summary: + traces.append(trace.as_short_dict()) + else: + traces.append(trace.as_dict()) + + return traces + + +@callback +def get_debug_traces(hass, summary=False): + """Return a serializable list of debug traces.""" + traces = {} + + for automation_id in hass.data[DATA_AUTOMATION_TRACE]: + traces[automation_id] = get_debug_traces_for_automation( + hass, automation_id, summary + ) + + return traces diff --git a/homeassistant/components/automation/websocket_api.py b/homeassistant/components/automation/websocket_api.py new file mode 100644 index 00000000000..aaebbef7f83 --- /dev/null +++ b/homeassistant/components/automation/websocket_api.py @@ -0,0 +1,229 @@ +"""Websocket API for automation.""" +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import ( + DATA_DISPATCHER, + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.script import ( + SCRIPT_BREAKPOINT_HIT, + SCRIPT_DEBUG_CONTINUE_ALL, + breakpoint_clear, + breakpoint_clear_all, + breakpoint_list, + breakpoint_set, + debug_continue, + debug_step, + debug_stop, +) + +from .trace import get_debug_trace, get_debug_traces + +# mypy: allow-untyped-calls, allow-untyped-defs + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the websocket API.""" + websocket_api.async_register_command(hass, websocket_automation_trace_get) + websocket_api.async_register_command(hass, websocket_automation_trace_list) + websocket_api.async_register_command(hass, websocket_automation_breakpoint_clear) + websocket_api.async_register_command(hass, websocket_automation_breakpoint_list) + websocket_api.async_register_command(hass, websocket_automation_breakpoint_set) + websocket_api.async_register_command(hass, websocket_automation_debug_continue) + websocket_api.async_register_command(hass, websocket_automation_debug_step) + websocket_api.async_register_command(hass, websocket_automation_debug_stop) + websocket_api.async_register_command(hass, websocket_subscribe_breakpoint_events) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "automation/trace/get", + vol.Required("automation_id"): str, + vol.Required("run_id"): str, + } +) +def websocket_automation_trace_get(hass, connection, msg): + """Get automation traces.""" + automation_id = msg["automation_id"] + run_id = msg["run_id"] + + trace = get_debug_trace(hass, automation_id, run_id) + + connection.send_result(msg["id"], trace) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "automation/trace/list"}) +def websocket_automation_trace_list(hass, connection, msg): + """Summarize automation traces.""" + automation_traces = get_debug_traces(hass, summary=True) + + connection.send_result(msg["id"], automation_traces) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "automation/debug/breakpoint/set", + vol.Required("automation_id"): str, + vol.Required("node"): str, + vol.Optional("run_id"): str, + } +) +def websocket_automation_breakpoint_set(hass, connection, msg): + """Set breakpoint.""" + automation_id = msg["automation_id"] + node = msg["node"] + run_id = msg.get("run_id") + + if ( + SCRIPT_BREAKPOINT_HIT not in hass.data.get(DATA_DISPATCHER, {}) + or not hass.data[DATA_DISPATCHER][SCRIPT_BREAKPOINT_HIT] + ): + raise HomeAssistantError("No breakpoint subscription") + + result = breakpoint_set(hass, automation_id, run_id, node) + connection.send_result(msg["id"], result) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "automation/debug/breakpoint/clear", + vol.Required("automation_id"): str, + vol.Required("node"): str, + vol.Optional("run_id"): str, + } +) +def websocket_automation_breakpoint_clear(hass, connection, msg): + """Clear breakpoint.""" + automation_id = msg["automation_id"] + node = msg["node"] + run_id = msg.get("run_id") + + result = breakpoint_clear(hass, automation_id, run_id, node) + + connection.send_result(msg["id"], result) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + {vol.Required("type"): "automation/debug/breakpoint/list"} +) +def websocket_automation_breakpoint_list(hass, connection, msg): + """List breakpoints.""" + breakpoints = breakpoint_list(hass) + for _breakpoint in breakpoints: + _breakpoint["automation_id"] = _breakpoint.pop("unique_id") + + connection.send_result(msg["id"], breakpoints) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + {vol.Required("type"): "automation/debug/breakpoint/subscribe"} +) +def websocket_subscribe_breakpoint_events(hass, connection, msg): + """Subscribe to breakpoint events.""" + + @callback + def breakpoint_hit(automation_id, run_id, node): + """Forward events to websocket.""" + connection.send_message( + websocket_api.event_message( + msg["id"], + { + "automation_id": automation_id, + "run_id": run_id, + "node": node, + }, + ) + ) + + remove_signal = async_dispatcher_connect( + hass, SCRIPT_BREAKPOINT_HIT, breakpoint_hit + ) + + @callback + def unsub(): + """Unsubscribe from breakpoint events.""" + remove_signal() + if ( + SCRIPT_BREAKPOINT_HIT not in hass.data.get(DATA_DISPATCHER, {}) + or not hass.data[DATA_DISPATCHER][SCRIPT_BREAKPOINT_HIT] + ): + breakpoint_clear_all(hass) + async_dispatcher_send(hass, SCRIPT_DEBUG_CONTINUE_ALL) + + connection.subscriptions[msg["id"]] = unsub + + connection.send_message(websocket_api.result_message(msg["id"])) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "automation/debug/continue", + vol.Required("automation_id"): str, + vol.Required("run_id"): str, + } +) +def websocket_automation_debug_continue(hass, connection, msg): + """Resume execution of halted automation.""" + automation_id = msg["automation_id"] + run_id = msg["run_id"] + + result = debug_continue(hass, automation_id, run_id) + + connection.send_result(msg["id"], result) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "automation/debug/step", + vol.Required("automation_id"): str, + vol.Required("run_id"): str, + } +) +def websocket_automation_debug_step(hass, connection, msg): + """Single step a halted automation.""" + automation_id = msg["automation_id"] + run_id = msg["run_id"] + + result = debug_step(hass, automation_id, run_id) + + connection.send_result(msg["id"], result) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "automation/debug/stop", + vol.Required("automation_id"): str, + vol.Required("run_id"): str, + } +) +def websocket_automation_debug_stop(hass, connection, msg): + """Stop a halted automation.""" + automation_id = msg["automation_id"] + run_id = msg["run_id"] + + result = debug_stop(hass, automation_id, run_id) + + connection.send_result(msg["id"], result) diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index b5aa1bf7af5..01e22297c0d 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -2,13 +2,6 @@ from collections import OrderedDict import uuid -import voluptuous as vol - -from homeassistant.components import websocket_api -from homeassistant.components.automation import ( - get_debug_traces, - get_debug_traces_for_automation, -) from homeassistant.components.automation.config import ( DOMAIN, PLATFORM_SCHEMA, @@ -16,25 +9,7 @@ from homeassistant.components.automation.config import ( ) from homeassistant.config import AUTOMATION_CONFIG_PATH from homeassistant.const import CONF_ID, SERVICE_RELOAD -from homeassistant.core import callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry -from homeassistant.helpers.dispatcher import ( - DATA_DISPATCHER, - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.script import ( - SCRIPT_BREAKPOINT_HIT, - SCRIPT_DEBUG_CONTINUE_ALL, - breakpoint_clear, - breakpoint_clear_all, - breakpoint_list, - breakpoint_set, - debug_continue, - debug_step, - debug_stop, -) from . import ACTION_DELETE, EditIdBasedConfigView @@ -42,16 +17,6 @@ from . import ACTION_DELETE, EditIdBasedConfigView async def async_setup(hass): """Set up the Automation config API.""" - websocket_api.async_register_command(hass, websocket_automation_trace_get) - websocket_api.async_register_command(hass, websocket_automation_trace_list) - websocket_api.async_register_command(hass, websocket_automation_breakpoint_clear) - websocket_api.async_register_command(hass, websocket_automation_breakpoint_list) - websocket_api.async_register_command(hass, websocket_automation_breakpoint_set) - websocket_api.async_register_command(hass, websocket_automation_debug_continue) - websocket_api.async_register_command(hass, websocket_automation_debug_step) - websocket_api.async_register_command(hass, websocket_automation_debug_stop) - websocket_api.async_register_command(hass, websocket_subscribe_breakpoint_events) - async def hook(action, config_key): """post_write_hook for Config View that reloads automations.""" await hass.services.async_call(DOMAIN, SERVICE_RELOAD) @@ -115,192 +80,3 @@ class EditAutomationConfigView(EditIdBasedConfigView): updated_value.update(cur_value) updated_value.update(new_value) data[index] = updated_value - - -@callback -@websocket_api.require_admin -@websocket_api.websocket_command( - {vol.Required("type"): "automation/trace/get", vol.Optional("automation_id"): str} -) -def websocket_automation_trace_get(hass, connection, msg): - """Get automation traces.""" - automation_id = msg.get("automation_id") - - if not automation_id: - automation_traces = get_debug_traces(hass) - else: - automation_traces = { - automation_id: get_debug_traces_for_automation(hass, automation_id) - } - - connection.send_result(msg["id"], automation_traces) - - -@callback -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required("type"): "automation/trace/list"}) -def websocket_automation_trace_list(hass, connection, msg): - """Summarize automation traces.""" - automation_traces = get_debug_traces(hass, summary=True) - - connection.send_result(msg["id"], automation_traces) - - -@callback -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required("type"): "automation/debug/breakpoint/set", - vol.Required("automation_id"): str, - vol.Required("node"): str, - vol.Optional("run_id"): str, - } -) -def websocket_automation_breakpoint_set(hass, connection, msg): - """Set breakpoint.""" - automation_id = msg["automation_id"] - node = msg["node"] - run_id = msg.get("run_id") - - if ( - SCRIPT_BREAKPOINT_HIT not in hass.data.get(DATA_DISPATCHER, {}) - or not hass.data[DATA_DISPATCHER][SCRIPT_BREAKPOINT_HIT] - ): - raise HomeAssistantError("No breakpoint subscription") - - result = breakpoint_set(hass, automation_id, run_id, node) - connection.send_result(msg["id"], result) - - -@callback -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required("type"): "automation/debug/breakpoint/clear", - vol.Required("automation_id"): str, - vol.Required("node"): str, - vol.Optional("run_id"): str, - } -) -def websocket_automation_breakpoint_clear(hass, connection, msg): - """Clear breakpoint.""" - automation_id = msg["automation_id"] - node = msg["node"] - run_id = msg.get("run_id") - - result = breakpoint_clear(hass, automation_id, run_id, node) - - connection.send_result(msg["id"], result) - - -@callback -@websocket_api.require_admin -@websocket_api.websocket_command( - {vol.Required("type"): "automation/debug/breakpoint/list"} -) -def websocket_automation_breakpoint_list(hass, connection, msg): - """List breakpoints.""" - breakpoints = breakpoint_list(hass) - for _breakpoint in breakpoints: - _breakpoint["automation_id"] = _breakpoint.pop("unique_id") - - connection.send_result(msg["id"], breakpoints) - - -@callback -@websocket_api.require_admin -@websocket_api.websocket_command( - {vol.Required("type"): "automation/debug/breakpoint/subscribe"} -) -def websocket_subscribe_breakpoint_events(hass, connection, msg): - """Subscribe to breakpoint events.""" - - @callback - def breakpoint_hit(automation_id, run_id, node): - """Forward events to websocket.""" - connection.send_message( - websocket_api.event_message( - msg["id"], - { - "automation_id": automation_id, - "run_id": run_id, - "node": node, - }, - ) - ) - - remove_signal = async_dispatcher_connect( - hass, SCRIPT_BREAKPOINT_HIT, breakpoint_hit - ) - - @callback - def unsub(): - """Unsubscribe from breakpoint events.""" - remove_signal() - if ( - SCRIPT_BREAKPOINT_HIT not in hass.data.get(DATA_DISPATCHER, {}) - or not hass.data[DATA_DISPATCHER][SCRIPT_BREAKPOINT_HIT] - ): - breakpoint_clear_all(hass) - async_dispatcher_send(hass, SCRIPT_DEBUG_CONTINUE_ALL) - - connection.subscriptions[msg["id"]] = unsub - - connection.send_message(websocket_api.result_message(msg["id"])) - - -@callback -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required("type"): "automation/debug/continue", - vol.Required("automation_id"): str, - vol.Required("run_id"): str, - } -) -def websocket_automation_debug_continue(hass, connection, msg): - """Resume execution of halted automation.""" - automation_id = msg["automation_id"] - run_id = msg["run_id"] - - result = debug_continue(hass, automation_id, run_id) - - connection.send_result(msg["id"], result) - - -@callback -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required("type"): "automation/debug/step", - vol.Required("automation_id"): str, - vol.Required("run_id"): str, - } -) -def websocket_automation_debug_step(hass, connection, msg): - """Single step a halted automation.""" - automation_id = msg["automation_id"] - run_id = msg["run_id"] - - result = debug_step(hass, automation_id, run_id) - - connection.send_result(msg["id"], result) - - -@callback -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required("type"): "automation/debug/stop", - vol.Required("automation_id"): str, - vol.Required("run_id"): str, - } -) -def websocket_automation_debug_stop(hass, connection, msg): - """Stop a halted automation.""" - automation_id = msg["automation_id"] - run_id = msg["run_id"] - - result = debug_stop(hass, automation_id, run_id) - - connection.send_result(msg["id"], result) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 257fd6d9715..a2df055331d 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -360,7 +360,7 @@ class _ScriptRun: handler = f"_async_{cv.determine_script_action(self._action)}_step" await getattr(self, handler)() except Exception as ex: - if not isinstance(ex, (_StopScript, asyncio.CancelledError)) and ( + if not isinstance(ex, _StopScript) and ( self._log_exceptions or log_exceptions ): self._log_exception(ex) diff --git a/tests/components/automation/test_websocket_api.py b/tests/components/automation/test_websocket_api.py new file mode 100644 index 00000000000..106f687f4ee --- /dev/null +++ b/tests/components/automation/test_websocket_api.py @@ -0,0 +1,803 @@ +"""Test Automation config panel.""" +from unittest.mock import patch + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components import automation, config + +from tests.common import assert_lists_same +from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +async def test_get_automation_trace(hass, hass_ws_client): + """Test tracing an automation.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + sun_config = { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": {"service": "test.automation"}, + } + moon_config = { + "id": "moon", + "trigger": [ + {"platform": "event", "event_type": "test_event2"}, + {"platform": "event", "event_type": "test_event3"}, + ], + "condition": { + "condition": "template", + "value_template": "{{ trigger.event.event_type=='test_event2' }}", + }, + "action": {"event": "another_event"}, + } + + assert await async_setup_component( + hass, + "automation", + { + "automation": [ + sun_config, + moon_config, + ] + }, + ) + + with patch.object(config, "SECTIONS", ["automation"]): + await async_setup_component(hass, "config", {}) + + client = await hass_ws_client() + + # Trigger "sun" automation + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + + # List traces + await client.send_json({"id": next_id(), "type": "automation/trace/list"}) + response = await client.receive_json() + assert response["success"] + run_id = response["result"]["sun"][-1]["run_id"] + + # Get trace + await client.send_json( + { + "id": next_id(), + "type": "automation/trace/get", + "automation_id": "sun", + "run_id": run_id, + } + ) + response = await client.receive_json() + assert response["success"] + trace = response["result"] + assert len(trace["action_trace"]) == 1 + assert len(trace["action_trace"]["action/0"]) == 1 + assert trace["action_trace"]["action/0"][0]["error"] + assert "result" not in trace["action_trace"]["action/0"][0] + assert trace["condition_trace"] == {} + assert trace["config"] == sun_config + assert trace["context"] + assert trace["error"] == "Unable to find service test.automation" + assert trace["state"] == "stopped" + assert trace["trigger"] == "event 'test_event'" + assert trace["unique_id"] == "sun" + assert trace["variables"] + + # Trigger "moon" automation, with passing condition + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + + # List traces + await client.send_json({"id": next_id(), "type": "automation/trace/list"}) + response = await client.receive_json() + assert response["success"] + run_id = response["result"]["moon"][-1]["run_id"] + + # Get trace + await client.send_json( + { + "id": next_id(), + "type": "automation/trace/get", + "automation_id": "moon", + "run_id": run_id, + } + ) + response = await client.receive_json() + assert response["success"] + trace = response["result"] + assert len(trace["action_trace"]) == 1 + assert len(trace["action_trace"]["action/0"]) == 1 + assert "error" not in trace["action_trace"]["action/0"][0] + assert "result" not in trace["action_trace"]["action/0"][0] + assert len(trace["condition_trace"]) == 1 + assert len(trace["condition_trace"]["condition/0"]) == 1 + assert trace["condition_trace"]["condition/0"][0]["result"] == {"result": True} + assert trace["config"] == moon_config + assert trace["context"] + assert "error" not in trace + assert trace["state"] == "stopped" + assert trace["trigger"] == "event 'test_event2'" + assert trace["unique_id"] == "moon" + assert trace["variables"] + + # Trigger "moon" automation, with failing condition + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + + # List traces + await client.send_json({"id": next_id(), "type": "automation/trace/list"}) + response = await client.receive_json() + assert response["success"] + run_id = response["result"]["moon"][-1]["run_id"] + + # Get trace + await client.send_json( + { + "id": next_id(), + "type": "automation/trace/get", + "automation_id": "moon", + "run_id": run_id, + } + ) + response = await client.receive_json() + assert response["success"] + trace = response["result"] + assert len(trace["action_trace"]) == 0 + assert len(trace["condition_trace"]) == 1 + assert len(trace["condition_trace"]["condition/0"]) == 1 + assert trace["condition_trace"]["condition/0"][0]["result"] == {"result": False} + assert trace["config"] == moon_config + assert trace["context"] + assert "error" not in trace + assert trace["state"] == "stopped" + assert trace["trigger"] == "event 'test_event3'" + assert trace["unique_id"] == "moon" + assert trace["variables"] + + # Trigger "moon" automation, with passing condition + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + + # List traces + await client.send_json({"id": next_id(), "type": "automation/trace/list"}) + response = await client.receive_json() + assert response["success"] + run_id = response["result"]["moon"][-1]["run_id"] + + # Get trace + await client.send_json( + { + "id": next_id(), + "type": "automation/trace/get", + "automation_id": "moon", + "run_id": run_id, + } + ) + response = await client.receive_json() + assert response["success"] + trace = response["result"] + assert len(trace["action_trace"]) == 1 + assert len(trace["action_trace"]["action/0"]) == 1 + assert "error" not in trace["action_trace"]["action/0"][0] + assert "result" not in trace["action_trace"]["action/0"][0] + assert len(trace["condition_trace"]) == 1 + assert len(trace["condition_trace"]["condition/0"]) == 1 + assert trace["condition_trace"]["condition/0"][0]["result"] == {"result": True} + assert trace["config"] == moon_config + assert trace["context"] + assert "error" not in trace + assert trace["state"] == "stopped" + assert trace["trigger"] == "event 'test_event2'" + assert trace["unique_id"] == "moon" + assert trace["variables"] + + +async def test_automation_trace_overflow(hass, hass_ws_client): + """Test the number of stored traces per automation is limited.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + sun_config = { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": {"event": "some_event"}, + } + moon_config = { + "id": "moon", + "trigger": {"platform": "event", "event_type": "test_event2"}, + "action": {"event": "another_event"}, + } + + assert await async_setup_component( + hass, + "automation", + { + "automation": [ + sun_config, + moon_config, + ] + }, + ) + + with patch.object(config, "SECTIONS", ["automation"]): + await async_setup_component(hass, "config", {}) + + client = await hass_ws_client() + + await client.send_json({"id": next_id(), "type": "automation/trace/list"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} + + # Trigger "sun" and "moon" automation once + hass.bus.async_fire("test_event") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + + # List traces + await client.send_json({"id": next_id(), "type": "automation/trace/list"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["moon"]) == 1 + moon_run_id = response["result"]["moon"][0]["run_id"] + assert len(response["result"]["sun"]) == 1 + + # Trigger "moon" automation enough times to overflow the number of stored traces + for _ in range(automation.trace.STORED_TRACES): + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + + await client.send_json({"id": next_id(), "type": "automation/trace/list"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["moon"]) == automation.trace.STORED_TRACES + assert len(response["result"]["sun"]) == 1 + assert int(response["result"]["moon"][0]["run_id"]) == int(moon_run_id) + 1 + assert ( + int(response["result"]["moon"][-1]["run_id"]) + == int(moon_run_id) + automation.trace.STORED_TRACES + ) + + +async def test_list_automation_traces(hass, hass_ws_client): + """Test listing automation traces.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + sun_config = { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": {"service": "test.automation"}, + } + moon_config = { + "id": "moon", + "trigger": [ + {"platform": "event", "event_type": "test_event2"}, + {"platform": "event", "event_type": "test_event3"}, + ], + "condition": { + "condition": "template", + "value_template": "{{ trigger.event.event_type=='test_event2' }}", + }, + "action": {"event": "another_event"}, + } + + assert await async_setup_component( + hass, + "automation", + { + "automation": [ + sun_config, + moon_config, + ] + }, + ) + + with patch.object(config, "SECTIONS", ["automation"]): + await async_setup_component(hass, "config", {}) + + client = await hass_ws_client() + + await client.send_json({"id": next_id(), "type": "automation/trace/list"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} + + # Trigger "sun" automation + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + + # Get trace + await client.send_json({"id": next_id(), "type": "automation/trace/list"}) + response = await client.receive_json() + assert response["success"] + assert "moon" not in response["result"] + assert len(response["result"]["sun"]) == 1 + + # Trigger "moon" automation, with passing condition + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + + # Trigger "moon" automation, with failing condition + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + + # Trigger "moon" automation, with passing condition + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + + # Get trace + await client.send_json({"id": next_id(), "type": "automation/trace/list"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["moon"]) == 3 + assert len(response["result"]["sun"]) == 1 + trace = response["result"]["sun"][0] + assert trace["last_action"] == "action/0" + assert trace["last_condition"] is None + assert trace["error"] == "Unable to find service test.automation" + assert trace["state"] == "stopped" + assert trace["timestamp"] + assert trace["trigger"] == "event 'test_event'" + assert trace["unique_id"] == "sun" + + trace = response["result"]["moon"][0] + assert trace["last_action"] == "action/0" + assert trace["last_condition"] == "condition/0" + assert "error" not in trace + assert trace["state"] == "stopped" + assert trace["timestamp"] + assert trace["trigger"] == "event 'test_event2'" + assert trace["unique_id"] == "moon" + + trace = response["result"]["moon"][1] + assert trace["last_action"] is None + assert trace["last_condition"] == "condition/0" + assert "error" not in trace + assert trace["state"] == "stopped" + assert trace["timestamp"] + assert trace["trigger"] == "event 'test_event3'" + assert trace["unique_id"] == "moon" + + trace = response["result"]["moon"][2] + assert trace["last_action"] == "action/0" + assert trace["last_condition"] == "condition/0" + assert "error" not in trace + assert trace["state"] == "stopped" + assert trace["timestamp"] + assert trace["trigger"] == "event 'test_event2'" + assert trace["unique_id"] == "moon" + + +async def test_automation_breakpoints(hass, hass_ws_client): + """Test automation breakpoints.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_last_action(automation_id, expected_action, expected_state): + await client.send_json({"id": next_id(), "type": "automation/trace/list"}) + response = await client.receive_json() + assert response["success"] + trace = response["result"][automation_id][-1] + assert trace["last_action"] == expected_action + assert trace["state"] == expected_state + return trace["run_id"] + + sun_config = { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + {"event": "event0"}, + {"event": "event1"}, + {"event": "event2"}, + {"event": "event3"}, + {"event": "event4"}, + {"event": "event5"}, + {"event": "event6"}, + {"event": "event7"}, + {"event": "event8"}, + ], + } + + assert await async_setup_component( + hass, + "automation", + { + "automation": [ + sun_config, + ] + }, + ) + + with patch.object(config, "SECTIONS", ["automation"]): + await async_setup_component(hass, "config", {}) + + client = await hass_ws_client() + + await client.send_json( + { + "id": next_id(), + "type": "automation/debug/breakpoint/set", + "automation_id": "sun", + "node": "1", + } + ) + response = await client.receive_json() + assert not response["success"] + + await client.send_json( + {"id": next_id(), "type": "automation/debug/breakpoint/list"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [] + + subscription_id = next_id() + await client.send_json( + {"id": subscription_id, "type": "automation/debug/breakpoint/subscribe"} + ) + response = await client.receive_json() + assert response["success"] + + await client.send_json( + { + "id": next_id(), + "type": "automation/debug/breakpoint/set", + "automation_id": "sun", + "node": "action/1", + } + ) + response = await client.receive_json() + assert response["success"] + await client.send_json( + { + "id": next_id(), + "type": "automation/debug/breakpoint/set", + "automation_id": "sun", + "node": "action/5", + } + ) + response = await client.receive_json() + assert response["success"] + + await client.send_json( + {"id": next_id(), "type": "automation/debug/breakpoint/list"} + ) + response = await client.receive_json() + assert response["success"] + assert_lists_same( + response["result"], + [ + {"node": "action/1", "run_id": "*", "automation_id": "sun"}, + {"node": "action/5", "run_id": "*", "automation_id": "sun"}, + ], + ) + + # Trigger "sun" automation + hass.bus.async_fire("test_event") + + response = await client.receive_json() + run_id = await assert_last_action("sun", "action/1", "running") + assert response["event"] == { + "automation_id": "sun", + "node": "action/1", + "run_id": run_id, + } + + await client.send_json( + { + "id": next_id(), + "type": "automation/debug/step", + "automation_id": "sun", + "run_id": run_id, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + run_id = await assert_last_action("sun", "action/2", "running") + assert response["event"] == { + "automation_id": "sun", + "node": "action/2", + "run_id": run_id, + } + + await client.send_json( + { + "id": next_id(), + "type": "automation/debug/continue", + "automation_id": "sun", + "run_id": run_id, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + run_id = await assert_last_action("sun", "action/5", "running") + assert response["event"] == { + "automation_id": "sun", + "node": "action/5", + "run_id": run_id, + } + + await client.send_json( + { + "id": next_id(), + "type": "automation/debug/stop", + "automation_id": "sun", + "run_id": run_id, + } + ) + response = await client.receive_json() + assert response["success"] + await hass.async_block_till_done() + await assert_last_action("sun", "action/5", "stopped") + + +async def test_automation_breakpoints_2(hass, hass_ws_client): + """Test execution resumes and breakpoints are removed after subscription removed.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_last_action(automation_id, expected_action, expected_state): + await client.send_json({"id": next_id(), "type": "automation/trace/list"}) + response = await client.receive_json() + assert response["success"] + trace = response["result"][automation_id][-1] + assert trace["last_action"] == expected_action + assert trace["state"] == expected_state + return trace["run_id"] + + sun_config = { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + {"event": "event0"}, + {"event": "event1"}, + {"event": "event2"}, + {"event": "event3"}, + {"event": "event4"}, + {"event": "event5"}, + {"event": "event6"}, + {"event": "event7"}, + {"event": "event8"}, + ], + } + + assert await async_setup_component( + hass, + "automation", + { + "automation": [ + sun_config, + ] + }, + ) + + with patch.object(config, "SECTIONS", ["automation"]): + await async_setup_component(hass, "config", {}) + + client = await hass_ws_client() + + subscription_id = next_id() + await client.send_json( + {"id": subscription_id, "type": "automation/debug/breakpoint/subscribe"} + ) + response = await client.receive_json() + assert response["success"] + + await client.send_json( + { + "id": next_id(), + "type": "automation/debug/breakpoint/set", + "automation_id": "sun", + "node": "action/1", + } + ) + response = await client.receive_json() + assert response["success"] + + # Trigger "sun" automation + hass.bus.async_fire("test_event") + + response = await client.receive_json() + run_id = await assert_last_action("sun", "action/1", "running") + assert response["event"] == { + "automation_id": "sun", + "node": "action/1", + "run_id": run_id, + } + + # Unsubscribe - execution should resume + await client.send_json( + {"id": next_id(), "type": "unsubscribe_events", "subscription": subscription_id} + ) + response = await client.receive_json() + assert response["success"] + await hass.async_block_till_done() + await assert_last_action("sun", "action/8", "stopped") + + # Should not be possible to set breakpoints + await client.send_json( + { + "id": next_id(), + "type": "automation/debug/breakpoint/set", + "automation_id": "sun", + "node": "1", + } + ) + response = await client.receive_json() + assert not response["success"] + + # Trigger "sun" automation, should finish without stopping on breakpoints + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + + new_run_id = await assert_last_action("sun", "action/8", "stopped") + assert new_run_id != run_id + + +async def test_automation_breakpoints_3(hass, hass_ws_client): + """Test breakpoints can be cleared.""" + id = 1 + + def next_id(): + nonlocal id + id += 1 + return id + + async def assert_last_action(automation_id, expected_action, expected_state): + await client.send_json({"id": next_id(), "type": "automation/trace/list"}) + response = await client.receive_json() + assert response["success"] + trace = response["result"][automation_id][-1] + assert trace["last_action"] == expected_action + assert trace["state"] == expected_state + return trace["run_id"] + + sun_config = { + "id": "sun", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + {"event": "event0"}, + {"event": "event1"}, + {"event": "event2"}, + {"event": "event3"}, + {"event": "event4"}, + {"event": "event5"}, + {"event": "event6"}, + {"event": "event7"}, + {"event": "event8"}, + ], + } + + assert await async_setup_component( + hass, + "automation", + { + "automation": [ + sun_config, + ] + }, + ) + + with patch.object(config, "SECTIONS", ["automation"]): + await async_setup_component(hass, "config", {}) + + client = await hass_ws_client() + + subscription_id = next_id() + await client.send_json( + {"id": subscription_id, "type": "automation/debug/breakpoint/subscribe"} + ) + response = await client.receive_json() + assert response["success"] + + await client.send_json( + { + "id": next_id(), + "type": "automation/debug/breakpoint/set", + "automation_id": "sun", + "node": "action/1", + } + ) + response = await client.receive_json() + assert response["success"] + + await client.send_json( + { + "id": next_id(), + "type": "automation/debug/breakpoint/set", + "automation_id": "sun", + "node": "action/5", + } + ) + response = await client.receive_json() + assert response["success"] + + # Trigger "sun" automation + hass.bus.async_fire("test_event") + + response = await client.receive_json() + run_id = await assert_last_action("sun", "action/1", "running") + assert response["event"] == { + "automation_id": "sun", + "node": "action/1", + "run_id": run_id, + } + + await client.send_json( + { + "id": next_id(), + "type": "automation/debug/continue", + "automation_id": "sun", + "run_id": run_id, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + run_id = await assert_last_action("sun", "action/5", "running") + assert response["event"] == { + "automation_id": "sun", + "node": "action/5", + "run_id": run_id, + } + + await client.send_json( + { + "id": next_id(), + "type": "automation/debug/stop", + "automation_id": "sun", + "run_id": run_id, + } + ) + response = await client.receive_json() + assert response["success"] + await hass.async_block_till_done() + await assert_last_action("sun", "action/5", "stopped") + + # Clear 1st breakpoint + await client.send_json( + { + "id": next_id(), + "type": "automation/debug/breakpoint/clear", + "automation_id": "sun", + "node": "action/1", + } + ) + response = await client.receive_json() + assert response["success"] + + # Trigger "sun" automation + hass.bus.async_fire("test_event") + + response = await client.receive_json() + run_id = await assert_last_action("sun", "action/5", "running") + assert response["event"] == { + "automation_id": "sun", + "node": "action/5", + "run_id": run_id, + } diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 2880287be94..6aeb71a7fd0 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -3,10 +3,9 @@ import json from unittest.mock import patch from homeassistant.bootstrap import async_setup_component -from homeassistant.components import automation, config +from homeassistant.components import config from homeassistant.helpers import entity_registry as er -from tests.common import assert_lists_same from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 @@ -166,772 +165,3 @@ async def test_delete_automation(hass, hass_client): assert written[0][0]["id"] == "moon" assert len(ent_reg.entities) == 1 - - -async def test_get_automation_trace(hass, hass_ws_client): - """Test tracing an automation.""" - id = 1 - - def next_id(): - nonlocal id - id += 1 - return id - - sun_config = { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": {"service": "test.automation"}, - } - moon_config = { - "id": "moon", - "trigger": [ - {"platform": "event", "event_type": "test_event2"}, - {"platform": "event", "event_type": "test_event3"}, - ], - "condition": { - "condition": "template", - "value_template": "{{ trigger.event.event_type=='test_event2' }}", - }, - "action": {"event": "another_event"}, - } - - assert await async_setup_component( - hass, - "automation", - { - "automation": [ - sun_config, - moon_config, - ] - }, - ) - - with patch.object(config, "SECTIONS", ["automation"]): - await async_setup_component(hass, "config", {}) - - client = await hass_ws_client() - - await client.send_json({"id": next_id(), "type": "automation/trace/get"}) - response = await client.receive_json() - assert response["success"] - assert response["result"] == {} - - await client.send_json( - {"id": next_id(), "type": "automation/trace/get", "automation_id": "sun"} - ) - response = await client.receive_json() - assert response["success"] - assert response["result"] == {"sun": []} - - # Trigger "sun" automation - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - - # Get trace - await client.send_json({"id": next_id(), "type": "automation/trace/get"}) - response = await client.receive_json() - assert response["success"] - assert "moon" not in response["result"] - assert len(response["result"]["sun"]) == 1 - trace = response["result"]["sun"][0] - assert len(trace["action_trace"]) == 1 - assert len(trace["action_trace"]["action/0"]) == 1 - assert trace["action_trace"]["action/0"][0]["error"] - assert "result" not in trace["action_trace"]["action/0"][0] - assert trace["condition_trace"] == {} - assert trace["config"] == sun_config - assert trace["context"] - assert trace["error"] == "Unable to find service test.automation" - assert trace["state"] == "stopped" - assert trace["trigger"]["description"] == "event 'test_event'" - assert trace["unique_id"] == "sun" - assert trace["variables"] - - # Trigger "moon" automation, with passing condition - hass.bus.async_fire("test_event2") - await hass.async_block_till_done() - - # Get trace - await client.send_json( - {"id": next_id(), "type": "automation/trace/get", "automation_id": "moon"} - ) - response = await client.receive_json() - assert response["success"] - assert "sun" not in response["result"] - assert len(response["result"]["moon"]) == 1 - trace = response["result"]["moon"][0] - assert len(trace["action_trace"]) == 1 - assert len(trace["action_trace"]["action/0"]) == 1 - assert "error" not in trace["action_trace"]["action/0"][0] - assert "result" not in trace["action_trace"]["action/0"][0] - assert len(trace["condition_trace"]) == 1 - assert len(trace["condition_trace"]["condition/0"]) == 1 - assert trace["condition_trace"]["condition/0"][0]["result"] == {"result": True} - assert trace["config"] == moon_config - assert trace["context"] - assert "error" not in trace - assert trace["state"] == "stopped" - assert trace["trigger"]["description"] == "event 'test_event2'" - assert trace["unique_id"] == "moon" - assert trace["variables"] - - # Trigger "moon" automation, with failing condition - hass.bus.async_fire("test_event3") - await hass.async_block_till_done() - - # Get trace - await client.send_json( - {"id": next_id(), "type": "automation/trace/get", "automation_id": "moon"} - ) - response = await client.receive_json() - assert response["success"] - assert "sun" not in response["result"] - assert len(response["result"]["moon"]) == 2 - trace = response["result"]["moon"][1] - assert len(trace["action_trace"]) == 0 - assert len(trace["condition_trace"]) == 1 - assert len(trace["condition_trace"]["condition/0"]) == 1 - assert trace["condition_trace"]["condition/0"][0]["result"] == {"result": False} - assert trace["config"] == moon_config - assert trace["context"] - assert "error" not in trace - assert trace["state"] == "stopped" - assert trace["trigger"]["description"] == "event 'test_event3'" - assert trace["unique_id"] == "moon" - assert trace["variables"] - - # Trigger "moon" automation, with passing condition - hass.bus.async_fire("test_event2") - await hass.async_block_till_done() - - # Get trace - await client.send_json( - {"id": next_id(), "type": "automation/trace/get", "automation_id": "moon"} - ) - response = await client.receive_json() - assert response["success"] - assert "sun" not in response["result"] - assert len(response["result"]["moon"]) == 3 - trace = response["result"]["moon"][2] - assert len(trace["action_trace"]) == 1 - assert len(trace["action_trace"]["action/0"]) == 1 - assert "error" not in trace["action_trace"]["action/0"][0] - assert "result" not in trace["action_trace"]["action/0"][0] - assert len(trace["condition_trace"]) == 1 - assert len(trace["condition_trace"]["condition/0"]) == 1 - assert trace["condition_trace"]["condition/0"][0]["result"] == {"result": True} - assert trace["config"] == moon_config - assert trace["context"] - assert "error" not in trace - assert trace["state"] == "stopped" - assert trace["trigger"]["description"] == "event 'test_event2'" - assert trace["unique_id"] == "moon" - assert trace["variables"] - - -async def test_automation_trace_overflow(hass, hass_ws_client): - """Test the number of stored traces per automation is limited.""" - id = 1 - - def next_id(): - nonlocal id - id += 1 - return id - - sun_config = { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": {"event": "some_event"}, - } - moon_config = { - "id": "moon", - "trigger": {"platform": "event", "event_type": "test_event2"}, - "action": {"event": "another_event"}, - } - - assert await async_setup_component( - hass, - "automation", - { - "automation": [ - sun_config, - moon_config, - ] - }, - ) - - with patch.object(config, "SECTIONS", ["automation"]): - await async_setup_component(hass, "config", {}) - - client = await hass_ws_client() - - await client.send_json({"id": next_id(), "type": "automation/trace/list"}) - response = await client.receive_json() - assert response["success"] - assert response["result"] == {} - - # Trigger "sun" and "moon" automation once - hass.bus.async_fire("test_event") - hass.bus.async_fire("test_event2") - await hass.async_block_till_done() - - # Get traces - await client.send_json({"id": next_id(), "type": "automation/trace/list"}) - response = await client.receive_json() - assert response["success"] - assert len(response["result"]["moon"]) == 1 - moon_run_id = response["result"]["moon"][0]["run_id"] - assert len(response["result"]["sun"]) == 1 - - # Trigger "moon" automation enough times to overflow the number of stored traces - for _ in range(automation.STORED_TRACES): - hass.bus.async_fire("test_event2") - await hass.async_block_till_done() - - await client.send_json({"id": next_id(), "type": "automation/trace/list"}) - response = await client.receive_json() - assert response["success"] - assert len(response["result"]["moon"]) == automation.STORED_TRACES - assert len(response["result"]["sun"]) == 1 - assert int(response["result"]["moon"][0]["run_id"]) == int(moon_run_id) + 1 - assert ( - int(response["result"]["moon"][-1]["run_id"]) - == int(moon_run_id) + automation.STORED_TRACES - ) - - -async def test_list_automation_traces(hass, hass_ws_client): - """Test listing automation traces.""" - id = 1 - - def next_id(): - nonlocal id - id += 1 - return id - - sun_config = { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": {"service": "test.automation"}, - } - moon_config = { - "id": "moon", - "trigger": [ - {"platform": "event", "event_type": "test_event2"}, - {"platform": "event", "event_type": "test_event3"}, - ], - "condition": { - "condition": "template", - "value_template": "{{ trigger.event.event_type=='test_event2' }}", - }, - "action": {"event": "another_event"}, - } - - assert await async_setup_component( - hass, - "automation", - { - "automation": [ - sun_config, - moon_config, - ] - }, - ) - - with patch.object(config, "SECTIONS", ["automation"]): - await async_setup_component(hass, "config", {}) - - client = await hass_ws_client() - - await client.send_json({"id": next_id(), "type": "automation/trace/list"}) - response = await client.receive_json() - assert response["success"] - assert response["result"] == {} - - # Trigger "sun" automation - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - - # Get trace - await client.send_json({"id": next_id(), "type": "automation/trace/list"}) - response = await client.receive_json() - assert response["success"] - assert "moon" not in response["result"] - assert len(response["result"]["sun"]) == 1 - - # Trigger "moon" automation, with passing condition - hass.bus.async_fire("test_event2") - await hass.async_block_till_done() - - # Trigger "moon" automation, with failing condition - hass.bus.async_fire("test_event3") - await hass.async_block_till_done() - - # Trigger "moon" automation, with passing condition - hass.bus.async_fire("test_event2") - await hass.async_block_till_done() - - # Get trace - await client.send_json({"id": next_id(), "type": "automation/trace/list"}) - response = await client.receive_json() - assert response["success"] - assert len(response["result"]["moon"]) == 3 - assert len(response["result"]["sun"]) == 1 - trace = response["result"]["sun"][0] - assert trace["last_action"] == "action/0" - assert trace["last_condition"] is None - assert trace["error"] == "Unable to find service test.automation" - assert trace["state"] == "stopped" - assert trace["timestamp"] - assert trace["trigger"] == "event 'test_event'" - assert trace["unique_id"] == "sun" - - trace = response["result"]["moon"][0] - assert trace["last_action"] == "action/0" - assert trace["last_condition"] == "condition/0" - assert "error" not in trace - assert trace["state"] == "stopped" - assert trace["timestamp"] - assert trace["trigger"] == "event 'test_event2'" - assert trace["unique_id"] == "moon" - - trace = response["result"]["moon"][1] - assert trace["last_action"] is None - assert trace["last_condition"] == "condition/0" - assert "error" not in trace - assert trace["state"] == "stopped" - assert trace["timestamp"] - assert trace["trigger"] == "event 'test_event3'" - assert trace["unique_id"] == "moon" - - trace = response["result"]["moon"][2] - assert trace["last_action"] == "action/0" - assert trace["last_condition"] == "condition/0" - assert "error" not in trace - assert trace["state"] == "stopped" - assert trace["timestamp"] - assert trace["trigger"] == "event 'test_event2'" - assert trace["unique_id"] == "moon" - - -async def test_automation_breakpoints(hass, hass_ws_client): - """Test automation breakpoints.""" - id = 1 - - def next_id(): - nonlocal id - id += 1 - return id - - async def assert_last_action(automation_id, expected_action, expected_state): - await client.send_json({"id": next_id(), "type": "automation/trace/list"}) - response = await client.receive_json() - assert response["success"] - trace = response["result"][automation_id][-1] - assert trace["last_action"] == expected_action - assert trace["state"] == expected_state - return trace["run_id"] - - sun_config = { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [ - {"event": "event0"}, - {"event": "event1"}, - {"event": "event2"}, - {"event": "event3"}, - {"event": "event4"}, - {"event": "event5"}, - {"event": "event6"}, - {"event": "event7"}, - {"event": "event8"}, - ], - } - - assert await async_setup_component( - hass, - "automation", - { - "automation": [ - sun_config, - ] - }, - ) - - with patch.object(config, "SECTIONS", ["automation"]): - await async_setup_component(hass, "config", {}) - - client = await hass_ws_client() - - await client.send_json( - { - "id": next_id(), - "type": "automation/debug/breakpoint/set", - "automation_id": "sun", - "node": "1", - } - ) - response = await client.receive_json() - assert not response["success"] - - await client.send_json( - {"id": next_id(), "type": "automation/debug/breakpoint/list"} - ) - response = await client.receive_json() - assert response["success"] - assert response["result"] == [] - - subscription_id = next_id() - await client.send_json( - {"id": subscription_id, "type": "automation/debug/breakpoint/subscribe"} - ) - response = await client.receive_json() - assert response["success"] - - await client.send_json( - { - "id": next_id(), - "type": "automation/debug/breakpoint/set", - "automation_id": "sun", - "node": "action/1", - } - ) - response = await client.receive_json() - assert response["success"] - await client.send_json( - { - "id": next_id(), - "type": "automation/debug/breakpoint/set", - "automation_id": "sun", - "node": "action/5", - } - ) - response = await client.receive_json() - assert response["success"] - - await client.send_json( - {"id": next_id(), "type": "automation/debug/breakpoint/list"} - ) - response = await client.receive_json() - assert response["success"] - assert_lists_same( - response["result"], - [ - {"node": "action/1", "run_id": "*", "automation_id": "sun"}, - {"node": "action/5", "run_id": "*", "automation_id": "sun"}, - ], - ) - - # Trigger "sun" automation - hass.bus.async_fire("test_event") - - response = await client.receive_json() - run_id = await assert_last_action("sun", "action/1", "running") - assert response["event"] == { - "automation_id": "sun", - "node": "action/1", - "run_id": run_id, - } - - await client.send_json( - { - "id": next_id(), - "type": "automation/debug/step", - "automation_id": "sun", - "run_id": run_id, - } - ) - response = await client.receive_json() - assert response["success"] - - response = await client.receive_json() - run_id = await assert_last_action("sun", "action/2", "running") - assert response["event"] == { - "automation_id": "sun", - "node": "action/2", - "run_id": run_id, - } - - await client.send_json( - { - "id": next_id(), - "type": "automation/debug/continue", - "automation_id": "sun", - "run_id": run_id, - } - ) - response = await client.receive_json() - assert response["success"] - - response = await client.receive_json() - run_id = await assert_last_action("sun", "action/5", "running") - assert response["event"] == { - "automation_id": "sun", - "node": "action/5", - "run_id": run_id, - } - - await client.send_json( - { - "id": next_id(), - "type": "automation/debug/stop", - "automation_id": "sun", - "run_id": run_id, - } - ) - response = await client.receive_json() - assert response["success"] - await hass.async_block_till_done() - await assert_last_action("sun", "action/5", "stopped") - - -async def test_automation_breakpoints_2(hass, hass_ws_client): - """Test execution resumes and breakpoints are removed after subscription removed.""" - id = 1 - - def next_id(): - nonlocal id - id += 1 - return id - - async def assert_last_action(automation_id, expected_action, expected_state): - await client.send_json({"id": next_id(), "type": "automation/trace/list"}) - response = await client.receive_json() - assert response["success"] - trace = response["result"][automation_id][-1] - assert trace["last_action"] == expected_action - assert trace["state"] == expected_state - return trace["run_id"] - - sun_config = { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [ - {"event": "event0"}, - {"event": "event1"}, - {"event": "event2"}, - {"event": "event3"}, - {"event": "event4"}, - {"event": "event5"}, - {"event": "event6"}, - {"event": "event7"}, - {"event": "event8"}, - ], - } - - assert await async_setup_component( - hass, - "automation", - { - "automation": [ - sun_config, - ] - }, - ) - - with patch.object(config, "SECTIONS", ["automation"]): - await async_setup_component(hass, "config", {}) - - client = await hass_ws_client() - - subscription_id = next_id() - await client.send_json( - {"id": subscription_id, "type": "automation/debug/breakpoint/subscribe"} - ) - response = await client.receive_json() - assert response["success"] - - await client.send_json( - { - "id": next_id(), - "type": "automation/debug/breakpoint/set", - "automation_id": "sun", - "node": "action/1", - } - ) - response = await client.receive_json() - assert response["success"] - - # Trigger "sun" automation - hass.bus.async_fire("test_event") - - response = await client.receive_json() - run_id = await assert_last_action("sun", "action/1", "running") - assert response["event"] == { - "automation_id": "sun", - "node": "action/1", - "run_id": run_id, - } - - # Unsubscribe - execution should resume - await client.send_json( - {"id": next_id(), "type": "unsubscribe_events", "subscription": subscription_id} - ) - response = await client.receive_json() - assert response["success"] - await hass.async_block_till_done() - await assert_last_action("sun", "action/8", "stopped") - - # Should not be possible to set breakpoints - await client.send_json( - { - "id": next_id(), - "type": "automation/debug/breakpoint/set", - "automation_id": "sun", - "node": "1", - } - ) - response = await client.receive_json() - assert not response["success"] - - # Trigger "sun" automation, should finish without stopping on breakpoints - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - - new_run_id = await assert_last_action("sun", "action/8", "stopped") - assert new_run_id != run_id - - -async def test_automation_breakpoints_3(hass, hass_ws_client): - """Test breakpoints can be cleared.""" - id = 1 - - def next_id(): - nonlocal id - id += 1 - return id - - async def assert_last_action(automation_id, expected_action, expected_state): - await client.send_json({"id": next_id(), "type": "automation/trace/list"}) - response = await client.receive_json() - assert response["success"] - trace = response["result"][automation_id][-1] - assert trace["last_action"] == expected_action - assert trace["state"] == expected_state - return trace["run_id"] - - sun_config = { - "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [ - {"event": "event0"}, - {"event": "event1"}, - {"event": "event2"}, - {"event": "event3"}, - {"event": "event4"}, - {"event": "event5"}, - {"event": "event6"}, - {"event": "event7"}, - {"event": "event8"}, - ], - } - - assert await async_setup_component( - hass, - "automation", - { - "automation": [ - sun_config, - ] - }, - ) - - with patch.object(config, "SECTIONS", ["automation"]): - await async_setup_component(hass, "config", {}) - - client = await hass_ws_client() - - subscription_id = next_id() - await client.send_json( - {"id": subscription_id, "type": "automation/debug/breakpoint/subscribe"} - ) - response = await client.receive_json() - assert response["success"] - - await client.send_json( - { - "id": next_id(), - "type": "automation/debug/breakpoint/set", - "automation_id": "sun", - "node": "action/1", - } - ) - response = await client.receive_json() - assert response["success"] - - await client.send_json( - { - "id": next_id(), - "type": "automation/debug/breakpoint/set", - "automation_id": "sun", - "node": "action/5", - } - ) - response = await client.receive_json() - assert response["success"] - - # Trigger "sun" automation - hass.bus.async_fire("test_event") - - response = await client.receive_json() - run_id = await assert_last_action("sun", "action/1", "running") - assert response["event"] == { - "automation_id": "sun", - "node": "action/1", - "run_id": run_id, - } - - await client.send_json( - { - "id": next_id(), - "type": "automation/debug/continue", - "automation_id": "sun", - "run_id": run_id, - } - ) - response = await client.receive_json() - assert response["success"] - - response = await client.receive_json() - run_id = await assert_last_action("sun", "action/5", "running") - assert response["event"] == { - "automation_id": "sun", - "node": "action/5", - "run_id": run_id, - } - - await client.send_json( - { - "id": next_id(), - "type": "automation/debug/stop", - "automation_id": "sun", - "run_id": run_id, - } - ) - response = await client.receive_json() - assert response["success"] - await hass.async_block_till_done() - await assert_last_action("sun", "action/5", "stopped") - - # Clear 1st breakpoint - await client.send_json( - { - "id": next_id(), - "type": "automation/debug/breakpoint/clear", - "automation_id": "sun", - "node": "action/1", - } - ) - response = await client.receive_json() - assert response["success"] - - # Trigger "sun" automation - hass.bus.async_fire("test_event") - - response = await client.receive_json() - run_id = await assert_last_action("sun", "action/5", "running") - assert response["event"] == { - "automation_id": "sun", - "node": "action/5", - "run_id": run_id, - }