DEV Community

Cover image for ESP-NOW paso a paso: enviar órdenes entre ESP32 con confirmación
Steven Carvajal
Steven Carvajal

Posted on Originally published at iot.gripe

ESP-NOW paso a paso: enviar órdenes entre ESP32 con confirmación

Publicado originalmente en iot.gripe, como parte 4 de la serie "Domótica con ESP32 y AWS desde cero".

ESP-NOW es el protocolo de Espressif para que dos ESP32 se hablen directamente, sin router ni servidor. En el proyecto de ejemplo de esta serie es lo que permite tener un solo hub conectado a internet: recibe las órdenes de la nube y las reparte a los nodos que mueven relés (parte 1).

Un ESP32-S3 arriba y dos ESP32-C3 abajo, placas que se pueden comunicar por ESP-NOW

Un ESP32-S3 como hub (arriba) y dos ESP32-C3 como nodos (abajo): entre ellos no hace falta router.

Los ejemplos básicos envían un mensaje de una placa a otra. En esta parte vamos un paso más allá: un mensaje con estructura propia, una confirmación de que la orden se aplicó y qué hacer cuando no llega.

Qué es ESP-NOW

ESP-NOW envía paquetes pequeños (hasta 250 bytes en la versión clásica) directamente de una dirección MAC a otra, usando la radio Wi-Fi pero sin asociarse a ninguna red. No hay handshake TLS, ni DHCP, ni router de por medio.

Ventajas para domótica:

  • Los nodos no necesitan la contraseña del Wi-Fi ni ocupan un lugar en el router.
  • Latencia de pocos milisegundos entre placas.
  • Bajo consumo, útil para nodos a batería (ESP32 con batería y deep sleep).

La contrapartida: no hay garantía de que el nodo aplicó la orden. La radio confirma que el paquete llegó a la otra antena, pero no que el programa lo procesó. Por eso conviene construir una confirmación propia encima.

El requisito que más falla: el mismo canal

ESP-NOW solo funciona si emisor y receptor están en el mismo canal de radio. Si el hub está conectado al Wi-Fi de la casa, su canal es el del router, que puede ser cualquiera del 1 al 13. Hay tres formas de resolverlo:

Opción Cómo Inconveniente
Fijar el canal Configuras el router en un canal fijo y lo pones como constante en los nodos Si alguien cambia el router, hay que reprogramar
Escanear El nodo escanea las redes, encuentra la del hub por su nombre y usa su canal, sin conectarse El nodo necesita saber el nombre de la red
Probar canales El nodo envía un mensaje en cada canal hasta que el hub responde Arranque más lento

Para empezar, lo más simple es la primera opción: fija el canal del router a mano (en automático puede cambiar solo) y usa ese número en los nodos.

El mensaje: una estructura con número de orden

En lugar de enviar texto, conviene definir una estructura que compartan el hub y los nodos:

// Mismo archivo en el hub y en los nodos
typedef struct __attribute__((packed)) {
  uint8_t  tipo;    // 1 = orden, 2 = confirmación
  uint8_t  salida;  // qué relé o luz del nodo
  uint8_t  valor;   // 0 = apagado, 1 = encendido
  uint16_t id;      // número de la orden, para emparejar la confirmación
} Mensaje;
Enter fullscreen mode Exit fullscreen mode

El id permite saber a qué orden corresponde cada confirmación. El atributo packed evita que el compilador agregue relleno entre campos, así ambos lados leen los mismos bytes.

El código del hub

Para probar sin nube, las órdenes llegan por el Monitor Serie: escribe 1 para encender y 0 para apagar. En la serie, más adelante, llegarán del Device Shadow (parte 5).

// Hub ESP-NOW: envía órdenes a un nodo y espera su confirmación.
#include <WiFi.h>
#include <esp_now.h>

#define WIFI_SSID      "tu-red-wifi"
#define WIFI_PASSWORD  "tu-clave-wifi"
#define TIMEOUT_MS     2000

typedef struct __attribute__((packed)) {
  uint8_t tipo, salida, valor;
  uint16_t id;
} Mensaje;

uint8_t macNodo[6] = { 0x24, 0x0A, 0xC4, 0x00, 0x00, 0x01 };  // la imprime el nodo al arrancar

uint16_t siguienteId = 1;
uint16_t idPendiente = 0;        // 0 = no hay orden esperando confirmación
unsigned long enviadoEn = 0;

void enviarOrden(uint8_t valor) {
  Mensaje m = { 1, 1, valor, siguienteId++ };
  esp_now_send(macNodo, (uint8_t*)&m, sizeof(m));
  idPendiente = m.id;
  enviadoEn = millis();
  Serial.printf("-> orden #%u: salida 1 = %u\n", m.id, valor);
}

void alRecibir(const esp_now_recv_info_t* info, const uint8_t* data, int len) {
  if (len != sizeof(Mensaje) || memcmp(info->src_addr, macNodo, 6) != 0) return;  // solo nodos conocidos
  Mensaje m;
  memcpy(&m, data, sizeof(m));
  if (m.tipo == 2 && m.id == idPendiente) {
    Serial.printf("<- confirmación #%u: salida %u = %u (%lu ms)\n", m.id, m.salida, m.valor, millis() - enviadoEn);
    idPendiente = 0;
  }
}

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) delay(300);
  Serial.printf("Wi-Fi en el canal %d. MAC del hub: %s\n", WiFi.channel(), WiFi.macAddress().c_str());

  if (esp_now_init() != ESP_OK) { Serial.println("Error iniciando ESP-NOW"); return; }
  esp_now_register_recv_cb(alRecibir);

  esp_now_peer_info_t peer = {};
  memcpy(peer.peer_addr, macNodo, 6);
  peer.channel = 0;      // 0 = el canal actual del Wi-Fi
  peer.encrypt = false;
  esp_now_add_peer(&peer);
}

void loop() {
  if (Serial.available()) {
    char c = Serial.read();
    if (c == '0' || c == '1') enviarOrden(c - '0');
  }
  if (idPendiente && millis() - enviadoEn > TIMEOUT_MS) {
    Serial.printf("!  sin confirmación de la orden #%u\n", idPendiente);
    idPendiente = 0;  // no se asume que el cambio se aplicó
  }
}
Enter fullscreen mode Exit fullscreen mode

El código del nodo

// Nodo ESP-NOW: aplica la orden y responde con una confirmación.
#include <WiFi.h>
#include <esp_now.h>
#include <esp_wifi.h>

#define CANAL    6   // el mismo canal fijo del router
#define LED_PIN  8   // LED del ESP32-C3 Super Mini (activo en bajo); aquí iría el relé

typedef struct __attribute__((packed)) {
  uint8_t tipo, salida, valor;
  uint16_t id;
} Mensaje;

uint8_t macHub[6] = { 0x24, 0x0A, 0xC4, 0x00, 0x00, 0x10 };  // la imprime el hub al arrancar

void alRecibir(const esp_now_recv_info_t* info, const uint8_t* data, int len) {
  if (len != sizeof(Mensaje) || memcmp(info->src_addr, macHub, 6) != 0) return;
  Mensaje m;
  memcpy(&m, data, sizeof(m));
  if (m.tipo != 1) return;

  digitalWrite(LED_PIN, m.valor ? LOW : HIGH);

  m.tipo = 2;  // misma orden, ahora como confirmación de lo que se aplicó
  esp_now_send(macHub, (uint8_t*)&m, sizeof(m));
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, HIGH);  // apagado al arrancar

  WiFi.mode(WIFI_STA);
  esp_wifi_set_channel(CANAL, WIFI_SECOND_CHAN_NONE);
  Serial.printf("Canal %d. MAC del nodo: %s\n", CANAL, WiFi.macAddress().c_str());

  if (esp_now_init() != ESP_OK) { Serial.println("Error iniciando ESP-NOW"); return; }
  esp_now_register_recv_cb(alRecibir);

  esp_now_peer_info_t peer = {};
  memcpy(peer.peer_addr, macHub, 6);
  peer.channel = 0;
  peer.encrypt = false;
  esp_now_add_peer(&peer);
}

void loop() {
  delay(50);
}
Enter fullscreen mode Exit fullscreen mode

Las firmas de los callbacks corresponden al core 3.x de ESP32 para Arduino. Si usas el core 2.x, el callback de recepción recibe const uint8_t* mac en lugar de esp_now_recv_info_t*.

Cómo probarlo

  1. Carga el nodo y copia la MAC que imprime en el Monitor Serie.
  2. Ponla en macNodo del hub, junto con tu red Wi-Fi, y carga el hub. Comprueba que el canal que imprime coincide con CANAL.
  3. Copia la MAC del hub en macHub del nodo y vuelve a cargarlo.
  4. En el Monitor Serie del hub escribe 1: la orden sale y la confirmación vuelve en milisegundos.
  5. Desconecta el nodo y escribe 0: a los 2 segundos verás el aviso de que no hubo confirmación.

Monitor Serie del hub ESP-NOW: órdenes enviadas, confirmaciones con su tiempo en milisegundos y una orden sin confirmación cuando el nodo está desconectado

Salida esperada en el Monitor Serie del hub. En verde, las confirmaciones; en amarillo, una orden sin respuesta. Las MAC son de ejemplo.

Buenas prácticas para un sistema real

  • Separa lo pedido de lo confirmado. El hub solo debería informar a la nube lo que un nodo confirmó. Si informa lo que pidió, la app mostrará "encendido" con la luz apagada.
  • Acepta mensajes solo de MAC conocidas, como hace el memcmp del código. Cualquier ESP32 cercano puede enviar paquetes ESP-NOW.
  • Decide qué pasa al reiniciar un nodo. Puede recuperar su último estado de la memoria NVS (parte 6) o pedírselo al hub al arrancar. Lo importante es que no quede en un estado distinto al que muestra la app.
  • Reintenta con cuidado. Un reintento o dos tras el tiempo de espera ayuda; reintentar sin fin satura la radio.

Límites de ESP-NOW

  • Máximo 20 peers registrados por dispositivo. Es un límite del SDK; con cifrado, el número de peers cifrados es menor.
  • Sin garantía de que la orden se aplicó. Por eso la confirmación y el tiempo de espera.
  • Mismo canal obligatorio. Si el router cambia de canal, los nodos dejan de oír al hub.
  • Cifrado opcional. ESP-NOW admite cifrar con una clave compartida (LMK). Si tus mensajes llevan datos sensibles, actívalo.

Si algo no funciona, revisa por qué ESP-NOW no funciona: causas y soluciones. Y para comparar ESP-NOW con Wi-Fi, BLE o Zigbee, está la guía de comunicación entre dispositivos.

El material

  • ESP32-S3 Super Mini

  • ESP32-C3 Super Mini

Preguntas frecuentes

¿ESP-NOW funciona si el hub está conectado al Wi-Fi?

Sí. El hub puede estar conectado al router y usar ESP-NOW a la vez, siempre que los nodos estén en el mismo canal que el router.

¿Qué alcance tiene?

El mismo que el Wi-Fi de las placas, porque usa la misma radio. En una casa normal cubre varias habitaciones; las paredes gruesas y la antena de cada placa lo reducen.

¿Puedo tener más de 20 nodos?

No registrados a la vez en un solo hub. Las salidas son un segundo hub, o enviar en broadcast y filtrar por un identificador dentro del mensaje.

¿Necesito el id en el mensaje?

Si solo envías una orden a la vez, no es imprescindible. Con varias órdenes seguidas, sin él no sabes qué confirmación corresponde a cuál.

Top comments (0)