diff --git a/homeassistant/components/blueprint/schemas.py b/homeassistant/components/blueprint/schemas.py index e04bc99e4b7..b734476b85a 100644 --- a/homeassistant/components/blueprint/schemas.py +++ b/homeassistant/components/blueprint/schemas.py @@ -3,9 +3,9 @@ from typing import Any import voluptuous as vol -from homeassistant.const import CONF_DOMAIN, CONF_NAME, CONF_PATH +from homeassistant.const import CONF_DOMAIN, CONF_NAME, CONF_PATH, CONF_SELECTOR from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from .const import ( CONF_BLUEPRINT, @@ -32,6 +32,7 @@ BLUEPRINT_INPUT_SCHEMA = vol.Schema( { vol.Optional(CONF_NAME): str, vol.Optional(CONF_DESCRIPTION): str, + vol.Optional(CONF_SELECTOR): selector.validate_selector, } ) diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml index effb19c47b7..8a35e9a7790 100644 --- a/homeassistant/components/sonos/services.yaml +++ b/homeassistant/components/sonos/services.yaml @@ -7,6 +7,10 @@ join: entity_id: description: Name(s) of entities that will join the master. example: "media_player.living_room_sonos" + selector: + entity: + integration: sonos + domain: media_player unjoin: description: Unjoin the player from a group. @@ -14,6 +18,10 @@ unjoin: entity_id: description: Name(s) of entities that will be unjoined from their group. example: "media_player.living_room_sonos" + selector: + entity: + integration: sonos + domain: media_player snapshot: description: Take a snapshot of the media player. @@ -21,6 +29,10 @@ snapshot: entity_id: description: Name(s) of entities that will be snapshot. example: "media_player.living_room_sonos" + selector: + entity: + integration: sonos + domain: media_player with_group: description: True (default) or False. Also snapshot the group layout. example: "true" @@ -31,6 +43,10 @@ restore: entity_id: description: Name(s) of entities that will be restored. example: "media_player.living_room_sonos" + selector: + entity: + integration: sonos + domain: media_player with_group: description: True (default) or False. Also restore the group layout. example: "true" @@ -41,6 +57,10 @@ set_sleep_timer: entity_id: description: Name(s) of entities that will have a timer set. example: "media_player.living_room_sonos" + selector: + entity: + integration: sonos + domain: media_player sleep_time: description: Number of seconds to set the timer. example: "900" @@ -51,6 +71,10 @@ clear_sleep_timer: entity_id: description: Name(s) of entities that will have the timer cleared. example: "media_player.living_room_sonos" + selector: + entity: + integration: sonos + domain: media_player set_option: description: Set Sonos sound options. @@ -58,6 +82,10 @@ set_option: entity_id: description: Name(s) of entities that will have options set. example: "media_player.living_room_sonos" + selector: + entity: + integration: sonos + domain: media_player night_sound: description: Enable Night Sound mode example: "true" @@ -74,6 +102,10 @@ play_queue: entity_id: description: Name(s) of entities that will start playing. example: "media_player.living_room_sonos" + selector: + entity: + integration: sonos + domain: media_player queue_position: description: Position of the song in the queue to start playing from. example: "0" @@ -84,6 +116,10 @@ remove_from_queue: entity_id: description: Name(s) of entities that will remove an item. example: "media_player.living_room_sonos" + selector: + entity: + integration: sonos + domain: media_player queue_position: description: Position in the queue to remove. example: "0" diff --git a/homeassistant/const.py b/homeassistant/const.py index 84a24ab1bad..d60e443818e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -154,6 +154,7 @@ CONF_RGB = "rgb" CONF_ROOM = "room" CONF_SCAN_INTERVAL = "scan_interval" CONF_SCENE = "scene" +CONF_SELECTOR = "selector" CONF_SENDER = "sender" CONF_SENSORS = "sensors" CONF_SENSOR_TYPE = "sensor_type" diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py new file mode 100644 index 00000000000..9f049e07213 --- /dev/null +++ b/homeassistant/helpers/selector.py @@ -0,0 +1,57 @@ +"""Selectors for Home Assistant.""" +from typing import Any, Callable, Dict, cast + +import voluptuous as vol + +from homeassistant.util import decorator + +SELECTORS = decorator.Registry() + + +def validate_selector(config: Any) -> Dict: + """Validate a selector.""" + if not isinstance(config, dict): + raise vol.Invalid("Expected a dictionary") + + if len(config) != 1: + raise vol.Invalid(f"Only one type can be specified. Found {', '.join(config)}") + + selector_type = list(config)[0] + + seslector_class = SELECTORS.get(selector_type) + + if seslector_class is None: + raise vol.Invalid(f"Unknown selector type {selector_type} found") + + return cast(Dict, seslector_class.CONFIG_SCHEMA(config[selector_type])) + + +class Selector: + """Base class for selectors.""" + + CONFIG_SCHEMA: Callable + + +@SELECTORS.register("entity") +class EntitySelector(Selector): + """Selector of a single entity.""" + + CONFIG_SCHEMA = vol.Schema( + { + vol.Optional("integration"): str, + vol.Optional("domain"): str, + } + ) + + +@SELECTORS.register("device") +class DeviceSelector(Selector): + """Selector of a single device.""" + + CONFIG_SCHEMA = vol.Schema( + { + vol.Optional("integration"): str, + vol.Optional("manufacturer"): str, + vol.Optional("model"): str, + } + ) diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 1e05ef63efb..c07d3bbc6ef 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -6,8 +6,9 @@ from typing import Dict import voluptuous as vol from voluptuous.humanize import humanize_error +from homeassistant.const import CONF_SELECTOR from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.util.yaml import load_yaml from .model import Integration @@ -27,6 +28,7 @@ FIELD_SCHEMA = vol.Schema( vol.Optional("default"): exists, vol.Optional("values"): exists, vol.Optional("required"): bool, + vol.Optional(CONF_SELECTOR): selector.validate_selector, } ) diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py new file mode 100644 index 00000000000..2b6b5cbc9f8 --- /dev/null +++ b/tests/helpers/test_selector.py @@ -0,0 +1,44 @@ +"""Test selectors.""" +import pytest +import voluptuous as vol + +from homeassistant.helpers import selector + + +@pytest.mark.parametrize( + "schema", ({}, {"non_existing": {}}, {"device": {}, "entity": {}}) +) +def test_invalid_base_schema(schema): + """Test base schema validation.""" + with pytest.raises(vol.Invalid): + selector.validate_selector(schema) + + +@pytest.mark.parametrize( + "schema", + ( + {}, + {"integration": "zha"}, + {"manufacturer": "mock-manuf"}, + {"model": "mock-model"}, + {"manufacturer": "mock-manuf", "model": "mock-model"}, + {"integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model"}, + ), +) +def test_device_selector_schema(schema): + """Test device selector.""" + selector.validate_selector({"device": schema}) + + +@pytest.mark.parametrize( + "schema", + ( + {}, + {"integration": "zha"}, + {"domain": "light"}, + {"integration": "zha", "domain": "light"}, + ), +) +def test_entity_selector_schema(schema): + """Test device selector.""" + selector.validate_selector({"entity": schema})