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