mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Add sensor state class validation for device classes (#84402)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
This commit is contained in:
parent
8165f487c7
commit
0a367359f4
@ -64,6 +64,7 @@ from .const import ( # noqa: F401
|
|||||||
ATTR_OPTIONS,
|
ATTR_OPTIONS,
|
||||||
ATTR_STATE_CLASS,
|
ATTR_STATE_CLASS,
|
||||||
CONF_STATE_CLASS,
|
CONF_STATE_CLASS,
|
||||||
|
DEVICE_CLASS_STATE_CLASSES,
|
||||||
DEVICE_CLASS_UNITS,
|
DEVICE_CLASS_UNITS,
|
||||||
DEVICE_CLASSES,
|
DEVICE_CLASSES,
|
||||||
DEVICE_CLASSES_SCHEMA,
|
DEVICE_CLASSES_SCHEMA,
|
||||||
@ -155,6 +156,7 @@ class SensorEntity(Entity):
|
|||||||
_attr_unit_of_measurement: None = (
|
_attr_unit_of_measurement: None = (
|
||||||
None # Subclasses of SensorEntity should not set this
|
None # Subclasses of SensorEntity should not set this
|
||||||
)
|
)
|
||||||
|
_invalid_state_class_reported = False
|
||||||
_invalid_unit_of_measurement_reported = False
|
_invalid_unit_of_measurement_reported = False
|
||||||
_last_reset_reported = False
|
_last_reset_reported = False
|
||||||
_sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED
|
_sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED
|
||||||
@ -409,26 +411,46 @@ class SensorEntity(Entity):
|
|||||||
state_class = self.state_class
|
state_class = self.state_class
|
||||||
|
|
||||||
# Sensors with device classes indicating a non-numeric value
|
# Sensors with device classes indicating a non-numeric value
|
||||||
# should not have a state class or unit of measurement
|
# should not have a unit of measurement
|
||||||
if device_class in {
|
if (
|
||||||
|
device_class
|
||||||
|
in {
|
||||||
SensorDeviceClass.DATE,
|
SensorDeviceClass.DATE,
|
||||||
SensorDeviceClass.ENUM,
|
SensorDeviceClass.ENUM,
|
||||||
SensorDeviceClass.TIMESTAMP,
|
SensorDeviceClass.TIMESTAMP,
|
||||||
}:
|
}
|
||||||
if self.state_class:
|
and unit_of_measurement
|
||||||
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:
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Sensor {self.entity_id} has a unit of measurement and thus "
|
f"Sensor {self.entity_id} has a unit of measurement and thus "
|
||||||
"indicating it has a numeric value; however, it has the "
|
"indicating it has a numeric value; however, it has the "
|
||||||
f"non-numeric device class: {device_class}"
|
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
|
# Checks below only apply if there is a value
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
@ -501,3 +501,64 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
|
|||||||
SensorDeviceClass.WEIGHT: set(UnitOfMass),
|
SensorDeviceClass.WEIGHT: set(UnitOfMass),
|
||||||
SensorDeviceClass.WIND_SPEED: set(UnitOfSpeed),
|
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},
|
||||||
|
}
|
||||||
|
@ -1096,40 +1096,6 @@ async def test_invalid_enumeration_entity_without_device_class(
|
|||||||
) in caplog.text
|
) 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(
|
@pytest.mark.parametrize(
|
||||||
"device_class",
|
"device_class",
|
||||||
(
|
(
|
||||||
@ -1365,3 +1331,32 @@ async def test_numeric_validation_ignores_custom_device_class(
|
|||||||
"thus indicating it has a numeric value; "
|
"thus indicating it has a numeric value; "
|
||||||
f"however, it has the non-numeric value: {native_value}"
|
f"however, it has the non-numeric value: {native_value}"
|
||||||
) not in caplog.text
|
) 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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user