Add Blueprint foundation to Scripts (#48621)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
pull/49794/head
Franck Nijhof 2021-04-28 00:15:38 +02:00 committed by GitHub
parent cd84595429
commit 89e7983ee0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 448 additions and 103 deletions

View File

@ -1,6 +1,9 @@
"""Provide configuration end points for scripts."""
from homeassistant.components.script import DOMAIN, SCRIPT_ENTRY_SCHEMA
from homeassistant.components.script.config import async_validate_config_item
from homeassistant.components.script import DOMAIN
from homeassistant.components.script.config import (
SCRIPT_ENTITY_SCHEMA,
async_validate_config_item,
)
from homeassistant.config import SCRIPT_CONFIG_PATH
from homeassistant.const import SERVICE_RELOAD
import homeassistant.helpers.config_validation as cv
@ -21,7 +24,7 @@ async def async_setup(hass):
"config",
SCRIPT_CONFIG_PATH,
cv.slug,
SCRIPT_ENTRY_SCHEMA,
SCRIPT_ENTITY_SCHEMA,
post_write_hook=hook,
data_validator=async_validate_config_item,
)

View File

@ -3,21 +3,21 @@ 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.trace import TRACE_CONFIG_SCHEMA
from homeassistant.components.blueprint import BlueprintInputs
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_MODE,
ATTR_NAME,
CONF_ALIAS,
CONF_DEFAULT,
CONF_DESCRIPTION,
CONF_ICON,
CONF_MODE,
CONF_NAME,
CONF_SELECTOR,
CONF_SEQUENCE,
CONF_VARIABLES,
SERVICE_RELOAD,
@ -27,6 +27,7 @@ from homeassistant.const import (
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
@ -36,63 +37,27 @@ from homeassistant.helpers.script import (
ATTR_MAX,
CONF_MAX,
CONF_MAX_EXCEEDED,
SCRIPT_MODE_SINGLE,
Script,
make_script_schema,
)
from homeassistant.helpers.selector import validate_selector
from homeassistant.helpers.service import async_set_service_schema
from homeassistant.helpers.trace import trace_get, trace_path
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
_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_TURN_ONOFF_SCHEMA = make_entity_service_schema(
{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):
"""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):
"""Call a service to reload scripts."""
@ -257,8 +226,50 @@ async def async_setup(hass, config):
return True
async def _async_process_config(hass, config, component):
"""Process script configuration."""
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)
async def service_handler(service):
"""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
)
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.
for script_entity in script_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]
for entity in entities:
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
service_desc = {
CONF_NAME: script_entity.name,
CONF_DESCRIPTION: cfg[CONF_DESCRIPTION],
CONF_FIELDS: cfg[CONF_FIELDS],
CONF_NAME: entity.name,
CONF_DESCRIPTION: entity.description,
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):
@ -302,10 +301,13 @@ class ScriptEntity(ToggleEntity):
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."""
self.object_id = object_id
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.script = Script(
hass,
@ -323,6 +325,7 @@ class ScriptEntity(ToggleEntity):
self._changed = asyncio.Event()
self._raw_config = raw_config
self._trace_config = cfg[CONF_TRACE]
self._blueprint_inputs = blueprint_inputs
@property
def should_poll(self):
@ -388,7 +391,12 @@ class ScriptEntity(ToggleEntity):
async def _async_run(self, variables, context):
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:
# Prepare tracing the execution of the script's sequence
script_trace.set_trace(trace_get())

View File

@ -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

View File

@ -1,20 +1,75 @@
"""Config validation helper for the script integration."""
import asyncio
from contextlib import suppress
import voluptuous as vol
from homeassistant.config import async_log_exception
from homeassistant.const import CONF_SEQUENCE
from homeassistant.components.blueprint import (
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.helpers import config_validation as cv
from homeassistant.helpers.script import async_validate_action_config
from homeassistant.helpers import config_per_platform, config_validation as cv
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):
"""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(
*[
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):
"""Validate config item."""
raw_config = None
try:
with suppress(ValueError): # Invalid config
raw_config = dict(config)
except ValueError:
# Invalid config
pass
try:
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)
return None
if isinstance(config, BlueprintInputs):
return config
config = ScriptConfig(config)
config.raw_config = raw_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):
"""Validate config."""
if DOMAIN in config:
validated_config = {}
for object_id, cfg in config[DOMAIN].items():
scripts = {}
for _, p_config in config_per_platform(config, DOMAIN):
for object_id, cfg in p_config.items():
cfg = await _try_async_validate_config_item(hass, object_id, cfg, config)
if cfg is not None:
validated_config[object_id] = cfg
config[DOMAIN] = validated_config
scripts[object_id] = cfg
# 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

View 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__)

View 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)

View File

@ -2,7 +2,7 @@
"domain": "script",
"name": "Scripts",
"documentation": "https://www.home-assistant.io/integrations/script",
"dependencies": ["trace"],
"dependencies": ["blueprint", "trace"],
"codeowners": ["@home-assistant/core"],
"quality_scale": "internal"
}

View File

@ -1,12 +1,13 @@
"""Trace support for script."""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
from homeassistant.components.trace import ActionTrace, async_store_trace
from homeassistant.components.trace.const import CONF_STORED_TRACES
from homeassistant.core import Context
from homeassistant.core import Context, HomeAssistant
class ScriptTrace(ActionTrace):
@ -16,17 +17,25 @@ class ScriptTrace(ActionTrace):
self,
item_id: str,
config: dict[str, Any],
blueprint_inputs: dict[str, Any],
context: Context,
):
) -> None:
"""Container for automation trace."""
key = ("script", item_id)
super().__init__(key, config, None, context)
super().__init__(key, config, blueprint_inputs, context)
@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 = ScriptTrace(item_id, config, context)
trace = ScriptTrace(item_id, config, blueprint_inputs, context)
async_store_trace(hass, trace, trace_config[CONF_STORED_TRACES])
try:

View File

@ -1,2 +1,3 @@
"""demo conftest."""
from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401
from tests.components.light.conftest import mock_light_profiles # noqa: F401

View File

@ -0,0 +1,3 @@
"""Conftest for emulated_hue tests."""
from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401

View File

@ -0,0 +1,3 @@
"""Conftest for script tests."""
from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401

View File

@ -1,2 +1,3 @@
"""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

View File

@ -0,0 +1,3 @@
"""Conftest for script tests."""
from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401

View 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"],
}

View File

@ -518,6 +518,36 @@ async def test_config_basic(hass):
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):
"""Test humanifying script started event."""
hass.config.components.add("recorder")

View File

@ -0,0 +1,3 @@
"""Conftest for trace tests."""
from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401

View File

@ -780,7 +780,6 @@ async def test_merge_id_schema(hass):
types = {
"panel_custom": "list",
"group": "dict",
"script": "dict",
"input_boolean": "dict",
"shell_command": "dict",
"qwikswitch": "dict",