Prechádzať zdrojové kódy

Implement support for formatted hex/base64 fields and inverted fields.

- formatted hex/base64 fields to handle differences between RGBW formats used by different bulbs. Issue #130, #114.
- inverted fields to handle cover devices which use closed percentage as opposed to HA's opened percentage to report position. Issue #148
- Adjust Digoo DGSP01 config to include format config, and use for regression testing of first change. Issue #89
Jason Rumney 3 rokov pred
rodič
commit
583ae203e8

+ 20 - 2
custom_components/tuya_local/devices/README.md

@@ -220,6 +220,15 @@ For sensors, this sets the state class of the sensor (measurement, total
 or total_increasing)
 or total_increasing)
 
 
 
 
+### `format`
+
+*Optional. default=None*
+
+For base64 and hex types, this specifies how to decode the binary data (after hex or base64 decoding).
+This is a container field, the contents of which should be a list consisting of `name`, `bytes` and `range` fields.  `range` is as described above.  `bytes` is the number of bytes for the field, which can be `1`, `2`, or `4`.  `name` is a name for the field, which will have special handling depending on
+the device type.
+
+
 ## Mapping Rules
 ## Mapping Rules
 
 
 Mapping rules can change the behavior of attributes beyond simple
 Mapping rules can change the behavior of attributes beyond simple
@@ -264,6 +273,15 @@ Home Assistant.  The scale can also be the other way, for a fan with speeds
 1, 2 and 3 as DPS values, this can be converted to a percentage with a scale
 1, 2 and 3 as DPS values, this can be converted to a percentage with a scale
 of 0.03.
 of 0.03.
 
 
+###`invert`
+
+*Optional, default=False*
+
+This can be used in an `integer` dps mapping to invert the range.  For example,
+some cover devices have an opposite idea of which end of the percentage scale open
+and closed are from what Home Assistant assumes.  To use this mapping option, a range
+must also be specified for the dps.
+
 ### `step`
 ### `step`
 
 
 *Optional, default=1*
 *Optional, default=1*
@@ -436,10 +454,10 @@ Humidifer can also cover dehumidifiers (use class to specify which).
 - **brightness** (optional, number 0-255): a dps to control the dimmer if available.
 - **brightness** (optional, number 0-255): a dps to control the dimmer if available.
 - **color_temp** (optional, number): a dps to control the color temperature if available.
 - **color_temp** (optional, number): a dps to control the color temperature if available.
     will be mapped so the minimum corresponds to 153 mireds (6500K), and max to 500 (2000K).
     will be mapped so the minimum corresponds to 153 mireds (6500K), and max to 500 (2000K).
-- **rgbhsv** (optional, hex): a dps to control the color of the light, using 14 digit hex encoding of RGB and HSV values. 
+- **rgbhsv** (optional, hex): a dps to control the color of the light, using encoded RGB and HSV values.  The `format` field names recognized for decoding this field are `r`, `g`, `b`, `h`, `s`, `v`.
 - **color_mode** (optional, mapping of strings): a dps to control which mode to use if the light supports multiple modes.
 - **color_mode** (optional, mapping of strings): a dps to control which mode to use if the light supports multiple modes.
     Special values: `white, color_temp, rgbw, hs, xy, rgb, rgbww`, others will be treated as effects,
     Special values: `white, color_temp, rgbw, hs, xy, rgb, rgbww`, others will be treated as effects,
-	Note: only white, color_temp and rgbw are currently supported.
+	Note: only white, color_temp and rgbw are currently supported, others listed above are reserved and may be implemented in future when the need arises.
 - **effect** (optional, mapping of strings): a dps to control effects / presets supported by the light.
 - **effect** (optional, mapping of strings): a dps to control effects / presets supported by the light.
    If the light mixes in color modes in the same dps, **color_mode** should be used instead.
    If the light mixes in color modes in the same dps, **color_mode** should be used instead.
 
 

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

@@ -42,6 +42,28 @@ secondary_entities:
       - id: 31
       - id: 31
         name: rgbhsv
         name: rgbhsv
         type: hex
         type: hex
+        format:
+          - name: r
+            bytes: 1
+          - name: g
+            bytes: 1
+          - name: b
+            bytes: 1
+          - name: h
+            bytes: 2
+            range:
+              min: 0
+              max: 360
+          - name: s
+            bytes: 1
+            range:
+              min: 0
+              max: 100
+          - name: v
+            bytes: 1
+            range:
+              min: 0
+              max: 100
       - id: 32
       - id: 32
         name: unknown_32
         name: unknown_32
         type: hex
         type: hex

+ 64 - 18
custom_components/tuya_local/generic/light.py

@@ -21,6 +21,7 @@ from homeassistant.components.light import (
 import homeassistant.util.color as color_util
 import homeassistant.util.color as color_util
 
 
 import logging
 import logging
+from struct import pack, unpack
 
 
 from ..device import TuyaLocalDevice
 from ..device import TuyaLocalDevice
 from ..helpers.device_config import TuyaEntityConfig
 from ..helpers.device_config import TuyaEntityConfig
@@ -100,7 +101,7 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
             if range:
             if range:
                 min = range["min"]
                 min = range["min"]
                 max = range["max"]
                 max = range["max"]
-                return unscaled * 347 / (max - min) + 153 - min
+                return round(unscaled * 347 / (max - min) + 153 - min)
             else:
             else:
                 return unscaled
                 return unscaled
         return None
         return None
@@ -127,15 +128,44 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
     @property
     @property
     def rgbw_color(self):
     def rgbw_color(self):
         """Get the current RGBW color of the light"""
         """Get the current RGBW color of the light"""
-        if self._rgbhsv_dps and self._rgbhsv_dps.rawtype == "hex":
+        if self._rgbhsv_dps:
             # color data in hex format RRGGBBHHHHSSVV (14 digit hex)
             # color data in hex format RRGGBBHHHHSSVV (14 digit hex)
+            # can also be base64 encoded.
             # Either RGB or HSV can be used.
             # 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)
+            color = self._rgbhsv_dps.decoded_value(self._device)
+
+            format = self._rgbhsv_dps.format
+            if format:
+                vals = unpack(format.get("format"), color)
+                rgbhsv = {}
+                idx = 0
+                for v in vals:
+                    # Range in HA is 0-100 for s, 0-255 for rgb and v, 0-360
+                    # for h
+                    n = format["names"][idx]
+                    r = format["ranges"][idx]
+                    if r["min"] != 0:
+                        raise AttributeError(
+                            f"Unhandled minimum range for {n} in RGBW value"
+                        )
+                    max = r["max"]
+                    scale = 1
+                    if n == "h":
+                        scale = 360 / max
+                    elif n == "s":
+                        scale = 100 / max
+                    else:
+                        scale = 255 / max
+
+                    rgbhsv[n] = round(scale * v)
+                    idx += 1
+
+                h = rgbhsv["h"]
+                s = rgbhsv["s"]
+                # convert RGB from H and S to seperate out the V component
+                r, g, b = color_util.color_hs_to_RGB(h, s)
+                w = rgbhsv["v"]
+                return (r, g, b, w)
 
 
     @property
     @property
     def effect_list(self):
     def effect_list(self):
@@ -221,20 +251,36 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
 
 
         if self._rgbhsv_dps:
         if self._rgbhsv_dps:
             rgbw = params.get(ATTR_RGBW_COLOR, None)
             rgbw = params.get(ATTR_RGBW_COLOR, None)
-            if rgbw:
+            format = self._rgbhsv_dps.format
+            if rgbw and format:
                 rgb = (rgbw[0], rgbw[1], rgbw[2])
                 rgb = (rgbw[0], rgbw[1], rgbw[2])
                 hs = color_util.color_RGB_to_hs(rgbw[0], rgbw[1], rgbw[2])
                 hs = color_util.color_RGB_to_hs(rgbw[0], rgbw[1], rgbw[2])
-                color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format(
-                    round(rgbw[0]),
-                    round(rgbw[1]),
-                    round(rgbw[2]),
-                    round(hs[0]),
-                    round(hs[1]),
-                    round(rgbw[3] * 100 / 255),
-                )
+                rgbhsv = {
+                    "r": rgb[0],
+                    "g": rgb[1],
+                    "b": rgb[3],
+                    "h": hs[0],
+                    "s": hs[1],
+                    "v": rgbw[3],
+                }
+                ordered = []
+                idx = 0
+                for n in format["names"]:
+                    r = format["ranges"][idx]
+                    scale = 1
+                    if n == "s":
+                        scale = r["max"] / 100
+                    elif n == "h":
+                        scale = r["max"] / 360
+                    else:
+                        scale = r["max"] / 255
+                    ordered[idx] = round(rgbhsv[n] * scale)
+                    idx += 1
+
+                binary = pack(format["format"], (*ordered,))
                 color_dps = self._rgbhsv_dps.get_values_to_set(
                 color_dps = self._rgbhsv_dps.get_values_to_set(
                     self._device,
                     self._device,
-                    color,
+                    self._rgbhsv_dps.encode_value(binary),
                 )
                 )
                 settings = {**settings, **color_dps}
                 settings = {**settings, **color_dps}
 
 

+ 85 - 1
custom_components/tuya_local/helpers/device_config.py

@@ -1,11 +1,12 @@
 """
 """
 Config parser for Tuya Local devices.
 Config parser for Tuya Local devices.
 """
 """
+from base64 import b64decode, b64encode
+
 from fnmatch import fnmatch
 from fnmatch import fnmatch
 import logging
 import logging
 from os import walk
 from os import walk
 from os.path import join, dirname, splitext, exists
 from os.path import join, dirname, splitext, exists
-from pydoc import locate
 
 
 from homeassistant.util import slugify
 from homeassistant.util import slugify
 from homeassistant.util.yaml import load_yaml
 from homeassistant.util.yaml import load_yaml
@@ -40,6 +41,29 @@ def _scale_range(r, s):
     return {"min": r["min"] / s, "max": r["max"] / s}
     return {"min": r["min"] / s, "max": r["max"] / s}
 
 
 
 
+_unsignedFmts = {
+    1: "B",
+    2: "H",
+    4: "I",
+}
+
+_signedFmts = {
+    1: "b",
+    2: "h",
+    4: "i",
+}
+
+
+def _bytesToFmt(bytes, signed=False):
+    "Convert a byte count to an unpack format."
+    knownFmts = _signedFmts if signed else _unsignedFmts
+
+    if bytes in knownFmts:
+        return _unsignedFmts[bytes]
+    else:
+        return f"{bytes}s"
+
+
 class TuyaDeviceConfig:
 class TuyaDeviceConfig:
     """Representation of a device config for Tuya Local devices."""
     """Representation of a device config for Tuya Local devices."""
 
 
@@ -264,10 +288,53 @@ class TuyaDpsConfig:
     def name(self):
     def name(self):
         return self._config["name"]
         return self._config["name"]
 
 
+    @property
+    def format(self):
+        fmt = self._config.get("format")
+        if fmt:
+            unpack_fmt = ">"
+            ranges = []
+            names = []
+            for f in fmt:
+                name = f.get("name")
+                bytes = f.get("bytes", 1)
+                range = f.get("range")
+                if range:
+                    min = range.get("min")
+                    max = range.get("max")
+                else:
+                    min = 0
+                    max = 256 ** bytes - 1
+
+                unpack_fmt = unpack_fmt + _bytesToFmt(bytes, min < 0)
+                ranges.append({"min": min, "max": max})
+                names.append(name)
+            _LOGGER.debug(f"format of {unpack_fmt} found")
+            return {"format": unpack_fmt, "ranges": ranges, "names": names}
+
+        return None
+
     def get_value(self, device):
     def get_value(self, device):
         """Return the value of the dps from the given device."""
         """Return the value of the dps from the given device."""
         return self._map_from_dps(device.get_property(self.id), device)
         return self._map_from_dps(device.get_property(self.id), device)
 
 
+    def decoded_value(self, device):
+        v = self.get_value(device)
+        if self.rawtype == "hex":
+            return bytes.fromhex(v)
+        elif self.rawtype == "base64":
+            return b64decode(v)
+        else:
+            return v
+
+    def encode_value(self, v):
+        if self.rawtype == "hex":
+            return v.hex()
+        elif self.rawtype == "base64":
+            return b64encode(v).decode("utf-8")
+        else:
+            return v
+
     def _match(self, matchdata, value):
     def _match(self, matchdata, value):
         """Return true val1 matches val2"""
         """Return true val1 matches val2"""
         if self.rawtype == "bitfield" and matchdata:
         if self.rawtype == "bitfield" and matchdata:
@@ -419,9 +486,12 @@ class TuyaDpsConfig:
             self.stringify = False
             self.stringify = False
 
 
         result = value
         result = value
+
         mapping = self._find_map_for_dps(value)
         mapping = self._find_map_for_dps(value)
         if mapping:
         if mapping:
             scale = mapping.get("scale", 1)
             scale = mapping.get("scale", 1)
+            invert = mapping.get("invert", False)
+
             if not isinstance(scale, (int, float)):
             if not isinstance(scale, (int, float)):
                 scale = 1
                 scale = 1
             redirect = mapping.get("value_redirect")
             redirect = mapping.get("value_redirect")
@@ -454,6 +524,12 @@ class TuyaDpsConfig:
                 result = result / scale
                 result = result / scale
                 replaced = True
                 replaced = True
 
 
+            if invert:
+                range = self._config.get("range")
+                if range and "min" in range and "max" in range:
+                    result = -1 * result + range["min"] + range["max"]
+                    replaced = True
+
             if replaced:
             if replaced:
                 _LOGGER.debug(
                 _LOGGER.debug(
                     "%s: Mapped dps %s value from %s to %s",
                     "%s: Mapped dps %s value from %s to %s",
@@ -512,6 +588,8 @@ class TuyaDpsConfig:
             replaced = False
             replaced = False
             scale = mapping.get("scale", 1)
             scale = mapping.get("scale", 1)
             redirect = mapping.get("value_redirect")
             redirect = mapping.get("value_redirect")
+            invert = mapping.get("invert", False)
+
             if not isinstance(scale, (int, float)):
             if not isinstance(scale, (int, float)):
                 scale = 1
                 scale = 1
             step = mapping.get("step")
             step = mapping.get("step")
@@ -551,6 +629,12 @@ class TuyaDpsConfig:
                 r_dps = self._entity.find_dps(redirect)
                 r_dps = self._entity.find_dps(redirect)
                 return r_dps.get_values_to_set(device, value)
                 return r_dps.get_values_to_set(device, value)
 
 
+            if invert:
+                range = self._config.get("range")
+                if range and "min" in range and "max" in range:
+                    result = -1 * result + range["min"] + range["max"]
+                    replaced = True
+
             if scale != 1 and isinstance(result, (int, float)):
             if scale != 1 and isinstance(result, (int, float)):
                 _LOGGER.debug(f"Scaling {result} by {scale}")
                 _LOGGER.debug(f"Scaling {result} by {scale}")
                 result = result * scale
                 result = result * scale