From b1d07759e20a8db4334e25cb8055f904ce9d4b16 Mon Sep 17 00:00:00 2001 From: Christoffer Andersson Date: Sun, 27 Sep 2026 14:42:36 +0200 Subject: [PATCH] [light] Resume the previous effect on off/on (#17791) Co-authored-by: J. Nick Koston --- esphome/components/light/__init__.py | 5 + esphome/components/light/light_call.cpp | 19 +++ esphome/components/light/light_call.h | 2 + esphome/components/light/light_state.h | 13 ++ esphome/core/defines.h | 1 + tests/components/light/common.yaml | 1 + .../fixtures/light_resume_effect.yaml | 47 ++++++ tests/integration/test_light_resume_effect.py | 147 ++++++++++++++++++ 8 files changed, 235 insertions(+) create mode 100644 tests/integration/fixtures/light_resume_effect.yaml create mode 100644 tests/integration/test_light_resume_effect.py diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 9ed183ea20..b175620bbf 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -87,6 +87,7 @@ IS_PLATFORM_COMPONENT = True DOMAIN = "light" CONF_GAMMA_TABLE_ID = "gamma_table_id" +CONF_RESUME_EFFECT = "resume_effect" @dataclass @@ -394,6 +395,7 @@ LIGHT_SCHEMA = ( } ), cv.Optional(CONF_INITIAL_STATE): LIGHT_STATE_SCHEMA, + cv.Optional(CONF_RESUME_EFFECT, default=False): cv.boolean, } ) ) @@ -524,6 +526,9 @@ async def setup_light_core_(light_var, config, output_var): # runtime actually understands. initial_state_config = config.get(CONF_INITIAL_STATE) initial_statements = await _initial_state_statements(initial_state_config) + if config[CONF_RESUME_EFFECT]: + cg.add_define("USE_LIGHT_RESUME_EFFECT") + cg.add(light_var.set_resume_effect(True)) restore_mode = config.get(CONF_RESTORE_MODE) restore_state_config = config.get(CONF_RESTORE_STATE) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 4251565e85..f540d2f31f 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -193,6 +193,11 @@ LightColorValues LightCall::validate_() { auto *name = this->parent_->get_name().c_str(); auto traits = this->parent_->get_traits(); +#ifdef USE_LIGHT_RESUME_EFFECT + // Snapshot before the adjustments below add flags of their own + const bool plain_turn_on = this->has_state() && this->state_ && (this->flags_ & ~STATE_ONLY_FLAGS_MASK) == 0; +#endif // USE_LIGHT_RESUME_EFFECT + // Color mode check if (this->has_color_mode() && !traits.supports_color_mode(this->color_mode_)) { ESP_LOGW(TAG, "'%s' does not support color mode %s", name, LOG_STR_ARG(color_mode_to_human(this->color_mode_))); @@ -334,6 +339,15 @@ LightColorValues LightCall::validate_() { // validate transition length/flash length/effect not used at the same time bool supports_transition = color_mode & ColorCapability::BRIGHTNESS; +#ifdef USE_LIGHT_RESUME_EFFECT + // A plain turn-on from off brings back the effect that was running when the light was turned off + if (this->parent_->resume_effect_ && plain_turn_on && !this->parent_->remote_values.is_on() && + this->parent_->previous_effect_index_ != 0) { + this->effect_ = this->parent_->previous_effect_index_; + this->set_flag_(FLAG_HAS_EFFECT); + } +#endif // USE_LIGHT_RESUME_EFFECT + // If effect is already active, remove effect start if (this->has_effect_() && this->effect_ == this->parent_->active_effect_index_) { this->clear_flag_(FLAG_HAS_EFFECT); @@ -376,6 +390,11 @@ LightColorValues LightCall::validate_() { // Reason: When user turns off the light in frontend, the effect should also stop bool target_state = this->has_state() ? this->state_ : v.is_on(); if (!this->has_flash_() && !target_state) { +#ifdef USE_LIGHT_RESUME_EFFECT + // Remember what was running, including no effect, when a lit light is explicitly turned off + if (this->parent_->resume_effect_ && explicit_turn_off_request && this->parent_->remote_values.is_on()) + this->parent_->previous_effect_index_ = this->parent_->active_effect_index_; +#endif // USE_LIGHT_RESUME_EFFECT if (this->has_effect_()) { log_invalid_parameter(name, LOG_STR("cannot start effect when turning off")); this->clear_flag_(FLAG_HAS_EFFECT); diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index e3352de727..c9f6af7c91 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -215,6 +215,8 @@ class LightCall { FLAG_SAVE = 1 << 15, }; static constexpr uint16_t CLAMP_FLAGS_MASK = 0x00FFu; // bits 0-7 + // Flags a plain turn-on may carry; any other flag means the caller asked for something specific + static constexpr uint16_t STATE_ONLY_FLAGS_MASK = FLAG_HAS_STATE | FLAG_PUBLISH | FLAG_SAVE; inline bool has_transition_() { return (this->flags_ & FLAG_HAS_TRANSITION) != 0; } inline bool has_flash_() { return (this->flags_ & FLAG_HAS_FLASH) != 0; } diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index f20fecc8dd..fcb2ca40c9 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -208,6 +208,11 @@ class LightState : public EntityBase, public Component { /// Set whether this light persists its state to preferences at all. void set_save_enabled(bool save_enabled) { this->save_enabled_ = save_enabled; } +#ifdef USE_LIGHT_RESUME_EFFECT + /// Set whether a plain turn-on restores the effect that was active when the light was turned off. + void set_resume_effect(bool resume_effect) { this->resume_effect_ = resume_effect; } +#endif // USE_LIGHT_RESUME_EFFECT + /// Return whether the light has any effects that meet the trait requirements. bool supports_effects() const { return !this->effects_.empty(); } @@ -392,6 +397,10 @@ class LightState : public EntityBase, public Component { /// 1-based index of the active effect, 0 if none; codegen caps effects at MAX_EFFECTS in effects.py uint16_t active_effect_index_{}; +#ifdef USE_LIGHT_RESUME_EFFECT + /// The effect index that was active when the light was last turned off; shares the active index's word + uint16_t previous_effect_index_{0}; +#endif // USE_LIGHT_RESUME_EFFECT /// Whether the light value should be written in the next cycle. bool next_write_{true}; // a plain bool: it is the most written flag, and still shares the index's word // for effects, true if a transformer (transition) is active. @@ -402,6 +411,10 @@ class LightState : public EntityBase, public Component { /// True while the active transformer publishes current_values on an interval from loop(). bool transition_publish_enabled_ : 1 {false}; #endif +#ifdef USE_LIGHT_RESUME_EFFECT + /// Whether a plain turn-on restores the effect that was active when the light was turned off. + bool resume_effect_ : 1 {false}; +#endif // USE_LIGHT_RESUME_EFFECT }; } // namespace esphome::light diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 757e3cfe79..1226847fcf 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -91,6 +91,7 @@ #define USE_LIGHT #define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT +#define USE_LIGHT_RESUME_EFFECT #define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL #define USE_LOCK #define USE_LOGGER diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 15829b1463..c40cfd5e3a 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -217,6 +217,7 @@ light: name: Binary Light output: light_test_binary restore_mode: RESTORE_AND_ON + resume_effect: true effects: - strobe: on_state: diff --git a/tests/integration/fixtures/light_resume_effect.yaml b/tests/integration/fixtures/light_resume_effect.yaml new file mode 100644 index 0000000000..e52dc1ea67 --- /dev/null +++ b/tests/integration/fixtures/light_resume_effect.yaml @@ -0,0 +1,47 @@ +esphome: + name: light-resume-effect + +host: + +api: + +logger: + level: DEBUG + +output: + - platform: template + id: light_output + type: float + write_action: + - logger.log: + format: "LIGHT_OUTPUT:%.2f" + args: [state] + +light: + - platform: monochromatic + name: "Test Light Resume" + id: test_light_resume + output: light_output + resume_effect: true + effects: + - pulse: + name: "Pulse Effect" + transition_length: 20ms + update_interval: 50ms + - strobe: + name: "Strobe Effect" + colors: + - state: true + duration: 50ms + - state: false + duration: 50ms + - platform: monochromatic + name: "Test Light No Resume" + id: test_light_no_resume + output: light_output + resume_effect: false + effects: + - pulse: + name: "Pulse Effect" + transition_length: 20ms + update_interval: 50ms diff --git a/tests/integration/test_light_resume_effect.py b/tests/integration/test_light_resume_effect.py new file mode 100644 index 0000000000..dce5feb9ab --- /dev/null +++ b/tests/integration/test_light_resume_effect.py @@ -0,0 +1,147 @@ +"""Integration test verifying light effect restore on/off cycles. + +Tests that when resume_effect is enabled, the previously active effect +is restored when the light is turned back on without explicit effect/color +parameters. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from aioesphomeapi import ColorMode, EntityState, LightInfo, LightState +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_resume_effect( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """When resume_effect is enabled, effect persists after off/on cycle.""" + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + light_resume = next(e for e in entities if e.object_id == "test_light_resume") + light_no_resume = next( + e for e in entities if e.object_id == "test_light_no_resume" + ) + + 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) + + # Drain initial state burst + 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( + light: LightInfo, 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) + + # Test 1: Start with Pulse effect + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + assert state.state is True + assert state.effect == "Pulse Effect" + + # Test 2: Turn off + state = await send_and_wait(light_resume, state=False) + assert state.state is False + + # Test 3: Turn on without specifying effect — should restore Pulse Effect + state = await send_and_wait(light_resume, state=True) + assert state.state is True + assert state.effect == "Pulse Effect", ( + "Effect should be restored when turning on without explicit effect parameter" + ) + + # Test 4: Switch to Strobe effect + state = await send_and_wait(light_resume, effect="Strobe Effect") + assert state.effect == "Strobe Effect" + + # Test 5: Turn off + state = await send_and_wait(light_resume, state=False) + assert state.state is False + + # Test 6: Turn on without effect — should restore Strobe Effect + state = await send_and_wait(light_resume, state=True) + assert state.state is True + assert state.effect == "Strobe Effect", ( + "Effect should be restored to Strobe after turning on" + ) + + # Test 7: Explicitly set effect to None while turning on — should not restore + state = await send_and_wait(light_resume, state=False) + assert state.state is False + + state = await send_and_wait(light_resume, state=True, effect="None") + assert state.state is True + assert state.effect == "None", ( + "Explicit effect=None should override restoration" + ) + + # Test 8: Turn off, then on with explicit effect — should use explicit effect + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + assert state.effect == "Pulse Effect" + + # Test 9: a turn-on that asks for something specific does not restore, and the + # effect it replaced must not come back on a later plain off/on + state = await send_and_wait(light_resume, state=False) + assert state.state is False + state = await send_and_wait(light_resume, state=True, brightness=0.5) + assert state.effect == "None", "A turn-on with brightness should not restore" + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait(light_resume, state=True) + assert state.effect == "None", ( + "An effect dropped on an earlier cycle must not return" + ) + + # Test 10: a plain turn-on sent to a light that is already on never starts the + # remembered effect + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait(light_resume, state=True, brightness=0.5) + assert state.effect == "None" + state = await send_and_wait(light_resume, state=True) + assert state.effect == "None", ( + "A lit light must not pick up the remembered effect" + ) + + # Test 11: a turn-on that names a color mode is not plain, so it does not restore + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait( + light_resume, state=True, color_mode=ColorMode.BRIGHTNESS + ) + assert state.effect == "None", "A turn-on with a color mode should not restore" + + # Test 12: Turn on effect, then off, then on without effect — should not restore Pulse Effect + state = await send_and_wait(light_no_resume, state=True, effect="Pulse Effect") + assert state.state is True + assert state.effect == "Pulse Effect" + + state = await send_and_wait(light_no_resume, state=False) + assert state.state is False + + state = await send_and_wait(light_no_resume, state=True) + assert state.state is True + assert state.effect == "None", ( + "Effect should NOT be restored when resume_effect=false" + )