From f744f7c34ee725a268c21efba136a7c98aab4159 Mon Sep 17 00:00:00 2001 From: Shulyaka <Shulyaka@gmail.com> Date: Wed, 2 Dec 2020 15:50:48 +0300 Subject: [PATCH] Add new number entity integration (#42735) Co-authored-by: Franck Nijhof <frenck@frenck.nl> --- CODEOWNERS | 1 + homeassistant/components/demo/__init__.py | 1 + homeassistant/components/demo/number.py | 130 ++++++++++++++++++ homeassistant/components/number/__init__.py | 103 ++++++++++++++ homeassistant/components/number/const.py | 14 ++ homeassistant/components/number/manifest.json | 7 + homeassistant/components/number/services.yaml | 11 ++ setup.cfg | 3 +- tests/components/demo/test_number.py | 97 +++++++++++++ tests/components/number/__init__.py | 1 + tests/components/number/test_init.py | 39 ++++++ 11 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/demo/number.py create mode 100644 homeassistant/components/number/__init__.py create mode 100644 homeassistant/components/number/const.py create mode 100644 homeassistant/components/number/manifest.json create mode 100644 homeassistant/components/number/services.yaml create mode 100644 tests/components/demo/test_number.py create mode 100644 tests/components/number/__init__.py create mode 100644 tests/components/number/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 6b2a8686dad..2ca8a0a0f85 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -308,6 +308,7 @@ homeassistant/components/nsw_rural_fire_service_feed/* @exxamalte homeassistant/components/nuheat/* @bdraco homeassistant/components/nuki/* @pschmitt @pvizeli homeassistant/components/numato/* @clssn +homeassistant/components/number/* @home-assistant/core @Shulyaka homeassistant/components/nut/* @bdraco homeassistant/components/nws/* @MatthewFlamm homeassistant/components/nzbget/* @chriscla diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index eea613cc401..09c3d27a1bc 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -19,6 +19,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ "light", "lock", "media_player", + "number", "sensor", "switch", "vacuum", diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py new file mode 100644 index 00000000000..a8b9cb0ac4d --- /dev/null +++ b/homeassistant/components/demo/number.py @@ -0,0 +1,130 @@ +"""Demo platform that offers a fake Number entity.""" +import voluptuous as vol + +from homeassistant.components.number import NumberEntity +from homeassistant.const import DEVICE_DEFAULT_NAME + +from . import DOMAIN + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the demo Number entity.""" + async_add_entities( + [ + DemoNumber( + "volume1", + "volume", + 42.0, + "mdi:volume-high", + False, + ), + DemoNumber( + "pwm1", + "PWM 1", + 42.0, + "mdi:square-wave", + False, + 0.0, + 1.0, + 0.01, + ), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoNumber(NumberEntity): + """Representation of a demo Number entity.""" + + def __init__( + self, + unique_id, + name, + state, + icon, + assumed, + min_value=None, + max_value=None, + step=None, + ): + """Initialize the Demo Number entity.""" + self._unique_id = unique_id + self._name = name or DEVICE_DEFAULT_NAME + self._state = state + self._icon = icon + self._assumed = assumed + self._min_value = min_value + self._max_value = max_value + self._step = step + + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + }, + "name": self.name, + } + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + + @property + def should_poll(self): + """No polling needed for a demo Number entity.""" + return False + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def icon(self): + """Return the icon to use for device if any.""" + return self._icon + + @property + def assumed_state(self): + """Return if the state is based on assumptions.""" + return self._assumed + + @property + def state(self): + """Return the current value.""" + return self._state + + @property + def min_value(self): + """Return the minimum value.""" + return self._min_value or super().min_value + + @property + def max_value(self): + """Return the maximum value.""" + return self._max_value or super().max_value + + @property + def step(self): + """Return the value step.""" + return self._step or super().step + + async def async_set_value(self, value): + """Update the current value.""" + num_value = float(value) + + if num_value < self.min_value or num_value > self.max_value: + raise vol.Invalid( + f"Invalid value for {self.entity_id}: {value} (range {self.min_value} - {self.max_value})" + ) + + self._state = num_value + self.async_write_ha_state() diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py new file mode 100644 index 00000000000..2fd04943e4d --- /dev/null +++ b/homeassistant/components/number/__init__.py @@ -0,0 +1,103 @@ +"""Component to allow numeric input for platforms.""" +from datetime import timedelta +import logging +from typing import Any, Dict + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, + ATTR_VALUE, + DEFAULT_MAX_VALUE, + DEFAULT_MIN_VALUE, + DEFAULT_STEP, + DOMAIN, + SERVICE_SET_VALUE, +) + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up Number entities.""" + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SET_VALUE, + {vol.Required(ATTR_VALUE): vol.Coerce(float)}, + "async_set_value", + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) # type: ignore + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) # type: ignore + + +class NumberEntity(Entity): + """Representation of a Number entity.""" + + @property + def capability_attributes(self) -> Dict[str, Any]: + """Return capability attributes.""" + return { + ATTR_MIN: self.min_value, + ATTR_MAX: self.max_value, + ATTR_STEP: self.step, + } + + @property + def min_value(self) -> float: + """Return the minimum value.""" + return DEFAULT_MIN_VALUE + + @property + def max_value(self) -> float: + """Return the maximum value.""" + return DEFAULT_MAX_VALUE + + @property + def step(self) -> float: + """Return the increment/decrement step.""" + step = DEFAULT_STEP + value_range = abs(self.max_value - self.min_value) + if value_range != 0: + while value_range <= step: + step /= 10.0 + return step + + def set_value(self, value: float) -> None: + """Set new value.""" + raise NotImplementedError() + + async def async_set_value(self, value: float) -> None: + """Set new value.""" + assert self.hass is not None + await self.hass.async_add_executor_job(self.set_value, value) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py new file mode 100644 index 00000000000..2aa8075cba3 --- /dev/null +++ b/homeassistant/components/number/const.py @@ -0,0 +1,14 @@ +"""Provides the constants needed for the component.""" + +ATTR_VALUE = "value" +ATTR_MIN = "min" +ATTR_MAX = "max" +ATTR_STEP = "step" + +DEFAULT_MIN_VALUE = 0.0 +DEFAULT_MAX_VALUE = 100.0 +DEFAULT_STEP = 1.0 + +DOMAIN = "number" + +SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/number/manifest.json b/homeassistant/components/number/manifest.json new file mode 100644 index 00000000000..549494fa3f5 --- /dev/null +++ b/homeassistant/components/number/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "number", + "name": "Number", + "documentation": "https://www.home-assistant.io/integrations/number", + "codeowners": ["@home-assistant/core", "@Shulyaka"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/number/services.yaml b/homeassistant/components/number/services.yaml new file mode 100644 index 00000000000..d18416f9974 --- /dev/null +++ b/homeassistant/components/number/services.yaml @@ -0,0 +1,11 @@ +# Describes the format for available Number entity services + +set_value: + description: Set the value of a Number entity. + fields: + entity_id: + description: Entity ID of the Number to set the new value. + example: number.volume + value: + description: The target value the entity should be set to. + example: 42 diff --git a/setup.cfg b/setup.cfg index 6ff4e1abb12..de5092dcecf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,7 +40,8 @@ warn_incomplete_stub = true warn_redundant_casts = true warn_unused_configs = true -[mypy-homeassistant.block_async_io,homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.__init__,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.runner,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.huawei_lte.*,homeassistant.components.hyperion.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zone.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*,tests.components.hyperion.*] + +[mypy-homeassistant.block_async_io,homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.__init__,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.runner,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.huawei_lte.*,homeassistant.components.hyperion.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.number.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zone.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*,tests.components.hyperion.*] strict = true ignore_errors = false warn_unreachable = true diff --git a/tests/components/demo/test_number.py b/tests/components/demo/test_number.py new file mode 100644 index 00000000000..711332b7817 --- /dev/null +++ b/tests/components/demo/test_number.py @@ -0,0 +1,97 @@ +"""The tests for the demo number component.""" + +import pytest +import voluptuous as vol + +from homeassistant.components.number.const import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, + ATTR_VALUE, + DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.setup import async_setup_component + +ENTITY_VOLUME = "number.volume" +ENTITY_PWM = "number.pwm_1" + + +@pytest.fixture(autouse=True) +async def setup_demo_number(hass): + """Initialize setup demo Number entity.""" + assert await async_setup_component(hass, DOMAIN, {"number": {"platform": "demo"}}) + await hass.async_block_till_done() + + +def test_setup_params(hass): + """Test the initial parameters.""" + state = hass.states.get(ENTITY_VOLUME) + assert state.state == "42.0" + + +def test_default_setup_params(hass): + """Test the setup with default parameters.""" + state = hass.states.get(ENTITY_VOLUME) + assert state.attributes.get(ATTR_MIN) == 0.0 + assert state.attributes.get(ATTR_MAX) == 100.0 + assert state.attributes.get(ATTR_STEP) == 1.0 + + state = hass.states.get(ENTITY_PWM) + assert state.attributes.get(ATTR_MIN) == 0.0 + assert state.attributes.get(ATTR_MAX) == 1.0 + assert state.attributes.get(ATTR_STEP) == 0.01 + + +async def test_set_value_bad_attr(hass): + """Test setting the value without required attribute.""" + state = hass.states.get(ENTITY_VOLUME) + assert state.state == "42.0" + + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: None, ATTR_ENTITY_ID: ENTITY_VOLUME}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_VOLUME) + assert state.state == "42.0" + + +async def test_set_value_bad_range(hass): + """Test setting the value out of range.""" + state = hass.states.get(ENTITY_VOLUME) + assert state.state == "42.0" + + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 1024, ATTR_ENTITY_ID: ENTITY_VOLUME}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_VOLUME) + assert state.state == "42.0" + + +async def test_set_set_value(hass): + """Test the setting of the value.""" + state = hass.states.get(ENTITY_VOLUME) + assert state.state == "42.0" + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 23, ATTR_ENTITY_ID: ENTITY_VOLUME}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_VOLUME) + assert state.state == "23.0" diff --git a/tests/components/number/__init__.py b/tests/components/number/__init__.py new file mode 100644 index 00000000000..e2e32e7a355 --- /dev/null +++ b/tests/components/number/__init__.py @@ -0,0 +1 @@ +"""The tests for Number integration.""" diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py new file mode 100644 index 00000000000..6037bde5afd --- /dev/null +++ b/tests/components/number/test_init.py @@ -0,0 +1,39 @@ +"""The tests for the Number component.""" +from unittest.mock import MagicMock + +from homeassistant.components.number import NumberEntity + + +class MockNumberEntity(NumberEntity): + """Mock NumberEntity device to use in tests.""" + + @property + def max_value(self) -> float: + """Return the max value.""" + return 1.0 + + @property + def state(self): + """Return the current value.""" + return "0.5" + + +async def test_step(hass): + """Test the step calculation.""" + number = NumberEntity() + assert number.step == 1.0 + + number_2 = MockNumberEntity() + assert number_2.step == 0.1 + + +async def test_sync_set_value(hass): + """Test if async set_value calls sync set_value.""" + number = NumberEntity() + number.hass = hass + + number.set_value = MagicMock() + await number.async_set_value(42) + + assert number.set_value.called + assert number.set_value.call_args[0][0] == 42 -- GitLab