Publicado originalmente en iot.gripe, como parte 3 de la serie "Domótica con ESP32 y AWS desde cero".
Conectar un ESP32 a AWS IoT Core parece sencillo hasta que el dispositivo dice "publicado" y a AWS no llega nada. En esta guía conectamos, paso a paso, un ESP32 a AWS IoT Core por MQTT sobre TLS, con un certificado propio y una política de mínimo privilegio, y lo controlamos a través del Device Shadow.
Al final vas a tener un ESP32 que enciende o apaga un LED cuando cambias un valor en la nube, y que reporta su estado real de vuelta. Es la base de cualquier proyecto IoT serio en AWS.
Un ESP32-S3 Super Mini: USB-C, botones BOOT y RESET, y un LED RGB integrado.
Qué vas a necesitar
- Una placa ESP32. Un ESP32-S3 da más margen para TLS; un ESP32-C3 también funciona, con un ajuste de memoria que explico en los errores comunes.
-
Arduino IDE (o
arduino-cli) con el core de ESP32 instalado. -
Dos librerías desde el Library Manager:
- MQTT, de Joël Gähwiler (256dpi/arduino-mqtt).
- ArduinoJson, de Benoît Blanchon, versión 7.
- Una cuenta de AWS y, si prefieres la terminal, la AWS CLI configurada.
¿Por qué esa librería MQTT y no PubSubClient, que es la más popular? Te lo cuento en los errores comunes.
Si todavía no tienes la placa:
ESP32-S3 Super Mini
Cable USB-C de datos
Cómo funciona la conexión
El ESP32 abre una conexión MQTT sobre TLS (puerto 8883) hacia el endpoint de tu cuenta en AWS IoT Core. No hay usuario ni contraseña: el dispositivo se identifica con un certificado X.509 propio, y una política de IoT define exactamente qué puede hacer.
Para intercambiar estado usamos el Device Shadow: un documento JSON que AWS guarda por cada dispositivo, con dos partes:
-
desired: lo que la nube (tu app, un script, un asistente de voz) quiere que pase. Por ejemplo,"led": "on". -
reported: lo que el dispositivo confirma que realmente pasó.
Cuando desired y reported no coinciden, AWS publica un delta en un tópico reservado, el ESP32 lo recibe, actúa y reporta el nuevo estado. La ventaja frente a tópicos propios: si el dispositivo estaba desconectado, al volver puede pedir el estado pendiente y ponerse al día.
Paso 1: Crear el Thing y su certificado
Un Thing es la representación del dispositivo en AWS IoT. Desde la consola: AWS IoT Core → Manage → All devices → Things → Create things → Create single thing, ponle un nombre (en esta guía, mi-esp32) y elige Auto-generate a new certificate.
Al final, AWS te deja descargar los archivos del certificado una sola vez:
-
certificate.pem.crt(certificado del dispositivo) -
private.pem.key(llave privada, no la compartas nunca) -
AmazonRootCA1.pem(la CA raíz de Amazon)
Si prefieres la terminal, esto hace lo mismo:
aws iot create-thing --thing-name mi-esp32
aws iot create-keys-and-certificate --set-as-active \
--certificate-pem-outfile certificate.pem.crt \
--public-key-outfile public.pem.key \
--private-key-outfile private.pem.key
# Guarda el "certificateArn" que devuelve este comando
curl -o AmazonRootCA1.pem https://www.amazontrust.com/repository/AmazonRootCA1.pem
Paso 2: Una política con el mínimo de permisos
Aquí es donde muchos tutoriales usan "Resource": "*" para que "funcione rápido". No lo hagas: con esa política, cualquiera que obtenga el certificado podría publicar en cualquier tópico de tu cuenta. La política de abajo solo le permite conectarse con su propio nombre y usar los tópicos de su Shadow.
Guarda esto como policy.json, cambiando REGION y CUENTA por los tuyos:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "arn:aws:iot:REGION:CUENTA:client/mi-esp32"
},
{
"Effect": "Allow",
"Action": ["iot:Subscribe", "iot:Receive"],
"Resource": [
"arn:aws:iot:REGION:CUENTA:topicfilter/$aws/things/mi-esp32/shadow/update/delta",
"arn:aws:iot:REGION:CUENTA:topic/$aws/things/mi-esp32/shadow/update/delta",
"arn:aws:iot:REGION:CUENTA:topicfilter/$aws/things/mi-esp32/shadow/update/rejected",
"arn:aws:iot:REGION:CUENTA:topic/$aws/things/mi-esp32/shadow/update/rejected",
"arn:aws:iot:REGION:CUENTA:topicfilter/$aws/things/mi-esp32/shadow/get/accepted",
"arn:aws:iot:REGION:CUENTA:topic/$aws/things/mi-esp32/shadow/get/accepted"
]
},
{
"Effect": "Allow",
"Action": "iot:Publish",
"Resource": [
"arn:aws:iot:REGION:CUENTA:topic/$aws/things/mi-esp32/shadow/update",
"arn:aws:iot:REGION:CUENTA:topic/$aws/things/mi-esp32/shadow/get"
]
}
]
}
Fíjate en dos detalles: iot:Subscribe se evalúa sobre topicfilter/... e iot:Receive sobre topic/..., por eso aparecen ambos. Y el client/mi-esp32 obliga a que el dispositivo se conecte usando exactamente ese nombre como client ID.
Crea la política y engánchala al certificado y al Thing:
aws iot create-policy --policy-name mi-esp32-policy \
--policy-document file://policy.json
aws iot attach-policy --policy-name mi-esp32-policy \
--target <certificateArn>
aws iot attach-thing-principal --thing-name mi-esp32 \
--principal <certificateArn>
Paso 3: Obtener el endpoint de tu cuenta
Cada cuenta tiene su propio endpoint de IoT. Lo ves en la consola, en Settings → Device data endpoint, o con:
aws iot describe-endpoint --endpoint-type iot:Data-ATS
Usa siempre el endpoint ATS (el que termina en -ats.iot.REGION.amazonaws.com), que es el que funciona con la CA raíz AmazonRootCA1.
Paso 4: Guardar las credenciales en secrets.h
En la carpeta del sketch crea un archivo secrets.h y pega el contenido de los tres archivos del paso 1. Las comillas R"EOF(...)EOF" son raw strings de C++: permiten pegar el certificado tal cual, con sus saltos de línea.
#pragma once
#define WIFI_SSID "tu-red-wifi"
#define WIFI_PASSWORD "tu-clave-wifi"
#define THING_NAME "mi-esp32"
#define AWS_IOT_ENDPOINT "xxxxxxxxxxxxxx-ats.iot.us-east-1.amazonaws.com"
// Contenido de AmazonRootCA1.pem
static const char AWS_CERT_CA[] PROGMEM = R"EOF(
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
)EOF";
// Contenido de certificate.pem.crt
static const char AWS_CERT_CRT[] PROGMEM = R"EOF(
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
)EOF";
// Contenido de private.pem.key
static const char AWS_CERT_PRIVATE[] PROGMEM = R"EOF(
-----BEGIN RSA PRIVATE KEY-----
...
-----END RSA PRIVATE KEY-----
)EOF";
Si usas Git, agrega secrets.h a tu .gitignore antes del primer commit. Una llave privada en un repositorio público es una puerta abierta a tu cuenta.
Paso 5: El código del ESP32
Este sketch se conecta, escucha el Shadow y controla un LED. Cambia LED_PIN por el pin de tu placa.
En el ESP32-S3 Super Mini los números de GPIO están impresos por detrás. El número que ves junto al pin donde conectas el LED es el que va en LED_PIN.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <MQTTClient.h> // Librería "MQTT" de 256dpi (Joël Gähwiler)
#include <ArduinoJson.h> // v7
#include "secrets.h"
#define LED_PIN 2 // ajusta al pin de tu placa
WiFiClientSecure net;
MQTTClient client(2048); // buffer amplio: el documento del Shadow no cabe en el tamaño por defecto
String topicUpdate, topicDelta, topicRejected, topicGet, topicGetAccepted;
void buildTopics() {
String base = String("$aws/things/") + THING_NAME + "/shadow";
topicUpdate = base + "/update";
topicDelta = base + "/update/delta";
topicRejected = base + "/update/rejected";
topicGet = base + "/get";
topicGetAccepted = base + "/get/accepted";
}
void reportLed(const char* state) {
JsonDocument doc;
doc["state"]["reported"]["led"] = state;
char buffer[128];
size_t n = serializeJson(doc, buffer);
client.publish(topicUpdate.c_str(), buffer, n);
}
void applyLed(const char* state) {
digitalWrite(LED_PIN, strcmp(state, "on") == 0 ? HIGH : LOW);
reportLed(state); // confirma a la nube lo que realmente hizo el dispositivo
}
void onMessage(String &topic, String &payload) {
Serial.printf("[%s] %s\n", topic.c_str(), payload.c_str());
if (topic == topicRejected) return; // solo para depurar: AWS explica aquí por qué rechazó un reporte
JsonDocument doc;
if (deserializeJson(doc, payload)) return;
// get/accepted trae el documento completo bajo state.desired;
// update/delta trae solo la diferencia, directo bajo state
JsonVariant state = (topic == topicGetAccepted)
? doc["state"]["desired"].as<JsonVariant>()
: doc["state"].as<JsonVariant>();
const char* led = state["led"];
if (led != nullptr) applyLed(led);
}
void connectWiFi() {
if (WiFi.status() == WL_CONNECTED) return;
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.print("Conectando a Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println(" listo");
}
void connectAWS() {
while (!client.connected()) {
connectWiFi();
Serial.print("Conectando a AWS IoT Core... ");
if (!client.connect(THING_NAME)) {
Serial.printf("falló (lastError=%d), reintento en 5 s\n", client.lastError());
delay(5000);
continue;
}
Serial.println("conectado");
delay(300); // deja asentar la sesión TLS antes de suscribirse
client.subscribe(topicDelta.c_str());
client.subscribe(topicRejected.c_str());
client.subscribe(topicGetAccepted.c_str());
// Pide el estado actual en vez de esperar a que llegue un delta
delay(200);
client.publish(topicGet.c_str(), "");
}
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
buildTopics();
connectWiFi();
net.setCACert(AWS_CERT_CA);
net.setCertificate(AWS_CERT_CRT);
net.setPrivateKey(AWS_CERT_PRIVATE);
client.begin(AWS_IOT_ENDPOINT, 8883, net);
client.onMessage(onMessage);
connectAWS();
}
void loop() {
client.loop();
delay(10);
if (!client.connected()) connectAWS();
}
Tres detalles de este código evitan problemas habituales:
-
El buffer de 2048 bytes. El documento del Shadow que llega en
get/acceptedincluyedesired,reported, metadatos y versión. Con un buffer pequeño, el mensaje simplemente no llega a tu funciónonMessage, sin ningún error visible. - La pausa de 300 ms antes de suscribirse. Deja que la sesión TLS termine de asentarse antes de pedir las suscripciones. Es un margen pequeño que ayuda a tener conexiones estables.
-
El
publishashadow/getal reconectar. Si el dispositivo estuvo desconectado, no conviene depender de que llegue undelta: pedir el estado completo en cada conexión garantiza que el dispositivo siempre se ponga al día.
Paso 6: Probarlo desde la nube
Sube el sketch, abre el Monitor Serie a 115200 baudios y espera el mensaje conectado. Después cambia el estado deseado.
Salida esperada en el Monitor Serie: la conexión, el estado inicial que llega por get/accepted y cada cambio que llega por update/delta.
Desde la consola: AWS IoT Core → Things → mi-esp32 → Device Shadows → Classic Shadow → Edit, y pon:
{
"state": {
"desired": { "led": "on" }
}
}
Desde la terminal (usa tu endpoint ATS en --endpoint-url):
aws iot-data update-thing-shadow --thing-name mi-esp32 \
--endpoint-url https://xxxxxxxxxxxxxx-ats.iot.us-east-1.amazonaws.com \
--cli-binary-format raw-in-base64-out \
--payload '{"state":{"desired":{"led":"on"}}}' \
salida.json
El LED se enciende, y en el Monitor Serie verás llegar el delta. Si vuelves a abrir el Shadow, reported.led también dirá "on": el dispositivo confirmó el cambio y el delta desaparece.
Para ver el tráfico del Shadow sin tocar el ESP32, abre AWS IoT Core → Test → MQTT test client y suscríbete a $aws/things/mi-esp32/shadow/+/accepted. El + es un comodín: recibes las respuestas de update y de get. Cada vez que cambias el estado deseado aparece un mensaje en update/accepted.
Errores comunes
"Publiqué y no llegó nada." Con algunas combinaciones de placa y versión del core, PubSubClient con WiFiClientSecure devuelve éxito en publish() pero el mensaje no llega a AWS. Antes de culpar a la política, prueba el mismo certificado desde otro cliente (por ejemplo, mosquitto_pub): si desde ahí funciona, cambia de librería. La librería MQTT de 256dpi suele resolverlo sin tocar nada más.
La conexión se cierra apenas se establece. Casi siempre es la política. Si el client ID no coincide con el de iot:Connect, o el dispositivo se suscribe o publica en un tópico que la política no permite, AWS IoT cierra la conexión sin darte una explicación en el dispositivo. Revisa que THING_NAME coincida exactamente con el recurso client/... de la política y que estén todos los tópicos que usa el código, incluidos los de shadow/get.
Para ver qué pasa del lado de AWS, suscríbete desde el MQTT test client a $aws/events/presence/#: si ves al dispositivo conectarse y desconectarse cada pocos segundos, el problema está en la política o en el client ID. Activar los registros de AWS IoT en CloudWatch también muestra el motivo exacto, como AUTHORIZATION_FAILURE.
El Shadow rechaza los reportes. Suscríbete a shadow/update/rejected, como en el código: ahí AWS publica el motivo exacto del rechazo (JSON mal formado, versión desactualizada, etc.). Sin esa suscripción, el error pasa en silencio.
Reinicios con Stack canary watchpoint triggered en un ESP32-C3. TLS + MQTT + JSON exprimen la pila de la tarea loop de Arduino. Dale más stack con esta línea, después de todos los #include:
SET_LOOP_TASK_STACK_SIZE(16 * 1024);
El documento del Shadow no puede crecer sin límite. El Shadow clásico admite hasta 8 KB por documento. Si guardas configuración ahí, mantenla compacta.
¿Cuánto cuesta?
Para un proyecto personal, prácticamente nada. AWS IoT Core cobra aproximadamente 1 dólar por millón de mensajes y 0,08 dólares por millón de minutos de conexión (precios de referencia al momento de escribir; revisa la página de precios de AWS para tu región). Un ESP32 conectado las 24 horas suma unos 43.200 minutos al mes: menos de medio centavo de dólar.
Si tienes muchos dispositivos, un hub que concentra la conexión con AWS mientras los demás hablan con él por ESP-NOW reduce todavía más las conexiones y los certificados. Las dos opciones están comparadas en la parte 1: arquitectura de una casa inteligente con ESP32 y AWS.
Seguridad: lo mínimo antes de ir a producción
- Un certificado por dispositivo. Si uno se compromete, lo revocas sin afectar a los demás.
-
Políticas de mínimo privilegio, como la del paso 2. Nada de
Resource: "*". - La llave privada nunca en el repositorio. Y si fabricas varios dispositivos, no quemes credenciales en el firmware: AWS IoT ofrece Fleet Provisioning para que cada dispositivo obtenga su propio certificado la primera vez que se conecta (parte 17).
Preguntas frecuentes
¿Necesito un servidor propio para controlar el ESP32?
No. AWS IoT Core hace de broker MQTT y guarda el estado en el Device Shadow. Tu app, un script o una función Lambda pueden cambiar el estado deseado y el dispositivo reacciona, sin servidores que mantener.
¿Por qué usar el Device Shadow en lugar de tópicos MQTT propios?
Porque guarda el estado aunque el dispositivo esté desconectado. Con tópicos propios, un mensaje enviado mientras el ESP32 estaba apagado se pierde; con el Shadow, al reconectar el dispositivo pide el estado y se pone al día.
¿Funciona con cualquier ESP32?
Sí, el código es el mismo. Con placas de menos memoria, como el ESP32-C3, agrega el ajuste de stack de la sección de errores como margen.
¿Puedo usar PubSubClient?
Mucha gente lo usa sin problemas. Si publish() devuelve éxito pero los mensajes no llegan a AWS, cambiar a la librería MQTT de 256dpi es lo primero que conviene probar.



Top comments (0)