From 0a367359f4d4841fa61bd64e562fa80d4cc90849 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 16 Jan 2023 14:31:24 +0100 Subject: [PATCH] Add sensor state class validation for device classes (#84402) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/sensor/__init__.py | 58 +++++++++++++------ homeassistant/components/sensor/const.py | 61 ++++++++++++++++++++ tests/components/sensor/test_init.py | 63 ++++++++++----------- 3 files changed, 130 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index aa53457afd6..d254bfa8666 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -64,6 +64,7 @@ from .const import ( # noqa: F401 ATTR_OPTIONS, ATTR_STATE_CLASS, CONF_STATE_CLASS, + DEVICE_CLASS_STATE_CLASSES, DEVICE_CLASS_UNITS, DEVICE_CLASSES, DEVICE_CLASSES_SCHEMA, @@ -155,6 +156,7 @@ class SensorEntity(Entity): _attr_unit_of_measurement: None = ( None # Subclasses of SensorEntity should not set this ) + _invalid_state_class_reported = False _invalid_unit_of_measurement_reported = False _last_reset_reported = False _sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED @@ -409,25 +411,45 @@ class SensorEntity(Entity): state_class = self.state_class # Sensors with device classes indicating a non-numeric value - # should not have a state class or unit of measurement - if device_class in { - SensorDeviceClass.DATE, - SensorDeviceClass.ENUM, - SensorDeviceClass.TIMESTAMP, - }: - if self.state_class: - raise ValueError( - f"Sensor {self.entity_id} has a state class and thus indicating " - "it has a numeric value; however, it has the non-numeric " - f"device class: {device_class}" - ) + # should not have a unit of measurement + if ( + device_class + in { + SensorDeviceClass.DATE, + SensorDeviceClass.ENUM, + SensorDeviceClass.TIMESTAMP, + } + and unit_of_measurement + ): + raise ValueError( + f"Sensor {self.entity_id} has a unit of measurement and thus " + "indicating it has a numeric value; however, it has the " + f"non-numeric device class: {device_class}" + ) - if unit_of_measurement: - raise ValueError( - f"Sensor {self.entity_id} has a unit of measurement and thus " - "indicating it has a numeric value; however, it has the " - f"non-numeric device class: {device_class}" - ) + # Validate state class for sensors with a device class + if ( + state_class + and not self._invalid_state_class_reported + and device_class + and (classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None + and state_class not in classes + ): + self._invalid_state_class_reported = True + report_issue = self._suggest_report_issue() + + # This should raise in Home Assistant Core 2023.6 + _LOGGER.warning( + "Entity %s (%s) is using state class '%s' which " + "is impossible considering device class ('%s') it is using; " + "Please update your configuration if your entity is manually " + "configured, otherwise %s", + self.entity_id, + type(self), + state_class, + device_class, + report_issue, + ) # Checks below only apply if there is a value if value is None: diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index ed79823cc32..6b5db8ecc7b 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -501,3 +501,64 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.WEIGHT: set(UnitOfMass), SensorDeviceClass.WIND_SPEED: set(UnitOfSpeed), } + +DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass | None]] = { + SensorDeviceClass.APPARENT_POWER: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.AQI: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.ATMOSPHERIC_PRESSURE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.BATTERY: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.CO: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.CO2: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.CURRENT: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.DATA_RATE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.DATA_SIZE: set(SensorStateClass), + SensorDeviceClass.DATE: set(), + SensorDeviceClass.DISTANCE: set(SensorStateClass), + SensorDeviceClass.DURATION: set(), + SensorDeviceClass.ENERGY: { + SensorStateClass.TOTAL, + SensorStateClass.TOTAL_INCREASING, + }, + SensorDeviceClass.ENUM: set(), + SensorDeviceClass.FREQUENCY: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.GAS: {SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING}, + SensorDeviceClass.HUMIDITY: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.ILLUMINANCE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.IRRADIANCE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.MOISTURE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.MONETARY: {SensorStateClass.TOTAL}, + SensorDeviceClass.NITROGEN_DIOXIDE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.NITROGEN_MONOXIDE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.NITROUS_OXIDE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.OZONE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.PM1: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.PM10: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.PM25: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.POWER_FACTOR: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.POWER: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.PRECIPITATION: { + SensorStateClass.TOTAL, + SensorStateClass.TOTAL_INCREASING, + }, + SensorDeviceClass.PRECIPITATION_INTENSITY: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.PRESSURE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.REACTIVE_POWER: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.SIGNAL_STRENGTH: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.SOUND_PRESSURE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.SPEED: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.SULPHUR_DIOXIDE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.TEMPERATURE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.TIMESTAMP: set(), + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.VOLTAGE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.VOLUME: { + SensorStateClass.TOTAL, + SensorStateClass.TOTAL_INCREASING, + }, + SensorDeviceClass.WATER: { + SensorStateClass.TOTAL, + SensorStateClass.TOTAL_INCREASING, + }, + SensorDeviceClass.WEIGHT: {SensorStateClass.TOTAL}, + SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT}, +} diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 73c37d5697b..6a5f4fcfffd 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1096,40 +1096,6 @@ async def test_invalid_enumeration_entity_without_device_class( ) in caplog.text -@pytest.mark.parametrize( - "device_class", - ( - SensorDeviceClass.DATE, - SensorDeviceClass.ENUM, - SensorDeviceClass.TIMESTAMP, - ), -) -async def test_non_numeric_device_class_with_state_class( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_custom_integrations: None, - device_class: SensorDeviceClass, -): - """Test error on numeric entities that provide an state class.""" - platform = getattr(hass.components, "test.sensor") - platform.init(empty=True) - platform.ENTITIES["0"] = platform.MockSensor( - name="Test", - native_value=None, - device_class=device_class, - 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 a state class and thus indicating it has a numeric " - f"value; however, it has the non-numeric device class: {device_class}" - ) in caplog.text - - @pytest.mark.parametrize( "device_class", ( @@ -1365,3 +1331,32 @@ async def test_numeric_validation_ignores_custom_device_class( "thus indicating it has a numeric value; " f"however, it has the non-numeric value: {native_value}" ) not in caplog.text + + +@pytest.mark.parametrize( + "device_class", + list(SensorDeviceClass), +) +async def test_device_classes_with_invalid_state_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, + device_class: SensorDeviceClass, +): + """Test error when unit of measurement is not valid for used device class.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + native_value=None, + state_class="INVALID!", + device_class=device_class, + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert ( + "is using state class 'INVALID!' which is impossible considering device " + f"class ('{device_class}') it is using" + ) in caplog.text