Введение
С базовым 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)) — если имя комнаты, полученное из одного топика, содержит символы, недопустимые в качестве части другого топика (например, случайно содержит символ/), сформированная команда окажется некорректной; для большей надёжности имена комнат, используемые как часть динамически формируемых топиков, стоит явно проверять или ограничивать допустимым набором символов.