diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 7f33ec1f1ec..78dae80ebfb 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -729,8 +729,11 @@ _FAKE_UUID_4_HEX = re.compile(r"^[0-9a-f]{32}$") def fake_uuid4_hex(value: Any) -> str: """Validate a fake v4 UUID generated by random_uuid_hex.""" - if not _FAKE_UUID_4_HEX.match(value): - raise vol.Invalid("Invalid UUID") + try: + if not _FAKE_UUID_4_HEX.match(value): + raise vol.Invalid("Invalid UUID") + except TypeError as exc: + raise vol.Invalid("Invalid UUID") from exc return cast(str, value) # Pattern.match throws if input is not a string diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index f280feb83b2..861b2143cb9 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -8,7 +8,7 @@ from typing import Any, cast import voluptuous as vol from homeassistant.const import CONF_MODE, CONF_UNIT_OF_MEASUREMENT -from homeassistant.core import split_entity_id +from homeassistant.core import split_entity_id, valid_entity_id from homeassistant.util import decorator from . import config_validation as cv @@ -74,44 +74,54 @@ class Selector: return {"selector": {self.selector_type: self.config}} +SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA = vol.Schema( + { + # Integration that provided the entity + vol.Optional("integration"): str, + # Domain the entity belongs to + vol.Optional("domain"): str, + # Device class of the entity + vol.Optional("device_class"): str, + } +) + + @SELECTORS.register("entity") class EntitySelector(Selector): - """Selector of a single entity.""" + """Selector of a single or list of entities.""" selector_type = "entity" - CONFIG_SCHEMA = vol.Schema( - { - # Integration that provided the entity - vol.Optional("integration"): str, - # Domain the entity belongs to - vol.Optional("domain"): str, - # Device class of the entity - vol.Optional("device_class"): str, - } + CONFIG_SCHEMA = SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA.extend( + {vol.Optional("multiple", default=False): cv.boolean} ) - def __call__(self, data: Any) -> str: + def __call__(self, data: Any) -> str | list[str]: """Validate the passed selection.""" - try: - entity_id = cv.entity_id(data) - domain = split_entity_id(entity_id)[0] - except vol.Invalid: - # Not a valid entity_id, maybe it's an entity entry id - return cv.entity_id_or_uuid(cv.string(data)) - else: - if "domain" in self.config and domain != self.config["domain"]: - raise vol.Invalid( - f"Entity {entity_id} belongs to domain {domain}, " - f"expected {self.config['domain']}" - ) - return entity_id + def validate(e_or_u: str) -> str: + e_or_u = cv.entity_id_or_uuid(e_or_u) + if not valid_entity_id(e_or_u): + return e_or_u + if allowed_domain := self.config.get("domain"): + domain = split_entity_id(e_or_u)[0] + if domain != allowed_domain: + raise vol.Invalid( + f"Entity {e_or_u} belongs to domain {domain}, " + f"expected {allowed_domain}" + ) + return e_or_u + + if not self.config["multiple"]: + return validate(data) + if not isinstance(data, list): + raise vol.Invalid("Value should be a list") + return cast(list, vol.Schema([validate])(data)) # Output is a list @SELECTORS.register("device") class DeviceSelector(Selector): - """Selector of a single device.""" + """Selector of a single or list of devices.""" selector_type = "device" @@ -124,31 +134,41 @@ class DeviceSelector(Selector): # Model of device vol.Optional("model"): str, # Device has to contain entities matching this selector - vol.Optional("entity"): EntitySelector.CONFIG_SCHEMA, + vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, + vol.Optional("multiple", default=False): cv.boolean, } ) - def __call__(self, data: Any) -> str: + def __call__(self, data: Any) -> str | list[str]: """Validate the passed selection.""" - return cv.string(data) + if not self.config["multiple"]: + return cv.string(data) + if not isinstance(data, list): + raise vol.Invalid("Value should be a list") + return [cv.string(val) for val in data] @SELECTORS.register("area") class AreaSelector(Selector): - """Selector of a single area.""" + """Selector of a single or list of areas.""" selector_type = "area" CONFIG_SCHEMA = vol.Schema( { - vol.Optional("entity"): EntitySelector.CONFIG_SCHEMA, + vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, vol.Optional("device"): DeviceSelector.CONFIG_SCHEMA, + vol.Optional("multiple", default=False): cv.boolean, } ) - def __call__(self, data: Any) -> str: + def __call__(self, data: Any) -> str | list[str]: """Validate the passed selection.""" - return cv.string(data) + if not self.config["multiple"]: + return cv.string(data) + if not isinstance(data, list): + raise vol.Invalid("Value should be a list") + return [cv.string(val) for val in data] @SELECTORS.register("number") diff --git a/tests/components/blueprint/test_importer.py b/tests/components/blueprint/test_importer.py index 382363aa560..623d1e9ebbf 100644 --- a/tests/components/blueprint/test_importer.py +++ b/tests/components/blueprint/test_importer.py @@ -25,13 +25,14 @@ COMMUNITY_POST_INPUTS = { "integration": "zha", "manufacturer": "IKEA of Sweden", "model": "TRADFRI remote control", + "multiple": False, } }, }, "light": { "name": "Light(s)", "description": "The light(s) to control", - "selector": {"target": {"entity": {"domain": "light"}}}, + "selector": {"target": {"entity": {"domain": "light", "multiple": False}}}, }, "force_brightness": { "name": "Force turn on brightness", @@ -218,10 +219,17 @@ async def test_fetch_blueprint_from_github_gist_url(hass, aioclient_mock): "motion_entity": { "name": "Motion Sensor", "selector": { - "entity": {"domain": "binary_sensor", "device_class": "motion"} + "entity": { + "domain": "binary_sensor", + "device_class": "motion", + "multiple": False, + } }, }, - "light_entity": {"name": "Light", "selector": {"entity": {"domain": "light"}}}, + "light_entity": { + "name": "Light", + "selector": {"entity": {"domain": "light", "multiple": False}}, + }, } assert imported_blueprint.suggested_filename == "balloob/motion_light" assert imported_blueprint.blueprint.metadata["source_url"] == url diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index edf856d6843..0af8a050ce8 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -37,12 +37,6 @@ def test_invalid_base_schema(schema): selector.validate_selector(schema) -def test_validate_selector(): - """Test return is the same as input.""" - schema = {"device": {"manufacturer": "mock-manuf", "model": "mock-model"}} - assert schema == selector.validate_selector(schema) - - def _test_selector( selector_type, schema, valid_selections, invalid_selections, converter=None ): @@ -99,6 +93,11 @@ def _test_selector( ("abc123",), (None,), ), + ( + {"multiple": True}, + (["abc123", "def456"],), + ("abc123", None, ["abc123", None]), + ), ), ) def test_device_selector_schema(schema, valid_selections, invalid_selections): @@ -123,6 +122,17 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections): ("binary_sensor.abc123", FAKE_UUID), (None, "sensor.abc123"), ), + ( + {"multiple": True, "domain": "sensor"}, + (["sensor.abc123", "sensor.def456"], ["sensor.abc123", FAKE_UUID]), + ( + "sensor.abc123", + FAKE_UUID, + None, + "abc123", + ["sensor.abc123", "light.def456"], + ), + ), ), ) def test_entity_selector_schema(schema, valid_selections, invalid_selections): @@ -165,6 +175,11 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections): ("abc123",), (None,), ), + ( + {"multiple": True}, + ((["abc123", "def456"],)), + (None, "abc123", ["abc123", None]), + ), ), ) def test_area_selector_schema(schema, valid_selections, invalid_selections):