• Уважаемые посетители сайта esp8266.ru!
    Мы отказались от размещения рекламы на страницах форума для большего комфорта пользователей.
    Вы можете оказать посильную поддержку администрации форума. Данные средства пойдут на оплату услуг облачных провайдеров для сайта esp8266.ru
  • Система автоматизации с открытым исходным кодом на базе esp8266/esp32 микроконтроллеров и приложения IoT Manager. Наша группа в Telegram

Управление Tuya розеткой локально (local Tuya Arduino + ESP32)

p-a-h-a

Member
Приветствую, возникла необходимость управлять розеткой Tuya по событию. Подобного не нашел.
Реализовано только для розетки только внутри wi-fi сети с фиксированным ip розетки.
Буду рад если кто-то расширит функционал, например расшифрует ответ, научится запрашивать статус, добавит новые устройств. Мне сейчас достаточно сделанного. LOCAL_KEY и devId брал на оф. сайте разработчиков туя, в ютубе смотрел инструкцию.

main.cpp:
C++:
// Код подключается к умной розетке Tuya  local по протоколу 3.3
#include <Arduino.h>
#include <WiFi.h>
#include "Tuya.h"

const char *ssid = "xxx";          // Имя вашей Wi-Fi сети
const char *password = "xxx";          // Пароль от вашей Wi-Fi сети
const char *devId = "xxx"; // Id розетки. В роутере нужно прописать постоянный IP для розетки
const char *LOCAL_KEY = "xxxxxxxxxx"; // Ваш ключ AES (16 байтов) Ищите на ютубе как его получить на сайте разработчика
const char *tuya_ip = "xxx.xxx.xxx.xxx";    // IP розетки. В роутере нужно прописать постоянный IP для розетки
Tuya Rozetka(tuya_ip, devId, LOCAL_KEY);

void setup(){
  Serial.begin(115200);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED)
    configTime(0, 0, "pool.ntp.org", "time.nist.gov");
}

void loop(){
  Rozetka.on();
  delay(3000);
  Rozetka.off();
  delay(3000);
  // static byte i;
  // Rozetka.relay(++i % 2);
  // delay(3000);
}
Tuya.h:
C++:
#pragma once
#include <Arduino.h>
#include <mbedtls/aes.h>
#include <WiFi.h>
#include <lwip/inet.h>
#include <time.h>
#define headerSize 31 // Фиксированный размер заголовка
#define TailSize 8    // Фиксированный размер хвоста (crc32 и фиксированный код 0xAA 0x55)

class Tuya
{
private:
  const char *devId;
  const char *LOCAL_KEY; // Ваш ключ AES (16 байтов)
  const char *tuya_ip;
  WiFiClient client;
  void payloadConfigure(char *input_string, const uint8_t len, const char *devId, const char *dpsValue);//Создает json запрос с командой
   void aesEncrypt(uint8_t *output_data, size_t output_data_len, const char *input_string, size_t input_string_len, const char *LOCAL_KEY);//Кодирует запрос
  uint32_t crc32(const uint8_t *data, size_t length);                   // CRC32
  bool sendCommand(uint8_t *payload, size_t size, const char *tuya_ip); // передает данные
public:
  Tuya() = delete;
  Tuya(const char *tuya_ip, const char *devId, const char *LOCAL_KEY)
      : tuya_ip(tuya_ip), devId(devId), LOCAL_KEY(LOCAL_KEY) {}
  bool relay(bool state);
  bool on() { return relay(LOW); }
  bool off() { return relay(HIGH); }
};

bool Tuya::relay(bool state)
{
  const size_t input_string_len = 97; // Длинна сообщения фиксирована для вкл/откл розетки
  char input_string[input_string_len] = {};
  const char *dpsValue = nullptr; // команда включения отключения в запросе (true/false)
  state ? dpsValue = "true" : dpsValue = "false";
  payloadConfigure(input_string, input_string_len, devId, dpsValue);
  const int padded_len = ((input_string_len / 16) + 1) * 16;
  constexpr size_t output_data_len = headerSize + padded_len + TailSize;
  uint8_t output_data[output_data_len] = {};
  aesEncrypt(output_data, output_data_len, input_string, input_string_len, LOCAL_KEY);
  return sendCommand(output_data, output_data_len, tuya_ip); // Отправка данных
}

void Tuya::aesEncrypt(uint8_t *output_data, size_t output_data_len, const char *input_string, size_t input_string_len, const char *LOCAL_KEY)
{
  {
    //Заголовок
        const uint8_t header[] = {
        0x00, 0x00, 0x55, 0xAA,//Фиксированный заголовок
        0x01, 0x00, 0x00, 0x00, //счетчик, Идентификатор сообщения. Такой же возвращается в ответ.
        0x00, 0x00, 0x00, 0x07, //Команда 0x00..0x0E
        0x00, 0x00, 0x00, 0x00, // Длина зашифрованных данных плюс 8 байт (CRC32 и суффикс)
        0x33, 0x2E, 0x33, 0x00, //Версия 3.3
     // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 //ноли
        };
    memcpy(output_data, header, sizeof(header)); // Копируем заголовок в массив байтов
  }
 
  uint32_t *p_comandCounter = (uint32_t *)(output_data + 4);       // Создаем ссылку на адресс масива где хранится номер команды
  static uint32_t counter = 0;
  *p_comandCounter = htonl(counter++);

// на потом, команды сообщения в 11м байте
  // enum  Comands {
  //   HEART_BEAT = 0x00,    //Пинг / проверка связи
  //   PRODUCT_INFO = 0x01,  //Информация об устройстве
  //   WORK_MODE = 0x02,     //Рабочий режим
  //   WIFI_STATE = 0x03,    //Состояние Wi-Fi
  //   WIFI_RESET = 0x04,    //Сброс Wi-Fi
  //   WIFI_MODE = 0x05,     //Режим Wi-Fi
  //   DATA = 0x06,          //Передача команд управления (вкл/выкл и др)
  //   STATE = 0x07,         //Получение состояния устройства
  //   STATE_RESPONSE = 0x08,//Ответ на запрос состояния
  //   DP_QUERY = 0x09,      //Запрос значения DP (data point)
  //   DP_QUERY_RESPONSE = 0x0A,//Ответ на DP_QUERY
  //   CONTROL = 0x0B,       //Управление устройством (часто тоже 0x07)
  //   STATUS_REPORT = 0x0C, //Отчет об изменении состояния
  //   STATUS_REPORT_RESPONSE = 0x0D,//Ответ на отчет
  //   RESET_FACTORY = 0x0E  //Сброс на заводские
  // };
  // output_data[11] = DATA;

  const size_t padded_len = ((input_string_len / 16) + 1) * 16; // Делаем длину строки кратной 16
  uint8_t* input_data = new uint8_t[padded_len]{};

  memcpy(input_data, input_string, input_string_len); // Копируем строку в массив байтов
  // Добавляем padding (PKCS7)
  const size_t block_size = 16;
  size_t pad_len = block_size - (input_string_len % block_size);
  for (size_t i = input_string_len; i < input_string_len + pad_len; i++)
  {
    input_data[i] = pad_len; // Заполнение байтами с значением pad_len
  }
  uint32_t *total_payload_len = (uint32_t *)(output_data + 12);       // Создаем ссылку на адресс масива где хранится длина сообщения
  *total_payload_len = htonl(padded_len + headerSize - (const int)8); // Расчет длинны сообщения, перевод байт в big-endian и сразу запись в массив

  // Инициализация AES контекста
  mbedtls_aes_context aes_ctx;
  mbedtls_aes_init(&aes_ctx);

  // Инициализация шифратора
  mbedtls_aes_setkey_enc(&aes_ctx, (const unsigned char *)LOCAL_KEY, 128);

  for (size_t i = 0; i < padded_len; i += 16)
  {
    mbedtls_aes_crypt_ecb(&aes_ctx, MBEDTLS_AES_ENCRYPT, input_data + i, output_data + i + headerSize);
  }
  mbedtls_aes_free(&aes_ctx); // Очистка контекста
  delete[] input_data;

  uint32_t crc = htonl(crc32(output_data, padded_len + headerSize));
  memcpy(&output_data[padded_len + headerSize], &crc, sizeof(crc));            // Пишем контрольную сумму в хвост
  memcpy(&output_data[padded_len + headerSize + TailSize - 2], "\xAA\x55", 2); // фиксированный хвост
}

void Tuya::payloadConfigure(char *input_string, const uint8_t len, const char *devId, const char *dpsValue)
{
  struct tm timeinfo;
  getLocalTime(&timeinfo); // Получаем системное время
  sprintf(input_string, "{\"devId\":\"%s\",\"uid\":\"%s\",\"t\":\"%lu\",\"dps\":{\"1\":%s}}", devId, devId, mktime(&timeinfo), dpsValue);
}

uint32_t Tuya::crc32(const uint8_t *data, size_t length)
{
  uint32_t crc = 0xFFFFFFFF;
  for (size_t i = 0; i < length; i++)
  {
    crc ^= data[i];
    for (uint8_t j = 0; j < 8; j++)
    {
      if (crc & 1)
        crc = (crc >> 1) ^ 0xEDB88320;
      else
        crc >>= 1;
    }
  }
  return ~crc;
}

bool Tuya::sendCommand(uint8_t *payload, size_t size, const char *tuya_ip)
{
  if (client.connect(tuya_ip, 6668))
  {
    delay(50);
    Serial.printf("Соединение с Tuya успешно!\n");
    client.write(payload, size);
    client.flush();

    // Чтение ответа
    delay(200);
    uint32_t start = millis();
    while (millis() - start < 700) // ждём до 1 сек
    {
      if (client.available())
      {
        uint8_t b = client.read();
        Serial.printf("%02X ", b);
        start = millis(); // сброс таймера, если получили байт
      }
      else
        delay(10); // подождать немного, если данных нет
    }

    client.stop();
    Serial.println("\nСоединение закрыто.");
    return true;
  }
  else
  {
    Serial.println("Ошибка подключения к розетке!");
    return false;
  }
}
platformio.ini:
Код:
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
board_build.filesystem = littlefs
build_unflags = -std=gnu++11
board_upload.wait_for_upload_port = yes
board_upload.use_1200bps_touch = yes
lib_deps =
Разбирался как все устроенно при помощи пайтон скрипта, который по сути делает тоже самое (вкл/откл) с подробным логом.
На всякий случай tuya.py:
Python:
import logging
import tinytuya
import time
#скрипт выводит в консоль зашифрованные пакеты для вкл и выкл
# Данные устройства
DEVICE_ID = "xxxxxxxxxxxxxxx"
DEVICE_IP = "xxx.xxx.xxx.xxx"
LOCAL_KEY = "xxxxxxxxxxxxxxx"

# Настройка логирования
logging.basicConfig(level=logging.DEBUG)
# Инициализация устройства
device = tinytuya.OutletDevice(DEVICE_ID, DEVICE_IP, LOCAL_KEY)
device.set_version(3.3)  # важно для новых устройств

# Проверка статуса
status = device.status()
print("Текущий статус:", status)

# Включение розетки
print("Включаем розетку...")
#device.set_status(True, 1)  # True = ВКЛ, False = ВЫКЛ, 1 = DP ID (обычно для розеток)
#time.sleep(3)
device.turn_on()
# Выключение розетки
print("Выключаем розетку...")
device.set_status(False, 1)
 

pvvx

Активный участник сообщества
Основная проблема Tuya WiFi розеток со встроенными ESP8266 – не работают без внешнего Tuya-Cloud. Конкретно после включения-отключения электросети им обязательно по старту необходимо соединиться с Tuya-Cloud. До этого никакие местные соединения не воспринимают. После соединения с Cloud - работают. У розеток с другими чипами, обычно работающими по WiFi и BLE, таких фокусов нет, а цена одинакова.

Другая проблема у всех WiFi розеток – большое потребление в сравнении с аналогичными Zigbee розетками. Рядом расположенные термометры показывают повышенную на +2С температуру, чего не сказывается при Zigbee розетке.

И в третьих – много этих WiFi розеток не навешать на один WiFi роутер. И их скопление создает вечный фон в 2.4ГГц по всем (не основным) каналам на уровнях -30Дбр. Это выше уровня сигнала любого беспроводного устройства из соседней комнаты. В итоге вы сокращаете зону охвата одной комнатой для всех других беспроводных участников в Умном Доме.

Все, у кого Умный дом начинает приближаться к частичному автоматическому управлению (используют несколько десятков устройств) избавляются от мелких WiFi устройств в нем.

А шифрация и дешифрация команд и прочего для WiFi Tuya есть в интеграции LocalTuya для Home Assistant. BLE дешифрация в соответствующей интеграции, но на сегодня уже устаревшей и несовместимой с новыми версиями Home Assistant.
 

p-a-h-a

Member
Все работает без интернета. Интернет нужен для первоначальной настройки и получением розеткой LOCAL_KEY.
Проверил. Подключился к изолированной точке доступа без интернета. Управление работает после пропадания питания. Наглядно видно по времени в ответе от розетки:
Payload: {"devId":"08051804bcddcxxxxxxxx","dps":{"1":true},"t":53} // время 53 в формате unix time
подключаю интернет к точке доступа, розетка синхронизировалась и подтянула время:
Payload: {"devId":"08051804bcddcxxxxxxxx","dps":{"1":false},"t":1747674697} // время 1747674697.
Я теперь задумался в чем отличия устройств Tuya от других работающих в приложении Tuya. Моя розетка отображается как ESP_542C4E. Так что думаю там 8266.
"интеграции LocalTuya для Home Assistant" как ее в ESP32 записать? А то не пользуюсь Home Assistant и пишу код под ESP чтоб без посредников работал.

Немножко продвинулся. Добавил подтверждение передачи команды и расшифровку ответа в строку.

Tuya.h:
C++:
#pragma once
#include <Arduino.h>
#include <mbedtls/aes.h>
#include <WiFi.h>
#include <lwip/inet.h>
#include <time.h>
#define headerSize 31 // Фиксированный размер заголовка
#define TailSize 8    // Фиксированный размер хвоста (crc32 и фиксированный код 0xAA 0x55)

class Tuya
{
private:
  const char *devId;
  const char *LOCAL_KEY; // Ваш ключ AES (16 байтов)
  const char *tuya_ip;
  WiFiClient client;
  void payloadConfigure(char *input_string, const uint8_t len, const char *devId, const char *dpsValue);                                   // Создает json запрос с командой
  void aesEncrypt(uint8_t *output_data, size_t output_data_len, const char *input_string, size_t input_string_len, const char *LOCAL_KEY); // Кодирует запрос
  bool aesdecrypt(const uint8_t *input_data, size_t input_data_len, char *output_data, const char *LOCAL_KEY);
  uint32_t crc32(const uint8_t *data, size_t length);                                                                                      // CRC32
  bool sendCommand(uint8_t *payload, size_t size, const char *tuya_ip);                                                                    // передает данные
public:
  Tuya() = delete;
  Tuya(const char *tuya_ip, const char *devId, const char *LOCAL_KEY)
      : tuya_ip(tuya_ip), devId(devId), LOCAL_KEY(LOCAL_KEY) {}
  bool relay(bool state);
  bool on() { return relay(LOW); }
  bool off() { return relay(HIGH); }
};

bool Tuya::relay(bool state)
{
  const size_t input_string_len = 97; // Длинна сообщения фиксирована для вкл/откл розетки
  char input_string[input_string_len] = {};
  const char *dpsValue = nullptr; // команда включения отключения в запросе (true/folse)
  state ? dpsValue = "true" : dpsValue = "false";
  payloadConfigure(input_string, input_string_len, devId, dpsValue);
  const int padded_len = ((input_string_len / 16) + 1) * 16;
  constexpr size_t output_data_len = headerSize + padded_len + TailSize;
  uint8_t output_data[output_data_len] = {};
  aesEncrypt(output_data, output_data_len, input_string, input_string_len, LOCAL_KEY);
  return sendCommand(output_data, output_data_len, tuya_ip); // Отправка данных
}

void Tuya::aesEncrypt(uint8_t *output_data, size_t output_data_len, const char *input_string, size_t input_string_len, const char *LOCAL_KEY)
{
  {
    // Заголовок
    const uint8_t header[] = {
        0x00, 0x00, 0x55, 0xAA, // Фиксированный заголовок
        0x01, 0x00, 0x00, 0x00, // счетчик, Идентификатор сообщения. Такой же возвращается в ответ.
        0x00, 0x00, 0x00, 0x07, // Команда 0x00..0x0E
        0x00, 0x00, 0x00, 0x00, // Длина зашифрованных данных плюс 8 байт (CRC32 и суффикс)
        0x33, 0x2E, 0x33, 0x00, // Версия 3.3
                                // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 //ноли
    };
    if (output_data_len < sizeof(header))
      return;
    memcpy(output_data, header, sizeof(header)); // Копируем заголовок в массив байтов
  }

  uint32_t *p_comandCounter = (uint32_t *)(output_data + 4); // Создаем ссылку на адресс масива где хранится номер команды
  static uint32_t counter = 0;
  *p_comandCounter = htonl(counter++);

  const size_t padded_len = ((input_string_len / 16) + 1) * 16; // Делаем длину строки кратной 16
  uint8_t *input_data = new uint8_t[padded_len]{};

  if (output_data_len < input_string_len)
    return;
  memcpy(input_data, input_string, input_string_len); // Копируем строку в массив байтов
  // Добавляем padding (PKCS7)
  const size_t block_size = 16;
  size_t pad_len = block_size - (input_string_len % block_size);
  for (size_t i = input_string_len; i < input_string_len + pad_len; i++)
  {
    input_data[i] = pad_len; // Заполнение байтами с значением pad_len
  }
  uint32_t *total_payload_len = (uint32_t *)(output_data + 12);       // Создаем ссылку на адресс масива где хранится длина сообщения
  *total_payload_len = htonl(padded_len + headerSize - (const int)8); // Расчет длинны сообщения, перевод байт в big-endian и сразу запись в массив

  // Инициализация AES контекста
  mbedtls_aes_context aes_ctx;
  mbedtls_aes_init(&aes_ctx);

  // Инициализация шифратора
  mbedtls_aes_setkey_enc(&aes_ctx, (const unsigned char *)LOCAL_KEY, 128);

  for (size_t i = 0; i < padded_len; i += 16)
  {
    mbedtls_aes_crypt_ecb(&aes_ctx, MBEDTLS_AES_ENCRYPT, input_data + i, output_data + i + headerSize);
  }
  mbedtls_aes_free(&aes_ctx); // Очистка контекста
  delete[] input_data;

  uint32_t crc = htonl(crc32(output_data, padded_len + headerSize));
  if (padded_len + headerSize < sizeof(crc))
    return;
  memcpy(&output_data[padded_len + headerSize], &crc, sizeof(crc));            // Пишем контрольную сумму в хвост
  memcpy(&output_data[padded_len + headerSize + TailSize - 2], "\xAA\x55", 2); // фиксированный хвост
}

void Tuya::payloadConfigure(char *input_string, const uint8_t len, const char *devId, const char *dpsValue)
{
  struct tm timeinfo;
  getLocalTime(&timeinfo); // Получаем системное время
  sprintf(input_string, "{\"devId\":\"%s\",\"uid\":\"%s\",\"t\":\"%lu\",\"dps\":{\"1\":%s}}", devId, devId, mktime(&timeinfo), dpsValue);
}

uint32_t Tuya::crc32(const uint8_t *data, size_t length)
{
  uint32_t crc = 0xFFFFFFFF;
  for (size_t i = 0; i < length; i++)
  {
    crc ^= data[i];
    for (uint8_t j = 0; j < 8; j++)
    {
      if (crc & 1)
        crc = (crc >> 1) ^ 0xEDB88320;
      else
        crc >>= 1;
    }
  }
  return ~crc;
}

bool Tuya::sendCommand(uint8_t *payload, size_t size, const char *tuya_ip)
{
  if (client.connect(tuya_ip, 6668))
  {
    delay(50);
    Serial.printf("Соединение с Tuya успешно!\n");
    client.write(payload, size);
    client.flush();

    // Чтение ответа
    delay(200);
    uint32_t start = millis();
    bool result = false;
    while (millis() - start < 1000) // ждём до 1 сек
    {
      u16_t len = client.available();
      if (len)
      {
        uint8_t *buf = new uint8_t[len];
        client.readBytes(buf, len);
        uint32_t *payload_size = (uint32_t *)(buf + 12);
        *payload_size = htonl(*payload_size);
        Serial.printf("\nПринят ответ размером %u байт. Payload_size = %u :\n", len, *payload_size);
        for (size_t i = 0; i < len; i++)
          Serial.printf("%02X ", buf[i]);
        if (*((uint32_t*)(payload + 4))==*((uint32_t*)(buf + 4))){ // сравниваем счетчики сообщений
          result = true;
        }

        if(*payload_size > 12){
            char str[80]={};
            #define START_PAYLOAD_BYTE 35
            aesdecrypt(buf + START_PAYLOAD_BYTE, *payload_size-27, str, LOCAL_KEY);
            Serial.printf("\nPayload: %s. Содержит сиволов:%u.", str, strlen(str)); //{"devId":"0805180743ddcxxxxxxx","dps":{"1":true},"t":1747670040}
        }
        delete[] buf;
      }
    }

    client.stop();
    Serial.printf("\nСоединение закрыто. Отправка %s\n", result ? "успешна!" : "с ошибкой(((");
    return result;
  }
  else
  {
    Serial.println("Ошибка подключения к розетке!");
    return false;
  }
}

bool Tuya::aesdecrypt(const uint8_t *input_data, size_t input_data_len, char *output_data, const char *LOCAL_KEY) {
  if (input_data_len % 16 != 0) {
    Serial.println("Ошибка: длина входных данных должна быть кратна 16");
    return false;
  }
  mbedtls_aes_context aes;
  mbedtls_aes_init(&aes);
  // Установка ключа
  if (mbedtls_aes_setkey_dec(&aes, (const unsigned char *)LOCAL_KEY, 128) != 0) {
    Serial.println("Ошибка установки ключа AES");
    mbedtls_aes_free(&aes);
    return false;
  }
  // Расшифровка по 16 байт
  for (size_t i = 0; i < input_data_len; i += 16) {
    if (mbedtls_aes_crypt_ecb(&aes, MBEDTLS_AES_DECRYPT, input_data + i, (unsigned char*)(output_data + i)) != 0) {
      Serial.println("Ошибка расшифровки блока");
      mbedtls_aes_free(&aes);
      return false;
    }
  }
  mbedtls_aes_free(&aes);
  uint8_t pad = output_data[input_data_len - 1];
  //Обрезаем padding в строке
  output_data[input_data_len - pad] = '\0'; // если это строка
  return true;
}
Теперь можно гарантировано включить прибор:
while (Rozetka.on() != true){};// повторяем пока не получим ответ от розетки. Да, это не правильно, нужно обрабатывать неотправку. Это демонстрационная строка.
 

pvvx

Активный участник сообщества
У вас старая версия розетки c ESP8266. По этому и подключается...
 

pvvx

Активный участник сообщества
Проверяется очень просто - в роутере ставим запрет выхода во внешнюю сеть и розетка не включает входной сокет по старту питания (пока не снюхается с Cloud, но роутер этого недопустит).
Пример:
Эти нельзя блокировать от внешней сети:
1747698536070.png
А эти можно - уже несколько лет так заблокированные и ограниченные по трафику в 64 кбит/c работают:
1747698572256.png
Ещё осталось заменить 8 шт дурных WiFi на Zigbee "розеток" и типа (с мониторингом) во втором доме.
Ныне Zigbee "розеток" давно за пару десятков шт.
И даже меньшее количество роутеры WiFi не выносили - в логах почти каждый день какая из массы WiFi розеток теряла соединение с роутером или не открывала сокет. Раз в месяц приходилось вручную передергивать какой либо WiFi розетке питание.
Чего ни разу не происходило с Zigbee розетками за несколько лет - поставил, вписал в скрипты и забыл.
Так что не всё хорошо с WiFi и не всем повезет купить правильную ESP розетку. Tuya не хочет чтобы ваши данные не поступали к ней...
 

pvvx

Активный участник сообщества
В инете можно найти как бороться с такими нежелающими работать только в локальной сети с помощью скриптов и перехватов-подстановки им внешнего трафика, но проще заменить.
 
Сверху Снизу