diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 47a80f00561..75f98447865 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -1,16 +1,20 @@ """Component to allow numeric input for platforms.""" from __future__ import annotations +from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass from datetime import timedelta +import inspect import logging +from math import ceil, floor from typing import Any, final import voluptuous as vol from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODE +from homeassistant.const import ATTR_MODE, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -19,6 +23,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util import temperature as temperature_util from .const import ( ATTR_MAX, @@ -41,6 +46,13 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) _LOGGER = logging.getLogger(__name__) +class NumberDeviceClass(StrEnum): + """Device class for numbers.""" + + # temperature (C/F) + TEMPERATURE = "temperature" + + class NumberMode(StrEnum): """Modes for number entities.""" @@ -49,6 +61,11 @@ class NumberMode(StrEnum): SLIDER = "slider" +UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { + NumberDeviceClass.TEMPERATURE: temperature_util.convert, +} + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Number entities.""" component = hass.data[DOMAIN] = EntityComponent( @@ -72,7 +89,15 @@ async def async_set_value(entity: NumberEntity, service_call: ServiceCall) -> No raise ValueError( f"Value {value} for {entity.name} is outside valid range {entity.min_value} - {entity.max_value}" ) - await entity.async_set_value(value) + try: + native_value = entity.convert_to_native_value(value) + # Clamp to the native range + native_value = min( + max(native_value, entity.native_min_value), entity.native_max_value + ) + await entity.async_set_native_value(native_value) + except NotImplementedError: + await entity.async_set_value(value) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -93,8 +118,56 @@ class NumberEntityDescription(EntityDescription): max_value: float | None = None min_value: float | None = None + native_max_value: float | None = None + native_min_value: float | None = None + native_unit_of_measurement: str | None = None + native_step: float | None = None step: float | None = None + def __post_init__(self) -> None: + """Post initialisation processing.""" + if ( + self.max_value is not None + or self.min_value is not None + or self.step is not None + or self.unit_of_measurement is not None + ): + caller = inspect.stack()[2] + module = inspect.getmodule(caller[0]) + if module and module.__file__ and "custom_components" in module.__file__: + report_issue = "report it to the custom component author." + else: + report_issue = ( + "create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + _LOGGER.warning( + "%s is setting deprecated attributes on an instance of " + "NumberEntityDescription, this is not valid and will be unsupported " + "from Home Assistant 2022.10. Please %s", + module.__name__ if module else self.__class__.__name__, + report_issue, + ) + self.native_unit_of_measurement = self.unit_of_measurement + + +def ceil_decimal(value: float, precision: float = 0) -> float: + """Return the ceiling of f with d decimals. + + This is a simple implementation which ignores floating point inexactness. + """ + factor = 10**precision + return ceil(value * factor) / factor + + +def floor_decimal(value: float, precision: float = 0) -> float: + """Return the floor of f with d decimals. + + This is a simple implementation which ignores floating point inexactness. + """ + factor = 10**precision + return floor(value * factor) / factor + class NumberEntity(Entity): """Representation of a Number entity.""" @@ -106,6 +179,12 @@ class NumberEntity(Entity): _attr_step: float _attr_mode: NumberMode = NumberMode.AUTO _attr_value: float + _attr_native_max_value: float + _attr_native_min_value: float + _attr_native_step: float + _attr_native_value: float + _attr_native_unit_of_measurement: str | None + _deprecated_number_entity_reported = False @property def capability_attributes(self) -> dict[str, Any]: @@ -117,40 +196,84 @@ class NumberEntity(Entity): ATTR_MODE: self.mode, } + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + if hasattr(self, "_attr_native_min_value"): + return self._attr_native_min_value + if ( + hasattr(self, "entity_description") + and self.entity_description.native_min_value is not None + ): + return self.entity_description.native_min_value + return DEFAULT_MIN_VALUE + @property def min_value(self) -> float: """Return the minimum value.""" if hasattr(self, "_attr_min_value"): + self._report_deprecated_number_entity() return self._attr_min_value if ( hasattr(self, "entity_description") and self.entity_description.min_value is not None ): + self._report_deprecated_number_entity() return self.entity_description.min_value - return DEFAULT_MIN_VALUE + return self._convert_to_state_value(self.native_min_value, floor_decimal) + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + if hasattr(self, "_attr_native_max_value"): + return self._attr_native_max_value + if ( + hasattr(self, "entity_description") + and self.entity_description.native_max_value is not None + ): + return self.entity_description.native_max_value + return DEFAULT_MAX_VALUE @property def max_value(self) -> float: """Return the maximum value.""" if hasattr(self, "_attr_max_value"): + self._report_deprecated_number_entity() return self._attr_max_value if ( hasattr(self, "entity_description") and self.entity_description.max_value is not None ): + self._report_deprecated_number_entity() return self.entity_description.max_value - return DEFAULT_MAX_VALUE + return self._convert_to_state_value(self.native_max_value, ceil_decimal) + + @property + def native_step(self) -> float | None: + """Return the increment/decrement step.""" + if hasattr(self, "_attr_native_step"): + return self._attr_native_step + if ( + hasattr(self, "entity_description") + and self.entity_description.native_step is not None + ): + return self.entity_description.native_step + return None @property def step(self) -> float: """Return the increment/decrement step.""" if hasattr(self, "_attr_step"): + self._report_deprecated_number_entity() return self._attr_step if ( hasattr(self, "entity_description") and self.entity_description.step is not None ): + self._report_deprecated_number_entity() return self.entity_description.step + if (native_step := self.native_step) is not None: + return native_step step = DEFAULT_STEP value_range = abs(self.max_value - self.min_value) if value_range != 0: @@ -169,10 +292,59 @@ class NumberEntity(Entity): """Return the entity state.""" return self.value + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the entity, if any.""" + if hasattr(self, "_attr_native_unit_of_measurement"): + return self._attr_native_unit_of_measurement + if hasattr(self, "entity_description"): + return self.entity_description.native_unit_of_measurement + return None + + @property + def unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the entity, after unit conversion.""" + if hasattr(self, "_attr_unit_of_measurement"): + return self._attr_unit_of_measurement + if ( + hasattr(self, "entity_description") + and self.entity_description.unit_of_measurement is not None + ): + return self.entity_description.unit_of_measurement + + native_unit_of_measurement = self.native_unit_of_measurement + + if ( + self.device_class == NumberDeviceClass.TEMPERATURE + and native_unit_of_measurement in (TEMP_CELSIUS, TEMP_FAHRENHEIT) + ): + return self.hass.config.units.temperature_unit + + return native_unit_of_measurement + + @property + def native_value(self) -> float | None: + """Return the value reported by the sensor.""" + return self._attr_native_value + @property def value(self) -> float | None: """Return the entity value to represent the entity state.""" - return self._attr_value + if hasattr(self, "_attr_value"): + self._report_deprecated_number_entity() + return self._attr_value + + if (native_value := self.native_value) is None: + return native_value + return self._convert_to_state_value(native_value, round) + + def set_native_value(self, value: float) -> None: + """Set new value.""" + raise NotImplementedError() + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self.hass.async_add_executor_job(self.set_native_value, value) def set_value(self, value: float) -> None: """Set new value.""" @@ -181,3 +353,69 @@ class NumberEntity(Entity): async def async_set_value(self, value: float) -> None: """Set new value.""" await self.hass.async_add_executor_job(self.set_value, value) + + def _convert_to_state_value(self, value: float, method: Callable) -> float: + """Convert a value in the number's native unit to the configured unit.""" + + native_unit_of_measurement = self.native_unit_of_measurement + unit_of_measurement = self.unit_of_measurement + device_class = self.device_class + + if ( + native_unit_of_measurement != unit_of_measurement + and device_class in UNIT_CONVERSIONS + ): + assert native_unit_of_measurement + assert unit_of_measurement + + value_s = str(value) + prec = len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 + + # Suppress ValueError (Could not convert value to float) + with suppress(ValueError): + value_new: float = UNIT_CONVERSIONS[device_class]( + value, + native_unit_of_measurement, + unit_of_measurement, + ) + + # Round to the wanted precision + value = method(value_new, prec) + + return value + + def convert_to_native_value(self, value: float) -> float: + """Convert a value to the number's native unit.""" + + native_unit_of_measurement = self.native_unit_of_measurement + unit_of_measurement = self.unit_of_measurement + device_class = self.device_class + + if ( + value is not None + and native_unit_of_measurement != unit_of_measurement + and device_class in UNIT_CONVERSIONS + ): + assert native_unit_of_measurement + assert unit_of_measurement + + value = UNIT_CONVERSIONS[device_class]( + value, + unit_of_measurement, + native_unit_of_measurement, + ) + + return value + + def _report_deprecated_number_entity(self) -> None: + """Report that the number entity has not been upgraded.""" + if not self._deprecated_number_entity_reported: + self._deprecated_number_entity_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + "Entity %s (%s) is using deprecated NumberEntity features which will " + "be unsupported from Home Assistant Core 2022.10, please %s", + self.entity_id, + type(self), + report_issue, + ) diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 8fdf03a7d7b..ccc6f0da0c5 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -4,19 +4,138 @@ from unittest.mock import MagicMock import pytest from homeassistant.components.number import ( + ATTR_MAX, + ATTR_MIN, ATTR_STEP, ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE, + NumberDeviceClass, NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_UNIT_OF_MEASUREMENT, + CONF_PLATFORM, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM class MockDefaultNumberEntity(NumberEntity): - """Mock NumberEntity device to use in tests.""" + """Mock NumberEntity device to use in tests. + + This class falls back on defaults for min_value, max_value, step. + """ + + @property + def native_value(self): + """Return the current value.""" + return 0.5 + + +class MockNumberEntity(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value as overridden methods. + Step is calculated based on the smaller max_value and min_value. + """ + + @property + def native_max_value(self) -> float: + """Return the max value.""" + return 0.5 + + @property + def native_min_value(self) -> float: + """Return the min value.""" + return -0.5 + + @property + def native_unit_of_measurement(self): + """Return the current value.""" + return "native_cats" + + @property + def native_value(self): + """Return the current value.""" + return 0.5 + + +class MockNumberEntityAttr(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value by setting _attr members. + Step is calculated based on the smaller max_value and min_value. + """ + + _attr_native_max_value = 1000.0 + _attr_native_min_value = -1000.0 + _attr_native_step = 100.0 + _attr_native_unit_of_measurement = "native_dogs" + _attr_native_value = 500.0 + + +class MockNumberEntityDescr(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value by entity description. + Step is calculated based on the smaller max_value and min_value. + """ + + def __init__(self): + """Initialize the clas instance.""" + self.entity_description = NumberEntityDescription( + "test", + native_max_value=10.0, + native_min_value=-10.0, + native_step=2.0, + native_unit_of_measurement="native_rabbits", + ) + + @property + def native_value(self): + """Return the current value.""" + return None + + +class MockDefaultNumberEntityDeprecated(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class falls back on defaults for min_value, max_value, step. + """ + + @property + def native_value(self): + """Return the current value.""" + return 0.5 + + +class MockNumberEntityDeprecated(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value as overridden methods. + Step is calculated based on the smaller max_value and min_value. + """ + + @property + def max_value(self) -> float: + """Return the max value.""" + return 0.5 + + @property + def min_value(self) -> float: + """Return the min value.""" + return -0.5 + + @property + def unit_of_measurement(self): + """Return the current value.""" + return "cats" @property def value(self): @@ -24,13 +143,36 @@ class MockDefaultNumberEntity(NumberEntity): return 0.5 -class MockNumberEntity(NumberEntity): - """Mock NumberEntity device to use in tests.""" +class MockNumberEntityAttrDeprecated(NumberEntity): + """Mock NumberEntity device to use in tests. - @property - def max_value(self) -> float: - """Return the max value.""" - return 1.0 + This class customizes min_value, max_value by setting _attr members. + Step is calculated based on the smaller max_value and min_value. + """ + + _attr_max_value = 1000.0 + _attr_min_value = -1000.0 + _attr_step = 100.0 + _attr_unit_of_measurement = "dogs" + _attr_value = 500.0 + + +class MockNumberEntityDescrDeprecated(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value by entity description. + Step is calculated based on the smaller max_value and min_value. + """ + + def __init__(self): + """Initialize the clas instance.""" + self.entity_description = NumberEntityDescription( + "test", + max_value=10.0, + min_value=-10.0, + step=2.0, + unit_of_measurement="rabbits", + ) @property def value(self): @@ -41,12 +183,97 @@ class MockNumberEntity(NumberEntity): async def test_step(hass: HomeAssistant) -> None: """Test the step calculation.""" number = MockDefaultNumberEntity() + number.hass = hass assert number.step == 1.0 number_2 = MockNumberEntity() + number_2.hass = hass assert number_2.step == 0.1 +async def test_attributes(hass: HomeAssistant) -> None: + """Test the attributes.""" + number = MockDefaultNumberEntity() + number.hass = hass + assert number.max_value == 100.0 + assert number.min_value == 0.0 + assert number.step == 1.0 + assert number.unit_of_measurement is None + assert number.value == 0.5 + + number_2 = MockNumberEntity() + number_2.hass = hass + assert number_2.max_value == 0.5 + assert number_2.min_value == -0.5 + assert number_2.step == 0.1 + assert number_2.unit_of_measurement == "native_cats" + assert number_2.value == 0.5 + + number_3 = MockNumberEntityAttr() + number_3.hass = hass + assert number_3.max_value == 1000.0 + assert number_3.min_value == -1000.0 + assert number_3.step == 100.0 + assert number_3.unit_of_measurement == "native_dogs" + assert number_3.value == 500.0 + + number_4 = MockNumberEntityDescr() + number_4.hass = hass + assert number_4.max_value == 10.0 + assert number_4.min_value == -10.0 + assert number_4.step == 2.0 + assert number_4.unit_of_measurement == "native_rabbits" + assert number_4.value is None + + +async def test_attributes_deprecated(hass: HomeAssistant, caplog) -> None: + """Test overriding the deprecated attributes.""" + number = MockDefaultNumberEntityDeprecated() + number.hass = hass + assert number.max_value == 100.0 + assert number.min_value == 0.0 + assert number.step == 1.0 + assert number.unit_of_measurement is None + assert number.value == 0.5 + + number_2 = MockNumberEntityDeprecated() + number_2.hass = hass + assert number_2.max_value == 0.5 + assert number_2.min_value == -0.5 + assert number_2.step == 0.1 + assert number_2.unit_of_measurement == "cats" + assert number_2.value == 0.5 + + number_3 = MockNumberEntityAttrDeprecated() + number_3.hass = hass + assert number_3.max_value == 1000.0 + assert number_3.min_value == -1000.0 + assert number_3.step == 100.0 + assert number_3.unit_of_measurement == "dogs" + assert number_3.value == 500.0 + + number_4 = MockNumberEntityDescrDeprecated() + number_4.hass = hass + assert number_4.max_value == 10.0 + assert number_4.min_value == -10.0 + assert number_4.step == 2.0 + assert number_4.unit_of_measurement == "rabbits" + assert number_4.value == 0.5 + + assert ( + "Entity None () " + "is using deprecated NumberEntity features" in caplog.text + ) + assert ( + "Entity None () " + "is using deprecated NumberEntity features" in caplog.text + ) + assert ( + "tests.components.number.test_init is setting deprecated attributes on an " + "instance of NumberEntityDescription" in caplog.text + ) + + async def test_sync_set_value(hass: HomeAssistant) -> None: """Test if async set_value calls sync set_value.""" number = MockDefaultNumberEntity() @@ -59,9 +286,7 @@ async def test_sync_set_value(hass: HomeAssistant) -> None: assert number.set_value.call_args[0][0] == 42 -async def test_custom_integration_and_validation( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +async def test_set_value(hass: HomeAssistant, enable_custom_integrations: None) -> None: """Test we can only set valid values.""" platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() @@ -79,9 +304,8 @@ async def test_custom_integration_and_validation( {ATTR_VALUE: 60.0, ATTR_ENTITY_ID: "number.test"}, blocking=True, ) - - hass.states.async_set("number.test", 60.0) await hass.async_block_till_done() + state = hass.states.get("number.test") assert state.state == "60.0" @@ -97,3 +321,252 @@ async def test_custom_integration_and_validation( await hass.async_block_till_done() state = hass.states.get("number.test") assert state.state == "60.0" + + +async def test_deprecated_attributes( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test entity using deprecated attributes.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init(empty=True) + platform.ENTITIES.append(platform.LegacyMockNumberEntity()) + entity = platform.ENTITIES[0] + entity._attr_name = "Test" + entity._attr_max_value = 25 + entity._attr_min_value = -25 + entity._attr_step = 2.5 + entity._attr_value = 51.0 + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("number.test") + assert state.state == "51.0" + assert state.attributes.get(ATTR_MAX) == 25.0 + assert state.attributes.get(ATTR_MIN) == -25.0 + assert state.attributes.get(ATTR_STEP) == 2.5 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 0.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.test") + assert state.state == "0.0" + + # test ValueError trigger + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 110.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("number.test") + assert state.state == "0.0" + + +async def test_deprecated_methods( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test entity using deprecated methods.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init(empty=True) + platform.ENTITIES.append( + platform.LegacyMockNumberEntity( + name="Test", + max_value=25.0, + min_value=-25.0, + step=2.5, + value=51.0, + ) + ) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("number.test") + assert state.state == "51.0" + assert state.attributes.get(ATTR_MAX) == 25.0 + assert state.attributes.get(ATTR_MIN) == -25.0 + assert state.attributes.get(ATTR_STEP) == 2.5 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 0.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.test") + assert state.state == "0.0" + + # test ValueError trigger + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 110.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("number.test") + assert state.state == "0.0" + + +@pytest.mark.parametrize( + "unit_system, native_unit, state_unit, initial_native_value, initial_state_value, " + "updated_native_value, updated_state_value, native_max_value, state_max_value, " + "native_min_value, state_min_value, native_step, state_step", + [ + ( + IMPERIAL_SYSTEM, + TEMP_FAHRENHEIT, + TEMP_FAHRENHEIT, + 100, + 100, + 50, + 50, + 140, + 140, + -9, + -9, + 3, + 3, + ), + ( + IMPERIAL_SYSTEM, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + 38, + 100, + 10, + 50, + 60, + 140, + -23, + -10, + 3, + 3, + ), + ( + METRIC_SYSTEM, + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + 100, + 38, + 50, + 10, + 140, + 60, + -9, + -23, + 3, + 3, + ), + ( + METRIC_SYSTEM, + TEMP_CELSIUS, + TEMP_CELSIUS, + 38, + 38, + 10, + 10, + 60, + 60, + -23, + -23, + 3, + 3, + ), + ], +) +async def test_temperature_conversion( + hass, + enable_custom_integrations, + unit_system, + native_unit, + state_unit, + initial_native_value, + initial_state_value, + updated_native_value, + updated_state_value, + native_max_value, + state_max_value, + native_min_value, + state_min_value, + native_step, + state_step, +): + """Test temperature conversion.""" + hass.config.units = unit_system + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockNumberEntity( + name="Test", + native_max_value=native_max_value, + native_min_value=native_min_value, + native_step=native_step, + native_unit_of_measurement=native_unit, + native_value=initial_native_value, + device_class=NumberDeviceClass.TEMPERATURE, + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == pytest.approx(float(initial_state_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == state_unit + assert state.attributes[ATTR_MAX] == state_max_value + assert state.attributes[ATTR_MIN] == state_min_value + assert state.attributes[ATTR_STEP] == state_step + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: updated_state_value, ATTR_ENTITY_ID: entity0.entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == pytest.approx(float(updated_state_value)) + assert entity0._values["native_value"] == updated_native_value + + # Set to the minimum value + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: state_min_value, ATTR_ENTITY_ID: entity0.entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == pytest.approx(float(state_min_value), rel=0.1) + + # Set to the maximum value + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: state_max_value, ATTR_ENTITY_ID: entity0.entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == pytest.approx(float(state_max_value), rel=0.1) diff --git a/tests/testing_config/custom_components/test/number.py b/tests/testing_config/custom_components/test/number.py index 93d7783d684..ac397a4d42b 100644 --- a/tests/testing_config/custom_components/test/number.py +++ b/tests/testing_config/custom_components/test/number.py @@ -13,10 +13,55 @@ ENTITIES = [] class MockNumberEntity(MockEntity, NumberEntity): - """Mock Select class.""" + """Mock number class.""" - _attr_value = 50.0 - _attr_step = 1.0 + @property + def native_max_value(self): + """Return the native native_max_value.""" + return self._handle("native_max_value") + + @property + def native_min_value(self): + """Return the native native_min_value.""" + return self._handle("native_min_value") + + @property + def native_step(self): + """Return the native native_step.""" + return self._handle("native_step") + + @property + def native_unit_of_measurement(self): + """Return the native unit_of_measurement.""" + return self._handle("native_unit_of_measurement") + + @property + def native_value(self): + """Return the native value of this sensor.""" + return self._handle("native_value") + + def set_native_value(self, value: float) -> None: + """Change the selected option.""" + self._values["native_value"] = value + + +class LegacyMockNumberEntity(MockEntity, NumberEntity): + """Mock Number class using deprecated features.""" + + @property + def max_value(self): + """Return the native max_value.""" + return self._handle("max_value") + + @property + def min_value(self): + """Return the native min_value.""" + return self._handle("min_value") + + @property + def step(self): + """Return the native step.""" + return self._handle("step") @property def value(self): @@ -25,7 +70,7 @@ class MockNumberEntity(MockEntity, NumberEntity): def set_value(self, value: float) -> None: """Change the selected option.""" - self._attr_value = value + self._values["value"] = value def init(empty=False): @@ -39,6 +84,7 @@ def init(empty=False): MockNumberEntity( name="test", unique_id=UNIQUE_NUMBER, + native_value=50.0, ), ] )