[light] Fix pulse and other effects (#17645)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
This commit is contained in:
Clyde Stubbs
2026-07-21 08:18:15 +12:00
committed by Jesse Hills
co-authored by Copilot Autofix powered by AI Jonathan Swoboda
parent 3ffc3a9610
commit 629afd38f6
7 changed files with 177 additions and 13 deletions
+5 -8
View File
@@ -219,14 +219,11 @@ LightColorValues LightCall::validate_() {
this->set_flag_(FLAG_HAS_STATE);
}
// Make sure a turn-on makes the light visible: if the resulting brightness would be zero
// (e.g. restored from a brightness=0 turn-off), reset it to full brightness.
if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS)) {
float brightness = this->has_brightness() ? this->brightness_ : this->parent_->remote_values.get_brightness();
if (brightness == 0.0f) {
this->brightness_ = 1.0f;
this->set_flag_(FLAG_HAS_BRIGHTNESS);
}
// Make sure a simple (no specific brightness) turn-on makes the light visible
if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS) && !this->has_brightness() &&
this->parent_->remote_values.get_brightness() == 0.0f) {
this->brightness_ = 1.0f;
this->set_flag_(FLAG_HAS_BRIGHTNESS);
}
// Set color brightness to 100% if currently zero and a color is set.
+8
View File
@@ -71,6 +71,14 @@ void LightState::setup() {
break;
}
// A light coming up on boot must never end up on-but-invisible: if the resolved restore
// state is on but its brightness is zero (e.g. a stale/persisted value from before a
// forced-on restore mode, or an inverted restore flipping a dim-to-0 off state to on),
// reset it to full brightness.
if (recovered.state && recovered.brightness == 0.0f) {
recovered.brightness = 1.0f;
}
call.set_color_mode_if_supported(recovered.color_mode);
call.set_state(recovered.state);
call.set_brightness_if_supported(recovered.brightness);
@@ -0,0 +1,35 @@
esphome:
name: light-effect-zero-bright
host:
api: # Port will be automatically injected
logger:
level: DEBUG
output:
- platform: template
id: pulse_output
type: float
write_action:
- logger.log:
format: "PULSE_OUTPUT:%.4f"
args: [state]
light:
- platform: monochromatic
name: "Test Pulse Light"
id: test_pulse_light
output: pulse_output
effects:
- pulse:
name: "Fast Pulse"
transition_length: 20ms
update_interval: 50ms
min_brightness: 0%
max_brightness: 100%
- strobe:
name: "Fast Strobe"
colors:
- state: true
duration: 50ms
- state: false
duration: 50ms
@@ -21,6 +21,11 @@ output:
type: float
write_action:
- lambda: ""
- platform: template
id: test_restore_and_on_output
type: float
write_action:
- lambda: ""
light:
- platform: rgb
@@ -37,3 +42,16 @@ light:
red: 1.0
green: 0.5
blue: 0.0
- platform: monochromatic
name: "Test Restore And On Light"
id: test_restore_and_on_light
output: test_restore_and_on_output
restore_mode: RESTORE_AND_ON
# Simulates a stale/persisted zero brightness: RESTORE_AND_ON always forces the light
# on at boot regardless of the recovered state, so a leftover brightness of 0 must not
# leave the light on-but-invisible.
initial_state:
color_mode: BRIGHTNESS
state: false
brightness: 0%
+5 -5
View File
@@ -341,14 +341,14 @@ async def test_light_calls(
assert state.state is True
assert state.brightness == pytest.approx(1.0)
# Test 31b: An explicit turn-on with brightness 0 still resets to full
# brightness - a turn-on must never leave the light on-but-invisible. This
# is the same path the restore logic exercises (set_state(true) +
# set_brightness(0) from a persisted brightness=0 turn-off).
# Test 31b: An explicit turn-on with brightness 0 respects the explicit value and
# stays dark. Only a turn-on with no brightness specified (Test 31) restores
# visibility -- an explicit brightness request (e.g. from a light effect's dark
# phase) is never overridden.
client.light_command(key=rgbcw_light.key, state=True, brightness=0.0)
state = await wait_for_state_change(rgbcw_light.key)
assert state.state is True
assert state.brightness == pytest.approx(1.0)
assert state.brightness == pytest.approx(0.0)
# Test 32: Turning a light on when it already has nonzero brightness leaves
# the brightness unchanged (the reset only happens when brightness is 0).
@@ -0,0 +1,91 @@
"""Integration test verifying light effects can dim to 0% brightness while staying on.
Regression test for https://github.com/esphome/esphome/issues/17639, where PR #17103's
"make turn-on visible" logic in LightCall::validate_() also clobbered brightness set by a
running effect (e.g. pulse, strobe), forcing it back to 100% and breaking the dark phase
of those effects.
Effect ticks are published with `publish: false` (so Home Assistant isn't spammed with
every frame), so the effect's actual output can't be observed via API state broadcasts.
Instead, this test reads the output component's log lines, which are written on every
update regardless of the publish flag.
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from aioesphomeapi import EntityState, LightState
import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_light_effect_zero_brightness(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Pulse and strobe effects must be able to reach 0% brightness while the light stays on."""
output_pattern = re.compile(r"PULSE_OUTPUT:([\d.]+)")
observed: list[float] = []
def on_log_line(line: str) -> None:
match = output_pattern.search(line)
if match:
observed.append(float(match.group(1)))
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
light = next(e for e in entities if e.object_id == "test_pulse_light")
state_futures: dict[int, asyncio.Future[LightState]] = {}
def on_state(state: EntityState) -> None:
if isinstance(state, LightState) and state.key in state_futures:
future = state_futures[state.key]
if not future.done():
future.set_result(state)
# ESPHome sends the current state of every entity right after connecting; drain
# that initial burst so it can't be mistaken for the response to a command below.
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState:
"""Send a light command and wait for the matching state response."""
state_futures[light.key] = asyncio.get_running_loop().create_future()
client.light_command(key=light.key, **kwargs)
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
# Turn the light on first so the effect starts from a known, visible state.
state = await send_and_wait(state=True, brightness=1.0)
assert state.state is True
assert state.brightness == pytest.approx(1.0)
for effect_name in ("Fast Pulse", "Fast Strobe"):
observed.clear()
state = await send_and_wait(effect=effect_name)
assert state.effect == effect_name
# Let several effect cycles run (update_interval/duration is 50ms in the fixture).
await asyncio.sleep(1.0)
assert observed, f"No output observed while running effect {effect_name!r}"
assert min(observed) == pytest.approx(0.0, abs=0.01), (
f"Effect {effect_name!r} never dimmed to 0% brightness while the light "
f"stayed on -- got min={min(observed):.4f} (values: {observed})"
)
assert max(observed) > 0.5, (
f"Effect {effect_name!r} never reached full brightness -- "
f"got max={max(observed):.4f}"
)
client.light_command(key=light.key, effect="None")
@@ -11,6 +11,14 @@ from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
"""Keep host preferences per-test so RESTORE_AND_ON never loads a stale value left
behind by a previous run (host preferences otherwise persist to ~/.esphome/prefs,
keyed only by device name)."""
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
@pytest.mark.asyncio
async def test_light_initial_state(
yaml_config: str,
@@ -36,3 +44,10 @@ async def test_light_initial_state(
assert state.red == pytest.approx(1.0, abs=0.01)
assert state.green == pytest.approx(0.5, abs=0.01)
assert state.blue == pytest.approx(0.0, abs=0.01)
# Regression test: RESTORE_AND_ON always forces the light on at boot, even when
# the recovered/initial brightness was 0 -- it must never come up on-but-invisible.
restore_and_on_light = require_entity(entities, "test_restore_and_on_light")
restore_and_on_state = helper.initial_states[restore_and_on_light.key]
assert restore_and_on_state.state is True
assert restore_and_on_state.brightness == pytest.approx(1.0)