mirror of
https://github.com/esphome/esphome.git
synced 2026-09-28 07:20:24 +00:00
[light] Resume the previous effect on off/on (#17791)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
co-authored by
J. Nick Koston
parent
b9dfae50c1
commit
b1d07759e2
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -217,6 +217,7 @@ light:
|
||||
name: Binary Light
|
||||
output: light_test_binary
|
||||
restore_mode: RESTORE_AND_ON
|
||||
resume_effect: true
|
||||
effects:
|
||||
- strobe:
|
||||
on_state:
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user