Crear un interface Web para controlar el ESP8266 o ESP32 por websockets


Volvemos con la ESP8266 y el ESP32. Después de muchas entradas donde hemos ido introduciéndonos progresivamente los conceptos, por fin nos toca ver cómo montar un interface web para el ESP8266.

Haremos referencia al ESP8266, pero el mismo código es compatible para el ESP32, ajustando el nombre de las librerías. Al final tenéis el código tanto para el ESP8266 como para el ESP32.

Para seguir esta entrada es muy recomendable que hayáis seguido las entradas anteriores, en especial las dos últimas sobre cómo realizar acciones / recibir información en formato JSON mediante AJAX y websockets, y la de cómo usar Material Design.

¿Sí? ¿Seguro? ¿Venís con los deberes hechos? Pues entonces no tendremos ningún problema en seguir esta entrada, que va a consistir en hacer un interface web para controlar el ESP8266.

En este interface vamos a visualizar el estado de las entradas digitales del ESP8266, en el ejemplo D0, D5, D6 y D7.

Además, podremos simularemos actuar los pines D8, D9 como salidas y sobre el D10 como salida PWM.

Más un "plus" donde podremos disparar acciones, es decir, funciones que habremos definido en el ESP8266 y en las que haríais … pues lo que quisierais hacer.

En este ejemplo, las lecturas de los estados de las entradas digitales van a ser "reales". Sin embargo las salidas y acciones las mantenemos "simuladas", es decir, mostraremos el puerto de serie. Esto lo hacemos por seguridad, para que podáis probar el ejemplo independientemente de a lo que tengáis conectado el ESP8266 y evitemos "chispar" algo.

Si estáis seguro, simplemente sustituir el código por el necesario para poner la salida digital o el PWM al valor oportuno.

¿Parece ambicioso? Bueno, es juntar las partes que hemos visto anteriormente, para ilustrar como podríamos montar un interface Web para controlar el ESP8266. Aunque nos queda bastante código por delante, así que ¡manos a la obra!

Nuestro programa principal queda de la siguiente forma.

#include <ESP8266WiFi.h>
#include <ESPAsyncWebServer.h>
#include <FS.h>
#include <ArduinoJson.h>

#include "config.h"  // Sustituir con datos de vuestra red
#include "API.hpp"
#include "WebSockets.hpp"
#include "Server.hpp"
#include "ESP8266_Utils.hpp"
#include "ESP8266_Utils_AWS.hpp"
#include "ReactiveArduinoLib.h"

auto obsD0 = Reactive::FromDigitalInput(D0);
auto obsD5 = Reactive::FromDigitalInput(D5);
auto obsD6 = Reactive::FromDigitalInput(D6);
auto obsD7 = Reactive::FromDigitalInput(D7);


void setup(void)
{
	Serial.begin(115200);
	SPIFFS.begin();

	ConnectWiFi_STA();

	InitServer();
	InitWebSockets();

	obsD0.Distinct().Do([](int i) { updateGPIO("D0", i); });
	obsD5.Distinct().Do([](int i) { updateGPIO("D5", i); });
	obsD6.Distinct().Do([](int i) { updateGPIO("D6", i); });
	obsD7.Distinct().Do([](int i) { updateGPIO("D7", i); });
}

void loop(void)
{
	obsD0.Next();
	obsD5.Next();
	obsD6.Next();
	obsD7.Next();
}

Hemos usado la librería "ReactiveArduino" para responder a los cambios en los pins, iniciado el servidor y los websockets, e incorporado los 'includes' oportunos.

Por su parte, nuestra definición del servidor en el fichero 'server.h', muy sencilla ya que vamos a ir por websocket y no necesitamos definir endpoints.

AsyncWebServer server(80);

void InitServer()
{
	server.serveStatic("/", SPIFFS, "/").setDefaultFile("index.html");

	server.onNotFound([](AsyncWebServerRequest *request) {
		request->send(400, "text/plain", "Not found");
	});

	server.begin();
  Serial.println("HTTP server started");
}

Por otro lado, nuestro fichero 'API.h' queda de la siguiente forma,

void setGPIO(String id, bool state)
{
   Serial.print("Set GPIO ");
   Serial.print(id);
   Serial.print(": ");
   Serial.println(state);
}

void setPWM(String id, int pwm)
{
   Serial.print("Set PWM ");
   Serial.print(id);
   Serial.print(": ");
   Serial.println(pwm);
}

void doAction(String actionId)
{
   Serial.print("Doing action: ");
   Serial.println(actionId);
}

Donde únicamente tenemos tres acciones de callback, una para establecer la acción de fijar una salida digital, otra para fijar un PWM, y otra para realizar acciones genéricas. Estas funciones son las que, por seguridad, dejamos como simplemente mostrar por puerto serie. Cambiarlas para vuestro proyecto según necesitéis.

En cuanto a nuestro fichero 'WebSockets.hpp' tenemos

AsyncWebSocket ws("/ws");

void ProcessRequest(AsyncWebSocketClient *client, String request)
{
  StaticJsonDocument<200> doc;
  DeserializationError error = deserializeJson(doc, request);
  if (error) { return; }
  
  String command = doc["command"];
  if(command == "setGPIO") 
    setGPIO(doc["id"], (bool)doc["status"]);
  else if(command == "setPWM")
    setPWM(doc["id"], (int)doc["pwm"]);
  else if(command == "doAction")
    doAction(doc["id"]);
}

void updateGPIO(String input, bool value)
{
  String response;
  StaticJsonDocument<300> doc;
  doc["command"] = "updateGPIO";
  doc["id"] = input;
  doc["status"] = value ? String("ON") : String("OFF");
  serializeJson(doc, response);

  ws.textAll(response);

  Serial.print(input);
  Serial.println(value ? String(" ON") : String(" OFF"));
}

Que, como vemos, se encarga de recibir las peticiones por websocket, y realizar la función oportuna en función del tipo de acción recibida.

Por otro lado, también contiene la función 'updateGPIO', que es llamada ante un cambio de estado un pin, y se encarga de hacer un broadcast a todos los clientes, informándoles del nuevo estado.

Hasta aquí, por parte del backend. En la parte del cliente, nuestra página web es un poco más larga que la anterior (el precio a pagar por ser menos fea),

<!DOCTYPE html>
<html class="no-js" lang="">

<head>
    <title>ESP8266 Async GPIO</title>
    <meta charset="utf-8">
    <meta http-equiv="x-ua-compatible" content="ie=edge">

    <meta name="description" content="">
    <meta name="viewport" content="width=device-width, initial-scale=1">
</head>

<body>
    <link rel="stylesheet" href="vendor/google-fonts.css">
    <link rel="stylesheet" href="vendor/material.css">
    <link rel="stylesheet" href="css/main.css">

    <div class="mdl-card mdl-shadow--2dp">
        <div class="mdl-card__title mdl-card--expand">
            <h2 class="mdl-card__title-text">Async GPIO</h2>
        </div>

        <div class="mdl-card__supporting-text">
            <h6>Input example:</h6>
            <ul class="mdl-list mdl-shadow--2dp">
                <li class="mdl-list__item">
                    <span class="mdl-list__item-primary-content">D0</span>
                    <span class="mdl-list__item-secondary-action">
                        <label id="input-label-D0" class="label-big Off-style">OFF</label>
                    </span>
                </li>
                <li class="mdl-list__item">
                    <span class="mdl-list__item-primary-content">D5</span>
                    <span class="mdl-list__item-secondary-action">
                        <label id="input-label-D5" class="label-big Off-style">OFF</label>
                    </span>
                </li>
                <li class="mdl-list__item">
                    <span class="mdl-list__item-primary-content">D6</span>
                    <span class="mdl-list__item-secondary-action">
                        <label id="input-label-D6" class="label-big Off-style">OFF</label>
                    </span>
                </li>
                <li class="mdl-list__item">
                    <span class="mdl-list__item-primary-content">D7</span>
                    <span class="mdl-list__item-secondary-action">
                        <label id="input-label-D7" class="label-big Off-style">OFF</label>
                    </span>
                </li>
            </ul>
        </div>

        <div class="mdl-card__supporting-text">
            <h6>Ouput example:</h6>
            <ul class="mdl-list mdl-shadow--2dp">
                <li class="mdl-list__item">
                    <span class="mdl-list__item-primary-content">D8</span>
                    <span class="mdl-list__item-secondary-action">
                        <label class="mdl-switch mdl-js-switch mdl-js-ripple-effect">
                            <input id="output-switch-D8" data-id="D8" type="checkbox" class="mdl-switch__input" checked 
                            onchange="sendGPIO(this.dataset.id, this.checked)"/>
                        </label>
                    </span>
                </li>
                <li class="mdl-list__item">
                    <span class="mdl-list__item-primary-content">D9</span>
                    <span class="mdl-list__item-secondary-action">
                        <label class="mdl-switch mdl-js-switch mdl-js-ripple-effect">
                            <input id="output-switch-D9" data-id="D9" type="checkbox" class="mdl-switch__input" checked 
                            onchange="sendGPIO(this.dataset.id, this.checked)"/>
                        </label>
                    </span>
                </li>
                <li class="mdl-list__item">
                    <span class="mdl-list__item-primary-content">D10</span>
                    <span class="mdl-list__item-secondary-action">
                        <label class="mdl-switch mdl-js-switch mdl-js-ripple-effect">
                            <input id="output-switch-D10" data-id="D10" type="checkbox" class="mdl-switch__input" checked 
                            onchange="sendGPIO(this.dataset.id, this.checked)"/>
                        </label>
                    </span>
                </li>
                <li class="mdl-list__item">
                    <span class="mdl-list__item-primary-content">D10</span>
                    <span class="mdl-list__item-secondary-action">
                        <div class="mdl-grid">
                            <div class="mdl-cell mdl-cell--10-col">
                                <input id="slider-pwm-1" data-id="1" class="mdl-slider mdl-js-slider"
                                    type="range" min="0" max="255" value="25"
                                    onchange="sendPWM(this.dataset.id, this.value);" >
                            </div>
                            <div class="mdl-cell mdl-cell--2-col">
                                <input id="slider-text-pwm-1" data-id="1" style="width:35px;"
                                onchange="sendPWM(this.dataset.id, this.value);" value="25"></input>
                            </div>
                        </div>
                    </span>
                </li>
            </ul>
        </div>

        <div class="mdl-card__supporting-text ">
            <div>
                <h6>Example</h6>
                <button class="mdl-button mdl-js-button mdl-button--primary mdl-js-ripple-effect" style="width: 160px;"
                    data-id="action-1" onclick="sendAction(this.dataset.id)">
                    Do something
                </button>
            </div>
        </div>
    </div>
</body>

<script type="text/javascript" src="./js/main.js"></script>
<script type="text/javascript" src="vendor/material.js"></script>
</html>

Nuestro fichero Javascript 'main.js' también es un poco más largo, aunque sigue siendo sencillo. Básicamente, hemos ampliado las funciones con las necesidades de nuestro nuevo UI.

var connection = new WebSocket('ws://' + location.hostname + '/ws', ['arduino']);

connection.onopen = function () {
	connection.send('Received from Client');
	console.log('Connected');
};

connection.onerror = function (error) {
  console.log('WebSocket Error', error);
};

connection.onmessage = function (e) {
    console.log('Received from server: ', e.data);
    processReceived(e.data);
};

connection.onclose = function () {
  console.log('WebSocket connection closed');
};

function processReceived(data)
{
	json = JSON.parse(data)
	if(json.command == 'updateGPIO')
	{
		updateGPIO(json.id, json.status);
	}
}

function sendGPIO(id, status)
{
	let data = {
		command : "setGPIO",
		id: id,
		status: status
	}

  let json = JSON.stringify(data);
  connection.send(json);
}

function sendPWM(id, pwm)
{
	updateSliderText(id, pwm);

	let data = {
		command : "setPWM",
		id: id,
		pwm: pwm
	}

  let json = JSON.stringify(data);
  connection.send(json);
}

function sendAction(id)
{
	let data = {
		command : "doAction",
		id: id,
	}

  let json = JSON.stringify(data);
  connection.send(json);
}

function updateGPIO(id, status)
{
    document.getElementById('input-label-' + id).textContent = status;

	if(status == 'ON')
	{
        document.getElementById('input-label-' + id).classList.add('On-style');
		document.getElementById('input-label-' + id).classList.remove('Off-style');
	}
	else
    {
		document.getElementById('input-label-' + id).classList.add('Off-style');
		document.getElementById('input-label-' + id).classList.remove('On-style');
	}
}

function updateSliderText(id, value) {
	document.getElementById('slider-pwm-' + id).value = value; 
	document.getElementById('slider-text-pwm-'+ id).value = value; 
  }

Finalmente, tenemos un mini fichero CSS donde únicamente hemos definido los estilos para los labels que muestran ON/OFF en la sección de entradas.

.label-big {
	border-radius: 3px;
    font-size: 16px;
    font-weight: 600;
    line-height: 2;
    padding: 0 8px;
    transition: opacity .2s linear;
    color: #fff
}

.On-style {
	background-color: #006b75;
}

.Off-style {
	background-color: #b60205;
}

Ahora, subimos todo este código al ESP8266 y comprobamos que el interface web se actualiza inmediatamente al cambiar el estado y, al ejecutar acciones sobre los botones, el puerto serie muestra correctamente las acciones recibidas.

Esto ya es un "buen ejemplo" de lo que podemos hacer con un ESP8266. ¡Pero siempre se puede mejorar! Hasta ahora hemos usado un vanilla Javascript (Javascript sin librerías) para la lógica del cliente. Lo cual, siempre es algo deseable.

Pero la tendencia de la programación es los lenguajes declarativos. ¿Es posible en nuestro pequeño ESP8266? ¡Pues sí! Así que, en la próxima entrada, nos adentraremos en el uso de VueJS en el ESP8266 y ESP32. ¡Hasta la próxima!

Descarga el código

Todo el código de esta entrada está disponible para su descarga en Github.

Versión para el ESP8266: https://github.com/luisllamasbinaburo/ESP8266-Examples

Versión para el ESP32: https://github.com/luisllamasbinaburo/ESP32-Examples

Si te ha gustado esta entrada y quieres leer más sobre ESP8266 o el ESP32 puedes consultar la sección tutoriales de ESP8266/32
5 1 vote
Article Rating
Previous Obtener orientación y altitud (AHRS) con IMU 9DOF y RTIMULib Arduino
Next Qué son las vías en una PCB y cuál es su función
6 Comments
oldest
newest
Inline Feedbacks
View all comments
erik
1 year ago

Hola,
Primero de todo geniales artículos muy fáciles de entender y muy bien explicados!Mi pregunta es saber si esta serie de artículos que dices que son para el esp8266 son también compatibles con el ESP32?

Rafael Reina
1 year ago

Hola Luis. Funciona perfecto contra el cliente Web.
Pero necesitaria conectarme desde otro ESP8266 como cliente para preguntar la temperatura del servidor y conectar la caldera, como ejemplo para entendernos.
He encontrado un articulo en ***link removed*** pero no logro hacerlo funcionar. Falla al hacer el Handshake
Creo que un articulo sobre como implementar un cliente en una ESP8266 seria muy interesante.
Y muchas gracias por tu magnifico trabajo

Alex
1 year ago

buenisimos manuales y excelente explicaciones,te felicito, tengo una duda en este respecto a las salidas, como actualizo los cambios en todos los demas clientes? gracias de antemano

Jordiet
1 year ago

Hola Luis, muchas gracias por compartir tus conocimientos en esta fantástica serie de tutoriales.
Estoy desarrollando un sistema de control de clima usando como base tus ejemplos y quisiera saber, en tu opinión, ¿cuál sería el método correcto para prevenir accesos indeseados en el sistema?
He estado leyendo acerca del uso de certificados SSL, pero me parece un poco exagerado, ya que al parecer esto ralentizaría el flujo de información y creo que un sistema de autenticación inicial via softAP que grabase unas credenciales podría ser suficiente.

Quizá encriptar los datos sensibles como passwords, no sé, la verdad.

Muchas gracias.