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.
This commit is contained in:
2026-05-11 01:42:02 +03:00
parent cdf7d94652
commit ad84b60ae4
2 changed files with 111 additions and 6 deletions
@@ -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()