Parcourir la source

Add select entity
- use it for the timer on arlec fans for testing.

Jason Rumney il y a 4 ans
Parent
commit
dab2b81bc1

+ 6 - 5
README.md

@@ -319,12 +319,13 @@ You can find these keys the same way as you would for any Tuya local integration
 
 ## Next steps
 
-1. Remove the need for custom classes for gpph heater and goldair dehumidifier.
+1. Implement the binary_sensor platform for exposing boolean read-only values,
+and cover platform for curtains, blinds, garage doors and the like.
+2. Remove the need for custom classes for gpph heater and goldair dehumidifier.
 These devices from upstream have some complex logic that currently cannot be represented in the config files.  Find a way to configure this logic so the last of the legacy code can be removed.
-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.
-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. select entities would help to surface more of the settings that do not fit into the standard types.
+3. 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.
+4. 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.
+5. 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.
 
 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.

+ 16 - 5
custom_components/tuya_local/devices/arlec_fan.yaml

@@ -29,8 +29,19 @@ primary_entity:
     - id: 103
       name: timer
       type: string
-      mapping:
-        - dps_val: "off"
-        - dps_val: 2hour
-        - dps_val: 4hour
-        - dps_val: 8hour
+secondary_entities:
+  - entity: select
+    name: timer
+    dps:
+      - id: 103
+        name: option
+        type: string
+        mapping:
+          - dps_val: "off"
+            value: "Off"
+          - dps_val: 2hour
+            value: "2 hours"
+          - dps_val: 4hour
+            value: "4 hours"
+          - dps_val: 8hour
+            value: "8 hours"

+ 81 - 0
custom_components/tuya_local/generic/select.py

@@ -0,0 +1,81 @@
+"""
+Platform for Tuya Select options that don't fit into other entity types.
+"""
+from homeassistant.components.select import SelectEntity
+
+from ..device import TuyaLocalDevice
+from ..helpers.device_config import TuyaEntityConfig
+
+
+class TuyaLocalSelect(SelectEntity):
+    """Representation of a Tuya Select"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the select.
+        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._option_dps = dps_map.pop("option")
+
+        if self._option_dps is None:
+            raise AttributeError(f"{config.name} is missing an option dps")
+        if not self._option_dps.values(device):
+            raise AttributeError(
+                f"{config.name} does not have a mapping to a list of options"
+            )
+
+        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 options(self):
+        "Return the list of possible options."
+        return self._option_dps.values(self._device)
+
+    @property
+    def current_option(self):
+        "Return the currently selected option"
+        return self._option_dps.get_value(self._device)
+
+    async def async_select_option(self, option):
+        "Set the option"
+        await self._option_dps.async_set_value(self._device, option)
+
+    @property
+    def device_state_attributes(self):
+        """Get additional attributes."""
+        attr = {}
+        for a in self._attr_dps:
+            attr[a.name] = a.get_value(self._device)
+        return attr
+
+    async def async_update(self):
+        """Update the device state."""
+        await self._device.async_refresh()

+ 49 - 0
custom_components/tuya_local/select.py

@@ -0,0 +1,49 @@
+"""
+Setup for different kinds of Tuya selects
+"""
+import logging
+
+from . import DOMAIN
+from .const import (
+    CONF_DEVICE_ID,
+    CONF_TYPE,
+)
+from .generic.select import TuyaLocalSelect
+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 select entity according to it's type."""
+    data = hass.data[DOMAIN][discovery_info[CONF_DEVICE_ID]]
+    device = data["device"]
+    selects = []
+
+    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 == "select" and discovery_info.get(ecfg.config_id, False):
+        data[ecfg.config_id] = TuyaLocalSelect(device, ecfg)
+        selects.append(data[ecfg.config_id])
+        if ecfg.deprecated:
+            _LOGGER.warning(ecfg.deprecation_message)
+        _LOGGER.debug(f"Adding select for {discovery_info[ecfg.config_id]}")
+
+    for ecfg in cfg.secondary_entities():
+        if ecfg.entity == "select" and discovery_info.get(ecfg.config_id, False):
+            data[ecfg.config_id] = TuyaLocalSelect(device, ecfg)
+            selects.append(data[ecfg.config_id])
+            if ecfg.deprecated:
+                _LOGGER.warning(ecfg.deprecation_message)
+            _LOGGER.debug(f"Adding select for {discovery_info[ecfg.config_id]}")
+
+    if not selects:
+        raise ValueError(f"{device.name} does not support use as a select device.")
+    async_add_entities(selects)
+
+
+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)

+ 1 - 0
hacs.json

@@ -8,6 +8,7 @@
 	"light",
 	"lock",
 	"number",
+	"select",
 	"sensor",
 	"switch"
     ],

+ 1 - 1
requirements-dev.txt

@@ -1,7 +1,7 @@
 black
 homeassistant
 isort
-pytest-homeassistant-custom-component
+pytest-homeassistant-custom-component==0.4.5
 pytest
 pytest-asyncio
 pytest-cov

+ 2 - 0
tests/devices/base_device_tests.py

@@ -8,6 +8,7 @@ from custom_components.tuya_local.generic.humidifier import TuyaLocalHumidifier
 from custom_components.tuya_local.generic.light import TuyaLocalLight
 from custom_components.tuya_local.generic.lock import TuyaLocalLock
 from custom_components.tuya_local.generic.number import TuyaLocalNumber
+from custom_components.tuya_local.generic.select import TuyaLocalSelect
 from custom_components.tuya_local.generic.sensor import TuyaLocalSensor
 from custom_components.tuya_local.generic.switch import TuyaLocalSwitch
 
@@ -24,6 +25,7 @@ DEVICE_TYPES = {
     "lock": TuyaLocalLock,
     "number": TuyaLocalNumber,
     "switch": TuyaLocalSwitch,
+    "select": TuyaLocalSelect,
     "sensor": TuyaLocalSensor,
 }
 

+ 19 - 0
tests/devices/test_arlec_fan.py

@@ -25,6 +25,7 @@ class TestArlecFan(TuyaDeviceTestCase):
     def setUp(self):
         self.setUpForConfig("arlec_fan.yaml", ARLEC_FAN_PAYLOAD)
         self.subject = self.entities["fan"]
+        self.timer = self.entities["select_timer"]
 
     def test_supported_features(self):
         self.assertEqual(
@@ -129,3 +130,21 @@ class TestArlecFan(TuyaDeviceTestCase):
     def test_device_state_attributes(self):
         self.dps[TIMER_DPS] = "2hour"
         self.assertEqual(self.subject.device_state_attributes, {"timer": "2hour"})
+        self.assertEqual(self.timer.device_state_attributes, {})
+
+    def test_timer_options(self):
+        self.assertCountEqual(
+            self.timer.options,
+            {"Off", "2 hours", "4 hours", "8 hours"},
+        )
+
+    def test_timer_current_option(self):
+        self.dps[TIMER_DPS] = "2hour"
+        self.assertEqual(self.timer.current_option, "2 hours")
+
+    async def test_select_option(self):
+        async with assert_device_properties_set(
+            self.timer._device,
+            {TIMER_DPS: "4hour"},
+        ):
+            await self.timer.async_select_option("4 hours")

+ 74 - 0
tests/test_select.py

@@ -0,0 +1,74 @@
+"""Tests for the select 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.select import TuyaLocalSelect
+from custom_components.tuya_local.select import async_setup_entry
+
+
+async def test_init_entry(hass):
+    """Test the initialisation."""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "arlec_fan",
+            CONF_DEVICE_ID: "dummy",
+            "fan": False,
+            "select_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"]["select_timer"]) == TuyaLocalSelect
+    m_add_entities.assert_called_once()
+
+
+async def test_init_entry_fails_if_device_has_no_select(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()