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

Add support for Arlec Fan with light.py

Additional feature: color temperature support for lights.

Unrelated update: pycryptodome 3.14.0

Issue #127
Jason Rumney 4 лет назад
Родитель
Сommit
799df82485

+ 1 - 0
ACKNOWLEDGEMENTS.md

@@ -74,4 +74,5 @@ Further device support has been made with the assistance of users.  Please consi
  - [Gekko47](https://github.com/Gekko47) for contributing support for ElectriQ CD12v2 dehumidifiers.
  - [andreq](https://github.com/andreq) for assistance with Inkbird ITC-308 thermostats.
  - [dlosito](https://github.com/dlosito) for assistance with a second variant of Awow TH213 thermostat.
+ - [UrZdcw9](https://github.com/UrZdcw9) for assistance with Arlec ceiling fan with light.
  

+ 1 - 1
README.md

@@ -92,7 +92,7 @@ the device will not work despite being listed below.
 - Anko HEGSM40 fan
 - Lexy F501 fan
 - Deta fan controller
-- Arlec Grid Connect Smart Ceiling Fan (without light)
+- Arlec Grid Connect Smart Ceiling Fan (with and without light)
 - Stirling FS1-40DC Pedestal fan
 - Aspen ASP 200 fan
 - TMWF02 fan controller

+ 66 - 0
custom_components/tuya_local/devices/arlec_fan_light.yaml

@@ -0,0 +1,66 @@
+name: ARLEC fan with light
+primary_entity:
+  entity: fan
+  dps:
+    - id: 1
+      name: switch
+      type: boolean
+    - id: 3
+      name: speed
+      type: integer
+      range:
+        min: 0
+        max: 6
+      mapping:
+        - scale: 0.06
+    - id: 4
+      name: direction
+      type: string
+    - id: 102
+      name: preset_mode
+      type: string
+      mapping:
+        - dps_val: nature
+          value: nature
+        - dps_val: sleep
+          value: sleep
+secondary_entities:
+  - entity: light
+    dps:
+      - id: 9
+        type: boolean
+        name: switch
+      - id: 10
+        type: integer
+        name: brightness
+        range:
+          min: 0
+          max: 100
+        mapping:
+          - step: 2
+            scale: 0.392
+      - id: 11
+        type: integer
+        name: color_temp
+        range:
+          min: 0
+          max: 100
+        mapping:
+          - step: 2
+  - entity: select
+    name: timer
+    icon: "mdi:timer"
+    category: config
+    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"

+ 59 - 11
custom_components/tuya_local/generic/light.py

@@ -7,9 +7,11 @@ from homeassistant.components.light import (
     LightEntity,
     ATTR_BRIGHTNESS,
     ATTR_COLOR_MODE,
+    ATTR_COLOR_TEMP,
     ATTR_EFFECT,
     ATTR_RGBW_COLOR,
     COLOR_MODE_BRIGHTNESS,
+    COLOR_MODE_COLOR_TEMP,
     COLOR_MODE_ONOFF,
     COLOR_MODE_RGBW,
     COLOR_MODE_UNKNOWN,
@@ -18,10 +20,14 @@ from homeassistant.components.light import (
 )
 import homeassistant.util.color as color_util
 
+import logging
+
 from ..device import TuyaLocalDevice
 from ..helpers.device_config import TuyaEntityConfig
 from ..helpers.mixin import TuyaLocalEntity
 
+_LOGGER = logging.getLogger(__name__)
+
 
 class TuyaLocalLight(TuyaLocalEntity, LightEntity):
     """Representation of a Tuya WiFi-connected light."""
@@ -37,6 +43,7 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
         self._switch_dps = dps_map.pop("switch", None)
         self._brightness_dps = dps_map.pop("brightness", None)
         self._color_mode_dps = dps_map.pop("color_mode", None)
+        self._color_temp_dps = dps_map.pop("color_temp", None)
         self._rgbhsv_dps = dps_map.pop("rgbhsv", None)
         self._effect_dps = dps_map.pop("effect", None)
         self._init_end(dps_map)
@@ -50,15 +57,12 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
                 for mode in self._color_mode_dps.values(self._device)
                 if mode in VALID_COLOR_MODES
             ]
-
-        elif self._rgbhsv_dps:
-            return [COLOR_MODE_RGBW]
-        elif self._brightness_dps:
-            return [COLOR_MODE_BRIGHTNESS]
-        elif self._switch_dps:
-            return [COLOR_MODE_ONOFF]
         else:
-            return []
+            mode = self.color_mode
+            if mode:
+                return [mode]
+
+        return []
 
     @property
     def supported_features(self):
@@ -78,6 +82,8 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
 
         if self._rgbhsv_dps:
             return COLOR_MODE_RGBW
+        elif self._color_temp_dps:
+            return COLOR_MODE_COLOR_TEMP
         elif self._brightness_dps:
             return COLOR_MODE_BRIGHTNESS
         elif self._switch_dps:
@@ -85,6 +91,20 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
         else:
             return COLOR_MODE_UNKNOWN
 
+    @property
+    def color_temp(self):
+        """Return the color temperature in mireds"""
+        if self._color_temp_dps:
+            unscaled = self._color_temp_dps.get_value(self._device)
+            range = self._color_temp_dps.range(self._device)
+            if range:
+                min = range["min"]
+                max = range["max"]
+                return unscaled * 347 / (max - min) + 153 - min
+            else:
+                return unscaled
+        return None
+
     @property
     def is_on(self):
         """Return the current state."""
@@ -150,7 +170,6 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
 
         if self._color_mode_dps:
             color_mode = params.get(ATTR_COLOR_MODE)
-            effect = params.get(ATTR_EFFECT)
             if color_mode:
                 color_values = self._color_mode_dps.get_values_to_set(
                     self._device, color_mode
@@ -159,6 +178,35 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
                     **settings,
                     **color_values,
                 }
+            elif not self._effect_dps:
+                effect = params.get(ATTR_EFFECT)
+                if effect:
+                    color_values = self._color_mode_dps.get_values_to_set(
+                        self._device, effect
+                    )
+                    settings = {
+                        **settings,
+                        **color_values,
+                    }
+
+        if self._color_temp_dps:
+            color_temp = params.get(ATTR_COLOR_TEMP)
+            range = self._color_temp_dps.range(self._device)
+
+            if range and color_temp:
+                min = range["min"]
+                max = range["max"]
+                color_temp = round((color_temp - 153 + min) * (max - min) / 347)
+
+            if color_temp:
+                color_values = self._color_temp_dps.get_values_to_set(
+                    self._device,
+                    color_temp,
+                )
+                settings = {
+                    **settings,
+                    **color_values,
+                }
 
         if self._brightness_dps:
             bright = params.get(ATTR_BRIGHTNESS, 255)
@@ -213,6 +261,6 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
             raise NotImplementedError()
 
     async def async_toggle(self):
-        dps_display_on = self.is_on
+        disp_on = self.is_on
 
-        await (self.async_turn_on() if not dps_display_on else self.async_turn_off())
+        await (self.async_turn_on() if not disp_on else self.async_turn_off())

+ 1 - 1
custom_components/tuya_local/manifest.json

@@ -7,6 +7,6 @@
     "issue_tracker": "https://github.com/make-all/tuya-local/issues",
     "dependencies": [],
     "codeowners": ["@make-all"],
-    "requirements": ["pycryptodome==3.13.0","tinytuya==1.3.1"],
+    "requirements": ["pycryptodome==3.14.0","tinytuya==1.3.1"],
     "config_flow": true
 }

+ 1 - 1
requirements-dev.txt

@@ -4,5 +4,5 @@ pytest-homeassistant-custom-component==0.4.5
 pytest
 pytest-asyncio
 pytest-cov
-pycryptodome==3.13.0
+pycryptodome==3.14.0
 tinytuya==1.3.1

+ 1 - 1
requirements-first.txt

@@ -1 +1 @@
-pycryptodome==3.13.0
+pycryptodome==3.14.0

+ 1 - 1
requirements.txt

@@ -1,2 +1,2 @@
-pycryptodome~=3.13.0
+pycryptodome~=3.14.0
 tinytuya~=1.3.1

+ 11 - 0
tests/const.py

@@ -649,6 +649,17 @@ ARLEC_FAN_PAYLOAD = {
     "103": "off",
 }
 
+ARLEC_FAN_LIGHT_PAYLOAD = {
+    "1": True,
+    "3": "6",
+    "4": "forward",
+    "9": False,
+    "10": 100,
+    "11": 100,
+    "102": "normal",
+    "103": "off",
+}
+
 CARSON_CB_PAYLOAD = {
     "1": True,
     "2": 20,

+ 150 - 0
tests/devices/test_arlec_fan_light.py

@@ -0,0 +1,150 @@
+from homeassistant.components.fan import (
+    DIRECTION_FORWARD,
+    DIRECTION_REVERSE,
+    SUPPORT_DIRECTION,
+    SUPPORT_PRESET_MODE,
+    SUPPORT_SET_SPEED,
+)
+from homeassistant.components.light import (
+    ATTR_BRIGHTNESS,
+    ATTR_COLOR_TEMP,
+    COLOR_MODE_COLOR_TEMP,
+)
+from ..const import ARLEC_FAN_LIGHT_PAYLOAD
+from ..helpers import assert_device_properties_set
+from ..mixins.select import BasicSelectTests
+from ..mixins.switch import SwitchableTests
+from .base_device_tests import TuyaDeviceTestCase
+
+SWITCH_DPS = "1"
+SPEED_DPS = "3"
+DIRECTION_DPS = "4"
+LIGHT_DPS = "9"
+BRIGHTNESS_DPS = "10"
+COLORTEMP_DPS = "11"
+PRESET_DPS = "102"
+TIMER_DPS = "103"
+
+
+class TestArlecFan(SwitchableTests, BasicSelectTests, TuyaDeviceTestCase):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig("arlec_fan_light.yaml", ARLEC_FAN_LIGHT_PAYLOAD)
+        self.subject = self.entities.get("fan")
+        self.light = self.entities.get("light")
+        self.timer = self.entities.get("select_timer")
+
+        self.setUpSwitchable(SWITCH_DPS, self.subject)
+        self.setUpBasicSelect(
+            TIMER_DPS,
+            self.entities["select_timer"],
+            {
+                "off": "Off",
+                "2hour": "2 hours",
+                "4hour": "4 hours",
+                "8hour": "8 hours",
+            },
+        )
+        self.mark_secondary(["select_timer"])
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features,
+            SUPPORT_DIRECTION | SUPPORT_PRESET_MODE | SUPPORT_SET_SPEED,
+        )
+
+    def test_preset_mode(self):
+        self.dps[PRESET_DPS] = "nature"
+        self.assertEqual(self.subject.preset_mode, "nature")
+
+        self.dps[PRESET_DPS] = "sleep"
+        self.assertEqual(self.subject.preset_mode, "sleep")
+
+        self.dps[PRESET_DPS] = None
+        self.assertIs(self.subject.preset_mode, None)
+
+    def test_preset_modes(self):
+        self.assertCountEqual(self.subject.preset_modes, ["nature", "sleep"])
+
+    async def test_set_preset_mode_to_nature(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PRESET_DPS: "nature"},
+        ):
+            await self.subject.async_set_preset_mode("nature")
+
+    async def test_set_preset_mode_to_sleep(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {PRESET_DPS: "sleep"},
+        ):
+            await self.subject.async_set_preset_mode("sleep")
+
+    def test_direction(self):
+        self.dps[DIRECTION_DPS] = "forward"
+        self.assertEqual(self.subject.current_direction, DIRECTION_FORWARD)
+        self.dps[DIRECTION_DPS] = "reverse"
+        self.assertEqual(self.subject.current_direction, DIRECTION_REVERSE)
+
+    async def test_set_direction_forward(self):
+        async with assert_device_properties_set(
+            self.subject._device, {DIRECTION_DPS: "forward"}
+        ):
+            await self.subject.async_set_direction(DIRECTION_FORWARD)
+
+    async def test_set_direction_reverse(self):
+        async with assert_device_properties_set(
+            self.subject._device, {DIRECTION_DPS: "reverse"}
+        ):
+            await self.subject.async_set_direction(DIRECTION_REVERSE)
+
+    def test_speed(self):
+        self.dps[SPEED_DPS] = "3"
+        self.assertEqual(self.subject.percentage, 50)
+
+    def test_speed_step(self):
+        self.assertAlmostEqual(self.subject.percentage_step, 16.67, 2)
+        self.assertEqual(self.subject.speed_count, 6)
+
+    async def test_set_speed(self):
+        async with assert_device_properties_set(self.subject._device, {SPEED_DPS: 2}):
+            await self.subject.async_set_percentage(33)
+
+    async def test_set_speed_in_normal_mode_snaps(self):
+        self.dps[PRESET_DPS] = "normal"
+        async with assert_device_properties_set(self.subject._device, {SPEED_DPS: 5}):
+            await self.subject.async_set_percentage(80)
+
+    def test_light_is_on(self):
+        self.dps[LIGHT_DPS] = False
+        self.assertFalse(self.light.is_on)
+        self.dps[LIGHT_DPS] = True
+        self.assertTrue(self.light.is_on)
+
+    def test_light_supported_color_modes(self):
+        self.assertCountEqual(
+            self.light.supported_color_modes,
+            [COLOR_MODE_COLOR_TEMP],
+        )
+
+    def test_light_color_mode(self):
+        self.assertEqual(self.light.color_mode, COLOR_MODE_COLOR_TEMP)
+
+    def test_light_brightness(self):
+        self.dps[BRIGHTNESS_DPS] = 50
+        self.assertAlmostEqual(self.light.brightness, 128, 0)
+
+    def test_light_color_temp(self):
+        self.dps[COLORTEMP_DPS] = 70
+        self.assertEqual(self.light.color_temp, 395.9)
+
+    async def test_light_async_turn_on(self):
+        async with assert_device_properties_set(
+            self.light._device,
+            {LIGHT_DPS: True, BRIGHTNESS_DPS: 44, COLORTEMP_DPS: 70},
+        ):
+            await self.light.async_turn_on(
+                brightness=112,
+                color_temp=396,
+            )