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

Handle conditional re-mapping of values.

Use it instead of complex mix of range and mapping in fan climate config.
Only one test remains skipped in fan, which is that fan mode cannot be set when no preset is set. But since that doesn't happen in reality, we can probably switch to using the generic climate class for fan now, leaving only dehumidifier and gpph heater legacy classes.
Jason Rumney 4 лет назад
Родитель
Сommit
8da2648e70

+ 27 - 4
custom_components/tuya_local/devices/goldair_fan.yaml

@@ -51,13 +51,37 @@ secondary_entities:
             icon: "mdi:fan"
             icon: "mdi:fan"
         name: hvac_mode
         name: hvac_mode
       - id: 2
       - id: 2
+        name: fan_mode
         type: integer
         type: integer
-        range:
-          min: 1
-          max: 12
         mapping:
         mapping:
           - constraint: preset_mode
           - constraint: preset_mode
             conditions:
             conditions:
+              - dps_val: normal
+                mapping:
+                  - dps_val: 1
+                    value: 1
+                  - dps_val: 2
+                    value: 2
+                  - dps_val: 3
+                    value: 3
+                  - dps_val: 4
+                    value: 4
+                  - dps_val: 5
+                    value: 5
+                  - dps_val: 6
+                    value: 6
+                  - dps_val: 7
+                    value: 7
+                  - dps_val: 8
+                    value: 8
+                  - dps_val: 9
+                    value: 9
+                  - dps_val: 10
+                    value: 10
+                  - dps_val: 11
+                    value: 11
+                  - dps_val: 12
+                    value: 12                    
               - dps_val: nature
               - dps_val: nature
                 step: 4
                 step: 4
                 mapping:
                 mapping:
@@ -76,7 +100,6 @@ secondary_entities:
                     value: medium
                     value: medium
                   - dps_val: 12
                   - dps_val: 12
                     value: high
                     value: high
-        name: fan_mode
       - id: 3
       - id: 3
         type: string
         type: string
         mapping:
         mapping:

+ 4 - 4
custom_components/tuya_local/generic/climate.py

@@ -272,7 +272,7 @@ class TuyaLocalClimate(ClimateEntity):
         if self._hvac_mode_dps is None:
         if self._hvac_mode_dps is None:
             return []
             return []
         else:
         else:
-            return self._hvac_mode_dps.values
+            return self._hvac_mode_dps.values(self._device)
 
 
     async def async_set_hvac_mode(self, hvac_mode):
     async def async_set_hvac_mode(self, hvac_mode):
         """Set new HVAC mode."""
         """Set new HVAC mode."""
@@ -292,7 +292,7 @@ class TuyaLocalClimate(ClimateEntity):
         """Return the list of presets that this device supports."""
         """Return the list of presets that this device supports."""
         if self._preset_mode_dps is None:
         if self._preset_mode_dps is None:
             return None
             return None
-        return self._preset_mode_dps.values
+        return self._preset_mode_dps.values(self._device)
 
 
     async def async_set_preset_mode(self, preset_mode):
     async def async_set_preset_mode(self, preset_mode):
         """Set the preset mode."""
         """Set the preset mode."""
@@ -312,7 +312,7 @@ class TuyaLocalClimate(ClimateEntity):
         """Return the list of swing modes that this device supports."""
         """Return the list of swing modes that this device supports."""
         if self._swing_mode_dps is None:
         if self._swing_mode_dps is None:
             return None
             return None
-        return self._swing_mode_dps.values
+        return self._swing_mode_dps.values(self._device)
 
 
     async def async_set_swing_mode(self, swing_mode):
     async def async_set_swing_mode(self, swing_mode):
         """Set the preset mode."""
         """Set the preset mode."""
@@ -332,7 +332,7 @@ class TuyaLocalClimate(ClimateEntity):
         """Return the list of swing modes that this device supports."""
         """Return the list of swing modes that this device supports."""
         if self._fan_mode_dps is None:
         if self._fan_mode_dps is None:
             return None
             return None
-        return self._fan_mode_dps.values
+        return self._fan_mode_dps.values(self._device)
 
 
     async def async_set_fan_mode(self, fan_mode):
     async def async_set_fan_mode(self, fan_mode):
         """Set the preset mode."""
         """Set the preset mode."""

+ 9 - 7
custom_components/tuya_local/generic/fan.py

@@ -131,18 +131,18 @@ class TuyaLocalFan(FanEntity):
         """Return the step for percentage."""
         """Return the step for percentage."""
         if self._speed_dps is None:
         if self._speed_dps is None:
             return None
             return None
-        if self._speed_dps.values is None:
+        if self._speed_dps.values(self._device) is None:
             return self._speed_dps.step(self._device)
             return self._speed_dps.step(self._device)
         else:
         else:
-            return 100 / len(self._speed_dps.values)
+            return 100 / len(self._speed_dps.values(self._device))
 
 
     @property
     @property
     def speed_count(self):
     def speed_count(self):
         """Return the number of speeds supported by the fan."""
         """Return the number of speeds supported by the fan."""
         if self._speed_dps is None:
         if self._speed_dps is None:
             return 0
             return 0
-        if self._speed_dps.values is not None:
-            return len(self._speed_dps.values)
+        if self._speed_dps.values(self._device) is not None:
+            return len(self._speed_dps.values(self._device))
         return int(round(100 / self.percentage_step))
         return int(round(100 / self.percentage_step))
 
 
     async def async_set_percentage(self, percentage):
     async def async_set_percentage(self, percentage):
@@ -150,8 +150,10 @@ class TuyaLocalFan(FanEntity):
         if self._speed_dps is None:
         if self._speed_dps is None:
             return None
             return None
         # If there is a fixed list of values, snap to the closest one
         # If there is a fixed list of values, snap to the closest one
-        if self._speed_dps.values is not None:
-            percentage = min(self._speed_dps.values, key=lambda x: abs(x - percentage))
+        if self._speed_dps.values(self._device) is not None:
+            percentage = min(
+                self._speed_dps.values(self._device), key=lambda x: abs(x - percentage)
+            )
 
 
         await self._speed_dps.async_set_value(self._device, percentage)
         await self._speed_dps.async_set_value(self._device, percentage)
 
 
@@ -167,7 +169,7 @@ class TuyaLocalFan(FanEntity):
         """Return the list of presets that this device supports."""
         """Return the list of presets that this device supports."""
         if self._preset_dps is None:
         if self._preset_dps is None:
             return []
             return []
-        return self._preset_dps.values
+        return self._preset_dps.values(self._device)
 
 
     async def async_set_preset_mode(self, preset_mode):
     async def async_set_preset_mode(self, preset_mode):
         """Set the preset mode."""
         """Set the preset mode."""

+ 1 - 1
custom_components/tuya_local/generic/humidifier.py

@@ -153,7 +153,7 @@ class TuyaLocalHumidifier(HumidifierEntity):
         """Return the list of presets that this device supports."""
         """Return the list of presets that this device supports."""
         if self._mode_dps is None:
         if self._mode_dps is None:
             return None
             return None
-        return self._mode_dps.values
+        return self._mode_dps.values(self._device)
 
 
     async def async_set_mode(self, mode):
     async def async_set_mode(self, mode):
         """Set the preset mode."""
         """Set the preset mode."""

+ 44 - 25
custom_components/tuya_local/helpers/device_config.py

@@ -239,10 +239,12 @@ class TuyaDpsConfig:
         settings = self.get_values_to_set(device, value)
         settings = self.get_values_to_set(device, value)
         await device.async_set_properties(settings)
         await device.async_set_properties(settings)
 
 
-    @property
-    def values(self):
+    def values(self, device):
         """Return the possible values a dps can take."""
         """Return the possible values a dps can take."""
         if "mapping" not in self._config.keys():
         if "mapping" not in self._config.keys():
+            _LOGGER.debug(
+                f"No mapping for {self.name}, unable to determine valid values"
+            )
             return None
             return None
         val = []
         val = []
         for m in self._config["mapping"]:
         for m in self._config["mapping"]:
@@ -251,49 +253,61 @@ class TuyaDpsConfig:
             for c in m.get("conditions", {}):
             for c in m.get("conditions", {}):
                 if "value" in c:
                 if "value" in c:
                     val.append(c["value"])
                     val.append(c["value"])
-
-        return list(set(val)) if len(val) > 0 else None
+            cond = self._active_condition(m, device)
+            if cond and "mapping" in cond:
+                _LOGGER.debug("Considering conditional mappings")
+                c_val = []
+                for m2 in cond["mapping"]:
+                    if "value" in m2:
+                        c_val.append(m2["value"])
+                # if given, the conditional mapping is an override
+                if c_val:
+                    _LOGGER.debug(f"Overriding {self.name} values {val} with {c_val}")
+                    val = c_val
+                    break
+        _LOGGER.debug(f"{self.name} values: {val}")
+        return list(set(val)) if val else None
 
 
     def range(self, device):
     def range(self, device):
         """Return the range for this dps if configured."""
         """Return the range for this dps if configured."""
         mapping = self._find_map_for_dps(device.get_property(self.id))
         mapping = self._find_map_for_dps(device.get_property(self.id))
-        if mapping is not None:
+        if mapping:
             _LOGGER.debug(f"Considering mapping for range of {self.name}")
             _LOGGER.debug(f"Considering mapping for range of {self.name}")
             cond = self._active_condition(mapping, device)
             cond = self._active_condition(mapping, device)
-            if cond is not None:
+            if cond:
                 constraint = mapping.get("constraint")
                 constraint = mapping.get("constraint")
                 _LOGGER.debug(f"Considering condition on {constraint}")
                 _LOGGER.debug(f"Considering condition on {constraint}")
             r = None if cond is None else cond.get("range")
             r = None if cond is None else cond.get("range")
-            if r is not None and "min" in r and "max" in r:
+            if r and "min" in r and "max" in r:
                 _LOGGER.info(f"Conditional range returned for {self.name}")
                 _LOGGER.info(f"Conditional range returned for {self.name}")
                 return r
                 return r
             r = mapping.get("range")
             r = mapping.get("range")
-            if r is not None and "min" in r and "max" in r:
+            if r and "min" in r and "max" in r:
                 _LOGGER.info(f"Mapped range returned for {self.name}")
                 _LOGGER.info(f"Mapped range returned for {self.name}")
                 return r
                 return r
         r = self._config.get("range")
         r = self._config.get("range")
-        if r is not None and "min" in r and "max" in r:
+        if r and "min" in r and "max" in r:
             return r
             return r
         else:
         else:
             return None
             return None
 
 
-    def step(self, device):
+    def step(self, device, scaled=True):
         step = 1
         step = 1
         scale = 1
         scale = 1
         mapping = self._find_map_for_dps(device.get_property(self.id))
         mapping = self._find_map_for_dps(device.get_property(self.id))
-        if mapping is not None:
+        if mapping:
             _LOGGER.debug(f"Considering mapping for step of {self.name}")
             _LOGGER.debug(f"Considering mapping for step of {self.name}")
             step = mapping.get("step", 1)
             step = mapping.get("step", 1)
             scale = mapping.get("scale", 1)
             scale = mapping.get("scale", 1)
             cond = self._active_condition(mapping, device)
             cond = self._active_condition(mapping, device)
-            if cond is not None:
+            if cond:
                 constraint = mapping.get("constraint")
                 constraint = mapping.get("constraint")
                 _LOGGER.debug(f"Considering condition on {constraint}")
                 _LOGGER.debug(f"Considering condition on {constraint}")
                 step = cond.get("step", step)
                 step = cond.get("step", step)
                 scale = cond.get("scale", scale)
                 scale = cond.get("scale", scale)
         if step != 1 or scale != 1:
         if step != 1 or scale != 1:
             _LOGGER.info(f"Step for {self.name} is {step} with scale {scale}")
             _LOGGER.info(f"Step for {self.name} is {step} with scale {scale}")
-        return step / scale
+        return step / scale if scaled else step
 
 
     @property
     @property
     def readonly(self):
     def readonly(self):
@@ -305,9 +319,9 @@ class TuyaDpsConfig:
 
 
     def invalid_for(self, value, device):
     def invalid_for(self, value, device):
         mapping = self._find_map_for_value(value)
         mapping = self._find_map_for_value(value)
-        if mapping is not None:
+        if mapping:
             cond = self._active_condition(mapping, device)
             cond = self._active_condition(mapping, device)
-            if cond is not None:
+            if cond:
                 return cond.get("invalid", False)
                 return cond.get("invalid", False)
         return False
         return False
 
 
@@ -333,7 +347,7 @@ class TuyaDpsConfig:
                 pass
                 pass
         result = value
         result = value
         mapping = self._find_map_for_dps(value)
         mapping = self._find_map_for_dps(value)
-        if mapping is not None:
+        if mapping:
             scale = mapping.get("scale", 1)
             scale = mapping.get("scale", 1)
             if not isinstance(scale, (int, float)):
             if not isinstance(scale, (int, float)):
                 scale = 1
                 scale = 1
@@ -341,7 +355,7 @@ class TuyaDpsConfig:
             replaced = "value" in mapping
             replaced = "value" in mapping
             result = mapping.get("value", result)
             result = mapping.get("value", result)
             cond = self._active_condition(mapping, device)
             cond = self._active_condition(mapping, device)
-            if cond is not None:
+            if cond:
                 if cond.get("invalid", False):
                 if cond.get("invalid", False):
                     return None
                     return None
                 replaced = replaced or "value" in cond
                 replaced = replaced or "value" in cond
@@ -354,7 +368,7 @@ class TuyaDpsConfig:
                         replaced = "value" in m
                         replaced = "value" in m
                         result = m.get("value", result)
                         result = m.get("value", result)
 
 
-            if redirect is not None:
+            if redirect:
                 _LOGGER.debug(f"Redirecting {self.name} to {redirect}")
                 _LOGGER.debug(f"Redirecting {self.name} to {redirect}")
                 r_dps = self._entity.find_dps(redirect)
                 r_dps = self._entity.find_dps(redirect)
                 return r_dps.get_value(device)
                 return r_dps.get_value(device)
@@ -389,7 +403,7 @@ class TuyaDpsConfig:
     def _active_condition(self, mapping, device):
     def _active_condition(self, mapping, device):
         constraint = mapping.get("constraint")
         constraint = mapping.get("constraint")
         conditions = mapping.get("conditions")
         conditions = mapping.get("conditions")
-        if constraint is not None and conditions is not None:
+        if constraint and conditions:
             c_dps = self._entity.find_dps(constraint)
             c_dps = self._entity.find_dps(constraint)
             c_val = None if c_dps is None else device.get_property(c_dps.id)
             c_val = None if c_dps is None else device.get_property(c_dps.id)
             if c_val is not None:
             if c_val is not None:
@@ -403,7 +417,7 @@ class TuyaDpsConfig:
         result = value
         result = value
         dps_map = {}
         dps_map = {}
         mapping = self._find_map_for_value(value)
         mapping = self._find_map_for_value(value)
-        if mapping is not None:
+        if mapping:
             replaced = False
             replaced = False
             scale = mapping.get("scale", 1)
             scale = mapping.get("scale", 1)
             redirect = mapping.get("value-redirect")
             redirect = mapping.get("value-redirect")
@@ -417,15 +431,20 @@ class TuyaDpsConfig:
                 replaced = True
                 replaced = True
             # Conditions may have side effect of setting another value.
             # Conditions may have side effect of setting another value.
             cond = self._active_condition(mapping, device)
             cond = self._active_condition(mapping, device)
-            if cond is not None:
+            if cond:
                 if cond.get("value") == value:
                 if cond.get("value") == value:
                     c_dps = self._entity.find_dps(mapping["constraint"])
                     c_dps = self._entity.find_dps(mapping["constraint"])
                     dps_map.update(c_dps.get_values_to_set(device, cond["dps_val"]))
                     dps_map.update(c_dps.get_values_to_set(device, cond["dps_val"]))
+                # Allow simple conditional mapping overrides
+                for m in cond.get("mapping", {}):
+                    if m.get("value") == value:
+                        result = m.get("dps_val", result)
+
                 scale = cond.get("scale", scale)
                 scale = cond.get("scale", scale)
                 step = cond.get("step", step)
                 step = cond.get("step", step)
                 redirect = cond.get("value-redirect", redirect)
                 redirect = cond.get("value-redirect", redirect)
 
 
-            if redirect is not None:
+            if redirect:
                 _LOGGER.debug(f"Redirecting {self.name} to {redirect}")
                 _LOGGER.debug(f"Redirecting {self.name} to {redirect}")
                 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)
@@ -435,7 +454,7 @@ class TuyaDpsConfig:
                 result = result * scale
                 result = result * scale
                 replaced = True
                 replaced = True
 
 
-            if step is not None and isinstance(result, (int, float)):
+            if step and isinstance(result, (int, float)):
                 _LOGGER.debug(f"Stepping {result} to {step}")
                 _LOGGER.debug(f"Stepping {result} to {step}")
                 result = step * round(float(result) / step)
                 result = step * round(float(result) / step)
                 replaced = True
                 replaced = True
@@ -450,12 +469,12 @@ class TuyaDpsConfig:
                 )
                 )
 
 
         r = self.range(device)
         r = self.range(device)
-        if r is not None:
+        if r:
             minimum = r["min"]
             minimum = r["min"]
             maximum = r["max"]
             maximum = r["max"]
             if result < minimum or result > maximum:
             if result < minimum or result > maximum:
                 raise ValueError(
                 raise ValueError(
-                    f"{self.name} ({value}) must be between {minimum} and {maximum}"
+                    f"{self.name} ({result}) must be between {minimum} and {maximum}"
                 )
                 )
 
 
         if self.type is int:
         if self.type is int:

+ 10 - 13
tests/devices/test_goldair_fan.py

@@ -279,19 +279,18 @@ class TestGoldairFan(IsolatedAsyncioTestCase):
         async with assert_device_properties_set(self.subject._device, {FANMODE_DPS: 8}):
         async with assert_device_properties_set(self.subject._device, {FANMODE_DPS: 8}):
             await self.subject.async_set_percentage(75)
             await self.subject.async_set_percentage(75)
 
 
-    @skip("Fan modes does not work without mapping")
     def test_climate_fan_modes(self):
     def test_climate_fan_modes(self):
         self.dps[PRESET_DPS] = "normal"
         self.dps[PRESET_DPS] = "normal"
         self.assertCountEqual(self.climate.fan_modes, list(range(1, 13)))
         self.assertCountEqual(self.climate.fan_modes, list(range(1, 13)))
 
 
         self.dps[PRESET_DPS] = "nature"
         self.dps[PRESET_DPS] = "nature"
-        self.assertCountEqual(self.climate.fan_modes, [1, 2, 3])
+        self.assertCountEqual(self.climate.fan_modes, ["low", "medium", "high"])
 
 
         self.dps[PRESET_DPS] = PRESET_SLEEP
         self.dps[PRESET_DPS] = PRESET_SLEEP
-        self.assertCountEqual(self.climate.fan_modes, [1, 2, 3])
+        self.assertCountEqual(self.climate.fan_modes, ["low", "medium", "high"])
 
 
         self.dps[PRESET_DPS] = None
         self.dps[PRESET_DPS] = None
-        self.assertEqual(self.climate.fan_modes, [])
+        self.assertEqual(self.climate.fan_modes, None)
 
 
     def test_climate_fan_mode_for_normal_preset(self):
     def test_climate_fan_mode_for_normal_preset(self):
         self.dps[PRESET_DPS] = "normal"
         self.dps[PRESET_DPS] = "normal"
@@ -332,42 +331,40 @@ class TestGoldairFan(IsolatedAsyncioTestCase):
         self.dps[FANMODE_DPS] = None
         self.dps[FANMODE_DPS] = None
         self.assertEqual(self.climate.fan_mode, None)
         self.assertEqual(self.climate.fan_mode, None)
 
 
-    @skip("Complex conditions not yet supported for setting")
     async def test_climate_set_fan_mode_for_eco_preset(self):
     async def test_climate_set_fan_mode_for_eco_preset(self):
         self.dps[PRESET_DPS] = "nature"
         self.dps[PRESET_DPS] = "nature"
 
 
         async with assert_device_properties_set(
         async with assert_device_properties_set(
             self.climate._device,
             self.climate._device,
-            {FANMODE_DPS: "4"},
+            {FANMODE_DPS: 4},
         ):
         ):
             await self.climate.async_set_fan_mode("low")
             await self.climate.async_set_fan_mode("low")
 
 
     def test_climate_fan_mode_for_sleep_preset(self):
     def test_climate_fan_mode_for_sleep_preset(self):
         self.dps[PRESET_DPS] = PRESET_SLEEP
         self.dps[PRESET_DPS] = PRESET_SLEEP
 
 
-        self.dps[FANMODE_DPS] = "4"
+        self.dps[FANMODE_DPS] = 4
         self.assertEqual(self.climate.fan_mode, "low")
         self.assertEqual(self.climate.fan_mode, "low")
 
 
-        self.dps[FANMODE_DPS] = "8"
+        self.dps[FANMODE_DPS] = 8
         self.assertEqual(self.climate.fan_mode, "medium")
         self.assertEqual(self.climate.fan_mode, "medium")
 
 
-        self.dps[FANMODE_DPS] = "12"
+        self.dps[FANMODE_DPS] = 12
         self.assertEqual(self.climate.fan_mode, "high")
         self.assertEqual(self.climate.fan_mode, "high")
 
 
         self.dps[FANMODE_DPS] = None
         self.dps[FANMODE_DPS] = None
         self.assertEqual(self.climate.fan_mode, None)
         self.assertEqual(self.climate.fan_mode, None)
 
 
-    @skip("Complex conditions not yet supported for setting")
     async def test_climate_set_fan_mode_for_sleep_preset(self):
     async def test_climate_set_fan_mode_for_sleep_preset(self):
         self.dps[PRESET_DPS] = PRESET_SLEEP
         self.dps[PRESET_DPS] = PRESET_SLEEP
 
 
         async with assert_device_properties_set(
         async with assert_device_properties_set(
             self.climate._device,
             self.climate._device,
-            {FANMODE_DPS: "8"},
+            {FANMODE_DPS: 8},
         ):
         ):
-            await self.climate.async_set_fan_mode(2)
+            await self.climate.async_set_fan_mode("medium")
 
 
-    @skip("Complex conditions not yet supported for setting")
+    @skip("Conditions not yet supported for setting")
     async def test_climate_set_fan_mode_does_nothing_when_preset_mode_is_not_set(self):
     async def test_climate_set_fan_mode_does_nothing_when_preset_mode_is_not_set(self):
         self.dps[PRESET_DPS] = None
         self.dps[PRESET_DPS] = None