Преглед изворни кода

Refactor conditions, and apply them to range and invalid judgement.

Unskip tests which can now pass due to this.
Jason Rumney пре 4 година
родитељ
комит
3ccb1a5551

+ 21 - 16
custom_components/tuya_local/devices/README.md

@@ -163,16 +163,18 @@ Home Assistant UI.
 
 ## Mapping Rules
 
-Mapping rules can change the behavior of attributes beyond simple copying
-of DPS values to attribute values.  Rules can be defined at the top level
-of the mapping element to apply to all values, or a list of rules that apply
-to particular dps values can be defined to change only particular cases.
-Rules can even depend on the values of other elements.
+Mapping rules can change the behavior of attributes beyond simple
+copying of DPS values to attribute values.  Rules can be defined
+without a dps_val to apply to all values, or a list of rules that
+apply to particular dps values can be defined to change only
+particular cases.  Rules can even depend on the values of other
+elements.
 
 ### `dps_val`
 
-//Mandatory for lists, not used at top level.//
-When a list of rules is defined, `dps_val` defines the DPS value that each
+//Optional, if not provided, the rule is a default that will apply to all
+values not covered by their own dps_val rule.//
+`dps_val` defines the DPS value that each
 rule in the list applies to. This can be used to map specific values from the
 Tuya protocol into attribute values that have specific meaning in Home
 Assistant.  For example, climate entities in Home Assistant define modes
@@ -211,15 +213,6 @@ If you don't specify any priorities, the icons will all get the same priority,
 so if any overlap exists in the rules, it won't always be predictable which
 icon will be displayed.
 
-### `invalid`
-
-//Optional. Boolean, default false.//
-Invalid set to true allows an attribute to temporarily be set read-only in
-some conditions.  Rather than passing requests to set the attribute through
-to the Tuya protocol, attempts to set it will throw an error while it meets
-the conditions to be `invalid`.
-
-
 ### `value-redirect`
 
 //Optional.//
@@ -233,6 +226,18 @@ one or the other. But Home Assistant just has one `temperature` attribute for
 setting target temperature, so the mapping needs to be done before passing to
 Home Assistant.
 
+### `invalid`
+
+//Optional. Boolean, default false.//
+Invalid set to true allows an attribute to temporarily be set read-only in
+some conditions.  Rather than passing requests to set the attribute through
+to the Tuya protocol, attempts to set it will throw an error while it meets
+the conditions to be `invalid`.  It does not make sense to set this at mapping
+level, as it would cause a situation where you can set a value then not be
+able to unset it.  Instead, this should be used with conditions, below, to
+make the behaviour dependent on another DPS, such as disabling fan speed 
+control when the preset is in sleep mode (since sleep mode should force low).
+
 
 ### `constraint`
 

+ 10 - 11
custom_components/tuya_local/devices/goldair_dehumidifier.yaml

@@ -90,19 +90,18 @@ secondary_entities:
         mapping:
           - dps_val: "1"
             value: 50
+            constraint: dehumidifier_mode
+            conditions:
+              - dps_val: "2"
+                invalid: true
+              - dps_val: "3"
+                invalid: true              
           - dps_val: "3"
             value: 100
-        constraint: dehumidifier_mode
-        conditions:
-          - dps_val: "1"
-            value: 50
-            invalid: true
-          - dps_val: "2"
-            value: 100
-            invalid: true
-          - dps_val: "3"
-            value: 100
-            invalid: true
+            constraint: dehumidifier_mode
+            conditions:
+              - dps_val: "1"
+                invalid: true
   - entity: climate
     legacy_class: ".dehumidifier.climate.GoldairDehumidifier"
     name: Dehumidifier as Climate

+ 32 - 30
custom_components/tuya_local/devices/goldair_fan.yaml

@@ -14,14 +14,14 @@ primary_entity:
         max: 12
       mapping:
         - scale: 0.12
-      constraint: preset_mode
-      conditions:
-        - dps_val: nature
-          mapping:
-            - step: 4
-        - dps_val: sleep
-          mapping:
-            - step: 4
+          constraint: preset_mode
+          conditions:
+            - dps_val: nature
+              mapping:
+                - step: 4
+            - dps_val: sleep
+              mapping:
+                - step: 4
     - id: 3
       type: string
       mapping:
@@ -53,28 +53,30 @@ secondary_entities:
         name: hvac_mode
       - id: 2
         type: integer
-        constraint: preset_mode
-        conditions:
-          - dps_val: normal
-            range:
-              min: 1
-              max: 12
-          - dps_val: nature
-            mapping:
-              - dps_val: 4
-                value: low
-              - dps_val: 8
-                value: medium
-              - dps_val: 12
-                value: high
-          - dps_val: sleep
-            mapping:
-              - dps_val: 4
-                value: low
-              - dps_val: 8
-                value: medium
-              - dps_val: 12
-                value: high            
+        range:
+          min: 1
+          max: 12
+        mapping:
+          - constraint: present_mode
+            conditions:
+              - dps_val: nature
+                step: 4
+                mapping:
+                  - dps_val: 4
+                    value: low
+                  - dps_val: 8
+                    value: medium
+                  - dps_val: 12
+                    value: high
+              - dps_val: sleep
+                step: 4
+                mapping:
+                  - dps_val: 4
+                    value: low
+                  - dps_val: 8
+                    value: medium
+                  - dps_val: 12
+                    value: high
         name: fan_mode
       - id: 3
         type: string

+ 6 - 0
custom_components/tuya_local/devices/goldair_gpph_heater.yaml

@@ -26,9 +26,15 @@ primary_entity:
           conditions:
             - dps_val: "ECO"
               value-redirect: eco_temperature
+              range:
+                min: 5
+                max: 21
             - dps_val: "AF"
               invalid: true
               value: 5
+              range:
+                min: 5
+                max: 5
       name: temperature
     - id: 3
       type: integer

+ 8 - 12
custom_components/tuya_local/generic/climate.py

@@ -191,18 +191,16 @@ class TuyaLocalClimate(ClimateEntity):
         """Return the minimum supported target temperature."""
         if self._temperature_dps is None:
             return None
-        if self._temperature_dps.range is None:
-            return DEFAULT_MIN_TEMP
-        return self._temperature_dps.range["min"]
+        range = self._temperature_dps.range(self._device)
+        return DEFAULT_MIN_TEMP if range is None else range["min"]
 
     @property
     def max_temp(self):
         """Return the maximum supported target temperature."""
         if self._temperature_dps is None:
             return None
-        if self._temperature_dps.range is None:
-            return DEFAULT_MAX_TEMP
-        return self._temperature_dps.range["max"]
+        range = self._temperature_dps.range(self._device)
+        return DEFAULT_MAX_TEMP if range is None else range["max"]
 
     async def async_set_temperature(self, **kwargs):
         """Set new target temperature."""
@@ -250,18 +248,16 @@ class TuyaLocalClimate(ClimateEntity):
         """Return the minimum supported target humidity."""
         if self._humidity_dps is None:
             return None
-        if self._humidity_dps.range is None:
-            return DEFAULT_MIN_HUMIDITY
-        return self._humidity_dps.range["min"]
+        range = self._humidity_dps.range(self._device)
+        return DEFAULT_MIN_HUMIDITY if range is None else range["min"]
 
     @property
     def max_humidity(self):
         """Return the maximum supported target humidity."""
         if self._humidity_dps is None:
             return None
-        if self._humidity_dps.range is None:
-            return DEFAULT_MAX_HUMIDITY
-        return self._humidity_dps.range["max"]
+        range = self._humidity_dps.range(self._device)
+        return DEFAULT_MAX_HUMIDITY if range is None else range["max"]
 
     async def async_set_humidity(self, target_humidity):
         if self._humidity_dps is None:

+ 4 - 6
custom_components/tuya_local/generic/humidifier.py

@@ -126,18 +126,16 @@ class TuyaLocalHumidifier(HumidifierEntity):
         """Return the minimum supported target humidity."""
         if self._humidity_dps is None:
             return None
-        if self._humidity_dps.range is None:
-            return DEFAULT_MIN_HUMIDITY
-        return self._humidity_dps.range["min"]
+        range = self._humidity_dps.range(self._device)
+        return DEFAULT_MIN_HUMIDITY if range is None else range["min"]
 
     @property
     def max_humidity(self):
         """Return the maximum supported target humidity."""
         if self._humidity_dps is None:
             return None
-        if self._humidity_dps.range is None:
-            return DEFAULT_MAX_HUMIDITY
-        return self._humidity_dps.range["max"]
+        range = self._humidity_dps.range(self._device)
+        return DEFAULT_MAX_HUMIDITY if range is None else range["max"]
 
     async def async_set_humidity(self, humidity):
         if self._humidity_dps is None:

+ 50 - 32
custom_components/tuya_local/helpers/device_config.py

@@ -222,7 +222,7 @@ class TuyaDpsConfig:
         """Set the value of the dps in the given device to given value."""
         if self.readonly:
             raise TypeError(f"{self.name} is read only")
-        if self.invalid:
+        if self.invalid_for(value, device):
             raise AttributeError(f"{self.name} cannot be set at this time")
 
         settings = self.get_values_to_set(device, value)
@@ -244,15 +244,26 @@ class TuyaDpsConfig:
 
         return list(set(val)) if len(val) > 0 else None
 
-    @property
-    def range(self):
+    def range(self, device):
         """Return the range for this dps if configured."""
-        if (
-            "range" in self._config.keys()
-            and "min" in self._config["range"].keys()
-            and "max" in self._config["range"].keys()
-        ):
-            return self._config["range"]
+        mapping = self._find_map_for_dps(device.get_property(self.id))
+        if mapping is not None:
+            _LOGGER.debug(f"Considering mapping for range of {self.name}")
+            cond = self._active_condition(mapping, device)
+            if cond is not None:
+                constraint = mapping.get("constraint")
+                _LOGGER.debug(f"Considering condition on {constraint}")
+            range = None if cond is None else cond.get("range")
+            if range is not None and "min" in range and "max" in range:
+                _LOGGER.info(f"Conditional range returned for {self.name}")
+                return range
+            range = mapping.get("range")
+            if range is not None and "min" in range and "max" in range:
+                _LOGGER.info(f"Mapped range returned for {self.name}")
+                return range
+        range = self._config.get("range")
+        if range is not None and "min" in range and "max" in range:
+            return range
         else:
             return None
 
@@ -269,8 +280,12 @@ class TuyaDpsConfig:
     def readonly(self):
         return "readonly" in self._config.keys() and self._config["readonly"] is True
 
-    @property
-    def invalid(self):
+    def invalid_for(self, value, device):
+        mapping = self._find_map_for_value(value)
+        if mapping is not None:
+            cond = self._active_condition(mapping, device)
+            if cond is not None:
+                return cond.get("invalid", False)
         return False
 
     @property
@@ -297,20 +312,11 @@ class TuyaDpsConfig:
                 scale = 1
             replaced = "value" in mapping
             result = mapping.get("value", result)
-            if "conditions" in mapping:
-                cond_dps = (
-                    self
-                    if "constraint" not in mapping
-                    else self._entity.find_dps(mapping["constraint"])
-                )
-                for c in mapping["conditions"]:
-                    if (
-                        "dps_val" in c
-                        and c["dps_val"] == device.get_property(cond_dps.id)
-                        and "value" in c
-                    ):
-                        result = c["value"]
-                        replaced = True
+            cond = self._active_condition(mapping, device)
+            if cond is not None:
+                replaced = replaced or "value" in cond
+                result = cond.get("value", result)
+                scale = cond.get("scale", scale)
 
             if scale != 1 and isinstance(result, (int, float)):
                 result = result / scale
@@ -342,6 +348,18 @@ class TuyaDpsConfig:
                         return m
         return default
 
+    def _active_condition(self, mapping, device):
+        constraint = mapping.get("constraint")
+        conditions = mapping.get("conditions")
+        if constraint is not None and conditions is not None:
+            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:
+                for cond in conditions:
+                    if c_val == cond.get("dps_val"):
+                        return cond
+        return None
+
     def get_values_to_set(self, device, value):
         """Return the dps values that would be set when setting to value"""
         result = value
@@ -359,11 +377,10 @@ class TuyaDpsConfig:
                 result = mapping["dps_val"]
                 replaced = True
             # Conditions may have side effect of setting another value.
-            if "conditions" in mapping and "constraint" in mapping:
+            cond = self._active_condition(mapping, device)
+            if cond is not None and cond.get("value") == value:
                 c_dps = self._entity.find_dps(mapping["constraint"])
-                for c in mapping["conditions"]:
-                    if "value" in c and c["value"] == value:
-                        dps_map.update(c_dps.get_values_to_set(device, c["dps_val"]))
+                dps_map.update(c_dps.get_values_to_set(device, cond["dps_val"]))
 
             if scale != 1 and isinstance(result, (int, float)):
                 _LOGGER.debug(f"Scaling {result} by {scale}")
@@ -384,9 +401,10 @@ class TuyaDpsConfig:
                     value,
                 )
 
-        if self.range is not None:
-            minimum = self.range["min"]
-            maximum = self.range["max"]
+        range = self.range(device)
+        if range is not None:
+            minimum = range["min"]
+            maximum = range["max"]
             if result < minimum or result > maximum:
                 raise ValueError(
                     f"Target {self.name} ({value}) must be between "

+ 2 - 4
tests/devices/test_gardenpac_heatpump.py

@@ -91,14 +91,12 @@ class TestGardenPACPoolHeatpump(IsolatedAsyncioTestCase):
     def test_maximum_target_temperature(self):
         self.assertEqual(self.subject.max_temp, 45)
 
-    @skip("Conditional ranges not supported yet")
     def test_minimum_fahrenheit_temperature(self):
-        self.dps[UNITS_DPS] = "F"
+        self.dps[UNITS_DPS] = False
         self.assertEqual(self.subject.min_temp, 60)
 
-    @skip("Conditional ranges not supported yet")
     def test_maximum_fahrenheit_temperature(self):
-        self.dps[UNITS_DPS] = "F"
+        self.dps[UNITS_DPS] = False
         self.assertEqual(self.subject.max_temp, 115)
 
     async def test_legacy_set_temperature_with_temperature(self):

+ 10 - 10
tests/devices/test_goldair_dehumidifier.py

@@ -264,32 +264,32 @@ class TestGoldairDehumidifier(IsolatedAsyncioTestCase):
     async def test_set_target_humidity_raises_error_outside_of_normal_preset(self):
         self.dps[PRESET_DPS] = PRESET_LOW
         with self.assertRaisesRegex(
-            ValueError, "Target humidity can only be changed while in Normal mode"
+            AttributeError, "target_humidity cannot be set at this time"
         ):
             await self.subject.async_set_humidity(50)
 
         self.dps[PRESET_DPS] = PRESET_HIGH
         with self.assertRaisesRegex(
-            ValueError, "Target humidity can only be changed while in Normal mode"
+            AttributeError, "target_humidity cannot be set at this time"
         ):
             await self.subject.async_set_humidity(50)
 
         self.dps[PRESET_DPS] = PRESET_LOW
         with self.assertRaisesRegex(
-            ValueError, "Target humidity can only be changed while in Normal mode"
+            AttributeError, "target_humidity cannot be set at this time"
         ):
             await self.subject.async_set_humidity(50)
 
         self.dps[PRESET_DPS] = PRESET_DRY_CLOTHES
         with self.assertRaisesRegex(
-            ValueError, "Target humidity can only be changed while in Normal mode"
+            AttributeError, "target_humidity cannot be set at this time"
         ):
             await self.subject.async_set_humidity(50)
 
         self.dps[PRESET_DPS] = PRESET_NORMAL
         self.dps[AIRCLEAN_DPS] = True
         with self.assertRaisesRegex(
-            ValueError, "Target humidity can only be changed while in Normal mode"
+            AttributeError, "target_humidity cannot be set at this time"
         ):
             await self.subject.async_set_humidity(50)
 
@@ -495,24 +495,24 @@ class TestGoldairDehumidifier(IsolatedAsyncioTestCase):
     def test_fan_modes_reflect_preset_mode(self):
         self.dps[PRESET_DPS] = PRESET_NORMAL
         self.assertCountEqual(self.subject.fan_modes, [FAN_LOW, FAN_HIGH])
-        self.assertCountEqual(self.fan.speed_count, 2)
+        self.assertEqual(self.fan.speed_count, 2)
 
         self.dps[PRESET_DPS] = PRESET_LOW
         self.assertEqual(self.subject.fan_modes, [FAN_LOW])
-        self.assertCountEqual(self.fan.speed_count, 0)
+        self.assertEqual(self.fan.speed_count, 0)
 
         self.dps[PRESET_DPS] = PRESET_HIGH
         self.assertEqual(self.subject.fan_modes, [FAN_HIGH])
-        self.assertCountEqual(self.fan.speed_count, 0)
+        self.assertEqual(self.fan.speed_count, 0)
 
         self.dps[PRESET_DPS] = PRESET_DRY_CLOTHES
         self.assertEqual(self.subject.fan_modes, [FAN_HIGH])
-        self.assertCountEqual(self.fan.speed_count, 0)
+        self.assertEqual(self.fan.speed_count, 0)
 
         self.dps[PRESET_DPS] = PRESET_NORMAL
         self.dps[AIRCLEAN_DPS] = True
         self.assertEqual(self.subject.fan_modes, [FAN_HIGH])
-        self.assertCountEqual(self.fan.speed_count, 0)
+        self.assertEqual(self.fan.speed_count, 0)
 
     async def test_set_fan_mode_to_low_succeeds_in_normal_preset(self):
         self.dps[PRESET_DPS] = PRESET_NORMAL

+ 8 - 11
tests/devices/test_goldair_fan.py

@@ -272,13 +272,12 @@ class TestGoldairFan(IsolatedAsyncioTestCase):
         ):
             await self.subject.async_set_percentage(80)
 
-    @skip("Complex conditions not supported yet")
     async def test_set_speed_in_sleep_mode_snaps(self):
         self.dps[PRESET_DPS] = "sleep"
         async with assert_device_properties_set(self.subject._device, {FANMODE_DPS: 8}):
             await self.subject.async_set_percentage(75)
 
-    @skip("Complex conditions not supported yet")
+    @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)))
@@ -292,29 +291,27 @@ class TestGoldairFan(IsolatedAsyncioTestCase):
         self.dps[PRESET_DPS] = None
         self.assertEqual(self.climate.fan_modes, [])
 
-    @skip("Complex conditions not supported yet")
     def test_climate_fan_mode_for_normal_preset(self):
         self.dps[PRESET_DPS] = "normal"
 
-        self.dps[FANMODE_DPS] = "1"
+        self.dps[FANMODE_DPS] = 1
         self.assertEqual(self.climate.fan_mode, 1)
 
-        self.dps[FANMODE_DPS] = "6"
+        self.dps[FANMODE_DPS] = 6
         self.assertEqual(self.climate.fan_mode, 6)
 
-        self.dps[FANMODE_DPS] = "12"
+        self.dps[FANMODE_DPS] = 12
         self.assertEqual(self.climate.fan_mode, 12)
 
         self.dps[FANMODE_DPS] = None
         self.assertEqual(self.climate.fan_mode, None)
 
-    @skip("Complex conditions not supported yet")
     async def test_climate_set_fan_mode_for_normal_preset(self):
         self.dps[PRESET_DPS] = "normal"
 
         async with assert_device_properties_set(
             self.climate._device,
-            {FANMODE_DPS: "6"},
+            {FANMODE_DPS: 6},
         ):
             await self.climate.async_set_fan_mode(6)
 
@@ -322,13 +319,13 @@ class TestGoldairFan(IsolatedAsyncioTestCase):
     def test_climate_fan_mode_for_eco_preset(self):
         self.dps[PRESET_DPS] = "nature"
 
-        self.dps[FANMODE_DPS] = "4"
+        self.dps[FANMODE_DPS] = 4
         self.assertEqual(self.climate.fan_mode, 1)
 
-        self.dps[FANMODE_DPS] = "8"
+        self.dps[FANMODE_DPS] = 8
         self.assertEqual(self.climate.fan_mode, 2)
 
-        self.dps[FANMODE_DPS] = "12"
+        self.dps[FANMODE_DPS] = 12
         self.assertEqual(self.climate.fan_mode, 3)
 
         self.dps[FANMODE_DPS] = None

+ 3 - 8
tests/devices/test_goldair_gpph_heater.py

@@ -126,7 +126,6 @@ class TestGoldairHeater(IsolatedAsyncioTestCase):
     def test_target_temperature_step(self):
         self.assertEqual(self.subject.target_temperature_step, 1)
 
-    @skip("Conditional ranges not yet implemented")
     def test_minimum_temperature(self):
         self.dps[PRESET_DPS] = "C"
         self.assertEqual(self.subject.min_temp, 5)
@@ -135,9 +134,8 @@ class TestGoldairHeater(IsolatedAsyncioTestCase):
         self.assertEqual(self.subject.min_temp, 5)
 
         self.dps[PRESET_DPS] = "AF"
-        self.assertIs(self.subject.min_temp, None)
+        self.assertIs(self.subject.min_temp, 5)
 
-    @skip("Conditional ranges not yet implemented")
     def test_maximum_target_temperature(self):
         self.dps[PRESET_DPS] = "C"
         self.assertEqual(self.subject.max_temp, 35)
@@ -146,7 +144,7 @@ class TestGoldairHeater(IsolatedAsyncioTestCase):
         self.assertEqual(self.subject.max_temp, 21)
 
         self.dps[PRESET_DPS] = "AF"
-        self.assertIs(self.subject.max_temp, None)
+        self.assertIs(self.subject.max_temp, 5)
 
     async def test_legacy_set_temperature_with_temperature(self):
         async with assert_device_properties_set(
@@ -200,7 +198,6 @@ class TestGoldairHeater(IsolatedAsyncioTestCase):
         ):
             await self.subject.async_set_target_temperature(24.6)
 
-    @skip("Conditional ranges not supported yet")
     async def test_set_target_temperature_fails_outside_valid_range_in_comfort(self):
         self.dps[PRESET_DPS] = "C"
 
@@ -214,7 +211,6 @@ class TestGoldairHeater(IsolatedAsyncioTestCase):
         ):
             await self.subject.async_set_target_temperature(36)
 
-    @skip("Conditional ranges not supported yet")
     async def test_set_target_temperature_fails_outside_valid_range_in_eco(self):
         self.dps[PRESET_DPS] = "ECO"
 
@@ -228,12 +224,11 @@ class TestGoldairHeater(IsolatedAsyncioTestCase):
         ):
             await self.subject.async_set_target_temperature(22)
 
-    @skip("Conditional ranges not supported yet")
     async def test_set_target_temperature_fails_in_anti_freeze(self):
         self.dps[PRESET_DPS] = "AF"
 
         with self.assertRaisesRegex(
-            ValueError, "You cannot set the temperature in Anti-freeze mode"
+            AttributeError, "temperature cannot be set at this time"
         ):
             await self.subject.async_set_target_temperature(25)