mirror of
https://github.com/esphome/esphome.git
synced 2026-08-18 03:29:23 +08:00
[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:
co-authored by
Copilot Autofix powered by AI
Jonathan Swoboda
parent
3ffc3a9610
commit
629afd38f6
@@ -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.
|
||||
|
||||
@@ -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%
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user