From 438307f548a63fb7b6184c89acb93e8ab09ab846 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:28:36 +0100 Subject: [PATCH] [light] Use register_apply_action for the light control actions (#19476) --- esphome/components/light/automation.h | 28 ---- esphome/components/light/automation.py | 125 ++++++------------ esphome/components/light/types.py | 2 - .../components/http_request/http_request.yaml | 2 +- tests/components/light/common.yaml | 10 ++ .../integration/test_light_control_action.py | 4 +- 6 files changed, 55 insertions(+), 116 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index ced15dfc60..5ce5f1ee60 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -31,34 +31,6 @@ template class ToggleAction final : pu transition_length_{}; }; -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `brightness: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class LightControlAction final : public Action { - public: - using ApplyFn = void (*)(LightState *, LightCall &, const std::remove_cvref_t &...); - LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - this->apply_(this->parent_, call, x...); - call.perform(); - } - - protected: - LightState *parent_; - ApplyFn apply_; -}; - template class DimRelativeAction final : public Action { public: explicit DimRelativeAction(LightState *parent) : parent_(parent) {} diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 7eaba9b117..d84df33a23 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -27,7 +27,7 @@ from esphome.const import ( CONF_WHITE, ) from esphome.core import CORE, ID, EsphomeError, Lambda -from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .types import ( @@ -37,8 +37,6 @@ from .types import ( AddressableSet, ColorMode, DimRelativeAction, - LightCall, - LightControlAction, LightEffectCycleAction, LightIsOffCondition, LightIsOnCondition, @@ -147,7 +145,7 @@ LIGHT_TURN_ON_ACTION_SCHEMA = automation.maybe_simple_id( ) -def _resolve_effect_index(config: ConfigType) -> int: +def _resolve_effect_index(config: ConfigType, original_name: str) -> int: """Resolve a static effect name to its 1-based index at codegen time. Effect index 0 means "None" (no effect). Effects are 1-indexed matching @@ -155,7 +153,6 @@ def _resolve_effect_index(config: ConfigType) -> int: """ from . import available_effects_str, find_effect_index - original_name = config[CONF_EFFECT] if original_name.lower() == "none": return 0 light_id = config[CONF_ID] @@ -173,87 +170,49 @@ def _resolve_effect_index(config: ConfigType) -> int: ) -@automation.register_action( - "light.turn_off", LightControlAction, LIGHT_TURN_OFF_ACTION_SCHEMA, synchronous=True +def _effect_index(config: ConfigType, value: str) -> str: + # Resolved at codegen time; the cast picks set_effect(uint32_t) over the optional overload. + return f"static_cast({_resolve_effect_index(config, value)})" + + +_LIGHT_CONTROL_FIELDS = ( + automation.ApplyField(CONF_COLOR_MODE, "set_color_mode", ColorMode), + automation.ApplyField(CONF_STATE, "set_state", cg.bool_), + automation.ApplyField(CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), + automation.ApplyField(CONF_FLASH_LENGTH, "set_flash_length", cg.uint32), + automation.ApplyField(CONF_BRIGHTNESS, "set_brightness", cg.float_), + automation.ApplyField(CONF_COLOR_BRIGHTNESS, "set_color_brightness", cg.float_), + automation.ApplyField(CONF_RED, "set_red", cg.float_), + automation.ApplyField(CONF_GREEN, "set_green", cg.float_), + automation.ApplyField(CONF_BLUE, "set_blue", cg.float_), + automation.ApplyField(CONF_WHITE, "set_white", cg.float_), + automation.ApplyField(CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), + automation.ApplyField(CONF_COLD_WHITE, "set_cold_white", cg.float_), + automation.ApplyField(CONF_WARM_WHITE, "set_warm_white", cg.float_), + automation.ApplyField( + CONF_EFFECT, "set_effect", cg.std_string, const_fn=_effect_index + ), ) -@automation.register_action( - "light.turn_on", LightControlAction, LIGHT_TURN_ON_ACTION_SCHEMA, synchronous=True + +automation.register_apply_action( + "light.turn_off", + LIGHT_TURN_OFF_ACTION_SCHEMA, + automation.ApplyField(CONF_STATE, "set_state", cg.bool_), + automation.ApplyField(CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), + call="make_call", ) -@automation.register_action( - "light.control", LightControlAction, LIGHT_CONTROL_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "light.turn_on", + LIGHT_TURN_ON_ACTION_SCHEMA, + *_LIGHT_CONTROL_FIELDS, + call="make_call", +) +automation.register_apply_action( + "light.control", + LIGHT_CONTROL_ACTION_SCHEMA, + *_LIGHT_CONTROL_FIELDS, + call="make_call", ) -async def light_control_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - FIELDS = ( - (CONF_COLOR_MODE, "set_color_mode", ColorMode), - (CONF_STATE, "set_state", cg.bool_), - (CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), - (CONF_FLASH_LENGTH, "set_flash_length", cg.uint32), - (CONF_BRIGHTNESS, "set_brightness", cg.float_), - (CONF_COLOR_BRIGHTNESS, "set_color_brightness", cg.float_), - (CONF_RED, "set_red", cg.float_), - (CONF_GREEN, "set_green", cg.float_), - (CONF_BLUE, "set_blue", cg.float_), - (CONF_WHITE, "set_white", cg.float_), - (CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), - (CONF_COLD_WHITE, "set_cold_white", cg.float_), - (CONF_WARM_WHITE, "set_warm_white", cg.float_), - ) - - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T (value, ref, or const-ref). Matches LightControlAction::ApplyFn. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - - for conf_key, setter, type_ in FIELDS: - if conf_key not in config: - continue - value = config[conf_key] - if isinstance(value, Lambda): - inner = await cg.process_lambda(value, normalized_args, return_type=type_) - body_lines.append(f"call.{setter}(({inner})({fwd_args}));") - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - if CONF_EFFECT in config: - if isinstance(config[CONF_EFFECT], Lambda): - inner_lambda = await cg.process_lambda( - config[CONF_EFFECT], normalized_args, return_type=cg.std_string - ) - body_lines.append( - f"{{ auto __effect_s = ({inner_lambda})({fwd_args});\n" - f"call.set_effect(parent->get_effect_index(" - f"__effect_s.c_str(), __effect_s.size())); }}" - ) - else: - # Cast disambiguates between set_effect(uint32_t) and - # set_effect(optional) when the literal is an int. - body_lines.append( - f"call.set_effect(static_cast({_resolve_effect_index(config)}));" - ) - - apply_args = [ - (LightState.operator("ptr"), "parent"), - (LightCall.operator("ref"), "call"), - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) def _record_effect_cycle_ref(config: ConfigType) -> ConfigType: diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 1778aa8410..4d2de314f6 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -14,7 +14,6 @@ Color = cg.esphome_ns.class_("Color") LightColorValues = light_ns.class_("LightColorValues") LightStateRTCState = light_ns.struct("LightStateRTCState") -LightCall = light_ns.class_("LightCall") # Addressable strips ChannelColors = light_ns.struct("ChannelColors") @@ -42,7 +41,6 @@ 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) diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index 4b3c2ca36b..172e0c235a 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -50,7 +50,7 @@ esphome: format: "After delay, body still: %s" args: - body.c_str() - # Regression test for esphome/esphome#16224: a LightControlAction + # Regression test for esphome/esphome#16224: a light.turn_on action # nested inside on_response with capture_response: true puts # `std::string &` into the trigger's Ts..., which exposed a codegen # bug where the apply lambda's parameter list did not match the diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 71c00e5f10..5ba245e7c3 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -77,6 +77,16 @@ esphome: - light.control: id: test_monochromatic_light state: on + # flash_length and a turn_off with its own transition + - light.turn_on: + id: test_rgb_light + flash_length: 750ms + - light.turn_on: + id: test_rgb_light + flash_length: !lambda return 250; + - light.turn_off: + id: test_rgb_light + transition_length: 300ms # Test static effect name resolution at codegen time - light.turn_on: id: test_monochromatic_light diff --git a/tests/integration/test_light_control_action.py b/tests/integration/test_light_control_action.py index 9a5c16a04d..bb690645bd 100644 --- a/tests/integration/test_light_control_action.py +++ b/tests/integration/test_light_control_action.py @@ -1,4 +1,4 @@ -"""Integration test for LightControlAction. +"""Integration test for the light control apply actions. Tests that light.turn_on, light.turn_off, and light.control automation actions work correctly with the compact per-field union storage. Exercises both constant @@ -19,7 +19,7 @@ async def test_light_control_action( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test LightControlAction with constants and lambdas.""" + """Test light.turn_on, light.turn_off and light.control with constants and lambdas.""" async with run_compiled(yaml_config), api_client_connected() as client: state_futures: dict[int, asyncio.Future[Any]] = {}