Reverse engineering внутренней шины панели управления, собственный компонент для ESPHome и интеграция в Home Assistant.
Введение
Стоит у меня старая посудомоечная машина Gorenje GV 51211. Работает исправно, но возраст у неё уже такой, что морально я давно готов к тому, что однажды она просто скажет: «На этом всё». В качестве умного дома я использую систему Home Assistant и возникла мысль: а почему бы не подключить к системе и посудомойку?
Удалённо управлять посудомоечной машиной я не собирался. Меня интересовал исключительно мониторинг:
-
какая программа выбрана;
-
включена ли отсрочка старта;
-
активирован ли режим половинной загрузки;
-
достаточно ли соли;
-
идёт ли сейчас мойка;
-
сколько примерно времени осталось до завершения.
Готового решения для моей посудомоечной машины Gorenje GV 51211. я не нашёл, поэтому решил разобраться с внутренним протоколом панели управления и прочитать её состояние напрямую.
Важное предупреждение
⚠️ Внутри бытовой техники присутствует сетевое напряжение 230 В.
Все работы выполняются на ваш страх и риск. Этот проект предназначен только для пассивного считывания низковольтной шины панели управления и не вмешивается в управление машиной.
Что было нужно
Аппаратная часть
-
Wemos D1 mini (ESP8266)
-
Преобразователь уровней 5V → 3.3V
-
Конденсатор 1000 мкФ
-
Несколько проводов
-
Паяльник
Программная часть
-
ESPHome
-
Home Assistant
Разборка и поиск интерфейса

После разборки передней панели я обнаружил плату пользовательского интерфейса. На неё приходил шлейф с пятью контактами:
-
5V
-
GND
-
DIO
-
CLK
-
STB
Такой набор сигналов похож на интерфейс драйверов типа TM1638, которые используются для управления светодиодными индикаторами и чтения клавиатуры.
Подключение Wemos d1 mini
Подключение получилось следующим:
|
Плата посудомойки |
Wemos D1 mini |
|---|---|
|
5V |
5V |
|
GND |
GND |
|
DIO |
D5 |
|
CLK |
D6 |
|
STB |
D7 |
Сигнальные линии были подключены через преобразователь уровней 5В-3.3В, а на питание установлен конденсатор 6.3В 1000 мкФ. Питание взял со шлейфа
Первые попытки
Сначала я попробовал использовать стандартные binary_sensor в ESPHome для отслеживания фронтов сигналов.
Это не сработало: поток данных оказался слишком быстрым, часть битов терялась, а лог заполнялся хаотичными пакетами.
Пришлось написать собственный внешний компонент для ESPHome с использованием аппаратных прерываний attachInterrupt().
Сниффинг протокола
Полный код включает:
-
washmashine.yaml -
components/tm1638_sniffer/__init__.py -
components/tm1638_sniffer/tm1638_sniffer.h -
components/tm1638_sniffer/tm1638_sniffer.cpp
__init__.py
import esphome.codegen as cgimport esphome.config_validation as cvfrom esphome.components import text_sensor, binary_sensor, sensorfrom esphome.const import CONF_ID, UNIT_MINUTE, ICON_TIMERCONF_PROGRAM = "program"CONF_DELAY_TIMER = "delay_timer"CONF_POWER = "power"CONF_SALT_MISSING = "salt_missing"CONF_HALF_LOAD = "half_load"CONF_RUNNING = "running"CONF_REMAINING_MINUTES = "remaining_minutes"AUTO_LOAD = ["text_sensor", "binary_sensor", "sensor"]CODEOWNERS = [""]tm1638_sniffer_ns = cg.esphome_ns.namespace("tm1638_sniffer")TM1638Sniffer = tm1638_sniffer_ns.class_("TM1638Sniffer", cg.Component)CONFIG_SCHEMA = cv.Schema({ cv.GenerateID(): cv.declare_id(TM1638Sniffer), cv.Optional(CONF_PROGRAM): text_sensor.text_sensor_schema(), cv.Optional(CONF_DELAY_TIMER): text_sensor.text_sensor_schema(), cv.Optional(CONF_POWER): binary_sensor.binary_sensor_schema(), cv.Optional(CONF_SALT_MISSING): binary_sensor.binary_sensor_schema(), cv.Optional(CONF_HALF_LOAD): binary_sensor.binary_sensor_schema(), cv.Optional(CONF_RUNNING): binary_sensor.binary_sensor_schema(), cv.Optional(CONF_REMAINING_MINUTES): sensor.sensor_schema( unit_of_measurement=UNIT_MINUTE, icon=ICON_TIMER, accuracy_decimals=0, ),}).extend(cv.COMPONENT_SCHEMA)async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if CONF_PROGRAM in config: sens = await text_sensor.new_text_sensor(config[CONF_PROGRAM]) cg.add(var.set_program_sensor(sens)) if CONF_DELAY_TIMER in config: sens = await text_sensor.new_text_sensor(config[CONF_DELAY_TIMER]) cg.add(var.set_delay_timer_sensor(sens)) if CONF_POWER in config: sens = await binary_sensor.new_binary_sensor(config[CONF_POWER]) cg.add(var.set_power_sensor(sens)) if CONF_SALT_MISSING in config: sens = await binary_sensor.new_binary_sensor(config[CONF_SALT_MISSING]) cg.add(var.set_salt_missing_sensor(sens)) if CONF_HALF_LOAD in config: sens = await binary_sensor.new_binary_sensor(config[CONF_HALF_LOAD]) cg.add(var.set_half_load_sensor(sens)) if CONF_RUNNING in config: sens = await binary_sensor.new_binary_sensor(config[CONF_RUNNING]) cg.add(var.set_running_sensor(sens)) if CONF_REMAINING_MINUTES in config: sens = await sensor.new_sensor(config[CONF_REMAINING_MINUTES]) cg.add(var.set_remaining_minutes_sensor(sens))
tm1638_sniffer.cpp
#include "tm1638_sniffer.h"#include "esphome/core/log.h"#include <Arduino.h>namespace esphome {namespace tm1638_sniffer {static const char *const TAG = "tm1638_sniffer";#define PIN_DIO D5#define PIN_CLK D6#define PIN_STB D7volatile bool TM1638Sniffer::active_ = false;volatile uint8_t TM1638Sniffer::bit_count_ = 0;volatile uint8_t TM1638Sniffer::cur_byte_ = 0;volatile uint8_t TM1638Sniffer::buf_[128];volatile uint8_t TM1638Sniffer::len_ = 0;volatile bool TM1638Sniffer::ready_ = false;uint8_t TM1638Sniffer::last_buf_[14];uint8_t TM1638Sniffer::last_len_ = 0;void TM1638Sniffer::setup() { pinMode(PIN_DIO, INPUT_PULLUP); pinMode(PIN_CLK, INPUT_PULLUP); pinMode(PIN_STB, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(PIN_STB), TM1638Sniffer::isr_stb, CHANGE); attachInterrupt(digitalPinToInterrupt(PIN_CLK), TM1638Sniffer::isr_clk, RISING); ESP_LOGI(TAG, "TM1638 dishwasher sniffer started");}void TM1638Sniffer::loop() { if (!ready_) return; noInterrupts(); uint8_t local_len = len_; if (local_len < 4 || local_len > 16) { ready_ = false; interrupts(); return; } uint8_t local_buf[128]; for (uint8_t i = 0; i < local_len; i++) { local_buf[i] = buf_[i]; } ready_ = false; interrupts(); if (local_buf[0] != 0xC0) return; if (local_len < 14) return; local_len = 14; bool same = last_len_ == local_len; if (same) { for (uint8_t i = 0; i < local_len; i++) { if (local_buf[i] != last_buf_[i]) { same = false; break; } } } if (!same) { last_len_ = local_len; for (uint8_t i = 0; i < local_len; i++) { last_buf_[i] = local_buf[i]; } std::string out = "display changed:"; char tmp[8]; for (uint8_t i = 0; i < local_len; i++) { snprintf(tmp, sizeof(tmp), " %02X", local_buf[i]); out += tmp; } ESP_LOGD(TAG, "%s", out.c_str()); decode_packet_(local_buf, local_len); } else { // Даже если дисплей не менялся, таймер остатка нужно обновлять примерно раз в минуту. static uint32_t last_timer_update_ms = 0; uint32_t now = millis(); if (was_running_ && selected_duration_min_ > 0 && now - last_timer_update_ms > 60000) { last_timer_update_ms = now; uint32_t elapsed_min = (now - started_at_ms_) / 60000; int remaining = selected_duration_min_ - elapsed_min; if (remaining < 0) remaining = 0; if (remaining_minutes_sensor_ != nullptr) { remaining_minutes_sensor_->publish_state(remaining); } } }}int TM1638Sniffer::program_duration_minutes_(const std::string &program) { if (program == "Эко") return 175; if (program == "Деликатная") return 110; if (program == "90 мин") return 90; if (program == "Быстрая") return 40; if (program == "Интенсивная") return 130; if (program == "Стандартная") return 155; return 0;}void TM1638Sniffer::decode_packet_(uint8_t *d, uint8_t len) { if (len < 14) return; bool power = d[2] & 0x01; bool salt_missing = d[6] & 0x01; bool half_load = d[8] & 0x01; bool any_program_led = (d[6] & 0x02) || (d[8] & 0x02) || (d[10] & 0x02) || (d[12] & 0x02) || (d[2] & 0x02) || (d[4] & 0x02); std::string program = "Не выбрана"; if (d[6] & 0x02) { program = "Эко"; } else if (d[8] & 0x02) { program = "Деликатная"; } else if (d[10] & 0x02) { program = "90 мин"; } else if (d[12] & 0x02) { program = "Быстрая"; } else if (d[2] & 0x02) { program = "Интенсивная"; } else if (d[4] & 0x02) { program = "Стандартная"; } if (any_program_led) { program_was_selected_ = true; selected_program_ = program; selected_duration_min_ = program_duration_minutes_(program); } bool running = power && program_was_selected_ && !any_program_led; if (!power) { program_was_selected_ = false; was_running_ = false; started_at_ms_ = 0; selected_duration_min_ = 0; selected_program_ = "Не выбрана"; } if (running && !was_running_) { was_running_ = true; started_at_ms_ = millis(); if (selected_duration_min_ == 0) { selected_duration_min_ = program_duration_minutes_(selected_program_); } ESP_LOGI(TAG, "Dishwasher started: program=%s duration=%d min", selected_program_.c_str(), selected_duration_min_); } if (!running && was_running_) { was_running_ = false; ESP_LOGI(TAG, "Dishwasher stopped or finished"); } int remaining = 0; if (running && selected_duration_min_ > 0) { uint32_t elapsed_min = (millis() - started_at_ms_) / 60000; remaining = selected_duration_min_ - elapsed_min; if (remaining < 0) remaining = 0; } std::string delay = "Выключен"; if (d[1] & 0x80) { delay = "3 часа"; } else if (d[3] & 0x80) { delay = "6 часов"; } else if (d[5] & 0x80) { delay = "9 часов"; } else if (d[7] & 0x80) { delay = "12 часов"; } if (program_sensor_ != nullptr) { program_sensor_->publish_state(any_program_led ? program : selected_program_); } if (delay_timer_sensor_ != nullptr) { delay_timer_sensor_->publish_state(delay); } if (power_sensor_ != nullptr) { power_sensor_->publish_state(power); } if (salt_missing_sensor_ != nullptr) { salt_missing_sensor_->publish_state(salt_missing); } if (half_load_sensor_ != nullptr) { half_load_sensor_->publish_state(half_load); } if (running_sensor_ != nullptr) { running_sensor_->publish_state(running); } if (remaining_minutes_sensor_ != nullptr) { remaining_minutes_sensor_->publish_state(remaining); } ESP_LOGD(TAG, "program=%s selected_program=%s delay=%s power=%s salt_missing=%s half_load=%s running=%s remaining=%d", program.c_str(), selected_program_.c_str(), delay.c_str(), power ? "ON" : "OFF", salt_missing ? "ON" : "OFF", half_load ? "ON" : "OFF", running ? "ON" : "OFF", remaining);}void ICACHE_RAM_ATTR TM1638Sniffer::isr_stb() { bool stb = digitalRead(PIN_STB); if (!stb) { active_ = true; bit_count_ = 0; cur_byte_ = 0; len_ = 0; } else { active_ = false; ready_ = true; }}void ICACHE_RAM_ATTR TM1638Sniffer::isr_clk() { if (!active_) return; if (len_ >= sizeof(buf_)) return; uint8_t bit = digitalRead(PIN_DIO) ? 1 : 0; cur_byte_ |= bit << bit_count_; bit_count_++; if (bit_count_ == 8) { buf_[len_++] = cur_byte_; cur_byte_ = 0; bit_count_ = 0; }}} // namespace tm1638_sniffer} // namespace esphome
tm1638_sniffer.h
#pragma once#include "esphome/core/component.h"#include "esphome/components/text_sensor/text_sensor.h"#include "esphome/components/binary_sensor/binary_sensor.h"#include "esphome/components/sensor/sensor.h"#include <Arduino.h>namespace esphome {namespace tm1638_sniffer {class TM1638Sniffer : public Component { public: void setup() override; void loop() override; void set_program_sensor(text_sensor::TextSensor *sensor) { program_sensor_ = sensor; } void set_delay_timer_sensor(text_sensor::TextSensor *sensor) { delay_timer_sensor_ = sensor; } void set_power_sensor(binary_sensor::BinarySensor *sensor) { power_sensor_ = sensor; } void set_salt_missing_sensor(binary_sensor::BinarySensor *sensor) { salt_missing_sensor_ = sensor; } void set_half_load_sensor(binary_sensor::BinarySensor *sensor) { half_load_sensor_ = sensor; } void set_running_sensor(binary_sensor::BinarySensor *sensor) { running_sensor_ = sensor; } void set_remaining_minutes_sensor(sensor::Sensor *sensor) { remaining_minutes_sensor_ = sensor; } protected: static void ICACHE_RAM_ATTR isr_stb(); static void ICACHE_RAM_ATTR isr_clk(); void decode_packet_(uint8_t *data, uint8_t len); int program_duration_minutes_(const std::string &program); text_sensor::TextSensor *program_sensor_{nullptr}; text_sensor::TextSensor *delay_timer_sensor_{nullptr}; binary_sensor::BinarySensor *power_sensor_{nullptr}; binary_sensor::BinarySensor *salt_missing_sensor_{nullptr}; binary_sensor::BinarySensor *half_load_sensor_{nullptr}; binary_sensor::BinarySensor *running_sensor_{nullptr}; sensor::Sensor *remaining_minutes_sensor_{nullptr}; bool program_was_selected_{false}; bool was_running_{false}; uint32_t started_at_ms_{0}; int selected_duration_min_{0}; std::string selected_program_{"Не выбрана"}; static volatile bool active_; static volatile uint8_t bit_count_; static volatile uint8_t cur_byte_; static volatile uint8_t buf_[128]; static volatile uint8_t len_; static volatile bool ready_; static uint8_t last_buf_[14]; static uint8_t last_len_;};} // namespace tm1638_sniffer} // namespace esphome
-
STBопределяет начало и конец передачи. -
CLKиспользуется для чтения каждого бита. -
DIOсодержит данные.
Данные передаются в формате LSB-first.
Первые осмысленные пакеты
После настройки сниффера в логах появились стабильные пакеты двух типов:
42 00 00 80 08C0 00 01 00 00 00 01 00 00 00 00 00 00 00
-
0x42— опрос кнопок. -
0xC0— запись данных в память дисплея.
Именно пакеты C0 содержали информацию о состоянии панели.
Борьба со спамом
Контроллер обновлял дисплей десятки раз в секунду.
Чтобы лог оставался читаемым, пришлось:
-
Игнорировать пакеты
0x42. -
Принимать только полные пакеты
0xC0длиной 14 байт. -
Выводить данные только при изменении содержимого.
После этого лог стал показывать только реальные изменения состояния машины.
Расшифровка программ
Ручное сопоставление пакетов и программ.
Программы
|
Программа |
Условие |
|---|---|
|
Эко |
|
|
Деликатная |
|
|
90 мин |
|
|
Быстрая |
|
|
Интенсивная |
|
|
Стандартная |
|
Отсрочка старта
|
Таймер |
Условие |
|---|---|
|
3 часа |
|
|
6 часов |
|
|
9 часов |
|
|
12 часов |
|
Дополнительные индикаторы
|
Функция |
Условие |
|---|---|
|
Мало соли |
|
|
1/2 загрузки |
|
Интеграция в Home Assistant

На основе расшифрованного протокола я создал внешний компонент для ESPHome, который публикует следующие сущности в Home Assistant:
washmashine.yaml
esphome: name: washmashine friendly_name: Washmashineesp8266: board: d1_miniexternal_components: - source: type: local path: componentslogger: level: DEBUG baud_rate: 0api: encryption: key: ""ota: - platform: esphome password: ""wifi: ssid: !secret wifi_ssid password: !secret wifi_password min_auth_mode: WPA2 ap: ssid: "Washmashine Fallback" password: ""captive_portal:tm1638_sniffer: program: name: "Dishwasher Program" delay_timer: name: "Dishwasher Delay Timer" power: name: "Dishwasher Power" salt_missing: name: "Dishwasher Salt Missing" half_load: name: "Dishwasher Half Load" running: name: "Dishwasher Running" remaining_minutes: name: "Dishwasher Remaining Minutes"
-
Dishwasher Program
-
Dishwasher Delay Timer
-
Dishwasher Power
-
Dishwasher Salt Missing
-
Dishwasher Half Load
-
Dishwasher Running
-
Dishwasher Remaining Minutes
Определение состояния Running
Отдельного сигнала «дверь закрыта» я не обнаружил.
Однако оказалось, что после выбора программы и закрытия дверцы индикаторы программ гаснут.
Это позволило реализовать следующую логику:
-
Пользователь выбирает программу.
-
Название программы и её длительность запоминаются.
-
Индикаторы программ гаснут.
-
Dishwasher Running = ON. -
Запускается обратный отсчёт.
Проблема мигающего индикатора
Во время набора воды (первые 2–3 минуты) индикатор выбранной программы мигает.
Из-за этого Running ошибочно переключался между ON и OFF.
Решение — трёхминутный startup grace period, в течение которого появление индикатора не считается остановкой мойки.
Расчёт оставшегося времени
Для каждой программы были заданы ориентировочные длительности:
|
Программа |
Время |
|---|---|
|
Эко |
175 мин |
|
Деликатная |
110 мин |
|
90 мин |
90 мин |
|
Быстрая |
40 мин |
|
Интенсивная |
130 мин |
|
Стандартная |
155 мин |
После старта компонент вычисляет, сколько времени прошло, и публикует количество оставшихся минут.
Возможность изменить программу после старта
Если пользователь запускает машину, а затем открывает дверцу и выбирает другую программу, компонент автоматически:
-
останавливает текущий отсчёт;
-
сбрасывает
Running; -
запоминает новую программу;
-
запускает новый таймер после повторного старта.
ссылка на оригинал статьи https://habr.com/ru/articles/1039434/