Add sensor state class validation for device classes (#84402)

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
This commit is contained in:
Franck Nijhof 2023-01-16 14:31:24 +01:00 committed by GitHub
parent 8165f487c7
commit 0a367359f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 130 additions and 52 deletions

View file

@ -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,26 +411,46 @@ 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 {
# should not have a 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}"
)
if unit_of_measurement:
}
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}"
)
# 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:
return None

View file

@ -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},
}

View file

@ -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