diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 2b13b40a16c..67fd175ce67 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -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. diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index bd778926d55..9d0181a05cf 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -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); diff --git a/tests/integration/fixtures/light_effect_zero_brightness.yaml b/tests/integration/fixtures/light_effect_zero_brightness.yaml new file mode 100644 index 00000000000..b98bed84dbc --- /dev/null +++ b/tests/integration/fixtures/light_effect_zero_brightness.yaml @@ -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 diff --git a/tests/integration/fixtures/light_initial_state.yaml b/tests/integration/fixtures/light_initial_state.yaml index 2654c76aa05..052de0a4e58 100644 --- a/tests/integration/fixtures/light_initial_state.yaml +++ b/tests/integration/fixtures/light_initial_state.yaml @@ -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% diff --git a/tests/integration/test_light_calls.py b/tests/integration/test_light_calls.py index a3a4103f5cd..b75e2fac62b 100644 --- a/tests/integration/test_light_calls.py +++ b/tests/integration/test_light_calls.py @@ -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). diff --git a/tests/integration/test_light_effect_zero_brightness.py b/tests/integration/test_light_effect_zero_brightness.py new file mode 100644 index 00000000000..6c386d4229d --- /dev/null +++ b/tests/integration/test_light_effect_zero_brightness.py @@ -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") diff --git a/tests/integration/test_light_initial_state.py b/tests/integration/test_light_initial_state.py index f1cd96dbf03..657e273fe73 100644 --- a/tests/integration/test_light_initial_state.py +++ b/tests/integration/test_light_initial_state.py @@ -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)