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:
parent
58c00da8a0
commit
15580281a3
4 changed files with 90 additions and 44 deletions
|
@ -729,8 +729,11 @@ _FAKE_UUID_4_HEX = re.compile(r"^[0-9a-f]{32}$")
|
||||||
|
|
||||||
def fake_uuid4_hex(value: Any) -> str:
|
def fake_uuid4_hex(value: Any) -> str:
|
||||||
"""Validate a fake v4 UUID generated by random_uuid_hex."""
|
"""Validate a fake v4 UUID generated by random_uuid_hex."""
|
||||||
if not _FAKE_UUID_4_HEX.match(value):
|
try:
|
||||||
raise vol.Invalid("Invalid UUID")
|
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
|
return cast(str, value) # Pattern.match throws if input is not a string
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ from typing import Any, cast
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.const import CONF_MODE, CONF_UNIT_OF_MEASUREMENT
|
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 homeassistant.util import decorator
|
||||||
|
|
||||||
from . import config_validation as cv
|
from . import config_validation as cv
|
||||||
|
@ -74,44 +74,54 @@ class Selector:
|
||||||
return {"selector": {self.selector_type: self.config}}
|
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")
|
@SELECTORS.register("entity")
|
||||||
class EntitySelector(Selector):
|
class EntitySelector(Selector):
|
||||||
"""Selector of a single entity."""
|
"""Selector of a single or list of entities."""
|
||||||
|
|
||||||
selector_type = "entity"
|
selector_type = "entity"
|
||||||
|
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
CONFIG_SCHEMA = SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA.extend(
|
||||||
{
|
{vol.Optional("multiple", default=False): cv.boolean}
|
||||||
# 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,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __call__(self, data: Any) -> str:
|
def __call__(self, data: Any) -> str | list[str]:
|
||||||
"""Validate the passed selection."""
|
"""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")
|
@SELECTORS.register("device")
|
||||||
class DeviceSelector(Selector):
|
class DeviceSelector(Selector):
|
||||||
"""Selector of a single device."""
|
"""Selector of a single or list of devices."""
|
||||||
|
|
||||||
selector_type = "device"
|
selector_type = "device"
|
||||||
|
|
||||||
|
@ -124,31 +134,41 @@ class DeviceSelector(Selector):
|
||||||
# Model of device
|
# Model of device
|
||||||
vol.Optional("model"): str,
|
vol.Optional("model"): str,
|
||||||
# Device has to contain entities matching this selector
|
# 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."""
|
"""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")
|
@SELECTORS.register("area")
|
||||||
class AreaSelector(Selector):
|
class AreaSelector(Selector):
|
||||||
"""Selector of a single area."""
|
"""Selector of a single or list of areas."""
|
||||||
|
|
||||||
selector_type = "area"
|
selector_type = "area"
|
||||||
|
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
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("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."""
|
"""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")
|
@SELECTORS.register("number")
|
||||||
|
|
|
@ -25,13 +25,14 @@ COMMUNITY_POST_INPUTS = {
|
||||||
"integration": "zha",
|
"integration": "zha",
|
||||||
"manufacturer": "IKEA of Sweden",
|
"manufacturer": "IKEA of Sweden",
|
||||||
"model": "TRADFRI remote control",
|
"model": "TRADFRI remote control",
|
||||||
|
"multiple": False,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"light": {
|
"light": {
|
||||||
"name": "Light(s)",
|
"name": "Light(s)",
|
||||||
"description": "The light(s) to control",
|
"description": "The light(s) to control",
|
||||||
"selector": {"target": {"entity": {"domain": "light"}}},
|
"selector": {"target": {"entity": {"domain": "light", "multiple": False}}},
|
||||||
},
|
},
|
||||||
"force_brightness": {
|
"force_brightness": {
|
||||||
"name": "Force turn on brightness",
|
"name": "Force turn on brightness",
|
||||||
|
@ -218,10 +219,17 @@ async def test_fetch_blueprint_from_github_gist_url(hass, aioclient_mock):
|
||||||
"motion_entity": {
|
"motion_entity": {
|
||||||
"name": "Motion Sensor",
|
"name": "Motion Sensor",
|
||||||
"selector": {
|
"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.suggested_filename == "balloob/motion_light"
|
||||||
assert imported_blueprint.blueprint.metadata["source_url"] == url
|
assert imported_blueprint.blueprint.metadata["source_url"] == url
|
||||||
|
|
|
@ -37,12 +37,6 @@ def test_invalid_base_schema(schema):
|
||||||
selector.validate_selector(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(
|
def _test_selector(
|
||||||
selector_type, schema, valid_selections, invalid_selections, converter=None
|
selector_type, schema, valid_selections, invalid_selections, converter=None
|
||||||
):
|
):
|
||||||
|
@ -99,6 +93,11 @@ def _test_selector(
|
||||||
("abc123",),
|
("abc123",),
|
||||||
(None,),
|
(None,),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
{"multiple": True},
|
||||||
|
(["abc123", "def456"],),
|
||||||
|
("abc123", None, ["abc123", None]),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_device_selector_schema(schema, valid_selections, invalid_selections):
|
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),
|
("binary_sensor.abc123", FAKE_UUID),
|
||||||
(None, "sensor.abc123"),
|
(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):
|
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",),
|
("abc123",),
|
||||||
(None,),
|
(None,),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
{"multiple": True},
|
||||||
|
((["abc123", "def456"],)),
|
||||||
|
(None, "abc123", ["abc123", None]),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_area_selector_schema(schema, valid_selections, invalid_selections):
|
def test_area_selector_schema(schema, valid_selections, invalid_selections):
|
||||||
|
|
Loading…
Add table
Reference in a new issue