
Привет, Хабр!
Так уж сложилось, что 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-канале ↩

