[climate] Fold ControlAction fields into a single stateless lambda (#16044)

This commit is contained in:
J. Nick Koston
2026-04-30 19:16:16 -05:00
committed by GitHub
parent 24fdfcf1a1
commit 3d69169141
4 changed files with 238 additions and 60 deletions

View File

@@ -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)

View File

@@ -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<typename... Ts> class ControlAction : public Action<Ts...> {
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<ClimateCall &> {

View File

@@ -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"

View File

@@ -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