diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index 1adc8616593..59012554f29 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -126,6 +126,17 @@ async def async_setup_platform( None, "total_gas_ft3", ), + DemoSensor( + unique_id="sensor_10", + name="Thermostat mode", + state="eco", + device_class=SensorDeviceClass.ENUM, + state_class=None, + unit_of_measurement=None, + battery=None, + options=["away", "comfort", "eco", "sleep"], + translation_key="thermostat_mode", + ), ] ) @@ -149,10 +160,12 @@ class DemoSensor(SensorEntity): unique_id: str, name: str, state: StateType, - device_class: SensorDeviceClass, + device_class: SensorDeviceClass | str, state_class: SensorStateClass | None, unit_of_measurement: str | None, battery: StateType, + options: list[str] | None = None, + translation_key: str | None = None, ) -> None: """Initialize the sensor.""" self._attr_device_class = device_class @@ -161,6 +174,8 @@ class DemoSensor(SensorEntity): self._attr_native_value = state self._attr_state_class = state_class self._attr_unique_id = unique_id + self._attr_options = options + self._attr_translation_key = translation_key self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index 7be1a133a74..b01cace6ede 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -61,5 +61,17 @@ } } } + }, + "entity": { + "sensor": { + "thermostat_mode": { + "state": { + "away": "Away", + "comfort": "Comfort", + "eco": "Eco", + "sleep": "Sleep" + } + } + } } } diff --git a/homeassistant/components/demo/translations/en.json b/homeassistant/components/demo/translations/en.json index 9f32e982947..da965601cc6 100644 --- a/homeassistant/components/demo/translations/en.json +++ b/homeassistant/components/demo/translations/en.json @@ -1,4 +1,16 @@ { + "entity": { + "sensor": { + "thermostat_mode": { + "state": { + "away": "Away", + "comfort": "Comfort", + "eco": "Eco", + "sleep": "Sleep" + } + } + } + }, "issues": { "bad_psu": { "fix_flow": { @@ -15,7 +27,8 @@ "fix_flow": { "abort": { "not_tea_time": "Can not re-heat the tea at this time" - } + }, + "step": {} }, "title": "The tea is cold" }, @@ -41,6 +54,9 @@ }, "options": { "step": { + "init": { + "data": {} + }, "options_1": { "data": { "bool": "Optional boolean", diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index a1a7e9ce1a7..e733cc877d1 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -77,6 +77,7 @@ _LOGGER: Final = logging.getLogger(__name__) ATTR_LAST_RESET: Final = "last_reset" ATTR_STATE_CLASS: Final = "state_class" +ATTR_OPTIONS: Final = "options" DOMAIN: Final = "sensor" @@ -103,6 +104,14 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `d`, `h`, `min`, `s` """ + ENUM = "enum" + """Enumeration. + + Provides a fixed list of options the state of the sensor can be in. + + Unit of measurement: `None` + """ + TIMESTAMP = "timestamp" """Timestamp. @@ -446,6 +455,7 @@ class SensorEntityDescription(EntityDescription): last_reset: datetime | None = None native_unit_of_measurement: str | None = None state_class: SensorStateClass | str | None = None + options: list[str] | None = None unit_of_measurement: None = None # Type override, use native_unit_of_measurement @@ -457,6 +467,7 @@ class SensorEntity(Entity): _attr_last_reset: datetime | None _attr_native_unit_of_measurement: str | None _attr_native_value: StateType | date | datetime | Decimal = None + _attr_options: list[str] | None _attr_state_class: SensorStateClass | str | None _attr_state: None = None # Subclasses of SensorEntity should not set this _attr_suggested_unit_of_measurement: str | None @@ -523,6 +534,15 @@ class SensorEntity(Entity): return self.entity_description.device_class return None + @property + def options(self) -> list[str] | None: + """Return a set of possible options.""" + if hasattr(self, "_attr_options"): + return self._attr_options + if hasattr(self, "entity_description"): + return self.entity_description.options + return None + @property def state_class(self) -> SensorStateClass | str | None: """Return the state class of this entity, if any.""" @@ -547,6 +567,9 @@ class SensorEntity(Entity): if state_class := self.state_class: return {ATTR_STATE_CLASS: state_class} + if options := self.options: + return {ATTR_OPTIONS: options} + return None def _get_initial_suggested_unit(self) -> str | None: @@ -679,6 +702,7 @@ class SensorEntity(Entity): unit_of_measurement = self.unit_of_measurement value = self.native_value device_class = self.device_class + state_class = self.state_class # Received a datetime if value is not None and device_class == DEVICE_CLASS_TIMESTAMP: @@ -715,6 +739,37 @@ class SensorEntity(Entity): f"but provides state {value}:{type(value)} resulting in '{err}'" ) from err + # Enum checks + if value is not None and ( + device_class == SensorDeviceClass.ENUM or self.options is not None + ): + if device_class != SensorDeviceClass.ENUM: + reason = "is missing the enum device class" + if device_class is not None: + reason = f"has device class '{device_class}' instead of 'enum'" + raise ValueError( + f"Sensor {self.entity_id} is providing enum options, but {reason}" + ) + + if state_class: + raise ValueError( + f"Sensor {self.entity_id} has an state_class and thus indicating " + "it has a numeric value; however, it has the enum device class" + ) + + if unit_of_measurement: + raise ValueError( + f"Sensor {self.entity_id} has an unit of measurement and thus " + "indicating it has a numeric value; " + "however, it has the enum device class" + ) + + if (options := self.options) and value not in options: + raise ValueError( + f"Sensor {self.entity_id} provides state value '{value}', " + "which is not in the list of options provided" + ) + if ( value is not None and native_unit_of_measurement != unit_of_measurement @@ -840,7 +895,7 @@ class SensorExtraStoredData(ExtraStoredData): # native_value is a dict, but does not have all values return None except DecimalInvalidOperation: - # native_value coulnd't be returned from decimal_str + # native_value couldn't be returned from decimal_str return None return cls(native_value, native_unit_of_measurement) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index f4b3897492d..af27f67456f 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -29,13 +29,14 @@ from homeassistant.const import ( VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, ) -from homeassistant.core import HomeAssistant, State, split_entity_id +from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import entity_sources from homeassistant.util import dt as dt_util from . import ( ATTR_LAST_RESET, + ATTR_OPTIONS, ATTR_STATE_CLASS, DOMAIN, STATE_CLASS_MEASUREMENT, @@ -724,3 +725,9 @@ def validate_statistics( ) return validation_result + + +@callback +def exclude_attributes(hass: HomeAssistant) -> set[str]: + """Exclude attributes from being recorded in the database.""" + return {ATTR_OPTIONS} diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 630506623de..f6b2c615123 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -857,6 +857,7 @@ def test_device_classes_aligned(): non_numeric_device_classes = { SensorDeviceClass.DATE, SensorDeviceClass.DURATION, + SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, } diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index c9c29d6c99a..51e1c014fc9 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -6,7 +6,7 @@ import pytest from pytest import approx from homeassistant.components.number import NumberDeviceClass -from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, LENGTH_CENTIMETERS, @@ -32,8 +32,9 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, VOLUME_FLUID_OUNCE, VOLUME_LITERS, + UnitOfTemperature, ) -from homeassistant.core import State +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component @@ -936,3 +937,124 @@ def test_device_classes_aligned(): for device_class in NumberDeviceClass: assert hasattr(SensorDeviceClass, device_class.name) assert getattr(SensorDeviceClass, device_class.name).value == device_class.value + + +async def test_value_unknown_in_enumeration( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, +): + """Test warning on invalid enum value.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + native_value="invalid_option", + device_class=SensorDeviceClass.ENUM, + options=["option1", "option2"], + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert ( + "Sensor sensor.test provides state value 'invalid_option', " + "which is not in the list of options provided" + ) in caplog.text + + +async def test_invalid_enumeration_entity_with_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, +): + """Test warning on entities that provide an enum with a device class.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + native_value=21, + device_class=SensorDeviceClass.POWER, + options=["option1", "option2"], + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert ( + "Sensor sensor.test is providing enum options, but has device class 'power' " + "instead of 'enum'" + ) in caplog.text + + +async def test_invalid_enumeration_entity_without_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, +): + """Test warning on entities that provide an enum without a device class.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + native_value=21, + options=["option1", "option2"], + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert ( + "Sensor sensor.test is providing enum options, but is missing " + "the enum device class" + ) in caplog.text + + +async def test_invalid_enumeration_with_state_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, +): + """Test warning on numeric entities that provide an enum.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + native_value=42, + device_class=SensorDeviceClass.ENUM, + state_class=SensorStateClass.MEASUREMENT, + options=["option1", "option2"], + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert ( + "Sensor sensor.test has an state_class and thus indicating " + "it has a numeric value; however, it has the enum device class" + ) in caplog.text + + +async def test_invalid_enumeration_with_unit_of_measurement( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, +): + """Test warning on numeric entities that provide an enum.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + native_value=42, + device_class=SensorDeviceClass.ENUM, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + options=["option1", "option2"], + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert ( + "Sensor sensor.test has an unit of measurement and thus indicating " + "it has a numeric value; however, it has the enum device class" + ) in caplog.text diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 1e129b7af92..63b3b65d011 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -10,7 +10,11 @@ from pytest import approx from homeassistant import loader from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, history -from homeassistant.components.recorder.db_schema import StatisticsMeta +from homeassistant.components.recorder.db_schema import ( + StateAttributes, + States, + StatisticsMeta, +) from homeassistant.components.recorder.models import ( StatisticData, StatisticMetaData, @@ -22,11 +26,14 @@ from homeassistant.components.recorder.statistics import ( list_statistic_ids, ) from homeassistant.components.recorder.util import get_instance, session_scope -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.components.sensor import ATTR_OPTIONS, DOMAIN +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component, setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM +from tests.common import async_fire_time_changed from tests.components.recorder.common import ( async_recorder_block_till_done, async_wait_recording_done, @@ -4320,3 +4327,27 @@ def record_states_partially_unavailable(hass, zero, entity_id, attributes): ) return four, states + + +async def test_exclude_attributes(recorder_mock: None, hass: HomeAssistant) -> None: + """Test sensor attributes to be excluded.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {"platform": "demo"}}) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + def _fetch_states() -> list[State]: + with session_scope(hass=hass) as session: + native_states = [] + for db_state, db_state_attributes in session.query(States, StateAttributes): + state = db_state.to_native() + state.attributes = db_state_attributes.to_native() + native_states.append(state) + return native_states + + states: list[State] = await hass.async_add_executor_job(_fetch_states) + assert len(states) > 1 + for state in states: + assert ATTR_OPTIONS not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index 9584e47ba0b..c7ca9a76005 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -107,6 +107,11 @@ class MockSensor(MockEntity, SensorEntity): """Return the native value of this sensor.""" return self._handle("native_value") + @property + def options(self): + """Return the options for this sensor.""" + return self._handle("options") + @property def state_class(self): """Return the state class of this sensor."""