浏览代码

Add support for Zemismart AM25 roller blinds

Issue #2218

This commit also adds tilt_position support to the cover platform,
and corresponding tests.
Jason Rumney 1 年之前
父节点
当前提交
4a69221d6b

+ 1 - 0
ACKNOWLEDGEMENTS.md

@@ -654,3 +654,4 @@ Further device support has been made with the assistance of users. Please consid
 - [Stalex25](https://github.com/Stalex25) for assisting with support for Vaco Moby Blue and Arrifana portable heatpumps.
 - [Stalex25](https://github.com/Stalex25) for assisting with support for Vaco Moby Blue and Arrifana portable heatpumps.
 - [flocke](https://github.com/flocke) for assisting with support for MeacoDry Arete Two dehumidifier.
 - [flocke](https://github.com/flocke) for assisting with support for MeacoDry Arete Two dehumidifier.
 - [FrederikM97](https://github.com/FrederickM97) for assisting with Cleverio PF100 pet feeder.
 - [FrederikM97](https://github.com/FrederickM97) for assisting with Cleverio PF100 pet feeder.
+- [chadtheriault](https://github.com/chadtheriault) for assisting with Zemismart AM25 roller blinds.

+ 1 - 0
DEVICES.md

@@ -596,6 +596,7 @@ of device.
 - Wistar roller blind controller
 - Wistar roller blind controller
 - Yueqing Combo YET848PC curtain motor
 - Yueqing Combo YET848PC curtain motor
 - ZC34T-03-3A swing arm window opener
 - ZC34T-03-3A swing arm window opener
+- Zemismart AM25 roller blinds
 - Zemismart curtain rail
 - Zemismart curtain rail
 - Zemismart roller shade
 - Zemismart roller shade
 
 

+ 36 - 1
custom_components/tuya_local/cover.py

@@ -9,6 +9,10 @@ from homeassistant.components.cover import (
     CoverEntity,
     CoverEntity,
     CoverEntityFeature,
     CoverEntityFeature,
 )
 )
+from homeassistant.util.percentage import (
+    percentage_to_ranged_value,
+    ranged_value_to_percentage,
+)
 
 
 from .device import TuyaLocalDevice
 from .device import TuyaLocalDevice
 from .helpers.config import async_tuya_setup_platform
 from .helpers.config import async_tuya_setup_platform
@@ -43,6 +47,7 @@ class TuyaLocalCover(TuyaLocalEntity, CoverEntity):
         dps_map = self._init_begin(device, config)
         dps_map = self._init_begin(device, config)
         self._position_dp = dps_map.pop("position", None)
         self._position_dp = dps_map.pop("position", None)
         self._currentpos_dp = dps_map.pop("current_position", None)
         self._currentpos_dp = dps_map.pop("current_position", None)
+        self._tiltpos_dp = dps_map.pop("tilt_position", None)
         self._control_dp = dps_map.pop("control", None)
         self._control_dp = dps_map.pop("control", None)
         self._action_dp = dps_map.pop("action", None)
         self._action_dp = dps_map.pop("action", None)
         self._open_dp = dps_map.pop("open", None)
         self._open_dp = dps_map.pop("open", None)
@@ -58,7 +63,10 @@ class TuyaLocalCover(TuyaLocalEntity, CoverEntity):
                 self._support_flags |= CoverEntityFeature.OPEN
                 self._support_flags |= CoverEntityFeature.OPEN
             if "close" in self._control_dp.values(self._device):
             if "close" in self._control_dp.values(self._device):
                 self._support_flags |= CoverEntityFeature.CLOSE
                 self._support_flags |= CoverEntityFeature.CLOSE
-        # Tilt not yet supported, as no test devices known
+        if self._tiltpos_dp:
+            self._support_flags |= CoverEntityFeature.SET_TILT_POSITION
+
+        # Open/close/stop tilt not yet supported, as no test devices known
 
 
     @property
     @property
     def device_class(self):
     def device_class(self):
@@ -110,6 +118,16 @@ class TuyaLocalCover(TuyaLocalEntity, CoverEntity):
             pos = self._position_dp.get_value(self._device)
             pos = self._position_dp.get_value(self._device)
             return pos
             return pos
 
 
+    @property
+    def current_cover_tilt_position(self):
+        """Return current tilt position of cover."""
+        if self._tiltpos_dp:
+            r = self._tiltpos_dp.range(self._device)
+            val = self._tiltpos_dp.get_value(self._device)
+            if r and val is not None:
+                return ranged_value_to_percentage(r, val)
+            return val
+
     @property
     @property
     def _current_state(self):
     def _current_state(self):
         """Return the current state of the cover if it can be determined,
         """Return the current state of the cover if it can be determined,
@@ -205,6 +223,23 @@ class TuyaLocalCover(TuyaLocalEntity, CoverEntity):
         else:
         else:
             raise NotImplementedError()
             raise NotImplementedError()
 
 
+    async def async_set_cover_tilt_position(self, tilt_position, **kwargs):
+        """Set the cover tilt position."""
+        if self._tiltpos_dp:
+            # If there is a fixed list of values, snap to the closest one
+            if self._tiltpos_dp.values(self._device):
+                tilt_position = min(
+                    self._tiltpos_dp.values(self._device),
+                    key=lambda x: abs(x - tilt_position),
+                )
+            elif self._tiltpos_dp.range(self._device):
+                r = self._tiltpos_dp.range(self._device)
+                tilt_position = percentage_to_ranged_value(r, tilt_position)
+
+            await self._tiltpos_dp.async_set_value(self._device, tilt_position)
+        else:
+            raise NotImplementedError
+
     async def async_stop_cover(self, **kwargs):
     async def async_stop_cover(self, **kwargs):
         """Stop the cover."""
         """Stop the cover."""
         if self._control_dp and "stop" in self._control_dp.values(self._device):
         if self._control_dp and "stop" in self._control_dp.values(self._device):

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

@@ -603,6 +603,7 @@ Either **position** or **open** should be specified.
 - **action** (optional, string): a dp that reports the current state of the cover.
 - **action** (optional, string): a dp that reports the current state of the cover.
    Special values are `opening, closing`
    Special values are `opening, closing`
 - **open** (optional, boolean): a dp that reports if the cover is open. Only used if **position** is not available.
 - **open** (optional, boolean): a dp that reports if the cover is open. Only used if **position** is not available.
+- **tilt_position** (optional, number): a dp to control the tilt opening of the cover (an example is venetian blinds that tilt as well as go up and down). The range will be auto-converted to the 0-100 expected by HA.
 
 
 ### `fan`
 ### `fan`
 - **switch** (optional, boolean): a dp to control the power state of the fan
 - **switch** (optional, boolean): a dp to control the power state of the fan

+ 105 - 0
custom_components/tuya_local/devices/zemismart_am25_rollerblind.yaml

@@ -0,0 +1,105 @@
+name: Roller blind
+products:
+  - id: 7qcsglvoqkrdduk6
+    name: AM25WIFI
+primary_entity:
+  entity: cover
+  class: blind
+  dps:
+    - id: 1
+      name: control
+      type: string
+      mapping:
+        - dps_val: open
+          value: open
+        - dps_val: stop
+          value: stop
+        - dps_val: close
+          value: close
+        - dps_val: continue
+          value: continue
+    - id: 2
+      name: position
+      type: integer
+      range:
+        min: 0
+        max: 100
+      mapping:
+        - invert: true
+    - id: 3
+      name: missing_current_position
+      type: integer
+      optional: true
+      range:
+        min: 0
+        max: 100
+      mapping:
+        - invert: true
+    - id: 7
+      name: work_state
+      type: string
+      optional: true
+    - id: 109
+      type: integer
+      name: tilt_position
+      range:
+        min: 1
+        max: 10
+secondary_entities:
+  - entity: binary_sensor
+    class: problem
+    category: diagnostic
+    dps:
+      - id: 12
+        type: bitfield
+        name: sensor
+        optional: true
+        mapping:
+          - dps_val: null
+            value: false
+          - dps_val: 0
+            value: false
+          - value: true
+      - id: 12
+        type: bitfield
+        name: fault_code
+        optional: true
+  - entity: select
+    name: Direction
+    icon: "mdi:swap-horizontal"
+    category: config
+    dps:
+      - id: 103
+        type: boolean
+        name: option
+        optional: true
+        mapping:
+          - dps_val: false
+            value: forward
+          - dps_val: true
+            value: reverse
+          - dps_val: null
+            value: forward
+            hidden: true
+  - entity: switch
+    name: Limit up
+    category: config
+    dps:
+      - id: 104
+        type: boolean
+        name: switch
+  - entity: switch
+    name: Limit down
+    category: config
+    dps:
+      - id: 105
+        type: boolean
+        name: switch
+  - entity: button
+    name: Reset limits
+    category: config
+    dps:
+      - id: 107
+        type: boolean
+        name: button
+        optional: true

+ 8 - 0
tests/const.py

@@ -1647,3 +1647,11 @@ BLE_WATERVALVE_PAYLOAD = {
     "12": "unknown",
     "12": "unknown",
     "15": 60,
     "15": 60,
 }
 }
+
+AM25_ROLLERBLIND_PAYLOAD = {
+    "1": "stop",
+    "2": 0,
+    "104": True,
+    "105": True,
+    "109": 4,
+}

+ 64 - 0
tests/devices/test_zemismart_am25_blind.py

@@ -0,0 +1,64 @@
+"""Tests for the tilt position feature of AM25 roller blind."""
+
+from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature
+
+from ..const import AM25_ROLLERBLIND_PAYLOAD
+from ..helpers import assert_device_properties_set
+from .base_device_tests import TuyaDeviceTestCase
+
+COMMAND_DPS = "1"
+POSITION_DPS = "2"
+CURRENTPOS_DPS = "3"
+WORKSTATE_DP = "7"
+FAULT_DP = "12"
+DIRECTION_DP = "103"
+LIMITUP_DP = "104"
+LIMITDOWN_DP = "105"
+LIMITRESET_DP = "107"
+TILTPOS_DP = "109"
+
+
+class TestAM25Blinds(TuyaDeviceTestCase):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig(
+            "zemismart_am25_rollerblind.yaml",
+            AM25_ROLLERBLIND_PAYLOAD,
+        )
+        self.subject = self.entities["cover_blind"]
+        self.mark_secondary(
+            [
+                "binary_sensor_problem",
+                "select_direction",
+                "switch_limit_up",
+                "switch_limit_down",
+                "button_reset_limits",
+            ]
+        )
+
+    def test_device_class_is_blind(self):
+        self.assertEqual(self.subject.device_class, CoverDeviceClass.BLIND)
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features,
+            (
+                CoverEntityFeature.OPEN
+                | CoverEntityFeature.CLOSE
+                | CoverEntityFeature.SET_POSITION
+                | CoverEntityFeature.STOP
+                | CoverEntityFeature.SET_TILT_POSITION
+            ),
+        )
+
+    def test_current_cover_tilt_position(self):
+        self.dps[TILTPOS_DP] = 1
+        self.assertEqual(self.subject.current_cover_tilt_position, 10)
+
+    async def test_set_cover_tilt_position(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {TILTPOS_DP: 5},
+        ):
+            await self.subject.async_set_cover_tilt_position(50)