Преглед изворни кода

Add number platform.

Use it to expose timer on goldair_gpph_heater for testing.
Jason Rumney пре 4 година
родитељ
комит
4dda290923

+ 7 - 36
README.md

@@ -193,42 +193,13 @@ Assistant.  Although Home Assistant allows you to change the name
 later, it will only change the name used in the UI, not the name of
 later, it will only change the name used in the UI, not the name of
 the entities.
 the entities.
 
 
-#### climate
+#### (entities)
 
 
-    _(boolean) (Optional)_ Whether to surface this
-device as a climate device. (supported for heaters, heatpumps,
-deprecated for fans, dehumidifiers and humidifiers which should use
-the fan and humidifier entities instead)
-
-#### humidifier
-
-    _(boolean) (Optional)_ Whether to surface this
-device as a humidifier device (supported only for humidifiers and
-dehumidifiers)
-
-#### fan
-
-    _(boolean) (Optional)_ Whether to surface this
-device as a fan device (supported for fans, humidifiers and
-dehumidifiers)
-
-#### light
-
-    _(boolean) (Optional)_ Whether to surface this
-device as a light.  This may be an auxiliary display light control on
-devices such as heaters.
-
-#### lock
-
-    _(boolean) (Optional)_ Whether to surface this
-device as a lock device. This may be an auxiliary lock such as a child
-lock for devices such as heaters.
-
-#### switch
-
-    _(boolean) (Optional)_ Whether to surface this
-device as a switch device. This may be a switch for an auxiliary
-function or a master switch for multi-function devices.
+    _(boolean) (Optional)_ A number of options
+will be available for each of the entities exposed by the device.
+They will be named for the platform type and an optional name for
+the entity as a suffix (eg `climate`, `humidifier`, `lock_child_lock`)
+Setting them to True will expose the entity in Home Assistant.
 
 
 ## Heater gotchas
 ## Heater gotchas
 
 
@@ -353,7 +324,7 @@ These devices from upstream have some complex logic that currently cannot be rep
 2. This component is mosty unit-tested thanks to the upstream project, but there are a few more to complete. Feel free to use existing specs as inspiration and the Sonar Cloud analysis to see where the gaps are.
 2. This component is mosty unit-tested thanks to the upstream project, but there are a few more to complete. Feel free to use existing specs as inspiration and the Sonar Cloud analysis to see where the gaps are.
 3. Once unit tests are complete, the next task is to complete the Home Assistant quality checklist before considering submission to the HA team for inclusion in standard installations.
 3. Once unit tests are complete, the next task is to complete the Home Assistant quality checklist before considering submission to the HA team for inclusion in standard installations.
 4. Discovery seems possible with the new tinytuya library, though the steps to get a local key will most likely remain manual.  Discovery also returns a productKey, which might help make the device detection more reliable where different devices use the same dps mapping but different names for the presets for example.
 4. Discovery seems possible with the new tinytuya library, though the steps to get a local key will most likely remain manual.  Discovery also returns a productKey, which might help make the device detection more reliable where different devices use the same dps mapping but different names for the presets for example.
-5. number and select entities would help to surface more of the settings that do not fit into the standard types.
+5. select entities would help to surface more of the settings that do not fit into the standard types.
 
 
 Please report any issues and feel free to raise pull requests.
 Please report any issues and feel free to raise pull requests.
 [Many others](https://github.com/make-all/tuya-local/blob/main/ACKNOWLEDGEMENTS.md) have contributed their help already.
 [Many others](https://github.com/make-all/tuya-local/blob/main/ACKNOWLEDGEMENTS.md) have contributed their help already.

+ 11 - 0
custom_components/tuya_local/devices/goldair_gpph_heater.yaml

@@ -141,3 +141,14 @@ secondary_entities:
             icon: "mdi:hand-back-right-off"
             icon: "mdi:hand-back-right-off"
           - dps_val: false
           - dps_val: false
             icon: "mdi:hand-back-right"
             icon: "mdi:hand-back-right"
+  - entity: number
+    name: Timer
+    dps:
+      - id: 102
+        type: integer
+        name: value
+        range:
+          min: 0
+          max: 1440
+        mapping:
+          - step: 60

+ 99 - 0
custom_components/tuya_local/generic/number.py

@@ -0,0 +1,99 @@
+"""
+Platform for Tuya Number options that don't fit into other entity types.
+"""
+from homeassistant.components.number import NumberEntity
+from homeassistant.components.number.const import (
+    DEFAULT_MIN_VALUE,
+    DEFAULT_MAX_VALUE,
+)
+
+from ..device import TuyaLocalDevice
+from ..helpers.device_config import TuyaEntityConfig
+
+MODE_AUTO = "auto"
+
+
+class TuyaLocalNumber(NumberEntity):
+    """Representation of a Tuya Number"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the sensor.
+        Args:
+            device (TuyaLocalDevice): the device API instance
+            config (TuyaEntityConfig): the configuration for this entity
+        """
+        self._device = device
+        self._config = config
+        self._attr_dps = []
+        dps_map = {c.name: c for c in config.dps()}
+        self._value_dps = dps_map.pop("value")
+
+        if self._value_dps is None:
+            raise AttributeError(f"{config.name} is missing a value dps")
+
+        for d in dps_map.values():
+            if not d.hidden:
+                self._attr_dps.append(d)
+
+    @property
+    def should_poll(self):
+        """Return the polling state."""
+        return True
+
+    @property
+    def name(self):
+        """Return the name for this entity."""
+        return self._config.name(self._device.name)
+
+    @property
+    def unique_id(self):
+        """Return the unique id of the device."""
+        return self._config.unique_id(self._device.unique_id)
+
+    @property
+    def device_info(self):
+        """Return device information about this device."""
+        return self._device.device_info
+
+    @property
+    def min_value(self):
+        r = self._value_dps.range(self._device)
+        return DEFAULT_MIN_VALUE if r is None else r["min"]
+
+    @property
+    def max_value(self):
+        r = self._value_dps.range(self._device)
+        return DEFAULT_MAX_VALUE if r is None else r["max"]
+
+    @property
+    def step(self):
+        return self._value_dps.step(self._device)
+
+    @property
+    def mode(self):
+        """Return the mode."""
+        m = self._config.mode
+        if m is None:
+            m = MODE_AUTO
+        return m
+
+    @property
+    def value(self):
+        """Return the current value of the number."""
+        return self._value_dps.get_value(self._device)
+
+    async def async_set_value(self, value):
+        """Set the number."""
+        await self._value_dps.async_set_value(self._device, value)
+
+    @property
+    def device_state_attributes(self):
+        """Get additional attributes that the integration itself does not support."""
+        attr = {}
+        for a in self._attr_dps:
+            attr[a.name] = a.get_value(self._device)
+        return attr
+
+    async def async_update(self):
+        await self._device.async_refresh()

+ 8 - 0
custom_components/tuya_local/generic/sensor.py

@@ -86,5 +86,13 @@ class TuyaLocalSensor(SensorEntity):
 
 
         return unit
         return unit
 
 
+    @property
+    def device_state_attributes(self):
+        """Get additional attributes that the integration itself does not support."""
+        attr = {}
+        for a in self._attr_dps:
+            attr[a.name] = a.get_value(self._device)
+        return attr
+
     async def async_update(self):
     async def async_update(self):
         await self._device.async_refresh()
         await self._device.async_refresh()

+ 5 - 0
custom_components/tuya_local/helpers/device_config.py

@@ -214,6 +214,11 @@ class TuyaEntityConfig:
                 priority = rule["priority"]
                 priority = rule["priority"]
         return icon
         return icon
 
 
+    @property
+    def mode(self):
+        """Return the mode (used by Number entities)."""
+        return self._config.get("mode")
+
     def dps(self):
     def dps(self):
         """Iterate through the list of dps for this entity."""
         """Iterate through the list of dps for this entity."""
         for d in self._config["dps"]:
         for d in self._config["dps"]:

+ 49 - 0
custom_components/tuya_local/number.py

@@ -0,0 +1,49 @@
+"""
+Setup for different kinds of Tuya numbers
+"""
+import logging
+
+from . import DOMAIN
+from .const import (
+    CONF_DEVICE_ID,
+    CONF_TYPE,
+)
+from .generic.number import TuyaLocalNumber
+from .helpers.device_config import get_config
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+    """Set up the number entity according to it's type."""
+    data = hass.data[DOMAIN][discovery_info[CONF_DEVICE_ID]]
+    device = data["device"]
+    numbers = []
+
+    cfg = get_config(discovery_info[CONF_TYPE])
+    if cfg is None:
+        raise ValueError(f"No device config found for {discovery_info}")
+    ecfg = cfg.primary_entity
+    if ecfg.entity == "number" and discovery_info.get(ecfg.config_id, False):
+        data[ecfg.config_id] = TuyaLocalNumber(device, ecfg)
+        numbers.append(data[ecfg.config_id])
+        if ecfg.deprecated:
+            _LOGGER.warning(ecfg.deprecation_message)
+        _LOGGER.debug(f"Adding number for {discovery_info[ecfg.config_id]}")
+
+    for ecfg in cfg.secondary_entities():
+        if ecfg.entity == "number" and discovery_info.get(ecfg.config_id, False):
+            data[ecfg.config_id] = TuyaLocalNumber(device, ecfg)
+            numbers.append(data[ecfg.config_id])
+            if ecfg.deprecated:
+                _LOGGER.warning(ecfg.deprecation_message)
+            _LOGGER.debug(f"Adding number for {discovery_info[ecfg.config_id]}")
+
+    if not numbers:
+        raise ValueError(f"{device.name} does not support use as a number device.")
+    async_add_entities(numbers)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    config = {**config_entry.data, **config_entry.options}
+    await async_setup_platform(hass, {}, async_add_entities, config)

+ 2 - 0
custom_components/tuya_local/translations/en.json

@@ -35,6 +35,7 @@
 		    "light_display": "Include display as a light entity",
 		    "light_display": "Include display as a light entity",
 		    "light_uv_sterilization": "Include UV sterilization as a light entitiy",
 		    "light_uv_sterilization": "Include UV sterilization as a light entitiy",
 		    "lock_child_lock": "Include child lock as a lock entity",
 		    "lock_child_lock": "Include child lock as a lock entity",
+		    "number_timer": "Include timer as a number entity",
 		    "sensor_current_humidity": "Include current humidity as a sensor entity",
 		    "sensor_current_humidity": "Include current humidity as a sensor entity",
 		    "sensor_current_temperature": "Include current temperature as a sensor entity",
 		    "sensor_current_temperature": "Include current temperature as a sensor entity",
 		    "switch_air_clean": "Include air clean as a switch entity",
 		    "switch_air_clean": "Include air clean as a switch entity",
@@ -77,6 +78,7 @@
 		"light_display": "Include display as a light entity",
 		"light_display": "Include display as a light entity",
 		"light_uv_sterilization": "Include UV sterilization as a light entitiy",
 		"light_uv_sterilization": "Include UV sterilization as a light entitiy",
 		"lock_child_lock": "Include child lock as a lock entity",
 		"lock_child_lock": "Include child lock as a lock entity",
+		"number_timer": "Include timer as a number entity",
 		"sensor_current_humidity": "Include current humidity as a sensor entity",
 		"sensor_current_humidity": "Include current humidity as a sensor entity",
 		"sensor_current_temperature": "Include current temperature as a sensor entity",
 		"sensor_current_temperature": "Include current temperature as a sensor entity",
 		"switch_air_clean": "Include air clean as a switch entity",
 		"switch_air_clean": "Include air clean as a switch entity",

+ 10 - 1
hacs.json

@@ -1,7 +1,16 @@
 {
 {
   "name": "Tuya Local",
   "name": "Tuya Local",
   "render_readme": true,
   "render_readme": true,
-  "domains": ["climate", "fan", "humidifier", "light", "lock", "sensor", "switch"],
+    "domains": [
+	"climate",
+	"fan",
+	"humidifier",
+	"light",
+	"lock",
+	"number",
+	"sensor",
+	"switch"
+    ],
   "homeassistant": "2021.10.0",
   "homeassistant": "2021.10.0",
   "iot_class": "Local Polling"
   "iot_class": "Local Polling"
 }
 }

+ 2 - 0
tests/devices/base_device_tests.py

@@ -7,6 +7,7 @@ from custom_components.tuya_local.generic.fan import TuyaLocalFan
 from custom_components.tuya_local.generic.humidifier import TuyaLocalHumidifier
 from custom_components.tuya_local.generic.humidifier import TuyaLocalHumidifier
 from custom_components.tuya_local.generic.light import TuyaLocalLight
 from custom_components.tuya_local.generic.light import TuyaLocalLight
 from custom_components.tuya_local.generic.lock import TuyaLocalLock
 from custom_components.tuya_local.generic.lock import TuyaLocalLock
+from custom_components.tuya_local.generic.number import TuyaLocalNumber
 from custom_components.tuya_local.generic.sensor import TuyaLocalSensor
 from custom_components.tuya_local.generic.sensor import TuyaLocalSensor
 from custom_components.tuya_local.generic.switch import TuyaLocalSwitch
 from custom_components.tuya_local.generic.switch import TuyaLocalSwitch
 
 
@@ -21,6 +22,7 @@ DEVICE_TYPES = {
     "humidifier": TuyaLocalHumidifier,
     "humidifier": TuyaLocalHumidifier,
     "light": TuyaLocalLight,
     "light": TuyaLocalLight,
     "lock": TuyaLocalLock,
     "lock": TuyaLocalLock,
+    "number": TuyaLocalNumber,
     "switch": TuyaLocalSwitch,
     "switch": TuyaLocalSwitch,
     "sensor": TuyaLocalSensor,
     "sensor": TuyaLocalSensor,
 }
 }

+ 27 - 0
tests/devices/test_goldair_gpph_heater.py

@@ -38,6 +38,7 @@ class TestGoldairHeater(TuyaDeviceTestCase):
         self.subject = self.entities.get("climate")
         self.subject = self.entities.get("climate")
         self.light = self.entities.get("light_display")
         self.light = self.entities.get("light_display")
         self.lock = self.entities.get("lock_child_lock")
         self.lock = self.entities.get("lock_child_lock")
+        self.timer = self.entities.get("number_timer")
 
 
     def test_supported_features(self):
     def test_supported_features(self):
         self.assertEqual(
         self.assertEqual(
@@ -409,3 +410,29 @@ class TestGoldairHeater(TuyaDeviceTestCase):
 
 
         async with assert_device_properties_set(self.light._device, {LIGHT_DPS: False}):
         async with assert_device_properties_set(self.light._device, {LIGHT_DPS: False}):
             await self.light.async_toggle()
             await self.light.async_toggle()
+
+    def test_timer_min_value(self):
+        self.assertEqual(self.timer.min_value, 0)
+
+    def test_timer_max_value(self):
+        self.assertEqual(self.timer.max_value, 1440)
+
+    def test_timer_step(self):
+        self.assertEqual(self.timer.step, 60)
+
+    def test_timer_mode(self):
+        self.assertEqual(self.timer.mode, "auto")
+
+    def test_timer_value(self):
+        self.dps[TIMER_DPS] = 1234
+        self.assertEqual(self.timer.value, 1234)
+
+    async def test_timer_set_value(self):
+        async with assert_device_properties_set(
+            self.timer._device,
+            {TIMER_DPS: 120},
+        ):
+            await self.timer.async_set_value(120)
+
+    def test_number_device_state_attributes(self):
+        self.assertEqual(self.timer.device_state_attributes, {})

+ 74 - 0
tests/test_number.py

@@ -0,0 +1,74 @@
+"""Tests for the number entity."""
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+from unittest.mock import AsyncMock, Mock
+
+from custom_components.tuya_local.const import (
+    CONF_DEVICE_ID,
+    CONF_TYPE,
+    DOMAIN,
+)
+from custom_components.tuya_local.generic.number import TuyaLocalNumber
+from custom_components.tuya_local.number import async_setup_entry
+
+
+async def test_init_entry(hass):
+    """Test the initialisation."""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "goldair_gpph_heater",
+            CONF_DEVICE_ID: "dummy",
+            "climate": False,
+            "number_timer": True,
+        },
+    )
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {
+        "dummy": {"device": m_device},
+    }
+
+    await async_setup_entry(hass, entry, m_add_entities)
+    assert type(hass.data[DOMAIN]["dummy"]["number_timer"]) == TuyaLocalNumber
+    m_add_entities.assert_called_once()
+
+
+async def test_init_entry_fails_if_device_has_no_number(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={CONF_TYPE: "simple_switch", CONF_DEVICE_ID: "dummy"},
+    )
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {
+        "dummy": {"device": m_device},
+    }
+    try:
+        await async_setup_entry(hass, entry, m_add_entities)
+        assert False
+    except ValueError:
+        pass
+    m_add_entities.assert_not_called()
+
+
+async def test_init_entry_fails_if_config_is_missing(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={CONF_TYPE: "non_existing", CONF_DEVICE_ID: "dummy"},
+    )
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {
+        "dummy": {"device": m_device},
+    }
+    try:
+        await async_setup_entry(hass, entry, m_add_entities)
+        assert False
+    except ValueError:
+        pass
+    m_add_entities.assert_not_called()