controlar-gpio-y-pwm-del-esp8266-esp32-desde-interface-web-con-vue-y-websockets

Controlar GPIO y PWM del ESP8266/ESP32 desde interface Web con Vue y Websockets

Seguimos con las entradas dedicas al ESP8266 y ESP32 viendo cómo controlar nuestro dispositivo desde una aplicación en Vue servida a través de Websockets.

Recordamos que en esta entrada hicimos un interface web para llamadas Ajax. Por su parte, en la entrada anterior vimos cómo integrar comunicación por Websockets en Vue gracias a la librería Native Websocket.

Por supuesto, si tenéis alguna duda sobre estos conceptos, huelga decir que es un buen momento para ir y dar un repaso, antes de seguir adelante.

Nuestro interface web va a tener la siguiente forma, donde incluimos funciones para leer y escribir el estado de los GPIO, establecer señales PWM, y realizar acciones.

esp-gpio-interface

Por supuesto, algunas de estas funciones van a ser mocks. Nuestro objetivo es presentar cómo estructurar las partes. Vuestra parte es adaptarlas a las necesidades reales de nuestro proyecto.

¿Ya? ¿Estamos listos? Pues a por ello. Como siempre, tenemos el código divido en archivos para que sea más sencillo de entender y de reaprovechar el código.

Nuestro fichero ‘ino’ 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();
}

Como de costumbre, estamos usando la librería AsyncWebSocket. Asimismo, usamos AsyncWebServer para servir nuestra página web. Las funciones InitServer() y InitWebSockets() están definidas en nuestras funciones de ‘utils’ correspondientes. Son las mismas que en el resto de ejemplos, por lo que no vamos a volver a copiarlas.

Por otro lado, estamos usando la librería ReactiveArduino (ya que es mia, pues la uso) para detectar cambios en los GPIO y desencadenar acciones de callback. Y poco más en nuestro loop principal.

Llegamos a la parte interesante de este ejemplo en cuanto al backend, el fichero ‘websocket.hpp’. Aquí definimos la lógica asociada a la comunicación por Websockets, y específica de nuestro proyecto.

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;
  serializeJson(doc, response);

  ws.textAll(response);

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

Como vemos, en este ejemplo tenemos dos funciones. Una de ‘ProcessRequest’, que se encarga de recepcionar las llamadas por websocket del cliente al ESP8266/ESP32. Aquí recibimos un fichero Json y, en función del comando recibido llamamos a la función correspondiente de nuestro API.

Por otro lado, tenemos la única función en este ejemplo que envía datos desde el backend al cliente, que corresponde con actualizar al cliente el estado de un GPIO. Esta función es llamada como respuesta a los cambios de estado mediante ReactiveArduino, que vimos en el loop principal.

La última “pieza” que nos falta por ver en nuestro ejemplo es el fichero ‘API.hpp’, que contiene la definición de las funciones que son llamadas por la función ‘ProcessRequest’ del fichero de Websocket.

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);
}

Como vemos, en este ejemplo lo único que hacemos es mostrar los datos por puerto serie. Depende de vosotros adaptar estas funciones de demo a vuestro hardware y proyecto.

Ya hemos terminado con la parte de backend, ahora vamos a la parte de front. Aquí usaremos nuestro ya conocido VueJS para la aplicación del cliente, unido al los CSS de Material Design que vimos en esta entrada.

Así nuestro fichero ‘index.html’ queda de la siguiente forma.

<!doctype html>
<html lang="">

<head>
    <title>ESP8266 VueJS</title>
    <meta charset="utf-8">
    <meta http-equiv="x-ua-compatible" content="ie=edge">
    <title></title>
    <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 id="app">
        <div class="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"></div>
            <div class="mdl-grid">
                <div class="mdl-cell mdl-cell--3-col">
                    <h6>Input example:</h6>
                    <ul class="mdl-list mdl-shadow--2dp">
                        <gpio-input v-for="item in gpio_input_list" :gpio="item" :key="item.id" />
                    </ul>
                </div>

                <div class="mdl-cell mdl-cell--3-col">
                    <h6>Ouput example:</h6>
                    <ul class="mdl-list mdl-shadow--2dp">
                        <gpio-output v-for="item in gpio_output_list" v-bind:gpio="item" v-bind:key="item.id" />
                    </ul>

                </div>

                <div class="mdl-cell mdl-cell--3-col">
                    <h6>PWM example:</h6>

                    <ul class="mdl-list mdl-shadow--2dp">
                        <pwm v-for="item in pwm_list" :gpio="item" :key="item.id" />
                    </ul>
                </div>

                <div class="mdl-cell mdl-cell--3-col">
                    <h6>Actions example:</h6>
                    <ul class="mdl-list mdl-shadow--2dp">
                        <action v-for="item in action_list" :action="item" :key="item.id" />
                    </ul>
                </div>
            </div>
        </div>
    </div>
    </div>

    <!-- Desde CDN -->
    <!--<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.min.js"></script>-->
    <script type="text/javascript" src="vendor/vue.min.js"></script>
    <script type="text/javascript" src="vendor/material.js"></script>
    <script type="text/javascript" src="vendor/nativeWs.min.js"></script>

    <!-- Cargamos el fichero que contiene nuestra App en Vue.JS -->
    <script type="text/javascript" src="js/app.js"></script>
</body>
</html>

No hay mucho que decir, simplemente tenemos cuatro columnas correspondientes, respectivamente, a Input GPIO, Output GPIO, PWM, y acciones de callback genéricas. El propósito, lógicamente, es mostrar una variedad de componentes, labels, sliders, etc. Luego vosotros lo ajustáis a vuestras necesidades.

Como de costumbre la “magia” de nuestro front reside en el JavaScript, donde definimos nuestra aplicación VueJS. Aquí hay algo más de chicha.

Vue.use(VueNativeSock.default, 'ws://' + location.hostname + '/ws', { format: 'json' })

Vue.component('gpio-input', {
  props: ['gpio'],
  template: `
            <li class="mdl-list__item">
                <span class="mdl-list__item-primary-content">{{gpio.text}}</span>
                <span class="mdl-list__item-secondary-action">
                    <label class="label-big" :class="{ \'On-style \': gpio.status, \'Off-style \': !gpio.status}"
                    :id="'input-label-' + gpio.id">{{ gpio.status ? "ON" : "OFF" }}</label>
                </span>
            </li> `
})

Vue.component('gpio-output', {
  props: ['gpio'],
  template: ` <li class="mdl-list__item">
                <span class="mdl-list__item-primary-content">{{gpio.text}}</span>
                <span class="mdl-list__item-secondary-action">
                    <label class="mdl-switch mdl-js-switch mdl-js-ripple-effect">
                        <input type="checkbox" class="mdl-switch__input" 
                        :id="'output-switch-' + gpio.id"
                        v-model="gpio.status" @change="sendGPIO" />
                    </label>
                </span>
              </li>`,
  methods: {
    sendGPIO: function (evt) {
      console.log(this.gpio.text + ': ' + this.gpio.status);

      let data = {
        command: "setGPIO",
        id: this.gpio.text,
        status: this.gpio.status
      }

      let json = JSON.stringify(data);
      this.$socket.send(json);
    }
  }
})

Vue.component('pwm', {
  props: ['gpio'],
  template: ` <li class="mdl-list__item">
                <span class="mdl-list__item-primary-content">{{gpio.text}}</span>
                <span class="mdl-list__item-secondary-action">
                    <div class="mdl-grid">
                        <div class="mdl-cell mdl-cell--10-col">
                            <input :id="'slice-pwm-' + gpio.id" class="mdl-slider mdl-js-slider"
                                type="range" min="0" max="255"
                                v-model="gpio.value" @change="sendPWM" >
                        </div>
                        <div class="mdl-cell mdl-cell--2-col">
                            <input :id="'slice-pwm-text-' + gpio.id" style="width:35px;"
                              v-model="gpio.value" @change="sendPWM" ></input>
                        </div>
                    </div>
                </span>
              </li>`,
  methods: {
    sendPWM: function (evt) {
      console.log(this.gpio.text + ': ' + this.gpio.value);

      let data = {
        command: "setPWM",
        id: this.gpio.text,
        pwm: this.gpio.value
      }

      let json = JSON.stringify(data);
      this.$socket.send(json);
    }
  }
})

Vue.component('action', {
  props: ['action'],
  template: ` <li class="mdl-list__item">
                <span class="mdl-list__item-primary-content">{{action.text}}</span>
                <span class="mdl-list__item-secondary-action">
                <button class="mdl-button mdl-js-button mdl-button--primary mdl-js-ripple-effect" style="width: 160px;"
                @click="doAction">
                Do something
            </button>
                </span>
              </li>`,
  methods: {
    doAction: function (evt) {
      console.log(this.action.text + ': ' + this.action.id);
      let data = {
        command: "doAction",
        id: this.action.id,
      }

      let json = JSON.stringify(data);
      this.$socket.send(json);

      this.action.callback();
    }
  }
})

// Definicion de nuestra app de ejemplo
var app = new Vue({
  el: '#app',
  data: function () {
    return {
      gpio_input_list: [
        { id: 0, text: 'D0', status: 0 },
        { id: 1, text: 'D5', status: 0 },
        { id: 2, text: 'D6', status: 0 },
        { id: 3, text: 'D7', status: 0 },
      ],
      gpio_output_list: [
        { id: 0, text: 'D8', status: 1 },
        { id: 1, text: 'D9', status: 0 },
        { id: 2, text: 'D10', status: 0 },
      ],
      pwm_list: [
        { id: 0, text: 'PWM1', value: 128 },
        { id: 1, text: 'PWM2', value: 128 },
      ],
      action_list: [
        { id: 0, text: 'ACTION1', callback: () => console.log("action1") },
        { id: 1, text: 'ACTION2', callback: () => console.log("action2") },
      ]
    }
  },
  mounted() {
    this.$socket.onmessage = (dr) => {
      console.log(dr);
      let json = JSON.parse(dr.data);
      let gpio = this.$data.gpio_input_list.find(gpio => gpio.text == json.id);
      gpio.status = json.status;
    }
  }
})

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

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

Parece mucho, pero si lo analizamos veremos que no es tanta cosa. En primer lugar, tenemos la declaración de NativeWebsocket que ya vimos en la entrada anterior. Nada complicado aquí.

A continuación, hemos definido cuatro componentes de Vue, respectivamente para Input GPIO, Output GPIO, PWM y acciones de callback. Cada componente tiene su template para la visualización, y el código necesario para enviar los datos por Json a través de Websockets.

Por otro lado, la App de VueJS, donde tenemos como data las listas de los cuatro tipos de objetos que estamos representando.

Si lo subimos todo al ESP8266/ESP32 y accedemos a la página web servida, veremos nuestra aplicación en Vue. Si cambiamos el estado de uno de los pines veremos que el cambio se comunica al cliente.

esp-gpio-interface

De forma similar, si interactuamos con la página web, podemos comprobar en la consola de desarrollo del navegador que los datos se envían correctamente.

A la vez que son recibidos por el ESP8266/ESP32, lo que podemos comprobar en la consola de comandos.

esp32-gpio-interface-web-consola

Venga, soy consciente de que es mucho código para una demo. Pero llevábamos muchas entradas introduciendo los componentes de esta aplicación de forma gradual. Si tenéis alguna duda, mirad el código con calma (lo tenéis todo más abajo), revisad las entradas anteriores, o publicad un comentario.

En la próxima entrada mudaremos el código

Descarga el código

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

github-full

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

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