Примечание: IP-адреса, device ID, product ID, MQTT-логины, ключи и имена хостов в статье заменены на условные. Методика сохранена, но при повторении нужно подставлять свои значения.
У меня есть настенный кондиционер Royal Clima Pandora RC-PDC22HN. В родном приложении SmartLife у него доступно довольно много функций, но в Home Assistant через стандартную Tuya-интеграцию виден только базовый набор: включить, выключить, изменить температуру и режим. Для обычного сценария этого достаточно, но в реальной автоматизации быстро захотелось большего: управлять дисплеем на внутреннем блоке, отключать звуковое подтверждение, видеть сырые параметры устройства и не зависеть от облачного профиля Tuya.
Перепрошивать Wi-Fi-модуль в кондиционере я не стал. Вместо этого пошёл более консервативным путём: прочитал локальные Tuya DPS через TinyTuya, аккуратно определил назначение нужных параметров и собрал небольшой MQTT-мост в Docker. Home Assistant в этой схеме видит кондиционер как обычное MQTT-устройство через MQTT Discovery.
Статья не про взлом криптографии Tuya и не про написание клиента протокола с нуля. Эту часть уже берёт на себя TinyTuya. Здесь речь о практическом reverse engineering на уровне смыслов: какой datapoint за что отвечает именно в этом кондиционере, какие значения безопасно читать, какие можно менять и как не потерять соседние флаги при записи.
Что получилось в итоге
После настройки в Home Assistant появились сущности:
climate.royal_clima_pandora switch.royal_clima_power switch.royal_clima_display switch.royal_clima_buzzer sensor.royal_clima_humidity sensor.royal_clima_dp123 sensor.royal_clima_raw_dps
Проверенные функции работают локально: питание, режимы cool, heat, dry, fan_only, auto, установка температуры, скорость вентилятора, включение и выключение дисплея, включение и выключение звукового сигнала. Отдельно публикуются сырые DPS, чтобы позже можно было исследовать дополнительные функции без переписывания всей интеграции.
Архитектура получилась такой:
Royal Clima / Tuya Wi-Fi module │ │ local Tuya protocol 3.5 ▼ Python bridge в Docker │ │ MQTT ▼ Mosquitto │ │ MQTT Discovery ▼ Home Assistant
Схема не требует custom integration для Home Assistant. Мост работает отдельным контейнером рядом с Home Assistant и Mosquitto, читает состояние кондиционера через локальную сеть и публикует команды/состояния в MQTT.
Стенд
Для воспроизводимости дальше используются условные значения:
Компонент | Значение |
|---|---|
Модель кондиционера | Royal Clima Pandora RC-PDC22HN |
Хост Home Assistant |
|
IP хоста Home Assistant |
|
IP кондиционера |
|
Локальный протокол Tuya |
|
Device ID |
|
Product ID |
|
MQTT-брокер | Mosquitto в Docker |
MQTT-пользователь |
|
Каталог проекта |
|
Из инструментов понадобились TinyTuya, Mosquitto, Python-клиент paho-mqtt, Docker Compose и встроенный механизм Home Assistant MQTT Discovery.
Почему стандартной Tuya-интеграции оказалось мало
Tuya-устройства описывают свои функции через DPS — datapoints. Это не человекочитаемые имена вроде display или buzzer, а числовые идентификаторы: 1, 2, 3, 123 и так далее. Значение DP может быть boolean, integer, enum, строкой или служебной JSON-подобной строкой.
Home Assistant через облачную Tuya-интеграцию обычно опирается на описание устройства, полученное из Tuya Cloud. Такое описание может быть неполным. В моём случае в облачной карте были видны только базовые параметры:
1 switch 2 temp_set 3 temp_current 4 mode 18 humidity_current 19 temp_unit_convert
Локальный опрос показал, что устройство отдаёт больше данных:
{ "dps": { "1": false, "2": 210, "3": 27, "4": "cold", "5": "low", "18": 0, "19": "c", "20": 0, "23": 81, "24": 700, "101": 0, "102": "off", "103": false, "105": "off", "110": 567, "120": "off", "123": "0008", "125": "great", "130": 26, "131": false, "132": false, "134": "{\"t\":1784217961,\"s\":false,\"clr\":false}" } }
Именно этот разрыв между облачным описанием и локальным состоянием стал причиной делать свой мост.
Получение local_key
Для локального управления Tuya-устройством нужны три вещи: IP-адрес устройства, device ID и local_key. IP и device ID можно увидеть через локальное сканирование TinyTuya, а ключ обычно получают через Tuya IoT project и TinyTuya wizard.
Пример результата локального сканирования:
TinyTuya device scan Address = 192.168.10.50 Device ID = bf1234567890abcdef1234 Product ID = kloexamplepandora Version = 3.5
После wizard появляется файл devices.json. В реальном файле будет настоящий local_key, поэтому его не стоит хранить в публичном репозитории и тем более публиковать в статье. В проекте я положил его отдельно от кода:
/opt/royal-clima/data/devices.json
Права на файл лучше сразу закрыть:
sudo chown root:root /opt/royal-clima/data/devices.json sudo chmod 600 /opt/royal-clima/data/devices.json
Минимальный вид записи в devices.json:
[ { "name": "Air Conditioner", "id": "bf1234567890abcdef1234", "key": "0123456789abcdef", "mac": "AA:BB:CC:DD:EE:FF", "category": "kt", "product_name": "Air conditioner", "product_id": "kloexamplepandora" } ]
Первый локальный опрос
Перед Docker и MQTT важно проверить самый простой сценарий: устройство вообще читается локально. Я использовал отдельный маленький скрипт read_status.py.
import json import tinytuya DEVICE_ID = "bf1234567890abcdef1234" AC_IP = "192.168.10.50" AC_VERSION = 3.5 DEVICES_JSON = "devices.json" with open(DEVICES_JSON, "r", encoding="utf-8") as file: devices = json.load(file) info = next(item for item in devices if item["id"] == DEVICE_ID) local_key = info["key"] device = tinytuya.Device(DEVICE_ID, AC_IP, local_key) device.set_version(AC_VERSION) device.set_socketTimeout(10) status = device.status() print(json.dumps(status, indent=2, ensure_ascii=False))
Запускал его в отдельном виртуальном окружении:
python3 -m venv .venv source .venv/bin/activate pip install tinytuya==1.20.0 python read_status.py
Если в ответе есть объект dps, можно двигаться дальше. Если появляется ошибка расшифровки, в первую очередь стоит проверить local_key и версию протокола. Если скрипт уходит в timeout, надо проверять сетевую доступность устройства, firewall и порт 6668.
ping 192.168.10.50 nc -vz 192.168.10.50 6668
Как я искал назначение DPS
На этом этапе легко испортить устройство лишними экспериментами, поэтому я придерживался простого правила: сначала только чтение, затем изменение одной функции за раз, потом повторное чтение и физическая проверка результата.
Для наблюдения использовался watcher. Он сравнивает предыдущее состояние с новым и печатает только изменившиеся DP. Служебный DP 134 исключён, потому что он менялся постоянно и только мешал анализу.
import json import time import tinytuya DEVICE_ID = "bf1234567890abcdef1234" AC_IP = "192.168.10.50" AC_VERSION = 3.5 DEVICES_JSON = "devices.json" IGNORE_DPS = {"134"} with open(DEVICES_JSON, "r", encoding="utf-8") as file: devices = json.load(file) local_key = next(item for item in devices if item["id"] == DEVICE_ID)["key"] device = tinytuya.Device(DEVICE_ID, AC_IP, local_key) device.set_version(AC_VERSION) device.set_socketTimeout(10) previous = {} while True: dps = device.status().get("dps", {}) for dp, value in dps.items(): if dp in IGNORE_DPS: continue if previous.get(dp) != value: print(f"DP {dp}: {previous.get(dp)!r} -> {value!r}") previous = dps time.sleep(1)
Дальше я открывал SmartLife или брал пульт и менял только одну функцию за раз: Display, Buzzer, режим работы, температуру, скорость вентилятора. Такой подход медленнее, чем хаотично нажимать всё подряд, зато он даёт чистые диффы.
Подтверждённая карта DP
После нескольких циклов наблюдения получилась такая карта:
DP | Пример значения | Назначение | Статус |
|---|---|---|---|
|
| питание | подтверждено |
|
| заданная температура, | подтверждено |
|
| текущая температура, °C | подтверждено |
|
| режим работы | подтверждено |
|
| скорость вентилятора | подтверждено |
|
| влажность | читается, польза сомнительная |
|
| единицы температуры | понятно, но обычно не трогаю |
|
| текущая температура в °F | дубль DP3 |
|
| заданная температура в °F × 10 | дубль DP2 |
|
| флаговая маска Display/Buzzer | подтверждено |
| JSON timestamp | heartbeat | игнорируется |
Самым интересным оказался DP123. Он не был отдельным переключателем. В нём одновременно лежали несколько флагов.
DP123: один datapoint, несколько флагов
При переключении дисплея и звукового подтверждения watcher показывал такие изменения:
DP 123: '0000' -> '0008' DP 123: '0008' -> '0000' DP 123: '0000' -> '0010' DP 123: '0010' -> '0000'
После проверки на самом кондиционере карта стала понятной:
DP123 | Display | Buzzer |
|---|---|---|
| OFF | OFF |
| ON | OFF |
| OFF | ON |
| ON | ON |
То есть значения 0008 и 0010 — не самостоятельные состояния, а биты одной маски:
DISPLAY_MASK = 0x0008 BUZZER_MASK = 0x0010
Из-за этого нельзя просто записать в DP123 новое значение вслепую. Если включить дисплей записью 0008, можно случайно выключить уже включённый Buzzer. Правильная операция выглядит иначе: прочитать текущее значение, изменить один бит и записать результат обратно.
def update_flag(raw_value, mask, enabled): current = int(raw_value, 16) if enabled: updated = current | mask else: updated = current & ~mask return f"{updated:04X}"
Например, если текущее значение 0010, а нужно включить Display, результатом будет 0018, а не 0008.
Структура проекта
После проверки DPS я перенёс логику в отдельный Docker-сервис. Структура проекта минимальная:
/opt/royal-clima/ ├── app/ │ ├── Dockerfile │ └── royal_clima_bridge.py ├── data/ │ └── devices.json ├── .env └── docker-compose.yml
Файл .env содержит параметры устройства и MQTT. В нём нет ничего сложного, но есть пароль MQTT, поэтому права на него тоже лучше закрыть.
sudo chown root:root /opt/royal-clima/.env sudo chmod 600 /opt/royal-clima/.env
Пример .env:
TZ=Europe/Moscow AC_DEVICE_ID=bf1234567890abcdef1234 AC_IP=192.168.10.50 AC_VERSION=3.5 DEVICES_JSON=/data/devices.json MQTT_HOST=127.0.0.1 MQTT_PORT=1883 MQTT_USER=ac_bridge MQTT_PASSWORD=CHANGE_ME_STRONG_PASSWORD MQTT_BASE_TOPIC=royal_clima/pandora HA_DISCOVERY_PREFIX=homeassistant POLL_INTERVAL=10 DISPLAY_MASK=0x0008 BUZZER_MASK=0x0010
docker-compose.yml:
version: "3.8" services: royal_clima_bridge: build: context: ./app container_name: royal_clima_bridge restart: unless-stopped network_mode: host env_file: - .env volumes: - ./data:/data:ro
Dockerfile:
FROM python:3.12-slim WORKDIR /app RUN pip install --no-cache-dir tinytuya==1.20.0 paho-mqtt==2.1.0 COPY royal_clima_bridge.py /app/royal_clima_bridge.py CMD ["python", "/app/royal_clima_bridge.py"]
Mosquitto и отдельный пользователь для моста
Mosquitto у меня уже работал рядом с Home Assistant. Для моста я сделал отдельного MQTT-пользователя, чтобы не использовать общий административный пароль.
Фрагмент конфигурации Mosquitto:
listener 1883 allow_anonymous false password_file /mosquitto/config/mqttuser
Создание пользователя:
sudo docker exec -it mosquitto \ mosquitto_passwd /mosquitto/config/mqttuser ac_bridge sudo docker restart mosquitto
Здесь намеренно нет ключа -c. Он нужен только при создании нового password-файла. Если файл уже существует, -c может перезаписать базу пользователей.
Проверка публикации:
sudo docker exec mosquitto mosquitto_pub \ -h 127.0.0.1 \ -u ac_bridge \ -P 'CHANGE_ME_STRONG_PASSWORD' \ -t test/royal_clima \ -m 'mqtt_auth_test'
Ключевые фрагменты MQTT-моста
Полный файл моста довольно длинный, поэтому в статье лучше оставить только важные части. Ниже — логика, которая определяет поведение интеграции.
Сначала задаются основные DP и соответствие режимов Tuya режимам Home Assistant:
DP_POWER = 1 DP_TARGET_TEMP = 2 DP_CURRENT_TEMP = 3 DP_MODE = 4 DP_FAN = 5 DP_HUMIDITY = 18 DP_FLAGS = 123 TUYA_TO_HA_MODE = { "cold": "cool", "hot": "heat", "wet": "dry", "wind": "fan_only", "auto": "auto", } HA_TO_TUYA_MODE = { "cool": "cold", "heat": "hot", "dry": "wet", "fan_only": "wind", "auto": "auto", }
Отдельно описываются скорости вентилятора. На моём устройстве средняя скорость приходила как middle, а в Home Assistant удобнее использовать medium.
TUYA_TO_HA_FAN = { "auto": "auto", "low": "low", "middle": "medium", "high": "high", } HA_TO_TUYA_FAN = { "auto": "auto", "low": "low", "medium": "middle", "high": "high", }
Работа с DP123 вынесена в отдельную функцию. Это уменьшает риск случайно затереть соседний бит.
def set_flag(flag_name, enabled): dps = read_dps() old_raw = str(dps.get(str(DP_FLAGS), "0000")) old_value = int(old_raw, 16) mask = FLAGS[flag_name] if enabled: new_value = old_value | mask else: new_value = old_value & ~mask new_raw = f"{new_value:04X}" if new_raw != old_raw: set_dp(DP_FLAGS, new_raw) publish_current_state()
Обработчик MQTT-команд разделяет обычные команды и флаговые команды:
def on_message(client, userdata, message): topic = message.topic payload = message.payload.decode().strip() if topic.endswith("/power/set"): set_dp(DP_POWER, payload.upper() == "ON") elif topic.endswith("/temperature/set"): set_dp(DP_TARGET_TEMP, int(float(payload) * 10)) elif topic.endswith("/mode/set"): if payload == "off": set_dp(DP_POWER, False) else: set_dp(DP_POWER, True) set_dp(DP_MODE, HA_TO_TUYA_MODE[payload]) elif topic.endswith("/display/set"): set_flag("display", payload.upper() == "ON") elif topic.endswith("/buzzer/set"): set_flag("buzzer", payload.upper() == "ON")
MQTT Discovery для Home Assistant
Чтобы не добавлять сущности вручную в configuration.yaml, мост при старте публикует retained discovery-конфигурацию. Например, climate-сущность описывается так:
climate_payload = { "name": "Royal Clima Pandora", "unique_id": "royal_clima_pandora_climate", "object_id": "royal_clima_pandora", "modes": ["off", "cool", "heat", "dry", "fan_only", "auto"], "mode_state_topic": "royal_clima/pandora/mode/state", "mode_command_topic": "royal_clima/pandora/mode/set", "temperature_state_topic": "royal_clima/pandora/temperature/state", "temperature_command_topic": "royal_clima/pandora/temperature/set", "current_temperature_topic": "royal_clima/pandora/current_temperature/state", "fan_mode_state_topic": "royal_clima/pandora/fan/state", "fan_mode_command_topic": "royal_clima/pandora/fan/set", "fan_modes": ["auto", "low", "medium", "high"], "min_temp": 16, "max_temp": 31, "temp_step": 0.5, "precision": 0.1, "temperature_unit": "C", "availability_topic": "royal_clima/pandora/availability", "device": DEVICE_INFO, }
Публикуется это в discovery topic:
homeassistant/climate/royal_clima_pandora/config
Для Display, Buzzer и Power используются обычные MQTT switches. В результате Home Assistant сам создаёт устройство и сущности после перезапуска моста или брокера.
Запуск контейнера
Сборка и запуск:
sudo bash -lc 'cd /opt/royal-clima && docker compose build' sudo bash -lc 'cd /opt/royal-clima && docker compose up -d'
Проверка логов:
sudo docker logs --tail=100 -f royal_clima_bridge
Нормальный старт выглядит примерно так:
2026-07-16 20:00:55 [INFO] Connecting to MQTT 127.0.0.1:1883 2026-07-16 20:00:55 [INFO] Connected to MQTT: rc=0 2026-07-16 20:00:55 [INFO] MQTT Discovery published 2026-07-16 20:00:55 [INFO] Published state: power=False mode=off target=21.0 current=27.0 fan=low DP123=0008
Если в логах есть предупреждение Paho о deprecated Callback API, это не обязательно блокирует работу. Позже код можно обновить под новый callback API, но для проверки самой схемы это не критично.
Проверка MQTT
Сначала я проверял не интерфейс Home Assistant, а сами MQTT-топики. Так проще понять, где именно ошибка: в кондиционере, мосте, брокере или discovery.
sudo bash -lc ' set -a . /opt/royal-clima/.env set +a docker exec mosquitto mosquitto_sub \ -h 127.0.0.1 \ -u "$MQTT_USER" \ -P "$MQTT_PASSWORD" \ -t "royal_clima/pandora/#" \ -v \ -C 20 '
Пример ожидаемого вывода:
royal_clima/pandora/availability online royal_clima/pandora/power/state OFF royal_clima/pandora/mode/state off royal_clima/pandora/temperature/state 21.0 royal_clima/pandora/current_temperature/state 27.0 royal_clima/pandora/fan/state low royal_clima/pandora/display/state ON royal_clima/pandora/buzzer/state OFF royal_clima/pandora/raw/123/state 0008
Отдельная команда для проверки дисплея:
sudo bash -lc ' set -a . /opt/royal-clima/.env set +a docker exec mosquitto mosquitto_pub \ -h 127.0.0.1 \ -u "$MQTT_USER" \ -P "$MQTT_PASSWORD" \ -t "royal_clima/pandora/display/set" \ -m "ON" '
В логе моста должно быть видно изменение DP123:
MQTT command: royal_clima/pandora/display/set = ON Set flag display=True: DP123 0000 -> 0008 Set DP 123 = '0008' Device response: ... '123': '0008'
Проверка в Home Assistant
После публикации MQTT Discovery в Home Assistant появилось устройство Royal Clima Pandora. В карточке climate отображается текущая температура и заданная температура, а рядом доступны отдельные переключатели Power, Display и Buzzer.
Проверку я делал последовательно. Сначала включение и выключение питания, затем установка температуры, потом режимы, скорость вентилятора и только после этого DP123-функции. Такой порядок полезен потому, что при ошибке сразу понятно, какой слой сломался.
Порядок проверки:
Power ON/OFF Temperature 21 → 22 °C Mode cool → dry → fan_only Fan low → medium → high Display ON/OFF Buzzer ON/OFF
Для температуры ожидаемая запись такая: если в интерфейсе выбрано 22 °C, в DP2 уходит 220, потому что у параметра scale = 1. Для режима нужен перевод значений: cool в Home Assistant соответствует cold у Tuya, dry соответствует wet, а fan_only соответствует wind.
Что делать с остальными DP
После того как базовая схема заработала, остаётся соблазн быстро превратить все неизвестные DP в переключатели. Я бы так не делал. В кондиционере могут быть не только пользовательские функции, но и служебные параметры.
Безопасная стратегия такая: все неизвестные DP сначала публикуются как diagnostic sensors, затем наблюдаются при переключении одной функции, после чего подтверждаются физическим поведением. Только после этого их можно превращать в switch, select или number.
Кандидаты для дальнейшего исследования:
DP | Пример | Возможный смысл |
|---|---|---|
|
| дополнительный режим |
|
| boolean-функция |
|
| дополнительный режим |
|
| дополнительный режим |
|
| диагностический статус |
|
| внутренний параметр или температура |
|
| boolean-функция |
|
| boolean-функция |
Для исследования удобно подписаться на сырые DPS:
sudo bash -lc ' set -a . /opt/royal-clima/.env set +a docker exec mosquitto mosquitto_sub \ -h 127.0.0.1 \ -u "$MQTT_USER" \ -P "$MQTT_PASSWORD" \ -t "royal_clima/pandora/raw/dps/attributes" \ -v '
И дальше вести таблицу наблюдений:
Функция | Было | Стало | Вывод |
|---|---|---|---|
Sleep |
|
| проверить |
Turbo |
|
| проверить |
Eco |
|
| проверить |
Swing vertical |
|
| проверить |
Self Clean |
|
| проверить |
Health/Ion |
|
| проверить |
Где помог ИИ
В этой задаче ИИ не угадывал назначение DP и не заменял эксперимент. Он был полезен как инженерный ассистент: помогал формулировать гипотезы, собирать маленькие проверочные скрипты, сравнивать логи и не раздувать мост раньше времени.
Рабочий процесс был таким: сначала фиксируется фактический вывод d.status(), затем выбирается одна гипотеза, затем пишется короткий проверочный скрипт. После этого меняется только одна функция кондиционера, сравнивается дифф и проверяется физический результат. Если в логе состояние изменилось, но кондиционер никак не отреагировал, гипотеза не считается подтверждённой.
Такой подход особенно помог с DP123. Если смотреть только на отдельные значения 0008 и 0010, можно решить, что это разные режимы. Но при сравнении нескольких переходов стало видно, что это именно битовая маска.
Подводные камни
Несколько клиентов TinyTuya одновременно. На время экспериментов лучше остановить лишние watcher-скрипты. Одновременный опрос устройства несколькими клиентами может давать нестабильные ответы.
Смена local_key. Если удалить устройство из SmartLife и привязать заново, ключ может измениться. После этого старый devices.json перестанет работать.
DP134 мешает анализу. Этот DP менялся постоянно и выглядел как heartbeat с timestamp. Для диффов его лучше исключать.
Неизвестные DP нельзя трогать вслепую. Запись в неподтверждённый DP может включить не пользовательскую функцию, а служебный режим.
MQTT retain важен. Discovery config и последние состояния лучше публиковать retained, чтобы Home Assistant корректно переживал перезапуск брокера или моста.
Блокировку облака лучше отложить. После нескольких дней стабильной локальной работы можно закрыть кондиционеру доступ в WAN через firewall, оставив доступ внутри LAN. Но делать это первым шагом не стоит: сначала нужно убедиться, что локальная схема действительно стабильна.
Выводы
Главный результат этой работы — не сам Python-скрипт, а методика. Если стандартная Tuya-интеграция Home Assistant показывает только базовые функции, это не значит, что остальные функции недоступны локально. Часто устройство отдаёт больше DPS, чем описывает облачный профиль, но эти параметры нужно аккуратно найти и подтвердить.
Для Royal Clima Pandora хватило нескольких шагов: получить local_key, прочитать локальные DPS, отделить служебный heartbeat от пользовательских параметров, определить битовую маску DP123, собрать тонкий MQTT-мост и опубликовать сущности через MQTT Discovery.
В результате штатный Wi-Fi-модуль остался на месте, SmartLife можно оставить как резервный канал, а Home Assistant получил локальное управление нужными функциями кондиционера.
Ссылки
Home Assistant MQTT integration: https://www.home-assistant.io/integrations/mqtt/
Home Assistant MQTT climate: https://www.home-assistant.io/integrations/climate.mqtt/
TinyTuya: https://github.com/jasonacox/tinytuya
Eclipse Mosquitto authentication methods: https://mosquitto.org/documentation/authentication-methods/
Mosquitto configuration manual: https://mosquitto.org/man/mosquitto-conf-5.html
Eclipse Paho MQTT Python client: https://eclipse.dev/paho/files/paho.mqtt.python/html/
