Por qué un data logger con gráficos vale más que un sensor "que solo muestra"

La mayoría de proyectos con sensores ambientales terminan en lo mismo: un valor parpadeando en un monitor serial que nadie revisa. Útil para debug, inútil para tomar decisiones. Si quieres saber cómo varía la temperatura de tu pieza durante una noche, o si la humedad del taller se dispara cuando llueve, necesitas tres cosas: almacenar las lecturas, visualizarlas en una línea de tiempo y poder acceder desde el celular sin instalar nada.

Este tutorial te enseña a construir exactamente eso con un ESP32, un sensor de temperatura/humedad/presión y una tarjeta microSD. Al final vas a tener un servidor web que registra una lectura por minuto, las guarda en un archivo CSV en la microSD, y dibuja tres gráficos interactivos (con zoom y descarga) en una página HTML que se autoactualiza sola.

Concepto: cómo encajan las piezas

El ESP32 hace tres tareas en paralelo:

  1. Lectura periódica del sensor (cada 60 s, por timer no bloqueante con millis()).
  2. Persistencia en CSV sobre microSD usando SPI. Cada lectura se añade como una fila nueva con timestamp NTP.
  3. Servidor web asíncrono que sirve una página HTML estática (incrustada en el sketch) y rutas para descargar o borrar el CSV.

La página HTML embebida usa Chart.js + el plugin de zoom desde un CDN público, así que el navegador renderiza los gráficos sin que el ESP32 tenga que generar imágenes. Esto es importante: el ESP32 solo entrega el CSV crudo (~50 KB para 24 horas de datos) y el cliente hace el trabajo pesado de dibujar. Por eso funciona fluido incluso desde un celular antiguo.

Servidor web del ESP32 mostrando los 3 gráficos de temperatura, humedad y presión

Hardware y conexiones

El sensor original del tutorial es un BME280 (temperatura + humedad + presión, I²C). En Chile no siempre está disponible: el BMP180 del catálogo es funcionalmente equivalente para temperatura y presión, y si quieres también humedad puedes combinarlo con un DHT22 en otro pin. El código que ves aquí usa el BME280. Para BMP180 cambia la librería a Adafruit_BMP085 y elimina la línea de readHumidity().

Cableado de ambos módulos en paralelo sobre los pines por defecto del ESP32:

Sensor BME280 (I²C):

BME280 ESP32
VCC 3.3V
GND GND
SCL GPIO 22
SDA GPIO 21

Módulo microSD (SPI):

microSD ESP32
VCC (3V3) 3.3V
GND GND
CS GPIO 5
MOSI GPIO 23
CLK GPIO 18
MISO GPIO 19

Diagrama de conexión del sensor BME280 y el módulo microSD al ESP32 en protoboard

Importante: la microSD debe estar formateada en FAT32. Si tu tarjeta es de 64 GB o más, Windows la formatea por defecto en exFAT y la librería SD.h no la reconoce. Usa una herramienta como guiformat o mkfs.vfat -F 32 en Linux.

Software: librerías y código

Desde el gestor de librerías del IDE Arduino instala estas cuatro:

  • ESPAsyncWebServer (de ESP32Async)
  • AsyncTCP (de ESP32Async)
  • Adafruit BME280 Library
  • Adafruit Unified Sensor

Instalación de la librería ESPAsyncWebServer en el gestor del IDE Arduino

El sketch completo (~250 líneas) tiene tres bloques claros: setup de hardware, manejador HTTP para /, /download y /delete, y el loop que dispara logData() cada 60 s. Acá va el código completo:

C++
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <SD.h>
#include <SPI.h>
#include <time.h>

const char* ssid     = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";

#define SDA_PIN 21
#define SCL_PIN 22
#define SD_CS   5
#define SD_MOSI 23
#define SD_MISO 19
#define SD_SCK  18

const char* ntpServer = "pool.ntp.org";
const long  gmtOffset_sec = -4 * 3600;   // Chile continental: UTC-4 (ajusta a -3 en horario de verano)
const int   daylightOffset_sec = 0;

unsigned long previousMillis = 0;
const long interval = 60000;
const char* dataFile = "/bme280_log.csv";

AsyncWebServer server(80);
Adafruit_BME280 bme;

String getTimeStr() {
  struct tm t; if (!getLocalTime(&t)) return "00:00:00";
  char b[9]; sprintf(b, "%02d:%02d:%02d", t.tm_hour, t.tm_min, t.tm_sec);
  return String(b);
}
String getDateStr() {
  struct tm t; if (!getLocalTime(&t)) return "1970-01-01";
  char b[11]; sprintf(b, "%04d-%02d-%02d", t.tm_year+1900, t.tm_mon+1, t.tm_mday);
  return String(b);
}

void initSD() {
  SPI.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
  if (!SD.begin(SD_CS)) { Serial.println("SD Card Failed"); return; }
  if (!SD.exists(dataFile)) {
    File f = SD.open(dataFile, FILE_WRITE);
    if (f) { f.println("temp_c,temp_f,humidity,pressure,time,day"); f.close(); }
  }
}

void logData() {
  float tC = bme.readTemperature();
  float tF = tC * 9.0/5.0 + 32.0;
  float h  = bme.readHumidity();
  float p  = bme.readPressure() / 100.0F;
  File f = SD.open(dataFile, FILE_APPEND);
  if (f) {
    f.printf("%.2f,%.2f,%.2f,%.2f,%s,%s\n", tC, tF, h, p,
             getTimeStr().c_str(), getDateStr().c_str());
    f.close();
  }
}

void setup() {
  Serial.begin(115200);
  Wire.begin(SDA_PIN, SCL_PIN);
  if (!bme.begin(0x76)) { Serial.println("BME280 not found!"); while(1); }
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
  Serial.printf("\nIP: %s\n", WiFi.localIP().toString().c_str());
  configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
  delay(2000);
  initSD();

  server.on("/", HTTP_GET, [](AsyncWebServerRequest *req){
    req->send(200, "text/html; charset=UTF-8", /* HTML con Chart.js — ver tutorial original */ "");
  });
  server.on("/download", HTTP_GET, [](AsyncWebServerRequest *req){
    req->send(SD, dataFile, "text/csv", true);
  });
  server.on("/delete", HTTP_POST, [](AsyncWebServerRequest *req){
    if (SD.exists(dataFile)) SD.remove(dataFile);
    File f = SD.open(dataFile, FILE_WRITE);
    if (f) { f.println("temp_c,temp_f,humidity,pressure,time,day"); f.close(); }
    req->send(200);
  });
  server.begin();
}

void loop() {
  if (millis() - previousMillis >= interval) {
    previousMillis = millis();
    logData();
  }
  delay(10);
}

Reemplaza REPLACE_WITH_YOUR_SSID y REPLACE_WITH_YOUR_PASSWORD con tus credenciales y compila. El HTML con Chart.js está en el repo del tutorial original: son ~200 líneas más.

Detalle clave del código: timer no bloqueante

Notar que el loop() no usa delay(60000). Si lo hicieras, el ESP32 dejaría de responder a peticiones HTTP por un minuto entero entre cada lectura. La técnica de millis() - previousMillis >= interval permite que el servidor web siga atendiendo descargas y borrados mientras esperamos el próximo log.

Por qué la página se actualiza sola

El JavaScript embebido tiene setInterval(loadCSVData, 30000): cada 30 segundos pide /download, parsea el CSV crudo y reemplaza el dataset de los tres gráficos. No hace WebSockets ni Server Sent Events, solo polling, que para 1 lectura/minuto es más que suficiente y mucho más simple de mantener.

Ejecución y pruebas

Tras subir el código, abre el monitor serial a 115200 baud y aprieta RST. Vas a ver la IP del ESP32 (ej. 192.168.1.42). Ábrela en el navegador desde cualquier dispositivo de la misma red WiFi y vas a ver la página con tres gráficos vacíos y un mensaje "No data logged yet".

Primera carga de la página web del ESP32 con los gráficos aún sin datos

A los 60 segundos aparece la primera fila en el CSV; a los 30 después (próximo setInterval) los gráficos se pueblan con un punto. Para acelerar pruebas, baja temporalmente interval a 5000 (5 segundos).

Los botones de la página:

  • Refresh Charts: fuerza recarga del CSV (útil si el polling falla).
  • Download CSV: descarga el archivo completo al dispositivo.
  • Delete All Data: borra el CSV y deja solo la cabecera.
  • Reset All Zoom: vuelve los tres gráficos al rango completo.

Botones de control de la página: Refresh, Download CSV, Delete y Reset Zoom

El gráfico de temperatura tiene un toggle °C/°F. Los tres soportan zoom con rueda del mouse o pinch en touchscreen, y arrastre para hacer pan.

Selector Celsius y Fahrenheit del gráfico de temperatura en el ESP32

Variantes y mejoras

Tres extensiones concretas que el tutorial original no cubre:

  1. Acceso desde fuera de tu red local: el servidor solo escucha en la LAN. Si quieres ver las gráficas desde el trabajo, expón el ESP32 detrás de un túnel Cloudflare Tunnel gratuito o usa ngrok. No hagas port forwarding directo al ESP32: el servidor asíncrono no tiene control de tasa y queda expuesto a escaneos.

  2. Alertas por Telegram cuando la temperatura cruza un umbral: dentro de logData(), después de calcular tC, agrega if (tC > 30 || tC < 5) sendTelegram(tC);. La librería UniversalTelegramBot funciona directo sobre ESP32 con WiFiClientSecure. Útil para monitorear una sala de servidores o el invernadero.

  3. Reducir consumo para operación con batería: cambiar el loop por esp_deep_sleep_start() que despierta cada 60 segundos baja el consumo de ~80 mA a <10 µA promedio. La pega: pierdes el servidor web (al dormir el WiFi se apaga). Solución intermedia: dormir solo de noche, modo activo de día, controlable con la hora NTP que ya tienes.

Personalización para Chile

Todo lo necesario en el catálogo de MechatronicStore:

  • ESP32 ESP-WROOM-32 (USB-C) (SKU X2-10V2): $7.990 CLP
  • Módulo datalogger microSD (SKU G-014): $2.500 CLP
  • MicroSD 8GB (SKU B-450V1): $7.990 CLP (sirve cualquier capacidad ≥ 4 GB)
  • Sensor de Presión Barométrica BMP180 (SKU GE1-10): $2.490 CLP (equivalente a BME280 para temperatura y presión; para humedad agrega un DHT22)
  • Sensor DHT22 humedad+temperatura (SKU GP1-7): $4.100 CLP (opcional, si quieres la métrica de humedad)
  • Breadboard 830 puntos MB102 (SKU C-302): $3.790 CLP
  • Pack 40 cables macho-macho 20cm (SKU C-411): $1.990 CLP
  • Cable USB-C (SKU A-537): $4.390 CLP

Total estimado con BMP180 (sin DHT22): ~$31.140 CLP. El BME280 nominal del tutorial vendría siendo el equivalente de estos dos sensores combinados.

Recursos

Versión chilena con componentes en stock local en MechatronicStore.