diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index e6af7751c88..1706d9b309d 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -7,6 +7,8 @@ from collections.abc import Awaitable, Iterable, Mapping from datetime import datetime, timedelta import functools as ft import logging +import math +import sys from timeit import default_timer as timer from typing import Any @@ -43,6 +45,10 @@ DATA_ENTITY_SOURCE = "entity_info" SOURCE_CONFIG_ENTRY = "config_entry" SOURCE_PLATFORM_CONFIG = "platform_config" +# Used when converting float states to string: limit precision according to machine +# epsilon to make the string representation readable +FLOAT_PRECISION = abs(int(math.floor(math.log10(abs(sys.float_info.epsilon))))) - 1 + @callback @bind_hass @@ -327,6 +333,19 @@ class Entity(ABC): self._async_write_ha_state() + def _stringify_state(self) -> str: + """Convert state to string.""" + if not self.available: + return STATE_UNAVAILABLE + state = self.state + if state is None: + return STATE_UNKNOWN + if isinstance(state, float): + # If the entity's state is a float, limit precision according to machine + # epsilon to make the string representation readable + return f"{state:.{FLOAT_PRECISION}}" + return str(state) + @callback def _async_write_ha_state(self) -> None: """Write the state to the state machine.""" @@ -346,11 +365,8 @@ class Entity(ABC): attr = self.capability_attributes attr = dict(attr) if attr else {} - if not self.available: - state = STATE_UNAVAILABLE - else: - sstate = self.state - state = STATE_UNKNOWN if sstate is None else str(sstate) + state = self._stringify_state() + if self.available: attr.update(self.state_attributes or {}) extra_state_attributes = self.extra_state_attributes # Backwards compatibility for "device_state_attributes" deprecated in 2021.4 diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index d6d80a9ad87..ca496723d99 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -162,6 +162,26 @@ async def test_increment(hass): assert float(state.state) == 51 +async def test_rounding(hass): + """Test increment introducing floating point error is rounded.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_2": {"initial": 2.4, "min": 0, "max": 51, "step": 1.2}}}, + ) + entity_id = "input_number.test_2" + assert 2.4 + 1.2 != 3.6 + + state = hass.states.get(entity_id) + assert float(state.state) == 2.4 + + await increment(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert float(state.state) == 3.6 + + async def test_decrement(hass): """Test decrement method.""" assert await async_setup_component( diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 8d587301fb8..8142f563f01 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -774,3 +774,17 @@ async def test_get_supported_features_raises_on_unknown(hass): """Test get_supported_features raises on unknown entity_id.""" with pytest.raises(HomeAssistantError): entity.get_supported_features(hass, "hello.world") + + +async def test_float_conversion(hass): + """Test conversion of float state to string rounds.""" + assert 2.4 + 1.2 != 3.6 + with patch.object(entity.Entity, "state", PropertyMock(return_value=2.4 + 1.2)): + ent = entity.Entity() + ent.hass = hass + ent.entity_id = "hello.world" + ent.async_write_ha_state() + + state = hass.states.get("hello.world") + assert state is not None + assert state.state == "3.6"