From 3d69169141984487a28d5b1e323a3740748a1408 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:16:16 -0500 Subject: [PATCH] [climate] Fold ControlAction fields into a single stateless lambda (#16044) --- esphome/components/climate/__init__.py | 87 +++++++++++------- esphome/components/climate/automation.h | 35 ++----- .../fixtures/climate_control_action.yaml | 92 +++++++++++++++++++ .../test_climate_control_action.py | 84 +++++++++++++++++ 4 files changed, 238 insertions(+), 60 deletions(-) create mode 100644 tests/integration/fixtures/climate_control_action.yaml create mode 100644 tests/integration/test_climate_control_action.py diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 0fdb18a92c..7c9002d6dc 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -48,13 +48,13 @@ from esphome.const import ( CONF_VISUAL, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import LambdaExpression, MockObjClass IS_PLATFORM_COMPONENT = True @@ -487,38 +487,57 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( ) async def climate_control_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 (mode := config.get(CONF_MODE)) is not None: - template_ = await cg.templatable(mode, args, ClimateMode) - cg.add(var.set_mode(template_)) - if (target_temp := config.get(CONF_TARGET_TEMPERATURE)) is not None: - template_ = await cg.templatable(target_temp, args, cg.float_) - cg.add(var.set_target_temperature(template_)) - if (target_temp_low := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None: - template_ = await cg.templatable(target_temp_low, args, cg.float_) - cg.add(var.set_target_temperature_low(template_)) - if (target_temp_high := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None: - template_ = await cg.templatable(target_temp_high, args, cg.float_) - cg.add(var.set_target_temperature_high(template_)) - if (target_humidity := config.get(CONF_TARGET_HUMIDITY)) is not None: - template_ = await cg.templatable(target_humidity, args, cg.float_) - cg.add(var.set_target_humidity(template_)) - if (fan_mode := config.get(CONF_FAN_MODE)) is not None: - template_ = await cg.templatable(fan_mode, args, ClimateFanMode) - cg.add(var.set_fan_mode(template_)) - if (custom_fan_mode := config.get(CONF_CUSTOM_FAN_MODE)) is not None: - template_ = await cg.templatable(custom_fan_mode, args, cg.std_string) - cg.add(var.set_custom_fan_mode(template_)) - if (preset := config.get(CONF_PRESET)) is not None: - template_ = await cg.templatable(preset, args, ClimatePreset) - cg.add(var.set_preset(template_)) - if (custom_preset := config.get(CONF_CUSTOM_PRESET)) is not None: - template_ = await cg.templatable(custom_preset, args, cg.std_string) - cg.add(var.set_custom_preset(template_)) - if (swing_mode := config.get(CONF_SWING_MODE)) is not None: - template_ = await cg.templatable(swing_mode, args, ClimateSwingMode) - cg.add(var.set_swing_mode(template_)) - return var + + # All configured fields are folded into a single stateless lambda whose + # constants live in flash; the action stores only a function pointer. + # For custom_fan_mode/custom_preset the static-string path emits the + # (const char *, size_t) overload of set_fan_mode/set_preset to avoid + # constructing a std::string and calling runtime strlen. + FIELDS = ( + (CONF_MODE, "set_mode", ClimateMode), + (CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), + (CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_), + (CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_), + (CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_), + (CONF_FAN_MODE, "set_fan_mode", ClimateFanMode), + (CONF_CUSTOM_FAN_MODE, "set_fan_mode", cg.std_string), + (CONF_PRESET, "set_preset", ClimatePreset), + (CONF_CUSTOM_PRESET, "set_preset", cg.std_string), + (CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), + ) + + fwd_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] + + for conf_key, setter, type_ in FIELDS: + if (value := config.get(conf_key)) is None: + continue + if isinstance(value, Lambda): + inner = await cg.process_lambda(value, args, return_type=type_) + body_lines.append(f"call.{setter}(({inner})({fwd_args}));") + elif type_ is cg.std_string: + # Static custom strings: emit a flash literal and pass the + # UTF-8 byte length to skip the runtime strlen inside + # set_fan_mode/set_preset. + literal = cg.safe_exp(value) + body_lines.append( + f"call.{setter}({literal}, {len(value.encode('utf-8'))});" + ) + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + + # Match ControlAction::ApplyFn signature: const Ts &... for trigger args. + apply_args = [ + (ClimateCall.operator("ref"), "call"), + *((t.operator("const").operator("ref"), n) for t, n in 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) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index fac56d9d9e..71d23fd6b6 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -5,42 +5,25 @@ namespace esphome::climate { +// 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. `target_temperature: !lambda "return x;"`) keep working. template class ControlAction : public Action { public: - explicit ControlAction(Climate *climate) : climate_(climate) {} - - TEMPLATABLE_VALUE(ClimateMode, mode) - TEMPLATABLE_VALUE(float, target_temperature) - TEMPLATABLE_VALUE(float, target_temperature_low) - TEMPLATABLE_VALUE(float, target_temperature_high) - TEMPLATABLE_VALUE(float, target_humidity) - TEMPLATABLE_VALUE(bool, away) - TEMPLATABLE_VALUE(ClimateFanMode, fan_mode) - TEMPLATABLE_VALUE(std::string, custom_fan_mode) - TEMPLATABLE_VALUE(ClimatePreset, preset) - TEMPLATABLE_VALUE(std::string, custom_preset) - TEMPLATABLE_VALUE(ClimateSwingMode, swing_mode) + using ApplyFn = void (*)(ClimateCall &, const Ts &...); + ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} void play(const Ts &...x) override { auto call = this->climate_->make_call(); - call.set_mode(this->mode_.optional_value(x...)); - call.set_target_temperature(this->target_temperature_.optional_value(x...)); - call.set_target_temperature_low(this->target_temperature_low_.optional_value(x...)); - call.set_target_temperature_high(this->target_temperature_high_.optional_value(x...)); - call.set_target_humidity(this->target_humidity_.optional_value(x...)); - if (away_.has_value()) { - call.set_preset(away_.value(x...) ? CLIMATE_PRESET_AWAY : CLIMATE_PRESET_HOME); - } - call.set_fan_mode(this->fan_mode_.optional_value(x...)); - call.set_fan_mode(this->custom_fan_mode_.optional_value(x...)); - call.set_preset(this->preset_.optional_value(x...)); - call.set_preset(this->custom_preset_.optional_value(x...)); - call.set_swing_mode(this->swing_mode_.optional_value(x...)); + this->apply_(call, x...); call.perform(); } protected: Climate *climate_; + ApplyFn apply_; }; class ControlTrigger : public Trigger { diff --git a/tests/integration/fixtures/climate_control_action.yaml b/tests/integration/fixtures/climate_control_action.yaml new file mode 100644 index 0000000000..1dd300fcc2 --- /dev/null +++ b/tests/integration/fixtures/climate_control_action.yaml @@ -0,0 +1,92 @@ +esphome: + name: climate-control-action-test +host: +api: +logger: + level: DEBUG + +globals: + - id: test_target_temp + type: float + initial_value: "21.5" + +sensor: + - platform: template + id: temp_sensor + name: "Temp" + lambda: 'return 20.0;' + update_interval: 60s + +climate: + - platform: thermostat + id: test_climate + name: "Test Climate" + sensor: temp_sensor + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + min_cooling_off_time: 300s + min_cooling_run_time: 300s + heat_action: + - logger.log: heating + idle_action: + - logger.log: idle + cool_action: + - logger.log: cooling + heat_cool_mode: + - logger.log: heat_cool + preset: + - name: Default + default_target_temperature_low: 18 °C + default_target_temperature_high: 22 °C + visual: + min_temperature: 10 °C + max_temperature: 30 °C + +button: + # mode only + - platform: template + id: btn_mode + name: "Set Mode Heat" + on_press: + - climate.control: + id: test_climate + mode: HEAT + + # mode + target_temperature_low + target_temperature_high + - platform: template + id: btn_mode_temps + name: "Set Mode Temps" + on_press: + - climate.control: + id: test_climate + mode: HEAT_COOL + target_temperature_low: 19.0 °C + target_temperature_high: 23.0 °C + + # target_temperature_low only + - platform: template + id: btn_low_only + name: "Set Low Only" + on_press: + - climate.control: + id: test_climate + target_temperature_low: 17.5 °C + + # Lambda path: target_temperature_high computed at runtime + - platform: template + id: btn_lambda_high + name: "Lambda High" + on_press: + - climate.control: + id: test_climate + target_temperature_high: !lambda "return id(test_target_temp);" + + # mode only — turn off via mode + - platform: template + id: btn_off + name: "Set Off" + on_press: + - climate.control: + id: test_climate + mode: "OFF" diff --git a/tests/integration/test_climate_control_action.py b/tests/integration/test_climate_control_action.py new file mode 100644 index 0000000000..2b0293b209 --- /dev/null +++ b/tests/integration/test_climate_control_action.py @@ -0,0 +1,84 @@ +"""Integration test for climate ControlAction. + +Tests that climate.control automation actions work correctly with the +single stateless apply lambda/function pointer implementation. Exercises +multiple field combinations and the lambda path. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ( + ButtonInfo, + ClimateInfo, + ClimateMode, + ClimateState, + EntityState, +) +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_climate_control_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test climate ControlAction with constants and lambdas.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + climate_state_future: asyncio.Future[ClimateState] | None = None + + def on_state(state: EntityState) -> None: + if ( + isinstance(state, ClimateState) + and climate_state_future is not None + and not climate_state_future.done() + ): + climate_state_future.set_result(state) + + async def wait_for_climate_state(timeout: float = 5.0) -> ClimateState: + nonlocal climate_state_future + climate_state_future = loop.create_future() + try: + return await asyncio.wait_for(climate_state_future, timeout) + finally: + climate_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_climate", ClimateInfo) + + async def press_and_wait(name: str) -> ClimateState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) + client.button_command(btn.key) + return await wait_for_climate_state() + + # mode only — set HEAT + state = await press_and_wait("Set Mode Heat") + assert state.mode == ClimateMode.HEAT + + # mode + target_temperature_low + target_temperature_high + state = await press_and_wait("Set Mode Temps") + assert state.mode == ClimateMode.HEAT_COOL + assert state.target_temperature_low == pytest.approx(19.0, abs=0.5) + assert state.target_temperature_high == pytest.approx(23.0, abs=0.5) + + # target_temperature_low only + state = await press_and_wait("Set Low Only") + assert state.target_temperature_low == pytest.approx(17.5, abs=0.5) + + # lambda path: target_temperature_high computed at runtime + state = await press_and_wait("Lambda High") + assert state.target_temperature_high == pytest.approx(21.5, abs=0.5) + + # mode only — turn off via mode + state = await press_and_wait("Set Off") + assert state.mode == ClimateMode.OFF