diff --git a/.gitignore b/.gitignore index 9b835ba..bfb7c28 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ /build/ **/platformio.ini /secrets.yaml +__pycache__/ +*.py[cod] +*$py.class \ No newline at end of file diff --git a/components/tuya/__init__.py b/components/tuya/__init__.py new file mode 100644 index 0000000..4367599 --- /dev/null +++ b/components/tuya/__init__.py @@ -0,0 +1,39 @@ +from esphome.components import time +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import uart +from esphome.const import CONF_ID, CONF_TIME_ID + +DEPENDENCIES = ["uart"] + +CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS = "ignore_mcu_update_on_datapoints" + +tuya_ns = cg.esphome_ns.namespace("tuya") +Tuya = tuya_ns.class_("Tuya", cg.Component, uart.UARTDevice) + +CONF_TUYA_ID = "tuya_id" +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Tuya), + cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), + cv.Optional(CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS): cv.ensure_list( + cv.uint8_t + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +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) + if CONF_TIME_ID in config: + time_ = await cg.get_variable(config[CONF_TIME_ID]) + cg.add(var.set_time_id(time_)) + if CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS in config: + for dp in config[CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS]: + cg.add(var.add_ignore_mcu_update_on_datapoints(dp)) diff --git a/components/tuya/tuya.cpp b/components/tuya/tuya.cpp new file mode 100644 index 0000000..4751d54 --- /dev/null +++ b/components/tuya/tuya.cpp @@ -0,0 +1,513 @@ +#include "tuya.h" +#include "esphome/core/log.h" +#include "esphome/core/util.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace tuya { + +static const char *TAG = "tuya"; +static const int COMMAND_DELAY = 50; +static const int RECEIVE_TIMEOUT = 300; + +void Tuya::setup() { + this->set_interval("heartbeat", 10000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); +} + +void Tuya::loop() { + while (this->available()) { + uint8_t c; + this->read_byte(&c); + this->handle_char_(c); + } + process_command_queue_(); +} + +void Tuya::dump_config() { + ESP_LOGCONFIG(TAG, "Tuya:"); + if (this->init_state_ != TuyaInitState::INIT_DONE) { + ESP_LOGCONFIG(TAG, " Configuration will be reported when setup is complete. Current init_state: %u", + static_cast(this->init_state_)); + ESP_LOGCONFIG(TAG, " If no further output is received, confirm that this is a supported Tuya device."); + return; + } + for (auto &info : this->datapoints_) { + if (info.type == TuyaDatapointType::RAW) + ESP_LOGCONFIG(TAG, " Datapoint %u: raw (value: %s)", info.id, hexencode(info.value_raw).c_str()); + else if (info.type == TuyaDatapointType::BOOLEAN) + ESP_LOGCONFIG(TAG, " Datapoint %u: switch (value: %s)", info.id, ONOFF(info.value_bool)); + else if (info.type == TuyaDatapointType::INTEGER) + ESP_LOGCONFIG(TAG, " Datapoint %u: int value (value: %d)", info.id, info.value_int); + else if (info.type == TuyaDatapointType::STRING) + 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 %u: enum (value: %d)", info.id, info.value_enum); + else if (info.type == TuyaDatapointType::BITMASK) + ESP_LOGCONFIG(TAG, " Datapoint %u: bitmask (value: %x)", info.id, info.value_bitmask); + else + 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_, + this->gpio_reset_); + } + ESP_LOGCONFIG(TAG, " Product: '%s'", this->product_.c_str()); + this->check_uart_settings(9600); +} + +bool Tuya::validate_message_() { + uint32_t at = this->rx_message_.size() - 1; + auto *data = &this->rx_message_[0]; + uint8_t new_byte = data[at]; + + // Byte 0: HEADER1 (always 0x55) + if (at == 0) + return new_byte == 0x55; + // Byte 1: HEADER2 (always 0xAA) + if (at == 1) + return new_byte == 0xAA; + + // Byte 2: VERSION + // no validation for the following fields: + uint8_t version = data[2]; + if (at == 2) + return true; + // Byte 3: COMMAND + uint8_t command = data[3]; + if (at == 3) + return true; + + // Byte 4: LENGTH1 + // Byte 5: LENGTH2 + if (at <= 5) + // no validation for these fields + return true; + + uint16_t length = (uint16_t(data[4]) << 8) | (uint16_t(data[5])); + + // wait until all data is read + if (at - 6 < length) + return true; + + // Byte 6+LEN: CHECKSUM - sum of all bytes (including header) modulo 256 + uint8_t rx_checksum = new_byte; + uint8_t calc_checksum = 0; + for (uint32_t i = 0; i < 6 + length; i++) + calc_checksum += data[i]; + + if (rx_checksum != calc_checksum) { + ESP_LOGW(TAG, "Tuya Received invalid message checksum %02X!=%02X", rx_checksum, calc_checksum); + return false; + } + + // 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, + 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 + return false; +} + +void Tuya::handle_char_(uint8_t c) { + this->rx_message_.push_back(c); + if (!this->validate_message_()) { + this->rx_message_.clear(); + } +} + +void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buffer, size_t len) { + TuyaCommandType command_type = (TuyaCommandType) command; + + if(this->expected_response_.has_value() && this->expected_response_ == command_type) + { + this->expected_response_.reset(); + } + + switch (command_type) { + 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; + } + if (this->init_state_ == TuyaInitState::INIT_HEARTBEAT) { + this->init_state_ = TuyaInitState::INIT_PRODUCT; + this->send_empty_command_(TuyaCommandType::PRODUCT_QUERY); + } + break; + case TuyaCommandType::PRODUCT_QUERY: { + // check it is a valid string made up of printable characters + bool valid = true; + for (int i = 0; i < len; i++) { + if (!std::isprint(buffer[i])) { + valid = false; + break; + } + } + if (valid) { + this->product_ = std::string(reinterpret_cast(buffer), len); + } else { + this->product_ = R"({"p":"INVALID"})"; + } + if (this->init_state_ == TuyaInitState::INIT_PRODUCT) { + this->init_state_ = TuyaInitState::INIT_CONF; + this->send_empty_command_(TuyaCommandType::CONF_QUERY); + } + break; + } + case TuyaCommandType::CONF_QUERY: { + if (len >= 2) { + 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 + if (this->gpio_status_ != -1) { + this->init_state_ = TuyaInitState::INIT_DATAPOINT; + this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY); + } else { + this->init_state_ = TuyaInitState::INIT_WIFI; + this->set_interval("wifi", 1000, [this] { this->send_wifi_status_(); }); + } + } + break; + } + case TuyaCommandType::WIFI_STATE: + if (this->init_state_ == TuyaInitState::INIT_WIFI) { + this->init_state_ = TuyaInitState::INIT_DATAPOINT; + this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY); + } + break; + case TuyaCommandType::WIFI_RESET: + ESP_LOGE(TAG, "WIFI_RESET is not handled"); + break; + case TuyaCommandType::WIFI_SELECT: + ESP_LOGE(TAG, "WIFI_SELECT is not handled"); + break; + case TuyaCommandType::DATAPOINT_DELIVER: + break; + case TuyaCommandType::DATAPOINT_REPORT: + if (this->init_state_ == TuyaInitState::INIT_DATAPOINT) { + this->init_state_ = TuyaInitState::INIT_DONE; + this->set_timeout("datapoint_dump", 1000, [this] { this->dump_config(); }); + } + this->handle_datapoint_(buffer, len); + break; + case TuyaCommandType::DATAPOINT_QUERY: + break; + case TuyaCommandType::WIFI_TEST: + this->send_command_(TuyaCommand{.cmd = TuyaCommandType::WIFI_TEST, .payload = std::vector{0x00, 0x00}}); + break; + case TuyaCommandType::LOCAL_TIME_QUERY: +#ifdef USE_TIME + if (this->time_id_.has_value()) { + this->send_local_time_(); + auto time_id = *this->time_id_; + time_id->add_on_time_sync_callback([this] { this->send_local_time_(); }); + } else { + 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 (0x%02X) received", command); + } +} + +void Tuya::handle_datapoint_(const uint8_t *buffer, size_t len) { + if (len < 2) + return; + + TuyaDatapoint datapoint{}; + datapoint.id = buffer[0]; + datapoint.type = (TuyaDatapointType) buffer[1]; + datapoint.value_uint = 0; + + // 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; + } + } + + size_t data_size = (buffer[2] << 8) + buffer[3]; + const uint8_t *data = buffer + 4; + size_t data_len = len - 4; + if (data_size != data_len) { + ESP_LOGW(TAG, "Datapoint %u is not expected size", datapoint.id); + return; + } + datapoint.len = data_len; + + switch (datapoint.type) { + case TuyaDatapointType::RAW: + datapoint.value_raw = std::vector(data, data + data_len); + ESP_LOGD(TAG, "Datapoint %u update to %s", datapoint.id, hexencode(datapoint.value_raw).c_str()); + break; + case TuyaDatapointType::BOOLEAN: + if (data_len != 1) { + ESP_LOGW(TAG, "Datapoint %u has bad boolean len %zu", datapoint.id, data_len); + return; + } + datapoint.value_bool = data[0]; + ESP_LOGD(TAG, "Datapoint %u update to %s", datapoint.id, ONOFF(datapoint.value_bool)); + break; + case TuyaDatapointType::INTEGER: + 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]); + ESP_LOGD(TAG, "Datapoint %u update to %d", datapoint.id, datapoint.value_int); + break; + case TuyaDatapointType::STRING: + datapoint.value_string = std::string(reinterpret_cast(data), data_len); + ESP_LOGD(TAG, "Datapoint %u update to %s", datapoint.id, datapoint.value_string.c_str()); + break; + case TuyaDatapointType::ENUM: + if (data_len != 1) { + ESP_LOGW(TAG, "Datapoint %u has bad enum len %zu", datapoint.id, data_len); + return; + } + datapoint.value_enum = data[0]; + ESP_LOGD(TAG, "Datapoint %u update to %d", datapoint.id, datapoint.value_enum); + break; + case TuyaDatapointType::BITMASK: + 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; + } + ESP_LOGD(TAG, "Datapoint %u update to %#08X", datapoint.id, datapoint.value_bitmask); + break; + default: + ESP_LOGW(TAG, "Datapoint %u has unknown type %#02hhX", datapoint.id, datapoint.type); + return; + } + + // Update internal datapoints + bool found = false; + for (auto &other : this->datapoints_) { + if (other.id == datapoint.id) { + other = datapoint; + found = true; + } + } + if (!found) { + this->datapoints_.push_back(datapoint); + } + + // Run through listeners + for (auto &listener : this->listeners_) + if (listener.datapoint_id == datapoint.id) + listener.on_datapoint(datapoint); +} + +void Tuya::send_raw_command_(TuyaCommand command) { + uint8_t len_hi = (uint8_t)(command.payload.size() >> 8); + uint8_t len_lo = (uint8_t)(command.payload.size() & 0xFF); + uint8_t version = 0; + + this->last_command_timestamp_ = millis(); + switch (command.cmd) + { + case TuyaCommandType::HEARTBEAT: + this->expected_response_ = TuyaCommandType::HEARTBEAT; + break; + case TuyaCommandType::PRODUCT_QUERY: + this->expected_response_ = TuyaCommandType::PRODUCT_QUERY; + break; + case TuyaCommandType::CONF_QUERY: + this->expected_response_ = TuyaCommandType::CONF_QUERY; + break; + case TuyaCommandType::DATAPOINT_DELIVER: + this->expected_response_ = TuyaCommandType::DATAPOINT_REPORT; + break; + case TuyaCommandType::DATAPOINT_QUERY: + this->expected_response_ = TuyaCommandType::DATAPOINT_REPORT; + break; + default: + break; + } + + 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()) + this->write_array(command.payload.data(), command.payload.size()); + + uint8_t checksum = 0x55 + 0xAA + (uint8_t) command.cmd + len_hi + len_lo; + for (auto &data : command.payload) + checksum += data; + this->write_byte(checksum); +} + +void Tuya::process_command_queue_() { + uint32_t delay = millis() - this->last_command_timestamp_; + + if(this->expected_response_.has_value() && delay > RECEIVE_TIMEOUT) + { + this->expected_response_.reset(); + } + + // Left check of delay since last command in case theres ever a command sent by calling send_raw_command_ directly + if (delay > COMMAND_DELAY && !this->command_queue_.empty() && this->rx_message_.empty() && !this->expected_response_.has_value()) { + this->send_raw_command_(command_queue_.front()); + this->command_queue_.erase(command_queue_.begin()); + } +} + +void Tuya::send_command_(TuyaCommand command) { + command_queue_.push_back(command); + process_command_queue_(); +} + +void Tuya::send_empty_command_(TuyaCommandType command) { + send_command_(TuyaCommand{.cmd = command, .payload = std::vector{0x04}}); +} + +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; + } + } + } + + 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()); + + this->send_command_(TuyaCommand{.cmd = TuyaCommandType::DATAPOINT_DELIVER, .payload = buffer}); +} + +void Tuya::register_listener(uint8_t datapoint_id, const std::function &func) { + auto listener = TuyaDatapointListener{ + .datapoint_id = datapoint_id, + .on_datapoint = func, + }; + this->listeners_.push_back(listener); + + // Run through existing datapoints + for (auto &datapoint : this->datapoints_) + if (datapoint.id == datapoint_id) + func(datapoint); +} + +} // namespace tuya +} // namespace esphome \ No newline at end of file diff --git a/components/tuya/tuya.h b/components/tuya/tuya.h new file mode 100644 index 0000000..ef2d321 --- /dev/null +++ b/components/tuya/tuya.h @@ -0,0 +1,121 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/components/uart/uart.h" + +#ifdef USE_TIME +#include "esphome/components/time/real_time_clock.h" +#endif + +namespace esphome { +namespace tuya { + +enum class TuyaDatapointType : uint8_t { + RAW = 0x00, // variable length + BOOLEAN = 0x01, // 1 byte (0/1) + INTEGER = 0x02, // 4 byte + STRING = 0x03, // variable length + ENUM = 0x04, // 1 byte + BITMASK = 0x05, // 2 bytes +}; + +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; + uint32_t value_bitmask; + }; + std::string value_string; + std::vector value_raw; +}; + +struct TuyaDatapointListener { + uint8_t datapoint_id; + std::function on_datapoint; +}; + +enum class TuyaCommandType : uint8_t { + HEARTBEAT = 0x00, + PRODUCT_QUERY = 0x01, + CONF_QUERY = 0x02, + WIFI_STATE = 0x03, + WIFI_RESET = 0x04, + WIFI_SELECT = 0x05, + DATAPOINT_DELIVER = 0x06, + DATAPOINT_REPORT = 0x07, + DATAPOINT_QUERY = 0x08, + WIFI_TEST = 0x0E, + LOCAL_TIME_QUERY = 0x1C, +}; + +enum class TuyaInitState : uint8_t { + INIT_HEARTBEAT = 0x00, + INIT_PRODUCT, + INIT_CONF, + INIT_WIFI, + INIT_DATAPOINT, + INIT_DONE, +}; + +struct TuyaCommand { + TuyaCommandType cmd; + std::vector payload; +}; + +class Tuya : public Component, public uart::UARTDevice { + public: + float get_setup_priority() const override { return setup_priority::LATE; } + void setup() override; + void loop() override; + void dump_config() override; + void register_listener(uint8_t datapoint_id, const std::function &func); + 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 + void add_ignore_mcu_update_on_datapoints(uint8_t ignore_mcu_update_on_datapoints) { + this->ignore_mcu_update_on_datapoints_.push_back(ignore_mcu_update_on_datapoints); + } + + 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); + void send_raw_command_(TuyaCommand command); + 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; + std::string product_ = ""; + std::vector listeners_; + std::vector datapoints_; + std::vector rx_message_; + std::vector ignore_mcu_update_on_datapoints_{}; + std::vector command_queue_; + optional expected_response_{}; + uint8_t wifi_status_ = -1; +}; + +} // namespace tuya +} // namespace esphome \ No newline at end of file diff --git a/components/tuya_light_plus/__init__.py b/components/tuya_light_plus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/components/tuya_light_plus/light.py b/components/tuya_light_plus/light.py new file mode 100644 index 0000000..c52fa78 --- /dev/null +++ b/components/tuya_light_plus/light.py @@ -0,0 +1,88 @@ +from esphome.components import light +import esphome.config_validation as cv +import esphome.codegen as cg +from esphome.const import ( + CONF_OUTPUT_ID, + CONF_MIN_VALUE, + CONF_MAX_VALUE, + CONF_GAMMA_CORRECT, + CONF_DEFAULT_TRANSITION_LENGTH, + CONF_SWITCH_DATAPOINT, + CONF_COLD_WHITE_COLOR_TEMPERATURE, + CONF_WARM_WHITE_COLOR_TEMPERATURE, +) +from esphome.components.tuya import CONF_TUYA_ID, Tuya + +DEPENDENCIES = ["tuya"] + +CONF_DIMMER_DATAPOINT = "dimmer_datapoint" +CONF_MIN_VALUE_DATAPOINT = "min_value_datapoint" +CONF_COLOR_TEMPERATURE_DATAPOINT = "color_temperature_datapoint" +CONF_COLOR_TEMPERATURE_MAX_VALUE = "color_temperature_max_value" + +tuya_ns = cg.esphome_ns.namespace("tuya") +TuyaLight = tuya_ns.class_("TuyaLightPlus", light.LightOutput, cg.Component) + +CONFIG_SCHEMA = cv.All( + light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(TuyaLight), + cv.GenerateID(CONF_TUYA_ID): cv.use_id(Tuya), + cv.Optional(CONF_DIMMER_DATAPOINT): cv.uint8_t, + cv.Optional(CONF_MIN_VALUE_DATAPOINT): cv.uint8_t, + cv.Optional(CONF_SWITCH_DATAPOINT): cv.uint8_t, + cv.Inclusive( + CONF_COLOR_TEMPERATURE_DATAPOINT, "color_temperature" + ): cv.uint8_t, + cv.Optional(CONF_MIN_VALUE): cv.int_, + cv.Optional(CONF_MAX_VALUE): cv.int_, + cv.Optional(CONF_COLOR_TEMPERATURE_MAX_VALUE): cv.int_, + cv.Inclusive( + CONF_COLD_WHITE_COLOR_TEMPERATURE, "color_temperature" + ): cv.color_temperature, + cv.Inclusive( + CONF_WARM_WHITE_COLOR_TEMPERATURE, "color_temperature" + ): cv.color_temperature, + # Change the default gamma_correct and default transition length settings. + # The Tuya MCU handles transitions and gamma correction on its own. + cv.Optional(CONF_GAMMA_CORRECT, default=1.0): cv.positive_float, + cv.Optional( + CONF_DEFAULT_TRANSITION_LENGTH, default="0s" + ): cv.positive_time_period_milliseconds, + } + ).extend(cv.COMPONENT_SCHEMA), + cv.has_at_least_one_key(CONF_DIMMER_DATAPOINT, CONF_SWITCH_DATAPOINT), +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + await cg.register_component(var, config) + await light.register_light(var, config) + + if CONF_DIMMER_DATAPOINT in config: + cg.add(var.set_dimmer_id(config[CONF_DIMMER_DATAPOINT])) + if CONF_MIN_VALUE_DATAPOINT in config: + cg.add(var.set_min_value_datapoint_id(config[CONF_MIN_VALUE_DATAPOINT])) + if CONF_SWITCH_DATAPOINT in config: + cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT])) + if CONF_COLOR_TEMPERATURE_DATAPOINT in config: + cg.add(var.set_color_temperature_id(config[CONF_COLOR_TEMPERATURE_DATAPOINT])) + cg.add( + var.set_cold_white_temperature(config[CONF_COLD_WHITE_COLOR_TEMPERATURE]) + ) + cg.add( + var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE]) + ) + if CONF_MIN_VALUE in config: + cg.add(var.set_min_value(config[CONF_MIN_VALUE])) + if CONF_MAX_VALUE in config: + cg.add(var.set_max_value(config[CONF_MAX_VALUE])) + if CONF_COLOR_TEMPERATURE_MAX_VALUE in config: + cg.add( + var.set_color_temperature_max_value( + config[CONF_COLOR_TEMPERATURE_MAX_VALUE] + ) + ) + paren = await cg.get_variable(config[CONF_TUYA_ID]) + cg.add(var.set_tuya_parent(paren)) diff --git a/components/tuya_light_plus/tuya_light_plus.cpp b/components/tuya_light_plus/tuya_light_plus.cpp new file mode 100644 index 0000000..289cbb0 --- /dev/null +++ b/components/tuya_light_plus/tuya_light_plus.cpp @@ -0,0 +1,93 @@ +#include "esphome/core/log.h" +#include "tuya_light_plus.h" + +namespace esphome { +namespace tuya { + +static const char *TAG = "tuya.light_plus"; + +void TuyaLightPlus::setup() { + if (this->color_temperature_id_.has_value()) { + this->parent_->register_listener(*this->color_temperature_id_, [this](TuyaDatapoint datapoint) { + auto call = this->state_->make_call(); + call.set_color_temperature(this->cold_white_temperature_ + + (this->warm_white_temperature_ - this->cold_white_temperature_) * + (float(datapoint.value_uint) / float(this->color_temperature_max_value_))); + call.perform(); + }); + } + if (this->dimmer_id_.has_value()) { + this->parent_->register_listener(*this->dimmer_id_, [this](TuyaDatapoint datapoint) { + auto call = this->state_->make_call(); + call.set_brightness(float(datapoint.value_uint) / this->max_value_); + call.perform(); + }); + } + if (switch_id_.has_value()) { + this->parent_->register_listener(*this->switch_id_, [this](TuyaDatapoint datapoint) { + auto call = this->state_->make_call(); + call.set_state(datapoint.value_bool); + call.perform(); + }); + } + if (min_value_datapoint_id_.has_value()) { + parent_->set_datapoint_value(*this->min_value_datapoint_id_, this->min_value_); + } +} + +void TuyaLightPlus::dump_config() { + ESP_LOGCONFIG(TAG, "Tuya Dimmer:"); + if (this->dimmer_id_.has_value()) + ESP_LOGCONFIG(TAG, " Dimmer has datapoint ID %u", *this->dimmer_id_); + if (this->switch_id_.has_value()) + ESP_LOGCONFIG(TAG, " Switch has datapoint ID %u", *this->switch_id_); +} + +light::LightTraits TuyaLightPlus::get_traits() { + auto traits = light::LightTraits(); + traits.set_supports_brightness(this->dimmer_id_.has_value()); + traits.set_supports_color_temperature(this->color_temperature_id_.has_value()); + if (this->color_temperature_id_.has_value()) { + traits.set_min_mireds(this->cold_white_temperature_); + traits.set_max_mireds(this->warm_white_temperature_); + } + return traits; +} + +void TuyaLightPlus::setup_state(light::LightState *state) { state_ = state; } + +void TuyaLightPlus::write_state(light::LightState *state) { + float brightness; + state->current_values_as_brightness(&brightness); + + if (brightness == 0.0f) { + // turning off, first try via switch (if exists), then dimmer + if (switch_id_.has_value()) { + parent_->set_datapoint_value(*this->switch_id_, false); + } else if (dimmer_id_.has_value()) { + parent_->set_datapoint_value(*this->dimmer_id_, 0); + } + return; + } + + if (this->color_temperature_id_.has_value()) { + 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(*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()) { + parent_->set_datapoint_value(*this->dimmer_id_, brightness_int); + } + if (this->switch_id_.has_value()) { + parent_->set_datapoint_value(*this->switch_id_, true); + } +} + +} // namespace tuya +} // namespace esphome diff --git a/components/tuya_light_plus/tuya_light_plus.h b/components/tuya_light_plus/tuya_light_plus.h new file mode 100644 index 0000000..40cf7b3 --- /dev/null +++ b/components/tuya_light_plus/tuya_light_plus.h @@ -0,0 +1,54 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/tuya/tuya.h" +#include "esphome/components/light/light_output.h" + +namespace esphome { +namespace tuya { + +class TuyaLightPlus : public Component, public light::LightOutput { + public: + void setup() override; + void dump_config() override; + void set_dimmer_id(uint8_t dimmer_id) { this->dimmer_id_ = dimmer_id; } + void set_min_value_datapoint_id(uint8_t min_value_datapoint_id) { + this->min_value_datapoint_id_ = min_value_datapoint_id; + } + void set_switch_id(uint8_t switch_id) { this->switch_id_ = switch_id; } + void set_color_temperature_id(uint8_t color_temperature_id) { this->color_temperature_id_ = color_temperature_id; } + void set_tuya_parent(Tuya *parent) { this->parent_ = parent; } + void set_min_value(uint32_t min_value) { min_value_ = min_value; } + void set_max_value(uint32_t max_value) { max_value_ = max_value; } + void set_color_temperature_max_value(uint32_t color_temperature_max_value) { + this->color_temperature_max_value_ = color_temperature_max_value; + } + void set_cold_white_temperature(float cold_white_temperature) { + this->cold_white_temperature_ = cold_white_temperature; + } + void set_warm_white_temperature(float warm_white_temperature) { + this->warm_white_temperature_ = warm_white_temperature; + } + light::LightTraits get_traits() override; + void setup_state(light::LightState *state) override; + void write_state(light::LightState *state) override; + + protected: + void update_dimmer_(uint32_t value); + void update_switch_(uint32_t value); + + Tuya *parent_; + optional dimmer_id_{}; + optional min_value_datapoint_id_{}; + optional switch_id_{}; + optional color_temperature_id_{}; + uint32_t min_value_ = 0; + uint32_t max_value_ = 255; + uint32_t color_temperature_max_value_ = 255; + float cold_white_temperature_; + float warm_white_temperature_; + light::LightState *state_{nullptr}; +}; + +} // namespace tuya +} // namespace esphome diff --git a/custom/tuya_light_plus.h b/custom/tuya_light_plus.h index f032a5d..048bd70 100644 --- a/custom/tuya_light_plus.h +++ b/custom/tuya_light_plus.h @@ -4,6 +4,8 @@ using namespace esphome; #define DOUBLE_TAP_TIMEOUT 300 +static const char* TAG = "NuttyTuyaLight"; + class TuyaLightPlus : public Component, public light::LightOutput, public api::CustomAPIDevice { public: @@ -235,14 +237,21 @@ void TuyaLightPlus::on_day_night_changed(std::string state) void TuyaLightPlus::handle_tuya_datapoint(tuya::TuyaDatapoint datapoint) { + ESP_LOGD(TAG, "Received Datapoint:"); if (datapoint.id == *this->switch_id_) { + ESP_LOGD(TAG, " Type: Switch"); + // Light turned on if (datapoint.value_bool) { + ESP_LOGD(TAG, " State: On"); + // Turned on with the physical button if (!this->tuya_state_) { + ESP_LOGD(TAG, "Turned on at device"); + if (this->has_double_tap_while_on_) { // We are in a double tap while on timeout period so this is a double tap @@ -281,18 +290,25 @@ void TuyaLightPlus::handle_tuya_datapoint(tuya::TuyaDatapoint datapoint) } // We got through all the double tap logic and the light is still on so update the Tuya state + ESP_LOGD(TAG, "Updating Tuya state to on"); this->tuya_state_ = true; } - - // Set the brightness to the correct level (it currently is at 0) - this->set_tuya_level(this->brightness_to_tuya_level(this->state_->current_values.get_brightness())); + else + { + // Set the brightness to the correct level (it currently is at 0) + this->set_tuya_level(this->brightness_to_tuya_level(this->state_->current_values.get_brightness())); + } } // Light turned off else { + ESP_LOGD(TAG, " State: Off"); + // Turned off with physical button if (this->tuya_state_) { + ESP_LOGD(TAG, "Turned off at device"); + if (has_double_tap_while_on_) { // Start the double tap while on timeout @@ -300,21 +316,28 @@ void TuyaLightPlus::handle_tuya_datapoint(tuya::TuyaDatapoint datapoint) } // Update the Tuya state + ESP_LOGD(TAG, "Updating Tuya state to off"); this->tuya_state_ = false; } // Set the Tuya level to 0 to prevent flashes during double taps + ESP_LOGD(TAG, "Updating Tuya level to 0"); this->set_tuya_level(0); // Set the current brightness to the default so that it will turn on at the default brightness + ESP_LOGD(TAG, "Updating brightness state to default"); this->state_->current_values.set_brightness(this->default_brightness_); } // Update the current values state + ESP_LOGD(TAG, "Updating state to new value"); this->state_->current_values.set_state(this->tuya_state_); } else if (datapoint.id == *this->dimmer_id_) { + ESP_LOGD(TAG, " Type: Brightness"); + ESP_LOGD(TAG, " Value: %u", datapoint.value_uint); + // Only react to dimmer level changes if the light is on if(this->tuya_state_) { @@ -326,13 +349,16 @@ void TuyaLightPlus::handle_tuya_datapoint(tuya::TuyaDatapoint datapoint) this->state_changed_at_ = millis(); // If the remote values do not reflect the current values update and publish the values - if (this->state_->current_values.get_state() != this->state_->remote_values.get_state()) + if (this->state_->current_values.get_state() != this->state_->remote_values.get_state() + || this->state_->current_values.get_brightness() != this->state_->remote_values.get_brightness()) { + ESP_LOGD(TAG, "Publishing new state"); this->state_->remote_values = this->state_->current_values; this->state_->publish_state(); } // Update any linked lights + ESP_LOGD(TAG, "Updating linked lights"); this->update_linked_lights(); } @@ -340,24 +366,12 @@ void TuyaLightPlus::set_tuya_state(bool state) { this->tuya_state_ = state; - // In version 1.19.0 the code below needs to change to: - // this->parent_->set_datapoint_value(*this->switch_id_, state); - tuya::TuyaDatapoint datapoint{}; - datapoint.id = *this->switch_id_; - datapoint.type = tuya::TuyaDatapointType::BOOLEAN; - datapoint.value_bool = state; - this->parent_->set_datapoint_value(datapoint); + this->parent_->set_datapoint_value(*this->switch_id_, state); } void TuyaLightPlus::set_tuya_level(uint32_t level) { - // In version 1.19.0 the code below needs to change to: - // this->parent_->set_datapoint_value(*this->dimmer_id_, std::max(level, this->min_value_)); - tuya::TuyaDatapoint datapoint{}; - datapoint.id = *this->dimmer_id_; - datapoint.type = tuya::TuyaDatapointType::INTEGER; - datapoint.value_uint = std::max(level, this->min_value_); - this->parent_->set_datapoint_value(datapoint); + this->parent_->set_datapoint_value(*this->dimmer_id_, std::max(level, this->min_value_)); } void TuyaLightPlus::update_linked_lights() diff --git a/devices/test_light.yaml b/devices/test_light.yaml new file mode 100644 index 0000000..05b1ef7 --- /dev/null +++ b/devices/test_light.yaml @@ -0,0 +1,16 @@ +substitutions: + device_id: computer_light + device_name: Computer Light + ip_address: !secret computer_light_ip + ota_pwd: !secret computer_light_ota_pwd + api_pwd: !secret computer_light_api_pwd + ap_wifi_pwd: !secret computer_light_ap_wifi_pwd + day_brightness: "1" + night_brightness: ".03" + day_auto_off_minutes: "0" + night_auto_off_minutes: "15" + linked_lights: "" + double_tap_while_off_stays_on: "true" + +packages: + feit_dimmer: !include ../packages/feit_dimmer_test.yaml diff --git a/packages/feit_dimmer.yaml b/packages/feit_dimmer.yaml index e76366a..efe7f9d 100644 --- a/packages/feit_dimmer.yaml +++ b/packages/feit_dimmer.yaml @@ -1,6 +1,7 @@ substitutions: platform: ESP8266 board: esp01_1m + log_level: verbose esphome: includes: @@ -10,11 +11,20 @@ esphome: then: - script.execute: startup +external_components: + # - source: github://nuttytree/esphome@more-tuya-reliability-improvements + - source: + type: local + path: ../components + components: [ tuya ] + packages: base: !include device_base.yaml logger: !include logger/logger_no_serial.yaml uart: !include uart/tuya.yaml +tuya: + light: - platform: custom lambda: |- @@ -39,5 +49,3 @@ light: name: ${device_name} gamma_correct: 1.0 default_transition_length: 0s - -tuya: diff --git a/packages/feit_dimmer_as_fan.yaml b/packages/feit_dimmer_as_fan.yaml index babdae8..7384032 100644 --- a/packages/feit_dimmer_as_fan.yaml +++ b/packages/feit_dimmer_as_fan.yaml @@ -17,6 +17,8 @@ fan: output: tuya_fan_output name: ${device_name} +tuya: + output: - platform: custom type: binary @@ -31,5 +33,3 @@ output: return {TuyaFanOutput}; outputs: id: tuya_fan_output - -tuya: diff --git a/packages/feit_dimmer_test.yaml b/packages/feit_dimmer_test.yaml new file mode 100644 index 0000000..ee2b71e --- /dev/null +++ b/packages/feit_dimmer_test.yaml @@ -0,0 +1,26 @@ +substitutions: + platform: ESP8266 + board: esp01_1m + log_level: verbose + +external_components: + # - source: github://nuttytree/esphome@more-tuya-reliability-improvements + - source: + type: local + path: ../components + components: [ tuya, tuya_light_plus ] + +packages: + base: !include device_base.yaml + logger: !include logger/logger_no_serial.yaml + uart: !include uart/tuya.yaml + +tuya: + +light: + - platform: tuya_light_plus + id: tuya_light + name: ${device_name} + switch_datapoint: 1 + dimmer_datapoint: 2 + max_value: 1000