From 51105a25db007d4a14a8de2013d27410d9a1e4a3 Mon Sep 17 00:00:00 2001 From: Iago Veiga Date: Thu, 27 Aug 2026 10:55:51 +0200 Subject: [PATCH] [tuya] Add water_heater platform (#17323) Co-authored-by: Claude Opus 4.8 Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + .../components/tuya/water_heater/__init__.py | 120 +++++++++++ .../tuya/water_heater/tuya_water_heater.cpp | 190 ++++++++++++++++++ .../tuya/water_heater/tuya_water_heater.h | 73 +++++++ tests/components/tuya/common.yaml | 21 ++ 5 files changed, 405 insertions(+) create mode 100644 esphome/components/tuya/water_heater/__init__.py create mode 100644 esphome/components/tuya/water_heater/tuya_water_heater.cpp create mode 100644 esphome/components/tuya/water_heater/tuya_water_heater.h diff --git a/CODEOWNERS b/CODEOWNERS index b898788b1a..e1287ca275 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -577,6 +577,7 @@ esphome/components/tuya/select/* @bearpawmaxim esphome/components/tuya/sensor/* @jesserockz esphome/components/tuya/switch/* @jesserockz esphome/components/tuya/text_sensor/* @dentra +esphome/components/tuya/water_heater/* @iago-veiga esphome/components/uart/* @esphome/core esphome/components/uart/button/* @ssieb esphome/components/uart/event/* @eoasmxd diff --git a/esphome/components/tuya/water_heater/__init__.py b/esphome/components/tuya/water_heater/__init__.py new file mode 100644 index 0000000000..7d1af791b4 --- /dev/null +++ b/esphome/components/tuya/water_heater/__init__.py @@ -0,0 +1,120 @@ +import esphome.codegen as cg +from esphome.components import water_heater +import esphome.config_validation as cv +from esphome.const import CONF_SUPPORTED_MODES, CONF_SWITCH_DATAPOINT +from esphome.types import ConfigType + +from .. import CONF_TUYA_ID, Tuya, tuya_ns + +DEPENDENCIES = ["tuya"] +CODEOWNERS = ["@iago-veiga"] + +CONF_TARGET_TEMPERATURE_DATAPOINT = "target_temperature_datapoint" +CONF_CURRENT_TEMPERATURE_DATAPOINT = "current_temperature_datapoint" +CONF_TARGET_TEMPERATURE_MULTIPLIER = "target_temperature_multiplier" +CONF_CURRENT_TEMPERATURE_MULTIPLIER = "current_temperature_multiplier" +CONF_MODE_DATAPOINT = "mode_datapoint" + +# Optional enum values that map a Tuya mode datapoint value to a WaterHeaterMode. +# Mirrors the "*_value" style used by the tuya climate fan modes. +CONF_ECO_VALUE = "eco_value" +CONF_ELECTRIC_VALUE = "electric_value" +CONF_PERFORMANCE_VALUE = "performance_value" +CONF_HIGH_DEMAND_VALUE = "high_demand_value" +CONF_HEAT_PUMP_VALUE = "heat_pump_value" +CONF_GAS_VALUE = "gas_value" + +# Map of config key -> C++ setter name, one per non-OFF WaterHeaterMode. OFF is not an enum +# value: it is represented by the switch datapoint being off, just like the tuya climate. +MODE_VALUES = { + CONF_ECO_VALUE: "set_eco_value", + CONF_ELECTRIC_VALUE: "set_electric_value", + CONF_PERFORMANCE_VALUE: "set_performance_value", + CONF_HIGH_DEMAND_VALUE: "set_high_demand_value", + CONF_HEAT_PUMP_VALUE: "set_heat_pump_value", + CONF_GAS_VALUE: "set_gas_value", +} + +TuyaWaterHeater = tuya_ns.class_( + "TuyaWaterHeater", water_heater.WaterHeater, cg.Component +) + + +def _validate(config: ConfigType) -> ConfigType: + # A mode datapoint is only useful if at least one mode value is mapped, and mode values + # only make sense together with a mode datapoint. + has_mode_values = any(key in config for key in MODE_VALUES) + if CONF_MODE_DATAPOINT in config and not has_mode_values: + raise cv.Invalid( + f"'{CONF_MODE_DATAPOINT}' requires at least one mode value " + f"(e.g. '{CONF_ECO_VALUE}' or '{CONF_ELECTRIC_VALUE}')" + ) + if has_mode_values and CONF_MODE_DATAPOINT not in config: + raise cv.Invalid(f"Mode values require '{CONF_MODE_DATAPOINT}' to be set") + return config + + +CONFIG_SCHEMA = cv.All( + water_heater.water_heater_schema(TuyaWaterHeater) + .extend( + { + cv.GenerateID(CONF_TUYA_ID): cv.use_id(Tuya), + cv.Required(CONF_SWITCH_DATAPOINT): cv.uint8_t, + cv.Optional(CONF_TARGET_TEMPERATURE_DATAPOINT): cv.uint8_t, + cv.Optional(CONF_CURRENT_TEMPERATURE_DATAPOINT): cv.uint8_t, + cv.Optional( + CONF_TARGET_TEMPERATURE_MULTIPLIER, default=1.0 + ): cv.positive_float, + cv.Optional( + CONF_CURRENT_TEMPERATURE_MULTIPLIER, default=1.0 + ): cv.positive_float, + cv.Optional(CONF_MODE_DATAPOINT): cv.uint8_t, + cv.Optional(CONF_ECO_VALUE): cv.uint8_t, + cv.Optional(CONF_ELECTRIC_VALUE): cv.uint8_t, + cv.Optional(CONF_PERFORMANCE_VALUE): cv.uint8_t, + cv.Optional(CONF_HIGH_DEMAND_VALUE): cv.uint8_t, + cv.Optional(CONF_HEAT_PUMP_VALUE): cv.uint8_t, + cv.Optional(CONF_GAS_VALUE): cv.uint8_t, + cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list( + water_heater.validate_water_heater_mode + ), + } + ) + .extend(cv.COMPONENT_SCHEMA), + _validate, +) + + +async def to_code(config: ConfigType) -> None: + var = await water_heater.new_water_heater(config) + await cg.register_component(var, config) + + paren = await cg.get_variable(config[CONF_TUYA_ID]) + cg.add(var.set_tuya_parent(paren)) + + cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT])) + + if (target_temp_dp := config.get(CONF_TARGET_TEMPERATURE_DATAPOINT)) is not None: + cg.add(var.set_target_temperature_id(target_temp_dp)) + if (current_temp_dp := config.get(CONF_CURRENT_TEMPERATURE_DATAPOINT)) is not None: + cg.add(var.set_current_temperature_id(current_temp_dp)) + + cg.add( + var.set_target_temperature_multiplier( + config[CONF_TARGET_TEMPERATURE_MULTIPLIER] + ) + ) + cg.add( + var.set_current_temperature_multiplier( + config[CONF_CURRENT_TEMPERATURE_MULTIPLIER] + ) + ) + + if (mode_dp := config.get(CONF_MODE_DATAPOINT)) is not None: + cg.add(var.set_mode_id(mode_dp)) + for key, setter in MODE_VALUES.items(): + if (value := config.get(key)) is not None: + cg.add(getattr(var, setter)(value)) + + if (supported_modes := config.get(CONF_SUPPORTED_MODES)) is not None: + cg.add(var.set_supported_modes(supported_modes)) diff --git a/esphome/components/tuya/water_heater/tuya_water_heater.cpp b/esphome/components/tuya/water_heater/tuya_water_heater.cpp new file mode 100644 index 0000000000..2fca3bf581 --- /dev/null +++ b/esphome/components/tuya/water_heater/tuya_water_heater.cpp @@ -0,0 +1,190 @@ +#include "tuya_water_heater.h" +#include "esphome/core/log.h" + +namespace esphome::tuya { + +static const char *const TAG = "tuya.water_heater"; + +void TuyaWaterHeater::setup() { + if (this->switch_id_.has_value()) { + this->parent_->register_listener(*this->switch_id_, [this](const TuyaDatapoint &datapoint) { + ESP_LOGV(TAG, "MCU reported switch is: %s", ONOFF(datapoint.value_bool)); + this->is_on_ = datapoint.value_bool; + this->set_state_flag_(water_heater::WATER_HEATER_STATE_ON, this->is_on_); + if (!this->is_on_) { + this->set_mode_(water_heater::WATER_HEATER_MODE_OFF); + } else { + // Turned on: use the last mode reported by the mode datapoint if we have one, otherwise + // fall back to a supported mode. Datapoints can arrive in any order, so the mode enum may + // have been reported before this switch update. + this->set_mode_(this->last_reported_mode_.value_or(this->default_on_mode_())); + } + this->publish_state(); + }); + } + + if (this->mode_id_.has_value()) { + this->parent_->register_listener(*this->mode_id_, [this](const TuyaDatapoint &datapoint) { + ESP_LOGV(TAG, "MCU reported mode value is: %u", datapoint.value_enum); + water_heater::WaterHeaterMode mode; + if (!this->mode_from_value_(datapoint.value_enum, mode)) { + return; + } + // Always remember the reported mode; only surface it while the heater is on (OFF is driven + // by the switch datapoint, not the mode enum). + this->last_reported_mode_ = mode; + if (this->is_on_ && this->mode_ != mode) { + this->set_mode_(mode); + this->publish_state(); + } + }); + } + + if (this->target_temperature_id_.has_value()) { + this->parent_->register_listener(*this->target_temperature_id_, [this](const TuyaDatapoint &datapoint) { + float value = datapoint.value_int * this->target_temperature_multiplier_; + ESP_LOGV(TAG, "MCU reported target temperature is: %.1f", value); + this->set_target_temperature_(value); + this->publish_state(); + }); + } + + if (this->current_temperature_id_.has_value()) { + this->parent_->register_listener(*this->current_temperature_id_, [this](const TuyaDatapoint &datapoint) { + float value = datapoint.value_int * this->current_temperature_multiplier_; + ESP_LOGV(TAG, "MCU reported current temperature is: %.1f", value); + this->set_current_temperature(value); + this->publish_state(); + }); + } +} + +water_heater::WaterHeaterCallInternal TuyaWaterHeater::make_call() { + return water_heater::WaterHeaterCallInternal(this); +} + +void TuyaWaterHeater::control(const water_heater::WaterHeaterCall &call) { + auto mode_val = call.get_mode(); + auto on_val = call.get_on(); + + // Determine the desired on/off state. An explicit on/off request wins; otherwise a mode of + // OFF means off and any other mode means on. + optional want_on = on_val; + if (mode_val.has_value() && !want_on.has_value()) { + want_on = *mode_val != water_heater::WATER_HEATER_MODE_OFF; + } + + if (want_on.has_value() && this->switch_id_.has_value()) { + ESP_LOGV(TAG, "Setting switch: %s", ONOFF(*want_on)); + this->parent_->set_boolean_datapoint_value(*this->switch_id_, *want_on); + } + + if (mode_val.has_value() && *mode_val != water_heater::WATER_HEATER_MODE_OFF && this->mode_id_.has_value()) { + uint8_t value; + if (this->value_from_mode_(*mode_val, value)) { + ESP_LOGV(TAG, "Setting mode value: %u", value); + this->parent_->set_enum_datapoint_value(*this->mode_id_, value); + } else { + ESP_LOGW(TAG, "No mode value configured for requested mode"); + } + } + + auto target_temp = call.get_target_temperature(); + if (!std::isnan(target_temp) && this->target_temperature_id_.has_value()) { + ESP_LOGV(TAG, "Setting target temperature: %.1f", target_temp); + this->parent_->set_integer_datapoint_value(*this->target_temperature_id_, + (int) (target_temp / this->target_temperature_multiplier_)); + } +} + +water_heater::WaterHeaterTraits TuyaWaterHeater::traits() { + water_heater::WaterHeaterTraits traits; + + traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_ON_OFF); + if (this->current_temperature_id_.has_value()) { + traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_CURRENT_TEMPERATURE); + } + if (this->target_temperature_id_.has_value()) { + traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE); + } + if (!this->supported_modes_.empty()) { + traits.set_supported_modes(this->supported_modes_); + traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_OPERATION_MODE); + } + return traits; +} + +bool TuyaWaterHeater::mode_from_value_(uint8_t value, water_heater::WaterHeaterMode &mode) const { + if (this->eco_value_ == value) { + mode = water_heater::WATER_HEATER_MODE_ECO; + } else if (this->electric_value_ == value) { + mode = water_heater::WATER_HEATER_MODE_ELECTRIC; + } else if (this->performance_value_ == value) { + mode = water_heater::WATER_HEATER_MODE_PERFORMANCE; + } else if (this->high_demand_value_ == value) { + mode = water_heater::WATER_HEATER_MODE_HIGH_DEMAND; + } else if (this->heat_pump_value_ == value) { + mode = water_heater::WATER_HEATER_MODE_HEAT_PUMP; + } else if (this->gas_value_ == value) { + mode = water_heater::WATER_HEATER_MODE_GAS; + } else { + return false; + } + return true; +} + +bool TuyaWaterHeater::value_from_mode_(water_heater::WaterHeaterMode mode, uint8_t &value) const { + optional mapped; + switch (mode) { + case water_heater::WATER_HEATER_MODE_ECO: + mapped = this->eco_value_; + break; + case water_heater::WATER_HEATER_MODE_ELECTRIC: + mapped = this->electric_value_; + break; + case water_heater::WATER_HEATER_MODE_PERFORMANCE: + mapped = this->performance_value_; + break; + case water_heater::WATER_HEATER_MODE_HIGH_DEMAND: + mapped = this->high_demand_value_; + break; + case water_heater::WATER_HEATER_MODE_HEAT_PUMP: + mapped = this->heat_pump_value_; + break; + case water_heater::WATER_HEATER_MODE_GAS: + mapped = this->gas_value_; + break; + default: + break; + } + if (mapped.has_value()) { + value = *mapped; + return true; + } + return false; +} + +water_heater::WaterHeaterMode TuyaWaterHeater::default_on_mode_() const { + // Prefer the first configured supported non-OFF mode so we never surface a mode the user + // cannot control. Fall back to ELECTRIC when no supported modes are configured. + for (water_heater::WaterHeaterMode mode : this->supported_modes_) { + if (mode != water_heater::WATER_HEATER_MODE_OFF) { + return mode; + } + } + return water_heater::WATER_HEATER_MODE_ELECTRIC; +} + +void TuyaWaterHeater::dump_config() { + LOG_WATER_HEATER("", "Tuya Water Heater", this); + if (this->switch_id_.has_value()) + ESP_LOGCONFIG(TAG, " Switch has datapoint ID %u", *this->switch_id_); + if (this->mode_id_.has_value()) + ESP_LOGCONFIG(TAG, " Mode has datapoint ID %u", *this->mode_id_); + if (this->target_temperature_id_.has_value()) + ESP_LOGCONFIG(TAG, " Target Temperature has datapoint ID %u", *this->target_temperature_id_); + if (this->current_temperature_id_.has_value()) + ESP_LOGCONFIG(TAG, " Current Temperature has datapoint ID %u", *this->current_temperature_id_); +} + +} // namespace esphome::tuya diff --git a/esphome/components/tuya/water_heater/tuya_water_heater.h b/esphome/components/tuya/water_heater/tuya_water_heater.h new file mode 100644 index 0000000000..5ce0ce4dee --- /dev/null +++ b/esphome/components/tuya/water_heater/tuya_water_heater.h @@ -0,0 +1,73 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/tuya/tuya.h" +#include "esphome/components/water_heater/water_heater.h" + +namespace esphome::tuya { + +class TuyaWaterHeater final : public water_heater::WaterHeater, public Component { + public: + void setup() override; + void dump_config() override; + + void set_tuya_parent(Tuya *parent) { this->parent_ = parent; } + + void set_switch_id(uint8_t switch_id) { this->switch_id_ = switch_id; } + void set_target_temperature_id(uint8_t target_temperature_id) { + this->target_temperature_id_ = target_temperature_id; + } + void set_current_temperature_id(uint8_t current_temperature_id) { + this->current_temperature_id_ = current_temperature_id; + } + void set_target_temperature_multiplier(float multiplier) { this->target_temperature_multiplier_ = multiplier; } + void set_current_temperature_multiplier(float multiplier) { this->current_temperature_multiplier_ = multiplier; } + + void set_mode_id(uint8_t mode_id) { this->mode_id_ = mode_id; } + void set_eco_value(uint8_t value) { this->eco_value_ = value; } + void set_electric_value(uint8_t value) { this->electric_value_ = value; } + void set_performance_value(uint8_t value) { this->performance_value_ = value; } + void set_high_demand_value(uint8_t value) { this->high_demand_value_ = value; } + void set_heat_pump_value(uint8_t value) { this->heat_pump_value_ = value; } + void set_gas_value(uint8_t value) { this->gas_value_ = value; } + + void set_supported_modes(const std::initializer_list &modes) { + this->supported_modes_ = modes; + } + + water_heater::WaterHeaterCallInternal make_call() override; + + protected: + void control(const water_heater::WaterHeaterCall &call) override; + water_heater::WaterHeaterTraits traits() override; + + /// Map a Tuya mode datapoint enum value to a WaterHeaterMode. Returns true when a mapping + /// exists, writing the result to \p mode. + bool mode_from_value_(uint8_t value, water_heater::WaterHeaterMode &mode) const; + /// Map a WaterHeaterMode to its configured Tuya enum value. Returns true when a mapping exists. + bool value_from_mode_(water_heater::WaterHeaterMode mode, uint8_t &value) const; + + Tuya *parent_{nullptr}; + optional switch_id_{}; + optional target_temperature_id_{}; + optional current_temperature_id_{}; + optional mode_id_{}; + optional eco_value_{}; + optional electric_value_{}; + optional performance_value_{}; + optional high_demand_value_{}; + optional heat_pump_value_{}; + optional gas_value_{}; + float target_temperature_multiplier_{1.0f}; + float current_temperature_multiplier_{1.0f}; + water_heater::WaterHeaterModeMask supported_modes_; + /// Last non-OFF mode reported by the mode datapoint, applied when the heater turns on. + optional last_reported_mode_{}; + bool is_on_{false}; + + /// The mode to show when the heater is on but no mode datapoint value is known yet: the last + /// reported mode, else the first configured supported non-OFF mode, else ELECTRIC. + water_heater::WaterHeaterMode default_on_mode_() const; +}; + +} // namespace esphome::tuya diff --git a/tests/components/tuya/common.yaml b/tests/components/tuya/common.yaml index 9986d398f1..f52d47e7a0 100644 --- a/tests/components/tuya/common.yaml +++ b/tests/components/tuya/common.yaml @@ -80,3 +80,24 @@ switch: - platform: tuya id: tuya_switch switch_datapoint: 1 + +water_heater: + - platform: tuya + id: tuya_water_heater + name: Tuya Water Heater + switch_datapoint: 1 + current_temperature_datapoint: 3 + target_temperature_datapoint: 2 + current_temperature_multiplier: 0.5 + target_temperature_multiplier: 0.5 + mode_datapoint: 4 + eco_value: 0 + electric_value: 2 + supported_modes: + - "OFF" + - ECO + - ELECTRIC + visual: + min_temperature: 30 + max_temperature: 75 + target_temperature_step: 1