Kaynağa Gözat

Add helper for parsing device configs.

Add some unit tests for it, and fix issues detected in config files and test payloads as a result.

Changed requirements to use latest versions of test tools, to catch errors as updated versions support them.
Jason Rumney 4 yıl önce
ebeveyn
işleme
bc0ccfea28

+ 0 - 0
custom_components/__init__.py


+ 1 - 1
custom_components/tuya_local/devices/gardenpac_heatpump.yaml

@@ -34,7 +34,7 @@ primary_entity:
       readonly: true
     - id: 105
       name: operating_mode
-      type: integer
+      type: string
       readonly: true
     - id: 106
       name: temperature

+ 2 - 1
custom_components/tuya_local/devices/goldair_dehumidifier.yaml

@@ -55,7 +55,8 @@ primary_entity:
           value: "OK"
       name: error
       readonly: true
-      - id: 103
+    - id: 103
+      type: integer
       name: current_temperature
       readonly: true
     - id: 104

+ 1 - 1
custom_components/tuya_local/devices/goldair_fan.yaml

@@ -50,7 +50,7 @@ primary_entity:
           value: sleep
       name: preset_mode
     - id: 8
-      type: string
+      type: boolean
       mapping:
         - dps_val: false
           value: "off"

+ 3 - 3
custom_components/tuya_local/devices/goldair_gpcv_heater.yaml

@@ -44,6 +44,6 @@ secondary_entities:
     legacy_class: ".gpcv_heater.lock.GoldairGPCVHeaterChildLock"
     name: "Child Lock"
     dps:
-      id: 2
-      type: boolean
-      name: lock
+      - id: 2
+        type: boolean
+        name: lock

+ 1 - 1
custom_components/tuya_local/devices/goldair_gpph_heater.yaml

@@ -75,7 +75,7 @@ primary_entity:
           value-redirect: power_level
       name: swing_mode
     - id: 106
-      value: integer
+      type: integer
       range:
         min: 5
         max: 21

+ 176 - 0
custom_components/tuya_local/helpers/device_config.py

@@ -0,0 +1,176 @@
+"""
+Config parser for Tuya Local devices.
+"""
+import logging
+
+from os import walk
+from os.path import join, dirname
+from fnmatch import fnmatch
+from homeassistant.util.yaml import load_yaml
+
+import custom_components.tuya_local.devices
+
+_CONFIG_DIR = dirname(custom_components.tuya_local.devices.__file__)
+_LOGGER = logging.getLogger("tuya_local")
+
+
+def _typematch(type, value):
+    if isinstance(value, type):
+        return True
+    # Allow values embedded in strings if they can be converted
+    # But not for bool, as everything can be converted to bool
+    elif isinstance(value, str) and type is not bool:
+        try:
+            type(value)
+            return True
+        except ValueError:
+            return False
+    return False
+
+
+class TuyaDeviceConfig:
+    """Representation of a device config for Tuya Local devices."""
+
+    def __init__(self, fname):
+        """Initialize the device config.
+        Args:
+            fname (string): The filename of the yaml config to load."""
+        self.__fname = fname
+        filename = join(_CONFIG_DIR, fname)
+        self.__config = load_yaml(filename)
+        _LOGGER.debug("Loaded device config %s", fname)
+
+    @property
+    def name(self):
+        """Return the friendly name for this device."""
+        return self.__config["name"]
+
+    @property
+    def primary_entity(self):
+        """Return the primary type of entity for this device."""
+        return TuyaEntityConfig(self, self.__config["primary_entity"])
+
+    def secondary_entities(self):
+        """Iterate through entites for any secondary entites supported."""
+        if "secondary_entities" in self.__config.keys():
+            for conf in self.__config["secondary_entities"]:
+                yield TuyaEntityConfig(self, conf)
+
+    def matches(self, dps):
+        """Determine if this device matches the provided dps map."""
+        for d in self.primary_entity.dps():
+            if d.id not in dps.keys() or not _typematch(d.type, dps[d.id]):
+                return False
+
+        for dev in self.secondary_entities():
+            for d in dev.dps():
+                if d.id not in dps.keys() or not _typematch(d.type, dps[d.id]):
+                    return False
+        _LOGGER.debug("Matched config for %s", self.name)
+        return True
+
+
+class TuyaEntityConfig:
+    """Representation of an entity config for a supported entity."""
+
+    def __init__(self, device, config):
+        self.__device = device
+        self.__config = config
+
+    @property
+    def name(self):
+        """The friendly name for this entity."""
+        if "name" in self.__config:
+            return self.__config["name"]
+        else:
+            return self.__device.name
+
+    @property
+    def legacy_device(self):
+        """Return the legacy device corresponding to this config."""
+        if "legacy_class" in self.__config:
+            return self.__config["legacy_class"]
+        else:
+            return None
+
+    @property
+    def entity(self):
+        """The entity type of this entity."""
+        return self.__config["entity"]
+
+    def dps(self):
+        """Iterate through the list of dps for this entity."""
+        for d in self.__config["dps"]:
+            yield TuyaDpsConfig(self, d)
+
+
+class TuyaDpsConfig:
+    """Representation of a dps config."""
+
+    def __init__(self, entity, config):
+        self.__entity = entity
+        self.__config = config
+
+    @property
+    def id(self):
+        return str(self.__config["id"])
+
+    @property
+    def type(self):
+        t = self.__config["type"]
+        types = {
+            "boolean": bool,
+            "integer": int,
+            "string": str,
+            "float": float,
+            "bitfield": int,
+        }
+        return types.get(t, None)
+
+    @property
+    def name(self):
+        return self.__config["name"]
+
+    @property
+    def isreadonly(self):
+        return "readonly" in self.__config.keys() and self.__config["readonly"] is True
+
+    @property
+    def map_from_dps(self, value):
+        result = value
+        if "mapping" in self.__config.keys():
+            for map in self.__config["mapping"]:
+                if map["dps_val"] == value and "value" in map:
+                    result = map["value"]
+                    _LOGGER.debug(
+                        "%s: Mapped dps %d value from %s to %s",
+                        self.__entity.__device.name,
+                        self.id,
+                        value,
+                        result,
+                    )
+        return result
+
+    @property
+    def map_to_dps(self, value):
+        result = value
+        if "mapping" in self.__config.keys():
+            for map in self.__config["mapping"]:
+                if "value" in map and map["value"] == value:
+                    result = map["dps_val"]
+                    _LOGGER.debug(
+                        "%s: Mapped dps %d to %s from %s",
+                        self.__entity.__device.name,
+                        self.id,
+                        result,
+                        value,
+                    )
+        return result
+
+
+def available_configs():
+    """List the available config files."""
+    for (path, dirs, files) in walk(_CONFIG_DIR):
+        for basename in sorted(files):
+            if fnmatch(basename, "*.yaml"):
+                yield basename

+ 7 - 6
requirements-dev.txt

@@ -1,7 +1,8 @@
-homeassistant~=0.110
+black
+homeassistant
+isort
+pytest-homeassistant-custom-component
+pytest
+pytest-cov
 pycryptodome~=3.9
-tinytuya~=1.2.3
-pytest~=5.4
-pytest-cov~=2.9
-black~=19.10b0
-isort~=4.3
+tinytuya~=1.2

+ 1 - 1
tests/const.py

@@ -23,7 +23,7 @@ GPCV_HEATER_PAYLOAD = {
     "7": "Low",
 }
 
-EUROM_600_HEATER_PAYLOAD = {"1": True, "2": 15, "5": 18}
+EUROM_600_HEATER_PAYLOAD = {"1": True, "2": 15, "5": 18, "6": 0}
 
 GECO_HEATER_PAYLOAD = {"1": True, "2": True, "3": 30, "4": 25, "5": 0, "6": 0}
 

+ 156 - 0
tests/test_device_config.py

@@ -0,0 +1,156 @@
+"""Test the config parser"""
+from custom_components.tuya_local.helpers.device_config import (
+    available_configs,
+    TuyaDeviceConfig,
+)
+
+from .const import (
+    DEHUMIDIFIER_PAYLOAD,
+    EUROM_600_HEATER_PAYLOAD,
+    FAN_PAYLOAD,
+    GARDENPAC_HEATPUMP_PAYLOAD,
+    GECO_HEATER_PAYLOAD,
+    GPCV_HEATER_PAYLOAD,
+    GPPH_HEATER_PAYLOAD,
+    GSH_HEATER_PAYLOAD,
+    KOGAN_HEATER_PAYLOAD,
+    KOGAN_SOCKET_PAYLOAD,
+    KOGAN_SOCKET_PAYLOAD2,
+    PURLINE_M100_HEATER_PAYLOAD,
+)
+
+
+def test_can_find_config_files():
+    """Test that the config files can be found by the parser."""
+    found = False
+    for cfg in available_configs():
+        found = True
+        break
+    assert found
+
+
+def test_config_files_parse():
+    for cfg in available_configs():
+        parsed = TuyaDeviceConfig(cfg)
+        assert parsed.name is not None
+
+
+def test_config_files_have_legacy_link():
+    """
+       Initially, we require a link between the new style config, and the old
+       classes so we can transition over to the new config.  When the
+       transition is complete, we will drop the requirement, as new devices
+       will only be added as config files.
+    """
+    for cfg in available_configs():
+        parsed = TuyaDeviceConfig(cfg)
+        assert parsed.primary_entity is not None
+        assert parsed.primary_entity.legacy_device is not None
+        for e in parsed.secondary_entities():
+            assert e.legacy_device is not None
+
+
+def test_gpph_heater_detection():
+    """Test that the GPPH heater can be detected from its sample payload."""
+    parsed = TuyaDeviceConfig("goldair_gpph_heater.yaml")
+    assert parsed.primary_entity.legacy_device == ".heater.climate.GoldairHeater"
+    assert parsed.matches(GPPH_HEATER_PAYLOAD)
+
+
+def test_gpcv_heater_detection():
+    """Test that the GPCV heater can be detected from its sample payload."""
+    parsed = TuyaDeviceConfig("goldair_gpcv_heater.yaml")
+    assert (
+        parsed.primary_entity.legacy_device == ".gpcv_heater.climate.GoldairGPCVHeater"
+    )
+    assert parsed.matches(GPCV_HEATER_PAYLOAD)
+
+
+def test_eurom_heater_detection():
+    """Test that the Eurom heater can be detected from its sample payload."""
+    parsed = TuyaDeviceConfig("eurom_600_heater.yaml")
+    assert (
+        parsed.primary_entity.legacy_device
+        == ".eurom_600_heater.climate.EuromMonSoleil600Heater"
+    )
+    assert parsed.matches(EUROM_600_HEATER_PAYLOAD)
+
+
+def test_geco_heater_detection():
+    """Test that the GECO heater can be detected from its sample payload."""
+    parsed = TuyaDeviceConfig("goldair_geco_heater.yaml")
+    assert (
+        parsed.primary_entity.legacy_device == ".geco_heater.climate.GoldairGECOHeater"
+    )
+    assert parsed.matches(GECO_HEATER_PAYLOAD)
+
+
+def test_kogan_heater_detection():
+    """Test that the Kogan heater can be detected from its sample payload."""
+    parsed = TuyaDeviceConfig("kogan_heater.yaml")
+    assert parsed.primary_entity.legacy_device == ".kogan_heater.climate.KoganHeater"
+    assert parsed.matches(KOGAN_HEATER_PAYLOAD)
+
+
+def test_goldair_dehumidifier_detection():
+    """Test that the Goldair dehumidifier can be detected from its sample payload."""
+    parsed = TuyaDeviceConfig("goldair_dehumidifier.yaml")
+    assert (
+        parsed.primary_entity.legacy_device
+        == ".dehumidifier.climate.GoldairDehumidifier"
+    )
+    assert parsed.matches(DEHUMIDIFIER_PAYLOAD)
+
+
+def test_goldair_fan_detection():
+    """Test that the Goldair fan can be detected from its sample payload."""
+    parsed = TuyaDeviceConfig("goldair_fan.yaml")
+    assert parsed.primary_entity.legacy_device == ".fan.climate.GoldairFan"
+    assert parsed.matches(FAN_PAYLOAD)
+
+
+def test_kogan_socket_detection():
+    """Test that the 1st gen Kogan Socket can be detected from its sample payload."""
+    parsed = TuyaDeviceConfig("kogan_switch.yaml")
+    assert (
+        parsed.primary_entity.legacy_device == ".kogan_socket.switch.KoganSocketSwitch"
+    )
+    assert parsed.matches(KOGAN_SOCKET_PAYLOAD)
+
+
+def test_kogan_socket2_detection():
+    """Test that the 2nd gen Kogan Socket can be detected from its sample payload."""
+    parsed = TuyaDeviceConfig("kogan_switch2.yaml")
+    assert (
+        parsed.primary_entity.legacy_device == ".kogan_socket.switch.KoganSocketSwitch"
+    )
+    assert parsed.matches(KOGAN_SOCKET_PAYLOAD2)
+
+
+def test_gsh_heater_detection():
+    """Test that the GSH heater can be detected from its sample payload."""
+    parsed = TuyaDeviceConfig("andersson_gsh_heater.yaml")
+    assert (
+        parsed.primary_entity.legacy_device == ".gsh_heater.climate.AnderssonGSHHeater"
+    )
+    assert parsed.matches(GSH_HEATER_PAYLOAD)
+
+
+def test_gardenpac_heatpump_detection():
+    """Test that the GardenPac heatpump can be detected from its sample payload."""
+    parsed = TuyaDeviceConfig("gardenpac_heatpump.yaml")
+    assert (
+        parsed.primary_entity.legacy_device
+        == ".gardenpac_heatpump.climate.GardenPACPoolHeatpump"
+    )
+    assert parsed.matches(GARDENPAC_HEATPUMP_PAYLOAD)
+
+
+def test_purline_heater_detection():
+    """Test that the Purline heater can be detected from its sample payload."""
+    parsed = TuyaDeviceConfig("purline_m100_heater.yaml")
+    assert (
+        parsed.primary_entity.legacy_device
+        == ".purline_m100_heater.climate.PurlineM100Heater"
+    )
+    assert parsed.matches(PURLINE_M100_HEATER_PAYLOAD)