From ad84b60ae408ffc663de4fd92b71da2cd91bfbc7 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 11 May 2026 01:42:02 +0300 Subject: [PATCH] fix(ha-light): apply brightness_scale once and respect boost multipliers `_send_entity_color` was multiplying the per-mapping `brightness_scale` into the brightness payload twice when the effective scale was below 1, yielding a quartered output for a configured half-scale. Conversely, when the value-stream multiplier exceeded 1.0 with a default scale, the entire scaling step was skipped and the boost was lost. Compute brightness as `clamp(max(r,g,b) * bs * vs, 0, 255)` once and ship it directly, with regression tests pinning the half-scale, boost, and 255-clamp cases. --- .../processing/ha_light_target_processor.py | 11 +- .../core/test_ha_light_target_processor.py | 106 ++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/server/src/ledgrab/core/processing/ha_light_target_processor.py b/server/src/ledgrab/core/processing/ha_light_target_processor.py index 4223c12..83e251b 100644 --- a/server/src/ledgrab/core/processing/ha_light_target_processor.py +++ b/server/src/ledgrab/core/processing/ha_light_target_processor.py @@ -400,17 +400,16 @@ class HALightTargetProcessor(TargetProcessor): # Cache for WS preview (always, even if HA call is skipped) self._latest_entity_colors[entity_id] = (r, g, b) - # Calculate brightness (0-255) from max channel - brightness = max(r, g, b) - + # Brightness (0-255) is derived from the max channel and scaled once by + # the per-mapping brightness_scale × value-source multiplier. eff_scale + # may exceed 1.0 (a boosting value source), so we clamp at 255. bs = ( mapping.brightness_scale.value if hasattr(mapping.brightness_scale, "value") else mapping.brightness_scale ) eff_scale = bs * vs_multiplier - if eff_scale < 1.0: - brightness = int(brightness * eff_scale) + brightness = max(0, min(255, int(max(r, g, b) * eff_scale))) should_be_on = ( brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0 @@ -430,7 +429,7 @@ class HALightTargetProcessor(TargetProcessor): service_data = { "rgb_color": [r, g, b], - "brightness": min(255, int(brightness * bs)), + "brightness": brightness, } transition_val = self._transition.value if transition_val > 0: diff --git a/server/tests/core/test_ha_light_target_processor.py b/server/tests/core/test_ha_light_target_processor.py index a1bc5e9..810df21 100644 --- a/server/tests/core/test_ha_light_target_processor.py +++ b/server/tests/core/test_ha_light_target_processor.py @@ -234,6 +234,112 @@ async def test_css_mode_still_uses_per_segment_average(): assert css_mgr.released == [("css_1", "t_css")] +@pytest.mark.asyncio +async def test_brightness_scale_applied_exactly_once(): + """Regression: brightness_scale must not be double-applied. + + Past bug: brightness was first multiplied by (bs * vs_multiplier) when + eff_scale < 1.0, then multiplied by `bs` again before being sent. With + bs=0.5 and vs=1.0 the user got quarter brightness instead of half. + """ + runtime = _FakeHARuntime() + ha_mgr = _FakeHAManager(runtime) + color_stream = _FakeColorStream((200, 100, 50)) + + proc = HALightTargetProcessor( + target_id="t_b", + ha_source_id="ha_1", + source_kind="color_vs", + color_value_source_id="vs_c", + light_mappings=[_mapping("light.a", scale=0.5)], + color_tolerance=0, + ctx=_make_ctx(ha_manager=ha_mgr, vs_manager=_FakeVSManager(color_stream)), + ) + + await proc.start() + for _ in range(40): + await asyncio.sleep(0.05) + if any(c.target["entity_id"] == "light.a" for c in runtime.calls): + break + await proc.stop() + + turn_on = next(c for c in runtime.calls if c.service == "turn_on") + # max(200,100,50)=200, scaled by bs=0.5 → 100. Pre-fix bug produced 50. + assert turn_on.service_data["brightness"] == 100 + assert turn_on.service_data["rgb_color"] == [200, 100, 50] + + +@pytest.mark.asyncio +async def test_brightness_boost_via_value_stream_multiplier(): + """vs_multiplier > 1.0 must boost brightness up to the 255 cap. + + Past bug: scaling was gated behind `if eff_scale < 1.0`, so any boost + direction (vs > 1.0 with bs <= 1.0) was silently ignored. + """ + + class _BoostingStream: + def get_value(self) -> float: + return 2.0 # 2× boost + + def get_color(self): + return (50, 30, 10) + + runtime = _FakeHARuntime() + ha_mgr = _FakeHAManager(runtime) + boosting_stream = _BoostingStream() + + proc = HALightTargetProcessor( + target_id="t_boost", + ha_source_id="ha_1", + source_kind="color_vs", + color_value_source_id="vs_color", + # Brightness VS is a separate stream — wire it through bfloat source_id. + brightness=BindableFloat(1.0, source_id="vs_brightness"), + light_mappings=[_mapping("light.a", scale=1.0)], + color_tolerance=0, + ctx=_make_ctx(ha_manager=ha_mgr, vs_manager=_FakeVSManager(boosting_stream)), + ) + + await proc.start() + for _ in range(40): + await asyncio.sleep(0.05) + if any(c.target["entity_id"] == "light.a" for c in runtime.calls): + break + await proc.stop() + + turn_on = next(c for c in runtime.calls if c.service == "turn_on") + # max(50,30,10)=50, vs_multiplier=2.0 → 100. Pre-fix bug produced 50. + assert turn_on.service_data["brightness"] == 100 + + +@pytest.mark.asyncio +async def test_brightness_clamped_at_255(): + color_stream = _FakeColorStream((200, 0, 0)) + runtime = _FakeHARuntime() + ha_mgr = _FakeHAManager(runtime) + + proc = HALightTargetProcessor( + target_id="t_clamp", + ha_source_id="ha_1", + source_kind="color_vs", + color_value_source_id="vs_c", + # bs=4.0 with channel max 200 → 800, must be clamped to 255. + light_mappings=[_mapping("light.a", scale=4.0)], + color_tolerance=0, + ctx=_make_ctx(ha_manager=ha_mgr, vs_manager=_FakeVSManager(color_stream)), + ) + + await proc.start() + for _ in range(40): + await asyncio.sleep(0.05) + if any(c.target["entity_id"] == "light.a" for c in runtime.calls): + break + await proc.stop() + + turn_on = next(c for c in runtime.calls if c.service == "turn_on") + assert turn_on.service_data["brightness"] == 255 + + @pytest.mark.asyncio async def test_get_state_reports_source_kind(): runtime = _FakeHARuntime()