Files
core/homeassistant/components/template/helpers.py

384 lines
12 KiB
Python

"""Helpers for template integration."""
from collections.abc import Callable
from enum import Enum
import hashlib
import itertools
import logging
from typing import Any
import voluptuous as vol
from homeassistant.components import blueprint
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ENTITY_PICTURE_TEMPLATE,
CONF_FRIENDLY_NAME,
CONF_ICON,
CONF_ICON_TEMPLATE,
CONF_NAME,
CONF_STATE,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import issue_registry as ir, template
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
async_get_platforms,
)
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import yaml as yaml_util
from homeassistant.util.hass_dict import HassKey
from .const import (
CONF_ADVANCED_OPTIONS,
CONF_ATTRIBUTE_TEMPLATES,
CONF_ATTRIBUTES,
CONF_AVAILABILITY,
CONF_AVAILABILITY_TEMPLATE,
CONF_DEFAULT_ENTITY_ID,
CONF_PICTURE,
DOMAIN,
PLATFORMS,
)
from .entity import AbstractTemplateEntity
from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
LEGACY_TEMPLATE_DEPRECATION_KEY = "deprecate_legacy_templates"
DATA_BLUEPRINTS = "template_blueprints"
DATA_DEPRECATION: HassKey[list[str]] = HassKey(LEGACY_TEMPLATE_DEPRECATION_KEY)
LEGACY_FIELDS = {
CONF_ICON_TEMPLATE: CONF_ICON,
CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE,
CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY,
CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES,
CONF_FRIENDLY_NAME: CONF_NAME,
}
_LOGGER = logging.getLogger(__name__)
type CreateTemplateEntitiesCallback = Callable[
[type[TemplateEntity], AddEntitiesCallback, HomeAssistant, list[dict], str | None],
None,
]
@callback
def templates_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list[str]:
"""Return all template entity ids that reference the blueprint."""
return [
entity_id
for platform in async_get_platforms(hass, DOMAIN)
for entity_id, template_entity in platform.entities.items()
if isinstance(template_entity, AbstractTemplateEntity)
and template_entity.referenced_blueprint == blueprint_path
]
@callback
def blueprint_in_template(hass: HomeAssistant, entity_id: str) -> str | None:
"""Return the blueprint the template entity is based on or None."""
for platform in async_get_platforms(hass, DOMAIN):
if isinstance(
(template_entity := platform.entities.get(entity_id)),
AbstractTemplateEntity,
):
return template_entity.referenced_blueprint
return None
def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool:
"""Return True if any template references the blueprint."""
return len(templates_with_blueprint(hass, blueprint_path)) > 0
async def _reload_blueprint_templates(hass: HomeAssistant, blueprint_path: str) -> None:
"""Reload all templates that rely on a specific blueprint."""
await hass.services.async_call(DOMAIN, SERVICE_RELOAD)
@singleton(DATA_BLUEPRINTS)
@callback
def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints:
"""Get template blueprints."""
from .config import TEMPLATE_BLUEPRINT_SCHEMA # noqa: PLC0415
return blueprint.DomainBlueprints(
hass,
DOMAIN,
_LOGGER,
_blueprint_in_use,
_reload_blueprint_templates,
TEMPLATE_BLUEPRINT_SCHEMA,
)
def rewrite_legacy_to_modern_config(
hass: HomeAssistant,
entity_cfg: dict[str, Any],
extra_legacy_fields: dict[str, str],
) -> dict[str, Any]:
"""Rewrite legacy config."""
entity_cfg = {**entity_cfg}
for from_key, to_key in itertools.chain(
LEGACY_FIELDS.items(), extra_legacy_fields.items()
):
if from_key not in entity_cfg or to_key in entity_cfg:
continue
val = entity_cfg.pop(from_key)
if isinstance(val, str):
val = template.Template(val, hass)
entity_cfg[to_key] = val
if CONF_NAME in entity_cfg and isinstance(entity_cfg[CONF_NAME], str):
entity_cfg[CONF_NAME] = template.Template(entity_cfg[CONF_NAME], hass)
return entity_cfg
def rewrite_legacy_to_modern_configs(
hass: HomeAssistant,
domain: str,
entity_cfg: dict[str, dict],
extra_legacy_fields: dict[str, str],
) -> list[dict]:
"""Rewrite legacy configuration definitions to modern ones."""
entities = []
for object_id, entity_conf in entity_cfg.items():
entity_conf = {**entity_conf, CONF_DEFAULT_ENTITY_ID: f"{domain}.{object_id}"}
entity_conf = rewrite_legacy_to_modern_config(
hass, entity_conf, extra_legacy_fields
)
if CONF_NAME not in entity_conf:
entity_conf[CONF_NAME] = template.Template(object_id, hass)
entities.append(entity_conf)
return entities
@callback
def async_create_template_tracking_entities(
entity_cls: type[Entity],
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant,
definitions: list[dict],
unique_id_prefix: str | None,
) -> None:
"""Create the template tracking entities."""
entities: list[Entity] = []
for definition in definitions:
unique_id = definition.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
entities.append(entity_cls(hass, definition, unique_id)) # type: ignore[call-arg]
async_add_entities(entities)
def _format_template(value: Any) -> Any:
if isinstance(value, template.Template):
return value.template
if isinstance(value, Enum):
return value.name
if isinstance(value, (int, float, str, bool)):
return value
return str(value)
def format_migration_config(
config: ConfigType | list[ConfigType], depth: int = 0
) -> ConfigType | list[ConfigType]:
"""Recursive method to format templates as strings from ConfigType."""
types = (dict, list)
if depth > 9:
raise RecursionError
if isinstance(config, list):
items = []
for item in config:
if isinstance(item, types):
if len(item) > 0:
items.append(format_migration_config(item, depth + 1))
else:
items.append(_format_template(item))
return items # type: ignore[return-value]
formatted_config = {}
for field, value in config.items():
if isinstance(value, types):
if len(value) > 0:
formatted_config[field] = format_migration_config(value, depth + 1)
else:
formatted_config[field] = _format_template(value)
return formatted_config
def create_legacy_template_issue(
hass: HomeAssistant, config: ConfigType, domain: str
) -> None:
"""Create a repair for legacy template entities."""
if domain not in PLATFORMS:
return
breadcrumb = "Template Entity"
# Default entity id should be in most legacy configuration because
# it's created from the legacy slug. Vacuum and Lock do not have a
# slug, therefore we need to use the name or unique_id.
if (default_entity_id := config.get(CONF_DEFAULT_ENTITY_ID)) is not None:
breadcrumb = default_entity_id.split(".")[-1]
elif (unique_id := config.get(CONF_UNIQUE_ID)) is not None:
breadcrumb = f"unique_id: {unique_id}"
elif (name := config.get(CONF_NAME)) and isinstance(name, template.Template):
breadcrumb = name.template
issue_id = f"{LEGACY_TEMPLATE_DEPRECATION_KEY}_{domain}_{breadcrumb}_{hashlib.md5(','.join(config.keys()).encode()).hexdigest()}"
if (deprecation_list := hass.data.get(DATA_DEPRECATION)) is None:
hass.data[DATA_DEPRECATION] = deprecation_list = []
deprecation_list.append(issue_id)
try:
modified_yaml = format_migration_config(config)
yaml_config = yaml_util.dump({DOMAIN: [{domain: [modified_yaml]}]})
# Format to show up properly in a numbered bullet on the repair.
yaml_config = " ```\n " + yaml_config.replace("\n", "\n ") + "```"
except RecursionError:
yaml_config = f"{DOMAIN}:\n - {domain}: - ..."
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
breaks_in_ha_version="2026.6",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_legacy_templates",
translation_placeholders={
"domain": domain,
"breadcrumb": breadcrumb,
"config": yaml_config,
},
)
async def async_setup_template_platform(
hass: HomeAssistant,
domain: str,
config: ConfigType,
state_entity_cls: type[TemplateEntity],
trigger_entity_cls: type[TriggerEntity] | None,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None,
legacy_fields: dict[str, str] | None = None,
legacy_key: str | None = None,
) -> None:
"""Set up the Template platform."""
if discovery_info is None:
# Legacy Configuration
if legacy_fields is not None:
if legacy_key:
configs = rewrite_legacy_to_modern_configs(
hass, domain, config[legacy_key], legacy_fields
)
else:
configs = [rewrite_legacy_to_modern_config(hass, config, legacy_fields)]
for definition in configs:
create_legacy_template_issue(hass, definition, domain)
async_create_template_tracking_entities(
state_entity_cls,
async_add_entities,
hass,
configs,
None,
)
else:
_LOGGER.warning(
"Template %s entities can only be configured under template:", domain
)
return
# Trigger Configuration
if "coordinator" in discovery_info:
if trigger_entity_cls:
entities = [
trigger_entity_cls(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
]
async_add_entities(entities)
else:
raise PlatformNotReady(
f"The template {domain} platform doesn't support trigger entities"
)
return
# Modern Configuration
async_create_template_tracking_entities(
state_entity_cls,
async_add_entities,
hass,
discovery_info["entities"],
discovery_info["unique_id"],
)
async def async_setup_template_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
state_entity_cls: type[TemplateEntity],
config_schema: vol.Schema | vol.All,
replace_value_template: bool = False,
) -> None:
"""Setup the Template from a config entry."""
options = dict(config_entry.options)
options.pop("template_type")
if advanced_options := options.pop(CONF_ADVANCED_OPTIONS, None):
options = {**options, **advanced_options}
if replace_value_template and CONF_VALUE_TEMPLATE in options:
options[CONF_STATE] = options.pop(CONF_VALUE_TEMPLATE)
validated_config = config_schema(options)
async_add_entities(
[state_entity_cls(hass, validated_config, config_entry.entry_id)]
)
def async_setup_template_preview[T: TemplateEntity](
hass: HomeAssistant,
name: str,
config: ConfigType,
state_entity_cls: type[T],
schema: vol.Schema | vol.All,
replace_value_template: bool = False,
) -> T:
"""Setup the Template preview."""
if replace_value_template and CONF_VALUE_TEMPLATE in config:
config[CONF_STATE] = config.pop(CONF_VALUE_TEMPLATE)
validated_config = schema(config | {CONF_NAME: name})
return state_entity_cls(hass, validated_config, None)