Введение
С прошивкой ESP32, публикующей данные и принимающей команды через MQTT (статьи 313–314), переходим на сторону Qt-приложения. Модуль Qt MQTT (часть Qt дополнительных модулей, устанавливаемая отдельно от основного Qt SDK) предоставляет QMqttClient — клиентский класс, концептуально аналогичный PubSubClient на стороне ESP32, но с привычным для Qt API на основе сигналов и слотов.
Концепция
QMqttClient подключается к брокеру через connectToHost() (асинхронно, аналогично QTcpSocket из основного цикла статей про сеть), и после успешного подключения (сигнал connected()) можно подписываться на топики через subscribe(), получающий объект QMqttSubscription — у этого объекта есть сигнал messageReceived(), срабатывающий при получении каждого нового сообщения по данной подписке. Публикация сообщений происходит через publish(), принимающий топик и данные в виде QByteArray.
Пример кода: QMqttClient в Qt
// mqttsensorclient.h — подключение к брокеру и подписка на топики датчиков
#pragma once
#include <QMqttClient>
#include <QObject>
class MqttSensorClient : public QObject
{
Q_OBJECT
public:
explicit MqttSensorClient(QObject *parent = nullptr) : QObject(parent)
{
m_client.setHostname("192.168.1.100");
m_client.setPort(1883);
m_client.setClientId("qt_dashboard_app");
connect(&m_client, &QMqttClient::connected, this, &MqttSensorClient::onConnected);
connect(&m_client, &QMqttClient::disconnected, this, &MqttSensorClient::onDisconnected);
}
void connectToBroker()
{
m_client.connectToHost();
}
void publishCommand(const QString &topic, const QString &payload)
{
m_client.publish(topic, payload.toUtf8());
}
private slots:
void onConnected()
{
qDebug() << "Подключено к MQTT-брокеру";
// Подписка с подстановочным символом — получаем данные температуры из ЛЮБОЙ комнаты
auto *subscription = m_client.subscribe("home/+/temperature", 0);
if (!subscription) {
qWarning() << "Не удалось подписаться на топик";
return;
}
connect(subscription, &QMqttSubscription::messageReceived, this,
&MqttSensorClient::onTemperatureMessage);
auto *stateSubscription = m_client.subscribe("home/+/light/state", 0);
connect(stateSubscription, &QMqttSubscription::messageReceived, this,
&MqttSensorClient::onLightStateMessage);
}
void onDisconnected()
{
qWarning() << "Соединение с MQTT-брокером потеряно";
emit connectionStateChanged(false);
}
void onTemperatureMessage(const QMqttMessage &message)
{
// Извлекаем название комнаты из самого топика (например, home/livingroom/temperature)
const QString topic = message.topic().name();
const QStringList parts = topic.split('/');
if (parts.size() != 3) return;
const QString room = parts[1];
const double temperature = QString::fromUtf8(message.payload()).toDouble();
emit temperatureUpdated(room, temperature);
}
void onLightStateMessage(const QMqttMessage &message)
{
const QStringList parts = message.topic().name().split('/');
if (parts.size() != 4) return;
const QString room = parts[1];
const bool isOn = (message.payload() == "ON");
emit lightStateUpdated(room, isOn);
}
signals:
void connectionStateChanged(bool connected);
void temperatureUpdated(const QString &room, double temperature);
void lightStateUpdated(const QString &room, bool isOn);
private:
QMqttClient m_client;
};
Пояснения к коду
Подписка через m_client.subscribe("home/+/temperature", 0) использует тот же подстановочный символ +, разобранный концептуально в статье 311 — одна подписка автоматически покрывает температурные топики любой комнаты, без необходимости знать заранее полный список комнат или подписываться на каждую отдельно. onTemperatureMessage() извлекает конкретное название комнаты непосредственно из полного имени топика, по которому пришло сообщение (message.topic().name()), что демонстрирует практичный способ использования самой структуры топика как источника дополнительного контекста, а не только полезной нагрузки сообщения.
Каждая подписка возвращает свой собственный объект QMqttSubscription с собственным сигналом messageReceived — это позволяет подключать разные обработчики к разным подпискам (onTemperatureMessage для температурных топиков, onLightStateMessage для состояния света), вместо одного универсального обработчика, разбирающего все возможные типы сообщений вперемешку, что аналогично структурным преимуществам MessageDispatcher из статьи 288, но реализованным через нативный механизм подписок самого MQTT-протокола.
Подводные камни
- Создание подписки и подключение к её сигналу
messageReceivedтолько внутриonConnected(), без сохранения возвращённого указателя наQMqttSubscriptionгде-либо за пределами этого метода — если подписка нужна на постоянной основе (а не разово), и методonConnected()может быть вызван повторно при переподключении, важно либо явно отслеживать уже существующие подписки, либо понимать, как конкретноQMqttClientведёт себя при повторной попытке подписаться на тот же топик после восстановления соединения, чтобы не создавать неожиданное дублирование обработки сообщений. - Жёстко закодированный ожидаемый формат структуры топика (
parts.size() != 3для температуры,!= 4для состояния света) делает код хрупким при изменении соглашения об именовании топиков где-либо в системе — если прошивка устройства начнёт публиковать топики с другой структурой (добавится дополнительный уровень иерархии), код разбора на Qt-стороне молча перестанет обрабатывать такие сообщения без явной ошибки, что может остаться незамеченным долгое время. - QoS, указанный при подписке (0 в примере), должен соответствовать реальным требованиям к надёжности доставки для конкретного типа данных (статья 311) — для команд управления, в отличие от часто обновляемых показаний датчиков, может быть оправдан более высокий QoS, и выбор одного и того же низкого QoS для абсолютно всех подписок без разбора их разной критичности — упрощение, не всегда соответствующее реальным потребностям конкретных типов сообщений в системе.
- Отсутствие явной обработки сценария, когда
m_client.subscribe()возвращаетnullptr(что происходит, если подписка не удалась, например при превышении лимита одновременных подписок или ошибке протокола) — без проверки этого возвращаемого значения (как частично показано в примере для первой подписки, но не для второй) попытка вызватьconnect()на нулевом указателе привела бы к неопределённому поведению или явному крашу приложения.