mirror of
https://github.com/esphome/esphome.git
synced 2026-09-29 16:00:23 +00:00
[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:
co-authored by
Claude Opus 4.8
Jesse Hills
parent
fb132b4421
commit
51105a25db
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user