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

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 лет назад
Родитель
Сommit
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)
 
 
+### `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 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
 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`
 
 *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.
 - **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).
-- **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.
     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.
    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
         name: rgbhsv
         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
         name: unknown_32
         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 logging
+from struct import pack, unpack
 
 from ..device import TuyaLocalDevice
 from ..helpers.device_config import TuyaEntityConfig
@@ -100,7 +101,7 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
             if range:
                 min = range["min"]
                 max = range["max"]
-                return unscaled * 347 / (max - min) + 153 - min
+                return round(unscaled * 347 / (max - min) + 153 - min)
             else:
                 return unscaled
         return None
@@ -127,15 +128,44 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
     @property
     def rgbw_color(self):
         """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)
+            # can also be base64 encoded.
             # 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
     def effect_list(self):
@@ -221,20 +251,36 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
 
         if self._rgbhsv_dps:
             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])
                 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(
                     self._device,
-                    color,
+                    self._rgbhsv_dps.encode_value(binary),
                 )
                 settings = {**settings, **color_dps}
 

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

@@ -1,11 +1,12 @@
 """
 Config parser for Tuya Local devices.
 """
+from base64 import b64decode, b64encode
+
 from fnmatch import fnmatch
 import logging
 from os import walk
 from os.path import join, dirname, splitext, exists
-from pydoc import locate
 
 from homeassistant.util import slugify
 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}
 
 
+_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:
     """Representation of a device config for Tuya Local devices."""
 
@@ -264,10 +288,53 @@ class TuyaDpsConfig:
     def name(self):
         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):
         """Return the value of the dps from the given 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):
         """Return true val1 matches val2"""
         if self.rawtype == "bitfield" and matchdata:
@@ -419,9 +486,12 @@ class TuyaDpsConfig:
             self.stringify = False
 
         result = value
+
         mapping = self._find_map_for_dps(value)
         if mapping:
             scale = mapping.get("scale", 1)
+            invert = mapping.get("invert", False)
+
             if not isinstance(scale, (int, float)):
                 scale = 1
             redirect = mapping.get("value_redirect")
@@ -454,6 +524,12 @@ class TuyaDpsConfig:
                 result = result / scale
                 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:
                 _LOGGER.debug(
                     "%s: Mapped dps %s value from %s to %s",
@@ -512,6 +588,8 @@ class TuyaDpsConfig:
             replaced = False
             scale = mapping.get("scale", 1)
             redirect = mapping.get("value_redirect")
+            invert = mapping.get("invert", False)
+
             if not isinstance(scale, (int, float)):
                 scale = 1
             step = mapping.get("step")
@@ -551,6 +629,12 @@ class TuyaDpsConfig:
                 r_dps = self._entity.find_dps(redirect)
                 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)):
                 _LOGGER.debug(f"Scaling {result} by {scale}")
                 result = result * scale