Преглед изворни кода

Add support for Digoo DG-SP01 smart plug with nightlight.

Issue #89

New platform features: RGBW support for light entities.

Update tinytuya and pycryptodome dependencies to latest versions.
Jason Rumney пре 4 година
родитељ
комит
dcee0f54eb

+ 59 - 0
custom_components/tuya_local/devices/digoo_dgsp01_dual_nightlight_switch.yaml

@@ -0,0 +1,59 @@
+name: Digoo DG-SP01
+primary_entity:
+  entity: switch
+  class: outlet
+  dps:
+    - id: 1
+      type: boolean
+      name: switch
+secondary_entities:
+  - entity: light
+    name: Night Light
+    dps:
+      - id: 27
+        name: switch
+        type: boolean
+      - id: 28
+        name: color_mode
+        type: string
+        mapping:
+          - dps_val: white
+            value: white
+          - dps_val: colour
+            value: rgbw
+          - dps_val: scene
+            value: colorloop
+          - dps_val: music
+            value: random
+          - dps_val: scene_1
+            value: Scene 1
+          - dps_val: scene_2
+            value: Scene 2
+          - dps_val: scene_3
+            value: Scene 3
+          - dps_val: scene_4
+            value: Scene 4
+      - id: 29
+        name: brightness
+        type: integer
+        range:
+          min: 25
+          max: 255
+      - id: 31
+        name: rgbhsv
+        type: hex
+      - id: 32
+        name: unknown_32
+        type: hex
+      - id: 33
+        name: unknown_33
+        type: hex
+      - id: 34
+        name: unknown_34
+        type: hex
+      - id: 35
+        name: unknown_35
+        type: hex
+      - id: 36
+        name: unknown_36
+        type: hex

+ 218 - 135
custom_components/tuya_local/generic/light.py

@@ -1,135 +1,218 @@
-"""
-Platform to control Tuya lights.
-Initially based on the secondary panel lighting control on some climate
-devices, so only providing simple on/off control.
-"""
-from homeassistant.components.light import (
-    LightEntity,
-    ATTR_BRIGHTNESS,
-    ATTR_EFFECT,
-    COLOR_MODE_BRIGHTNESS,
-    COLOR_MODE_ONOFF,
-    COLOR_MODE_UNKNOWN,
-    SUPPORT_EFFECT,
-)
-
-from ..device import TuyaLocalDevice
-from ..helpers.device_config import TuyaEntityConfig
-from ..helpers.mixin import TuyaLocalEntity
-
-
-class TuyaLocalLight(TuyaLocalEntity, LightEntity):
-    """Representation of a Tuya WiFi-connected light."""
-
-    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
-        """
-        Initialize the light.
-        Args:
-            device (TuyaLocalDevice): The device API instance.
-            config (TuyaEntityConfig): The configuration for this entity.
-        """
-        dps_map = self._init_begin(device, config)
-        self._switch_dps = dps_map.pop("switch", None)
-        self._brightness_dps = dps_map.pop("brightness", None)
-        self._effect_dps = dps_map.pop("effect", None)
-        self._init_end(dps_map)
-
-    @property
-    def supported_color_modes(self):
-        """Return the supported color modes for this light."""
-        if self._brightness_dps:
-            return [COLOR_MODE_BRIGHTNESS]
-        elif self._switch_dps:
-            return [COLOR_MODE_ONOFF]
-        else:
-            return []
-
-    @property
-    def supported_features(self):
-        """Return the supported features for this light."""
-        if self._effect_dps:
-            return SUPPORT_EFFECT
-        else:
-            return 0
-
-    @property
-    def color_mode(self):
-        """Return the color mode of the light"""
-        if self._brightness_dps:
-            return COLOR_MODE_BRIGHTNESS
-        elif self._switch_dps:
-            return COLOR_MODE_ONOFF
-        else:
-            return COLOR_MODE_UNKNOWN
-
-    @property
-    def is_on(self):
-        """Return the current state."""
-        if self._switch_dps:
-            return self._switch_dps.get_value(self._device)
-        elif self._brightness_dps:
-            b = self.brightness
-            return isinstance(b, int) and b > 0
-        else:
-            # There shouldn't be lights without control, but if there are, assume always on if they are responding
-            return self.available
-
-    @property
-    def brightness(self):
-        """Get the current brightness of the light"""
-        if self._brightness_dps is None:
-            return None
-        return self._brightness_dps.get_value(self._device)
-
-    @property
-    def effect_list(self):
-        """Return the list of valid effects for the light"""
-        if self._effect_dps is None:
-            return None
-        return self._effect_dps.values(self._device)
-
-    @property
-    def effect(self):
-        """Return the current effect setting of this light"""
-        if self._effect_dps is None:
-            return None
-        return self._effect_dps.get_value(self._device)
-
-    async def async_turn_on(self, **params):
-        settings = {}
-        if self._switch_dps:
-            settings = {
-                **settings,
-                **self._switch_dps.get_values_to_set(self._device, True),
-            }
-
-        if self._brightness_dps:
-            bright = params.get(ATTR_BRIGHTNESS, 255)
-            bright_values = self._brightness_dps.get_values_to_set(self._device, bright)
-            settings = {
-                **settings,
-                **bright_values,
-            }
-        if self._effect_dps:
-            effect = params.get(ATTR_EFFECT, None)
-            if effect:
-                effect_values = self._effect_dps.get_values_to_set(self._device, effect)
-                settings = {
-                    **settings,
-                    **effect_values,
-                }
-
-        await self._device.async_set_properties(settings)
-
-    async def async_turn_off(self):
-        if self._switch_dps:
-            await self._switch_dps.async_set_value(self._device, False)
-        elif self._brightness_dps:
-            await self._brightness_dps.async_set_value(self._device, 0)
-        else:
-            raise NotImplementedError()
-
-    async def async_toggle(self):
-        dps_display_on = self.is_on
-
-        await (self.async_turn_on() if not dps_display_on else self.async_turn_off())
+"""
+Platform to control Tuya lights.
+Initially based on the secondary panel lighting control on some climate
+devices, so only providing simple on/off control.
+"""
+from homeassistant.components.light import (
+    LightEntity,
+    ATTR_BRIGHTNESS,
+    ATTR_COLOR_MODE,
+    ATTR_EFFECT,
+    ATTR_RGBW_COLOR,
+    COLOR_MODE_BRIGHTNESS,
+    COLOR_MODE_ONOFF,
+    COLOR_MODE_RGBW,
+    COLOR_MODE_UNKNOWN,
+    SUPPORT_EFFECT,
+    VALID_COLOR_MODES,
+)
+import homeassistant.util.color as color_util
+
+from ..device import TuyaLocalDevice
+from ..helpers.device_config import TuyaEntityConfig
+from ..helpers.mixin import TuyaLocalEntity
+
+
+class TuyaLocalLight(TuyaLocalEntity, LightEntity):
+    """Representation of a Tuya WiFi-connected light."""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialize the light.
+        Args:
+            device (TuyaLocalDevice): The device API instance.
+            config (TuyaEntityConfig): The configuration for this entity.
+        """
+        dps_map = self._init_begin(device, config)
+        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._rgbhsv_dps = dps_map.pop("rgbhsv", None)
+        self._effect_dps = dps_map.pop("effect", None)
+        self._init_end(dps_map)
+
+    @property
+    def supported_color_modes(self):
+        """Return the supported color modes for this light."""
+        if self._color_mode_dps:
+            return [
+                mode
+                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 []
+
+    @property
+    def supported_features(self):
+        """Return the supported features for this light."""
+        if self.effect_list:
+            return SUPPORT_EFFECT
+        else:
+            return 0
+
+    @property
+    def color_mode(self):
+        """Return the color mode of the light"""
+        if self._color_mode_dps:
+            mode = self._color_mode_dps.get_value(self._device)
+            if mode in VALID_COLOR_MODES:
+                return mode
+
+        if 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 COLOR_MODE_UNKNOWN
+
+    @property
+    def is_on(self):
+        """Return the current state."""
+        if self._switch_dps:
+            return self._switch_dps.get_value(self._device)
+        elif self._brightness_dps:
+            b = self.brightness
+            return isinstance(b, int) and b > 0
+        else:
+            # There shouldn't be lights without control, but if there are,
+            # assume always on if they are responding
+            return self.available
+
+    @property
+    def brightness(self):
+        """Get the current brightness of the light"""
+        if self._brightness_dps:
+            return self._brightness_dps.get_value(self._device)
+
+    @property
+    def rgbw_color(self):
+        """Get the current RGBW color of the light"""
+        if self._rgbhsv_dps and self._rgbhsv_dps.rawtype == "hex":
+            # color data in hex format RRGGBBHHHHSSVV (14 digit hex)
+            # Either RGB or HSV can be used.
+            color = self._rgbhsv_dps.get_value(self._device)
+            h = int(color[6:10], 16)
+            s = int(color[10:12], 16)
+            r, g, b = color_util.color_hs_to_RGB(h, s)
+            w = int(color[12:14], 16) * 255 / 100
+            return (r, g, b, w)
+
+    @property
+    def effect_list(self):
+        """Return the list of valid effects for the light"""
+        if self._effect_dps:
+            return self._effect_dps.values(self._device)
+        elif self._color_mode_dps:
+            return [
+                effect
+                for effect in self._color_mode_dps.values(self._device)
+                if effect not in VALID_COLOR_MODES
+            ]
+
+    @property
+    def effect(self):
+        """Return the current effect setting of this light"""
+        if self._effect_dps:
+            return self._effect_dps.get_value(self._device)
+        elif self._color_mode_dps:
+            mode = self._color_mode_dps.get_value(self._device)
+            if mode in VALID_COLOR_MODES:
+                return None
+            return mode
+
+    async def async_turn_on(self, **params):
+        settings = {}
+        if self._switch_dps:
+            settings = {
+                **settings,
+                **self._switch_dps.get_values_to_set(self._device, True),
+            }
+
+        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
+                )
+                settings = {
+                    **settings,
+                    **color_values,
+                }
+
+        if self._brightness_dps:
+            bright = params.get(ATTR_BRIGHTNESS, 255)
+            bright_values = self._brightness_dps.get_values_to_set(
+                self._device,
+                bright,
+            )
+            settings = {
+                **settings,
+                **bright_values,
+            }
+
+        if self._rgbhsv_dps:
+            rgbw = params.get(ATTR_RGBW_COLOR, None)
+            if rgbw:
+                rgb = (rgbw[0], rgbw[1], rgbw[2])
+                hs = color_util.color_RGB_to_hs(rgb)
+                color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format(
+                    round(rgb[0]),
+                    round(rgb[1]),
+                    round(rgb[2]),
+                    round(hs[0]),
+                    round(hs[1]),
+                    round(rgbw[3] * 100 / 255),
+                )
+                color_dps = self._rgbhsv_dps.get_values_to_set(
+                    self._device,
+                    color,
+                )
+                settings = {**settings, **color_dps}
+
+        if self._effect_dps:
+            effect = params.get(ATTR_EFFECT, None)
+            if effect:
+                effect_values = self._effect_dps.get_values_to_set(
+                    self._device,
+                    effect,
+                )
+                settings = {
+                    **settings,
+                    **effect_values,
+                }
+
+        await self._device.async_set_properties(settings)
+
+    async def async_turn_off(self):
+        if self._switch_dps:
+            await self._switch_dps.async_set_value(self._device, False)
+        elif self._brightness_dps:
+            await self._brightness_dps.async_set_value(self._device, 0)
+        else:
+            raise NotImplementedError()
+
+    async def async_toggle(self):
+        dps_display_on = self.is_on
+
+        await (self.async_turn_on() if not dps_display_on else self.async_turn_off())

+ 2 - 2
custom_components/tuya_local/manifest.json

@@ -2,11 +2,11 @@
     "domain": "tuya_local",
     "iot_class": "local_polling",
     "name": "Tuya Local",
-    "version": "0.14.4",
+    "version": "0.15.0",
     "documentation": "https://github.com/make-all/tuya-local",
     "issue_tracker": "https://github.com/make-all/tuya-local/issues",
     "dependencies": [],
     "codeowners": ["@make-all"],
-    "requirements": ["pycryptodome==3.11.0","tinytuya==1.2.11"],
+    "requirements": ["pycryptodome==3.13.0","tinytuya==1.3.1"],
     "config_flow": true
 }

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

@@ -32,6 +32,7 @@
                     "select": "Include a select entity",
                     "sensor": "Include a sensor entity",
                     "switch": "Include a switch entity",
+		    "vacuum": "Include a vacuum entity",
                     "binary_sensor_continuous_heat": "Include continuous heat alarm as a binary_sensor entity",
                     "binary_sensor_defrost": "Include defrost as a binary_sensor entity",
                     "binary_sensor_error": "Include error as a binary_sensor entity.",
@@ -63,6 +64,7 @@
                     "light_backlight": "Include backlight as a light entity",
                     "light_display": "Include display as a light entity",
                     "light_flame": "Include flame as a light entity",
+		    "light_night_light": "Include night light as a light entity",
                     "light_uv_sterilization": "Include UV sterilization as a light entity",
                     "lock_child_lock": "Include child lock as a lock entity",
                     "number_calibration_offset": "Include calibration offset as a number entity",
@@ -175,6 +177,7 @@
                     "select": "Include a select entity",
                     "sensor": "Include a sensor entity",
                     "switch": "Include a switch entity",
+		    "vacuum": "Include a vacuum entity",
                     "binary_sensor_continuous_heat": "Include continuous heat alarm as a binary_sensor entity",
                     "binary_sensor_defrost": "Include defrost as a binary_sensor entity",
                     "binary_sensor_error": "Include error as a binary_sensor entity.",
@@ -206,6 +209,7 @@
                     "light_backlight": "Include backlight as a light entity",
                     "light_display": "Include display as a light entity",
                     "light_flame": "Include flame as a light entity",
+		    "light_night_light": "Include night light as a light entity",
                     "light_uv_sterilization": "Include UV sterilization as a light entity",
                     "lock_child_lock": "Include child lock as a lock entity",
                     "number_calibration_offset": "Include calibration offset as a number entity",

+ 8 - 8
requirements-dev.txt

@@ -1,8 +1,8 @@
-black
-isort
-pytest-homeassistant-custom-component==0.4.5
-pytest
-pytest-asyncio
-pytest-cov
-pycryptodome==3.11.0
-tinytuya==1.2.11
+black
+isort
+pytest-homeassistant-custom-component==0.4.5
+pytest
+pytest-asyncio
+pytest-cov
+pycryptodome==3.13.0
+tinytuya==1.3.1

+ 1 - 1
requirements-first.txt

@@ -1 +1 @@
-pycryptodome==3.11.0
+pycryptodome==3.13.0

+ 2 - 2
requirements.txt

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

+ 13 - 0
tests/const.py

@@ -815,6 +815,19 @@ DIGOO_DGSP202_SOCKET_PAYLOAD = {
     "20": 240,
 }
 
+DIGOO_DGSP01_SOCKET_PAYLOAD = {
+    "1": True,
+    "27": True,
+    "28": "colour",
+    "29": 76,
+    "31": "1c0d00001b640b",
+    "32": "3855b40168ffff",
+    "33": "ffff500100ff00",
+    "34": "ffff8003ff000000ff000000ff000000000000000000",
+    "35": "ffff5001ff0000",
+    "36": "ffff0505ff000000ff00ffff00ff00ff0000ff000000",
+}
+
 WOOX_R4028_SOCKET_PAYLOAD = {
     "1": True,
     "2": True,

+ 132 - 0
tests/devices/test_digoo_dgsp01_dual_nightlight_switch.py

@@ -0,0 +1,132 @@
+"""Tests for the switch entity."""
+from homeassistant.components.switch import DEVICE_CLASS_OUTLET
+from homeassistant.components.light import (
+    COLOR_MODE_RGBW,
+    COLOR_MODE_WHITE,
+    EFFECT_COLORLOOP,
+    EFFECT_RANDOM,
+    SUPPORT_EFFECT,
+)
+from ..const import DIGOO_DGSP01_SOCKET_PAYLOAD
+
+from ..mixins.switch import BasicSwitchTests
+from .base_device_tests import TuyaDeviceTestCase
+
+SWITCH_DPS = "1"
+LIGHTSW_DPS = "27"
+COLORMODE_DPS = "28"
+BRIGHTNESS_DPS = "29"
+RGBW_DPS = "31"
+UNKNOWN32_DPS = "32"
+UNKNOWN33_DPS = "33"
+UNKNOWN34_DPS = "34"
+UNKNOWN35_DPS = "35"
+UNKNOWN36_DPS = "36"
+
+
+class TestDigooNightlightSwitch(BasicSwitchTests, TuyaDeviceTestCase):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig(
+            "digoo_dgsp01_dual_nightlight_switch.yaml",
+            DIGOO_DGSP01_SOCKET_PAYLOAD,
+        )
+        self.subject = self.entities.get("switch")
+        self.light = self.entities.get("light_night_light")
+
+        self.setUpBasicSwitch(
+            SWITCH_DPS, self.subject, device_class=DEVICE_CLASS_OUTLET
+        )
+
+    def test_light_is_on(self):
+        self.dps[LIGHTSW_DPS] = True
+        self.assertTrue(self.light.is_on)
+        self.dps[LIGHTSW_DPS] = False
+        self.assertFalse(self.light.is_on)
+
+    def test_light_brightness(self):
+        self.dps[BRIGHTNESS_DPS] = 45
+        self.assertEqual(self.light.brightness, 45)
+
+    def test_light_color_mode(self):
+        self.dps[COLORMODE_DPS] = "colour"
+        self.assertEqual(self.light.color_mode, COLOR_MODE_RGBW)
+        self.dps[COLORMODE_DPS] = "white"
+        self.assertEqual(self.light.color_mode, COLOR_MODE_WHITE)
+        self.dps[COLORMODE_DPS] = "scene"
+        self.assertEqual(self.light.color_mode, COLOR_MODE_RGBW)
+        self.dps[COLORMODE_DPS] = "music"
+        self.assertEqual(self.light.color_mode, COLOR_MODE_RGBW)
+        self.dps[COLORMODE_DPS] = "scene_1"
+        self.assertEqual(self.light.color_mode, COLOR_MODE_RGBW)
+        self.dps[COLORMODE_DPS] = "scene_2"
+        self.assertEqual(self.light.color_mode, COLOR_MODE_RGBW)
+        self.dps[COLORMODE_DPS] = "scene_3"
+        self.assertEqual(self.light.color_mode, COLOR_MODE_RGBW)
+        self.dps[COLORMODE_DPS] = "scene_4"
+        self.assertEqual(self.light.color_mode, COLOR_MODE_RGBW)
+
+    def test_light_rgbw_color(self):
+        self.dps[RGBW_DPS] = "ffff00003c6464"
+        self.assertSequenceEqual(
+            self.light.rgbw_color,
+            (255, 255, 0, 255),
+        )
+
+    def test_light_effect_list(self):
+        self.assertCountEqual(
+            self.light.effect_list,
+            [
+                EFFECT_COLORLOOP,
+                EFFECT_RANDOM,
+                "Scene 1",
+                "Scene 2",
+                "Scene 3",
+                "Scene 4",
+            ],
+        )
+
+    def test_light_effect(self):
+        self.dps[COLORMODE_DPS] = "scene"
+        self.assertEqual(self.light.effect, EFFECT_COLORLOOP)
+        self.dps[COLORMODE_DPS] = "music"
+        self.assertEqual(self.light.effect, EFFECT_RANDOM)
+        self.dps[COLORMODE_DPS] = "scene_1"
+        self.assertEqual(self.light.effect, "Scene 1")
+        self.dps[COLORMODE_DPS] = "scene_2"
+        self.assertEqual(self.light.effect, "Scene 2")
+        self.dps[COLORMODE_DPS] = "scene_3"
+        self.assertEqual(self.light.effect, "Scene 3")
+        self.dps[COLORMODE_DPS] = "scene_4"
+        self.assertEqual(self.light.effect, "Scene 4")
+        self.dps[COLORMODE_DPS] = "white"
+        self.assertIsNone(self.light.effect)
+        self.dps[COLORMODE_DPS] = "colour"
+        self.assertIsNone(self.light.effect)
+
+    def test_light_supported_color_modes(self):
+        self.assertCountEqual(
+            self.light.supported_color_modes,
+            {COLOR_MODE_RGBW, COLOR_MODE_WHITE},
+        )
+
+    def test_light_supported_features(self):
+        self.assertEqual(self.light.supported_features, SUPPORT_EFFECT)
+
+    def test_extra_state_attributes_set(self):
+        self.dps[UNKNOWN32_DPS] = "32"
+        self.dps[UNKNOWN33_DPS] = "33"
+        self.dps[UNKNOWN34_DPS] = "34"
+        self.dps[UNKNOWN35_DPS] = "35"
+        self.dps[UNKNOWN36_DPS] = "36"
+        self.assertDictEqual(
+            self.light.extra_state_attributes,
+            {
+                "unknown_32": "32",
+                "unknown_33": "33",
+                "unknown_34": "34",
+                "unknown_35": "35",
+                "unknown_36": "36",
+            },
+        )