Теплица от мастерской в двадцати метрах отделялась парой грядок и одной стеной, роутера там не было и не предвиделось, а показания термометра видеть хотелось. Я перепробовал всё по возрастанию боли: провод через окно, ретранслятор WiFi за тысячу рублей, и наконец наткнулся на ESP-NOW — фирменный протокол Espressif, при котором две платы обмениваются пакетами напрямую, по радиоканалу, без сети вообще. Ни роутера, ни IP-адресов, ни DHCP: пакет летит с MAC-адреса на MAC-адрес за единицы миллисекунд.
В этом уроке — вся механика пары: пейринг по MAC, честная проверка доставки через колбэк, лимиты протокола с цифрами дальности — от академических замеров до моей теплицы. Радио движок симулятора не эмулирует, поэтому обмен двух плат рисуется моделью в Serial с честной пометкой. Сетью без роутера мы уже занимались в уроке про точку доступа — там плата поднимала собственную сеть WiFi; ESP-NOW идёт дальше и не поднимает даже её.
Пейринг, MAC и один канал
ESP-NOW живёт поверх того же WiFi-радио чипа, но мимо всего, что делает WiFi сетью: нет ассоциации с точкой доступа, нет TCP, нет заголовков HTTP. Отправка — esp_now_send с адресом получателя и указателем на байты; приём — колбэк, в который система кладёт MAC отправителя и содержимое пакета. Накладные расходы оттого копеечные, а задержка на моих платах — единицы миллисекунд: HTTP-запрос через роутер даже в локальной сети занимает десятки и сотни.
Требований к паре два, и оба про совпадение. Первое: у каждой платы известен MAC — свой печатает WiFi.macAddress(), я делаю это в setup каждого скетча и подписываю маркером на корпусе. Второе: обе платы сидят на одном канале радио. Пока обе в WIFI_STA и ни одна не подключена к роутеру, они живут на канале 1 — совпадение получается бесплатно. Как только одна из пары подключается к роутеру, её канал становится роутерным, и пара замолкает: этой канальной дисциплине посвящён третий урок кластера.
Экономика радио — отдельный плюс. На отправку пакета радиомодуль просыпается на единицы миллисекунд и сразу засыпает: ни ассоциации с точкой доступа, ни служебных маяков, ни DTIM-окон, которыми дышит WiFi-клиент. Для батарейного узла это означает, что сам обмен почти ничего не стоит — весь расход определяется сном между отправками, и именно поэтому ESP-NOW у меня первый кандидат на роль канала для узла, который просыпается по таймеру раз в минуту и говорит три слова.
#include <WiFi.h>#include <esp_now.h> // Пакет: только фиксированные поля. String внутри ломает приёмник.typedef struct { uint8_t id; // номер платы: у соседа другой uint32_t schetchik; // счётчик отправок int16_t temp10; // температура x10: 243 это 24.3} Paket; Paket moj;uint8_t macSosed[6] = {0x24, 0x6F, 0x28, 0xAA, 0xBB, 0x02}; // с визитки соседаvolatile int statPoslednego = -1; // -1 неизвестно, 1 успех, 0 промахunsigned long tPoslano = 0;int schet = 0; void poslano(const uint8_t *mac, esp_now_send_status_t status) { // Колбэк срабатывает из системного контекста: только запись, без печати. statPoslednego = (status == ESP_NOW_SEND_SUCCESS) ? 1 : 0;} void prinyato(const uint8_t *mac, const uint8_t *buf, int len) { if (len != sizeof(Paket)) return; // чужой формат — мимо Paket p; memcpy(&p, buf, sizeof(Paket)); Serial.print("RX id="); Serial.print(p.id); Serial.print(" n="); Serial.print(p.schetchik); Serial.print(" t="); Serial.println(p.temp10 / 10.0, 1);} void setup() { Serial.begin(115200); pinMode(2, OUTPUT); // светодиод DevKit на GPIO 2 WiFi.mode(WIFI_STA); // радиостанция, никакой сети Serial.print("moy MAC: "); Serial.println(WiFi.macAddress()); // визитка: перепишите соседу if (esp_now_init() != ESP_OK) { Serial.println("esp_now_init ne vstal"); return; } esp_now_register_send_cb(poslano); esp_now_register_recv_cb(prinyato); esp_now_peer_info_t peer = {}; memcpy(peer.peer_addr, macSosed, 6); peer.channel = 0; // 0 = текущий канал этой платы peer.encrypt = false; esp_now_add_peer(&peer); // без записи в таблице пиров send не уйдёт} void loop() { if (millis() - tPoslano > 2000) { tPoslano = millis(); schet = schet + 1; moj.id = 1; moj.schetchik = schet; moj.temp10 = 243; digitalWrite(2, HIGH); // вспышка: пакет ушёл в эфир esp_now_send(macSosed, (uint8_t *)&moj, sizeof(moj)); delay(30); digitalWrite(2, LOW); if (statPoslednego == 1) Serial.println("TX: SUCCESS"); if (statPoslednego == 0) Serial.println("TX: FAIL - adresat molchit"); statPoslednego = -1; }}Разбор по узлам. Возврат esp_now_send — это «пакет поставлен в очередь», не «дошёл»: у радиопротокола нет повторов и квитанций TCP, поэтому правда о доставке приходит позже, отдельным вызовом колбэка отправки со статусом SUCCESS или FAIL. Если адресат обесточен или ушёл за дальность, вернётся FAIL — у меня это повод поднять счётчик промахов, а не паниковать. В колбэке не затевайте тяжёлого: он срабатывает из системного контекста, поэтому у меня там только запись статуса в переменную, а вся печать — в loop. Расписание отправки держу на millis, без delay: принципы того же millis-каркаса.
Приёмник симметричен: в колбэк приходит указатель на байты и длина, я сравниваю длину с sizeof(Paket) и копирую memcpy в структуру той же раскладки. Проверка длины — страховка от чужих устройств в эфире: пакет другого формата просто отбрасывается. И честное предупреждение: в свежих версиях ядра сигнатуры колбэков поменялись — приём получает структуру-описатель, где сидит и RSSI этого пакета. Если компилятор ругается на типы колбэка, вы попали в новый API: поправьте сигнатуру по подсказке компилятора, логика не меняется. Силу сигнала в целом я диагностирую отдельно — RSSI в Serial.
Отладка пары — это два Serial-монитора: у каждой платы свой порт, и я пишу префиксы [A] и [B] прямо в строки печати. Ночь, когда пара «не работает», чаще всего оказывалась ночью с выключенной второй платой: я смотрел в пустое окно монитора и грешил на радио. Печать из колбэка в свежих версиях ядра допустима, но до первой необходимости не тащите её туда — статус через переменную в loop, как в скетче выше, а в колбэке только самый лёгкий код.
Содержание пакета — вопрос уже не радио, а дисциплины. Один пакет — одно событие: показание, команда, статус; суточный архив в двадцать байт не влезет и в двадцать раз больший лимит, да и не его это работа. Частота у моих узлов — раз в две секунды в бодром режиме и раз в минуту в дежурном; при плотном потоке драйвер может отбрасывать пакеты, поэтому честный потолок пары — единицы пакетов в секунду, а не сотни. И держите в пакете счётчик отправок, как в скетче выше: приёмник сразу видит пропуски — на деградацию эфира это указывает раньше, чем RSSI и раньше FAIL.
Лимиты и дальность в цифрах
| Канал | Нужна сеть | Задержка и размер | Когда беру |
|---|---|---|---|
| HTTP-запрос | роутер и IP | десятки-сотни мс, ответ по запросу | панели, интеграции, облачные сервисы |
| WebSocket | роутер | единицы мс, двусторонний поток | живая страница, команды туда-обратно |
| BLE-уведомления | нет | до пары десятков метров в квартире | телефон в роли приёмника |
| ESP-NOW | нет | единицы мс, до 250 байт на пакет | ESP-к-ESP: узлы, парные устройства |
Теперь потолки. Классическая версия протокола кладёт в один пакет до 250 байт полезной нагрузки — для показаний, состояний и команд этого хватает с избытком: мой пакет термометра весит меньше десятка. В свежих версиях ESP-IDF появился формат v2 с потолком 1470 байт, но ардуино-ядро исторически везёт классику — проверьте свою версию, прежде чем заталкивать большие кадры. Адресатов в таблице пиров держат около двадцати, шифрованных меньше: для пары это не ограничение вовсе, для сети — уже вопрос архитектуры, и он разобран в третьем уроке кластера.
Дальность в цифрах, как она есть. Академический замер в помещении даёт порядка пятнадцати метров, на открытом воздухе — до девяноста; обзоры с прямой видимостью и штатными антеннами обещают 220-400 метров, а в дальнобойном LR-режиме энтузиасты добирали и до пятисот. Моя теплица честно держит связь через одну стену и двадцать метров огорода; внутри железобетонного дома две стены съедают половину запаса. Цифры у вас будут своими — антенны, этажи, стены; меряйте на месте. А в проекте, где платы меняются, адреса соседей удобнее держать не в скетче, а в хранилище — как я устроил настройки в Preferences.
Сценарии пары, которые у меня прижились: пульт и реле на калитке, звонок у двери и тихий пьезоизлучатель в мастерской, счётчик импульсов воды и дисплей в доме. Во всех случаях одна сторона — источник события, вторая — исполнитель, и обе знают MAC друг друга наизусть. Как только источников становится три и больше, пара дорастает до звезды — сюжета третьего урока, где появится шлюз с роутером на борту.
Итог: ESP-NOW — радиоканал точка-точка без всякой сети, где от вас требуют совпадения канала, записанного в таблицу пиров MAC-адреса и честного отношения к лимитам: 250 байт на пакет, около двадцати адресатов, доставка подтверждается колбэком, а не возвратом send. Задержка в единицы миллисекунд и ноль инфраструктуры делают его первым выбором для парных устройств: пульт-выключатель, репитер показаний, дверь и звонок.
Домашнее задание: соберите пару из двух плат, распечатайте визитки MAC, прошейте скетч из урока на обе и поиграйте расстоянием, глядя на статусы колбэка. Когда пара заработает, добавьте пульту команды из монитора: разбор чисел из Serial вам пригодится сразу, вместе с таймаутами parseInt — они разобраны в уроке про чтение чисел.
- 1. esp_now_send вернул ESP_OK, а плату-адресат выключили из розетки. Что произошло на самом деле?
- 2. Сколько полезных байтов кладёт в один пакет классическая версия ESP-NOW?
- 3. Почему структура с полем String прилетает на приёмник мусором?
- 4. Что обязано совпасть у двух плат, чтобы ESP-NOW заработал?
- 5. Как узнать MAC-адрес своей платы?
- ESP-NOW — обмен пакетами по MAC без сети: без роутера, без IP, задержка в единицы миллисекунд
- esp_now_send сообщает о постановке в очередь; реальный статус доставки приходит в колбэке отправки
- В пакет тащат только фиксированные типы; String и указатели внутри структуры — мусор на приёмнике
- Лимиты классики: 250 байт на пакет, около двадцати пиров; обе платы — на одном канале радио
- Дальность меряется на месте: от ~15 м в помещении до сотен метров на прямой видимости
Частые вопросы по теме урока
- esp_now_send вернул ESP_OK, а плату-адресат выключили из розетки. Что произошло на самом деле?
- Возврат send — лишь постановка в очередь. Реальный статус доставки приходит в колбэке отправки: SUCCESS или FAIL, и это единственный источник правды о судьбе пакета. Пакет поставлен в очередь; о доставке скажет колбэк со статусом FAIL
- Сколько полезных байтов кладёт в один пакет классическая версия ESP-NOW?
- Классика — 250 байт (ESP_NOW_MAX_DATA_LEN); 1470 — потолок нового формата v2 из свежих ESP-IDF, которого в привычном ардуино-ядре долго не было. Проверяйте свою версию. 250 байт
- Почему структура с полем String прилетает на приёмник мусором?
- Пакет едет как сырые байты: указатель строки на приёмнике указывает в чужую память. В пакет тащат только фиксированные типы и массивы char фиксированной длины. String хранит указатель в кучу отправителя, и на чужой плате он пуст
- Что обязано совпасть у двух плат, чтобы ESP-NOW заработал?
- Сеть не нужна вовсе: нужен общий канал радио и запись пира с MAC адресата — без esp_now_add_peer пакет просто не уйдёт. Канал радио и MAC адресата в таблице пиров
- Как узнать MAC-адрес своей платы?
- MAC у платы постоянный и печатается одной строкой. Мой скетч-визитка из трёх строк — стандартная процедура перед сборкой пары. Печатью WiFi.macAddress() в setup