From 2d2ff199498c56fd75dadc909c7bae7e2f3f5c96 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 9 Feb 2023 12:54:59 +0100 Subject: [PATCH] Round value in state_with_unit template function (#87619) --- homeassistant/components/sensor/__init__.py | 32 +++++- homeassistant/helpers/template.py | 34 +++++- tests/helpers/test_template.py | 110 ++++++++++++++++++++ 3 files changed, 171 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 66da8fff575..324be677ec2 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping +from contextlib import suppress from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone from decimal import Decimal, InvalidOperation as DecimalInvalidOperation @@ -17,6 +18,7 @@ from homeassistant.config_entries import ConfigEntry # pylint: disable=[hass-deprecated-import] from homeassistant.const import ( # noqa: F401 + ATTR_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT, DEVICE_CLASS_AQI, DEVICE_CLASS_BATTERY, @@ -48,7 +50,7 @@ from homeassistant.const import ( # noqa: F401 DEVICE_CLASS_VOLTAGE, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA, @@ -828,3 +830,31 @@ def async_update_suggested_units(hass: HomeAssistant) -> None: f"{DOMAIN}.private", sensor_private_options, ) + + +@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: + 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) + + return value diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index adbb8fe0d36..3c9040f050e 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -733,10 +733,21 @@ class AllStates: self._collect_all_lifecycle() return self._hass.states.async_entity_ids_count() - def __call__(self, entity_id: str) -> str: + def __call__( + self, + entity_id: str, + rounded: bool | object = _SENTINEL, + with_unit: bool = False, + ) -> str: """Return the states.""" state = _get_state(self._hass, entity_id) - return STATE_UNKNOWN if state is None else state.state + if state is None: + return STATE_UNKNOWN + if rounded is _SENTINEL: + rounded = with_unit + if rounded or with_unit: + return state.format_state(rounded, with_unit) # type: ignore[arg-type] + return state.state def __repr__(self) -> str: """Representation of All States.""" @@ -792,7 +803,7 @@ class DomainStates: class TemplateStateBase(State): """Class to represent a state object in a template.""" - __slots__ = ("_hass", "_collect", "_entity_id") + __slots__ = ("_hass", "_collect", "_entity_id", "__dict__") _state: State @@ -886,9 +897,24 @@ class TemplateStateBase(State): @property def state_with_unit(self) -> str: """Return the state concatenated with the unit if available.""" + return self.format_state(rounded=True, with_unit=True) + + def format_state(self, rounded: bool, with_unit: bool) -> str: + """Return a formatted version of the state.""" + # Import here, not at top-level, to avoid circular import + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + async_rounded_state, + ) + self._collect_state() unit = self._state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - return f"{self._state.state} {unit}" if unit else self._state.state + if rounded and split_entity_id(self._entity_id)[0] == SENSOR_DOMAIN: + state = async_rounded_state(self._hass, self._entity_id, self._state) + else: + state = self._state.state + return f"{state} {unit}" if with_unit and unit else state def __eq__(self, other: Any) -> bool: """Ensure we collect on equality check.""" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index bdff145789e..113be8e520f 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -3662,6 +3662,116 @@ def test_state_with_unit(hass: HomeAssistant) -> None: assert tpl.async_render() == "" +def test_state_with_unit_and_rounding(hass: HomeAssistant) -> None: + """Test formatting the state rounded and with unit.""" + entity_registry = er.async_get(hass) + entry = entity_registry.async_get_or_create( + "sensor", "test", "very_unique", suggested_object_id="test" + ) + entity_registry.async_update_entity_options( + entry.entity_id, + "sensor", + { + "suggested_display_precision": 2, + }, + ) + assert entry.entity_id == "sensor.test" + + hass.states.async_set("sensor.test", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + hass.states.async_set("sensor.test2", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + + # state_with_unit property + tpl = template.Template("{{ states.sensor.test.state_with_unit }}", hass) + tpl2 = template.Template("{{ states.sensor.test2.state_with_unit }}", hass) + + # AllStates.__call__ defaults + tpl3 = template.Template("{{ states('sensor.test') }}", hass) + tpl4 = template.Template("{{ states('sensor.test2') }}", hass) + + # AllStates.__call__ and with_unit=True + tpl5 = template.Template("{{ states('sensor.test', with_unit=True) }}", hass) + tpl6 = template.Template("{{ states('sensor.test2', with_unit=True) }}", hass) + + # AllStates.__call__ and rounded=True + tpl7 = template.Template("{{ states('sensor.test', rounded=True) }}", hass) + tpl8 = template.Template("{{ states('sensor.test2', rounded=True) }}", hass) + + assert tpl.async_render() == "23.00 beers" + assert tpl2.async_render() == "23 beers" + assert tpl3.async_render() == 23 + assert tpl4.async_render() == 23 + assert tpl5.async_render() == "23.00 beers" + assert tpl6.async_render() == "23 beers" + assert tpl7.async_render() == 23.0 + assert tpl8.async_render() == 23 + + 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"}) + + assert tpl.async_render() == "23.02 beers" + assert tpl2.async_render() == "23.015 beers" + assert tpl3.async_render() == 23.015 + assert tpl4.async_render() == 23.015 + assert tpl5.async_render() == "23.02 beers" + assert tpl6.async_render() == "23.015 beers" + assert tpl7.async_render() == 23.02 + assert tpl8.async_render() == 23.015 + + +@pytest.mark.parametrize( + ("rounded", "with_unit", "output1_1", "output1_2", "output2_1", "output2_2"), + [ + (False, False, 23, 23.015, 23, 23.015), + (False, True, "23 beers", "23.015 beers", "23 beers", "23.015 beers"), + (True, False, 23.0, 23.02, 23, 23.015), + (True, True, "23.00 beers", "23.02 beers", "23 beers", "23.015 beers"), + ], +) +def test_state_with_unit_and_rounding_options( + hass: HomeAssistant, + rounded: str, + with_unit: str, + output1_1, + output1_2, + output2_1, + output2_2, +) -> None: + """Test formatting the state rounded and with unit.""" + entity_registry = er.async_get(hass) + entry = entity_registry.async_get_or_create( + "sensor", "test", "very_unique", suggested_object_id="test" + ) + entity_registry.async_update_entity_options( + entry.entity_id, + "sensor", + { + "suggested_display_precision": 2, + }, + ) + assert entry.entity_id == "sensor.test" + + hass.states.async_set("sensor.test", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + hass.states.async_set("sensor.test2", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + + tpl = template.Template( + f"{{{{ states('sensor.test', rounded={rounded}, with_unit={with_unit}) }}}}", + hass, + ) + tpl2 = template.Template( + f"{{{{ states('sensor.test2', rounded={rounded}, with_unit={with_unit}) }}}}", + hass, + ) + + assert tpl.async_render() == output1_1 + assert tpl2.async_render() == output2_1 + + 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"}) + + assert tpl.async_render() == output1_2 + assert tpl2.async_render() == output2_2 + + def test_length_of_states(hass: HomeAssistant) -> None: """Test fetching the length of states.""" hass.states.async_set("sensor.test", "23")