[water_heater] Add On/Off and Away mode support to template platform (#13839)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
tronikos
2026-02-10 04:45:18 -08:00
committed by GitHub
parent e85a022c77
commit e3141211c3
7 changed files with 164 additions and 26 deletions
@@ -3,6 +3,7 @@ import esphome.codegen as cg
from esphome.components import water_heater from esphome.components import water_heater
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_AWAY,
CONF_ID, CONF_ID,
CONF_MODE, CONF_MODE,
CONF_OPTIMISTIC, CONF_OPTIMISTIC,
@@ -18,6 +19,7 @@ from esphome.types import ConfigType
from .. import template_ns from .. import template_ns
CONF_CURRENT_TEMPERATURE = "current_temperature" CONF_CURRENT_TEMPERATURE = "current_temperature"
CONF_IS_ON = "is_on"
TemplateWaterHeater = template_ns.class_( TemplateWaterHeater = template_ns.class_(
"TemplateWaterHeater", cg.Component, water_heater.WaterHeater "TemplateWaterHeater", cg.Component, water_heater.WaterHeater
@@ -51,6 +53,8 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list( cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list(
water_heater.validate_water_heater_mode water_heater.validate_water_heater_mode
), ),
cv.Optional(CONF_AWAY): cv.returning_lambda,
cv.Optional(CONF_IS_ON): cv.returning_lambda,
} }
) )
.extend(cv.COMPONENT_SCHEMA) .extend(cv.COMPONENT_SCHEMA)
@@ -98,6 +102,22 @@ async def to_code(config: ConfigType) -> None:
if CONF_SUPPORTED_MODES in config: if CONF_SUPPORTED_MODES in config:
cg.add(var.set_supported_modes(config[CONF_SUPPORTED_MODES])) cg.add(var.set_supported_modes(config[CONF_SUPPORTED_MODES]))
if CONF_AWAY in config:
template_ = await cg.process_lambda(
config[CONF_AWAY],
[],
return_type=cg.optional.template(bool),
)
cg.add(var.set_away_lambda(template_))
if CONF_IS_ON in config:
template_ = await cg.process_lambda(
config[CONF_IS_ON],
[],
return_type=cg.optional.template(bool),
)
cg.add(var.set_is_on_lambda(template_))
@automation.register_action( @automation.register_action(
"water_heater.template.publish", "water_heater.template.publish",
@@ -110,6 +130,8 @@ async def to_code(config: ConfigType) -> None:
cv.Optional(CONF_MODE): cv.templatable( cv.Optional(CONF_MODE): cv.templatable(
water_heater.validate_water_heater_mode water_heater.validate_water_heater_mode
), ),
cv.Optional(CONF_AWAY): cv.templatable(cv.boolean),
cv.Optional(CONF_IS_ON): cv.templatable(cv.boolean),
} }
), ),
) )
@@ -134,4 +156,12 @@ async def water_heater_template_publish_to_code(
template_ = await cg.templatable(mode, args, water_heater.WaterHeaterMode) template_ = await cg.templatable(mode, args, water_heater.WaterHeaterMode)
cg.add(var.set_mode(template_)) cg.add(var.set_mode(template_))
if CONF_AWAY in config:
template_ = await cg.templatable(config[CONF_AWAY], args, bool)
cg.add(var.set_away(template_))
if CONF_IS_ON in config:
template_ = await cg.templatable(config[CONF_IS_ON], args, bool)
cg.add(var.set_is_on(template_))
return var return var
@@ -11,12 +11,15 @@ class TemplateWaterHeaterPublishAction : public Action<Ts...>, public Parented<T
TEMPLATABLE_VALUE(float, current_temperature) TEMPLATABLE_VALUE(float, current_temperature)
TEMPLATABLE_VALUE(float, target_temperature) TEMPLATABLE_VALUE(float, target_temperature)
TEMPLATABLE_VALUE(water_heater::WaterHeaterMode, mode) TEMPLATABLE_VALUE(water_heater::WaterHeaterMode, mode)
TEMPLATABLE_VALUE(bool, away)
TEMPLATABLE_VALUE(bool, is_on)
void play(const Ts &...x) override { void play(const Ts &...x) override {
if (this->current_temperature_.has_value()) { if (this->current_temperature_.has_value()) {
this->parent_->set_current_temperature(this->current_temperature_.value(x...)); this->parent_->set_current_temperature(this->current_temperature_.value(x...));
} }
bool needs_call = this->target_temperature_.has_value() || this->mode_.has_value(); bool needs_call = this->target_temperature_.has_value() || this->mode_.has_value() || this->away_.has_value() ||
this->is_on_.has_value();
if (needs_call) { if (needs_call) {
auto call = this->parent_->make_call(); auto call = this->parent_->make_call();
if (this->target_temperature_.has_value()) { if (this->target_temperature_.has_value()) {
@@ -25,6 +28,12 @@ class TemplateWaterHeaterPublishAction : public Action<Ts...>, public Parented<T
if (this->mode_.has_value()) { if (this->mode_.has_value()) {
call.set_mode(this->mode_.value(x...)); call.set_mode(this->mode_.value(x...));
} }
if (this->away_.has_value()) {
call.set_away(this->away_.value(x...));
}
if (this->is_on_.has_value()) {
call.set_on(this->is_on_.value(x...));
}
call.perform(); call.perform();
} else { } else {
this->parent_->publish_state(); this->parent_->publish_state();
@@ -17,7 +17,7 @@ void TemplateWaterHeater::setup() {
} }
} }
if (!this->current_temperature_f_.has_value() && !this->target_temperature_f_.has_value() && if (!this->current_temperature_f_.has_value() && !this->target_temperature_f_.has_value() &&
!this->mode_f_.has_value()) !this->mode_f_.has_value() && !this->away_f_.has_value() && !this->is_on_f_.has_value())
this->disable_loop(); this->disable_loop();
} }
@@ -32,6 +32,12 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() {
if (this->target_temperature_f_.has_value()) { if (this->target_temperature_f_.has_value()) {
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE); traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE);
} }
if (this->away_f_.has_value()) {
traits.set_supports_away_mode(true);
}
if (this->is_on_f_.has_value()) {
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_ON_OFF);
}
return traits; return traits;
} }
@@ -62,6 +68,22 @@ void TemplateWaterHeater::loop() {
} }
} }
auto away = this->away_f_.call();
if (away.has_value()) {
if (*away != this->is_away()) {
this->set_state_flag_(water_heater::WATER_HEATER_STATE_AWAY, *away);
changed = true;
}
}
auto is_on = this->is_on_f_.call();
if (is_on.has_value()) {
if (*is_on != this->is_on()) {
this->set_state_flag_(water_heater::WATER_HEATER_STATE_ON, *is_on);
changed = true;
}
}
if (changed) { if (changed) {
this->publish_state(); this->publish_state();
} }
@@ -90,6 +112,17 @@ void TemplateWaterHeater::control(const water_heater::WaterHeaterCall &call) {
} }
} }
if (call.get_away().has_value()) {
if (this->optimistic_) {
this->set_state_flag_(water_heater::WATER_HEATER_STATE_AWAY, *call.get_away());
}
}
if (call.get_on().has_value()) {
if (this->optimistic_) {
this->set_state_flag_(water_heater::WATER_HEATER_STATE_ON, *call.get_on());
}
}
this->set_trigger_.trigger(); this->set_trigger_.trigger();
if (this->optimistic_) { if (this->optimistic_) {
@@ -24,6 +24,8 @@ class TemplateWaterHeater : public Component, public water_heater::WaterHeater {
this->target_temperature_f_.set(std::forward<F>(f)); this->target_temperature_f_.set(std::forward<F>(f));
} }
template<typename F> void set_mode_lambda(F &&f) { this->mode_f_.set(std::forward<F>(f)); } template<typename F> void set_mode_lambda(F &&f) { this->mode_f_.set(std::forward<F>(f)); }
template<typename F> void set_away_lambda(F &&f) { this->away_f_.set(std::forward<F>(f)); }
template<typename F> void set_is_on_lambda(F &&f) { this->is_on_f_.set(std::forward<F>(f)); }
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
void set_restore_mode(TemplateWaterHeaterRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } void set_restore_mode(TemplateWaterHeaterRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
@@ -49,6 +51,8 @@ class TemplateWaterHeater : public Component, public water_heater::WaterHeater {
TemplateLambda<float> current_temperature_f_; TemplateLambda<float> current_temperature_f_;
TemplateLambda<float> target_temperature_f_; TemplateLambda<float> target_temperature_f_;
TemplateLambda<water_heater::WaterHeaterMode> mode_f_; TemplateLambda<water_heater::WaterHeaterMode> mode_f_;
TemplateLambda<bool> away_f_;
TemplateLambda<bool> is_on_f_;
TemplateWaterHeaterRestoreMode restore_mode_{WATER_HEATER_NO_RESTORE}; TemplateWaterHeaterRestoreMode restore_mode_{WATER_HEATER_NO_RESTORE};
water_heater::WaterHeaterModeMask supported_modes_; water_heater::WaterHeaterModeMask supported_modes_;
bool optimistic_{true}; bool optimistic_{true};
@@ -13,6 +13,8 @@ esphome:
id: template_water_heater id: template_water_heater
target_temperature: 50.0 target_temperature: 50.0
mode: ECO mode: ECO
away: false
is_on: true
# Templated # Templated
- water_heater.template.publish: - water_heater.template.publish:
@@ -20,6 +22,8 @@ esphome:
current_temperature: !lambda "return 45.0;" current_temperature: !lambda "return 45.0;"
target_temperature: !lambda "return 55.0;" target_temperature: !lambda "return 55.0;"
mode: !lambda "return water_heater::WATER_HEATER_MODE_GAS;" mode: !lambda "return water_heater::WATER_HEATER_MODE_GAS;"
away: !lambda "return true;"
is_on: !lambda "return false;"
# Test C++ API: set_template() with stateless lambda (no captures) # Test C++ API: set_template() with stateless lambda (no captures)
# NOTE: set_template() is not intended to be a public API, but we test it to ensure it doesn't break. # NOTE: set_template() is not intended to be a public API, but we test it to ensure it doesn't break.
@@ -414,6 +418,8 @@ water_heater:
current_temperature: !lambda "return 42.0f;" current_temperature: !lambda "return 42.0f;"
target_temperature: !lambda "return 60.0f;" target_temperature: !lambda "return 60.0f;"
mode: !lambda "return water_heater::WATER_HEATER_MODE_ECO;" mode: !lambda "return water_heater::WATER_HEATER_MODE_ECO;"
away: !lambda "return false;"
is_on: !lambda "return true;"
supported_modes: supported_modes:
- "OFF" - "OFF"
- ECO - ECO
@@ -4,6 +4,14 @@ host:
api: api:
logger: logger:
globals:
- id: global_away
type: bool
initial_value: "false"
- id: global_is_on
type: bool
initial_value: "true"
water_heater: water_heater:
- platform: template - platform: template
id: test_boiler id: test_boiler
@@ -11,6 +19,8 @@ water_heater:
optimistic: true optimistic: true
current_temperature: !lambda "return 45.0f;" current_temperature: !lambda "return 45.0f;"
target_temperature: !lambda "return 60.0f;" target_temperature: !lambda "return 60.0f;"
away: !lambda "return id(global_away);"
is_on: !lambda "return id(global_is_on);"
# Note: No mode lambda - we want optimistic mode changes to stick # Note: No mode lambda - we want optimistic mode changes to stick
# A mode lambda would override mode changes in loop() # A mode lambda would override mode changes in loop()
supported_modes: supported_modes:
@@ -22,3 +32,8 @@ water_heater:
min_temperature: 30.0 min_temperature: 30.0
max_temperature: 85.0 max_temperature: 85.0
target_temperature_step: 0.5 target_temperature_step: 0.5
set_action:
- lambda: |-
// Sync optimistic state back to globals so lambdas reflect the change
id(global_away) = id(test_boiler).is_away();
id(global_is_on) = id(test_boiler).is_on();
+65 -24
View File
@@ -5,7 +5,13 @@ from __future__ import annotations
import asyncio import asyncio
import aioesphomeapi import aioesphomeapi
from aioesphomeapi import WaterHeaterInfo, WaterHeaterMode, WaterHeaterState from aioesphomeapi import (
WaterHeaterFeature,
WaterHeaterInfo,
WaterHeaterMode,
WaterHeaterState,
WaterHeaterStateFlag,
)
import pytest import pytest
from .state_utils import InitialStateHelper from .state_utils import InitialStateHelper
@@ -22,18 +28,25 @@ async def test_water_heater_template(
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
async with run_compiled(yaml_config), api_client_connected() as client: async with run_compiled(yaml_config), api_client_connected() as client:
states: dict[int, aioesphomeapi.EntityState] = {} states: dict[int, aioesphomeapi.EntityState] = {}
gas_mode_future: asyncio.Future[WaterHeaterState] = loop.create_future() state_future: asyncio.Future[WaterHeaterState] | None = None
eco_mode_future: asyncio.Future[WaterHeaterState] = loop.create_future()
def on_state(state: aioesphomeapi.EntityState) -> None: def on_state(state: aioesphomeapi.EntityState) -> None:
states[state.key] = state states[state.key] = state
if isinstance(state, WaterHeaterState): if (
# Wait for GAS mode isinstance(state, WaterHeaterState)
if state.mode == WaterHeaterMode.GAS and not gas_mode_future.done(): and state_future is not None
gas_mode_future.set_result(state) and not state_future.done()
# Wait for ECO mode (we start at OFF, so test transitioning to ECO) ):
elif state.mode == WaterHeaterMode.ECO and not eco_mode_future.done(): state_future.set_result(state)
eco_mode_future.set_result(state)
async def wait_for_state(timeout: float = 5.0) -> WaterHeaterState:
"""Wait for next water heater state change."""
nonlocal state_future
state_future = loop.create_future()
try:
return await asyncio.wait_for(state_future, timeout)
finally:
state_future = None
# Get entities and set up state synchronization # Get entities and set up state synchronization
entities, services = await client.list_entities_services() entities, services = await client.list_entities_services()
@@ -89,24 +102,52 @@ async def test_water_heater_template(
f"Expected target temp 60.0, got {initial_state.target_temperature}" f"Expected target temp 60.0, got {initial_state.target_temperature}"
) )
# Verify supported features: away mode and on/off (fixture has away + is_on lambdas)
assert (
test_water_heater.supported_features & WaterHeaterFeature.SUPPORTS_AWAY_MODE
) != 0, "Expected SUPPORTS_AWAY_MODE in supported_features"
assert (
test_water_heater.supported_features & WaterHeaterFeature.SUPPORTS_ON_OFF
) != 0, "Expected SUPPORTS_ON_OFF in supported_features"
# Verify initial state: on (is_on lambda returns true), not away (away lambda returns false)
assert (initial_state.state & WaterHeaterStateFlag.ON) != 0, (
"Expected initial state to include ON flag"
)
assert (initial_state.state & WaterHeaterStateFlag.AWAY) == 0, (
"Expected initial state to not include AWAY flag"
)
# Test turning on away mode
client.water_heater_command(test_water_heater.key, away=True)
away_on_state = await wait_for_state()
assert (away_on_state.state & WaterHeaterStateFlag.AWAY) != 0
# ON flag should still be set (is_on lambda returns true)
assert (away_on_state.state & WaterHeaterStateFlag.ON) != 0
# Test turning off away mode
client.water_heater_command(test_water_heater.key, away=False)
away_off_state = await wait_for_state()
assert (away_off_state.state & WaterHeaterStateFlag.AWAY) == 0
assert (away_off_state.state & WaterHeaterStateFlag.ON) != 0
# Test turning off (on=False)
client.water_heater_command(test_water_heater.key, on=False)
off_state = await wait_for_state()
assert (off_state.state & WaterHeaterStateFlag.ON) == 0
assert (off_state.state & WaterHeaterStateFlag.AWAY) == 0
# Test turning back on (on=True)
client.water_heater_command(test_water_heater.key, on=True)
on_state = await wait_for_state()
assert (on_state.state & WaterHeaterStateFlag.ON) != 0
# Test changing to GAS mode # Test changing to GAS mode
client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.GAS) client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.GAS)
gas_state = await wait_for_state()
try:
gas_state = await asyncio.wait_for(gas_mode_future, timeout=5.0)
except TimeoutError:
pytest.fail("GAS mode change not received within 5 seconds")
assert isinstance(gas_state, WaterHeaterState)
assert gas_state.mode == WaterHeaterMode.GAS assert gas_state.mode == WaterHeaterMode.GAS
# Test changing to ECO mode (from GAS) # Test changing to ECO mode (from GAS)
client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO) client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO)
eco_state = await wait_for_state()
try:
eco_state = await asyncio.wait_for(eco_mode_future, timeout=5.0)
except TimeoutError:
pytest.fail("ECO mode change not received within 5 seconds")
assert isinstance(eco_state, WaterHeaterState)
assert eco_state.mode == WaterHeaterMode.ECO assert eco_state.mode == WaterHeaterMode.ECO