Allow core integrations to describe their conditions (#147529)

Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
This commit is contained in:
Erik Montnemery 2025-07-04 16:03:42 +02:00 committed by GitHub
parent e47bdc06a0
commit 510fd09163
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 907 additions and 6 deletions

View File

@ -76,6 +76,7 @@ from .exceptions import HomeAssistantError
from .helpers import ( from .helpers import (
area_registry, area_registry,
category_registry, category_registry,
condition,
config_validation as cv, config_validation as cv,
device_registry, device_registry,
entity, 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(restore_state.async_load(hass)),
create_eager_task(hass.config_entries.async_initialize()), create_eager_task(hass.config_entries.async_initialize()),
create_eager_task(async_get_system_info(hass)), create_eager_task(async_get_system_info(hass)),
create_eager_task(condition.async_setup(hass)),
create_eager_task(trigger.async_setup(hass)), create_eager_task(trigger.async_setup(hass)),
) )

View File

@ -35,6 +35,10 @@ from homeassistant.exceptions import (
Unauthorized, Unauthorized,
) )
from homeassistant.helpers import config_validation as cv, entity, template 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.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entityfilter import ( from homeassistant.helpers.entityfilter import (
INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA,
@ -76,6 +80,7 @@ from . import const, decorators, messages
from .connection import ActiveConnection from .connection import ActiveConnection
from .messages import construct_event_message, construct_result_message 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_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json"
ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_trigger_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_ping)
async_reg(hass, handle_render_template) async_reg(hass, handle_render_template)
async_reg(hass, handle_subscribe_bootstrap_integrations) 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_events)
async_reg(hass, handle_subscribe_trigger) async_reg(hass, handle_subscribe_trigger)
async_reg(hass, handle_subscribe_trigger_platforms) 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: async def _async_get_all_service_descriptions_json(hass: HomeAssistant) -> bytes:
"""Return JSON of descriptions (i.e. user documentation) for all service calls.""" """Return JSON of descriptions (i.e. user documentation) for all service calls."""
descriptions = await async_get_all_service_descriptions(hass) descriptions = await async_get_all_service_descriptions(hass)

View File

@ -5,19 +5,17 @@ from __future__ import annotations
import abc import abc
import asyncio import asyncio
from collections import deque 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 contextlib import contextmanager
from datetime import datetime, time as dt_time, timedelta from datetime import datetime, time as dt_time, timedelta
import functools as ft import functools as ft
import logging import logging
import re import re
import sys import sys
from typing import Any, Protocol, cast from typing import TYPE_CHECKING, Any, Protocol, cast
import voluptuous as vol import voluptuous as vol
from homeassistant.components import zone as zone_cmp
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import ( from homeassistant.const import (
ATTR_DEVICE_CLASS, ATTR_DEVICE_CLASS,
ATTR_GPS_ACCURACY, ATTR_GPS_ACCURACY,
@ -54,11 +52,20 @@ from homeassistant.exceptions import (
HomeAssistantError, HomeAssistantError,
TemplateError, 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 import dt as dt_util
from homeassistant.util.async_ import run_callback_threadsafe 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 . import config_validation as cv, entity_registry as er
from .integration_platform import async_process_integration_platforms
from .template import Template, render_complex from .template import Template, render_complex
from .trace import ( from .trace import (
TraceElement, TraceElement,
@ -76,6 +83,8 @@ ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config"
FROM_CONFIG_FORMAT = "{}_from_config" FROM_CONFIG_FORMAT = "{}_from_config"
VALIDATE_CONFIG_FORMAT = "{}_validate_config" VALIDATE_CONFIG_FORMAT = "{}_validate_config"
_LOGGER = logging.getLogger(__name__)
_PLATFORM_ALIASES: dict[str | None, str | None] = { _PLATFORM_ALIASES: dict[str | None, str | None] = {
"and": None, "and": None,
"device": "device_automation", "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): class Condition(abc.ABC):
"""Condition class.""" """Condition class."""
@ -717,6 +819,8 @@ def time(
for the opposite. "(23:59 <= now < 00:01)" would be the same as for the opposite. "(23:59 <= now < 00:01)" would be the same as
"not (00:01 <= now < 23:59)". "not (00:01 <= now < 23:59)".
""" """
from homeassistant.components.sensor import SensorDeviceClass # noqa: PLC0415
now = dt_util.now() now = dt_util.now()
now_time = now.time() now_time = now.time()
@ -824,6 +928,8 @@ def zone(
Async friendly. Async friendly.
""" """
from homeassistant.components import zone as zone_cmp # noqa: PLC0415
if zone_ent is None: if zone_ent is None:
raise ConditionErrorMessage("zone", "no zone specified") raise ConditionErrorMessage("zone", "no zone specified")
@ -1080,3 +1186,109 @@ def async_extract_devices(config: ConfigType | Template) -> set[str]:
referenced.add(device_id) referenced.add(device_id)
return referenced 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

View File

@ -67,6 +67,7 @@ _LOGGER = logging.getLogger(__name__)
# #
BASE_PRELOAD_PLATFORMS = [ BASE_PRELOAD_PLATFORMS = [
"backup", "backup",
"condition",
"config", "config",
"config_flow", "config_flow",
"diagnostics", "diagnostics",
@ -857,6 +858,11 @@ class Integration:
# True. # True.
return self.manifest.get("import_executor", 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 @cached_property
def has_services(self) -> bool: def has_services(self) -> bool:
"""Return if the integration has services.""" """Return if the integration has services."""

View File

@ -12,6 +12,7 @@ from . import (
application_credentials, application_credentials,
bluetooth, bluetooth,
codeowners, codeowners,
conditions,
config_flow, config_flow,
config_schema, config_schema,
dependencies, dependencies,
@ -38,6 +39,7 @@ INTEGRATION_PLUGINS = [
application_credentials, application_credentials,
bluetooth, bluetooth,
codeowners, codeowners,
conditions,
config_schema, config_schema,
dependencies, dependencies,
dhcp, dhcp,

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

View File

@ -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( TRIGGER_ICONS_SCHEMA = cv.schema_with_slug_keys(
vol.Schema( vol.Schema(
{ {
@ -166,6 +176,7 @@ def icon_schema(
schema = vol.Schema( schema = vol.Schema(
{ {
vol.Optional("conditions"): CONDITION_ICONS_SCHEMA,
vol.Optional("config"): DATA_ENTRY_ICONS_SCHEMA, vol.Optional("config"): DATA_ENTRY_ICONS_SCHEMA,
vol.Optional("issues"): vol.Schema( vol.Optional("issues"): vol.Schema(
{str: {"fix_flow": DATA_ENTRY_ICONS_SCHEMA}} {str: {"fix_flow": DATA_ENTRY_ICONS_SCHEMA}}

View File

@ -416,6 +416,22 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
}, },
slug_validator=translation_key_validator, 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.Optional("triggers"): cv.schema_with_slug_keys(
{ {
vol.Required("name"): translation_value_validator, vol.Required("name"): translation_value_validator,

View File

@ -75,6 +75,7 @@ from homeassistant.core import (
from homeassistant.helpers import ( from homeassistant.helpers import (
area_registry as ar, area_registry as ar,
category_registry as cr, category_registry as cr,
condition,
device_registry as dr, device_registry as dr,
entity, entity,
entity_platform, entity_platform,
@ -296,6 +297,7 @@ async def async_test_home_assistant(
# Load the registries # Load the registries
entity.async_setup(hass) entity.async_setup(hass)
loader.async_setup(hass) loader.async_setup(hass)
await condition.async_setup(hass)
await trigger.async_setup(hass) await trigger.async_setup(hass)
# setup translation cache instead of calling translation.async_setup(hass) # setup translation cache instead of calling translation.async_setup(hass)

View File

@ -19,6 +19,7 @@ from homeassistant.components.websocket_api.auth import (
TYPE_AUTH_REQUIRED, TYPE_AUTH_REQUIRED,
) )
from homeassistant.components.websocket_api.commands import ( from homeassistant.components.websocket_api.commands import (
ALL_CONDITION_DESCRIPTIONS_JSON_CACHE,
ALL_SERVICE_DESCRIPTIONS_JSON_CACHE, ALL_SERVICE_DESCRIPTIONS_JSON_CACHE,
ALL_TRIGGER_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 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("annotatedyaml.loader.load_yaml")
@patch.object(Integration, "has_triggers", return_value=True) @patch.object(Integration, "has_triggers", return_value=True)
async def test_subscribe_triggers( async def test_subscribe_triggers(

View File

@ -1,14 +1,21 @@
"""Test the condition helper.""" """Test the condition helper."""
from datetime import timedelta from datetime import timedelta
import io
from typing import Any from typing import Any
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
from freezegun import freeze_time from freezegun import freeze_time
import pytest import pytest
from pytest_unordered import unordered
import voluptuous as vol import voluptuous as vol
from homeassistant.components.device_automation import (
DOMAIN as DOMAIN_DEVICE_AUTOMATION,
)
from homeassistant.components.sensor import SensorDeviceClass 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 ( from homeassistant.const import (
ATTR_DEVICE_CLASS, ATTR_DEVICE_CLASS,
CONF_CONDITION, CONF_CONDITION,
@ -27,10 +34,12 @@ from homeassistant.helpers import (
) )
from homeassistant.helpers.template import Template from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import Integration, async_get_integration
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util 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): 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}}], "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