- PVSM.RU - https://www.pvsm.ru -
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 |
|---|---|
|
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
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))
#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
#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 загрузки |
|

На основе расшифрованного протокола я создал внешний компонент для ESPHome, который публикует следующие сущности в Home Assistant:
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
Отдельного сигнала «дверь закрыта» я не обнаружил.
Однако оказалось, что после выбора программы и закрытия дверцы индикаторы программ гаснут.
Это позволило реализовать следующую логику:
Пользователь выбирает программу.
Название программы и её длительность запоминаются.
Индикаторы программ гаснут.
Dishwasher Running = ON.
Запускается обратный отсчёт.
Во время набора воды (первые 2–3 минуты) индикатор выбранной программы мигает.
Из-за этого Running ошибочно переключался между ON и OFF.
Решение — трёхминутный startup grace period, в течение которого появление индикатора не считается остановкой мойки.
Для каждой программы были заданы ориентировочные длительности:
|
Программа |
Время |
|---|---|
|
Эко |
175 мин |
|
Деликатная |
110 мин |
|
90 мин |
90 мин |
|
Быстрая |
40 мин |
|
Интенсивная |
130 мин |
|
Стандартная |
155 мин |
После старта компонент вычисляет, сколько времени прошло, и публикует количество оставшихся минут.
Если пользователь запускает машину, а затем открывает дверцу и выбирает другую программу, компонент автоматически:
останавливает текущий отсчёт;
сбрасывает Running;
запоминает новую программу;
запускает новый таймер после повторного старта.
Автор: 4yGON
Источник [1]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/esp8266/452259
Ссылки в тексте:
[1] Источник: https://habr.com/ru/articles/1039434/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1039434
Нажмите здесь для печати.