Allow customizing sensor state precision (#86074)

* Allow customizing sensor precision

* Don't convert integer strings to floats

* Tweak converting sensor state to number

* Drop default rounding to 2 decimals

* Adjust test

* Tweak rounding, improve test coverage

* Don't convert to a number if not necessary

* Raise if native_precision is set and state is not numeric

* Address review comments

* Address comments, simplify

* Don't call  property twice

* Make exception more helpful
This commit is contained in:
Erik Montnemery 2023-01-25 08:55:46 +01:00 committed by GitHub
parent ba63a9600e
commit 086a6460ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 445 additions and 94 deletions

View File

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

View File

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

View File

@ -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",
[

View File

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