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
#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
<!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
Materiales necesarios:
- ESP32 DevKit
- Sensor DHT22
- LED RGB
- Resistencias 220Ω
- Protoboard y cables
Funcionalidades:
- Conexión WiFi/Bluetooth
- Control de GPIO en tiempo real
- Gráficas de sensores
- Notificaciones push
Características:
- Login con JWT tokens
- Roles: Admin, Operador, Viewer
- Cifrado HTTPS
- Logs de auditoría
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
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
- Configuración Hardware: Conexión de sensores y actuadores a los ESP32
- Desarrollo Backend: Servidor Node.js con API REST y WebSocket
- Base de Datos: Configuración PostgreSQL e InfluxDB para datos históricos
- Frontend Web: Dashboard React con componentes reutilizables
- App Móvil: Desarrollo Flutter con arquitectura BLoC
- Integración: Pruebas de comunicación y sincronización
- Deployment: Containerización con Docker y CI/CD
- 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