diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 4de089f9cf3..6cb0b45418c 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -249,7 +249,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( metric_unit=UnitOfLength.METERS, us_customary_unit=UnitOfLength.FEET, value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), - native_precision=0, + suggested_display_precision=0, ), AccuWeatherSensorDescription( key="CloudCover", diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 93f6d26e145..754471c9d8b 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -69,8 +69,8 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( key=ATTR_API_CAQI, icon="mdi:air-filter", name=ATTR_API_CAQI, - native_precision=0, native_unit_of_measurement="CAQI", + suggested_display_precision=0, attrs=lambda data: { ATTR_LEVEL: data[ATTR_API_CAQI_LEVEL], ATTR_ADVICE: data[ATTR_API_ADVICE], @@ -81,17 +81,17 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( key=ATTR_API_PM1, device_class=SensorDeviceClass.PM1, name="PM1.0", - native_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), AirlySensorEntityDescription( key=ATTR_API_PM25, device_class=SensorDeviceClass.PM25, name="PM2.5", - native_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, attrs=lambda data: { ATTR_LIMIT: data[f"{ATTR_API_PM25}_{SUFFIX_LIMIT}"], ATTR_PERCENT: round(data[f"{ATTR_API_PM25}_{SUFFIX_PERCENT}"]), @@ -101,9 +101,9 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( key=ATTR_API_PM10, device_class=SensorDeviceClass.PM10, name=ATTR_API_PM10, - native_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, attrs=lambda data: { ATTR_LIMIT: data[f"{ATTR_API_PM10}_{SUFFIX_LIMIT}"], ATTR_PERCENT: round(data[f"{ATTR_API_PM10}_{SUFFIX_PERCENT}"]), @@ -113,32 +113,32 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( key=ATTR_API_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, name=ATTR_API_HUMIDITY.capitalize(), - native_precision=1, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), AirlySensorEntityDescription( key=ATTR_API_PRESSURE, device_class=SensorDeviceClass.PRESSURE, name=ATTR_API_PRESSURE.capitalize(), - native_precision=0, native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), AirlySensorEntityDescription( key=ATTR_API_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, name=ATTR_API_TEMPERATURE.capitalize(), - native_precision=1, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), AirlySensorEntityDescription( key=ATTR_API_CO, name="Carbon monoxide", - native_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, attrs=lambda data: { ATTR_LIMIT: data[f"{ATTR_API_CO}_{SUFFIX_LIMIT}"], ATTR_PERCENT: round(data[f"{ATTR_API_CO}_{SUFFIX_PERCENT}"]), @@ -148,9 +148,9 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( key=ATTR_API_NO2, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, name="Nitrogen dioxide", - native_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, attrs=lambda data: { ATTR_LIMIT: data[f"{ATTR_API_NO2}_{SUFFIX_LIMIT}"], ATTR_PERCENT: round(data[f"{ATTR_API_NO2}_{SUFFIX_PERCENT}"]), @@ -160,9 +160,9 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( key=ATTR_API_SO2, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, name="Sulphur dioxide", - native_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, attrs=lambda data: { ATTR_LIMIT: data[f"{ATTR_API_SO2}_{SUFFIX_LIMIT}"], ATTR_PERCENT: round(data[f"{ATTR_API_SO2}_{SUFFIX_PERCENT}"]), @@ -172,9 +172,9 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( key=ATTR_API_O3, device_class=SensorDeviceClass.OZONE, name="Ozone", - native_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, attrs=lambda data: { ATTR_LIMIT: data[f"{ATTR_API_O3}_{SUFFIX_LIMIT}"], ATTR_PERCENT: round(data[f"{ATTR_API_O3}_{SUFFIX_PERCENT}"]), diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index bed886d4ba0..08e6a8a0c90 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone from decimal import Decimal, InvalidOperation as DecimalInvalidOperation import logging -from math import ceil, floor, log10 +from math import floor, log10 import re from typing import Any, Final, cast, final @@ -138,10 +138,10 @@ class SensorEntityDescription(EntityDescription): device_class: SensorDeviceClass | None = None last_reset: datetime | None = None - native_precision: int | None = None native_unit_of_measurement: str | None = None options: list[str] | None = None state_class: SensorStateClass | str | None = None + suggested_display_precision: int | None = None suggested_unit_of_measurement: str | None = None unit_of_measurement: None = None # Type override, use native_unit_of_measurement @@ -152,12 +152,12 @@ 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 _attr_state_class: SensorStateClass | str | None _attr_state: None = None # Subclasses of SensorEntity should not set this + _attr_suggested_display_precision: int | None _attr_suggested_unit_of_measurement: str | None _attr_unit_of_measurement: None = ( None # Subclasses of SensorEntity should not set this @@ -166,7 +166,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_display_precision: int | None = None _sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED @callback @@ -236,7 +236,8 @@ class SensorEntity(Entity): await super().async_internal_added_to_hass() if not self.registry_entry: return - self.async_registry_entry_updated() + self._async_read_entity_options() + self._update_suggested_precision() @property def device_class(self) -> SensorDeviceClass | None: @@ -254,7 +255,7 @@ class SensorEntity(Entity): if ( self.state_class is not None or self.native_unit_of_measurement is not None - or self.native_precision is not None + or self.suggested_display_precision is not None ): return True # Sensors with custom device classes are not considered numeric @@ -359,59 +360,14 @@ class SensorEntity(Entity): 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 + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + if hasattr(self, "_attr_suggested_display_precision"): + return self._attr_suggested_display_precision if hasattr(self, "entity_description"): - return self.entity_description.native_precision + return self.entity_description.suggested_display_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.""" @@ -576,7 +532,7 @@ class SensorEntity(Entity): ) return value - precision = self.precision + suggested_precision = self.suggested_display_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 @@ -593,13 +549,13 @@ class SensorEntity(Entity): 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: + if suggested_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)})" + f"suggested precision {suggested_precision} thus indicating it " + f"has a numeric value; however, it has the non-numeric value: " + f"{value} ({type(value)})" ) from err # This should raise in Home Assistant Core 2023.4 if not self._invalid_numeric_value_reported: @@ -629,7 +585,18 @@ class SensorEntity(Entity): # Unit conversion needed converter = UNIT_CONVERTERS[device_class] - if precision is None: + converted_numerical_value = UNIT_CONVERTERS[device_class].convert( + float(numerical_value), + native_unit_of_measurement, + unit_of_measurement, + ) + + # If unit conversion is happening, and there's no rounding for display, + # do a best effort rounding here. + if ( + suggested_precision is None + and self._sensor_option_display_precision is None + ): # Deduce the precision by finding the decimal point, if any value_s = str(value) precision = ( @@ -648,20 +615,10 @@ class SensorEntity(Entity): ) 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}" - # This can be replaced with adding the z option when we drop support for - # Python 3.10 - value = NEGATIVE_ZERO_PATTERN.sub(r"\1", value) - elif precision is not None: - value = f"{numerical_value:.{precision}f}" - # This can be replaced with adding the z option when we drop support for - # Python 3.10 - value = NEGATIVE_ZERO_PATTERN.sub(r"\1", value) + value = f"{converted_numerical_value:.{precision}f}" + # This can be replaced with adding the z option when we drop support for + # Python 3.10 + value = NEGATIVE_ZERO_PATTERN.sub(r"\1", value) # Validate unit of measurement used for sensors with a device class if ( @@ -703,15 +660,35 @@ class SensorEntity(Entity): return super().__repr__() - def _custom_precision_or_none(self) -> int | None: - """Return a custom precisions or None if not set.""" + def _suggested_precision_or_none(self) -> int | None: + """Return suggested display precision, or None if not set.""" assert self.registry_entry if (sensor_options := self.registry_entry.options.get(DOMAIN)) and ( - precision := sensor_options.get("precision") + precision := sensor_options.get("suggested_display_precision") ) is not None: - return int(precision) + return cast(int, precision) return None + def _update_suggested_precision(self) -> None: + """Update suggested display precision stored in registry.""" + assert self.registry_entry + + display_precision = self.suggested_display_precision + + if ( + sensor_options := self.registry_entry.options.get(DOMAIN, {}) + ) and sensor_options.get("suggested_display_precision") == display_precision: + return + + registry = er.async_get(self.hass) + sensor_options = dict(sensor_options) + sensor_options.pop("suggested_display_precision", None) + if display_precision is not None: + sensor_options["suggested_display_precision"] = display_precision + registry.async_update_entity_options( + self.entity_id, DOMAIN, sensor_options or None + ) + def _custom_unit_or_undef( self, primary_key: str, secondary_key: str ) -> str | None | UndefinedType: @@ -732,7 +709,16 @@ 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._async_read_entity_options() + + @callback + def _async_read_entity_options(self) -> None: + """Read entity options from entity registry. + + Called when the entity registry entry has been updated and before the sensor is + added to the state machine. + """ + self._sensor_option_display_precision = self._suggested_precision_or_none() assert self.registry_entry if ( sensor_options := self.registry_entry.options.get(f"{DOMAIN}.private") diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index a96408e35ac..d80c5a331ca 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -44,7 +44,7 @@ async def test_sensor_without_forecast(hass): state = hass.states.get("sensor.home_cloud_ceiling") assert state - assert state.state == "3200" + assert state.state == "3200.0" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_ICON) == "mdi:weather-fog" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.METERS @@ -54,6 +54,7 @@ async def test_sensor_without_forecast(hass): entry = registry.async_get("sensor.home_cloud_ceiling") assert entry assert entry.unique_id == "0123456-ceiling" + assert entry.options["sensor"] == {"suggested_display_precision": 0} state = hass.states.get("sensor.home_precipitation") assert state @@ -665,7 +666,7 @@ async def test_availability(hass): state = hass.states.get("sensor.home_cloud_ceiling") assert state assert state.state != STATE_UNAVAILABLE - assert state.state == "3200" + assert state.state == "3200.0" future = utcnow() + timedelta(minutes=60) with patch( @@ -696,7 +697,7 @@ async def test_availability(hass): state = hass.states.get("sensor.home_cloud_ceiling") assert state assert state.state != STATE_UNAVAILABLE - assert state.state == "3200" + assert state.state == "3200.0" async def test_manual_update_entity(hass): @@ -736,7 +737,7 @@ async def test_sensor_imperial_units(hass): state = hass.states.get("sensor.home_cloud_ceiling") assert state - assert state.state == "10500" + assert state.state == "10500.0" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_ICON) == "mdi:weather-fog" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.FEET @@ -749,7 +750,7 @@ async def test_state_update(hass): state = hass.states.get("sensor.home_cloud_ceiling") assert state assert state.state != STATE_UNAVAILABLE - assert state.state == "3200" + assert state.state == "3200.0" future = utcnow() + timedelta(minutes=60) diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index 5e76e96305e..c8a79e04be2 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -28,7 +28,7 @@ async def test_async_setup_entry(hass, aioclient_mock): state = hass.states.get("sensor.home_pm2_5") assert state is not None assert state.state != STATE_UNAVAILABLE - assert state.state == "4" + assert state.state == "4.37" async def test_config_not_ready(hass, aioclient_mock): diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index 0501ae4193a..6aa24559314 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -38,7 +38,7 @@ async def test_sensor(hass, aioclient_mock): state = hass.states.get("sensor.home_caqi") assert state - assert state.state == "7" + assert state.state == "7.29" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "CAQI" assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" @@ -46,10 +46,11 @@ async def test_sensor(hass, aioclient_mock): entry = registry.async_get("sensor.home_caqi") assert entry assert entry.unique_id == "123-456-caqi" + assert entry.options["sensor"] == {"suggested_display_precision": 0} state = hass.states.get("sensor.home_humidity") assert state - assert state.state == "68.3" + assert state.state == "68.35" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY @@ -58,10 +59,11 @@ async def test_sensor(hass, aioclient_mock): entry = registry.async_get("sensor.home_humidity") assert entry assert entry.unique_id == "123-456-humidity" + assert entry.options["sensor"] == {"suggested_display_precision": 1} state = hass.states.get("sensor.home_pm1_0") assert state - assert state.state == "3" + assert state.state == "2.83" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -73,10 +75,11 @@ async def test_sensor(hass, aioclient_mock): entry = registry.async_get("sensor.home_pm1_0") assert entry assert entry.unique_id == "123-456-pm1" + assert entry.options["sensor"] == {"suggested_display_precision": 0} state = hass.states.get("sensor.home_pm2_5") assert state - assert state.state == "4" + assert state.state == "4.37" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -88,10 +91,11 @@ async def test_sensor(hass, aioclient_mock): entry = registry.async_get("sensor.home_pm2_5") assert entry assert entry.unique_id == "123-456-pm25" + assert entry.options["sensor"] == {"suggested_display_precision": 0} state = hass.states.get("sensor.home_pm10") assert state - assert state.state == "6" + assert state.state == "6.06" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -103,16 +107,18 @@ async def test_sensor(hass, aioclient_mock): entry = registry.async_get("sensor.home_pm10") assert entry assert entry.unique_id == "123-456-pm10" + assert entry.options["sensor"] == {"suggested_display_precision": 0} state = hass.states.get("sensor.home_carbon_monoxide") assert state - assert state.state == "162" + assert state.state == "162.49" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert entry.options["sensor"] == {"suggested_display_precision": 0} entry = registry.async_get("sensor.home_carbon_monoxide") assert entry @@ -120,7 +126,7 @@ async def test_sensor(hass, aioclient_mock): state = hass.states.get("sensor.home_nitrogen_dioxide") assert state - assert state.state == "16" + assert state.state == "16.04" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -132,10 +138,11 @@ async def test_sensor(hass, aioclient_mock): entry = registry.async_get("sensor.home_nitrogen_dioxide") assert entry assert entry.unique_id == "123-456-no2" + assert entry.options["sensor"] == {"suggested_display_precision": 0} state = hass.states.get("sensor.home_ozone") assert state - assert state.state == "42" + assert state.state == "41.52" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -147,10 +154,11 @@ async def test_sensor(hass, aioclient_mock): entry = registry.async_get("sensor.home_ozone") assert entry assert entry.unique_id == "123-456-o3" + assert entry.options["sensor"] == {"suggested_display_precision": 0} state = hass.states.get("sensor.home_sulphur_dioxide") assert state - assert state.state == "14" + assert state.state == "13.97" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -162,10 +170,11 @@ async def test_sensor(hass, aioclient_mock): entry = registry.async_get("sensor.home_sulphur_dioxide") assert entry assert entry.unique_id == "123-456-so2" + assert entry.options["sensor"] == {"suggested_display_precision": 0} state = hass.states.get("sensor.home_pressure") assert state - assert state.state == "1020" + assert state.state == "1019.86" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.HPA assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE @@ -174,10 +183,11 @@ async def test_sensor(hass, aioclient_mock): entry = registry.async_get("sensor.home_pressure") assert entry assert entry.unique_id == "123-456-pressure" + assert entry.options["sensor"] == {"suggested_display_precision": 0} state = hass.states.get("sensor.home_temperature") assert state - assert state.state == "14.4" + assert state.state == "14.37" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE @@ -186,6 +196,7 @@ async def test_sensor(hass, aioclient_mock): entry = registry.async_get("sensor.home_temperature") assert entry assert entry.unique_id == "123-456-temperature" + assert entry.options["sensor"] == {"suggested_display_precision": 1} async def test_availability(hass, aioclient_mock): @@ -195,7 +206,7 @@ async def test_availability(hass, aioclient_mock): state = hass.states.get("sensor.home_humidity") assert state assert state.state != STATE_UNAVAILABLE - assert state.state == "68.3" + assert state.state == "68.35" aioclient_mock.clear_requests() aioclient_mock.get( @@ -218,7 +229,7 @@ async def test_availability(hass, aioclient_mock): state = hass.states.get("sensor.home_humidity") assert state assert state.state != STATE_UNAVAILABLE - assert state.state == "68.3" + assert state.state == "68.35" async def test_manual_update_entity(hass, aioclient_mock): diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 17a794ac0d3..98cc3efb2e0 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -518,195 +518,6 @@ async def test_custom_unit( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == state_unit -@pytest.mark.parametrize( - "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 - ), - ( - SensorDeviceClass.ATMOSPHERIC_PRESSURE, - UnitOfPressure.INHG, - UnitOfPressure.HPA, - -0.0001, - 3, - "0.000", # Native precision is 3 - "0.00", # 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", - ), - ( - SensorDeviceClass.DISTANCE, - UnitOfLength.KILOMETERS, - 1, - -0.04, - "-0.040", - "0.0", # Make sure minus is dropped - ), - ], -) -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", [ @@ -1188,6 +999,121 @@ async def test_unit_conversion_priority_suggested_unit_change( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == original_unit +@pytest.mark.parametrize( + "native_unit, suggested_precision, native_value, device_class", + [ + # Distance + ( + UnitOfLength.KILOMETERS, + 4, + 1000, + SensorDeviceClass.DISTANCE, + ), + ], +) +async def test_suggested_precision_option( + hass, + enable_custom_integrations, + native_unit, + suggested_precision, + native_value, + device_class, +): + """Test suggested precision is stored in the registry.""" + + entity_registry = er.async_get(hass) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + suggested_display_precision=suggested_precision, + unique_id="very_unique", + ) + entity0 = platform.ENTITIES["0"] + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Assert the suggested precision is stored in the registry + entry = entity_registry.async_get(entity0.entity_id) + assert entry.options == { + "sensor": {"suggested_display_precision": suggested_precision} + } + + +@pytest.mark.parametrize( + "native_unit, old_precision, new_precision, native_value, device_class", + [ + ( + UnitOfLength.KILOMETERS, + 4, + 1, + 1000, + SensorDeviceClass.DISTANCE, + ), + ], +) +async def test_suggested_precision_option_update( + hass, + enable_custom_integrations, + native_unit, + old_precision, + new_precision, + native_value, + device_class, +): + """Test suggested precision stored in the registry is updated.""" + + entity_registry = er.async_get(hass) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + + # Pre-register entities + entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") + entity_registry.async_update_entity_options( + entry.entity_id, + "sensor", + { + "suggested_display_precision": old_precision, + }, + ) + entity_registry.async_update_entity_options( + entry.entity_id, + "sensor.private", + { + "suggested_unit_of_measurement": native_unit, + }, + ) + + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + suggested_display_precision=new_precision, + unique_id="very_unique", + ) + entity0 = platform.ENTITIES["0"] + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Assert the suggested precision is stored in the registry + entry = entity_registry.async_get(entity0.entity_id) + assert entry.options == { + "sensor": { + "suggested_display_precision": new_precision, + }, + "sensor.private": { + "suggested_unit_of_measurement": native_unit, + }, + } + + @pytest.mark.parametrize( "unit_system, native_unit, original_unit, native_value, original_value, device_class", [ @@ -1519,10 +1445,10 @@ async def test_non_numeric_validation_raise( 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, + suggested_display_precision=precision, ) entity0 = platform.ENTITIES["0"] @@ -1650,7 +1576,7 @@ async def test_device_classes_with_invalid_state_class( @pytest.mark.parametrize( - "device_class,state_class,native_unit_of_measurement,native_precision,is_numeric", + "device_class,state_class,native_unit_of_measurement,suggested_precision,is_numeric", [ (SensorDeviceClass.ENUM, None, None, None, False), (SensorDeviceClass.DATE, None, None, None, False), @@ -1669,7 +1595,7 @@ async def test_numeric_state_expected_helper( device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, native_unit_of_measurement: str | None, - native_precision: int | None, + suggested_precision: int | None, is_numeric: bool, ) -> None: """Test numeric_state_expected helper.""" @@ -1681,7 +1607,7 @@ async def test_numeric_state_expected_helper( device_class=device_class, state_class=state_class, native_unit_of_measurement=native_unit_of_measurement, - native_precision=native_precision, + suggested_display_precision=suggested_precision, ) assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index 6594d9bbfae..4eae52fd4a5 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -98,9 +98,9 @@ class MockSensor(MockEntity, SensorEntity): return self._handle("last_reset") @property - def native_precision(self): + def suggested_display_precision(self): """Return the number of digits after the decimal point.""" - return self._handle("native_precision") + return self._handle("suggested_display_precision") @property def native_unit_of_measurement(self):