Sfoglia il codice sorgente

Add a button entity, use it for Avatto Curtain Switch.

- Add a button entity #244, #318
- Use it to improve Avatto Curtain Switch #292
Jason Rumney 3 anni fa
parent
commit
ae58f88ac0

+ 16 - 0
custom_components/tuya_local/button.py

@@ -0,0 +1,16 @@
+"""
+Setup for different kinds of Tuya button devices
+"""
+from .generic.button import TuyaLocalButton
+from .helpers.config import async_tuya_setup_platform
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    config = {**config_entry.data, **config_entry.options}
+    await async_tuya_setup_platform(
+        hass,
+        async_add_entities,
+        config,
+        "button",
+        TuyaLocalButton,
+    )

+ 7 - 0
custom_components/tuya_local/devices/README.md

@@ -390,6 +390,13 @@ HA documentation for the entity type to see what is valid (these may expand over
 ### binary_sensor
 - **sensor** (required, boolean) the dp to attach to the sensor.
 
+### button
+- **button** (required, boolean) the dp to attach to the button.  Any
+read value will be ignored, but the dp is expected to be present for
+device detection unless set to optional.  A value of true will be sent
+for a button press, map this to the desired dps_val if a different
+value is required.
+
 ### climate
 - **aux_heat** (optional, boolean) a dp to control the aux heat switch if the device has one.
 - **current_temperature** (optional, number) a dp that reports the current temperature.

+ 40 - 8
custom_components/tuya_local/devices/avatto_curtain_switch.yaml

@@ -1,19 +1,36 @@
 name: Avatto curtain switch
 primary_entity:
-  entity: select
-  icon: "mdi:curtains"
+  entity: button
+  name: stop
+  icon: "mdi:pause-octagon"
   dps:
     - id: 1
-      name: option
+      name: button
       type: string
       mapping:
-        - dps_val: open
-          value: Open
-        - dps_val: close
-          value: Close
         - dps_val: stop
-          value: Stop
+          value: true
 secondary_entities:
+  - entity: button
+    name: open
+    icon: "mdi:curtains"
+    dps:
+      - id: 1
+        name: button
+        type: string
+        mapping:
+          - dps_val: open
+            value: true
+  - entity: button
+    name: close
+    icon: "mdi:curtains-closed"
+    dps:
+      - id: 1
+        name: button
+        type: string
+        mapping:
+          - dps_val: close
+            value: true
   - entity: light
     category: config
     name: Backlight
@@ -21,3 +38,18 @@ secondary_entities:
       - id: 101
         type: boolean
         name: switch
+  - entity: select
+    deprecated: button
+    category: config
+    icon: "mdi:curtains"
+    dps:
+      - id: 1
+        name: option
+        type: string
+        mapping:
+          - dps_val: open
+            value: Open
+          - dps_val: close
+            value: Close
+          - dps_val: stop
+            value: Stop

+ 40 - 0
custom_components/tuya_local/generic/button.py

@@ -0,0 +1,40 @@
+"""
+Platform to control Tuya buttons.
+Buttons provide a way to send data to a Tuya dp which may not itself
+be readable.  If the device does not return any state for the dp, then
+it should be set as optional so it is not required to be present for detection.
+"""
+from homeassistant.components.button import ButtonEntity, ButtonDeviceClass
+
+from ..device import TuyaLocalDevice
+from ..helpers.device_config import TuyaEntityConfig
+from ..helpers.mixin import TuyaLocalEntity
+
+
+class TuyaLocalButton(TuyaLocalEntity, ButtonEntity):
+    """Representation of a Tuya Button"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialize the button.
+        Args:
+            device (TuyaLocalDevice): The device API instance.
+            config (TuyaEntityConfig): The config portion for this entity.
+        """
+        dps_map = self._init_begin(device, config)
+        self._button_dp = dps_map.pop("button")
+        self._init_end(dps_map)
+
+    @property
+    def device_class(self):
+        """Return the class for this device"""
+        dclass = self._config.device_class
+        try:
+            return ButtonDeviceClass(dclass)
+        except ValueError:
+            if dclass:
+                _LOGGER.warning(f"Unrecognized button device class of {dclass} ignored")
+
+    async def async_press(self):
+        """Press the button"""
+        await self._button_dp.async_set_value(self._device, True)

+ 5 - 0
tests/const.py

@@ -1484,6 +1484,11 @@ AVATTO_BLINDS_PAYLOAD = {
     "11": 0,
 }
 
+AVATTO_CURTAIN_PAYLOAD = {
+    "1": "stop",
+    "101": True,
+}
+
 ORION_SIREN_PAYLOAD = {
     "1": "normal",
     "5": "middle",

+ 2 - 0
tests/devices/base_device_tests.py

@@ -5,6 +5,7 @@ from uuid import uuid4
 from homeassistant.helpers.entity import EntityCategory
 
 from custom_components.tuya_local.generic.binary_sensor import TuyaLocalBinarySensor
+from custom_components.tuya_local.generic.button import TuyaLocalButton
 from custom_components.tuya_local.generic.climate import TuyaLocalClimate
 from custom_components.tuya_local.generic.cover import TuyaLocalCover
 from custom_components.tuya_local.generic.fan import TuyaLocalFan
@@ -26,6 +27,7 @@ from custom_components.tuya_local.helpers.device_config import (
 
 DEVICE_TYPES = {
     "binary_sensor": TuyaLocalBinarySensor,
+    "button": TuyaLocalButton,
     "climate": TuyaLocalClimate,
     "cover": TuyaLocalCover,
     "fan": TuyaLocalFan,

+ 53 - 0
tests/devices/test_avatto_curtain_switch.py

@@ -0,0 +1,53 @@
+"""Tests for the Avatto roller blinds controller."""
+
+from ..const import AVATTO_CURTAIN_PAYLOAD
+from ..helpers import assert_device_properties_set
+from ..mixins.light import BasicLightTests
+from ..mixins.select import BasicSelectTests
+from ..mixins.button import MultiButtonTests
+from .base_device_tests import TuyaDeviceTestCase
+
+COMMAND_DP = "1"
+BACKLIGHT_DP = "101"
+
+
+class TestAvattoCurtainSwitch(
+    MultiButtonTests, BasicSelectTests, BasicLightTests, TuyaDeviceTestCase
+):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig("avatto_curtain_switch.yaml", AVATTO_CURTAIN_PAYLOAD)
+        self.setUpMultiButtons(
+            [
+                {
+                    "dps": COMMAND_DP,
+                    "name": "button_stop",
+                    "testdata": "stop",
+                },
+                {
+                    "dps": COMMAND_DP,
+                    "name": "button_open",
+                    "testdata": "open",
+                },
+                {
+                    "dps": COMMAND_DP,
+                    "name": "button_close",
+                    "testdata": "close",
+                },
+            ]
+        )
+        self.setUpBasicSelect(
+            COMMAND_DP,
+            self.entities.get("select"),
+            {
+                "stop": "Stop",
+                "open": "Open",
+                "close": "Close",
+            },
+        ),
+        self.setUpBasicLight(
+            BACKLIGHT_DP,
+            self.entities.get("light_backlight"),
+        )
+        self.mark_secondary(["select", "light_backlight"])

+ 75 - 0
tests/mixins/button.py

@@ -0,0 +1,75 @@
+# Mixins for testing buttons
+from homeassistant.components.button import ButtonDeviceClass
+
+from ..helpers import assert_device_properties_set
+
+
+class BasicButtonTests:
+    def setUpBasicButton(
+        self,
+        dps,
+        subject,
+        device_class=None,
+        testdata=True,
+    ):
+        self.basicButton = subject
+        self.basicButtonDps = dps
+        try:
+            self.basicButtonDevClass = ButtonDeviceClass(device_class)
+        except ValueError:
+            self.basicButtonDevClass = None
+
+        self.basicButtonPushData = testdata
+
+    async def test_basic_button_press(self):
+        async with assert_device_properties_set(
+            self.basicButton._device,
+            {self.basicButtonDps: self.basicButtonPushData},
+        ):
+            await self.basicButton.async_press()
+
+    def test_basic_button_device_class(self):
+        self.assertEqual(self.basicButton.device_class, self.basicButtonDevClass)
+
+    def test_basic_button_extra_attributes(self):
+        self.assertEqual(self.basicButton.extra_state_attributes, {})
+
+
+class MultiButtonTests:
+    def setUpMultiButtons(self, buttons):
+        self.multiButton = {}
+        self.multiButtonDps = {}
+        self.multiButtonDevClass = {}
+        self.multiButtonPushData = {}
+
+        for b in buttons:
+            name = b.get("name")
+            subject = self.entities.get(name)
+            if subject is None:
+                raise AttributeError(f"No button for {name} found.")
+            self.multiButton[name] = subject
+            self.multiButtonDps[name] = b.get("dps")
+            self.multiButtonPushData[name] = b.get("testdata", True)
+            try:
+                self.multiButtonDevClass[name] = ButtonDeviceClass(
+                    b.get("device_class", None)
+                )
+            except ValueError:
+                self.multiButtonDevClass[name] = None
+
+    async def test_multi_button_press(self):
+        for key, subject in self.multiButton.items():
+            dp = self.multiButtonDps[key]
+            async with assert_device_properties_set(
+                subject._device,
+                {dp: self.multiButtonPushData[key]},
+            ):
+                await subject.async_press()
+
+    def test_multi_button_device_class(self):
+        for key, subject in self.multiButton.items():
+            self.assertEqual(subject.device_class, self.multiButtonDevClass[key])
+
+    def test_multi_button_extra_attributes(self):
+        for subject in self.multiButton.values():
+            self.assertEqual(subject.extra_state_attributes, {})

+ 121 - 0
tests/test_button.py

@@ -0,0 +1,121 @@
+"""Tests for the button entity."""
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+import pytest
+from unittest.mock import AsyncMock, Mock
+
+from custom_components.tuya_local.const import (
+    CONF_DEVICE_ID,
+    CONF_PROTOCOL_VERSION,
+    CONF_TYPE,
+    DOMAIN,
+)
+from custom_components.tuya_local.generic.button import TuyaLocalButton
+from custom_components.tuya_local.button import async_setup_entry
+
+
+@pytest.mark.asyncio
+async def test_init_entry(hass):
+    """Test the initialisation."""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "avatto_curtain_switch",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {}
+    hass.data[DOMAIN]["dummy"] = {}
+    hass.data[DOMAIN]["dummy"]["device"] = m_device
+
+    await async_setup_entry(hass, entry, m_add_entities)
+    assert type(hass.data[DOMAIN]["dummy"]["button_stop"]) == TuyaLocalButton
+    m_add_entities.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_init_entry_as_secondary(hass):
+    """Test the initialisation."""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "avatto_curtain_switch",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {}
+    hass.data[DOMAIN]["dummy"] = {}
+    hass.data[DOMAIN]["dummy"]["device"] = m_device
+
+    await async_setup_entry(hass, entry, m_add_entities)
+    assert type(hass.data[DOMAIN]["dummy"]["button_open"]) == TuyaLocalButton
+    m_add_entities.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_init_entry_fails_if_device_has_no_button(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "kogan_heater",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {}
+    hass.data[DOMAIN]["dummy"] = {}
+    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()
+
+
+@pytest.mark.asyncio
+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",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {}
+    hass.data[DOMAIN]["dummy"] = {}
+    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()