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
This commit is contained in:
J. Nick Koston 2023-07-02 20:53:50 -05:00 committed by GitHub
parent ab50069918
commit b24c6adc75
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 110 additions and 22 deletions

View File

@ -10,6 +10,7 @@ from decimal import Decimal, InvalidOperation as DecimalInvalidOperation
import logging import logging
from math import ceil, floor, log10 from math import ceil, floor, log10
import re import re
import sys
from typing import Any, Final, cast, final from typing import Any, Final, cast, final
from typing_extensions import Self from typing_extensions import Self
@ -92,6 +93,8 @@ ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
NEGATIVE_ZERO_PATTERN = re.compile(r"^-(0\.?0*)$") NEGATIVE_ZERO_PATTERN = re.compile(r"^-(0\.?0*)$")
PY_311 = sys.version_info >= (3, 11, 0)
SCAN_INTERVAL: Final = timedelta(seconds=30) SCAN_INTERVAL: Final = timedelta(seconds=30)
__all__ = [ __all__ = [
@ -638,10 +641,12 @@ class SensorEntity(Entity):
) )
precision = precision + floor(ratio_log) precision = precision + floor(ratio_log)
value = f"{converted_numerical_value:.{precision}f}" if PY_311:
# This can be replaced with adding the z option when we drop support for value = f"{converted_numerical_value:z.{precision}f}"
# Python 3.10 else:
value = NEGATIVE_ZERO_PATTERN.sub(r"\1", value) value = f"{converted_numerical_value:.{precision}f}"
if value.startswith("-0") and NEGATIVE_ZERO_PATTERN.match(value):
value = value[1:]
else: else:
value = converted_numerical_value 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 @callback
def async_rounded_state(hass: HomeAssistant, entity_id: str, state: State) -> str: def async_rounded_state(hass: HomeAssistant, entity_id: str, state: State) -> str:
"""Return the state rounded for presentation.""" """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 value = state.state
if (precision := display_precision()) is None: if (precision := _display_precision(hass, entity_id)) is None:
return value return value
with suppress(TypeError, ValueError): with suppress(TypeError, ValueError):
numerical_value = float(value) numerical_value = float(value)
value = f"{numerical_value:.{precision}f}" if PY_311:
# This can be replaced with adding the z option when we drop support for value = f"{numerical_value:z.{precision}f}"
# Python 3.10 else:
value = NEGATIVE_ZERO_PATTERN.sub(r"\1", value) value = f"{numerical_value:.{precision}f}"
if value.startswith("-0") and NEGATIVE_ZERO_PATTERN.match(value):
value = value[1:]
return value return value

View File

@ -17,6 +17,7 @@ from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
SensorStateClass, SensorStateClass,
async_rounded_state,
async_update_suggested_units, async_update_suggested_units,
) )
from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.config_entries import ConfigEntry, ConfigFlow
@ -557,6 +558,22 @@ async def test_restore_sensor_restore_state(
100, 100,
"38", "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( async def test_custom_unit(
@ -592,10 +609,15 @@ async def test_custom_unit(
assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}})
await hass.async_block_till_done() 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.state == custom_state
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == state_unit 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( @pytest.mark.parametrize(
( (
@ -902,7 +924,7 @@ async def test_custom_unit_change(
"1000000", "1000000",
"1093613", "1093613",
SensorDeviceClass.DISTANCE, SensorDeviceClass.DISTANCE,
), )
], ],
) )
async def test_unit_conversion_priority( async def test_unit_conversion_priority(
@ -1130,6 +1152,9 @@ async def test_unit_conversion_priority_precision(
"sensor": {"suggested_display_precision": 2}, "sensor": {"suggested_display_precision": 2},
"sensor.private": {"suggested_unit_of_measurement": automatic_unit}, "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 # Unregistered entity -> Follow native unit
state = hass.states.get(entity1.entity_id) 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 float(state.state) == pytest.approx(custom_state)
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit 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( @pytest.mark.parametrize(
( (
@ -2362,3 +2401,39 @@ async def test_name(hass: HomeAssistant) -> None:
state = hass.states.get(entity4.entity_id) state = hass.states.get(entity4.entity_id)
assert state.attributes == {"device_class": "battery", "friendly_name": "Battery"} 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"

View File

@ -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.test", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"})
hass.states.async_set("sensor.test2", "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 # state_with_unit property
tpl = template.Template("{{ states.sensor.test.state_with_unit }}", hass) 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 # AllStates.__call__ and rounded=True
tpl7 = template.Template("{{ states('sensor.test', rounded=True) }}", hass) tpl7 = template.Template("{{ states('sensor.test', rounded=True) }}", hass)
tpl8 = template.Template("{{ states('sensor.test2', 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 tpl.async_render() == "23.00 beers"
assert tpl2.async_render() == "23 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 tpl6.async_render() == "23 beers"
assert tpl7.async_render() == 23.0 assert tpl7.async_render() == 23.0
assert tpl8.async_render() == 23 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.test", "23.015", {ATTR_UNIT_OF_MEASUREMENT: "beers"})
hass.states.async_set("sensor.test2", "23.015", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) hass.states.async_set("sensor.test2", "23.015", {ATTR_UNIT_OF_MEASUREMENT: "beers"})