From 3179101fbc8ad23bf604ef9782af1f86648027cb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Jan 2023 11:00:07 +0100 Subject: [PATCH] Warn if numeric sensors have an invalid value (#85863) Co-authored-by: mib1185 --- homeassistant/components/sensor/__init__.py | 30 ++++- tests/components/sensor/test_init.py | 134 ++++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 9480c5fe464..aa53457afd6 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -401,7 +401,12 @@ class SensorEntity(Entity): native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement value = self.native_value - device_class = self.device_class + device_class: SensorDeviceClass | None = None + with suppress(ValueError): + # For the sake of validation, we can ignore custom device classes + # (customization and legacy style translations) + device_class = SensorDeviceClass(str(self.device_class)) + state_class = self.state_class # Sensors with device classes indicating a non-numeric value # should not have a state class or unit of measurement @@ -478,6 +483,29 @@ class SensorEntity(Entity): f"Sensor {self.entity_id} provides state value '{value}', " "which is not in the list of options provided" ) + return value + + # If the sensor has neither a device class, a state class nor + # a unit_of measurement then there are no further checks or conversions + if not device_class and not state_class and not unit_of_measurement: + return value + + if not isinstance(value, (int, float, Decimal)): + try: + _ = float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + _LOGGER.warning( + "Sensor %s has device class %s, state class %s and unit %s " + "thus indicating it has a numeric value; however, it has the " + "non-numeric value: %s (%s). This will stop working in 2023.4", + self.entity_id, + device_class, + state_class, + unit_of_measurement, + value, + type(value), + ) + return value if ( native_unit_of_measurement != unit_of_measurement diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index f4aff0bd119..73c37d5697b 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1,6 +1,9 @@ """The test for sensor entity.""" +from __future__ import annotations + from datetime import date, datetime, timezone from decimal import Decimal +from typing import Any import pytest from pytest import approx @@ -1231,3 +1234,134 @@ async def test_device_classes_with_invalid_unit_of_measurement( "is using native unit of measurement 'INVALID!' which is not a valid " f"unit for the device class ('{device_class}') it is using" ) in caplog.text + + +@pytest.mark.parametrize( + "device_class,state_class,unit", + [ + (SensorDeviceClass.AQI, None, None), + (None, SensorStateClass.MEASUREMENT, None), + (None, None, UnitOfTemperature.CELSIUS), + ], +) +@pytest.mark.parametrize( + "native_value,expected", + [ + ("abc", "abc"), + ("13.7.1", "13.7.1"), + (datetime(2012, 11, 10, 7, 35, 1), "2012-11-10 07:35:01"), + (date(2012, 11, 10), "2012-11-10"), + ], +) +async def test_non_numeric_validation( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, + native_value: Any, + expected: str, + device_class: SensorDeviceClass | None, + state_class: SensorStateClass | None, + unit: str | None, +) -> None: + """Test error on expected numeric entities.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + native_value=native_value, + device_class=device_class, + native_unit_of_measurement=unit, + state_class=state_class, + ) + entity0 = platform.ENTITIES["0"] + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.state == expected + + assert ( + "thus indicating it has a numeric value; " + f"however, it has the non-numeric value: {native_value}" + ) in caplog.text + + +@pytest.mark.parametrize( + "device_class,state_class,unit", + [ + (SensorDeviceClass.AQI, None, None), + (None, SensorStateClass.MEASUREMENT, None), + (None, None, UnitOfTemperature.CELSIUS), + ], +) +@pytest.mark.parametrize( + "native_value,expected", + [ + (13, "13"), + (17.50, "17.5"), + (Decimal(18.50), "18.5"), + ("19.70", "19.70"), + (None, STATE_UNKNOWN), + ], +) +async def test_numeric_validation( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, + native_value: Any, + expected: str, + device_class: SensorDeviceClass | None, + state_class: SensorStateClass | None, + unit: str | None, +) -> None: + """Test does not error on expected numeric entities.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + native_value=native_value, + device_class=device_class, + native_unit_of_measurement=unit, + state_class=state_class, + ) + entity0 = platform.ENTITIES["0"] + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.state == expected + + assert ( + "thus indicating it has a numeric value; " + f"however, it has the non-numeric value: {native_value}" + ) not in caplog.text + + +async def test_numeric_validation_ignores_custom_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, +) -> None: + """Test does not error on expected numeric entities.""" + native_value = "Three elephants" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + native_value=native_value, + device_class="custom__deviceclass", + ) + entity0 = platform.ENTITIES["0"] + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.state == "Three elephants" + + assert ( + "thus indicating it has a numeric value; " + f"however, it has the non-numeric value: {native_value}" + ) not in caplog.text