Просмотр исходного кода

Add support for M027 curtain

Issue #69
Jason Rumney 3 лет назад
Родитель
Сommit
8317dcbe9f

+ 1 - 0
ACKNOWLEDGEMENTS.md

@@ -91,3 +91,4 @@ Further device support has been made with the assistance of users.  Please consi
 - [Swiftnesses](https://github.com/Swiftnesses) for assistance with Devola patio heaters.
 - [odeBuXTeR](https://github.com/odeBuXTeR) for contributing support for Poolex Q-line pool heatpumps.
 - [peterforeman](https:github.com/peterforeman) for assistance with improving Fairland/Madimack support.
+- [Krispkiwi](https://github.com/Krispkiwi) for assistance with M027 curtain modules.

+ 1 - 0
README.md

@@ -161,6 +161,7 @@ Other brands may work with the above configurations
 - Simple Blind Controller
 - Kogan Garage Door with tilt sensor
 - QS-WIFI-C01(BK) Curtain Module
+- M027 Curtain Module (sold under several brands, including zemismart, meterk and others)
 
 ### Vacuum Cleaners
 - Lefant M213 Vacuum Cleaner

+ 74 - 0
custom_components/tuya_local/devices/m027_curtain.yaml

@@ -0,0 +1,74 @@
+name: M027 Curtain
+primary_entity:
+  entity: cover
+  class: curtain
+  dps:
+    - id: 1
+      name: control
+      type: string
+      mapping:
+        - dps_val: open
+          value: open
+        - dps_val: close
+          value: close
+        - dps_val: stop
+          value: stop
+    - id: 2
+      name: position
+      type: integer
+      unit: "%"
+      range:
+        min: 0
+        max: 100
+    - id: 3
+      name: current_position
+      type: integer
+      unit: "%"
+    - id: 7
+      name: action
+      type: string
+      mapping:
+        - dps_val: opening
+          constraint: current_position
+          conditions:
+            - dps_val: 100
+              value: opened
+            - value: opening
+        - dps_val: closing
+          constraint: current_position
+          conditions:
+            - dps_val: 0
+              value: closed
+            - value: closing
+secondary_entities:
+  - entity: select
+    name: Mode
+    icon: "mdi:theme-light-dark"
+    category: config
+    dps:
+      - id: 4
+        name: option
+        type: string
+        mapping:
+          - dps_val: morning
+            value: Morning
+          - dps_val: night
+            value: Night
+  - entity: sensor
+    name: Time Remaining
+    icon: "mdi:timer"
+    category: diagnostic
+    dps:
+      - id: 9
+        name: sensor
+        type: integer
+        unit: s
+  - entity: sensor
+    name: Travel Time
+    icon: "mdi:hourglass"
+    category: diagnostic
+    dps:
+      - id: 10
+        name: sensor
+        type: integer
+        unit: ms

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

@@ -31,6 +31,7 @@ class TuyaLocalCover(TuyaLocalEntity, CoverEntity):
         """
         dps_map = self._init_begin(device, config)
         self._position_dps = dps_map.pop("position", None)
+        self._currentpos_dps = dps_map.pop("current_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)
@@ -68,6 +69,8 @@ class TuyaLocalCover(TuyaLocalEntity, CoverEntity):
     @property
     def current_cover_position(self):
         """Return current position of cover."""
+        if self._currentpos_dps:
+            return self._currentpos_dps.get_value(self._device)
         if self._position_dps:
             return self._position_dps.get_value(self._device)
 

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

@@ -114,6 +114,7 @@
                     "select_initial_state": "Include initial state as a select entity",
                     "select_installation": "Include installation as a select entity",
                     "select_max_range": "Include max range as a select entity",
+                    "select_mode": "Include mode as a select entity",
                     "select_motor_reverse_mode": "Include motor reverse mode as a select entity",
                     "select_open_window_detection": "Include open window detection as a select entity",
                     "select_pir_timeout": "Include PIR timeout as a select entity",
@@ -155,6 +156,7 @@
                     "sensor_status": "Include status as a sensor entity",
                     "sensor_time_remaining": "Include time remaining as a sensor entity",
                     "sensor_timer": "Include time remaining as a sensor entity",
+                    "sensor_travel_time": "Include travel time as a sensor entity",
                     "sensor_voltage": "Include voltage as a sensor entity",
                     "sensor_ambient_temperature": "Include ambient temperature as a sensor entity",
                     "sensor_compressor_speed": "Include compressor speed as a sensor entity",
@@ -302,6 +304,7 @@
                     "select_initial_state": "Include initial state as a select entity",
                     "select_installation": "Include installation as a select entity",
                     "select_max_range": "Include max range as a select entity",
+                    "select_mode": "Include mode as a select entity",
                     "select_motor_reverse_mode": "Include motor reverse mode as a select entity",
                     "select_open_window_detection": "Include open window detection as a select entity",
                     "select_pir_timeout": "Include PIR timeout as a select entity",
@@ -343,6 +346,7 @@
                     "sensor_status": "Include status as a sensor entity",
                     "sensor_time_remaining": "Include time remaining as a sensor entity",
                     "sensor_timer": "Include time remaining as a sensor entity",
+                    "sensor_travel_time": "Include travel time as a sensor entity",
                     "sensor_voltage": "Include voltage as a sensor entity",
                     "sensor_ambient_temperature": "Include ambient temperature as a sensor entity",
                     "sensor_compressor_speed": "Include compressor speed as a sensor entity",

+ 10 - 0
tests/const.py

@@ -1220,3 +1220,13 @@ QS_C01_CURTAIN_PAYLOAD = {
     "8": "forward",
     "10": 20,
 }
+
+M027_CURTAIN_PAYLOAD = {
+    "1": "stop",
+    "2": 0,
+    "3": 0,
+    "4": "morning",
+    "7": "opening",
+    "9": 0,
+    "10": 20000,
+}

+ 125 - 0
tests/devices/test_m027_curtain.py

@@ -0,0 +1,125 @@
+"""Tests for the M027 curtain module."""
+from homeassistant.components.cover import (
+    CoverDeviceClass,
+    SUPPORT_CLOSE,
+    SUPPORT_OPEN,
+    SUPPORT_SET_POSITION,
+    SUPPORT_STOP,
+)
+from homeassistant.const import TIME_MILLISECONDS, TIME_SECONDS
+
+from ..const import M027_CURTAIN_PAYLOAD
+from ..helpers import assert_device_properties_set
+from ..mixins.sensor import MultiSensorTests
+from ..mixins.select import BasicSelectTests
+from .base_device_tests import TuyaDeviceTestCase
+
+COMMAND_DPS = "1"
+POSITION_DPS = "2"
+CURRENTPOS_DPS = "3"
+MODE_DPS = "4"
+ACTION_DPS = "7"
+TIMER_DPS = "9"
+TRAVELTIME_DPS = "10"
+
+
+class TestM027Curtains(MultiSensorTests, BasicSelectTests, TuyaDeviceTestCase):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig("m027_curtain.yaml", M027_CURTAIN_PAYLOAD)
+        self.subject = self.entities["cover"]
+        self.setUpMultiSensors(
+            [
+                {
+                    "dps": TRAVELTIME_DPS,
+                    "name": "sensor_travel_time",
+                    "min": 1,
+                    "max": 120000,
+                    "unit": TIME_MILLISECONDS,
+                },
+                {
+                    "dps": TIMER_DPS,
+                    "name": "sensor_time_remaining",
+                    "min": 0,
+                    "max": 86400,
+                    "unit": TIME_SECONDS,
+                },
+            ]
+        )
+        self.setUpBasicSelect(
+            MODE_DPS,
+            self.entities.get("select_mode"),
+            {
+                "morning": "Morning",
+                "night": "Night",
+            },
+        ),
+        self.mark_secondary(
+            ["sensor_travel_time", "sensor_time_remaining", "select_mode"]
+        )
+
+    def test_device_class_is_curtain(self):
+        self.assertEqual(self.subject.device_class, CoverDeviceClass.CURTAIN)
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features,
+            SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION | SUPPORT_STOP,
+        )
+
+    def test_current_cover_position(self):
+        self.dps[CURRENTPOS_DPS] = 47
+        self.assertEqual(self.subject.current_cover_position, 47)
+
+    def test_is_opening(self):
+        self.dps[ACTION_DPS] = "opening"
+        self.dps[CURRENTPOS_DPS] = 100
+        self.assertFalse(self.subject.is_opening)
+        self.dps[CURRENTPOS_DPS] = 50
+        self.assertTrue(self.subject.is_opening)
+        self.dps[ACTION_DPS] = "closing"
+        self.assertFalse(self.subject.is_opening)
+
+    def test_is_closing(self):
+        self.dps[ACTION_DPS] = "closing"
+        self.dps[CURRENTPOS_DPS] = 0
+        self.assertFalse(self.subject.is_closing)
+        self.dps[CURRENTPOS_DPS] = 50
+        self.assertTrue(self.subject.is_closing)
+        self.dps[ACTION_DPS] = "opening"
+        self.assertFalse(self.subject.is_closing)
+
+    def test_is_closed(self):
+        self.dps[CURRENTPOS_DPS] = 100
+        self.assertFalse(self.subject.is_closed)
+        self.dps[CURRENTPOS_DPS] = 0
+        self.assertTrue(self.subject.is_closed)
+
+    async def test_open_cover(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {COMMAND_DPS: "open"},
+        ):
+            await self.subject.async_open_cover()
+
+    async def test_close_cover(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {COMMAND_DPS: "close"},
+        ):
+            await self.subject.async_close_cover()
+
+    async def test_stop_cover(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {COMMAND_DPS: "stop"},
+        ):
+            await self.subject.async_stop_cover()
+
+    async def test_set_cover_position(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {POSITION_DPS: 23},
+        ):
+            await self.subject.async_set_cover_position(23)