From 2d22bd4951a6299fd46df178252616212670f510 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 8 Feb 2026 21:35:29 -0800 Subject: [PATCH] fix --- .../template/water_heater/__init__.py | 36 +++---- .../template/water_heater/automation.h | 12 +-- .../water_heater/template_water_heater.cpp | 41 +++---- .../water_heater/template_water_heater.h | 4 +- .../components/water_heater/water_heater.cpp | 36 +++++-- .../components/water_heater/water_heater.h | 29 ++--- tests/components/template/common-base.yaml | 14 +-- .../fixtures/water_heater_template.yaml | 2 +- .../integration/test_water_heater_template.py | 102 ++++++++++-------- 9 files changed, 145 insertions(+), 131 deletions(-) diff --git a/esphome/components/template/water_heater/__init__.py b/esphome/components/template/water_heater/__init__.py index aaa85d811a..9978e945ca 100644 --- a/esphome/components/template/water_heater/__init__.py +++ b/esphome/components/template/water_heater/__init__.py @@ -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 diff --git a/esphome/components/template/water_heater/automation.h b/esphome/components/template/water_heater/automation.h index 12f10e93a1..d19542db41 100644 --- a/esphome/components/template/water_heater/automation.h +++ b/esphome/components/template/water_heater/automation.h @@ -11,15 +11,15 @@ class TemplateWaterHeaterPublishAction : public Action, public Parentedcurrent_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, public Parentedmode_.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(); diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index d3ea17ab4f..4babb44625 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -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); } } diff --git a/esphome/components/template/water_heater/template_water_heater.h b/esphome/components/template/water_heater/template_water_heater.h index a202405fbf..045a142e40 100644 --- a/esphome/components/template/water_heater/template_water_heater.h +++ b/esphome/components/template/water_heater/template_water_heater.h @@ -24,8 +24,8 @@ class TemplateWaterHeater : public Component, public water_heater::WaterHeater { this->target_temperature_f_.set(std::forward(f)); } template void set_mode_lambda(F &&f) { this->mode_f_.set(std::forward(f)); } - template void set_on_lambda(F &&f) { this->on_f_.set(std::forward(f)); } template void set_away_lambda(F &&f) { this->away_f_.set(std::forward(f)); } + template void set_is_on_lambda(F &&f) { this->is_on_f_.set(std::forward(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 current_temperature_f_; TemplateLambda target_temperature_f_; TemplateLambda mode_f_; - TemplateLambda on_f_; TemplateLambda away_f_; + TemplateLambda is_on_f_; TemplateWaterHeaterRestoreMode restore_mode_{WATER_HEATER_NO_RESTORE}; water_heater::WaterHeaterModeMask supported_modes_; bool optimistic_{true}; diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index ad9183a2f5..9d7ae0cbc0 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -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; } } diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index 2ea97e7c4c..93fcf5f401 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -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 &get_away() const { return this->away_; } - const optional &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 away_; - optional 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; } }; diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 352b65c0f6..e9ddfcf43e 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -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 diff --git a/tests/integration/fixtures/water_heater_template.yaml b/tests/integration/fixtures/water_heater_template.yaml index 3e2503209e..8b62112cd0 100644 --- a/tests/integration/fixtures/water_heater_template.yaml +++ b/tests/integration/fixtures/water_heater_template.yaml @@ -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: diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index f3354a54b3..3879492dda 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -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" + )