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 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;
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ó
}
}
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);
}
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
- Carga el nodo y copia la MAC que imprime en el Monitor Serie.
- Ponla en
macNododel hub, junto con tu red Wi-Fi, y carga el hub. Comprueba que el canal que imprime coincide conCANAL. - Copia la MAC del hub en
macHubdel nodo y vuelve a cargarlo. - En el Monitor Serie del hub escribe
1: la orden sale y la confirmación vuelve en milisegundos. - Desconecta el nodo y escribe
0: a los 2 segundos verás el aviso de que no hubo confirmación.

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
memcmpdel 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
Para el hub: dos núcleos, USB-C y LED RGB integrado.
Enlace de afiliado: si compras, el sitio recibe una comisión sin costo extra para ti.
ESP32-C3 Super Mini
Para cada nodo: pequeña, barata y con ESP-NOW.
Enlace de afiliado: si compras, el sitio recibe una comisión sin costo extra para ti.
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.