1
mirror of https://github.com/home-assistant/core synced 2024-09-18 19:55:20 +02:00

Allow area, device, and entity selectors to optionally support multiple selections like target selector (#63138)

* Allow area, device, and entity selectors to optionally support multiple selections like target selector

* Update according to code review comments

* Adjust tests

* Update according to review comments

* Tweak error message for multiple entities

Co-authored-by: Erik <erik@montnemery.com>
This commit is contained in:
Richard T. Schaefer 2022-03-03 03:35:06 -06:00 committed by GitHub
parent 58c00da8a0
commit 15580281a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 90 additions and 44 deletions

View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -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):