Embedded

Прошивка ESP32: подписка на топики и удалённое управление устройством

Введение

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

Концепция

PubSubClient принимает подписку через subscribe(topic), после чего все сообщения, опубликованные в этот топик любым другим клиентом сети, доставляются текущему устройству через зарегистрированную callback-функцию, вызываемую библиотекой автоматически при получении нового сообщения — внутри этой функции прошивка разбирает полученные данные и выполняет соответствующее действие, аналогично обработке команд через Serial из основного Arduino-цикла, но теперь источником команд является сеть, а не физический кабель.

Пример кода: Прошивка ESP32

// firmware/src/main.cpp — подписка на топик управления реле через MQTT
#include <WiFi.h>
#include <PubSubClient.h>

const char *WIFI_SSID = "MyHomeNetwork";
const char *WIFI_PASSWORD = "secure_password_here";
const char *MQTT_BROKER = "192.168.1.100";
const char *DEVICE_ID = "esp32_livingroom_01";
const char *COMMAND_TOPIC = "home/livingroom/light/set";
const char *STATE_TOPIC = "home/livingroom/light/state";

#define RELAY_PIN 5

WiFiClient espClient;
PubSubClient mqttClient(espClient);

void setup()
{
    Serial.begin(115200);
    pinMode(RELAY_PIN, OUTPUT);
    connectToWiFi();
    mqttClient.setServer(MQTT_BROKER, 1883);
    mqttClient.setCallback(onMqttMessage); // регистрация обработчика входящих сообщений
}

void loop()
{
    if (WiFi.status() != WL_CONNECTED) connectToWiFi();
    if (!mqttClient.connected()) connectToMqtt();
    mqttClient.loop();
}

void connectToWiFi()
{
    if (WiFi.status() == WL_CONNECTED) return;
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
    while (WiFi.status() != WL_CONNECTED) delay(500);
}

void connectToMqtt()
{
    if (mqttClient.connect(DEVICE_ID)) {
        // Подписка выполняется ПОСЛЕ успешного подключения, и должна повторяться
        // при каждом переподключении — подписка не сохраняется автоматически между сессиями
        mqttClient.subscribe(COMMAND_TOPIC);
        publishState(); // сообщаем текущее состояние сразу после восстановления связи
    }
}

void onMqttMessage(char *topic, byte *payload, unsigned int length)
{
    // payload приходит как массив байт без завершающего нуля — явное преобразование в строку обязательно
    String message;
    for (unsigned int i = 0; i < length; i++) {
        message += (char)payload[i];
    }

    if (String(topic) == COMMAND_TOPIC) {
        if (message == "ON") {
            digitalWrite(RELAY_PIN, HIGH);
        } else if (message == "OFF") {
            digitalWrite(RELAY_PIN, LOW);
        }
        publishState(); // подтверждаем фактическое состояние после выполнения команды
    }
}

void publishState()
{
    const bool isOn = digitalRead(RELAY_PIN) == HIGH;
    mqttClient.publish(STATE_TOPIC, isOn ? "ON" : "OFF", true); // retain=true — см. пояснения ниже
}

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

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

publish(STATE_TOPIC, isOn ? "ON" : "OFF", true) — третий аргумент true устанавливает флаг retain, означающий, что брокер сохраняет последнее опубликованное в этот топик сообщение и немедленно отправляет его любому новому подписчику сразу при подписке, даже если это сообщение было опубликовано до того, как новый подписчик подключился — это важно для топиков состояния (light/state): Qt-приложение, запущенное или переподключившееся позже момента изменения состояния, сразу узнает текущее состояние света, не дожидаясь следующего реального изменения.

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

  • Забытая повторная подписка после переподключения к брокеру — если код подписывается на топик только один раз при самом первом успешном подключении (а не при каждом восстановлении соединения после разрыва, как в примере), после временного сбоя сети и последующего автоматического переподключения устройство перестанет получать команды управления, даже формально оставаясь «подключённым» к брокеру — подписка должна повторяться при каждом успешном connect(), а не один единственный раз за всё время работы устройства.
  • Обработка payload без явного учёта того, что это просто массив байт без завершающего нуля (в отличие от привычных C-строк) — попытка напрямую интерпретировать payload как обычную null-terminated строку (например, прямой String((char*)payload) без учёта длины length) может прочитать за пределы реальных полученных данных, захватив случайный «мусор» из памяти после конца сообщения; явный цикл по length байт, как в примере, — корректный, безопасный способ преобразования.
  • Отправка команды без последующего реального подтверждения её выполнения (если бы прошивка просто выполняла команду без вызова publishState() после) — без отдельного топика состояния, обновляемого именно после фактического выполнения действия, Qt-приложение, отправившее команду «включить свет», не имеет надёжного способа узнать, действительно ли команда была получена и выполнена устройством, а не просто потерялась где-то по дороге — явное разделение команды и состояния (статья 311) и обязательная публикация состояния после каждого изменения — важная практика надёжного MQTT-проектирования.
  • Использование retain=true без понимания его долгосрочных последствий — retained-сообщение продолжает храниться брокером даже после отключения публиковавшего его устройства, и новый подписчик получит это, возможно, уже устаревшее состояние, потенциально не отражающее реальное текущее физическое состояние устройства, если оно было выключено или заменено без явной публикации актуального состояния перед отключением — для критичных состояний стоит явно продумать механизм очистки или обновления retained-сообщений при ожидаемом отключении устройства (например, через Last Will and Testament — отдельную возможность MQTT, публикующую заданное сообщение от имени клиента при обнаружении его неожиданного отключения брокером).