From 9384ec18f847226276f0189f6e7039ee6fcd8c74 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Mar 2023 15:59:51 +0100 Subject: [PATCH] Add filters to climate and light service descriptions (#86162) * Add filters to climate and light service descriptions * Allow specifying enums directly * Update service descriptions * Adjust test * Cache entity features * Lint * Improve error handling, add list of known base components * Don't allow specifying an entity feature as int --- .yamllint | 2 +- .../components/climate/services.yaml | 22 ++ homeassistant/components/light/services.yaml | 204 ++++++++++++++++++ homeassistant/helpers/selector.py | 68 ++++++ homeassistant/helpers/service.py | 120 ++++++++++- script/hassfest/services.py | 14 +- tests/helpers/test_selector.py | 51 ++++- 7 files changed, 467 insertions(+), 14 deletions(-) diff --git a/.yamllint b/.yamllint index c2f877a2b7a..e587d75d799 100644 --- a/.yamllint +++ b/.yamllint @@ -25,7 +25,7 @@ rules: comments: level: error require-starting-space: true - min-spaces-from-content: 2 + min-spaces-from-content: 1 comments-indentation: level: error document-end: diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 40d518456b4..33e114c87f5 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -6,6 +6,8 @@ set_aux_heat: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.AUX_HEAT fields: aux_heat: name: Auxiliary heating @@ -20,6 +22,8 @@ set_preset_mode: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.PRESET_MODE fields: preset_mode: name: Preset mode @@ -35,10 +39,16 @@ set_temperature: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE + - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE fields: temperature: name: Temperature description: New target temperature for HVAC. + filter: + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE selector: number: min: 0 @@ -48,6 +58,9 @@ set_temperature: target_temp_high: name: Target temperature high description: New target high temperature for HVAC. + filter: + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE advanced: true selector: number: @@ -58,6 +71,9 @@ set_temperature: target_temp_low: name: Target temperature low description: New target low temperature for HVAC. + filter: + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE advanced: true selector: number: @@ -92,6 +108,8 @@ set_humidity: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.TARGET_HUMIDITY fields: humidity: name: Humidity @@ -109,6 +127,8 @@ set_fan_mode: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.FAN_MODE fields: fan_mode: name: Fan mode @@ -152,6 +172,8 @@ set_swing_mode: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.SWING_MODE fields: swing_mode: name: Swing mode diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index b7843a2f0ec..65bf77f15c7 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -12,6 +12,9 @@ turn_on: transition: name: Transition description: Duration it takes to get to next state. + filter: + supported_features: + - light.LightEntityFeature.TRANSITION selector: number: min: 0 @@ -20,11 +23,27 @@ turn_on: rgb_color: name: Color description: The color for the light (based on RGB - red, green, blue). + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: color_rgb: rgbw_color: name: RGBW-color description: A list containing four integers between 0 and 255 representing the RGBW (red, green, blue, white) color for the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[255, 100, 100, 50]" selector: @@ -32,6 +51,14 @@ turn_on: rgbww_color: name: RGBWW-color description: A list containing five integers between 0 and 255 representing the RGBWW (red, green, blue, cold white, warm white) color for the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[255, 100, 100, 50, 70]" selector: @@ -39,6 +66,14 @@ turn_on: color_name: name: Color name description: A human readable color name. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: select: @@ -195,6 +230,14 @@ turn_on: hs_color: name: Hue/Sat color description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[300, 70]" selector: @@ -202,6 +245,14 @@ turn_on: xy_color: name: XY-color description: Color for the light in XY-format. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[0.52, 0.43]" selector: @@ -209,6 +260,15 @@ turn_on: color_temp: name: Color temperature description: Color temperature for the light in mireds. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: color_temp: min_mireds: 153 @@ -216,6 +276,15 @@ turn_on: kelvin: name: Color temperature (Kelvin) description: Color temperature for the light in Kelvin. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -228,6 +297,16 @@ turn_on: description: Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -238,6 +317,16 @@ turn_on: description: Number indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: number: min: 0 @@ -246,6 +335,16 @@ turn_on: brightness_step: name: Brightness step value description: Change brightness by an amount. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -254,6 +353,16 @@ turn_on: brightness_step_pct: name: Brightness step description: Change brightness by a percentage. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: number: min: -100 @@ -265,6 +374,10 @@ turn_on: Set the light to white mode and change its brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.WHITE advanced: true selector: number: @@ -280,6 +393,9 @@ turn_on: flash: name: Flash description: If the light should flash. + filter: + supported_features: + - light.LightEntityFeature.FLASH advanced: true selector: select: @@ -291,6 +407,9 @@ turn_on: effect: name: Effect description: Light effect. + filter: + supported_features: + - light.LightEntityFeature.EFFECT selector: text: @@ -304,6 +423,9 @@ turn_off: transition: name: Transition description: Duration it takes to get to next state. + filter: + supported_features: + - light.LightEntityFeature.TRANSITION selector: number: min: 0 @@ -312,6 +434,9 @@ turn_off: flash: name: Flash description: If the light should flash. + filter: + supported_features: + - light.LightEntityFeature.FLASH advanced: true selector: select: @@ -333,6 +458,9 @@ toggle: transition: name: Transition description: Duration it takes to get to next state. + filter: + supported_features: + - light.LightEntityFeature.TRANSITION selector: number: min: 0 @@ -341,6 +469,14 @@ toggle: rgb_color: name: RGB-color description: Color for the light in RGB-format. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[255, 100, 100]" selector: @@ -348,6 +484,14 @@ toggle: color_name: name: Color name description: A human readable color name. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: select: @@ -504,6 +648,14 @@ toggle: hs_color: name: Hue/Sat color description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[300, 70]" selector: @@ -511,6 +663,14 @@ toggle: xy_color: name: XY-color description: Color for the light in XY-format. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[0.52, 0.43]" selector: @@ -518,12 +678,30 @@ toggle: color_temp: name: Color temperature (mireds) description: Color temperature for the light in mireds. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: color_temp: kelvin: name: Color temperature (Kelvin) description: Color temperature for the light in Kelvin. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -536,6 +714,16 @@ toggle: description: Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -546,6 +734,16 @@ toggle: description: Number indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: number: min: 0 @@ -561,6 +759,9 @@ toggle: flash: name: Flash description: If the light should flash. + filter: + supported_features: + - light.LightEntityFeature.FLASH advanced: true selector: select: @@ -572,5 +773,8 @@ toggle: effect: name: Effect description: Light effect. + filter: + supported_features: + - light.LightEntityFeature.EFFECT selector: text: diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 865ea6374e1..e2f58e357ed 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -2,6 +2,8 @@ from __future__ import annotations from collections.abc import Callable, Mapping, Sequence +from enum import IntFlag +from functools import cache from typing import Any, Generic, Literal, TypedDict, TypeVar, cast from uuid import UUID @@ -79,6 +81,69 @@ class Selector(Generic[_T]): return {"selector": {self.selector_type: self.config}} +@cache +def _entity_features() -> dict[str, type[IntFlag]]: + """Return a cached lookup of entity feature enums.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntityFeature, + ) + from homeassistant.components.calendar import CalendarEntityFeature + from homeassistant.components.camera import CameraEntityFeature + from homeassistant.components.climate import ClimateEntityFeature + from homeassistant.components.cover import CoverEntityFeature + from homeassistant.components.fan import FanEntityFeature + from homeassistant.components.humidifier import HumidifierEntityFeature + from homeassistant.components.light import LightEntityFeature + from homeassistant.components.lock import LockEntityFeature + from homeassistant.components.media_player import MediaPlayerEntityFeature + from homeassistant.components.remote import RemoteEntityFeature + from homeassistant.components.siren import SirenEntityFeature + from homeassistant.components.update import UpdateEntityFeature + from homeassistant.components.vacuum import VacuumEntityFeature + from homeassistant.components.water_heater import WaterHeaterEntityFeature + + return { + "AlarmControlPanelEntityFeature": AlarmControlPanelEntityFeature, + "CalendarEntityFeature": CalendarEntityFeature, + "CameraEntityFeature": CameraEntityFeature, + "ClimateEntityFeature": ClimateEntityFeature, + "CoverEntityFeature": CoverEntityFeature, + "FanEntityFeature": FanEntityFeature, + "HumidifierEntityFeature": HumidifierEntityFeature, + "LightEntityFeature": LightEntityFeature, + "LockEntityFeature": LockEntityFeature, + "MediaPlayerEntityFeature": MediaPlayerEntityFeature, + "RemoteEntityFeature": RemoteEntityFeature, + "SirenEntityFeature": SirenEntityFeature, + "UpdateEntityFeature": UpdateEntityFeature, + "VacuumEntityFeature": VacuumEntityFeature, + "WaterHeaterEntityFeature": WaterHeaterEntityFeature, + } + + +def _validate_supported_feature(supported_feature: int | str) -> int: + """Validate a supported feature and resolve an enum string to its value.""" + + if isinstance(supported_feature, int): + return supported_feature + + known_entity_features = _entity_features() + + try: + _, enum, feature = supported_feature.split(".", 2) + except ValueError as exc: + raise vol.Invalid( + f"Invalid supported feature '{supported_feature}', expected " + ".." + ) from exc + + try: + return cast(int, getattr(known_entity_features[enum], feature).value) + except (AttributeError, KeyError) as exc: + raise vol.Invalid(f"Unknown supported feature '{supported_feature}'") from exc + + ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( { # Integration that provided the entity @@ -87,6 +152,8 @@ ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( vol.Optional("domain"): vol.All(cv.ensure_list, [str]), # Device class of the entity vol.Optional("device_class"): vol.All(cv.ensure_list, [str]), + # Features supported by the entity + vol.Optional("supported_features"): [vol.All(str, _validate_supported_feature)], } ) @@ -97,6 +164,7 @@ class EntityFilterSelectorConfig(TypedDict, total=False): integration: str domain: str | list[str] device_class: str | list[str] + supported_features: list[str] DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 9f6f65f1d2d..33c677454bc 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -4,9 +4,11 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Iterable import dataclasses -from functools import partial, wraps +from enum import Enum +from functools import cache, partial, wraps import logging -from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, TypeVar +from types import ModuleType +from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, TypeVar, cast import voluptuous as vol @@ -42,6 +44,7 @@ from . import ( entity_registry, template, ) +from .selector import TargetSelector from .typing import ConfigType, TemplateVarsType if TYPE_CHECKING: @@ -58,6 +61,112 @@ _LOGGER = logging.getLogger(__name__) SERVICE_DESCRIPTION_CACHE = "service_description_cache" +@cache +def _base_components() -> dict[str, ModuleType]: + """Return a cached lookup of base components.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components import ( + alarm_control_panel, + calendar, + camera, + climate, + cover, + fan, + humidifier, + light, + lock, + media_player, + remote, + siren, + update, + vacuum, + water_heater, + ) + + return { + "alarm_control_panel": alarm_control_panel, + "calendar": calendar, + "camera": camera, + "climate": climate, + "cover": cover, + "fan": fan, + "humidifier": humidifier, + "light": light, + "lock": lock, + "media_player": media_player, + "remote": remote, + "siren": siren, + "update": update, + "vacuum": vacuum, + "water_heater": water_heater, + } + + +def _validate_option_or_feature(option_or_feature: str, label: str) -> Any: + """Validate attribute option or supported feature.""" + try: + domain, enum, option = option_or_feature.split(".", 2) + except ValueError as exc: + raise vol.Invalid( + f"Invalid {label} '{option_or_feature}', expected " + ".." + ) from exc + + base_components = _base_components() + if not (base_component := base_components.get(domain)): + raise vol.Invalid(f"Unknown base component '{domain}'") + + try: + attribute_enum = getattr(base_component, enum) + except AttributeError as exc: + raise vol.Invalid(f"Unknown {label} enum '{domain}.{enum}'") from exc + + if not issubclass(attribute_enum, Enum): + raise vol.Invalid(f"Expected {label} '{domain}.{enum}' to be an enum") + + try: + return getattr(attribute_enum, option).value + except AttributeError as exc: + raise vol.Invalid(f"Unknown {label} '{enum}.{option}'") from exc + + +def validate_attribute_option(attribute_option: str) -> Any: + """Validate attribute option.""" + return _validate_option_or_feature(attribute_option, "attribute option") + + +def validate_supported_feature(supported_feature: str) -> Any: + """Validate supported feature.""" + return _validate_option_or_feature(supported_feature, "supported feature") + + +# Basic schemas which translate attribute and supported feature enum names +# to their values. Full validation is done by hassfest.services +_FIELD_SCHEMA = vol.Schema( + { + vol.Optional("filter"): { + vol.Optional("attribute"): { + vol.Required(str): [vol.All(str, validate_attribute_option)], + }, + vol.Optional("supported_features"): [ + vol.All(str, validate_supported_feature) + ], + }, + }, + extra=vol.ALLOW_EXTRA, +) + +_SERVICE_SCHEMA = vol.Schema( + { + vol.Optional("target"): vol.Any(TargetSelector.CONFIG_SCHEMA, None), + vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}), + }, + extra=vol.ALLOW_EXTRA, +) + +_SERVICES_SCHEMA = vol.Schema({cv.slug: _SERVICE_SCHEMA}) + + class ServiceParams(TypedDict): """Type for service call parameters.""" @@ -421,13 +530,16 @@ async def async_extract_config_entry_ids( def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: """Load services file for an integration.""" try: - return load_yaml(str(integration.file_path / "services.yaml")) + return cast( + JSON_TYPE, + _SERVICES_SCHEMA(load_yaml(str(integration.file_path / "services.yaml"))), + ) except FileNotFoundError: _LOGGER.warning( "Unable to find services.yaml for the %s integration", integration.domain ) return {} - except HomeAssistantError: + except (HomeAssistantError, vol.Invalid): _LOGGER.warning( "Unable to parse services.yaml for the %s integration", integration.domain ) diff --git a/script/hassfest/services.py b/script/hassfest/services.py index d9351b80370..bb969313967 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -10,7 +10,7 @@ from voluptuous.humanize import humanize_error from homeassistant.const import CONF_SELECTOR from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv, selector, service from homeassistant.util.yaml import load_yaml from .model import Config, Integration @@ -33,6 +33,14 @@ FIELD_SCHEMA = vol.Schema( vol.Optional("required"): bool, vol.Optional("advanced"): bool, vol.Optional(CONF_SELECTOR): selector.validate_selector, + vol.Optional("filter"): { + vol.Optional("attribute"): { + vol.Required(str): [vol.All(str, service.validate_attribute_option)], + }, + vol.Optional("supported_features"): [ + vol.All(str, service.validate_supported_feature) + ], + }, } ) @@ -40,9 +48,7 @@ SERVICE_SCHEMA = vol.Schema( { vol.Required("description"): str, vol.Optional("name"): str, - vol.Optional("target"): vol.Any( - selector.TargetSelector.CONFIG_SCHEMA, None # pylint: disable=no-member - ), + vol.Optional("target"): vol.Any(selector.TargetSelector.CONFIG_SCHEMA, None), vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), } ) diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 6f1cb2baef7..0a24eac38c6 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -69,10 +69,9 @@ def _test_selector( # Serialize selector selector_instance = selector.selector({selector_type: schema}) - assert ( - selector.selector(selector_instance.serialize()["selector"]).config - == selector_instance.config - ) + assert selector_instance.serialize() == { + "selector": {selector_type: selector_instance.config} + } # Test serialized selector can be dumped to YAML yaml.dump(selector_instance.serialize()) @@ -227,6 +226,29 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> ("light.abc123", "binary_sensor.abc123", FAKE_UUID), (None,), ), + ( + { + "filter": [ + {"supported_features": ["light.LightEntityFeature.EFFECT"]}, + ] + }, + ("light.abc123", "blah.blah", FAKE_UUID), + (None,), + ), + ( + { + "filter": [ + { + "supported_features": [ + "light.LightEntityFeature.EFFECT", + "light.LightEntityFeature.TRANSITION", + ] + }, + ] + }, + ("light.abc123", "blah.blah", FAKE_UUID), + (None,), + ), ), ) def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -234,6 +256,25 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("entity", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + "schema", + ( + # Feature should be string specifying an enum member, not an int + {"filter": [{"supported_features": [1]}]}, + # Invalid feature + {"filter": [{"supported_features": ["blah"]}]}, + # Unknown feature enum + {"filter": [{"supported_features": ["blah.FooEntityFeature.blah"]}]}, + # Unknown feature enum member + {"filter": [{"supported_features": ["light.LightEntityFeature.blah"]}]}, + ), +) +def test_entity_selector_schema_error(schema) -> None: + """Test number selector.""" + with pytest.raises(vol.Invalid): + selector.validate_selector({"entity": schema}) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), ( @@ -359,7 +400,7 @@ def test_addon_selector_schema(schema, valid_selections, invalid_selections) -> @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), - (({}, (1, "one", None), ()),), # Everything can be coarced to bool + (({}, (1, "one", None), ()),), # Everything can be coerced to bool ) def test_boolean_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test boolean selector."""