DEV Community

Cover image for Conectar un ESP32 a AWS IoT Core paso a paso
Steven Carvajal
Steven Carvajal

Posted on Originally published at iot.gripe

Conectar un ESP32 a AWS IoT Core paso a paso

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.

ESP32-S3 Super Mini con USB-C, botones BOOT y RESET y LED RGB integrado

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
Enter fullscreen mode Exit fullscreen mode

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"
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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.

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();
}
Enter fullscreen mode Exit fullscreen mode

Tres detalles de este código evitan problemas habituales:

  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 ayuda a tener conexiones estables.
  3. El publish a shadow/get al reconectar. Si el dispositivo estuvo desconectado, no conviene depender de que llegue un delta: 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.

Monitor Serie del ESP32: conexión a Wi-Fi y a AWS IoT Core, el documento del Shadow recibido en get/accepted y dos mensajes delta que encienden y apagan el LED

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" }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)