[light] Add light.effect.next / light.effect.previous actions (#16491)

This commit is contained in:
Jesse Hills
2026-05-22 13:42:50 +12:00
committed by GitHub
parent 1d3eea098e
commit 0b2eb6481f
6 changed files with 283 additions and 5 deletions
+31 -2
View File
@@ -86,10 +86,22 @@ class EffectRef:
component_path: list[str | int] # path_context when the action was validated
@dataclass
class EffectCycleRef:
"""A pending light.effect.next/previous action to validate.
Records that the referenced light needs at least one effect configured.
"""
light_id: ID
component_path: list[str | int]
@dataclass
class LightData:
gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr
effect_refs: list[EffectRef] = field(default_factory=list)
effect_cycle_refs: list[EffectCycleRef] = field(default_factory=list)
def _get_data() -> LightData:
@@ -160,13 +172,15 @@ def _final_validate(config: ConfigType) -> ConfigType:
this never runs — but the ID validator will catch the missing light ID separately.
"""
data = _get_data()
if not data.effect_refs:
if not data.effect_refs and not data.effect_cycle_refs:
return config
# Drain the list so we only validate once even though
# Drain the lists so we only validate once even though
# FINAL_VALIDATE_SCHEMA runs for each light platform instance.
refs = data.effect_refs
data.effect_refs = []
cycle_refs = data.effect_cycle_refs
data.effect_cycle_refs = []
fconf = fv.full_config.get()
@@ -188,6 +202,21 @@ def _final_validate(config: ConfigType) -> ConfigType:
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
)
for ref in cycle_refs:
try:
light_path = fconf.get_path_for_id(ref.light_id)[:-1]
light_config = fconf.get_config_for_path(light_path)
except KeyError:
continue
if not light_config.get(CONF_EFFECTS):
raise cv.FinalExternalInvalid(
f"Light '{ref.light_id}' has no effects configured, but a "
f"'light.effect.next' or 'light.effect.previous' action "
f"references it. Add at least one effect to the light.",
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
)
return config
+41
View File
@@ -104,6 +104,47 @@ template<bool HasTransitionLength, typename... Ts> class DimRelativeAction : pub
transition_length_{};
};
// Cycle through the light's configured effects. `Forward` selects direction
// at compile time so the chosen branch is the only one that gets instantiated
// per action site. `include_none` is runtime so a single set of templates
// covers both the "wrap through None" and "skip None" variants.
template<bool Forward, typename... Ts> class LightEffectCycleAction : public Action<Ts...> {
public:
explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {}
void set_include_none(bool include_none) { this->include_none_ = include_none; }
void play(const Ts &...) override {
size_t count = this->parent_->get_effect_count();
if (count == 0) {
return;
}
uint32_t current = this->parent_->get_current_effect_index();
uint32_t next;
if (this->include_none_) {
uint32_t total = static_cast<uint32_t>(count) + 1;
if constexpr (Forward) {
next = (current + 1) % total;
} else {
next = (current + total - 1) % total;
}
} else {
if constexpr (Forward) {
next = (current % static_cast<uint32_t>(count)) + 1;
} else {
next = (current <= 1) ? static_cast<uint32_t>(count) : current - 1;
}
}
auto call = this->parent_->turn_on();
call.set_effect(next);
call.perform();
}
protected:
LightState *parent_;
bool include_none_{false};
};
template<typename... Ts> class LightIsOnCondition : public Condition<Ts...> {
public:
explicit LightIsOnCondition(LightState *state) : state_(state) {}
+74 -2
View File
@@ -26,8 +26,8 @@ from esphome.const import (
CONF_WARM_WHITE,
CONF_WHITE,
)
from esphome.core import CORE, EsphomeError, Lambda
from esphome.cpp_generator import LambdaExpression
from esphome.core import CORE, ID, EsphomeError, Lambda
from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType
from esphome.types import ConfigType
from .types import (
@@ -39,12 +39,15 @@ from .types import (
DimRelativeAction,
LightCall,
LightControlAction,
LightEffectCycleAction,
LightIsOffCondition,
LightIsOnCondition,
LightState,
ToggleAction,
)
CONF_INCLUDE_NONE = "include_none"
@automation.register_action(
"light.toggle",
@@ -253,6 +256,75 @@ async def light_control_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda)
def _record_effect_cycle_ref(config: ConfigType) -> ConfigType:
"""Record a cycle-action reference for later validation against the target light."""
from . import EffectCycleRef, _get_data
_get_data().effect_cycle_refs.append(
EffectCycleRef(
light_id=config[CONF_ID],
component_path=path_context.get(),
)
)
return config
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(LightState),
cv.Optional(CONF_INCLUDE_NONE, default=False): cv.boolean,
}
)
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA.add_extra(_record_effect_cycle_ref)
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA = automation.maybe_simple_id(
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA
)
@automation.register_action(
"light.effect.next",
LightEffectCycleAction,
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
synchronous=True,
)
async def light_effect_next_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
return await _light_effect_cycle_to_code(config, action_id, template_arg, True)
@automation.register_action(
"light.effect.previous",
LightEffectCycleAction,
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
synchronous=True,
)
async def light_effect_previous_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
return await _light_effect_cycle_to_code(config, action_id, template_arg, False)
async def _light_effect_cycle_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
forward: bool,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
cycle_template_arg = cg.TemplateArguments(forward, *template_arg)
var = cg.new_Pvariable(action_id, cycle_template_arg, paren)
cg.add(var.set_include_none(config[CONF_INCLUDE_NONE]))
return var
CONF_RELATIVE_BRIGHTNESS = "relative_brightness"
LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema(
{
+1
View File
@@ -39,6 +39,7 @@ LIMIT_MODES = {
# Actions
ToggleAction = light_ns.class_("ToggleAction", automation.Action)
LightControlAction = light_ns.class_("LightControlAction", automation.Action)
LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action)
DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action)
AddressableSet = light_ns.class_("AddressableSet", automation.Action)
LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition)
@@ -9,13 +9,17 @@ import pytest
from esphome import config_validation as cv
from esphome.components.light import (
EffectCycleRef,
EffectRef,
_final_validate,
_get_data,
available_effects_str,
find_effect_index,
)
from esphome.components.light.automation import _record_effect_ref
from esphome.components.light.automation import (
_record_effect_cycle_ref,
_record_effect_ref,
)
from esphome.config import Config, path_context
from esphome.const import CONF_EFFECT, CONF_EFFECTS, CONF_ID, CONF_NAME
from esphome.core import ID, Lambda
@@ -215,6 +219,111 @@ def test_final_validate_drains_refs() -> None:
fv.full_config.reset(token)
# --- _final_validate: EffectCycleRef ---
def _setup_cycle_final_validate(
cycle_refs: list[EffectCycleRef],
light_configs: list[ConfigType],
declare_ids: list[tuple[ID, list[str | int]]],
) -> Token:
"""Set up CORE.data and fv.full_config for EffectCycleRef final_validate tests."""
data = _get_data()
data.effect_cycle_refs = cycle_refs
full_conf = Config()
full_conf["light"] = light_configs
for id_, path in declare_ids:
full_conf.declare_ids.append((id_, path))
return fv.full_config.set(full_conf)
def test_final_validate_cycle_accepts_light_with_effects() -> None:
"""Cycle ref against a light with effects should not raise."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_cycle_rejects_light_without_effects_key() -> None:
"""Cycle ref against a light with no CONF_EFFECTS key should raise."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"):
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_cycle_rejects_light_with_empty_effects() -> None:
"""Cycle ref against a light with empty effects list should raise."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: []}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"):
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_cycle_unknown_light_id_skipped() -> None:
"""Cycle refs to unknown light IDs should be silently skipped."""
data = _get_data()
data.effect_cycle_refs = [
EffectCycleRef(
light_id=ID("nonexistent", is_declaration=True),
component_path=["esphome"],
)
]
full_conf = Config()
token = fv.full_config.set(full_conf)
try:
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_drains_cycle_refs() -> None:
"""Cycle refs should be drained after validation to avoid redundant runs."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
_final_validate({})
assert _get_data().effect_cycle_refs == []
finally:
fv.full_config.reset(token)
# --- _record_effect_ref ---
@@ -278,3 +387,19 @@ def test_record_effect_ref_skips_no_effect_key() -> None:
config: ConfigType = {CONF_ID: ID("led1", is_declaration=True)}
_record_effect_ref(config)
assert _get_data().effect_refs == []
# --- _record_effect_cycle_ref ---
@pytest.mark.usefixtures("_path_ctx")
def test_record_effect_cycle_ref() -> None:
"""Cycle-action config should be recorded with light_id and path."""
light_id = ID("led1", is_declaration=True)
config: ConfigType = {CONF_ID: light_id}
result = _record_effect_cycle_ref(config)
assert result is config
data = _get_data()
assert len(data.effect_cycle_refs) == 1
assert data.effect_cycle_refs[0].light_id is light_id
assert data.effect_cycle_refs[0].component_path == ["esphome"]
+10
View File
@@ -103,6 +103,16 @@ esphome:
- light.turn_on:
id: test_monochromatic_light
effect: !lambda 'return iteration > 1 ? "Strobe" : "none";'
# Cycle through configured effects (skip "None")
- light.effect.next: test_monochromatic_light
- light.effect.previous: test_monochromatic_light
# Cycle through effects including "None"
- light.effect.next:
id: test_monochromatic_light
include_none: true
- light.effect.previous:
id: test_monochromatic_light
include_none: true
- light.dim_relative:
id: test_monochromatic_light
relative_brightness: 5%