#include "tuya.h" #include "esphome/core/log.h" #include "esphome/core/helpers.h" namespace esphome { namespace tuya { static const char *TAG = "tuya"; void Tuya::setup() { this->set_interval("heartbeat", 1000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); } void Tuya::loop() { while (this->available()) { uint8_t c; this->read_byte(&c); this->handle_char_(c); } } 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", // NOLINT 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::BOOLEAN) ESP_LOGCONFIG(TAG, " Datapoint %d: 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); else if (info.type == TuyaDatapointType::ENUM) ESP_LOGCONFIG(TAG, " Datapoint %d: 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); else ESP_LOGCONFIG(TAG, " Datapoint %d: 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, // NOLINT hexencode(message_data, length).c_str(), 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) { switch ((TuyaCommandType) command) { case TuyaCommandType::HEARTBEAT: ESP_LOGV(TAG, "MCU Heartbeat (0x%02X)", buffer[0]); 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) { gpio_status_ = buffer[0]; gpio_reset_ = buffer[1]; } if (this->init_state_ == TuyaInitState::INIT_CONF) { // 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. uint8_t c[] = {0x04}; this->init_state_ = TuyaInitState::INIT_WIFI; this->send_command_(TuyaCommandType::WIFI_STATE, c, 1); } 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, "TUYA_CMD_WIFI_RESET is not handled"); break; case TuyaCommandType::WIFI_SELECT: ESP_LOGE(TAG, "TUYA_CMD_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: { uint8_t c[] = {0x00, 0x00}; this->send_command_(TuyaCommandType::WIFI_TEST, c, 2); break; } default: ESP_LOGE(TAG, "invalid command (%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; 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, "invalid datapoint update"); return; } switch (datapoint.type) { case TuyaDatapointType::BOOLEAN: if (data_len != 1) return; datapoint.value_bool = data[0]; break; case TuyaDatapointType::INTEGER: if (data_len != 4) return; datapoint.value_uint = (uint32_t(data[0]) << 24) | (uint32_t(data[1]) << 16) | (uint32_t(data[2]) << 8) | (uint32_t(data[3]) << 0); break; case TuyaDatapointType::ENUM: if (data_len != 1) 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); break; default: return; } ESP_LOGV(TAG, "Datapoint %u update to %u", datapoint.id, datapoint.value_uint); // 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_command_(TuyaCommandType command, const uint8_t *buffer, uint16_t len) { uint8_t len_hi = len >> 8; uint8_t len_lo = len >> 0; uint8_t version = 0; ESP_LOGV(TAG, "Sending Tuya: CMD=0x%02X VERSION=%u DATA=[%s] INIT_STATE=%u", command, version, // NOLINT hexencode(buffer, len).c_str(), this->init_state_); this->write_array({0x55, 0xAA, version, (uint8_t) command, len_hi, len_lo}); if (len != 0) this->write_array(buffer, len); uint8_t checksum = 0x55 + 0xAA + (uint8_t) command + len_hi + len_lo; for (int i = 0; i < len; i++) checksum += buffer[i]; this->write_byte(checksum); } 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) { if (other.value_uint == datapoint.value_uint) { ESP_LOGV(TAG, "Not sending unchanged value"); return; } } } 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::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; } buffer.push_back(data.size() >> 8); buffer.push_back(data.size() >> 0); buffer.insert(buffer.end(), data.begin(), data.end()); this->send_command_(TuyaCommandType::DATAPOINT_DELIVER, buffer.data(), buffer.size()); } 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