From 52af92d4b58aa317ecbd0a4eb65bd24f02e6ed86 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 7 Feb 2026 01:35:37 -0800 Subject: [PATCH 01/15] Add On/Off and Away mode support to template platform Implemented support for On/Off and Away modes in the template water heater platform, including optimistic control and lambda-based state reporting. Refactored the base 'WaterHeaterCall' to replace the 'state_' bitmask with 'optional' for 'on' and 'away' fields. This change was necessary to enable partial (delta) updates. The previous bitmask implementation did not distinguish between a field being "set to false" and "not set at all," causing unintended state resets (e.g., turning the device off when only adjusting temperature). --- .../template/water_heater/__init__.py | 30 ++++++++++++++ .../template/water_heater/automation.h | 11 ++++- .../water_heater/template_water_heater.cpp | 32 ++++++++++++++- .../water_heater/template_water_heater.h | 4 ++ .../components/water_heater/water_heater.cpp | 28 +++++-------- .../components/water_heater/water_heater.h | 10 +++-- tests/components/template/common-base.yaml | 10 +++++ .../fixtures/water_heater_template.yaml | 2 + .../integration/test_water_heater_template.py | 41 +++++++++++++++++++ 9 files changed, 144 insertions(+), 24 deletions(-) diff --git a/esphome/components/template/water_heater/__init__.py b/esphome/components/template/water_heater/__init__.py index 5f96155fbf..aaa85d811a 100644 --- a/esphome/components/template/water_heater/__init__.py +++ b/esphome/components/template/water_heater/__init__.py @@ -3,8 +3,10 @@ import esphome.codegen as cg from esphome.components import water_heater import esphome.config_validation as cv from esphome.const import ( + CONF_AWAY, CONF_ID, CONF_MODE, + CONF_ON, CONF_OPTIMISTIC, CONF_RESTORE_MODE, CONF_SET_ACTION, @@ -48,6 +50,8 @@ 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 ), @@ -95,6 +99,22 @@ 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_AWAY in config: + template_ = await cg.process_lambda( + config[CONF_AWAY], + [], + return_type=cg.optional.template(cg.bool_), + ) + cg.add(var.set_away_lambda(template_)) + if CONF_SUPPORTED_MODES in config: cg.add(var.set_supported_modes(config[CONF_SUPPORTED_MODES])) @@ -110,6 +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), } ), ) @@ -134,4 +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_)) + return var diff --git a/esphome/components/template/water_heater/automation.h b/esphome/components/template/water_heater/automation.h index 3dad2b85ae..12f10e93a1 100644 --- a/esphome/components/template/water_heater/automation.h +++ b/esphome/components/template/water_heater/automation.h @@ -11,12 +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(); + bool needs_call = this->target_temperature_.has_value() || this->mode_.has_value() || this->on_.has_value() || + this->away_.has_value(); if (needs_call) { auto call = this->parent_->make_call(); if (this->target_temperature_.has_value()) { @@ -25,6 +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...)); + } 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 c354deee0e..d3ea17ab4f 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->mode_f_.has_value() && !this->on_f_.has_value() && !this->away_f_.has_value()) this->disable_loop(); } @@ -32,6 +32,12 @@ 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); + } return traits; } @@ -62,6 +68,22 @@ 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()) { + this->set_state_flag_(water_heater::WATER_HEATER_STATE_AWAY, *away); + changed = true; + } + } + if (changed) { this->publish_state(); } @@ -89,6 +111,14 @@ 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_away().has_value()) { + this->set_state_flag_(water_heater::WATER_HEATER_STATE_AWAY, *call.get_away()); + } + } this->set_trigger_.trigger(); diff --git a/esphome/components/template/water_heater/template_water_heater.h b/esphome/components/template/water_heater/template_water_heater.h index 22173209aa..a202405fbf 100644 --- a/esphome/components/template/water_heater/template_water_heater.h +++ b/esphome/components/template/water_heater/template_water_heater.h @@ -24,6 +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)); } void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } 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 current_temperature_f_; TemplateLambda target_temperature_f_; TemplateLambda mode_f_; + TemplateLambda on_f_; + TemplateLambda away_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 e6d1562352..ad9183a2f5 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -60,20 +60,12 @@ WaterHeaterCall &WaterHeaterCall::set_target_temperature_high(float temperature) } WaterHeaterCall &WaterHeaterCall::set_away(bool away) { - if (away) { - this->state_ |= WATER_HEATER_STATE_AWAY; - } else { - this->state_ &= ~WATER_HEATER_STATE_AWAY; - } + this->away_ = away; return *this; } WaterHeaterCall &WaterHeaterCall::set_on(bool on) { - if (on) { - this->state_ |= WATER_HEATER_STATE_ON; - } else { - this->state_ &= ~WATER_HEATER_STATE_ON; - } + this->on_ = on; return *this; } @@ -92,11 +84,11 @@ void WaterHeaterCall::perform() { if (!std::isnan(this->target_temperature_high_)) { ESP_LOGD(TAG, " Target Temperature High: %.2f", this->target_temperature_high_); } - if (this->state_ & WATER_HEATER_STATE_AWAY) { - ESP_LOGD(TAG, " Away: YES"); + if (this->away_.has_value()) { + ESP_LOGD(TAG, " Away: %s", YESNO(*this->away_)); } - if (this->state_ & WATER_HEATER_STATE_ON) { - ESP_LOGD(TAG, " On: YES"); + if (this->on_.has_value()) { + ESP_LOGD(TAG, " On: %s", YESNO(*this->on_)); } this->parent_->control(*this); } @@ -137,13 +129,13 @@ void WaterHeaterCall::validate_() { this->target_temperature_high_ = NAN; } } - if ((this->state_ & WATER_HEATER_STATE_AWAY) && !traits.get_supports_away_mode()) { + 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->state_ &= ~WATER_HEATER_STATE_AWAY; + this->away_.reset(); } // If ON/OFF not supported, device is always on - clear the flag silently - if (!traits.has_feature_flags(WATER_HEATER_SUPPORTS_ON_OFF)) { - this->state_ &= ~WATER_HEATER_STATE_ON; + if (this->on_.has_value() && !traits.has_feature_flags(WATER_HEATER_SUPPORTS_ON_OFF)) { + this->on_.reset(); } } diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index 7bd05ba7f5..6677de7d36 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -89,8 +89,8 @@ 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_; } - /// Get state flags value - uint32_t get_state() const { return this->state_; } + const optional &get_away() const { return this->away_; } + const optional &get_on() const { return this->on_; } protected: void validate_(); @@ -99,7 +99,8 @@ class WaterHeaterCall { float target_temperature_{NAN}; float target_temperature_low_{NAN}; float target_temperature_high_{NAN}; - uint32_t state_{0}; + optional away_; + optional on_; }; struct WaterHeaterCallInternal : public WaterHeaterCall { @@ -110,7 +111,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->state_ = restore.state_; + this->away_ = restore.away_; + this->on_ = restore.on_; return *this; } }; diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index b8742f8c7b..119afb22aa 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -414,6 +414,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;" supported_modes: - "OFF" - ECO @@ -424,6 +426,14 @@ 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 1aaded1991..f04a2a86b6 100644 --- a/tests/integration/fixtures/water_heater_template.yaml +++ b/tests/integration/fixtures/water_heater_template.yaml @@ -11,6 +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;" # 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 6b4a685d0d..07e5a9c53f 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -64,6 +64,12 @@ 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)) @@ -88,6 +94,9 @@ 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) + assert initial_state.is_on is True + assert initial_state.away is False # Test changing to GAS mode client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.GAS) @@ -100,6 +109,38 @@ 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.away is True and not away_future.done(): + away_future.set_result(state) + if state.is_on is False 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.away is True + + # 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.is_on is False + # Test changing to ECO mode (from GAS) client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO) From 1aa4891c83f2fe9fa22e798d93c320b827d3d368 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 7 Feb 2026 01:44:43 -0800 Subject: [PATCH 02/15] fix --- tests/components/template/common-base.yaml | 6 +++--- tests/integration/fixtures/water_heater_template.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 119afb22aa..352b65c0f6 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -414,7 +414,7 @@ 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;" + "on": !lambda "return true;" away: !lambda "return false;" supported_modes: - "OFF" @@ -428,11 +428,11 @@ water_heater: - logger.log: "set_action" - water_heater.template.publish: id: template_water_heater - on: false + "on": false away: true - water_heater.template.publish: id: template_water_heater - on: !lambda "return true;" + "on": !lambda "return true;" away: !lambda "return false;" datetime: diff --git a/tests/integration/fixtures/water_heater_template.yaml b/tests/integration/fixtures/water_heater_template.yaml index f04a2a86b6..3e2503209e 100644 --- a/tests/integration/fixtures/water_heater_template.yaml +++ b/tests/integration/fixtures/water_heater_template.yaml @@ -11,7 +11,7 @@ water_heater: optimistic: true current_temperature: !lambda "return 45.0f;" target_temperature: !lambda "return 60.0f;" - on: !lambda "return true;" + "on": !lambda "return true;" away: !lambda "return false;" # Note: No mode lambda - we want optimistic mode changes to stick # A mode lambda would override mode changes in loop() From 02d3202026d799c6ad6f09fa8881841b23d18ee0 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 7 Feb 2026 02:08:28 -0800 Subject: [PATCH 03/15] add deprecated functions --- esphome/components/water_heater/water_heater.h | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index 6677de7d36..2ea97e7c4c 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -92,6 +92,21 @@ class WaterHeaterCall { 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; + } + protected: void validate_(); WaterHeater *parent_; From 9bc93415d870cb5b363de1cbf1af2a6a5ab920d5 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 7 Feb 2026 03:07:09 -0800 Subject: [PATCH 04/15] fix --- .../integration/test_water_heater_template.py | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index 07e5a9c53f..f3354a54b3 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -5,7 +5,12 @@ from __future__ import annotations import asyncio import aioesphomeapi -from aioesphomeapi import WaterHeaterInfo, WaterHeaterMode, WaterHeaterState +from aioesphomeapi import ( + WaterHeaterInfo, + WaterHeaterMode, + WaterHeaterState, + WaterHeaterStateFlag, +) import pytest from .state_utils import InitialStateHelper @@ -95,8 +100,12 @@ async def test_water_heater_template( f"Expected target temp 60.0, got {initial_state.target_temperature}" ) # Verify On/Away (from lambdas in fixture) - assert initial_state.is_on is True - assert initial_state.away is False + assert (initial_state.state & WaterHeaterStateFlag.ON) != 0, ( + "Expected state ON (bit 1 set)" + ) + assert (initial_state.state & WaterHeaterStateFlag.AWAY) == 0, ( + "Expected state NOT AWAY (bit 0 unset)" + ) # Test changing to GAS mode client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.GAS) @@ -118,9 +127,13 @@ async def test_water_heater_template( isinstance(state, WaterHeaterState) and state.key == test_water_heater.key ): - if state.away is True and not away_future.done(): + if ( + state.state & WaterHeaterStateFlag.AWAY + ) != 0 and not away_future.done(): away_future.set_result(state) - if state.is_on is False and not off_future.done(): + if ( + state.state & WaterHeaterStateFlag.ON + ) == 0 and not off_future.done(): off_future.set_result(state) client.subscribe_states(on_state_update) @@ -131,7 +144,7 @@ async def test_water_heater_template( 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.away is True + assert (away_state.state & WaterHeaterStateFlag.AWAY) != 0 # Change power client.water_heater_command(test_water_heater.key, is_on=False) @@ -139,7 +152,7 @@ async def test_water_heater_template( 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.is_on is False + 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) From 2d22bd4951a6299fd46df178252616212670f510 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 8 Feb 2026 21:35:29 -0800 Subject: [PATCH 05/15] 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" + ) From 0c510ff1e7e9e262ff26a83c27f35f58d7f8934a Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 8 Feb 2026 22:09:08 -0800 Subject: [PATCH 06/15] update --- .../integration/test_water_heater_template.py | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index 3879492dda..3b6863766f 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -40,8 +40,6 @@ 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 @@ -52,16 +50,6 @@ 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() @@ -154,25 +142,3 @@ 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" - ) From cbdb870ce367727df9ba6c2ce0df9244d61bea9c Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 9 Feb 2026 12:25:32 -0800 Subject: [PATCH 07/15] Add get_away and get_on --- .../water_heater/template_water_heater.cpp | 10 ++++------ esphome/components/water_heater/water_heater.h | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 4babb44625..57c76286a0 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -112,16 +112,14 @@ void TemplateWaterHeater::control(const water_heater::WaterHeaterCall &call) { } } - if ((call.get_state_mask() & water_heater::WATER_HEATER_STATE_AWAY) != 0) { + if (call.get_away().has_value()) { if (this->optimistic_) { - this->set_state_flag_(water_heater::WATER_HEATER_STATE_AWAY, - (call.get_state() & water_heater::WATER_HEATER_STATE_AWAY) != 0); + 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 (call.get_on().has_value()) { if (this->optimistic_) { - this->set_state_flag_(water_heater::WATER_HEATER_STATE_ON, - (call.get_state() & water_heater::WATER_HEATER_STATE_ON) != 0); + this->set_state_flag_(water_heater::WATER_HEATER_STATE_ON, *call.get_on()); } } diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index 93fcf5f401..070ae99575 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -90,9 +90,22 @@ class WaterHeaterCall { float get_target_temperature_low() const { return this->target_temperature_low_; } float get_target_temperature_high() const { return this->target_temperature_high_; } /// Get state flags value + ESPDEPRECATED("get_state() is deprecated, use get_away() and get_on() instead. (Removed in 2026.8.0)", "2026.2.0") 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_; } + + optional get_away() const { + if (this->state_mask_ & WATER_HEATER_STATE_AWAY) { + return (this->state_ & WATER_HEATER_STATE_AWAY) != 0; + } + return {}; + } + + optional get_on() const { + if (this->state_mask_ & WATER_HEATER_STATE_ON) { + return (this->state_ & WATER_HEATER_STATE_ON) != 0; + } + return {}; + } protected: void validate_(); From b97a728cf1e8b35c0f9e60ca9c5f7a50fdd5c224 Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Mon, 9 Feb 2026 20:40:44 -0700 Subject: [PATCH 08/15] [ld2450] add on_data callback (#13601) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/ld2450/__init__.py | 13 ++++++++++++- esphome/components/ld2450/ld2450.cpp | 6 ++++++ esphome/components/ld2450/ld2450.h | 12 ++++++++++++ tests/components/ld2450/common.yaml | 3 +++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index bd6d697c90..5854a5794c 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -1,7 +1,8 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE, CONF_TRIGGER_ID AUTO_LOAD = ["ld24xx"] DEPENDENCIES = ["uart"] @@ -11,6 +12,8 @@ MULTI_CONF = True ld2450_ns = cg.esphome_ns.namespace("ld2450") LD2450Component = ld2450_ns.class_("LD2450Component", cg.Component, uart.UARTDevice) +LD2450DataTrigger = ld2450_ns.class_("LD2450DataTrigger", automation.Trigger.template()) + CONF_LD2450_ID = "ld2450_id" CONFIG_SCHEMA = cv.All( @@ -20,6 +23,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_THROTTLE): cv.invalid( f"{CONF_THROTTLE} has been removed; use per-sensor filters, instead" ), + cv.Optional(CONF_ON_DATA): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LD2450DataTrigger), + } + ), } ) .extend(uart.UART_DEVICE_SCHEMA) @@ -45,3 +53,6 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) + for conf in config.get(CONF_ON_DATA, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index b04b509a16..1ea5c18271 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -413,6 +413,10 @@ void LD2450Component::restart_and_read_all_info() { this->set_timeout(1500, [this]() { this->read_all_info(); }); } +void LD2450Component::add_on_data_callback(std::function &&callback) { + this->data_callback_.add(std::move(callback)); +} + // Send command with values to LD2450 void LD2450Component::send_command_(uint8_t command, const uint8_t *command_value, uint8_t command_value_len) { ESP_LOGV(TAG, "Sending COMMAND %02X", command); @@ -613,6 +617,8 @@ void LD2450Component::handle_periodic_data_() { this->still_presence_millis_ = App.get_loop_component_start_time(); } #endif + + this->data_callback_.call(); } bool LD2450Component::handle_ack_data_() { diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index b94c3cac37..fe69cd81d0 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -141,6 +141,9 @@ class LD2450Component : public Component, public uart::UARTDevice { int32_t zone2_x1, int32_t zone2_y1, int32_t zone2_x2, int32_t zone2_y2, int32_t zone3_x1, int32_t zone3_y1, int32_t zone3_x2, int32_t zone3_y2); + /// Add a callback that will be called after each successfully processed periodic data frame. + void add_on_data_callback(std::function &&callback); + protected: void send_command_(uint8_t command_str, const uint8_t *command_value, uint8_t command_value_len); void set_config_mode_(bool enable); @@ -190,6 +193,15 @@ class LD2450Component : public Component, public uart::UARTDevice { #ifdef USE_TEXT_SENSOR std::array direction_text_sensors_{}; #endif + + LazyCallbackManager data_callback_; +}; + +class LD2450DataTrigger : public Trigger<> { + public: + explicit LD2450DataTrigger(LD2450Component *parent) { + parent->add_on_data_callback([this]() { this->trigger(); }); + } }; } // namespace esphome::ld2450 diff --git a/tests/components/ld2450/common.yaml b/tests/components/ld2450/common.yaml index cfa3c922fc..617228ca34 100644 --- a/tests/components/ld2450/common.yaml +++ b/tests/components/ld2450/common.yaml @@ -1,5 +1,8 @@ ld2450: - id: ld2450_radar + on_data: + then: + - logger.log: "LD2450 Radar Data Received" button: - platform: ld2450 From 5caed68cd9a9a36e9d3fb805bb2e464dcfe18e73 Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 10 Feb 2026 03:36:56 -0800 Subject: [PATCH 09/15] [api] Deprecate WATER_HEATER_COMMAND_HAS_STATE (#13892) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 4 +++- esphome/components/api/api_connection.cpp | 6 +++++- esphome/components/api/api_pb2.h | 2 ++ esphome/components/api/api_pb2_dump.cpp | 4 ++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index d25934c60b..18dac6a2d1 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1155,9 +1155,11 @@ enum WaterHeaterCommandHasField { WATER_HEATER_COMMAND_HAS_NONE = 0; WATER_HEATER_COMMAND_HAS_MODE = 1; WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE = 2; - WATER_HEATER_COMMAND_HAS_STATE = 4; + WATER_HEATER_COMMAND_HAS_STATE = 4 [deprecated=true]; WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_LOW = 8; WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH = 16; + WATER_HEATER_COMMAND_HAS_ON_STATE = 32; + WATER_HEATER_COMMAND_HAS_AWAY_STATE = 64; } message WaterHeaterCommandRequest { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ddc24a7e2c..c00f413e67 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1343,8 +1343,12 @@ void APIConnection::on_water_heater_command_request(const WaterHeaterCommandRequ call.set_target_temperature_low(msg.target_temperature_low); if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH) call.set_target_temperature_high(msg.target_temperature_high); - if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_STATE) { + if ((msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_AWAY_STATE) || + (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_STATE)) { call.set_away((msg.state & water_heater::WATER_HEATER_STATE_AWAY) != 0); + } + if ((msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_ON_STATE) || + (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_STATE)) { call.set_on((msg.state & water_heater::WATER_HEATER_STATE_ON) != 0); } call.perform(); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 15819da172..d001f869c5 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -147,6 +147,8 @@ enum WaterHeaterCommandHasField : uint32_t { WATER_HEATER_COMMAND_HAS_STATE = 4, WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_LOW = 8, WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH = 16, + WATER_HEATER_COMMAND_HAS_ON_STATE = 32, + WATER_HEATER_COMMAND_HAS_AWAY_STATE = 64, }; #ifdef USE_NUMBER enum NumberMode : uint32_t { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index f1e3bdcafe..73690610ed 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -385,6 +385,10 @@ const char *proto_enum_to_string(enums::Water return "WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_LOW"; case enums::WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH: return "WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH"; + case enums::WATER_HEATER_COMMAND_HAS_ON_STATE: + return "WATER_HEATER_COMMAND_HAS_ON_STATE"; + case enums::WATER_HEATER_COMMAND_HAS_AWAY_STATE: + return "WATER_HEATER_COMMAND_HAS_AWAY_STATE"; default: return "UNKNOWN"; } From 1c3af302991d9e907b5df7161f61a5b8dd959805 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:45:31 +0000 Subject: [PATCH 10/15] Bump aioesphomeapi from 43.14.0 to 44.0.0 (#13906) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1771867535..b8adc22013 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.1.0 click==8.1.7 esphome-dashboard==20260110.0 -aioesphomeapi==43.14.0 +aioesphomeapi==44.0.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 22b038f6a424d7b604590a0c101b83e04fa725bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 05:47:46 -0600 Subject: [PATCH 11/15] Import WaterHeaterFeature from aioesphomeapi instead of redefining locally --- tests/integration/test_water_heater_template.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index 3b6863766f..acb884c9d9 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -6,28 +6,18 @@ import asyncio import aioesphomeapi from aioesphomeapi import ( + WaterHeaterFeature, WaterHeaterInfo, WaterHeaterMode, 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, From 820afea83a94a4dc3926ffdcf08fd5e48485c5ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 06:06:54 -0600 Subject: [PATCH 12/15] Fix publish action skipping away: false and is_on: false The walrus operator treats false as falsy, so publishing away: false or is_on: false was silently ignored. Use key presence check instead. --- esphome/components/template/water_heater/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/template/water_heater/__init__.py b/esphome/components/template/water_heater/__init__.py index 9978e945ca..71f98c826a 100644 --- a/esphome/components/template/water_heater/__init__.py +++ b/esphome/components/template/water_heater/__init__.py @@ -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 away := config.get(CONF_AWAY): - template_ = await cg.templatable(away, args, bool) + if CONF_AWAY in config: + template_ = await cg.templatable(config[CONF_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) + 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 From 0503760af42ccc13e373c4efa7c84bdc856548ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 06:09:18 -0600 Subject: [PATCH 13/15] Add integration tests for toggling away and on/off state flags --- .../integration/test_water_heater_template.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index acb884c9d9..7a2a570694 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -111,6 +111,94 @@ async def test_water_heater_template( "Expected initial state to not include AWAY flag" ) + # Test turning on away mode + away_on_future: asyncio.Future[WaterHeaterState] = loop.create_future() + + def on_away_on(state: aioesphomeapi.EntityState) -> None: + if ( + isinstance(state, WaterHeaterState) + and (state.state & WaterHeaterStateFlag.AWAY) + and not away_on_future.done() + ): + away_on_future.set_result(state) + + client.subscribe_states(on_away_on) + client.water_heater_command(test_water_heater.key, away=True) + + try: + away_on_state = await asyncio.wait_for(away_on_future, timeout=5.0) + except TimeoutError: + pytest.fail("Away mode on not received within 5 seconds") + + 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 + away_off_future: asyncio.Future[WaterHeaterState] = loop.create_future() + + def on_away_off(state: aioesphomeapi.EntityState) -> None: + if ( + isinstance(state, WaterHeaterState) + and not (state.state & WaterHeaterStateFlag.AWAY) + and not away_off_future.done() + ): + away_off_future.set_result(state) + + client.subscribe_states(on_away_off) + client.water_heater_command(test_water_heater.key, away=False) + + try: + away_off_state = await asyncio.wait_for(away_off_future, timeout=5.0) + except TimeoutError: + pytest.fail("Away mode off not received within 5 seconds") + + assert (away_off_state.state & WaterHeaterStateFlag.AWAY) == 0 + assert (away_off_state.state & WaterHeaterStateFlag.ON) != 0 + + # Test turning off (on=False) + off_future: asyncio.Future[WaterHeaterState] = loop.create_future() + + def on_turn_off(state: aioesphomeapi.EntityState) -> None: + if ( + isinstance(state, WaterHeaterState) + and not (state.state & WaterHeaterStateFlag.ON) + and not off_future.done() + ): + off_future.set_result(state) + + client.subscribe_states(on_turn_off) + client.water_heater_command(test_water_heater.key, on=False) + + try: + off_state = await asyncio.wait_for(off_future, timeout=5.0) + except TimeoutError: + pytest.fail("Turn off not received within 5 seconds") + + assert (off_state.state & WaterHeaterStateFlag.ON) == 0 + assert (off_state.state & WaterHeaterStateFlag.AWAY) == 0 + + # Test turning back on (on=True) + on_future: asyncio.Future[WaterHeaterState] = loop.create_future() + + def on_turn_on(state: aioesphomeapi.EntityState) -> None: + if ( + isinstance(state, WaterHeaterState) + and (state.state & WaterHeaterStateFlag.ON) + and not on_future.done() + ): + on_future.set_result(state) + + client.subscribe_states(on_turn_on) + client.water_heater_command(test_water_heater.key, on=True) + + try: + on_state = await asyncio.wait_for(on_future, timeout=5.0) + except TimeoutError: + pytest.fail("Turn on not received within 5 seconds") + + assert (on_state.state & WaterHeaterStateFlag.ON) != 0 + # Test changing to GAS mode client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.GAS) From 6410c6cf9b8bd344089c38ca48ff9bc29cc34e2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 06:14:41 -0600 Subject: [PATCH 14/15] improve tests --- .../integration/test_water_heater_template.py | 113 ++++-------------- 1 file changed, 22 insertions(+), 91 deletions(-) diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index 7a2a570694..096d4c8461 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -28,18 +28,25 @@ async def test_water_heater_template( loop = asyncio.get_running_loop() async with run_compiled(yaml_config), api_client_connected() as client: states: dict[int, aioesphomeapi.EntityState] = {} - gas_mode_future: asyncio.Future[WaterHeaterState] = loop.create_future() - eco_mode_future: asyncio.Future[WaterHeaterState] = loop.create_future() + state_future: asyncio.Future[WaterHeaterState] | None = None def on_state(state: aioesphomeapi.EntityState) -> None: states[state.key] = state - if isinstance(state, WaterHeaterState): - # Wait for GAS mode - if state.mode == WaterHeaterMode.GAS and not gas_mode_future.done(): - gas_mode_future.set_result(state) - # 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) + if ( + isinstance(state, WaterHeaterState) + and state_future is not None + and not state_future.done() + ): + state_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 entities, services = await client.list_entities_services() @@ -112,111 +119,35 @@ async def test_water_heater_template( ) # Test turning on away mode - away_on_future: asyncio.Future[WaterHeaterState] = loop.create_future() - - def on_away_on(state: aioesphomeapi.EntityState) -> None: - if ( - isinstance(state, WaterHeaterState) - and (state.state & WaterHeaterStateFlag.AWAY) - and not away_on_future.done() - ): - away_on_future.set_result(state) - - client.subscribe_states(on_away_on) client.water_heater_command(test_water_heater.key, away=True) - - try: - away_on_state = await asyncio.wait_for(away_on_future, timeout=5.0) - except TimeoutError: - pytest.fail("Away mode on not received within 5 seconds") - + 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 - away_off_future: asyncio.Future[WaterHeaterState] = loop.create_future() - - def on_away_off(state: aioesphomeapi.EntityState) -> None: - if ( - isinstance(state, WaterHeaterState) - and not (state.state & WaterHeaterStateFlag.AWAY) - and not away_off_future.done() - ): - away_off_future.set_result(state) - - client.subscribe_states(on_away_off) client.water_heater_command(test_water_heater.key, away=False) - - try: - away_off_state = await asyncio.wait_for(away_off_future, timeout=5.0) - except TimeoutError: - pytest.fail("Away mode off not received within 5 seconds") - + 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) - off_future: asyncio.Future[WaterHeaterState] = loop.create_future() - - def on_turn_off(state: aioesphomeapi.EntityState) -> None: - if ( - isinstance(state, WaterHeaterState) - and not (state.state & WaterHeaterStateFlag.ON) - and not off_future.done() - ): - off_future.set_result(state) - - client.subscribe_states(on_turn_off) client.water_heater_command(test_water_heater.key, on=False) - - try: - off_state = await asyncio.wait_for(off_future, timeout=5.0) - except TimeoutError: - pytest.fail("Turn off not received within 5 seconds") - + 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) - on_future: asyncio.Future[WaterHeaterState] = loop.create_future() - - def on_turn_on(state: aioesphomeapi.EntityState) -> None: - if ( - isinstance(state, WaterHeaterState) - and (state.state & WaterHeaterStateFlag.ON) - and not on_future.done() - ): - on_future.set_result(state) - - client.subscribe_states(on_turn_on) client.water_heater_command(test_water_heater.key, on=True) - - try: - on_state = await asyncio.wait_for(on_future, timeout=5.0) - except TimeoutError: - pytest.fail("Turn on not received within 5 seconds") - + on_state = await wait_for_state() assert (on_state.state & WaterHeaterStateFlag.ON) != 0 # Test changing to GAS mode client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.GAS) - - 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) + gas_state = await wait_for_state() assert gas_state.mode == WaterHeaterMode.GAS # Test changing to ECO mode (from GAS) client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO) - - 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) + eco_state = await wait_for_state() assert eco_state.mode == WaterHeaterMode.ECO From 1b3f3c04b924dfffdd2f09eff94a16dcc75e454f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Feb 2026 06:17:52 -0600 Subject: [PATCH 15/15] Use mutable globals in water heater test fixture Use globals for away/is_on lambdas and sync them in set_action so optimistic state changes persist across loop iterations. --- .../fixtures/water_heater_template.yaml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/integration/fixtures/water_heater_template.yaml b/tests/integration/fixtures/water_heater_template.yaml index 8b62112cd0..0c82ff68ce 100644 --- a/tests/integration/fixtures/water_heater_template.yaml +++ b/tests/integration/fixtures/water_heater_template.yaml @@ -4,6 +4,14 @@ host: api: logger: +globals: + - id: global_away + type: bool + initial_value: "false" + - id: global_is_on + type: bool + initial_value: "true" + water_heater: - platform: template id: test_boiler @@ -11,8 +19,8 @@ water_heater: optimistic: true current_temperature: !lambda "return 45.0f;" target_temperature: !lambda "return 60.0f;" - away: !lambda "return false;" - is_on: !lambda "return true;" + 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 # A mode lambda would override mode changes in loop() supported_modes: @@ -24,3 +32,8 @@ water_heater: min_temperature: 30.0 max_temperature: 85.0 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();