Теплица стоит в пятистах метрах от дома, и тянуть туда роутер я не стал: счётчик воды и дисплей в мастерской общаются напрямую, по радио, протоколом ESP-NOW. Суть простая: две платы знают MAC-адреса друг друга и шлют кадры минус роутер, DHCP и IP-стек — обмен точка-точка между ESP32 и ESP8266, как его описывает документация Arduino. Задержка доставки в квартире — единицы миллисекунд, полезная нагрузка — до 250 байт на ESP32 и 200 на ESP8266: для показаний и команд за глаза. Моя пара честно держит связь через теплицу и огород, где ни одного роутера и в помине нет — и не будет.
Пейринг, печать своего MAC через WiFi.macAddress(), лимиты и дальность в цифрах уже разобраны в уроке про пару плат — пересказывать не буду. Здесь то, что превращает «привет» в протокол: пара зеркальных структур, одинаковых на обеих платах байт в байт, колбэки отправки и приёма с регистрацией через esp_now_register_send_cb и esp_now_register_recv_cb, счётчик seq в самом пакете и канальная дисциплина, на которой пары чаще всего и ломаются. MAC второй платы я после первого же проекта подписываю маркером прямо на корпусе: экран в мониторе забывается, а надпись — нет.
Когда выбор стоит между ESP-NOW и MQTT, я рассуждаю так. Есть роутер и брокер, данные едут в дом и в облако — беру MQTT: топики, retained-сообщения и QoS стоят возни с настройкой. Сети нет — дача, гараж, подвал — или нужна команда с задержкой в единицы мс — беру ESP-NOW. Полное сравнение — в таблице ниже, а батарейный узел, который просыпается, шлёт кадр и засыпает, дружит с ESP-NOW особенно хорошо: бюджет такого сна я считал в уроке про deep sleep.
Пара структур: одна декларация на две платы
Ядро протокола на прикладном уровне — структура, одинаковая в обоих скетчах: struct Paket { float temp; int vlazh; unsigned int seq; }. Отправитель кладёт в неё данные и зовёт esp_now_send(macB, (uint8_t *)&pk, sizeof(pk)) — радио уносит байты как есть. Приёмник в колбэке делает memcpy в свою копию. Всё держится на одном условии: раскладка полей совпадает байт в байт. Добавите поле только с одной стороны — смещения поедут, и приёмник будет уверенно читать мусор, не подозревая о подмене.
В структуру — только фиксированные типы: uint8_t, int16_t, float и char-массивы заданной длины. String живёт указателем в куче отправителя и на приёмнике указывает в никуда — эта грабля с разбором лежит в уроке про пару плат. Мой пакет термометра весит двенадцать байт, и потолок в 250 вмещает десятки полей с запасом; суточный архив графиков в один кадр не влезет и не должен — одно событие на один пакет, архивы собирает шлюз или приёмник с картой памяти.
seq — счётчик отправок внутри самой структуры, один беззнаковый int. Приёмник печатает его рядом с показаниями и сразу видит пропуски: после 38 пришёл 41 — два кадра не доехали, эфир деградирует раньше, чем вы это заметите глазами. Тот же счётчик ловит дубли: колбэк доставки не гарантирует отсутствие повторов на прикладном уровне, а кадр-двойник отличается по seq за секунду.
Про раскладку — одна привычка, которая экономит часы. Float на ESP32 занимает четыре байта, int и unsigned int — тоже по четыре, и компилятор вправе проложить между полями выравнивающие байты: если держать поля в порядке убывания размера и не смешивать однобайтовые с четырёхбайтовыми вперемешку, паддинга не будет. А чтобы не гадать, в setup обеих плат я вписываю Serial.println(sizeof(Paket)): напечатали одинаково — раскладка совпала, разъехались — сначала чиним структуру, потом ищем, почему приёмник читает мусор.
#include <WiFi.h>#include <esp_now.h> struct Paket { // ОДНА декларация на обе платы, байт в байт float temp; int vlazh; unsigned int seq;}; uint8_t macB[6] = {0x24, 0x6F, 0x28, 0xAA, 0xBB, 0xCC}; // MAC платы BPaket pk;unsigned long poslednij = 0; void otpravlen(const uint8_t *mac, esp_now_send_status_t status) { Serial.print("[send_cb] seq="); Serial.print(pk.seq); Serial.println(status == ESP_NOW_SEND_SUCCESS ? " SUCCESS" : " FAIL");} void setup() { Serial.begin(115200); WiFi.mode(WIFI_STA); // без роутера: платы живут на канале 1 Serial.print("moya MAC: "); Serial.println(WiFi.macAddress()); // печатает и плата B в своём setup esp_now_init(); esp_now_register_send_cb(otpravlen); esp_now_peer_info_t peer; // таблица пиров: кому шлем memset(&peer, 0, sizeof(peer)); memcpy(peer.peer_addr, macB, 6); peer.channel = 0; // 0 = текущий канал peer.encrypt = false; esp_now_add_peer(&peer);} void loop() { if (millis() - poslednij >= 2000) { poslednij = millis(); pk.temp = 18.5; // здесь показания датчиков pk.vlazh = 63; pk.seq++; esp_now_send(macB, (uint8_t *)&pk, sizeof(pk)); // 12 байт в эфир }}esp_now_register_send_cb(otpravlen) подписывает функцию, которую радио вызовет после каждой попытки отправки — со статусом ESP_NOW_SEND_SUCCESS или FAIL. Читаю её честно: SUCCESS значит «кадр ушёл в радио и сосед его услышал», но не «насос включился». Дошло ли дело до дела — знает только логика приёмника; для критичных команд я вешаю на приёмник короткую квитанцию тем же ESP-NOW, и seq помогает связать команду с ответом — колбэку на отправителе остаётся роль детектора мёртвого соседа.
Про шифрование: по умолчанию кадры летят открытым текстом, и любой сосед с ESP32 и сканером их прочитает. Для показаний температуры мне всё равно, а для команды на реле калитки я включаю peer.encrypt = true с общим LMK-ключом на обеих платах: цена — пара десятков микросекунд на шифрование и запись ключа в таблицу пиров, а шифрованных пиров у пары меньше, чем открытых, — для пары из двух плат ограничения не видно вовсе.
Адрес пира до send живёт в таблице: esp_now_add_peer(&peer) с MAC второй платы и encrypt=false для старта. MAC печатает сама вторая плата через WiFi.macAddress() в собственном setup — я подписываю его маркером на корпусе. Для broadcast-адресации есть зарезервированный MAC из шести 0xFF: команда улетает всем в эфире, и таблицу пиров заполнять не надо, — я пользуюсь им для «световой сигнал по всему дому», а в паре всё же честный адрес соседа. А вот поле channel в таблице пира — не «какой-нибудь»: ноль означает «текущий канал», и здесь начинается самая тихая ловушка протокола, у которой ниже отдельный разбор.
#include <WiFi.h>#include <esp_now.h> struct Paket { float temp; int vlazh; unsigned int seq; }; // та же строка! Paket vhod; // глобальная копия для loopvolatile bool prishlo = false; void prinyal(const uint8_t *mac, const uint8_t *dannye, int len) { if (len != sizeof(Paket)) { // чужая раскладка — мусор не читаем Serial.println("[recv_cb] razmer ne soshelsya"); return; } memcpy(&vhod, dannye, sizeof(vhod)); // только копирование и флаг prishlo = true;} void setup() { Serial.begin(115200); WiFi.mode(WIFI_STA); Serial.print("moya MAC: "); // этот адрес вписать в плату A Serial.println(WiFi.macAddress()); pinMode(26, OUTPUT); // реле-индикатор «данные идут» esp_now_init(); esp_now_register_recv_cb(prinyal);} void loop() { if (prishlo) { prishlo = false; Serial.print("[rx] temp="); Serial.print(vhod.temp); Serial.print(" vlazh="); Serial.print(vhod.vlazh); Serial.print(" seq="); Serial.println(vhod.seq); digitalWrite(26, HIGH); // вспышка: кадр принят и разобран delay(40); digitalWrite(26, LOW); }}esp_now_register_recv_cb(prinyal) подписывает функцию, которую стек зовёт на каждый входящий кадр с указателем на данные и длиной. Внутри — дисциплина: сверить len с sizeof(Paket), скопировать байты в глобальную копию, поднять volatile-флаг, выйти. Печать в Serial и щелчок реле — в loop() по флагу: колбэк живёт в контексте WiFi-задачи, и долгая работа там подвешивает весь стек. В ядре 3.x подпись колбэка другая — данные приходят в структуре esp_now_recv_info_t, а MAC отправителя лежит в info->src_addr; байты и длина те же, поэтому memcpy и сверка размера не меняются.
Указатель из колбэка — временный: буфер валиден только внутри функции, после выхода из неё данные стек перезапишет. Поэтому скопировать байты надо там же, немедленно, — как в скетче выше, — и только потом флагом будить loop(): попытка сохранить указатель и разобрать кадр в loop() даёт порой правильные числа, порой мусор, и баг этот ловится днями. Volatile на флаге — той же природы: колбэк и loop живут в разных контекстах, и без volatile компилятор вправе закэшировать переменную в регистре.
Пока обе платы в WIFI_STA и ни одна не подключена к роутеру, они живут на канале 1 — совпадение бесплатно. Как только одна из пары подключается к роутеру, её канал становится роутерным — скажем, шестым, — и кадры летят в пустоту: пара молчит при идеальном коде. Диагностика в одну строку: WiFi.channel() на обеих платах, цифры обязаны совпадать. Сеть из многих узлов с шлюзом, который совмещает ESP-NOW и роутер, — отдельная история в уроке про ESP-NOW-сеть.
| ESP-NOW | MQTT | |
|---|---|---|
| Задержка доставки | 1-3 мс в квартире | десятки-сотни мс: путь до брокера и обратно |
| Настройка | MAC пира и пара строк кода | WiFi, брокер, клиент, топики, QoS |
| Зависимость от роутера | нет: сеть не нужна вовсе | полная: без WiFi и брокера тишина |
| Потолок payload | 250 байт на кадр (ESP8266 — 200) | практически не ограничен |
Симулятор: эфир из четырёх чисел и контрольная сумма
Радио и структур движок не умеет, поэтому эфир изображает Serial: отправитель печатает кадр из четырёх чисел — температура, влажность, seq, сумма, — приёмник его разбирает. Структуру заменяет массив int paket[4]: в реальном скетче это та самая структура из двенадцати байт. Контрольная сумма — байт-чекер по модулю 256: приёмник пересчитывает её из трёх полей и сравнивает с четвёртым числом; не сошлось — кадр отброшен, светодиод-исполнитель не щёлкает. Обе стороны живут в одном скетче-модели и разделены только пометками [tx], [ether] и [rx] — на живой паре это два разных устройства в двух разных корпусах.
Честная оговорка про реальность: на радио-уровне ESP-NOW битые кадры молча выкидывает CRC самого WiFi, поэтому поверх чистой пары сумму никто не гоняет. Она нужна, когда кадр едет дальше приёмника — через шлюз в Serial-мост или по цепочке узлов: сумма в структуре ловит то, что теряется после радио. В симе «помеха» портит каждый четвёртый кадр; пришлите в Serial число 0 — помехи выключатся, число от 2 до 10 — станут чаще. Ползунок A0 — датчик температуры: крутите, и она поедет в кадре. Понаблюдайте за счётчиками в строках «ok» — со включённой помехой брошенных кадров будет четверть, без неё — ноль.
- 1. Структуру пакета ESP-NOW я объявляю:
- 2. Потолок полезной нагрузки одного кадра ESP-NOW на ESP32:
- 3. Задержка доставки кадра ESP-NOW в квартире:
- 4. ESP_NOW_SEND_SUCCESS в колбэке отправки значит:
- 5. Пара работала, отправитель подключили к роутеру — связь пропала. Виноват:
- struct Paket одинаков в обоих скетчах: отправка esp_now_send(mac, (uint8_t*)&pk, sizeof(pk)), приём memcpy по сверенной длине.
- В пакете только фиксированные типы: String и указатели на приёмнике превращаются в мусор.
- esp_now_register_send_cb даёт статус SUCCESS/FAIL — это «услышано», не «выполнено»; seq в структуре ловит пропуски и дубли.
- Канал обеих плат обязан совпадать: подключение к роутеру уводит плату на роутерный канал, и пара молчит при верном коде.
- ESP-NOW — 1-3 мс и 250 байт без роутера; MQTT — топики и QoS, когда сеть и брокер уже есть.
Частые вопросы по теме урока
- Структуру пакета ESP-NOW я объявляю:
- Кадр едет сырыми байтами: раскладка полей на отправителе и приёмнике обязана совпадать. Поле, добавленное с одной стороны, сдвигает смещения — приёмник читает мусор. одинаково в обоих скетчах, байт в байт
- Потолок полезной нагрузки одного кадра ESP-NOW на ESP32:
- Классический лимит — 250 байт на ESP32 и 200 на ESP8266. Двенадцатибайтный пакет термометра влезает с запасом, суточный архив — не влезет и не должен. 250 байт
- Задержка доставки кадра ESP-NOW в квартире:
- Кадр летит напрямую между платами, минуя роутер и IP-стек, — единицы миллисекунд. От роутера зависят HTTP и MQTT, а ESP-NOW без него живёт. 1-3 мс
- ESP_NOW_SEND_SUCCESS в колбэке отправки значит:
- Колбэк говорит про доставку байтов до соседа, а не про исполнение команды. За «включилось» отвечает логика приёмника — квитанцией или seq на прикладном уровне. кадр ушёл в радио и сосед его услышал
- Пара работала, отправитель подключили к роутеру — связь пропала. Виноват:
- Подключение к роутеру переводит плату на его канал, а пара обязана сидеть на одном. Диагностика — WiFi.channel() на обеих платах, лечение — канал приёмника или архитектура шлюза. канал Wi-Fi: он сменился на роутерный