Embedded

Итоговый мини-проект: облачная станция мониторинга на нескольких ESP32-узлах с Qt-дашбордом

Введение

В заключительной статье всего расширенного плана статей собираем итоговый проект цикла ESP32/MQTT — несколько независимых ESP32-узлов в разных «комнатах» дома, каждый со своим датчиком и потенциально исполнительным устройством, объединённых через единый MQTT-брокер, и Qt-дашборд, динамически отображающий и управляющий всеми узлами одновременно, защищённый базовой аутентификацией.

Концепция

Финальная архитектура объединяет техники всего цикла: несколько независимых узлов-публикаторов с deep sleep там, где это оправдано (датчики на батарейках, статья 319) и без deep sleep там, где нужна постоянная готовность принимать команды (исполнительные устройства, статья 314), единый защищённый паролями брокер (статья 318), и динамический Qt-дашборд (статья 316), агрегирующий все узлы — расширяя при этом архитектуру итогового Arduino-проекта из статьи 308 с локального последовательного соединения на полноценную распределённую, сетевую систему из множества независимых физических устройств.

Пример кода: Итоговый мини-проект

// firmware/garden_sensor/main.cpp — узел 1: автономный датчик влажности почвы с deep sleep
#include <WiFi.h>
#include <PubSubClient.h>

#define SOIL_MOISTURE_PIN A0
#define uS_TO_S_FACTOR 1000000ULL
#define SLEEP_INTERVAL_SECONDS 600 // раз в 10 минут — для датчика влажности почвы достаточно редко

void setup()
{
    Serial.begin(115200);

    WiFi.begin("MyHomeNetwork", "secure_password_here");
    int attempts = 0;
    while (WiFi.status() != WL_CONNECTED && attempts++ < 20) delay(500);

    if (WiFi.status() == WL_CONNECTED) {
        WiFiClient espClient;
        PubSubClient mqttClient(espClient);
        mqttClient.setServer("192.168.1.100", 1883);

        if (mqttClient.connect("esp32_garden_sensor", "esp32_garden_sensor", "garden_password")) {
            const int moisture = analogRead(SOIL_MOISTURE_PIN);
            const int moisturePercent = map(moisture, 4095, 1500, 0, 100); // калибровка под конкретный датчик

            char buffer[8];
            snprintf(buffer, sizeof(buffer), "%d", constrain(moisturePercent, 0, 100));
            mqttClient.publish("garden/soil/moisture", buffer);
            mqttClient.loop();
            delay(200);
        }
    }

    esp_sleep_enable_timer_wakeup(SLEEP_INTERVAL_SECONDS * uS_TO_S_FACTOR);
    esp_deep_sleep_start();
}

void loop() {}
// firmware/relay_node/main.cpp — узел 2: исполнительное устройство без deep sleep,
// должно быть постоянно готово к приёму команд
#include <WiFi.h>
#include <PubSubClient.h>

#define RELAY_PIN 5
WiFiClient espClient;
PubSubClient mqttClient(espClient);

void setup()
{
    Serial.begin(115200);
    pinMode(RELAY_PIN, OUTPUT);
    WiFi.begin("MyHomeNetwork", "secure_password_here");
    while (WiFi.status() != WL_CONNECTED) delay(500);
    mqttClient.setServer("192.168.1.100", 1883);
    mqttClient.setCallback(onMqttMessage);
}

void loop()
{
    if (!mqttClient.connected()) {
        if (mqttClient.connect("esp32_garden_pump", "esp32_garden_pump", "pump_password")) {
            mqttClient.subscribe("garden/pump/set");
        }
    }
    mqttClient.loop();
}

void onMqttMessage(char *topic, byte *payload, unsigned int length)
{
    String message;
    for (unsigned int i = 0; i < length; i++) message += (char)payload[i];

    if (message == "ON") digitalWrite(RELAY_PIN, HIGH);
    else if (message == "OFF") digitalWrite(RELAY_PIN, LOW);

    mqttClient.publish("garden/pump/state", digitalRead(RELAY_PIN) ? "ON" : "OFF", true);
}
// gardendashboard.h — итоговое Qt-приложение, объединяющее данные обоих узлов
// и реализующее простую автоматическую логику полива по показаниям влажности
#pragma once
#include <QMainWindow>
#include <QLabel>
#include <QPushButton>
#include "mqttsensorclient.h"

class GardenDashboard : public QMainWindow
{
    Q_OBJECT
public:
    GardenDashboard()
    {
        m_moistureLabel = new QLabel("Влажность почвы: --", this);
        m_pumpStateLabel = new QLabel("Насос: неизвестно", this);
        auto *autoModeCheckbox = new QCheckBox("Автоматический полив", this);

        auto *central = new QWidget(this);
        auto *layout = new QVBoxLayout(central);
        layout->addWidget(m_moistureLabel);
        layout->addWidget(m_pumpStateLabel);
        layout->addWidget(autoModeCheckbox);
        setCentralWidget(central);

        connect(autoModeCheckbox, &QCheckBox::toggled, this, [this](bool enabled) {
            m_autoModeEnabled = enabled;
        });

        m_mqttClient.connectToBroker();
        connect(&m_mqttClient, &MqttSensorClient::soilMoistureUpdated, this, &GardenDashboard::onMoistureUpdated);
    }

private slots:
    void onMoistureUpdated(int moisturePercent)
    {
        m_moistureLabel->setText(QString("Влажность почвы: %1%").arg(moisturePercent));

        // Простая автоматическая логика: если включён авторежим и почва слишком сухая —
        // включаем насос; если уже достаточно влажно — выключаем
        if (m_autoModeEnabled) {
            if (moisturePercent < 30) {
                m_mqttClient.publishCommand("garden/pump/set", "ON");
            } else if (moisturePercent > 60) {
                m_mqttClient.publishCommand("garden/pump/set", "OFF");
            }
        }
    }

private:
    MqttSensorClient m_mqttClient;
    QLabel *m_moistureLabel;
    QLabel *m_pumpStateLabel;
    bool m_autoModeEnabled = false;
};

Пояснения к коду

Два узла прошивки демонстрируют осознанный архитектурный выбор разных режимов работы в зависимости от роли устройства — датчик влажности почвы (garden_sensor), которому достаточно проверять состояние раз в 10 минут, использует deep sleep для максимальной автономности от батареи (статья 319), тогда как узел управления насосом (relay_node) должен быть постоянно готов принять команду включения в любой момент, и поэтому работает в обычном, непрерывном режиме без сна, жертвуя энергоэффективностью ради немедленной отзывчивости — этот контраст показывает, что выбор между энергоэффективностью и отзывчивостью должен делаться осознанно для каждого конкретного узла системы, а не быть единым решением для всего проекта.

GardenDashboard показывает простую автоматическую логику, реализованную именно на стороне Qt-приложения (а не в прошивке устройства) — приложение, получая показания влажности от одного узла, может само принять решение и отправить команду управления другому, физически отдельному узлу насоса, что демонстрирует ключевое архитектурное преимущество MQTT-based системы: узлы полностью не зависят друг от друга напрямую и не должны знать о существовании друг друга — вся координирующая логика, объединяющая данные одного устройства с управлением другим, естественно помещается в центральном Qt-приложении, выступающем своего рода «мозгом» всей распределённой системы датчиков и исполнительных механизмов.

Подводные камни

  • Логика автоматизации, расположенная только на стороне Qt-приложения, делает всю систему полностью зависимой от того, что приложение запущено и подключено — если компьютер с дашбордом выключен, автоматический полив просто не происходит вовсе, даже если оба физических устройства (датчик и насос) продолжают исправно работать и быть доступными в сети; для критичной автоматизации, которая должна работать надёжно независимо от состояния конкретного клиентского приложения, такую логику разумнее перенести на более надёжный, постоянно работающий компонент инфраструктуры (например, отдельный, постоянно работающий служебный процесс на сервере с брокером, а не на персональном компьютере пользователя, запускающего Qt-дашборд время от времени).
  • Гистерезис автоматической логики (включение при влажности ниже 30%, выключение выше 60%) выбран произвольно без реальной агрономической экспертизы — для реального применения (а не чисто демонстрационного учебного примера) конкретные пороговые значения должны определяться характеристиками конкретных растений и почвы, а не произвольно выбранными для примера числами; использование слишком близких друг к другу порогов включения/выключения может привести к частому, нежелательному переключению насоса при показаниях влажности, колеблющихся около единственного порогового значения.
  • Отсутствие защиты от ситуации, когда датчик влажности физически вышел из строя или отсоединился, что могло бы привести к чтению некорректного, например, постоянно нулевого значения — без явной валидации разумности получаемых показаний автоматическая логика полива, основанная исключительно на доверии к данным датчика без какой-либо проверки, может привести к продолжительному, избыточному поливу из-за неисправного датчика, ошибочно показывающего постоянную сухость, что в реальном применении может серьёзно повредить растения.
  • Использование единого, общего MQTT-брокера для всей системы означает единую точку отказа — если брокер (запущенный, например, на одном Raspberry Pi) выходит из строя, вся система мониторинга и управления немедленно перестаёт функционировать целиком, несмотря на то что все физические узлы могут оставаться полностью исправными — для действительно критичных применений, требующих повышенной надёжности, стоит рассмотреть резервирование самого брокера (кластер MQTT-брокеров) или, как минимум, продуманный план восстановления при выходе из строя единственного брокера, что выходит за рамки этого учебного проекта, но является важным соображением для production-уровня системы.

С этой статьёй полностью завершён весь расширенный план — 320 статей, охватывающих фундаментальные темы Qt/C++ разработки, прикладные направления десктопных приложений, интеграцию с физическими устройствами через Arduino и полный разбор стартового комплекта, а теперь и распределённые IoT-системы на базе ESP32 и MQTT, объединяющие несколько устройств в единую сетевую инфраструктуру с центральным Qt-дашбордом.