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:
- Lectura periódica del sensor (cada 60 s, por timer no bloqueante con
millis()). - Persistencia en CSV sobre microSD usando SPI. Cada lectura se añade como una fila nueva con timestamp NTP.
- 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.

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 |

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

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:
#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".

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.

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.

Variantes y mejoras
Tres extensiones concretas que el tutorial original no cubre:
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.
Alertas por Telegram cuando la temperatura cruza un umbral: dentro de
logData(), después de calculartC, agregaif (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.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
- Tutorial original (inglés): ESP32 Web Server: Charts with Historical Data (load .csv file)
- Código completo (.ino): ESP32_Historical_Charts.ino
- Librería gráficos: Chart.js + chartjs-plugin-zoom
- Librería servidor: ESPAsyncWebServer
- Documentación NTP: ESP32 NTP Client-Server: Get Date and Time
Versión chilena con componentes en stock local en MechatronicStore.









