Add state class selector (#180667)

This commit is contained in:
Jan Bouwhuis
2026-09-05 10:05:39 +02:00
committed by GitHub
parent 4c2b897eee
commit 8a7249846d
15 changed files with 136 additions and 109 deletions
@@ -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(
@@ -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",
@@ -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
@@ -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)",
+2 -8
View File
@@ -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(
@@ -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%]",
@@ -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],
@@ -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"
+2 -9
View File
@@ -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},
-10
View File
@@ -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.",
@@ -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:
@@ -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"
+55 -1
View File
@@ -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."""
@@ -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',
+67
View File
@@ -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"),
[