En la mayoría de las casas chilenas no hay termostato central ni registro de temperatura: uno cree que la pieza del fondo es más fría que el living, pero no tiene con qué demostrarlo. Este proyecto te da esa evidencia. Un ESP32 con un sensor BME280 anota temperatura, humedad y presión cada 60 segundos en una tarjeta microSD, y levanta un servidor web en tu propia red donde ves los gráficos y. esto es lo interesante. estadísticas calculadas: máximas y mínimas de las últimas 24 horas y 7 días, cuánto tiempo estuviste sobre 25 °C, cuánto demora la casa en pasar de su mínima a su máxima, y el patrón diurno promedio hora por hora.
Al terminar vas a tener un registrador autónomo funcionando, vas a entender por qué el sensor va por I2C mientras la tarjeta va por SPI, y vas a saber leer los números que el navegador calcula a partir del archivo .csv.

El flujo del dato, en una frase
Todo el proyecto se entiende con esta cadena: el ESP32 lee el sensor → escribe una línea en un archivo .csv de la microSD → el navegador descarga ese archivo entero → y JavaScript hace los gráficos y las cuentas.
Esa última parte es la decisión de diseño clave y conviene subrayarla, porque el tutorial original la da por sentada: el ESP32 no calcula ninguna estadística. Solo guarda filas de texto. Las máximas, las mínimas, el tiempo por banda de temperatura y el promedio por hora los calcula tu teléfono o tu notebook cuando abres la página. Eso libera al microcontrolador de trabajo pesado y de memoria RAM, y además significa que el histórico completo vive en la tarjeta: si el ESP32 se apaga, los datos siguen ahí.

Hardware: dos buses distintos conviviendo
El BME280 habla I2C (dos cables: datos y reloj) y el módulo microSD habla SPI (cuatro cables). Son protocolos independientes, así que pueden funcionar al mismo tiempo sin pelearse por los pines.
Conexiones del sensor BME280:
| BME280 | ESP32 |
|---|---|
| VCC | 3.3V |
| GND | GND |
| SCK (SCL) | GPIO 22 |
| SDI (SDA) | GPIO 21 |
Conexiones del módulo microSD:
| Módulo microSD | ESP32 |
|---|---|
| 3V3 | 3.3V |
| CS | GPIO 5 |
| MOSI | GPIO 23 |
| CLK | GPIO 18 |
| MISO | GPIO 19 |
| GND | GND |

Con todo armado en la protoboard, el circuito se ve así:

Por qué un BME280 y no un DHT22
Esta comparación no está en el tutorial original y vale la pena antes de comprar. El DHT11/DHT22 es más barato pero usa un protocolo propietario de un solo hilo, tiene una resolución de 0,1 °C con ±0,5 °C de error y no mide presión. El BME280 entrega temperatura (±1 °C), humedad (±3 %) y presión barométrica, todo por I2C. La presión es lo que le da sentido al proyecto en Chile: los cambios de presión anticipan frentes de mal tiempo mejor que cualquier otra variable que puedas medir en tu casa.
El BMP180 del catálogo es el hermano menor: mide presión y temperatura, pero no humedad. Si te importa la humedad relativa. y para condensación en invierno te importa. el BME280 es el que necesitas.
Preparar la microSD y el entorno
Formatea la tarjeta como FAT32 antes de insertarla. Este paso se salta muy seguido y produce el clásico SD Card Failed en el monitor serial. Tarjetas de más de 32 GB suelen venir en exFAT de fábrica y la librería SD.h no las lee: por eso la de 16 GB es la opción cómoda.
Después instala cuatro librerías desde el gestor del IDE de Arduino:
- ESPAsyncWebServer by ESP32Async
- AsyncTCP by ESP32Async
- Adafruit BME280
- Adafruit Unified Sensor




Ajusta la zona horaria a Chile antes de compilar
Acá hay un detalle que el original no cubre y que arruina todas las estadísticas si lo dejas pasar. El sketch sincroniza la hora por NTP con estas dos líneas:
const long gmtOffset_sec = 3600; // Change to your timezone
const int daylightOffset_sec = 0;
Ese 3600 es UTC+1 (Europa). Chile continental está en UTC, 4 en invierno y UTC, 3 en horario de verano, así que el valor correcto es:
- Horario de invierno (abril a septiembre):
-14400 - Horario de verano (septiembre a abril):
-10800
Si dejas el valor europeo, cada fila del .csv queda estampada con cinco horas de más y el gráfico de "patrón diurno" te va a mostrar el peak de calor de madrugada. Como el offset se aplica en el momento de escribir, corregirlo después no arregla los datos ya guardados: hay que borrar el archivo y empezar de nuevo.
El sketch: registrar cada 60 segundos
Esta es la parte del código que se encarga de la hora, de inicializar la tarjeta y de escribir una fila. Fíjate en initSD(): si el archivo no existe, escribe primero la fila de encabezados; si existe, abre en modo FILE_APPEND y agrega al final, que es lo que permite que el histórico sobreviva a los reinicios.
/*
Rui Santos & Sara Santos - Random Nerd Tutorials
Complete project details at https://RandomNerdTutorials.com/esp32-web-server-charts-statistics/
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
#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>
// REPLACE WITH YOUR NETWORK CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";
// BME280 GPIOs
#define SDA_PIN 21
#define SCL_PIN 22
// MicroSD Card Module GPIOs
#define SD_CS 5
#define SD_MOSI 23
#define SD_MISO 19
#define SD_SCK 18
// NTP server
const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = 3600; // Change to your timezone
const int daylightOffset_sec = 0;
// Timer to take new readings
unsigned long previousMillis = 0;
const long interval = 60000; // 60 seconds
// CSV filename
const char* dataFile = "/bme280_log.csv";
// AsyncWebServer Web Server
AsyncWebServer server(80);
// BME280
Adafruit_BME280 bme;
// Get Time
String getTimeStr() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) return "00:00:00";
char buf[9]; sprintf(buf, "%02d:%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
return String(buf);
}
// Get Date
String getDateStr() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) return "1970-01-01";
char buf[11]; sprintf(buf, "%04d-%02d-%02d", timeinfo.tm_year+1900, timeinfo.tm_mon+1, timeinfo.tm_mday);
return String(buf);
}
// Initialize MicroSD Card and create the .csv file
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(); }
}
}
// Log BME280 data on the MicroSD Card
void logData() {
float tempC = bme.readTemperature();
float tempF = tempC * 9.0 / 5.0 + 32.0;
float hum = bme.readHumidity();
float press = bme.readPressure() / 100.0F;
File file = SD.open(dataFile, FILE_APPEND);
if (file) {
file.printf("%.2f,%.2f,%.2f,%.2f,%s,%s\n",
tempC, tempF, hum, press, getTimeStr().c_str(), getDateStr().c_str());
file.close();
}
}
El resto del sketch —setup() con el servidor asíncrono y la página HTML completa. está en el código original enlazado al final; es largo porque la página web viaja incrustada como texto dentro del propio programa.
El navegador hace las cuentas
Cuando abres la IP del ESP32, el JavaScript de la página pide /download, recibe el .csv crudo y lo convierte en objetos. Fíjate en que ordena por marca de tiempo: eso hace que el gráfico no se desarme si alguna fila quedó fuera de orden.
function parseCSV(csv) {
const lines = csv.trim().split('\n');
const result = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const [temp_c, temp_f, hum, press, time, day] = lines[i].split(',');
if (temp_c === undefined || time === undefined || day === undefined) continue;
const ts = new Date(`${day}T${time}`);
result.push({
temp_c: parseFloat(temp_c),
temp_f: parseFloat(temp_f),
hum: parseFloat(hum),
press: parseFloat(press),
time, day,
ts: isNaN(ts.getTime()) ? null : ts
});
}
result.sort((a, b) => {
if (!a.ts || !b.ts) return 0;
return a.ts - b.ts;
});
return result;
}
La función más interesante es la que calcula cuánto tiempo pasó la temperatura dentro de un rango. No hay integración ni matemática rara: se recorren las lecturas de a pares y se suma el intervalo entre una y la siguiente, asignándolo a la banda en la que estaba la primera.
function timeInBand(readings, lo, hi) {
const MAX_GAP_MIN = 15;
let total = 0;
for (let i = 0; i < readings.length - 1; i++) {
const cur = readings[i];
const next = readings[i+1];
if (!cur.ts || !next.ts) continue;
const gapMin = (next.ts - cur.ts) / 60000;
if (gapMin <= 0 || gapMin > MAX_GAP_MIN) continue;
if (cur.temp_c >= lo && cur.temp_c < hi) {
total += gapMin;
}
}
return total;
}
Ese MAX_GAP_MIN = 15 es la parte que hay que entender. Si el ESP32 se desconectó cuatro horas por un corte de luz, entre dos lecturas consecutivas va a haber un salto de 240 minutos. Sin ese filtro, esas cuatro horas se sumarían como si la temperatura se hubiera mantenido constante todo ese rato, y el resultado sería una mentira. Con el filtro, los huecos mayores a 15 minutos simplemente se descartan: la estadística es conservadora, prefiere subestimar antes que inventar.
Ojo con la consecuencia práctica: si subes el interval del sketch de 60 segundos a, digamos, 20 minutos para ahorrar batería, tienes que subir también MAX_GAP_MIN o todos los tiempos por banda te van a dar cero.
Probar el montaje
Carga el sketch, abre el monitor serial a 115200 baudios y espera la dirección IP:

Escribe esa IP en el navegador de cualquier dispositivo conectado a la misma red WiFi. Arriba vas a encontrar los botones para refrescar, descargar el .csv, borrar todo el registro y resetear el zoom:

Los gráficos aceptan zoom con la rueda del mouse o con dos dedos en el celular, y doble clic vuelve a la vista completa:

Y más abajo, la sección de estadísticas se va llenando a medida que se acumulan lecturas:

El gráfico de presión es el que menos llama la atención al principio y el más útil después de una semana:

Errores comunes y cómo salir de ellos
BME280 not found!en el monitor serial. El sketch inicializa conbme.begin(0x76), pero muchos módulos vienen configurados en0x77. Prueba cambiando ese valor. Si sigue fallando, revisa que el sensor esté alimentado con 3.3V y no 5V.SD Card Failed. Casi siempre es formato (debe ser FAT32) o alimentación: el módulo lector consume picos al escribir, y si lo alimentas desde un pin ya cargado se reinicia el ESP32.- La página carga pero los gráficos salen vacíos. Las librerías de gráficos se descargan desde una CDN en internet. El ESP32 sirve la página, pero el dispositivo que la abre necesita salida a internet para bajar esos archivos. Si estás en una red aislada, no vas a ver ningún gráfico.
- Todas las horas dicen
00:00:00. El NTP no alcanzó a sincronizar antes de la primera lectura. Sube eldelay(2000)posterior aconfigTime()o verifica que el router deje salir tráfico al puerto 123. - La estadística "tiempo sobre 25 °C" siempre da
0h 0m. Revisa elMAX_GAP_MINexplicado arriba, y recuerda que solo considera los últimos 7 días.
Variantes y mejoras
1. Alertas a Telegram cuando se pasa un umbral. Es la extensión más útil y no está en el original. Con la librería UniversalTelegramBot y un bot creado con BotFather, puedes agregar dentro de logData() una comparación simple: si tempC supera tu umbral y no mandaste aviso en la última hora, envías un mensaje. Sirve para invernaderos, para el datacenter casero o para saber que la pieza del bebé bajó de 18 °C durante la noche.
2. Alargar la autonomía con deep sleep. Registrando cada 60 segundos y con el WiFi siempre activo, el ESP32 consume del orden de 80 a 120 mA y no dura nada con batería. Si aceptas consultar la página solo cada cierto rato, puedes subir interval a 10 o 15 minutos y usar esp_deep_sleep_start() entre lecturas: el consumo promedio baja a menos de 1 mA. Acuérdate de ajustar también MAX_GAP_MIN en el JavaScript.
3. Dos sensores para medir el aislamiento térmico de tu casa. Con un segundo BME280 en la dirección 0x77 puedes registrar interior y exterior en el mismo .csv y comparar las curvas. El desfase entre el peak exterior y el interior te dice, literalmente, cuántas horas tarda el calor en atravesar tus muros. Es el dato que más sirve antes de invertir en aislación.
Personalización para Chile
Todo lo necesario está en el catálogo de MechatronicStore, con stock al día de hoy:
- Placa ESP32 DevKit V1 (SKU X2-10V2). $7.990 CLP
- Sensor BME280 I2C 3.3V (SKU GR1-10). $8.690 CLP
- Módulo Lector Micro SD SPI (SKU K-110). $2.900 CLP
- Micro SD 16 GB (SKU B-450V3). $10.990 CLP
- Protoboard 830 puntos MB102 (SKU C-302). $3.790 CLP
- Cables macho macho 40 piezas 20 cm (SKU C-411). $1.990 CLP
- Cable USB a Micro USB (SKU X3-10). $1.290 CLP
Total aproximado: $37.640 CLP.
Equivalencias respecto del tutorial original: donde el autor menciona una "ESP32 development board" genérica, el ESP32 DevKit V1 del catálogo cumple exactamente la misma función y trae los mismos 30 pines con la numeración GPIO del diagrama. El "MicroSD card module" corresponde al Módulo Lector Micro SD SPI (SKU K-110); si prefieres el formato pensado para registro de datos, el Módulo datalogger (SKU G-014, $2.500) usa el mismo bus SPI y los mismos pines.
Un detalle de compra: la microSD de 8 GB (SKU B-450V1) cuesta $9.990 y la de 16 GB $10.990. Por mil pesos de diferencia conviene la de 16 GB, aunque para este proyecto sobra cualquiera: una fila del .csv pesa unos 45 bytes, así que un año completo registrando cada 60 segundos ocupa alrededor de 24 MB.
Recursos
- Tutorial original: ESP32 Web Server: Charts with Temperature Statistics. Random Nerd Tutorials
- Librería ESPAsyncWebServer: github.com/ESP32Async/ESPAsyncWebServer
- Librería AsyncTCP: github.com/ESP32Async/AsyncTCP
- Librería Adafruit BME280: github.com/adafruit/Adafruit_BME280_Library
- Documentación de Chart.js (gráficos del navegador): chartjs.org/docs
Versión chilena inspirada en el tutorial de Random Nerd Tutorials, con componentes en stock local en MechatronicStore.











