Procházet zdrojové kódy

fix(light): complete brightness range clamping (#5195)

* fix(light): complete brightness_to_value range clamping

The previous workaround (c33c67b2) only covered the regular brightness
path. The white brightness path (ATTR_WHITE) had the same issue. It also
used duplicated logic that was susceptible to future drift.

Extract the brightness range scaling algorithm into a helper method and
apply it to both ATTR_BRIGHTNESS and ATTR_WHITE paths. This ensures both
paths preserve the intended behavior (snapping bright==1 to the minimum)
while safely clamping values that fall below the target range.

Test coverage expanded significantly to cover both paths and all edge
cases (wide ranges, proportional mid-values, upper bounds).

* refactor(light): clarify brightness DP helper name

* refactor(light): clarify HA brightness helper naming

---------

Co-authored-by: Yuris Auzins <zuz666@users.noreply.github.com>
Zuz666 před 1 měsícem
rodič
revize
bc720ba712
2 změnil soubory, kde provedl 97 přidání a 13 odebrání
  1. 11 13
      custom_components/tuya_local/light.py
  2. 86 0
      tests/test_light.py

+ 11 - 13
custom_components/tuya_local/light.py

@@ -37,6 +37,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
     )
 
 
+def _ha_brightness_to_dp_value(ha_brightness, dp_range):
+    """Convert HA brightness to a clamped device DP value."""
+    if ha_brightness == 1 and dp_range[0] != 0:
+        return dp_range[0]
+
+    dp_value = color_util.brightness_to_value(dp_range, ha_brightness)
+    return max(dp_range[0], dp_value)
+
+
 class TuyaLocalLight(TuyaLocalEntity, LightEntity):
     """Representation of a Tuya WiFi-connected light."""
 
@@ -295,11 +304,7 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
                 bright = params.get(ATTR_WHITE)
                 r = self._brightness_dps.range(self._device)
                 if r:
-                    # ensure full range is used
-                    if bright == 1 and r[0] != 0:
-                        bright = r[0]
-                    else:
-                        bright = color_util.brightness_to_value(r, bright)
+                    bright = _ha_brightness_to_dp_value(bright, r)
 
                 _LOGGER.info(
                     "%s setting white brightness to %d", self._config.config_id, bright
@@ -472,14 +477,7 @@ class TuyaLocalLight(TuyaLocalEntity, LightEntity):
 
             r = self._brightness_dps.range(self._device)
             if r:
-                # ensure full range is used
-                if bright == 1 and r[0] != 0:
-                    bright = r[0]
-                else:
-                    bright = color_util.brightness_to_value(r, bright)
-                    # workaround brightness_to_value not respecting the minimum
-                    if bright < r[0]:
-                        bright = r[0]
+                bright = _ha_brightness_to_dp_value(bright, r)
             _LOGGER.info("%s setting brightness to %d", self._config.config_id, bright)
             settings = {
                 **settings,

+ 86 - 0
tests/test_light.py

@@ -203,6 +203,92 @@ async def test_async_turn_on_with_brightness_on_packed_dp():
     assert sent_value & 0x000100000000 != 0
 
 
+@pytest.mark.parametrize(
+    ("brightness_range", "ha_value", "expected_dps", "path"),
+    [
+        # brightness_to_value() uses scale_to_ranged_value((1,255), target, bright).
+        # For small DP ranges, low HA brightness maps below the configured minimum:
+        #   (1, 6), 20   -> 20 * 6/255       = 0.470   -> below min 1
+        #   (10, 15), 20 -> 20 * 6/255 + 9    = 9.470   -> below min 10
+        #   (1, 6), 1    -> 1  * 6/255        = 0.024   -> below min 1 (HA minimum)
+        # Each case is tested via both code paths: brightness and white.
+        # 1-2. Original bug: low brightness maps below min
+        ({"min": 1, "max": 6}, 20, 1, "brightness"),
+        ({"min": 1, "max": 6}, 20, 1, "white"),
+        # 3-4. Generic min: not just tied to min=1
+        ({"min": 10, "max": 15}, 20, 10, "brightness"),
+        ({"min": 10, "max": 15}, 20, 10, "white"),
+        # 5-6. Wide range snap: ensures bright=1 snaps to physical min, not proportional (3.92 -> 4)
+        ({"min": 1, "max": 1000}, 1, 1, "brightness"),
+        ({"min": 1, "max": 1000}, 1, 1, "white"),
+        # 7-8. Wide offset range snap: proves snap uses actual DP min
+        ({"min": 10, "max": 1000}, 1, 10, "brightness"),
+        ({"min": 10, "max": 1000}, 1, 10, "white"),
+        # 9-10. Normal mid-range value: proves we don't over-clamp
+        ({"min": 1, "max": 6}, 128, 3, "brightness"),
+        ({"min": 1, "max": 6}, 128, 3, "white"),
+        # 11-12. Maximum value: proves upper bound is reachable
+        ({"min": 1, "max": 6}, 255, 6, "brightness"),
+        ({"min": 1, "max": 6}, 255, 6, "white"),
+    ],
+)
+@pytest.mark.asyncio
+async def test_async_turn_on_clamps_low_brightness_to_range_min(
+    brightness_range, ha_value, expected_dps, path
+):
+    """Low non-zero HA brightness should clamp to the first DP range value.
+
+    Tests both the regular brightness path (ATTR_BRIGHTNESS) and the white
+    brightness path (ATTR_WHITE) in async_turn_on, ensuring brightness_to_value
+    results below the DP minimum are clamped correctly.
+    """
+    mock_device = AsyncMock()
+    mock_device.get_property = Mock()
+
+    if path == "white":
+        dps_config = [
+            {"id": "1", "name": "switch", "type": "boolean"},
+            {
+                "id": "2",
+                "name": "color_mode",
+                "type": "string",
+                "mapping": [
+                    {"dps_val": "white", "value": "white"},
+                    {"dps_val": "colour", "value": "hs"},
+                ],
+            },
+            {
+                "id": "3",
+                "name": "brightness",
+                "type": "integer",
+                "range": brightness_range,
+            },
+        ]
+        device_dps = {"1": True, "2": "white", "3": brightness_range["min"]}
+        call_kwargs = {"white": ha_value}
+        assert_dp = "3"
+    else:
+        dps_config = [
+            {"id": "1", "name": "switch", "type": "boolean"},
+            {
+                "id": "2",
+                "name": "brightness",
+                "type": "integer",
+                "range": brightness_range,
+            },
+        ]
+        device_dps = {"1": True, "2": brightness_range["min"]}
+        call_kwargs = {"brightness": ha_value}
+        assert_dp = "2"
+
+    mock_device.get_property.side_effect = lambda arg: device_dps[arg]
+    mock_config = Mock()
+    config = TuyaEntityConfig(mock_config, {"entity": "light", "dps": dps_config})
+    light = TuyaLocalLight(mock_device, config)
+    await light.async_turn_on(**call_kwargs)
+    mock_device.async_set_properties.assert_called_once_with({assert_dp: expected_dps})
+
+
 @pytest.mark.asyncio
 async def test_is_off_when_off_by_brightness():
     """Test that the light appears off when turned off by brightness."""