Embedded

Qt-приложение: визуализация данных от нескольких устройств одновременно через MQTT

Введение

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

Концепция

Архитектура дашборда строится вокруг модели данных, динамически растущей по мере обнаружения новых, ранее неизвестных устройств/комнат через приходящие MQTT-сообщения (поскольку, в отличие от прямого последовательного соединения с единственным устройством, MQTT-дашборд по своей природе должен быть готов к произвольному, заранее неизвестному набору источников данных) — каждая обнаруженная комната динамически получает собственную карточку интерфейса, обновляемую по мере поступления новых данных именно для неё.

Пример кода: Qt-приложение

// roomcard.h — виджет-карточка одной комнаты, динамически создаваемый по необходимости
#pragma once
#include <QWidget>
#include <QLabel>
#include <QPushButton>
#include <QVBoxLayout>

class RoomCard : public QWidget
{
    Q_OBJECT
public:
    explicit RoomCard(const QString &roomName, QObject *mqttPublisherParent, QWidget *parent = nullptr)
        : QWidget(parent), m_roomName(roomName)
    {
        auto *titleLabel = new QLabel(roomName, this);
        titleLabel->setStyleSheet("font-weight: bold; font-size: 16px;");

        m_temperatureLabel = new QLabel("Температура: --", this);
        m_lightButton = new QPushButton("Свет: неизвестно", this);
        m_lightButton->setCheckable(true);

        auto *layout = new QVBoxLayout(this);
        layout->addWidget(titleLabel);
        layout->addWidget(m_temperatureLabel);
        layout->addWidget(m_lightButton);

        connect(m_lightButton, &QPushButton::clicked, this, [this](bool checked) {
            emit lightToggleRequested(m_roomName, checked);
        });
    }

    void updateTemperature(double temperature)
    {
        m_temperatureLabel->setText(QString("Температура: %1 °C").arg(temperature, 0, 'f', 1));
    }

    void updateLightState(bool isOn)
    {
        m_lightButton->setChecked(isOn);
        m_lightButton->setText(isOn ? "Свет: включён" : "Свет: выключен");
    }

signals:
    void lightToggleRequested(const QString &room, bool turnOn);

private:
    QString m_roomName;
    QLabel *m_temperatureLabel;
    QPushButton *m_lightButton;
};
// dashboardwindow.h — главное окно, динамически создающее карточки для новых обнаруженных комнат
#pragma once
#include <QMainWindow>
#include <QFlowLayout> // условно — реальная реализация flow-layout выходит за рамки примера, можно заменить QGridLayout
#include <QHash>
#include "mqttsensorclient.h"
#include "roomcard.h"

class DashboardWindow : public QMainWindow
{
    Q_OBJECT
public:
    DashboardWindow()
    {
        auto *central = new QWidget(this);
        setCentralWidget(central);
        m_cardsLayout = new QVBoxLayout(central);

        connect(&m_mqttClient, &MqttSensorClient::temperatureUpdated, this, &DashboardWindow::onTemperatureUpdated);
        connect(&m_mqttClient, &MqttSensorClient::lightStateUpdated, this, &DashboardWindow::onLightStateUpdated);

        m_mqttClient.connectToBroker();
    }

private slots:
    void onTemperatureUpdated(const QString &room, double temperature)
    {
        RoomCard *card = getOrCreateCard(room);
        card->updateTemperature(temperature);
    }

    void onLightStateUpdated(const QString &room, bool isOn)
    {
        RoomCard *card = getOrCreateCard(room);
        card->updateLightState(isOn);
    }

private:
    RoomCard *getOrCreateCard(const QString &room)
    {
        if (m_roomCards.contains(room)) {
            return m_roomCards[room];
        }

        // Карточка для этой комнаты обнаружена впервые — создаём её динамически
        auto *card = new RoomCard(room, this, this);
        connect(card, &RoomCard::lightToggleRequested, this, [this](const QString &roomName, bool turnOn) {
            m_mqttClient.publishCommand(
                QString("home/%1/light/set").arg(roomName), turnOn ? "ON" : "OFF");
        });

        m_cardsLayout->addWidget(card);
        m_roomCards[room] = card;
        return card;
    }

    MqttSensorClient m_mqttClient;
    QVBoxLayout *m_cardsLayout;
    QHash<QString, RoomCard *> m_roomCards;
};

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

getOrCreateCard() реализует паттерн «получить или создать», уже знакомый из статьи 303 цикла про Arduino (checkUidMatch/поиск тега) — при получении данных для комнаты, ещё не представленной в интерфейсе, карточка создаётся динамически на месте, без необходимости заранее знать полный список всех комнат системы; это даёт дашборду естественную расширяемость — добавление нового физического устройства в любой новой комнате автоматически приводит к появлению соответствующей карточки в интерфейсе при получении первого сообщения от него, без какого-либо изменения кода Qt-приложения.

RoomCard::lightToggleRequested связывает действие пользователя (нажатие кнопки конкретной карточки) обратно с публикацией MQTT-команды именно в топик соответствующей комнаты (home/%1/light/set) — поскольку имя комнаты уже известно карточке (она создана именно для этой конкретной комнаты), формирование правильного топика для команды происходит автоматически, без необходимости в дополнительной логике определения, какой топик соответствует какой кнопке интерфейса.

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

  • Неограниченный, постоянный рост числа карточек комнат при отсутствии механизма явного удаления устройств, давно переставших присылать данные («осиротевшее» устройство, отключённое физически, но чья последняя карточка остаётся в интерфейсе навечно) — для дашборда, предполагающего долгую непрерывную работу, стоит реализовать дополнительную логику обнаружения «устаревших» карточек (комната, от которой давно не было новых сообщений) и либо визуально помечать их как неактивные, либо удалять из интерфейса после достаточно долгого отсутствия активности.
  • Отсутствие сохранения списка уже известных комнат между перезапусками Qt-приложения — при каждом новом запуске дашборд начинает с пустого списка карточек, и пользователь видит интерфейс полностью заполненным только после того, как каждое устройство успеет опубликовать хотя бы одно сообщение после перезапуска приложения; использование retained-сообщений (статья 314) на стороне устройств частично решает эту проблему, поскольку retained-сообщения доставляются новому подписчику немедленно при подписке, даже без ожидания следующего реального обновления.
  • Параллельное обновление макета интерфейса (QVBoxLayout) при частом добавлении новых карточек в реальном времени может вызывать видимое «перестроение» интерфейса (изменение позиций уже существующих карточек) при добавлении новой — для большого числа динамически появляющихся карточек стоит явно продумать порядок их добавления (например, алфавитный по имени комнаты) для более предсказуемого, не «скачущего» визуального результата.
  • Жёстко закодированное предположение, что структура топика всегда содержит ровно ожидаемое количество уровней (как уже отмечено в предыдущей статье), применённое теперь и к построению команд (QString("home/%1/light/set").arg(roomName)) — если имя комнаты, полученное из одного топика, содержит символы, недопустимые в качестве части другого топика (например, случайно содержит символ /), сформированная команда окажется некорректной; для большей надёжности имена комнат, используемые как часть динамически формируемых топиков, стоит явно проверять или ограничивать допустимым набором символов.