Перейти к содержимому

Arduino ESP32 шаг за шагом. Телеметрия через WiFi и MQTT для чайников

Доброго здравия, уважаемые читатели!

Пролог

Постоянные читатели моего канала и сайта, наверное, знают, что я практически не использую в своих “рабочих” проектах для ESP32 Arduino IDE и Arduino-фреймворки. Я предпочитаю использовать “родной” для ESP32 фреймворк – ESP-IDF, непосредственно или с помощью PlatformIO. Об этом я писал не уже раз – можете поискать на этом сайте, почему я так делаю. Поэтому и статей на тему Arduino стараюсь избегать.

Когда-то я написал статью Телеметрия на ESP8266 + MQTT. Пошаговое руководство по созданию DIY-проекта с удаленным управлением, в которой было достаточно подробно (на мой взгляд) рассказано, как создать проект для Arduino и ESP8266. Насколько мне известно, очень многие читатели пытались адаптировать код, приведенный в ней, и для ESP32. Но сделать это оказалось не очень просто и не всем под силу, потому что: 1) ESP8266 отличается от ESP32 довольно значительно, 2) в фреймворке Arduino ESP32 (который как раз и является адаптацией ESP-IDF под принципы Arduino) используются немного другие библиотеки, классы и методы работы. Разумеется, у начинающих программистов это может вызывать некоторые трудности. Многие просили помочь с адаптацией кода.

Поэтому я и решил написать новую статью, специально ориентированную под фреймворк Arduino ESP32, все-таки ESP8266 сильно устарел. Хотя мне и не очень нравится такой подход в программировании ESP32.

Но в данной статье я, возможно, в некоторых случаях “пойду своим путём” и предложу альтернативные подходы, отличающиеся от уже известных примеров в сети Интернет. А кроме этого, расскажу про “путь самурая”, или как можно легко и просто использовать библиотеки ESP-IDF в проектах Arduino.

Я то расскажу, но Вы сами вольны решать – какой способ вы будете использовать для вашего проекта.

 


О чем эта статья?

Данная статья ориентирована в первую очередь на начинающих программистов, которые хотят научиться программированию микроконтроллеров ESP32. Не тупо переписать код из примеров, а разобраться и понять – почему это сделано так и как можно сделать по другому. Поэтому без необходимой “теории” не обойдется, будет много “лишнего” текста. В этой и последующих статьях серии я постараюсь максимально подробно рассказать, как создать проект на ESP32 с подключением к сети WiFi и удаленным управлением через MQTT с помощью фреймворка Arduino. Ибо зачем ещё связываться с ESP32, если не подключать его к сети? – для автономной работы есть огромная куча других микроконтроллеров. Конечно, никто не запрещает использовать ESP32 в полностью автономном режиме, но по опыту знаю, что ESP32 обычно в первый раз покупают, чтобы подключить его к сети и порулить им на расстоянии. Ведь вы за этим и открыли данную страницу сайта? Итак…

В данной статье я достаточно подробно расскажу как:

  1. Создать Arduino – проект для ESP32 в различных IDE: Arduino IDE v2, Visual Studio Code+PlatformIO и Visual Studio Code+ESP-IDF в режиме “Arduino как компонент ESP-IDF”.
  2. Настроить проект в минимальной комплектации
  3. Подключить необходимые библиотеки к проекту
  4. Подключить ESP32 к вашей сети WiFi.
  5. Получить точное текущее время с NTP-сервера в интернете.
  6. Подключиться к MQTT-брокеру, в том числе и по TLS/SSL-протоколу
  7. Отправить HTTP или HTTPS запрос на какой-нибудь сервер в сети интернет
  8. Сохранять значения параметров на FLASH-памяти ESP32

В следующих статьях серии я постараюсь рассказать как:

  1. Создать устройство для дистанционного управления реле через MQTT
  2. Опубликовать состояние цифровых входов (например нажатие на кнопку) на MQTT-брокере
  3. Прочитать и опубликовать температуру с датчика температуры и влажности, например DHT22
  4. Создать персональную метеостанцию и отправить данные на народный мониторинг, open-monitoring.online или thingspeak.com
  5. Использовать измеренные данные с датчика температуры и влажности, например для управления отоплением или вентилятором

 

Вам понадобятся:

 

По сути, эту статью можно было бы разбить на несколько отдельных статей. Но потом я решил – пусть все будет в одном месте, а для удобства поиска нужной информации пусть будет оглавление. В конце статьи, как обычно, есть ссылка на готовые демо проекты на GitHub (вам останется только изменить данные для подключения на свои), так что если Вам лень читать – листайте в конец статьи. 

 


Оглавление

 


Знакомство с ESP32

Прежде чем мы начнем, необходим четко уяснить, чем ESP32 отличается от ESP8266 и Arduino-контроллеров, с которыми вы, возможно, имели дело ранее.

Весь программный интерфейс (API) ESP32 спроектирован так, чтобы всегда работать под управлением операционной системы реального времени FreeRTOS. То есть “внутри” прошивки любого ESP32 будет всегда работать планировщик, задачи, очереди, мьютексы и вот это все.

– “Позвольте“, спросит кто-то, “я запускаю Arduino IDE”, создаю новый проект, там есть setup() и loop(), и нет никаких портосов! Знать ничего не знаю и не знать хочу!

Ответ состоит в том, что если вы не видите суслика FreeRTOS, это ещё не значит что его нет! Он запускается на нулевом ядре при старте ESP32 и сидит тихо мирно и починяет примус выполняет некоторые фоновые задачи – работает планировщик, службы таймеров, сторожевой таймер WDT и некоторые другие.

Дело в том, что ваш Arduino-проект, код которого вы видите у себя на экране – это всего-лишь пользовательская задача FreeRTOS, которая запускается уже на первом ядре с размером стека 8 килобайт. По умолчанию, Arduino Core создает задачу, которая вызывает функции setup() и затем бесконечно вызывает loop() внутри FreeRTOS-задачи.

Пример простейшего Arduino-кода:

#include "Arduino.h"

void setup() {
  Serial.begin(115200);
  // Ваша инициализация
}

void loop() {
  Serial.println("loop");
  delay(1000);
}

Внутри Arduino-ESP32 этот скетч запускается примерно так (упрощённо):

#include "Arduino.h"

void arduinoTask(void *pvParameters) {
  setup();
  while (true) {
    loop();
  }
}

extern "C" void app_main() {
  initArduino();
  xTaskCreatePinnedToCore(
    arduinoTask,
    "arduinoTask",
    8192,
    NULL,
    1,
    NULL,
    1
  );
}

Примерно так же можете и вы – запускать задачи, создавать очереди, события и прочие объекты FreeRTOS. Даже из Arduino IDE. А также пользоваться многими другими API, которые есть в ESP-IDF. Для всего этого в Espressif разработали платформу Arduino ESP32 (мне еще попадалось название просто Arduino32).

 

Особенности программирования в многозадачной среде RTOS

Поскольку ESP32 работает под управлением операционной системы реального времени (RTOS), это необходимо всегда “держать в уме”. Даже если вы не создаете никаких задач, очередей и каких-либо других объектов FreeRTOS.

Повторю ещё и ещё раз: ваш Arduino-скетч – это одна из задач FreeRTOS. В связи с этим вам придется познакомиться с понятиями как задача, планировщик задач, приоритет и т.д. и т.п.

 

Кардинальная смена идеологии программирования

Давайте вспомним, как нас учили (и учат до сих пор) программировать в Arduino на простых однопоточных контроллерах без каких то там RTOS:

  • Рабочий цикл loop() должен выполняться непрерывно и каждый его цикл должен занимать как можно меньше времени
  • Использование функций для генерации задержек типа delay() считается дурным тоном, так как “крадет” время процессора и противоречит предыдущему пункту

На ESP32 это уже не совсем так. Можно даже сказать – наоборот. Этот момент требует отдельного пояснения.

В FreeRTOS используется вытесняющая многозадачность (preemptive multitasking). Это значит, что любая задача может быть прервана планировщиком операционной системы, если появляется задача с более высоким приоритетом, или для переключения между задачами с одинаковым приоритетом (тайм-слисинг, то есть по таймеру). Таким образом, задачи не обязаны сами уступать управление — планировщик сам решает, когда и какую задачу запускать, чтобы обеспечить справедливое распределение времени между ними. Но, с другой стороны, управление задачам с меньшим приоритетом может быть передано только в том случае, если нет “бодрствующих” задач с большим приоритетом.

Поэтому считается очень хорошим тоном, когда задача сама полностью добровольно уступает управление другим задачами и “засыпает” на некоторое время. Это позволяет выполняться задачам с меньшим приоритетом (иначе они никогда не получат процессорного времени), и обеспечивает более равномерное распределение ресурсов процессора. Сделать это можно с помощью функций задержки vTaskDelay(), vTaskDelayUntil() или с помощью специальных функций ожидания какого-либо события или объекта. 

ℹ Если не соблюдать это требование (добровольный уход в сон) и попытаться выполнять цикл задачи непрерывно, через какое время сработает сторожевой таймер WDT и погрозит пальчиком – “ай-ай-ай, нехорошо это!”.

В связи с этим и принцип работы delay() кардинально изменился на противоположный: если раньше с помощью него мы просто тупо приостанавливали выполнение программы, то на ESP32 в фреймворке Arduino ESP32 он просто вызывает тот же самый vTaskDelay(), который просто заставляет задачу “заснуть” на заданное количество тиков операционной системы. То есть не “приостанавливает выполнение всего-всего-всего“, а “передает управление другому“.

void delay(uint32_t ms)
{
    vTaskDelay(ms / portTICK_PERIOD_MS);
}

Обо всем этом более подробно можно почитать в другой статье: ESP-IDF: а что под капотом? Обзор базовых объектов.

Подведем итоги:

  • Было на Arduino AVR и ESP8266 (NO RTOS)loop() должен выполняться как можно быстрее и непрерывно, delay() не желателен, так как “крадет” время процессора
  • Стало на ESP32 (причем не важно – ESP-IDF или Arduino ESP32): желательно, чтобы цикл задачи добровольно периодически отдавал управление задачам с меньшим приоритетом с помощью delay()vTaskDelay() или функций ожидания; delay() работает по другому.

К слову, есть ещё один вариант добровольно отдать управление – макрос taskYIELD(), но он просто позволяет отдать управление планировщику до истечения выделенного задаче кванта времени и не влияет на задачи с меньшим приоритетом.

 

Необходимость защиты персональных данных блокировки совместного доступа к одним и тем же ресурсам из разных задач

Каждая задача имеет свой собственный и глубоко личный стек и некоторые другие данные, которые в совокупности называются контекстом задачи. Контекст задачи — это среда выполнения кода внутри задачи RTOS, со всеми её атрибутами: собственным стеком, приоритетом и возможностью быть вытесненной планировщиком задач. В стеке сохраняются локальные переменные, аргументы и адреса возврата вызываемых функций и состояние регистров процессора при переключении управления при прерываниях. Залезать в “чужой” стек категорически не допускается.

Кроме вашей задачи, в системе запущены и работают и другие задачи, например системный цикл событий, который по обрабатывает поступающие сигналы из всех других задач. А ещё есть обработчики прерываний. Все они могут вызывать те или иные функции обратного вызова (callbacks). И эти самые обработчики функций обратного вызова, если вы их используете, как правило вызываются не из контекста вашего скетча, а из контекста цикла событий или прерываний!

Из всего этого следует простой вывод: на ESP32 “по умолчанию” запрещен прямой доступ к данным задач из функций обратного вызова и прерываний. Даже если вы знать ничего не хотите об этой вашей RTOS. Конечно, иногда могут быть исключения. Например – атомарные операции, когда данные в переменную записываются за один такт процессора. Теоретически это должны быть целые числа до INT32, но…. официальная документация Espressif знать об этом ничего не знает. А по этому не стоит надеяться на это на все 100%.

 

Прерывание выполнения задачи в любой момент

Планировщик задач может остановить вашу задачу (скетч) в любой момент и отдать управление другой задаче с более высоким приоритетом. А вы, например, только что собрались измерять интервал между импульсами или передавать по шине критически важные данные. И ничего хорошего из этого не выйдет, если заранее не предпринять специальных мер. 

 

Выводы

Из всех вышесказанного можно сделать вывод: хотите сами программировать ESP32 – будьте добреньки изучить хотя бы самый минимум по FreeRTOS и многопоточной безопасности. По другому – никак. Иначе будут проблемы, глюки, сбои и спонтанные перезагрузки по казалось бы непонятным причинам.

 

Загрузчик (bootloader) и flash-память

Чипы ESP8266 и ESP32 имеют довольно небольшой размер встроенной EPROM-памяти – в него помещается только специальная программа – загрузчик первой очереди. Для хранения и запуска двоичного скомпилированного кода программ и данных используется внешняя (или в некоторых случаях встроенная непосредственно в чип) микросхема flash-памяти. Под словом “внешняя” в данном случае понимается то, что она находится вне корпуса чипа (но для модулей она находится внутри модуля, под жестяной крышечкой). Микросхема flash-памяти подключена к микроконтроллеру через один из интерфейсов SPI. Стандартный размер flash-памяти – 4Мб, но могут быть и другие варианты – 2, 8 или 16 Мб.

Внутреннее устройство flash-памяти напоминает устройство жесткого диска вашего компьютера – все доступное пространство разбивается на отдельные разделы, каждый из которых используется строго по его назначению. Есть разделы, предназначенные для хранения прошивки, служебных данных или данных пользователя. Например:

  • если вы планируете хранить какие-либо настройки, вам потребуется раздел nvs,
  • для хранения прошивки – необходим раздел app,
  • если планируете использовать “обновления по воздуху” (OTA) – то нужны уже как минимум два раздела app,
  • для создания файлов потребуется раздел spiffs или ffat.

Соответственно, доступный для прошивки размер памяти всегда существенно меньше 4Мб. Подробнее о том, как выбрать таблицу разделов будет рассказано немного ниже.

Итак, ваша прошивка храниться на flash-памяти. Как она запускается? При запуске CPU из встроенной EPROM запускается загрузчик первой очереди. Он инициализирует некоторое “железо”, регистры, подключается к flash-памяти и запускает из специального раздела загрузчик второй очереди. Загрузчик второй очереди выполняет всю остальную работу по считыванию и запуску вашей прошивки.

Этот же самый bootloader управляет и записью прошивки на flash-память. Он позволяет загружать скомпилированный вами код в микроконтроллер без использования специальных аппаратных программаторов. То есть по сути это программный программатор ESP. Именно он берет на себя функции получения двоичных данных из UART и записи на flash-память. 

Перевод загрузчика в режим программирования (приема прошивки) производится с помощью strapping pin GPIO0 – низкий уровень на этом выводе при сбросе микроконтроллера означает, что загрузчику нужно быть готовым к принятию двоичных данных прошивки. К этому выводу почти на всех платах с ESP подключена кнопка BOOT. Соответственно для перевода ESP32 в режим программирования необходимо зажать кнопку BOOT, и не отпуская её, кратковременно нажать кнопку RESET. После данных манипуляций микроконтроллер переходит в режим программирования. 

На платах разработчика (ESP32-DevKit-V1, ESP32-DevKitС-V4 и подобных) обычно стоит специальная схема для управления уровнями на GPIO0 и RESET, поэтому нажимать кнопку BOOT обычно не требуется. Но для плат типа ESP32 Relay X4 приходится выполнять данные манипуляции вручную.

 

Фреймворк (платформа) Arduino ESP32

Arduino ESP32 — это платформа, предоставляющая поддержку микроконтроллеров серии ESP32 (и их вариантов, таких как ESP32-C3, ESP32-S2, ESP32-S3 и др.) в среде разработки Arduino. Это позволяет использовать привычный и простой язык программирования Arduino (C++) и его экосистему библиотек для разработки приложений под чипы Espressif, не вдаваясь в детали низкоуровневого программирования или работы с официальным фреймворком ESP-IDF. Проект поддерживается Espressif и сообществом, и его документация доступна онлайн здесь.

В его состав входит Arduino core (ядро Arduino для ESP32) — это программный слой, который реализует совместимость между аппаратной платформой ESP32 и стандартным API Arduino. Он включает драйверы, библиотеки и интеграцию с Arduino IDE, чтобы код, написанный для Arduino, мог работать на ESP32. Таким образом, Arduino core для ESP32 — это адаптация Arduino API и среды для работы с чипами Espressif. 

В состав фреймворка Arduino ESP32 входят как адаптации под ESP32 уже привычных стандартных Arduino-библиотек; так и специальные библиотеки, реализующие работу с аппаратными возможностями чипов ESP32:

  • ArduinoOTA — обеспечивает обновление прошивки “по воздуху” (OTA)
  • AsyncUDP — асинхронная работа с UDP-протоколом
  • BLE — поддержка Bluetooth Low Energy (BLE) v4.2
  • BluetoothSerial — сервер последовательной передачи данных по Bluetooth Classic (только для ESP32, не поддерживается на ESP32-S2, ESP32-C3, ESP32-S3)
  • DNSServer — базовый DNS-сервер, включая captive portal
  • EEPROM — эмуляция EEPROM в NVS-разделе Fдфыр-памяти, можно использовать для быстрого перехода с других микроконтроллеров
  • ESP32 — здесь собраны дополнительные примеры для работы с аналоговым выводом, камерой, DeepSleep, FreeRTOS, GPIO, I2S, Touch и др.
  • ESPmDNS — сервис mDNS
  • Ethernet — поддержка Ethernet-соединений для плат, которые имеют контроллер Ethernet
  • FFatLittleFSSPIFFS — поддержка различных файловых систем для работы с Flash-памятью
  • HTTPClientHTTPUpdateHTTPUpdateServer — работа с HTTP-запросами и обновлениями по HTTP
  • NetBIOS — NetBIOS name advertiser
  • Preferences — энергонезависимое хранилище параметров в виде пар “ключ”-“значение”, более удобная и во всем лучшая альтернатива EEPROM
  • ESP RainMaker — поддержка платформы RainMaker для быстрого создания IoT-устройств
  • SDSD_MMC — библиотека для работы с SD-картами
  • SimpleBLE — минимальный BLE advertiser
  • SPI — драйвер SPI, поддерживается только режим master
  • SR — библиотека для AI-решений на базе ESP32-S3 и ESP32-P4 (например, распознавание речи)
  • Ticker — таймер для вызова функций по интервалам
  • Update — библиотека для обновления скетча через OTA
  • USB — драйвер USB (только device)
  • WebServer — простой HTTP-сервер
  • WiFi — драйвер Wi-Fi
  • NetworkClientSecure — Wi-Fi клиент с поддержкой TLS-шифрования
  • Wire — драйвер шины I2C

Для каждой библиотеки есть примеры использования, которые можно найти в папке examples соответствующей библиотеки. Некоторые библиотеки реализуют стандартные Arduino API, другие — специфичны только для ESP32 и расширяют возможности платформы Arduino. Полный список и описание библиотек можно найти в официальной документации и в README arduino-esp32.

Но для разработчиков Arduino ESP32 доступны нее только эти библиотеки, но и многие библиотеки ESP-IDF – в чем мы сегодня с вами и убедимся. В данной статье будут примеры использования нативных библиотек ESP-IDF в обычном коде Arduino.

 

Что мы теряем при использовании Arduino ESP32?

То есть получается, что нет необходимости использовать ESP-IDF? Ведь все равно все и так доступно.

ℹ Arduino ценен в первую очередь даже не простотой кода, а огромным количеством публичных библиотек для различных датчиков и устройств. ESP-IDF похвастаться этим, увы, не может.

С одной стороны это, конечно же так. Но давайте посмотрим, что мы теряем, если отказываемся от “чистой” ESP-IDF. Если использовать Arduino ESP32 вместо ESP-IDF, вы получаете более простой и быстрый старт, но теряете ряд возможностей и гибкости, которые предоставляет официальный фреймворк Espressif:

  • Ограниченный доступ к низкоуровневым функциям и настройкам. Arduino core инкапсулирует только часть функций ESP-IDF, поэтому некоторые продвинутые возможности и тонкая настройка железа могут быть недоступны или сильно ограничены.
  • Ограниченные возможности настройки. В Arduino IDE отсутствует штатная возможность редактирования файла sdkconfig.h, который используется для настройки проекта. В стандартной Arduino IDE изменить параметры sdkconfig напрямую нельзя, так как Arduino ESP32 использует заранее скомпилированные библиотеки, и изменения в файлах sdkconfig или sdkconfig.h внутри проекта Arduino не повлияют на итоговую прошивку. См. следующий раздел.
  • Ограниченные возможности отладки. В Arduino IDE отсутствуют продвинутые инструменты отладки, которые доступны в ESP-IDF (например, интеграция с VSCode/Eclipse, трассировка, профилирование и др.).
  • Меньше гибкости для сложных проектов. Для сложных и профессиональных проектов, где требуется модификация низкоуровневого кода, использование оригинального ESP-IDF гораздо предпочтительнее, так как Arduino core не позволяет менять внутренние механизмы работы фреймворка.
  • Ограниченная поддержка новых функций и чипов. Некоторые новые возможности и поддержка последних чипов могут появляться в ESP-IDF раньше, чем в Arduino core, либо быть недоступны вовсе.
  • Меньше возможностей для оптимизации. В ESP-IDF доступны гибкие настройки компиляции, управления памятью, энергопотреблением и т.д.

Таким образом, Arduino ESP32 подходит для новичков и быстрой разработки, прототипирования и простых проектов, но для профессиональной разработки и использования всех возможностей чипа рекомендуется использовать другой фреймворк ESP-IDF, который предоставляет больше гибкости и необходимых инструментов.

И я с этим полностью согласен и поэтому я не использую Arduino ESP32 в своих проектах. И вам не советую. Но вы ж меня не слушаете, и продолжаете “стоять на своем”. Что ж… Сами просили, сами виноваты – теперь читайте…

 

Настройка параметров native библиотек и проекта

Файл sdkconfig в проекте ESP-IDF (и Arduino ESP32 тоже) хранит текущие значения всех параметров конфигурации проекта, таких как настройки железа, опции компиляции, таблица разделов flash-памяти, параметры компонентов и т.д. Этот файл автоматически обновляется при изменении конфигурации через специальные инструменты и не рекомендуется для ручного редактирования, так как между параметрами могут быть зависимости, которые легко нарушить вручную.

Как редактировать sdkconfig:

  • Основной способ — использовать команду py menuconfig, которая открывает текстовый интерфейс для настройки всех параметров. После сохранения изменения автоматически записываются в файл sdkconfig.
  • Также можно использовать IDE с поддержкой ESP-IDF (например, Visual Studio Code с ESP-IDF плагином или Espressif-IDE), где есть графический редактор конфигурации, работающий с этим файлом.

Но изменение sdkconfig в случае использования Arduino ESP32 напрямую невозможно! Это связано с тем, что ESP-IDF библиотеки включаются в проект Arduino как уже собранные бинарные файлы, см. FAQ Arduino-esp32.

Если вам все-таки нужно изменить параметры sdkconfig, есть два поддерживаемых способа:

  1. Использовать Arduino как компонент в проекте ESP-IDF
    Вы создаёте проект на ESP-IDF и добавляете Arduino как компонент. В этом случае вы получаете доступ к инструменту menuconfig и можете настраивать sdkconfig так же, как в обычном проекте ESP-IDF. Подробнее: Arduino as an ESP-IDF component.
  2. Использовать Arduino Library Builder
    Этот инструмент позволяет собрать Arduino core с нужными параметрами sdkconfig. В составе Lib Builder есть редактор sdkconfig, где вы можете изменить нужные опции перед компиляцией библиотек. Подробнее: Library Builder — Sdkconfig Editor.

В данном проекте нам не потребуется что-либо изменять, можно оставить значения по умолчанию. Этот раздел был изложен просто для общего понимания ситуации, и может пригодится вам в будущем.

 

Настройка таблицы разделов flash-памяти

Одним из самых важных параметров ESP32, является таблица разделов flash-памяти. Если вы когда-либо создавали проекты с помощью ESP-IDF, то наверняка знаете, что можно создать произвольную “разметку диска”, которую потом подгрузить в систему сборки с помощью вышеупомянутой системы конфигурации ESP-IDF menuconfig. Об этом я писал в статье Настройка таблицы разделов FLASH-памяти для ESP32.

Но… как я уже написал чуть выше, фреймворк Arduino ESP32 лишает нас нормальной возможности пользоваться menuconfig. Дабы обойти это ограничение, разработчики придумали набор заранее настроенных таблиц разметок, из которых вы можете подобрать себе один приемлемый вариант. Набор разметок будет зависеть от того, какую именно плату вы выбрали в вашей IDE.

От выбранной таблицы разметки, в свою очередь, напрямую зависит размер раздела (или разделов) APP, в который(е) загружается ваше приложениеВы можете иметь 4 Мб flash-памяти, но не сможете, например, загрузить скетч размером более 512 Кб.

Например, для платы ESP32 Dev Module этот перечень может выглядеть так:

  • Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)разметка для модулей с 4 Мб flash “по умолчанию” с поддержкой SPIFFS и OTA:
    • раздел nvs размером 0x5000 байт (что составляет 20480 байт в десятичном выражении или 20 Кб),
    • 2 раздела app (ota_0 & ota_1) по 0x140000 байт (что составляет 1,25 Мб) каждый,
    • раздел spiffs размером 0x160000 байт (что составляет 1,375 Мб),
    • раздел coredump размером 0x10000 байт (что составляет 64 Кб).

       

  • Default 4MB with ffat (1.2MB APP/1.5MB FATFS)почти то же самое, но раздел SPIFFS заменен на FFAT:
    • раздел nvs размером 0x5000 байт,
    • 2 раздела app (ota_0 & ota_1) по 0x140000 байт каждый,
    • раздел ffat размером 0x160000 байт,
    • раздел coredump размером 0x10000 байт.

       

  • 8M with spiffs (3MB APP/1.5MB SPIFFS) разметка для модулей с 8 Мб flash с поддержкой SPIFFS и OTA:
    • раздел nvs размером 0x5000 байт,
    • 2 раздела app (ota_0 & ota_1) по 0x330000 байт каждый,
    • раздел spiffs размером 0x180000 байт,
    • раздел coredump размером 0x10000 байт.

       

  • Minimal (1.3MB APP/700KB SPIFFS)минималистичный вариант для модулей с 4 Мб flash, имеет только один раздел app, поэтому не подходит для OTA
    • раздел nvs размером 0x5000 байт,
    • 1 раздел app (ota_0) размером 0x140000 байт,
    • раздел spiffs размером 0xA0000 байт,
    • раздел coredump размером 0x10000 байт.

       

  • No OTA (2MB APP/2MB SPIFFS) вариант без ОТА для модулей с 4 Мб flash, имеет только один раздел app
    • раздел nvs размером 0x5000 байт,
    • 1 раздел app (ota_0) размером 0x200000 байт,
    • раздел spiffs размером 0x1E0000 байт,
    • раздел coredump размером 0x10000 байт.

       

  • No OTA (1MB APP/3MB SPIFFS) вариант без ОТА для модулей с 4 Мб flash, имеет только один раздел app
    • раздел nvs размером 0x5000 байт,
    • 1 раздел app (ota_0) размером 0x100000 байт,
    • раздел spiffs размером 0x2E0000 байт,
    • раздел coredump размером 0x10000 байт.

       

  • No OTA (2MB APP/2MB FATFS) аналогично пред-предыдущему варианту, но с FFAT
    • раздел nvs размером 0x5000 байт,
    • 1 раздел app (ota_0) размером 0x200000 байт,
    • раздел ffat размером 0x1E0000 байт,
    • раздел coredump размером 0x10000 байт.

       

  • No OTA (1MB APP/3MB FATFS) аналогично пред-предыдущему варианту, но с FFAT
    • раздел nvs размером 0x5000 байт,
    • 1 раздел app (ota_0) размером 0x100000 байт,
    • раздел ffat размером 0x2E0000 байт,
    • раздел coredump размером 0x10000 байт.

       

  • Huge APP (3MB No OTA/1MB SPIFFS) вариант без ОТА с относительно небольшим разделом spiffs для модулей с 4 Мб flash
    • раздел nvs размером 0x5000 байт,
    • 1 раздел app (ota_0) размером 0x300000 байт,
    • раздел spiffs размером 0xE0000 байт,
    • раздел coredump размером 0x10000 байт.


  • Minimal SPIFFS (1.9MB APP with OTA/190KB SPIFFS)вариант для модулей с 4 Мб flash с поддержкой OTA и минимальным разделом SPIFFS

    • раздел nvs размером 0x5000 байт,
    • 2 раздела app (ota_0 и ota_1) размером по 0x1E0000 байт каждый,
    • раздел spiffs размером 0x20000 байт,
    • раздел coredump размером 0x10000 байт.


  • 16M Flash (2MB APP/12.5MB FATFS) разметка для модулей с 16 Мб flash с огромным разделом FFAT и с поддержкой OTA:
    • раздел nvs размером 0x5000 байт,
    • 2 раздела app (ota_0 & ota_1) по 0x200000 байт каждый,
    • раздел spiffs размером 0xBE0000 байт,
    • раздел coredump размером 0x10000 байт.

       

  • 16M Flash (3MB APP/9.9MB FATFS) – разметка для модулей с 16 Мб flash с большим разделом FFAT и с поддержкой OTA:
    • раздел nvs размером 0x5000 байт,
    • 2 раздела app (ota_0 & ota_1) по 0x300000 байт каждый,
    • раздел spiffs размером 0x9E0000 байт,
    • раздел coredump размером 0x10000 байт

  • RainMakerразметка без файловых систем, но с поддержкой “больших” OTA и двумя NVS-разделами для разных задач
    • 2 раздела nvs размерами 0x5000 и 0x6000 байт,
    • 2 раздела app (ota_0 & ota_1) по 0x1E0000 байт каждый,
    • раздел coredump размером 0x10000 байт

Примечание: числа 0x00000 представлены в шестнадцатеричном формате. 

Имеются и другие варианты разметки, перечень может изменяться в зависимости от выбранной платы. Вы можете выбрать любой из предложенных вариантов в зависимости от объема flash-памяти, который установлен в вашем модуле или на плате.

А как же собственные варианты? А никак! Точнее – в Arduino IDE почти никак. Добавить свою разметку можно, путем редактирования существующей, но при обновлении платформы ваши изменения будут уничтожены.

 

Курс молодого бойца-ардуинщика будем считать пройденным, можно создавать проект.

 


1. Создаем и настраиваем новый проект

Создание нового проекта сильно зависит от того, какую IDE вы планируете использовать для работы: Arduino IDE, Visual Studio Code + PlatformIO IDE или Espressif IDE + Arduino как компонент в проекте ESP-IDF.

🔷 Если вы ещё не знаете, что такое IDE, и чем оно отличается от платформы, и в чем, собственно, разница между этими вариантами, советую вам прочитать другую статью: Как и на чём программировать ESP32

1.1. Arduino IDE

Если у вас ещё не была установлена Arduino IDE – советую вам вторую версию, она гораздо более удобная, чем первая. Хотя всё еще недостаточно удобная для более-менее удобной работы. Скачать дистрибутив можно по ссылке: www.arduino.cc/en/software/.

 

1.1.1. Подготовка Arduino IDE к работе

По умолчанию Arduino IDE не имеет встроенной поддержки ESP32, поэтому если вы создаете новый проект в первый раз, необходимо вначале добавить ссылку на менеджер плат а настройках iDE: https://dl.espressif.com/dl/package_esp32_index.json. В сети могут попадаться ссылки и на другие сервера, но я предпочитаю “официальный”.

Настройка менеджера плат

Теперь можно установить необходимые платы в систему.  Откройте менеджер плат через меню Инструменты → Плата → Менеджер плат, введите в строке поиска “esp32” и установите набор плат “ESP32 от Espressif Systems“.

Скачивание необходимых пакетов

Всё готово к работе.

 

1.1.2. Создаем новый проект

Создать новый проект очень легко и просто: выберите меню Файл → Новый скетч. Да даже и это делать не обязательно – пустой скетч IDE создаст сама.

 

1.1.3. Настраиваем проект Arduino IDE

Теперь необходим выбрать плату, с которой вы будете работать, порт подключения и некоторые её параметры. Это можно сделать через меню Инструменты → Плата → esp32. Например это может быть ESP32 Dev Module, которая подойдет к многим отладочным платам типа ESP32-DevKitC и NodeMCU32.

Затем выберите COM-порт, через который будете работать с этой платой:

Скриншот приведен для текущей версии платформы и выбранной платы. Для других параметров будет другой набор настроек

Ниже (на скриншоте этот блок выделен зеленым прямоугольником) можно выбрать некоторые параметры платы. Остановимся на них немного поподробнее.

🔷 Данный список зависит от версии платформы и выбранной платы, поэтому на вашем экране список может быть другим.

  • CPU Frequency – рабочая частота CPU, можно выбрать 240, 160 или 80 МГц, с повышением частоты растет скорость работы и энергопотребление
  • Flash Frequency – рабочая частота flash-памяти; если вы не знаете что это такое – лучше оставьте как есть
  • Flash Mode – режим работы SPI-шины, к которой подключена flash-память; если вы не знаете что это такое – лучше оставьте как есть
  • Flash Size – размер flash-памяти, обычно это 4Мб, но бывают модули и с 8 и с 16 Мб
  • Partition Scheme – схема разделов flash-памяти, которые мы обсуждали выше
  • Core Debug Level – уровень отладочных сообщений для системных библиотек
  • PSRAM – имеет ли ваш модуль микросхему дополнительной оперативной памяти, подключенной через SPI
  • Arduino Runs On – ядро CPU, на котором будет запущена прикладная задача; по умолчанию это CPU #1 (второе ядро)
  • Events Run On – – ядро CPU, на котором будет запущена задача цикла событий (он необходим для работы WiFi и прочего железа); по умолчанию это CPU #0 (первое ядро)
  • Erase All Flash Before Sketch Upload – стереть всю микросхему flash-памяти перед записью скетча, это может быть полезно, если необходимо удалить ранее сохраненные файлы и настройки
  • Upload Speed – скорость UART, с которой будет передаваться двоичный файл прошивки загрузчику; чем больше значение, тем быстрее, но возможны сбои
  • JTAG Adapter – если вы используете аппаратный JTAG отладчик или плату со встроенным отладчиком, то можно выбрать его здесь

Я бы не советовал изменять значения по умолчанию, если вы не совсем уверены в том, что и самое главное, зачем, вы собираетесь изменять. Для целей данной статьи вообще можно пока с этими параметрами не заморачиваться и оставить всё “как есть”

Arduino IDE не сохраняет выбранную плату, порт и параметры платы в свойствах проекта! Поэтому при одновременной работе над несколькими проектами и (или) разными портами придется изрядно помучаться и каждый раз восстанавливать настройки конкретной платы. Впрочем это не единственный недостаток этой IDE.

 

1.1.4. Подключение сторонних библиотек к проекту Arduino IDE

В процессе работы вам понадобятся некоторые сторонние библиотеки, которых нет в платформе Arduino ESP32 – например драйверы датчиков и устройств. Arduino IDE обладает довольно простым менеджером библиотек, который можно открыть через меню Скетч → Подключить библиотеку → Управление библиотеками…

Здесь вы можете найти необходимые для работы публичные библиотеки с помощью простого фильтра, и установить их в систему. Все предельно просто, что не может не радовать начинающего программиста микроконтроллеров. Но простота эта порождает некоторые неудобства в дальнейшем, когда вы освоитесь. 

Пример поиска необходимых библиотек в публичном каталоге

На операционной системе Windows скачанные библиотеки автоматически устанавливаются в папку %UserProfile%\AppData\Local\Arduino15\libraries – сюда будут помещены все библиотеки, которые вы скачиваете через менеджер библиотек; либо с помощью установки из ZIP-файлов. Сюда же вы можете поместить “свои”, общие для нескольких проектов. На этом возможности менеджера библиотек, по сути, и заканчиваются.

Если необходимо просто разделить код на несколько отдельных файлов, чтобы например вынести код отдельной задачи в отдельный файл-библиотеку, то придется поместить его в ту же самую папку, где лежит основной скетч.

При компиляции любого проекта никакой дополнительной обработки общих библиотек не производится – они компилируются все подряд, вне зависимости от того, нужны в данном проекте или нет. Это приводит к тому, что при каждой компиляции любого проекта Arduino IDE компилирует все ранее скачанные библиотеки, что приводит к существенному замедлению работы. Кроме того, это приводит к проблемам, когда та или иная библиотека может работать только с одним типом микроконтроллеров, а IDE пытается компилировать её для всего подряд.

В итоге можно сделать вывод, что Arduino IDE – это “IDE для единственного проекта“. Знаю, многие сейчас будут отрицать это и защищать любимую IDE, но позвольте я останусь при своем мнении. Я с некоторых пор не пользуюсь этой IDE, со словом “совсем”. А ваш выбор – это ваш выбор, не зря же я написал эту главу.

 


1.2. Visual Studio Code + PlatformIO

Visual Studio Code + PlatformIO – на мой взгляд гораздо более удобный инструмент, чем Arduino IDE. PlatformIO – мультиплатформенный плагин для Visual Studio Code, и умеет работать с множеством различных микроконтроллеров.

По сути, PlatformIO сам по себе ничего не компилирует, а только настраивает среду сборки под каждый конкретный микроконтроллер в соответствии с некоторыми правилами, заданными используемой в данный момент платформой. Применительно к ESP32 ваша программа в конечном итоге будет скомпилирована системой сборки Espressif, а сам PlatformIO берет на себя лишь фоновую работу по подключению сторонних библиотек и настройку зависимостей и системы сборки.

Все необходимые для этого фреймворки, параметры и инструменты компиляции для каждого конкретного микроконтроллера, в терминологии PlatformIO называются Platforms и их необходимо устанавливать дополнительно. То есть установили соответствующую “платформу” – получили возможность работать с микроконтроллерами AVR, установили другую – с ESP8266, третью – c ESP32 и т.д.

 

1.2.1. Подготовка PlatformIO к работе

Как установить и настроить PlatformIO IDE я уже писал в другой статье: Переползаем с Arduino IDE на VSCode + PlatformIO, поэтому не буду ещё раз описывать этот процесс. 

Прежде чем начинать работу с Arduino ESP32, необходимо установить платформу “Espressif 32“, которая включает в себя сразу два фреймворка – ESP-IDF и Arduino ESP32, а также все необходимые инструменты:

  • framework-arduinoespressif32
  • framework-espidf
  • tool-cmake
  • tool-dfuutil-arduino
  • tool-esp-rom-elfs
  • и т.д.

Поэтому, если ранее вы не устанавливали данную платформу, необходимо выполнить следующие шаги:

  1. Откройте PIO home (через кнопку на панели внизу или на панели слева)
  2. Откройте вкладку Platforms
  3. В строке поиска введите “esp”, например…
  4. Выберите и установите платформу Espressif 32

Установка Espressif 32

При этом необходимо дождаться сообщения об завершении процесса установки.

 

1.2.2. Создаем новый проект

Создание нового проекта подробно описано здесь: Создание PlatformIO / ESP-IDF проекта и настройка platformio.ini

Для создания нового проекта кликните на пиктограмму 🏠 на нижней панели и на открывшейся странице выберите кнопку “➕ New Project“. Откроется окно мастера создания проекта, в котором необходимо указать следующий параметры:

  • Имя проекта, то есть каталог проекта
  • Плату по умолчанию (platformio позволяет компилировать проект сразу для нескольких вариантов плат, например ESP32 и ESP32-S3). Например это может быть “Espressif ESP32 Dev Module
  • Фреймворк (платформу) разработки. В данном конкретном случае необходимо выбрать “Arduino
  • Каталог на диске, в котором находится папка с проектом. Впрочем, можно использовать каталог по умолчанию

Мастер создания нового проекта

После заполнения всех полей нажмите кнопку “Полный Финиш” для запуска процесса создания проекта.

Иногда этот процесс может “зависнуть” на длительное время. Связано это с тем, что pio пытается в фоне загружать недостающие файлы, например выбранный фреймворк. Необходимо просто подождать. Но есть обходной вариант: “снимаете” процесс через менеджер задач windows и заново запускаете PlatformIO. Вероятнее всего на этот момент папка проекта будет уже полностью сформирована. Просто откройте созданный проект и запустите компиляцию – pio вновь начнет скачивать недостающие пакеты, но уже явно, а не в фоне, и этот процесс хотя бы можно контролировать.

Если все прошло гладко, вы увидите примерно следующую заготовку кода:

Очень похоже на предыдущую главу, но в папке проекта появились подпапки и “лишние” файлы. Подробно о структуре проекта я рассказывал здесь: Создание PlatformIO / ESP-IDF проекта и настройка platformio.ini. В данной статье остановлюсь только на основных моментах:

  • Основной код находится в папке \src проекта в файле main.cpp
  • В папку \lib можно поместить локальные библиотеки, которые требуются только для данного проекта. Например это могут быть части кода, которые вы решите вынести в отдельные файлы для удобства разработки.
  • Все настройки проекта хранятся в файле platformio.ini. Этим и займемся ниже.

На остальное пока не стоит обращать внимания.

 

1.2.3. Настраиваем проект PlatformIO

Все настройки проекта PlatformIO хранятся в файле platformio.ini. И при переключении между проектами ничего не потеряется и не сбросится. Более того, можно сохранить workspace в файле и тогда при следующем открытии проекта и этого файла восстановятся даже ранее открытые папки и файлы.

Файл platformio.ini редактируется сугубо вручную. Специального “визуального интерфейса” для его настройки нет. Для редактирования файла необходимо воспользоваться средствами Visual Studio Code или сторонним текстовым редактором, например Notepad+ – по своей природе это обычный текстовый файл в кодировке UTF-8.

Официальную документацию по настройке этого файла можно почитать по ссылке: https://docs.platformio.org/page/projectconf.html. Опций много, поэтому рекомендую ознакомиться – в данной статье изложен только самый минимум.

Секции platformio.ini

Весь файл настроек разделен на секции вида [env:%target_name%], где %target_name% – целевая плата. В случае использования Espressif ESP32 Dev Module это будет [env:esp32dev]. Все настройки, касающиеся компиляции проекта для выбранной платы находятся в ней.

; PlatformIO Project Configuration File
;
;   Build options: build flags, source filter
;   Upload options: custom upload port, speed and extra flags
;   Library options: dependencies, extra library storages
;   Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html

[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino

По умолчанию в файле platformio.ini имеется только одна такая секция. Вы же можете добавить несколько отдельных секций и компилировать один и тот же проект для разных плат; или одной и той же платы, но разных вариантов сборки с разными опциями. К слову, название указанное в заголовке секции после двоеточия, ни на что особо не влияет, а сама целевая плата задается “внутри” этой секции (см. ниже по тексту) в параметре board, поэтому в названии секции можно написать и [env:esp32dev1] и [env:esp32dev_test].

[env:esp32dev_test]
platform = espressif32
board = esp32dev
framework = arduino

Когда целевых плат (секций) становится более одной, может появиться необходимость указания каких-либо опций, которые должны быть всегда одинаковы для всех вариантов сборки. Такие параметры можно определить в общей секции [env], то есть без указания целевой платформы.

; Общие для всех настройки проекта
[env]
platform = espressif32
framework = arduino

; Настройки для платы ESP32 DevKit
[env:esp32dev]
board = esp32dev

; Настройки для платы ESP32 Adafruit Feather
[env:adafruit_feather]
board = adafruit_feather_esp32_v2

В общую секцию имеет смысл записать параметры платформы и фреймворка, а также подключаемые сторонние библиотеки.

 

Параметры платы

Я не буду подробно рассматривать все возможные параметры, их очень много и это не является целью данной статьи. Расскажу только о тех, которые нам действительно могут  понадобится. Например файл конфигурации проекта может выглядеть так:

[env:esp32dev]
platform = espressif32
framework = arduino
board = esp32dev
upload_speed = 921600
monitor_speed = 115200

где:

  • platform – целевая платформа сборки, в нашем случае это espressif32
  • framework – используемый для работы фреймворк, то есть набор API – программных интерфейсов для ваших приложений, для espressif32 это может быть либо arduino либо espidf.
  • board – целевая плата, список возможных можно посмотреть, например, тут:
  • upload_speed – скорость загрузки двоичного файла в микроконтроллер, например 115200
  • monitor_speed – скорость вывода отладочных сообщений в монитор порта, должно соответствовать тому, что вы указали в Serial.begin().

 

1.2.4. Подключение сторонних библиотек к проекту PlatformIO

PlatformIO предоставляет в ваше распоряжение шикарный менеджер библиотек – это лучшее из того, что предоставляют в этом плане остальные рассматриваемые IDE. Вы можете подключать локальные частные, локальные глобальные библиотеки, библиотеки из интернета, гитхаба и т.д. и т.п. Лишь бы они были корректно оформлены.

Подключение сторонних библиотек к проекту PlatformIO я достаточно подробно (на мой взгляд) описывал здесь: Подключение библиотек к проекту PlatformIO. Повторяться не вижу никакого смысла, поэтому если вы не знакомы с этим механизмом – просто прочтите это.

Сторонние библиотеки подключаются к проекту pio через тот же самый файл platformio.ini, про который написано выше. Причем сделать это можно самыми разными способами. Я буду использовать метод подключения библиотек по ссылкам GitHub и других репозиториев, что избавляет меня от необходимости самому скачивать и устанавливать архив в систему. Например:

[env]
...
; Библиотеки
lib_deps =
    ; MQTT PubSubClient
    https://github.com/knolleary/pubsubclient

Ссылки на библиотеки, использованные в данном проекте, будут указаны ниже по тексту, в соответствующих разделах или последующих статьях цикла. Как и примеры их интеграции в проект.

Новички могут столкнуться с непростой задачей: где взять ссылки на необходимые библиотеки. Проще всего, наверное, на первых порах узнать это… в менеджере библиотек Arduino IDE – как правило в описании библиотеки есть ссылка на публичный репозиторий на GitHub или аналог. Вот и воспользуйтесь этой ссылкой. Или прямым поиском по GitHub – он там тоже неплохо устроен.

 


1.3. ESP-IDF + Arduino как компонент в проекте ESP-IDF

С некоторых пор китайцы добавили поддержку фреймворка Arduino ESP32 в свою true среду разработки Espressif IDE, которая предназначена для работы только с фреймворком ESP-IDF. Противоречие? Нет!

Дело с том, что они с некоторой версии добавили возможность подключения фреймворка Arduino к проекту ESP-IDF как внешнего компонента (библиотеки). Таким образом, фактически вы работаете с проектом ESP-IDF, и получаете доступ ко всем native возможностям, в том числе и к menuconfig. Одновременно вы можете использовать Arduino API, что существенно облегчает разработку для новичков в программировании. Бинго! Это позволит вам использовать фреймворк Arduino в ваших проектах ESP-IDF с полной гибкостью ESP-IDF.

Но это оборачивается некоторыми сложностями в настройке проекта и подключении сторонних “ардуиновских” библиотек к проекту, так как ESP-IDF умеет работать только с компонентами. Поэтому этот способ рекомендуется производителем только для опытных пользователей. Для его использования необходимо установить набор инструментов ESP-IDF.

 

1.3.1. Подготовка к работе

Как установить Espressif IDE и ESP-IDF я уже рассказывал здесь: Установка Espressif IDE в ОС Windows. Если кратко, то установка выглядит следующим образом:

  • Установите Visual Studio Code.
  • Установите расширение ESP-IDF Extension через меню Extensions (Ctrl+Shift+X).
  • Запустите команду ESP-IDF: Configure ESP-IDF Extension через Command Palette или левую панель “ESP-IDF Explorer” и следуйте указаниям мастера установки для загрузки необходимой версии ESP-IDF и необходимых инструментов. 

Подробно весь процесс установки описывать здесь нет смысла, поэтому просто почитайте об этом здесь: Установка Espressif IDE в ОС Windows

 

1.3.2. Создаем новый проект

Откройте Command Palette (Ctrl+Shift+P) и выполните команду ESP-IDF: New Project. Эта же самая команда доступна через левую панель “ESP-IDF Explorer“, но называется она уже почему-то New Project Wizard. В открывшемся окне укажите имя проекта, путь к нему, целевую плату и порт, через который будем её прошивать.

Мастер создания проекта ESP-IDF

Затем необходимо выбрать шаблон проекта. Здесь у нас есть два пути:

  • Создать пустой проект ESP-IDF и потом “вручную” добавить в него компонент arduino-esp32. Подробнее об этом процессе можно почитать из официальной документации.
  • Не мучаться и сразу выбрать шаблон arduino-as-component` из списка Extention. Тогда мастер создания проекта сам всё сделает. А если не видно разницы, то зачем платить делать больше?

Здесь созданный шаблон приложения будет немного отличаться от общепринятого:

Шаблон приложения Arduino, созданный мастером Espressif IDE

Здесь вместо привычных setup() и loop() присутствует только функция задачи “по умолчанию”, которая называется app_main(); а бесконечный рабочий цикл вы должны организовать сами, по необходимости.

Впрочем, можно из этой функции создать и запустить любые другие, “свои”, задачи, позволив app_main() завершиться. В этом случае задача по умолчанию будет завершена и выгружена из памяти за ненадобностью. Обо всем этом подробнее я писал здесь: Создаем задачу FreeRTOS: динамический и статический способ, и нам это еще пригодится в дальнейшем.

 

1.3.3. Настраиваем проект ESP-IDF + Arduino ESP32

Как я уже упомянул, с использованием ESP-IDF вам предоставляется полная возможность управлять настройкой всех компонентов ESP-IDF с помощью редактора конфигурации menuconfig. Вызвать его можно с помощью команды py menuconfig (текстовый вариант), либо с помощью встроенного пункта меню SDK Configuration Editor:

Конфигуратор ESP-IDF (один из вариантов)

Параметров здесь очень много, и наверное, даже не стоит здесь их перечислять. На эту тему уже были статьи на этом сайте, например: Настройка ESP-IDF проекта. Кроме того, хотелось бы отдельно отметить, что в данном случае становится возможным создание собственных таблиц разметок flash-памяти, например как это описано в другой статье: Настройка таблицы разделов FLASH-памяти для ESP32

 

1.3.4. Подключение сторонних библиотек к проекту ESP-IDF + Arduino ESP32

А вот с библиотеками, увы, не все так гладко и легко, как хотелось бы. Дело в том, что система сборки ESP-IDF “понимает” только один формат библиотек – components cmake (компоненты). Поэтому каждую стороннюю библиотеку, как публичную, так и личную, придется “оформлять” как компонент cmake.

Для этого потребуется выполнить несколько шагов:

1. Поместите библиотеку в подпапку components проекта.

Перейдите в папку вашего проекта ESP-IDF и создайте подпапку components, если её ещё нет. Затем клонируйте или скопируйте необходимую библиотеку в эту папку. Например так:

cd c:\Projects\Espressif\your_project
mkdir components
git clone --recursive https://github.com/Author/new_library.git components/new_library

Или просто скопируйте содержимое вашей локальной библиотеки в components/new_library.

2. Создайте файл CMakeLists.txt для библиотеки

В папке вашей библиотеки components/new_library создайте файл CMakeLists.txt со следующим содержимым (замените имена файлов на ваши):

idf_component_register(SRCS "new_library.cpp" "another_source.c"
                      INCLUDE_DIRS "."
                      REQUIRES arduino-esp32
                      )

3. Выполните тестовую сборку вашего проекта.

Более подробно о том, как правильно добавлять библиотеки в систему сборки, вы можете прочитать тут:  Система сборки ESP-IDF (перевод).

 

На этом вводную часть можно считать законченной, приступаем непосредственно к программированию.

В дальнейшем ниже по тексту я буду использовать в примерах VSCode + PlatformIO как самый удобный и быстрый вариант разработки. Ну а вы теперь сами сможете адаптировать примеры под удобный вам вариант.

 


2. Подключение к сети WiFi

Поскольку эта статья о том, как создать устройство с удаленным управлением и контролем, первым делом нам понадобиться подключить его к сети WiFi и интернету. Конечно, можно управлять ESP и без WiFi, например через BT и GPRS – но данная статья не об этом.

На ESP32 поддерживаются следующие режимы работы Wi-Fi:

  • Station mode (STA) — режим клиента: ESP32 подключается к существующей Wi-Fi сети (точке доступа).
  • Access Point mode (AP) — режим точки доступа: ESP32 создает собственную Wi-Fi сеть, к которой могут подключаться другие устройства.
  • Station/AP coexistence mode (APSTA) — одновременная работа в режиме клиента и точки доступа: ESP32 может быть подключён к одной Wi-Fi сети и одновременно создавать свою.
  • NULL modeWi-Fi отключён: интерфейсы станции и точки доступа не инициализированы, это может использоваться для работы в режиме сниффера или для временного отключения Wi-Fi без выгрузки драйвера.

В рамках данной статьи я буду рассматривать исключительно STA режим, то есть мы будем подключать наш микроконтроллер к существующей Wi-Fi сети (точке доступа). Для этого нам понадобятся параметры этой сети: как минимум SSID этой сети (имя сети) и кодовая фраза (пароль) для подключения. 

 

ℹ Здесь и далее с статье SSID сети и пароль доступа указаны в открытом виде как обычные константы – сделано это в основном для простоты понимания. Таким образом ESP32 может подключиться только к одной-единственной сети, для которой мы заранее указали эти параметры. Для изменения SSID сети или пароля придется внести изменения в исходный код прошивки и заново перезалить прошивку в ESP32. Это может быть проблематичным в некоторых случаях.
В качестве альтернативы можно заранее настроить несколько разных “комплектов” SSID + пароль и переключаться между ними; либо использовать предусмотренный разработчиками режим SmartConfig для настройки сети после прошивки ESP32.

 

2.1. Простой пример подключения в режиме STA

В сети интернет и на сайте производителя вы можете найти, как правило, один и тот же пример кода, с помощью которого происходит подключение к сети WiFi:

#include <WiFi.h>

const char* ssid     = "your-ssid";      // Замените на имя вашей WiFi сети
const char* password = "your-password";  // Замените на пароль вашей WiFi сети

// ------------------------------------
// Функция настройки, выполняется только один раз после запуска микроконтроллера
// ------------------------------------

void setup() {
  // Настраиваем UART0-порт на скорость 115200
  Serial.begin(115200);
  Serial.println();

  // Выводим сообщение о том, что мы собираемся подключиться к сети 
  Serial.print("Connecting to ");
  Serial.println(ssid);

  // Инициируем подключение
  WiFi.begin(ssid, password);

  // Ожидание подключения
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  // Подключение выполнено, выводим сообщение и полученный от роутера IP-адрес
  Serial.println("");
  Serial.println("WiFi connected");
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
}

// ------------------------------------
// Рабочий цикл, повторяется непрерывно
// ------------------------------------

void loop() {
  // Ваш основной код
}

Здесь после вызова WiFi.begin() происходит ничем не ограниченное ожидание подключения в цикле, а затем выводится IP-адрес устройства, полученный от роутера. После этого могут могут выполняться другие действия.

Хотя этот пример и приведен в официальной документации, но он, скажем так, не совсем оптимален. Например я сразу вижу явные и довольно критичные проблемы:

  • Если в момент запуска доступа к заданной сети нет, то устройство не начнет выполнять основную работу в цикле loop() до момента подключения
  • В случае потери соединения и повторного переподключения мы никогда об этом не узнаем – а нужно будет вновь выполнить некоторые действия.
  • Данный код не сообщает никаких дополнительных сведений о причинах проблемы, когда что-то пошло не так.

Поэтому не будем слепо его копировать, а давайте попробуем разобраться что к чему, и попробуем его улучшить. Или переписать все “с нуля”. Но для этого нам потребуется немного погрузиться в “теорию”. Если вы уже знаете как работает класс WiFi STA или считаете, что для теория только для упоротых, то можете с чистой совестью пропустить следующую главу.

 

2.2. Классы WiFi и с чем их едят

Прежде чем работать с WiFi-объектами, необходимо подключить к проекту соответствующий модуль:

#include <WiFi.h>

Он подключает к вашему скетчу модуль, в котором объявлен класс WiFiClass:

class WiFiClass : public WiFiGenericClass, public WiFiSTAClass, public WiFiScanClass, public WiFiAPClass
{
  ...
}

WiFiClass просто объединяет в себе несколько других классов: WiFiGenericClass, WiFiSTAClass, WiFiScanClass, WiFiAPClass для большего удобства работы. В контексте данной статьи нам интересен только WiFiSTAClass. При особой необходимости, наверное, можно использовать и WiFiSTAClass напрямую.

В Arduino скетчах не требуется объявлять переменную – экземпляр класса WiFiClass, так как она уже объявлена в том же WiFi.h:

extern WiFiClass WiFi; 

 

Когда запускается WiFi-клиент, это автоматически вызывает следующие события (кроме всего прочего):

  • запускается системный цикл событий для передачи “сигналов” управления (событий)
  • инициализируется nvs-раздел, который используется для хранения служебных данных WiFi
  • запускается специальная служебная задача “wifi”, которая будет выполнять всю фоновую работу по обслуживанию соединения

То есть вся работа с WiFi драйвером выполняется “в фоне”, помимо вашего скетча. В связи с эти хочется ещё и ещё раз упомянуть о потокобезопасности класса WiFiClass.

⚠ Из callback-ов, подключаемых к объекту WiFiClass, нельзя напрямую изменять переменные вашего скетча. Рекомендуется использовать для этого любые средства синхронизации потоков – мьютексы, очереди, группы событий и т.д.

⚠ В официальной документации нет явной информации о потокобезопасности класса WiFiClass и его компонентов в Arduino-ESP32. Поэтому, если требуется доступ к одному объекту WiFiClient из нескольких потоков, рекомендуется самостоятельно обеспечивать синхронизацию доступа (например, через мьютексы), чтобы избежать возможных проблем.

 

2.2.1 Подключение к сети WiFi в режиме STA

Подключение к существующей сети в Arduino-ESP32 сводится к вызову одного-единственного метода: WiFi.begin(). Именно он и только он запускает процесс подключения к сети. Всё остальное, что обычно имеется в примерах (и в примере выше) – лишнее, мусор и шелуха, от которого при желании можно легко избавиться.

В Arduino-ESP32 режим STA (Station) устанавливается автоматически при вызове этого же самого метода WiFi.begin(ssid, password); — отдельной функции для явного выбора режима STA в Arduino API не предусмотрено. 

Имеется несколько вариантов этого метода:

// Вариант 1А
// Макcимально подробный метод подключения с передачей всех возможных опций
wl_status_t begin(const char* wpa2_ssid, wpa2_auth_method_t method, const char* wpa2_identity=NULL, const char* wpa2_username=NULL, const char *wpa2_password=NULL, const char* ca_pem=NULL, const char* client_crt=NULL, const char* client_key=NULL, int32_t channel=0, const uint8_t* bssid=0, bool connect=true);

// Вариант 1Б
// То же самое, но строковые аргументы приведены к классу String
wl_status_t begin(const String& wpa2_ssid, wpa2_auth_method_t method, const String& wpa2_identity = (const char*)NULL, const String& wpa2_username = (const char*)NULL, const String& wpa2_password = (const char*)NULL, const String& ca_pem = (const char*)NULL, const String& client_crt = (const char*)NULL, const String& client_key = (const char*)NULL, int32_t channel=0, const uint8_t* bssid=0, bool connect=true)

// Вариант 2А
// Упрощенный вариант, который и используется почти во всех примерах
wl_status_t begin(const char* ssid, const char *passphrase = NULL, int32_t channel = 0, const uint8_t* bssid = NULL, bool connect = true);

// Вариант 2Б
// То же самое, но строковые аргументы приведены к классу String
wl_status_t begin(const String& ssid, const String& passphrase = (const char*)NULL, int32_t channel = 0, const uint8_t* bssid = NULL, bool connect = true)

// Вариант 2В
// То же самое, но можно передать переменные вместо const char
wl_status_t begin(char* ssid, char *passphrase = NULL, int32_t channel = 0, const uint8_t* bssid = NULL, bool connect = true);

// Вариант 3
// Использует ранее сохранённые параметры подключения (SSID и пароль), которые были сохранены во flash-памяти устройства при предыдущих успешных соединениях
wl_status_t begin()

Эти функции возвращают результат типа wl_status_t, описание которого приведено ниже.

Позвольте, а где же здесь метод с двумя параметрами begin(ssid, password)?

А это и есть “вариант 2” – просто канал, bssid и признак подключения не указаны, а используются значения по умолчанию, то есть WiFi.begin(ssid, password, 0, NULL, true).

 

2.2.1.1 Управление автоматическим подключением и переподключением

А что произойдёт, если я укажу connect = false, то есть WiFi.begin(ssid, password, 0, NULL, false)?

Тогда автоматического подключения не произойдет и для подключения потребуется вызвать WiFi.begin() без параметров. Это можно использовать для отложенного запуска процесса подключения по каким-либо причинам.

 

А что будет. если соединение внезапно потеряно прервано, например роутер выключен или перезагружен? Вновь вызывать WiFi.begin()?

WiFi.reconnect(), но совсем не обязательно. Если включена опция WiFi.setAutoReconnect(true) (а она включена “по умолчанию), то драйвер сам будет пытаться переподключиться к той же сети, без вашего участия.

Но вы можете отключить эту опцию, чтобы самому управлять процессом переподключения – например для осуществления попытки подключения к другой сети, с другими параметрами. Или если хотите сами полностью “рулить” данным процессом. Само переподключение можно выполнить, в том числе, в обработчике события ARDUINO_EVENT_WIFI_STA_DISCONNECTED:

void WiFiEvent(WiFiEvent_t event, arduino_event_info_t info){
  switch(event){
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
      Serial.println("Disconnected from station, attempting reconnection");
      WiFi.reconnect();
      break;
    default:
      break;
  }
}

 

А зачем ещё нужен метод WiFi.setAutoConnect() и чем он отличается от WiFi.setAutoReconnect()?

WiFi.setAutoConnect() является устаревшим и не рекомендуется к использованию.

 

2.2.1.2 Выбор наилучшей точки доступа

Перед подключением Вы можете дополнительно выбрать методы сканирования сети для оптимального поиска точки доступа, но делать это нужно перед вызовом любого варианта begin():

// Задать минимальный тип безопасности для поиска точек доступа, по умолчанию это WIFI_AUTH_WPA2_PSK
void setMinSecurity(wifi_auth_mode_t minSecurity); 

// Задать метод сканирования сети, по умолчанию это WIFI_FAST_SCAN - быстрое сканирование каналов
void setScanMethod(wifi_scan_method_t scanMethod);

// Задать метод сортировки полученного списка AP, по умолчанию WIFI_CONNECT_AP_BY_SIGNAL - сортировка по уровню сигнала
void setSortMethod(wifi_sort_method_t sortMethod);

 

2.2.1.3 Ожидание подключения

Подключение к сети WiFi занимает заметное время – от единиц до нескольких десятков секунд, а может и дольше… В примере, который приведен выше, для ожидания подключения использовался цикл. Класс WiFiSTAClass предоставляет для ожидания специальный метод, где мы можем дополнительно указать время ожидания в миллисекундах:

uint8_t waitForConnectResult(unsigned long timeoutLength = 60000); 

Если за указанное время не удалось подключиться к сети, то ожидание будет прервано. Согласитесь, что это гораздо лучше бесконечного цикла.

 

2.2.1.4 Как проверить состояние подключения или если вдруг что-то пошло не так

Дабы проверить, есть ли подключение в настоящий момент, можно воспользоваться функцией:

bool isConnected(); 

 

Более расширенные сведения о состоянии подключения можно узнать с помощью метода status():

static wl_status_t status(); 

Она возвращает результат типа wl_status_t, в котором закодированы возможные состояния подключения:

typedef enum {
    WL_NO_SHIELD        = 255, // Нет оборудования, для совместимости с WiFi Shield library
    WL_IDLE_STATUS      = 0,   // WiFi выключено
    WL_NO_SSID_AVAIL    = 1,   // Точка доступа с указанной SSID не найдена
    WL_SCAN_COMPLETED   = 2,   // Сканирование сети завершено, но подключения еще нет
    WL_CONNECTED        = 3,   // Подключение успешно выполнено
    WL_CONNECT_FAILED   = 4,   // Не удалось выполнить подключение
    WL_CONNECTION_LOST  = 5,   // Подключение было, но потерялося
    WL_DISCONNECTED     = 6    // Подключения нет
} wl_status_t; 

Этот же самый код возвращают и все методы begin().

 


Лирическое отступление от основной темы: const или #define?

Может быть вы обратили внимание, как в примере выше объявлены строковые константы:

const char* ssid     = "your-ssid";      // Замените на имя вашей WiFi сети
const char* password = "your-password";  // Замените на пароль вашей WiFi сети

Точно также можно объявить и константы – числа, и другие типы данных. В учебниках часто пишут, что именно так и нужно, ибо “правильно”.

Но, если вы заглянете в исходный код “рабочих” проектов или даже в собственно исходники Arduino ESP32 или ESP-IDF, то гораздо чаще встретите неправильные мёд объявления вида #define "бла-бла-бла" или #define 10, например примерно так:

#define CONFIG_WIFI_SSID "your-ssid" // Замените на имя вашей WiFi сети 
#define CONFIG_WIFI_PSWD "your-password" // Замените на пароль вашей WiFi сети

Почему это очень часто объявлено именно так, а не через const %тип_данных%?

 

Дело в том, что #define – это не ключевое слово языка Си, а директива препроцессора, и это совсем не объявления констант!

Перед компиляцией специальная программка – “препроцессор” проходится по вашему коду и подготавливает его к обработке компилятором. В том числе заменяет все вхождения CONFIG_WIFI_SSID и CONFIG_WIFI_PSWD на "your-ssid" и "your-password" соответственно. Без каких-либо рассуждений и определений типов!

То есть  на “вход” компилятора поступит такая команда, буквально:

WiFi.begin("your-ssid", "your-password");

И получается, что тип константы потом определяет сам компилятор – в данном случае он сам попытается привести их к типам, которые указаны в объявлении функции или метода класса.

В некоторых случаях это может привести к маловразумительным ошибкам, с которыми очень сложно бороться. Например если вы попытаетесь объявить макрос таким образом:

#define CONFIG_INT_VALUE 022

То, наверное, ожидаете, что в исходный код для компилятора попадет именно значение 22 в десятичном выражении?

А вот и нет! В код попадет десятичное значение 18! Потому что компилятор по нулю перед числом определит это значение как восьмеричное. И вы можете долго искать причину, почему программа работает неправильно.

 

Так почему же многие программисты продолжают этим #define активно пользоваться? Потому то с помощью этих самых макросов можно управлять процессом сборки

Например можно написать такой макрос:

#if defined(CONFIG_WIFI_SSID)

  // Здесь поместим весь код, который относится к WiFi и все что с ним связано

#endif // CONFIG_WIFI_SSID

В этом случае, если вы закомментируете объявление макроса // #define CONFIG_WIFI_SSID "your-ssid", то препроцессор полностью удалит из подготовленного к компиляции кода всё, что связано с WiFi. С помощью const бла-бла-бла провернуть такой “фокус” не удастся – ваш код даже с условиями будет компилироваться в любом случае.

Таким образом, с помощью макросов препроцессора очень удобно управлять сборкой кода с различной функциональностью. Не ошибусь, если заявлю, что вся система конфигурирования ESP-IDF, вызываемая командой menuconfig, построена именно на этих самых макросах препроцессора.

Теперь вы можете сами обдуманно решить, какой метод использовать в вашем проекте. В данном примере я пока оставлю всё, как рекомендуют нам учебники.

 


2.3. Оптимизируем процесс подключения

Первым делом выкинем из setup() все, кроме инициации подключения:

#include <WiFi.h>

const char* ssid     = "your-ssid";      // Замените на имя вашей WiFi сети
const char* password = "your-password";  // Замените на пароль вашей WiFi сети

// ------------------------------------
// Функция настройки, выполняется только один раз после запуска микроконтроллера
// ------------------------------------

void setup() {
  // Настраиваем UART0-порт на скорость 115200
  Serial.begin(115200);
  Serial.println();

  // Выводим сообщение о том, что мы собираемся подключиться к сети 
  Serial.print("Connecting to ");
  Serial.println(ssid);

  // запускаем процесс подключения
  WiFi.begin(ssid, password);

  // И без какого-либо ожидания идем дальше 
  Serial.println("Waiting for WiFi..."); 
}

// ------------------------------------
// Рабочий цикл, повторяется непрерывно
// ------------------------------------

void loop() {
  // Ваш основной код
}

Таким образом мы избавились от бесконечного ожидания соединения.

Но у нас появилась проблема – как определить момент подключения к сети, чтобы выполнить какие-то дополнительные действия. Это можно сделать как минимум двумя способами:

  • В каждой итерации рабочего цикла считывать состояние WiFi соединения и сравнивать его с предыдущим. Если состояние изменилось, то можно предпринять какие либо действия, например вывести сообщение
  • Написать и подключить обработчик событий WiFi, который сам будет отслеживать состояние WiFi

А кроме этого, добавим соли и перца по вкусу некоторые другие небольшие усовершенствования в код. Я приведу пример для обоих вариантов, а вы уж сами решите, что вам ближе.

 

2.3.1. Отслеживаем состояние WiFi в рабочем цикле

Отслеживать состояние необходимо в рабочем цикле. Но запихивать код проверки непосредственно в loop() – плохая идея. Поэтому я написал специальную функцию – wifiCheck():

wl_status_t wifi_status_prev   = WL_IDLE_STATUS;   // Здесь мы будем хранить предыдущее состояние подключения
wl_status_t wifi_status_now    = WL_IDLE_STATUS;   // Здесь мы будем хранить текущее состояние подключения

// Проверка состояния WiFi подключения в рабочем цикле
void wifiCheck()
{
  // Считываем состояние подключения
  wifi_status_now = WiFi.status();
  // Если состояние изменилось...
  if (wifi_status_prev != wifi_status_now) {
    // Выводим сообщение
    switch (wifi_status_now) {
      case WL_NO_SSID_AVAIL:
        Serial.println("WiFi SSID is not available"); 
        break;
      case WL_SCAN_COMPLETED:
        Serial.println("WiFi scan completed"); 
        break;
      case WL_CONNECTED:
        Serial.print("WiFi connected, IP address: ");
        Serial.println(WiFi.localIP());
        // Здесь мы должны сделать что-то после подключения к WiFi-сети
        break;
      case WL_CONNECT_FAILED:
        Serial.println("Failed to connect to access point"); 
        break;
      case WL_CONNECTION_LOST:
        Serial.println("Lost connection to WiFi access point"); 
        if (wifi_status_prev == WL_CONNECTED) {
          // Здесь мы должны сделать что-то после отключения от WiFi-сети
        };
        break;
      case WL_DISCONNECTED:
        Serial.println("Disconnected from WiFi access point"); 
        if (wifi_status_prev == WL_CONNECTED) {
          // Здесь мы должны сделать что-то после отключения от WiFi-сети
        };
        break;
      default:
        break;
    };
    // Сохраняем новое значение
    wifi_status_prev = wifi_status_now;
  };
}

Затем модифицируем основной код таким образом:

// Функция настройки, выполняется только один раз после запуска микроконтроллера
void setup() {
  // Настраиваем UART0-порт на скорость 115200
  Serial.begin(115200);
  Serial.println();

  // Выводим сообщение о том, что мы собираемся подключиться к сети 
  Serial.print("Connecting to ");
  Serial.println(wifi_ssid);

  // Настраиваем новое подключение
  WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN);
  WiFi.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL);
  WiFi.setHostname("esp32_test");

  // Запускаем процесс подключения
  WiFi.begin(wifi_ssid, wifi_password);

  // И без какого-либо ожидания идем дальше 
  Serial.println("Waiting for WiFi..."); 
}

// Рабочий цикл, повторяется непрерывно
void loop() {
  // Проверка состояния подключения
  wifiCheck();

  // Делаем какую-то основную полезную работу, не связанную с WiFi
  
  // Ожидание
  delay(500);
}

 

Проверяем работоспособность:

rst:0x1 (POWERON_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT)
configsip: 0, SPIWP:0xee
clk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00
mode:DIO, clock div:2
load:0x3fff0030,len:1184
load:0x40078000,len:13232
load:0x40080400,len:3028 
entry 0x400805e4

Connecting to k12iot
Waiting for WiFi...
Disconnected from WiFi access point
WiFi connected, IP address: 192.168.10.94

 

2.3.2. Отслеживаем состояние WiFi через callback

Другой способ, ещё более “красивый” и удобный – использовать функцию(и) обратного вызова. Данный вариант хорош хотя бы тем, что мы полностью освободили цикл loop() от чуждых ему обязанностей проверять состояние WiFi каждые nnn миллисекунд. Не говоря уже о том, что он даже выглядит более просто и понятно.

#include <WiFi.h>

const char* wifi_ssid          = "your-ssid";         // Замените на имя вашей WiFi сети
const char* wifi_password      = "your-password";     // Замените на пароль вашей WiFi сети

// Этот обработчик вызывается при различных WiFi-событиях
void cbWiFiEvent(WiFiEvent_t event) {
  switch (event) {
    case ARDUINO_EVENT_WIFI_STA_CONNECTED:
      Serial.println("Connected to access point");
      break;
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      Serial.print("WiFi connected, IP address: ");
      Serial.println(WiFi.localIP());
      break;
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
      Serial.println("Disconnected from WiFi access point");
      break;
    default:
      break;
  }
}

// Функция настройки, выполняется только один раз после запуска микроконтроллера
void setup() {
  // Настраиваем UART0-порт на скорость 115200
  Serial.begin(115200);
  Serial.println();

  // Выводим сообщение о том, что мы собираемся подключиться к сети 
  Serial.print("Connecting to ");
  Serial.println(wifi_ssid);

  // Регистрируем обработчик событий WiFi
  WiFi.onEvent(cbWiFiEvent);

  // Настраиваем новое подключение
  WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN);
  WiFi.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL);
  WiFi.setHostname("esp32_test");

  // Запускаем процесс подключения
  WiFi.begin(wifi_ssid, wifi_password);

  // И без какого-либо ожидания идем дальше 
  Serial.println("Waiting for WiFi..."); 
}

// Рабочий цикл, повторяется непрерывно
void loop() {
  // Делаем какую-то основную полезную работу, не связанную с WiFi
}

 

Проверяем работоспособность:

rst:0x1 (POWERON_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT)
configsip: 0, SPIWP:0xee
clk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00
mode:DIO, clock div:2
load:0x3fff0030,len:1184
load:0x40078000,len:13232
load:0x40080400,len:3028 
entry 0x400805e4

Connecting to k12iot
Waiting for WiFi...
Connected to access point
WiFi connected, IP address: 192.168.10.94

 

Но тут есть один подводный камень, про который я упоминал выше: cbWiFiEvent() вызывается не из контекста задачи-скетча, а из контекста цикла событий. Поэтому напрямую обращаться к объектам, которые объявлены и используются в цикле loop(), уже нельзя. Будем учитывать этот момент в дальнейшем.

 


3. Получение актуальной даты и времени с серверов NTP

Следующий шаг, который мы должны выполнить после подключения к сети интернет – получить актуальную дату и время с серверов NTP. Правильное время на сетевом микроконтроллере – не блажь, а требование безопасности. Правильное время используется не только для работы по расписанию, но и для проверки SSL и TLS-сертификатов при установке защищенных соединений.

Конечно, можно подключить к ESP32 микросхему часов реального времени, например DS3231 и пропустить этот этап. Но и в отправке запроса к SNTP службе нет ничего сложного, поэтому чаще всего в часах нет особой необходимости.

Как работать с датой и временем, я уже подробно описывал в другой статье: Работа с датой и временем и SNTP-синхронизация на ESP32 и ESP8266, повторяться не вижу особого смысла – с тех пор ничего не изменилось. Но с учетом того, что код выполняется на ESP32, появляются некоторые особенности…

3.1. Вариант “на языке Arduino”

Итак, напишем функцию для синхронизации времени, примеры я уже приводил не раз:

// Настройка и запуск синхронизации времени
void sntpSyncStart()
{
  // Для работы TLS-соединения нужны корректные дата и время, получаем их с NTP серверов
  configTime(3 * 3600, 0, "pool.ntp.org", "time.nist.gov");
  // Ждем, пока локальное время синхронизируется
  Serial.print("Waiting for NTP time sync: ");
  int i = 0;
  time_t now = time(nullptr);
  while (now < 1000000000) {
    now = time(nullptr);
    i++;
    if (i > 60) {
      // Если в течение этого времени не удалось подключиться - выходим с false
      // Бесконечно ждать подключения опасно - если подключение было разорвано во время работы
      // нужно всё равно "обслуживать" реле и датчики, иначе может случиться беда
      Serial.println("");
      Serial.println("Time sync failed!");
      return;
    };
    Serial.print(".");
    delay(500);
  }

  // Время успешно синхронизировано, выводим его в монитор порта
  Serial.println(" ок");
  struct tm timeinfo;
  gmtime_r(&now, &timeinfo);
  Serial.print("Current time: ");
  Serial.print(asctime(&timeinfo));
}

Разумеется, сразу же возникает соблазн запихнуть её в обработчик событий WiFi:

// Этот обработчик вызывается при различных WiFi-событиях
void cbWiFiEvent(WiFiEvent_t event) {
  switch (event) {
    case ARDUINO_EVENT_WIFI_STA_CONNECTED:
      Serial.println("Connected to access point");
      break;
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      Serial.print("WiFi connected, IP address: ");
      Serial.println(WiFi.localIP());
      // Запускаем синхронизацию времени
      sntpSyncStart();
      break;
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
      Serial.println("Disconnected from WiFi access point");
      break;
    default:
      break;
  }
}

И это вполне успешно работает:

Connecting to k12iot
Waiting for WiFi...
Connected to access point
WiFi connected, IP address: 192.168.10.94
Waiting for NTP time sync: ... ок
Current time: Fri Jul 18 18:56:02 2025

Но… это плохой путь (если вы использовали обработчик событий WiFi). Поясню почему.

Как я уже не один раз сказал – обработчик события вызывается из контекста задачи цикла событий. И, в принципе, здесь нам абсолютно все равно, из контекста какой задачи запускается эта синхронизация – все равно она запускается еще в одной, отдельной задаче (да-да-да, еще один сервис будет выполняться параллельно). Но есть другой фактор риска.

Цикл событий просто тупо перебирает список подписчиков тех или иных событий и в цикле выполняет их callback-и. Соответственно, если какой-то из обработчиков будет выполняться долго или зависнет – остальные подписчики будут ждать. Чего доброго, еще и WDT сработает (сторожевой таймер задач – прим. авт.). Поэтому необходимо всегда стремиться к тому, что бы обратного вызова выполнялась как можно быстрее. Этого правила стоит придерживаться для любых callback-ов. А здесь мы намеренно ждем аж до 60 секунд, чем ввергаем в шок вышеупомянутый цикл событий. Нехорошо это…

ℹ️ Разумеется, все вышеописанное относится только к случаю, когда вы используете функцию обратного вызова при определении состояния WiFi подключения (вариант из раздела 2.3.2). Если вы использовали вариант, описанный в разделе 2.3.1 – можете смело использовать предложенный выше вариант и не читать далее.

3.2. Вариант “на языке ESP-IDF”

Данную проблему можно решить, на мой взгляд, тем же способом, что и в предыдущем случае – то есть использованием соответствующей callback-функции, тем более что ESP-IDF SNTP API предполагает её использование. Но… разработчики Arduino ESP32, а конкретно модуля esp32-hal-time.с, видимо, посчитали это архитектурным излишеством, и исключили из Arduino SNTP API.

Поэтому нам придется “вернуться к корням” и переписать это на “языке ESP-IDF” с использованием ещё одной функции обратного вызова:

#include <time.h>
#include "esp_sntp.h"

const char* timezone           = "MSK-3";          // Часовой пояс
const char* sntp_server1       = "pool.ntp.org";   // Сервер синхронизации времени 1
const char* sntp_server2       = "time.nist.gov";  // Сервер синхронизации времени 2

// Обработчик синхронизации времени
void cbSntpSync(struct timeval *tv)
{
  struct tm timeinfo;
  localtime_r(&tv->tv_sec, &timeinfo);
  if (timeinfo.tm_year < (1970 - 1900)) {
    Serial.println("Time synchronization failed!");
  } else {
    Serial.print("Time synchronization completed, current time: ");
    Serial.println(asctime(&timeinfo));
  };
}

// Настройка и запуск синхронизации времени
void sntpSyncStart()
{
  // Инициализация ESP-IDF netif
  esp_netif_init();
  // Останавливаем SNTP, если она была запущена
  if(sntp_enabled()){
      sntp_stop();
  };
  // Настройка SNTP-синхронизации
  sntp_setoperatingmode(SNTP_OPMODE_POLL);
  sntp_setservername(0, (char*)sntp_server1);
  sntp_setservername(1, (char*)sntp_server2);
  sntp_set_time_sync_notification_cb(cbSntpSync);
  sntp_init();
  Serial.println("Time synchronization start...");
  // Устанавливаем часовой пояс системы
  setenv("TZ", timezone, 1);
  tzset();
}

Проверяем результаты:

Connecting to k12iot
Waiting for WiFi...
Disconnected from WiFi access point
Connected to access point
WiFi connected, IP address: 192.168.10.94
Time synchronization start...
Time synchronization completed, current time: Fri Jul 18 22:41:29 2025

Очевидным плюсом такого подхода будет также то, что callback будет срабатывать при каждой синхронизации времени, а не только в первый раз – и в ы при желании можете узнать об этом.

Вот так мы вполне удачным образом скрестили ежа и ужа код ESP-IDF и Arduino ESP32. Это вариант, разумеется, вы можете использовать при любом варианте проверки состояния WiFi.

 


4. Подключение к MQTT-брокеру

Для удаленного управления ESP32 можно использовать множество различных сетевых протоколов, например:

  • HTTP / HTTPS
    Протоколы для обмена данными по модели клиент-сервер, часто прячется под псевдонимом WEB-интерфейс
  • MQTT
    Лёгкий publish/subscribe протокол для IoT, оптимален для обмена короткими сообщениями между устройствами и сервером (брокером).
  • UDP
    Протокол передачи данных без установления соединения, подходит для быстрой передачи небольших пакетов, но без гарантии доставки.
  • CoAP
    Лёгкий протокол, похожий на HTTP, но оптимизированный для устройств с ограниченными ресурсами и работы в ненадёжных сетях.

Так почему я предпочитаю использовать MQTT-протокол, а не Web-интерфейс, к примеру? Ведь Web-интерфейс значительно проще? А потому, что Web-интерфейс проще только для пользователя вашей системы, но обладает значительными недостатками:

  • Web-интерфейс нельзя использовать вне локальной сети без “белого IP”, проброса портов или других ухищрений. Для меня, когда я живу в режиме “5 город / 2 деревня”, а устройства есть и там и там – это является самой критичной проблемой.
  • Для создания грамотного web-интерфейса вам потребуется изучить не только C/C++, но и языки разметки HTML, CSS и хотя бы немного стать web-дизнайнером.
  • HTTP(S) Web-сервер заметно сильнее нагружает процессор, чем легкий и быстрый MQTT.

Разумеется, идеальных вещей в нашем мире не существует, MQTT тоже имеет свои недостатки. Например необходимость устанавливать на смартфон дополнительное приложение и настраивать его. Но лично для меня это не столь критично.

Именно  поэтому в данной статье я буду рассматривать только управление через MQTT. Если вы не знакомы с этим протоколом, рекомендую вам предварительно ознакомиться со следующими статьями по данной теме:

  1. Что такое MQTT и с чем его едят?
  2. ESP32 MQTT Client API
  3. Настраиваем MQTT DASH для Android
  4. Публичные облачные сервера для IoT устройств

 

Какие данные нам понадобятся?

Для подключения к MQTT-брокеру нам понадобятся:

  • URI-адрес сервера или его IP-адрес
  • Номер порта для подключения, стандартно это 1883 или 8883, но могут быть варианты
  • Логин и пароль учетной записи на сервере, по именем которой мы будем получать доступ к данным
  • Для защищенных SSL/TLS-соединений дополнительно потребуется корневой СА-сертификат для проверки подлинности сервера. О том, что это такое и зачем это нужно – я писал в другой статье: HTTPS, SSL/TLS соединения на ESP32 и ESP-IDF

 

Какую библиотеку будем использовать?

Фреймворк Arduino ESP32 , в отличие от ESP-IDF, не имеет встроенной поддержки MQTT-протокола. Посему нам придется использовать сторонние библиотеки. В предыдущей версии статьи “шаг за шагом” для ESP8266, если вы её читали, я использовал для подключения к MQTT-брокеру довольно известную библиотеку PubSubClient: https://github.com/knolleary/pubsubclient. Это, наверное, одна из самых известных и популярных библиотек среди Arduinо-сообщества. Поэтому не будем изменять традициям, и повторим этот опыт.

Но лично мне эта идея не очень нравится. Почему? Да потому что PubSubClient, на мой взгляд, плохо приспособлена для работы в составе FreeRTOS:

  • Для обеспечения функционирования клиента необходимо в каждом цикле loop() и, желательно, как можно чаще, вызывать метод mqttClient.loop(). Ну а мы же теперь понимаем, исходя из раздела “Особенности программирования в многозадачной среде RTOS“, что этого лучше избегать, чтобы дать возможность работать задачам с меньшим приоритетом.
  • PubSubClient совсем не рассчитана на работу в многопоточных операционных системах и никак не обеспечивает потокобезопасность. Поэтому при необходимости отправки или получения данных из разных задач придется придется самим обеспечивать защиту совместного доступа.
  • PubSubClient обладает довольно скромными возможностями по сравнению с средствами, предоставляемыми “встроенным” ESP32 MQTT Client API, особенно в плане SSL/TLS подключений.

Пока вы используете PubSubClient имея в скетче только одну-единственную задачу “по умолчанию”, она, конечно же будет работать совершенно нормально. Но как только вам понадобится создать ещё одну или несколько задач и поручить общаться с брокером, то вам придется обеспечивать защиту от попыток одновременного доступа, да и “обслуживать” PubSubClient становится непонятно как.

PubSubClient будет отличным решением, если вы предпочтете отказаться от функций обратного вызова, и организовать проверку подключения к WiFi в цикле loop(), как это было описано выше в разделе 2.3.1. “Отслеживаем состояние WiFi в рабочем цикле”.

В остальных вариантах, на мой взгляд, на ESP32 гораздо рациональнее и оптимальнее использовать встроенный в ESP-IDF клиент – ESP32 MQTT Client API. Да и вообще: зачем городить огород из разных сторонних библиотек, когда Espressif давно позаботились об этом и обеспечили нам отличный API esp-mqtt для работы с MQTT, “из коробки” поддерживающий многозадачность, очередь отправки, SSL/TLS, максимально “заточенный” под ESP32 и к тому же достаточно удобный и простой. Но есть маленькая проблема.

Компонент ESP-MQTT был создан для работы в составе фреймворка ESP-IDF. И для его работы может понадобиться его конфигурирование через menuconfig. A в Arduino ESP32, как мы помним, в общем случае не поддерживается menuconfig. Но это не беда – стандартных настроек ESP-MQTT по умолчанию вполне достаточно в нашем проекте, поэтому даже не будем с этим заморачиваться.

Поэтому я решил в данной статье предложить вам оба варианта, а вы сами решите, какой вам больше подходит.

И не стоит также забывать, что кроме этого, имеется и еще множество альтернативных вариантов, например библиотеки-“обертки” ESP-MQTT, специально адаптированные под экосистему Arduino, например:

  • Johboh/MQTTRemote – совместимая с Arduino (с использованием Arduino IDE или PlatformIO) и ESP-IDF (с использованием Espressif IoT Development Framework или PlatformIO) библиотека MQTT-оболочка для настройки MQTT-подключения.
  • cyijun/ESP32MQTTClient – потокобезопасный MQTT-клиент для ESP-IDF или Arduino ESP32. Эта библиотека совместима с C++ arduino-esp32v2/v3+ и ESP-IDFv4.x/v5.x.

Эти обертки построены на базе “стандартного” esp-mqtt, но позволяют упростить процесс настройки. Вы можете воспользоваться этими вариантами, возможно они покажутся вам проще. Но в данной статье мы будем кушать конфеты без фантиков.

 


4.1. Подключение к MQTT серверу с помощью PubSubClient

Существует две популярные версии данной библиотеки (хотя может быть и больше):

Чем они отличаются? В основной версии значение QoS можно указать только при подписке. А при публикации – нет! Я считаю, что это не есть хорошо (к слову, разработчики MQTT клиента для ESP-IDF считают так же, поскольку в ESP32 реализован “по второму типу”).

Лично я предпочитаю пользоваться версией от Imroy. Но в данном примере я буду использовать основную ветку. Просто потому что во 99.9% примеров в сети используется именно она – не будем нарушать традиции.

Вначале рассмотрим вариант подключения к MQTT серверу в открытом виде, то есть без TLS-шифрования; а затем уже перейдем к чуть более сложному, защищенному соединению. О том, что это такое и зачем это нужно – рекомендую почитать в другой статье: HTTPS, SSL/TLS соединения на ESP32 и ESP-IDF, здесь я не буду подробно останавливаться на “теоретических” вопросах.

4.1.1. Подключаем библиотеку PubSubClient к проекту

Подключение библиотеки к проекту зависит от того, какую IDE вы использовали для своей работы.

Для Arduino IDE все просто: откройте менеджер библиотек, введите в поиске “PubSubClient”, и нажмите “Установка” под нужной библиотекой:

 

Для PlatformIO IDE ситуация выглядит несколько сложнее: вам необходимо открыть в Visual Studio Code файл platformio.ini и отредактировать его следующим образом:

; PlatformIO Project Configuration File
;
;   Build options: build flags, source filter
;   Upload options: custom upload port, speed and extra flags
;   Library options: dependencies, extra library storages
;   Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html

[env] 
; Сторонние библиотеки 
lib_deps = 
  ; MQTT PubSubClient 
  https://github.com/knolleary/pubsubclient

[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
upload_speed = 921600
monitor_speed = 115200

При первой компиляции PlatformIO сам скачает библиотеку и подключит её к проекту:

Library Manager: Installing git+https://github.com/knolleary/pubsubclient
git version 2.44.0.windows.1
Cloning into 'C:\Users\kotyara12\.platformio\.cache\tmp\pkg-installing-qc5p0p9v'...
remote: Enumerating objects: 59, done.
remote: Counting objects: 100% (59/59), done.
remote: Compressing objects: 100% (52/52), done.
remote: Total 59 (delta 5), reused 29 (delta 1), pack-reused 0 (from 0)
Receiving objects: 100% (59/59), 32.69 KiB | 3.27 MiB/s, done.
Resolving deltas: 100% (5/5), done.
Library Manager: PubSubClient@2.8.0+sha.2d228f2 has been installed!

Есть и другие варианты подключить библиотеку, подробнее об них можно почитать здесь: Подключение библиотек к проекту PlatformIO

 

Для ESP-IDF + Arduino как компонент ситуация самая сложная – всё придется делать “руками”:

  1. Откройте каталог проекта через проводник, Total Commander или любой другой файловый менеджер
  2. Создайте в каталоге проекта подкаталог components и перейдите в него
  3. Скачайте zip-архив с библиотекой (https://github.com/knolleary/pubsubclient/archive/refs/heads/master.zip) и распакуйте его в каталог components
  4. Переименуйте папку pubsubclient-master в просто pubsubclient (данный шаг не обязателен)
  5. Перейдите в каталог pubsubclient и создайте в нем текстовый файл с именем  CMakeLists.txt со следующим содержимым:
idf_component_register(SRCS "PubSubClient.cpp"
                      INCLUDE_DIRS "src"
                      REQUIRES arduino-esp32)

 


4.1.2. Класс PubSubClient и с чем его едят

Вначале необходимо разобраться, что из себя представляет этот самый PubSubClient.

Конструкторов у класса довольно много, я даже не стал копировать все в текст статьи, ибо по сути нам пока интересны первые два, а точнее – второй, где мы должны передать единственный аргумент – ссылку на экземпляр WiFiClient.

PubSubClient();
PubSubClient(Client& client);
PubSubClient(IPAddress, uint16_t, Client& client);
PubSubClient(IPAddress, uint16_t, Client& client, Stream&);
PubSubClient(IPAddress, uint16_t, MQTT_CALLBACK_SIGNATURE,Client& client);
PubSubClient(IPAddress, uint16_t, MQTT_CALLBACK_SIGNATURE,Client& client, Stream&);
...

Можно, конечно, при объявлении переменной (и вызове конструктора) ничего не передавать, но тогда придется это сделать потом отдельно, с помощью метода setClient(Client& client):

PubSubClient& setClient(Client& client);

 

Раз уж мы вспомнили про setClient, наверное логично будет сразу же упомянуть и о других “настроечных” методах данного класса:

// Позволяет задать IP адрес (и порт) сервера в виде структуры IPAddress
PubSubClient& setServer(IPAddress ip, uint16_t port);

// Позволяет задать IP адрес (и порт) сервера в виде массива байт
PubSubClient& setServer(uint8_t * ip, uint16_t port);

// Позволяет задать сервер (и порт) в виде доменного имени
PubSubClient& setServer(const char * domain, uint16_t port);

// Задаем callback-функцию, которая будет вызываться, когда "прилетит" входящее сообщение
PubSubClient& setCallback(MQTT_CALLBACK_SIGNATURE);

// Задать указатель на класс WiFiClient, с помощью которого и происходит физическая передача данных
PubSubClient& setClient(Client& client);

// Указать ссылку на поток для передачи больших массивов данных (например это может быть файловый поток)
PubSubClient& setStream(Stream& stream);

// Установить минимальный интервал поддержания связи между клиентом и сервером
PubSubClient& setKeepAlive(uint16_t keepAlive);

// Установить таймаут передачи данных, при превышении которого пакет считается потерянным
PubSubClient& setSocketTimeout(uint16_t timeout);

 

Подключение к серверу

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

boolean connect(const char* id);
boolean connect(const char* id, const char* user, const char* pass);
boolean connect(const char* id, const char* willTopic, uint8_t willQos, boolean willRetain, const char* willMessage);
boolean connect(const char* id, const char* user, const char* pass, const char* willTopic, uint8_t willQos, boolean willRetain, const char* willMessage);
boolean connect(const char* id, const char* user, const char* pass, const char* willTopic, uint8_t willQos, boolean willRetain, const char* willMessage, boolean cleanSession);

Здесь, наверное, необходимы пояснения:

  • const char* id – идентификатор клиента, должен быть уникальным в пределах одного сервера (см. примечание)
  • const char* user – логин пользователя
  • const char* pass – пароль пользователя
  • const char* willTopic – топик Last Will and Testament (Последняя воля и завещание), в который сервер опубликует willMessage сообщение, когда клиент неожиданно отключится от сервера
  • uint8_t willQos – уровень обслуживания для LWT-сообщения
  • boolean willRetain – должен ли сервер хранить последнее LWT-сообщение после публикации
  • const char* willMessage – содержимое LWT-сообщения
  • boolean cleanSession – при подключении начинать “чистый” сеанс, то есть клиент заново возобновляет все подписки; а сервер в свою очередь, отправляет клиенту все retained сообщения, на которые он подписался

Если вам не знакомы эти термины, прошу прочитать эту статью: Что такое MQTT и с чем его едят?

Примечание 1. Во многих примерах ClientID генерируется “на лету” из MAC-адреса устройства, но я не вижу необходимости тратить на это свободную память кучи. Ибо нефиг. И задаю его как const char* – так и проще и “легче”. Только не забывайте менять его от проекта к проекту (он должен быть уникальным в пределах одного сервера).

Примечание 2. LWT можно задать только при подключении к серверу и только один раз, что противоречит спецификации протокола MQTT. Впрочем это почти во всех клиентах так.

 

Если по каким-либо причинам необходимо отключиться от сервера “штатно”, необходимо вызвать disconnect():

void disconnect();

Проверить состояние подключения к серверу можно с помощью функции connected():

boolean connected();

 

Функционирование клиента

Поскольку PubSubClient создавался для классической однозадачной экосистемы Arduino, необходимо постоянно вызывать метод loop() для его функционирования:

boolean loop();

Он выполняет всю внутреннюю работу по обмену данными с сервером и поддержанию соединения. По большому счету, можно создать отдельную задачу для работы PubSubClient, и поместить вызов этого метода в рабочий цикл этой задачи.

 

Отправка данных на сервер (публикация)

Для отправки сообщения на сервер необходимо воспользоваться одним из методов, указанных ниже:

// Отправить сообщение payload в топик topic
boolean publish(const char* topic, const char* payload);

// Отправить сообщение payload в топик topic с сохранением на сервере (только последнего сообщения)
boolean publish(const char* topic, const char* payload, boolean retained);

// Отправить сообщение payload заданной длины в топик topic
boolean publish(const char* topic, const uint8_t * payload, unsigned int plength);

// Отправить сообщение payload заданной длины в топик topic с сохранением на сервере (только последнего сообщения) 
boolean publish(const char* topic, const uint8_t * payload, unsigned int plength, boolean retained);

Обратите внимание – топик и сами данные должны быть представлены как const char* или набор байт uint8_t*. Как видите, в данной реализации при отправке сообщения нельзя указать QoS, что, в принципе, противоречит описанию протокола. Я не знаю, почему автор так сделал.

Отправка осуществляется только если клиент подключен к северу, иначе данные будут отброшены.

 

Получение входящих данных

С помощью указанных ниже методов можно подписаться на интересующие нас топики:

// Подписаться на топик
boolean subscribe(const char* topic);

// Подписаться на топик с заданным уровнем обслуживания
boolean subscribe(const char* topic, uint8_t qos);

// Отписаться от топика
boolean unsubscribe(const char* topic);

Подписаться можно не только на конкретный топик, но и на шаблон топиков, например “#“.

Когда в топике, на который мы подписались, появиться новое входящее сообщение, будет вызвана функция обратного вызова, которую мы должны были предварительно зарегистрировать с помощью метода setCallback(). Этот метод должен быть создан по следующему прототипу:

void (*callback)(char*, uint8_t*, unsigned int)

Например:

// Функция обратного вызова при поступлении входящего сообщения от брокера
void mqtt_incoming(char* topic, uint8_t* payload, unsigned int length)
{
  // Для более корректного сравнения строк приводим их к нижнему регистру и обрезаем пробелы с краев
  String _topic = topic;
  _topic.toLowerCase();
  _topic.trim();
  String _payload;
  for (unsigned int i = 0; i < length; i++) {
    _payload += String((char)payload[i]);
  };
  _payload.toLowerCase();
  _payload.trim();

  // Вывод поступившего сообщения в лог, больше никакого смысла этот блок кода не несет, можно исключить
  Serial.print("Message arrived [");
  Serial.print(_topic.c_str());
  Serial.print("]: ");
  Serial.print(_payload.c_str());
  Serial.println();
}

Теперь мы можем попробовать подключиться к серверу, для начала без шифрования.

 


4.1.3. Подключение к MQTT без шифрования

Вначале необходимо подключить библиотеку к скетчу:

#include "PubSubClient.h"

Объявим константы, в которых будут храниться параметры подключения к серверу:

// -------------------------------------------------------
// Параметры подключения к MQTT брокеру 
// -------------------------------------------------------

const char* mqtt_server        = "xx.wqtt.ru";     // Адрес MQTT сервера
const int mqtt_port            = 1234;             // Номер порта
const char* mqtt_client_id     = "esp32_demo";     // Использовать статический mqtt_clientId оптимальнее с точки зрения фрагментации кучи, только не забывайте изменять его на разных устройствах
const char* mqtt_user          = "u_xxxx";         // Имя пользователя
const char* mqtt_pass          = "xXxXxXxX";       // Пароль

Ещё нам понадобятся парочка глобальных переменных:

// -------------------------------------------------------
// Глобальные переменные
// -------------------------------------------------------

WiFiClient wifi_client;                            // Указатель на "простого" WiFi-клиента
PubSubClient mqtt_client(wifi_client);             // Указатель на MQTT-клиента

Теперь, используя знания, полученные в предыдущем разделе, напишем функцию подключения к MQTT-серверу:

// -------------------------------------------------------
// Подключение к MQTT брокеру :: версия для PubSubClient от knolleary
// -------------------------------------------------------
bool mqttConnect()
{
  if (!mqtt_client.connected()) {
    Serial.print("Connecting to MQTT broker: ");
    // Настраиваем MQTT клиент
    mqtt_client.setServer(mqtt_server, mqtt_port);
    
    // Пробуем подключиться
    if (mqtt_client.connect(mqtt_client_id, mqtt_user, mqtt_pass)) {
      Serial.println("ok");
    } else {
      Serial.print("failed, error code: ");
      Serial.print(mqtt_client.state());
      Serial.println("!");
    };
    return mqtt_client.connected();
  };
  return true;
}

и функцию отключения:

void mqttDisconnect()
{
  if (mqtt_client.connected()) {
    mqtt_client.disconnect();
  };
}

Куда будем их добавлять? Хороший вопрос!

 

4.1.3.1. Неправильный мёд подход

Если вы использовали синхронный способ проверки состояния подключения к WiFi в рабочем цикле (раздел 2.3.1), то можно легко впихнуть написанные функции в wifiCheck():

// Проверка состояния WiFi подключения в рабочем цикле
bool wifiCheck()
{
  // Считываем состояние подключения
  wifi_status_now = WiFi.status();
  // Если состояние изменилось...
  if (wifi_status_prev != wifi_status_now) {
    // Выводим сообщение
    switch (wifi_status_now) {
      case WL_NO_SSID_AVAIL:
        Serial.println("WiFi SSID is not available"); 
        break;
      case WL_SCAN_COMPLETED:
        Serial.println("WiFi scan completed"); 
        break;
      case WL_CONNECTED:
        Serial.print("WiFi connected, IP address: ");
        Serial.println(WiFi.localIP());
        // Запускаем синхронизацию времени
        sntpSyncStart();
        // Подключаемся к MQTT-брокеру
        mqttConnect();
        break;
      case WL_CONNECT_FAILED:
        Serial.println("Failed to connect to access point"); 
        break;
      case WL_CONNECTION_LOST:
        Serial.println("Lost connection to WiFi access point"); 
        if (wifi_status_prev == WL_CONNECTED) {
          // Закрываем соединение с MQTT-брокером
          mqttDisconnect();
        };
        break;
      case WL_DISCONNECTED:
        Serial.println("Disconnected from WiFi access point"); 
        if (wifi_status_prev == WL_CONNECTED) {
          // Закрываем соединение с MQTT-брокером
          mqttDisconnect();
        };
        break;
      default:
        break;
    };
    // Сохраняем новое значение
    wifi_status_prev = wifi_status_now;
  };
  // Возвращаем true, если соединение есть...
  return wifi_status_now == WL_CONNECTED;
}

Не забываем добавить обработку клиента MQTT в основной цикл скетча:

// Рабочий цикл, повторяется непрерывно
void loop() {
  // Проверка состояния подключения только для варианта 2.3.1
  // wifiCheck();

  // Основной цикл клиента MQTT
  mqtt_client.loop();

  // Делаем какую-то основную полезную работу, не связанную с WiFi
  
  // Ожидание
  delay(100);
}

И это будет хорошо и достаточно надежно работать! Вы можете использовать этот способ в этом случае, но есть одна проблемка (см. ниже)…

 

Но если вы использовали асинхронный подход из раздел 2.3.2 с использованием функций обратного вызова, то возникает соблазн поступить точно также:

// Этот обработчик вызывается при различных WiFi-событиях
void cbWiFiEvent(WiFiEvent_t event) {
  switch (event) {
    case ARDUINO_EVENT_WIFI_STA_CONNECTED:
      Serial.println("Connected to access point");
      break;
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      Serial.print("WiFi connected, IP address: ");
      Serial.println(WiFi.localIP());
      // Запускаем синхронизацию времени
      sntpSyncStart();
      // Подключаемся к MQTT-брокеру
      mqttConnect();
      break;
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
      Serial.println("Disconnected from WiFi access point");
      // Закрываем соединение с MQTT-брокером
      mqttDisconnect();
      break;
    default:
      break;
  }
}

Но тут стоит вспомнить про то, что callback вызывается из другой задачи с другим контекстом, и это может легко привести к конфликтам!

 

Хорошо, путь даже так, проверим это:

Connecting to k12iot
Waiting for WiFi...
Connected to access point
WiFi connected, IP address: 192.168.10.94
Time synchronization start...
Connecting to MQTT broker: ok
Time synchronization completed, current time: Mon Jul 21 22:49:16 2025

Вроде бы работает, но подключение к MQTT серверу происходит быстрее, чем успевает синхронизироваться время.  Мы запускаем синхронизацию времени и подключение к серверу практически одновременно, а синхронизация времени требует некоторого времени… Непорядок!

Пока мы подключаемся к MQTT-серверу без TLS-шифрования, в этом нет никакой особой проблемы, ну разве что на mqtt может улететь “кривое” время запуска, например. Но как только мы попытается использовать для подключения к MQTT шифрование, да ещё и с проверкой сертификата сервера – это станет проблемой. Поэтому данную проблему лучше исправить сразу и навсегда, дабы не искать потом причину неудач.

 

4.1.3.2. Правильный мёд подход

Как это можно исправить?

Я могу предложить свой, но это далеко не единственный вариант, вы можете сделать лучше. Для такого простого клиента, как PubSubClient, я просто “переместил” весь код работы в основной цикл скетча loop() с проверкой всех состояний.

Для этого пишем примерно следующее:

void mqttExec()
{
  if (WiFi.status() == WL_CONNECTED) {
    // Есть подключение к WiFi
    if (mqtt_client.connected()) {
      // Обрабатываем внутренние операции MQTT клиента
      mqtt_client.loop();
    } else {
      // Проверяем время: 1700000000 = 14 Nov 2023 22:13:20 GMT
      if (time(NULL) > 1700000000) {
        // Время успешно синхронизировано, можно подключаться
        mqttConnect();
      };
    };
  } else {
    // Нет подключения к WiFi
    mqttDisconnect();
  };
}

из обработчика cbWiFiEvent() убираем всё “лишнее”:

// Этот обработчик вызывается при различных WiFi-событиях
void cbWiFiEvent(WiFiEvent_t event) {
  switch (event) {
    case ARDUINO_EVENT_WIFI_STA_CONNECTED:
      Serial.println("Connected to access point");
      break;
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      Serial.print("WiFi connected, IP address: ");
      Serial.println(WiFi.localIP());
      // Запускаем синхронизацию времени
      sntpSyncStart();
      break;
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
      Serial.println("Disconnected from WiFi access point");
      break;
    default:
      break;
  }
}

И чуть-чуть подправим рабочий цикл:

// Рабочий цикл, повторяется непрерывно
void loop() {
  // Проверка состояния подключения только для варианта 2.3.1
  // wifiCheck();

  // Основной цикл клиента MQTT
  mqttExec();

  // Делаем какую-то основную полезную работу, не связанную с WiFi
  
  // Ожидание
  delay(100);
}

 

Можно проверить, все работает хорошо, даже при асинхронном подходе:

Connecting to k12iot
Waiting for WiFi...
Connected to access point
WiFi connected, IP address: 192.168.10.94
Time synchronization start...
Time synchronization completed, current time: Mon Jul 21 23:14:15 2025
Connecting to MQTT broker: ok

Все работает как положено и вся работа с клиентом пока осуществляется из контекста основной задачи скетча. Задача выполнена.

 


4.1.4. Подключение к MQTT с использованием TLS-шифрования

TLS (Transport Layer Security) соединения используются для обеспечения безопасности передачи данных между устройством и удалённым сервером. TLS защищает данные от перехвата, подделки и других угроз, которые возможны при передаче по незащищённым каналам. В частности, TLS обеспечивает:

  • Шифрование передаваемых данных — чтобы никто не мог прочитать передаваемую по каналу связи информацию.
  • Аутентификацию сервера — чтобы убедиться, что устройство подключается именно к тому серверу, которому доверяет (например, с помощью проверки сертификата CA).
  • Целостность данных — чтобы данные не были изменены в процессе передачи.

Использование TLS рекомендуется для всех внешних коммуникаций устройства, например, при работе с MQTT или при обновлениях по воздуху (OTA), чтобы предотвратить атаки типа “человек посередине” и другие угрозы безопасности, подробнее смотри в документации: ESP32: TLS (Transport Layer Security) And IoT Devices и  ESP-IDF Security Overview.

Если вы читали мою предыдущую статью HTTPS, SSL/TLS соединения на Arduino и ESP8266, то, наверное, помните, что на ESP8266 не получиться поддерживать несколько защищенных соединений одновременно. На ESP32 такой проблемы нет – вы вполне можете одновременно подключаться к нескольким ресурсам с использованием TLS, и свободная память еще останется. Кроме того, фреймворк Arduino ESP32 имеет расширенные возможности TLS по сравнению ESP8266. Поэтому можно смело рекомендовать использование SSL/TLS везде, где только можно.

Прежде чем мы продолжим, оставлю здесь ссылки на дополнительные статьи по данной теме:

 

4.1.4.1. Библиотека NetworkClientSecure, mbedTLS и все, все, все

В Arduino ESP32 за “безопасные” TLS-соединения отвечает библиотека NetworkClientSecure. Она реализует защищённые соединения по протоколу TLS 1.2 с использованием библиотеки mbedTLS (на уровне ESP-IDF). Её можно использовать не только для подключения к MQTT-брокеру, но и для отправки HTTPS-запросов, о чем и будет рассказано ниже.

Для проверки подлинности сервера и установки защищённого соединения можно использовать следующие методы:

  • setCACert – для проверки сертификата сервера (тот ли он, за кого себя выдает),
  • setCertificate и setPrivateKey – для проверки сертификата клиента (его обычно используют вместо пары логин/пароль),
  • а также поддерживается работа с набором корневых сертификатов и режимом PSK (pre-shared key) для MQTT и других протоколов

Более подробную информацию можно почерпнуть из официальной документации: NetworkClientSecure. Поскольку это довольно обзорная статья, я не буду сильно углубляться в детали. Поскольку я в примере рассматриваю подключение к публичному серверу, то я и буду рассматривать только этот вариант.

 

Проверка сертификата сервера

Для установки защищенного соединения с сервером необходимо проверить валидность его сертификата – а тот ли это сервер, к которому мы пытаемся подключиться. Сертификат это своего рода удостоверение личности сервера. Но серверов много, хранить сертификаты для каждого сервера в интернете никто не будет – это были бы очень большие массивы информации. Поэтому на стороне клиента обычно хранят только сертификаты корневых центров сертификации, которые выпускает сертификаты для дочерних центров сертификации, а те, в свою очередь, выдают сертификаты конечным серверам. 

Сертификат любого сервера и центра сертификации имеет определенный срок действия, после которого он заменяется новым. Именно для проверки срока действия нам и нужна была актуальная дата. 

При установке TLS-соединения сервер предоставляет нам свой сертификат вместе со всей восходящей цепочкой сертификатов центров сертификации, включая корневой. Если корневой сертификат присутствует в нашей базе данных и мы можем его проверить и доверять ему, значит мы можем доверять и конечному серверу. На “взрослых” компьютерах для хранения списков доверенных корневых сертификатов имеется специальная “база данных”. На ESP32 тоже имеется возможность подключения набора корневых сертификатов – bundle, но в данном примере мы поступим попроще.

То есть, для корректной проверки сертификата сервера перед установкой защищенного соединения нам потребуется:

  • сертификат корневого (первого в списке) центра сертификации, который в конечном итоге выдал сертификат серверу, к которому мы хотим подключиться
  • корректные дата и время для проверки

Как получить файл сертификата корневого центра сертификации с помощью браузера – я рассказывал здесь: HTTPS, SSL/TLS соединения на Arduino и ESP8266

Но срок действия любых сертификатов всегда ограничен, и спустя какое то время он станет не действителен. После этого ваше устройство перестанет подключаться к серверу через TLS-соединение. Обычно сроки действия сертификатов CA достаточно большие (десятки лет). Кстати, один и тот же центр сертификации может выдать сертификаты сразу нескольким серверам, которые вы хотите использовать – и тогда корневой сертификат понадобиться только один.

Как обновить сертификат? Обновить сертификат можно, только заменив его в скетче. И даже если вы задействуете набор сертификатов от mozilla, встроенный в ESP-IDF, его тоже придется обновлять перепрошивкой.

Но есть альтернативный вариант: использовать небезопасные безопасные соединенияsetInsecure(). В этом случае клиент просто не проверяет сертификат сервера на его валидность и безусловно доверяет любому серверу. После чего устанавливает с сервером зашифрованный канал связи. То есть передаваемые данные передаются как при “нормальном” TLS-соединении, но сервер могут и подменить – “однако в процессе пути собака могла подрасти“. Зато – не нужно заботиться о сроках действия сертификата.

Как поступить в каждом конкретном случае – решать только вам! Например я сильно сомневаюсь, чтобы кто-то попытался подменить сервер MQTT. Но стараюсь, чтобы все “было по правилам”. В качестве альтернативы можно предложить такой способ: в обычном случае использовать нормальный способ (с проверкой сертификата), а в случае 3-х (5, 10…) неудачных попыток соединения попытаться подключиться без проверки сертификата, и если это удалось – отправить уведомление автору скетча о необходимости сменить сертификат.

 

4.1.4.2. Добавляем TLS-подключение в функцию подключения к MQTT серверу

Предполагается, что файл корневого сертификата вы уже получили. Для сервера wqtt.ru, который я использовал для проверки примеров в статье, вы найдете его содержимое немного ниже – файл вам уже не понадобиться.

Интегрировать библиотеку NetworkClientSecure в наш скетч очень просто:

#include <WiFiClientSecure.h>

Затем необходимо заменить переменную для WiFi клиента, которую используем для MQTT-клиента, на более “безопасную” версию:

// WiFiClient wifi_client;                          // Указатель на WiFi-клиента без шифрования
WiFiClientSecure wifi_client;                    // Указатель на WiFi-клиента c поддержкой mbedTLS

Если вы хотите иметь полноценную проверку сертификата сервера, то добавим содержимое сертификата в виде строки const char:

// Корневой сертификат ISRG Root x1, действителен до 4 июня 2035 года
const char* cert_ISRG_Root_x1 = \
  "-----BEGIN CERTIFICATE-----\n" \
  "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n" \
  "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n" \
  "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n" \
  "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n" \
  "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n" \
  "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n" \
  "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n" \
  "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n" \
  "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n" \
  "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n" \
  "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n" \
  "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n" \
  "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n" \
  "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n" \
  "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n" \
  "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n" \
  "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n" \
  "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n" \
  "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n" \
  "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n" \
  "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n" \
  "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n" \
  "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n" \
  "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n" \
  "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n" \
  "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n" \
  "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n" \
  "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n" \
  "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" \
  "-----END CERTIFICATE-----\n";

Если вы не хотите выполнять проверку сертификата сервера, то добавлять это в скетч не обязательно.

Ну и наконец, в функции подключения к MQTT серверу перед строчкой mqtt_client.connect(...) добавим ещё одну:

// Прикрепляем корневой сертификат сервера - безопасно, но нужно следить за сроками действия
wifi_client.setCACert(cert_ISRG_Root_x1);

Либо, если вы не хотите выполнять проверку сертификата сервера, то необходимо добавить другую команду:

// Не проверять сертификат сервера - не безопасно, но просто и не нужно заботиться о сроках действия сертификатов
wifi_client.setInsecure(); 

И не забудьте заменить порт в настройках сервера на “безопасный”:

mqtt_client.setServer(mqtt_server, mqtt_port_tls);

Этого вполне достаточно, проверим:

Connecting to k12iot
Waiting for WiFi...
Connected to access point
WiFi connected, IP address: 192.168.10.94
Time synchronization start...
Time synchronization completed, current time: Tue Jul 22 22:00:53 2025
Connecting to MQTT broker: ok

Вуаля, всё успешно работает. Теперь передаваемые данные зашифрованы, и сосед – хозяин роутера, через который мы выходим в сеть интернет, не сможет перехватить наши данные.

 

4.1.4.3. Переключение режимов сборки скетча с помощью условных макросов

Помните, я выше рассказывал, что можно управлять сборкой с помощью макросов препроцессора? Давайте провернем этот нехитрый фокус.

Объявим два макроса, с помощью первого

#define CONFIG_MQTT_USE_TLS 1                      // Будем ли мы использовать mbedTLS для MQTT-подключения
#define CONFIG_MQTT_USE_SERVER_CERT 1              // Будем ли мы проверять сертификат сервера

Тогда с помощью условных макросов #if ... #endif, мы можем “насовсем” отключать или включать нужные участки кода:

#if CONFIG_MQTT_USE_TLS && CONFIG_MQTT_USE_SERVER_CERT

// Корневой сертификат ISRG Root x1, действителен до 4 июня 2035 года
const char* cert_ISRG_Root_x1 = \
  "-----BEGIN CERTIFICATE-----\n" \
  "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n" \
  "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n" \
  "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n" \
  "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n" \
  "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n" \
  "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n" \
  "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n" \
  "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n" \
  "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n" \
  "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n" \
  "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n" \
  "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n" \
  "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n" \
  "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n" \
  "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n" \
  "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n" \
  "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n" \
  "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n" \
  "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n" \
  "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n" \
  "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n" \
  "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n" \
  "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n" \
  "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n" \
  "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n" \
  "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n" \
  "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n" \
  "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n" \
  "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" \
  "-----END CERTIFICATE-----\n";

#endif // CONFIG_MQTT_USE_SERVER_CERT

...

#if CONFIG_MQTT_USE_TLS
  WiFiClientSecure wifi_client;                    // Указатель на WiFi-клиента c поддержкой mbedTLS
#else
  WiFiClient wifi_client;                          // Указатель на WiFi-клиента без TLS
#endif // CONFIG_MQTT_USE_TLS

...

bool mqttConnect()
{
  if (!mqtt_client.connected()) {
    Serial.print("Connecting to MQTT broker: ");
    // Настраиваем MQTT клиент
    #if CONFIG_MQTT_USE_TLS
      // Безопасное подключение
      mqtt_client.setServer(mqtt_server, mqtt_port_tls);

      #if CONFIG_MQTT_USE_SERVER_CERT
        // Прикрепляем корневой сертификат сервера - безопасно, но нужно следить за сроками действия
        wifi_client.setCACert(cert_ISRG_Root_x1);
      #else
        // Не проверять сертификат сервера - не безопасно, но просто и не нужно заботиться о сроках действия сертификатов
        wifi_client.setInsecure(); 
      #endif // CONFIG_MQTT_USE_SERVER_CERT
    #else
      // Обычное подключение
      mqtt_client.setServer(mqtt_server, mqtt_port);
    #endif // CONFIG_MQTT_USE_TLS

    // Пробуем подключиться
    if (mqtt_client.connect(mqtt_client_id, mqtt_user, mqtt_pass)) {
      Serial.println("ok");
    } else {
      Serial.print("failed, error code: ");
      Serial.print(mqtt_client.state());
      Serial.println("!");
    };
    return mqtt_client.connected();
  };
  return true;
}

Изменением двух символов (с 0 на 1 и наоборот) в макросах теперь можно легко управлять тем, каким именно способом будет осуществляться подключение к MQTT-брокеру. И зверей убивать не надо © Простоквашино. Пользуйтесь на здоровье!

 


4.1.5. Публикация исходящих данных на MQTT-сервере

Для публикации данных на сервер можно воспользоваться одним из методов, указанных ниже:

// Отправить сообщение payload в топик topic
boolean publish(const char* topic, const char* payload);

// Отправить сообщение payload в топик topic с сохранением на сервере (только последнего сообщения)
boolean publish(const char* topic, const char* payload, boolean retained);

// Отправить сообщение payload заданной длины в топик topic
boolean publish(const char* topic, const uint8_t * payload, unsigned int plength);

// Отправить сообщение payload заданной длины в топик topic с сохранением на сервере (только последнего сообщения) 
boolean publish(const char* topic, const uint8_t * payload, unsigned int plength, boolean retained);

Обратите внимание – топик и сами данные должны быть представлены как const char* или набор байт uint8_t*. Поэтому, если вы используете динамические строки класса String, то необходимо привести их к нужному виду, например так:

// -------------------------------------------------------
// Топики MQTT 
// -------------------------------------------------------

const char* mqtt_topic_status   = "dzen/esp32_artuino/status";
const char* mqtt_topic_config   = "dzen/esp32_artuino/config";

...

// Публикуем статус устройства
String payload = "online";
mqtt_client.publish(mqtt_topic_status, payload.c_str(), false);

 


4.1.6. Оформление подписки и получение входящих данных от MQTT-сервера

Если вам нужно получать какие-либо данные с других устройств или с панели управления, сразу после подключения к серверу можно оформить подписку на интересующие ваше устройство топики. Для этого воспользуйтесь методами:

// Подписаться на топик
boolean subscribe(const char* topic);

// Подписаться на топик с заданным уровнем обслуживания
boolean subscribe(const char* topic, uint8_t qos);

// Отписаться от топика
boolean unsubscribe(const char* topic);

Допустим, у нас есть некая переменная, для простоты так и назовем её config_value, а значения для её изменения будем ждать в топике “dzen/esp32_artuino/config“. Тогда мы можем написать такой код:

// -------------------------------------------------------
// Топики MQTT 
// -------------------------------------------------------

const char* mqtt_topic_config   = "dzen/esp32_artuino/config";

// -------------------------------------------------------
// Глобальные переменные
// -------------------------------------------------------

int config_value = 0;                              // Некая переменная для настройки извне

Далее, мы должны написать функцию обратного вызова, которая будет срабатывать при поступлении новых данных:

// Функция обратного вызова при поступлении входящего сообщения от брокера
void mqttOnIncomingMessage(char* topic, uint8_t* payload, unsigned int length)
{
  // Преобразуем поступившие данные в String
  String _topic = topic;
  String _payload;
  for (unsigned int i = 0; i < length; i++) {
    _payload += String((char)payload[i]);
  };
  _payload.toLowerCase();
  _payload.trim();

  // Вывод поступившего сообщения в лог, больше никакого смысла этот блок кода не несет, можно исключить
  Serial.print("Message arrived [");
  Serial.print(_topic.c_str());
  Serial.print("]: ");
  Serial.print(_payload.c_str());
  Serial.println();

  // Сравниваем топик с эталонным значением
  if (_topic.equalsIgnoreCase(mqtt_topic_config)) {
    // Сохраняем полученное значение в переменной
    config_value = _payload.toInt();
    Serial.print("New value for variable config_value=");
    Serial.print(config_value);
    Serial.println(" received");
  };
}

И, наконец, регистрируем её в функции mqttConnect() сразу после подключения к серверу:

if (mqtt_client.connect(mqtt_client_id, mqtt_user, mqtt_pass)) {
  Serial.println("ok");

  // Устанавливаем сallback и подписываемся на топик некоего параметра
  mqtt_client.setCallback(mqttOnIncomingMessage);
  mqtt_client.subscribe(mqtt_topic_config);

  // Публикуем статус устройства
  String payload = "online";
  mqtt_client.publish(mqtt_topic_status, payload.c_str(), false);
} else {
  Serial.print("failed, error code: ");
  Serial.print(mqtt_client.state());
  Serial.println("!");
};
return mqtt_client.connected();

Проверим работу:

Connecting to k12iot
Waiting for WiFi...
Disconnected from WiFi access point
Connected to access point
WiFi connected, IP address: 192.168.10.94
Time synchronization start...
Time synchronization completed, current time: Wed Jul 23 19:58:19 2025
Connecting to MQTT broker: ok
Message arrived [dzen/esp32_arduino/config]: 10
New value for variable config_value=10 received

Подписываться можно не на конкретный топик, а например на все топики устройства, например так: “dzen/esp32_artuino/+“, впрочем я об этом уже писал здесь: Что такое MQTT и с чем его едят?

 


4.1.7. LWT

В заключение модифицируем наш код так, чтобы при неожиданном отключении устройства от сервера, в топке “dzen/esp32_artuino/status” само собой волшебным образом появилось сообщение offline. Сделать это очень просто:

...
// Пробуем подключиться и публикуем LWT сообщение "offline"
if (mqtt_client.connect(mqtt_client_id, mqtt_user, mqtt_pass, mqtt_topic_status, 1, true, "offline")) {
  Serial.println("ok");
  ...

Вуаля! Теперь если отключить ESP32, спустя несколько секунд в топике status вместо сообщения online появиться offline. Теперь мы всегда точно знаем, включено ли наше устройство на ESP32 и можно ли им управлять.

dzen
  esp32_arduino
    status = offline
    config = 10

 


4.2. Подключение к MQTT серверу с помощью ESP MQTT Client

В состав ESP-IDF входит довольно мощная и удобная библиотека для работы с MQTT – esp-mqtt. Можно также воспользоваться ей для работы с MQTT, хотя это и немного более сложный путь, чем описан в предыдущем разделе. Но ведь «у самурая нет цели, есть только путь». Вы же не боитесь трудностей?

Что это дает:

  • Поддержка всех типов соединений (TCP, TLS, WebSocket) для подключения к серверу и множества настраиваемых параметров, полностью соответствует протоколу MQTT
  • ESP32 MQTT Client запускает для своей работы отдельную задачу, поэтому из loop() вашего скетча не нужно будет тратить время на его “обслуживание” – он работает в фоне и незаметно для вас
  • Полностью асинхронный программный интерфейс, можно безопасно настраивать подключение к серверу прямо в callback-е WiFi Event
  • ESP32 MQTT API потокобезопасен и может совместно использоваться несколькими задачами одновременно
  • ESP32 MQTT API имеет исходящую очередь отправки, поэтому сообщения не будут потеряны, даже если клиент отключен от сервера (только с QoS > 0)
  • Для работы не нужно подключать WiFiClientSecure и создавать переменных типа WiFiClient – все работает на уровне netif, с любыми протоколами транспортного уровня полностью автоматически.

Из недостатков можно отметить:

  • Сравнительно высокую сложность разработки кода
  • Невозможность конфигурования клиента с помощью menuconfig (за исключением режима сборки “ESP-IDF & Arduino as component”), так что работать придется с настройками по умолчанию. 
  • Мне не удалось отключить проверку сертификата сервера. То есть вы можете использовать либо обычный TCP (без шифрования), либо “нормальный” TLS, промежуточных вариантов не дано. Это можно было бы настроить через menuconfig, но он не доступен.

Прежде всего я советовал бы вам прочитать другую статью ESP32 MQTT Client API, в которой подробно объясняется работа с этим API. В целом подходы, изложенные в ней, соответствуют тому, что можно применять в Arduino ESP32. Но… Arduino ESP32 пока поддерживает более старую версию ESP32 MQTT API, так что настроить MQTT будет даже немного проще, а в остальном ничего не изменилось. Впрочем, это может со временем измениться, и вам придется воспользоваться примерами настройки подключения из статьи ESP32 MQTT Client API.

 

4.2.1. Подключение к MQTT серверу

Подключаем API к проекту:

#include "mqtt_client.h"

Для общения с брокером нам понадобиться только одна переменная типа esp_mqtt_client_handle_t, её лучше инициализировать как NULL:

// -------------------------------------------------------
// Глобальные переменные
// -------------------------------------------------------

esp_mqtt_client_handle_t mqtt_client = NULL;       // Указатель на MQTT-клиента

int config_value = 0;                              // Некая переменная для настройки извне

В данном варианте я не буду отдельно рассматривать варианты подключения с шифрованием и без оного – статья и без этого получилась слишком длинной. Дальше в примере доступны оба варианта, выбрать один из которых можно с помощью условного макроса, как это описано выше в разделе 4.1.4.3.

В библиотеке mbedTLS для варианта ESP-IDF, по умолчанию запрещено пропускать проверку сертификата сервера, поэтому придется добавлять и проверять корневой сертификат сервера:

static const char cert_ISRG_Root_x1[] = \
  "-----BEGIN CERTIFICATE-----\n" \
  "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n" \
  "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n" \
  "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n" \
  "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n" \
  "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n" \
  "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n" \
  "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n" \
  "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n" \
  "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n" \
  "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n" \
  "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n" \
  "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n" \
  "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n" \
  "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n" \
  "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n" \
  "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n" \
  "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n" \
  "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n" \
  "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n" \
  "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n" \
  "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n" \
  "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n" \
  "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n" \
  "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n" \
  "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n" \
  "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n" \
  "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n" \
  "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n" \
  "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" \
  "-----END CERTIFICATE-----\n";

ESP MQTT API рассчитано на работу с использованием системного цикла событий, который должен быть предварительно запущен. Но нам ничего запускать не потребуется, так как тот же самый цикл событий используется драйвером WiFi и к этому моменту должен уже работать.

MQTT API имеет свой собственный класс событий – MQTT_EVENTS. Он включает в себя несколько разных идентификаторов событий, на которые мы можем по разному реагировать. или игнорировать. Для этого мы должны создать обработчик событий MQTT_EVENTS

Я создал такой обработчик:

// Обработчик событий MQTT клиента
void cbMqttEvent(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data)
{
  // Извлекаем нужные данные
  esp_mqtt_event_handle_t event = (esp_mqtt_event_handle_t)event_data;
  esp_mqtt_client_handle_t client = event->client;

  // В зависимости от идентификатора события действуем по разному...
  switch ((esp_mqtt_event_id_t)event_id) {
    // Подключились к серверу - публикуем некие данные и подписываемся
    case MQTT_EVENT_CONNECTED:
        Serial.println("Successfully connected to MQTT server");
        // Публикуем состояние устройства
        esp_mqtt_client_publish(client, mqtt_topic_status, "online", 0, 1, 0);
        // Подписываемся на топик параметра
        esp_mqtt_client_subscribe(client, mqtt_topic_config, 0);
        break;
    // Отключились от сервера
    case MQTT_EVENT_DISCONNECTED:
        Serial.println("Connection to MQTT server lost");
        break;
    // Подписка выполнена
    case MQTT_EVENT_SUBSCRIBED:
        Serial.println("Subscription successfully completed");
        break;
    // Сообщение опубликовано
    case MQTT_EVENT_PUBLISHED:
        Serial.print("Message sent successfully, id: ");
        Serial.println(event->msg_id);
        break;
    // Поступил блок данных
    case MQTT_EVENT_DATA:
        Serial.print("Incoming message received: topic=");
        Serial.write(event->topic, event->topic_len);
        Serial.print(", data=");
        Serial.write(event->data, event->data_len);
        Serial.println();
        // Сравниваем топики 
        if (strncasecmp(mqtt_topic_config, (const char*)event->topic, event->topic_len) == 0) {
          // Сохраняем полученное значение в переменной
          config_value = atoi(event->data);
          Serial.print("New value for variable config_value=");
          Serial.print(config_value);
          Serial.println(" received");
        };
        break;
    // Ошибка
    case MQTT_EVENT_ERROR:
        Serial.print("MQTT error");
        break;
    // Все остальное
    default:
        break;
  }
}

Как оно работает?

  • При получении события  MQTT_EVENT_CONNECTED (подключение выполнено) выводим сообщение в лог, публикуем состояние “online” в топике “dzen/esp32_arduino/status” и подписываемся на топик настроек “dzen/esp32_arduino/config
  • При получении события MQTT_EVENT_DATA (входящие данные) сравниваем топики и записываем новое значение в переменную
  • В остальных случаях просто выводим отладочное сообщение для информации

Теперь можно написать функцию запуска MQTT-клиента. Не подключения, а именно запуска клиента!

Её можно разбить на два этапа: инициализации клиента и его непосредственного запуска. Настройка MQTT-клиента, конечно, выглядит гораздо сложнее, чем в случае с PubSubClient, зато все параметры в одном месте:

// Инициализация ESP-IDF netif
esp_netif_init();

// Структура настройки - заполним нулями
esp_mqtt_client_config_t mqttCfg = {0};

// Настраиваем MQTT сервер
mqttCfg.host = mqtt_server;
#if CONFIG_MQTT_USE_TLS
  // Безопасное подключение
  mqttCfg.transport = MQTT_TRANSPORT_OVER_SSL;
  mqttCfg.port = mqtt_port_tls;
  // Прикрепляем корневой сертификат сервера
  mqttCfg.cert_pem = (const char*)cert_ISRG_Root_x1;
  mqttCfg.cert_len = strlen(cert_ISRG_Root_x1)+1;
#else
  // Обычное подключение
  mqttCfg.transport = MQTT_TRANSPORT_OVER_TCP;
  mqttCfg.port = mqtt_port;
#endif // CONFIG_MQTT_USE_TLS

// Автоматическое переподключение к серверу
mqttCfg.disable_auto_reconnect = false;

// Логин / пароль
mqttCfg.username = mqtt_user;
mqttCfg.password = mqtt_pass;
// Для автогенерации ESP32_%CHIPID% укажите NULL
mqttCfg.client_id = NULL; 

// Параметры сеанса
mqttCfg.disable_clean_session = false;
mqttCfg.keepalive = 60;

// Завещание
mqttCfg.lwt_topic = mqtt_topic_status;
mqttCfg.lwt_msg = "offline";
mqttCfg.lwt_msg_len = strlen(mqttCfg.lwt_msg);
mqttCfg.lwt_qos = 1;
mqttCfg.lwt_retain = false;

Затем создаем задачу под MQTT-клиент:

// Создаем задачу MQTT клиента
mqtt_client = esp_mqtt_client_init(&mqttCfg);
// Регистрируем обработчик событий
esp_mqtt_client_register_event(mqtt_client, MQTT_EVENT_ANY, cbMqttEvent, NULL);

Затем, если все прошло успешно, можно уже запустить клиент:

// Запускаем задачу MQTT клиента
esp_err_t err = esp_mqtt_client_start(mqtt_client);
// Проверяем результаты работы
if (err == ESP_OK) {
  Serial.println("ok");
} else {
  Serial.print("failed, err=");
  Serial.println(err);
};

Полная функция запуска MQTT Client выглядит так:

// Запуск MQTT клиента
bool mqttStart()
{
  Serial.print("Start MQTT client: ");
  if (mqtt_client == NULL) {
    // Инициализация ESP-IDF netif
    esp_netif_init();

    // Структура настройки - заполним нулями
    esp_mqtt_client_config_t mqttCfg = {0};
    
    // Настраиваем MQTT сервер
    mqttCfg.host = mqtt_server;
    #if CONFIG_MQTT_USE_TLS
      // Безопасное подключение
      mqttCfg.transport = MQTT_TRANSPORT_OVER_SSL;
      mqttCfg.port = mqtt_port_tls;
      // Прикрепляем корневой сертификат сервера
      mqttCfg.cert_pem = (const char*)cert_ISRG_Root_x1;
      mqttCfg.cert_len = strlen(cert_ISRG_Root_x1)+1;
    #else
      // Обычное подключение
      mqttCfg.transport = MQTT_TRANSPORT_OVER_TCP;
      mqttCfg.port = mqtt_port;
    #endif // CONFIG_MQTT_USE_TLS

    // Автоматическое переподключение к серверу
    mqttCfg.disable_auto_reconnect = false;

    // Логин / пароль
    mqttCfg.username = mqtt_user;
    mqttCfg.password = mqtt_pass;
    // Для автогенерации ESP32_%CHIPID% укажите NULL
    mqttCfg.client_id = NULL; 
    
    // Параметры сеанса
    mqttCfg.disable_clean_session = false;
    mqttCfg.keepalive = 60;
    
    // Завещание
    mqttCfg.lwt_topic = mqtt_topic_status;
    mqttCfg.lwt_msg = "offline";
    mqttCfg.lwt_msg_len = strlen(mqttCfg.lwt_msg);
    mqttCfg.lwt_qos = 1;
    mqttCfg.lwt_retain = false;

    // Создаем задачу MQTT клиента
    mqtt_client = esp_mqtt_client_init(&mqttCfg);
    // Регистрируем обработчик событий
    esp_mqtt_client_register_event(mqtt_client, MQTT_EVENT_ANY, cbMqttEvent, NULL);
  };
  
  // Запускаем задачу MQTT клиента
  esp_err_t err = esp_mqtt_client_start(mqtt_client);
  // Проверяем результаты работы
  if (err == ESP_OK) {
    Serial.println("ok");
  } else {
    Serial.print("failed, err=");
    Serial.println(err);
  };
  return err == ESP_OK;
}

В случае потери доступа в сеть необходимо приостановить работу клиента:

// Останавливаем mqtt клиент
void mqttStop()
{
  if (mqtt_client) {
    esp_mqtt_client_stop(mqtt_client);
  };
}

Обе этих функции можно и нужно запихнуть в обработчик событий WiFi-подключения – именно там им и место:

// Этот обработчик вызывается при различных WiFi-событиях
void cbWiFiEvent(WiFiEvent_t event) {
  switch (event) {
    case ARDUINO_EVENT_WIFI_STA_CONNECTED:
      Serial.println("Connected to access point");
      break;
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      Serial.print("WiFi connected, IP address: ");
      Serial.println(WiFi.localIP());
      // Запускаем синхронизацию времени
      sntpSyncStart();
      // Запускаем MQTT-клиента
      mqttStart();
      break;
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
      Serial.println("Disconnected from WiFi access point");
      mqttStop();
      break;
    default:
      break;
  }
}

А вот рабочий цикл loop() мы полностью освободили для прикладных задач: 

// Рабочий цикл, повторяется непрерывно
void loop() {
  // Делаем какую-то основную полезную работу, не связанную с WiFi
  
  // Ожидание
  delay(100);
}

Красота!

Проверяем:

Connecting to k12iot
Waiting for WiFi...
Disconnected from WiFi access point
Connected to access point
WiFi connected, IP address: 192.168.10.94
Time synchronization start...
Start MQTT client: ok
Successfully connected to MQTT server
Message sent successfully, id: 12784
Subscription successfully completed
Time synchronization completed, current time: Thu Jul 24 20:49:31 2025
Incoming message received: topic=dzen/esp32_arduino/config, data=20
New value for variable config_value=20 received

Всё работает как часы. Хотя, судя по логу, синхронизация времени прошла позже, чем подключился клиент, но это скорее всего связано с асинхронностью работы разных задач.

Код, хоть выглядит и несколько громоздким, зато надежен. Как видите, не так уж было и сложно, “не так уж и страшен ESP-IDF, как его малюют”. И вполне можно использовать из экосистемы Arduino.

 


5. Создаем и отправляем HTTP(S)-запросы

Ещё одна частая необходимость – отправлять HTTP GET-запросы на различные ресурсы “в этих ваших интерьнетах”. С помощью этих самых запросов можно:

Если вы не сталкивались с этим, то рекомендую вам предварительно ознакомиться с другой статьей: HTTP запросы на ESP8266 и ESP32 и другими статьями.

Давайте посмотрим, как это можно реализовать. Как и в предыдущих случаях, сделать это можно как с помощью библиотек Arduino ESP32WiFiClient или HTTPClient, так и с помощью более низкоуровневой библиотеки ESP HTTP Client.

В качестве примеров я буду использовать два варианта:

Многие современные сайты и сервисы, которые допускают обмена данными через REST API, допускают только шифрованные TLS-подключения. Например telegram api вернет ошибку, если вы попытаетесь отправить запрос через открытое HTTP-подключению. Поэтому волей-неволей нам придется шспользовать HTTPS протокол. Подробнее о защищенных соединениях вы можете прочитать тут: HTTPS, SSL/TLS соединения на Arduino или в разделе 4.1.4.1. Библиотека NetworkClientSecure данной статьи.

 


5.1. Отправка запросов с помощью Arduino WiFiClient

Во первых, можно вообще не использовать никаких дополнительных библиотек, а воспользоваться средствами уже подключенной к проекту библиотеки WiFi, а точнее – классом WiFiClient. Но этот класс представляет нам только функции транспортного уровня, то есть может только принимать и отправлять какие-то обезличенные данные по каналу связи между вашим устройством и неким сервером. Всё остальное – формирование заголовков и тела запроса, анализ ответа сервера – всё это придется делать что называется “ручками”. Этот способ подходит для простых задач, когда не требуется дополнительная обработка заголовков или тела ответа.

Примерный порядок работы:

  • Устанавливается соединение с сервером с помощью метода connect()
  • Формируются вручную и построчно отправляются заголовки HTTP GET-запроса
  • Читается и при необходимости анализируется ответ сервера

Обращение к web-серверу напишем в виде отдельной функции.

 

5.1.1 Динамическое и статическое создание экземпляра WiFiClient

Для работы c HTTP-запросами нам понадобится “отдельный” экземпляр класса WiFiClient, причем это может быть как “простой” WiFiClient, так и его потомок – “защищенный” WiFiClientSecure. Не допускается использовать тот же самый экземпляр, что мы использовали, например для PubSubClient. Поэтому нам нужно создать ещё одну переменную. Но сделать это можно двумя способами: статическим и динамическим. 

Глобальная статическая переменная

Когда я рассказывал про PubSubClient, я просто объявил глобальную переменную wifi_client – она находится вне какой-либо функции вашего скетча и доступна во всех частях кода. Можно и сейчас сделать точно также (я привел варианты как для HTTP, так и для HTTPS-запросов – выберите один вариант):

// Можно использовать для HTTP-запросов
WiFiClient global_http_wifi_client;

// Можно использовать для HTTPS-запросов
WiFiClientSecure global_https_wifi_client;

В этом случае компилятор поместит этот объект global_http(s)_wifi_client в специальную секцию памяти BSS, а инициализация экземпляра класса (то есть вызов конструктора класса) будет происходить даже до того, как скетч будет запущен на выполнение. Переменная будет находиться там постоянно, все время пока работает ваше приложение. Даже если вы отправляете HTTP-запросы один раз в пять минут, память под global_http(s)_wifi_client будет занята всегда.

Тоже самое происходит, если вы помечаете переменную как static.

Для MQTT-клиента это было совершенно правильно, так как PubSubClient постоянно поддерживает соединение постоянно. Для HTTP-запросов это может оказаться лишним и приведет к перерасходу оперативной памяти.

 

Глобальная локальная переменная

Допустим, мы хотим написать некую функцию, которая будет выполнять некий HTTP-запрос время от времени. В этом случае разумным будет поместить объявление переменной внутри этой функции, например так:

void sendHttpRequest(const char * data)
{
  WiFiClient local_wifi_client;

  ... ваш код...
}

или так:

void sendHttpsRequest(const char * data)
{
  WiFiClientSecure local_wifi_client;

  ... ваш код...
}

Локальные переменные компилятор помещает в стек задачи, в которой выполняется вызываемая функция. Экземпляр класса будет создан и инициализирован в момент входа в данную функцию, а после выхода из неё – удален безвозвратно (только если вы не пометили её как static).  Вызывали функцию десять раз – десять раз объект будет создан и удален.

Это приемлемое поведение для таких объектов в большинстве случаев. Но есть одно небольшое “но” – стек задачи “не резиновый”. Потому что у каждой задачи ESP32 (а скетч – это тоже задача, как мы помним) – свой личный стек. Если попытаться запихнуть в стек одновременно много “тяжелых” объектов, а памяти под задачу при её создании выделено слишком мало, то можно нарваться на переполнение стека, и как следствие – аварийную перезагрузку. Конечно, такое поведение маловероятно, но не исключено полностью.

 

Динамическая переменная

Есть альтернативный вариант – выделить под объект память в общей куче heap. Сделать это можно так:

void sendHttpRequest(const char * data)
{ 
  // Создаем новый объект в heap
  WiFiClient* heap_wifi_client = new WiFiClient(); 

  ... ваш код... 

  // Не забываем удалить объект после использования и освободить память
  delete heap_wifi_client;
  heap_wifi_client = NULL;
}

или так:

void sendHttpsRequest(const char * data)
{ 
  // Создаем новый объект в heap
  WiFiClientSecure* heap_wifi_client = new WiFiClientSecure(); 

  ... ваш код... 

  // Не забываем удалить объект после использования и освободить память
  delete heap_wifi_client;
  heap_wifi_client = NULL;
}

В этом случае переменная-объект размещается в общей для всех задач куче с помощью new, и “живет” до тех пор, пока не будет принудительно удалена с помощью delete. При использовании этого метода мы почти не затрагиваем стек задачи, но должны всегда сами тщательно следить за уничтожением объектов.

Никогда не забывайте удалять объекты, под которые вы выделяли память из кучи! Иначе это приведет к так называемой “утечке памяти”, и при каждой итерации свободная память будет уменьшаться, уменьшаться и уменьшаться, что приведет к печальным последствиям.

Ещё одно отличие динамических переменных – для этого варианта при к доступе к полям структур или методам класса необходимо указывать -> вместо точки:

client->connect(hostName, httpPort);

 

В качестве вывода из всего сказанного можно сказать следующее: нет плохих или хороших универсальных способов. Какой из перечисленных выше методов выбрать – решаете только вы сами в каждом конкретном практическом случае.

Ну а мы приступаем к практическим экспериментам. В примерах ниже я буду использовать локальные переменные.

 


5.1.2 Отправка GET-запроса с использованием WiFiClient

Вариант 1 без шифрования

Отправка данных в открытом виде, без шифрования:

void sendNaronmonData(const float value1, const float value2, const float value3)
{
  WiFiClient wifi_client;
  Serial.print("Send data to narodmon.ru: ");
  if (wifi_client.connect("narodmon.ru", 80)) {
    // Формируем и отправляем GET-запрос для отправки данных
    wifi_client.printf("GET /get?ID=00FF00FF00FF00FF&sensor1=%f&sensor2=%f&sensor3=%f HTTP/1.1\r\n", value1, value2, value3);
    // Отправляем служебные HTTP-заголовки
    wifi_client.println("Host: narodmon.ru");
    wifi_client.println("User-Agent: ESP32");
    wifi_client.println("Connection: close");
    // Отправляем пустую строку, которая указывает серверу, что запрос полностью отправлен
    wifi_client.println();
    // Читаем ответ сервера (только первую строку)
    Serial.print("responce code: ");
    Serial.println(wifi_client.readStringUntil('\n'));
    // Закрываем соединение
    wifi_client.stop();
  } else {
    Serial.println("failed to connect");
  };
}

 

Вариант 2 без шифрования

Точно тоже самое, но записано другим способом:

const char* requestNarodmon = "GET /get?ID=00FF00FF00FF00FF&sensor1=%f&sensor2=%f&sensor3=%f HTTP/1.1\r\n"
                              "Host: narodmon.ru\r\n"
                              "User-Agent: ESP32\r\n"
                              "Connection: close\r\n\r\n";

void sendNaronmonData(const float value1, const float value2, const float value3)
{
  WiFiClient wifi_client;
  Serial.print("Send data to narodmon.ru: ");
  if (wifi_client.connect("narodmon.ru", 80)) {
    // Формируем и отправляем GET-запрос
    wifi_client.printf(requestNarodmon, value1, value2, value3);
    // Читаем ответ сервера (только первую строку)
    Serial.print("responce code: ");
    Serial.println(wifi_client.readStringUntil('\n'));
    // Закрываем соединение
    wifi_client.stop();
  } else {
    Serial.println("failed to connect");
  };
}

Мне этот вариант нравится больше.

 

Вариант  с TLS-шифрованием

Добавим шифрование. Для этого мы должны изменить следующее:

  • Заменить класс WiFiClient на WiFiClientSecure
  • Заменить порт на сервере с 80 на 443
  • Добавить корневой сертификат сервера setCACert(cert_ISRG_Root_x1) (это не обязательно должен быть cert_ISRG_Root_x1) или отключить проверку сертификата через setInsecure()
const char* requestNarodmon = "GET /get?ID=00FF00FF00FF00FF&sensor1=%f&sensor2=%f&sensor3=%f HTTP/1.1\r\n"
                              "Host: narodmon.ru\r\n"
                              "User-Agent: ESP32\r\n"
                              "Connection: close\r\n\r\n";

void sendNaronmonData(const float value1, const float value2, const float value3)
{
  WiFiClientSecure wifi_client;
  Serial.print("Send data to narodmon.ru: ");
  // Не проверять сертификат сервера - не безопасно, но просто и не нужно заботиться о сроках действия сертификатов
  // wifi_client.setInsecure(); 
  // Или прикрепляем корневой сертификат сервера - безопасно, но нужно следить за сроками действия
  wifi_client.setCACert(cert_ISRG_Root_x1);
  if (wifi_client.connect("narodmon.ru", 443)) {
    // Формируем и отправляем GET-запрос
    wifi_client.printf(requestNarodmon, value1, value2, value3);
    // Читаем ответ сервера (только первую строку)
    Serial.print("responce code: ");
    Serial.println(wifi_client.readStringUntil('\n'));
    // Закрываем соединение
    wifi_client.stop();
  } else {
    Serial.println("failed to connect");
  };
}

 

5.1.3 Отправка POST-запроса с использованием WiFiClient

Telegram API принимает только защищенные запросы, поэтому простой HTTP-вариант отметаем сразу. Кроме того, лучше воспользоваться POST-запросом, так как это дает больше возможностей. Сообщение и сопроводительные данные “упаковываются” в JSON-объект и прикрепляются к запросу. Подробнее обо всем этом здесь: Отправка сообщений в Telegram на ESP8266 (и ESP32) с использованием фреймворка Arduino

Объявляем необходимые константы:

const char* tgToken = "токен вашего бота";    // Укажите здесь токен вашего бота
const char* chatId  = "идентификатор чата";   // Укажите здесь номер чата получателя

Далее пишем такой код, он не сильно отличается от предыдущего варианта:

const char* resuestTgMessage = "POST https://api.telegram.org/bot%s/sendMessage HTTP/1.1\r\n"
                               "Host: api.telegram.org\r\n"
                               "User-Agent: ESP8266\r\n"
                               "Content-Type: application/json\r\n"
                               "Content-Length: %d\r\n\r\n"
                               "%s\r\n\r\n";

void sendTelegramMessage(const char* message)
{
  WiFiClientSecure wifi_client;
  Serial.print("Send message to telegram: ");
  Serial.print(message);
  Serial.print(": ");
  // Формируем JSON-пакет с данными
  String sJSON = "{\"chat_id\":" + String(chatId) + ",\"parse_mode\":\"HTML\",\"disable_notification\":false,\"text\":\"" + String(message) + "\"}";
  // Не проверять сертификат сервера - не безопасно, но просто и не нужно заботиться о сроках действия сертификатов
  // wifi_client.setInsecure(); 
  // Или прикрепляем корневой сертификат сервера - безопасно, но нужно следить за сроками действия
  wifi_client.setCACert(cert_TG_API_Root);
  if (wifi_client.connect("api.telegram.org", 443)) {
    // Формируем и отправляем POST-запрос
    wifi_client.printf(resuestTgMessage, tgToken, sJSON.length(), sJSON.c_str());
    // Читаем ответ сервера (только первую строку)
    Serial.print("responce code: ");
    Serial.println(wifi_client.readStringUntil('\n'));
    // Закрываем соединение
    wifi_client.stop();
  } else {
    Serial.println("failed to connect");
  };
}

 


5.2. Библиотека Arduino HTTPClient

Для работы с HTTP(S)-запросами в фреймворке Arduino ESP32 имеется специальная библиотека HTTPClient. Она позволяет выполнять GET, PUT, POST и другие типы запросов без необходимости “вручную” отправлять заголовки и тело запроса. В ней уже реализованы все необходимые механизмы для работы с HTTP-запросами и вам не придется с ними разбираться.

Но размер двоичного кода, генерируемого компилятором, будет немного больше, чем в предыдущем случае – но это очень часто не важно. А вот удобство работы решает всё.

Давайте попробуем реализовать то же самое, но с перламутровыми пуговицами этой библиотекой.

5.2.1 Отправка GET-запроса с использованием HTTPClient

HTTPClient по умолчанию использует “внутренний” экземпляр WiFiClient, поэтому объявлять его не нужно (хотя и можно это сделать, если вам это необходимо для каких-то целей).

void sendNaronmonData(const float value1, const float value2, const float value3)
{
  Serial.print("Send data to narodmon.ru: ");
  // Объявляем переменную HTTP Client
  HTTPClient http_tg;
  // Формируем динамическую URI
  String sURI = "https://narodmon.ru/get?ID=00FF00FF00FF00FF&sensor1=" + String(value1) + "&sensor2=" + String(value2) + "&sensor3=" + String(value3);
  // Пробуем подключиться к серверу с указанным сертификатом
  if (http_tg.begin(sURI.c_str(), cert_ISRG_Root_x1)) {
    // Отправляем GET-запрос
    int httpCode = http_tg.GET();
    // Выводим в лог код ответа сервера
    Serial.printf("Responce code: %d \"%s\"\r\n", httpCode, http_tg.errorToString(httpCode));
    // Закрываем соединение
    http_tg.end();
  } else {
    Serial.println("Failed to connect to narodmon.ru");
  };
}

 

5.2.2 Отправка POST-запроса с использованием HTTPClient

Теперь давайте попробуем отправить сообщение в telegram с помощью POST-запроса:

const char* tgToken = "токен вашего бота";    // Укажите здесь токен вашего бота
const char* chatId  = "идентификатор чата";   // Укажите здесь номер чата получателя

void sendTelegramMessage(char* message)
{
  Serial.print("Send message to telegram: ");
  Serial.print(message);
  Serial.print(": ");
  // Формируем динамическую URI
  String sURI = "https://api.telegram.org/bot" + String(tgToken) + "/sendMessage";
  // Формируем JSON-пакет с данными
  String sJSON = "{\"chat_id\":" + String(chatId) + ",\"parse_mode\":\"HTML\",\"disable_notification\":false,\"text\":\"" + String(message) + "\"}";
  Serial.println(sJSON.c_str());
  // Объявляем переменную HTTP Client
  HTTPClient http_tg;
  // Пробуем подключиться к Telegram API по сгенерированному URI и с указанным сертификатом
  if (http_tg.begin(sURI.c_str(), cert_TG_API_Root)) {
    // Добавляем заголовок с типом данных
    http_tg.addHeader("Content-Type", "application/json");
    // Формируем и отправляем POST-запрос
    int httpCode = http_tg.POST(sJSON);
    // Выводим в лог код ответа сервера
    Serial.printf("API responce code: %d \"%s\"\r\n", httpCode, http_tg.errorToString(httpCode));
    // Закрываем соединение
    http_tg.end();
  } else {
    Serial.println("Failed to connect to telegram API");
  };
}

Проверим работу:

sendTelegramMessage("Привет мир");
Connecting to k12iot
Waiting for WiFi...
Connected to access point
WiFi connected, IP address: 192.168.10.94
Time synchronization start...
Time synchronization completed, current time: Fri Jul 25 19:54:22 2025
Connecting to MQTT broker: ok
Send message to telegram: Привет мир: {"chat_id":-00000000000000,"parse_mode":"HTML","disable_notification":false,"text":"Привет мир"}
API responce code: 200 ""

 


5.3. Библиотека ESP-IDF ESP HTTP Client

Если вам нужно больше возможностей или более низкоуровневый контроль над процессом, то для ESP-IDF существует компонент esp_http_client, который вполне можно использовать и из экосистемы Arduino. Подробнее об этом компоненте я писал ранее в других статьях: HTTP запросы на ESP8266 и ESP32 и HTTPS, SSL/TLS соединения на ESP32 и ESP-IDF.

При работе с ESP HTTP Client из Arduino ESP32 имеется одна особенность: нельзя отключить проверку сертификата сервера, так как мы не имеем доступа к menuconfig (кроме варианта с “arduino as component“, конечно), а по умолчанию эта опция заблокирована. Поэтому либо используем “опасные” подключения, либо прикрепляем к проекту корневой сертификат сервера и проверяем его “как положено”.

5.3.1 Отправка GET-запроса с использованием ESP HTTP Client

Прежде всего необходимо подключить библиотеку к проекту:

#include "esp_http_client.h"

Теперь можно писать код для работы с HTTP. Он более “громоздкий”, чем для HTTPClient, но по сути ничуть не более сложный.

void sendNaronmonData(const float value1, const float value2, const float value3)
{
  Serial.print("Send data to narodmon.ru: ");
  
  // Формируем динамическую URI
  String sURI = "https://narodmon.ru/get?ID=00FF00FF00FF00FF&sensor1=" + String(value1) + "&sensor2=" + String(value2) + "&sensor3=" + String(value3);
  
  // Настраиваем HTTP подключение
  esp_http_client_config_t cfgHttp = {0};
  cfgHttp.method = HTTP_METHOD_GET;
  cfgHttp.url = sURI.c_str();
  cfgHttp.transport_type = HTTP_TRANSPORT_OVER_SSL;
  cfgHttp.cert_pem = cert_ISRG_Root_x1;

  // Создаем http_client
  esp_http_client_handle_t client = esp_http_client_init(&cfgHttp);
  if (client) {
    // Запускаем на выполнение
    esp_err_t ret = esp_http_client_perform(client);
    if (ret == ESP_OK) {
      // Запрашиваем код возврата сервера
      int retCode = esp_http_client_get_status_code(client);
      if (retCode == HttpStatus_Ok) {
        Serial.println("ok");
      } else {
        Serial.printf("failed to send data, error code: #%d!\n", retCode);
      };
    } else {
      Serial.printf("failed to complete http request, error code: 0x%x!\n", ret);
    };
    // Не забываем удалить клиента после использования
    esp_http_client_cleanup(client);
  };
}

 

5.3.2 Отправка GET-запроса с использованием ESP HTTP Client

Telegram API принимает только защищенные запросы, поэтому простой HTTP-вариант отметаем сразу. Кроме того, лучше воспользоваться POST-запросом, так как это дает больше возможностей. Сообщение и сопроводительные данные “упаковываются” в JSON-объект и прикрепляются к запросу. Подробнее обо всем этом здесь: Отправка сообщений в Telegram на ESP32 с использованием фреймворка ESP-IDF

Прежде всего необходимо подключить библиотеку к проекту:

#include "esp_http_client.h"

Объявляем необходимые константы:

const char* tgToken = "токен вашего бота";    // Укажите здесь токен вашего бота
const char* chatId  = "идентификатор чата";   // Укажите здесь номер чата получателя

Функция отправки сообщения: 

void sendTelegramMessage(char* message)
{
  Serial.print("Send message to telegram: ");
  Serial.print(message);
  Serial.print(": ");

  // Формируем динамическую URI
  String sURI = "https://api.telegram.org/bot" + String(tgToken) + "/sendMessage";
  // Формируем JSON-пакет с данными
  String sJSON = "{\"chat_id\":" + String(chatId) + ",\"parse_mode\":\"HTML\",\"disable_notification\":false,\"text\":\"" + String(message) + "\"}";
  Serial.println(sJSON.c_str());

  // Настраиваем HTTP подключение
  esp_http_client_config_t cfgHttp = {0};
  cfgHttp.method = HTTP_METHOD_POST;
  cfgHttp.url = sURI.c_str();
  cfgHttp.transport_type = HTTP_TRANSPORT_OVER_SSL;
  cfgHttp.cert_pem = cert_TG_API_Root;

  // Создаем http_client
  esp_http_client_handle_t client = esp_http_client_init(&cfgHttp);
  if (client) {
    // Указываем тип POST-данных
    esp_http_client_set_header(client, "Content-Type", "application/json");
    // Добавляем JSON в тело запроса
    esp_http_client_set_post_field(client, sJSON.c_str(), sJSON.length());
    // Запускаем на выполнение
    esp_err_t ret = esp_http_client_perform(client);
    if (ret == ESP_OK) {
      // Запрашиваем код возврата API
      int retCode = esp_http_client_get_status_code(client);
      if (retCode == HttpStatus_Ok) {
        // Все хорошо, сообщение отправлено
        Serial.println("ok");
      } else if (retCode == HttpStatus_Forbidden) {
        // Слишком много запросов, нужно подождать
        Serial.println("Failed to send message, too many messages, please wait");
      } else {
        // Другая ошибка
        Serial.printf("Failed to send message, API error code: #%d!\n", retCode);
      };
    } else {
      Serial.printf("Failed to complete request to Telegram API, error code: 0x%x!\n", ret);
    };
    // Не забываем удалить клиента после использования
    esp_http_client_cleanup(client);
  };
}

Проверяем работу (токен я не указывал и API послал меня лесом, разумеется):

NVS partition initilized
Read config_value from NVS: 10
Connecting to k12iot
Waiting for WiFi...
Connected to access point
WiFi connected, IP address: 192.168.10.94
Time synchronization start...
Start MQTT client: ok
Successfully connected to MQTT server
Message sent successfully, id: 56337
Subscription successfully completed
Time synchronization completed, current time: Fri Jul 25 21:26:05 2025
Send message to telegram: Привет мир: {"chat_id":идентификатор_чата,"parse_mode":"HTML","disable_notification":false,"text":"Привет мир"}
Failed to send message, API error code: #404!

Похожим образом можно отправлять не только текст, но и картинки, файлы, создавать меню и т.д. 

 


6. Сохранение значений переменных в энергонезависимой памяти

Почти в любых прикладных задачах требуется сохранять какие-либо данные между выключениями устройства. Как я уже писал выше, на ESP32 нет привычной многим EEPROM. Вместо этого производитель предлагает сохранять ваши данные на Flash-памяти. Для хранения различных данных и параметров прошивки разработчики предусмотрели специальный раздел и библиотека NVS (Non-Volatile Storage). Ключевой особенностью этой библиотеки является хранение данных в виде пар “ключ-значение во flash-памяти. В NVS данные организованы в пространства имён (namespaces), поддерживаются базовые типы данных (вплоть до 64-битных целых чисел), строки и бинарные массивы (blobs). Подробнее об этом я уже писал в статье: NVS: энергонезависимая библиотека хранения параметров.

Ключевые особенности NVS:

  • Хранение данных во flash-памяти с учётом минимизации износа (wear leveling).
  • Защита от потери данных при внезапном отключении питания (атомарные обновления).
  • Поддержка шифрования (AES-XTS).
  • Возможность разделения данных по разным пространствам имён и разделам flash-памяти.
  • Используется другими компонентами ESP-IDF, например, Wi-Fi и Bluetooth для хранения своих настроек.

Рекомендации по использованию:

  • NVS лучше всего подходит для хранения небольших и редко изменяемых данных (например, настроек).
  • Не рекомендуется использовать NVS для частого логирования или хранения больших объёмов данных — для этого лучше использовать файловую систему.
  • Для разных групп данных с разной частотой обновления рекомендуется использовать отдельные разделы NVS, чтобы снизить износ памяти и риск повреждения данных.

Минимальный размер раздела NVS — 3 страницы по 4096 байт (одна страница всегда резервируется и не используется для хранения данных)

Подробнее о NVS можно узнать в официальной документации Espressif Non-Volatile Storage Library. В принципе, вам ничто и никто не мешает использовать напрямую ESP NVS API из Arduino, как это мы сделали выше с MQTT и HTTP, но это может показаться кому-то слишком сложным. Поэтому в фреймворке Arduino ESP32 разработчиками предусмотрено аж целых две библиотеки для работы с NVS.

 

6.1. Библиотека EEPROM (устаревший способ)

Библиотека EEPROM реализована как контейнер поверх NVS и предназначена только для обеспечения совместимости со старыми Arduino-проектами и быстрого переноса проектов с других платформ. Ваши данные здесь хранятся как огромный blob массив, что жутко медленно, неудобно и быстро изнашивает память.

Для новых проектов рекомендуется использовать Preferences, так как он работает напрямую с NVS и хранит каждую запись как отдельный объект, что эффективнее и быстрее. Подробнее об этом вы можете узнать здесь: EEPROM is deprecated. For new applications on ESP32, use Preferences.

Я полностью согласен с разработчиками в данном случае. Логика работы с EEPROM – это кошмар для программиста. Поэтому примера работы с EEPROM в данной статье не будет. Забудем об ней как страшный сон.

 


6.2. Библиотека Preferences (рекомендуемый способ)

Библиотека Preferences предназначена для хранения и извлечения небольших данных (например, настроек или параметров устройства) в энергонезависимой Flash-памяти ESP32. Она реализует работу с NVS (Non-Volatile Storage) и является рекомендуемой заменой устаревшей библиотеки EEPROM для платформы Arduino-ESP32.

Идеология работы:

  • Все данные организованы в пространства имён (namespace). В каждом пространстве имён хранятся пары ключзначение, где ключ — это строка до 15 символов, а значение — любой поддерживаемый тип данных.
  • Пространства имён и ключи должны быть уникальны и чувствительны к регистру. Один и тот же ключ может использоваться в разных пространствах имён без конфликтов.
  • В каждый момент времени может быть открыто только одно пространство имён. Для работы с другим пространством его нужно сначала закрыть, а затем открыть новое.
  • Для хранения данных поддерживаются различные типы: булевые значения, целые числа разных разрядностей, строки, массивы байт и др. Тип данных должен совпадать при сохранении и извлечении.
  • Библиотека предоставляет все необходимые методы для создания, открытия и закрытия пространств имён, записи и чтения данных, проверки существования ключа, удаления отдельных ключей или всех данных в пространстве имён, а также определения типа данных по ключу и количества доступных ключей.

Для чего используется:

  • Для хранения пользовательских настроек, параметров конфигурации, состояний и другой информации, которую необходимо сохранять между перезагрузками устройства.
  • Предпочтительна для хранения множества небольших значений, а не больших объёмов данных.

Как работает:

  • Перед записью или чтением данных необходимо открыть пространство имён. Если оно не существует, оно будет создано автоматически.
  • Для записи данных пространство должно быть открыто в режиме чтения/записи; для чтения — достаточно режима только для чтения.
  • При записи изменений новое значение записывается на flash как новая запись, а старая запись помечается как удаленная. Когда вся страница будет помечена как удаленная, её можно “отформатировать” и вновь начать её использование. Это обеспечивает равномерный износ памяти.
  • После завершения работы пространство имён рекомендуется закрывать.
  • Для удаления данных можно удалить отдельный ключ или очистить всё пространство имён.

Библиотека Preferences обеспечивает удобный и надёжный способ работы с энергонезависимой памятью на всех чипах семейства ESP32, делая хранение настроек и параметров простым и безопасным процессом.

6.2.1. Порядок работы с библиотекой Preferences

Подключаем библиотеку к скетчу:

#include <Preferences.h>

После этого можно объявить переменную – указатель на экземпляр класса Preferences для пространства имен, с которым мы хотим работать. Для удобства одновременной работы рекомендуется придерживаться правила: одно пространство имен – одна переменная. Например так:

Preferences app_settings;

После этого необходимо открыть необходимое пространство имен с помощью метода begin():

// Открыть пространство имен
bool begin(const char * name, bool readOnly=false, const char* partition_label=NULL);

// Закрыть пространство имен
void end();

Примечания:

  • Имя пространства имен не может превышать 15 символов.
  • Поскольку на Flash памяти может быть несколько разделов NVS, можно дополнительно указать нужный раздел в аргументе partition_label. Если у вас только один раздел NVS, можно оставить этот параметр пустым.
  • В случае успеха метод вернет true – теперь можно читать или записывать значения.

Открыть пространство имен удобнее всего в секции setup():

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

  // Открываем простраство имен
  settings.begin("settings", false); 
  
  ...
};

Теперь уже можно записывать или считывать какие-то значения.

Для записи значений используйте любую из предложенных функций:

size_t putChar(const char* key, int8_t value);
size_t putUChar(const char* key, uint8_t value);
size_t putShort(const char* key, int16_t value);
size_t putUShort(const char* key, uint16_t value);
size_t putInt(const char* key, int32_t value);
size_t putUInt(const char* key, uint32_t value);
size_t putLong(const char* key, int32_t value);
size_t putULong(const char* key, uint32_t value);
size_t putLong64(const char* key, int64_t value);
size_t putULong64(const char* key, uint64_t value);
size_t putFloat(const char* key, float_t value);
size_t putDouble(const char* key, double_t value);
size_t putBool(const char* key, bool value);
size_t putString(const char* key, const char* value);
size_t putString(const char* key, String value);
size_t putBytes(const char* key, const void* value, size_t len); 

Для записи потребуется придумать ключ – уникальное имя записи, так же длиной не более 15 символов.

Для чтения значений имеются соответствующие им аналоги:

int8_t getChar(const char* key, int8_t defaultValue = 0);
uint8_t getUChar(const char* key, uint8_t defaultValue = 0);
int16_t getShort(const char* key, int16_t defaultValue = 0);
uint16_t getUShort(const char* key, uint16_t defaultValue = 0);
int32_t getInt(const char* key, int32_t defaultValue = 0);
uint32_t getUInt(const char* key, uint32_t defaultValue = 0);
int32_t getLong(const char* key, int32_t defaultValue = 0);
uint32_t getULong(const char* key, uint32_t defaultValue = 0);
int64_t getLong64(const char* key, int64_t defaultValue = 0);
uint64_t getULong64(const char* key, uint64_t defaultValue = 0);
float_t getFloat(const char* key, float_t defaultValue = NAN);
double_t getDouble(const char* key, double_t defaultValue = NAN);
bool getBool(const char* key, bool defaultValue = false);
size_t getString(const char* key, char* value, size_t maxLen);
String getString(const char* key, String defaultValue = String());
size_t getBytesLength(const char* key);
size_t getBytes(const char* key, void * buf, size_t maxLen); 

Если значение с заданным ключом не существует, то функция вернет значение по умолчанию.

Можно проверить, есть ли сохраненное значение с заданным ключом:

bool isKey(const char* key); 

 

6.2.2. Пример использования

Реальный пример использования данной библиотеке я планирую предоставить в одной из следующих статей, посвященных реальным проектам на фреймворке Arduino ESP32. А и в данной статье попробуем сохранить значение, на которое мы раньше подписывались через MQTT-брокер.

#include <Preferences.h>

int config_value = 0;                              // Некая переменная для настройки извне
Preferences app_settings;                          // Параметры устройства в NVS-разделе


Затеем в функции setup() добавим следующее:

void setup() {
  // Настраиваем UART0-порт на скорость 115200
  Serial.begin(115200);
  Serial.println();

  // Открываем пространство имен
  app_settings.begin("settings", false); 
  // Считываем последнее сохраненное значение
  config_value = app_settings.getInt("config", config_value);
  Serial.print("Read config_value from NVS: ");
  Serial.println(config_value);
  ...
}

И в функцию-обработчик входящих сообщений добавим ещё строчку:

// Сравниваем топик с эталонным значением
if (_topic.equalsIgnoreCase(mqtt_topic_config)) {
  // Сохраняем полученное значение в переменной
  config_value = _payload.toInt();
  app_settings.putInt("config", config_value);
  Serial.print("New value for variable config_value=");
  Serial.print(config_value);
  Serial.print(" received and saved");
};

Проверим работу:

Read config_value from NVS: 20
Connecting to k12iot
Waiting for WiFi...
Disconnected from WiFi access point
Connected to access point
WiFi connected, IP address: 192.168.10.94
Time synchronization start...
Time synchronization completed, current time: Thu Jul 24 21:28:36 2025
Connecting to MQTT broker: ok
Message arrived [dzen/esp32_arduino/config]: 10
New value for variable config_value=10 received and saved

Теперь мы научились не только управлять параметрами устройства удаленно, но и сохранять эти параметры в “постоянной” памяти.

 


6.3. Библиотека NVS для ESP-IDF

Библиотека Preferences проста и удобна, но это всего-лишь обертка к “стандартной” библиотеке ESP NVS. И таки она не намного сложнее своей обертки. Я уже однажды писал статью на эту тему, рекомендую её вам изучить для понимания “физики процессов”: NVS: энергонезависимая библиотека хранения параметров.

Давайте попробуем реализовать то же самое, что и приведено было приведено выше. Примеры кода по аналогии с предыдущим примером я приведу для варианта с ESP MQTT клиентом.

#include "nvs.h"
#include "nvs_flash.h"

В начале необходимо инициализировать NVS-раздел в целом, в этом нет ничего сложного:

void nvsInit()
{
  esp_err_t err = nvs_flash_init();
  if ((err == ESP_ERR_NVS_NO_FREE_PAGES) || (err == ESP_ERR_NVS_NEW_VERSION_FOUND)) {
    Serial.println("Erasing NVS partition...");
    nvs_flash_erase();
    err = nvs_flash_init();
  };
  if (err == ESP_OK) {
    Serial.println("NVS partition initilized");
  } else {
    Serial.print("NVS partition initialization error: ");
    Serial.print(err);
    Serial.print(", ");
    Serial.println(esp_err_to_name(err));
  };
}

Затем можно открыть необходимое пространство имен и прочитать запись:

nvs_handle_t config_handle; // Указатель на NVS-пространство имен

bool nvsOpen(const char* nspace)
{
  // Открываем нужное пространство имен
  esp_err_t err = nvs_open(nspace, NVS_READWRITE, &config_handle); 
  if (err != ESP_OK) {
    Serial.print("Error opening NVS namespace \"");
    Serial.print(nspace);
    Serial.print("\": ");
    Serial.print(err);
    Serial.print(", ");
    Serial.println(esp_err_to_name(err));
    return false;
  };
  return true;
}
// Функция настройки, выполняется только один раз после запуска микроконтроллера
void setup() {
  // Настраиваем UART0-порт на скорость 115200
  Serial.begin(115200);
  Serial.println();

  // Инициализация NVS
  nvsInit();
  if (nvsOpen("settings")) {
    nvs_get_i32(config_handle, "config", &config_value);
    Serial.print("Read config_value from NVS: ");
    Serial.println(config_value);
  };
  ...
}

Ну и функция записи принятого значения:

if (strncasecmp(mqtt_topic_config, (const char*)event->topic, event->topic_len) == 0) {
  // Сохраняем полученное значение в переменной
  config_value = atoi(event->data);
  Serial.print("New value for variable config_value=");
  Serial.print(config_value);
  Serial.println(" received");
  // Записываем новое значение в память
  nvs_set_i32(config_handle, "config", config_value);
  // Сохраняем изменения
  nvs_commit(config_handle);
};

Этого будет вполне достаточно для начала. Если запустить этот код, результат будет аналогичен предыдущему варианту, только размер скомпилированной прошивки получится немного поменьше.

NVS partition initilized
Read config_value from NVS: 10
Connecting to k12iot
Waiting for WiFi...
Connected to access point
WiFi connected, IP address: 192.168.10.94
Time synchronization start...
Start MQTT client: ok
Successfully connected to MQTT server
Message sent successfully, id: 16667
Subscription successfully completed
Time synchronization completed, current time: Thu Jul 24 22:07:39 2025

 

Что ж, на этом, думаю, можно и закончить. Можно приступать к созданию реальных устройств.

Но это уже совсем другая история…

 


Ссылки на демонстрационные материалы

1. Проект Arduino ESP32 + PubSubClient + HTTPClient + Preferences
2. Проект ArduinoESP32 + ESP MQTT Client + ESP HTTP Client + NVS (Arduino & ESP-IDF в одном флаконе)

 

Использованная литература и ссылки на другие материалы

1. Arduino-ESP32 : Wi-Fi API
2. ESP-IDF : ESP-MQTT API
3. Статья “ESP MQTT Client API”
4. Статья “NVS: энергонезависимая библиотека хранения параметров”

 


Пожалуйста, оцените статью:
[ 0 из 5, всего 0 оценок ]

-= Каталог статей (по разделам) =-   -= Архив статей (подряд) =-

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *