This commit is contained in:
tronikos
2026-02-08 21:35:29 -08:00
parent 9bc93415d8
commit 2d22bd4951
9 changed files with 145 additions and 131 deletions
@@ -6,7 +6,6 @@ from esphome.const import (
CONF_AWAY,
CONF_ID,
CONF_MODE,
CONF_ON,
CONF_OPTIMISTIC,
CONF_RESTORE_MODE,
CONF_SET_ACTION,
@@ -20,6 +19,7 @@ from esphome.types import ConfigType
from .. import template_ns
CONF_CURRENT_TEMPERATURE = "current_temperature"
CONF_IS_ON = "is_on"
TemplateWaterHeater = template_ns.class_(
"TemplateWaterHeater", cg.Component, water_heater.WaterHeater
@@ -50,11 +50,11 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_CURRENT_TEMPERATURE): cv.returning_lambda,
cv.Optional(CONF_TARGET_TEMPERATURE): cv.returning_lambda,
cv.Optional(CONF_MODE): cv.returning_lambda,
cv.Optional(CONF_ON): cv.returning_lambda,
cv.Optional(CONF_AWAY): cv.returning_lambda,
cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list(
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)
@@ -99,24 +99,24 @@ async def to_code(config: ConfigType) -> None:
)
cg.add(var.set_mode_lambda(template_))
if CONF_ON in config:
template_ = await cg.process_lambda(
config[CONF_ON],
[],
return_type=cg.optional.template(cg.bool_),
)
cg.add(var.set_on_lambda(template_))
if CONF_SUPPORTED_MODES in config:
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(cg.bool_),
return_type=cg.optional.template(bool),
)
cg.add(var.set_away_lambda(template_))
if CONF_SUPPORTED_MODES in config:
cg.add(var.set_supported_modes(config[CONF_SUPPORTED_MODES]))
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(
@@ -130,8 +130,8 @@ async def to_code(config: ConfigType) -> None:
cv.Optional(CONF_MODE): cv.templatable(
water_heater.validate_water_heater_mode
),
cv.Optional(CONF_ON): cv.templatable(cv.boolean),
cv.Optional(CONF_AWAY): cv.templatable(cv.boolean),
cv.Optional(CONF_IS_ON): cv.templatable(cv.boolean),
}
),
)
@@ -156,12 +156,12 @@ async def water_heater_template_publish_to_code(
template_ = await cg.templatable(mode, args, water_heater.WaterHeaterMode)
cg.add(var.set_mode(template_))
if on := config.get(CONF_ON):
template_ = await cg.templatable(on, args, bool)
cg.add(var.set_on(template_))
if away := config.get(CONF_AWAY):
template_ = await cg.templatable(away, args, bool)
cg.add(var.set_away(template_))
if is_on := config.get(CONF_IS_ON):
template_ = await cg.templatable(is_on, args, bool)
cg.add(var.set_is_on(template_))
return var
@@ -11,15 +11,15 @@ class TemplateWaterHeaterPublishAction : public Action<Ts...>, public Parented<T
TEMPLATABLE_VALUE(float, current_temperature)
TEMPLATABLE_VALUE(float, target_temperature)
TEMPLATABLE_VALUE(water_heater::WaterHeaterMode, mode)
TEMPLATABLE_VALUE(bool, on)
TEMPLATABLE_VALUE(bool, away)
TEMPLATABLE_VALUE(bool, is_on)
void play(const Ts &...x) override {
if (this->current_temperature_.has_value()) {
this->parent_->set_current_temperature(this->current_temperature_.value(x...));
}
bool needs_call = this->target_temperature_.has_value() || this->mode_.has_value() || this->on_.has_value() ||
this->away_.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) {
auto call = this->parent_->make_call();
if (this->target_temperature_.has_value()) {
@@ -28,12 +28,12 @@ class TemplateWaterHeaterPublishAction : public Action<Ts...>, public Parented<T
if (this->mode_.has_value()) {
call.set_mode(this->mode_.value(x...));
}
if (this->on_.has_value()) {
call.set_on(this->on_.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();
} else {
this->parent_->publish_state();
@@ -17,7 +17,7 @@ void TemplateWaterHeater::setup() {
}
}
if (!this->current_temperature_f_.has_value() && !this->target_temperature_f_.has_value() &&
!this->mode_f_.has_value() && !this->on_f_.has_value() && !this->away_f_.has_value())
!this->mode_f_.has_value() && !this->away_f_.has_value() && !this->is_on_f_.has_value())
this->disable_loop();
}
@@ -32,11 +32,11 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() {
if (this->target_temperature_f_.has_value()) {
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE);
}
if (this->on_f_.has_value()) {
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_ON_OFF);
}
if (this->away_f_.has_value()) {
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_AWAY_MODE);
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;
}
@@ -68,14 +68,6 @@ void TemplateWaterHeater::loop() {
}
}
auto on = this->on_f_.call();
if (on.has_value()) {
if (*on != this->is_on()) {
this->set_state_flag_(water_heater::WATER_HEATER_STATE_ON, *on);
changed = true;
}
}
auto away = this->away_f_.call();
if (away.has_value()) {
if (*away != this->is_away()) {
@@ -84,6 +76,14 @@ void TemplateWaterHeater::loop() {
}
}
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) {
this->publish_state();
}
@@ -111,12 +111,17 @@ void TemplateWaterHeater::control(const water_heater::WaterHeaterCall &call) {
this->target_temperature_ = call.get_target_temperature();
}
}
if (this->optimistic_) {
if (call.get_on().has_value()) {
this->set_state_flag_(water_heater::WATER_HEATER_STATE_ON, *call.get_on());
if ((call.get_state_mask() & water_heater::WATER_HEATER_STATE_AWAY) != 0) {
if (this->optimistic_) {
this->set_state_flag_(water_heater::WATER_HEATER_STATE_AWAY,
(call.get_state() & water_heater::WATER_HEATER_STATE_AWAY) != 0);
}
if (call.get_away().has_value()) {
this->set_state_flag_(water_heater::WATER_HEATER_STATE_AWAY, *call.get_away());
}
if ((call.get_state_mask() & water_heater::WATER_HEATER_STATE_ON) != 0) {
if (this->optimistic_) {
this->set_state_flag_(water_heater::WATER_HEATER_STATE_ON,
(call.get_state() & water_heater::WATER_HEATER_STATE_ON) != 0);
}
}
@@ -24,8 +24,8 @@ class TemplateWaterHeater : public Component, public water_heater::WaterHeater {
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_on_lambda(F &&f) { this->on_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_restore_mode(TemplateWaterHeaterRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
@@ -51,8 +51,8 @@ class TemplateWaterHeater : public Component, public water_heater::WaterHeater {
TemplateLambda<float> current_temperature_f_;
TemplateLambda<float> target_temperature_f_;
TemplateLambda<water_heater::WaterHeaterMode> mode_f_;
TemplateLambda<bool> on_f_;
TemplateLambda<bool> away_f_;
TemplateLambda<bool> is_on_f_;
TemplateWaterHeaterRestoreMode restore_mode_{WATER_HEATER_NO_RESTORE};
water_heater::WaterHeaterModeMask supported_modes_;
bool optimistic_{true};
@@ -60,12 +60,22 @@ WaterHeaterCall &WaterHeaterCall::set_target_temperature_high(float temperature)
}
WaterHeaterCall &WaterHeaterCall::set_away(bool away) {
this->away_ = away;
if (away) {
this->state_ |= WATER_HEATER_STATE_AWAY;
} else {
this->state_ &= ~WATER_HEATER_STATE_AWAY;
}
this->state_mask_ |= WATER_HEATER_STATE_AWAY;
return *this;
}
WaterHeaterCall &WaterHeaterCall::set_on(bool on) {
this->on_ = on;
if (on) {
this->state_ |= WATER_HEATER_STATE_ON;
} else {
this->state_ &= ~WATER_HEATER_STATE_ON;
}
this->state_mask_ |= WATER_HEATER_STATE_ON;
return *this;
}
@@ -84,11 +94,11 @@ void WaterHeaterCall::perform() {
if (!std::isnan(this->target_temperature_high_)) {
ESP_LOGD(TAG, " Target Temperature High: %.2f", this->target_temperature_high_);
}
if (this->away_.has_value()) {
ESP_LOGD(TAG, " Away: %s", YESNO(*this->away_));
if (this->state_mask_ & WATER_HEATER_STATE_AWAY) {
ESP_LOGD(TAG, " Away: %s", (this->state_ & WATER_HEATER_STATE_AWAY) ? "YES" : "NO");
}
if (this->on_.has_value()) {
ESP_LOGD(TAG, " On: %s", YESNO(*this->on_));
if (this->state_mask_ & WATER_HEATER_STATE_ON) {
ESP_LOGD(TAG, " On: %s", (this->state_ & WATER_HEATER_STATE_ON) ? "YES" : "NO");
}
this->parent_->control(*this);
}
@@ -129,13 +139,17 @@ void WaterHeaterCall::validate_() {
this->target_temperature_high_ = NAN;
}
}
if (this->away_.has_value() && *this->away_ && !traits.get_supports_away_mode()) {
ESP_LOGW(TAG, "'%s' - Away mode not supported", this->parent_->get_name().c_str());
this->away_.reset();
if (!traits.get_supports_away_mode()) {
if (this->state_ & WATER_HEATER_STATE_AWAY) {
ESP_LOGW(TAG, "'%s' - Away mode not supported", this->parent_->get_name().c_str());
}
this->state_ &= ~WATER_HEATER_STATE_AWAY;
this->state_mask_ &= ~WATER_HEATER_STATE_AWAY;
}
// If ON/OFF not supported, device is always on - clear the flag silently
if (this->on_.has_value() && !traits.has_feature_flags(WATER_HEATER_SUPPORTS_ON_OFF)) {
this->on_.reset();
if (!traits.has_feature_flags(WATER_HEATER_SUPPORTS_ON_OFF)) {
this->state_ &= ~WATER_HEATER_STATE_ON;
this->state_mask_ &= ~WATER_HEATER_STATE_ON;
}
}
+8 -21
View File
@@ -89,23 +89,10 @@ class WaterHeaterCall {
float get_target_temperature() const { return this->target_temperature_; }
float get_target_temperature_low() const { return this->target_temperature_low_; }
float get_target_temperature_high() const { return this->target_temperature_high_; }
const optional<bool> &get_away() const { return this->away_; }
const optional<bool> &get_on() const { return this->on_; }
ESPDEPRECATED("set_state() is deprecated, use set_on() and set_away() instead. (Removed in 2026.8.0)", "2026.2.0")
void set_state(uint32_t state) {
this->set_away((state & WATER_HEATER_STATE_AWAY) != 0);
this->set_on((state & WATER_HEATER_STATE_ON) != 0);
}
ESPDEPRECATED("get_state() is deprecated, use is_on() and is_away() instead. (Removed in 2026.8.0)", "2026.2.0")
uint32_t get_state() const {
uint32_t state = 0;
if (this->away_.value_or(false))
state |= WATER_HEATER_STATE_AWAY;
if (this->on_.value_or(false))
state |= WATER_HEATER_STATE_ON;
return state;
}
/// Get state flags value
uint32_t get_state() const { return this->state_; }
/// Get mask of state flags that are being changed
uint32_t get_state_mask() const { return this->state_mask_; }
protected:
void validate_();
@@ -114,8 +101,8 @@ class WaterHeaterCall {
float target_temperature_{NAN};
float target_temperature_low_{NAN};
float target_temperature_high_{NAN};
optional<bool> away_;
optional<bool> on_;
uint32_t state_{0};
uint32_t state_mask_{0};
};
struct WaterHeaterCallInternal : public WaterHeaterCall {
@@ -126,8 +113,8 @@ struct WaterHeaterCallInternal : public WaterHeaterCall {
this->target_temperature_ = restore.target_temperature_;
this->target_temperature_low_ = restore.target_temperature_low_;
this->target_temperature_high_ = restore.target_temperature_high_;
this->away_ = restore.away_;
this->on_ = restore.on_;
this->state_ = restore.state_;
this->state_mask_ = restore.state_mask_;
return *this;
}
};
+5 -9
View File
@@ -13,6 +13,8 @@ esphome:
id: template_water_heater
target_temperature: 50.0
mode: ECO
away: false
is_on: true
# Templated
- water_heater.template.publish:
@@ -20,6 +22,8 @@ esphome:
current_temperature: !lambda "return 45.0;"
target_temperature: !lambda "return 55.0;"
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)
# NOTE: set_template() is not intended to be a public API, but we test it to ensure it doesn't break.
@@ -414,8 +418,8 @@ water_heater:
current_temperature: !lambda "return 42.0f;"
target_temperature: !lambda "return 60.0f;"
mode: !lambda "return water_heater::WATER_HEATER_MODE_ECO;"
"on": !lambda "return true;"
away: !lambda "return false;"
is_on: !lambda "return true;"
supported_modes:
- "OFF"
- ECO
@@ -426,14 +430,6 @@ water_heater:
- PERFORMANCE
set_action:
- logger.log: "set_action"
- water_heater.template.publish:
id: template_water_heater
"on": false
away: true
- water_heater.template.publish:
id: template_water_heater
"on": !lambda "return true;"
away: !lambda "return false;"
datetime:
- platform: template
@@ -11,8 +11,8 @@ water_heater:
optimistic: true
current_temperature: !lambda "return 45.0f;"
target_temperature: !lambda "return 60.0f;"
"on": !lambda "return true;"
away: !lambda "return false;"
is_on: !lambda "return true;"
# Note: No mode lambda - we want optimistic mode changes to stick
# A mode lambda would override mode changes in loop()
supported_modes:
+57 -45
View File
@@ -11,12 +11,23 @@ from aioesphomeapi import (
WaterHeaterState,
WaterHeaterStateFlag,
)
from aioesphomeapi.model import APIIntEnum
import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction
class WaterHeaterFeature(APIIntEnum):
"""ESPHome water heater feature flags (WaterHeaterFeature)."""
SUPPORTS_CURRENT_TEMPERATURE = 1 << 0
SUPPORTS_TARGET_TEMPERATURE = 1 << 1
SUPPORTS_OPERATION_MODE = 1 << 2
SUPPORTS_AWAY_MODE = 1 << 3
SUPPORTS_ON_OFF = 1 << 4
@pytest.mark.asyncio
async def test_water_heater_template(
yaml_config: str,
@@ -29,6 +40,8 @@ async def test_water_heater_template(
states: dict[int, aioesphomeapi.EntityState] = {}
gas_mode_future: asyncio.Future[WaterHeaterState] = loop.create_future()
eco_mode_future: asyncio.Future[WaterHeaterState] = loop.create_future()
away_on_future: asyncio.Future[WaterHeaterState] = loop.create_future()
on_off_future: asyncio.Future[WaterHeaterState] = loop.create_future()
def on_state(state: aioesphomeapi.EntityState) -> None:
states[state.key] = state
@@ -39,6 +52,16 @@ async def test_water_heater_template(
# Wait for ECO mode (we start at OFF, so test transitioning to ECO)
elif state.mode == WaterHeaterMode.ECO and not eco_mode_future.done():
eco_mode_future.set_result(state)
# Wait for away=True
elif (
state.state & WaterHeaterStateFlag.AWAY
) != 0 and not away_on_future.done():
away_on_future.set_result(state)
# Wait for on=False
elif (
state.state & WaterHeaterStateFlag.ON
) == 0 and not on_off_future.done():
on_off_future.set_result(state)
# Get entities and set up state synchronization
entities, services = await client.list_entities_services()
@@ -69,12 +92,6 @@ async def test_water_heater_template(
f"Expected 4 supported modes, got {len(supported_modes)}: {supported_modes}"
)
# Verify supported features
# WATER_HEATER_SUPPORTS_AWAY_MODE (1 << 3) = 8
# WATER_HEATER_SUPPORTS_ON_OFF (1 << 4) = 16
assert test_water_heater.supported_features & 8
assert test_water_heater.supported_features & 16
# Subscribe with the wrapper that filters initial states
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
@@ -99,12 +116,21 @@ async def test_water_heater_template(
assert initial_state.target_temperature == 60.0, (
f"Expected target temp 60.0, got {initial_state.target_temperature}"
)
# Verify On/Away (from lambdas in fixture)
# 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 state ON (bit 1 set)"
"Expected initial state to include ON flag"
)
assert (initial_state.state & WaterHeaterStateFlag.AWAY) == 0, (
"Expected state NOT AWAY (bit 0 unset)"
"Expected initial state to not include AWAY flag"
)
# Test changing to GAS mode
@@ -118,42 +144,6 @@ async def test_water_heater_template(
assert isinstance(gas_state, WaterHeaterState)
assert gas_state.mode == WaterHeaterMode.GAS
# Test changing away mode and power (optimistic)
away_future: asyncio.Future[WaterHeaterState] = loop.create_future()
off_future: asyncio.Future[WaterHeaterState] = loop.create_future()
def on_state_update(state: aioesphomeapi.EntityState) -> None:
if (
isinstance(state, WaterHeaterState)
and state.key == test_water_heater.key
):
if (
state.state & WaterHeaterStateFlag.AWAY
) != 0 and not away_future.done():
away_future.set_result(state)
if (
state.state & WaterHeaterStateFlag.ON
) == 0 and not off_future.done():
off_future.set_result(state)
client.subscribe_states(on_state_update)
# Change away mode
client.water_heater_command(test_water_heater.key, away=True)
try:
away_state = await asyncio.wait_for(away_future, timeout=5.0)
except TimeoutError:
pytest.fail("Away mode change not received within 5 seconds")
assert (away_state.state & WaterHeaterStateFlag.AWAY) != 0
# Change power
client.water_heater_command(test_water_heater.key, is_on=False)
try:
off_state = await asyncio.wait_for(off_future, timeout=5.0)
except TimeoutError:
pytest.fail("Power off change not received within 5 seconds")
assert (off_state.state & WaterHeaterStateFlag.ON) == 0
# Test changing to ECO mode (from GAS)
client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO)
@@ -164,3 +154,25 @@ async def test_water_heater_template(
assert isinstance(eco_state, WaterHeaterState)
assert eco_state.mode == WaterHeaterMode.ECO
# Test away mode: set away=True (optimistic update; lambda may override on next loop)
client.water_heater_command(test_water_heater.key, away=True)
try:
away_state = await asyncio.wait_for(away_on_future, timeout=5.0)
except TimeoutError:
pytest.fail("Away=True state not received within 5 seconds")
assert isinstance(away_state, WaterHeaterState)
assert (away_state.state & WaterHeaterStateFlag.AWAY) != 0, (
"Expected state to include AWAY flag after away=True command"
)
# Test on/off: set on=False
client.water_heater_command(test_water_heater.key, on=False)
try:
off_state = await asyncio.wait_for(on_off_future, timeout=5.0)
except TimeoutError:
pytest.fail("On=False state not received within 5 seconds")
assert isinstance(off_state, WaterHeaterState)
assert (off_state.state & WaterHeaterStateFlag.ON) == 0, (
"Expected state to not include ON flag after on=False command"
)