ESP-NOW: связь двух ESP32 без роутера
Две платы могут общаться напрямую, без роутера и без Wi-Fi-сети. Протокол Espressif ESP-NOW соединяет устройства по MAC-адресам: нет подключения, нет получения адреса, нет ожидания в несколько секунд. Пакет уходит за миллисекунды, что делает протокол идеальным для пультов, датчиков на батарейках и связи между узлами робота.
Примеры проверены на пакете плат esp32 by Espressif Systems 3.x.
Чем это лучше Wi-Fi
| Wi-Fi | ESP-NOW | |
|---|---|---|
| Нужен роутер | Да | Нет |
| Время до первой отправки | секунды | миллисекунды |
| Расход энергии | высокий | низкий |
| Размер сообщения | любой | 250 байт (1470 во второй версии) |
| Дальность | как у Wi-Fi | сравнима, иногда больше |
| Выход в интернет | Есть | Нет |
Главный выигрыш — время. Датчику, который просыпается раз в полчаса, подключение к роутеру обходится в несколько секунд работы радио. С ESP-NOW он отправляет пакет и засыпает обратно почти мгновенно, и батарея живёт в разы дольше. Про режимы сна — в статье Питание ESP32 и режимы сна.
Узнать MAC-адрес
Адресация в ESP-NOW идёт по MAC, поэтому первым делом нужно узнать адрес приёмника:
#include <WiFi.h>
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
Serial.print("MAC этой платы: ");
Serial.println(WiFi.macAddress());
}
void loop() {
}
Адрес выглядит как A0:B7:65:12:34:56. Запишите его — он понадобится в скетче
отправителя.
У платы два MAC-адреса: у интерфейса клиента (WiFi.macAddress()) и у точки доступа
(WiFi.softAPmacAddress()). Они разные. Указывать нужно адрес того интерфейса, через
который реально пойдёт обмен — обычно это интерфейс клиента.
Отправитель
#include <WiFi.h>
#include <esp_now.h>
uint8_t receiverAddress[] = {0xA0, 0xB7, 0x65, 0x12, 0x34, 0x56};
struct Message {
uint8_t axis;
int16_t angle;
};
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
if (esp_now_init() != ESP_OK) {
Serial.println("Не удалось запустить ESP-NOW");
return;
}
esp_now_peer_info_t peer = {};
memcpy(peer.peer_addr, receiverAddress, 6);
peer.channel = 0; // 0 — текущий канал
peer.encrypt = false;
if (esp_now_add_peer(&peer) != ESP_OK) {
Serial.println("Не удалось добавить получателя");
return;
}
}
void loop() {
Message message = {0, 90};
esp_err_t result = esp_now_send(receiverAddress, (uint8_t*) &message, sizeof(message));
// ESP_OK — пакет принят в очередь, а не доставлен получателю
Serial.println(result == ESP_OK ? "Поставлено в очередь" : "Ошибка отправки");
delay(2000);
}
Данные передаются как есть — структуру можно отправить целиком, без сериализации. Главное, чтобы на обеих сторонах она была объявлена одинаково: тот же порядок и те же типы полей.
Вызов WiFi.mode(WIFI_STA) перед esp_now_init() обязателен: радио должно быть
запущено до инициализации протокола.
Приёмник
#include <WiFi.h>
#include <esp_now.h>
struct Message {
uint8_t axis;
int16_t angle;
};
// Всё, что нужно знать о пакете: содержимое плюс адрес отправителя
struct ReceivedMessage {
Message payload;
uint8_t sender[6];
};
QueueHandle_t messageQueue;
void onDataReceived(const esp_now_recv_info_t* info, const uint8_t* data, int len) {
// 1. Обязательно проверяем длину: короткий или битый пакет иначе
// заставит memcpy прочитать чужую память.
if (len != sizeof(Message)) {
return;
}
// 2. Складываем в очередь и сразу выходим. Никакой печати и обработки здесь.
ReceivedMessage item;
memcpy(&item.payload, data, sizeof(Message));
memcpy(item.sender, info->src_addr, 6);
xQueueSend(messageQueue, &item, 0); // 0 — не ждать, если очередь полна
}
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
messageQueue = xQueueCreate(10, sizeof(ReceivedMessage));
if (esp_now_init() != ESP_OK) {
Serial.println("Не удалось запустить ESP-NOW");
return;
}
esp_now_register_recv_cb(onDataReceived);
}
void loop() {
ReceivedMessage item;
while (xQueueReceive(messageQueue, &item, 0) == pdTRUE) {
Serial.printf("Ось %u, угол %d, от %02X:%02X:%02X:%02X:%02X:%02X\n",
item.payload.axis, item.payload.angle,
item.sender[0], item.sender[1], item.sender[2],
item.sender[3], item.sender[4], item.sender[5]);
}
}
Обработчик приёма выполняется внутри высокоприоритетной задачи Wi-Fi, а не в
loop(). Документация Espressif требует, чтобы он только проверил пакет, скопировал
данные и вышел. Печать в порт, разбор JSON, работа с файлами или моторами прямо в
обработчике тормозят радиостек и рвут связь.
Второе обязательное правило — проверять len. Он приходит именно для того, чтобы
вы не копировали в свою структуру пакет неизвестного размера: злонамеренный или просто
битый пакет иначе приведёт к чтению за границей буфера.
Обратите внимание, почему здесь именно очередь, а не пара глобальных переменных с
флагом. Обработчик и loop() выполняются в разных задачах, поэтому пока loop()
читает структуру, обработчик может начать записывать в неё следующий пакет — и
получится смесь двух сообщений. Атрибут volatile от этого не спасает: он запрещает
компилятору кешировать значение, но не делает копирование составной структуры
неделимым.
Очередь FreeRTOS решает обе проблемы разом: она копирует данные под собственной блокировкой и хранит несколько сообщений, если они пришли пачкой. Именно этот способ рекомендует и документация Espressif. Подробнее про очереди — в статье Два ядра ESP32 и FreeRTOS.
Сигнатура колбэка приёма менялась. В ядре 2.x первым аргументом был
const uint8_t* mac, в 3.x — структура const esp_now_recv_info_t* info, где адрес
отправителя лежит в поле src_addr. Если пример из интернета не компилируется с
ошибкой о несовместимом типе аргумента — вы нашли код под старую версию. Колбэк
отправки в поздних версиях 3.x тоже сменил сигнатуру, так что подтверждение доставки
лучше писать по документации своей версии.
Указатели внутри info действительны только пока выполняется колбэк — сохранять их
наружу нельзя, нужно копировать значения.
Широковещательная рассылка
Чтобы отправить сообщение всем сразу, используется специальный адрес из одних единиц. Его тоже нужно добавить как получателя:
uint8_t broadcastAddress[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
esp_now_peer_info_t peer = {};
memcpy(peer.peer_addr, broadcastAddress, 6);
peer.channel = 0;
peer.encrypt = false; // для широковещательной рассылки шифрование недоступно
esp_now_add_peer(&peer);
esp_now_send(broadcastAddress, (uint8_t*) &message, sizeof(message));
Удобно для сценария «пульт управляет несколькими устройствами»: одна отправка — все получатели услышали.
Ограничения
- Размер пакета — 250 байт в первой версии протокола и до 1470 байт во второй. Если хоть одно устройство в сети работает по версии 1, ориентируйтесь на 250: это предел совместимости.
- Число получателей — до 20 всего.
- Шифрованных получателей меньше: около шести-семи при настройках по умолчанию.
ESP_OKотesp_now_send()не означает доставку. Это лишь подтверждение, что пакет принят в очередь на отправку. Факт ухода в эфир приходит отдельно, в колбэк отправки, а дошло ли сообщение до программы на той стороне — не знает никто: квитирование при необходимости делают сами.
Совмещение с обычным Wi-Fi
Здесь главная сложность, из-за которой проекты ломаются. Радиомодуль один, и он работает на одном канале за раз. Пока плата подключена к роутеру, канал диктует роутер, и произвольно выбрать свой для ESP-NOW уже нельзя.
Три рабочие стратегии:
Подстроиться под роутер. Подключиться к сети, узнать канал через WiFi.channel()
и задать этот же канал всем узлам ESP-NOW. Самый надёжный путь, если шлюзу нужен
интернет.
Развести по устройствам. Узлы работают только с ESP-NOW на фиксированном канале, а отдельная плата-шлюз занимается Wi-Fi и передаёт данные дальше. Проще в отладке и устойчивее.
Разнести по времени. Собрать данные по ESP-NOW, затем поднять Wi-Fi, отправить всё разом и выключить. Подходит для устройств с редкой отправкой.
Если узлы «то видят друг друга, то нет» — в девяти случаях из десяти дело именно в
канале. Начните с того, чтобы явно задать один и тот же канал на всех платах через
WiFi.setChannel() и убедиться, что ни одна из них не подключается к роутеру.
Новый интерфейс в ядре 3.x
Помимо привычных функций, в ядре 3.x появилась объектная обёртка — библиотека
ESP_NOW с классами ESP_NOW_Class и ESP_NOW_Peer. Она скрывает работу с
колбэками: получатель описывается классом-наследником, у которого переопределяются
методы onReceive() и onSent().
Обёртка удобнее для проектов с несколькими типами узлов, но требует наследования: конструктор и методы отправки объявлены защищёнными, напрямую создать объект нельзя. Готовые примеры лежат в среде разработки — File → Examples → ESP_NOW.
Функции esp_now_init() и esp_now_send() при этом никуда не делись и работают
по-прежнему: обёртка построена поверх них.
Что дальше
- Обычное подключение к сети — Подключение ESP32 к Wi-Fi.
- Как заставить датчик жить на батарейке — Питание ESP32 и режимы сна.
- Какие чипы поддерживают протокол — Какую ESP32 выбрать.