"""Selectors for Home Assistant.""" from __future__ import annotations from collections.abc import Callable, Sequence from typing import Any, TypedDict, cast import voluptuous as vol import yaml from homeassistant.backports.enum import StrEnum from homeassistant.const import CONF_MODE, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import split_entity_id, valid_entity_id from homeassistant.util import decorator from homeassistant.util.yaml.dumper import represent_odict from . import config_validation as cv SELECTORS: decorator.Registry[str, type[Selector]] = decorator.Registry() def _get_selector_class(config: Any) -> type[Selector]: """Get selector class type.""" 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: str = list(config)[0] if (selector_class := SELECTORS.get(selector_type)) is None: raise vol.Invalid(f"Unknown selector type {selector_type} found") return selector_class def selector(config: Any) -> Selector: """Instantiate a selector.""" selector_class = _get_selector_class(config) selector_type = list(config)[0] return selector_class(config[selector_type]) def validate_selector(config: Any) -> dict: """Validate a selector.""" selector_class = _get_selector_class(config) selector_type = list(config)[0] # Selectors can be empty if config[selector_type] is None: return {selector_type: {}} return { selector_type: cast(dict, selector_class.CONFIG_SCHEMA(config[selector_type])) } class Selector: """Base class for selectors.""" CONFIG_SCHEMA: Callable config: Any selector_type: str def __init__(self, config: Any = None) -> None: """Instantiate a selector.""" # Selectors can be empty if config is None: config = {} self.config = self.CONFIG_SCHEMA(config) def serialize(self) -> Any: """Serialize Selector for voluptuous_serialize.""" 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"): vol.Any(str, [str]), # Device class of the entity vol.Optional("device_class"): str, } ) class SingleEntitySelectorConfig(TypedDict, total=False): """Class to represent a single entity selector config.""" integration: str domain: str device_class: str SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema( { # Integration linked to it with a config entry vol.Optional("integration"): str, # Manufacturer of device vol.Optional("manufacturer"): str, # Model of device vol.Optional("model"): str, # Device has to contain entities matching this selector vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, } ) class SingleDeviceSelectorConfig(TypedDict, total=False): """Class to represent a single device selector config.""" integration: str manufacturer: str model: str entity: SingleEntitySelectorConfig class ActionSelectorConfig(TypedDict): """Class to represent an action selector config.""" @SELECTORS.register("action") class ActionSelector(Selector): """Selector of an action sequence (script syntax).""" selector_type = "action" CONFIG_SCHEMA = vol.Schema({}) def __init__(self, config: ActionSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> Any: """Validate the passed selection.""" return data class AddonSelectorConfig(TypedDict, total=False): """Class to represent an addon selector config.""" name: str slug: str @SELECTORS.register("addon") class AddonSelector(Selector): """Selector of a add-on.""" selector_type = "addon" CONFIG_SCHEMA = vol.Schema( { vol.Optional("name"): str, vol.Optional("slug"): str, } ) def __init__(self, config: AddonSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> str: """Validate the passed selection.""" addon: str = vol.Schema(str)(data) return addon class AreaSelectorConfig(TypedDict, total=False): """Class to represent an area selector config.""" entity: SingleEntitySelectorConfig device: SingleDeviceSelectorConfig multiple: bool @SELECTORS.register("area") class AreaSelector(Selector): """Selector of a single or list of areas.""" selector_type = "area" CONFIG_SCHEMA = vol.Schema( { vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, vol.Optional("device"): SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA, vol.Optional("multiple", default=False): cv.boolean, } ) def __init__(self, config: AreaSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> str | list[str]: """Validate the passed selection.""" if not self.config["multiple"]: area_id: str = vol.Schema(str)(data) return area_id if not isinstance(data, list): raise vol.Invalid("Value should be a list") return [vol.Schema(str)(val) for val in data] class AttributeSelectorConfig(TypedDict): """Class to represent an attribute selector config.""" entity_id: str @SELECTORS.register("attribute") class AttributeSelector(Selector): """Selector for an entity attribute.""" selector_type = "attribute" CONFIG_SCHEMA = vol.Schema({vol.Required("entity_id"): cv.entity_id}) def __init__(self, config: AttributeSelectorConfig) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> str: """Validate the passed selection.""" attribute: str = vol.Schema(str)(data) return attribute class BooleanSelectorConfig(TypedDict): """Class to represent a boolean selector config.""" @SELECTORS.register("boolean") class BooleanSelector(Selector): """Selector of a boolean value.""" selector_type = "boolean" CONFIG_SCHEMA = vol.Schema({}) def __init__(self, config: BooleanSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> bool: """Validate the passed selection.""" value: bool = vol.Coerce(bool)(data) return value class ColorRGBSelectorConfig(TypedDict): """Class to represent a color RGB selector config.""" @SELECTORS.register("color_rgb") class ColorRGBSelector(Selector): """Selector of an RGB color value.""" selector_type = "color_rgb" CONFIG_SCHEMA = vol.Schema({}) def __init__(self, config: ColorRGBSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> list[int]: """Validate the passed selection.""" value: list[int] = vol.All(list, vol.ExactSequence((cv.byte,) * 3))(data) return value class ColorTempSelectorConfig(TypedDict, total=False): """Class to represent a color temp selector config.""" max_mireds: int min_mireds: int @SELECTORS.register("color_temp") class ColorTempSelector(Selector): """Selector of an color temperature.""" selector_type = "color_temp" CONFIG_SCHEMA = vol.Schema( { vol.Optional("max_mireds"): vol.Coerce(int), vol.Optional("min_mireds"): vol.Coerce(int), } ) def __init__(self, config: ColorTempSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> int: """Validate the passed selection.""" value: int = vol.All( vol.Coerce(float), vol.Range( min=self.config.get("min_mireds"), max=self.config.get("max_mireds"), ), )(data) return value class DateSelectorConfig(TypedDict): """Class to represent a date selector config.""" @SELECTORS.register("date") class DateSelector(Selector): """Selector of a date.""" selector_type = "date" CONFIG_SCHEMA = vol.Schema({}) def __init__(self, config: DateSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> Any: """Validate the passed selection.""" cv.date(data) return data class DateTimeSelectorConfig(TypedDict): """Class to represent a date time selector config.""" @SELECTORS.register("datetime") class DateTimeSelector(Selector): """Selector of a datetime.""" selector_type = "datetime" CONFIG_SCHEMA = vol.Schema({}) def __init__(self, config: DateTimeSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> Any: """Validate the passed selection.""" cv.datetime(data) return data class DeviceSelectorConfig(TypedDict, total=False): """Class to represent a device selector config.""" integration: str manufacturer: str model: str entity: SingleEntitySelectorConfig multiple: bool @SELECTORS.register("device") class DeviceSelector(Selector): """Selector of a single or list of devices.""" selector_type = "device" CONFIG_SCHEMA = SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA.extend( {vol.Optional("multiple", default=False): cv.boolean} ) def __init__(self, config: DeviceSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> str | list[str]: """Validate the passed selection.""" if not self.config["multiple"]: device_id: str = vol.Schema(str)(data) return device_id if not isinstance(data, list): raise vol.Invalid("Value should be a list") return [vol.Schema(str)(val) for val in data] class DurationSelectorConfig(TypedDict, total=False): """Class to represent a duration selector config.""" enable_day: bool @SELECTORS.register("duration") class DurationSelector(Selector): """Selector for a duration.""" selector_type = "duration" CONFIG_SCHEMA = vol.Schema( { # Enable day field in frontend. A selection with `days` set is allowed # even if `enable_day` is not set vol.Optional("enable_day"): cv.boolean, } ) def __init__(self, config: DurationSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> dict[str, float]: """Validate the passed selection.""" cv.time_period_dict(data) return cast(dict[str, float], data) class EntitySelectorConfig(SingleEntitySelectorConfig, total=False): """Class to represent an entity selector config.""" exclude_entities: list[str] include_entities: list[str] multiple: bool @SELECTORS.register("entity") class EntitySelector(Selector): """Selector of a single or list of entities.""" selector_type = "entity" CONFIG_SCHEMA = SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("exclude_entities"): [str], vol.Optional("include_entities"): [str], vol.Optional("multiple", default=False): cv.boolean, } ) def __init__(self, config: EntitySelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> str | list[str]: """Validate the passed selection.""" include_entities = self.config.get("include_entities") exclude_entities = self.config.get("exclude_entities") 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_domains := cv.ensure_list(self.config.get("domain")): domain = split_entity_id(e_or_u)[0] if domain not in allowed_domains: raise vol.Invalid( f"Entity {e_or_u} belongs to domain {domain}, " f"expected {allowed_domains}" ) if include_entities: vol.In(include_entities)(e_or_u) if exclude_entities: vol.NotIn(exclude_entities)(e_or_u) 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 class IconSelectorConfig(TypedDict, total=False): """Class to represent an icon selector config.""" placeholder: str @SELECTORS.register("icon") class IconSelector(Selector): """Selector for an icon.""" selector_type = "icon" CONFIG_SCHEMA = vol.Schema( {vol.Optional("placeholder"): str} # Frontend also has a fallbackPath option, this is not used by core ) def __init__(self, config: IconSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> str: """Validate the passed selection.""" icon: str = vol.Schema(str)(data) return icon class LocationSelectorConfig(TypedDict, total=False): """Class to represent a location selector config.""" radius: bool icon: str @SELECTORS.register("location") class LocationSelector(Selector): """Selector for a location.""" selector_type = "location" CONFIG_SCHEMA = vol.Schema( {vol.Optional("radius"): bool, vol.Optional("icon"): str} ) DATA_SCHEMA = vol.Schema( { vol.Required("latitude"): float, vol.Required("longitude"): float, vol.Optional("radius"): float, } ) def __init__(self, config: LocationSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> dict[str, float]: """Validate the passed selection.""" location: dict[str, float] = self.DATA_SCHEMA(data) return location class MediaSelectorConfig(TypedDict): """Class to represent a media selector config.""" @SELECTORS.register("media") class MediaSelector(Selector): """Selector for media.""" selector_type = "media" CONFIG_SCHEMA = vol.Schema({}) DATA_SCHEMA = vol.Schema( { # Although marked as optional in frontend, this field is required vol.Required("entity_id"): cv.entity_id_or_uuid, # Although marked as optional in frontend, this field is required vol.Required("media_content_id"): str, # Although marked as optional in frontend, this field is required vol.Required("media_content_type"): str, vol.Remove("metadata"): dict, } ) def __init__(self, config: MediaSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> dict[str, float]: """Validate the passed selection.""" media: dict[str, float] = self.DATA_SCHEMA(data) return media class NumberSelectorConfig(TypedDict, total=False): """Class to represent a number selector config.""" min: float max: float step: float unit_of_measurement: str mode: NumberSelectorMode class NumberSelectorMode(StrEnum): """Possible modes for a number selector.""" BOX = "box" SLIDER = "slider" def has_min_max_if_slider(data: Any) -> Any: """Validate configuration.""" if data["mode"] == "box": return data if "min" not in data or "max" not in data: raise vol.Invalid("min and max are required in slider mode") return data @SELECTORS.register("number") class NumberSelector(Selector): """Selector of a numeric value.""" selector_type = "number" CONFIG_SCHEMA = vol.All( vol.Schema( { vol.Optional("min"): vol.Coerce(float), vol.Optional("max"): vol.Coerce(float), # Controls slider steps, and up/down keyboard binding for the box # user input is not rounded vol.Optional("step", default=1): vol.All( vol.Coerce(float), vol.Range(min=1e-3) ), vol.Optional(CONF_UNIT_OF_MEASUREMENT): str, vol.Optional(CONF_MODE, default=NumberSelectorMode.SLIDER): vol.All( vol.Coerce(NumberSelectorMode), lambda val: val.value ), } ), has_min_max_if_slider, ) def __init__(self, config: NumberSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> float: """Validate the passed selection.""" value: float = vol.Coerce(float)(data) if "min" in self.config and value < self.config["min"]: raise vol.Invalid(f"Value {value} is too small") if "max" in self.config and value > self.config["max"]: raise vol.Invalid(f"Value {value} is too large") return value class ObjectSelectorConfig(TypedDict): """Class to represent an object selector config.""" @SELECTORS.register("object") class ObjectSelector(Selector): """Selector for an arbitrary object.""" selector_type = "object" CONFIG_SCHEMA = vol.Schema({}) def __init__(self, config: ObjectSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> Any: """Validate the passed selection.""" return data select_option = vol.All( dict, vol.Schema( { vol.Required("value"): str, vol.Required("label"): str, } ), ) class SelectOptionDict(TypedDict): """Class to represent a select option dict.""" value: str label: str class SelectSelectorMode(StrEnum): """Possible modes for a number selector.""" LIST = "list" DROPDOWN = "dropdown" class SelectSelectorConfig(TypedDict, total=False): """Class to represent a select selector config.""" options: Sequence[SelectOptionDict] | Sequence[str] # required multiple: bool custom_value: bool mode: SelectSelectorMode @SELECTORS.register("select") class SelectSelector(Selector): """Selector for an single-choice input select.""" selector_type = "select" CONFIG_SCHEMA = vol.Schema( { vol.Required("options"): vol.All(vol.Any([str], [select_option])), vol.Optional("multiple", default=False): cv.boolean, vol.Optional("custom_value", default=False): cv.boolean, vol.Optional("mode"): vol.All( vol.Coerce(SelectSelectorMode), lambda val: val.value ), } ) def __init__(self, config: SelectSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> Any: """Validate the passed selection.""" options = [] if self.config["options"]: if isinstance(self.config["options"][0], str): options = self.config["options"] else: options = [option["value"] for option in self.config["options"]] parent_schema = vol.In(options) if self.config["custom_value"]: parent_schema = vol.Any(parent_schema, str) if not self.config["multiple"]: return parent_schema(vol.Schema(str)(data)) if not isinstance(data, list): raise vol.Invalid("Value should be a list") return [parent_schema(vol.Schema(str)(val)) for val in data] class TargetSelectorConfig(TypedDict, total=False): """Class to represent a target selector config.""" entity: SingleEntitySelectorConfig device: SingleDeviceSelectorConfig @SELECTORS.register("target") class TargetSelector(Selector): """Selector of a target value (area ID, device ID, entity ID etc). Value should follow cv.TARGET_SERVICE_FIELDS format. """ selector_type = "target" CONFIG_SCHEMA = vol.Schema( { vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, vol.Optional("device"): SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA, } ) TARGET_SELECTION_SCHEMA = vol.Schema(cv.TARGET_SERVICE_FIELDS) def __init__(self, config: TargetSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> dict[str, list[str]]: """Validate the passed selection.""" target: dict[str, list[str]] = self.TARGET_SELECTION_SCHEMA(data) return target class TemplateSelectorConfig(TypedDict): """Class to represent an template selector config.""" @SELECTORS.register("template") class TemplateSelector(Selector): """Selector for an template.""" selector_type = "template" CONFIG_SCHEMA = vol.Schema({}) def __init__(self, config: TemplateSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> str: """Validate the passed selection.""" template = cv.template(data) return template.template class TextSelectorConfig(TypedDict, total=False): """Class to represent a text selector config.""" multiline: bool suffix: str type: TextSelectorType class TextSelectorType(StrEnum): """Enum for text selector types.""" COLOR = "color" DATE = "date" DATETIME_LOCAL = "datetime-local" EMAIL = "email" MONTH = "month" NUMBER = "number" PASSWORD = "password" SEARCH = "search" TEL = "tel" TEXT = "text" TIME = "time" URL = "url" WEEK = "week" @SELECTORS.register("text") class TextSelector(Selector): """Selector for a multi-line text string.""" selector_type = "text" CONFIG_SCHEMA = vol.Schema( { vol.Optional("multiline", default=False): bool, vol.Optional("suffix"): str, # The "type" controls the input field in the browser, the resulting # data can be any string so we don't validate it. vol.Optional("type"): vol.All( vol.Coerce(TextSelectorType), lambda val: val.value ), } ) def __init__(self, config: TextSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> str: """Validate the passed selection.""" text: str = vol.Schema(str)(data) return text class ThemeSelectorConfig(TypedDict): """Class to represent a theme selector config.""" @SELECTORS.register("theme") class ThemeSelector(Selector): """Selector for an theme.""" selector_type = "theme" CONFIG_SCHEMA = vol.Schema({}) def __init__(self, config: ThemeSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> str: """Validate the passed selection.""" theme: str = vol.Schema(str)(data) return theme class TimeSelectorConfig(TypedDict): """Class to represent a time selector config.""" @SELECTORS.register("time") class TimeSelector(Selector): """Selector of a time value.""" selector_type = "time" CONFIG_SCHEMA = vol.Schema({}) def __init__(self, config: TimeSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) def __call__(self, data: Any) -> str: """Validate the passed selection.""" cv.time(data) return cast(str, data) yaml.SafeDumper.add_representer( Selector, lambda dumper, value: represent_odict( dumper, "tag:yaml.org,2002:map", value.serialize() ), )