3D рендер модели модуля
3D рендер модели модуля

Привет, Хабр!

Так уж сложилось, что 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 страница «подтормаживала» из-за сканирования доступных сетей в процессе её формирования. Для решения этой проблемы я разделил генерацию страницы на два этапа:

  1. Основной веб-страницы Wi-Fi настроек;

  2. Список доступных сетей.

И затем реализовал асинхронную подгрузку списка сетей с помощью 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:

Автоматическое обнаружение MQTT
Автоматическое обнаружение MQTT

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

МУО в панели MQTT
МУО в панели 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%.

На этом можно и завершить статью, надеюсь, она была вам полезна. Ссылка на исходники проекта размещены ниже. Спасибо за уделенное время, всем успехов и хороших проектов! И как всегда: если у вас остались вопросы или есть что добавить, добро пожаловать в комментарии!

И если моя работа была вам полезна, то буду благодарен за финансовую поддержку. Для моей деятельности нужно оборудование, и не всегда на него есть свободные средства. Ссылки на донат доступны в моем профиле. Спасибо большое!

Ссылки к статье:


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud - в нашем Telegram-канале