Răsfoiți Sursa

Add support for a simple garage door, as a cover device.

Issue #46

Awaiting further feedback on that issue for a request for a curtain device, so
a more complex cover device can also be added.
Jason Rumney 4 ani în urmă
părinte
comite
78a623f50f

+ 1 - 0
ACKNOWLEDGEMENTS.md

@@ -53,3 +53,4 @@ Further device support has been made with the assistance of users.  Please consi
  - [nzcodarnoc](https://github.com/nzcodarnoc) for contributing support for Kogan KASHMFP heaters.
  - [pascaltippelt](https://github.com/pascaltippelt) for assistance in supporting Minco MH-1823 thermostat.
  - [voed](https://github.com/voed) for assistance in supporting Advanced Energy monitoring smart switch, based on CBE smart switch but seeming to follow a Tuya Standard Template, so probably applicable to others.
+ - [myevit](https://github.com/myevit) for assistance in supporting simple garage doors.

+ 3 - 0
README.md

@@ -109,6 +109,9 @@ the device will not work despite being listed below.
 - Other brands may work with the above configurations
 - Simple Switch - a switch only, can be a fallback for many other unsupported devices, to allow just power to be switched on/off.
 
+### Covers
+- Simple Garage Door
+
 ### Miscellaneous
 - Qoto 03 Smart Water Valve / Sprinkler Controller
 

+ 49 - 0
custom_components/tuya_local/cover.py

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

+ 17 - 0
custom_components/tuya_local/devices/garage_door_opener.yaml

@@ -0,0 +1,17 @@
+# Based on info on the web, this is a common subset of Garage door opener dps.
+name: Simple Garage Door
+primary_entity:
+  entity: cover
+  class: garage
+  dps:
+    - id: 1
+      name: control
+      type: boolean
+      mapping:
+        - dps_val: true
+          value: "open"
+        - dps_val: false
+          value: "close"
+    - id: 101
+      name: open
+      type: boolean

+ 140 - 0
custom_components/tuya_local/generic/cover.py

@@ -0,0 +1,140 @@
+"""
+Platform to control tuya cover devices.
+"""
+import logging
+
+from homeassistant.components.cover import (
+    CoverEntity,
+    DEVICE_CLASSES,
+    SUPPORT_CLOSE,
+    SUPPORT_OPEN,
+    SUPPORT_SET_POSITION,
+    SUPPORT_STOP,
+)
+
+from ..device import TuyaLocalDevice
+from ..helpers.device_config import TuyaEntityConfig
+from ..helpers.mixin import TuyaLocalEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class TuyaLocalCover(TuyaLocalEntity, CoverEntity):
+    """Representation of a Tuya Cover Entity."""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the cover device.
+        Args:
+          device (TuyaLocalDevice): The device API instance
+          config (TuyaEntityConfig): The entity config
+        """
+        dps_map = self._init_begin(device, config)
+        self._position_dps = dps_map.pop("position", None)
+        self._control_dps = dps_map.pop("control", None)
+        self._action_dps = dps_map.pop("action", None)
+        self._open_dps = dps_map.pop("open", None)
+
+        self._init_end(dps_map)
+
+        self._support_flags = 0
+        if self._position_dps:
+            self._support_flags |= SUPPORT_SET_POSITION
+        if self._control_dps:
+            if "stop" in self._control_dps.values(self._device):
+                self._support_flags |= SUPPORT_STOP
+            if "open" in self._control_dps.values(self._device):
+                self._support_flags |= SUPPORT_OPEN
+            if "close" in self._control_dps.values(self._device):
+                self._support_flags |= SUPPORT_CLOSE
+        # Tilt not yet supported, as no test devices known
+
+    @property
+    def device_class(self):
+        """Return the class of ths device"""
+        dclass = self._config.device_class
+        if dclass in DEVICE_CLASSES:
+            return dclass
+        else:
+            return None
+
+    @property
+    def supported_features(self):
+        """Inform HA of the supported features."""
+        return self._support_flags
+
+    @property
+    def current_cover_position(self):
+        """Return current position of cover."""
+        if self._position_dps:
+            return self._position_dps.get_value(self._device)
+
+        if self._open_dps:
+            state = self._open_dps.get_value(self._device)
+            if state is not None:
+                return 100 if state else 0
+
+    @property
+    def is_opening(self):
+        """Return if the cover is opening or not."""
+        # If dps is available to inform current action, use that
+        if self._action_dps:
+            return self._action_dps.get_value(self._device) == "opening"
+        # Otherwise use last command and check it hasn't completed
+        if self._control_dps:
+            return (
+                self._control_dps.get_value(self._device) == "open"
+                and self.current_cover_position != 100
+            )
+
+    @property
+    def is_closing(self):
+        """Return if the cover is closing or not."""
+        # If dps is available to inform current action, use that
+        if self._action_dps:
+            return self._action_dps.get_value(self._device) == "closing"
+        # Otherwise use last command and check it hasn't completed
+        if self._control_dps:
+            return (
+                self._control_dps.get_value(self._device) == "close"
+                and not self.is_closed
+            )
+
+    @property
+    def is_closed(self):
+        """Return if the cover is closed or not."""
+        return self.current_cover_position == 0
+
+    async def async_open_cover(self, **kwargs):
+        """Open the cover."""
+        if self._control_dps and "open" in self._control_dps.values(self._device):
+            await self._control_dps.async_set_value(self._device, "open")
+        elif self._position_dps:
+            await self._position_dps.async_set_value(self._device, 100)
+        else:
+            raise NotImplementedError()
+
+    async def async_close_cover(self, **kwargs):
+        """Close the cover."""
+        if self._control_dps and "close" in self._control_dps.values(self._device):
+            await self._control_dps.async_set_value(self._device, "close")
+        elif self._position_dps:
+            await self._position_dps.async_set_value(self._device, 0)
+        else:
+            raise NotImplementedError()
+
+    async def async_set_cover_position(self, position, **kwargs):
+        """Set the cover to a specific position."""
+        if position is None:
+            raise AttributeError()
+        if self._position_dps:
+            await self._position_dps.async_set_value(self._device, position)
+        else:
+            raise NotImplementedError()
+
+    async def async_stop_cover(self, **kwargs):
+        """Stop the cover."""
+        if self._control_dps and "stop" in self._control_dps.values(self._device):
+            await self._control_dps.async_set_value(self._device, "stop")
+        else:
+            raise NotImplementedError()

+ 1 - 0
hacs.json

@@ -4,6 +4,7 @@
     "domains": [
 	"binary_sensor",
 	"climate",
+	"cover",
 	"fan",
 	"humidifier",
 	"light",

+ 5 - 0
tests/const.py

@@ -615,3 +615,8 @@ MINCO_MH1823D_THERMOSTAT_PAYLOAD = {
     "106": 35,
     "107": 95,
 }
+
+SIMPLE_GARAGE_DOOR_PAYLOAD = {
+    "1": True,
+    "101": False,
+}

+ 2 - 0
tests/devices/base_device_tests.py

@@ -4,6 +4,7 @@ from uuid import uuid4
 
 from custom_components.tuya_local.generic.binary_sensor import TuyaLocalBinarySensor
 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
 from custom_components.tuya_local.generic.humidifier import TuyaLocalHumidifier
 from custom_components.tuya_local.generic.light import TuyaLocalLight
@@ -23,6 +24,7 @@ from ..helpers import assert_device_properties_set
 DEVICE_TYPES = {
     "binary_sensor": TuyaLocalBinarySensor,
     "climate": TuyaLocalClimate,
+    "cover": TuyaLocalCover,
     "fan": TuyaLocalFan,
     "humidifier": TuyaLocalHumidifier,
     "light": TuyaLocalLight,

+ 89 - 0
tests/devices/test_garage_door_opener.py

@@ -0,0 +1,89 @@
+"""Tests for the simple garage door opener."""
+from homeassistant.components.cover import (
+    DEVICE_CLASS_GARAGE,
+    SUPPORT_CLOSE,
+    SUPPORT_OPEN,
+)
+
+from ..const import SIMPLE_GARAGE_DOOR_PAYLOAD
+from ..helpers import assert_device_properties_set
+from .base_device_tests import TuyaDeviceTestCase
+
+SWITCH_DPS = "1"
+OPEN_DPS = "101"
+
+
+class TestSimpleGarageOpener(TuyaDeviceTestCase):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig("garage_door_opener.yaml", SIMPLE_GARAGE_DOOR_PAYLOAD)
+        self.subject = self.entities["cover"]
+
+    def test_device_class_is_garage(self):
+        self.assertEqual(self.subject.device_class, DEVICE_CLASS_GARAGE)
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features,
+            SUPPORT_OPEN | SUPPORT_CLOSE,
+        )
+
+    def test_current_cover_position(self):
+        self.dps[OPEN_DPS] = True
+        self.assertEqual(self.subject.current_cover_position, 100)
+        self.dps[OPEN_DPS] = False
+        self.assertEqual(self.subject.current_cover_position, 0)
+
+    def test_is_opening(self):
+        self.dps[SWITCH_DPS] = False
+        self.dps[OPEN_DPS] = False
+        self.assertFalse(self.subject.is_opening)
+        self.dps[OPEN_DPS] = True
+        self.assertFalse(self.subject.is_opening)
+        self.dps[SWITCH_DPS] = True
+        self.assertFalse(self.subject.is_opening)
+        self.dps[OPEN_DPS] = False
+        self.assertTrue(self.subject.is_opening)
+
+    def test_is_closing(self):
+        self.dps[SWITCH_DPS] = False
+        self.dps[OPEN_DPS] = False
+        self.assertFalse(self.subject.is_closing)
+        self.dps[OPEN_DPS] = True
+        self.assertTrue(self.subject.is_closing)
+        self.dps[SWITCH_DPS] = True
+        self.assertFalse(self.subject.is_closing)
+        self.dps[OPEN_DPS] = False
+        self.assertFalse(self.subject.is_closing)
+
+    def test_is_closed(self):
+        self.dps[OPEN_DPS] = True
+        self.assertFalse(self.subject.is_closed)
+        self.dps[OPEN_DPS] = False
+        self.assertTrue(self.subject.is_closed)
+
+    async def test_open_cover(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {SWITCH_DPS: True},
+        ):
+            await self.subject.async_open_cover()
+
+    async def test_close_cover(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {SWITCH_DPS: False},
+        ):
+            await self.subject.async_close_cover()
+
+    async def test_set_cover_position_not_supported(self):
+        with self.assertRaises(NotImplementedError):
+            await self.subject.async_set_cover_position(50)
+
+    async def test_stop_cover_not_supported(self):
+        with self.assertRaises(NotImplementedError):
+            await self.subject.async_stop_cover()
+
+    def test_device_state_attributes(self):
+        self.assertEqual(self.subject.device_state_attributes, {})

+ 79 - 0
tests/test_cover.py

@@ -0,0 +1,79 @@
+"""Tests for the cover 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.cover import TuyaLocalCover
+from custom_components.tuya_local.cover import async_setup_entry
+
+
+async def test_init_entry(hass):
+    """Test the initialisation."""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "garage_door_opener",
+            CONF_DEVICE_ID: "dummy",
+            "cover": 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"]["cover"]) == TuyaLocalCover
+    m_add_entities.assert_called_once()
+
+
+async def test_init_entry_fails_if_device_has_no_cover(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={CONF_TYPE: "kogan_heater", 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"},
+    )
+    # 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()