diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index eda30bd786e..8aee9b5dad1 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -8,20 +8,27 @@ namespace esphome::light { enum class LimitMode { CLAMP, DO_NOTHING }; -template class ToggleAction : public Action { +template class ToggleAction : public Action { public: explicit ToggleAction(LightState *state) : state_(state) {} - TEMPLATABLE_VALUE(uint32_t, transition_length) + template void set_transition_length(V value) requires(HasTransitionLength) { + this->transition_length_ = value; + } void play(const Ts &...x) override { auto call = this->state_->toggle(); - call.set_transition_length(this->transition_length_.optional_value(x...)); + if constexpr (HasTransitionLength) { + call.set_transition_length(this->transition_length_.optional_value(x...)); + } call.perform(); } protected: LightState *state_; + struct NoTransition {}; + [[no_unique_address]] std::conditional_t, NoTransition> + transition_length_{}; }; // Unique Empty per field so [[no_unique_address]] is guaranteed to coalesce. diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index ea953e31999..389a6c4f58d 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -60,8 +60,10 @@ from .types import ( ) async def light_toggle_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if CONF_TRANSITION_LENGTH in config: + has_transition_length = CONF_TRANSITION_LENGTH in config + toggle_template_arg = cg.TemplateArguments(has_transition_length, *template_arg) + var = cg.new_Pvariable(action_id, toggle_template_arg, paren) + if has_transition_length: template_ = await cg.templatable( config[CONF_TRANSITION_LENGTH], args, cg.uint32 ) diff --git a/tests/integration/fixtures/light_toggle_action.yaml b/tests/integration/fixtures/light_toggle_action.yaml new file mode 100644 index 00000000000..265d8ba1acb --- /dev/null +++ b/tests/integration/fixtures/light_toggle_action.yaml @@ -0,0 +1,37 @@ +esphome: + name: light-toggle-action-test +host: +api: +logger: + level: DEBUG + +output: + - platform: template + id: test_out + type: float + write_action: + - lambda: "" + +light: + - platform: monochromatic + name: "Test Light" + id: test_light + output: test_out + default_transition_length: 0s + +button: + # Test 1: light.toggle without transition_length (HasTransitionLength=false) + - platform: template + id: btn_toggle + name: "Toggle" + on_press: + - light.toggle: test_light + + # Test 2: light.toggle with transition_length (HasTransitionLength=true) + - platform: template + id: btn_toggle_with_trans + name: "Toggle With Trans" + on_press: + - light.toggle: + id: test_light + transition_length: 0s diff --git a/tests/integration/test_light_toggle_action.py b/tests/integration/test_light_toggle_action.py new file mode 100644 index 00000000000..ffbadabb5bd --- /dev/null +++ b/tests/integration/test_light_toggle_action.py @@ -0,0 +1,67 @@ +"""Integration test for light::ToggleAction. + +Tests both ToggleAction and +ToggleAction instantiations. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, EntityState, LightInfo, LightState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_toggle_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test light.toggle with and without transition_length.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + light_state_future: asyncio.Future[LightState] | None = None + + def on_state(state: EntityState) -> None: + if ( + isinstance(state, LightState) + and light_state_future is not None + and not light_state_future.done() + ): + light_state_future.set_result(state) + + async def wait_for_light_state(timeout: float = 5.0) -> LightState: + nonlocal light_state_future + light_state_future = loop.create_future() + try: + return await asyncio.wait_for(light_state_future, timeout) + finally: + light_state_future = None + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + require_entity(entities, "test_light", LightInfo) + + async def press_and_wait(name: str) -> LightState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) + client.button_command(btn.key) + return await wait_for_light_state() + + # Test 1: toggle without transition_length flips off->on + state = await press_and_wait("Toggle") + assert state.state is True + + # Test 2: toggle with transition_length flips on->off + state = await press_and_wait("Toggle With Trans") + assert state.state is False + + # Test 3: toggle without transition_length flips off->on again + state = await press_and_wait("Toggle") + assert state.state is True