diff --git a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp index ad3d18efae..6b2954a6a0 100644 --- a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp +++ b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp @@ -8,8 +8,8 @@ static const char *TAG = "tuya.binary_sensor"; void TuyaBinarySensor::setup() { this->parent_->register_listener(this->sensor_id_, [this](TuyaDatapoint datapoint) { + ESP_LOGV(TAG, "MCU reported binary sensor %u is: %s", datapoint.id, ONOFF(datapoint.value_bool)); this->publish_state(datapoint.value_bool); - ESP_LOGD(TAG, "MCU reported binary sensor is: %s", ONOFF(datapoint.value_bool)); }); } diff --git a/esphome/components/tuya/climate/__init__.py b/esphome/components/tuya/climate/__init__.py index a24ae23edc..06c80964ee 100644 --- a/esphome/components/tuya/climate/__init__.py +++ b/esphome/components/tuya/climate/__init__.py @@ -1,12 +1,20 @@ from esphome.components import climate import esphome.config_validation as cv import esphome.codegen as cg -from esphome.const import CONF_ID, CONF_SWITCH_DATAPOINT +from esphome.const import ( + CONF_ID, + CONF_SWITCH_DATAPOINT, + CONF_SUPPORTS_COOL, + CONF_SUPPORTS_HEAT, +) from .. import tuya_ns, CONF_TUYA_ID, Tuya DEPENDENCIES = ["tuya"] CODEOWNERS = ["@jesserockz"] +CONF_ACTIVE_STATE_DATAPOINT = "active_state_datapoint" +CONF_ACTIVE_STATE_HEATING_VALUE = "active_state_heating_value" +CONF_ACTIVE_STATE_COOLING_VALUE = "active_state_cooling_value" CONF_TARGET_TEMPERATURE_DATAPOINT = "target_temperature_datapoint" CONF_CURRENT_TEMPERATURE_DATAPOINT = "current_temperature_datapoint" CONF_TEMPERATURE_MULTIPLIER = "temperature_multiplier" @@ -59,12 +67,30 @@ def validate_temperature_multipliers(value): return value +def validate_active_state_values(value): + if CONF_ACTIVE_STATE_DATAPOINT not in value: + return value + if value[CONF_SUPPORTS_COOL] and CONF_ACTIVE_STATE_COOLING_VALUE not in value: + raise cv.Invalid( + ( + f"{CONF_ACTIVE_STATE_COOLING_VALUE} required if using " + f"{CONF_ACTIVE_STATE_DATAPOINT} and device supports cooling" + ) + ) + return value + + CONFIG_SCHEMA = cv.All( climate.CLIMATE_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(TuyaClimate), cv.GenerateID(CONF_TUYA_ID): cv.use_id(Tuya), + cv.Optional(CONF_SUPPORTS_HEAT, default=True): cv.boolean, + cv.Optional(CONF_SUPPORTS_COOL, default=False): cv.boolean, cv.Optional(CONF_SWITCH_DATAPOINT): cv.uint8_t, + cv.Optional(CONF_ACTIVE_STATE_DATAPOINT): cv.uint8_t, + cv.Optional(CONF_ACTIVE_STATE_HEATING_VALUE, default=1): cv.uint8_t, + cv.Optional(CONF_ACTIVE_STATE_COOLING_VALUE): cv.uint8_t, cv.Optional(CONF_TARGET_TEMPERATURE_DATAPOINT): cv.uint8_t, cv.Optional(CONF_CURRENT_TEMPERATURE_DATAPOINT): cv.uint8_t, cv.Optional(CONF_TEMPERATURE_MULTIPLIER): cv.positive_float, @@ -74,6 +100,7 @@ CONFIG_SCHEMA = cv.All( ).extend(cv.COMPONENT_SCHEMA), cv.has_at_least_one_key(CONF_TARGET_TEMPERATURE_DATAPOINT, CONF_SWITCH_DATAPOINT), validate_temperature_multipliers, + validate_active_state_values, ) @@ -85,8 +112,20 @@ async def to_code(config): paren = await cg.get_variable(config[CONF_TUYA_ID]) cg.add(var.set_tuya_parent(paren)) + cg.add(var.set_supports_heat(config[CONF_SUPPORTS_HEAT])) + cg.add(var.set_supports_cool(config[CONF_SUPPORTS_COOL])) if CONF_SWITCH_DATAPOINT in config: cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT])) + if CONF_ACTIVE_STATE_DATAPOINT in config: + cg.add(var.set_active_state_id(config[CONF_ACTIVE_STATE_DATAPOINT])) + if CONF_ACTIVE_STATE_HEATING_VALUE in config: + cg.add( + var.set_active_state_heating_value(config[CONF_ACTIVE_STATE_HEATING_VALUE]) + ) + if CONF_ACTIVE_STATE_COOLING_VALUE in config: + cg.add( + var.set_active_state_cooling_value(config[CONF_ACTIVE_STATE_COOLING_VALUE]) + ) if CONF_TARGET_TEMPERATURE_DATAPOINT in config: cg.add(var.set_target_temperature_id(config[CONF_TARGET_TEMPERATURE_DATAPOINT])) if CONF_CURRENT_TEMPERATURE_DATAPOINT in config: diff --git a/esphome/components/tuya/climate/tuya_climate.cpp b/esphome/components/tuya/climate/tuya_climate.cpp index d1f6829e72..962d18a391 100644 --- a/esphome/components/tuya/climate/tuya_climate.cpp +++ b/esphome/components/tuya/climate/tuya_climate.cpp @@ -9,65 +9,67 @@ static const char *TAG = "tuya.climate"; void TuyaClimate::setup() { if (this->switch_id_.has_value()) { this->parent_->register_listener(*this->switch_id_, [this](TuyaDatapoint datapoint) { + ESP_LOGV(TAG, "MCU reported switch is: %s", ONOFF(datapoint.value_bool)); + this->mode = climate::CLIMATE_MODE_OFF; if (datapoint.value_bool) { - this->mode = climate::CLIMATE_MODE_HEAT; - } else { - this->mode = climate::CLIMATE_MODE_OFF; + if (this->supports_heat_ && this->supports_cool_) { + this->mode = climate::CLIMATE_MODE_AUTO; + } else if (this->supports_heat_) { + this->mode = climate::CLIMATE_MODE_HEAT; + } else if (this->supports_cool_) { + this->mode = climate::CLIMATE_MODE_COOL; + } } this->compute_state_(); this->publish_state(); - ESP_LOGD(TAG, "MCU reported switch is: %s", ONOFF(datapoint.value_bool)); + }); + } + if (this->active_state_id_.has_value()) { + this->parent_->register_listener(*this->active_state_id_, [this](TuyaDatapoint datapoint) { + ESP_LOGV(TAG, "MCU reported active state is: %u", datapoint.value_enum); + this->active_state_ = datapoint.value_enum; + this->compute_state_(); + this->publish_state(); }); } if (this->target_temperature_id_.has_value()) { this->parent_->register_listener(*this->target_temperature_id_, [this](TuyaDatapoint datapoint) { this->target_temperature = datapoint.value_int * this->target_temperature_multiplier_; + ESP_LOGV(TAG, "MCU reported target temperature is: %.1f", this->target_temperature); this->compute_state_(); this->publish_state(); - ESP_LOGD(TAG, "MCU reported target temperature is: %.1f", this->target_temperature); }); } if (this->current_temperature_id_.has_value()) { this->parent_->register_listener(*this->current_temperature_id_, [this](TuyaDatapoint datapoint) { this->current_temperature = datapoint.value_int * this->current_temperature_multiplier_; + ESP_LOGV(TAG, "MCU reported current temperature is: %.1f", this->current_temperature); this->compute_state_(); this->publish_state(); - ESP_LOGD(TAG, "MCU reported current temperature is: %.1f", this->current_temperature); }); } } void TuyaClimate::control(const climate::ClimateCall &call) { if (call.get_mode().has_value()) { - this->mode = *call.get_mode(); - - TuyaDatapoint datapoint{}; - datapoint.id = *this->switch_id_; - datapoint.type = TuyaDatapointType::BOOLEAN; - datapoint.value_bool = this->mode != climate::CLIMATE_MODE_OFF; - this->parent_->set_datapoint_value(datapoint); - ESP_LOGD(TAG, "Setting switch: %s", ONOFF(datapoint.value_bool)); + const bool switch_state = *call.get_mode() != climate::CLIMATE_MODE_OFF; + ESP_LOGV(TAG, "Setting switch: %s", ONOFF(switch_state)); + this->parent_->set_datapoint_value(*this->switch_id_, switch_state); } if (call.get_target_temperature().has_value()) { - this->target_temperature = *call.get_target_temperature(); - - TuyaDatapoint datapoint{}; - datapoint.id = *this->target_temperature_id_; - datapoint.type = TuyaDatapointType::INTEGER; - datapoint.value_int = (int) (this->target_temperature / this->target_temperature_multiplier_); - this->parent_->set_datapoint_value(datapoint); - ESP_LOGD(TAG, "Setting target temperature: %.1f", this->target_temperature); + const float target_temperature = *call.get_target_temperature(); + ESP_LOGV(TAG, "Setting target temperature: %.1f", target_temperature); + this->parent_->set_datapoint_value(*this->target_temperature_id_, + (int) (target_temperature / this->target_temperature_multiplier_)); } - - this->compute_state_(); - this->publish_state(); } climate::ClimateTraits TuyaClimate::traits() { auto traits = climate::ClimateTraits(); traits.set_supports_current_temperature(this->current_temperature_id_.has_value()); - traits.set_supports_heat_mode(true); + traits.set_supports_heat_mode(this->supports_heat_); + traits.set_supports_cool_mode(this->supports_cool_); traits.set_supports_action(true); return traits; } @@ -76,6 +78,8 @@ void TuyaClimate::dump_config() { LOG_CLIMATE("", "Tuya Climate", this); if (this->switch_id_.has_value()) ESP_LOGCONFIG(TAG, " Switch has datapoint ID %u", *this->switch_id_); + if (this->active_state_id_.has_value()) + ESP_LOGCONFIG(TAG, " Active state has datapoint ID %u", *this->active_state_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()) @@ -94,30 +98,27 @@ void TuyaClimate::compute_state_() { return; } - const bool too_cold = this->current_temperature < this->target_temperature - 1; - const bool too_hot = this->current_temperature > this->target_temperature + 1; - const bool on_target = this->current_temperature == this->target_temperature; - - climate::ClimateAction target_action; - if (too_cold) { - // too cold -> show as heating if possible, else idle - if (this->traits().supports_mode(climate::CLIMATE_MODE_HEAT)) { + climate::ClimateAction target_action = climate::CLIMATE_ACTION_IDLE; + if (this->active_state_id_.has_value()) { + if (this->supports_heat_ && this->active_state_heating_value_.has_value() && + this->active_state_ == this->active_state_heating_value_) { target_action = climate::CLIMATE_ACTION_HEATING; - } else { - target_action = climate::CLIMATE_ACTION_IDLE; - } - } else if (too_hot) { - // too hot -> show as cooling if possible, else idle - if (this->traits().supports_mode(climate::CLIMATE_MODE_COOL)) { + } else if (this->supports_cool_ && this->active_state_cooling_value_.has_value() && + this->active_state_ == this->active_state_cooling_value_) { target_action = climate::CLIMATE_ACTION_COOLING; - } else { - target_action = climate::CLIMATE_ACTION_IDLE; } - } else if (on_target) { - target_action = climate::CLIMATE_ACTION_IDLE; } else { - target_action = this->action; + // Fallback to active state calc based on temp and hysteresis + const float temp_diff = this->target_temperature - this->current_temperature; + if (std::abs(temp_diff) > this->hysteresis_) { + if (this->supports_heat_ && temp_diff > 0) { + target_action = climate::CLIMATE_ACTION_HEATING; + } else if (this->supports_cool_ && temp_diff < 0) { + target_action = climate::CLIMATE_ACTION_COOLING; + } + } } + this->switch_to_action_(target_action); } diff --git a/esphome/components/tuya/climate/tuya_climate.h b/esphome/components/tuya/climate/tuya_climate.h index e9c366e898..f015bc337c 100644 --- a/esphome/components/tuya/climate/tuya_climate.h +++ b/esphome/components/tuya/climate/tuya_climate.h @@ -11,7 +11,12 @@ class TuyaClimate : public climate::Climate, public Component { public: void setup() override; void dump_config() override; + void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } + void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } void set_switch_id(uint8_t switch_id) { this->switch_id_ = switch_id; } + void set_active_state_id(uint8_t state_id) { this->active_state_id_ = state_id; } + void set_active_state_heating_value(uint8_t value) { this->active_state_heating_value_ = value; } + void set_active_state_cooling_value(uint8_t value) { this->active_state_cooling_value_ = value; } void set_target_temperature_id(uint8_t target_temperature_id) { this->target_temperature_id_ = target_temperature_id; } @@ -40,11 +45,18 @@ class TuyaClimate : public climate::Climate, public Component { void switch_to_action_(climate::ClimateAction action); Tuya *parent_; + bool supports_heat_; + bool supports_cool_; optional switch_id_{}; + optional active_state_id_{}; + optional active_state_heating_value_{}; + optional active_state_cooling_value_{}; optional target_temperature_id_{}; optional current_temperature_id_{}; float current_temperature_multiplier_{1.0f}; float target_temperature_multiplier_{1.0f}; + float hysteresis_{1.0f}; + uint8_t active_state_; }; } // namespace tuya diff --git a/esphome/components/tuya/fan/tuya_fan.cpp b/esphome/components/tuya/fan/tuya_fan.cpp index 62f4a78db7..b2326034a1 100644 --- a/esphome/components/tuya/fan/tuya_fan.cpp +++ b/esphome/components/tuya/fan/tuya_fan.cpp @@ -14,29 +14,29 @@ void TuyaFan::setup() { if (this->speed_id_.has_value()) { this->parent_->register_listener(*this->speed_id_, [this](TuyaDatapoint datapoint) { + ESP_LOGV(TAG, "MCU reported speed of: %d", datapoint.value_enum); auto call = this->fan_->make_call(); if (datapoint.value_enum < this->speed_count_) call.set_speed(datapoint.value_enum + 1); else ESP_LOGCONFIG(TAG, "Speed has invalid value %d", datapoint.value_enum); - ESP_LOGD(TAG, "MCU reported speed of: %d", datapoint.value_enum); call.perform(); }); } if (this->switch_id_.has_value()) { this->parent_->register_listener(*this->switch_id_, [this](TuyaDatapoint datapoint) { + ESP_LOGV(TAG, "MCU reported switch is: %s", ONOFF(datapoint.value_bool)); auto call = this->fan_->make_call(); call.set_state(datapoint.value_bool); call.perform(); - ESP_LOGD(TAG, "MCU reported switch is: %s", ONOFF(datapoint.value_bool)); }); } if (this->oscillation_id_.has_value()) { this->parent_->register_listener(*this->oscillation_id_, [this](TuyaDatapoint datapoint) { + ESP_LOGV(TAG, "MCU reported oscillation is: %s", ONOFF(datapoint.value_bool)); auto call = this->fan_->make_call(); call.set_oscillating(datapoint.value_bool); call.perform(); - ESP_LOGD(TAG, "MCU reported oscillation is: %s", ONOFF(datapoint.value_bool)); }); } if (this->direction_id_.has_value()) { @@ -66,37 +66,21 @@ void TuyaFan::dump_config() { void TuyaFan::write_state() { if (this->switch_id_.has_value()) { - TuyaDatapoint datapoint{}; - datapoint.id = *this->switch_id_; - datapoint.type = TuyaDatapointType::BOOLEAN; - datapoint.value_bool = this->fan_->state; - this->parent_->set_datapoint_value(datapoint); - ESP_LOGD(TAG, "Setting switch: %s", ONOFF(this->fan_->state)); + ESP_LOGV(TAG, "Setting switch: %s", ONOFF(this->fan_->state)); + this->parent_->set_datapoint_value(*this->switch_id_, this->fan_->state); } if (this->oscillation_id_.has_value()) { - TuyaDatapoint datapoint{}; - datapoint.id = *this->oscillation_id_; - datapoint.type = TuyaDatapointType::BOOLEAN; - datapoint.value_bool = this->fan_->oscillating; - this->parent_->set_datapoint_value(datapoint); - ESP_LOGD(TAG, "Setting oscillating: %s", ONOFF(this->fan_->oscillating)); + ESP_LOGV(TAG, "Setting oscillating: %s", ONOFF(this->fan_->oscillating)); + this->parent_->set_datapoint_value(*this->oscillation_id_, this->fan_->oscillating); } if (this->direction_id_.has_value()) { - TuyaDatapoint datapoint{}; - datapoint.id = *this->direction_id_; - datapoint.type = TuyaDatapointType::BOOLEAN; bool enable = this->fan_->direction == fan::FAN_DIRECTION_REVERSE; - datapoint.value_bool = enable; - this->parent_->set_datapoint_value(datapoint); - ESP_LOGD(TAG, "Setting reverse direction: %s", ONOFF(enable)); + ESP_LOGV(TAG, "Setting reverse direction: %s", ONOFF(enable)); + this->parent_->set_datapoint_value(*this->direction_id_, enable); } if (this->speed_id_.has_value()) { - TuyaDatapoint datapoint{}; - datapoint.id = *this->speed_id_; - datapoint.type = TuyaDatapointType::ENUM; - datapoint.value_enum = this->fan_->speed - 1; - ESP_LOGD(TAG, "Setting speed: %d", datapoint.value_enum); - this->parent_->set_datapoint_value(datapoint); + ESP_LOGV(TAG, "Setting speed: %d", this->fan_->speed); + this->parent_->set_datapoint_value(*this->speed_id_, this->fan_->speed); } } diff --git a/esphome/components/tuya/light/tuya_light.cpp b/esphome/components/tuya/light/tuya_light.cpp index f03e66e150..4befad401e 100644 --- a/esphome/components/tuya/light/tuya_light.cpp +++ b/esphome/components/tuya/light/tuya_light.cpp @@ -31,11 +31,7 @@ void TuyaLight::setup() { }); } if (min_value_datapoint_id_.has_value()) { - TuyaDatapoint datapoint{}; - datapoint.id = *this->min_value_datapoint_id_; - datapoint.type = TuyaDatapointType::INTEGER; - datapoint.value_int = this->min_value_; - parent_->set_datapoint_value(datapoint); + parent_->set_datapoint_value(*this->min_value_datapoint_id_, this->min_value_); } } @@ -67,49 +63,29 @@ void TuyaLight::write_state(light::LightState *state) { if (brightness == 0.0f) { // turning off, first try via switch (if exists), then dimmer if (switch_id_.has_value()) { - TuyaDatapoint datapoint{}; - datapoint.id = *this->switch_id_; - datapoint.type = TuyaDatapointType::BOOLEAN; - datapoint.value_bool = false; - - parent_->set_datapoint_value(datapoint); + parent_->set_datapoint_value(*this->switch_id_, false); } else if (dimmer_id_.has_value()) { - TuyaDatapoint datapoint{}; - datapoint.id = *this->dimmer_id_; - datapoint.type = TuyaDatapointType::INTEGER; - datapoint.value_int = 0; - parent_->set_datapoint_value(datapoint); + parent_->set_datapoint_value(*this->dimmer_id_, 0); } return; } if (this->color_temperature_id_.has_value()) { - TuyaDatapoint datapoint{}; - datapoint.id = *this->color_temperature_id_; - datapoint.type = TuyaDatapointType::INTEGER; - datapoint.value_int = + uint32_t color_temp_int = static_cast(this->color_temperature_max_value_ * (state->current_values.get_color_temperature() - this->cold_white_temperature_) / (this->warm_white_temperature_ - this->cold_white_temperature_)); - parent_->set_datapoint_value(datapoint); + parent_->set_datapoint_value(*this->color_temperature_id_, color_temp_int); } auto brightness_int = static_cast(brightness * this->max_value_); brightness_int = std::max(brightness_int, this->min_value_); if (this->dimmer_id_.has_value()) { - TuyaDatapoint datapoint{}; - datapoint.id = *this->dimmer_id_; - datapoint.type = TuyaDatapointType::INTEGER; - datapoint.value_int = brightness_int; - parent_->set_datapoint_value(datapoint); + parent_->set_datapoint_value(*this->dimmer_id_, brightness_int); } if (this->switch_id_.has_value()) { - TuyaDatapoint datapoint{}; - datapoint.id = *this->switch_id_; - datapoint.type = TuyaDatapointType::BOOLEAN; - datapoint.value_bool = true; - parent_->set_datapoint_value(datapoint); + parent_->set_datapoint_value(*this->switch_id_, true); } } diff --git a/esphome/components/tuya/sensor/tuya_sensor.cpp b/esphome/components/tuya/sensor/tuya_sensor.cpp index b8e2aa97b7..483552950f 100644 --- a/esphome/components/tuya/sensor/tuya_sensor.cpp +++ b/esphome/components/tuya/sensor/tuya_sensor.cpp @@ -9,17 +9,17 @@ static const char *TAG = "tuya.sensor"; void TuyaSensor::setup() { this->parent_->register_listener(this->sensor_id_, [this](TuyaDatapoint datapoint) { if (datapoint.type == TuyaDatapointType::BOOLEAN) { + ESP_LOGV(TAG, "MCU reported sensor %u is: %s", datapoint.id, ONOFF(datapoint.value_bool)); this->publish_state(datapoint.value_bool); - ESP_LOGD(TAG, "MCU reported sensor is: %s", ONOFF(datapoint.value_bool)); } else if (datapoint.type == TuyaDatapointType::INTEGER) { + ESP_LOGV(TAG, "MCU reported sensor %u is: %d", datapoint.id, datapoint.value_int); this->publish_state(datapoint.value_int); - ESP_LOGD(TAG, "MCU reported sensor is: %d", datapoint.value_int); } else if (datapoint.type == TuyaDatapointType::ENUM) { + ESP_LOGV(TAG, "MCU reported sensor %u is: %u", datapoint.id, datapoint.value_enum); this->publish_state(datapoint.value_enum); - ESP_LOGD(TAG, "MCU reported sensor is: %d", datapoint.value_enum); } else if (datapoint.type == TuyaDatapointType::BITMASK) { + ESP_LOGV(TAG, "MCU reported sensor %u is: %x", datapoint.id, datapoint.value_bitmask); this->publish_state(datapoint.value_bitmask); - ESP_LOGD(TAG, "MCU reported sensor is: %x", datapoint.value_bitmask); } }); } diff --git a/esphome/components/tuya/switch/tuya_switch.cpp b/esphome/components/tuya/switch/tuya_switch.cpp index 8f7c7f170d..c735b32341 100644 --- a/esphome/components/tuya/switch/tuya_switch.cpp +++ b/esphome/components/tuya/switch/tuya_switch.cpp @@ -8,19 +8,14 @@ static const char *TAG = "tuya.switch"; void TuyaSwitch::setup() { this->parent_->register_listener(this->switch_id_, [this](TuyaDatapoint datapoint) { + ESP_LOGV(TAG, "MCU reported switch %u is: %s", this->switch_id_, ONOFF(datapoint.value_bool)); this->publish_state(datapoint.value_bool); - ESP_LOGD(TAG, "MCU reported switch is: %s", ONOFF(datapoint.value_bool)); }); } void TuyaSwitch::write_state(bool state) { - TuyaDatapoint datapoint{}; - datapoint.id = this->switch_id_; - datapoint.type = TuyaDatapointType::BOOLEAN; - datapoint.value_bool = state; - this->parent_->set_datapoint_value(datapoint); - ESP_LOGD(TAG, "Setting switch: %s", ONOFF(state)); - + ESP_LOGV(TAG, "Setting switch %u: %s", this->switch_id_, ONOFF(state)); + this->parent_->set_datapoint_value(this->switch_id_, state); this->publish_state(state); } diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index f4a72e8109..96e832fe89 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -1,5 +1,6 @@ #include "tuya.h" #include "esphome/core/log.h" +#include "esphome/core/util.h" #include "esphome/core/helpers.h" namespace esphome { @@ -9,7 +10,7 @@ static const char *TAG = "tuya"; static const int COMMAND_DELAY = 50; void Tuya::setup() { - this->set_interval("heartbeat", 1000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); + this->set_interval("heartbeat", 10000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); } void Tuya::loop() { @@ -31,17 +32,17 @@ void Tuya::dump_config() { } for (auto &info : this->datapoints_) { if (info.type == TuyaDatapointType::BOOLEAN) - ESP_LOGCONFIG(TAG, " Datapoint %d: switch (value: %s)", info.id, ONOFF(info.value_bool)); + ESP_LOGCONFIG(TAG, " Datapoint %u: switch (value: %s)", info.id, ONOFF(info.value_bool)); else if (info.type == TuyaDatapointType::INTEGER) - ESP_LOGCONFIG(TAG, " Datapoint %d: int value (value: %d)", info.id, info.value_int); + ESP_LOGCONFIG(TAG, " Datapoint %u: int value (value: %d)", info.id, info.value_int); else if (info.type == TuyaDatapointType::STRING) - ESP_LOGCONFIG(TAG, " Datapoint %d: string value (value: %s)", info.id, info.value_string.c_str()); + ESP_LOGCONFIG(TAG, " Datapoint %u: string value (value: %s)", info.id, info.value_string.c_str()); else if (info.type == TuyaDatapointType::ENUM) - ESP_LOGCONFIG(TAG, " Datapoint %d: enum (value: %d)", info.id, info.value_enum); + ESP_LOGCONFIG(TAG, " Datapoint %u: enum (value: %d)", info.id, info.value_enum); else if (info.type == TuyaDatapointType::BITMASK) - ESP_LOGCONFIG(TAG, " Datapoint %d: bitmask (value: %x)", info.id, info.value_bitmask); + ESP_LOGCONFIG(TAG, " Datapoint %u: bitmask (value: %x)", info.id, info.value_bitmask); else - ESP_LOGCONFIG(TAG, " Datapoint %d: unknown", info.id); + ESP_LOGCONFIG(TAG, " Datapoint %u: unknown", info.id); } if ((this->gpio_status_ != -1) || (this->gpio_reset_ != -1)) { ESP_LOGCONFIG(TAG, " GPIO Configuration: status: pin %d, reset: pin %d (not supported)", this->gpio_status_, @@ -98,8 +99,8 @@ bool Tuya::validate_message_() { // valid message const uint8_t *message_data = data + 6; - ESP_LOGV(TAG, "Received Tuya: CMD=0x%02X VERSION=%u DATA=[%s] INIT_STATE=%u", command, version, // NOLINT - hexencode(message_data, length).c_str(), this->init_state_); + ESP_LOGV(TAG, "Received Tuya: CMD=0x%02X VERSION=%u DATA=[%s] INIT_STATE=%u", command, version, + hexencode(message_data, length).c_str(), static_cast(this->init_state_)); this->handle_command_(command, version, message_data, length); // return false to reset rx buffer @@ -117,6 +118,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff switch ((TuyaCommandType) command) { case TuyaCommandType::HEARTBEAT: ESP_LOGV(TAG, "MCU Heartbeat (0x%02X)", buffer[0]); + this->protocol_version_ = version; if (buffer[0] == 0) { ESP_LOGI(TAG, "MCU restarted"); this->init_state_ = TuyaInitState::INIT_HEARTBEAT; @@ -148,8 +150,8 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff } case TuyaCommandType::CONF_QUERY: { if (len >= 2) { - gpio_status_ = buffer[0]; - gpio_reset_ = buffer[1]; + this->gpio_status_ = buffer[0]; + this->gpio_reset_ = buffer[1]; } if (this->init_state_ == TuyaInitState::INIT_CONF) { // If mcu returned status gpio, then we can ommit sending wifi state @@ -158,10 +160,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY); } else { this->init_state_ = TuyaInitState::INIT_WIFI; - // If we were following the spec to the letter we would send - // state updates until connected to both WiFi and API/MQTT. - // Instead we just claim to be connected immediately and move on. - this->send_command_(TuyaCommand{.cmd = TuyaCommandType::WIFI_STATE, .payload = std::vector{0x04}}); + this->set_interval("wifi", 1000, [this] { this->send_wifi_status_(); }); } } break; @@ -173,10 +172,10 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff } break; case TuyaCommandType::WIFI_RESET: - ESP_LOGE(TAG, "TUYA_CMD_WIFI_RESET is not handled"); + ESP_LOGE(TAG, "WIFI_RESET is not handled"); break; case TuyaCommandType::WIFI_SELECT: - ESP_LOGE(TAG, "TUYA_CMD_WIFI_SELECT is not handled"); + ESP_LOGE(TAG, "WIFI_SELECT is not handled"); break; case TuyaCommandType::DATAPOINT_DELIVER: break; @@ -189,48 +188,24 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff break; case TuyaCommandType::DATAPOINT_QUERY: break; - case TuyaCommandType::WIFI_TEST: { + case TuyaCommandType::WIFI_TEST: this->send_command_(TuyaCommand{.cmd = TuyaCommandType::WIFI_TEST, .payload = std::vector{0x00, 0x00}}); break; - } - case TuyaCommandType::LOCAL_TIME_QUERY: { + case TuyaCommandType::LOCAL_TIME_QUERY: #ifdef USE_TIME if (this->time_id_.has_value()) { + this->send_local_time_(); auto time_id = *this->time_id_; - auto now = time_id->now(); - - if (now.is_valid()) { - uint8_t year = now.year - 2000; - uint8_t month = now.month; - uint8_t day_of_month = now.day_of_month; - uint8_t hour = now.hour; - uint8_t minute = now.minute; - uint8_t second = now.second; - // Tuya days starts from Monday, esphome uses Sunday as day 1 - uint8_t day_of_week = now.day_of_week - 1; - if (day_of_week == 0) { - day_of_week = 7; - } - this->send_command_(TuyaCommand{ - .cmd = TuyaCommandType::LOCAL_TIME_QUERY, - .payload = std::vector{0x01, year, month, day_of_month, hour, minute, second, day_of_week}}); - } else { - ESP_LOGW(TAG, "TUYA_CMD_LOCAL_TIME_QUERY is not handled because time is not valid"); - // By spec we need to notify MCU that the time was not obtained - this->send_command_( - TuyaCommand{.cmd = TuyaCommandType::LOCAL_TIME_QUERY, - .payload = std::vector{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}); - } + time_id->add_on_time_sync_callback([this] { this->send_local_time_(); }); } else { - ESP_LOGW(TAG, "TUYA_CMD_LOCAL_TIME_QUERY is not handled because time is not configured"); + ESP_LOGW(TAG, "LOCAL_TIME_QUERY is not handled because time is not configured"); } #else ESP_LOGE(TAG, "LOCAL_TIME_QUERY is not handled"); #endif break; - } default: - ESP_LOGE(TAG, "invalid command (%02x) received", command); + ESP_LOGE(TAG, "Invalid command (0x%02X) received", command); } } @@ -243,8 +218,8 @@ void Tuya::handle_datapoint_(const uint8_t *buffer, size_t len) { datapoint.type = (TuyaDatapointType) buffer[1]; datapoint.value_uint = 0; - // drop update if datapoint is in ignore_mcu_datapoint_update list - for (auto i : this->ignore_mcu_update_on_datapoints_) { + // Drop update if datapoint is in ignore_mcu_datapoint_update list + for (uint8_t i : this->ignore_mcu_update_on_datapoints_) { if (datapoint.id == i) { ESP_LOGV(TAG, "Datapoint %u found in ignore_mcu_update_on_datapoints list, dropping MCU update", datapoint.id); return; @@ -255,38 +230,57 @@ void Tuya::handle_datapoint_(const uint8_t *buffer, size_t len) { const uint8_t *data = buffer + 4; size_t data_len = len - 4; if (data_size != data_len) { - ESP_LOGW(TAG, "invalid datapoint update"); + ESP_LOGW(TAG, "Datapoint %u is not expected size", datapoint.id); return; } + datapoint.len = data_len; switch (datapoint.type) { case TuyaDatapointType::BOOLEAN: - if (data_len != 1) + if (data_len != 1) { + ESP_LOGW(TAG, "Datapoint %u has bad boolean len %zu", datapoint.id, data_len); return; + } datapoint.value_bool = data[0]; break; case TuyaDatapointType::INTEGER: - if (data_len != 4) + if (data_len != 4) { + ESP_LOGW(TAG, "Datapoint %u has bad integer len %zu", datapoint.id, data_len); return; + } datapoint.value_uint = encode_uint32(data[0], data[1], data[2], data[3]); break; case TuyaDatapointType::STRING: datapoint.value_string = std::string(reinterpret_cast(data), data_len); break; case TuyaDatapointType::ENUM: - if (data_len != 1) + if (data_len != 1) { + ESP_LOGW(TAG, "Datapoint %u has bad enum len %zu", datapoint.id, data_len); return; + } datapoint.value_enum = data[0]; break; case TuyaDatapointType::BITMASK: - if (data_len != 2) - return; - datapoint.value_bitmask = (uint16_t(data[0]) << 8) | (uint16_t(data[1]) << 0); + switch (data_len) { + case 1: + datapoint.value_bitmask = encode_uint32(0, 0, 0, data[0]); + break; + case 2: + datapoint.value_bitmask = encode_uint32(0, 0, data[0], data[1]); + break; + case 4: + datapoint.value_bitmask = encode_uint32(data[0], data[1], data[2], data[3]); + break; + default: + ESP_LOGW(TAG, "Datapoint %u has bad bitmask len %zu", datapoint.id, data_len); + return; + } break; default: + ESP_LOGW(TAG, "Datapoint %u has unknown type 0x%02hhX", datapoint.id, datapoint.type); return; } - ESP_LOGV(TAG, "Datapoint %u update to %u", datapoint.id, datapoint.value_uint); + ESP_LOGD(TAG, "Datapoint %u update to %u", datapoint.id, datapoint.value_uint); // Update internal datapoints bool found = false; @@ -313,8 +307,8 @@ void Tuya::send_raw_command_(TuyaCommand command) { this->last_command_timestamp_ = millis(); - ESP_LOGV(TAG, "Sending Tuya: CMD=0x%02X VERSION=%u DATA=[%s] INIT_STATE=%u", command.cmd, version, // NOLINT - hexencode(command.payload).c_str(), this->init_state_); + ESP_LOGV(TAG, "Sending Tuya: CMD=0x%02X VERSION=%u DATA=[%s] INIT_STATE=%u", static_cast(command.cmd), + version, hexencode(command.payload).c_str(), static_cast(this->init_state_)); this->write_array({0x55, 0xAA, version, (uint8_t) command.cmd, len_hi, len_lo}); if (!command.payload.empty()) @@ -344,53 +338,113 @@ void Tuya::send_empty_command_(TuyaCommandType command) { send_command_(TuyaCommand{.cmd = command, .payload = std::vector{0x04}}); } -void Tuya::set_datapoint_value(TuyaDatapoint datapoint) { - std::vector buffer; - ESP_LOGV(TAG, "Datapoint %u set to %u", datapoint.id, datapoint.value_uint); - for (auto &other : this->datapoints_) { - if (other.id == datapoint.id) { - // String value is stored outside the union; must be checked separately. - if (datapoint.type == TuyaDatapointType::STRING) { - if (other.value_string == datapoint.value_string) { - ESP_LOGV(TAG, "Not sending unchanged value"); - return; - } - } else if (other.value_uint == datapoint.value_uint) { - ESP_LOGV(TAG, "Not sending unchanged value"); - return; +void Tuya::send_wifi_status_() { + uint8_t status = 0x02; + if (network_is_connected()) { + status = 0x03; + + // Protocol version 3 also supports specifying when connected to "the cloud" + if (this->protocol_version_ >= 0x03) { + if (remote_is_connected()) { + status = 0x04; } } } - buffer.push_back(datapoint.id); - buffer.push_back(static_cast(datapoint.type)); - std::vector data; - switch (datapoint.type) { - case TuyaDatapointType::BOOLEAN: - data.push_back(datapoint.value_bool); - break; - case TuyaDatapointType::INTEGER: - data.push_back(datapoint.value_uint >> 24); - data.push_back(datapoint.value_uint >> 16); - data.push_back(datapoint.value_uint >> 8); - data.push_back(datapoint.value_uint >> 0); - break; - case TuyaDatapointType::STRING: - for (char const &c : datapoint.value_string) { - data.push_back(c); - } - break; - case TuyaDatapointType::ENUM: - data.push_back(datapoint.value_enum); - break; - case TuyaDatapointType::BITMASK: - data.push_back(datapoint.value_bitmask >> 8); - data.push_back(datapoint.value_bitmask >> 0); - break; - default: - return; + if (status == this->wifi_status_) { + return; } + ESP_LOGD(TAG, "Sending WiFi Status"); + this->wifi_status_ = status; + this->send_command_(TuyaCommand{.cmd = TuyaCommandType::WIFI_STATE, .payload = std::vector{status}}); +} + +#ifdef USE_TIME +void Tuya::send_local_time_() { + std::vector payload; + auto time_id = *this->time_id_; + time::ESPTime now = time_id->now(); + if (now.is_valid()) { + uint8_t year = now.year - 2000; + uint8_t month = now.month; + uint8_t day_of_month = now.day_of_month; + uint8_t hour = now.hour; + uint8_t minute = now.minute; + uint8_t second = now.second; + // Tuya days starts from Monday, esphome uses Sunday as day 1 + uint8_t day_of_week = now.day_of_week - 1; + if (day_of_week == 0) { + day_of_week = 7; + } + ESP_LOGD(TAG, "Sending local time"); + payload = std::vector{0x01, year, month, day_of_month, hour, minute, second, day_of_week}; + } else { + // By spec we need to notify MCU that the time was not obtained if this is a response to a query + ESP_LOGW(TAG, "Sending missing local time"); + payload = std::vector{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + } + this->send_command_(TuyaCommand{.cmd = TuyaCommandType::LOCAL_TIME_QUERY, .payload = payload}); +} +#endif + +void Tuya::set_datapoint_value(uint8_t datapoint_id, uint32_t value) { + ESP_LOGD(TAG, "Setting datapoint %u to %u", datapoint_id, value); + optional datapoint = this->get_datapoint_(datapoint_id); + if (!datapoint.has_value()) { + ESP_LOGE(TAG, "Attempt to set unknown datapoint %u", datapoint_id); + return; + } + if (datapoint->value_uint == value) { + ESP_LOGV(TAG, "Not sending unchanged value"); + return; + } + + std::vector data; + switch (datapoint->len) { + case 4: + data.push_back(value >> 24); + data.push_back(value >> 16); + case 2: + data.push_back(value >> 8); + case 1: + data.push_back(value >> 0); + break; + default: + ESP_LOGE(TAG, "Unexpected datapoint length %zu", datapoint->len); + return; + } + this->send_datapoint_command_(datapoint->id, datapoint->type, data); +} + +void Tuya::set_datapoint_value(uint8_t datapoint_id, std::string value) { + ESP_LOGD(TAG, "Setting datapoint %u to %s", datapoint_id, value.c_str()); + optional datapoint = this->get_datapoint_(datapoint_id); + if (!datapoint.has_value()) { + ESP_LOGE(TAG, "Attempt to set unknown datapoint %u", datapoint_id); + } + if (datapoint->value_string == value) { + ESP_LOGV(TAG, "Not sending unchanged value"); + return; + } + std::vector data; + for (char const &c : value) { + data.push_back(c); + } + this->send_datapoint_command_(datapoint->id, datapoint->type, data); +} + +optional Tuya::get_datapoint_(uint8_t datapoint_id) { + for (auto &datapoint : this->datapoints_) + if (datapoint.id == datapoint_id) + return datapoint; + return {}; +} + +void Tuya::send_datapoint_command_(uint8_t datapoint_id, TuyaDatapointType datapoint_type, std::vector data) { + std::vector buffer; + buffer.push_back(datapoint_id); + buffer.push_back(static_cast(datapoint_type)); buffer.push_back(data.size() >> 8); buffer.push_back(data.size() >> 0); buffer.insert(buffer.end(), data.begin(), data.end()); diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index a2b4040eb3..f98b7522fc 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -23,12 +23,13 @@ enum class TuyaDatapointType : uint8_t { struct TuyaDatapoint { uint8_t id; TuyaDatapointType type; + size_t len; union { bool value_bool; int value_int; uint32_t value_uint; uint8_t value_enum; - uint16_t value_bitmask; + uint32_t value_bitmask; }; std::string value_string; }; @@ -73,7 +74,8 @@ class Tuya : public Component, public uart::UARTDevice { void loop() override; void dump_config() override; void register_listener(uint8_t datapoint_id, const std::function &func); - void set_datapoint_value(TuyaDatapoint datapoint); + void set_datapoint_value(uint8_t datapoint_id, uint32_t value); + void set_datapoint_value(uint8_t datapoint_id, std::string value); #ifdef USE_TIME void set_time_id(time::RealTimeClock *time_id) { this->time_id_ = time_id; } #endif @@ -84,6 +86,7 @@ class Tuya : public Component, public uart::UARTDevice { protected: void handle_char_(uint8_t c); void handle_datapoint_(const uint8_t *buffer, size_t len); + optional get_datapoint_(uint8_t datapoint_id); bool validate_message_(); void handle_command_(uint8_t command, uint8_t version, const uint8_t *buffer, size_t len); @@ -91,11 +94,15 @@ class Tuya : public Component, public uart::UARTDevice { void process_command_queue_(); void send_command_(TuyaCommand command); void send_empty_command_(TuyaCommandType command); + void send_datapoint_command_(uint8_t datapoint_id, TuyaDatapointType datapoint_type, std::vector data); + void send_wifi_status_(); #ifdef USE_TIME + void send_local_time_(); optional time_id_{}; #endif TuyaInitState init_state_ = TuyaInitState::INIT_HEARTBEAT; + uint8_t protocol_version_ = -1; int gpio_status_ = -1; int gpio_reset_ = -1; uint32_t last_command_timestamp_ = 0; @@ -105,6 +112,7 @@ class Tuya : public Component, public uart::UARTDevice { std::vector rx_message_; std::vector ignore_mcu_update_on_datapoints_{}; std::vector command_queue_; + uint8_t wifi_status_ = -1; }; } // namespace tuya diff --git a/esphome/core/util.cpp b/esphome/core/util.cpp index 4e15d142be..73086a750a 100644 --- a/esphome/core/util.cpp +++ b/esphome/core/util.cpp @@ -16,6 +16,10 @@ #include "esphome/components/ethernet/ethernet_component.h" #endif +#ifdef USE_MQTT +#include "esphome/components/mqtt/mqtt_client.h" +#endif + #ifdef USE_MDNS #ifdef ARDUINO_ARCH_ESP32 #include @@ -41,6 +45,26 @@ bool network_is_connected() { return false; } +bool api_is_connected() { +#ifdef USE_API + if (api::global_api_server != nullptr) { + return api::global_api_server->is_connected(); + } +#endif + return false; +} + +bool mqtt_is_connected() { +#ifdef USE_MQTT + if (mqtt::global_mqtt_client != nullptr) { + return mqtt::global_mqtt_client->is_connected(); + } +#endif + return false; +} + +bool remote_is_connected() { return api_is_connected() || mqtt_is_connected(); } + #if defined(ARDUINO_ARCH_ESP8266) && defined(USE_MDNS) bool mdns_setup; #endif diff --git a/esphome/core/util.h b/esphome/core/util.h index 4b3e79353a..180c7aaefa 100644 --- a/esphome/core/util.h +++ b/esphome/core/util.h @@ -15,6 +15,15 @@ bool network_is_connected(); /// Get the active network hostname std::string network_get_address(); +/// Return whether the node has at least one client connected to the native API +bool api_is_connected(); + +/// Return whether the node has an active connection to an MQTT broker +bool mqtt_is_connected(); + +/// Return whether the node has any form of "remote" connection via the API or to an MQTT broker +bool remote_is_connected(); + /// Manually set up the network stack (outside of the App.setup() loop, for example in OTA safe mode) #ifdef ARDUINO_ARCH_ESP8266 void network_setup_mdns(IPAddress address, int interface);