1. Название и модель
Эти термостаты продают под именами EZAIOT, XYZOO и без имени. Карточка узкая: product id dq6nlukkifyawj9n, версия 1.15.0. Другой корпус с тем же рисунком может не совпасть. Чип — Beken BK7231N, плата generic-bk7231n-qfn32-tuya. Сложность 3/5.
С основным MCU связь по UART 38400, не 9600. Часть точек (гистерезис, пределы температуры) задаётся с экрана и от прошивки экрана зависит.
От сети на время прошивки термостат отключают. Автор пишет, что старые прошивки уже не берутся облачными утилитами Tuya. Путь в карточке — отверстия и ltchiptool.
2. Что даёт перепрошивка
- Питание, datapoint 1
- Уставка, datapoint 16, и текущая температура, datapoint 24
- Состояние нагрева, datapoint 110
- Блокировка и подсветка отдельными точками в полном файле карточки
3. Что теряется
- Приложение Tuya перестаёт управлять термостатом
- Недельное расписание — сырой datapoint 30. Его легко сломать обрезанным конфигом
- Гарантия после снятия со стены обычно заканчивается
4. Что понадобится
- Отвёртка, чтобы снять корпус по штатной инструкции монтажа
- Штыри Dupont, отверстия под них уже есть, паять не нужно
- ltchiptool и ESPHome с поддержкой BK72xx
- Питание 3,3 В, не от сети термостата
5. Разборка и пины
Программатор USB–UART подключают к модулю, который прошивают, крест-накрест:
- Земля переходника (GND) — на землю платы. Этот провод обязателен.
- TX переходника — на RX модуля. На плате площадка часто подписана RX, RXD или RXD0.
- RX переходника — на TX модуля. На плате это TX, TXD или TXD0.
- Питание логики — 3,3 В. Пять вольт на эти площадки не подают. Если плату уже кормит её блок, линию 3,3 В с переходника не подключают вторым источником.
- Для входа в загрузчик ESP8266 и ESP32 площадку GPIO0 (IO0, BOOT) замыкают на землю и в этот момент подают питание. После старта записи землю с GPIO0 снимают.
Если у этой модели в тексте ниже другие имена площадок, прямой порядок TX–TX или прошивка по USB, делайте как написано там. Общая схема выше — для обычного модульного UART.
Корпус открывают как при монтаже на стену. На плате подписаны отверстия BK7231N: GND, RX, TX, 3V3. Карточка предупреждает, что TX и RX иногда прямые, а не крест-накрест. Если ltchiptool не читает флеш, провода RX и TX меняют местами.
Когда программа пишет Getting bus..., землю кратковременно отключают и подключают снова. Это перезапуск вместо кнопки CEN.
6. Прошивка
Готового файла «прошивка именно этой модели» на этом сайте нет. Её собирает ESPHome из текстового конфига YAML: туда входят Wi-Fi, пины и команды второго чипа. Чужой .bin из интернета почти наверняка собран с чужим паролем сети.
Какую программу ставить
- ESPHome Dashboard — дополнение в Home Assistant или программа на компьютере. В ней пишут YAML, нажимают Install, и она сама собирает прошивку. Установка: дополнение для Home Assistant или команда
pip install esphome. - ESPHome Web, сайт web.esphome.io, — загрузчик в браузере Chrome или Edge. Им записывают уже собранный
.binна ESP8266 и ESP32 через USB–UART. На BK72xx он не рассчитан. - esptool — программа Espressif, чтобы снять заводскую копию флеша и записать
.bin, если браузер порт не видит. Репозиторий: espressif/esptool. - ltchiptool — то же самое для чипов Beken BK72xx (модули WB3S, WBR3, CB3S и похожие). ESPHome для них собирается через LibreTiny. Программа: libretiny-eu/ltchiptool, описание платы: компонент bk72xx.
Как собрать конфиг
- В Dashboard нажмите New Device и выберите чип, который стоит на плате: ESP8266, ESP32 или BK72xx. Имя — латиницей, без пробелов.
- Пароль Wi-Fi уберите в файл
secrets.yamlрядом с конфигом. В YAML оставьтеssid: !secret wifi_ssidиpassword: !secret wifi_password. - Блок UART, модель и скорости из раздела ниже вставьте в этот файл. Полный пример, если он длинный, берите по ссылке в источниках внизу страницы, а не из гайда соседней модели.
- Строки
api:иota:оставьте. Безapi:Home Assistant устройство не увидит. Ключ шифрования Dashboard предлагает сам, его потом спросит интеграция ESPHome.
Как залить первый раз
- Подключите USB–UART, как в разделе «Разборка и пины»: земля переходника на землю платы, TX переходника на RX модуля, RX переходника на TX модуля. Логика 3,3 В. Имена площадок на конкретной плате — там же.
- Для ESP8266 и ESP32 замкните GPIO0 (IO0, BOOT) на землю и в этот момент подайте питание. Для BK72xx ltchiptool сам просит перезапустить чип, обычно кратким отключением земли.
- В Dashboard: Install → Plug into this computer, выберите порт переходника. Либо Install → Manual download, сохраните
.binи откройте его на web.esphome.io. Из терминала то же самое делаетesphome run имя-устройства.yaml. - Когда в логе появится IP-адрес, GPIO0 от земли отпустите, провода можно отпаять. Следующие обновления идут по Wi-Fi, пункт OTA, паяльник больше не нужен.
Дальше — только то, чем эта модель отличается от общей схемы.
Сначала дамп штатной прошивки средствами ltchiptool. Плата и линия к MCU:
Пины RX и TX в полном файле карточки завязаны на разводку BK7231N. Берите их из исходника, вместе со стартовым скриптом для нестандартных точек. Без этого скрипта расписание и подсветка не оживут.
Базовый конфиг этой модели с карточки или из репозитория компонента. Wi-Fi, пароль и OTA задайте как в общем разделе выше. Пароли из исходника сюда не перенесены.
substitutions:
name: house-thermostat
friendly_name: House Thermostat
bk72xx:
board: generic-bk7231n-qfn32-tuya
esphome:
name: $name
friendly_name: $friendly_name
name_add_mac_suffix: false
on_boot:
priority: -200
then:
- delay: 500ms
- script.execute: tuya_kick_first #necessary for the device to start correctly
external_components:
- source: github://SaschaKP/tuya_thermostat
components: [tuyanew]
refresh: 0s
preferences:
flash_write_interval:
seconds: 60
# Enable logging
logger:
level: INFO
baud_rate: 0 #115200
# hardware_uart: UART1
globals:
- id: second_sent
type: bool
initial_value: 'false'
restore_value: false
interval:
- interval: 20min
then:
- if:
condition:
api.connected:
then:
- script.execute: push_weather
# Enable Home Assistant API
button:
- platform: restart
name: "Restart"
id: button_restart
uart:
- id: mcu_uart
tx_pin: 1
rx_pin: 0
baud_rate: 38400
parity: NONE
data_bits: 8
stop_bits: 1
rx_buffer_size: 2048
# debug:
# sequence:
# - lambda: UARTDebug::log_hex(direction, bytes, ':');
time:
- platform: homeassistant
id: ha_time
tuyanew:
id: tuya_id
uart_id: mcu_uart
# The MCU uses this for displaying the time in the display as well as for
# scheduled programmes one may have configured in their thermostat.
time_id: ha_time
on_weather_request:
then:
script.execute: push_weather
on_datapoint_update:
- sensor_datapoint: 106 # BackLight (6A)
then:
- lambda: |-
if (x.len >= 8) {
uint16_t end = (x.value_raw[0] << 8) + x.value_raw[1]; // 2 byte = total minutes
uint16_t start = (x.value_raw[2] << 8) + x.value_raw[3]; // 2 byte = total minutes
TimeEntityRestoreState current_time;
current_time.second = 0; //set this to a valid zero
current_time.hour = (uint8_t)(end / 60);
current_time.minute = (uint8_t)(end % 60);
current_time.apply(id(bl_end_time));
current_time.hour = (uint8_t)(start / 60);
current_time.minute = (uint8_t)(start % 60);
current_time.apply(id(bl_start_time));
id(bl_level_input).publish_state(x.value_raw[6]);
id(bl_auto_off_input).publish_state((x.value_raw[7] == 0x00) ? "OFF" : "ON");
ESP_LOGD("tuya_bl", "backlight setting - End %02i:%02i - Start %02i:%02i - Stanby BL %i - AutoOFF %i", (uint8_t)(end / 60), end % 60, (uint8_t)(start / 60), start % 60, x.value_raw[6], x.value_raw[7]);
}
- sensor_datapoint: 30 # Weekly Program (1E)
then:
- lambda: |-
if (x.len >= 32) {
datetime::TimeEntity* times[] = {id(p1_time), id(p2_time), id(p3_time), id(p4_time), id(p5_time), id(p6_time), id(p7_time), id(p8_time)};
number::Number* temps[] = {id(p1_temp), id(p2_temp), id(p3_temp), id(p4_temp), id(p5_temp), id(p6_temp), id(p7_temp), id(p8_temp)};
for (int i = 0; i < 32; i+=4) {
TimeEntityRestoreState current_time;
current_time.hour = x.value_raw[i];
current_time.minute = x.value_raw[i+1];
current_time.second = 0;
current_time.apply(times[i / 4]);
temps[i / 4]->publish_state(((x.value_raw[i+2] << 8) + x.value_raw[i+3]) / 10.0f);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
ESP_LOGD("tuya_week_prg", "P%i: %02i:%02i - %.1f °C", (i / 4) + 1, x.value_raw[i], x.value_raw[i+1], ((x.value_raw[i+2] << 8) + x.value_raw[i+3]) / 10.0f);
#endif
}
}
text_sensor:
- platform: homeassistant
id: ha_weather_state # Extracts weather from the forecast
entity_id: weather.forecast_casa # use your forecast entity ID in home assistant
internal: true
on_value:
then:
- script.execute: push_weather
sensor:
- platform: homeassistant
id: ha_weather_temp
entity_id: weather.forecast_casa # use your forecast entity ID in home assistant
attribute: temperature # Extracts the current temperature from the weather
internal: true
- platform: homeassistant
id: ha_weather_humidity
entity_id: weather.forecast_casa # use your forecast entity ID in home assistant
attribute: humidity # Extracts humidity from the forecast
internal: true
on_value:
then:
- script.execute: push_weather
- platform: tuyanew
id: isteresi_temp
name: Hysteresis
entity_category: diagnostic
unit_of_measurement: "°C"
sensor_datapoint: 112
accuracy_decimals: 1
icon: mdi:thermometer-check
filters:
- lambda: return x * 0.1f;
binary_sensor:
- platform: tuyanew
name: "Fault"
device_class: problem
sensor_datapoint: 45
climate:
- platform: tuyanew
name: "Thermostat"
switch_datapoint: 1
target_temperature_datapoint: 16
current_temperature_datapoint: 24
temperature_multiplier: 0.1
active_state:
datapoint: 110
visual:
min_temperature: 5 °C
max_temperature: 50 °C
temperature_step:
target_temperature: 0.5 °C
current_temperature: 0.1 °C
switch:
- platform: tuyanew
name: "Child Lock"
switch_datapoint: 40
icon: mdi:lock
- platform: tuyanew
name: "Frost Protection"
switch_datapoint: 10
icon: mdi:snowflake-melt
number:
- platform: tuyanew
name: "Temperature Calibration"
number_datapoint: 109
min_value: -9.9
max_value: 9.9
step: 0.1
entity_category: config
unit_of_measurement: "°C"
icon: mdi:thermometer-lines
multiply: 10
- platform: template
name: "BackLight MIN"
id: bl_level_input
min_value: 0
max_value: 100
step: 1
unit_of_measurement: "%"
mode: BOX
optimistic: true
entity_category: config
icon: mdi:brightness-percent
set_action:
then:
- lambda: |-
id(push_backlight_dynamic)->execute(id(bl_start_time).hour * 60 + id(bl_start_time).minute, id(bl_end_time).hour * 60 + id(bl_end_time).minute, x, (id(bl_auto_off_input).current_option() == "ON") ? 0x01 : 0x00);
- { platform: template, icon: mdi:home-thermometer, name: "P1 Temp", id: p1_temp, mode: BOX, entity_category: config, unit_of_measurement: °C, min_value: 5, max_value: 50, step: 0.5, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
- { platform: template, icon: mdi:home-thermometer, name: "P2 Temp", id: p2_temp, mode: BOX, entity_category: config, unit_of_measurement: °C, min_value: 5, max_value: 50, step: 0.5, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
- { platform: template, icon: mdi:home-thermometer, name: "P3 Temp", id: p3_temp, mode: BOX, entity_category: config, unit_of_measurement: °C, min_value: 5, max_value: 50, step: 0.5, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
- { platform: template, icon: mdi:home-thermometer, name: "P4 Temp", id: p4_temp, mode: BOX, entity_category: config, unit_of_measurement: °C, min_value: 5, max_value: 50, step: 0.5, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
- { platform: template, icon: mdi:home-thermometer, name: "P5 Temp", id: p5_temp, mode: BOX, entity_category: config, unit_of_measurement: °C, min_value: 5, max_value: 50, step: 0.5, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
- { platform: template, icon: mdi:home-thermometer, name: "P6 Temp", id: p6_temp, mode: BOX, entity_category: config, unit_of_measurement: °C, min_value: 5, max_value: 50, step: 0.5, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
- { platform: template, icon: mdi:home-thermometer, name: "P7 WE Temp", id: p7_temp, mode: BOX, entity_category: config, unit_of_measurement: °C, min_value: 5, max_value: 50, step: 0.5, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
- { platform: template, icon: mdi:home-thermometer, name: "P8 WE Temp", id: p8_temp, mode: BOX, entity_category: config, unit_of_measurement: °C, min_value: 5, max_value: 50, step: 0.5, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
select:
- platform: tuyanew
name: "Work Mode"
enum_datapoint: 2
options:
0: "Schedule"
1: "Manual"
- platform: template
name: "BackLight Auto Off"
id: bl_auto_off_input
options: ["OFF", "ON"]
optimistic: true
entity_category: config
set_action:
then:
- lambda: |-
id(push_backlight_dynamic)->execute(id(bl_start_time).hour * 60 + id(bl_start_time).minute, id(bl_end_time).hour * 60 + id(bl_end_time).minute, (uint8_t)id(bl_level_input).state, (x == "ON") ? 0x01 : 0x00);
- platform: tuyanew
name: "Weekly Type"
id: weekly_type_select
enum_datapoint: 31
entity_category: config
options:
0: "5+2"
1: "6+1"
2: "7"
icon: mdi:calendar-edit
datetime:
- platform: template
name: "BackLight OFF"
id: bl_end_time
type: time
optimistic: true
entity_category: config
set_action:
then:
- lambda: |-
id(push_backlight_dynamic)->execute(id(bl_start_time).hour * 60 + id(bl_start_time).minute, x.hour * 60 + x.minute, (uint8_t)id(bl_level_input).state, (id(bl_auto_off_input).current_option() == "ON") ? 0x01 : 0x00);
- platform: template
name: "BackLight ON"
id: bl_start_time
type: time
optimistic: true
entity_category: config
set_action:
then:
- lambda: |-
id(push_backlight_dynamic)->execute(x.hour * 60 + x.minute, id(bl_end_time).hour * 60 + id(bl_end_time).minute, (uint8_t)id(bl_level_input).state, (id(bl_auto_off_input).current_option() == "ON") ? 0x01 : 0x00);
- { platform: template, name: "P1 Wakeup", id: p1_time, entity_category: config, type: time, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
- { platform: template, name: "P2 Outside", id: p2_time, entity_category: config, type: time, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
- { platform: template, name: "P3 Pause", id: p3_time, entity_category: config, type: time, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
- { platform: template, name: "P4 Inside", id: p4_time, entity_category: config, type: time, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
- { platform: template, name: "P5 Evening", id: p5_time, entity_category: config, type: time, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
- { platform: template, name: "P6 Night", id: p6_time, entity_category: config, type: time, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
- { platform: template, name: "P7 WE Day", id: p7_time, entity_category: config, type: time, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
- { platform: template, name: "P8 WE Night", id: p8_time, entity_category: config, type: time, optimistic: true, set_action: {then: {script.execute: push_full_schedule}} }
script:
- id: tuya_kick_first
then:
- delay: 150ms
# 1. Query product info
- uart.write:
id: mcu_uart
data: [0x55,0xAA,0x00,0x01,0x00,0x00,0x00]
- delay: 150ms
# Heartbeat (state 0)
- uart.write:
id: mcu_uart
data: [0x55,0xAA,0x00,0x03,0x00,0x01,0x00,0x03]
- delay: 150ms
- uart.write:
id: mcu_uart
data: [0x55,0xAA,0x00,0x03,0x00,0x01,0x00,0x03]
- delay: 150ms
# Heartbeat (state 2)
- uart.write:
id: mcu_uart
data: [0x55,0xAA,0x00,0x03,0x00,0x01,0x02,0x05]
- id: tuya_kick_second
then:
# Heartbeat (state 3)
- uart.write:
id: mcu_uart
data: [0x55,0xAA,0x00,0x03,0x00,0x01,0x03,0x06]
- delay: 150ms
# Query all DP status
- uart.write:
id: mcu_uart
data: [0x55,0xAA,0x00,0x08,0x00,0x00,0x07]
- id: push_weather
then:
- lambda: |-
bool data_valid = true;
// Validity check of numeric and textual data
if (std::isnan(id(ha_weather_temp).state) ||
std::isnan(id(ha_weather_humidity).state) ||
id(ha_weather_state).state == "unavailable") {
data_valid = false;
}
if (!data_valid) {
// No data available, skip
return;
}
uint8_t icon_code = 2; // Default on CLOUD
std::string state = id(ha_weather_state).state;
// 0: NO ICON (Clear Night)
if (state == "clear-night" || state == "unknown") {
icon_code = 0; // NO ICON IF AT NIGHT (CLEAR) OR UNKNOWN
}
else if (state == "sunny") {
icon_code = 1; // SUN
}
else if (state == "partlycloudy" || state == "windy" || state == "windy-variant") {
icon_code = 6; // SUN AND CLOUD
}
else if (state == "cloudy" || state == "fog" || state == "haze" || state == "dust" || state == "exceptional") {
icon_code = 2; // CLOUD
}
else if (state == "rainy" || state == "pouring" || state == "lightning" || state == "lightning-rainy") {
icon_code = 3; // RAIN
}
else if (state == "snowy-rainy" || state == "hail") {
icon_code = 5; // SNOW AND RAIN
}
else if (state == "snowy") {
icon_code = 4; // SNOW
}
int16_t temp = (int)id(ha_weather_temp).state;
uint8_t hum = (int)id(ha_weather_humidity).state;
id(tuya_id).send_weather_data(icon_code, temp, hum);
- id: push_backlight_dynamic
parameters:
start_mins: uint16_t
end_mins: uint16_t
lux: uint8_t
aoff: uint8_t
mode: restart
then:
- delay: 500ms #avoid multiple sending if user changes a value within a delay of time
- lambda: |-
std::vector<uint8_t> data;
data.reserve(8);
data.push_back((uint8_t)(end_mins >> 8));
data.push_back((uint8_t)(end_mins & 0xFF));
data.push_back((uint8_t)(start_mins >> 8));
data.push_back((uint8_t)(start_mins & 0xFF));
data.push_back(0x00); //Reserved
data.push_back(0x00); //Reserved
data.push_back(lux);
data.push_back(aoff);
id(tuya_id).set_raw_datapoint_value(106, data);
ESP_LOGD("backlight", "Sync MCU: End %02d:%02d, Start %02d:%02d, Lux %d, AutoOff %d",
id(bl_end_time).hour, id(bl_end_time).minute,
id(bl_start_time).hour, id(bl_start_time).minute,
lux, aoff);
- id: push_full_schedule
mode: restart
then:
- delay: 500ms #avoid multiple sending if user changes a value within a delay of time
- lambda: |-
datetime::TimeEntity* times[] = {id(p1_time), id(p2_time), id(p3_time), id(p4_time), id(p5_time), id(p6_time), id(p7_time), id(p8_time)};
number::Number* temps[] = {id(p1_temp), id(p2_temp), id(p3_temp), id(p4_temp), id(p5_temp), id(p6_temp), id(p7_temp), id(p8_temp)};
// Total Buffer: 55 AA 00 06 (Header/Ver/Cmd) + 00 24 (Len) + 1E 00 00 20 (DP Header) + 32 byte data + Checksum
std::vector<uint8_t> data;
data.reserve(32);
for (uint8_t i = 0; i < 8; ++i) {
data.push_back((uint8_t)times[i]->hour);
data.push_back((uint8_t)times[i]->minute);
float s = temps[i]->state;
uint16_t temp = std::isnan(s) ? 200 : (uint16_t)(s * 10);
data.push_back((uint8_t)(temp >> 8));
data.push_back((uint8_t)(temp & 0xFF));
}
id(tuya_id).set_raw_datapoint_value(30, data);
ESP_LOGD("tuya_schedule", "Complete programming sent (32 byte payload)");
7. Настройка в Home Assistant
Климат ставит температуру. Недельную программу, если она нужна, сначала проверьте на одном дне. Точка 30 — не обычное число, а пакет.
8. Известные баги и как их обойти
- Скорость 9600, привычная по другим Tuya, здесь не подходит. В карточке 38400
- Product id другой, чем dq6nlukkifyawj9n: конфиг может молчать
- Прошивка от сети, не сняв термостат с 220 В, опасна и по карточке запрещена
- esptool этот чип не пишет
9. Источники
- Каталог EZAIOT/XYZOO на devices.esphome.io https://devices.esphome.io/devices/EZAIOT-XYZOO-Thermostat/
- GitHub Исходник карточки https://github.com/esphome/esphome-devices/blob/main/src/docs/devices/EZAIOT-XYZOO-Thermostat/index.md
- Документация ltchiptool https://github.com/libretiny-eu/ltchiptool
- Документация Компонент bk72xx https://esphome.io/components/bk72xx/