From b24c6adc75b97825339776e56c800046bdd5647a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Jul 2023 20:53:50 -0500 Subject: [PATCH] Avoid regex for negative zero check in sensor (#95691) * Avoid regex for negative zero check in sensor We can avoid calling the regex for every sensor value since most of the time values are not negative zero * tweak * tweak * Apply suggestions from code review * simpler * cover * safer and still fast * safer and still fast * prep for py3.11 * fix check * add missing cover * more coverage * coverage * coverage --- homeassistant/components/sensor/__init__.py | 47 ++++++------ tests/components/sensor/test_init.py | 79 ++++++++++++++++++++- tests/helpers/test_template.py | 6 ++ 3 files changed, 110 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index ad09a1b5fdb..2477e849666 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -10,6 +10,7 @@ from decimal import Decimal, InvalidOperation as DecimalInvalidOperation import logging from math import ceil, floor, log10 import re +import sys from typing import Any, Final, cast, final from typing_extensions import Self @@ -92,6 +93,8 @@ ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" NEGATIVE_ZERO_PATTERN = re.compile(r"^-(0\.?0*)$") +PY_311 = sys.version_info >= (3, 11, 0) + SCAN_INTERVAL: Final = timedelta(seconds=30) __all__ = [ @@ -638,10 +641,12 @@ class SensorEntity(Entity): ) precision = precision + floor(ratio_log) - 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) + if PY_311: + value = f"{converted_numerical_value:z.{precision}f}" + else: + value = f"{converted_numerical_value:.{precision}f}" + if value.startswith("-0") and NEGATIVE_ZERO_PATTERN.match(value): + value = value[1:] else: value = converted_numerical_value @@ -883,29 +888,31 @@ def async_update_suggested_units(hass: HomeAssistant) -> None: ) +def _display_precision(hass: HomeAssistant, entity_id: str) -> int | None: + """Return the display precision.""" + if not (entry := er.async_get(hass).async_get(entity_id)) or not ( + sensor_options := entry.options.get(DOMAIN) + ): + return None + if (display_precision := sensor_options.get("display_precision")) is not None: + return cast(int, display_precision) + return sensor_options.get("suggested_display_precision") + + @callback def async_rounded_state(hass: HomeAssistant, entity_id: str, state: State) -> str: """Return the state rounded for presentation.""" - - def display_precision() -> int | None: - """Return the display precision.""" - if not (entry := er.async_get(hass).async_get(entity_id)) or not ( - sensor_options := entry.options.get(DOMAIN) - ): - return None - if (display_precision := sensor_options.get("display_precision")) is not None: - return cast(int, display_precision) - return sensor_options.get("suggested_display_precision") - value = state.state - if (precision := display_precision()) is None: + if (precision := _display_precision(hass, entity_id)) is None: return value with suppress(TypeError, ValueError): numerical_value = float(value) - 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) + if PY_311: + value = f"{numerical_value:z.{precision}f}" + else: + value = f"{numerical_value:.{precision}f}" + if value.startswith("-0") and NEGATIVE_ZERO_PATTERN.match(value): + value = value[1:] return value diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index d1da0a8166f..b5d425029d0 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -17,6 +17,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, SensorStateClass, + async_rounded_state, async_update_suggested_units, ) from homeassistant.config_entries import ConfigEntry, ConfigFlow @@ -557,6 +558,22 @@ async def test_restore_sensor_restore_state( 100, "38", ), + ( + SensorDeviceClass.ATMOSPHERIC_PRESSURE, + UnitOfPressure.INHG, + UnitOfPressure.HPA, + UnitOfPressure.HPA, + -0.00, + "0.0", + ), + ( + SensorDeviceClass.ATMOSPHERIC_PRESSURE, + UnitOfPressure.INHG, + UnitOfPressure.HPA, + UnitOfPressure.HPA, + -0.00001, + "0", + ), ], ) async def test_custom_unit( @@ -592,10 +609,15 @@ async def test_custom_unit( assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) await hass.async_block_till_done() - state = hass.states.get(entity0.entity_id) + entity_id = entity0.entity_id + state = hass.states.get(entity_id) assert state.state == custom_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == state_unit + assert ( + async_rounded_state(hass, entity_id, hass.states.get(entity_id)) == custom_state + ) + @pytest.mark.parametrize( ( @@ -902,7 +924,7 @@ async def test_custom_unit_change( "1000000", "1093613", SensorDeviceClass.DISTANCE, - ), + ) ], ) async def test_unit_conversion_priority( @@ -1130,6 +1152,9 @@ async def test_unit_conversion_priority_precision( "sensor": {"suggested_display_precision": 2}, "sensor.private": {"suggested_unit_of_measurement": automatic_unit}, } + assert float(async_rounded_state(hass, entity0.entity_id, state)) == pytest.approx( + round(automatic_state, 2) + ) # Unregistered entity -> Follow native unit state = hass.states.get(entity1.entity_id) @@ -1172,6 +1197,20 @@ async def test_unit_conversion_priority_precision( assert float(state.state) == pytest.approx(custom_state) assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit + # Set a display_precision, this should have priority over suggested_display_precision + entity_registry.async_update_entity_options( + entity0.entity_id, + "sensor", + {"suggested_display_precision": 2, "display_precision": 4}, + ) + entry0 = entity_registry.async_get(entity0.entity_id) + assert entry0.options["sensor"]["suggested_display_precision"] == 2 + assert entry0.options["sensor"]["display_precision"] == 4 + await hass.async_block_till_done() + assert float(async_rounded_state(hass, entity0.entity_id, state)) == pytest.approx( + round(custom_state, 4) + ) + @pytest.mark.parametrize( ( @@ -2362,3 +2401,39 @@ async def test_name(hass: HomeAssistant) -> None: state = hass.states.get(entity4.entity_id) assert state.attributes == {"device_class": "battery", "friendly_name": "Battery"} + + +def test_async_rounded_state_unregistered_entity_is_passthrough( + hass: HomeAssistant, +) -> None: + """Test async_rounded_state on unregistered entity is passthrough.""" + hass.states.async_set("sensor.test", "1.004") + state = hass.states.get("sensor.test") + assert async_rounded_state(hass, "sensor.test", state) == "1.004" + hass.states.async_set("sensor.test", "-0.0") + state = hass.states.get("sensor.test") + assert async_rounded_state(hass, "sensor.test", state) == "-0.0" + + +def test_async_rounded_state_registered_entity_with_display_precision( + hass: HomeAssistant, +) -> None: + """Test async_rounded_state on registered with display precision. + + The -0 should be dropped. + """ + 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", + {"suggested_display_precision": 2, "display_precision": 4}, + ) + entity_id = entry.entity_id + hass.states.async_set(entity_id, "1.004") + state = hass.states.get(entity_id) + assert async_rounded_state(hass, entity_id, state) == "1.0040" + hass.states.async_set(entity_id, "-0.0") + state = hass.states.get(entity_id) + assert async_rounded_state(hass, entity_id, state) == "0.0000" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 73854147372..cd0b5a2ab88 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -3889,6 +3889,8 @@ def test_state_with_unit_and_rounding(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) hass.states.async_set("sensor.test2", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + hass.states.async_set("sensor.test3", "-0.0", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + hass.states.async_set("sensor.test4", "-0", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) # state_with_unit property tpl = template.Template("{{ states.sensor.test.state_with_unit }}", hass) @@ -3905,6 +3907,8 @@ def test_state_with_unit_and_rounding(hass: HomeAssistant) -> None: # AllStates.__call__ and rounded=True tpl7 = template.Template("{{ states('sensor.test', rounded=True) }}", hass) tpl8 = template.Template("{{ states('sensor.test2', rounded=True) }}", hass) + tpl9 = template.Template("{{ states('sensor.test3', rounded=True) }}", hass) + tpl10 = template.Template("{{ states('sensor.test4', rounded=True) }}", hass) assert tpl.async_render() == "23.00 beers" assert tpl2.async_render() == "23 beers" @@ -3914,6 +3918,8 @@ def test_state_with_unit_and_rounding(hass: HomeAssistant) -> None: assert tpl6.async_render() == "23 beers" assert tpl7.async_render() == 23.0 assert tpl8.async_render() == 23 + assert tpl9.async_render() == 0.0 + assert tpl10.async_render() == 0 hass.states.async_set("sensor.test", "23.015", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) hass.states.async_set("sensor.test2", "23.015", {ATTR_UNIT_OF_MEASUREMENT: "beers"})