Add initial blueprint support (#42469)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com> Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
parent
a84dc14569
commit
0fb587727c
30 changed files with 2144 additions and 22 deletions
|
@ -64,6 +64,7 @@ homeassistant/components/bitcoin/* @fabaff
|
||||||
homeassistant/components/bizkaibus/* @UgaitzEtxebarria
|
homeassistant/components/bizkaibus/* @UgaitzEtxebarria
|
||||||
homeassistant/components/blebox/* @gadgetmobile
|
homeassistant/components/blebox/* @gadgetmobile
|
||||||
homeassistant/components/blink/* @fronzbot
|
homeassistant/components/blink/* @fronzbot
|
||||||
|
homeassistant/components/blueprint/* @home-assistant/core
|
||||||
homeassistant/components/bmp280/* @belidzs
|
homeassistant/components/bmp280/* @belidzs
|
||||||
homeassistant/components/bmw_connected_drive/* @gerard33 @rikroe
|
homeassistant/components/bmw_connected_drive/* @gerard33 @rikroe
|
||||||
homeassistant/components/bond/* @prystupa
|
homeassistant/components/bond/* @prystupa
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
"""Allow to set up simple automation rules via the config file."""
|
"""Allow to set up simple automation rules via the config file."""
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Awaitable, Callable, List, Optional, Set, cast
|
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Union, cast
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
from voluptuous.humanize import humanize_error
|
||||||
|
|
||||||
|
from homeassistant.components import blueprint
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_ENTITY_ID,
|
ATTR_ENTITY_ID,
|
||||||
ATTR_NAME,
|
ATTR_NAME,
|
||||||
|
@ -47,6 +49,7 @@ from homeassistant.helpers.script import (
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.script_variables import ScriptVariables
|
from homeassistant.helpers.script_variables import ScriptVariables
|
||||||
from homeassistant.helpers.service import async_register_admin_service
|
from homeassistant.helpers.service import async_register_admin_service
|
||||||
|
from homeassistant.helpers.singleton import singleton
|
||||||
from homeassistant.helpers.trigger import async_initialize_triggers
|
from homeassistant.helpers.trigger import async_initialize_triggers
|
||||||
from homeassistant.helpers.typing import TemplateVarsType
|
from homeassistant.helpers.typing import TemplateVarsType
|
||||||
from homeassistant.loader import bind_hass
|
from homeassistant.loader import bind_hass
|
||||||
|
@ -58,7 +61,7 @@ from homeassistant.util.dt import parse_datetime
|
||||||
DOMAIN = "automation"
|
DOMAIN = "automation"
|
||||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||||
|
|
||||||
GROUP_NAME_ALL_AUTOMATIONS = "all automations"
|
DATA_BLUEPRINTS = "automation_blueprints"
|
||||||
|
|
||||||
CONF_DESCRIPTION = "description"
|
CONF_DESCRIPTION = "description"
|
||||||
CONF_HIDE_ENTITY = "hide_entity"
|
CONF_HIDE_ENTITY = "hide_entity"
|
||||||
|
@ -70,13 +73,9 @@ CONF_CONDITION_TYPE = "condition_type"
|
||||||
CONF_INITIAL_STATE = "initial_state"
|
CONF_INITIAL_STATE = "initial_state"
|
||||||
CONF_SKIP_CONDITION = "skip_condition"
|
CONF_SKIP_CONDITION = "skip_condition"
|
||||||
CONF_STOP_ACTIONS = "stop_actions"
|
CONF_STOP_ACTIONS = "stop_actions"
|
||||||
|
CONF_BLUEPRINT = "blueprint"
|
||||||
|
CONF_INPUT = "input"
|
||||||
|
|
||||||
CONDITION_USE_TRIGGER_VALUES = "use_trigger_values"
|
|
||||||
CONDITION_TYPE_AND = "and"
|
|
||||||
CONDITION_TYPE_NOT = "not"
|
|
||||||
CONDITION_TYPE_OR = "or"
|
|
||||||
|
|
||||||
DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND
|
|
||||||
DEFAULT_INITIAL_STATE = True
|
DEFAULT_INITIAL_STATE = True
|
||||||
DEFAULT_STOP_ACTIONS = True
|
DEFAULT_STOP_ACTIONS = True
|
||||||
|
|
||||||
|
@ -114,6 +113,13 @@ PLATFORM_SCHEMA = vol.All(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@singleton(DATA_BLUEPRINTS)
|
||||||
|
@callback
|
||||||
|
def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: # type: ignore
|
||||||
|
"""Get automation blueprints."""
|
||||||
|
return blueprint.DomainBlueprints(hass, DOMAIN, _LOGGER) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
@bind_hass
|
@bind_hass
|
||||||
def is_on(hass, entity_id):
|
def is_on(hass, entity_id):
|
||||||
"""
|
"""
|
||||||
|
@ -221,6 +227,7 @@ async def async_setup(hass, config):
|
||||||
conf = await component.async_prepare_reload()
|
conf = await component.async_prepare_reload()
|
||||||
if conf is None:
|
if conf is None:
|
||||||
return
|
return
|
||||||
|
async_get_blueprints(hass).async_reset_cache()
|
||||||
await _async_process_config(hass, conf, component)
|
await _async_process_config(hass, conf, component)
|
||||||
hass.bus.async_fire(EVENT_AUTOMATION_RELOADED, context=service_call.context)
|
hass.bus.async_fire(EVENT_AUTOMATION_RELOADED, context=service_call.context)
|
||||||
|
|
||||||
|
@ -506,7 +513,11 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
|
||||||
return {CONF_ID: self._id}
|
return {CONF_ID: self._id}
|
||||||
|
|
||||||
|
|
||||||
async def _async_process_config(hass, config, component):
|
async def _async_process_config(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
component: EntityComponent,
|
||||||
|
) -> None:
|
||||||
"""Process config and add automations.
|
"""Process config and add automations.
|
||||||
|
|
||||||
This method is a coroutine.
|
This method is a coroutine.
|
||||||
|
@ -514,9 +525,28 @@ async def _async_process_config(hass, config, component):
|
||||||
entities = []
|
entities = []
|
||||||
|
|
||||||
for config_key in extract_domain_configs(config, DOMAIN):
|
for config_key in extract_domain_configs(config, DOMAIN):
|
||||||
conf = config[config_key]
|
conf: List[Union[Dict[str, Any], blueprint.BlueprintInputs]] = config[ # type: ignore
|
||||||
|
config_key
|
||||||
|
]
|
||||||
|
|
||||||
for list_no, config_block in enumerate(conf):
|
for list_no, config_block in enumerate(conf):
|
||||||
|
if isinstance(config_block, blueprint.BlueprintInputs): # type: ignore
|
||||||
|
blueprint_inputs = config_block
|
||||||
|
|
||||||
|
try:
|
||||||
|
config_block = cast(
|
||||||
|
Dict[str, Any],
|
||||||
|
PLATFORM_SCHEMA(blueprint_inputs.async_substitute()),
|
||||||
|
)
|
||||||
|
except vol.Invalid as err:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Blueprint %s generated invalid automation with inputs %s: %s",
|
||||||
|
blueprint_inputs.blueprint.name,
|
||||||
|
blueprint_inputs.inputs,
|
||||||
|
humanize_error(config_block, err),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
automation_id = config_block.get(CONF_ID)
|
automation_id = config_block.get(CONF_ID)
|
||||||
name = config_block.get(CONF_ALIAS) or f"{config_key} {list_no}"
|
name = config_block.get(CONF_ALIAS) or f"{config_key} {list_no}"
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import asyncio
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components import blueprint
|
||||||
from homeassistant.components.device_automation.exceptions import (
|
from homeassistant.components.device_automation.exceptions import (
|
||||||
InvalidDeviceAutomationConfig,
|
InvalidDeviceAutomationConfig,
|
||||||
)
|
)
|
||||||
|
@ -14,7 +15,14 @@ from homeassistant.helpers.script import async_validate_actions_config
|
||||||
from homeassistant.helpers.trigger import async_validate_trigger_config
|
from homeassistant.helpers.trigger import async_validate_trigger_config
|
||||||
from homeassistant.loader import IntegrationNotFound
|
from homeassistant.loader import IntegrationNotFound
|
||||||
|
|
||||||
from . import CONF_ACTION, CONF_CONDITION, CONF_TRIGGER, DOMAIN, PLATFORM_SCHEMA
|
from . import (
|
||||||
|
CONF_ACTION,
|
||||||
|
CONF_CONDITION,
|
||||||
|
CONF_TRIGGER,
|
||||||
|
DOMAIN,
|
||||||
|
PLATFORM_SCHEMA,
|
||||||
|
async_get_blueprints,
|
||||||
|
)
|
||||||
|
|
||||||
# mypy: allow-untyped-calls, allow-untyped-defs
|
# mypy: allow-untyped-calls, allow-untyped-defs
|
||||||
# mypy: no-check-untyped-defs, no-warn-return-any
|
# mypy: no-check-untyped-defs, no-warn-return-any
|
||||||
|
@ -22,6 +30,10 @@ from . import CONF_ACTION, CONF_CONDITION, CONF_TRIGGER, DOMAIN, PLATFORM_SCHEMA
|
||||||
|
|
||||||
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."""
|
||||||
|
if blueprint.is_blueprint_instance_config(config):
|
||||||
|
blueprints = async_get_blueprints(hass)
|
||||||
|
return await blueprints.async_inputs_from_config(config)
|
||||||
|
|
||||||
config = PLATFORM_SCHEMA(config)
|
config = PLATFORM_SCHEMA(config)
|
||||||
|
|
||||||
config[CONF_TRIGGER] = await async_validate_trigger_config(
|
config[CONF_TRIGGER] = await async_validate_trigger_config(
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
"domain": "automation",
|
"domain": "automation",
|
||||||
"name": "Automation",
|
"name": "Automation",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/automation",
|
"documentation": "https://www.home-assistant.io/integrations/automation",
|
||||||
|
"dependencies": ["blueprint"],
|
||||||
"after_dependencies": [
|
"after_dependencies": [
|
||||||
"device_automation",
|
"device_automation",
|
||||||
"webhook"
|
"webhook"
|
||||||
|
|
19
homeassistant/components/blueprint/__init__.py
Normal file
19
homeassistant/components/blueprint/__init__.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
"""The blueprint integration."""
|
||||||
|
from . import websocket_api
|
||||||
|
from .const import DOMAIN # noqa
|
||||||
|
from .errors import ( # noqa
|
||||||
|
BlueprintException,
|
||||||
|
BlueprintWithNameException,
|
||||||
|
FailedToLoad,
|
||||||
|
InvalidBlueprint,
|
||||||
|
InvalidBlueprintInputs,
|
||||||
|
MissingPlaceholder,
|
||||||
|
)
|
||||||
|
from .models import Blueprint, BlueprintInputs, DomainBlueprints # noqa
|
||||||
|
from .schemas import is_blueprint_instance_config # noqa
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass, config):
|
||||||
|
"""Set up the blueprint integration."""
|
||||||
|
websocket_api.async_setup(hass)
|
||||||
|
return True
|
9
homeassistant/components/blueprint/const.py
Normal file
9
homeassistant/components/blueprint/const.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
"""Constants for the blueprint integration."""
|
||||||
|
BLUEPRINT_FOLDER = "blueprints"
|
||||||
|
|
||||||
|
CONF_BLUEPRINT = "blueprint"
|
||||||
|
CONF_USE_BLUEPRINT = "use_blueprint"
|
||||||
|
CONF_INPUT = "input"
|
||||||
|
CONF_SOURCE_URL = "source_url"
|
||||||
|
|
||||||
|
DOMAIN = "blueprint"
|
80
homeassistant/components/blueprint/errors.py
Normal file
80
homeassistant/components/blueprint/errors.py
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
"""Blueprint errors."""
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
from voluptuous.humanize import humanize_error
|
||||||
|
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
|
|
||||||
|
class BlueprintException(HomeAssistantError):
|
||||||
|
"""Base exception for blueprint errors."""
|
||||||
|
|
||||||
|
def __init__(self, domain: str, msg: str) -> None:
|
||||||
|
"""Initialize a blueprint exception."""
|
||||||
|
super().__init__(msg)
|
||||||
|
self.domain = domain
|
||||||
|
|
||||||
|
|
||||||
|
class BlueprintWithNameException(BlueprintException):
|
||||||
|
"""Base exception for blueprint errors."""
|
||||||
|
|
||||||
|
def __init__(self, domain: str, blueprint_name: str, msg: str) -> None:
|
||||||
|
"""Initialize blueprint exception."""
|
||||||
|
super().__init__(domain, msg)
|
||||||
|
self.blueprint_name = blueprint_name
|
||||||
|
|
||||||
|
|
||||||
|
class FailedToLoad(BlueprintWithNameException):
|
||||||
|
"""When we failed to load the blueprint."""
|
||||||
|
|
||||||
|
def __init__(self, domain: str, blueprint_name: str, exc: Exception) -> None:
|
||||||
|
"""Initialize blueprint exception."""
|
||||||
|
super().__init__(domain, blueprint_name, f"Failed to load blueprint: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidBlueprint(BlueprintWithNameException):
|
||||||
|
"""When we encountered an invalid blueprint."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
domain: str,
|
||||||
|
blueprint_name: str,
|
||||||
|
blueprint_data: Any,
|
||||||
|
msg_or_exc: vol.Invalid,
|
||||||
|
):
|
||||||
|
"""Initialize an invalid blueprint error."""
|
||||||
|
if isinstance(msg_or_exc, vol.Invalid):
|
||||||
|
msg_or_exc = humanize_error(blueprint_data, msg_or_exc)
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
domain,
|
||||||
|
blueprint_name,
|
||||||
|
f"Invalid blueprint: {msg_or_exc}",
|
||||||
|
)
|
||||||
|
self.blueprint_data = blueprint_data
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidBlueprintInputs(BlueprintException):
|
||||||
|
"""When we encountered invalid blueprint inputs."""
|
||||||
|
|
||||||
|
def __init__(self, domain: str, msg: str):
|
||||||
|
"""Initialize an invalid blueprint inputs error."""
|
||||||
|
super().__init__(
|
||||||
|
domain,
|
||||||
|
f"Invalid blueprint inputs: {msg}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MissingPlaceholder(BlueprintWithNameException):
|
||||||
|
"""When we miss a placeholder."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, domain: str, blueprint_name: str, placeholder_names: Iterable[str]
|
||||||
|
) -> None:
|
||||||
|
"""Initialize blueprint exception."""
|
||||||
|
super().__init__(
|
||||||
|
domain,
|
||||||
|
blueprint_name,
|
||||||
|
f"Missing placeholder {', '.join(sorted(placeholder_names))}",
|
||||||
|
)
|
177
homeassistant/components/blueprint/importer.py
Normal file
177
homeassistant/components/blueprint/importer.py
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
"""Import logic for blueprint."""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
import yarl
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||||
|
from homeassistant.util import yaml
|
||||||
|
|
||||||
|
from .models import Blueprint
|
||||||
|
from .schemas import is_blueprint_config
|
||||||
|
|
||||||
|
COMMUNITY_TOPIC_PATTERN = re.compile(
|
||||||
|
r"^https://community.home-assistant.io/t/[a-z0-9-]+/(?P<topic>\d+)(?:/(?P<post>\d+)|)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
COMMUNITY_CODE_BLOCK = re.compile(
|
||||||
|
r'<code class="lang-(?P<syntax>[a-z]+)">(?P<content>(?:.|\n)*)</code>', re.MULTILINE
|
||||||
|
)
|
||||||
|
|
||||||
|
GITHUB_FILE_PATTERN = re.compile(
|
||||||
|
r"^https://github.com/(?P<repository>.+)/blob/(?P<path>.+)$"
|
||||||
|
)
|
||||||
|
GITHUB_RAW_FILE_PATTERN = re.compile(r"^https://raw.githubusercontent.com/")
|
||||||
|
|
||||||
|
COMMUNITY_TOPIC_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
"slug": str,
|
||||||
|
"title": str,
|
||||||
|
"post_stream": {"posts": [{"updated_at": cv.datetime, "cooked": str}]},
|
||||||
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ImportedBlueprint:
|
||||||
|
"""Imported blueprint."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
suggested_filename: str
|
||||||
|
raw_data: str
|
||||||
|
blueprint: Blueprint
|
||||||
|
|
||||||
|
|
||||||
|
def _get_github_import_url(url: str) -> str:
|
||||||
|
"""Convert a GitHub url to the raw content.
|
||||||
|
|
||||||
|
Async friendly.
|
||||||
|
"""
|
||||||
|
match = GITHUB_RAW_FILE_PATTERN.match(url)
|
||||||
|
if match is not None:
|
||||||
|
return url
|
||||||
|
|
||||||
|
match = GITHUB_FILE_PATTERN.match(url)
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
raise ValueError("Not a GitHub file url")
|
||||||
|
|
||||||
|
repo, path = match.groups()
|
||||||
|
|
||||||
|
return f"https://raw.githubusercontent.com/{repo}/{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_community_post_import_url(url: str) -> str:
|
||||||
|
"""Convert a forum post url to an import url.
|
||||||
|
|
||||||
|
Async friendly.
|
||||||
|
"""
|
||||||
|
match = COMMUNITY_TOPIC_PATTERN.match(url)
|
||||||
|
if match is None:
|
||||||
|
raise ValueError("Not a topic url")
|
||||||
|
|
||||||
|
_topic, post = match.groups()
|
||||||
|
|
||||||
|
json_url = url
|
||||||
|
|
||||||
|
if post is not None:
|
||||||
|
# Chop off post part, ie /2
|
||||||
|
json_url = json_url[: -len(post) - 1]
|
||||||
|
|
||||||
|
json_url += ".json"
|
||||||
|
|
||||||
|
return json_url
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_blueprint_from_community_topic(
|
||||||
|
url: str,
|
||||||
|
topic: dict,
|
||||||
|
) -> Optional[ImportedBlueprint]:
|
||||||
|
"""Extract a blueprint from a community post JSON.
|
||||||
|
|
||||||
|
Async friendly.
|
||||||
|
"""
|
||||||
|
block_content = None
|
||||||
|
blueprint = None
|
||||||
|
post = topic["post_stream"]["posts"][0]
|
||||||
|
|
||||||
|
for match in COMMUNITY_CODE_BLOCK.finditer(post["cooked"]):
|
||||||
|
block_syntax, block_content = match.groups()
|
||||||
|
|
||||||
|
if block_syntax not in ("auto", "yaml"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
block_content = block_content.strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = yaml.parse_yaml(block_content)
|
||||||
|
except HomeAssistantError:
|
||||||
|
if block_syntax == "yaml":
|
||||||
|
raise
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not is_blueprint_config(data):
|
||||||
|
continue
|
||||||
|
|
||||||
|
blueprint = Blueprint(data)
|
||||||
|
break
|
||||||
|
|
||||||
|
if blueprint is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return ImportedBlueprint(url, topic["slug"], block_content, blueprint)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_blueprint_from_community_post(
|
||||||
|
hass: HomeAssistant, url: str
|
||||||
|
) -> Optional[ImportedBlueprint]:
|
||||||
|
"""Get blueprints from a community post url.
|
||||||
|
|
||||||
|
Method can raise aiohttp client exceptions, vol.Invalid.
|
||||||
|
|
||||||
|
Caller needs to implement own timeout.
|
||||||
|
"""
|
||||||
|
import_url = _get_community_post_import_url(url)
|
||||||
|
session = aiohttp_client.async_get_clientsession(hass)
|
||||||
|
|
||||||
|
resp = await session.get(import_url, raise_for_status=True)
|
||||||
|
json_resp = await resp.json()
|
||||||
|
json_resp = COMMUNITY_TOPIC_SCHEMA(json_resp)
|
||||||
|
return _extract_blueprint_from_community_topic(url, json_resp)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_blueprint_from_github_url(
|
||||||
|
hass: HomeAssistant, url: str
|
||||||
|
) -> ImportedBlueprint:
|
||||||
|
"""Get a blueprint from a github url."""
|
||||||
|
import_url = _get_github_import_url(url)
|
||||||
|
session = aiohttp_client.async_get_clientsession(hass)
|
||||||
|
|
||||||
|
resp = await session.get(import_url, raise_for_status=True)
|
||||||
|
raw_yaml = await resp.text()
|
||||||
|
data = yaml.parse_yaml(raw_yaml)
|
||||||
|
blueprint = Blueprint(data)
|
||||||
|
|
||||||
|
parsed_import_url = yarl.URL(import_url)
|
||||||
|
suggested_filename = f"{parsed_import_url.parts[1]}-{parsed_import_url.parts[-1]}"
|
||||||
|
if suggested_filename.endswith(".yaml"):
|
||||||
|
suggested_filename = suggested_filename[:-5]
|
||||||
|
|
||||||
|
return ImportedBlueprint(url, suggested_filename, raw_yaml, blueprint)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_blueprint_from_url(hass: HomeAssistant, url: str) -> ImportedBlueprint:
|
||||||
|
"""Get a blueprint from a url."""
|
||||||
|
for func in (fetch_blueprint_from_community_post, fetch_blueprint_from_github_url):
|
||||||
|
try:
|
||||||
|
return await func(hass, url)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise HomeAssistantError("Unsupported url")
|
9
homeassistant/components/blueprint/manifest.json
Normal file
9
homeassistant/components/blueprint/manifest.json
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"domain": "blueprint",
|
||||||
|
"name": "Blueprint",
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/blueprint",
|
||||||
|
"codeowners": [
|
||||||
|
"@home-assistant/core"
|
||||||
|
],
|
||||||
|
"quality_scale": "internal"
|
||||||
|
}
|
231
homeassistant/components/blueprint/models.py
Normal file
231
homeassistant/components/blueprint/models.py
Normal file
|
@ -0,0 +1,231 @@
|
||||||
|
"""Blueprint models."""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
from typing import Any, Dict, Optional, Union
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
from voluptuous.humanize import humanize_error
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_DOMAIN, CONF_NAME, CONF_PATH
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import placeholder
|
||||||
|
from homeassistant.util import yaml
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
BLUEPRINT_FOLDER,
|
||||||
|
CONF_BLUEPRINT,
|
||||||
|
CONF_INPUT,
|
||||||
|
CONF_SOURCE_URL,
|
||||||
|
CONF_USE_BLUEPRINT,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from .errors import (
|
||||||
|
BlueprintException,
|
||||||
|
FailedToLoad,
|
||||||
|
InvalidBlueprint,
|
||||||
|
InvalidBlueprintInputs,
|
||||||
|
MissingPlaceholder,
|
||||||
|
)
|
||||||
|
from .schemas import BLUEPRINT_INSTANCE_FIELDS, BLUEPRINT_SCHEMA
|
||||||
|
|
||||||
|
|
||||||
|
class Blueprint:
|
||||||
|
"""Blueprint of a configuration structure."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
data: dict,
|
||||||
|
*,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
expected_domain: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize a blueprint."""
|
||||||
|
try:
|
||||||
|
data = self.data = BLUEPRINT_SCHEMA(data)
|
||||||
|
except vol.Invalid as err:
|
||||||
|
raise InvalidBlueprint(expected_domain, path, data, err) from err
|
||||||
|
|
||||||
|
self.placeholders = placeholder.extract_placeholders(data)
|
||||||
|
|
||||||
|
# In future, we will treat this as "incorrect" and allow to recover from this
|
||||||
|
data_domain = data[CONF_BLUEPRINT][CONF_DOMAIN]
|
||||||
|
if expected_domain is not None and data_domain != expected_domain:
|
||||||
|
raise InvalidBlueprint(
|
||||||
|
expected_domain,
|
||||||
|
path or self.name,
|
||||||
|
data,
|
||||||
|
f"Found incorrect blueprint type {data_domain}, expected {expected_domain}",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.domain = data_domain
|
||||||
|
|
||||||
|
missing = self.placeholders - set(data[CONF_BLUEPRINT].get(CONF_INPUT, {}))
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise InvalidBlueprint(
|
||||||
|
data_domain,
|
||||||
|
path or self.name,
|
||||||
|
data,
|
||||||
|
f"Missing input definition for {', '.join(missing)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return blueprint name."""
|
||||||
|
return self.data[CONF_BLUEPRINT][CONF_NAME]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def metadata(self) -> dict:
|
||||||
|
"""Return blueprint metadata."""
|
||||||
|
return self.data[CONF_BLUEPRINT]
|
||||||
|
|
||||||
|
def update_metadata(self, *, source_url: Optional[str] = None) -> None:
|
||||||
|
"""Update metadata."""
|
||||||
|
if source_url is not None:
|
||||||
|
self.data[CONF_BLUEPRINT][CONF_SOURCE_URL] = source_url
|
||||||
|
|
||||||
|
|
||||||
|
class BlueprintInputs:
|
||||||
|
"""Inputs for a blueprint."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, blueprint: Blueprint, config_with_inputs: Dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Instantiate a blueprint inputs object."""
|
||||||
|
self.blueprint = blueprint
|
||||||
|
self.config_with_inputs = config_with_inputs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inputs(self):
|
||||||
|
"""Return the inputs."""
|
||||||
|
return self.config_with_inputs[CONF_USE_BLUEPRINT][CONF_INPUT]
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
"""Validate the inputs."""
|
||||||
|
missing = self.blueprint.placeholders - set(self.inputs)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise MissingPlaceholder(
|
||||||
|
self.blueprint.domain, self.blueprint.name, missing
|
||||||
|
)
|
||||||
|
|
||||||
|
# In future we can see if entities are correct domain, areas exist etc
|
||||||
|
# using the new selector helper.
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_substitute(self) -> dict:
|
||||||
|
"""Get the blueprint value with the inputs substituted."""
|
||||||
|
processed = placeholder.substitute(self.blueprint.data, self.inputs)
|
||||||
|
combined = {**self.config_with_inputs, **processed}
|
||||||
|
# From config_with_inputs
|
||||||
|
combined.pop(CONF_USE_BLUEPRINT)
|
||||||
|
# From blueprint
|
||||||
|
combined.pop(CONF_BLUEPRINT)
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
class DomainBlueprints:
|
||||||
|
"""Blueprints for a specific domain."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
domain: str,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize a domain blueprints instance."""
|
||||||
|
self.hass = hass
|
||||||
|
self.domain = domain
|
||||||
|
self.logger = logger
|
||||||
|
self._blueprints = {}
|
||||||
|
self._load_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
hass.data.setdefault(DOMAIN, {})[domain] = self
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_reset_cache(self) -> None:
|
||||||
|
"""Reset the blueprint cache."""
|
||||||
|
self._blueprints = {}
|
||||||
|
|
||||||
|
def _load_blueprint(self, blueprint_path) -> Blueprint:
|
||||||
|
"""Load a blueprint."""
|
||||||
|
try:
|
||||||
|
blueprint_data = yaml.load_yaml(
|
||||||
|
self.hass.config.path(BLUEPRINT_FOLDER, self.domain, blueprint_path)
|
||||||
|
)
|
||||||
|
except (HomeAssistantError, FileNotFoundError) as err:
|
||||||
|
raise FailedToLoad(self.domain, blueprint_path, err) from err
|
||||||
|
|
||||||
|
return Blueprint(
|
||||||
|
blueprint_data, expected_domain=self.domain, path=blueprint_path
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_blueprints(self) -> Dict[str, Union[Blueprint, BlueprintException]]:
|
||||||
|
"""Load all the blueprints."""
|
||||||
|
blueprint_folder = pathlib.Path(
|
||||||
|
self.hass.config.path(BLUEPRINT_FOLDER, self.domain)
|
||||||
|
)
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
for blueprint_path in blueprint_folder.glob("**/*.yaml"):
|
||||||
|
blueprint_path = str(blueprint_path.relative_to(blueprint_folder))
|
||||||
|
if self._blueprints.get(blueprint_path) is None:
|
||||||
|
try:
|
||||||
|
self._blueprints[blueprint_path] = self._load_blueprint(
|
||||||
|
blueprint_path
|
||||||
|
)
|
||||||
|
except BlueprintException as err:
|
||||||
|
self._blueprints[blueprint_path] = None
|
||||||
|
results[blueprint_path] = err
|
||||||
|
continue
|
||||||
|
|
||||||
|
results[blueprint_path] = self._blueprints[blueprint_path]
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def async_get_blueprints(
|
||||||
|
self,
|
||||||
|
) -> Dict[str, Union[Blueprint, BlueprintException]]:
|
||||||
|
"""Get all the blueprints."""
|
||||||
|
async with self._load_lock:
|
||||||
|
return await self.hass.async_add_executor_job(self._load_blueprints)
|
||||||
|
|
||||||
|
async def async_get_blueprint(self, blueprint_path: str) -> Blueprint:
|
||||||
|
"""Get a blueprint."""
|
||||||
|
if blueprint_path in self._blueprints:
|
||||||
|
return self._blueprints[blueprint_path]
|
||||||
|
|
||||||
|
async with self._load_lock:
|
||||||
|
# Check it again
|
||||||
|
if blueprint_path in self._blueprints:
|
||||||
|
return self._blueprints[blueprint_path]
|
||||||
|
|
||||||
|
try:
|
||||||
|
blueprint = await self.hass.async_add_executor_job(
|
||||||
|
self._load_blueprint, blueprint_path
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._blueprints[blueprint_path] = None
|
||||||
|
raise
|
||||||
|
|
||||||
|
self._blueprints[blueprint_path] = blueprint
|
||||||
|
return blueprint
|
||||||
|
|
||||||
|
async def async_inputs_from_config(
|
||||||
|
self, config_with_blueprint: dict
|
||||||
|
) -> BlueprintInputs:
|
||||||
|
"""Process a blueprint config."""
|
||||||
|
try:
|
||||||
|
config_with_blueprint = BLUEPRINT_INSTANCE_FIELDS(config_with_blueprint)
|
||||||
|
except vol.Invalid as err:
|
||||||
|
raise InvalidBlueprintInputs(
|
||||||
|
self.domain, humanize_error(config_with_blueprint, err)
|
||||||
|
) from err
|
||||||
|
|
||||||
|
bp_conf = config_with_blueprint[CONF_USE_BLUEPRINT]
|
||||||
|
blueprint = await self.async_get_blueprint(bp_conf[CONF_PATH])
|
||||||
|
inputs = BlueprintInputs(blueprint, config_with_blueprint)
|
||||||
|
inputs.validate()
|
||||||
|
return inputs
|
57
homeassistant/components/blueprint/schemas.py
Normal file
57
homeassistant/components/blueprint/schemas.py
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
"""Schemas for the blueprint integration."""
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_DOMAIN, CONF_NAME, CONF_PATH
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
|
from .const import CONF_BLUEPRINT, CONF_INPUT, CONF_USE_BLUEPRINT
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def is_blueprint_config(config: Any) -> bool:
|
||||||
|
"""Return if it is a blueprint config."""
|
||||||
|
return isinstance(config, dict) and CONF_BLUEPRINT in config
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def is_blueprint_instance_config(config: Any) -> bool:
|
||||||
|
"""Return if it is a blueprint instance config."""
|
||||||
|
return isinstance(config, dict) and CONF_USE_BLUEPRINT in config
|
||||||
|
|
||||||
|
|
||||||
|
BLUEPRINT_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
# No definition yet for the inputs.
|
||||||
|
vol.Required(CONF_BLUEPRINT): vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_NAME): str,
|
||||||
|
vol.Required(CONF_DOMAIN): str,
|
||||||
|
vol.Optional(CONF_INPUT, default=dict): {str: None},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_yaml_suffix(value: str) -> str:
|
||||||
|
"""Validate value has a YAML suffix."""
|
||||||
|
if not value.endswith(".yaml"):
|
||||||
|
raise vol.Invalid("Path needs to end in .yaml")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
BLUEPRINT_INSTANCE_FIELDS = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_USE_BLUEPRINT): vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_PATH): vol.All(cv.path, validate_yaml_suffix),
|
||||||
|
vol.Required(CONF_INPUT): {str: cv.match_all},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA,
|
||||||
|
)
|
86
homeassistant/components/blueprint/websocket_api.py
Normal file
86
homeassistant/components/blueprint/websocket_api.py
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
"""Websocket API for blueprint."""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import async_timeout
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components import websocket_api
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
|
from . import importer, models
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__package__)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_setup(hass: HomeAssistant):
|
||||||
|
"""Set up the websocket API."""
|
||||||
|
websocket_api.async_register_command(hass, ws_list_blueprints)
|
||||||
|
websocket_api.async_register_command(hass, ws_import_blueprint)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.async_response
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "blueprint/list",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def ws_list_blueprints(hass, connection, msg):
|
||||||
|
"""List available blueprints."""
|
||||||
|
domain_blueprints: Optional[Dict[str, models.DomainBlueprints]] = hass.data.get(
|
||||||
|
DOMAIN, {}
|
||||||
|
)
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
for domain, domain_results in zip(
|
||||||
|
domain_blueprints,
|
||||||
|
await asyncio.gather(
|
||||||
|
*[db.async_get_blueprints() for db in domain_blueprints.values()]
|
||||||
|
),
|
||||||
|
):
|
||||||
|
for path, value in domain_results.items():
|
||||||
|
if isinstance(value, models.Blueprint):
|
||||||
|
domain_results[path] = {
|
||||||
|
"metadata": value.metadata,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
domain_results[path] = {"error": str(value)}
|
||||||
|
|
||||||
|
results[domain] = domain_results
|
||||||
|
|
||||||
|
connection.send_result(msg["id"], results)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.async_response
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "blueprint/import",
|
||||||
|
vol.Required("url"): cv.url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def ws_import_blueprint(hass, connection, msg):
|
||||||
|
"""Import a blueprint."""
|
||||||
|
async with async_timeout.timeout(10):
|
||||||
|
imported_blueprint = await importer.fetch_blueprint_from_url(hass, msg["url"])
|
||||||
|
|
||||||
|
if imported_blueprint is None:
|
||||||
|
connection.send_error(
|
||||||
|
msg["id"], websocket_api.ERR_NOT_SUPPORTED, "This url is not supported"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
connection.send_result(
|
||||||
|
msg["id"],
|
||||||
|
{
|
||||||
|
"url": imported_blueprint.url,
|
||||||
|
"suggested_filename": imported_blueprint.suggested_filename,
|
||||||
|
"raw_data": imported_blueprint.raw_data,
|
||||||
|
"blueprint": {
|
||||||
|
"metadata": imported_blueprint.blueprint.metadata,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
|
@ -87,7 +87,7 @@ from homeassistant.helpers import (
|
||||||
template as template_helper,
|
template as template_helper,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.logging import KeywordStyleAdapter
|
from homeassistant.helpers.logging import KeywordStyleAdapter
|
||||||
from homeassistant.util import slugify as util_slugify
|
from homeassistant.util import sanitize_path, slugify as util_slugify
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
|
@ -113,6 +113,17 @@ port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535))
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def path(value: Any) -> str:
|
||||||
|
"""Validate it's a safe path."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise vol.Invalid("Expected a string")
|
||||||
|
|
||||||
|
if sanitize_path(value) != value:
|
||||||
|
raise vol.Invalid("Invalid path")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
# Adapted from:
|
# Adapted from:
|
||||||
# https://github.com/alecthomas/voluptuous/issues/115#issuecomment-144464666
|
# https://github.com/alecthomas/voluptuous/issues/115#issuecomment-144464666
|
||||||
def has_at_least_one_key(*keys: str) -> Callable:
|
def has_at_least_one_key(*keys: str) -> Callable:
|
||||||
|
|
53
homeassistant/helpers/placeholder.py
Normal file
53
homeassistant/helpers/placeholder.py
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
"""Placeholder helpers."""
|
||||||
|
from typing import Any, Dict, Set
|
||||||
|
|
||||||
|
from homeassistant.util.yaml import Placeholder
|
||||||
|
|
||||||
|
|
||||||
|
class UndefinedSubstitution(Exception):
|
||||||
|
"""Error raised when we find a substitution that is not defined."""
|
||||||
|
|
||||||
|
def __init__(self, placeholder: str) -> None:
|
||||||
|
"""Initialize the undefined substitution exception."""
|
||||||
|
super().__init__(f"No substitution found for placeholder {placeholder}")
|
||||||
|
self.placeholder = placeholder
|
||||||
|
|
||||||
|
|
||||||
|
def extract_placeholders(obj: Any) -> Set[str]:
|
||||||
|
"""Extract placeholders from a structure."""
|
||||||
|
found: Set[str] = set()
|
||||||
|
_extract_placeholders(obj, found)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_placeholders(obj: Any, found: Set[str]) -> None:
|
||||||
|
"""Extract placeholders from a structure."""
|
||||||
|
if isinstance(obj, Placeholder):
|
||||||
|
found.add(obj.name)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(obj, list):
|
||||||
|
for val in obj:
|
||||||
|
_extract_placeholders(val, found)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for val in obj.values():
|
||||||
|
_extract_placeholders(val, found)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def substitute(obj: Any, substitutions: Dict[str, Any]) -> Any:
|
||||||
|
"""Substitute values."""
|
||||||
|
if isinstance(obj, Placeholder):
|
||||||
|
if obj.name not in substitutions:
|
||||||
|
raise UndefinedSubstitution(obj.name)
|
||||||
|
return substitutions[obj.name]
|
||||||
|
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [substitute(val, substitutions) for val in obj]
|
||||||
|
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {key: substitute(val, substitutions) for key, val in obj.items()}
|
||||||
|
|
||||||
|
return obj
|
|
@ -1,14 +1,17 @@
|
||||||
"""YAML utility functions."""
|
"""YAML utility functions."""
|
||||||
from .const import _SECRET_NAMESPACE, SECRET_YAML
|
from .const import _SECRET_NAMESPACE, SECRET_YAML
|
||||||
from .dumper import dump, save_yaml
|
from .dumper import dump, save_yaml
|
||||||
from .loader import clear_secret_cache, load_yaml, secret_yaml
|
from .loader import clear_secret_cache, load_yaml, parse_yaml, secret_yaml
|
||||||
|
from .objects import Placeholder
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"SECRET_YAML",
|
"SECRET_YAML",
|
||||||
"_SECRET_NAMESPACE",
|
"_SECRET_NAMESPACE",
|
||||||
|
"Placeholder",
|
||||||
"dump",
|
"dump",
|
||||||
"save_yaml",
|
"save_yaml",
|
||||||
"clear_secret_cache",
|
"clear_secret_cache",
|
||||||
"load_yaml",
|
"load_yaml",
|
||||||
"secret_yaml",
|
"secret_yaml",
|
||||||
|
"parse_yaml",
|
||||||
]
|
]
|
||||||
|
|
|
@ -3,7 +3,7 @@ from collections import OrderedDict
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from .objects import NodeListClass
|
from .objects import NodeListClass, Placeholder
|
||||||
|
|
||||||
# mypy: allow-untyped-calls, no-warn-return-any
|
# mypy: allow-untyped-calls, no-warn-return-any
|
||||||
|
|
||||||
|
@ -60,3 +60,8 @@ yaml.SafeDumper.add_representer(
|
||||||
NodeListClass,
|
NodeListClass,
|
||||||
lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value),
|
lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
yaml.SafeDumper.add_representer(
|
||||||
|
Placeholder,
|
||||||
|
lambda dumper, value: dumper.represent_scalar("!placeholder", value.name),
|
||||||
|
)
|
||||||
|
|
|
@ -4,14 +4,14 @@ import fnmatch
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import Dict, Iterator, List, TypeVar, Union, overload
|
from typing import Dict, Iterator, List, TextIO, TypeVar, Union, overload
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
from .const import _SECRET_NAMESPACE, SECRET_YAML
|
from .const import _SECRET_NAMESPACE, SECRET_YAML
|
||||||
from .objects import NodeListClass, NodeStrClass
|
from .objects import NodeListClass, NodeStrClass, Placeholder
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import keyring
|
import keyring
|
||||||
|
@ -56,17 +56,23 @@ def load_yaml(fname: str) -> JSON_TYPE:
|
||||||
"""Load a YAML file."""
|
"""Load a YAML file."""
|
||||||
try:
|
try:
|
||||||
with open(fname, encoding="utf-8") as conf_file:
|
with open(fname, encoding="utf-8") as conf_file:
|
||||||
# If configuration file is empty YAML returns None
|
return parse_yaml(conf_file)
|
||||||
# We convert that to an empty dict
|
|
||||||
return yaml.load(conf_file, Loader=SafeLineLoader) or OrderedDict()
|
|
||||||
except yaml.YAMLError as exc:
|
|
||||||
_LOGGER.error(str(exc))
|
|
||||||
raise HomeAssistantError(exc) from exc
|
|
||||||
except UnicodeDecodeError as exc:
|
except UnicodeDecodeError as exc:
|
||||||
_LOGGER.error("Unable to read file %s: %s", fname, exc)
|
_LOGGER.error("Unable to read file %s: %s", fname, exc)
|
||||||
raise HomeAssistantError(exc) from exc
|
raise HomeAssistantError(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_yaml(content: Union[str, TextIO]) -> JSON_TYPE:
|
||||||
|
"""Load a YAML file."""
|
||||||
|
try:
|
||||||
|
# If configuration file is empty YAML returns None
|
||||||
|
# We convert that to an empty dict
|
||||||
|
return yaml.load(content, Loader=SafeLineLoader) or OrderedDict()
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
_LOGGER.error(str(exc))
|
||||||
|
raise HomeAssistantError(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def _add_reference(
|
def _add_reference(
|
||||||
obj: Union[list, NodeListClass], loader: yaml.SafeLoader, node: yaml.nodes.Node
|
obj: Union[list, NodeListClass], loader: yaml.SafeLoader, node: yaml.nodes.Node
|
||||||
|
@ -325,3 +331,4 @@ yaml.SafeLoader.add_constructor("!include_dir_named", _include_dir_named_yaml)
|
||||||
yaml.SafeLoader.add_constructor(
|
yaml.SafeLoader.add_constructor(
|
||||||
"!include_dir_merge_named", _include_dir_merge_named_yaml
|
"!include_dir_merge_named", _include_dir_merge_named_yaml
|
||||||
)
|
)
|
||||||
|
yaml.SafeLoader.add_constructor("!placeholder", Placeholder.from_node)
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
"""Custom yaml object types."""
|
"""Custom yaml object types."""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
class NodeListClass(list):
|
class NodeListClass(list):
|
||||||
|
@ -7,3 +10,15 @@ class NodeListClass(list):
|
||||||
|
|
||||||
class NodeStrClass(str):
|
class NodeStrClass(str):
|
||||||
"""Wrapper class to be able to add attributes on a string."""
|
"""Wrapper class to be able to add attributes on a string."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Placeholder:
|
||||||
|
"""A placeholder that should be substituted."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_node(cls, loader: yaml.Loader, node: yaml.nodes.Node) -> "Placeholder":
|
||||||
|
"""Create a new placeholder from a node."""
|
||||||
|
return cls(node.value)
|
||||||
|
|
|
@ -1232,3 +1232,25 @@ async def test_automation_variables(hass, caplog):
|
||||||
hass.bus.async_fire("test_event_3", {"break": 0})
|
hass.bus.async_fire("test_event_3", {"break": 0})
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert len(calls) == 3
|
assert len(calls) == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_blueprint_automation(hass, calls):
|
||||||
|
"""Test blueprint automation."""
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
"automation",
|
||||||
|
{
|
||||||
|
"automation": {
|
||||||
|
"use_blueprint": {
|
||||||
|
"path": "test_event_service.yaml",
|
||||||
|
"input": {
|
||||||
|
"trigger_event": "blueprint_event",
|
||||||
|
"service_to_call": "test.automation",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
hass.bus.async_fire("blueprint_event")
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
1
tests/components/blueprint/__init__.py
Normal file
1
tests/components/blueprint/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for the blueprint integration."""
|
137
tests/components/blueprint/test_importer.py
Normal file
137
tests/components/blueprint/test_importer.py
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
"""Test blueprint importing."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.blueprint import importer
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
|
from tests.common import load_fixture
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def community_post():
|
||||||
|
"""Topic JSON with a codeblock marked as auto syntax."""
|
||||||
|
return load_fixture("blueprint/community_post.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_community_post_import_url():
|
||||||
|
"""Test variations of generating import forum url."""
|
||||||
|
assert (
|
||||||
|
importer._get_community_post_import_url(
|
||||||
|
"https://community.home-assistant.io/t/test-topic/123"
|
||||||
|
)
|
||||||
|
== "https://community.home-assistant.io/t/test-topic/123.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
importer._get_community_post_import_url(
|
||||||
|
"https://community.home-assistant.io/t/test-topic/123/2"
|
||||||
|
)
|
||||||
|
== "https://community.home-assistant.io/t/test-topic/123.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_github_import_url():
|
||||||
|
"""Test getting github import url."""
|
||||||
|
assert (
|
||||||
|
importer._get_github_import_url(
|
||||||
|
"https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml"
|
||||||
|
)
|
||||||
|
== "https://raw.githubusercontent.com/balloob/home-assistant-config/main/blueprints/automation/motion_light.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
importer._get_github_import_url(
|
||||||
|
"https://raw.githubusercontent.com/balloob/home-assistant-config/main/blueprints/automation/motion_light.yaml"
|
||||||
|
)
|
||||||
|
== "https://raw.githubusercontent.com/balloob/home-assistant-config/main/blueprints/automation/motion_light.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_blueprint_from_community_topic(community_post):
|
||||||
|
"""Test extracting blueprint."""
|
||||||
|
imported_blueprint = importer._extract_blueprint_from_community_topic(
|
||||||
|
"http://example.com", json.loads(community_post)
|
||||||
|
)
|
||||||
|
assert imported_blueprint is not None
|
||||||
|
assert imported_blueprint.url == "http://example.com"
|
||||||
|
assert imported_blueprint.blueprint.domain == "automation"
|
||||||
|
assert imported_blueprint.blueprint.placeholders == {
|
||||||
|
"service_to_call",
|
||||||
|
"trigger_event",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_blueprint_from_community_topic_invalid_yaml():
|
||||||
|
"""Test extracting blueprint with invalid YAML."""
|
||||||
|
with pytest.raises(HomeAssistantError):
|
||||||
|
importer._extract_blueprint_from_community_topic(
|
||||||
|
"http://example.com",
|
||||||
|
{
|
||||||
|
"post_stream": {
|
||||||
|
"posts": [
|
||||||
|
{"cooked": '<code class="lang-yaml">invalid: yaml: 2</code>'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test__extract_blueprint_from_community_topic_wrong_lang():
|
||||||
|
"""Test extracting blueprint with invalid YAML."""
|
||||||
|
assert (
|
||||||
|
importer._extract_blueprint_from_community_topic(
|
||||||
|
"http://example.com",
|
||||||
|
{
|
||||||
|
"post_stream": {
|
||||||
|
"posts": [
|
||||||
|
{"cooked": '<code class="lang-php">invalid yaml + 2</code>'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_blueprint_from_community_url(hass, aioclient_mock, community_post):
|
||||||
|
"""Test fetching blueprint from url."""
|
||||||
|
aioclient_mock.get(
|
||||||
|
"https://community.home-assistant.io/t/test-topic/123.json", text=community_post
|
||||||
|
)
|
||||||
|
imported_blueprint = await importer.fetch_blueprint_from_url(
|
||||||
|
hass, "https://community.home-assistant.io/t/test-topic/123/2"
|
||||||
|
)
|
||||||
|
assert isinstance(imported_blueprint, importer.ImportedBlueprint)
|
||||||
|
assert imported_blueprint.blueprint.domain == "automation"
|
||||||
|
assert imported_blueprint.blueprint.placeholders == {
|
||||||
|
"service_to_call",
|
||||||
|
"trigger_event",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url",
|
||||||
|
(
|
||||||
|
"https://raw.githubusercontent.com/balloob/home-assistant-config/main/blueprints/automation/motion_light.yaml",
|
||||||
|
"https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def test_fetch_blueprint_from_github_url(hass, aioclient_mock, url):
|
||||||
|
"""Test fetching blueprint from url."""
|
||||||
|
aioclient_mock.get(
|
||||||
|
"https://raw.githubusercontent.com/balloob/home-assistant-config/main/blueprints/automation/motion_light.yaml",
|
||||||
|
text=Path(
|
||||||
|
hass.config.path("blueprints/automation/test_event_service.yaml")
|
||||||
|
).read_text(),
|
||||||
|
)
|
||||||
|
|
||||||
|
imported_blueprint = await importer.fetch_blueprint_from_url(hass, url)
|
||||||
|
assert isinstance(imported_blueprint, importer.ImportedBlueprint)
|
||||||
|
assert imported_blueprint.blueprint.domain == "automation"
|
||||||
|
assert imported_blueprint.blueprint.placeholders == {
|
||||||
|
"service_to_call",
|
||||||
|
"trigger_event",
|
||||||
|
}
|
1
tests/components/blueprint/test_init.py
Normal file
1
tests/components/blueprint/test_init.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for the blueprint init."""
|
154
tests/components/blueprint/test_models.py
Normal file
154
tests/components/blueprint/test_models.py
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
"""Test blueprint models."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.blueprint import errors, models
|
||||||
|
from homeassistant.util.yaml import Placeholder
|
||||||
|
|
||||||
|
from tests.async_mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def blueprint_1():
|
||||||
|
"""Blueprint fixture."""
|
||||||
|
return models.Blueprint(
|
||||||
|
{
|
||||||
|
"blueprint": {
|
||||||
|
"name": "Hello",
|
||||||
|
"domain": "automation",
|
||||||
|
"input": {"test-placeholder": None},
|
||||||
|
},
|
||||||
|
"example": Placeholder("test-placeholder"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def domain_bps(hass):
|
||||||
|
"""Domain blueprints fixture."""
|
||||||
|
return models.DomainBlueprints(hass, "automation", logging.getLogger(__name__))
|
||||||
|
|
||||||
|
|
||||||
|
def test_blueprint_model_init():
|
||||||
|
"""Test constructor validation."""
|
||||||
|
with pytest.raises(errors.InvalidBlueprint):
|
||||||
|
models.Blueprint({})
|
||||||
|
|
||||||
|
with pytest.raises(errors.InvalidBlueprint):
|
||||||
|
models.Blueprint(
|
||||||
|
{"blueprint": {"name": "Hello", "domain": "automation"}},
|
||||||
|
expected_domain="not-automation",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(errors.InvalidBlueprint):
|
||||||
|
models.Blueprint(
|
||||||
|
{
|
||||||
|
"blueprint": {
|
||||||
|
"name": "Hello",
|
||||||
|
"domain": "automation",
|
||||||
|
"input": {"something": None},
|
||||||
|
},
|
||||||
|
"trigger": {"platform": Placeholder("non-existing")},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_blueprint_properties(blueprint_1):
|
||||||
|
"""Test properties."""
|
||||||
|
assert blueprint_1.metadata == {
|
||||||
|
"name": "Hello",
|
||||||
|
"domain": "automation",
|
||||||
|
"input": {"test-placeholder": None},
|
||||||
|
}
|
||||||
|
assert blueprint_1.domain == "automation"
|
||||||
|
assert blueprint_1.name == "Hello"
|
||||||
|
assert blueprint_1.placeholders == {"test-placeholder"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_blueprint_update_metadata():
|
||||||
|
"""Test properties."""
|
||||||
|
bp = models.Blueprint(
|
||||||
|
{
|
||||||
|
"blueprint": {
|
||||||
|
"name": "Hello",
|
||||||
|
"domain": "automation",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
bp.update_metadata(source_url="http://bla.com")
|
||||||
|
assert bp.metadata["source_url"] == "http://bla.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_blueprint_inputs(blueprint_1):
|
||||||
|
"""Test blueprint inputs."""
|
||||||
|
inputs = models.BlueprintInputs(
|
||||||
|
blueprint_1,
|
||||||
|
{"use_blueprint": {"path": "bla", "input": {"test-placeholder": 1}}},
|
||||||
|
)
|
||||||
|
inputs.validate()
|
||||||
|
assert inputs.inputs == {"test-placeholder": 1}
|
||||||
|
assert inputs.async_substitute() == {"example": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_blueprint_inputs_validation(blueprint_1):
|
||||||
|
"""Test blueprint input validation."""
|
||||||
|
inputs = models.BlueprintInputs(
|
||||||
|
blueprint_1,
|
||||||
|
{"use_blueprint": {"path": "bla", "input": {"non-existing-placeholder": 1}}},
|
||||||
|
)
|
||||||
|
with pytest.raises(errors.MissingPlaceholder):
|
||||||
|
inputs.validate()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_domain_blueprints_get_blueprint_errors(hass, domain_bps):
|
||||||
|
"""Test domain blueprints."""
|
||||||
|
assert hass.data["blueprint"]["automation"] is domain_bps
|
||||||
|
|
||||||
|
with pytest.raises(errors.FailedToLoad), patch(
|
||||||
|
"homeassistant.util.yaml.load_yaml", side_effect=FileNotFoundError
|
||||||
|
):
|
||||||
|
await domain_bps.async_get_blueprint("non-existing-path")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.util.yaml.load_yaml", return_value={"blueprint": "invalid"}
|
||||||
|
):
|
||||||
|
assert await domain_bps.async_get_blueprint("non-existing-path") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_domain_blueprints_caching(domain_bps):
|
||||||
|
"""Test domain blueprints cache blueprints."""
|
||||||
|
obj = object()
|
||||||
|
with patch.object(domain_bps, "_load_blueprint", return_value=obj):
|
||||||
|
assert await domain_bps.async_get_blueprint("something") is obj
|
||||||
|
|
||||||
|
# Now we hit cache
|
||||||
|
assert await domain_bps.async_get_blueprint("something") is obj
|
||||||
|
|
||||||
|
obj_2 = object()
|
||||||
|
domain_bps.async_reset_cache()
|
||||||
|
|
||||||
|
# Now we call this method again.
|
||||||
|
with patch.object(domain_bps, "_load_blueprint", return_value=obj_2):
|
||||||
|
assert await domain_bps.async_get_blueprint("something") is obj_2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_domain_blueprints_inputs_from_config(domain_bps, blueprint_1):
|
||||||
|
"""Test DomainBlueprints.async_inputs_from_config."""
|
||||||
|
with pytest.raises(errors.InvalidBlueprintInputs):
|
||||||
|
await domain_bps.async_inputs_from_config({"not-referencing": "use_blueprint"})
|
||||||
|
|
||||||
|
with pytest.raises(errors.MissingPlaceholder), patch.object(
|
||||||
|
domain_bps, "async_get_blueprint", return_value=blueprint_1
|
||||||
|
):
|
||||||
|
await domain_bps.async_inputs_from_config(
|
||||||
|
{"use_blueprint": {"path": "bla.yaml", "input": {}}}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(domain_bps, "async_get_blueprint", return_value=blueprint_1):
|
||||||
|
inputs = await domain_bps.async_inputs_from_config(
|
||||||
|
{"use_blueprint": {"path": "bla.yaml", "input": {"test-placeholder": None}}}
|
||||||
|
)
|
||||||
|
assert inputs.blueprint is blueprint_1
|
||||||
|
assert inputs.inputs == {"test-placeholder": None}
|
71
tests/components/blueprint/test_schemas.py
Normal file
71
tests/components/blueprint/test_schemas.py
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
"""Test schemas."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components.blueprint import schemas
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"blueprint",
|
||||||
|
(
|
||||||
|
# Test allow extra
|
||||||
|
{
|
||||||
|
"trigger": "Test allow extra",
|
||||||
|
"blueprint": {"name": "Test Name", "domain": "automation"},
|
||||||
|
},
|
||||||
|
# Bare minimum
|
||||||
|
{"blueprint": {"name": "Test Name", "domain": "automation"}},
|
||||||
|
# Empty triggers
|
||||||
|
{"blueprint": {"name": "Test Name", "domain": "automation", "input": {}}},
|
||||||
|
# No definition of input
|
||||||
|
{
|
||||||
|
"blueprint": {
|
||||||
|
"name": "Test Name",
|
||||||
|
"domain": "automation",
|
||||||
|
"input": {
|
||||||
|
"some_placeholder": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_blueprint_schema(blueprint):
|
||||||
|
"""Test different schemas."""
|
||||||
|
try:
|
||||||
|
schemas.BLUEPRINT_SCHEMA(blueprint)
|
||||||
|
except vol.Invalid:
|
||||||
|
_LOGGER.exception("%s", blueprint)
|
||||||
|
assert False, "Expected schema to be valid"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"blueprint",
|
||||||
|
(
|
||||||
|
# no domain
|
||||||
|
{"blueprint": {}},
|
||||||
|
# non existing key in blueprint
|
||||||
|
{
|
||||||
|
"blueprint": {
|
||||||
|
"name": "Example name",
|
||||||
|
"domain": "automation",
|
||||||
|
"non_existing": None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
# non existing key in input
|
||||||
|
{
|
||||||
|
"blueprint": {
|
||||||
|
"name": "Example name",
|
||||||
|
"domain": "automation",
|
||||||
|
"input": {"some_placeholder": {"non_existing": "bla"}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_blueprint_schema_invalid(blueprint):
|
||||||
|
"""Test different schemas."""
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
schemas.BLUEPRINT_SCHEMA(blueprint)
|
82
tests/components/blueprint/test_websocket_api.py
Normal file
82
tests/components/blueprint/test_websocket_api.py
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
"""Test websocket API."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components import automation
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def setup_bp(hass):
|
||||||
|
"""Fixture to set up the blueprint component."""
|
||||||
|
assert await async_setup_component(hass, "blueprint", {})
|
||||||
|
|
||||||
|
# Trigger registration of automation blueprints
|
||||||
|
automation.async_get_blueprints(hass)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_blueprints(hass, hass_ws_client):
|
||||||
|
"""Test listing blueprints."""
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
await client.send_json({"id": 5, "type": "blueprint/list"})
|
||||||
|
|
||||||
|
msg = await client.receive_json()
|
||||||
|
|
||||||
|
assert msg["id"] == 5
|
||||||
|
assert msg["success"]
|
||||||
|
blueprints = msg["result"]
|
||||||
|
assert blueprints.get("automation") == {
|
||||||
|
"test_event_service.yaml": {
|
||||||
|
"metadata": {
|
||||||
|
"domain": "automation",
|
||||||
|
"input": {"service_to_call": None, "trigger_event": None},
|
||||||
|
"name": "Call service based on event",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"in_folder/in_folder_blueprint.yaml": {
|
||||||
|
"metadata": {
|
||||||
|
"domain": "automation",
|
||||||
|
"input": {"action": None, "trigger": None},
|
||||||
|
"name": "In Folder Blueprint",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_blueprint(hass, aioclient_mock, hass_ws_client):
|
||||||
|
"""Test listing blueprints."""
|
||||||
|
raw_data = Path(
|
||||||
|
hass.config.path("blueprints/automation/test_event_service.yaml")
|
||||||
|
).read_text()
|
||||||
|
|
||||||
|
aioclient_mock.get(
|
||||||
|
"https://raw.githubusercontent.com/balloob/home-assistant-config/main/blueprints/automation/motion_light.yaml",
|
||||||
|
text=raw_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
await client.send_json(
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "blueprint/import",
|
||||||
|
"url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = await client.receive_json()
|
||||||
|
|
||||||
|
assert msg["id"] == 5
|
||||||
|
assert msg["success"]
|
||||||
|
assert msg["result"] == {
|
||||||
|
"suggested_filename": "balloob-motion_light",
|
||||||
|
"url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml",
|
||||||
|
"raw_data": raw_data,
|
||||||
|
"blueprint": {
|
||||||
|
"metadata": {
|
||||||
|
"domain": "automation",
|
||||||
|
"input": {"service_to_call": None, "trigger_event": None},
|
||||||
|
"name": "Call service based on event",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
783
tests/fixtures/blueprint/community_post.json
vendored
Normal file
783
tests/fixtures/blueprint/community_post.json
vendored
Normal file
|
@ -0,0 +1,783 @@
|
||||||
|
{
|
||||||
|
"post_stream": {
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"id": 1144853,
|
||||||
|
"name": "Paulus Schoutsen",
|
||||||
|
"username": "balloob",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/balloob/{size}/21956_2.png",
|
||||||
|
"created_at": "2020-10-16T12:20:12.688Z",
|
||||||
|
"cooked": "\u003cp\u003ehere a test topic.\u003cbr\u003e\nhere a test topic.\u003cbr\u003e\nhere a test topic.\u003cbr\u003e\nhere a test topic.\u003c/p\u003e\n\u003ch1\u003eBlock without syntax\u003c/h1\u003e\n\u003cpre\u003e\u003ccode class=\"lang-auto\"\u003eblueprint:\n domain: automation\n name: Example Blueprint from post\n input:\n trigger_event:\n service_to_call:\ntrigger:\n platform: event\n event_type: !placeholder trigger_event\naction:\n service: !placeholder service_to_call\n\u003c/code\u003e\u003c/pre\u003e",
|
||||||
|
"post_number": 1,
|
||||||
|
"post_type": 1,
|
||||||
|
"updated_at": "2020-10-20T08:24:14.189Z",
|
||||||
|
"reply_count": 0,
|
||||||
|
"reply_to_post_number": null,
|
||||||
|
"quote_count": 0,
|
||||||
|
"incoming_link_count": 0,
|
||||||
|
"reads": 2,
|
||||||
|
"readers_count": 1,
|
||||||
|
"score": 0.4,
|
||||||
|
"yours": true,
|
||||||
|
"topic_id": 236133,
|
||||||
|
"topic_slug": "test-topic",
|
||||||
|
"display_username": "Paulus Schoutsen",
|
||||||
|
"primary_group_name": null,
|
||||||
|
"primary_group_flair_url": null,
|
||||||
|
"primary_group_flair_bg_color": null,
|
||||||
|
"primary_group_flair_color": null,
|
||||||
|
"version": 2,
|
||||||
|
"can_edit": true,
|
||||||
|
"can_delete": false,
|
||||||
|
"can_recover": false,
|
||||||
|
"can_wiki": true,
|
||||||
|
"read": true,
|
||||||
|
"user_title": "Founder of Home Assistant",
|
||||||
|
"title_is_group": false,
|
||||||
|
"actions_summary": [
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"can_act": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"can_act": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"can_act": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"can_act": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"moderator": true,
|
||||||
|
"admin": true,
|
||||||
|
"staff": true,
|
||||||
|
"user_id": 3,
|
||||||
|
"hidden": false,
|
||||||
|
"trust_level": 2,
|
||||||
|
"deleted_at": null,
|
||||||
|
"user_deleted": false,
|
||||||
|
"edit_reason": null,
|
||||||
|
"can_view_edit_history": true,
|
||||||
|
"wiki": false,
|
||||||
|
"reviewable_id": 0,
|
||||||
|
"reviewable_score_count": 0,
|
||||||
|
"reviewable_score_pending_count": 0,
|
||||||
|
"user_created_at": "2016-03-30T07:50:25.541Z",
|
||||||
|
"user_date_of_birth": null,
|
||||||
|
"user_signature": null,
|
||||||
|
"can_accept_answer": false,
|
||||||
|
"can_unaccept_answer": false,
|
||||||
|
"accepted_answer": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1144854,
|
||||||
|
"name": "Paulus Schoutsen",
|
||||||
|
"username": "balloob",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/balloob/{size}/21956_2.png",
|
||||||
|
"created_at": "2020-10-16T12:20:17.535Z",
|
||||||
|
"cooked": "",
|
||||||
|
"post_number": 2,
|
||||||
|
"post_type": 3,
|
||||||
|
"updated_at": "2020-10-16T12:20:17.535Z",
|
||||||
|
"reply_count": 0,
|
||||||
|
"reply_to_post_number": null,
|
||||||
|
"quote_count": 0,
|
||||||
|
"incoming_link_count": 1,
|
||||||
|
"reads": 2,
|
||||||
|
"readers_count": 1,
|
||||||
|
"score": 5.4,
|
||||||
|
"yours": true,
|
||||||
|
"topic_id": 236133,
|
||||||
|
"topic_slug": "test-topic",
|
||||||
|
"display_username": "Paulus Schoutsen",
|
||||||
|
"primary_group_name": null,
|
||||||
|
"primary_group_flair_url": null,
|
||||||
|
"primary_group_flair_bg_color": null,
|
||||||
|
"primary_group_flair_color": null,
|
||||||
|
"version": 1,
|
||||||
|
"can_edit": true,
|
||||||
|
"can_delete": true,
|
||||||
|
"can_recover": false,
|
||||||
|
"can_wiki": true,
|
||||||
|
"read": true,
|
||||||
|
"user_title": "Founder of Home Assistant",
|
||||||
|
"title_is_group": false,
|
||||||
|
"actions_summary": [
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"can_act": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"can_act": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"can_act": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"can_act": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"moderator": true,
|
||||||
|
"admin": true,
|
||||||
|
"staff": true,
|
||||||
|
"user_id": 3,
|
||||||
|
"hidden": false,
|
||||||
|
"trust_level": 2,
|
||||||
|
"deleted_at": null,
|
||||||
|
"user_deleted": false,
|
||||||
|
"edit_reason": null,
|
||||||
|
"can_view_edit_history": true,
|
||||||
|
"wiki": false,
|
||||||
|
"action_code": "visible.disabled",
|
||||||
|
"reviewable_id": 0,
|
||||||
|
"reviewable_score_count": 0,
|
||||||
|
"reviewable_score_pending_count": 0,
|
||||||
|
"user_created_at": "2016-03-30T07:50:25.541Z",
|
||||||
|
"user_date_of_birth": null,
|
||||||
|
"user_signature": null,
|
||||||
|
"can_accept_answer": false,
|
||||||
|
"can_unaccept_answer": false,
|
||||||
|
"accepted_answer": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1144872,
|
||||||
|
"name": "Paulus Schoutsen",
|
||||||
|
"username": "balloob",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/balloob/{size}/21956_2.png",
|
||||||
|
"created_at": "2020-10-16T12:27:53.926Z",
|
||||||
|
"cooked": "\u003cp\u003eTest reply!\u003c/p\u003e",
|
||||||
|
"post_number": 3,
|
||||||
|
"post_type": 1,
|
||||||
|
"updated_at": "2020-10-16T12:27:53.926Z",
|
||||||
|
"reply_count": 0,
|
||||||
|
"reply_to_post_number": null,
|
||||||
|
"quote_count": 0,
|
||||||
|
"incoming_link_count": 0,
|
||||||
|
"reads": 2,
|
||||||
|
"readers_count": 1,
|
||||||
|
"score": 0.4,
|
||||||
|
"yours": true,
|
||||||
|
"topic_id": 236133,
|
||||||
|
"topic_slug": "test-topic",
|
||||||
|
"display_username": "Paulus Schoutsen",
|
||||||
|
"primary_group_name": null,
|
||||||
|
"primary_group_flair_url": null,
|
||||||
|
"primary_group_flair_bg_color": null,
|
||||||
|
"primary_group_flair_color": null,
|
||||||
|
"version": 1,
|
||||||
|
"can_edit": true,
|
||||||
|
"can_delete": true,
|
||||||
|
"can_recover": false,
|
||||||
|
"can_wiki": true,
|
||||||
|
"read": true,
|
||||||
|
"user_title": "Founder of Home Assistant",
|
||||||
|
"title_is_group": false,
|
||||||
|
"actions_summary": [
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"can_act": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"can_act": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"can_act": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"can_act": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"moderator": true,
|
||||||
|
"admin": true,
|
||||||
|
"staff": true,
|
||||||
|
"user_id": 3,
|
||||||
|
"hidden": false,
|
||||||
|
"trust_level": 2,
|
||||||
|
"deleted_at": null,
|
||||||
|
"user_deleted": false,
|
||||||
|
"edit_reason": null,
|
||||||
|
"can_view_edit_history": true,
|
||||||
|
"wiki": false,
|
||||||
|
"reviewable_id": 0,
|
||||||
|
"reviewable_score_count": 0,
|
||||||
|
"reviewable_score_pending_count": 0,
|
||||||
|
"user_created_at": "2016-03-30T07:50:25.541Z",
|
||||||
|
"user_date_of_birth": null,
|
||||||
|
"user_signature": null,
|
||||||
|
"can_accept_answer": false,
|
||||||
|
"can_unaccept_answer": false,
|
||||||
|
"accepted_answer": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stream": [
|
||||||
|
1144853,
|
||||||
|
1144854,
|
||||||
|
1144872
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timeline_lookup": [
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
3
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"suggested_topics": [
|
||||||
|
{
|
||||||
|
"id": 17750,
|
||||||
|
"title": "Tutorial: Creating your first add-on",
|
||||||
|
"fancy_title": "Tutorial: Creating your first add-on",
|
||||||
|
"slug": "tutorial-creating-your-first-add-on",
|
||||||
|
"posts_count": 26,
|
||||||
|
"reply_count": 14,
|
||||||
|
"highest_post_number": 27,
|
||||||
|
"image_url": null,
|
||||||
|
"created_at": "2017-05-14T07:51:33.946Z",
|
||||||
|
"last_posted_at": "2020-07-28T11:29:27.892Z",
|
||||||
|
"bumped": true,
|
||||||
|
"bumped_at": "2020-07-28T11:29:27.892Z",
|
||||||
|
"archetype": "regular",
|
||||||
|
"unseen": false,
|
||||||
|
"last_read_post_number": 18,
|
||||||
|
"unread": 7,
|
||||||
|
"new_posts": 2,
|
||||||
|
"pinned": false,
|
||||||
|
"unpinned": null,
|
||||||
|
"visible": true,
|
||||||
|
"closed": false,
|
||||||
|
"archived": false,
|
||||||
|
"notification_level": 2,
|
||||||
|
"bookmarked": false,
|
||||||
|
"liked": false,
|
||||||
|
"thumbnails": null,
|
||||||
|
"tags": [],
|
||||||
|
"like_count": 9,
|
||||||
|
"views": 4355,
|
||||||
|
"category_id": 25,
|
||||||
|
"featured_link": null,
|
||||||
|
"has_accepted_answer": false,
|
||||||
|
"posters": [
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Original Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 3,
|
||||||
|
"username": "balloob",
|
||||||
|
"name": "Paulus Schoutsen",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/balloob/{size}/21956_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 9852,
|
||||||
|
"username": "JSCSJSCS",
|
||||||
|
"name": "",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/jscsjscs/{size}/38256_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 11494,
|
||||||
|
"username": "so3n",
|
||||||
|
"name": "",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/so3n/{size}/46007_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 9094,
|
||||||
|
"username": "IoTnerd",
|
||||||
|
"name": "Balázs Suhajda",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/iotnerd/{size}/33526_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": "latest",
|
||||||
|
"description": "Most Recent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 73134,
|
||||||
|
"username": "diord",
|
||||||
|
"name": "",
|
||||||
|
"avatar_template": "/letter_avatar/diord/{size}/5_70a404e2c8e633b245e797a566d32dc7.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 65981,
|
||||||
|
"title": "Lovelace: Button card",
|
||||||
|
"fancy_title": "Lovelace: Button card",
|
||||||
|
"slug": "lovelace-button-card",
|
||||||
|
"posts_count": 4608,
|
||||||
|
"reply_count": 3522,
|
||||||
|
"highest_post_number": 4691,
|
||||||
|
"image_url": null,
|
||||||
|
"created_at": "2018-08-28T00:18:19.312Z",
|
||||||
|
"last_posted_at": "2020-10-20T07:33:29.523Z",
|
||||||
|
"bumped": true,
|
||||||
|
"bumped_at": "2020-10-20T07:33:29.523Z",
|
||||||
|
"archetype": "regular",
|
||||||
|
"unseen": false,
|
||||||
|
"last_read_post_number": 1938,
|
||||||
|
"unread": 369,
|
||||||
|
"new_posts": 2384,
|
||||||
|
"pinned": false,
|
||||||
|
"unpinned": null,
|
||||||
|
"visible": true,
|
||||||
|
"closed": false,
|
||||||
|
"archived": false,
|
||||||
|
"notification_level": 2,
|
||||||
|
"bookmarked": false,
|
||||||
|
"liked": false,
|
||||||
|
"thumbnails": null,
|
||||||
|
"tags": [],
|
||||||
|
"like_count": 1700,
|
||||||
|
"views": 184752,
|
||||||
|
"category_id": 34,
|
||||||
|
"featured_link": null,
|
||||||
|
"has_accepted_answer": false,
|
||||||
|
"posters": [
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Original Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 25984,
|
||||||
|
"username": "kuuji",
|
||||||
|
"name": "Alexandre",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/kuuji/{size}/41093_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 2019,
|
||||||
|
"username": "iantrich",
|
||||||
|
"name": "Ian",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/iantrich/{size}/154042_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 33228,
|
||||||
|
"username": "jimz011",
|
||||||
|
"name": "Jim",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/jimz011/{size}/62413_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 4931,
|
||||||
|
"username": "petro",
|
||||||
|
"name": "Petro",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/petro/{size}/47791_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": "latest",
|
||||||
|
"description": "Most Recent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 26227,
|
||||||
|
"username": "RomRider",
|
||||||
|
"name": "",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/romrider/{size}/41384_2.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10564,
|
||||||
|
"title": "Professional/Commercial Use?",
|
||||||
|
"fancy_title": "Professional/Commercial Use?",
|
||||||
|
"slug": "professional-commercial-use",
|
||||||
|
"posts_count": 54,
|
||||||
|
"reply_count": 37,
|
||||||
|
"highest_post_number": 54,
|
||||||
|
"image_url": null,
|
||||||
|
"created_at": "2017-01-27T05:01:57.453Z",
|
||||||
|
"last_posted_at": "2020-10-20T07:03:57.895Z",
|
||||||
|
"bumped": true,
|
||||||
|
"bumped_at": "2020-10-20T07:03:57.895Z",
|
||||||
|
"archetype": "regular",
|
||||||
|
"unseen": false,
|
||||||
|
"last_read_post_number": 7,
|
||||||
|
"unread": 0,
|
||||||
|
"new_posts": 47,
|
||||||
|
"pinned": false,
|
||||||
|
"unpinned": null,
|
||||||
|
"visible": true,
|
||||||
|
"closed": false,
|
||||||
|
"archived": false,
|
||||||
|
"notification_level": 2,
|
||||||
|
"bookmarked": false,
|
||||||
|
"liked": false,
|
||||||
|
"thumbnails": null,
|
||||||
|
"tags": [],
|
||||||
|
"like_count": 21,
|
||||||
|
"views": 10695,
|
||||||
|
"category_id": 17,
|
||||||
|
"featured_link": null,
|
||||||
|
"has_accepted_answer": false,
|
||||||
|
"posters": [
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Original Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 4758,
|
||||||
|
"username": "oobie11",
|
||||||
|
"name": "Bryan",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/oobie11/{size}/37858_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 18386,
|
||||||
|
"username": "pitp2",
|
||||||
|
"name": "",
|
||||||
|
"avatar_template": "/letter_avatar/pitp2/{size}/5_70a404e2c8e633b245e797a566d32dc7.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 23116,
|
||||||
|
"username": "jortegamx",
|
||||||
|
"name": "Jake",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/jortegamx/{size}/45515_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 39038,
|
||||||
|
"username": "orif73",
|
||||||
|
"name": "orif73",
|
||||||
|
"avatar_template": "/letter_avatar/orif73/{size}/5_70a404e2c8e633b245e797a566d32dc7.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": "latest",
|
||||||
|
"description": "Most Recent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 41040,
|
||||||
|
"username": "devastator",
|
||||||
|
"name": "",
|
||||||
|
"avatar_template": "/letter_avatar/devastator/{size}/5_70a404e2c8e633b245e797a566d32dc7.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 219480,
|
||||||
|
"title": "What the heck is with the 'latest state change' not being kept after restart?",
|
||||||
|
"fancy_title": "What the heck is with the \u0026lsquo;latest state change\u0026rsquo; not being kept after restart?",
|
||||||
|
"slug": "what-the-heck-is-with-the-latest-state-change-not-being-kept-after-restart",
|
||||||
|
"posts_count": 37,
|
||||||
|
"reply_count": 13,
|
||||||
|
"highest_post_number": 38,
|
||||||
|
"image_url": "https://community-assets.home-assistant.io/original/3X/3/4/349d096b209d40d5f424b64e970bcf360332cc7f.png",
|
||||||
|
"created_at": "2020-08-18T13:10:09.367Z",
|
||||||
|
"last_posted_at": "2020-10-20T00:32:07.312Z",
|
||||||
|
"bumped": true,
|
||||||
|
"bumped_at": "2020-10-20T00:32:07.312Z",
|
||||||
|
"archetype": "regular",
|
||||||
|
"unseen": false,
|
||||||
|
"last_read_post_number": 8,
|
||||||
|
"unread": 0,
|
||||||
|
"new_posts": 30,
|
||||||
|
"pinned": false,
|
||||||
|
"unpinned": null,
|
||||||
|
"visible": true,
|
||||||
|
"closed": false,
|
||||||
|
"archived": false,
|
||||||
|
"notification_level": 2,
|
||||||
|
"bookmarked": false,
|
||||||
|
"liked": false,
|
||||||
|
"thumbnails": [
|
||||||
|
{
|
||||||
|
"max_width": null,
|
||||||
|
"max_height": null,
|
||||||
|
"width": 469,
|
||||||
|
"height": 59,
|
||||||
|
"url": "https://community-assets.home-assistant.io/original/3X/3/4/349d096b209d40d5f424b64e970bcf360332cc7f.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [],
|
||||||
|
"like_count": 26,
|
||||||
|
"views": 1722,
|
||||||
|
"category_id": 52,
|
||||||
|
"featured_link": null,
|
||||||
|
"has_accepted_answer": false,
|
||||||
|
"posters": [
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Original Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 3124,
|
||||||
|
"username": "andriej",
|
||||||
|
"name": "",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/andriej/{size}/24457_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 15052,
|
||||||
|
"username": "Misiu",
|
||||||
|
"name": "",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/misiu/{size}/20752_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 4629,
|
||||||
|
"username": "lolouk44",
|
||||||
|
"name": "lolouk44",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/lolouk44/{size}/119845_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 51736,
|
||||||
|
"username": "hmoffatt",
|
||||||
|
"name": "Hamish Moffatt",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/hmoffatt/{size}/88700_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": "latest",
|
||||||
|
"description": "Most Recent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 78711,
|
||||||
|
"username": "Astrosteve",
|
||||||
|
"name": "Steve",
|
||||||
|
"avatar_template": "/letter_avatar/astrosteve/{size}/5_70a404e2c8e633b245e797a566d32dc7.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 162594,
|
||||||
|
"title": "A different take on designing a Lovelace UI",
|
||||||
|
"fancy_title": "A different take on designing a Lovelace UI",
|
||||||
|
"slug": "a-different-take-on-designing-a-lovelace-ui",
|
||||||
|
"posts_count": 641,
|
||||||
|
"reply_count": 425,
|
||||||
|
"highest_post_number": 654,
|
||||||
|
"image_url": null,
|
||||||
|
"created_at": "2020-01-11T23:09:25.207Z",
|
||||||
|
"last_posted_at": "2020-10-19T23:32:15.555Z",
|
||||||
|
"bumped": true,
|
||||||
|
"bumped_at": "2020-10-19T23:32:15.555Z",
|
||||||
|
"archetype": "regular",
|
||||||
|
"unseen": false,
|
||||||
|
"last_read_post_number": 7,
|
||||||
|
"unread": 32,
|
||||||
|
"new_posts": 615,
|
||||||
|
"pinned": false,
|
||||||
|
"unpinned": null,
|
||||||
|
"visible": true,
|
||||||
|
"closed": false,
|
||||||
|
"archived": false,
|
||||||
|
"notification_level": 2,
|
||||||
|
"bookmarked": false,
|
||||||
|
"liked": false,
|
||||||
|
"thumbnails": null,
|
||||||
|
"tags": [],
|
||||||
|
"like_count": 453,
|
||||||
|
"views": 68547,
|
||||||
|
"category_id": 9,
|
||||||
|
"featured_link": null,
|
||||||
|
"has_accepted_answer": false,
|
||||||
|
"posters": [
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Original Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 11256,
|
||||||
|
"username": "Mattias_Persson",
|
||||||
|
"name": "Mattias Persson",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/mattias_persson/{size}/14773_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 27634,
|
||||||
|
"username": "Jason_hill",
|
||||||
|
"name": "Jason Hill",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/jason_hill/{size}/93218_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 46782,
|
||||||
|
"username": "Martin_Pejstrup",
|
||||||
|
"name": "mpejstrup",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/martin_pejstrup/{size}/78412_2.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": null,
|
||||||
|
"description": "Frequent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 46841,
|
||||||
|
"username": "spudje",
|
||||||
|
"name": "",
|
||||||
|
"avatar_template": "/letter_avatar/spudje/{size}/5_70a404e2c8e633b245e797a566d32dc7.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"extras": "latest",
|
||||||
|
"description": "Most Recent Poster",
|
||||||
|
"user": {
|
||||||
|
"id": 20924,
|
||||||
|
"username": "Diego_Santos",
|
||||||
|
"name": "Diego Santos",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/diego_santos/{size}/29096_2.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [],
|
||||||
|
"id": 236133,
|
||||||
|
"title": "Test Topic",
|
||||||
|
"fancy_title": "Test Topic",
|
||||||
|
"posts_count": 3,
|
||||||
|
"created_at": "2020-10-16T12:20:12.580Z",
|
||||||
|
"views": 13,
|
||||||
|
"reply_count": 0,
|
||||||
|
"like_count": 0,
|
||||||
|
"last_posted_at": "2020-10-16T12:27:53.926Z",
|
||||||
|
"visible": false,
|
||||||
|
"closed": false,
|
||||||
|
"archived": false,
|
||||||
|
"has_summary": false,
|
||||||
|
"archetype": "regular",
|
||||||
|
"slug": "test-topic",
|
||||||
|
"category_id": 1,
|
||||||
|
"word_count": 37,
|
||||||
|
"deleted_at": null,
|
||||||
|
"user_id": 3,
|
||||||
|
"featured_link": null,
|
||||||
|
"pinned_globally": false,
|
||||||
|
"pinned_at": null,
|
||||||
|
"pinned_until": null,
|
||||||
|
"image_url": null,
|
||||||
|
"draft": null,
|
||||||
|
"draft_key": "topic_236133",
|
||||||
|
"draft_sequence": 8,
|
||||||
|
"posted": true,
|
||||||
|
"unpinned": null,
|
||||||
|
"pinned": false,
|
||||||
|
"current_post_number": 1,
|
||||||
|
"highest_post_number": 3,
|
||||||
|
"last_read_post_number": 3,
|
||||||
|
"last_read_post_id": 1144872,
|
||||||
|
"deleted_by": null,
|
||||||
|
"has_deleted": false,
|
||||||
|
"actions_summary": [
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"count": 0,
|
||||||
|
"hidden": false,
|
||||||
|
"can_act": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"count": 0,
|
||||||
|
"hidden": false,
|
||||||
|
"can_act": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"count": 0,
|
||||||
|
"hidden": false,
|
||||||
|
"can_act": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"chunk_size": 20,
|
||||||
|
"bookmarked": false,
|
||||||
|
"topic_timer": null,
|
||||||
|
"private_topic_timer": null,
|
||||||
|
"message_bus_last_id": 5,
|
||||||
|
"participant_count": 1,
|
||||||
|
"show_read_indicator": false,
|
||||||
|
"thumbnails": null,
|
||||||
|
"can_vote": false,
|
||||||
|
"vote_count": null,
|
||||||
|
"user_voted": false,
|
||||||
|
"details": {
|
||||||
|
"notification_level": 3,
|
||||||
|
"notifications_reason_id": 1,
|
||||||
|
"can_move_posts": true,
|
||||||
|
"can_edit": true,
|
||||||
|
"can_delete": true,
|
||||||
|
"can_remove_allowed_users": true,
|
||||||
|
"can_invite_to": true,
|
||||||
|
"can_invite_via_email": true,
|
||||||
|
"can_create_post": true,
|
||||||
|
"can_reply_as_new_topic": true,
|
||||||
|
"can_flag_topic": true,
|
||||||
|
"can_convert_topic": true,
|
||||||
|
"can_review_topic": true,
|
||||||
|
"can_remove_self_id": 3,
|
||||||
|
"participants": [
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"username": "balloob",
|
||||||
|
"name": "Paulus Schoutsen",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/balloob/{size}/21956_2.png",
|
||||||
|
"post_count": 3,
|
||||||
|
"primary_group_name": null,
|
||||||
|
"primary_group_flair_url": null,
|
||||||
|
"primary_group_flair_color": null,
|
||||||
|
"primary_group_flair_bg_color": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_by": {
|
||||||
|
"id": 3,
|
||||||
|
"username": "balloob",
|
||||||
|
"name": "Paulus Schoutsen",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/balloob/{size}/21956_2.png"
|
||||||
|
},
|
||||||
|
"last_poster": {
|
||||||
|
"id": 3,
|
||||||
|
"username": "balloob",
|
||||||
|
"name": "Paulus Schoutsen",
|
||||||
|
"avatar_template": "/user_avatar/community.home-assistant.io/balloob/{size}/21956_2.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
29
tests/helpers/test_placeholder.py
Normal file
29
tests/helpers/test_placeholder.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
"""Test placeholders."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.helpers import placeholder
|
||||||
|
from homeassistant.util.yaml import Placeholder
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_placeholders():
|
||||||
|
"""Test extracting placeholders from data."""
|
||||||
|
assert placeholder.extract_placeholders(Placeholder("hello")) == {"hello"}
|
||||||
|
assert placeholder.extract_placeholders(
|
||||||
|
{"info": [1, Placeholder("hello"), 2, Placeholder("world")]}
|
||||||
|
) == {"hello", "world"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_substitute():
|
||||||
|
"""Test we can substitute."""
|
||||||
|
assert placeholder.substitute(Placeholder("hello"), {"hello": 5}) == 5
|
||||||
|
|
||||||
|
with pytest.raises(placeholder.UndefinedSubstitution):
|
||||||
|
placeholder.substitute(Placeholder("hello"), {})
|
||||||
|
|
||||||
|
assert (
|
||||||
|
placeholder.substitute(
|
||||||
|
{"info": [1, Placeholder("hello"), 2, Placeholder("world")]},
|
||||||
|
{"hello": 5, "world": 10},
|
||||||
|
)
|
||||||
|
== {"info": [1, 5, 2, 10]}
|
||||||
|
)
|
|
@ -0,0 +1,8 @@
|
||||||
|
blueprint:
|
||||||
|
name: "In Folder Blueprint"
|
||||||
|
domain: automation
|
||||||
|
input:
|
||||||
|
trigger:
|
||||||
|
action:
|
||||||
|
trigger: !placeholder trigger
|
||||||
|
action: !placeholder action
|
|
@ -0,0 +1,11 @@
|
||||||
|
blueprint:
|
||||||
|
name: "Call service based on event"
|
||||||
|
domain: automation
|
||||||
|
input:
|
||||||
|
trigger_event:
|
||||||
|
service_to_call:
|
||||||
|
trigger:
|
||||||
|
platform: event
|
||||||
|
event_type: !placeholder trigger_event
|
||||||
|
action:
|
||||||
|
service: !placeholder service_to_call
|
|
@ -461,3 +461,20 @@ def test_duplicate_key(caplog):
|
||||||
with patch_yaml_files(files):
|
with patch_yaml_files(files):
|
||||||
load_yaml_config_file(YAML_CONFIG_FILE)
|
load_yaml_config_file(YAML_CONFIG_FILE)
|
||||||
assert "contains duplicate key" in caplog.text
|
assert "contains duplicate key" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_placeholder_class():
|
||||||
|
"""Test placeholder class."""
|
||||||
|
placeholder = yaml_loader.Placeholder("hello")
|
||||||
|
placeholder2 = yaml_loader.Placeholder("hello")
|
||||||
|
|
||||||
|
assert placeholder.name == "hello"
|
||||||
|
assert placeholder == placeholder2
|
||||||
|
|
||||||
|
assert len({placeholder, placeholder2}) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_placeholder():
|
||||||
|
"""Test loading placeholders."""
|
||||||
|
data = {"hello": yaml.Placeholder("test_name")}
|
||||||
|
assert yaml.parse_yaml(yaml.dump(data)) == data
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue