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

fix (device_config): handle 2 different use cases for value_redirect

in #3987, a change was made to the way value_redirect was applied when
setting values, to properly handle the case for redirecting to alternate dps
when a dps is missing.

But other devices were already using value_redirect to handle
redirecting to dipswitch style Tuya interfaces for multi-value
settings (such as fan speed or presets). That was broken by the
change, as reported in #4724

Handle both these use cases by applying #3987 change only when there
is not a reverse mapping for the specific value being set.

Add test cases for both these use cases, to avoid future regression.
Jason Rumney 12 часов назад
Родитель
Сommit
271265239e
2 измененных файлов с 110 добавлено и 5 удалено
  1. 14 5
      custom_components/tuya_local/helpers/device_config.py
  2. 96 0
      tests/test_device_config.py

+ 14 - 5
custom_components/tuya_local/helpers/device_config.py

@@ -946,19 +946,28 @@ class TuyaDpsConfig:
         if self.readonly:
             return dps_map
 
-        # Special case: if the current value has a redirect mapping,
-        # follow that.
+        # Use cases for value_redirect:
+        #  1. To merge multiple dps into a single HA setting (eg where the
+        #     manufacturer has chosen to implement speeds as dipswitch type
+        #     binary dps rather than a single dp with multiple values)
+        #     This style will have values on the main dp alongside the dp
+        #     to redirect to.
+        #  2. Alternate dps to cover multiple device variants with a single
+        #     config. This variant covers the same values on each dp, and
+        #     the redirect should be followed first (typically conditional
+        #     on the dps_val being None).
         current_value = device.get_property(self.id)
         current_mapping = self._find_map_for_dps(current_value, device)
-        if current_mapping:
+        mapping = self._find_map_for_value(value, device)
+        # Case 2 above: there is no value specific mapping, but based on
+        # current dps_val we should redirect.
+        if current_mapping and not mapping:
             redirect = current_mapping.get("value_redirect")
             if redirect:
                 return self._entity.find_dps(redirect).get_values_to_set(
                     device,
                     value,
                 )
-        # If no redirect, we need to check for mapped values in reverse
-        mapping = self._find_map_for_value(value, device)
         scale = self.scale(device)
         mask = self.mask
         if mapping:

+ 96 - 0
tests/test_device_config.py

@@ -18,6 +18,7 @@ from custom_components.tuya_local.helpers.device_config import (
 from custom_components.tuya_local.sensor import TuyaLocalSensor
 
 from .const import GPPH_HEATER_PAYLOAD, KOGAN_HEATER_PAYLOAD
+from .helpers import assert_device_properties_set
 
 PRODUCT_SCHEMA = vol.Schema(
     {
@@ -302,6 +303,16 @@ KNOWN_DPS = {
 }
 
 
+def mock_device(dps, mocker):
+    """Helper function to create a mock device with specified dps."""
+    device = mocker.MagicMock()
+    device.get_property.side_effect = lambda id: dps.get(id)
+    device.has_returned_state = True
+    device.unique_id = "test_device_id"
+    device.name = "Test Device"
+    return device
+
+
 def test_can_find_config_files():
     """Test that the config files can be found by the parser."""
     found = False
@@ -811,3 +822,88 @@ def test_matched_product_id_with_conflict_rejected():
     """Test that matching with product id fails when there is a conflict"""
     cfg = get_config("smartplugv1")
     assert not cfg.matches({"1": "wrong_type"}, ["37mnhia3pojleqfh"])
+
+
+def test_multi_stage_redirect(mocker):
+    """Test that multi stage redirects work correctly for read."""
+
+    # Redirect used to combine multiple dps into a single value
+    kc_cfg = get_config("kcvents_vt501_fan")
+    for entity in kc_cfg.all_entities():
+        if entity.entity == "fan":
+            fan = entity
+            break
+    assert fan is not None
+    speed = fan.find_dps("speed")
+    assert speed is not None
+    dps = {"1": True, "101": True, "102": False, "103": False}
+    device = mock_device(dps, mocker)
+    assert speed.values(device) == [33, 66, 100]
+    assert speed.get_value(device) == 33
+    dps["101"] = False
+    dps["102"] = True
+    assert speed.get_value(device) == 66
+    dps["102"] = False
+    dps["103"] = True
+    assert speed.get_value(device) == 100
+
+    # Redirect used for alternate dps
+    dewin_cfg = get_config("dewin_kws306wf_energymeter")
+    for entity in dewin_cfg.all_entities():
+        if entity.entity == "switch" and entity.name is None:
+            switch = entity
+            break
+    assert switch is not None
+    main = switch.find_dps("switch")
+    alt = switch.find_dps("alt")
+    assert main is not None and alt is not None
+    dps = {"16": True, "141": None}
+    device = mock_device(dps, mocker)
+    assert main.get_value(device) is True
+    dps["16"] = False
+    assert main.get_value(device) is False
+    dps["141"] = True
+    dps["16"] = None
+    assert main.get_value(device) is True
+    dps["141"] = False
+    assert main.get_value(device) is False
+
+
+@pytest.mark.asyncio
+async def test_setting_multi_stage_redirect(mocker):
+    """Test that multi stage redirects work correctly for write."""
+
+    # Redirect used to combine multiple dps into a single value
+    kc_cfg = get_config("kcvents_vt501_fan")
+    for entity in kc_cfg.all_entities():
+        if entity.entity == "fan":
+            fan = entity
+            break
+    assert fan is not None
+    speed = fan.find_dps("speed")
+    assert speed is not None
+    dps = {"1": True, "101": True, "102": False, "103": False}
+    device = mock_device(dps, mocker)
+    async with assert_device_properties_set(device, {"102": True}):
+        await speed.async_set_value(device, 66)
+    async with assert_device_properties_set(device, {"103": True}):
+        await speed.async_set_value(device, 100)
+
+    # Redirect used for alternate dps
+    dewin_cfg = get_config("dewin_kws306wf_energymeter")
+    for entity in dewin_cfg.all_entities():
+        if entity.entity == "switch" and entity.name is None:
+            switch = entity
+            break
+    assert switch is not None
+    main = switch.find_dps("switch")
+    alt = switch.find_dps("alt")
+    assert main is not None and alt is not None
+    dps = {"16": True, "141": None}
+    device = mock_device(dps, mocker)
+    async with assert_device_properties_set(device, {"16": False}):
+        await main.async_set_value(device, False)
+    dps["16"] = None
+    dps["141"] = True
+    async with assert_device_properties_set(device, {"141": False}):
+        await main.async_set_value(device, False)