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

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