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:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user