Cloud, DevOps e IoT en español

Cómo conectar un ESP32 a AWS IoT Core paso a paso (con Device Shadow)

30 de septiembre de 2026

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 te muestro, paso a paso, cómo lo hice en IHouse, la plataforma de domótica que tengo funcionando en mi casa: un ESP32-S3 conectado a AWS IoT Core por MQTT sobre TLS, que recibe órdenes a través del Device Shadow y controla las luces.

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.

ESP32-S3 Super Mini, la placa que uso como dispositivo conectado a AWS IoT Core en mi proyecto IHouse

El ESP32-S3 Super Mini que uso en IHouse: USB-C, botones BOOT y RESET, y un LED RGB integrado.

Qué vas a necesitar

¿Por qué esa librería MQTT y no PubSubClient, que es la más popular? Te lo cuento en los errores comunes: fue el problema más difícil de todo el proyecto.

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 uso el Device Shadow: un documento JSON que AWS guarda por cada dispositivo, con dos partes:

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:

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. En mi proyecto la política del dispositivo 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 es una versión simplificada del firmware real de IHouse: se conecta, escucha el Shadow y controla un LED. Cambia LED_PIN por el pin de tu placa.

Cara trasera del ESP32-S3 Super Mini con la serigrafía de los números de GPIO de cada pin

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 decisiones de este código salen directamente de problemas que tuve en el proyecto real:

  1. El buffer de 2048 bytes. El documento del Shadow que llega en get/accepted incluye desired, reported, metadatos y versión. Con un buffer pequeño, el mensaje simplemente no llega a tu función onMessage, sin ningún error visible.
  2. 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 en el firmware real me dio conexiones estables.
  3. El publish a shadow/get al reconectar. En teoría, AWS vuelve a enviar el delta pendiente cuando el dispositivo se reconecta, pero en la práctica no me resultó confiable. 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.

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.

Cliente de prueba MQTT de AWS IoT Core mostrando un mensaje en el tópico shadow/update/accepted con el estado desired, metadatos y versión

Un mensaje real de mi dispositivo en update/accepted (oculté parte del nombre del Thing). En IHouse el Shadow guarda un campo compacto code en lugar de led, pero el flujo es el mismo. El aviso azul solo indica que no se puede publicar en un tópico con comodín; suscribirse sí se puede.

Errores comunes (y los que me pasaron a mí)

"Publiqué y no llegó nada." Con la librería PubSubClient, publish() devolvía éxito en el ESP32-S3, pero el mensaje nunca llegaba a AWS: ni los reportes al Shadow ni las peticiones a shadow/get (0 respuestas en 5 intentos). Descarté la política (otro cliente MQTT con el mismo certificado publicaba sin problema), el QoS y el tamaño del buffer. La causa era la combinación de PubSubClient con WiFiClientSecure en esa placa. Al cambiar a la librería MQTT de 256dpi, con el mismo certificado, los mismos tópicos y el mismo diseño, todo funcionó a la primera.

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 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.

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.

Con un ESP32-C3, AWS cierra la conexión en menos de un segundo. El C3 tiene menos RAM y un solo núcleo, y TLS + MQTT + JSON exprimen el stack por defecto de la tarea loop de Arduino. El síntoma que vi fue la conexión cerrándose con CLIENT_ERROR casi al instante. La solución es ampliar el stack después de todos los #include:

SET_LOOP_TASK_STACK_SIZE(16 * 1024);

Con ese ajuste el C3 funciona, aunque con menos margen. Por eso uso ESP32-S3 para el dispositivo que se conecta a la nube.

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.

En IHouse bajé todavía más el costo con una arquitectura de gateway: solo un ESP32 por casa se conecta a AWS y los demás dispositivos hablan con él por ESP-NOW, sin certificados ni conexiones propias. Te lo cuento en un próximo artículo.

Seguridad: lo mínimo antes de ir a producción

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. En mi experiencia, un ESP32-S3 da más margen cuando el dispositivo hace TLS, MQTT y JSON al mismo tiempo.

¿Puedo usar PubSubClient?

Mucha gente lo usa sin problemas. En mi caso, con un ESP32-S3 y WiFiClientSecure, los mensajes no llegaban a AWS aunque la librería reportara éxito, y cambiar a la librería MQTT de 256dpi lo resolvió. Si te pasa lo mismo, ya sabes por dónde empezar.