Руководства · 19 мин чтения

ESP-NOW на ESP32: беспроводная связь между платами без роутера

ESP-NOW на ESP32: как узнать MAC-адрес, отправить структуру до 250 байт в одну или обе стороны, подключить до 20 пиров, какая дальность, что изменилось в core 3.x и как совместить с Wi-Fi.

ESP-NOW — беспроводной протокол от Espressif, с которым платы ESP32 и ESP8266 отправляют данные напрямую друг другу без роутера, точки доступа и интернета. Платы используют свой Wi-Fi-радиомодуль, но ни к какой сети не подключаются: каждое устройство находит другое по MAC-адресу и передаёт небольшие пакеты до 250 байт за несколько миллисекунд. Устанавливать соединение, получать IP-адрес и вводить пароль не нужно, поэтому нажатие кнопки на одной плате мгновенно включает реле на другой, а датчик на батарейке может проснуться, отправить показание и сразу уснуть снова.

В этом руководстве настроим ESP-NOW с нуля: сначала узнаешь MAC-адреса своих плат, затем на примерах односторонней и двусторонней связи отправишь в одном пакете разные значения — температуру, счётчик и текст — с помощью структуры (struct). Дальше разберёмся с ограничением в 20 пиров, широковещательными сообщениями, дальностью и скоростью, функциями, которые изменились в ESP32 Arduino core 3.x, и согласованием каналов — единственным правилом для одновременной работы ESP-NOW и Wi-Fi. В последней части удалённый датчик на батарейке будет просыпаться из глубокого сна, отправлять показания на шлюз, подключённый к Wi-Fi, а смотреть их можно будет с телефона. Если это первый проект на ESP32, сначала выполни настройку Arduino IDE из статьи Что такое ESP32?.

Отладочная плата ESP32, которая обменивается данными по ESP-NOW
Для ESP-NOW не нужен дополнительный модуль: благодаря встроенному в ESP32 Wi-Fi-радиомодулю платы общаются напрямую, без роутера.

Что такое ESP-NOW и как он работает?

В обычном Wi-Fi плата ESP32 сначала подключается к роутеру, проверяет пароль, получает IP-адрес по DHCP и только после этого может отправлять данные; обычно на это уходит 1–3 секунды. ESP-NOW пропускает все эти шаги. Он кладёт данные в короткий Wi-Fi-пакет стандарта IEEE 802.11, который называется vendor-specific action frame (кадр действия, определяемый производителем), и отправляет его прямо на MAC-адрес другой платы. Принимающая плата отвечает аппаратным кадром подтверждения (ACK), а функция обратного вызова (callback) на стороне отправителя сообщает, доставлен ли пакет. Если ACK не пришёл, уровень Wi-Fi автоматически повторяет отправку несколько раз. Проще говоря, ESP-NOW работает как невидимый почтовый ящик между двумя платами: пишешь адрес, опускаешь пакет и получаешь ответ, когда другая сторона его приняла.

ХарактеристикаЗначение
РазработчикEspressif Systems
Частота2,4 GHz (Wi-Fi-радиомодуль, каналы 1–13)
Данных в одном пакете250 байт (ESP-NOW v1); 1470 байт с v2 на ESP-IDF 5.4 и новее
Зарегистрированные пирыДо 20; по умолчанию до 7 зашифрованных
Скорость по умолчанию1 Mbps
Подтверждение доставкиЕсть для unicast (ACK), нет для broadcast
ШифрованиеПо желанию, CCMP (AES-128); ключи PMK и LMK по 16 байт
Роутер / точка доступаНе нужен
Поддерживаемые чипыЧипы ESP32 с Wi-Fi (ESP32, S2, S3, C2, C3, C6…) и ESP8266

Главные преимущества ESP-NOW — быстрый отклик и низкое энергопотребление. При нажатии кнопки светодиод на другой стороне загорается через несколько миллисекунд; то же самое по Wi-Fi требует сначала подключиться, а потом отправить запрос на сервер. Для датчика на батарейке разница ещё больше: во время подключения к Wi-Fi радиомодуль секундами потребляет больше 100 mA, а с ESP-NOW плата просыпается, отправляет показание и снова засыпает в сумме за несколько сотен миллисекунд. С другой стороны, ESP-NOW не выходит в интернет: чтобы данные попали в интернет или на телефон, где-то в сети должна быть ESP32-шлюз, подключённая к Wi-Fi. Такую схему соберём дальше в этом руководстве.

ESP-NOW, Wi-Fi, Bluetooth и nRF24L01: сравнение

СпособДоп. модульРоутерРазмер пакетаТипичная дальность (открытое пространство)Лучше всего для
ESP-NOWНе нуженНе нужен250 байт100–200 м, в режиме LR большеКоманды и данные датчиков между платами
Wi-Fi (HTTP, MQTT)Не нуженНуженПрактически не ограниченЗона покрытия роутераИнтернет, веб-страницы, облако
Bluetooth (BLE)Не нуженНе нужен20 байт по умолчанию10–20 м в помещенииПодключение к телефону
nRF24L01Нужен (SPI)Не нужен32 байтаДесятки метров, с версией PA+LNA намного большеПлаты без беспроводной связи, например Arduino Uno и Nano
LoRa (SX1278)Нужен (SPI)Не нуженДо 255 байтНесколько километровОчень большие расстояния, редкие данные

Если нужно общаться с телефоном, правильный выбор — Bluetooth; если нужен интернет, используй Wi-Fi. Но когда несколько плат ESP32 должны общаться только между собой, ESP-NOW обычно самый простой путь: ни дополнительного модуля, ни кабеля, ни роутера, всего несколько строк кода. Для плат без беспроводной связи вроде Arduino Uno лучше подходит nRF24L01, а для дальности в километры — модули LoRa. Все варианты рядом сравниваются в статье способы беспроводной связи для Arduino, а про подключение к телефону — в нашем руководстве ESP32 Bluetooth.

Какие платы поддерживают ESP-NOW?

Плата / чипESP-NOWПримечание
ESP32 (WROOM-32, DevKit)ДаПоддерживает и режим Long Range (LR)
ESP32-S2, S3, C3, C6ДаТот же код Arduino, меняется только выбор платы
ESP32-C2 (ESP8684)ДаНет режима LR
ESP32-H2НетНет Wi-Fi-радиомодуля, значит, нет и ESP-NOW
ESP8266 (NodeMCU, Wemos D1 Mini)ДаДругая библиотека (espnow.h), только v1 и 250 байт
Raspberry Pi Pico W / Pico 2 WНетWi-Fi-чип (CYW43439) не умеет работать с ESP-NOW
Arduino Uno, Nano, MegaНетМожно подключить к ESP32 через последовательный порт (UART)

ESP-NOW работает и между разными чипами ESP: датчик на ESP32-C3 может отправлять пакеты на шлюз на классической ESP32 или на плату ESP8266. Условий всего два: обе стороны работают на одном Wi-Fi-канале, а структура данных на обеих сторонах объявлена совершенно одинаково. В примерах ниже используются две платы ESP32 DevKit; какие пины твоей платы безопасно использовать, можно посмотреть в утилите распиновки.

Шаг 1: узнаём MAC-адрес ESP32

В ESP-NOW платы находят друг друга по 6-байтному MAC-адресу; этот адрес записывается в каждую ESP32 на заводе и никогда не меняется. Загрузи скетч ниже в плату, которая будет приёмником, и открой монитор порта на скорости 115200 бод. Скетч выводит адрес и в читаемом виде, и строкой-массивом, которую можно сразу вставить в код другой платы. Для примеров с двумя платами сделай это на обеих и запиши адреса.

C++ (Arduino IDE)
1#include <WiFi.h>
2#include <esp_wifi.h>
3
4void setup() {
5  Serial.begin(115200);
6  delay(1000);
7  WiFi.mode(WIFI_STA);                 // ESP-NOW istasyon arayüzünü kullanır / ESP-NOW uses the station interface
8
9  uint8_t mac[6];
10  if (esp_wifi_get_mac(WIFI_IF_STA, mac) == ESP_OK) {
11    Serial.printf("STA MAC: %02X:%02X:%02X:%02X:%02X:%02X\n",
12                  mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
13    // Diğer karta kopyalanacak satır / line to paste into the other board's code
14    Serial.printf("uint8_t karsiMac[] = {0x%02X, 0x%02X, 0x%02X, 0x%02X, 0x%02X, 0x%02X};\n",
15                  mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
16  } else {
17    Serial.println("MAC okunamadi / could not read MAC");
18  }
19}
20
21void loop() {
22}
У ESP32 два отдельных MAC-адреса Wi-Fi: для режима станции (STA) и для точки доступа (AP). Адрес AP обычно равен адресу STA, у которого последний байт больше на единицу. Во всех примерах этого руководства платы работают в режиме WIFI_STA, поэтому на другой плате всегда указывай адрес STA; адрес не того интерфейса — самая частая причина сообщения «failed». В ESP32 Arduino core 3.x функция WiFi.macAddress() может вернуть 00:00:00:00:00:00, если вызвать её до полного запуска Wi-Fi; у вызова esp_wifi_get_mac() в скетче выше такой проблемы нет.

Отправка данных в структуре и предел в 250 байт

ESP-NOW отправляет только массив байтов и понятия не имеет, что внутри. Самый аккуратный способ отправить сразу несколько разных значений — собрать их в структуру (struct) и отправить её байты ровно в том виде, в каком они лежат в памяти. Приёмник объявляет такую же структуру и копирует в неё пришедшие байты через memcpy. Чтобы это работало, у структуры на обеих сторонах должны быть одинаковые порядок, типы и размер. Для ускорения доступа компилятор может вставлять между полями невидимые выравнивающие байты (padding): структура Paket из примеров ниже обычно занимает 36 байт, но с __attribute__((packed)) сокращается ровно до 30 байт. С packed каждый байт гарантированно стоит на одном и том же месте у ESP32 и ESP8266, у Arduino и MicroPython.

ТипРазмерПримечание
bool, uint8_t, int8_t, char1 байтФлаг, небольшое число, один символ
int16_t, uint16_t2 байта−32768…32767 или 0…65535
int, int32_t, uint32_t4 байтаint на ESP32 и ESP8266 занимает 4 байта, а на Arduino Uno — 2
float4 байтаОколо 7 значащих цифр
double8 байтИспользуй float, если double действительно не нужен
char mesaj[16]16 байтТекст фиксированной длины; должен заканчиваться на '\0'
String, char*, указательОтправить нельзяУходит только адрес в памяти, который на другой стороне ничего не значит

В пакет помещается максимум 250 байт. Это ограничение пришло из первой версии ESP-NOW (v1) и действует на всех платах, включая ESP8266. Arduino core 3.2 и новее на базе ESP-IDF 5.4 умеет отправлять пакеты до 1470 байт по ESP-NOW v2, но плата с v1 (ESP8266 или ESP32 со старым ядром) обрезает пакеты длиннее 250 байт или вообще их отбрасывает. Поэтому в смешанной сети надёжнее всего укладываться в 250 байт. Строка static_assert в скетче отправителя останавливает компиляцию с понятным сообщением, если структура вырастет больше 250 байт. Если нужно передать данные крупнее, раздели их на пронумерованные части и собери по порядку на приёмнике; для показаний датчиков, состояний кнопок или команд нескольких десятков байт более чем достаточно.

Односторонний ESP-NOW: скетч отправителя

  1. Проверь, что обе платы ESP32 выбраны в Arduino IDE как ESP32 Dev Module. Библиотека ESP-NOW входит в пакет платы, так что больше ничего устанавливать не нужно.
  2. Впиши MAC-адрес, считанный с платы-приёмника, в массив aliciMac в коде ниже.
  3. Подключи на отправителе тактовую кнопку между GPIO 4 и GND; так как используется INPUT_PULLUP, дополнительный резистор не нужен. Подробности — в статье Как подключить кнопку к Arduino.
  4. Загрузи скетч в отправителя и открой монитор порта на скорости 115200 бод. Пока приёмник не запущен, сообщение «Teslim edilemedi / failed» каждые две секунды — это нормально.
C++ (Arduino IDE)
1#include <WiFi.h>
2#include <esp_now.h>
3#include <esp_idf_version.h>
4
5// Alıcının STA MAC adresi (mac_oku çıktısı) / receiver's STA MAC address (from the MAC sketch)
6uint8_t aliciMac[] = {0x24, 0x6F, 0x28, 0xAA, 0xBB, 0xCC};
7
8const int BUTON_PIN = 4;               // buton GPIO 4 ile GND arasında / button between GPIO 4 and GND
9
10// İki kartta da birebir aynı olmalı / must be identical on both boards
11typedef struct __attribute__((packed)) {
12  uint8_t  id;                         // gönderen kart numarası / sender board number
13  uint32_t sayac;                      // paket sayacı / packet counter
14  float    sicaklik;                   // °C
15  float    nem;                        // % bağıl nem / % relative humidity
16  bool     buton;                      // buton basılı mı / is the button pressed
17  char     mesaj[16];                  // kısa metin, String değil / short text, not String
18} Paket;
19static_assert(sizeof(Paket) <= 250, "Paket 250 bayti asamaz / packet must not exceed 250 bytes");
20
21Paket paket;
22
23// Teslim sonucu: alıcı paketi aldıysa SUCCESS / delivery result: SUCCESS if the receiver got it
24#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
25void gonderimSonucu(const esp_now_send_info_t *bilgi, esp_now_send_status_t durum) {   // çekirdek 3.3+ / core 3.3+
26#else
27void gonderimSonucu(const uint8_t *mac, esp_now_send_status_t durum) {                // çekirdek 2.x-3.2 / core 2.x-3.2
28#endif
29  Serial.println(durum == ESP_NOW_SEND_SUCCESS ? "Teslim edildi / delivered" : "Teslim edilemedi / failed");
30}
31
32void setup() {
33  Serial.begin(115200);
34  pinMode(BUTON_PIN, INPUT_PULLUP);
35  WiFi.mode(WIFI_STA);                 // önce Wi-Fi, sonra ESP-NOW / Wi-Fi first, then ESP-NOW
36
37  if (esp_now_init() != ESP_OK) {
38    Serial.println("ESP-NOW baslatilamadi / ESP-NOW init failed");
39    return;
40  }
41  esp_now_register_send_cb(gonderimSonucu);
42
43  esp_now_peer_info_t es = {};         // tüm alanları sıfırla / zero every field
44  memcpy(es.peer_addr, aliciMac, 6);
45  es.channel = 0;                      // 0 = şu anki kanal / 0 = current channel
46  es.ifidx = WIFI_IF_STA;
47  es.encrypt = false;
48  if (esp_now_add_peer(&es) != ESP_OK) {
49    Serial.println("Es eklenemedi / failed to add peer");
50    return;
51  }
52  paket.id = 1;
53}
54
55void loop() {
56  paket.sayac++;
57  paket.sicaklik = 20.0 + random(0, 100) / 10.0;   // deneme değeri / test value
58  paket.nem = 40.0 + random(0, 200) / 10.0;
59  paket.buton = (digitalRead(BUTON_PIN) == LOW);
60  snprintf(paket.mesaj, sizeof(paket.mesaj), "Selam %lu", (unsigned long)paket.sayac);
61
62  esp_err_t sonuc = esp_now_send(aliciMac, (uint8_t *)&paket, sizeof(paket));
63  if (sonuc != ESP_OK) {
64    Serial.printf("Gonderme hatasi / send error: %s\n", esp_err_to_name(sonuc));
65  }
66  delay(2000);
67}

Сначала скетч запускает Wi-Fi в режиме WIFI_STA: без Wi-Fi-радиомодуля ESP-NOW работать не может, поэтому esp_now_init() всегда должна идти после строки WiFi.mode(). Затем приёмник добавляется в список как пир (peer). Важно очистить структуру esp_now_peer_info_t с помощью = {}: случайные остаточные значения в неочищенных полях вызывают ошибку «Peer interface is invalid». channel = 0 означает «использовать текущий канал платы». esp_now_send() ставит пакет в очередь и сразу возвращает управление; ESP_OK значит лишь, что пакет принят к отправке. Настоящий результат приходит через несколько миллисекунд в функцию gonderimSonucu(): ESP_NOW_SEND_SUCCESS означает, что приёмник подтвердил пакет на аппаратном уровне. Блок #if в начале выбирает нужную форму функции обратного вызова для отправки, которая зависит от версии ядра; это объясняется в разделе про core 3.x.

Скетч приёмника ESP-NOW

C++ (Arduino IDE)
1#include <WiFi.h>
2#include <esp_now.h>
3#include <esp_idf_version.h>
4
5// Vericidekiyle birebir aynı yapı / exactly the same struct as the sender
6typedef struct __attribute__((packed)) {
7  uint8_t  id;
8  uint32_t sayac;
9  float    sicaklik;
10  float    nem;
11  bool     buton;
12  char     mesaj[16];
13} Paket;
14
15Paket gelen;
16volatile bool yeniPaket = false;
17
18// Wi-Fi görevinde çalışır: kısa tut / runs in the Wi-Fi task: keep it short
19#if ESP_IDF_VERSION_MAJOR >= 5
20void veriGeldi(const esp_now_recv_info_t *bilgi, const uint8_t *veri, int uzunluk) {   // çekirdek 3.x / core 3.x
21#else
22void veriGeldi(const uint8_t *mac, const uint8_t *veri, int uzunluk) {                // çekirdek 2.x / core 2.x
23#endif
24  if (uzunluk != sizeof(Paket)) return;  // farklı boyut: başka bir paket / wrong size: some other packet
25  memcpy(&gelen, veri, sizeof(Paket));   // veriyi hemen kopyala / copy the data right away
26  yeniPaket = true;
27}
28
29void setup() {
30  Serial.begin(115200);
31  WiFi.mode(WIFI_STA);
32
33  if (esp_now_init() != ESP_OK) {
34    Serial.println("ESP-NOW baslatilamadi / ESP-NOW init failed");
35    return;
36  }
37  esp_now_register_recv_cb(veriGeldi);
38  Serial.println("Alici hazir / receiver ready");
39}
40
41void loop() {
42  if (yeniPaket) {
43    yeniPaket = false;
44    Paket p = gelen;                     // kopya üzerinde çalış / work on a copy
45    p.mesaj[sizeof(p.mesaj) - 1] = '\0'; // metin her zaman sonlansın / always terminate the text
46    Serial.printf("id=%u sayac=%lu sicaklik=%.1f nem=%.1f buton=%d mesaj=%s\n",
47                  (unsigned)p.id, (unsigned long)p.sayac, p.sicaklik, p.nem, (int)p.buton, p.mesaj);
48  }
49}

На приёмнике пакеты приходят в функцию veriGeldi(). Она выполняется не в твоём loop(), а в высокоприоритетной задаче Wi-Fi, поэтому внутри неё нельзя использовать Serial.print, delay и долгие вычисления. Правильный подход — проверить, что размер пакета совпадает с ожидаемой структурой, скопировать данные в свою переменную через memcpy и сообщить об этом loop() через флаг volatile. В core 3.x параметр bilgi также сообщает, кто отправил пакет (bilgi->src_addr), и уровень сигнала (bilgi->rx_ctrl->rssi, в dBm); эти данные действительны только внутри функции, так что, если они понадобятся позже, скопируй их. Приёмнику не нужно добавлять отправителя в пиры: незашифрованные адресные (unicast) и широковещательные пакеты принимаются и от плат, которых нет в списке.

«Доставлено» означает, что пакет дошёл до Wi-Fi-оборудования приёмника, а не что программа на приёмнике его обработала. Если другая плата включена и находится на том же канале, ACK вернётся, даже если ESP-NOW на ней вообще не запускался. Для важных команд используй отдельное программное сообщение-подтверждение и добавляй в пакеты счётчик: если приёмник видит, что счётчик перескочил, он знает, сколько пакетов потеряно, а если один и тот же номер пришёл дважды, отбрасывает дубликат.

Двусторонняя связь: две платы ESP32 управляют друг другом

При двусторонней связи каждая плата одновременно и отправитель, и приёмник. Скетч ниже загружается в обе платы; разница только в том, что на каждой в массив karsiMac записывается адрес другой платы. Нажимаешь кнопку на одной плате — светодиод на другой включается или выключается, а плата, которая переключила светодиод, отвечает сообщением-подтверждением. Подключение на обеих платах одинаковое:

ДетальПин ESP32Примечание
КнопкаGPIO 4 и GNDИспользуется встроенная подтяжка, дополнительный резистор не нужен
СветодиодGPIO 2На большинстве DevKit это встроенный синий светодиод; для внешнего светодиода нужен резистор 220 Ω
ПитаниеUSBПлаты можно питать от разных USB-портов или блоков питания
C++ (Arduino IDE)
1#include <WiFi.h>
2#include <esp_now.h>
3#include <esp_idf_version.h>
4
5// Bu kodu iki karta da yükle; yalnız karşı kartın MAC adresini değiştir
6// Upload to both boards; change only the other board's MAC address
7uint8_t karsiMac[] = {0x24, 0x6F, 0x28, 0xAA, 0xBB, 0xCC};
8
9const int BUTON_PIN = 4;               // buton GPIO 4 ile GND arasında / button between GPIO 4 and GND
10const int LED_PIN = 2;                 // dahili LED / onboard LED
11
12typedef struct __attribute__((packed)) {
13  uint8_t tur;                         // 1 = komut, 2 = onay / 1 = command, 2 = acknowledgement
14  bool    ledAcik;                     // istenen ya da uygulanan durum / requested or applied state
15} Mesaj;
16
17volatile bool komutGeldi = false;
18volatile bool onayGeldi = false;
19volatile bool gelenLed = false;
20bool karsiLed = false;                 // karşı karttaki LED için son isteğimiz / our last request for the other LED
21int sonButon = HIGH;
22
23#if ESP_IDF_VERSION_MAJOR >= 5
24void veriGeldi(const esp_now_recv_info_t *bilgi, const uint8_t *veri, int uzunluk) {   // çekirdek 3.x / core 3.x
25#else
26void veriGeldi(const uint8_t *mac, const uint8_t *veri, int uzunluk) {                // çekirdek 2.x / core 2.x
27#endif
28  if (uzunluk != sizeof(Mesaj)) return;
29  Mesaj m;
30  memcpy(&m, veri, sizeof(m));
31  if (m.tur == 1) {
32    gelenLed = m.ledAcik;
33    komutGeldi = true;
34  } else if (m.tur == 2) {
35    onayGeldi = true;
36  }
37}
38
39void gonder(uint8_t tur, bool ledAcik) {
40  Mesaj m = {tur, ledAcik};
41  esp_now_send(karsiMac, (uint8_t *)&m, sizeof(m));
42}
43
44void setup() {
45  Serial.begin(115200);
46  pinMode(BUTON_PIN, INPUT_PULLUP);
47  pinMode(LED_PIN, OUTPUT);
48  WiFi.mode(WIFI_STA);
49
50  if (esp_now_init() != ESP_OK) {
51    Serial.println("ESP-NOW baslatilamadi / ESP-NOW init failed");
52    return;
53  }
54  esp_now_register_recv_cb(veriGeldi);
55
56  esp_now_peer_info_t es = {};
57  memcpy(es.peer_addr, karsiMac, 6);
58  es.ifidx = WIFI_IF_STA;              // kanal 0, şifresiz / channel 0, unencrypted
59  esp_now_add_peer(&es);
60}
61
62void loop() {
63  // 1) Butona basılınca karşı kartın LED'ini değiştir / on press, toggle the other board's LED
64  int buton = digitalRead(BUTON_PIN);
65  if (buton != sonButon) {
66    delay(30);                         // kontak sıçramasını bekle / wait out contact bounce
67    buton = digitalRead(BUTON_PIN);
68    if (sonButon == HIGH && buton == LOW) {
69      karsiLed = !karsiLed;
70      gonder(1, karsiLed);
71      Serial.println(karsiLed ? "Komut: AC / command: ON" : "Komut: KAPAT / command: OFF");
72    }
73    sonButon = buton;
74  }
75
76  // 2) Gelen komutu uygula ve onay gönder / apply an incoming command and reply
77  if (komutGeldi) {
78    komutGeldi = false;
79    digitalWrite(LED_PIN, gelenLed ? HIGH : LOW);
80    gonder(2, gelenLed);
81  }
82
83  // 3) Karşı karttan gelen onay / acknowledgement from the other board
84  if (onayGeldi) {
85    onayGeldi = false;
86    Serial.println("Karsi kart uyguladi / the other board applied it");
87  }
88}

Поле tur в структуре сообщения говорит, что это за пакет: 1 — команда, 2 — подтверждение. Плата, на которой нажали кнопку, отправляет состояние, в которое должен перейти светодиод другой платы; плата, получившая команду, устанавливает светодиод и отправляет то же состояние обратно с типом 2. Так отправитель получает и аппаратный ACK, и подтверждение того, что «светодиод действительно переключился». Пришедшая команда снова передаётся в loop() через флаги volatile, и digitalWrite с ответом выполняются уже там. Это надёжнее, чем вызывать esp_now_send() внутри функции обратного вызова. Пауза 30 мс для кнопки отсекает дребезг контактов. Подключи вместо светодиода реле — и тот же код превратится в беспроводной выключатель света; как подключать реле, рассказано в статье Что такое модуль реле?.

Несколько устройств: ограничение в 20 пиров и широковещательная рассылка

ESP-NOW поддерживает все три схемы: один отправитель и много приёмников (пульт и пять плат с реле), много отправителей и один приёмник (пять датчиков и один шлюз) и сети, где все общаются со всеми. Список пиров платы вмещает максимум 20 записей, и по умолчанию зашифрованными могут быть только 7 из них. Это ограничение касается только плат, которым ты отправляешь. Шлюзу не нужно добавлять датчики, которые шлют ему незашифрованные пакеты, поэтому он может слушать больше 20 датчиков. Если список заполнен, esp_now_add_peer() возвращает ESP_ERR_ESPNOW_FULL; тогда ненужные пиры можно удалить через esp_now_del_peer() и добавить новые. Скетч ниже добавляет в список три приёмника и широковещательный адрес, а затем отправляет данные и каждому по очереди, и широковещательно:

C++ (Arduino IDE)
1#include <WiFi.h>
2#include <esp_now.h>
3
4// Üç alıcının STA MAC adresleri / STA MAC addresses of three receivers
5uint8_t alicilar[3][6] = {
6  {0x24, 0x6F, 0x28, 0x11, 0x11, 0x11},
7  {0x24, 0x6F, 0x28, 0x22, 0x22, 0x22},
8  {0x24, 0x6F, 0x28, 0x33, 0x33, 0x33}
9};
10uint8_t yayinMac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};   // yayın adresi / broadcast address
11
12typedef struct __attribute__((packed)) {
13  uint8_t  hedef;                      // 0 = herkes, 1-3 = tek alıcı / 0 = everyone, 1-3 = one receiver
14  uint32_t sayac;
15} Komut;
16
17Komut komut;
18
19bool esEkle(const uint8_t *mac) {
20  esp_now_peer_info_t es = {};
21  memcpy(es.peer_addr, mac, 6);
22  es.ifidx = WIFI_IF_STA;
23  return esp_now_add_peer(&es) == ESP_OK;
24}
25
26void setup() {
27  Serial.begin(115200);
28  WiFi.mode(WIFI_STA);
29  if (esp_now_init() != ESP_OK) {
30    Serial.println("ESP-NOW baslatilamadi / ESP-NOW init failed");
31    return;
32  }
33  for (int i = 0; i < 3; i++) esEkle(alicilar[i]);
34  esEkle(yayinMac);                    // yayın da eş olarak eklenmeli / broadcast must be added as a peer too
35
36  esp_now_peer_num_t sayi;
37  esp_now_get_peer_num(&sayi);
38  Serial.printf("Kayitli es / registered peers: %d / 20\n", sayi.total_num);
39}
40
41void loop() {
42  komut.sayac++;
43
44  // 1) Tek tek: her alıcıya ayrı paket ve ayrı teslim onayı / one by one: separate packet and ACK each
45  for (int i = 0; i < 3; i++) {
46    komut.hedef = i + 1;
47    esp_now_send(alicilar[i], (uint8_t *)&komut, sizeof(komut));
48    delay(10);
49  }
50  delay(2000);
51
52  // 2) Yayın: tek paket, aynı kanaldaki herkes alır, onay yok / broadcast: one packet, everyone on the channel, no ACK
53  komut.hedef = 0;
54  esp_now_send(yayinMac, (uint8_t *)&komut, sizeof(komut));
55  delay(2000);
56}

Широковещательный адрес FF:FF:FF:FF:FF:FF тоже нужно добавить в пиры перед отправкой на него. Один широковещательный пакет доходит до всех приёмников ESP-NOW на том же канале; MAC-адреса приёмников знать не нужно, поэтому настроить такой способ проще всего. Цена в том, что у broadcast нет подтверждения доставки, результат отправки всегда выглядит успешным, а широковещательные пакеты нельзя зашифровать. При отправке по очереди каждый пакет получает свой ACK. Если передать в esp_now_send() вместо адреса NULL, пакет отправится отдельно каждому пиру из списка; если в списке есть и широковещательный адрес, пакет также один раз уйдёт широковещательно. Практичный подход для многих проектов: при первом запуске платы находят друг друга широковещательной рассылкой, затем запоминают MAC-адреса и переходят на адресную отправку с подтверждением.

Дальность и скорость ESP-NOW

ПараметрТипичное значениеПримечание
Дальность в помещении20–40 м (1–3 стены)Сильнее всего сигнал ослабляют железобетонные стены и металлические предметы
Дальность на открытом пространстве100–200 мСо встроенной печатной антенной; растёт, если поднять платы выше над землёй
Режим LR (Long Range)До 1 кмДанные Espressif для прямой видимости; скорость падает до 0,5 или 0,25 Mbps
Скорость по умолчанию1 MbpsЧем выше скорость, тем меньше дальность
Время передачи пакета 250 байтОколо 2,5 мсОколо 3 мс на пакет с учётом ACK и ожидания
Теоретическая частота пакетов~300 пакетов в секунду (~70 кБ/с)С пакетами по 250 байт; на практике меньше
Задержка командыНесколько миллисекундНе нужны ни установка соединения, ни сервер

Эти значения сильно зависят от обстановки, но расчёт простой. На скорости 1 Mbps по умолчанию каждый байт находится в эфире 8 микросекунд; 250 байт данных вместе с заголовками и синхропреамбулой занимают около 2,5 мс. С учётом ACK от приёмника и ожидания свободного канала на пакет уходит примерно 3 мс, значит, в секунду можно отправить не больше 300 пакетов. Для данных датчиков и команд этого с избытком, но для потокового аудио или видео ESP-NOW не подходит. Чтобы увеличить дальность, поднимай платы повыше над землёй, не закрывай антенну металлическим корпусом, батарейками или кабелями, используй модули с внешней антенной (например, ESP32-WROOM-32U) и включи режим Long Range (LR) от Espressif на обеих сторонах командой esp_wifi_set_protocol(WIFI_IF_STA, WIFI_PROTOCOL_LR). LR работает только между двумя платами ESP32; интерфейс, настроенный только на LR, не может подключиться к обычным роутерам, а ESP8266 и ESP32-C2 этот режим не поддерживают. При проверке дальности на core 3.x выводи на приёмнике bilgi->rx_ctrl->rssi: −50 dBm — очень сильный сигнал, −80 dBm — близко к пределу, а около −90 dBm начинаются потери пакетов.

Что изменилось в ESP32 Arduino core 3.x

Большинство примеров ESP-NOW в интернете написано для ESP32 Arduino core 2.x. Если через Менеджер плат установлена версия 3.x, такой код не скомпилируется: вместе с ESP-IDF 5 компания Espressif изменила форму функций обратного вызова. Изменения пришли в два этапа: в core 3.0 поменялась функция обратного вызова для приёма, а в core 3.3 на базе ESP-IDF 5.5 — для отправки. Какая версия установлена, видно рядом с пакетом esp32 в разделе «Инструменты › Плата › Менеджер плат» (Tools › Board › Boards Manager).

ЧтоCore 2.xCore 3.0–3.2Core 3.3 и новее
Колбэк приёма(const uint8_t *mac, const uint8_t *veri, int uzunluk) (MAC, данные, длина)(const esp_now_recv_info_t *bilgi, const uint8_t *veri, int uzunluk) (сведения о пакете, данные, длина)Как в 3.0
Адрес отправителяmacbilgi->src_addrbilgi->src_addr
Колбэк отправки(const uint8_t *mac, esp_now_send_status_t durum) (MAC, статус)Как в 2.x(const esp_now_send_info_t *bilgi, esp_now_send_status_t durum) (сведения об отправке, статус)
Максимальный пакет250 байт250 байт; 1470 байт с v2 на 3.21470 байт с v2
Установка каналаesp_wifi_set_channel()Также WiFi.setChannel()Как в 3.0
Готовая библиотека классовНетESP32_NOW.h (ESP_NOW_Peer, ESP_NOW_Serial)ESP32_NOW.h с поддержкой v2

При компиляции старого кода на 3.x для колбэка приёма появится ошибка invalid conversion from 'void (*)(const uint8_t*, const uint8_t*, int)' ... to 'esp_now_recv_cb_t' (недопустимое преобразование типа функции). Чтобы её исправить, сделай первым параметром функции const esp_now_recv_info_t * и бери адрес отправителя из bilgi->src_addr. При переходе на core 3.3 появляется похожая ошибка, на этот раз для esp_now_send_cb_t: первым параметром колбэка отправки должен быть const esp_now_send_info_t *, а адрес приёмника лежит в bilgi->des_addr. Скетчи из этого руководства сами выбирают нужную форму с помощью макроса ESP_IDF_VERSION, поэтому без изменений компилируются на 2.x, 3.0–3.2 и 3.3+. В core 3.x также появилась библиотека на классах ESP32_NOW.h: onReceive() и onSent() пишутся в классе, унаследованном от ESP_NOW_Peer, а класс ESP_NOW_Serial позволяет работать со связью ESP-NOW так же, как с Serial. Её примеры находятся в разделе ESP_NOW меню «Файл › Примеры» (File › Examples). Мы используем функции esp_now.h напрямую, потому что они совместимы с ESP8266 и со старым кодом.

ESP-NOW и Wi-Fi одновременно: согласование канала

У ESP32 один радиомодуль на 2,4 GHz, и в каждый момент он работает только на одном канале. Как только плата подключается к роутеру, канал выбираешь уже не ты, а роутер: если роутер на 6-м канале, ESP-NOW тоже работает на 6-м канале. Поэтому каждый датчик, который шлёт пакеты на шлюз, подключённый к Wi-Fi, тоже должен перейти на канал роутера. Если каналы не совпадают, отправитель выводит «failed»; если пир добавлен с ненулевым каналом, в мониторе порта появляется ещё и ошибка ESPNOW «Peer channel is not equal to the home channel, send fail!» (канал пира не совпадает с основным каналом). Чтобы всё работало без проблем, соблюдай эти правила:

  1. Шлюз сначала подключается к Wi-Fi, только потом запускает ESP-NOW и выводит канал роутера в монитор порта через WiFi.channel().
  2. Датчики к роутеру не подключаются: они находят канал, сканируя эфир в поисках имени сети (WiFi.scanNetworks() и WiFi.channel(i)), или используют фиксированный канал из кода. Пароль от Wi-Fi для этого не нужен.
  3. Переключи канал роутера с «Авто» на фиксированный (1, 6 или 11). В автоматическом режиме роутер может сменить канал, и датчики потеряют шлюз.
  4. Отключи энергосбережение на шлюзе командой WiFi.setSleep(false). По умолчанию ESP32, подключённая к роутеру, периодически усыпляет радиомодуль и может пропустить пакеты ESP-NOW, пришедшие в это время.
  5. Укажи на датчиках MAC-адрес STA шлюза; скетч шлюза ниже выводит этот адрес в монитор порта при запуске.

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

C++ (Arduino IDE)
1#include <WiFi.h>
2#include <WebServer.h>
3#include <esp_now.h>
4#include <esp_idf_version.h>
5
6const char *ssid = "AgAdi";            // Wi-Fi adın / your Wi-Fi name
7const char *sifre = "Sifre123";        // Wi-Fi şifren / your Wi-Fi password
8
9// Sensör kodundakiyle birebir aynı / identical to the sensor sketch
10typedef struct __attribute__((packed)) {
11  uint8_t  id;                         // sensör numarası 1-5 / sensor number 1-5
12  uint32_t sayac;
13  float    sicaklik;
14  float    nem;
15  float    pil;                        // pil gerilimi (V) / battery voltage (V)
16} SensorPaketi;
17
18const int SENSOR_SAYISI = 5;
19SensorPaketi sonVeri[SENSOR_SAYISI + 1];       // 1..5 kullanılır / indexes 1..5 are used
20unsigned long sonZaman[SENSOR_SAYISI + 1];     // son paketin zamanı / time of the last packet
21QueueHandle_t kuyruk;
22WebServer server(80);
23
24#if ESP_IDF_VERSION_MAJOR >= 5
25void veriGeldi(const esp_now_recv_info_t *bilgi, const uint8_t *veri, int uzunluk) {   // çekirdek 3.x / core 3.x
26#else
27void veriGeldi(const uint8_t *mac, const uint8_t *veri, int uzunluk) {                // çekirdek 2.x / core 2.x
28#endif
29  if (uzunluk != sizeof(SensorPaketi)) return;
30  SensorPaketi p;
31  memcpy(&p, veri, sizeof(p));
32  xQueueSend(kuyruk, &p, 0);           // kuyruğa at, Wi-Fi görevini bekletme / queue it, never block the Wi-Fi task
33}
34
35void anaSayfa() {
36  String html = "<meta name='viewport' content='width=device-width'>"
37                "<meta http-equiv='refresh' content='10'>"
38                "<h2>ESP-NOW sensorleri</h2><table border=1 cellpadding=6>"
39                "<tr><th>ID</th><th>Sicaklik</th><th>Nem</th><th>Pil</th><th>Son paket</th></tr>";
40  for (int i = 1; i <= SENSOR_SAYISI; i++) {
41    if (sonZaman[i] == 0) continue;    // bu sensörden veri yok / no data from this sensor yet
42    html += "<tr><td>" + String(i) + "</td><td>" + String(sonVeri[i].sicaklik, 1) +
43            " &deg;C</td><td>%" + String(sonVeri[i].nem, 0) + "</td><td>" + String(sonVeri[i].pil, 2) +
44            " V</td><td>" + String((millis() - sonZaman[i]) / 1000) + " sn once</td></tr>";
45  }
46  html += "</table>";
47  server.send(200, "text/html", html);
48}
49
50void setup() {
51  Serial.begin(115200);
52  kuyruk = xQueueCreate(10, sizeof(SensorPaketi));
53
54  WiFi.mode(WIFI_STA);
55  WiFi.begin(ssid, sifre);
56  while (WiFi.status() != WL_CONNECTED) delay(500);
57  WiFi.setSleep(false);                // güç tasarrufu kapalı: paket kaçmaz / power save off: no missed packets
58
59  Serial.print("IP: ");
60  Serial.println(WiFi.localIP());
61  Serial.printf("Kanal / channel: %d\n", (int)WiFi.channel());
62  Serial.print("Gecit MAC / gateway MAC: ");
63  Serial.println(WiFi.macAddress());   // sensörlere bu adresi yaz / put this address in the sensors
64
65  if (esp_now_init() != ESP_OK) {
66    Serial.println("ESP-NOW baslatilamadi / ESP-NOW init failed");
67    return;
68  }
69  esp_now_register_recv_cb(veriGeldi);
70
71  server.on("/", anaSayfa);
72  server.begin();
73}
74
75void loop() {
76  SensorPaketi p;
77  while (xQueueReceive(kuyruk, &p, 0) == pdTRUE) {
78    if (p.id >= 1 && p.id <= SENSOR_SAYISI) {
79      sonVeri[p.id] = p;
80      sonZaman[p.id] = millis();
81      Serial.printf("Sensor %u: %.1f C  %%%.0f  %.2f V\n", (unsigned)p.id, p.sicaklik, p.nem, p.pil);
82    }
83  }
84  server.handleClient();
85}

В колбэке приёма шлюз только кладёт пакет в очередь (queue) FreeRTOS и сразу выходит. loop() разбирает очередь и сохраняет последнее показание и время прихода для каждого датчика, а веб-сервер показывает эту таблицу. Очередь гарантирует, что ни один пакет не потеряется, если несколько датчиков отправят данные в одно и то же мгновение; к тому же это практичный способ следовать совету Espressif не выполнять долгую работу в колбэках. Страница обновляется сама каждые 10 секунд, а по столбцу «Son paket» (последний пакет) сразу видно, что датчик замолчал (села батарейка или он вне зоны досягаемости?). Если шлюз вообще не подключается к Wi-Fi, дело не в ESP-NOW: пройди проверки из статьи ESP32 не подключается к Wi-Fi. Если данные нужно передавать в систему умного дома, вместо веб-страницы можно использовать MQTT и Home Assistant.

Пример удалённого датчика: DHT22, глубокий сон и ESP-NOW

Теперь соберём датчик на батарейке, который отправляет данные на шлюз. При каждом пробуждении датчик измеряет температуру, влажность и напряжение батареи, отправляет один пакет ESP-NOW, ждёт подтверждения доставки (ACK) и уходит в глубокий сон на 60 секунд. Для температуры и влажности используем DHT22; из Менеджера библиотек нужно установить DHT sensor library от Adafruit. Как работает датчик, рассказано в нашем руководстве Датчик температуры и влажности DHT11; у DHT22 подключение такое же, а в коде достаточно указать тип датчика DHT22.

ESP32 DevKit
ESP32 DevKit
DHT22 (AM2302)
DHT22 (AM2302)
Литий-ионный аккумулятор 18650
Литий-ионный аккумулятор 18650
Модуль зарядки TP4056
Модуль зарядки TP4056
СоединениеПин ESP32Примечание
DHT22 VCC (+)3V3Работает от 3,3–5 V
DHT22 DATAGPIO 4Для датчика без платы добавь резистор 10 kΩ между DATA и 3V3
DHT22 GND (−)GNDОбщая земля
Батарея +GPIO 34 (через делитель)Два резистора по 100 kΩ; 4,2 V батареи доходят до пина как 2,1 V
Батарея −GNDНижний резистор делителя тоже подключается к GND
C++ (Arduino IDE)
1#include <WiFi.h>
2#include <esp_now.h>
3#include <esp_wifi.h>
4#include <esp_idf_version.h>
5#include <DHT.h>
6
7const char *AG_ADI = "AgAdi";          // ağ geçidinin bağlandığı Wi-Fi (şifre gerekmez) / Wi-Fi the gateway uses (no password needed)
8uint8_t gecitMac[] = {0x24, 0x6F, 0x28, 0xAA, 0xBB, 0xCC};   // ağ geçidinin STA MAC adresi / gateway STA MAC
9const uint8_t SENSOR_ID = 1;           // her sensöre farklı numara / a different number for each sensor
10const uint64_t UYKU_US = 60ULL * 1000000ULL;   // 60 saniye uyku / sleep for 60 seconds
11
12#define DHT_PIN 4
13#define PIL_PIN 34                     // ADC1 pini, 100k + 100k bölücü / ADC1 pin, 100k + 100k divider
14DHT dht(DHT_PIN, DHT22);
15
16typedef struct __attribute__((packed)) {
17  uint8_t  id;
18  uint32_t sayac;
19  float    sicaklik;
20  float    nem;
21  float    pil;
22} SensorPaketi;
23
24RTC_DATA_ATTR uint8_t kanal = 0;       // derin uykuda silinmez / kept during deep sleep
25RTC_DATA_ATTR uint32_t paketSayaci = 0;
26volatile bool sonucGeldi = false;
27volatile bool teslimEdildi = false;
28
29#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
30void gonderimSonucu(const esp_now_send_info_t *bilgi, esp_now_send_status_t durum) {   // çekirdek 3.3+ / core 3.3+
31#else
32void gonderimSonucu(const uint8_t *mac, esp_now_send_status_t durum) {                // çekirdek 2.x-3.2 / core 2.x-3.2
33#endif
34  teslimEdildi = (durum == ESP_NOW_SEND_SUCCESS);
35  sonucGeldi = true;
36}
37
38uint8_t kanalBul(const char *ag) {     // ağın kanalını tarayarak bul / find the network's channel by scanning
39  int n = WiFi.scanNetworks();
40  for (int i = 0; i < n; i++) {
41    if (WiFi.SSID(i) == ag) return WiFi.channel(i);
42  }
43  return 1;                            // bulunamazsa kanal 1 / channel 1 if not found
44}
45
46void uyu() {
47  esp_sleep_enable_timer_wakeup(UYKU_US);
48  esp_deep_sleep_start();
49}
50
51void setup() {
52  Serial.begin(115200);
53  dht.begin();
54  WiFi.mode(WIFI_STA);
55
56  if (kanal == 0) kanal = kanalBul(AG_ADI);   // yalnız ilk açılışta ya da hatadan sonra tara / scan only at first boot or after a failure
57  esp_wifi_set_promiscuous(true);
58  esp_wifi_set_channel(kanal, WIFI_SECOND_CHAN_NONE);
59  esp_wifi_set_promiscuous(false);
60
61  if (esp_now_init() != ESP_OK) uyu();
62  esp_now_register_send_cb(gonderimSonucu);
63
64  esp_now_peer_info_t es = {};
65  memcpy(es.peer_addr, gecitMac, 6);
66  es.channel = kanal;
67  es.ifidx = WIFI_IF_STA;
68  esp_now_add_peer(&es);
69
70  SensorPaketi p;
71  p.id = SENSOR_ID;
72  p.sayac = ++paketSayaci;
73  p.sicaklik = dht.readTemperature();
74  p.nem = dht.readHumidity();
75  p.pil = analogReadMilliVolts(PIL_PIN) * 2 / 1000.0;   // bölücü yarıya indirir / the divider halves it
76
77  esp_now_send(gecitMac, (uint8_t *)&p, sizeof(p));
78  unsigned long baslangic = millis();
79  while (!sonucGeldi && millis() - baslangic < 200) delay(1);   // en fazla 200 ms bekle / wait up to 200 ms
80
81  Serial.printf("Kanal %u, %.1f C, %%%.0f, %.2f V, %s\n", (unsigned)kanal, p.sicaklik, p.nem, p.pil,
82                teslimEdildi ? "teslim edildi / delivered" : "teslim edilemedi / failed");
83  if (!teslimEdildi) kanal = 0;        // teslim edilemedi: sonraki uyanışta yeniden tara / not delivered: rescan next wake
84  Serial.flush();
85  uyu();
86}
87
88void loop() {
89}

Ключ к этому скетчу — переменные с пометкой RTC_DATA_ATTR. В глубоком сне основная память ESP32 стирается, а эти переменные в памяти RTC сохраняются. Датчик ищет канал сканированием только при первом запуске (на это уходит около 2 секунд), а при следующих пробуждениях сразу использует найденный канал; если пакет не удалось доставить, он сбрасывает переменную канала и при следующем пробуждении сканирует снова. Поэтому, даже если роутер сменит канал, датчик восстановится за один цикл. Напряжение батареи считывается уже откалиброванным через analogReadMilliVolts() и умножается на 2, потому что делитель делит его пополам; номиналы резисторов можно проверить в утилите делителя напряжения. GPIO 34 — пин ADC1; это важно, потому что пины ADC2 не могут делать аналоговые измерения, пока включён Wi-Fi-радиомодуль. Подробности о режимах сна — в нашем руководстве Глубокий сон ESP32.

Грубо оценим время работы от батареи. Пробуждение без сканирования (загрузка, измерение и отправка) длится около 0,3 секунды и потребляет в среднем около 100 mA, то есть примерно 0,008 mAh на одно пробуждение. При пробуждении раз в минуту получается 1440 пробуждений в сутки, в сумме около 12 mAh. Голый модуль ESP32 в глубоком сне потребляет около 10 µA; вместе с током покоя DHT22 и резисторами делителя расход во сне остаётся ниже 2 mAh в сутки. Итого датчик тратит около 14 mAh в сутки, и аккумулятора 18650 на 2000 mAh хватает на 4–5 месяцев. Чтобы посчитать со своими значениями, воспользуйся калькулятором времени работы от батареи.

Эта оценка верна для голого модуля или энергоэффективных плат. На обычной ESP32 DevKit стабилизатор, USB-UART-преобразователь и светодиод питания даже во сне потребляют несколько mA и сокращают время работы до нескольких недель. Для долгоживущих датчиков используй стабилизатор с малым током покоя (например, HT7333), выпаяй светодиод питания или выбери готовую энергоэффективную плату. Если увеличить интервал измерений с 60 секунд до 5 минут, батарея проработает больше чем в три раза дольше. Для безопасной зарядки аккумулятора 18650 можно использовать модуль TP4056 с защитой.

ESP-NOW на ESP8266

Плата Wemos D1 Mini на ESP8266
Платы на ESP8266, например Wemos D1 Mini, тоже поддерживают ESP-NOW, но их библиотека Arduino и сигнатуры функций отличаются от ESP32.

Платы ESP8266 могут работать по ESP-NOW в одной сети с платами ESP32, но библиотека Arduino у них другая: подключается espnow.h вместо esp_now.h и ESP8266WiFi.h вместо WiFi.h, а функции берутся из SDK ESP8266, а не из ESP-IDF. Основные отличия:

ЗадачаESP32 (esp_now.h)ESP8266 (espnow.h)
Инициализацияesp_now_init() == ESP_OKesp_now_init() == 0
РольНе нужнаesp_now_set_self_role(ESP_NOW_ROLE_COMBO)
Добавить пирesp_now_add_peer(&es) со структуройesp_now_add_peer(mac, ESP_NOW_ROLE_COMBO, channel, NULL, 0)
Колбэк приёма(const esp_now_recv_info_t *bilgi, const uint8_t *veri, int uzunluk) (сведения о пакете, данные, длина)(uint8_t *mac, uint8_t *data, uint8_t len) (MAC, данные, длина)
Колбэк отправки(..., esp_now_send_status_t durum)(uint8_t *mac, uint8_t status); 0 означает успех
Максимальный пакет250 байт (1470 с v2)250 байт

На ESP8266 не забудь добавить WiFi.disconnect() после WiFi.mode(WIFI_STA): при запуске ESP8266 сама пытается переподключиться к последнему роутеру, а это меняет канал и может приводить к потере пакетов. Если структура данных на обеих сторонах объявлена как packed, ESP32 и ESP8266 одинаково читают одни и те же байты, ведь оба процессора 32-битные и хранят данные младшим байтом вперёд (little-endian). Пины и настройка ESP8266 описаны в нашем руководстве ESP8266 NodeMCU.

ESP-NOW в MicroPython и Raspberry Pi Pico

Если ESP32 программируется на MicroPython, для ESP-NOW есть готовый модуль espnow; он входит в сборки MicroPython для ESP32 и ESP8266. Код ниже принимает 30-байтную структуру Paket, которую отправляет пример отправителя на Arduino, и раскладывает её с помощью struct.unpack(). В строке формата < означает порядок «младшим байтом вперёд» без выравнивания; B — однобайтное число, I — 4-байтное беззнаковое число, f — float, а 16s — 16 байт текста. Поле bool из Arduino читается через B как 0 или 1. Загрузи код в плату через Thonny и впиши выведенный им MAC-адрес в массив aliciMac отправителя на Arduino:

Python
1import network
2import espnow
3import struct
4import binascii
5
6sta = network.WLAN(network.STA_IF)     # ESP-NOW istasyon arayüzünü kullanır / ESP-NOW uses the station interface
7sta.active(True)
8print("MAC:", binascii.hexlify(sta.config("mac"), ":").decode())   # bu adresi vericiye yaz / put it in the sender
9
10e = espnow.ESPNow()
11e.active(True)
12
13BICIM = "<BIffB16s"                    # Arduino'daki Paket yapısı / the Paket struct from Arduino
14BOYUT = struct.calcsize(BICIM)         # 30 bayt / 30 bytes
15
16while True:
17    mac, msg = e.recv()                # paket gelene kadar bekle / wait for a packet
18    if msg and len(msg) == BOYUT:
19        kimlik, sayac, sicaklik, nem, buton, mesaj = struct.unpack(BICIM, msg)
20        metin = mesaj.split(b"\x00")[0].decode()
21        print(binascii.hexlify(mac, ":").decode(), kimlik, sayac,
22              round(sicaklik, 1), round(nem, 1), bool(buton), metin)

Отправка пакета из MicroPython тоже занимает две строки: добавь приёмник через e.add_peer(mac) и отправь ту же структуру командой e.send(mac, struct.pack(BICIM, 2, 1, 23.5, 45.0, 0, b"Selam")) — так платы на Arduino и MicroPython могут работать вместе в одной сети. На плате с MicroPython, подключённой к Wi-Fi, отключи энергосбережение через sta.config(pm=sta.PM_NONE), чтобы не пропускать пакеты. Если MicroPython в новинку, начни с нашего руководства Основы MicroPython. А вот у семейства Raspberry Pi Pico ESP-NOW нет: у обычной Pico беспроводной связи нет вообще, а чип CYW43439 на Pico W и Pico 2 W умеет только стандартные Wi-Fi и Bluetooth. Чтобы включить Pico в сеть ESP-NOW, подключи к ней маленькую ESP32-C3 через последовательную связь UART и используй её как мост или пусть Pico W общается со шлюзом по Wi-Fi; про Wi-Fi на Pico W рассказано в статье Pico W: Wi-Fi и веб-сервер. Тот же подход с мостом подходит и для Arduino Uno.

Шифрование пакетов ESP-NOW

По умолчанию пакеты ESP-NOW не шифруются: любой на том же канале может перехватить их из эфира и отправить поддельные пакеты, имитирующие твою структуру. Для проектов вроде дверных замков или гаражных ворот используй шифрование. ESP-NOW применяет CCMP, как и WPA2, и работает с двумя ключами: 16-байтным PMK, одинаковым на всех устройствах (задаётся через esp_now_set_pmk()), и отдельным 16-байтным LMK для каждого пира. При добавлении пира задай es.encrypt = true и скопируй ключ в es.lmk; другая сторона тоже должна добавить тебя как зашифрованный пир с тем же LMK. Чтобы принимать зашифрованные пакеты, приёмник обязан добавить отправителя в свой список. Широковещательные пакеты зашифровать нельзя, а зашифрованных пиров по умолчанию может быть не больше 7. Само по себе шифрование не мешает повторно отправить перехваченный пакет позже; добавить к важным командам возрастающий счётчик и отбрасывать на приёмнике старые номера — хорошая дополнительная защита.

Идеи проектов на ESP-NOW

  • Беспроводной дверной звонок: при нажатии кнопки на ESP32 у двери звучит зуммер на плате внутри дома; ради времени работы от батареи сделай кнопку пином, который будит плату из глубокого сна.
  • Контроль температуры в нескольких комнатах: поставь в каждую комнату датчик DHT22 или BME280, собирай показания на одном шлюзе и сравнивай их на веб-странице.
  • Полив сада: датчик влажности почвы отправляет команду «пора поливать» плате рядом с насосом; про подключение — в статье Датчик влажности почвы.
  • Беспроводной пульт для робота: отправляй значения джойстика 20 раз в секунду; про механику робота читай в статье Как собрать робота-машинку на Arduino.
  • Сигнализация в саду: когда PIR-датчик в саду замечает движение, шлюз отправляет уведомление в Telegram.
  • Синхронное световое шоу: одним широковещательным пакетом переключай ленты WS2812B на нескольких платах на один и тот же цвет одновременно.

Частые ошибки ESP-NOW и их решение

Признак / ошибкаПричинаРешение
«ESPNOW: Peer interface is invalid», пир не добавляетсяesp_now_peer_info_t используется без очисткиОчисти структуру через = {} или memset и задай ifidx = WIFI_IF_STA
Постоянные сообщения «failed»Неверный MAC (адрес AP или опечатка), приёмник выключен или на другом каналеЗаново считай MAC-адрес STA приёмника и проверь каналы обеих плат
Пакеты приходят, но значения — мусорСтруктуры на двух сторонах отличаются или содержат StringОбъяви структуру как packed с одинаковым порядком полей на обеих сторонах, а для текста используй массив char
«invalid conversion ... to 'esp_now_recv_cb_t'»Код для core 2.x компилируется на 3.xНапиши колбэк приёма с const esp_now_recv_info_t *
«invalid conversion ... to 'esp_now_send_cb_t'»Код для core 3.0–3.2 компилируется на 3.3+Напиши колбэк отправки с const esp_now_send_info_t * или используй макрос ESP_IDF_VERSION
Ошибка «Peer channel is not equal to the home channel, send fail!» (канал пира не совпадает с основным)Канал платы, подключённой к Wi-Fi, отличается от канала пираДобавь пир с channel = 0 и переведи датчики на канал роутера
Шлюз, подключённый к Wi-Fi, пропускает часть пакетовЭнергосбережение Wi-Fi (modem sleep)Используй WiFi.setSleep(false)
ESP_ERR_ESPNOW_NOT_INITesp_now_init() не вызвана или вызвана до запуска Wi-FiСначала WiFi.mode(WIFI_STA), потом esp_now_init()
ESP_ERR_ESPNOW_FULLВ списке пиров уже 20 записейУдали старые пиры через esp_now_del_peer() или используй broadcast
ESP_ERR_ESPNOW_NO_MEMПакеты отправляются подряд, пока очередь отправки ещё не освободиласьДожидайся результата отправки, прежде чем слать следующий пакет
MAC-адрес показывается как 00:00:00:00:00:00В core 3.x функция WiFi.macAddress() вызвана до полного запуска Wi-FiИспользуй esp_wifi_get_mac() после WiFi.mode(WIFI_STA)
При поиске неисправности начни с самого простого: положи две платы рядом и сначала отправь пакет на широковещательный адрес. Если широковещательный пакет приходит, с радиомодулем, каналом и структурой данных всё в порядке, а проблема в MAC-адресе. Если не приходит ничего, выбери Debug в «Инструменты › Core Debug Level»; в мониторе порта появятся подробные журналы ядра. Если плата постоянно перезагружается, скорее всего, виновато питание; как справиться со скачком тока при включении радиомодуля, рассказано в нашем руководстве ESP32: Brownout и Guru Meditation.

ESP-NOW — самый быстрый способ научить несколько плат ESP32 общаться без роутера, кабелей и дополнительных модулей: узнай MAC-адрес, объяви одинаковую структуру данных на обеих сторонах, добавь пир и отправляй. Если ESP-NOW работает вместе с Wi-Fi, помни единственное правило: все должны быть на одном канале. В приложении BlueGrays можно пошагово, со схемами подключения и готовым кодом, попробовать все составные части из этого руководства, включая управление светодиодом и кнопкой на ESP32, измерение температуры с DHT22, подключение к Wi-Fi, веб-сервер и глубокий сон.

Частые вопросы

Нужны ли для ESP-NOW роутер или интернет?

Нет. ESP-NOW использует Wi-Fi-радиомодуль плат, но не подключается ни к какой сети: платы отправляют пакеты напрямую на MAC-адреса друг друга. Если данные нужно передать в интернет, понадобится одна ESP32 в роли шлюза, подключённая одновременно к Wi-Fi и к ESP-NOW.

Какая дальность у ESP-NOW?

С платами ESP32 со встроенной антенной обычно получается 20–40 метров в помещении, в зависимости от стен, и около 100–200 метров на открытом пространстве. С модулями с внешней антенной и режимом LR (Long Range), включённым на обеих сторонах, по данным Espressif, в прямой видимости возможно до 1 километра.

Сколько устройств можно подключить по ESP-NOW?

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

Можно ли использовать ESP-NOW и Wi-Fi одновременно?

Да, но единственный радиомодуль ESP32 в каждый момент работает только на одном канале, поэтому все устройства ESP-NOW должны использовать канал роутера. Отключи энергосбережение на шлюзе через WiFi.setSleep(false), а на датчиках находи канал сканированием по имени сети или переключи роутер на фиксированный канал.

Могут ли ESP32 и ESP8266 общаться по ESP-NOW?

Да. Оба чипа поддерживают ESP-NOW и могут отправлять пакеты друг другу; на ESP8266 отличаются только библиотека (espnow.h) и сигнатуры функций. Объяви структуру данных как packed и одинаково на обеих сторонах и держи пакеты в пределах 250 байт.

Какой максимальный объём данных у ESP-NOW?

В ESP-NOW v1 пакет вмещает максимум 250 байт, и это ограничение действует на всех платах, включая ESP8266. Платы на ESP-IDF 5.4 и новее (Arduino core 3.2+) могут отправлять до 1470 байт по ESP-NOW v2, но платы с v1 не принимают пакеты длиннее 250 байт.

Все проекты
Мигающий светодиод на ESP32
#103Новичок

Мигающий светодиод на ESP32

Учит базовой работе с выходами GPIO на ESP32. Эффект мигания получается включением и выключением светодиода с цифрового пина. Это ESP32-версия классического проекта мигания на Arduino UNO; базовые команды мигания в среде Arduino те же, но расположение пинов ESP32 и уровни GPIO 3,3 V отличаются от Arduino UNO.

4.7(37)
99
Управление светодиодом кнопкой на ESP32 (переключение)
#105Новичок

Управление светодиодом кнопкой на ESP32 (переключение)

При каждом нажатии кнопки состояние светодиода меняется: если горел — гаснет, если не горел — загорается. Классический шаблон переключения. Антидребезг убирает механический шум кнопки, чтобы одно нажатие давало одно переключение.

4.6(39)
57
Двусторонняя связь между двумя Arduino
#58Средний
Premium

Двусторонняя связь между двумя Arduino

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

4.5(20)
26
Температура и влажность с DHT22 на ESP32 (с LCD-дисплеем)
#117Средний

Температура и влажность с DHT22 на ESP32 (с LCD-дисплеем)

Этот проект измеряет температуру и влажность воздуха датчиком DHT22 и в реальном времени показывает данные на I2C LCD 16x2. Данные читаются с датчика по однопроводному протоколу через библиотеку Adafruit DHT. Проект служит основой для IoT-метеостанций, умного дома или систем мониторинга среды в теплицах. Связь с DHT22 идёт через пин GPIO 4 у ESP32.

4.8(38)
56
Подключение ESP32 к Wi-Fi в режиме STA (с экраном LCD)
#127Продвинутый

Подключение ESP32 к Wi-Fi в режиме STA (с экраном LCD)

В этом проекте ESP32 надёжно и устойчиво к сбоям подключается к домашней сети Wi-Fi в режиме станции (STA). SSID и пароль хранятся в отдельном файле secrets.h, чтобы не утекли в Git; настроено автоматическое переподключение, а уровень сигнала RSSI и IP-адрес выводятся на I2C LCD-экран. Встроенный светодиод служит индикатором состояния. Это менеджер подключения уровня реальных устройств.

4.4(21)
34
Асинхронный веб-сервер на ESP32 (с выводом IP на LCD)
#129Продвинутый

Асинхронный веб-сервер на ESP32 (с выводом IP на LCD)

В этом проекте ESP32 превращается в асинхронный веб-сервер, которым можно управлять с телефона или компьютера в той же сети Wi-Fi. Благодаря библиотеке ESPAsyncWebServer сервер может обрабатывать несколько запросов одновременно (в отличие от классического WebServer.h, который блокируется на одном запросе). Открыв IP-адрес ESP32 в браузере, ты нажимаешь кнопки ON/OFF (маршруты /on и /off) и включаешь и выключаешь светодиод на GPIO 5.

4.8(16)
26
Глубокий сон ESP32: экономия батареи (OLED + счётчик в RTC RAM)
#135Продвинутый
Premium

Глубокий сон ESP32: экономия батареи (OLED + счётчик в RTC RAM)

Режим глубокого сна (Deep Sleep) у ESP32 снижает потребление до ~10 µA, выключая процессор и большую часть периферии, — это важно для IoT-устройств на батарейках. В этом проекте используются три источника пробуждения: таймер (15 секунд), внешний пин (EXT0 — кнопка GPIO 33) и касание (T0/GPIO 4). Переменные с RTC_DATA_ATTR сохраняются во время глубокого сна — счётчики загрузок, пробуждений по таймеру и по кнопке ведутся непрерывно.

4.3(10)
16
Регистратор температуры на ESP32 (DHT22 + DS1307 RTC + microSD + OLED)
#144Продвинутый
Premium

Регистратор температуры на ESP32 (DHT22 + DS1307 RTC + microSD + OLED)

Собери на ESP32 регистратор температуры с отметками времени! Каждую минуту читай температуру и влажность с DHT22, бери реальное время из DS1307 RTC и записывай всё на карту microSD в формате CSV. OLED показывает текущую температуру (крупным шрифтом), влажность в процентах, время и счётчик записей. Файл /temp_log.csv на SD-карте открывается в Excel, Google Sheets или Python pandas — идеально для теплиц, комнат, лабораторий и хранения продуктов.

4.7(7)
12