DEV Community

Cover image for API serverless para controlar un ESP32 con AWS Lambda
Steven Carvajal
Steven Carvajal

Posted on Originally published at iot.gripe

API serverless para controlar un ESP32 con AWS Lambda

Publicado originalmente en iot.gripe, como parte 8 de la serie "Domótica con ESP32 y AWS desde cero".

La web app puede hablar con AWS IoT Core directamente (parte 10), pero hay clientes que no pueden: un Atajo de Siri, un script en otro servidor, un botón de otra plataforma. Solo saben hacer una petición HTTP. Para ellos, lo más simple es una API HTTPS que por dentro cambie el Device Shadow.

En esta parte construimos esa API con una función Lambda, vemos las opciones para exponerla y protegerla, y la probamos con curl. En la parte 9 la llamamos desde Siri.

La idea

Cliente HTTP  --POST /orden-->  Lambda  --UpdateThingShadow-->  AWS IoT Core  --delta-->  ESP32
Enter fullscreen mode Exit fullscreen mode

La Lambda hace tres cosas: comprueba quién llama, valida la orden y actualiza el estado deseado del dispositivo. El ESP32 no cambia nada: recibe el delta como siempre (parte 5).

Cómo exponer la Lambda

Lambda Function URL API Gateway (HTTP API)
Configuración Una opción en la función Rutas, integraciones y etapas
Costo extra Ninguno, solo la Lambda Por millón de peticiones (bajo)
Autenticación incluida IAM o ninguna IAM, JWT (Cognito) o authorizer propio
Límite de peticiones Concurrencia de la Lambda Throttling configurable por ruta
Dominio propio No directamente Sí

Function URL es ideal para una API pequeña con un solo endpoint. API Gateway conviene cuando quieres varias rutas, un dominio propio o validar tokens de Cognito sin escribir código.

Cómo autenticar

Opción Cómo funciona Para quién
IAM (SigV4) El cliente firma cada petición con credenciales de AWS Otros servicios de AWS, scripts con la CLI
JWT de Cognito El cliente envía el token del login; API Gateway lo verifica Tu propia app web o móvil
Token propio El cliente envía un secreto en una cabecera; la Lambda lo compara Clientes simples como un Atajo de Siri

Un Atajo de iOS no puede firmar con SigV4 ni renovar un token de Cognito con facilidad, así que para la voz lo práctico es un token propio. Reglas si eliges esta opción:

  • Genera el token al azar y con longitud suficiente (por ejemplo, 32 bytes en hexadecimal).
  • Guárdalo fuera del código: en AWS Secrets Manager o en Parameter Store como SecureString. Si guardas varios, guarda un hash, no el token.
  • Compáralo en tiempo constante para no filtrar información por el tiempo de respuesta.
  • Permite revocarlo sin desplegar código nuevo.

El código de la Lambda

Node.js 22 con el SDK v3 de AWS. Lee el token esperado de Parameter Store una vez por contenedor:

import { IoTDataPlaneClient, UpdateThingShadowCommand } from "@aws-sdk/client-iot-data-plane";
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
import { timingSafeEqual } from "node:crypto";

const iot = new IoTDataPlaneClient({});
const ssm = new SSMClient({});

const THING = process.env.THING_NAME;                  // el dispositivo que controla esta API
const SALIDAS = new Set(["sala", "cocina"]);           // lista blanca de claves del Shadow
let tokenEsperado;                                     // caché por contenedor

async function obtenerToken() {
  if (!tokenEsperado) {
    const r = await ssm.send(new GetParameterCommand({ Name: process.env.TOKEN_PARAM, WithDecryption: true }));
    tokenEsperado = Buffer.from(r.Parameter.Value);
  }
  return tokenEsperado;
}

const respuesta = (statusCode, cuerpo) => ({
  statusCode,
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(cuerpo),
});

export const handler = async (event) => {
  // 1. Quién llama
  const recibido = Buffer.from(event.headers?.["x-api-token"] ?? "");
  const esperado = await obtenerToken();
  if (recibido.length !== esperado.length || !timingSafeEqual(recibido, esperado)) {
    return respuesta(401, { error: "No autorizado" });
  }

  // 2. Qué pide
  let orden;
  try {
    orden = JSON.parse(event.body ?? "{}");
  } catch {
    return respuesta(400, { error: "JSON inválido" });
  }
  const { salida, valor } = orden;
  if (!SALIDAS.has(salida) || !["on", "off"].includes(valor)) {
    return respuesta(400, { error: "Orden no válida" });
  }

  // 3. Cambiar el estado deseado
  const payload = JSON.stringify({ state: { desired: { [salida]: valor } } });
  await iot.send(new UpdateThingShadowCommand({ thingName: THING, payload: Buffer.from(payload) }));

  return respuesta(200, { ok: true, salida, valor });
};
Enter fullscreen mode Exit fullscreen mode

Fíjate en la lista blanca de salidas: la Lambda nunca escribe en el Shadow una clave que no conoce, aunque el cliente la envíe.

Permisos mínimos de la Lambda

El rol de ejecución solo necesita dos permisos, sobre recursos concretos:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "iot:UpdateThingShadow",
      "Resource": "arn:aws:iot:REGION:CUENTA:thing/mi-esp32"
    },
    {
      "Effect": "Allow",
      "Action": "ssm:GetParameter",
      "Resource": "arn:aws:ssm:REGION:CUENTA:parameter/demo/api-token"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Más los permisos básicos de registros en CloudWatch (la política administrada AWSLambdaBasicExecutionRole). Si el parámetro está cifrado con una clave KMS propia, añade kms:Decrypt sobre esa clave.

Crear la Function URL

En la consola: Lambda → tu función → Configuration → Function URL → Create. Con Auth type: NONE la URL es pública y la autenticación la hace tu código; con AWS_IAM, AWS exige firma SigV4.

Con NONE, cualquiera que conozca la URL puede invocar la función. Tu código rechaza las peticiones sin token, pero cada intento cuenta como una invocación. Para limitar el abuso:

  • configura una concurrencia reservada baja en la función, como tope;
  • o pon la API detrás de API Gateway con throttling.

Probar con curl

# Sin token: 401
curl -i -X POST "$URL" -d '{"salida":"sala","valor":"on"}'

# Orden válida: 200
curl -i -X POST "$URL" -H "x-api-token: $TOKEN" \
  -H "Content-Type: application/json" -d '{"salida":"sala","valor":"on"}'

# Salida desconocida: 400
curl -i -X POST "$URL" -H "x-api-token: $TOKEN" -d '{"salida":"garaje","valor":"on"}'
Enter fullscreen mode Exit fullscreen mode

Terminal con tres llamadas curl a la API: sin token responde 401, con una orden válida responde 200 y con una salida desconocida responde 400

Respuestas esperadas. La URL y el token son de ejemplo.

Con la orden válida, el ESP32 recibe el delta y enciende la salida. Puedes comprobarlo en el Shadow desde la consola de AWS IoT.

Errores comunes

  • AccessDeniedException al actualizar el Shadow. Al rol le falta iot:UpdateThingShadow sobre ese Thing, o el ARN tiene otra región.
  • event.body llega en Base64. Pasa con contenido binario: revisa event.isBase64Encoded y decodifica.
  • CORS en el navegador. Si la API se llama desde una web, configura CORS en la Function URL o en API Gateway. Los Atajos y curl no lo necesitan.
  • Tiempo de arranque en frío. La primera invocación tarda algo más; para una orden de voz no se nota.

Preguntas frecuentes

¿Por qué no publicar directamente por MQTT desde la Lambda?

Puedes, con PublishCommand del mismo SDK. Actualizar el Shadow tiene la ventaja de que la orden queda guardada si el dispositivo está desconectado.

¿Una API key de API Gateway sirve como autenticación?

No es su propósito: las API keys de API Gateway son para planes de uso y límites, no para identificar usuarios. Úsalas junto con otra forma de autenticación.

¿Cuánto cuesta?

Con unas decenas de órdenes al día, la Lambda entra en la capa gratuita y la actualización del Shadow cuesta fracciones de centavo. Lo vemos en la parte 22.

Top comments (0)