Add Blueprint foundation to Scripts (#48621)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
parent
cd84595429
commit
89e7983ee0
17 changed files with 448 additions and 103 deletions
|
@ -1,6 +1,9 @@
|
||||||
"""Provide configuration end points for scripts."""
|
"""Provide configuration end points for scripts."""
|
||||||
from homeassistant.components.script import DOMAIN, SCRIPT_ENTRY_SCHEMA
|
from homeassistant.components.script import DOMAIN
|
||||||
from homeassistant.components.script.config import async_validate_config_item
|
from homeassistant.components.script.config import (
|
||||||
|
SCRIPT_ENTITY_SCHEMA,
|
||||||
|
async_validate_config_item,
|
||||||
|
)
|
||||||
from homeassistant.config import SCRIPT_CONFIG_PATH
|
from homeassistant.config import SCRIPT_CONFIG_PATH
|
||||||
from homeassistant.const import SERVICE_RELOAD
|
from homeassistant.const import SERVICE_RELOAD
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
@ -21,7 +24,7 @@ async def async_setup(hass):
|
||||||
"config",
|
"config",
|
||||||
SCRIPT_CONFIG_PATH,
|
SCRIPT_CONFIG_PATH,
|
||||||
cv.slug,
|
cv.slug,
|
||||||
SCRIPT_ENTRY_SCHEMA,
|
SCRIPT_ENTITY_SCHEMA,
|
||||||
post_write_hook=hook,
|
post_write_hook=hook,
|
||||||
data_validator=async_validate_config_item,
|
data_validator=async_validate_config_item,
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,21 +3,21 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any, Dict, cast
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
from voluptuous.humanize import humanize_error
|
||||||
|
|
||||||
from homeassistant.components.trace import TRACE_CONFIG_SCHEMA
|
from homeassistant.components.blueprint import BlueprintInputs
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_ENTITY_ID,
|
ATTR_ENTITY_ID,
|
||||||
ATTR_MODE,
|
ATTR_MODE,
|
||||||
ATTR_NAME,
|
ATTR_NAME,
|
||||||
CONF_ALIAS,
|
CONF_ALIAS,
|
||||||
CONF_DEFAULT,
|
|
||||||
CONF_DESCRIPTION,
|
CONF_DESCRIPTION,
|
||||||
CONF_ICON,
|
CONF_ICON,
|
||||||
CONF_MODE,
|
CONF_MODE,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
CONF_SELECTOR,
|
|
||||||
CONF_SEQUENCE,
|
CONF_SEQUENCE,
|
||||||
CONF_VARIABLES,
|
CONF_VARIABLES,
|
||||||
SERVICE_RELOAD,
|
SERVICE_RELOAD,
|
||||||
|
@ -27,6 +27,7 @@ from homeassistant.const import (
|
||||||
STATE_ON,
|
STATE_ON,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import extract_domain_configs
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.config_validation import make_entity_service_schema
|
from homeassistant.helpers.config_validation import make_entity_service_schema
|
||||||
from homeassistant.helpers.entity import ToggleEntity
|
from homeassistant.helpers.entity import ToggleEntity
|
||||||
|
@ -36,63 +37,27 @@ from homeassistant.helpers.script import (
|
||||||
ATTR_MAX,
|
ATTR_MAX,
|
||||||
CONF_MAX,
|
CONF_MAX,
|
||||||
CONF_MAX_EXCEEDED,
|
CONF_MAX_EXCEEDED,
|
||||||
SCRIPT_MODE_SINGLE,
|
|
||||||
Script,
|
Script,
|
||||||
make_script_schema,
|
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.selector import validate_selector
|
|
||||||
from homeassistant.helpers.service import async_set_service_schema
|
from homeassistant.helpers.service import async_set_service_schema
|
||||||
from homeassistant.helpers.trace import trace_get, trace_path
|
from homeassistant.helpers.trace import trace_get, trace_path
|
||||||
from homeassistant.loader import bind_hass
|
from homeassistant.loader import bind_hass
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
from .helpers import async_get_blueprints
|
||||||
from .trace import trace_script
|
from .trace import trace_script
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
DOMAIN = "script"
|
|
||||||
|
|
||||||
ATTR_LAST_ACTION = "last_action"
|
|
||||||
ATTR_LAST_TRIGGERED = "last_triggered"
|
|
||||||
ATTR_VARIABLES = "variables"
|
|
||||||
|
|
||||||
CONF_ADVANCED = "advanced"
|
|
||||||
CONF_EXAMPLE = "example"
|
|
||||||
CONF_FIELDS = "fields"
|
|
||||||
CONF_REQUIRED = "required"
|
|
||||||
CONF_TRACE = "trace"
|
|
||||||
|
|
||||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
|
||||||
|
|
||||||
EVENT_SCRIPT_STARTED = "script_started"
|
|
||||||
|
|
||||||
|
|
||||||
SCRIPT_ENTRY_SCHEMA = make_script_schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_ALIAS): cv.string,
|
|
||||||
vol.Optional(CONF_TRACE, default={}): TRACE_CONFIG_SCHEMA,
|
|
||||||
vol.Optional(CONF_ICON): cv.icon,
|
|
||||||
vol.Required(CONF_SEQUENCE): cv.SCRIPT_SCHEMA,
|
|
||||||
vol.Optional(CONF_DESCRIPTION, default=""): cv.string,
|
|
||||||
vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA,
|
|
||||||
vol.Optional(CONF_FIELDS, default={}): {
|
|
||||||
cv.string: {
|
|
||||||
vol.Optional(CONF_ADVANCED, default=False): cv.boolean,
|
|
||||||
vol.Optional(CONF_DEFAULT): cv.match_all,
|
|
||||||
vol.Optional(CONF_DESCRIPTION): cv.string,
|
|
||||||
vol.Optional(CONF_EXAMPLE): cv.string,
|
|
||||||
vol.Optional(CONF_NAME): cv.string,
|
|
||||||
vol.Optional(CONF_REQUIRED, default=False): cv.boolean,
|
|
||||||
vol.Optional(CONF_SELECTOR): validate_selector,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
SCRIPT_MODE_SINGLE,
|
|
||||||
)
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
|
||||||
{DOMAIN: cv.schema_with_slug_keys(SCRIPT_ENTRY_SCHEMA)}, extra=vol.ALLOW_EXTRA
|
|
||||||
)
|
|
||||||
|
|
||||||
SCRIPT_SERVICE_SCHEMA = vol.Schema(dict)
|
SCRIPT_SERVICE_SCHEMA = vol.Schema(dict)
|
||||||
SCRIPT_TURN_ONOFF_SCHEMA = make_entity_service_schema(
|
SCRIPT_TURN_ONOFF_SCHEMA = make_entity_service_schema(
|
||||||
{vol.Optional(ATTR_VARIABLES): {str: cv.match_all}}
|
{vol.Optional(ATTR_VARIABLES): {str: cv.match_all}}
|
||||||
|
@ -201,9 +166,13 @@ def areas_in_script(hass: HomeAssistant, entity_id: str) -> list[str]:
|
||||||
|
|
||||||
async def async_setup(hass, config):
|
async def async_setup(hass, config):
|
||||||
"""Load the scripts from the configuration."""
|
"""Load the scripts from the configuration."""
|
||||||
hass.data[DOMAIN] = component = EntityComponent(_LOGGER, DOMAIN, hass)
|
hass.data[DOMAIN] = component = EntityComponent(LOGGER, DOMAIN, hass)
|
||||||
|
|
||||||
await _async_process_config(hass, config, component)
|
# 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()
|
||||||
|
|
||||||
async def reload_service(service):
|
async def reload_service(service):
|
||||||
"""Call a service to reload scripts."""
|
"""Call a service to reload scripts."""
|
||||||
|
@ -257,8 +226,50 @@ async def async_setup(hass, config):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _async_process_config(hass, config, component):
|
async def _async_process_config(hass, config, component) -> bool:
|
||||||
"""Process script configuration."""
|
"""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)
|
||||||
|
|
||||||
async def service_handler(service):
|
async def service_handler(service):
|
||||||
"""Execute a service call to script.<script name>."""
|
"""Execute a service call to script.<script name>."""
|
||||||
|
@ -268,33 +279,21 @@ async def _async_process_config(hass, config, component):
|
||||||
variables=service.data, context=service.context
|
variables=service.data, context=service.context
|
||||||
)
|
)
|
||||||
|
|
||||||
script_entities = [
|
|
||||||
ScriptEntity(hass, object_id, cfg, cfg.raw_config)
|
|
||||||
for object_id, cfg in config.get(DOMAIN, {}).items()
|
|
||||||
]
|
|
||||||
|
|
||||||
await component.async_add_entities(script_entities)
|
|
||||||
|
|
||||||
# Register services for all entities that were created successfully.
|
# Register services for all entities that were created successfully.
|
||||||
for script_entity in script_entities:
|
for entity in entities:
|
||||||
object_id = script_entity.object_id
|
|
||||||
if component.get_entity(script_entity.entity_id) is None:
|
|
||||||
_LOGGER.error("Couldn't load script %s", object_id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
cfg = config[DOMAIN][object_id]
|
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN, object_id, service_handler, schema=SCRIPT_SERVICE_SCHEMA
|
DOMAIN, entity.object_id, service_handler, schema=SCRIPT_SERVICE_SCHEMA
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register the service description
|
# Register the service description
|
||||||
service_desc = {
|
service_desc = {
|
||||||
CONF_NAME: script_entity.name,
|
CONF_NAME: entity.name,
|
||||||
CONF_DESCRIPTION: cfg[CONF_DESCRIPTION],
|
CONF_DESCRIPTION: entity.description,
|
||||||
CONF_FIELDS: cfg[CONF_FIELDS],
|
CONF_FIELDS: entity.fields,
|
||||||
}
|
}
|
||||||
async_set_service_schema(hass, DOMAIN, object_id, service_desc)
|
async_set_service_schema(hass, DOMAIN, entity.object_id, service_desc)
|
||||||
|
|
||||||
|
return blueprints_used
|
||||||
|
|
||||||
|
|
||||||
class ScriptEntity(ToggleEntity):
|
class ScriptEntity(ToggleEntity):
|
||||||
|
@ -302,10 +301,13 @@ class ScriptEntity(ToggleEntity):
|
||||||
|
|
||||||
icon = None
|
icon = None
|
||||||
|
|
||||||
def __init__(self, hass, object_id, cfg, raw_config):
|
def __init__(self, hass, object_id, cfg, raw_config, blueprint_inputs):
|
||||||
"""Initialize the script."""
|
"""Initialize the script."""
|
||||||
self.object_id = object_id
|
self.object_id = object_id
|
||||||
self.icon = cfg.get(CONF_ICON)
|
self.icon = cfg.get(CONF_ICON)
|
||||||
|
self.description = cfg[CONF_DESCRIPTION]
|
||||||
|
self.fields = cfg[CONF_FIELDS]
|
||||||
|
|
||||||
self.entity_id = ENTITY_ID_FORMAT.format(object_id)
|
self.entity_id = ENTITY_ID_FORMAT.format(object_id)
|
||||||
self.script = Script(
|
self.script = Script(
|
||||||
hass,
|
hass,
|
||||||
|
@ -323,6 +325,7 @@ class ScriptEntity(ToggleEntity):
|
||||||
self._changed = asyncio.Event()
|
self._changed = asyncio.Event()
|
||||||
self._raw_config = raw_config
|
self._raw_config = raw_config
|
||||||
self._trace_config = cfg[CONF_TRACE]
|
self._trace_config = cfg[CONF_TRACE]
|
||||||
|
self._blueprint_inputs = blueprint_inputs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def should_poll(self):
|
def should_poll(self):
|
||||||
|
@ -388,7 +391,12 @@ class ScriptEntity(ToggleEntity):
|
||||||
|
|
||||||
async def _async_run(self, variables, context):
|
async def _async_run(self, variables, context):
|
||||||
with trace_script(
|
with trace_script(
|
||||||
self.hass, self.object_id, self._raw_config, context, self._trace_config
|
self.hass,
|
||||||
|
self.object_id,
|
||||||
|
self._raw_config,
|
||||||
|
self._blueprint_inputs,
|
||||||
|
context,
|
||||||
|
self._trace_config,
|
||||||
) as script_trace:
|
) as script_trace:
|
||||||
# Prepare tracing the execution of the script's sequence
|
# Prepare tracing the execution of the script's sequence
|
||||||
script_trace.set_trace(trace_get())
|
script_trace.set_trace(trace_get())
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
blueprint:
|
||||||
|
name: Confirmable Notification
|
||||||
|
description: >-
|
||||||
|
A script that sends an actionable notification with a confirmation before
|
||||||
|
running the specified action.
|
||||||
|
domain: script
|
||||||
|
source_url: https://github.com/home-assistant/core/blob/master/homeassistant/components/script/blueprints/confirmable_notification.yaml
|
||||||
|
input:
|
||||||
|
notify_device:
|
||||||
|
name: Device to notify
|
||||||
|
description: Device needs to run the official Home Assistant app to receive notifications.
|
||||||
|
selector:
|
||||||
|
device:
|
||||||
|
integration: mobile_app
|
||||||
|
title:
|
||||||
|
name: "Title"
|
||||||
|
description: "The title of the button shown in the notification."
|
||||||
|
default: ""
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
message:
|
||||||
|
name: "Message"
|
||||||
|
description: "The message body"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
confirm_text:
|
||||||
|
name: "Confirmation Text"
|
||||||
|
description: "Text to show on the confirmation button"
|
||||||
|
default: "Confirm"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
confirm_action:
|
||||||
|
name: "Confirmation Action"
|
||||||
|
description: "Action to run when notification is confirmed"
|
||||||
|
default: []
|
||||||
|
selector:
|
||||||
|
action:
|
||||||
|
dismiss_text:
|
||||||
|
name: "Dismiss Text"
|
||||||
|
description: "Text to show on the dismiss button"
|
||||||
|
default: "Dismiss"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
dismiss_action:
|
||||||
|
name: "Dismiss Action"
|
||||||
|
description: "Action to run when notification is dismissed"
|
||||||
|
default: []
|
||||||
|
selector:
|
||||||
|
action:
|
||||||
|
|
||||||
|
mode: restart
|
||||||
|
|
||||||
|
sequence:
|
||||||
|
- alias: "Send notification"
|
||||||
|
domain: mobile_app
|
||||||
|
type: notify
|
||||||
|
device_id: !input notify_device
|
||||||
|
title: !input title
|
||||||
|
message: !input message
|
||||||
|
data:
|
||||||
|
actions:
|
||||||
|
- action: "CONFIRM"
|
||||||
|
title: !input confirm_text
|
||||||
|
- action: "DISMISS"
|
||||||
|
title: !input dismiss_text
|
||||||
|
- alias: "Awaiting response"
|
||||||
|
wait_for_trigger:
|
||||||
|
- platform: event
|
||||||
|
event_type: mobile_app_notification_action
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ wait.trigger.event.data.action == 'CONFIRM' }}"
|
||||||
|
sequence: !input confirm_action
|
||||||
|
- conditions: "{{ wait.trigger.event.data.action == 'DISMISS' }}"
|
||||||
|
sequence: !input dismiss_action
|
|
@ -1,20 +1,75 @@
|
||||||
"""Config validation helper for the script integration."""
|
"""Config validation helper for the script integration."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config import async_log_exception
|
from homeassistant.components.blueprint import (
|
||||||
from homeassistant.const import CONF_SEQUENCE
|
BlueprintInputs,
|
||||||
|
is_blueprint_instance_config,
|
||||||
|
)
|
||||||
|
from homeassistant.components.trace import TRACE_CONFIG_SCHEMA
|
||||||
|
from homeassistant.config import async_log_exception, config_without_domain
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_ALIAS,
|
||||||
|
CONF_DEFAULT,
|
||||||
|
CONF_DESCRIPTION,
|
||||||
|
CONF_ICON,
|
||||||
|
CONF_NAME,
|
||||||
|
CONF_SELECTOR,
|
||||||
|
CONF_SEQUENCE,
|
||||||
|
CONF_VARIABLES,
|
||||||
|
)
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_per_platform, config_validation as cv
|
||||||
from homeassistant.helpers.script import async_validate_action_config
|
from homeassistant.helpers.script import (
|
||||||
|
SCRIPT_MODE_SINGLE,
|
||||||
|
async_validate_action_config,
|
||||||
|
make_script_schema,
|
||||||
|
)
|
||||||
|
from homeassistant.helpers.selector import validate_selector
|
||||||
|
|
||||||
from . import DOMAIN, SCRIPT_ENTRY_SCHEMA
|
from .const import (
|
||||||
|
CONF_ADVANCED,
|
||||||
|
CONF_EXAMPLE,
|
||||||
|
CONF_FIELDS,
|
||||||
|
CONF_REQUIRED,
|
||||||
|
CONF_TRACE,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from .helpers import async_get_blueprints
|
||||||
|
|
||||||
|
SCRIPT_ENTITY_SCHEMA = make_script_schema(
|
||||||
|
{
|
||||||
|
vol.Optional(CONF_ALIAS): cv.string,
|
||||||
|
vol.Optional(CONF_TRACE, default={}): TRACE_CONFIG_SCHEMA,
|
||||||
|
vol.Optional(CONF_ICON): cv.icon,
|
||||||
|
vol.Required(CONF_SEQUENCE): cv.SCRIPT_SCHEMA,
|
||||||
|
vol.Optional(CONF_DESCRIPTION, default=""): cv.string,
|
||||||
|
vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA,
|
||||||
|
vol.Optional(CONF_FIELDS, default={}): {
|
||||||
|
cv.string: {
|
||||||
|
vol.Optional(CONF_ADVANCED, default=False): cv.boolean,
|
||||||
|
vol.Optional(CONF_DEFAULT): cv.match_all,
|
||||||
|
vol.Optional(CONF_DESCRIPTION): cv.string,
|
||||||
|
vol.Optional(CONF_EXAMPLE): cv.string,
|
||||||
|
vol.Optional(CONF_NAME): cv.string,
|
||||||
|
vol.Optional(CONF_REQUIRED, default=False): cv.boolean,
|
||||||
|
vol.Optional(CONF_SELECTOR): validate_selector,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SCRIPT_MODE_SINGLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_validate_config_item(hass, config, full_config=None):
|
async def async_validate_config_item(hass, config, full_config=None):
|
||||||
"""Validate config item."""
|
"""Validate config item."""
|
||||||
config = SCRIPT_ENTRY_SCHEMA(config)
|
if is_blueprint_instance_config(config):
|
||||||
|
blueprints = async_get_blueprints(hass)
|
||||||
|
return await blueprints.async_inputs_from_config(config)
|
||||||
|
|
||||||
|
config = SCRIPT_ENTITY_SCHEMA(config)
|
||||||
config[CONF_SEQUENCE] = await asyncio.gather(
|
config[CONF_SEQUENCE] = await asyncio.gather(
|
||||||
*[
|
*[
|
||||||
async_validate_action_config(hass, action)
|
async_validate_action_config(hass, action)
|
||||||
|
@ -34,11 +89,8 @@ class ScriptConfig(dict):
|
||||||
async def _try_async_validate_config_item(hass, object_id, config, full_config=None):
|
async def _try_async_validate_config_item(hass, object_id, config, full_config=None):
|
||||||
"""Validate config item."""
|
"""Validate config item."""
|
||||||
raw_config = None
|
raw_config = None
|
||||||
try:
|
with suppress(ValueError): # Invalid config
|
||||||
raw_config = dict(config)
|
raw_config = dict(config)
|
||||||
except ValueError:
|
|
||||||
# Invalid config
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cv.slug(object_id)
|
cv.slug(object_id)
|
||||||
|
@ -47,6 +99,9 @@ async def _try_async_validate_config_item(hass, object_id, config, full_config=N
|
||||||
async_log_exception(ex, DOMAIN, full_config or config, hass)
|
async_log_exception(ex, DOMAIN, full_config or config, hass)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if isinstance(config, BlueprintInputs):
|
||||||
|
return config
|
||||||
|
|
||||||
config = ScriptConfig(config)
|
config = ScriptConfig(config)
|
||||||
config.raw_config = raw_config
|
config.raw_config = raw_config
|
||||||
return config
|
return config
|
||||||
|
@ -54,12 +109,16 @@ async def _try_async_validate_config_item(hass, object_id, config, full_config=N
|
||||||
|
|
||||||
async def async_validate_config(hass, config):
|
async def async_validate_config(hass, config):
|
||||||
"""Validate config."""
|
"""Validate config."""
|
||||||
if DOMAIN in config:
|
scripts = {}
|
||||||
validated_config = {}
|
for _, p_config in config_per_platform(config, DOMAIN):
|
||||||
for object_id, cfg in config[DOMAIN].items():
|
for object_id, cfg in p_config.items():
|
||||||
cfg = await _try_async_validate_config_item(hass, object_id, cfg, config)
|
cfg = await _try_async_validate_config_item(hass, object_id, cfg, config)
|
||||||
if cfg is not None:
|
if cfg is not None:
|
||||||
validated_config[object_id] = cfg
|
scripts[object_id] = cfg
|
||||||
config[DOMAIN] = validated_config
|
|
||||||
|
# Create a copy of the configuration with all config for current
|
||||||
|
# component removed and add validated config back in.
|
||||||
|
config = config_without_domain(config, DOMAIN)
|
||||||
|
config[DOMAIN] = scripts
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
20
homeassistant/components/script/const.py
Normal file
20
homeassistant/components/script/const.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
"""Constants for the script integration."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
DOMAIN = "script"
|
||||||
|
|
||||||
|
ATTR_LAST_ACTION = "last_action"
|
||||||
|
ATTR_LAST_TRIGGERED = "last_triggered"
|
||||||
|
ATTR_VARIABLES = "variables"
|
||||||
|
|
||||||
|
CONF_ADVANCED = "advanced"
|
||||||
|
CONF_EXAMPLE = "example"
|
||||||
|
CONF_FIELDS = "fields"
|
||||||
|
CONF_REQUIRED = "required"
|
||||||
|
CONF_TRACE = "trace"
|
||||||
|
|
||||||
|
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||||
|
|
||||||
|
EVENT_SCRIPT_STARTED = "script_started"
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__package__)
|
15
homeassistant/components/script/helpers.py
Normal file
15
homeassistant/components/script/helpers.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
"""Helpers for automation integration."""
|
||||||
|
from homeassistant.components.blueprint import DomainBlueprints
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.singleton import singleton
|
||||||
|
|
||||||
|
from .const import DOMAIN, LOGGER
|
||||||
|
|
||||||
|
DATA_BLUEPRINTS = "script_blueprints"
|
||||||
|
|
||||||
|
|
||||||
|
@singleton(DATA_BLUEPRINTS)
|
||||||
|
@callback
|
||||||
|
def async_get_blueprints(hass: HomeAssistant) -> DomainBlueprints:
|
||||||
|
"""Get script blueprints."""
|
||||||
|
return DomainBlueprints(hass, DOMAIN, LOGGER)
|
|
@ -2,7 +2,7 @@
|
||||||
"domain": "script",
|
"domain": "script",
|
||||||
"name": "Scripts",
|
"name": "Scripts",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/script",
|
"documentation": "https://www.home-assistant.io/integrations/script",
|
||||||
"dependencies": ["trace"],
|
"dependencies": ["blueprint", "trace"],
|
||||||
"codeowners": ["@home-assistant/core"],
|
"codeowners": ["@home-assistant/core"],
|
||||||
"quality_scale": "internal"
|
"quality_scale": "internal"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
"""Trace support for script."""
|
"""Trace support for script."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.components.trace import ActionTrace, async_store_trace
|
from homeassistant.components.trace import ActionTrace, async_store_trace
|
||||||
from homeassistant.components.trace.const import CONF_STORED_TRACES
|
from homeassistant.components.trace.const import CONF_STORED_TRACES
|
||||||
from homeassistant.core import Context
|
from homeassistant.core import Context, HomeAssistant
|
||||||
|
|
||||||
|
|
||||||
class ScriptTrace(ActionTrace):
|
class ScriptTrace(ActionTrace):
|
||||||
|
@ -16,17 +17,25 @@ class ScriptTrace(ActionTrace):
|
||||||
self,
|
self,
|
||||||
item_id: str,
|
item_id: str,
|
||||||
config: dict[str, Any],
|
config: dict[str, Any],
|
||||||
|
blueprint_inputs: dict[str, Any],
|
||||||
context: Context,
|
context: Context,
|
||||||
):
|
) -> None:
|
||||||
"""Container for automation trace."""
|
"""Container for automation trace."""
|
||||||
key = ("script", item_id)
|
key = ("script", item_id)
|
||||||
super().__init__(key, config, None, context)
|
super().__init__(key, config, blueprint_inputs, context)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def trace_script(hass, item_id, config, context, trace_config):
|
def trace_script(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
item_id: str,
|
||||||
|
config: dict[str, Any],
|
||||||
|
blueprint_inputs: dict[str, Any],
|
||||||
|
context: Context,
|
||||||
|
trace_config: dict[str, Any],
|
||||||
|
) -> Iterator[ScriptTrace]:
|
||||||
"""Trace execution of a script."""
|
"""Trace execution of a script."""
|
||||||
trace = ScriptTrace(item_id, config, context)
|
trace = ScriptTrace(item_id, config, blueprint_inputs, context)
|
||||||
async_store_trace(hass, trace, trace_config[CONF_STORED_TRACES])
|
async_store_trace(hass, trace, trace_config[CONF_STORED_TRACES])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
"""demo conftest."""
|
"""demo conftest."""
|
||||||
|
from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401
|
||||||
from tests.components.light.conftest import mock_light_profiles # noqa: F401
|
from tests.components.light.conftest import mock_light_profiles # noqa: F401
|
||||||
|
|
3
tests/components/emulated_hue/conftest.py
Normal file
3
tests/components/emulated_hue/conftest.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
"""Conftest for emulated_hue tests."""
|
||||||
|
|
||||||
|
from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401
|
3
tests/components/logbook/conftest.py
Normal file
3
tests/components/logbook/conftest.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
"""Conftest for script tests."""
|
||||||
|
|
||||||
|
from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401
|
|
@ -1,2 +1,3 @@
|
||||||
"""Test fixtures for mqtt component."""
|
"""Test fixtures for mqtt component."""
|
||||||
|
from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401
|
||||||
from tests.components.light.conftest import mock_light_profiles # noqa: F401
|
from tests.components.light.conftest import mock_light_profiles # noqa: F401
|
||||||
|
|
3
tests/components/script/conftest.py
Normal file
3
tests/components/script/conftest.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
"""Conftest for script tests."""
|
||||||
|
|
||||||
|
from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401
|
114
tests/components/script/test_blueprint.py
Normal file
114
tests/components/script/test_blueprint.py
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
"""Test script blueprints."""
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import pathlib
|
||||||
|
from typing import Iterator
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from homeassistant.components import script
|
||||||
|
from homeassistant.components.blueprint.models import Blueprint, DomainBlueprints
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
from homeassistant.util import yaml
|
||||||
|
|
||||||
|
from tests.common import async_mock_service
|
||||||
|
|
||||||
|
BUILTIN_BLUEPRINT_FOLDER = pathlib.Path(script.__file__).parent / "blueprints"
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def patch_blueprint(blueprint_path: str, data_path: str) -> Iterator[None]:
|
||||||
|
"""Patch blueprint loading from a different source."""
|
||||||
|
orig_load = DomainBlueprints._load_blueprint
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def mock_load_blueprint(self, path: str) -> Blueprint:
|
||||||
|
if path != blueprint_path:
|
||||||
|
assert False, f"Unexpected blueprint {path}"
|
||||||
|
return orig_load(self, path)
|
||||||
|
|
||||||
|
return Blueprint(
|
||||||
|
yaml.load_yaml(data_path), expected_domain=self.domain, path=path
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.blueprint.models.DomainBlueprints._load_blueprint",
|
||||||
|
mock_load_blueprint,
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
async def test_confirmable_notification(hass: HomeAssistant) -> None:
|
||||||
|
"""Test confirmable notification blueprint."""
|
||||||
|
with patch_blueprint(
|
||||||
|
"confirmable_notification.yaml",
|
||||||
|
BUILTIN_BLUEPRINT_FOLDER / "confirmable_notification.yaml",
|
||||||
|
):
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
script.DOMAIN,
|
||||||
|
{
|
||||||
|
"script": {
|
||||||
|
"confirm": {
|
||||||
|
"use_blueprint": {
|
||||||
|
"path": "confirmable_notification.yaml",
|
||||||
|
"input": {
|
||||||
|
"notify_device": "frodo",
|
||||||
|
"title": "Lord of the things",
|
||||||
|
"message": "Throw ring in mountain?",
|
||||||
|
"confirm_action": [
|
||||||
|
{
|
||||||
|
"service": "homeassistant.turn_on",
|
||||||
|
"target": {"entity_id": "mount.doom"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
turn_on_calls = async_mock_service(hass, "homeassistant", "turn_on")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.mobile_app.device_action.async_call_action_from_config"
|
||||||
|
) as mock_call_action:
|
||||||
|
|
||||||
|
# Trigger script
|
||||||
|
await hass.services.async_call(script.DOMAIN, "confirm")
|
||||||
|
|
||||||
|
# Give script the time to attach the trigger.
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
hass.bus.async_fire("mobile_app_notification_action", {"action": "CONFIRM"})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(mock_call_action.mock_calls) == 1
|
||||||
|
_hass, config, variables, _context = mock_call_action.mock_calls[0][1]
|
||||||
|
|
||||||
|
title_tpl = config.pop("title")
|
||||||
|
message_tpl = config.pop("message")
|
||||||
|
title_tpl.hass = hass
|
||||||
|
message_tpl.hass = hass
|
||||||
|
|
||||||
|
assert config == {
|
||||||
|
"alias": "Send notification",
|
||||||
|
"domain": "mobile_app",
|
||||||
|
"type": "notify",
|
||||||
|
"device_id": "frodo",
|
||||||
|
"data": {
|
||||||
|
"actions": [
|
||||||
|
{"action": "CONFIRM", "title": "Confirm"},
|
||||||
|
{"action": "DISMISS", "title": "Dismiss"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert title_tpl.async_render(variables) == "Lord of the things"
|
||||||
|
assert message_tpl.async_render(variables) == "Throw ring in mountain?"
|
||||||
|
|
||||||
|
assert len(turn_on_calls) == 1
|
||||||
|
assert turn_on_calls[0].data == {
|
||||||
|
"entity_id": ["mount.doom"],
|
||||||
|
}
|
|
@ -518,6 +518,36 @@ async def test_config_basic(hass):
|
||||||
assert test_script.attributes["icon"] == "mdi:party"
|
assert test_script.attributes["icon"] == "mdi:party"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_multiple_domains(hass):
|
||||||
|
"""Test splitting configuration over multiple domains."""
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
"script",
|
||||||
|
{
|
||||||
|
"script": {
|
||||||
|
"first_script": {
|
||||||
|
"alias": "Main domain",
|
||||||
|
"sequence": [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"script second": {
|
||||||
|
"second_script": {
|
||||||
|
"alias": "Secondary domain",
|
||||||
|
"sequence": [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
test_script = hass.states.get("script.first_script")
|
||||||
|
assert test_script
|
||||||
|
assert test_script.name == "Main domain"
|
||||||
|
|
||||||
|
test_script = hass.states.get("script.second_script")
|
||||||
|
assert test_script
|
||||||
|
assert test_script.name == "Secondary domain"
|
||||||
|
|
||||||
|
|
||||||
async def test_logbook_humanify_script_started_event(hass):
|
async def test_logbook_humanify_script_started_event(hass):
|
||||||
"""Test humanifying script started event."""
|
"""Test humanifying script started event."""
|
||||||
hass.config.components.add("recorder")
|
hass.config.components.add("recorder")
|
||||||
|
|
3
tests/components/trace/conftest.py
Normal file
3
tests/components/trace/conftest.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
"""Conftest for trace tests."""
|
||||||
|
|
||||||
|
from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401
|
|
@ -780,7 +780,6 @@ async def test_merge_id_schema(hass):
|
||||||
types = {
|
types = {
|
||||||
"panel_custom": "list",
|
"panel_custom": "list",
|
||||||
"group": "dict",
|
"group": "dict",
|
||||||
"script": "dict",
|
|
||||||
"input_boolean": "dict",
|
"input_boolean": "dict",
|
||||||
"shell_command": "dict",
|
"shell_command": "dict",
|
||||||
"qwikswitch": "dict",
|
"qwikswitch": "dict",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue