Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions homeassistant/helpers/config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -729,8 +729,11 @@ def uuid4_hex(value: Any) -> str:

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


Expand Down
88 changes: 54 additions & 34 deletions homeassistant/helpers/selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
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
Expand Down Expand Up @@ -74,44 +74,54 @@ def serialize(self) -> Any:
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we still verify it looks like a uuid ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh nm you do that before passing to validate.

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"

Expand All @@ -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")
Expand Down
14 changes: 11 additions & 3 deletions tests/components/blueprint/test_importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@ def community_post():
"integration": "zha",
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI remote control",
"multiple": False,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not update all tests but also leave some where we don't specify multiple, to verify the default is False.

Copy link
Copy Markdown
Contributor

@emontnemery emontnemery Mar 2, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's exactly what happens; "multiple" has False as default value so it's added to the validated blueprint input 👍
The changes just adjusts the test because the validated blueprint input has changed.

}
},
},
"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",
Expand Down Expand Up @@ -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
27 changes: 21 additions & 6 deletions tests/helpers/test_selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down Expand Up @@ -99,6 +93,11 @@ def default_converter(x):
("abc123",),
(None,),
),
(
{"multiple": True},
(["abc123", "def456"],),
("abc123", None, ["abc123", None]),
),
),
)
def test_device_selector_schema(schema, valid_selections, invalid_selections):
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down