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:

  1. 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.
  2. 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.
  3. 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.

Diagrama de conexiones del ESP32 con el sensor BME280 por I2C y el módulo microSD por SPI en protoboard

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:

  1. Setup: inicializa Serial, conecta WiFi, arranca el BME280, monta la SD y sincroniza el reloj con un servidor NTP.
  2. Verificación de archivo: si /data.txt no existe en la SD, crea uno con el header CSV ("Epoch Time, Temperature, Humidity, Pressure").
  3. Loop temporizado: cada 30 segundos lee el sensor + epoch time, concatena los valores en formato CSV y anexa al archivo.
  4. Persistencia: cada appendFile() cierra el archivo después de escribir, así que un corte de luz NO corrompe los datos previos.
C++
// 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

  1. Pega tus credenciales WiFi en las variables ssid y password.
  2. Sube el código (selecciona "ESP32 Dev Module" en Tools > Board).
  3. 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.
  4. Deja correr unas horas o el tiempo que quieras. Después saca la SD, conéctala al PC y abre data.txt con Excel o LibreOffice (importar como CSV con coma como separador).
  5. Convierte la columna de Epoch time a fecha: en Excel usa =A2/86400+25569 y formatea como fecha; en Python con pandas usa pd.to_datetime(df['Epoch Time'], unit='s').

Monitor serial del IDE Arduino mostrando las lecturas del BME280 que se anexan al archivo data.txt cada 30 segundos

Archivo data.txt abierto en el PC mostrando las columnas Epoch Time, Temperature, Humidity y Pressure en formato CSV

Variantes y mejoras

Tres extensiones concretas más allá del proyecto base:

  1. Servidor web en el mismo ESP32 para descargar los datos sin sacar la SD: agrega la librería ESPAsyncWebServer y expón el archivo /data.txt por HTTP. Desde el celular conectado a la misma WiFi, abres http://<ip-del-esp>/data.txt y descargas las lecturas. Cero contacto físico con el dispositivo después de instalarlo.

  2. 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ón gmtime(&now) te da la fecha estructurada lista para componer el nombre del archivo.

  3. Bajo consumo con deep sleep para baterías: si vas a alimentar con baterías, el loop() con delay() está siempre despertando el ESP32 (consume ~80 mA). Reemplázalo por esp_deep_sleep_start() después de cada lectura, con esp_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 y bme.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

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