mirror of
https://github.com/home-assistant/core.git
synced 2025-07-07 21:37:07 +00:00
Allow core integrations to describe their conditions (#147529)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
This commit is contained in:
parent
e47bdc06a0
commit
510fd09163
@ -76,6 +76,7 @@ from .exceptions import HomeAssistantError
|
||||
from .helpers import (
|
||||
area_registry,
|
||||
category_registry,
|
||||
condition,
|
||||
config_validation as cv,
|
||||
device_registry,
|
||||
entity,
|
||||
@ -452,6 +453,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
|
||||
create_eager_task(restore_state.async_load(hass)),
|
||||
create_eager_task(hass.config_entries.async_initialize()),
|
||||
create_eager_task(async_get_system_info(hass)),
|
||||
create_eager_task(condition.async_setup(hass)),
|
||||
create_eager_task(trigger.async_setup(hass)),
|
||||
)
|
||||
|
||||
|
@ -35,6 +35,10 @@ from homeassistant.exceptions import (
|
||||
Unauthorized,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, entity, template
|
||||
from homeassistant.helpers.condition import (
|
||||
async_get_all_descriptions as async_get_all_condition_descriptions,
|
||||
async_subscribe_platform_events as async_subscribe_condition_platform_events,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entityfilter import (
|
||||
INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA,
|
||||
@ -76,6 +80,7 @@ from . import const, decorators, messages
|
||||
from .connection import ActiveConnection
|
||||
from .messages import construct_event_message, construct_result_message
|
||||
|
||||
ALL_CONDITION_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_condition_descriptions_json"
|
||||
ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json"
|
||||
ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_trigger_descriptions_json"
|
||||
|
||||
@ -101,6 +106,7 @@ def async_register_commands(
|
||||
async_reg(hass, handle_ping)
|
||||
async_reg(hass, handle_render_template)
|
||||
async_reg(hass, handle_subscribe_bootstrap_integrations)
|
||||
async_reg(hass, handle_subscribe_condition_platforms)
|
||||
async_reg(hass, handle_subscribe_events)
|
||||
async_reg(hass, handle_subscribe_trigger)
|
||||
async_reg(hass, handle_subscribe_trigger_platforms)
|
||||
@ -501,6 +507,53 @@ def _send_handle_entities_init_response(
|
||||
)
|
||||
|
||||
|
||||
async def _async_get_all_condition_descriptions_json(hass: HomeAssistant) -> bytes:
|
||||
"""Return JSON of descriptions (i.e. user documentation) for all condition."""
|
||||
descriptions = await async_get_all_condition_descriptions(hass)
|
||||
if ALL_CONDITION_DESCRIPTIONS_JSON_CACHE in hass.data:
|
||||
cached_descriptions, cached_json_payload = hass.data[
|
||||
ALL_CONDITION_DESCRIPTIONS_JSON_CACHE
|
||||
]
|
||||
# If the descriptions are the same, return the cached JSON payload
|
||||
if cached_descriptions is descriptions:
|
||||
return cast(bytes, cached_json_payload)
|
||||
json_payload = json_bytes(
|
||||
{
|
||||
condition: description
|
||||
for condition, description in descriptions.items()
|
||||
if description is not None
|
||||
}
|
||||
)
|
||||
hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] = (descriptions, json_payload)
|
||||
return json_payload
|
||||
|
||||
|
||||
@decorators.websocket_command({vol.Required("type"): "condition_platforms/subscribe"})
|
||||
@decorators.async_response
|
||||
async def handle_subscribe_condition_platforms(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle subscribe conditions command."""
|
||||
|
||||
async def on_new_conditions(new_conditions: set[str]) -> None:
|
||||
"""Forward new conditions to websocket."""
|
||||
descriptions = await async_get_all_condition_descriptions(hass)
|
||||
new_condition_descriptions = {}
|
||||
for condition in new_conditions:
|
||||
if (description := descriptions[condition]) is not None:
|
||||
new_condition_descriptions[condition] = description
|
||||
if not new_condition_descriptions:
|
||||
return
|
||||
connection.send_event(msg["id"], new_condition_descriptions)
|
||||
|
||||
connection.subscriptions[msg["id"]] = async_subscribe_condition_platform_events(
|
||||
hass, on_new_conditions
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
conditions_json = await _async_get_all_condition_descriptions_json(hass)
|
||||
connection.send_message(construct_event_message(msg["id"], conditions_json))
|
||||
|
||||
|
||||
async def _async_get_all_service_descriptions_json(hass: HomeAssistant) -> bytes:
|
||||
"""Return JSON of descriptions (i.e. user documentation) for all service calls."""
|
||||
descriptions = await async_get_all_service_descriptions(hass)
|
||||
|
@ -5,19 +5,17 @@ from __future__ import annotations
|
||||
import abc
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from collections.abc import Callable, Container, Generator
|
||||
from collections.abc import Callable, Container, Coroutine, Generator, Iterable
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, time as dt_time, timedelta
|
||||
import functools as ft
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from typing import Any, Protocol, cast
|
||||
from typing import TYPE_CHECKING, Any, Protocol, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import zone as zone_cmp
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_GPS_ACCURACY,
|
||||
@ -54,11 +52,20 @@ from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
TemplateError,
|
||||
)
|
||||
from homeassistant.loader import IntegrationNotFound, async_get_integration
|
||||
from homeassistant.loader import (
|
||||
Integration,
|
||||
IntegrationNotFound,
|
||||
async_get_integration,
|
||||
async_get_integrations,
|
||||
)
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.async_ import run_callback_threadsafe
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.yaml import load_yaml_dict
|
||||
from homeassistant.util.yaml.loader import JSON_TYPE
|
||||
|
||||
from . import config_validation as cv, entity_registry as er
|
||||
from .integration_platform import async_process_integration_platforms
|
||||
from .template import Template, render_complex
|
||||
from .trace import (
|
||||
TraceElement,
|
||||
@ -76,6 +83,8 @@ ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config"
|
||||
FROM_CONFIG_FORMAT = "{}_from_config"
|
||||
VALIDATE_CONFIG_FORMAT = "{}_validate_config"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_PLATFORM_ALIASES: dict[str | None, str | None] = {
|
||||
"and": None,
|
||||
"device": "device_automation",
|
||||
@ -94,6 +103,99 @@ INPUT_ENTITY_ID = re.compile(
|
||||
)
|
||||
|
||||
|
||||
CONDITION_DESCRIPTION_CACHE: HassKey[dict[str, dict[str, Any] | None]] = HassKey(
|
||||
"condition_description_cache"
|
||||
)
|
||||
CONDITION_PLATFORM_SUBSCRIPTIONS: HassKey[
|
||||
list[Callable[[set[str]], Coroutine[Any, Any, None]]]
|
||||
] = HassKey("condition_platform_subscriptions")
|
||||
CONDITIONS: HassKey[dict[str, str]] = HassKey("conditions")
|
||||
|
||||
|
||||
# Basic schemas to sanity check the condition descriptions,
|
||||
# full validation is done by hassfest.conditions
|
||||
_FIELD_SCHEMA = vol.Schema(
|
||||
{},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
_CONDITION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def starts_with_dot(key: str) -> str:
|
||||
"""Check if key starts with dot."""
|
||||
if not key.startswith("."):
|
||||
raise vol.Invalid("Key does not start with .")
|
||||
return key
|
||||
|
||||
|
||||
_CONDITIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Remove(vol.All(str, starts_with_dot)): object,
|
||||
cv.slug: vol.Any(None, _CONDITION_SCHEMA),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant) -> None:
|
||||
"""Set up the condition helper."""
|
||||
hass.data[CONDITION_DESCRIPTION_CACHE] = {}
|
||||
hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS] = []
|
||||
hass.data[CONDITIONS] = {}
|
||||
await async_process_integration_platforms(
|
||||
hass, "condition", _register_condition_platform, wait_for_platforms=True
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_subscribe_platform_events(
|
||||
hass: HomeAssistant,
|
||||
on_event: Callable[[set[str]], Coroutine[Any, Any, None]],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to condition platform events."""
|
||||
condition_platform_event_subscriptions = hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS]
|
||||
|
||||
def remove_subscription() -> None:
|
||||
condition_platform_event_subscriptions.remove(on_event)
|
||||
|
||||
condition_platform_event_subscriptions.append(on_event)
|
||||
return remove_subscription
|
||||
|
||||
|
||||
async def _register_condition_platform(
|
||||
hass: HomeAssistant, integration_domain: str, platform: ConditionProtocol
|
||||
) -> None:
|
||||
"""Register a condition platform."""
|
||||
|
||||
new_conditions: set[str] = set()
|
||||
|
||||
if hasattr(platform, "async_get_conditions"):
|
||||
for condition_key in await platform.async_get_conditions(hass):
|
||||
hass.data[CONDITIONS][condition_key] = integration_domain
|
||||
new_conditions.add(condition_key)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Integration %s does not provide condition support, skipping",
|
||||
integration_domain,
|
||||
)
|
||||
return
|
||||
|
||||
# We don't use gather here because gather adds additional overhead
|
||||
# when wrapping each coroutine in a task, and we expect our listeners
|
||||
# to call condition.async_get_all_descriptions which will only yield
|
||||
# the first time it's called, after that it returns cached data.
|
||||
for listener in hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS]:
|
||||
try:
|
||||
await listener(new_conditions)
|
||||
except Exception:
|
||||
_LOGGER.exception("Error while notifying condition platform listener")
|
||||
|
||||
|
||||
class Condition(abc.ABC):
|
||||
"""Condition class."""
|
||||
|
||||
@ -717,6 +819,8 @@ def time(
|
||||
for the opposite. "(23:59 <= now < 00:01)" would be the same as
|
||||
"not (00:01 <= now < 23:59)".
|
||||
"""
|
||||
from homeassistant.components.sensor import SensorDeviceClass # noqa: PLC0415
|
||||
|
||||
now = dt_util.now()
|
||||
now_time = now.time()
|
||||
|
||||
@ -824,6 +928,8 @@ def zone(
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
from homeassistant.components import zone as zone_cmp # noqa: PLC0415
|
||||
|
||||
if zone_ent is None:
|
||||
raise ConditionErrorMessage("zone", "no zone specified")
|
||||
|
||||
@ -1080,3 +1186,109 @@ def async_extract_devices(config: ConfigType | Template) -> set[str]:
|
||||
referenced.add(device_id)
|
||||
|
||||
return referenced
|
||||
|
||||
|
||||
def _load_conditions_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE:
|
||||
"""Load conditions file for an integration."""
|
||||
try:
|
||||
return cast(
|
||||
JSON_TYPE,
|
||||
_CONDITIONS_SCHEMA(
|
||||
load_yaml_dict(str(integration.file_path / "conditions.yaml"))
|
||||
),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
_LOGGER.warning(
|
||||
"Unable to find conditions.yaml for the %s integration", integration.domain
|
||||
)
|
||||
return {}
|
||||
except (HomeAssistantError, vol.Invalid) as ex:
|
||||
_LOGGER.warning(
|
||||
"Unable to parse conditions.yaml for the %s integration: %s",
|
||||
integration.domain,
|
||||
ex,
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def _load_conditions_files(
|
||||
hass: HomeAssistant, integrations: Iterable[Integration]
|
||||
) -> dict[str, JSON_TYPE]:
|
||||
"""Load condition files for multiple integrations."""
|
||||
return {
|
||||
integration.domain: _load_conditions_file(hass, integration)
|
||||
for integration in integrations
|
||||
}
|
||||
|
||||
|
||||
async def async_get_all_descriptions(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, dict[str, Any] | None]:
|
||||
"""Return descriptions (i.e. user documentation) for all conditions."""
|
||||
descriptions_cache = hass.data[CONDITION_DESCRIPTION_CACHE]
|
||||
|
||||
conditions = hass.data[CONDITIONS]
|
||||
# See if there are new conditions not seen before.
|
||||
# Any condition that we saw before already has an entry in description_cache.
|
||||
all_conditions = set(conditions)
|
||||
previous_all_conditions = set(descriptions_cache)
|
||||
# If the conditions are the same, we can return the cache
|
||||
if previous_all_conditions == all_conditions:
|
||||
return descriptions_cache
|
||||
|
||||
# Files we loaded for missing descriptions
|
||||
new_conditions_descriptions: dict[str, JSON_TYPE] = {}
|
||||
# We try to avoid making a copy in the event the cache is good,
|
||||
# but now we must make a copy in case new conditions get added
|
||||
# while we are loading the missing ones so we do not
|
||||
# add the new ones to the cache without their descriptions
|
||||
conditions = conditions.copy()
|
||||
|
||||
if missing_conditions := all_conditions.difference(descriptions_cache):
|
||||
domains_with_missing_conditions = {
|
||||
conditions[missing_condition] for missing_condition in missing_conditions
|
||||
}
|
||||
ints_or_excs = await async_get_integrations(
|
||||
hass, domains_with_missing_conditions
|
||||
)
|
||||
integrations: list[Integration] = []
|
||||
for domain, int_or_exc in ints_or_excs.items():
|
||||
if type(int_or_exc) is Integration and int_or_exc.has_conditions:
|
||||
integrations.append(int_or_exc)
|
||||
continue
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(int_or_exc, Exception)
|
||||
_LOGGER.debug(
|
||||
"Failed to load conditions.yaml for integration: %s",
|
||||
domain,
|
||||
exc_info=int_or_exc,
|
||||
)
|
||||
|
||||
if integrations:
|
||||
new_conditions_descriptions = await hass.async_add_executor_job(
|
||||
_load_conditions_files, hass, integrations
|
||||
)
|
||||
|
||||
# Make a copy of the old cache and add missing descriptions to it
|
||||
new_descriptions_cache = descriptions_cache.copy()
|
||||
for missing_condition in missing_conditions:
|
||||
domain = conditions[missing_condition]
|
||||
|
||||
if (
|
||||
yaml_description := new_conditions_descriptions.get(domain, {}).get( # type: ignore[union-attr]
|
||||
missing_condition
|
||||
)
|
||||
) is None:
|
||||
_LOGGER.debug(
|
||||
"No condition descriptions found for condition %s, skipping",
|
||||
missing_condition,
|
||||
)
|
||||
new_descriptions_cache[missing_condition] = None
|
||||
continue
|
||||
|
||||
description = {"fields": yaml_description.get("fields", {})}
|
||||
|
||||
new_descriptions_cache[missing_condition] = description
|
||||
|
||||
hass.data[CONDITION_DESCRIPTION_CACHE] = new_descriptions_cache
|
||||
return new_descriptions_cache
|
||||
|
@ -67,6 +67,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
#
|
||||
BASE_PRELOAD_PLATFORMS = [
|
||||
"backup",
|
||||
"condition",
|
||||
"config",
|
||||
"config_flow",
|
||||
"diagnostics",
|
||||
@ -857,6 +858,11 @@ class Integration:
|
||||
# True.
|
||||
return self.manifest.get("import_executor", True)
|
||||
|
||||
@cached_property
|
||||
def has_conditions(self) -> bool:
|
||||
"""Return if the integration has conditions."""
|
||||
return "conditions.yaml" in self._top_level_files
|
||||
|
||||
@cached_property
|
||||
def has_services(self) -> bool:
|
||||
"""Return if the integration has services."""
|
||||
|
@ -12,6 +12,7 @@ from . import (
|
||||
application_credentials,
|
||||
bluetooth,
|
||||
codeowners,
|
||||
conditions,
|
||||
config_flow,
|
||||
config_schema,
|
||||
dependencies,
|
||||
@ -38,6 +39,7 @@ INTEGRATION_PLUGINS = [
|
||||
application_credentials,
|
||||
bluetooth,
|
||||
codeowners,
|
||||
conditions,
|
||||
config_schema,
|
||||
dependencies,
|
||||
dhcp,
|
||||
|
225
script/hassfest/conditions.py
Normal file
225
script/hassfest/conditions.py
Normal file
@ -0,0 +1,225 @@
|
||||
"""Validate conditions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant.const import CONF_SELECTOR
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import condition, config_validation as cv, selector
|
||||
from homeassistant.util.yaml import load_yaml_dict
|
||||
|
||||
from .model import Config, Integration
|
||||
|
||||
|
||||
def exists(value: Any) -> Any:
|
||||
"""Check if value exists."""
|
||||
if value is None:
|
||||
raise vol.Invalid("Value cannot be None")
|
||||
return value
|
||||
|
||||
|
||||
FIELD_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("example"): exists,
|
||||
vol.Optional("default"): exists,
|
||||
vol.Optional("required"): bool,
|
||||
vol.Optional(CONF_SELECTOR): selector.validate_selector,
|
||||
}
|
||||
)
|
||||
|
||||
CONDITION_SCHEMA = vol.Any(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}),
|
||||
}
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
CONDITIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Remove(vol.All(str, condition.starts_with_dot)): object,
|
||||
cv.slug: CONDITION_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
NON_MIGRATED_INTEGRATIONS = {
|
||||
"device_automation",
|
||||
"sun",
|
||||
}
|
||||
|
||||
|
||||
def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool:
|
||||
"""Recursively go through a dir and it's children and find the regex."""
|
||||
pattern = re.compile(search_pattern)
|
||||
|
||||
for fil in path.glob(glob_pattern):
|
||||
if not fil.is_file():
|
||||
continue
|
||||
|
||||
if pattern.search(fil.read_text()):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def validate_conditions(config: Config, integration: Integration) -> None: # noqa: C901
|
||||
"""Validate conditions."""
|
||||
try:
|
||||
data = load_yaml_dict(str(integration.path / "conditions.yaml"))
|
||||
except FileNotFoundError:
|
||||
# Find if integration uses conditions
|
||||
has_conditions = grep_dir(
|
||||
integration.path,
|
||||
"**/condition.py",
|
||||
r"async_get_conditions",
|
||||
)
|
||||
|
||||
if has_conditions and integration.domain not in NON_MIGRATED_INTEGRATIONS:
|
||||
integration.add_error(
|
||||
"conditions", "Registers conditions but has no conditions.yaml"
|
||||
)
|
||||
return
|
||||
except HomeAssistantError:
|
||||
integration.add_error("conditions", "Invalid conditions.yaml")
|
||||
return
|
||||
|
||||
try:
|
||||
conditions = CONDITIONS_SCHEMA(data)
|
||||
except vol.Invalid as err:
|
||||
integration.add_error(
|
||||
"conditions", f"Invalid conditions.yaml: {humanize_error(data, err)}"
|
||||
)
|
||||
return
|
||||
|
||||
icons_file = integration.path / "icons.json"
|
||||
icons = {}
|
||||
if icons_file.is_file():
|
||||
with contextlib.suppress(ValueError):
|
||||
icons = json.loads(icons_file.read_text())
|
||||
condition_icons = icons.get("conditions", {})
|
||||
|
||||
# Try loading translation strings
|
||||
if integration.core:
|
||||
strings_file = integration.path / "strings.json"
|
||||
else:
|
||||
# For custom integrations, use the en.json file
|
||||
strings_file = integration.path / "translations/en.json"
|
||||
|
||||
strings = {}
|
||||
if strings_file.is_file():
|
||||
with contextlib.suppress(ValueError):
|
||||
strings = json.loads(strings_file.read_text())
|
||||
|
||||
error_msg_suffix = "in the translations file"
|
||||
if not integration.core:
|
||||
error_msg_suffix = f"and is not {error_msg_suffix}"
|
||||
|
||||
# For each condition in the integration:
|
||||
# 1. Check if the condition description is set, if not,
|
||||
# check if it's in the strings file else add an error.
|
||||
# 2. Check if the condition has an icon set in icons.json.
|
||||
# raise an error if not.,
|
||||
for condition_name, condition_schema in conditions.items():
|
||||
if integration.core and condition_name not in condition_icons:
|
||||
# This is enforced for Core integrations only
|
||||
integration.add_error(
|
||||
"conditions",
|
||||
f"Condition {condition_name} has no icon in icons.json.",
|
||||
)
|
||||
if condition_schema is None:
|
||||
continue
|
||||
if "name" not in condition_schema and integration.core:
|
||||
try:
|
||||
strings["conditions"][condition_name]["name"]
|
||||
except KeyError:
|
||||
integration.add_error(
|
||||
"conditions",
|
||||
f"Condition {condition_name} has no name {error_msg_suffix}",
|
||||
)
|
||||
|
||||
if "description" not in condition_schema and integration.core:
|
||||
try:
|
||||
strings["conditions"][condition_name]["description"]
|
||||
except KeyError:
|
||||
integration.add_error(
|
||||
"conditions",
|
||||
f"Condition {condition_name} has no description {error_msg_suffix}",
|
||||
)
|
||||
|
||||
# The same check is done for the description in each of the fields of the
|
||||
# condition schema.
|
||||
for field_name, field_schema in condition_schema.get("fields", {}).items():
|
||||
if "fields" in field_schema:
|
||||
# This is a section
|
||||
continue
|
||||
if "name" not in field_schema and integration.core:
|
||||
try:
|
||||
strings["conditions"][condition_name]["fields"][field_name]["name"]
|
||||
except KeyError:
|
||||
integration.add_error(
|
||||
"conditions",
|
||||
(
|
||||
f"Condition {condition_name} has a field {field_name} with no "
|
||||
f"name {error_msg_suffix}"
|
||||
),
|
||||
)
|
||||
|
||||
if "description" not in field_schema and integration.core:
|
||||
try:
|
||||
strings["conditions"][condition_name]["fields"][field_name][
|
||||
"description"
|
||||
]
|
||||
except KeyError:
|
||||
integration.add_error(
|
||||
"conditions",
|
||||
(
|
||||
f"Condition {condition_name} has a field {field_name} with no "
|
||||
f"description {error_msg_suffix}"
|
||||
),
|
||||
)
|
||||
|
||||
if "selector" in field_schema:
|
||||
with contextlib.suppress(KeyError):
|
||||
translation_key = field_schema["selector"]["select"][
|
||||
"translation_key"
|
||||
]
|
||||
try:
|
||||
strings["selector"][translation_key]
|
||||
except KeyError:
|
||||
integration.add_error(
|
||||
"conditions",
|
||||
f"Condition {condition_name} has a field {field_name} with a selector with a translation key {translation_key} that is not in the translations file",
|
||||
)
|
||||
|
||||
# The same check is done for the description in each of the sections of the
|
||||
# condition schema.
|
||||
for section_name, section_schema in condition_schema.get("fields", {}).items():
|
||||
if "fields" not in section_schema:
|
||||
# This is not a section
|
||||
continue
|
||||
if "name" not in section_schema and integration.core:
|
||||
try:
|
||||
strings["conditions"][condition_name]["sections"][section_name][
|
||||
"name"
|
||||
]
|
||||
except KeyError:
|
||||
integration.add_error(
|
||||
"conditions",
|
||||
f"Condition {condition_name} has a section {section_name} with no name {error_msg_suffix}",
|
||||
)
|
||||
|
||||
|
||||
def validate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
"""Handle dependencies for integrations."""
|
||||
# check conditions.yaml is valid
|
||||
for integration in integrations.values():
|
||||
validate_conditions(config, integration)
|
@ -120,6 +120,16 @@ CUSTOM_INTEGRATION_SERVICE_ICONS_SCHEMA = cv.schema_with_slug_keys(
|
||||
)
|
||||
|
||||
|
||||
CONDITION_ICONS_SCHEMA = cv.schema_with_slug_keys(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional("condition"): icon_value_validator,
|
||||
}
|
||||
),
|
||||
slug_validator=translation_key_validator,
|
||||
)
|
||||
|
||||
|
||||
TRIGGER_ICONS_SCHEMA = cv.schema_with_slug_keys(
|
||||
vol.Schema(
|
||||
{
|
||||
@ -166,6 +176,7 @@ def icon_schema(
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Optional("conditions"): CONDITION_ICONS_SCHEMA,
|
||||
vol.Optional("config"): DATA_ENTRY_ICONS_SCHEMA,
|
||||
vol.Optional("issues"): vol.Schema(
|
||||
{str: {"fix_flow": DATA_ENTRY_ICONS_SCHEMA}}
|
||||
|
@ -416,6 +416,22 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
|
||||
},
|
||||
slug_validator=translation_key_validator,
|
||||
),
|
||||
vol.Optional("conditions"): cv.schema_with_slug_keys(
|
||||
{
|
||||
vol.Required("name"): translation_value_validator,
|
||||
vol.Required("description"): translation_value_validator,
|
||||
vol.Required("description_configured"): translation_value_validator,
|
||||
vol.Optional("fields"): cv.schema_with_slug_keys(
|
||||
{
|
||||
vol.Required("name"): str,
|
||||
vol.Required("description"): translation_value_validator,
|
||||
vol.Optional("example"): translation_value_validator,
|
||||
},
|
||||
slug_validator=translation_key_validator,
|
||||
),
|
||||
},
|
||||
slug_validator=translation_key_validator,
|
||||
),
|
||||
vol.Optional("triggers"): cv.schema_with_slug_keys(
|
||||
{
|
||||
vol.Required("name"): translation_value_validator,
|
||||
|
@ -75,6 +75,7 @@ from homeassistant.core import (
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
category_registry as cr,
|
||||
condition,
|
||||
device_registry as dr,
|
||||
entity,
|
||||
entity_platform,
|
||||
@ -296,6 +297,7 @@ async def async_test_home_assistant(
|
||||
# Load the registries
|
||||
entity.async_setup(hass)
|
||||
loader.async_setup(hass)
|
||||
await condition.async_setup(hass)
|
||||
await trigger.async_setup(hass)
|
||||
|
||||
# setup translation cache instead of calling translation.async_setup(hass)
|
||||
|
@ -19,6 +19,7 @@ from homeassistant.components.websocket_api.auth import (
|
||||
TYPE_AUTH_REQUIRED,
|
||||
)
|
||||
from homeassistant.components.websocket_api.commands import (
|
||||
ALL_CONDITION_DESCRIPTIONS_JSON_CACHE,
|
||||
ALL_SERVICE_DESCRIPTIONS_JSON_CACHE,
|
||||
ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE,
|
||||
)
|
||||
@ -710,6 +711,91 @@ async def test_get_services(
|
||||
assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is old_cache
|
||||
|
||||
|
||||
@patch("annotatedyaml.loader.load_yaml")
|
||||
@patch.object(Integration, "has_conditions", return_value=True)
|
||||
async def test_subscribe_conditions(
|
||||
mock_has_conditions: Mock,
|
||||
mock_load_yaml: Mock,
|
||||
hass: HomeAssistant,
|
||||
websocket_client: MockHAClientWebSocket,
|
||||
) -> None:
|
||||
"""Test condition_platforms/subscribe command."""
|
||||
sun_condition_descriptions = """
|
||||
sun: {}
|
||||
"""
|
||||
device_automation_condition_descriptions = """
|
||||
device: {}
|
||||
"""
|
||||
|
||||
def _load_yaml(fname, secrets=None):
|
||||
if fname.endswith("device_automation/conditions.yaml"):
|
||||
condition_descriptions = device_automation_condition_descriptions
|
||||
elif fname.endswith("sun/conditions.yaml"):
|
||||
condition_descriptions = sun_condition_descriptions
|
||||
else:
|
||||
raise FileNotFoundError
|
||||
with io.StringIO(condition_descriptions) as file:
|
||||
return parse_yaml(file)
|
||||
|
||||
mock_load_yaml.side_effect = _load_yaml
|
||||
|
||||
assert await async_setup_component(hass, "sun", {})
|
||||
assert await async_setup_component(hass, "system_health", {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert ALL_CONDITION_DESCRIPTIONS_JSON_CACHE not in hass.data
|
||||
|
||||
await websocket_client.send_json_auto_id({"type": "condition_platforms/subscribe"})
|
||||
|
||||
# Test start subscription with initial event
|
||||
msg = await websocket_client.receive_json()
|
||||
assert msg == {"id": 1, "result": None, "success": True, "type": "result"}
|
||||
msg = await websocket_client.receive_json()
|
||||
assert msg == {"event": {"sun": {"fields": {}}}, "id": 1, "type": "event"}
|
||||
|
||||
old_cache = hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE]
|
||||
|
||||
# Test we receive an event when a new platform is loaded, if it has descriptions
|
||||
assert await async_setup_component(hass, "calendar", {})
|
||||
assert await async_setup_component(hass, "device_automation", {})
|
||||
await hass.async_block_till_done()
|
||||
msg = await websocket_client.receive_json()
|
||||
assert msg == {
|
||||
"event": {"device": {"fields": {}}},
|
||||
"id": 1,
|
||||
"type": "event",
|
||||
}
|
||||
|
||||
# Initiate a second subscription to check the cache is updated because of the new
|
||||
# condition
|
||||
await websocket_client.send_json_auto_id({"type": "condition_platforms/subscribe"})
|
||||
msg = await websocket_client.receive_json()
|
||||
assert msg == {"id": 2, "result": None, "success": True, "type": "result"}
|
||||
msg = await websocket_client.receive_json()
|
||||
assert msg == {
|
||||
"event": {"device": {"fields": {}}, "sun": {"fields": {}}},
|
||||
"id": 2,
|
||||
"type": "event",
|
||||
}
|
||||
|
||||
assert hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] is not old_cache
|
||||
|
||||
# Initiate a third subscription to check the cache is not updated because no new
|
||||
# condition was added
|
||||
old_cache = hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE]
|
||||
await websocket_client.send_json_auto_id({"type": "condition_platforms/subscribe"})
|
||||
msg = await websocket_client.receive_json()
|
||||
assert msg == {"id": 3, "result": None, "success": True, "type": "result"}
|
||||
msg = await websocket_client.receive_json()
|
||||
assert msg == {
|
||||
"event": {"device": {"fields": {}}, "sun": {"fields": {}}},
|
||||
"id": 3,
|
||||
"type": "event",
|
||||
}
|
||||
|
||||
assert hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] is old_cache
|
||||
|
||||
|
||||
@patch("annotatedyaml.loader.load_yaml")
|
||||
@patch.object(Integration, "has_triggers", return_value=True)
|
||||
async def test_subscribe_triggers(
|
||||
|
@ -1,14 +1,21 @@
|
||||
"""Test the condition helper."""
|
||||
|
||||
from datetime import timedelta
|
||||
import io
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from freezegun import freeze_time
|
||||
import pytest
|
||||
from pytest_unordered import unordered
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_automation import (
|
||||
DOMAIN as DOMAIN_DEVICE_AUTOMATION,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.components.sun import DOMAIN as DOMAIN_SUN
|
||||
from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
CONF_CONDITION,
|
||||
@ -27,10 +34,12 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import Integration, async_get_integration
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.yaml.loader import parse_yaml
|
||||
|
||||
from tests.common import MockModule, mock_integration, mock_platform
|
||||
from tests.common import MockModule, MockPlatform, mock_integration, mock_platform
|
||||
|
||||
|
||||
def assert_element(trace_element, expected_element, path):
|
||||
@ -2517,3 +2526,280 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None
|
||||
"conditions/1/entity_id/0": [{"result": {"result": True, "state": 100.0}}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sun_condition_descriptions",
|
||||
[
|
||||
"""
|
||||
sun:
|
||||
fields:
|
||||
after:
|
||||
example: sunrise
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- sunrise
|
||||
- sunset
|
||||
after_offset:
|
||||
selector:
|
||||
time: null
|
||||
before:
|
||||
example: sunrise
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- sunrise
|
||||
- sunset
|
||||
before_offset:
|
||||
selector:
|
||||
time: null
|
||||
""",
|
||||
"""
|
||||
.sunrise_sunset_selector: &sunrise_sunset_selector
|
||||
example: sunrise
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- sunrise
|
||||
- sunset
|
||||
.offset_selector: &offset_selector
|
||||
selector:
|
||||
time: null
|
||||
sun:
|
||||
fields:
|
||||
after: *sunrise_sunset_selector
|
||||
after_offset: *offset_selector
|
||||
before: *sunrise_sunset_selector
|
||||
before_offset: *offset_selector
|
||||
""",
|
||||
],
|
||||
)
|
||||
async def test_async_get_all_descriptions(
|
||||
hass: HomeAssistant, sun_condition_descriptions: str
|
||||
) -> None:
|
||||
"""Test async_get_all_descriptions."""
|
||||
device_automation_condition_descriptions = """
|
||||
device: {}
|
||||
"""
|
||||
|
||||
assert await async_setup_component(hass, DOMAIN_SUN, {})
|
||||
assert await async_setup_component(hass, DOMAIN_SYSTEM_HEALTH, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
def _load_yaml(fname, secrets=None):
|
||||
if fname.endswith("device_automation/conditions.yaml"):
|
||||
condition_descriptions = device_automation_condition_descriptions
|
||||
elif fname.endswith("sun/conditions.yaml"):
|
||||
condition_descriptions = sun_condition_descriptions
|
||||
with io.StringIO(condition_descriptions) as file:
|
||||
return parse_yaml(file)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.helpers.condition._load_conditions_files",
|
||||
side_effect=condition._load_conditions_files,
|
||||
) as proxy_load_conditions_files,
|
||||
patch(
|
||||
"annotatedyaml.loader.load_yaml",
|
||||
side_effect=_load_yaml,
|
||||
),
|
||||
patch.object(Integration, "has_conditions", return_value=True),
|
||||
):
|
||||
descriptions = await condition.async_get_all_descriptions(hass)
|
||||
|
||||
# Test we only load conditions.yaml for integrations with conditions,
|
||||
# system_health has no conditions
|
||||
assert proxy_load_conditions_files.mock_calls[0][1][1] == unordered(
|
||||
[
|
||||
await async_get_integration(hass, DOMAIN_SUN),
|
||||
]
|
||||
)
|
||||
|
||||
# system_health does not have conditions and should not be in descriptions
|
||||
assert descriptions == {
|
||||
DOMAIN_SUN: {
|
||||
"fields": {
|
||||
"after": {
|
||||
"example": "sunrise",
|
||||
"selector": {"select": {"options": ["sunrise", "sunset"]}},
|
||||
},
|
||||
"after_offset": {"selector": {"time": None}},
|
||||
"before": {
|
||||
"example": "sunrise",
|
||||
"selector": {"select": {"options": ["sunrise", "sunset"]}},
|
||||
},
|
||||
"before_offset": {"selector": {"time": None}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Verify the cache returns the same object
|
||||
assert await condition.async_get_all_descriptions(hass) is descriptions
|
||||
|
||||
# Load the device_automation integration and check a new cache object is created
|
||||
assert await async_setup_component(hass, DOMAIN_DEVICE_AUTOMATION, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"annotatedyaml.loader.load_yaml",
|
||||
side_effect=_load_yaml,
|
||||
),
|
||||
patch.object(Integration, "has_conditions", return_value=True),
|
||||
):
|
||||
new_descriptions = await condition.async_get_all_descriptions(hass)
|
||||
assert new_descriptions is not descriptions
|
||||
assert new_descriptions == {
|
||||
"device": {
|
||||
"fields": {},
|
||||
},
|
||||
DOMAIN_SUN: {
|
||||
"fields": {
|
||||
"after": {
|
||||
"example": "sunrise",
|
||||
"selector": {"select": {"options": ["sunrise", "sunset"]}},
|
||||
},
|
||||
"after_offset": {"selector": {"time": None}},
|
||||
"before": {
|
||||
"example": "sunrise",
|
||||
"selector": {"select": {"options": ["sunrise", "sunset"]}},
|
||||
},
|
||||
"before_offset": {"selector": {"time": None}},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
# Verify the cache returns the same object
|
||||
assert await condition.async_get_all_descriptions(hass) is new_descriptions
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("yaml_error", "expected_message"),
|
||||
[
|
||||
(
|
||||
FileNotFoundError("Blah"),
|
||||
"Unable to find conditions.yaml for the sun integration",
|
||||
),
|
||||
(
|
||||
HomeAssistantError("Test error"),
|
||||
"Unable to parse conditions.yaml for the sun integration: Test error",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_async_get_all_descriptions_with_yaml_error(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
yaml_error: Exception,
|
||||
expected_message: str,
|
||||
) -> None:
|
||||
"""Test async_get_all_descriptions."""
|
||||
assert await async_setup_component(hass, DOMAIN_SUN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
def _load_yaml_dict(fname, secrets=None):
|
||||
raise yaml_error
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.helpers.condition.load_yaml_dict",
|
||||
side_effect=_load_yaml_dict,
|
||||
),
|
||||
patch.object(Integration, "has_conditions", return_value=True),
|
||||
):
|
||||
descriptions = await condition.async_get_all_descriptions(hass)
|
||||
|
||||
assert descriptions == {DOMAIN_SUN: None}
|
||||
|
||||
assert expected_message in caplog.text
|
||||
|
||||
|
||||
async def test_async_get_all_descriptions_with_bad_description(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test async_get_all_descriptions."""
|
||||
sun_service_descriptions = """
|
||||
sun:
|
||||
fields: not_a_dict
|
||||
"""
|
||||
|
||||
assert await async_setup_component(hass, DOMAIN_SUN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
def _load_yaml(fname, secrets=None):
|
||||
with io.StringIO(sun_service_descriptions) as file:
|
||||
return parse_yaml(file)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"annotatedyaml.loader.load_yaml",
|
||||
side_effect=_load_yaml,
|
||||
),
|
||||
patch.object(Integration, "has_conditions", return_value=True),
|
||||
):
|
||||
descriptions = await condition.async_get_all_descriptions(hass)
|
||||
|
||||
assert descriptions == {DOMAIN_SUN: None}
|
||||
|
||||
assert (
|
||||
"Unable to parse conditions.yaml for the sun integration: "
|
||||
"expected a dictionary for dictionary value @ data['sun']['fields']"
|
||||
) in caplog.text
|
||||
|
||||
|
||||
async def test_invalid_condition_platform(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test invalid condition platform."""
|
||||
mock_integration(hass, MockModule("test", async_setup=AsyncMock(return_value=True)))
|
||||
mock_platform(hass, "test.condition", MockPlatform())
|
||||
|
||||
await async_setup_component(hass, "test", {})
|
||||
|
||||
assert (
|
||||
"Integration test does not provide condition support, skipping" in caplog.text
|
||||
)
|
||||
|
||||
|
||||
@patch("annotatedyaml.loader.load_yaml")
|
||||
@patch.object(Integration, "has_conditions", return_value=True)
|
||||
async def test_subscribe_conditions(
|
||||
mock_has_conditions: Mock,
|
||||
mock_load_yaml: Mock,
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test condition.async_subscribe_platform_events."""
|
||||
sun_condition_descriptions = """
|
||||
sun: {}
|
||||
"""
|
||||
|
||||
def _load_yaml(fname, secrets=None):
|
||||
if fname.endswith("sun/conditions.yaml"):
|
||||
condition_descriptions = sun_condition_descriptions
|
||||
else:
|
||||
raise FileNotFoundError
|
||||
with io.StringIO(condition_descriptions) as file:
|
||||
return parse_yaml(file)
|
||||
|
||||
mock_load_yaml.side_effect = _load_yaml
|
||||
|
||||
async def broken_subscriber(_):
|
||||
"""Simulate a broken subscriber."""
|
||||
raise Exception("Boom!") # noqa: TRY002
|
||||
|
||||
condition_events = []
|
||||
|
||||
async def good_subscriber(new_conditions: set[str]):
|
||||
"""Simulate a working subscriber."""
|
||||
condition_events.append(new_conditions)
|
||||
|
||||
condition.async_subscribe_platform_events(hass, broken_subscriber)
|
||||
condition.async_subscribe_platform_events(hass, good_subscriber)
|
||||
|
||||
assert await async_setup_component(hass, "sun", {})
|
||||
|
||||
assert condition_events == [{"sun"}]
|
||||
assert "Error while notifying condition platform listener" in caplog.text
|
||||
|
Loading…
x
Reference in New Issue
Block a user