[tuya] Add water_heater platform (#17323)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
This commit is contained in:
Iago Veiga
2026-08-27 20:55:51 +12:00
committed by GitHub
co-authored by Claude Opus 4.8 Jesse Hills
parent fb132b4421
commit 51105a25db
5 changed files with 405 additions and 0 deletions
+1
View File
@@ -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
@@ -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))
@@ -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<bool> 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<uint8_t> 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
@@ -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<water_heater::WaterHeaterMode> &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<uint8_t> switch_id_{};
optional<uint8_t> target_temperature_id_{};
optional<uint8_t> current_temperature_id_{};
optional<uint8_t> mode_id_{};
optional<uint8_t> eco_value_{};
optional<uint8_t> electric_value_{};
optional<uint8_t> performance_value_{};
optional<uint8_t> high_demand_value_{};
optional<uint8_t> heat_pump_value_{};
optional<uint8_t> 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<water_heater::WaterHeaterMode> 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
+21
View File
@@ -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