diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 0ba5ee363e9..fe4709a3021 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -79,27 +79,27 @@ class Selector(Generic[_T]): return {"selector": {self.selector_type: self.config}} -SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA = vol.Schema( +ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( { # Integration that provided the entity vol.Optional("integration"): str, # Domain the entity belongs to - vol.Optional("domain"): vol.Any(str, [str]), + vol.Optional("domain"): vol.All(cv.ensure_list, [str]), # Device class of the entity - vol.Optional("device_class"): str, + vol.Optional("device_class"): vol.All(cv.ensure_list, [str]), } ) -class SingleEntitySelectorConfig(TypedDict, total=False): +class EntityFilterSelectorConfig(TypedDict, total=False): """Class to represent a single entity selector config.""" integration: str domain: str | list[str] - device_class: str + device_class: str | list[str] -SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema( +DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( { # Integration linked to it with a config entry vol.Optional("integration"): str, @@ -108,18 +108,21 @@ SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema( # Model of device vol.Optional("model"): str, # Device has to contain entities matching this selector - vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, + vol.Optional("entity"): vol.All( + cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] + ), } ) -class SingleDeviceSelectorConfig(TypedDict, total=False): +class DeviceFilterSelectorConfig(TypedDict, total=False): """Class to represent a single device selector config.""" integration: str manufacturer: str model: str - entity: SingleEntitySelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + filter: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] class ActionSelectorConfig(TypedDict): @@ -176,8 +179,8 @@ class AddonSelector(Selector[AddonSelectorConfig]): class AreaSelectorConfig(TypedDict, total=False): """Class to represent an area selector config.""" - entity: SingleEntitySelectorConfig - device: SingleDeviceSelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] multiple: bool @@ -189,8 +192,14 @@ class AreaSelector(Selector[AreaSelectorConfig]): CONFIG_SCHEMA = vol.Schema( { - vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, - vol.Optional("device"): SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA, + vol.Optional("entity"): vol.All( + cv.ensure_list, + [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + vol.Optional("device"): vol.All( + cv.ensure_list, + [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], + ), vol.Optional("multiple", default=False): cv.boolean, } ) @@ -399,7 +408,7 @@ class DeviceSelectorConfig(TypedDict, total=False): integration: str manufacturer: str model: str - entity: SingleEntitySelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] multiple: bool @@ -409,8 +418,14 @@ class DeviceSelector(Selector[DeviceSelectorConfig]): selector_type = "device" - CONFIG_SCHEMA = SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA.extend( - {vol.Optional("multiple", default=False): cv.boolean} + CONFIG_SCHEMA = DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Optional("multiple", default=False): cv.boolean, + vol.Optional("filter"): vol.All( + cv.ensure_list, + [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + }, ) def __init__(self, config: DeviceSelectorConfig | None = None) -> None: @@ -457,7 +472,7 @@ class DurationSelector(Selector[DurationSelectorConfig]): return cast(dict[str, float], data) -class EntitySelectorConfig(SingleEntitySelectorConfig, total=False): +class EntitySelectorConfig(EntityFilterSelectorConfig, total=False): """Class to represent an entity selector config.""" exclude_entities: list[str] @@ -471,11 +486,15 @@ class EntitySelector(Selector[EntitySelectorConfig]): selector_type = "entity" - CONFIG_SCHEMA = SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("exclude_entities"): [str], vol.Optional("include_entities"): [str], vol.Optional("multiple", default=False): cv.boolean, + vol.Optional("filter"): vol.All( + cv.ensure_list, + [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + ), } ) @@ -784,8 +803,8 @@ class SelectSelector(Selector[SelectSelectorConfig]): class TargetSelectorConfig(TypedDict, total=False): """Class to represent a target selector config.""" - entity: SingleEntitySelectorConfig - device: SingleDeviceSelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] class StateSelectorConfig(TypedDict, total=False): @@ -832,8 +851,14 @@ class TargetSelector(Selector[TargetSelectorConfig]): CONFIG_SCHEMA = vol.Schema( { - vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, - vol.Optional("device"): SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA, + vol.Optional("entity"): vol.All( + cv.ensure_list, + [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + vol.Optional("device"): vol.All( + cv.ensure_list, + [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], + ), } ) diff --git a/tests/components/blueprint/snapshots/test_importer.ambr b/tests/components/blueprint/snapshots/test_importer.ambr index 1401a8f1741..6e5648b54d9 100644 --- a/tests/components/blueprint/snapshots/test_importer.ambr +++ b/tests/components/blueprint/snapshots/test_importer.ambr @@ -18,9 +18,13 @@ 'description': 'The light(s) to control', 'selector': dict({ 'target': OrderedDict({ - 'entity': OrderedDict({ - 'domain': 'light', - }), + 'entity': list([ + OrderedDict({ + 'domain': list([ + 'light', + ]), + }), + ]), }), }), }), @@ -111,9 +115,13 @@ 'description': 'The light(s) to control', 'selector': dict({ 'target': OrderedDict({ - 'entity': OrderedDict({ - 'domain': 'light', - }), + 'entity': list([ + OrderedDict({ + 'domain': list([ + 'light', + ]), + }), + ]), }), }), }), @@ -191,8 +199,12 @@ 'name': 'Motion Sensor', 'selector': dict({ 'entity': OrderedDict({ - 'domain': 'binary_sensor', - 'device_class': 'motion', + 'domain': list([ + 'binary_sensor', + ]), + 'device_class': list([ + 'motion', + ]), 'multiple': False, }), }), @@ -201,7 +213,9 @@ 'name': 'Light', 'selector': dict({ 'entity': OrderedDict({ - 'domain': 'light', + 'domain': list([ + 'light', + ]), 'multiple': False, }), }), diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 75e8a8dc542..a5fa5c7a50d 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -92,6 +92,17 @@ def _test_selector( (None,), ), ({"entity": {"device_class": "motion"}}, ("abc123",), (None,)), + ({"entity": {"device_class": ["motion", "temperature"]}}, ("abc123",), (None,)), + ( + { + "entity": [ + {"domain": "light"}, + {"domain": "binary_sensor", "device_class": "motion"}, + ] + }, + ("abc123",), + (None,), + ), ( { "integration": "zha", @@ -107,6 +118,35 @@ def _test_selector( (["abc123", "def456"],), ("abc123", None, ["abc123", None]), ), + ( + { + "filter": { + "integration": "zha", + "manufacturer": "mock-manuf", + "model": "mock-model", + } + }, + ("abc123",), + (None,), + ), + ( + { + "filter": [ + { + "integration": "zha", + "manufacturer": "mock-manuf", + "model": "mock-model", + }, + { + "integration": "matter", + "manufacturer": "other-mock-manuf", + "model": "other-mock-model", + }, + ] + }, + ("abc123",), + (None,), + ), ), ) def test_device_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -126,6 +166,11 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> (None, "dog.abc123"), ), ({"device_class": "motion"}, ("sensor.abc123", FAKE_UUID), (None, "abc123")), + ( + {"device_class": ["motion", "temperature"]}, + ("sensor.abc123", FAKE_UUID), + (None, "abc123"), + ), ( {"integration": "zha", "domain": "light"}, ("light.abc123", FAKE_UUID), @@ -167,6 +212,21 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> ["sensor.abc123", "sensor.ghi789"], ), ), + ( + {"filter": {"domain": "light"}}, + ("light.abc123", FAKE_UUID), + (None,), + ), + ( + { + "filter": [ + {"domain": "light"}, + {"domain": "binary_sensor", "device_class": "motion"}, + ] + }, + ("light.abc123", "binary_sensor.abc123", FAKE_UUID), + (None,), + ), ), ) def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -196,11 +256,31 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> ("abc123",), (None,), ), + ( + { + "entity": [ + {"domain": "light"}, + {"domain": "binary_sensor", "device_class": "motion"}, + ] + }, + ("abc123",), + (None,), + ), ( {"device": {"integration": "demo", "model": "mock-model"}}, ("abc123",), (None,), ), + ( + { + "device": [ + {"integration": "demo", "model": "mock-model"}, + {"integration": "other-demo", "model": "other-mock-model"}, + ] + }, + ("abc123",), + (None,), + ), ( { "entity": {"domain": "binary_sensor", "device_class": "motion"}, @@ -345,6 +425,16 @@ def test_state_selector_schema(schema, valid_selections, invalid_selections) -> ({"entity": {}}, (), ()), ({"entity": {"domain": "light"}}, (), ()), ({"entity": {"domain": "binary_sensor", "device_class": "motion"}}, (), ()), + ( + { + "entity": [ + {"domain": "light"}, + {"domain": "binary_sensor", "device_class": "motion"}, + ] + }, + (), + (), + ), ( { "entity": { @@ -357,6 +447,16 @@ def test_state_selector_schema(schema, valid_selections, invalid_selections) -> (), ), ({"device": {"integration": "demo", "model": "mock-model"}}, (), ()), + ( + { + "device": [ + {"integration": "demo", "model": "mock-model"}, + {"integration": "other-demo", "model": "other-mock-model"}, + ], + }, + (), + (), + ), ( { "entity": {"domain": "binary_sensor", "device_class": "motion"},