Cómo Utilizar AWS IoT Device Shadows para gestionar el estado de nuestros dispositivos IOT

Claudia Márquez
Caleidos
Published in
11 min readOct 18, 2023

En nuestro artículo anterior, “Control Remoto de Luces IoT: Creación de una Interfaz Web con AWS Amplify, API Gateway, Lambda y ESP32,” exploramos cómo construir una solución serverless que permitía encender y apagar una luz LED a través de una interfaz web. Sin embargo, aunque logramos controlar la luz de manera efectiva, todavía no teníamos un mecanismo para conocer su estado en tiempo real.

En este artículo, exploraremos qué son los AWS IoT Device Shadows y cómo utilizarlos para mantener un seguimiento en tiempo real del estado de la luz y cómo sincronizar esta información con nuestra interfaz web. Al final de esta guía, habrás mejorado tu solución de control de luces IoT con la capacidad de conocer y mostrar el estado actual de la luz en tu aplicación web.

¿Qué son los AWS IoT Device Shadows?

Los “Device Shadows” son representaciones virtuales y persistentes de dispositivos IoT. Cada “Shadow” (sombra) permite almacenar y recuperar el estado actual del dispositivo en formato JSON a través del protocolo MQTT o HTTP. Para lograr que nuestra web obtenga los estados de manera automática, vamos a utilizar MQQT sobre Websockets.

Por defecto, todos los dispositivos tienen un “Classic Shadow” que se puede acceder por el tópico $aws/things/nombre/shadow. En esta publicación, estaremos utilizando este tipo de “Shadow”, pero si tienes más estados que deseas almacenar, puedes optar por usar los “Named Shadow”.

Arquitectura

El flujo es el siguiente:

  1. La web enviará un mensaje al tópico $aws/things/ESP32/shadow/update con el estado en “on” para actualizar la sombra del dispositivo.
  2. Este mensaje se enviará al ESP32 en el tópico $aws/things/ESP32/shadow/update/delta. El ESP32 encenderá o apagará la luz según el estado deseado.
  3. El ESP32 reportará el nuevo estado al tópico $aws/things/ESP32/shadow/update
  4. El cambio de la sombra será reportado hacia nuestra web en el tópico $aws/things/ESP32/shadow/accepted y podremos cambiar nuestra web según el estado recibido.

Actualizando el código del ESP32

Este es el nuevo código que deberás cargar en tu ESP32. El archivo “secrets.h” mantiene el mismo código que se presentó en nuestro primer artículo.

#include "secrets.h"
#include <WiFiClientSecure.h>
#include <MQTTClient.h>
#include <ArduinoJson.h>
#include "WiFi.h"

// The MQTT topics that this device should publish/subscribe
#define AWS_IOT_PUBLISH_TOPIC "$aws/things/ESP32/shadow/update"
#define AWS_IOT_SUBSCRIBE_TOPIC "$aws/things/ESP32/shadow/update/delta"

WiFiClientSecure net = WiFiClientSecure();
MQTTClient client = MQTTClient(256);

char sndPayloadOff[512];
char sndPayloadOn[512];

#define ledPin 32

void connectAWS()
{
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

Serial.println("Connecting to Wi-Fi");

while (WiFi.status() != WL_CONNECTED){
delay(500);
Serial.print(".");
}

// Configure WiFiClientSecure to use the AWS IoT device credentials
net.setCACert(AWS_CERT_CA);
net.setCertificate(AWS_CERT_CRT);
net.setPrivateKey(AWS_CERT_PRIVATE);

// Connect to the MQTT broker on the AWS endpoint we defined earlier
client.begin(AWS_IOT_ENDPOINT, 8883, net);

// Create a message handler
client.onMessage(messageHandler);

Serial.print("Connecting to AWS IOT");

while (!client.connect(THINGNAME)) {
Serial.print(".");
delay(100);
}

if(!client.connected()){
Serial.println("AWS IoT Timeout!");
return;
}

// Subscribe to a topic
client.subscribe(AWS_IOT_SUBSCRIBE_TOPIC);

Serial.println("AWS IoT Connected!");
}

void messageHandler(String &topic, String &payload) {
Serial.println("incoming: " + topic + " - " + payload);

StaticJsonDocument<200> doc;
deserializeJson(doc, payload);
const char *sensor = doc["state"]["status"];

Serial.println(sensor);

if(strcmp(sensor, "on") == 0)
{
digitalWrite(ledPin, HIGH);
Serial.println("Turn on led");
client.publish(AWS_IOT_PUBLISH_TOPIC, sndPayloadOn);
}
else
{
digitalWrite(ledPin, LOW);
Serial.println("Turn off led");
client.publish(AWS_IOT_PUBLISH_TOPIC, sndPayloadOff);
}
}

void setup() {
Serial.begin(115200);
connectAWS();
pinMode(ledPin, OUTPUT);
digitalWrite(ledPin, LOW);

sprintf(sndPayloadOn,"{\"state\": { \"reported\": { \"status\": \"on\" } }}");
sprintf(sndPayloadOff,"{\"state\": { \"reported\": { \"status\": \"off\" } }}");
Serial.println("Setting Lamp Status to Off");
client.publish(AWS_IOT_PUBLISH_TOPIC, sndPayloadOff);

}

void loop() {
client.loop();
}

Hemos actualizado los temas MQTT a los que este dispositivo publicará y a los que se suscribirá para enviar y recibir cambios en el estado de la luz.

// The MQTT topics that this device should publish/subscribe
#define AWS_IOT_PUBLISH_TOPIC "$aws/things/ESP32/shadow/update"
#define AWS_IOT_SUBSCRIBE_TOPIC "$aws/things/ESP32/shadow/update/delta"

En la función setup, hemos agregado mensajes para reportar el estado de nuestro dispositivo. Por defecto, cuando el ESP32 se inicializa, el estado de la luz está apagado, por lo que informamos este estado a AWS IoT Core.

sprintf(sndPayloadOn,"{\"state\": { \"reported\": { \"status\": \"on\" } }}");
sprintf(sndPayloadOff,"{\"state\": { \"reported\": { \"status\": \"off\" } }}");
Serial.println("Setting Lamp Status to Off");
client.publish(AWS_IOT_PUBLISH_TOPIC, sndPayloadOff);

Además, cuando recibimos un mensaje que indica un cambio en el estado deseado, una vez que cambiamos el estado de la luz, reportamos este cambio al tema MQTT definido.

if(strcmp(sensor, "on") == 0)
{
digitalWrite(ledPin, HIGH);
Serial.println("Turn on led");
client.publish(AWS_IOT_PUBLISH_TOPIC, sndPayloadOn);
}
else
{
digitalWrite(ledPin, LOW);
Serial.println("Turn off led");
client.publish(AWS_IOT_PUBLISH_TOPIC, sndPayloadOff);
}

Una vez que hayas cargado este código y lo ejecutes por primera vez, al acceder a tu “Thing” en la consola de AWS IoT Core, verás en la sección “Device Shadows” que se ha creado un “Classic Shadow”.

Creando un Cognito Identity Pool

Vamos a configurar un Cognito Identity Pool que permitirá que nuestra aplicación web tenga permisos para suscribirse y publicar mensajes en AWS IoT Core. En este ejemplo, lo configuraremos de manera pública para simplificar el proceso, pero en un entorno de producción, generalmente se vincularía a usuarios específicos para controlar el acceso.

Ve a la consola de Amazon Cognito y haz clic en “Crear Identity Pool.”

Selecciona la opción “Guest access” y luego haz clic en “Next.”

En la sección “Configure permissions”, haz clic en “Create a new IAM role”, ingresa un nombre como “cognito-iot” y presiona “Next.”

En la pantalla “Configure properties”, ingresa un nombre descriptivo y luego haz clic en “Next.”

En la pantalla final, haz clic en “Create identity pool”

Después de crear el Identity Pool, verás una lista de Identity Pools en la consola de Amazon Cognito. Debes copiar el “Identity pool ID” ya que lo utilizaremos en la configuración de nuestra aplicación web.

Para conceder permisos al rol “cognito-iot,” ve al servicio IAM (Identity and Access Management) y selecciona el rol “cognito-iot” que se creó durante la configuración del Identity Pool.

Agrega una “inline policy” al rol para permitir la suscripción y publicación en los temas de AWS IoT Core. Esta política debe incluir los permisos necesarios para interactuar con los tópicos MQTT.

Esta es la política que debes agregar:


{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"iot:Subscribe"
],
"Resource": [
"arn:aws:iot:<tu region>:<tu-cuenta>:topicfilter/*"
]
},
{
"Effect": "Allow",
"Action": [
"iot:Connect"
],
"Resource": [
"arn:aws:iot:<tu region>:<tu-cuenta>:client/*"
]
},
{
"Effect": "Allow",
"Action": [
"iot:Publish",
"iot:Receive"
],
"Resource": [
"arn:aws:iot:<tu region>:<tu-cuenta>:topic/*"
]
},
{
"Action": [
"iot:GetThingShadow"
],
"Effect": "Allow",
"Resource": [
"arn:aws:iot:<tu region>:<tu-cuenta>:thing/*"
]
}
]
}

Actualizando el código de nuestra web

Vamos a realizar una actualización en el código de nuestra aplicación web para habilitar la obtención y actualización del estado utilizando MQTT a través de Websockets. Asegúrate de tener Node.js instalado para seguir estos pasos.

Clona o descarga el código desde este repositorio de GitHub.

Abre el archivo “Settings.js” y actualiza la información con tu región, el endpoint de IoT de tu cuenta y el ID del Identity Pool que creamos en la sección anterior.

Para generar el código, ejecuta estos dos comandos en la carpeta del proyecto:

npm install
npm run build

Esto creará una carpeta llamada “dist” que contiene el código JavaScript compilado.

Puedes abrir el archivo “index.html” en tu navegador para interactuar con la aplicación web actualizada.

Alternativamente, también tienes la opción de actualizar la aplicación en AWS Amplify. Para hacerlo, crea un archivo zip que contenga el archivo “index.html” y la carpeta “dist”. Luego, ve a la consola de Amplify, selecciona la aplicación y carga el archivo zip con el código para actualizar la aplicación.

Siguiendo estos pasos, habrás realizado la actualización necesaria en la aplicación web para obtener y actualizar el estado de tus dispositivos IoT a través de MQTT sobre Websockets.

Entendiendo el código fuente

Revisemos el código de Javascript que estamos utilizando para controlar el dispositivo de IoT y cambiar su estado utilizando AWS IoT y Cognito Identity.

Importación de Módulos: Se importan varios módulos de AWS, incluyendo Cognito Identity y IoT Data Plane, así como el AWS IoT Device SDK v2. También se importa un módulo personalizado llamado Settings que contiene la configuración del proyecto.

// Importa los módulos AWS e IoT necesarios
import {
CognitoIdentityClient,
GetCredentialsForIdentityCommand,
GetIdCommand,
} from '@aws-sdk/client-cognito-identity'
import {
IoTDataPlaneClient,
GetThingShadowCommand,
} from '@aws-sdk/client-iot-data-plane' // ES Modules import

import { mqtt, iot } from 'aws-iot-device-sdk-v2'

const Settings = require('./settings') // Importa la configuración personalizada

Configuración Inicial: Se crea la variable que almacena el estado de la luz (inicialmente se establece en “on”). Se obtienen referencias a elementos HTML con los IDs “lightbulbIcon,” “loadingIcon” y “content” para controlar el icono, el indicador de carga y el contenido de la página.


// Variable para control de luz
let light = 'on'

// Get the icon element by its id
const icon = document.getElementById('lightbulbIcon')
const loadingIcon = document.getElementById('loadingIcon')
const content = document.getElementById('content')
const button = document.getElementById('toggleButton')

Configuración de Cliente Cognito Identity: Se crea un cliente de Cognito Identity especificando la región. Se utiliza un comando para obtener el ID de identidad del Cognito Identity Pool.


// Configura el cliente de Cognito Identity
const client = new CognitoIdentityClient({ region: Settings.AWS_REGION })

// Obtiene el ID de identidad del Cognito Identity Pool
const getIdCommand = new GetIdCommand({
IdentityPoolId: Settings.AWS_COGNITO_IDENTITY_POOL_ID, // Reemplaza con el ID de tu Identity Pool
})

const response = await client.send(getIdCommand)
const identityId = response.IdentityId

Obtención de Credenciales Temporales: Se utiliza un comando para obtener credenciales temporales asociadas al ID de identidad.


// Obtiene las credenciales temporales para la identidad
const getCredentialsCommand = new GetCredentialsForIdentityCommand({
IdentityId: identityId,
})

const credentialsResponse = await client.send(getCredentialsCommand)

Obtención del Estado Actual del Device Shadow: Se crea un cliente IoT Data Plane con las credenciales obtenidas. Se utiliza un comando para obtener el estado actual del Device Shadow y se cambia el estado de la luz según la respuesta.


// Obtiene el estado actual del Shadow
const iotClient = new IoTDataPlaneClient({
region: Settings.AWS_REGION,
credentials: {
accessKeyId: credentialsResponse.Credentials.AccessKeyId,
secretAccessKey: credentialsResponse.Credentials.SecretKey,
sessionToken: credentialsResponse.Credentials.SessionToken,
},
})

const input = {
thingName: Settings.AWS_IOT_THING,
}

// Actualizar el estado de la luz en la carga
const command = new GetThingShadowCommand(input)
const iotResponse = await iotClient
.send(command)
.then((response) => {
const payload = JSON.parse(Buffer.from(response.payload))

light = payload.state.reported.status === 'on' ? 'off' : 'on'
changeIcon(payload.state.reported.status)
})
.catch((error) => {
console.error('Error getting device shadow:', error)
})

Función connect_websocket: Esta función establece una conexión WebSocket para interactuar con AWS IoT. Configura la conexión y maneja eventos como la conexión, interrupción, reinicio y desconexión.


// Función para establecer una conexión WebSocket con IoT
async function connect_websocket(credentials) {
return new Promise((resolve, reject) => {
let config =
iot.AwsIotMqttConnectionConfigBuilder.new_builder_for_websocket()
.with_clean_session(true)
.with_client_id(`pub_sub_sample(${new Date()})`)
.with_endpoint(Settings.AWS_IOT_ENDPOINT)
.with_credentials(
Settings.AWS_REGION,
credentials.AccessKeyId,
credentials.SecretKey,
credentials.SessionToken
)
.with_use_websockets()
.with_keep_alive_seconds(30)
.build()

console.log('Connecting websocket...')
const client = new mqtt.MqttClient()

const connection = client.new_connection(config)
connection.on('connect', (session_present) => {
resolve(connection)
})
connection.on('interrupt', (error) => {
console.log(`Connection interrupted: error=${error}`)
})
connection.on('resume', (return_code, session_present) => {
console.log(
`Resumed: rc: ${return_code} existing session: ${session_present}`
)
})
connection.on('disconnect', () => {
console.log('Disconnected')
})
connection.on('error', (error) => {
reject(error)
})
connection.connect()
})
}

// Inicializa la conexión WebSocket con las credenciales de Cognito obtenidas
const connectionPromise = connect_websocket(credentialsResponse.Credentials)

Suscripción a un Tema MQTT: Se suscribe al tema MQTT deseado para recibir actualizaciones sobre el estado de la luz. Cuando se recibe un mensaje en el tema, se llama a la función changeIcon para actualizar el icono y el texto del botón.


connectionPromise.then((connection) => {
loadingIcon.style.display = 'none'
content.style.display = 'block'
connection.subscribe(
Settings.AWS_IOT_PUBLISH_TOPIC + '/accepted',
mqtt.QoS.AtLeastOnce,
(topic, payload, dup, qos, retain) => {
const status = JSON.parse(Buffer.from(payload))
if (status?.state?.reported) {
light = status?.state?.reported.status === 'on' ? 'off' : 'on'
changeIcon(status.state.reported.status)
button.disabled = false
}
}
)
})

Función PublishMessage: Esta función se utiliza para publicar un mensaje en el tema MQTT que cambia el estado de la luz. Se basa en la variable light para determinar el estado deseado.


// Función asincrónica para publicar un mensaje en el topic
async function PublishMessage() {
const msg = {
state: {
desired: {
status: light,
},
},
}

// Utiliza la conexión para enviar el mensaje al Device Shadow
connectionPromise.then((connection) => {
connection
.publish(Settings.AWS_IOT_PUBLISH_TOPIC, msg, mqtt.QoS.AtLeastOnce)
.catch((reason) => {
log(`Error publishing: ${reason}`)
})
})
}

Función changeIcon: Esta función se encarga de cambiar el color del icono y el texto del botón en función del estado informado. Si el estado informado es “on,” el icono se vuelve amarillo y el texto del botón se cambia a “Apagar la Luz.” Si el estado informado es “off,” el icono se vuelve gris y el texto del botón se establece en “Prender la Luz.”


// Comprobar el estado y cambiar el color del icono y el texto del botón
function changeIcon(reportedState) {
if (reportedState === 'on') {
// Si el estado informado es "on", cambia el color del icono a amarillo
icon.classList.remove('text-gray-500')
icon.classList.add('text-yellow-500')
toggleButton.textContent = 'Apagar la Luz'
} else {
// Si el estado informado es "off", establece el color predeterminado (gris)
icon.classList.remove('text-yellow-500')
icon.classList.add('text-gray-500')
toggleButton.textContent = 'Prender la Luz'
}
}

Manejo del Evento de Clic: Se agrega un event listener al botón con el ID “toggleButton” para detectar los clics en el botón. Cuando se hace clic en el botón, se llama a la función PublishMessage para cambiar el estado de la luz.


// Escucha el evento de clic en el botón con ID 'toggleButton' y llama a la función PublishMessage
button.addEventListener('click', function () {
PublishMessage()
button.disabled = true
})

Veámoslo en acción

Cuando accedemos a la página web, tenemos la opción de pulsar el botón “Prender la luz”. Al hacerlo, la luz se encenderá, y al mismo tiempo, la interfaz web actualizará su estado para mostrar el icono de una bombilla encendida. Si decidimos apagar la luz, simplemente pulsamos el botón correspondiente, y tanto la luz LED como la interfaz web responderán apagándose. Esta sencilla interacción demuestra la efectividad de nuestro sistema en tiempo real para controlar y reflejar el estado de la luz de manera sincronizada.

Conclusión

En este artículo, hemos avanzado en la gestión de dispositivos IoT al explorar AWS IoT Device Shadows, lo que nos ha permitido controlar y rastrear en tiempo real el estado de nuestros dispositivos. Hemos actualizado el código del ESP32 y configurado un Cognito Identity Pool, lo que nos da la capacidad de suscribirnos y publicar mensajes en AWS IoT Core a través de MQTT sobre websockets. Este enfoque nos permite crear aplicaciones web más reactivas que reflejan en tiempo real el estado de nuestros dispositivos IoT, lo que es esencial para soluciones IoT avanzadas. La combinación de MQTT sobre Websockets y AWS Cognito proporciona una base sólida para el desarrollo de aplicaciones IoT inteligentes y seguras.

--

--