diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 893d5f3728b..6dd745861e0 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone from decimal import Decimal, InvalidOperation as DecimalInvalidOperation import logging -from math import floor, log10 +from math import ceil, floor, log10 from typing import Any, Final, cast, final from homeassistant.config_entries import ConfigEntry @@ -133,11 +133,12 @@ class SensorEntityDescription(EntityDescription): """A class that describes sensor entities.""" device_class: SensorDeviceClass | None = None - suggested_unit_of_measurement: str | None = None last_reset: datetime | None = None + native_precision: int | None = None native_unit_of_measurement: str | None = None - state_class: SensorStateClass | str | None = None options: list[str] | None = None + state_class: SensorStateClass | str | None = None + suggested_unit_of_measurement: str | None = None unit_of_measurement: None = None # Type override, use native_unit_of_measurement @@ -147,6 +148,7 @@ class SensorEntity(Entity): entity_description: SensorEntityDescription _attr_device_class: SensorDeviceClass | None _attr_last_reset: datetime | None + _attr_native_precision: int | None _attr_native_unit_of_measurement: str | None _attr_native_value: StateType | date | datetime | Decimal = None _attr_options: list[str] | None @@ -160,6 +162,7 @@ class SensorEntity(Entity): _invalid_state_class_reported = False _invalid_unit_of_measurement_reported = False _last_reset_reported = False + _sensor_option_precision: int | None = None _sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED @callback @@ -337,6 +340,60 @@ class SensorEntity(Entity): """Return the value reported by the sensor.""" return self._attr_native_value + @property + def native_precision(self) -> int | None: + """Return the number of digits after the decimal point for the sensor's state. + + If native_precision is None, no rounding is done unless the sensor is subject + to unit conversion. + + The display precision is influenced by unit conversion, a sensor which has + native_unit_of_measurement 'Wh' and is converted to 'kWh' will have its + native_precision increased by 3. + """ + if hasattr(self, "_attr_native_precision"): + return self._attr_native_precision + if hasattr(self, "entity_description"): + return self.entity_description.native_precision + return None + + @final + @property + def precision(self) -> int | None: + """Return the number of digits after the decimal point for the sensor's state. + + This is the precision after unit conversion. + """ + # Highest priority, for registered entities: precision set by user + if self._sensor_option_precision is not None: + return self._sensor_option_precision + + # Second priority, native precision + if (precision := self.native_precision) is None: + return None + + device_class = self.device_class + native_unit_of_measurement = self.native_unit_of_measurement + unit_of_measurement = self.unit_of_measurement + + if ( + native_unit_of_measurement != unit_of_measurement + and device_class in UNIT_CONVERTERS + ): + converter = UNIT_CONVERTERS[device_class] + + # Scale the precision when converting to a larger or smaller unit + # For example 1.1 Wh should be rendered as 0.0011 kWh, not 0.0 kWh + ratio_log = log10( + converter.get_unit_ratio( + native_unit_of_measurement, unit_of_measurement + ) + ) + ratio_log = floor(ratio_log) if ratio_log > 0 else ceil(ratio_log) + precision = max(0, precision + ratio_log) + + return precision + @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the sensor, if any.""" @@ -399,7 +456,7 @@ class SensorEntity(Entity): @final @property - def state(self) -> Any: + def state(self) -> Any: # noqa: C901 """Return the state of the sensor and perform unit conversions, if needed.""" native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement @@ -508,17 +565,36 @@ class SensorEntity(Entity): ) 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: + precision = self.precision + + # If the sensor has neither a device class, a state class, a unit of measurement + # nor a precision then there are no further checks or conversions + if ( + not device_class + and not state_class + and not unit_of_measurement + and precision is None + ): return value - if not self._invalid_numeric_value_reported and not isinstance( - value, (int, float, Decimal) - ): + # From here on a numerical value is expected + numerical_value: int | float | Decimal + if not isinstance(value, (int, float, Decimal)): try: - _ = float(value) # type: ignore[arg-type] - except (TypeError, ValueError): + if isinstance(value, str) and "." not in value: + numerical_value = int(value) + else: + numerical_value = float(value) # type:ignore[arg-type] + except (TypeError, ValueError) as err: + # Raise if precision is not None, for other cases log a warning + if precision is not None: + raise ValueError( + f"Sensor {self.entity_id} has device class {device_class}, " + f"state class {state_class} unit {unit_of_measurement} and " + f"precision {precision} thus indicating it has a numeric value;" + f" however, it has the non-numeric value: {value} " + f"({type(value)})" + ) from err # This should raise in Home Assistant Core 2023.4 self._invalid_numeric_value_reported = True report_issue = self._suggest_report_issue() @@ -536,39 +612,43 @@ class SensorEntity(Entity): report_issue, ) return value + else: + numerical_value = value if ( native_unit_of_measurement != unit_of_measurement and device_class in UNIT_CONVERTERS ): + # Unit conversion needed converter = UNIT_CONVERTERS[device_class] - value_s = str(value) - prec = len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 - - # Scale the precision when converting to a larger unit - # For example 1.1 Wh should be rendered as 0.0011 kWh, not 0.0 kWh - ratio_log = max( - 0, - log10( - converter.get_unit_ratio( - native_unit_of_measurement, unit_of_measurement - ) - ), - ) - prec = prec + floor(ratio_log) - - # Suppress ValueError (Could not convert sensor_value to float) - with suppress(ValueError): - value_f = float(value) # type: ignore[arg-type] - value_f_new = converter.convert( - value_f, - native_unit_of_measurement, - unit_of_measurement, + if precision is None: + # Deduce the precision by finding the decimal point, if any + value_s = str(value) + precision = ( + len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 ) - # Round to the wanted precision - value = round(value_f_new) if prec == 0 else round(value_f_new, prec) + # Scale the precision when converting to a larger unit + # For example 1.1 Wh should be rendered as 0.0011 kWh, not 0.0 kWh + ratio_log = max( + 0, + log10( + converter.get_unit_ratio( + native_unit_of_measurement, unit_of_measurement + ) + ), + ) + precision = precision + floor(ratio_log) + + converted_numerical_value = converter.convert( + float(numerical_value), + native_unit_of_measurement, + unit_of_measurement, + ) + value = f"{converted_numerical_value:.{precision}f}" + elif precision is not None: + value = f"{numerical_value:.{precision}f}" # Validate unit of measurement used for sensors with a device class if ( @@ -608,6 +688,15 @@ class SensorEntity(Entity): return super().__repr__() + def _custom_precision_or_none(self) -> int | None: + """Return a custom precisions or None if not set.""" + assert self.registry_entry + if (sensor_options := self.registry_entry.options.get(DOMAIN)) and ( + precision := sensor_options.get("precision") + ) is not None: + return int(precision) + return None + def _custom_unit_or_undef( self, primary_key: str, secondary_key: str ) -> str | None | UndefinedType: @@ -628,6 +717,7 @@ class SensorEntity(Entity): @callback def async_registry_entry_updated(self) -> None: """Run when the entity registry entry has been updated.""" + self._sensor_option_precision = self._custom_precision_or_none() self._sensor_option_unit_of_measurement = self._custom_unit_or_undef( DOMAIN, CONF_UNIT_OF_MEASUREMENT ) diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 248dc020732..151ee7c42fb 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -20,7 +20,7 @@ async def test_airzone_create_sensors( # Zones state = hass.states.get("sensor.despacho_temperature") - assert state.state == "21.2" + assert state.state == "21.20" state = hass.states.get("sensor.despacho_humidity") assert state.state == "36" diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 6a5f4fcfffd..cd62228f83a 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -41,23 +41,29 @@ from tests.common import mock_restore_cache_with_extra_data UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.FAHRENHEIT, 100, - 100, + "100", ), ( US_CUSTOMARY_SYSTEM, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT, 38, - 100, + "100", ), ( METRIC_SYSTEM, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS, 100, - 38, + "38", + ), + ( + METRIC_SYSTEM, + UnitOfTemperature.CELSIUS, + UnitOfTemperature.CELSIUS, + 38, + "38", ), - (METRIC_SYSTEM, UnitOfTemperature.CELSIUS, UnitOfTemperature.CELSIUS, 38, 38), ], ) async def test_temperature_conversion( @@ -85,7 +91,7 @@ async def test_temperature_conversion( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert float(state.state) == approx(float(state_value)) + assert state.state == state_value assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == state_unit @@ -407,7 +413,7 @@ async def test_restore_sensor_restore_state( @pytest.mark.parametrize( - "device_class,native_unit,custom_unit,state_unit,native_value,custom_value", + "device_class, native_unit, custom_unit, state_unit, native_value, custom_state", [ # Smaller to larger unit, InHg is ~33x larger than hPa -> 1 more decimal ( @@ -416,7 +422,7 @@ async def test_restore_sensor_restore_state( UnitOfPressure.INHG, UnitOfPressure.INHG, 1000.0, - 29.53, + "29.53", ), ( SensorDeviceClass.PRESSURE, @@ -424,7 +430,15 @@ async def test_restore_sensor_restore_state( UnitOfPressure.HPA, UnitOfPressure.HPA, 1.234, - 12.34, + "12.340", + ), + ( + SensorDeviceClass.ATMOSPHERIC_PRESSURE, + UnitOfPressure.HPA, + UnitOfPressure.MMHG, + UnitOfPressure.MMHG, + 1000, + "750", ), ( SensorDeviceClass.PRESSURE, @@ -432,7 +446,7 @@ async def test_restore_sensor_restore_state( UnitOfPressure.MMHG, UnitOfPressure.MMHG, 1000, - 750, + "750", ), # Not a supported pressure unit ( @@ -441,7 +455,7 @@ async def test_restore_sensor_restore_state( "peer_pressure", UnitOfPressure.HPA, 1000, - 1000, + "1000", ), ( SensorDeviceClass.TEMPERATURE, @@ -449,7 +463,7 @@ async def test_restore_sensor_restore_state( UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.FAHRENHEIT, 37.5, - 99.5, + "99.5", ), ( SensorDeviceClass.TEMPERATURE, @@ -457,7 +471,7 @@ async def test_restore_sensor_restore_state( UnitOfTemperature.CELSIUS, UnitOfTemperature.CELSIUS, 100, - 38.0, + "38", ), ], ) @@ -469,7 +483,7 @@ async def test_custom_unit( custom_unit, state_unit, native_value, - custom_value, + custom_state, ): """Test custom unit.""" entity_registry = er.async_get(hass) @@ -495,12 +509,184 @@ async def test_custom_unit( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert float(state.state) == approx(float(custom_value)) + assert state.state == custom_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == state_unit @pytest.mark.parametrize( - "native_unit,custom_unit,state_unit,native_value,custom_value,device_class", + "device_class,native_unit,custom_unit,native_value,native_precision,default_state,custom_state", + [ + ( + SensorDeviceClass.ATMOSPHERIC_PRESSURE, + UnitOfPressure.HPA, + UnitOfPressure.INHG, + 1000.0, + 2, + "1000.00", # Native precision is 2 + "29.530", # One digit of precision added when converting + ), + ( + SensorDeviceClass.ATMOSPHERIC_PRESSURE, + UnitOfPressure.INHG, + UnitOfPressure.HPA, + 29.9211, + 3, + "29.921", # Native precision is 3 + "1013.24", # One digit of precision removed when converting + ), + ], +) +async def test_native_precision_scaling( + hass, + enable_custom_integrations, + device_class, + native_unit, + custom_unit, + native_value, + native_precision, + default_state, + custom_state, +): + """Test native precision is influenced by unit conversion.""" + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + native_value=str(native_value), + native_precision=native_precision, + native_unit_of_measurement=native_unit, + device_class=device_class, + unique_id="very_unique", + ) + + 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 == default_state + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit + + entity_registry.async_update_entity_options( + entry.entity_id, "sensor", {"unit_of_measurement": custom_unit} + ) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.state == custom_state + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit + + +@pytest.mark.parametrize( + "device_class,native_unit,custom_precision,native_value,default_state,custom_state", + [ + ( + SensorDeviceClass.ATMOSPHERIC_PRESSURE, + UnitOfPressure.HPA, + 4, + 1000.0, + "1000.000", + "1000.0000", + ), + ], +) +async def test_custom_precision_native_precision( + hass, + enable_custom_integrations, + device_class, + native_unit, + custom_precision, + native_value, + default_state, + custom_state, +): + """Test custom precision.""" + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + native_value=str(native_value), + native_precision=3, + native_unit_of_measurement=native_unit, + device_class=device_class, + unique_id="very_unique", + ) + + 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 == default_state + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit + + entity_registry.async_update_entity_options( + entry.entity_id, "sensor", {"precision": custom_precision} + ) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.state == custom_state + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit + + +@pytest.mark.parametrize( + "device_class,native_unit,custom_precision,native_value,custom_state", + [ + ( + SensorDeviceClass.ATMOSPHERIC_PRESSURE, + UnitOfPressure.HPA, + 4, + 1000.0, + "1000.0000", + ), + ], +) +async def test_custom_precision_no_native_precision( + hass, + enable_custom_integrations, + device_class, + native_unit, + custom_precision, + native_value, + custom_state, +): + """Test custom precision.""" + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") + entity_registry.async_update_entity_options( + entry.entity_id, "sensor", {"precision": custom_precision} + ) + await hass.async_block_till_done() + + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + native_value=str(native_value), + native_unit_of_measurement=native_unit, + device_class=device_class, + unique_id="very_unique", + ) + + 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 == custom_state + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit + + +@pytest.mark.parametrize( + "native_unit, custom_unit, state_unit, native_value, native_state, custom_state, device_class", [ # Distance ( @@ -508,7 +694,8 @@ async def test_custom_unit( UnitOfLength.MILES, UnitOfLength.MILES, 1000, - 621, + "1000", + "621", SensorDeviceClass.DISTANCE, ), ( @@ -516,7 +703,8 @@ async def test_custom_unit( UnitOfLength.INCHES, UnitOfLength.INCHES, 7.24, - 2.85, + "7.24", + "2.85", SensorDeviceClass.DISTANCE, ), ( @@ -524,7 +712,8 @@ async def test_custom_unit( "peer_distance", UnitOfLength.KILOMETERS, 1000, - 1000, + "1000", + "1000", SensorDeviceClass.DISTANCE, ), # Energy @@ -533,7 +722,8 @@ async def test_custom_unit( UnitOfEnergy.MEGA_WATT_HOUR, UnitOfEnergy.MEGA_WATT_HOUR, 1000, - 1.0, + "1000", + "1.000", SensorDeviceClass.ENERGY, ), ( @@ -541,7 +731,8 @@ async def test_custom_unit( UnitOfEnergy.MEGA_WATT_HOUR, UnitOfEnergy.MEGA_WATT_HOUR, 1000, - 278, + "1000", + "278", SensorDeviceClass.ENERGY, ), ( @@ -549,7 +740,8 @@ async def test_custom_unit( "BTU", UnitOfEnergy.KILO_WATT_HOUR, 1000, - 1000, + "1000", + "1000", SensorDeviceClass.ENERGY, ), # Power factor @@ -558,7 +750,8 @@ async def test_custom_unit( PERCENTAGE, PERCENTAGE, 1.0, - 100, + "1.0", + "100.0", SensorDeviceClass.POWER_FACTOR, ), ( @@ -566,7 +759,8 @@ async def test_custom_unit( None, None, 100, - 1, + "100", + "1.00", SensorDeviceClass.POWER_FACTOR, ), ( @@ -574,7 +768,8 @@ async def test_custom_unit( None, "Cos φ", 1.0, - 1.0, + "1.0", + "1.0", SensorDeviceClass.POWER_FACTOR, ), # Pressure @@ -584,7 +779,8 @@ async def test_custom_unit( UnitOfPressure.INHG, UnitOfPressure.INHG, 1000.0, - 29.53, + "1000.0", + "29.53", SensorDeviceClass.PRESSURE, ), ( @@ -592,7 +788,8 @@ async def test_custom_unit( UnitOfPressure.HPA, UnitOfPressure.HPA, 1.234, - 12.34, + "1.234", + "12.340", SensorDeviceClass.PRESSURE, ), ( @@ -600,7 +797,8 @@ async def test_custom_unit( UnitOfPressure.MMHG, UnitOfPressure.MMHG, 1000, - 750, + "1000", + "750", SensorDeviceClass.PRESSURE, ), # Not a supported pressure unit @@ -609,7 +807,8 @@ async def test_custom_unit( "peer_pressure", UnitOfPressure.HPA, 1000, - 1000, + "1000", + "1000", SensorDeviceClass.PRESSURE, ), # Speed @@ -618,7 +817,8 @@ async def test_custom_unit( UnitOfSpeed.MILES_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR, 100, - 62, + "100", + "62", SensorDeviceClass.SPEED, ), ( @@ -626,7 +826,8 @@ async def test_custom_unit( UnitOfVolumetricFlux.INCHES_PER_HOUR, UnitOfVolumetricFlux.INCHES_PER_HOUR, 78, - 0.13, + "78", + "0.13", SensorDeviceClass.SPEED, ), ( @@ -634,7 +835,8 @@ async def test_custom_unit( "peer_distance", UnitOfSpeed.KILOMETERS_PER_HOUR, 100, - 100, + "100", + "100", SensorDeviceClass.SPEED, ), # Volume @@ -643,7 +845,8 @@ async def test_custom_unit( UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_FEET, 100, - 3531, + "100", + "3531", SensorDeviceClass.VOLUME, ), ( @@ -651,7 +854,8 @@ async def test_custom_unit( UnitOfVolume.FLUID_OUNCES, UnitOfVolume.FLUID_OUNCES, 2.3, - 77.8, + "2.3", + "77.8", SensorDeviceClass.VOLUME, ), ( @@ -659,7 +863,8 @@ async def test_custom_unit( "peer_distance", UnitOfVolume.CUBIC_METERS, 100, - 100, + "100", + "100", SensorDeviceClass.VOLUME, ), # Weight @@ -668,7 +873,8 @@ async def test_custom_unit( UnitOfMass.OUNCES, UnitOfMass.OUNCES, 100, - 3.5, + "100", + "3.5", SensorDeviceClass.WEIGHT, ), ( @@ -676,7 +882,8 @@ async def test_custom_unit( UnitOfMass.GRAMS, UnitOfMass.GRAMS, 78, - 2211, + "78", + "2211", SensorDeviceClass.WEIGHT, ), ( @@ -684,7 +891,8 @@ async def test_custom_unit( "peer_distance", UnitOfMass.GRAMS, 100, - 100, + "100", + "100", SensorDeviceClass.WEIGHT, ), ], @@ -696,7 +904,8 @@ async def test_custom_unit_change( custom_unit, state_unit, native_value, - custom_value, + native_state, + custom_state, device_class, ): """Test custom unit changes are picked up.""" @@ -716,7 +925,7 @@ async def test_custom_unit_change( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert float(state.state) == approx(float(native_value)) + assert state.state == native_state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == native_unit entity_registry.async_update_entity_options( @@ -725,7 +934,7 @@ async def test_custom_unit_change( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert float(state.state) == approx(float(custom_value)) + assert state.state == custom_state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == state_unit entity_registry.async_update_entity_options( @@ -734,19 +943,19 @@ async def test_custom_unit_change( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert float(state.state) == approx(float(native_value)) + assert state.state == native_state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == native_unit entity_registry.async_update_entity_options("sensor.test", "sensor", None) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert float(state.state) == approx(float(native_value)) + assert state.state == native_state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == native_unit @pytest.mark.parametrize( - "unit_system, native_unit, automatic_unit, suggested_unit, custom_unit, native_value, automatic_value, suggested_value, custom_value, device_class", + "unit_system, native_unit, automatic_unit, suggested_unit, custom_unit, native_value, native_state, automatic_state, suggested_state, custom_state, device_class", [ # Distance ( @@ -756,9 +965,10 @@ async def test_custom_unit_change( UnitOfLength.METERS, UnitOfLength.YARDS, 1000, - 621, - 1000000, - 1093613, + "1000", + "621", + "1000000", + "1093613", SensorDeviceClass.DISTANCE, ), ], @@ -772,9 +982,10 @@ async def test_unit_conversion_priority( suggested_unit, custom_unit, native_value, - automatic_value, - suggested_value, - custom_value, + native_state, + automatic_state, + suggested_state, + custom_state, device_class, ): """Test priority of unit conversion.""" @@ -826,7 +1037,7 @@ async def test_unit_conversion_priority( # Registered entity -> Follow automatic unit conversion state = hass.states.get(entity0.entity_id) - assert float(state.state) == approx(float(automatic_value)) + assert state.state == automatic_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == automatic_unit # Assert the automatic unit conversion is stored in the registry entry = entity_registry.async_get(entity0.entity_id) @@ -836,12 +1047,12 @@ async def test_unit_conversion_priority( # Unregistered entity -> Follow native unit state = hass.states.get(entity1.entity_id) - assert float(state.state) == approx(float(native_value)) + assert state.state == native_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit # Registered entity with suggested unit state = hass.states.get(entity2.entity_id) - assert float(state.state) == approx(float(suggested_value)) + assert state.state == suggested_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit # Assert the suggested unit is stored in the registry entry = entity_registry.async_get(entity2.entity_id) @@ -851,7 +1062,7 @@ async def test_unit_conversion_priority( # Unregistered entity with suggested unit state = hass.states.get(entity3.entity_id) - assert float(state.state) == approx(float(suggested_value)) + assert state.state == suggested_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit # Set a custom unit, this should have priority over the automatic unit conversion @@ -861,7 +1072,7 @@ async def test_unit_conversion_priority( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert float(state.state) == approx(float(custom_value)) + assert state.state == custom_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit entity_registry.async_update_entity_options( @@ -870,7 +1081,7 @@ async def test_unit_conversion_priority( await hass.async_block_till_done() state = hass.states.get(entity2.entity_id) - assert float(state.state) == approx(float(custom_value)) + assert state.state == custom_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit @@ -964,7 +1175,7 @@ async def test_unit_conversion_priority_suggested_unit_change( UnitOfLength.KILOMETERS, UnitOfLength.MILES, 1000, - 621, + 621.0, SensorDeviceClass.DISTANCE, ), ( @@ -1219,7 +1430,7 @@ async def test_device_classes_with_invalid_unit_of_measurement( (date(2012, 11, 10), "2012-11-10"), ], ) -async def test_non_numeric_validation( +async def test_non_numeric_validation_warn( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, @@ -1253,6 +1464,51 @@ async def test_non_numeric_validation( ) in caplog.text +@pytest.mark.parametrize( + "device_class,state_class,unit,precision", ((None, None, None, 1),) +) +@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_raise( + 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, + precision, +) -> None: + """Test error on expected numeric entities.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_precision=precision, + native_unit_of_measurement=unit, + native_value=native_value, + 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 is None + + assert ("Error adding entities for domain sensor with platform test") in caplog.text + + @pytest.mark.parametrize( "device_class,state_class,unit", [ diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index 9b3911313a6..55ce53c9702 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -98,6 +98,11 @@ class MockSensor(MockEntity, SensorEntity): """Return the last_reset of this sensor.""" return self._handle("last_reset") + @property + def native_precision(self): + """Return the number of digits after the decimal point.""" + return self._handle("native_precision") + @property def native_unit_of_measurement(self): """Return the native unit_of_measurement of this sensor."""