El problema: un Serial.print no es persistencia
Un sensor que solo imprime datos al monitor serial es útil para depurar, pero el día que quieras analizar las lecturas de una semana en Excel o construir un dashboard, vas a necesitar persistencia real. Este proyecto resuelve eso: un ESP32 lee temperatura, humedad y presión del BME280 cada 30 segundos y los anexa a un archivo CSV en una microSD, con marca de tiempo absoluta sincronizada por NTP.
Al final del tutorial vas a tener un dispositivo que se puede dejar en un invernadero, una bodega o una habitación durante días o semanas, y después leer la microSD en tu PC para encontrar un archivo CSV listo para graficar en cualquier hoja de cálculo o herramienta de análisis.
Por qué microSD y no la nube directamente
Mucha gente pregunta "¿no es mejor enviar todo a Firebase o ThingSpeak?". Sí... y no. Hay tres razones técnicas para grabar localmente:
- Autonomía: si el WiFi se cae 4 horas, la cloud no recibe nada. La microSD sigue grabando porque el timestamp se obtiene una vez al inicio y después el ESP32 mantiene el reloj internamente.
- Volumen: una lectura cada 30 segundos = 2.880 puntos por día. Subir eso a la cloud gratuita (ThingSpeak limita a 8 mil mensajes/día) se acaba en menos de tres días. En una microSD de 8 GB caben años de datos.
- Resiliencia: el dispositivo funciona sin internet residencial estable. Útil para mediciones de campo, invernaderos rurales o bodegas sin WiFi propio.
La combinación ideal es híbrida: graba SIEMPRE en SD y opcionalmente sincroniza a la cloud cuando hay WiFi. Este tutorial cubre la mitad indispensable (SD); la sincronización queda como variante al final.
Hardware: dos buses (I²C + SPI) sin conflicto
El proyecto usa dos periféricos en el mismo ESP32:
- BME280 por I²C (2 cables de datos: SDA + SCL) entrega temperatura, humedad y presión barométrica en un solo módulo.
- Módulo microSD por SPI (4 cables de datos: MOSI, MISO, CLK, CS).
Estas dos interfaces NO compiten porque usan pines distintos. La asignación recomendada:
BME280 ↔ ESP32:
| BME280 | ESP32 |
|---|---|
| VIN | 3.3 V |
| GND | GND |
| SCL | GPIO22 |
| SDA | GPIO21 |
Módulo microSD ↔ ESP32 (SPI):
| microSD | ESP32 |
|---|---|
| VCC | 3.3 V |
| GND | GND |
| CS | GPIO5 |
| MOSI | GPIO23 |
| CLK | GPIO18 |
| MISO | GPIO19 |
Trampa común con módulos SD: algunos vienen con regulador para 5 V, y aun así usar 3.3 V funciona, pero verifica el voltaje en el silkscreen del módulo antes de conectar. Si ves el LED del módulo muy tenue, prueba alimentar VCC con 5 V (la lógica SPI sigue siendo compatible con 3.3 V gracias al regulador interno).
Conflicto GPIO12: si tu placa ESP32 falla al bootear después de conectar la SD, revisa que MISO NO esté en GPIO12. Ese pin tiene una función de "strapping" durante el reset que puede impedir el arranque si está en HIGH cuando enciendes. Mueve MISO a GPIO19 (como en esta guía) y el problema desaparece.

Software: librerías necesarias
Desde el Library Manager del IDE Arduino, instala:
- Adafruit BME280 Library (de Adafruit)
- Adafruit Unified Sensor (dependencia de la anterior)
Las librerías para SD (FS.h, SD.h, SPI.h), WiFi (WiFi.h) y tiempo (time.h) ya vienen incluidas con el core ESP32, así que no hay que instalar nada extra.
Arquitectura del sketch
El programa hace cuatro cosas, en este orden:
- Setup: inicializa Serial, conecta WiFi, arranca el BME280, monta la SD y sincroniza el reloj con un servidor NTP.
- Verificación de archivo: si
/data.txtno existe en la SD, crea uno con el header CSV ("Epoch Time, Temperature, Humidity, Pressure"). - Loop temporizado: cada 30 segundos lee el sensor + epoch time, concatena los valores en formato CSV y anexa al archivo.
- Persistencia: cada
appendFile()cierra el archivo después de escribir, así que un corte de luz NO corrompe los datos previos.
// Librerías para la tarjeta SD
#include "FS.h"
#include "SD.h"
#include <SPI.h>
// Librerías para el sensor BME280
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
// Librerías para obtener tiempo del servidor NTP
#include <WiFi.h>
#include "time.h"
// Reemplaza con tus credenciales WiFi
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";
unsigned long lastTime = 0;
unsigned long timerDelay = 30000; // 30 segundos entre lecturas
Adafruit_BME280 bme;
float temp;
float hum;
float pres;
String dataMessage;
const char* ntpServer = "pool.ntp.org";
unsigned long epochTime;
unsigned long getTime() {
time_t now;
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) return 0;
time(&now);
return now;
}
void initWiFi() {
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
Serial.print("Conectando a WiFi");
while (WiFi.status() != WL_CONNECTED) {
Serial.print('.');
delay(1000);
}
Serial.println(WiFi.localIP());
}
void initBME() {
if (!bme.begin(0x76)) {
Serial.println("BME280 no detectado, revisa el cableado");
while (1);
}
}
void initSDCard() {
if (!SD.begin()) {
Serial.println("Falló el montaje de la SD");
return;
}
uint8_t cardType = SD.cardType();
if (cardType == CARD_NONE) {
Serial.println("No hay SD insertada");
return;
}
uint64_t cardSize = SD.cardSize() / (1024 * 1024);
Serial.printf("Tamaño SD: %lluMB\n", cardSize);
}
void writeFile(fs::FS &fs, const char * path, const char * message) {
File file = fs.open(path, FILE_WRITE);
if (!file) return;
file.print(message);
file.close();
}
void appendFile(fs::FS &fs, const char * path, const char * message) {
File file = fs.open(path, FILE_APPEND);
if (!file) return;
file.print(message);
file.close();
}
void setup() {
Serial.begin(115200);
initWiFi();
initBME();
initSDCard();
configTime(0, 0, ntpServer);
File file = SD.open("/data.txt");
if (!file) {
writeFile(SD, "/data.txt", "Epoch Time, Temperature, Humidity, Pressure \r\n");
}
file.close();
}
void loop() {
if ((millis() - lastTime) > timerDelay) {
epochTime = getTime();
temp = bme.readTemperature();
hum = bme.readHumidity();
pres = bme.readPressure() / 100.0F;
dataMessage = String(epochTime) + "," + String(temp) + "," + String(hum) + "," + String(pres) + "\r\n";
Serial.print("Guardando: ");
Serial.println(dataMessage);
appendFile(SD, "/data.txt", dataMessage.c_str());
lastTime = millis();
}
}
Por qué Epoch time y no fecha legible: el Unix timestamp es un entero de 10 dígitos. Es compacto, fácil de ordenar y todos los lenguajes (Python, JS, Excel) lo convierten a fecha legible con una sola función. Si guardas "2026-05-21 14:32:01" como string gastas 19 bytes por lectura; con epoch gastas 10. En un mes de logging la diferencia son varios MB.
Dirección I²C del BME280: el sketch usa 0x76. Algunos clones tienen la dirección 0x77 (depende de si el pin SDO está conectado a alto o a bajo). Si bme.begin(0x76) falla, prueba bme.begin(0x77).
Ejecución y extracción de datos
- Pega tus credenciales WiFi en las variables
ssidypassword. - Sube el código (selecciona "ESP32 Dev Module" en Tools > Board).
- Abre el Serial Monitor a 115200 baud. Vas a ver el IP local cuando se conecte y después un mensaje "Guardando:
, , , " cada 30 segundos. - Deja correr unas horas o el tiempo que quieras. Después saca la SD, conéctala al PC y abre
data.txtcon Excel o LibreOffice (importar como CSV con coma como separador). - Convierte la columna de Epoch time a fecha: en Excel usa
=A2/86400+25569y formatea como fecha; en Python con pandas usapd.to_datetime(df['Epoch Time'], unit='s').


Variantes y mejoras
Tres extensiones concretas más allá del proyecto base:
Servidor web en el mismo ESP32 para descargar los datos sin sacar la SD: agrega la librería
ESPAsyncWebServery expón el archivo/data.txtpor HTTP. Desde el celular conectado a la misma WiFi, abreshttp://<ip-del-esp>/data.txty descargas las lecturas. Cero contacto físico con el dispositivo después de instalarlo.Rotación de archivos por día: en lugar de un único
/data.txt, usa el epoch time para generar nombres como/2026-05-21.csv. Así no terminas con un archivo de 500 MB después de un año. La funcióngmtime(&now)te da la fecha estructurada lista para componer el nombre del archivo.Bajo consumo con deep sleep para baterías: si vas a alimentar con baterías, el
loop()condelay()está siempre despertando el ESP32 (consume ~80 mA). Reemplázalo poresp_deep_sleep_start()después de cada lectura, conesp_sleep_enable_timer_wakeup(30 * 1000000)para despertar cada 30 segundos. El consumo baja a ~10 µA mientras duerme: meses de autonomía con 4 pilas AA.
Personalización para Chile
Con stock local en MechatronicStore:
- ESP32 ESP-WROOM-32 Tipo C (SKU X2-10V2): $7.990 CLP. La placa de desarrollo del tutorial.
- Módulo datalogger microSD (SKU G-014): $2.500 CLP. Es el adaptador SPI que usas para conectar la microSD al ESP32.
- Micro SD 8 GB (SKU B-450V1): $7.990 CLP. Sobra y resta para meses de logging; si planeas años, considera la versión 64 GB (SKU B-450V2, $11.990 CLP).
- Cables macho-hembra 30 cm (SKU C-418): $1.990 CLP. Para conectar el sensor y el módulo SD a la breadboard.
- Protoboard 830 puntos MB102 (SKU C-302): $3.790 CLP.
Sobre el sensor BME280: actualmente NO tenemos el BME280 (humedad + presión + temperatura) combinado en una sola placa. Alternativas funcionalmente equivalentes con stock:
- Sensor de presión BMP180 (SKU GE1-10, $2.490 CLP) + HTU21D temperatura/humedad (SKU GE2-6, $4.490 CLP): te da los mismos 3 parámetros usando dos módulos. Total: $6.980 CLP. El código cambia poco: reemplazas
bme.readPressure()por la lectura del BMP180 ybme.readTemperature()/bme.readHumidity()por el HTU21D. - DHT22 (SKU GP1-7, $4.100 CLP): solo temperatura + humedad (sin presión). Si no necesitas la presión barométrica, es la opción más simple.
Costo total estimado (con BMP180 + HTU21D como sustitutos del BME280): ~$31.250 CLP.
Recursos
- Tutorial original (español): Registro de Datos con ESP32: Sensor BME280 y Tarjeta MicroSD, Soloelectronicos.com
- Librería Adafruit BME280: adafruit/Adafruit_BME280_Library en GitHub
- Documentación oficial ESP32, tarjeta SD: docs.espressif.com, Arduino ESP32 SD
- Servidor NTP usado: pool.ntp.org (red global pública).
Versión chilena con componentes en stock local en MechatronicStore.









