interface-web-para-el-esp8266-o-esp32-con-vue-y-mqtt

Interface web para ESP32 con Vue y MQTT

  • 8 min

Una interfaz web con Vue y MQTT es una forma cómoda de control para un ESP32 o ESP8266 usando un broker como intermediario.

En este ejemplo vamos a montar un panel de control web que se comunica con una placa ESP mediante MQTT. La página se encarga de la interfaz, el dispositivo publica y consume mensajes, y el broker hace de punto común entre ambos.

La ventaja de este enfoque es que desacoplamos la web del dispositivo. El navegador no necesita conectarse directamente al ESP, y podemos integrar otros clientes, automatizaciones o servicios que también hablen MQTT.

Vamos a usar el mismo tipo de ejercicio de control web que hemos visto con otras comunicaciones: leer GPIO, establecer salidas PWM y lanzar acciones. Ahora toca hacerlo a través de MQTT.

Así que sin más marear la perdiz, ¡vamos al lío!

En este caso, nuestro fichero principal es muy similar al que usaríamos en el caso de WebSockets. Cambian los #includes y la parte de comunicación, que ahora delegamos en MQTT.

#include <WiFi.h>
#include <SPIFFS.h>
#include <ESPAsyncWebServer.h>
#include <AsyncMqttClient.h>
#include <ArduinoJson.h>

#include "config.h" // Sustituir con datos de vuestra red
#include "API.hpp"
#include "MQTT.hpp"
#include "Server.hpp"
#include "ESP32_Utils.hpp"
#include "ESP32_Utils_MQTT_Async.hpp"
#include "ReactiveArduinoLib.h"

auto obsD0 = Reactive::FromDigitalInput(0);
auto obsD5 = Reactive::FromDigitalInput(5);
auto obsD6 = Reactive::FromDigitalInput(6);
auto obsD7 = Reactive::FromDigitalInput(7);

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

  delay(500);

  WiFi.onEvent(WiFiEvent);
  InitMqtt();

  ConnectWiFi_STA();
  InitServer();

  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()
{
  obsD0.Next();
  obsD5.Next();
  obsD6.Next();
  obsD7.Next();
}
Copied!

En el fichero API.hpp tenemos las funciones que emulan las acciones a realizar al pulsar al interface.

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

Por tro lado, el fichero MQTT.hpp contiene toda la lógica asociada a la comunicación del proyecto.


#pragma once

const IPAddress MQTT_HOST(192, 168, 1, 150);
const int MQTT_PORT = 1883;

AsyncMqttClient mqttClient;

String GetPayloadContent(char* data, size_t len)
{
  String content = "";
  for(size_t i = 0; i < len; i++)
  {
    content.concat(data[i]);
  }
  return content;
}

void SuscribeMqtt()
{
  uint16_t packetIdSub = mqttClient.subscribe("device/0/#", 0);
  Serial.print("Subscribing at QoS 2, packetId: ");
  Serial.println(packetIdSub);
}

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

  mqttClient.publish("device/0/GPIO", 0, true, (char*)payload.c_str());

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

void OnMqttReceived(char* topic, char* payload, AsyncMqttClientMessageProperties properties, size_t len, size_t index, size_t total)
{
  Serial.print("Received on ");
  Serial.print(topic);
  Serial.print(": ");

  String content = GetPayloadContent(payload, len);

  StaticJsonDocument<200> doc;
  DeserializationError error = deserializeJson(doc, content);
  if(error) return;

  int id = doc["id"];
  bool ledStatus = doc["status"];

  String command = doc["command"];
  if(content.indexOf("GPIO") > 0 && command == "setGPIO")
    setGPIO(doc["id"], (bool)doc["status"]);
  else if(content.indexOf("PWM") > 0 && command == "setPWM")
    setPWM(doc["id"], (int)doc["pwm"]);
  else if(content.indexOf("Action") > 0 && command == "doAction")
    doAction(doc["id"]);
  else
  {
    //otras acciones
  }
}
Copied!

Por el lado del cliente

La página ‘index.html’ contiene la definición de nuestro interface.

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

<head>
  <title>ESP32 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="css/main.css">
  <link href="./vendor/google-fonts.css" rel="stylesheet">
  <link href="./vendor/vuetify.min.css" rel="stylesheet">

  <v-app id="app">
    <v-toolbar app>Mqtt ESP32</v-toolbar>
    <v-content>
      <v-container fluid grid-list-md text-xs-center>

        <v-layout row wrap>
          <v-flex xs3>
            <v-card>
              <v-toolbar color="blue" dark>
                <v-toolbar-title class="text-xs-center">Input example</v-toolbar-title>
              </v-toolbar>
              <v-list subheader>
                  <gpio-input v-for="item in gpio_input_list" :gpio="item" :key="item.id" />
              </v-list>
            </v-card>
          </v-flex>

          <v-flex xs3>
              <v-card>
                <v-toolbar color="blue" dark>
                  <v-toolbar-title class="text-xs-center">Output example</v-toolbar-title>
                </v-toolbar>
                <v-list subheader>
                    <gpio-output v-for="item in gpio_output_list" v-bind:gpio="item" v-bind:key="item.id" />
                </v-list>
              </v-card>
            </v-flex>

            <v-flex xs3>
                <v-card>
                  <v-toolbar color="blue" dark>
                    <v-toolbar-title class="text-xs-center">PWM example</v-toolbar-title>
                  </v-toolbar>
                  <v-list subheader>
                      <pwm v-for="item in pwm_list" :gpio="item" :key="item.id" />
                  </v-list>
                </v-card>
              </v-flex>

              <v-flex xs3>
                  <v-card>
                    <v-toolbar color="blue" dark>
                      <v-toolbar-title class="text-xs-center">Actions example</v-toolbar-title>
                    </v-toolbar>
                    <v-list subheader>
                        <action v-for="item in action_list" :action="item" :key="item.id" />
                    </v-list>
                  </v-card>
                </v-flex>
        </v-layout>
      </v-container>
    </v-content>
  </v-app>

  <!-- Desde CDN -->
  <!--<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.min.js"></script>-->
  <script type="text/javascript" src="./vendor/vue.min.js"></script>
  <script type="text/javascript" src="./vendor/vuetify.min.js"></script>
  <script type="text/javascript" src="./vendor/nativeWs.min.js"></script>
  <script type="text/javascript" src="./vendor/mqttws31.min.js"></script>

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

La “chicha” de nuestra web está en el fichero App.js que contiene la lógica de nuestra aplicación

Vue.component('gpio-input', {
  props: ['gpio'],
  template: `
    <v-list-tile avatar>
      <v-list-tile-content>
        <v-list-tile-title>{{gpio.text}}</v-list-tile-title>
      </v-list-tile-content>
    <v-list-tile-action>
      <v-list-tile-action-text>{{ gpio.status ? "ON " : "OFF "}}</v-list-tile-action-text>
      <v-icon :color="gpio.status ? 'teal' : 'grey'">fiber_manual_record</v-icon>
    </v-list-tile-action>
    </v-list-tile>
    `
})

Vue.component('gpio-output', {
  props: ['gpio'],
  template: ` 
    <v-list-tile avatar>
      <v-list-tile-content>
        <v-list-tile-title>{{gpio.text}}</v-list-tile-title>
      </v-list-tile-content>
      <v-list-tile-action>
        <v-switch v-model="gpio.status" class="ma-2" :label="gpio.status ? 'ON' : 'OFF'" @change="sendGPIO"></v-switch>
      </v-list-tile-action>
    </v-list-tile>
`,
  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 topic = 'device/0/GPIO/';
      let payload = JSON.stringify(data);
      let pubQoS = 0;
      let retain = false;
      client.send(topic, payload, Number(pubQoS), retain);
    }
  }
})

Vue.component('pwm', {
  props: ['gpio'],
  template: `     
      <v-list-tile avatar>
        <v-list-tile-content>
          <v-list-tile-title>{{gpio.text}}</v-list-tile-title>
        </v-list-tile-content>
        <v-list-tile-action>
        <v-slider thumb-label v-model="gpio.value" min="0" max="255" @change="sendPWM">
            <template v-slot:append>
              <v-text-field class="mt-0 pt-0" hide-details single-line  type="number" style="width: 50px"
                    v-model="gpio.value" @change="sendPWM"></v-text-field>
              </template>
              </v-slider>
        </v-list-tile-action>
      </v-list-tile>`,
  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 topic = 'device/0/PWM/';
      let payload = JSON.stringify(data);
      let pubQoS = 0;
      let retain = false;
      client.send(topic, payload, Number(pubQoS), retain);
    }
  }
})

Vue.component('action', {
  props: ['action'],
  template: ` 
        <v-list-tile avatar>
        <v-list-tile-content>
          <v-list-tile-title>{{action.text}}</v-list-tile-title>
        </v-list-tile-content>
      <v-list-tile-action>
      <v-btn text small color="flat" @click="doAction">Do something</v-btn>
      </v-list-tile-action>
      </v-list-tile>
`,
  methods: {
    doAction: function (evt) {
      console.log(this.action.text + ': ' + this.action.id);
      let data = {
        command: "doAction",
        id: this.action.id,
      }

      let topic = 'device/0/Action/';
      let payload = JSON.stringify(data);
      let pubQoS = 0;
      let retain = false;
      client.send(topic, payload, Number(pubQoS), retain);
    }
  }
})

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() {
    client = new Paho.MQTT.Client("192.168.1.150", 9001, createGuid())
    var options = {
      onSuccess: onConnect,
      onFailure: onFailure
    };
    client.onConnectionLost = onConnectionLost;
    client.onMessageArrived = onMessageArrived;
    client.connect(options);
  }
})
Copied!

Finalmente tenemos el fichero API.js, que contiene las funciones propias de MQTT usadas en el lado cliente.

function onConnect() {
  var options = {
    qos: 0,
    onSuccess: onSubSuccess,
    onFailure: onSubFailure
  };
  client.subscribe('device/0/#', options);
}

function onFailure(message) {
  console.log(message)
}

function onConnectionLost(responseObject) {
  if (responseObject.errorCode !== 0) {
    console.log("onConnectionLost:" + responseObject.errorMessage);
  }
}

function onMessageArrived(message) {
  console.log(message)
  var topic = message.destinationName;
  var json = message.payloadString;

  let payload = JSON.parse(json);
  console.log(payload)
  if(payload.command.includes("updateGPIO"))
  {
    let gpio = app.gpio_input_list.find(gpio => gpio.text == payload.id);
    gpio.status = payload.status;
  }
}

function onSubFailure(message) {
  console.log(message)
}

function onSubSuccess(message) {
  console.log(message)
}

function createGuid() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
    var r = Math.random() * 16 | 0,
      v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}
Copied!

Resultado

Subimos todo eso a nuestro ESP8266/ESP32 y si todo ha salido bien, deberíamos ver el interface de nuestro proyecto.

esp32-mqtt-gpio-result

Asimismo, comprobamos que al realizar las acciones, estas se reciben y se muestran en puerto serie. De igual forma, si realizamos un cambio de estado en uno de los pines, veremos reflejado el cambio de estado en la aplicación del cliente.

esp32-mqtt-gpio-serial

¡Y con esto hemos terminado! Tenemos una interfaz web en Vue que se comunica por MQTT con nuestra placa ESP, manteniendo separadas la interfaz, la lógica del dispositivo y el transporte de mensajes.

Os dejo aquí todo el código del ejemplo para que podáis revisarlo con más calma y adaptarlo a vuestros propios proyectos.

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