1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-27 14:31:13 +00:00

Disable experimental conditions according to labs flag setting (#157345)

This commit is contained in:
Erik Montnemery
2025-11-26 17:59:12 +01:00
committed by GitHub
parent ec77add1a6
commit 21d914c8ca
4 changed files with 228 additions and 32 deletions

View File

@@ -117,6 +117,10 @@ SERVICE_TRIGGER = "trigger"
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
_EXPERIMENTAL_CONDITION_PLATFORMS = {
"light",
}
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
@@ -131,6 +135,19 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
}
@callback
def is_disabled_experimental_condition(hass: HomeAssistant, platform: str) -> bool:
"""Check if the platform is a disabled experimental condition platform."""
return (
platform in _EXPERIMENTAL_CONDITION_PLATFORMS
and not labs.async_is_preview_feature_enabled(
hass,
DOMAIN,
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG,
)
)
@callback
def is_disabled_experimental_trigger(hass: HomeAssistant, platform: str) -> bool:
"""Check if the platform is a disabled experimental trigger platform."""

View File

@@ -110,6 +110,9 @@ INPUT_ENTITY_ID = re.compile(
CONDITION_DESCRIPTION_CACHE: HassKey[dict[str, dict[str, Any] | None]] = HassKey(
"condition_description_cache"
)
CONDITION_DISABLED_CONDITIONS: HassKey[set[str]] = HassKey(
"condition_disabled_conditions"
)
CONDITION_PLATFORM_SUBSCRIPTIONS: HassKey[
list[Callable[[set[str]], Coroutine[Any, Any, None]]]
] = HassKey("condition_platform_subscriptions")
@@ -151,9 +154,27 @@ _CONDITIONS_DESCRIPTION_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant) -> None:
"""Set up the condition helper."""
from homeassistant.components import automation, labs # noqa: PLC0415
hass.data[CONDITION_DESCRIPTION_CACHE] = {}
hass.data[CONDITION_DISABLED_CONDITIONS] = set()
hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS] = []
hass.data[CONDITIONS] = {}
@callback
def new_triggers_conditions_listener() -> None:
"""Handle new_triggers_conditions flag change."""
# Invalidate the cache
hass.data[CONDITION_DESCRIPTION_CACHE] = {}
hass.data[CONDITION_DISABLED_CONDITIONS] = set()
labs.async_listen(
hass,
automation.DOMAIN,
automation.NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG,
new_triggers_conditions_listener,
)
await async_process_integration_platforms(
hass, "condition", _register_condition_platform, wait_for_platforms=True
)
@@ -352,11 +373,21 @@ def trace_condition_function(condition: ConditionCheckerType) -> ConditionChecke
async def _async_get_condition_platform(
hass: HomeAssistant, condition_key: str
) -> tuple[str, ConditionProtocol | None]:
from homeassistant.components import automation # noqa: PLC0415
platform_and_sub_type = condition_key.split(".")
platform: str | None = platform_and_sub_type[0]
platform = _PLATFORM_ALIASES.get(platform, platform)
if platform is None:
return "", None
if automation.is_disabled_experimental_condition(hass, platform):
raise vol.Invalid(
f"Condition '{condition_key}' requires the experimental 'New triggers and "
"conditions' feature to be enabled in Home Assistant Labs settings "
f"(feature flag: '{automation.NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG}')"
)
try:
integration = await async_get_integration(hass, platform)
except IntegrationNotFound:
@@ -1209,6 +1240,8 @@ async def async_get_all_descriptions(
hass: HomeAssistant,
) -> dict[str, dict[str, Any] | None]:
"""Return descriptions (i.e. user documentation) for all conditions."""
from homeassistant.components import automation # noqa: PLC0415
descriptions_cache = hass.data[CONDITION_DESCRIPTION_CACHE]
conditions = hass.data[CONDITIONS]
@@ -1217,7 +1250,12 @@ async def async_get_all_descriptions(
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:
# mypy complains: Invalid index type "HassKey[set[str]]" for "HassDict"
if (
previous_all_conditions | hass.data[CONDITION_DISABLED_CONDITIONS] # type: ignore[index]
== all_conditions
):
return descriptions_cache
# Files we loaded for missing descriptions
@@ -1257,6 +1295,9 @@ async def async_get_all_descriptions(
new_descriptions_cache = descriptions_cache.copy()
for missing_condition in missing_conditions:
domain = conditions[missing_condition]
if automation.is_disabled_experimental_condition(hass, domain):
hass.data[CONDITION_DISABLED_CONDITIONS].add(missing_condition)
continue
if (
yaml_description := new_conditions_descriptions.get(domain, {}).get(

View File

@@ -1,9 +1,13 @@
"""Test light conditions."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from homeassistant.components import automation
from homeassistant.const import (
ATTR_LABEL_ID,
CONF_CONDITION,
CONF_OPTIONS,
CONF_TARGET,
@@ -83,6 +87,39 @@ async def has_call_after_trigger(
return has_calls
@pytest.fixture(name="enable_experimental_triggers_conditions")
def enable_experimental_triggers_conditions() -> Generator[None]:
"""Enable experimental triggers and conditions."""
with patch(
"homeassistant.components.labs.async_is_preview_feature_enabled",
return_value=True,
):
yield
@pytest.mark.parametrize(
"condition",
[
"light.is_off",
"light.is_on",
],
)
async def test_light_conditions_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
) -> None:
"""Test the light conditions are gated by the labs flag."""
await setup_automation_with_light_condition(
hass, condition=condition, target={ATTR_LABEL_ID: "test_label"}, behavior="any"
)
assert (
"Unnamed automation failed to setup conditions and has been disabled: "
f"Condition '{condition}' requires the experimental 'New triggers and "
"conditions' feature to be enabled in Home Assistant Labs settings "
"(feature flag: 'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("light"),
@@ -166,6 +203,7 @@ async def test_light_state_condition_behavior_any(
assert not await has_call_after_trigger(hass, service_calls)
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("light"),

View File

@@ -13,6 +13,7 @@ import voluptuous as vol
from homeassistant.components.device_automation import (
DOMAIN as DOMAIN_DEVICE_AUTOMATION,
)
from homeassistant.components.light import DOMAIN as DOMAIN_LIGHT
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
@@ -47,6 +48,7 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.yaml.loader import parse_yaml
from tests.common import MockModule, MockPlatform, mock_integration, mock_platform
from tests.typing import WebSocketGenerator
def assert_element(trace_element, expected_element, path):
@@ -2500,7 +2502,9 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None
],
)
async def test_async_get_all_descriptions(
hass: HomeAssistant, sun_condition_descriptions: str
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
sun_condition_descriptions: str,
) -> None:
"""Test async_get_all_descriptions."""
device_automation_condition_descriptions = """
@@ -2514,6 +2518,18 @@ async def test_async_get_all_descriptions(
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
"""
light_condition_descriptions = """
is_off:
target:
entity:
domain: light
is_on:
target:
entity:
domain: light
"""
ws_client = await hass_ws_client(hass)
assert await async_setup_component(hass, DOMAIN_SUN, {})
assert await async_setup_component(hass, DOMAIN_SYSTEM_HEALTH, {})
@@ -2522,6 +2538,8 @@ async def test_async_get_all_descriptions(
def _load_yaml(fname, secrets=None):
if fname.endswith("device_automation/conditions.yaml"):
condition_descriptions = device_automation_condition_descriptions
elif fname.endswith("light/conditions.yaml"):
condition_descriptions = light_condition_descriptions
elif fname.endswith("sun/conditions.yaml"):
condition_descriptions = sun_condition_descriptions
with io.StringIO(condition_descriptions) as file:
@@ -2549,7 +2567,7 @@ async def test_async_get_all_descriptions(
)
# system_health does not have conditions and should not be in descriptions
assert descriptions == {
expected_descriptions = {
"sun": {
"fields": {
"after": {
@@ -2579,6 +2597,7 @@ async def test_async_get_all_descriptions(
}
}
}
assert descriptions == expected_descriptions
# Verify the cache returns the same object
assert await condition.async_get_all_descriptions(hass) is descriptions
@@ -2596,35 +2615,8 @@ async def test_async_get_all_descriptions(
):
new_descriptions = await condition.async_get_all_descriptions(hass)
assert new_descriptions is not descriptions
assert new_descriptions == {
"sun": {
"fields": {
"after": {
"example": "sunrise",
"selector": {
"select": {
"custom_value": False,
"multiple": False,
"options": ["sunrise", "sunset"],
"sort": False,
}
},
},
"after_offset": {"selector": {"time": {}}},
"before": {
"example": "sunrise",
"selector": {
"select": {
"custom_value": False,
"multiple": False,
"options": ["sunrise", "sunset"],
"sort": False,
}
},
},
"before_offset": {"selector": {"time": {}}},
}
},
# The device automation conditions should now be present
expected_descriptions |= {
"device": {
"fields": {
"entity": {
@@ -2644,6 +2636,114 @@ async def test_async_get_all_descriptions(
}
},
}
assert new_descriptions == expected_descriptions
# Verify the cache returns the same object
assert await condition.async_get_all_descriptions(hass) is new_descriptions
# Load the light integration and check a new cache object is created
assert await async_setup_component(hass, DOMAIN_LIGHT, {})
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
# No light conditions added, they are gated by the automation.new_triggers_conditions
# labs flag
assert new_descriptions == expected_descriptions
# Verify the cache returns the same object
assert await condition.async_get_all_descriptions(hass) is new_descriptions
# Enable the new_triggers_conditions flag and verify light conditions are loaded
assert await async_setup_component(hass, "labs", {})
await ws_client.send_json_auto_id(
{
"type": "labs/update",
"domain": "automation",
"preview_feature": "new_triggers_conditions",
"enabled": True,
}
)
msg = await ws_client.receive_json()
assert msg["success"]
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
# The light conditions should now be present
assert new_descriptions == expected_descriptions | {
"light.is_off": {
"fields": {},
"target": {
"entity": [
{
"domain": [
"light",
],
},
],
},
},
"light.is_on": {
"fields": {},
"target": {
"entity": [
{
"domain": [
"light",
],
},
],
},
},
}
# Verify the cache returns the same object
assert await condition.async_get_all_descriptions(hass) is new_descriptions
# Disable the new_triggers_conditions flag and verify light conditions are removed
assert await async_setup_component(hass, "labs", {})
await ws_client.send_json_auto_id(
{
"type": "labs/update",
"domain": "automation",
"preview_feature": "new_triggers_conditions",
"enabled": False,
}
)
msg = await ws_client.receive_json()
assert msg["success"]
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
# The light conditions should no longer be present
assert new_descriptions == expected_descriptions
# Verify the cache returns the same object
assert await condition.async_get_all_descriptions(hass) is new_descriptions