Browse Source

Add device config helpers to return possible matches and judge quality.

Move extra None fields out of test payloads to judge quality correctly.
Jason Rumney 4 years ago
parent
commit
9ad346da40

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

@@ -15,6 +15,10 @@ _LOGGER = logging.getLogger("tuya_local")
 
 
 def _typematch(type, value):
+    # Workaround annoying legacy of bool being a subclass of int in Python
+    if type is int and isinstance(value, bool):
+        return False
+
     if isinstance(value, type):
         return True
     # Allow values embedded in strings if they can be converted
@@ -79,6 +83,22 @@ class TuyaDeviceConfig:
         _LOGGER.debug("Matched config for %s", self.name)
         return True
 
+    def match_quality(self, dps):
+        """Determine the match quality for the provided dps map."""
+        keys = list(dps.keys())
+        total = len(keys)
+        for d in self.primary_entity.dps():
+            if d.id not in keys or not _typematch(d.type, dps[d.id]):
+                return 0
+            keys.remove(d.id)
+
+        for dev in self.secondary_entities():
+            for d in dev.dps():
+                if d.id not in keys or not _typematch(d.type, dps[d.id]):
+                    return 0
+                keys.remove(d.id)
+        return (total - len(keys)) * 100 / total
+
 
 class TuyaEntityConfig:
     """Representation of an entity config for a supported entity."""
@@ -93,7 +113,7 @@ class TuyaEntityConfig:
         return self.__config.get("name", self.__device_name)
 
     @property
-    def legacy_device(self):
+    def legacy_class(self):
         """Return the legacy device corresponding to this config."""
         return self.__config.get("legacy_class", None)
 
@@ -176,3 +196,11 @@ def available_configs():
         for basename in sorted(files):
             if fnmatch(basename, "*.yaml"):
                 yield basename
+
+
+def possible_matches(dps):
+    """Return possible matching configs for a given set of dps values."""
+    for cfg in available_configs():
+        parsed = TuyaDeviceConfig(cfg)
+        if parsed.matches(dps):
+            yield parsed

+ 11 - 8
tests/const.py

@@ -53,24 +53,27 @@ KOGAN_SOCKET_PAYLOAD = {
     "4": 200,
     "5": 460,
     "6": 2300,
-    "9": None,
-    "18": None,
-    "19": None,
-    "20": None,
 }
 
 KOGAN_SOCKET_PAYLOAD2 = {
     "1": True,
-    "2": None,
-    "4": None,
-    "5": None,
-    "6": None,
     "9": 0,
     "18": 200,
     "19": 460,
     "20": 2300,
 }
 
+KOGAN_SOCKET_CLEAR_PAYLOAD = {
+    "2": None,
+    "4": None,
+    "5": None,
+    "6": None,
+    "9": None,
+    "18": None,
+    "19": None,
+    "20": None,
+}
+
 GSH_HEATER_PAYLOAD = {
     "1": True,
     "2": 22,

+ 5 - 2
tests/kogan_socket/test_alt_switch.py

@@ -17,7 +17,7 @@ from custom_components.tuya_local.kogan_socket.const import (
 )
 from custom_components.tuya_local.kogan_socket.switch import KoganSocketSwitch
 
-from ..const import KOGAN_SOCKET_PAYLOAD2
+from ..const import KOGAN_SOCKET_PAYLOAD2, KOGAN_SOCKET_CLEAR_PAYLOAD
 from ..helpers import assert_device_properties_set
 
 
@@ -29,7 +29,10 @@ class TestKoganSocket(IsolatedAsyncioTestCase):
 
         self.subject = KoganSocketSwitch(self.mock_device())
 
-        self.dps = KOGAN_SOCKET_PAYLOAD2.copy()
+        # since the socket needs to handle both types, give the mock some
+        # dummy fields to prevent breakage.
+        self.dps = KOGAN_SOCKET_CLEAR_PAYLOAD.copy()
+        self.dps.update(KOGAN_SOCKET_PAYLOAD2)
         self.subject._device.get_property.side_effect = lambda id: self.dps[id]
 
     def test_should_poll(self):

+ 6 - 2
tests/kogan_socket/test_switch.py

@@ -13,7 +13,7 @@ from custom_components.tuya_local.kogan_socket.const import (
 )
 from custom_components.tuya_local.kogan_socket.switch import KoganSocketSwitch
 
-from ..const import KOGAN_SOCKET_PAYLOAD
+from ..const import KOGAN_SOCKET_PAYLOAD, KOGAN_SOCKET_CLEAR_PAYLOAD
 from ..helpers import assert_device_properties_set
 
 
@@ -25,7 +25,11 @@ class TestKoganSocket(IsolatedAsyncioTestCase):
 
         self.subject = KoganSocketSwitch(self.mock_device())
 
-        self.dps = KOGAN_SOCKET_PAYLOAD.copy()
+        # since the socket needs to handle both types, give the mock some
+        # dummy fields to prevent breakage.
+        self.dps = KOGAN_SOCKET_CLEAR_PAYLOAD.copy()
+        self.dps.update(KOGAN_SOCKET_PAYLOAD)
+
         self.subject._device.get_property.side_effect = lambda id: self.dps[id]
 
     def test_should_poll(self):

+ 167 - 134
tests/test_device_config.py

@@ -1,6 +1,25 @@
 """Test the config parser"""
+import unittest
+
+from warnings import warn
+
+from custom_components.tuya_local.const import (
+    CONF_TYPE_DEHUMIDIFIER,
+    CONF_TYPE_EUROM_600_HEATER,
+    CONF_TYPE_FAN,
+    CONF_TYPE_GARDENPAC_HEATPUMP,
+    CONF_TYPE_GECO_HEATER,
+    CONF_TYPE_GPCV_HEATER,
+    CONF_TYPE_GPPH_HEATER,
+    CONF_TYPE_GSH_HEATER,
+    CONF_TYPE_KOGAN_HEATER,
+    CONF_TYPE_KOGAN_SWITCH,
+    CONF_TYPE_PURLINE_M100_HEATER,
+)
+
 from custom_components.tuya_local.helpers.device_config import (
     available_configs,
+    possible_matches,
     TuyaDeviceConfig,
 )
 
@@ -20,137 +39,151 @@ from .const import (
 )
 
 
-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)
+class TestDeviceConfig(unittest.TestCase):
+    """Test the device config parser"""
+
+    def test_can_find_config_files(self):
+        """Test that the config files can be found by the parser."""
+        found = False
+        for cfg in available_configs():
+            found = True
+            break
+        self.assertTrue(found)
+
+    def test_config_files_parse(self):
+        for cfg in available_configs():
+            parsed = TuyaDeviceConfig(cfg)
+            self.assertIsNotNone(parsed.name)
+
+    def test_config_files_have_legacy_link(self):
+        """
+        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)
+            self.assertIsNotNone(parsed.legacy_type)
+            self.assertIsNotNone(parsed.primary_entity)
+            self.assertIsNotNone(parsed.primary_entity.legacy_class)
+            for e in parsed.secondary_entities():
+                self.assertIsNotNone(e.legacy_class)
+
+    def _test_detect(self, payload, legacy_type, legacy_class):
+        """Test that payload is detected as the correct type and class."""
+        matched = False
+        false_matches = []
+        quality = 0
+        for cfg in possible_matches(payload):
+            self.assertTrue(cfg.matches(payload))
+            if cfg.legacy_type == legacy_type:
+                self.assertFalse(matched)
+                matched = True
+                quality = cfg.match_quality(payload)
+                self.assertEqual(cfg.primary_entity.legacy_class, legacy_class)
+            else:
+                false_matches.append(cfg)
+
+        self.assertTrue(matched)
+        if quality < 100:
+            warn(f"{legacy_type} detected with quality {quality}")
+
+        best_q = 0
+        for cfg in false_matches:
+            q = cfg.match_quality(payload)
+            if q > best_q:
+                best_q = q
+            warn(f"{legacy_type} also detectable as {cfg.legacy_type} with quality {q}")
+
+        self.assertGreater(quality, best_q)
+
+    def test_gpph_heater_detection(self):
+        """Test that GPPH heater can be detected from its sample payload."""
+        self._test_detect(
+            GPPH_HEATER_PAYLOAD, CONF_TYPE_GPPH_HEATER, ".heater.climate.GoldairHeater"
+        )
+
+    def test_gpcv_heater_detection(self):
+        """Test that GPCV heater can be detected from its sample payload."""
+        self._test_detect(
+            GPCV_HEATER_PAYLOAD,
+            CONF_TYPE_GPCV_HEATER,
+            ".gpcv_heater.climate.GoldairGPCVHeater",
+        )
+
+    def test_eurom_heater_detection(self):
+        """Test that Eurom heater can be detected from its sample payload."""
+        self._test_detect(
+            EUROM_600_HEATER_PAYLOAD,
+            CONF_TYPE_EUROM_600_HEATER,
+            ".eurom_600_heater.climate.EuromMonSoleil600Heater",
+        )
+
+    def test_geco_heater_detection(self):
+        """Test that GECO heater can be detected from its sample payload."""
+        self._test_detect(
+            GECO_HEATER_PAYLOAD,
+            CONF_TYPE_GECO_HEATER,
+            ".geco_heater.climate.GoldairGECOHeater",
+        )
+
+    def test_kogan_heater_detection(self):
+        """Test that Kogan heater can be detected from its sample payload."""
+        self._test_detect(
+            KOGAN_HEATER_PAYLOAD,
+            CONF_TYPE_KOGAN_HEATER,
+            ".kogan_heater.climate.KoganHeater",
+        )
+
+    def test_goldair_dehumidifier_detection(self):
+        """Test that Goldair dehumidifier can be detected from its sample payload."""
+        self._test_detect(
+            DEHUMIDIFIER_PAYLOAD,
+            CONF_TYPE_DEHUMIDIFIER,
+            ".dehumidifier.climate.GoldairDehumidifier",
+        )
+
+    def test_goldair_fan_detection(self):
+        """Test that Goldair fan can be detected from its sample payload."""
+        self._test_detect(FAN_PAYLOAD, CONF_TYPE_FAN, ".fan.climate.GoldairFan")
+
+    def test_kogan_socket_detection(self):
+        """Test that 1st gen Kogan Socket can be detected from its sample payload."""
+        self._test_detect(
+            KOGAN_SOCKET_PAYLOAD,
+            CONF_TYPE_KOGAN_SWITCH,
+            ".kogan_socket.switch.KoganSocketSwitch",
+        )
+
+    def test_kogan_socket2_detection(self):
+        """Test that 2nd gen Kogan Socket can be detected from its sample payload."""
+        self._test_detect(
+            KOGAN_SOCKET_PAYLOAD2,
+            CONF_TYPE_KOGAN_SWITCH,
+            ".kogan_socket.switch.KoganSocketSwitch",
+        )
+
+    def test_gsh_heater_detection(self):
+        """Test that GSH heater can be detected from its sample payload."""
+        self._test_detect(
+            GSH_HEATER_PAYLOAD,
+            CONF_TYPE_GSH_HEATER,
+            ".gsh_heater.climate.AnderssonGSHHeater",
+        )
+
+    def test_gardenpac_heatpump_detection(self):
+        """Test that GardenPac heatpump can be detected from its sample payload."""
+        self._test_detect(
+            GARDENPAC_HEATPUMP_PAYLOAD,
+            CONF_TYPE_GARDENPAC_HEATPUMP,
+            ".gardenpac_heatpump.climate.GardenPACPoolHeatpump",
+        )
+
+    def test_purline_heater_detection(self):
+        """Test that Purline heater can be detected from its sample payload."""
+        self._test_detect(
+            PURLINE_M100_HEATER_PAYLOAD,
+            CONF_TYPE_PURLINE_M100_HEATER,
+            ".purline_m100_heater.climate.PurlineM100Heater",
+        )