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.yamlcomponents/tm1638_sniffer/__init__.pycomponents/tm1638_sniffer/tm1638_sniffer.hcomponents/tm1638_sniffer/tm1638_sniffer.cpp
__init__.py
import esphome.codegen as cg import esphome.config_validation as cv from esphome.components import text_sensor, binary_sensor, sensor from esphome.const import CONF_ID, UNIT_MINUTE, ICON_TIMER CONF_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 D7 volatile 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 08 C0 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: Washmashine esp8266: board: d1_mini external_components: - source: type: local path: components logger: level: DEBUG baud_rate: 0 api: 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;запоминает новую программу;
запускает новый таймер после повторного старта.
