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

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"
         name: hvac_mode
       - id: 2
+        name: fan_mode
         type: integer
-        range:
-          min: 1
-          max: 12
         mapping:
           - constraint: preset_mode
             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
                 step: 4
                 mapping:
@@ -76,7 +100,6 @@ secondary_entities:
                     value: medium
                   - dps_val: 12
                     value: high
-        name: fan_mode
       - id: 3
         type: string
         mapping:

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

@@ -272,7 +272,7 @@ class TuyaLocalClimate(ClimateEntity):
         if self._hvac_mode_dps is None:
             return []
         else:
-            return self._hvac_mode_dps.values
+            return self._hvac_mode_dps.values(self._device)
 
     async def async_set_hvac_mode(self, hvac_mode):
         """Set new HVAC mode."""
@@ -292,7 +292,7 @@ class TuyaLocalClimate(ClimateEntity):
         """Return the list of presets that this device supports."""
         if self._preset_mode_dps is 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):
         """Set the preset mode."""
@@ -312,7 +312,7 @@ class TuyaLocalClimate(ClimateEntity):
         """Return the list of swing modes that this device supports."""
         if self._swing_mode_dps is 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):
         """Set the preset mode."""
@@ -332,7 +332,7 @@ class TuyaLocalClimate(ClimateEntity):
         """Return the list of swing modes that this device supports."""
         if self._fan_mode_dps is 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):
         """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."""
         if self._speed_dps is 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)
         else:
-            return 100 / len(self._speed_dps.values)
+            return 100 / len(self._speed_dps.values(self._device))
 
     @property
     def speed_count(self):
         """Return the number of speeds supported by the fan."""
         if self._speed_dps is None:
             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))
 
     async def async_set_percentage(self, percentage):
@@ -150,8 +150,10 @@ class TuyaLocalFan(FanEntity):
         if self._speed_dps is None:
             return None
         # 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)
 
@@ -167,7 +169,7 @@ class TuyaLocalFan(FanEntity):
         """Return the list of presets that this device supports."""
         if self._preset_dps is None:
             return []
-        return self._preset_dps.values
+        return self._preset_dps.values(self._device)
 
     async def async_set_preset_mode(self, 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."""
         if self._mode_dps is None:
             return None
-        return self._mode_dps.values
+        return self._mode_dps.values(self._device)
 
     async def async_set_mode(self, 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)
         await device.async_set_properties(settings)
 
-    @property
-    def values(self):
+    def values(self, device):
         """Return the possible values a dps can take."""
         if "mapping" not in self._config.keys():
+            _LOGGER.debug(
+                f"No mapping for {self.name}, unable to determine valid values"
+            )
             return None
         val = []
         for m in self._config["mapping"]:
@@ -251,49 +253,61 @@ class TuyaDpsConfig:
             for c in m.get("conditions", {}):
                 if "value" in c:
                     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):
         """Return the range for this dps if configured."""
         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}")
             cond = self._active_condition(mapping, device)
-            if cond is not None:
+            if cond:
                 constraint = mapping.get("constraint")
                 _LOGGER.debug(f"Considering condition on {constraint}")
             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}")
                 return r
             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}")
                 return r
         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
         else:
             return None
 
-    def step(self, device):
+    def step(self, device, scaled=True):
         step = 1
         scale = 1
         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}")
             step = mapping.get("step", 1)
             scale = mapping.get("scale", 1)
             cond = self._active_condition(mapping, device)
-            if cond is not None:
+            if cond:
                 constraint = mapping.get("constraint")
                 _LOGGER.debug(f"Considering condition on {constraint}")
                 step = cond.get("step", step)
                 scale = cond.get("scale", scale)
         if step != 1 or scale != 1:
             _LOGGER.info(f"Step for {self.name} is {step} with scale {scale}")
-        return step / scale
+        return step / scale if scaled else step
 
     @property
     def readonly(self):
@@ -305,9 +319,9 @@ class TuyaDpsConfig:
 
     def invalid_for(self, value, device):
         mapping = self._find_map_for_value(value)
-        if mapping is not None:
+        if mapping:
             cond = self._active_condition(mapping, device)
-            if cond is not None:
+            if cond:
                 return cond.get("invalid", False)
         return False
 
@@ -333,7 +347,7 @@ class TuyaDpsConfig:
                 pass
         result = value
         mapping = self._find_map_for_dps(value)
-        if mapping is not None:
+        if mapping:
             scale = mapping.get("scale", 1)
             if not isinstance(scale, (int, float)):
                 scale = 1
@@ -341,7 +355,7 @@ class TuyaDpsConfig:
             replaced = "value" in mapping
             result = mapping.get("value", result)
             cond = self._active_condition(mapping, device)
-            if cond is not None:
+            if cond:
                 if cond.get("invalid", False):
                     return None
                 replaced = replaced or "value" in cond
@@ -354,7 +368,7 @@ class TuyaDpsConfig:
                         replaced = "value" in m
                         result = m.get("value", result)
 
-            if redirect is not None:
+            if redirect:
                 _LOGGER.debug(f"Redirecting {self.name} to {redirect}")
                 r_dps = self._entity.find_dps(redirect)
                 return r_dps.get_value(device)
@@ -389,7 +403,7 @@ class TuyaDpsConfig:
     def _active_condition(self, mapping, device):
         constraint = mapping.get("constraint")
         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_val = None if c_dps is None else device.get_property(c_dps.id)
             if c_val is not None:
@@ -403,7 +417,7 @@ class TuyaDpsConfig:
         result = value
         dps_map = {}
         mapping = self._find_map_for_value(value)
-        if mapping is not None:
+        if mapping:
             replaced = False
             scale = mapping.get("scale", 1)
             redirect = mapping.get("value-redirect")
@@ -417,15 +431,20 @@ class TuyaDpsConfig:
                 replaced = True
             # Conditions may have side effect of setting another value.
             cond = self._active_condition(mapping, device)
-            if cond is not None:
+            if cond:
                 if cond.get("value") == value:
                     c_dps = self._entity.find_dps(mapping["constraint"])
                     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)
                 step = cond.get("step", step)
                 redirect = cond.get("value-redirect", redirect)
 
-            if redirect is not None:
+            if redirect:
                 _LOGGER.debug(f"Redirecting {self.name} to {redirect}")
                 r_dps = self._entity.find_dps(redirect)
                 return r_dps.get_values_to_set(device, value)
@@ -435,7 +454,7 @@ class TuyaDpsConfig:
                 result = result * scale
                 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}")
                 result = step * round(float(result) / step)
                 replaced = True
@@ -450,12 +469,12 @@ class TuyaDpsConfig:
                 )
 
         r = self.range(device)
-        if r is not None:
+        if r:
             minimum = r["min"]
             maximum = r["max"]
             if result < minimum or result > maximum:
                 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:

+ 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}):
             await self.subject.async_set_percentage(75)
 
-    @skip("Fan modes does not work without mapping")
     def test_climate_fan_modes(self):
         self.dps[PRESET_DPS] = "normal"
         self.assertCountEqual(self.climate.fan_modes, list(range(1, 13)))
 
         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.assertCountEqual(self.climate.fan_modes, [1, 2, 3])
+        self.assertCountEqual(self.climate.fan_modes, ["low", "medium", "high"])
 
         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):
         self.dps[PRESET_DPS] = "normal"
@@ -332,42 +331,40 @@ class TestGoldairFan(IsolatedAsyncioTestCase):
         self.dps[FANMODE_DPS] = 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):
         self.dps[PRESET_DPS] = "nature"
 
         async with assert_device_properties_set(
             self.climate._device,
-            {FANMODE_DPS: "4"},
+            {FANMODE_DPS: 4},
         ):
             await self.climate.async_set_fan_mode("low")
 
     def test_climate_fan_mode_for_sleep_preset(self):
         self.dps[PRESET_DPS] = PRESET_SLEEP
 
-        self.dps[FANMODE_DPS] = "4"
+        self.dps[FANMODE_DPS] = 4
         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.dps[FANMODE_DPS] = "12"
+        self.dps[FANMODE_DPS] = 12
         self.assertEqual(self.climate.fan_mode, "high")
 
         self.dps[FANMODE_DPS] = 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):
         self.dps[PRESET_DPS] = PRESET_SLEEP
 
         async with assert_device_properties_set(
             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):
         self.dps[PRESET_DPS] = None