Cloud, DevOps e IoT en español

Parte 10 de 22 de la serie Domótica con ESP32 y AWS desde cero

Panel web con React y MQTT sobre AWS IoT Core

6 de octubre de 2026 · Steven Carvajal · Tutoriales

Código de este artículo en GitHub →

Un panel web que muestra cada luz con un interruptor, y que se actualiza al instante cuando la luz cambia, necesita una conexión en tiempo real. Con AWS IoT Core, el navegador puede conectarse directamente al broker por MQTT sobre WebSocket, sin ningún servidor en medio.

En esta parte conectamos una app React a AWS IoT Core, mostramos el estado del Device Shadow y vemos cómo hacer que la interfaz nunca muestre un estado falso.

El stack

Cómo se autentica el navegador

El navegador no tiene un certificado X.509 como el ESP32. Las opciones son:

OpciónCómo funciona
URL firmada con SigV4El navegador obtiene credenciales temporales de AWS (con Cognito Identity Pool) y firma la URL del WebSocket
URL firmada en el servidorUna Lambda verifica el login y devuelve la URL ya firmada
Custom authorizerAWS IoT llama a una Lambda tuya para validar un token propio

La primera es la más directa y la que usamos aquí. La segunda da más control (por ejemplo, permisos que cambian por usuario) a cambio de una Lambda más.

Paso 1: credenciales temporales con Cognito

Después del login con el User Pool (parte 11), la Identity Pool cambia el token del usuario por credenciales temporales de AWS:

import { fromCognitoIdentityPool } from "@aws-sdk/credential-providers";

const credenciales = fromCognitoIdentityPool({
  clientConfig: { region: REGION },
  identityPoolId: IDENTITY_POOL_ID,
  logins: { [`cognito-idp.${REGION}.amazonaws.com/${USER_POOL_ID}`]: idToken },
});

El rol autenticado de la Identity Pool necesita permisos IAM de IoT: iot:Connect, iot:Subscribe, iot:Receive e iot:Publish, limitados a los recursos que use la app.

La política de IoT que suele faltar

Con identidades autenticadas de Cognito, AWS IoT exige dos permisos: el del rol IAM y una política de IoT adjunta a la identidad de Cognito (AttachPolicy con el identity ID como target). Si falta la segunda, el WebSocket abre pero la conexión MQTT se cierra sin CONNACK, y no es obvio por qué.

aws iot attach-policy --policy-name panel-web-policy \
  --target "us-east-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"   # identity ID del usuario

Como hay que hacerlo por cada usuario, normalmente lo hace una Lambda la primera vez que el usuario inicia sesión. Esta es una de las razones por las que muchos proyectos prefieren firmar la URL en el servidor.

Paso 2: firmar la URL

La firma se hace contra el servicio iotdevicegateway. Un detalle propio de AWS IoT: el token de sesión no entra en la firma; se agrega a la URL después de firmar.

import { SignatureV4 } from "@smithy/signature-v4";
import { HttpRequest } from "@smithy/protocol-http";
import { formatUrl } from "@aws-sdk/util-format-url";
import { Sha256 } from "@aws-crypto/sha256-js";

async function urlFirmada() {
  const { accessKeyId, secretAccessKey, sessionToken } = await credenciales();

  const signer = new SignatureV4({
    service: "iotdevicegateway",
    region: REGION,
    credentials: { accessKeyId, secretAccessKey },  // sin sessionToken
    sha256: Sha256,
  });

  const firmada = await signer.presign(
    new HttpRequest({ method: "GET", protocol: "wss:", hostname: IOT_ENDPOINT, path: "/mqtt", headers: { host: IOT_ENDPOINT } }),
    { expiresIn: 3600 }
  );
  return `${formatUrl(firmada)}&X-Amz-Security-Token=${encodeURIComponent(sessionToken!)}`;
}

Si incluyes el token en la firma, el WebSocket suele cerrarse de inmediato con el código 1006.

Paso 3: conectar y pedir el estado

import mqtt from "mqtt";

const base = `$aws/things/${THING}/shadow`;
const cliente = mqtt.connect(await urlFirmada(), {
  clientId: `panel-${crypto.randomUUID()}`,
  protocolVersion: 4,     // MQTT 3.1.1
  reconnectPeriod: 5000,
  connectTimeout: 10000,
});

cliente.on("connect", () => {
  // Primero suscribirse; pedir el estado solo cuando AWS confirmó la suscripción
  cliente.subscribe([`${base}/get/accepted`, `${base}/update/accepted`], () => {
    cliente.publish(`${base}/get`, "");
  });
});

Esperar la confirmación de la suscripción (SUBACK) antes de publicar en get evita una carrera: si la respuesta llega antes de que la suscripción esté activa, se pierde y la interfaz arranca con un estado vacío.

El client ID tiene que estar permitido en la política (iot:Connect sobre client/panel-*, por ejemplo) y debe ser único: si dos pestañas usan el mismo, AWS desconecta la anterior.

Paso 4: mostrar solo lo confirmado

cliente.on("message", (_topic, buffer) => {
  let doc: { state?: { reported?: Record<string, string> } };
  try {
    doc = JSON.parse(buffer.toString());
  } catch {
    return;
  }
  const reportado = doc.state?.reported;  // nunca desired
  if (reportado) setEstado((prev) => ({ ...prev, ...reportado }));
});

function alternar(salida: string, encender: boolean) {
  setPendiente((prev) => ({ ...prev, [salida]: true }));
  cliente.publish(`${base}/update`, JSON.stringify({ state: { desired: { [salida]: encender ? "on" : "off" } } }));
}

La regla es la misma que en el dispositivo: el interruptor solo cambia con reported. Si lo movieras con desired, se vería encendido aunque el ESP32 estuviera desconectado.

Para que el usuario sepa que su toque se registró, un estado pendiente ayuda:

Si el dispositivo responde rápido, el estado pendiente apenas se ve. Si está desconectado, el usuario lo nota sin un mensaje de error. Para mostrar explícitamente "desconectado", está la parte 19.

Paso 5: desconectarse cuando nadie mira

Mantener el WebSocket abierto con el celular bloqueado gasta batería y datos. Con el evento visibilitychange puedes cerrar la conexión cuando la página lleva unos segundos oculta y abrir una nueva al volver:

let temporizador: ReturnType<typeof setTimeout> | null = null;

document.addEventListener("visibilitychange", () => {
  if (document.hidden) {
    temporizador = setTimeout(() => cliente.end(), 10_000);
  } else {
    if (temporizador) clearTimeout(temporizador);
    if (!cliente.connected) reconectar();  // URL nueva, suscribirse y pedir el estado
  }
});

Al reconectar, pide una URL nueva: las credenciales temporales y la firma vencen. Si el token de Cognito también venció, renueva la sesión antes.

Errores comunes

SíntomaCausa probable
WebSocket cerrado con 1006 al abrirFirma inválida: región, servicio o token de sesión dentro de la firma
WebSocket abre, MQTT se cierra sin CONNACKFalta la política de IoT en la identidad de Cognito, o el client ID no está permitido
Conecta pero no llegan mensajesFalta iot:Subscribe (sobre topicfilter/...) o iot:Receive (sobre topic/...)
Una pestaña desconecta a la otraMismo client ID en las dos
Funciona una hora y luego fallaVenció la URL o las credenciales: genera una nueva al reconectar

Preguntas frecuentes

¿Por qué no usar AWS Amplify?

Amplify resuelve la autenticación y la conexión con menos código, pero agrega peso y esconde lo que pasa por debajo. Con el SDK directo ves cada pieza, lo que ayuda mucho a depurar.

¿Cuánto dura la URL firmada?

Lo que indiques en expiresIn, como máximo lo que duren las credenciales temporales. Una hora es un valor habitual.

¿Cuántas conexiones MQTT abre la app?

Una por pestaña. AWS IoT Core cobra por minuto de conexión, a precios de fracciones de centavo; el motivo real para desconectar en segundo plano es la batería del celular.

Sigue leyendo