
Привет, Хабр!
Так уж сложилось, что 99% устройств моего «умного дома» были спроектированы и собраны самостоятельно — что тут поделать, каждый развлекается как может. Об одном из таких устройств я писал ранее, а именно — о модуле управления освещением с радарным датчиком HLK-LD2402. И в соответствии с жизненным циклом отладки, а также процессом эксплуатации устройства назрела необходимость в программных улучшениях, о которых я постараюсь коротко рассказать в этой статье.
Итак, давайте же сразу «с места в карьер», без лишних рассуждений, перейдем к делу. Ниже описаны мои замечания по работе МУО (модуль управления освещением) и методы их устранения.
❯ Ложные срабатывания и фильтрация данных радара
Спустя некоторое время использования своей «самоделки» я решил проанализировать логи работы устройства в Home Assistant и обнаружил несколько ложных срабатываний в автоматическом режиме. Причиной тому послужило резкое однократное изменение значения дистанции в «сырых» данных радарного датчика. «Интересно», — подумал я, ведь наивно полагал, что данные с HLK-LD2402 уже обработаны встроенным фильтром.
Для устранения данной проблемы было решено обработать получаемые данные радарного датчика фильтром Калмана, что является распространенной практикой для различных устройств (гироскопы, радарные или ультразвуковые дальномеры и т. п., где существует большая вероятность возникновения «шума» в данных). Ниже представлен «классический» код функции одномерного фильтра Калмана, который использоваться для решения описанной проблемы:
Код фильтра
// Глобальные переменные для фильтра Калмана float R = 4.85; // среднее отклонение (сила сглаживания) float Q = 0.015; // скорость реакции на изменение (подбирается вручную) float Pc = 0.0; float G = 0.0; float P = 1.0; float Xp = 0.0; float Zp = 0.0; float Xe = 0.0; // Фильтрация сигнала с датчика float filter_distance(float raw_distance) { Pc = P + Q; G = Pc/(Pc + R); P = (1-G)*Pc; Xp = Xe; Zp = Xp; Xe = G*(raw_distance-Zp)+Xp; // фильтрованное значение return(Xe); }
Где переменная float R — это дисперсия шума измерений. Чем выше это число, тем больше фильтр «не доверяет» новым значениям и тем сильнее сглаживание.
А float Q — оценка шума процесса. Она определяет динамику: чем выше значение, тем быстрее фильтр реагирует на резкое реальное изменение расстояния.
И чтобы использовать наш фильтр, применяется следующая конструкция:
distance = filter_distance(get_data);
Изначально я планировал жестко задать коэффициенты фильтра R и Q в прошивке, но позже пришел в себя и решил реализовать возможность их настройки через пользовательский интерфейс и об этом я расскажу позже.
❯ Задаём таймер отключения
И здесь почему-то я поленился реализовать в интерфейсе устройства функцию изменения таймера задержки отключения и жестко прописал его значение в прошивке. Ну что ж, придется исправляться.
Для реализации данной фичи, необходимо добавить элемент в структуру данных для хранения значения:
struct { //... int off_time; //... } settings;
Как вы наверное догадались, в off_time мы и будем сохранять значение задержки.
И добавим элемент управления в веб-интерфейс модуля, мне почему-то захотелось сделать «ползунок»:
Код здесь
//файл main_page.ino html += "<text>Задержка отключения, сек </text>\ <div class =\"live\">\ <input name=\"flevel\" id=\"flying\" type=\"range\"\ min=\"1\" max=\"500\" value=\""+String(settings.off_time)+"\"\ step=\"1\">\ </div>";
И js обработчик элемента:
Код здесь
//flying файл const_js.h var flying = document.getElementById('flying'); flying.addEventListener('input', function() { response.innerHTML = flying.value; }); flying.addEventListener('change', function() { var value = flying.value; var xhr = new XMLHttpRequest(); xhr.open('POST', '?page=indata', true); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); xhr.onreadystatechange = function() { if(xhr.readyState === 4 && xhr.status === 200) { document.getElementById('response').innerHTML = xhr.responseText; } }; xhr.send('flevel=' + encodeURIComponent(value)); });
Также добавим функцию приёма значения в файле inputdata.ino для последующего сохранения:
Код здесь
if(server.hasArg("flevel")){ String msg = server.arg("flevel"); int level = msg.toInt(); settings.off_time = level; save_config(); server.send(200, "text/html", String(server.arg("flevel").c_str())); if(settings.mqtt_en){ MQTT_send_data("jsondata", JSON_DATA());} }
В итоге мы сможем задавать значение задержки в пользовательском веб-интерфейсе, и это будет выглядеть так:

❯ Визуализация данных
Раз уж зашла речь о веб-интерфейсе, то мне очень не хватало визуализации данных радара для настройки положения модуля. Да, в интерфейсе в реальном времени отображается значение дистанции до объекта, но не наблюдается динамика, поэтому я решил реализовать небольшой график, который бы отображал в реальном времени изменение дистанции. И так как мы используем веб-интерфейс для настройки устройства, данный элемент мы можем реализовать с помощью небольшого JavaScript и html кода и правки стилей.
Добавим элемент графика в наш html-код:
Код здесь
//В файле main_page.ino html += "<div class=\"chart-container\">\ <canvas id=\"graphic\" width=\"500px\" >\ </canvas></div><br>";
И немного стиля:
Кот здесь
/* В файле const_js.h */ .chart-container { max-width: 500px; width: 100%; margin: 0 auto; } #graphic { width: 100%; height: 150px; border: none; background: #ebe6e6; display: block; border-radius: 10px; }
JS код, который отвечает за отрисовку графика:
Код JS функции рисования графика
const ctx = document.getElementById('graphic').getContext('2d'); const data_g = new Array(100).fill(0); // 100 значений сразу let counter = 0; function draw() { ctx.clearRect(0, 0, 500, 150); // Всегда 100 столбцов for (let i = 0; i < 100; i++) { const maxVal = Math.max(...data_g); const scale = maxVal ? 150 / maxVal : 1; const height = data_g[i] * scale; ctx.fillStyle = '#2196F3'; ctx.fillRect(i * 5, 150 - height, 5, height); } } // Отрисовываем сразу draw();
В результате у нас получается вот такой симпатичный график:

Ниже демонстрация работы графика в реальном времени:

❯ Настройка Wi-Fi
Здесь в целом всё было неплохо, но меня раздражал один нюанс: при откры��ии настроек Wi-Fi страница «подтормаживала» из-за сканирования доступных сетей в процессе её формирования. Для решения этой проблемы я разделил генерацию страницы на два этапа:
Основной веб-страницы Wi-Fi настроек;
Список доступных сетей.
И затем реализовал асинхронную подгрузку списка сетей с помощью JS. В коде это реализовано так:
Код функции формирования основной страницы настройки Wi-Fi
void wlanPageHandler(){ if (captivePortal()) { return; } if (!validateToken()) { server.sendHeader("Location", "/login"); server.sendHeader("Cache-Control", "no-cache"); server.send(301); return; } if (server.hasArg("ssid")){ if (server.hasArg("password")){ strncpy(settings.mySSID, server.arg("ssid").c_str(), MAX_STRING_LENGTH); strncpy(settings.myPW, server.arg("password").c_str(), MAX_STRING_LENGTH); save_config(); WiFi.begin(settings.mySSID, settings.myPW); }else{ strncpy(settings.mySSID, server.arg("ssid").c_str(), MAX_STRING_LENGTH); strncpy(settings.myPW, server.arg("password").c_str(), MAX_STRING_LENGTH); save_config(); WiFi.begin(settings.mySSID, settings.myPW); } while(WiFi.status() != WL_CONNECTED){ delay(100); } delay(100); } String html = "<html><head><meta charset=\"UTF-8\"><title>Wi-Fi конфигурация</title>"; html += "<link href=\"style.css\" rel=\"stylesheet\" type=\"text/css\" />"; html += "<script src=\"script.js?script=js_wifi\"></script>"; html += "<h2>Настройка беспроводного соединения</h2>"; html += "<form>"; if (WiFi.status() == WL_CONNECTED){ IPAddress ip = WiFi.localIP(); String ipStr = String(ip[0]) + '.' + String(ip[1]) + '.' + String(ip[2]) + '.' + String(ip[3]); html += "Устройство подключено к сети "+String(WiFi.SSID())+"<br>"; html += "Уровень сигнала: "+String(WiFi.RSSI())+" dBi <br>"; html += "IP адрес подключения: "+String(ipStr)+"<br>"; }else{ html += "Устройство отключено от сети<br>"; } html += "Для подключения к Wi-Fi выберите сеть из списка...</form>"; html += "<form method=\"POST\">"; html += "<div id=\"response\"><text>Выполняется сканирование ... </text></div>"; html += "</form>"; html += "</body>"; html += "<center><br><a href=\"/\">Вернуться назад</a><br></center>"; html += "<footer>© <b>CYBEREX TECH</b>, 2025. Версия микро ПО <b>"+version_code+"</b>.</footer></html>"; server.send(200, "text/html", html); }
Код функции формирования списка Wi-Fi сетей
void scan_network(){ if (!validateToken()) { server.sendHeader("Location", "/login"); server.sendHeader("Cache-Control", "no-cache"); server.send(301); return; } String html = ""; int ap_count = WiFi.scanNetworks(); if (ap_count == 0){ html += "Не найдено ни одной беспроводной сети.<br>"; }else{ for (uint8_t ap_idx = 0; ap_idx < ap_count; ap_idx++){ html += "<input type=\"radio\" name=\"ssid\" value=\"" + String(WiFi.SSID(ap_idx)) + "\"><text>"; html += "<b>"+String(WiFi.SSID(ap_idx)) + "</b> Уровень сигнала: " + WiFi.RSSI(ap_idx) +" dBi"; (WiFi.encryptionType(ap_idx) == ENC_TYPE_NONE) ? html += " " : html += " [защищена]"; html += "</text><br>"; } html += "<br>WiFi пароль доступа (если сеть защищена):<br>"; html += "<input type=\"text\" name=\"password\">"; html += "<input type=\"submit\" value=\"Подключиться\">"; } server.send(200, "text/html", html); }
JavaScript-код для асинхронной подгрузки списка сетей
const char js_wifi[] PROGMEM = R"rawliteral( document.addEventListener('DOMContentLoaded', async function() { try { const response = await fetch('?page=scan_wifi'); const data = await response.text(); document.getElementById('response').innerHTML = data; } catch (error) { console.error('Ошибка загрузки:', error); document.getElementById('response').innerHTML = `<div class="error">Ошибка загрузки ${error.message}</div>`; } }); )rawliteral";

Теперь страница открывается мгновенно, и мой внутренний перфекционист удовлетворен.
❯ Интергация в умный дом или MQTT Discovery
Мой умный дом построен на платформе Home Assistant (HA), которая поддерживает автоматическое обнаружение IoT-устройств, использующих протокол MQTT, благодаря механизму MQTT Discovery. В моём МУО также реализован данный метод обнаружения, но немного криво: в HA автоматически определялись датчики без привязки к конкретному устройству, поэтому пользователю предлагалось самостоятельно конфигурировать карточку объектов для этого модуля.
Чтобы исправить ситуацию, нам нужно немного изменить конфигурационный топик и добавить в него описание устройства для группировки сенсоров и элементов управления нашего модуля:
Код функции конфигурационного топика
void send_mqtt(String tops, String data, String subscr) { if(!annonce_mqtt_discovery) { String device_id = "radar_light_switch_" + ch_id; String configuration_url = "http://" + WiFi.localIP().toString(); String top = String(settings.mqtt_topic) + "/jsondata"; String control = String(settings.mqtt_topic) + "/control"; // Создаем конфигурацию устройства DynamicJsonDocument deviceDoc(512); deviceDoc["ids"][0] = device_id; deviceDoc["name"] = "Радарный модуль УО"; deviceDoc["mdl"] = version_code; deviceDoc["sw"] = "1.0.2"; deviceDoc["mf"] = "CYBEREX TECH"; deviceDoc["configuration_url"] = configuration_url; // Вспомогательная функция для публикации конфигурации auto publishConfig = [&](const String& type, const String& entity_id, JsonDocument& doc) { doc["device"] = deviceDoc.as<JsonObject>(); String payload; serializeJson(doc, payload); client.publish(("homeassistant/" + type + "/" + entity_id + "/config").c_str(), payload.c_str(), true); }; // Датчик расстояния { DynamicJsonDocument doc(384); doc["device_class"] = "distance"; doc["name"] = "Датчик присутствия"; doc["state_topic"] = top; doc["unit_of_measurement"] = "cm"; doc["value_template"] = "{{ value_json.distance }}"; doc["unique_id"] = ch_id + "_d"; publishConfig("sensor", ch_id + "_d", doc); } // Верхний порог (сенсор) { DynamicJsonDocument doc(384); doc["device_class"] = "distance"; doc["name"] = "Порог срабатывания верхний"; doc["state_topic"] = top; doc["unit_of_measurement"] = "cm"; doc["value_template"] = "{{ value_json.h_on }}"; doc["unique_id"] = ch_id + "_d_on"; publishConfig("sensor", ch_id + "_d_on", doc); } // Нижний порог (сенсор) { DynamicJsonDocument doc(384); doc["device_class"] = "distance"; doc["name"] = "Порог срабатывания нижний"; doc["state_topic"] = top; doc["unit_of_measurement"] = "cm"; doc["value_template"] = "{{ value_json.h_off }}"; doc["unique_id"] = ch_id + "_d_off"; publishConfig("sensor", ch_id + "_d_off", doc); } // Задержка выключения (сенсор) { DynamicJsonDocument doc(512); doc["device_class"] = "duration"; doc["name"] = "Задержка отключения"; doc["state_topic"] = top; doc["unit_of_measurement"] = "s"; doc["value_template"] = "{{ value_json.d_time }}"; doc["unique_id"] = ch_id + "_d_off_time"; publishConfig("sensor", ch_id + "_d_off_time", doc); } // Минимальный порог сработки { DynamicJsonDocument doc(512); doc["name"] = "Мин порог"; doc["command_topic"] = control; doc["state_topic"] = top; doc["unit_of_measurement"] = "см"; doc["value_template"] = "{{ value_json.h_off }}"; doc["command_template"] = "{\"min_threshold\": {{ value }}}"; doc["min"] = 0; doc["max"] = 300; doc["step"] = 1; doc["mode"] = "slider"; doc["unique_id"] = ch_id + "_min"; publishConfig("number", ch_id + "_min", doc); } // Максимальный порог сработки { DynamicJsonDocument doc(512); doc["name"] = "Макс порог"; doc["command_topic"] = control; doc["state_topic"] = top; doc["unit_of_measurement"] = "см"; doc["value_template"] = "{{ value_json.h_on }}"; doc["command_template"] = "{\"max_threshold\": {{ value }}}"; doc["min"] = 0; doc["max"] = 300; doc["step"] = 1; doc["mode"] = "slider"; doc["unique_id"] = ch_id + "_max"; publishConfig("number", ch_id + "_max", doc); } // Время задержки отключения { DynamicJsonDocument doc(512); doc["name"] = "Задержка отключения"; doc["command_topic"] = control; doc["state_topic"] = top; doc["unit_of_measurement"] = "сек"; doc["value_template"] = "{{ value_json.d_time }}"; doc["command_template"] = "{\"delaytime_threshold\": {{ value }}}"; doc["min"] = 0; doc["max"] = 500; doc["step"] = 1; doc["mode"] = "slider"; doc["unique_id"] = ch_id + "t_delay"; publishConfig("number", ch_id + "t_delay", doc); } // Фильтр коэффициент R { DynamicJsonDocument doc(512); doc["name"] = "Фильтр R"; doc["command_topic"] = control; doc["state_topic"] = top; //doc["unit_of_measurement"] = ""; doc["value_template"] = "{{ value_json.filter_r }}"; doc["command_template"] = "{\"filter_r_threshold\": {{ value }}}"; doc["min"] = 0; doc["max"] = 6; doc["step"] = 0.1; doc["mode"] = "slider"; doc["unique_id"] = ch_id + "r_filter"; publishConfig("number", ch_id + "r_filter", doc); } // Фильтр коэффициент Q { DynamicJsonDocument doc(512); doc["name"] = "Фильтр Q"; doc["command_topic"] = control; doc["state_topic"] = top; //doc["unit_of_measurement"] = ""; doc["value_template"] = "{{ value_json.filter_q }}"; doc["command_template"] = "{\"filter_q_threshold\": {{ value }}}"; doc["min"] = 0.001; doc["max"] = 0.1; doc["step"] = 0.001; doc["mode"] = "slider"; doc["unique_id"] = ch_id + "q_filter"; publishConfig("number", ch_id + "q_filter", doc); } // Переключатель освещения { DynamicJsonDocument doc(384); doc["name"] = "Управление освещением"; doc["command_topic"] = control; doc["state_topic"] = top; doc["payload_on"] = "0"; doc["payload_off"] = "0"; doc["state_on"] = "On"; doc["state_off"] = "Off"; doc["value_template"] = "{{ value_json.c }}"; doc["unique_id"] = ch_id + "_s_off"; publishConfig("switch", ch_id + "_s_off", doc); } // Переключатель авторежима { DynamicJsonDocument doc(384); doc["name"] = "Автоматический режим"; doc["command_topic"] = control; doc["state_topic"] = top; doc["payload_on"] = "1"; doc["payload_off"] = "2"; doc["state_on"] = "1"; doc["state_off"] = "0"; doc["value_template"] = "{{ value_json.a }}"; doc["unique_id"] = ch_id + "aut"; publishConfig("switch", ch_id + "aut", doc); } annonce_mqtt_discovery = true; } // Отправляем данные client.publish(tops.c_str(), data.c_str()); client.subscribe(subscr.c_str()); }
И не забыть про колбэк:
Код функции обратного вызова MQTT
void callback(char* topic, byte* payload, unsigned int length) { String message; for (int i = 0; i < length; i++) { message = message + (char)payload[i]; } // Пробуем распарсить как JSON if (message.startsWith("{")) { DynamicJsonDocument doc(512); DeserializationError error = deserializeJson(doc, message); if (!error) { // Обработка минимального порога (auto_off) if (doc.containsKey("min_threshold")) { float min_val = doc["min_threshold"]; // Преобразуем обратно: делим на 0.0003 (или умножаем на 3333.33) settings.auto_off = min_val / 0.0003; // Обратное преобразование save_config(); if(settings.mqtt_en) { MQTT_send_data("jsondata", JSON_DATA()); } return; } // Обработка максимального порога (auto_on) if (doc.containsKey("max_threshold")) { float max_val = doc["max_threshold"]; // Преобразуем обратно: делим на 0.0003 settings.auto_on = max_val / 0.0003; // Обратное преобразование save_config(); if(settings.mqtt_en) { MQTT_send_data("jsondata", JSON_DATA()); } return; } // Обработка порога задержки отключения if (doc.containsKey("delaytime_threshold")) { float time_val = doc["delaytime_threshold"]; settings.off_time = time_val; // Сохраняем время задержки в сек save_config(); if(settings.mqtt_en) { MQTT_send_data("jsondata", JSON_DATA()); } return; } // Обработка порога коэфф фильтра R if (doc.containsKey("filter_r_threshold")) { float r_val = doc["filter_r_threshold"]; settings.r_filter = r_val; // Сохраняем коэффициент R в память R = r_val; save_config(); if(settings.mqtt_en) { MQTT_send_data("jsondata", JSON_DATA()); } return; } // Обработка порога коэфф фильтра Q if (doc.containsKey("filter_q_threshold")) { float q_val = doc["filter_q_threshold"]; settings.q_filter = q_val; // Сохраняем коэффициент Q в память Q = q_val; save_config(); if(settings.mqtt_en) { MQTT_send_data("jsondata", JSON_DATA()); } return; }
После этих изменений устройство будет интегрироваться в «умный дом» на уровне Plug & Play — от пользователя больше не потребуется ручное прописывание конфигураций, а обнаруженное устройство будет отображаться в панели интеграции MQTT:

А так выглядит панель управления модулем:

Также в конфигурационном топике указана ссылка на веб-интерфейс для настройки устройства, которая будет отображаться в панели модуля в Home Assistant. На скриншоте я выделил красным данный элемент, при нажатии на который веб-интерфейс для конфигурации модуля откроется в отдельной вкладке.
А так элементы управления модуля выглядят на главной панели HA:

❯ Итоги
Несмотря на то, что статье я затронул код, который связан в основном с (заметным глазу пользователя) элементами управления, также была выполнена оптимизация функционального кода устройства, что позволило сэкономить ресурсы оперативной памяти микроконтроллера. Ниже представлено сравнение вывода компиляции до и после оптимизации:
До:
. Variables and constants in RAM (global, static), used 64880 / 80192 bytes (80%) ║ SEGMENT BYTES DESCRIPTION ╠══ DATA 1504 initialized variables ╠══ RODATA 35240 constants ╚══ BSS 28136 zeroed variables . Instruction RAM (IRAM_ATTR, ICACHE_RAM_ATTR), used 60743 / 65536 bytes (92%) ║ SEGMENT BYTES DESCRIPTION ╠══ ICACHE 32768 reserved space for flash instruction cache ╚══ IRAM 27975 code in IRAM . Code in flash (default, ICACHE_FLASH_ATTR), used 332452 / 1048576 bytes (31%) ║ SEGMENT BYTES DESCRIPTION ╚══ IROM 332452 code in flash
После:
. Variables and constants in RAM (global, static), used 43312 / 80192 bytes (54%) ║ SEGMENT BYTES DESCRIPTION ╠══ DATA 1516 initialized variables ╠══ RODATA 13636 constants ╚══ BSS 28160 zeroed variables . Instruction RAM (IRAM_ATTR, ICACHE_RAM_ATTR), used 60871 / 65536 bytes (92%) ║ SEGMENT BYTES DESCRIPTION ╠══ ICACHE 32768 reserved space for flash instruction cache ╚══ IRAM 28103 code in IRAM . Code in flash (default, ICACHE_FLASH_ATTR), used 372276 / 1048576 bytes (35%) ║ SEGMENT BYTES DESCRIPTION ╚══ IROM 372276 code in flash
Загрузка RAM уменьшилась с 80% до 54%.
На этом можно и завершить статью, надеюсь, она была вам полезна. Ссылка на исходники проекта размещены ниже. Спасибо за уделенное время, всем успехов и хороших проектов! И как всегда: если у вас остались вопросы или есть что добавить, добро пожаловать в комментарии!
И если моя работа была вам полезна, то буду благодарен за финансовую поддержку. Для моей деятельности нужно оборудование, и не всегда на него есть свободные средства. Ссылки на донат доступны в моем профиле. Спасибо большое!
Ссылки к статье:
Просто «включатель» света с радаром 24 ГГц для «Умного дома»;
GitHub проекта (исходный код, проект платы и модели корпуса).
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud - в нашем Telegram-канале ↩
