From 1a02f7e41f1a495b67291be4135e83121c774497 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 04:36:44 -0500 Subject: [PATCH 1/8] [climate] Use bitmask template parameter for ControlAction unused fields Apply the bitmask pattern from LightControlAction (#16039) to climate::ControlAction. Parameterize on a uint16_t Fields bitmask encoding which of the 10 templatable fields are configured. Unused fields are elided via [[no_unique_address]] and skipped in play() via if constexpr. Also drop the unused `away` TEMPLATABLE_VALUE -- its YAML key is cv.invalid("Use preset instead") and no caller of set_away exists in the repo. Per-instance: 20 B (mode only) to 28 B (mode + 2 temps), down from ~64-72 B baseline. --- esphome/components/climate/__init__.py | 65 +++++++++++++------------ esphome/components/climate/automation.h | 65 +++++++++++++++---------- 2 files changed, 74 insertions(+), 56 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 0fdb18a92c..413a2a5b56 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -487,37 +487,40 @@ 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_)) + + # Order/bits must match CLIMATE_CONTROL_FIELDS in automation.h. + 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_custom_fan_mode", + cg.std_string, + ), # internal setter name + (CONF_PRESET, "set_preset", ClimatePreset), + ( + CONF_CUSTOM_PRESET, + "set_custom_preset", + cg.std_string, + ), # internal setter name + (CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), + ) + assert len(FIELDS) <= 16, "ControlAction Fields bitmask exceeds uint16_t" + + field_mask = sum(1 << i for i, (k, _, _) in enumerate(FIELDS) if k in config) + control_template_arg = cg.TemplateArguments( + cg.RawExpression(f"static_cast({field_mask})"), *template_arg + ) + var = cg.new_Pvariable(action_id, control_template_arg, paren) + + for conf_key, setter, type_ in FIELDS: + if (value := config.get(conf_key)) is not None: + template_ = await cg.templatable(value, args, type_) + cg.add(getattr(var, setter)(template_)) return var diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index fac56d9d9e..9e38ef6407 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -5,43 +5,58 @@ namespace esphome::climate { -template class ControlAction : public Action { +// Unique Empty per field so [[no_unique_address]] is guaranteed to coalesce. +namespace climate_control_detail { +template struct Empty {}; +} // namespace climate_control_detail + +// X-macro: (type, field_name, call_setter, bit_index). Order and bit values must +// match the FIELDS table in __init__.py. call_setter is the ClimateCall method +// invoked in play() — for custom_fan_mode/custom_preset this dispatches to the +// std::string overload of set_fan_mode/set_preset respectively. +#define CLIMATE_CONTROL_FIELDS(X) \ + X(ClimateMode, mode, set_mode, 0) \ + X(float, target_temperature, set_target_temperature, 1) \ + X(float, target_temperature_low, set_target_temperature_low, 2) \ + X(float, target_temperature_high, set_target_temperature_high, 3) \ + X(float, target_humidity, set_target_humidity, 4) \ + X(ClimateFanMode, fan_mode, set_fan_mode, 5) \ + X(std::string, custom_fan_mode, set_fan_mode, 6) \ + X(ClimatePreset, preset, set_preset, 7) \ + X(std::string, custom_preset, set_preset, 8) \ + X(ClimateSwingMode, swing_mode, set_swing_mode, 9) + +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) +#define CLIMATE_FIELD_SETTER_(type, name, call_setter, idx) \ + template void set_##name(V value) requires((Fields & (1 << (idx))) != 0) { this->name##_ = value; } +#define CLIMATE_FIELD_APPLY_(type, name, call_setter, idx) \ + if constexpr ((Fields & (1 << (idx))) != 0) \ + call.call_setter(this->name##_.value(x...)); +#define CLIMATE_FIELD_DECL_(type, name, call_setter, idx) \ + [[no_unique_address]] std::conditional_t<(Fields & (1 << (idx))) != 0, TemplatableStorage, \ + climate_control_detail::Empty<(idx)>> \ + name##_{}; + + CLIMATE_CONTROL_FIELDS(CLIMATE_FIELD_SETTER_) 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...)); + CLIMATE_CONTROL_FIELDS(CLIMATE_FIELD_APPLY_) call.perform(); } protected: Climate *climate_; + CLIMATE_CONTROL_FIELDS(CLIMATE_FIELD_DECL_) + +#undef CLIMATE_FIELD_DECL_ +#undef CLIMATE_FIELD_APPLY_ +#undef CLIMATE_FIELD_SETTER_ }; +#undef CLIMATE_CONTROL_FIELDS class ControlTrigger : public Trigger { public: From 66115c117a4ba0a9efe097e673c7fe753e4bec1f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:03:57 -0500 Subject: [PATCH 2/8] [climate] Add integration test for ControlAction --- .../fixtures/climate_control_action.yaml | 90 +++++++++++++++++++ .../test_climate_control_action.py | 72 +++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/integration/fixtures/climate_control_action.yaml create mode 100644 tests/integration/test_climate_control_action.py diff --git a/tests/integration/fixtures/climate_control_action.yaml b/tests/integration/fixtures/climate_control_action.yaml new file mode 100644 index 0000000000..f462465240 --- /dev/null +++ b/tests/integration/fixtures/climate_control_action.yaml @@ -0,0 +1,90 @@ +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 + 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: + # Test 1: mode only (mask 1) + - platform: template + id: btn_mode + name: "Set Mode Heat" + on_press: + - climate.control: + id: test_climate + mode: HEAT + + # Test 2: mode + low + high (mask 0b1101 = 13) + - 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 + + # Test 3: just target_temp_low (mask 0b0100 = 4) + - platform: template + id: btn_low_only + name: "Set Low Only" + on_press: + - climate.control: + id: test_climate + target_temperature_low: 17.5 °C + + # Test 4: lambda for target_temperature_high (exercises lambda path) + - 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);" + + # Test 5: 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..505fcf0f3d --- /dev/null +++ b/tests/integration/test_climate_control_action.py @@ -0,0 +1,72 @@ +"""Integration test for climate ControlAction. + +Tests that climate.control automation actions work correctly with the +per-instance bitmask field storage. Exercises multiple field combinations +to cover different bitmask variants and the lambda path. +""" + +import asyncio +from typing import Any + +import pytest + +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.""" + async with run_compiled(yaml_config), api_client_connected() as client: + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + client.subscribe_states(on_state) + + entities = await client.list_entities_services() + climate = next(e for e in entities[0] if e.object_id == "test_climate") + buttons = {e.name: e for e in entities[0] if hasattr(e, "name")} + + async def wait_for_state(key: int, timeout: float = 5.0) -> Any: + loop = asyncio.get_running_loop() + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + async def press_and_wait(button_name: str) -> Any: + btn = buttons[button_name] + client.button_command(btn.key) + return await wait_for_state(climate.key) + + # Test 1: mode only (mask 1) — set HEAT + state = await press_and_wait("Set Mode Heat") + # ClimateMode.CLIMATE_MODE_HEAT == 3 + assert state.mode == 3 + + # Test 2: mode + low + high (mask 13) — HEAT_COOL with both temps + state = await press_and_wait("Set Mode Temps") + # CLIMATE_MODE_HEAT_COOL == 1 + assert state.mode == 1 + assert state.target_temperature_low == pytest.approx(19.0, abs=0.5) + assert state.target_temperature_high == pytest.approx(23.0, abs=0.5) + + # Test 3: low only (mask 4) + state = await press_and_wait("Set Low Only") + assert state.target_temperature_low == pytest.approx(17.5, abs=0.5) + + # Test 4: lambda high — global is 21.5 + state = await press_and_wait("Lambda High") + assert state.target_temperature_high == pytest.approx(21.5, abs=0.5) + + # Test 5: turn off via mode (mask 1) + state = await press_and_wait("Set Off") + # CLIMATE_MODE_OFF == 0 + assert state.mode == 0 From 497665f59f1053077e5fea88d125803f4f52f92b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:08:35 -0500 Subject: [PATCH 3/8] [climate] Use InitialStateHelper in integration test --- .../test_climate_control_action.py | 64 +++++++++++-------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/tests/integration/test_climate_control_action.py b/tests/integration/test_climate_control_action.py index 505fcf0f3d..0070f9829e 100644 --- a/tests/integration/test_climate_control_action.py +++ b/tests/integration/test_climate_control_action.py @@ -5,11 +5,20 @@ per-instance bitmask field storage. Exercises multiple field combinations to cover different bitmask variants and the lambda path. """ -import asyncio -from typing import Any +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 @@ -20,41 +29,45 @@ async def test_climate_control_action( 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: - state_futures: dict[int, asyncio.Future[Any]] = {} + climate_state_future: asyncio.Future[ClimateState] | None = None - def on_state(state: Any) -> None: - if state.key in state_futures and not state_futures[state.key].done(): - state_futures[state.key].set_result(state) + 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) - client.subscribe_states(on_state) - - entities = await client.list_entities_services() - climate = next(e for e in entities[0] if e.object_id == "test_climate") - buttons = {e.name: e for e in entities[0] if hasattr(e, "name")} - - async def wait_for_state(key: int, timeout: float = 5.0) -> Any: - loop = asyncio.get_running_loop() - state_futures[key] = loop.create_future() + 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(state_futures[key], timeout) + return await asyncio.wait_for(climate_state_future, timeout) finally: - state_futures.pop(key, None) + climate_state_future = None - async def press_and_wait(button_name: str) -> Any: - btn = buttons[button_name] + 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_state(climate.key) + return await wait_for_climate_state() # Test 1: mode only (mask 1) — set HEAT state = await press_and_wait("Set Mode Heat") - # ClimateMode.CLIMATE_MODE_HEAT == 3 - assert state.mode == 3 + assert state.mode == ClimateMode.HEAT # Test 2: mode + low + high (mask 13) — HEAT_COOL with both temps state = await press_and_wait("Set Mode Temps") - # CLIMATE_MODE_HEAT_COOL == 1 - assert state.mode == 1 + 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) @@ -68,5 +81,4 @@ async def test_climate_control_action( # Test 5: turn off via mode (mask 1) state = await press_and_wait("Set Off") - # CLIMATE_MODE_OFF == 0 - assert state.mode == 0 + assert state.mode == ClimateMode.OFF From fdb183f7728d3be445a770747c6f0c7aa2b8fa5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:21:27 -0500 Subject: [PATCH 4/8] [climate] Add auto_mode to thermostat fixture so HEAT_COOL is supported --- tests/integration/fixtures/climate_control_action.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/fixtures/climate_control_action.yaml b/tests/integration/fixtures/climate_control_action.yaml index f462465240..0fba8e2693 100644 --- a/tests/integration/fixtures/climate_control_action.yaml +++ b/tests/integration/fixtures/climate_control_action.yaml @@ -33,6 +33,8 @@ climate: - logger.log: idle cool_action: - logger.log: cooling + auto_mode: + - logger.log: auto preset: - name: Default default_target_temperature_low: 18 °C From 9089cc968ebb0148b4d10c58b318870526e70937 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 05:42:18 -0500 Subject: [PATCH 5/8] [climate] Use heat_cool_mode (not auto_mode) to enable HEAT_COOL in test fixture --- tests/integration/fixtures/climate_control_action.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/fixtures/climate_control_action.yaml b/tests/integration/fixtures/climate_control_action.yaml index 0fba8e2693..596151ea51 100644 --- a/tests/integration/fixtures/climate_control_action.yaml +++ b/tests/integration/fixtures/climate_control_action.yaml @@ -33,8 +33,8 @@ climate: - logger.log: idle cool_action: - logger.log: cooling - auto_mode: - - logger.log: auto + heat_cool_mode: + - logger.log: heat_cool preset: - name: Default default_target_temperature_low: 18 °C From a35aecefeed0ad5941ef9e31a8f098bf50b52073 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 06:30:46 -0500 Subject: [PATCH 6/8] [climate] Fold ControlAction fields into a single stateless lambda --- esphome/components/climate/__init__.py | 53 ++++++++++++++----------- esphome/components/climate/automation.h | 52 +++++------------------- 2 files changed, 40 insertions(+), 65 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 413a2a5b56..b033776f27 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 @@ -488,7 +488,11 @@ 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]) - # Order/bits must match CLIMATE_CONTROL_FIELDS in automation.h. + # All configured fields are folded into a single stateless lambda whose + # constants live in flash; the action stores only a function pointer. + # `call_setter` is the ClimateCall method invoked in the lambda body — + # for custom_fan_mode/custom_preset this dispatches to the std::string + # overload of set_fan_mode/set_preset respectively. FIELDS = ( (CONF_MODE, "set_mode", ClimateMode), (CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), @@ -496,32 +500,35 @@ async def climate_control_to_code(config, action_id, template_arg, args): (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_custom_fan_mode", - cg.std_string, - ), # internal setter name + (CONF_CUSTOM_FAN_MODE, "set_fan_mode", cg.std_string), (CONF_PRESET, "set_preset", ClimatePreset), - ( - CONF_CUSTOM_PRESET, - "set_custom_preset", - cg.std_string, - ), # internal setter name + (CONF_CUSTOM_PRESET, "set_preset", cg.std_string), (CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), ) - assert len(FIELDS) <= 16, "ControlAction Fields bitmask exceeds uint16_t" - field_mask = sum(1 << i for i, (k, _, _) in enumerate(FIELDS) if k in config) - control_template_arg = cg.TemplateArguments( - cg.RawExpression(f"static_cast({field_mask})"), *template_arg - ) - var = cg.new_Pvariable(action_id, control_template_arg, paren) + 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 not None: - template_ = await cg.templatable(value, args, type_) - cg.add(getattr(var, setter)(template_)) - return var + 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}));") + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + + apply_args = [ + (ClimateCall.operator("ref"), "call"), + *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 9e38ef6407..b0353a41f9 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -5,58 +5,26 @@ namespace esphome::climate { -// Unique Empty per field so [[no_unique_address]] is guaranteed to coalesce. -namespace climate_control_detail { -template struct Empty {}; -} // namespace climate_control_detail - -// X-macro: (type, field_name, call_setter, bit_index). Order and bit values must -// match the FIELDS table in __init__.py. call_setter is the ClimateCall method -// invoked in play() — for custom_fan_mode/custom_preset this dispatches to the -// std::string overload of set_fan_mode/set_preset respectively. -#define CLIMATE_CONTROL_FIELDS(X) \ - X(ClimateMode, mode, set_mode, 0) \ - X(float, target_temperature, set_target_temperature, 1) \ - X(float, target_temperature_low, set_target_temperature_low, 2) \ - X(float, target_temperature_high, set_target_temperature_high, 3) \ - X(float, target_humidity, set_target_humidity, 4) \ - X(ClimateFanMode, fan_mode, set_fan_mode, 5) \ - X(std::string, custom_fan_mode, set_fan_mode, 6) \ - X(ClimatePreset, preset, set_preset, 7) \ - X(std::string, custom_preset, set_preset, 8) \ - X(ClimateSwingMode, swing_mode, set_swing_mode, 9) - -template class ControlAction : public Action { +// All configured fields are baked into a single stateless lambda whose +// constants live in flash. The action only stores a function pointer +// (4 bytes) plus the parent (4 bytes), 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) {} - -#define CLIMATE_FIELD_SETTER_(type, name, call_setter, idx) \ - template void set_##name(V value) requires((Fields & (1 << (idx))) != 0) { this->name##_ = value; } -#define CLIMATE_FIELD_APPLY_(type, name, call_setter, idx) \ - if constexpr ((Fields & (1 << (idx))) != 0) \ - call.call_setter(this->name##_.value(x...)); -#define CLIMATE_FIELD_DECL_(type, name, call_setter, idx) \ - [[no_unique_address]] std::conditional_t<(Fields & (1 << (idx))) != 0, TemplatableStorage, \ - climate_control_detail::Empty<(idx)>> \ - name##_{}; - - CLIMATE_CONTROL_FIELDS(CLIMATE_FIELD_SETTER_) + using ApplyFn = void (*)(ClimateCall &, Ts...); + ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} void play(const Ts &...x) override { auto call = this->climate_->make_call(); - CLIMATE_CONTROL_FIELDS(CLIMATE_FIELD_APPLY_) + this->apply_(call, x...); call.perform(); } protected: Climate *climate_; - CLIMATE_CONTROL_FIELDS(CLIMATE_FIELD_DECL_) - -#undef CLIMATE_FIELD_DECL_ -#undef CLIMATE_FIELD_APPLY_ -#undef CLIMATE_FIELD_SETTER_ + ApplyFn apply_; }; -#undef CLIMATE_CONTROL_FIELDS class ControlTrigger : public Trigger { public: From c9db3048878c68735476eb2f56c32d5e8e16eebb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 06:32:11 -0500 Subject: [PATCH 7/8] [climate] Pass codegen-known length for static custom_fan_mode/custom_preset --- esphome/components/climate/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index b033776f27..0f8bdb8ab0 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -515,6 +515,11 @@ async def climate_control_to_code(config, action_id, template_arg, args): 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 codegen-known + # 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)});") else: body_lines.append(f"call.{setter}({cg.safe_exp(value)});") From a7a45a556bb434a11e8543415147b70331775e66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 06:54:23 -0500 Subject: [PATCH 8/8] [climate] Address review feedback (const ref args, utf-8 byte length, comments) --- esphome/components/climate/__init__.py | 18 +++++++++++------- esphome/components/climate/automation.h | 10 +++++----- .../fixtures/climate_control_action.yaml | 10 +++++----- .../integration/test_climate_control_action.py | 14 +++++++------- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 0f8bdb8ab0..7c9002d6dc 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -490,9 +490,9 @@ async def climate_control_to_code(config, action_id, template_arg, args): # All configured fields are folded into a single stateless lambda whose # constants live in flash; the action stores only a function pointer. - # `call_setter` is the ClimateCall method invoked in the lambda body — - # for custom_fan_mode/custom_preset this dispatches to the std::string - # overload of set_fan_mode/set_preset respectively. + # 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_), @@ -516,16 +516,20 @@ async def climate_control_to_code(config, action_id, template_arg, args): 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 codegen-known - # length to skip the runtime strlen inside set_fan_mode/set_preset. + # 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)});") + 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"), - *args, + *((t.operator("const").operator("ref"), n) for t, n in args), ] apply_lambda = LambdaExpression( ["\n".join(body_lines)], diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index b0353a41f9..71d23fd6b6 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -6,13 +6,13 @@ namespace esphome::climate { // All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores a function pointer -// (4 bytes) plus the parent (4 bytes), 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. +// 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: - using ApplyFn = void (*)(ClimateCall &, Ts...); + using ApplyFn = void (*)(ClimateCall &, const Ts &...); ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} void play(const Ts &...x) override { diff --git a/tests/integration/fixtures/climate_control_action.yaml b/tests/integration/fixtures/climate_control_action.yaml index 596151ea51..1dd300fcc2 100644 --- a/tests/integration/fixtures/climate_control_action.yaml +++ b/tests/integration/fixtures/climate_control_action.yaml @@ -44,7 +44,7 @@ climate: max_temperature: 30 °C button: - # Test 1: mode only (mask 1) + # mode only - platform: template id: btn_mode name: "Set Mode Heat" @@ -53,7 +53,7 @@ button: id: test_climate mode: HEAT - # Test 2: mode + low + high (mask 0b1101 = 13) + # mode + target_temperature_low + target_temperature_high - platform: template id: btn_mode_temps name: "Set Mode Temps" @@ -64,7 +64,7 @@ button: target_temperature_low: 19.0 °C target_temperature_high: 23.0 °C - # Test 3: just target_temp_low (mask 0b0100 = 4) + # target_temperature_low only - platform: template id: btn_low_only name: "Set Low Only" @@ -73,7 +73,7 @@ button: id: test_climate target_temperature_low: 17.5 °C - # Test 4: lambda for target_temperature_high (exercises lambda path) + # Lambda path: target_temperature_high computed at runtime - platform: template id: btn_lambda_high name: "Lambda High" @@ -82,7 +82,7 @@ button: id: test_climate target_temperature_high: !lambda "return id(test_target_temp);" - # Test 5: turn off via mode + # mode only — turn off via mode - platform: template id: btn_off name: "Set Off" diff --git a/tests/integration/test_climate_control_action.py b/tests/integration/test_climate_control_action.py index 0070f9829e..2b0293b209 100644 --- a/tests/integration/test_climate_control_action.py +++ b/tests/integration/test_climate_control_action.py @@ -1,8 +1,8 @@ """Integration test for climate ControlAction. Tests that climate.control automation actions work correctly with the -per-instance bitmask field storage. Exercises multiple field combinations -to cover different bitmask variants and the lambda path. +single stateless apply lambda/function pointer implementation. Exercises +multiple field combinations and the lambda path. """ from __future__ import annotations @@ -61,24 +61,24 @@ async def test_climate_control_action( client.button_command(btn.key) return await wait_for_climate_state() - # Test 1: mode only (mask 1) — set HEAT + # mode only — set HEAT state = await press_and_wait("Set Mode Heat") assert state.mode == ClimateMode.HEAT - # Test 2: mode + low + high (mask 13) — HEAT_COOL with both temps + # 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) - # Test 3: low only (mask 4) + # target_temperature_low only state = await press_and_wait("Set Low Only") assert state.target_temperature_low == pytest.approx(17.5, abs=0.5) - # Test 4: lambda high — global is 21.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) - # Test 5: turn off via mode (mask 1) + # mode only — turn off via mode state = await press_and_wait("Set Off") assert state.mode == ClimateMode.OFF