diff --git a/homeassistant/components/history_stats/config_flow.py b/homeassistant/components/history_stats/config_flow.py index f3790b1da204..0da9538f6a67 100644 --- a/homeassistant/components/history_stats/config_flow.py +++ b/homeassistant/components/history_stats/config_flow.py @@ -26,6 +26,8 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, + StateClassSelector, + StateClassSelectorConfig, StateSelector, StateSelectorConfig, TemplateSelector, @@ -142,12 +144,8 @@ def _get_options_schema_with_entity_id(entity_id: str, type: str) -> vol.Schema: vol.Optional(CONF_DURATION): DurationSelector( DurationSelectorConfig(enable_day=True, allow_negative=False), ), - vol.Optional(CONF_STATE_CLASS): SelectSelector( - SelectSelectorConfig( - options=state_class_options, - translation_key=CONF_STATE_CLASS, - mode=SelectSelectorMode.DROPDOWN, - ), + vol.Optional(CONF_STATE_CLASS): StateClassSelector( + StateClassSelectorConfig(state_classes=state_class_options), ), vol.Optional(SECTION_ADDITIONAL_SETTINGS): section( vol.Schema( diff --git a/homeassistant/components/history_stats/strings.json b/homeassistant/components/history_stats/strings.json index 74898703f6a9..1a3391130734 100644 --- a/homeassistant/components/history_stats/strings.json +++ b/homeassistant/components/history_stats/strings.json @@ -107,12 +107,6 @@ } }, "selector": { - "state_class": { - "options": { - "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", - "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" - } - }, "type": { "options": { "count": "Count", diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 7feda1efe0b2..2616d0a795f8 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS, DEVICE_CLASS_UNITS as SENSOR_DEVICE_CLASS_UNITS, SensorDeviceClass, - SensorStateClass, ) from homeassistant.components.text import TextMode from homeassistant.const import ( @@ -955,13 +954,7 @@ SENSOR_KNX_SCHEMA = AllSerializeFirst( sort=True, ) ), - probatio.Optional(CONF_SENSOR_STATE_CLASS): selector.SelectSelector( - selector.SelectSelectorConfig( - options=list(SensorStateClass), - translation_key="component.knx.selector.sensor_state_class", - mode=selector.SelectSelectorMode.DROPDOWN, - ) - ), + probatio.Optional(CONF_SENSOR_STATE_CLASS): selector.StateClassSelector(), probatio.Optional(CONF_ALWAYS_CALLBACK): selector.BooleanSelector(), probatio.Required(CONF_SYNC_STATE, default=True): SyncStateSelector( allow_false=True diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index e5588d0abcb8..0636c30732fd 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -1389,14 +1389,6 @@ } }, "selector": { - "sensor_state_class": { - "options": { - "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", - "measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]", - "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", - "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" - } - }, "telegram_backend": { "options": { "postgres": "PostgreSQL (External)", diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 1ed27c440c3c..80f086364566 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -52,7 +52,6 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_UNITS, STATE_CLASS_UNITS, SensorDeviceClass, - SensorStateClass, ) from homeassistant.components.valve import ValveState from homeassistant.config_entries import ( @@ -122,6 +121,7 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, + StateClassSelector, TemplateSelector, TemplateSelectorConfig, TextSelector, @@ -761,13 +761,7 @@ SENSOR_ENTITY_CATEGORY_SELECTOR = SelectSelector( sort=True, ) ) -SENSOR_STATE_CLASS_SELECTOR = SelectSelector( - SelectSelectorConfig( - options=[device_class.value for device_class in SensorStateClass], - mode=SelectSelectorMode.DROPDOWN, - translation_key=CONF_STATE_CLASS, - ) -) +SENSOR_STATE_CLASS_SELECTOR = StateClassSelector() STEP_SELECTOR = NumberSelector(NumberSelectorConfig(min=1e-3, step=1e-3)) SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector( SelectSelectorConfig( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 214054e8f0dd..1d7f6af31dca 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1320,14 +1320,6 @@ "off": "[%key:common::state::off%]" } }, - "state_class": { - "options": { - "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", - "measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]", - "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", - "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" - } - }, "supported_color_modes": { "options": { "brightness": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::brightness%]", diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 1d3f15760954..567e01234853 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.components.rest.schema import ( # pylint: disable=home-assis DEFAULT_METHOD, METHODS, ) -from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.config_entries import ( SOURCE_USER, ConfigEntry, @@ -59,6 +59,7 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, + StateClassSelector, TemplateSelector, TextSelector, TextSelectorConfig, @@ -155,14 +156,7 @@ SENSOR_SETTINGS = vol.Schema( vol.Optional(CONF_DEVICE_CLASS): DeviceClassSelector( DeviceClassSelectorConfig(domain=Platform.SENSOR) ), - vol.Optional(CONF_STATE_CLASS): SelectSelector( - SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], - mode=SelectSelectorMode.DROPDOWN, - translation_key="state_class", - sort=True, - ) - ), + vol.Optional(CONF_STATE_CLASS): StateClassSelector(), vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( SelectSelectorConfig( options=[cls.value for cls in UnitOfTemperature], diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index a3f980ab7f42..7e9f10a962c4 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -177,14 +177,6 @@ } }, "selector": { - "state_class": { - "options": { - "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", - "measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]", - "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", - "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" - } - }, "unit_of_measurement": { "options": { "none": "No unit of measurement" diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 1da37ca7aaac..ad5e1964ced3 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker import voluptuous as vol from homeassistant.components.recorder import CONF_DB_URL, get_instance -from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -55,14 +55,7 @@ OPTIONS_SCHEMA: vol.Schema = vol.Schema( vol.Optional(CONF_DEVICE_CLASS): selector.DeviceClassSelector( selector.DeviceClassSelectorConfig(domain=Platform.SENSOR) ), - vol.Optional(CONF_STATE_CLASS): selector.SelectSelector( - selector.SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], - mode=selector.SelectSelectorMode.DROPDOWN, - translation_key="state_class", - sort=True, - ) - ), + vol.Optional(CONF_STATE_CLASS): selector.StateClassSelector(), } ), {"collapsed": True}, diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index b2dcc59e8a37..6e6c19dc6b6b 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -107,16 +107,6 @@ } } }, - "selector": { - "state_class": { - "options": { - "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", - "measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]", - "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", - "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" - } - } - }, "services": { "query": { "description": "Executes a SQL query and returns the result.", diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 335820b3e79f..e68085bdf52c 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -11,7 +11,6 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_STATE_CLASSES, DEVICE_CLASS_UNITS, - SensorStateClass, ) from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -324,14 +323,7 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: vol.Optional(CONF_DEVICE_CLASS): selector.DeviceClassSelector( selector.DeviceClassSelectorConfig(domain=Platform.SENSOR), ), - vol.Optional(CONF_STATE_CLASS): selector.SelectSelector( - selector.SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], - mode=selector.SelectSelectorMode.DROPDOWN, - translation_key="sensor_state_class", - sort=True, - ), - ), + vol.Optional(CONF_STATE_CLASS): selector.StateClassSelector(), } if domain == Platform.SWITCH: diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 702be3b61052..b686a204814b 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -1146,14 +1146,6 @@ "text": "Text" } }, - "sensor_state_class": { - "options": { - "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", - "measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]", - "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", - "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" - } - }, "sensor_unit_of_measurement": { "options": { "none": "No unit of measurement" diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 946e14c936d6..9a52e6e83d18 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -5,7 +5,7 @@ from copy import deepcopy from enum import StrEnum from functools import cache import importlib -from typing import Any, Literal, Required, TypedDict, cast, override +from typing import TYPE_CHECKING, Any, Literal, Required, TypedDict, cast, override from uuid import UUID import voluptuous as vol @@ -20,6 +20,9 @@ from . import config_validation as cv SELECTORS: decorator.Registry[str, type[Selector]] = decorator.Registry() +if TYPE_CHECKING: + from homeassistant.components.sensor import SensorStateClass + def _get_selector_type_and_class(config: Any) -> tuple[str, type[Selector]]: """Get selector type and class.""" @@ -1966,6 +1969,57 @@ class SerialPortSelector(Selector[SerialPortSelectorConfig]): return serial +class StateClassSelectorConfig(BaseSelectorConfig, total=False): + """Class to represent a sensor state class selector config.""" + + multiple: bool + state_classes: Sequence[str | SensorStateClass] + + +@SELECTORS.register("state_class") +class StateClassSelector(Selector[StateClassSelectorConfig]): + """Selector for sensor state class.""" + + selector_type = "state_class" + + @staticmethod + def _valid_state_classes(options: list[str]) -> list[str]: + """Validate state classes and raise if invalid.""" + vol.In(_enum_options(Platform.SENSOR, "SensorStateClass"))(options) + return options + + CONFIG_SCHEMA = vol.All( + make_selector_config_schema( + { + vol.Optional("multiple", default=False): cv.boolean, + vol.Optional("state_classes"): vol.All( + cv.ensure_list, [str], [_valid_state_classes] + ), + }, + ), + ) + + def __init__(self, config: StateClassSelectorConfig | None = None) -> None: + """Instantiate a state class selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + state_classes_filter = self.config.get("state_classes") + valid_options = [ + option + for option in _enum_options(Platform.SENSOR, "SensorStateClass") + if state_classes_filter is None or option in state_classes_filter + ] + options_schema = vol.In(valid_options) + + if not self.config["multiple"]: + return options_schema(vol.Schema(str)(data)) + if not isinstance(data, list): + raise vol.Invalid("Value should be a list") + return [options_schema(vol.Schema(str)(val)) for val in data] + + class StateSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a state selector config.""" diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index dd5bae21929b..07c5794ba60c 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -2420,18 +2420,8 @@ 'optional': True, 'required': False, 'selector': dict({ - 'select': dict({ - 'custom_value': False, - 'mode': 'dropdown', + 'state_class': dict({ 'multiple': False, - 'options': list([ - 'measurement', - 'measurement_angle', - 'total', - 'total_increasing', - ]), - 'sort': False, - 'translation_key': 'component.knx.selector.sensor_state_class', }), }), 'type': 'ha_selector', diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 7a59a70e15d2..cb49f9e17057 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -1699,6 +1699,73 @@ def test_device_class_selector_schema( _test_selector("device_class", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + ("schema", "raises"), + [ + (None, does_not_raise()), + ({}, does_not_raise()), + ({"multiple": False}, does_not_raise()), + ({"multiple": True}, does_not_raise()), + ({"state_classes": "total"}, does_not_raise()), + ({"state_classes": ["total"]}, does_not_raise()), + ({"state_classes": ["total", "measurement"]}, does_not_raise()), + ({"state_classes": ["cat"]}, pytest.raises(vol.Invalid)), + ({"state_classes": ["total", "beer"]}, pytest.raises(vol.Invalid)), + ({"state_classes": ["cat", "total"]}, pytest.raises(vol.Invalid)), + ], +) +def test_state_class_selector_validate_schema( + schema: dict, raises: AbstractContextManager +) -> None: + """Test state class selector schemas.""" + # Validate selector configuration + + with raises: + selector.validate_selector({"state_class": schema}) + + +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + [ + ( + {}, + ("measurement", "total", "total_increasing", "measurement_angle"), + ("cat", 0, None, ["measurement"]), + ), + ( + None, + ("measurement", "total", "total_increasing", "measurement_angle"), + ("cat", 0, None, ["measurement"]), + ), + ( + {"multiple": True}, + (["measurement"], ["total", "total_increasing", "measurement_angle"]), + ("measurement", 0, None, ["cat"]), + ), + ( + { + "state_classes": ["measurement", "total", "total_increasing"], + "multiple": True, + }, + (["measurement"], ["total", "total_increasing"]), + ("measurement", 0, None, ["cat"], ["measurement_angle"]), + ), + ( + { + "state_classes": ["measurement", "total", "total_increasing"], + }, + ("measurement", "total", "total_increasing"), + (["measurement"], 0, None, "dog", "measurement_angle"), + ), + ], +) +def test_state_class_selector_schema( + schema, valid_selections, invalid_selections +) -> None: + """Test state class selector.""" + _test_selector("state_class", schema, valid_selections, invalid_selections) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), [