Módulo 12

Interfaz de usuario: app o dashboard

Proyecto Final Integral

ESP32 Mecatrónica IoT UNAM

Introducción Teórica

Las interfaces de usuario (UI) para sistemas IoT basados en ESP32 representan el punto de contacto crítico entre los usuarios y los sistemas mecatrónicos. En la era de la Industria 4.0, el diseño de interfaces intuitivas, responsivas y eficientes es fundamental para la adopción exitosa de tecnologías IoT en entornos industriales y comerciales.

Una interfaz bien diseñada no solo mejora la experiencia del usuario, sino que también reduce errores operativos, aumenta la eficiencia y facilita la toma de decisiones basada en datos. Las opciones incluyen aplicaciones móviles nativas, aplicaciones web progresivas (PWA), y dashboards web en tiempo real.

Aplicaciones Móviles

  • Nativas: iOS/Android optimizadas
  • Híbridas: Flutter, React Native
  • PWA: Web apps con funcionalidad nativa
  • Control remoto: Acceso desde cualquier lugar

Dashboards Web

  • Tiempo real: WebSocket, SSE
  • Responsive: Adaptable a dispositivos
  • Analytics: Visualización de datos
  • Multi-usuario: Roles y permisos

Principios de Diseño UX/UI

  • Usabilidad: Interfaz intuitiva
  • Accesibilidad: Diseño inclusivo
  • Performance: Carga rápida
  • Consistencia: Patrones unificados

Explicación Técnica Detallada

La implementación de interfaces de usuario para ESP32 requiere conocimientos de múltiples tecnologías y protocolos. La arquitectura típica incluye el ESP32 como servidor web, protocolos de comunicación en tiempo real, y frameworks frontend modernos.

ESP32 Web Server

El ESP32 puede actuar como servidor web completo utilizando bibliotecas como:

  • ESPAsyncWebServer - Servidor asíncrono
  • WebSocketsServer - Comunicación bidireccional
  • SPIFFS/LittleFS - Sistema de archivos
  • ArduinoJson - Serialización de datos

Tecnologías Frontend

Para crear interfaces modernas y funcionales:

  • HTML5/CSS3 - Estructura y estilos
  • JavaScript ES6+ - Lógica frontend
  • React/Vue.js - Frameworks reactivos
  • Chart.js/D3.js - Visualización de datos

Servidor Web Básico con ESP32

C++ (Arduino IDE)
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <SPIFFS.h>
#include <ArduinoJson.h>

const char* ssid = "TuRedWiFi";
const char* password = "TuPassword";

AsyncWebServer server(80);
AsyncWebSocket ws("/ws");

// Variables globales para sensores
float temperatura = 0.0;
float humedad = 0.0;
bool ledEstado = false;

void setup() {
    Serial.begin(115200);
    
    // Inicializar SPIFFS
    if(!SPIFFS.begin(true)){
        Serial.println("Error montando SPIFFS");
        return;
    }
    
    // Conectar WiFi
    WiFi.begin(ssid, password);
    while (WiFi.status() != WL_CONNECTED) {
        delay(1000);
        Serial.println("Conectando a WiFi...");
    }
    
    Serial.print("IP Address: ");
    Serial.println(WiFi.localIP());
    
    // Configurar WebSocket
    ws.onEvent(onWsEvent);
    server.addHandler(&ws);
    
    // Servir archivos estáticos
    server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
        request->send(SPIFFS, "/index.html", "text/html");
    });
    
    // API REST para datos de sensores
    server.on("/api/sensors", HTTP_GET, [](AsyncWebServerRequest *request){
        DynamicJsonDocument doc(200);
        doc["temperatura"] = temperatura;
        doc["humedad"] = humedad;
        doc["timestamp"] = millis();
        
        String response;
        serializeJson(doc, response);
        request->send(200, "application/json", response);
    });
    
    // Control de LED via POST
    server.on("/api/led", HTTP_POST, [](AsyncWebServerRequest *request){
        if (request->hasParam("estado", true)) {
            ledEstado = request->getParam("estado", true)->value() == "1";
            digitalWrite(LED_BUILTIN, ledEstado ? HIGH : LOW);
            request->send(200, "text/plain", ledEstado ? "ON" : "OFF");
        } else {
            request->send(400, "text/plain", "Parámetro estado requerido");
        }
    });
    
    server.begin();
}

void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, 
               AwsEventType type, void *arg, uint8_t *data, size_t len) {
    if (type == WS_EVT_CONNECT) {
        Serial.printf("Cliente WebSocket conectado: %u\n", client->id());
    } else if (type == WS_EVT_DISCONNECT) {
        Serial.printf("Cliente WebSocket desconectado: %u\n", client->id());
    } else if (type == WS_EVT_DATA) {
        // Procesar datos recibidos via WebSocket
        String mensaje = String((char*)data);
        Serial.println("Mensaje recibido: " + mensaje);
    }
}

void loop() {
    // Simular lectura de sensores
    temperatura = random(200, 350) / 10.0; // 20-35°C
    humedad = random(400, 800) / 10.0;     // 40-80%
    
    // Enviar datos via WebSocket cada 5 segundos
    static unsigned long lastUpdate = 0;
    if (millis() - lastUpdate > 5000) {
        enviarDatosWebSocket();
        lastUpdate = millis();
    }
    
    delay(100);
}

void enviarDatosWebSocket() {
    DynamicJsonDocument doc(300);
    doc["tipo"] = "datos_sensores";
    doc["temperatura"] = temperatura;
    doc["humedad"] = humedad;
    doc["led"] = ledEstado;
    doc["timestamp"] = millis();
    
    String mensaje;
    serializeJson(doc, mensaje);
    ws.textAll(mensaje);
}

Dashboard Web con Visualización en Tiempo Real

HTML/CSS/JavaScript
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ESP32 Dashboard IoT</title>
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <style>
        .sensor-card {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border-radius: 15px;
            padding: 2rem;
            margin: 1rem 0;
            box-shadow: 0 10px 30px rgba(0,0,0,0.2);
        }
        .status-indicator {
            width: 20px;
            height: 20px;
            border-radius: 50%;
            display: inline-block;
            margin-right: 10px;
        }
        .online { background-color: #28a745; }
        .offline { background-color: #dc3545; }
        #chart-container {
            height: 400px;
            margin: 2rem 0;
        }
    </style>
</head>
<body>
    <div class="container-fluid">
        <header class="py-3 border-bottom">
            <h1><i class="fas fa-microchip"></i> ESP32 IoT Dashboard</h1>
            <span id="connection-status">
                <span class="status-indicator offline"></span>
                Desconectado
            </span>
        </header>

        <div class="row mt-4">
            <div class="col-md-3">
                <div class="sensor-card">
                    <h3><i class="fas fa-thermometer-half"></i> Temperatura</h3>
                    <h2 id="temperatura">--</h2>
                    <p>°C</p>
                </div>
            </div>
            <div class="col-md-3">
                <div class="sensor-card">
                    <h3><i class="fas fa-tint"></i> Humedad</h3>
                    <h2 id="humedad">--</h2>
                    <p>%</p>
                </div>
            </div>
            <div class="col-md-3">
                <div class="sensor-card">
                    <h3><i class="fas fa-lightbulb"></i> Control LED</h3>
                    <button id="led-btn" class="btn btn-light btn-lg" onclick="toggleLED()">
                        OFF
                    </button>
                </div>
            </div>
            <div class="col-md-3">
                <div class="sensor-card">
                    <h3><i class="fas fa-clock"></i> Última Actualización</h3>
                    <p id="timestamp">--</p>
                </div>
            </div>
        </div>

        <div class="row">
            <div class="col-12">
                <div class="card">
                    <div class="card-header">
                        <h3><i class="fas fa-chart-line"></i> Gráficas en Tiempo Real</h3>
                    </div>
                    <div class="card-body">
                        <canvas id="sensorChart"></canvas>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script>
        // Variables globales
        let socket;
        let sensorChart;
        let datos = {
            temperatura: [],
            humedad: [],
            timestamps: []
        };

        // Inicializar dashboard
        document.addEventListener('DOMContentLoaded', function() {
            inicializarWebSocket();
            inicializarGraficas();
        });

        function inicializarWebSocket() {
            const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
            socket = new WebSocket(`${protocol}//${window.location.host}/ws`);
            
            socket.onopen = function(event) {
                console.log('WebSocket conectado');
                actualizarEstadoConexion(true);
            };
            
            socket.onmessage = function(event) {
                const data = JSON.parse(event.data);
                if (data.tipo === 'datos_sensores') {
                    actualizarDashboard(data);
                }
            };
            
            socket.onclose = function(event) {
                console.log('WebSocket desconectado');
                actualizarEstadoConexion(false);
                // Reconectar después de 3 segundos
                setTimeout(inicializarWebSocket, 3000);
            };
            
            socket.onerror = function(error) {
                console.error('Error WebSocket:', error);
            };
        }

        function inicializarGraficas() {
            const ctx = document.getElementById('sensorChart').getContext('2d');
            sensorChart = new Chart(ctx, {
                type: 'line',
                data: {
                    labels: datos.timestamps,
                    datasets: [{
                        label: 'Temperatura (°C)',
                        data: datos.temperatura,
                        borderColor: '#ff6384',
                        backgroundColor: 'rgba(255, 99, 132, 0.1)',
                        tension: 0.4
                    }, {
                        label: 'Humedad (%)',
                        data: datos.humedad,
                        borderColor: '#36a2eb',
                        backgroundColor: 'rgba(54, 162, 235, 0.1)',
                        tension: 0.4
                    }]
                },
                options: {
                    responsive: true,
                    maintainAspectRatio: false,
                    plugins: {
                        title: {
                            display: true,
                            text: 'Sensores en Tiempo Real'
                        }
                    },
                    scales: {
                        x: {
                            display: true,
                            title: {
                                display: true,
                                text: 'Tiempo'
                            }
                        },
                        y: {
                            display: true,
                            title: {
                                display: true,
                                text: 'Valor'
                            }
                        }
                    }
                }
            });
        }

        function actualizarDashboard(data) {
            // Actualizar valores
            document.getElementById('temperatura').textContent = data.temperatura.toFixed(1);
            document.getElementById('humedad').textContent = data.humedad.toFixed(1);
            document.getElementById('timestamp').textContent = 
                new Date(data.timestamp).toLocaleTimeString();
            
            // Actualizar botón LED
            const ledBtn = document.getElementById('led-btn');
            ledBtn.textContent = data.led ? 'ON' : 'OFF';
            ledBtn.className = data.led ? 'btn btn-success btn-lg' : 'btn btn-secondary btn-lg';
            
            // Actualizar gráficas
            const tiempo = new Date().toLocaleTimeString();
            datos.temperatura.push(data.temperatura);
            datos.humedad.push(data.humedad);
            datos.timestamps.push(tiempo);
            
            // Mantener solo los últimos 20 puntos
            if (datos.temperatura.length > 20) {
                datos.temperatura.shift();
                datos.humedad.shift();
                datos.timestamps.shift();
            }
            
            sensorChart.update();
        }

        function actualizarEstadoConexion(conectado) {
            const status = document.getElementById('connection-status');
            if (conectado) {
                status.innerHTML = '<span class="status-indicator online"></span>Conectado';
            } else {
                status.innerHTML = '<span class="status-indicator offline"></span>Desconectado';
            }
        }

        function toggleLED() {
            fetch('/api/led', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: 'estado=' + (document.getElementById('led-btn').textContent === 'OFF' ? '1' : '0')
            })
            .then(response => response.text())
            .then(data => {
                console.log('LED:', data);
            })
            .catch(error => {
                console.error('Error:', error);
            });
        }
    </script>
</body>
</html>

Ejercicios Prácticos Visuales

1

Dashboard Web Básico

Básico 30-45 min
Objetivo: Crear un dashboard web básico que muestre datos de sensores y controle actuadores del ESP32.

Materiales necesarios:
  • ESP32 DevKit
  • Sensor DHT22
  • LED RGB
  • Resistencias 220Ω
  • Protoboard y cables
2

App Móvil con Flutter

Intermedio 60-90 min
Objetivo: Desarrollar una aplicación móvil multiplataforma usando Flutter para controlar dispositivos ESP32.

Funcionalidades:
  • Conexión WiFi/Bluetooth
  • Control de GPIO en tiempo real
  • Gráficas de sensores
  • Notificaciones push
3

Sistema de Autenticación

Avanzado 90-120 min
Objetivo: Implementar un sistema de autenticación seguro con roles de usuario y cifrado SSL/TLS.

Características:
  • Login con JWT tokens
  • Roles: Admin, Operador, Viewer
  • Cifrado HTTPS
  • Logs de auditoría
4

Dashboard Multi-Dispositivo

Experto 2-3 horas
Objetivo: Crear un sistema centralizado que maneje múltiples ESP32 con diferentes sensores y actuadores.

Arquitectura:
  • Broker MQTT central
  • Base de datos InfluxDB
  • Dashboard Grafana
  • API REST para integración

Proyecto Aplicado: Sistema de Control Industrial Completo

Descripción: Desarrollo de un sistema completo de monitoreo y control industrial que incluye interfaces web y móvil para la gestión de una línea de producción automatizada.

Hardware Requerido

  • 3x ESP32 DevKit V1 - Nodos de control distribuido
  • Sensores: DHT22, BMP280, Current sensor ACS712
  • Actuadores: Relés 4 canales, servomotores, motores DC
  • Comunicación: Módulo LoRa SX1278 (opcional)
  • Alimentación: Fuentes 12V/5V reguladas
  • Pantalla: TFT 2.8" para HMI local

Stack Tecnológico

  • Backend: Node.js + Express + Socket.io
  • Base de datos: PostgreSQL + InfluxDB
  • Frontend Web: React + Material-UI + Chart.js
  • App Móvil: Flutter con BLoC pattern
  • DevOps: Docker + GitHub Actions
  • Monitoreo: Grafana + Prometheus

Aplicación Móvil Flutter

Dart (Flutter)
import 'package:flutter/material.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:charts_flutter/charts_flutter.dart' as charts;
import 'dart:convert';
import 'dart:async';

class ESP32Dashboard extends StatefulWidget {
  @override
  _ESP32DashboardState createState() => _ESP32DashboardState();
}

class _ESP32DashboardState extends State<ESP32Dashboard> {
  late WebSocketChannel channel;
  bool isConnected = false;
  Map<String, dynamic> sensorData = {};
  List<SensorReading> temperatureData = [];
  List<SensorReading> humidityData = [];
  
  @override
  void initState() {
    super.initState();
    connectWebSocket();
  }
  
  void connectWebSocket() {
    try {
      channel = WebSocketChannel.connect(
        Uri.parse('ws://192.168.1.100/ws'),
      );
      
      setState(() {
        isConnected = true;
      });
      
      channel.stream.listen(
        (data) {
          final decoded = json.decode(data);
          setState(() {
            sensorData = decoded;
            updateChartData(decoded);
          });
        },
        onDone: () {
          setState(() {
            isConnected = false;
          });
          // Reconectar después de 3 segundos
          Timer(Duration(seconds: 3), connectWebSocket);
        },
        onError: (error) {
          setState(() {
            isConnected = false;
          });
        },
      );
    } catch (e) {
      setState(() {
        isConnected = false;
      });
    }
  }
  
  void updateChartData(Map<String, dynamic> data) {
    final timestamp = DateTime.now();
    
    temperatureData.add(SensorReading(timestamp, data['temperatura'] ?? 0.0));
    humidityData.add(SensorReading(timestamp, data['humedad'] ?? 0.0));
    
    // Mantener solo los últimos 50 puntos
    if (temperatureData.length > 50) {
      temperatureData.removeAt(0);
      humidityData.removeAt(0);
    }
  }
  
  void sendCommand(String command, dynamic value) {
    if (isConnected) {
      final message = json.encode({
        'command': command,
        'value': value,
        'timestamp': DateTime.now().millisecondsSinceEpoch,
      });
      channel.sink.add(message);
    }
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('ESP32 Control Panel'),
        backgroundColor: Colors.blue[800],
        actions: [
          Icon(
            isConnected ? Icons.wifi : Icons.wifi_off,
            color: isConnected ? Colors.green : Colors.red,
          ),
          SizedBox(width: 16),
        ],
      ),
      body: Column(
        children: [
          // Tarjetas de sensores
          Container(
            height: 120,
            child: ListView(
              scrollDirection: Axis.horizontal,
              padding: EdgeInsets.all(16),
              children: [
                SensorCard(
                  title: 'Temperatura',
                  value: '${sensorData['temperatura']?.toStringAsFixed(1) ?? '--'}°C',
                  icon: Icons.thermostat,
                  color: Colors.orange,
                ),
                SensorCard(
                  title: 'Humedad',
                  value: '${sensorData['humedad']?.toStringAsFixed(1) ?? '--'}%',
                  icon: Icons.opacity,
                  color: Colors.blue,
                ),
                SensorCard(
                  title: 'Presión',
                  value: '${sensorData['presion']?.toStringAsFixed(0) ?? '--'} hPa',
                  icon: Icons.speed,
                  color: Colors.green,
                ),
              ],
            ),
          ),
          
          // Controles
          Padding(
            padding: EdgeInsets.all(16),
            child: Card(
              child: Padding(
                padding: EdgeInsets.all(16),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'Controles',
                      style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                    ),
                    SizedBox(height: 16),
                    Row(
                      mainAxisAlignment: MainAxisAlignment.spaceAround,
                      children: [
                        ElevatedButton.icon(
                          onPressed: () => sendCommand('led', !sensorData['led']),
                          icon: Icon(Icons.lightbulb),
                          label: Text(sensorData['led'] == true ? 'LED ON' : 'LED OFF'),
                          style: ElevatedButton.styleFrom(
                            backgroundColor: sensorData['led'] == true ? Colors.green : Colors.grey,
                          ),
                        ),
                        ElevatedButton.icon(
                          onPressed: () => sendCommand('relay1', !sensorData['relay1']),
                          icon: Icon(Icons.power),
                          label: Text('Relay 1'),
                          style: ElevatedButton.styleFrom(
                            backgroundColor: sensorData['relay1'] == true ? Colors.red : Colors.grey,
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
            ),
          ),
          
          // Gráficas
          Expanded(
            child: Padding(
              padding: EdgeInsets.all(16),
              child: Card(
                child: Padding(
                  padding: EdgeInsets.all(16),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Tendencias en Tiempo Real',
                        style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                      ),
                      SizedBox(height: 16),
                      Expanded(
                        child: charts.TimeSeriesChart(
                          _createChartData(),
                          animate: true,
                          dateTimeFactory: const charts.LocalDateTimeFactory(),
                          primaryMeasureAxis: charts.NumericAxisSpec(
                            tickProviderSpec: charts.BasicNumericTickProviderSpec(
                              desiredTickCount: 5,
                            ),
                          ),
                          domainAxis: charts.DateTimeAxisSpec(
                            tickFormatterSpec: charts.AutoDateTimeTickFormatterSpec(
                              hour: charts.TimeFormatterSpec(
                                format: 'HH:mm',
                                transitionFormat: 'HH:mm',
                              ),
                            ),
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
  
  List<charts.Series<SensorReading, DateTime>> _createChartData() {
    return [
      charts.Series<SensorReading, DateTime>(
        id: 'Temperatura',
        colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault,
        domainFn: (SensorReading reading, _) => reading.timestamp,
        measureFn: (SensorReading reading, _) => reading.value,
        data: temperatureData,
      ),
      charts.Series<SensorReading, DateTime>(
        id: 'Humedad',
        colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault,
        domainFn: (SensorReading reading, _) => reading.timestamp,
        measureFn: (SensorReading reading, _) => reading.value,
        data: humidityData,
      ),
    ];
  }
  
  @override
  void dispose() {
    channel.sink.close();
    super.dispose();
  }
}

class SensorCard extends StatelessWidget {
  final String title;
  final String value;
  final IconData icon;
  final Color color;
  
  const SensorCard({
    Key? key,
    required this.title,
    required this.value,
    required this.icon,
    required this.color,
  }) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    return Container(
      width: 160,
      margin: EdgeInsets.only(right: 16),
      child: Card(
        elevation: 4,
        child: Padding(
          padding: EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Row(
                children: [
                  Icon(icon, color: color, size: 24),
                  SizedBox(width: 8),
                  Expanded(
                    child: Text(
                      title,
                      style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
                      overflow: TextOverflow.ellipsis,
                    ),
                  ),
                ],
              ),
              SizedBox(height: 8),
              Text(
                value,
                style: TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.bold,
                  color: color,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class SensorReading {
  final DateTime timestamp;
  final double value;
  
  SensorReading(this.timestamp, this.value);
}

Procedimiento de Implementación

  1. Configuración Hardware: Conexión de sensores y actuadores a los ESP32
  2. Desarrollo Backend: Servidor Node.js con API REST y WebSocket
  3. Base de Datos: Configuración PostgreSQL e InfluxDB para datos históricos
  4. Frontend Web: Dashboard React con componentes reutilizables
  5. App Móvil: Desarrollo Flutter con arquitectura BLoC
  6. Integración: Pruebas de comunicación y sincronización
  7. Deployment: Containerización con Docker y CI/CD
  8. Monitoreo: Configuración Grafana para métricas del sistema

Evaluación y Troubleshooting

Problemas Comunes

  • Latencia alta en WebSocket: Verificar red WiFi y optimizar código
  • Desconexiones frecuentes: Implementar heartbeat y reconexión automática
  • Consumo excesivo de memoria: Optimizar buffers y liberar recursos
  • Interfaz no responsive: Usar media queries y flexbox
  • Autenticación fallida: Verificar certificados SSL y tokens JWT
  • Datos inconsistentes: Validar en cliente y servidor

Criterios de Evaluación

  • Funcionalidad (30%): Todas las características implementadas
  • Usabilidad (25%): Interfaz intuitiva y accesible
  • Performance (20%): Tiempo de respuesta < 200ms
  • Código (15%): Buenas prácticas y documentación
  • Seguridad (10%): Autenticación y validación

Entregables:
  • Código fuente completo con documentación
  • Video demo del sistema funcionando
  • Arquitectura del sistema y diagramas
  • Manual de usuario y técnico