core/homeassistant/components/script/__init__.py

419 lines
13 KiB
Python
Raw Normal View History

"""Support for scripts."""
2021-03-18 13:31:38 +00:00
from __future__ import annotations
import asyncio
import logging
from typing import Any, Dict, cast
import voluptuous as vol
from voluptuous.humanize import humanize_error
from homeassistant.components.blueprint import BlueprintInputs
2016-02-19 05:27:50 +00:00
from homeassistant.const import (
2019-07-31 19:25:30 +00:00
ATTR_ENTITY_ID,
2021-03-05 18:08:04 +00:00
ATTR_MODE,
ATTR_NAME,
CONF_ALIAS,
CONF_DESCRIPTION,
2020-02-17 16:44:36 +00:00
CONF_ICON,
CONF_MODE,
CONF_NAME,
CONF_SEQUENCE,
CONF_VARIABLES,
SERVICE_RELOAD,
SERVICE_TOGGLE,
2019-07-31 19:25:30 +00:00
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_ON,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import extract_domain_configs
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import make_entity_service_schema
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.script import (
ATTR_CUR,
ATTR_MAX,
CONF_MAX,
CONF_MAX_EXCEEDED,
Script,
)
from homeassistant.helpers.service import async_set_service_schema
from homeassistant.helpers.trace import trace_get, trace_path
from homeassistant.loader import bind_hass
2016-04-21 22:52:20 +00:00
from .config import ScriptConfig, async_validate_config_item
from .const import (
ATTR_LAST_ACTION,
ATTR_LAST_TRIGGERED,
ATTR_VARIABLES,
CONF_FIELDS,
CONF_TRACE,
DOMAIN,
ENTITY_ID_FORMAT,
EVENT_SCRIPT_STARTED,
LOGGER,
2019-07-31 19:25:30 +00:00
)
from .helpers import async_get_blueprints
from .trace import trace_script
SCRIPT_SERVICE_SCHEMA = vol.Schema(dict)
SCRIPT_TURN_ONOFF_SCHEMA = make_entity_service_schema(
2020-09-11 10:24:16 +00:00
{vol.Optional(ATTR_VARIABLES): {str: cv.match_all}}
2019-07-31 19:25:30 +00:00
)
RELOAD_SERVICE_SCHEMA = vol.Schema({})
@bind_hass
2015-10-15 06:09:52 +00:00
def is_on(hass, entity_id):
"""Return if the script is on based on the statemachine."""
2015-10-15 06:09:52 +00:00
return hass.states.is_state(entity_id, STATE_ON)
@callback
2021-03-18 13:31:38 +00:00
def scripts_with_entity(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Return all scripts that reference the entity."""
if DOMAIN not in hass.data:
return []
component = hass.data[DOMAIN]
return [
script_entity.entity_id
for script_entity in component.entities
if entity_id in script_entity.script.referenced_entities
]
@callback
2021-03-18 13:31:38 +00:00
def entities_in_script(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Return all entities in script."""
if DOMAIN not in hass.data:
return []
component = hass.data[DOMAIN]
script_entity = component.get_entity(entity_id)
if script_entity is None:
return []
return list(script_entity.script.referenced_entities)
@callback
2021-03-18 13:31:38 +00:00
def scripts_with_device(hass: HomeAssistant, device_id: str) -> list[str]:
"""Return all scripts that reference the device."""
if DOMAIN not in hass.data:
return []
component = hass.data[DOMAIN]
return [
script_entity.entity_id
for script_entity in component.entities
if device_id in script_entity.script.referenced_devices
]
@callback
2021-03-18 13:31:38 +00:00
def devices_in_script(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Return all devices in script."""
if DOMAIN not in hass.data:
return []
component = hass.data[DOMAIN]
script_entity = component.get_entity(entity_id)
if script_entity is None:
return []
return list(script_entity.script.referenced_devices)
@callback
def scripts_with_area(hass: HomeAssistant, area_id: str) -> list[str]:
"""Return all scripts that reference the area."""
if DOMAIN not in hass.data:
return []
component = hass.data[DOMAIN]
return [
script_entity.entity_id
for script_entity in component.entities
if area_id in script_entity.script.referenced_areas
]
@callback
def areas_in_script(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Return all areas in a script."""
if DOMAIN not in hass.data:
return []
component = hass.data[DOMAIN]
script_entity = component.get_entity(entity_id)
if script_entity is None:
return []
return list(script_entity.script.referenced_areas)
async def async_setup(hass, config):
2016-03-07 17:49:31 +00:00
"""Load the scripts from the configuration."""
hass.data[DOMAIN] = component = EntityComponent(LOGGER, DOMAIN, hass)
# To register scripts as valid domain for Blueprint
async_get_blueprints(hass)
if not await _async_process_config(hass, config, component):
await async_get_blueprints(hass).async_populate()
2015-10-15 06:09:52 +00:00
async def reload_service(service):
"""Call a service to reload scripts."""
conf = await component.async_prepare_reload()
if conf is None:
return
2015-10-15 06:09:52 +00:00
await _async_process_config(hass, conf, component)
2015-10-15 06:09:52 +00:00
async def turn_on_service(service):
2016-03-08 16:55:57 +00:00
"""Call a service to turn script on."""
variables = service.data.get(ATTR_VARIABLES)
for script_entity in await component.async_extract_from_service(service):
await script_entity.async_turn_on(
variables=variables, context=service.context, wait=False
)
2015-10-15 06:09:52 +00:00
async def turn_off_service(service):
2016-03-08 16:55:57 +00:00
"""Cancel a script."""
# Stopping a script is ok to be done in parallel
script_entities = await component.async_extract_from_service(service)
if not script_entities:
return
await asyncio.wait(
[
asyncio.create_task(script_entity.async_turn_off())
for script_entity in script_entities
]
)
async def toggle_service(service):
2016-03-08 16:55:57 +00:00
"""Toggle a script."""
for script_entity in await component.async_extract_from_service(service):
await script_entity.async_toggle(context=service.context, wait=False)
2019-07-31 19:25:30 +00:00
hass.services.async_register(
DOMAIN, SERVICE_RELOAD, reload_service, schema=RELOAD_SERVICE_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_TURN_ON, turn_on_service, schema=SCRIPT_TURN_ONOFF_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_TURN_OFF, turn_off_service, schema=SCRIPT_TURN_ONOFF_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_TOGGLE, toggle_service, schema=SCRIPT_TURN_ONOFF_SCHEMA
)
return True
async def _async_process_config(hass, config, component) -> bool:
"""Process script configuration.
Return true, if Blueprints were used.
"""
entities = []
blueprints_used = False
for config_key in extract_domain_configs(config, DOMAIN):
conf: dict[str, dict[str, Any] | BlueprintInputs] = config[config_key]
for object_id, config_block in conf.items():
raw_blueprint_inputs = None
raw_config = None
if isinstance(config_block, BlueprintInputs):
blueprints_used = True
blueprint_inputs = config_block
raw_blueprint_inputs = blueprint_inputs.config_with_inputs
try:
raw_config = blueprint_inputs.async_substitute()
config_block = cast(
Dict[str, Any],
await async_validate_config_item(hass, raw_config),
)
except vol.Invalid as err:
LOGGER.error(
"Blueprint %s generated invalid script with input %s: %s",
blueprint_inputs.blueprint.name,
blueprint_inputs.inputs,
humanize_error(config_block, err),
)
continue
else:
raw_config = cast(ScriptConfig, config_block).raw_config
entities.append(
ScriptEntity(
hass, object_id, config_block, raw_config, raw_blueprint_inputs
)
)
await component.async_add_entities(entities)
2019-07-31 19:25:30 +00:00
async def service_handler(service):
"""Execute a service call to script.<script name>."""
entity_id = ENTITY_ID_FORMAT.format(service.service)
script_entity = component.get_entity(entity_id)
await script_entity.async_turn_on(
variables=service.data, context=service.context
)
# Register services for all entities that were created successfully.
for entity in entities:
hass.services.async_register(
DOMAIN, entity.object_id, service_handler, schema=SCRIPT_SERVICE_SCHEMA
2019-07-31 19:25:30 +00:00
)
# Register the service description
service_desc = {
CONF_NAME: entity.name,
CONF_DESCRIPTION: entity.description,
CONF_FIELDS: entity.fields,
}
async_set_service_schema(hass, DOMAIN, entity.object_id, service_desc)
return blueprints_used
2016-04-21 22:52:20 +00:00
class ScriptEntity(ToggleEntity):
"""Representation of a script entity."""
2016-03-08 16:55:57 +00:00
2020-02-17 16:44:36 +00:00
icon = None
def __init__(self, hass, object_id, cfg, raw_config, blueprint_inputs):
2016-03-08 16:55:57 +00:00
"""Initialize the script."""
self.object_id = object_id
self.icon = cfg.get(CONF_ICON)
self.description = cfg[CONF_DESCRIPTION]
self.fields = cfg[CONF_FIELDS]
2015-10-28 19:24:33 +00:00
self.entity_id = ENTITY_ID_FORMAT.format(object_id)
Add support for simultaneous runs of Script helper (#31937) * Add tests for legacy Script helper behavior * Add Script helper if_running and run_mode options - if_running controls what happens if Script run while previous run has not completed. Can be: - error: Raise an exception - ignore: Return without doing anything (previous run continues as-is) - parallel: Start run in new task - restart: Stop previous run before starting new run - run_mode controls when call to async_run will return. Can be: - background: Returns immediately - legacy: Implements previous behavior, which is to return when done, or when suspended by delay or wait_template - blocking: Returns when run has completed - If neither is specified, default is run_mode=legacy (and if_running is not used.) Otherwise, defaults are if_running=parallel and run_mode=background. If run_mode is set to legacy then if_running must be None. - Caller may supply a logger which will be used throughout instead of default module logger. - Move Script running state into new helper classes, comprised of an abstract base class and two concrete clases, one for legacy behavior and one for new behavior. - Remove some non-async methods, as well as call_from_config which has only been used in tests. - Adjust tests accordingly. * Change per review - Change run_mode default from background to blocking. - Make sure change listener is called, even when there's an unexpected exception. - Make _ScriptRun.async_stop more graceful by using an asyncio.Event for signaling instead of simply cancelling Task. - Subclass _ScriptRun for background & blocking behavior. Also: - Fix timeouts in _ScriptRun by converting timedeltas to float seconds. - General cleanup. * Change per review 2 - Don't propagate exceptions if call from user has already returned (i.e., for background runs or legacy runs that have suspended.) - Allow user to specify if exceptions should be logged. They will still be logged regardless if exception is not propagated. - Rename _start_script_delay and _start_wait_template_delay for clarity. - Remove return value from Script.async_run. - Fix missing await. - Change call to self.is_running in Script.async_run to direct test of self._runs. * Change per review 3 and add tests - Remove Script.set_logger(). - Enhance existing tests to check all run modes. - Add tests for new features. - Fix a few minor bugs found by tests.
2020-02-24 22:56:00 +00:00
self.script = Script(
hass,
cfg[CONF_SEQUENCE],
cfg.get(CONF_ALIAS, object_id),
DOMAIN,
running_description="script sequence",
change_listener=self.async_change_listener,
script_mode=cfg[CONF_MODE],
max_runs=cfg[CONF_MAX],
max_exceeded=cfg[CONF_MAX_EXCEEDED],
logger=logging.getLogger(f"{__name__}.{object_id}"),
variables=cfg.get(CONF_VARIABLES),
Add support for simultaneous runs of Script helper (#31937) * Add tests for legacy Script helper behavior * Add Script helper if_running and run_mode options - if_running controls what happens if Script run while previous run has not completed. Can be: - error: Raise an exception - ignore: Return without doing anything (previous run continues as-is) - parallel: Start run in new task - restart: Stop previous run before starting new run - run_mode controls when call to async_run will return. Can be: - background: Returns immediately - legacy: Implements previous behavior, which is to return when done, or when suspended by delay or wait_template - blocking: Returns when run has completed - If neither is specified, default is run_mode=legacy (and if_running is not used.) Otherwise, defaults are if_running=parallel and run_mode=background. If run_mode is set to legacy then if_running must be None. - Caller may supply a logger which will be used throughout instead of default module logger. - Move Script running state into new helper classes, comprised of an abstract base class and two concrete clases, one for legacy behavior and one for new behavior. - Remove some non-async methods, as well as call_from_config which has only been used in tests. - Adjust tests accordingly. * Change per review - Change run_mode default from background to blocking. - Make sure change listener is called, even when there's an unexpected exception. - Make _ScriptRun.async_stop more graceful by using an asyncio.Event for signaling instead of simply cancelling Task. - Subclass _ScriptRun for background & blocking behavior. Also: - Fix timeouts in _ScriptRun by converting timedeltas to float seconds. - General cleanup. * Change per review 2 - Don't propagate exceptions if call from user has already returned (i.e., for background runs or legacy runs that have suspended.) - Allow user to specify if exceptions should be logged. They will still be logged regardless if exception is not propagated. - Rename _start_script_delay and _start_wait_template_delay for clarity. - Remove return value from Script.async_run. - Fix missing await. - Change call to self.is_running in Script.async_run to direct test of self._runs. * Change per review 3 and add tests - Remove Script.set_logger(). - Enhance existing tests to check all run modes. - Add tests for new features. - Fix a few minor bugs found by tests.
2020-02-24 22:56:00 +00:00
)
self._changed = asyncio.Event()
self._raw_config = raw_config
self._trace_config = cfg[CONF_TRACE]
self._blueprint_inputs = blueprint_inputs
2015-10-15 06:09:52 +00:00
@property
def should_poll(self):
2016-03-07 17:49:31 +00:00
"""No polling needed."""
2015-10-15 06:09:52 +00:00
return False
@property
def name(self):
2016-03-07 17:49:31 +00:00
"""Return the name of the entity."""
2016-04-21 22:52:20 +00:00
return self.script.name
2015-10-15 06:09:52 +00:00
@property
def extra_state_attributes(self):
2016-03-07 17:49:31 +00:00
"""Return the state attributes."""
attrs = {
ATTR_LAST_TRIGGERED: self.script.last_triggered,
ATTR_MODE: self.script.script_mode,
ATTR_CUR: self.script.runs,
}
if self.script.supports_max:
attrs[ATTR_MAX] = self.script.max_runs
2016-04-21 22:52:20 +00:00
if self.script.last_action:
attrs[ATTR_LAST_ACTION] = self.script.last_action
2015-10-15 06:09:52 +00:00
return attrs
@property
def is_on(self):
2016-03-08 16:55:57 +00:00
"""Return true if script is on."""
2016-04-21 22:52:20 +00:00
return self.script.is_running
2015-10-15 06:09:52 +00:00
@callback
def async_change_listener(self):
"""Update state."""
self.async_write_ha_state()
self._changed.set()
async def async_turn_on(self, **kwargs):
"""Run the script.
Depending on the script's run mode, this may do nothing, restart the script or
fire an additional parallel run.
"""
variables = kwargs.get("variables")
2019-07-31 19:25:30 +00:00
context = kwargs.get("context")
wait = kwargs.get("wait", True)
self.async_set_context(context)
2019-07-31 19:25:30 +00:00
self.hass.bus.async_fire(
EVENT_SCRIPT_STARTED,
{ATTR_NAME: self.script.name, ATTR_ENTITY_ID: self.entity_id},
context=context,
)
coro = self._async_run(variables, context)
if wait:
await coro
return
# Caller does not want to wait for called script to finish so let script run in
# separate Task. However, wait for first state change so we can guarantee that
# it is written to the State Machine before we return.
self._changed.clear()
self.hass.async_create_task(coro)
await self._changed.wait()
2015-10-15 06:09:52 +00:00
async def _async_run(self, variables, context):
with trace_script(
self.hass,
self.object_id,
self._raw_config,
self._blueprint_inputs,
context,
self._trace_config,
) as script_trace:
# Prepare tracing the execution of the script's sequence
script_trace.set_trace(trace_get())
with trace_path("sequence"):
return await self.script.async_run(variables, context)
async def async_turn_off(self, **kwargs):
"""Stop running the script.
If multiple runs are in progress, all will be stopped.
"""
Add support for simultaneous runs of Script helper (#31937) * Add tests for legacy Script helper behavior * Add Script helper if_running and run_mode options - if_running controls what happens if Script run while previous run has not completed. Can be: - error: Raise an exception - ignore: Return without doing anything (previous run continues as-is) - parallel: Start run in new task - restart: Stop previous run before starting new run - run_mode controls when call to async_run will return. Can be: - background: Returns immediately - legacy: Implements previous behavior, which is to return when done, or when suspended by delay or wait_template - blocking: Returns when run has completed - If neither is specified, default is run_mode=legacy (and if_running is not used.) Otherwise, defaults are if_running=parallel and run_mode=background. If run_mode is set to legacy then if_running must be None. - Caller may supply a logger which will be used throughout instead of default module logger. - Move Script running state into new helper classes, comprised of an abstract base class and two concrete clases, one for legacy behavior and one for new behavior. - Remove some non-async methods, as well as call_from_config which has only been used in tests. - Adjust tests accordingly. * Change per review - Change run_mode default from background to blocking. - Make sure change listener is called, even when there's an unexpected exception. - Make _ScriptRun.async_stop more graceful by using an asyncio.Event for signaling instead of simply cancelling Task. - Subclass _ScriptRun for background & blocking behavior. Also: - Fix timeouts in _ScriptRun by converting timedeltas to float seconds. - General cleanup. * Change per review 2 - Don't propagate exceptions if call from user has already returned (i.e., for background runs or legacy runs that have suspended.) - Allow user to specify if exceptions should be logged. They will still be logged regardless if exception is not propagated. - Rename _start_script_delay and _start_wait_template_delay for clarity. - Remove return value from Script.async_run. - Fix missing await. - Change call to self.is_running in Script.async_run to direct test of self._runs. * Change per review 3 and add tests - Remove Script.set_logger(). - Enhance existing tests to check all run modes. - Add tests for new features. - Fix a few minor bugs found by tests.
2020-02-24 22:56:00 +00:00
await self.script.async_stop()
async def async_will_remove_from_hass(self):
"""Stop script and remove service when it will be removed from Home Assistant."""
Add support for simultaneous runs of Script helper (#31937) * Add tests for legacy Script helper behavior * Add Script helper if_running and run_mode options - if_running controls what happens if Script run while previous run has not completed. Can be: - error: Raise an exception - ignore: Return without doing anything (previous run continues as-is) - parallel: Start run in new task - restart: Stop previous run before starting new run - run_mode controls when call to async_run will return. Can be: - background: Returns immediately - legacy: Implements previous behavior, which is to return when done, or when suspended by delay or wait_template - blocking: Returns when run has completed - If neither is specified, default is run_mode=legacy (and if_running is not used.) Otherwise, defaults are if_running=parallel and run_mode=background. If run_mode is set to legacy then if_running must be None. - Caller may supply a logger which will be used throughout instead of default module logger. - Move Script running state into new helper classes, comprised of an abstract base class and two concrete clases, one for legacy behavior and one for new behavior. - Remove some non-async methods, as well as call_from_config which has only been used in tests. - Adjust tests accordingly. * Change per review - Change run_mode default from background to blocking. - Make sure change listener is called, even when there's an unexpected exception. - Make _ScriptRun.async_stop more graceful by using an asyncio.Event for signaling instead of simply cancelling Task. - Subclass _ScriptRun for background & blocking behavior. Also: - Fix timeouts in _ScriptRun by converting timedeltas to float seconds. - General cleanup. * Change per review 2 - Don't propagate exceptions if call from user has already returned (i.e., for background runs or legacy runs that have suspended.) - Allow user to specify if exceptions should be logged. They will still be logged regardless if exception is not propagated. - Rename _start_script_delay and _start_wait_template_delay for clarity. - Remove return value from Script.async_run. - Fix missing await. - Change call to self.is_running in Script.async_run to direct test of self._runs. * Change per review 3 and add tests - Remove Script.set_logger(). - Enhance existing tests to check all run modes. - Add tests for new features. - Fix a few minor bugs found by tests.
2020-02-24 22:56:00 +00:00
await self.script.async_stop()
# remove service
self.hass.services.async_remove(DOMAIN, self.object_id)