Dimensión Ambiental
Mide el aire que respiran tus alumnos
Tres dispositivos de bajo coste basados en el mismo hardware ESP32 + SCD41, cada uno optimizado para un uso distinto en el aula: indicación visual en tiempo real, orientación activa al docente, o registro histórico de datos. Código abierto.
¿Por qué medir el CO₂ en el aula?
El aire que respiramos en un espacio cerrado acumula dióxido de carbono (CO₂) con cada exhalación. En el exterior la concentración es de unos 420 ppm (partes por millón). En un aula mal ventilada con 25 alumnos puede superar fácilmente los 2.000–3.000 ppm en menos de una hora.
CO₂ y rendimiento cognitivo
Dos estudios de referencia cuantifican el efecto directamente. Allen et al. (2016), de la Harvard T.H. Chan School of Public Health, expusieron a 24 profesionales a tres niveles controlados de CO₂: ~550, ~945 y ~1.400 ppm. Las puntuaciones en nueve dominios cognitivos fueron un 15 % más bajas a ~945 ppm y un 50 % más bajas a ~1.400 ppm, comparadas con la condición de referencia. Satish et al. (2012), del Lawrence Berkeley National Laboratory, detectaron deterioro significativo en toma de decisiones ya a 1.000 ppm, agravado a 2.500 ppm.
Una revisión sistemática y metaanálisis (Fan et al., 2023) sobre quince estudios añade el dato más relevante para el aula: el impacto sobre las tareas cognitivas complejas es mayor en niños que en adultos, y comienza a manifestarse a concentraciones que habitualmente se consideran aceptables en edificios.
Allen et al. (2016) Environmental Health Perspectives, 124(6), 805–812 — Satish et al. (2012) Environmental Health Perspectives, 120(12), 1671–1677 — Fan et al. (2023) Building and Environment, 245, 110867.
Temperatura y humedad relativa
La temperatura óptima para el aprendizaje se sitúa entre 20 y 23 °C; por encima de 26 °C el rendimiento decrece. La humedad relativa ideal oscila entre el 40 % y el 60 %: fuera de ese rango aparecen problemas respiratorios o proliferación de ácaros y moho.
El RITE establece como límite de confort en aulas una concentración de CO₂
inferior a 900 ppm (inferior a 750 ppm en educación infantil).
Los umbrales por defecto del dispositivo son más permisivos y pueden ajustarse editando
UmbralesCO2::BUENO y UmbralesCO2::ALERTA en el código.
Los tres dispositivos
Los tres utilizan la placa Lolin D32 — que incorpora gestión de carga de batería LiPo — con el sensor SCD41 y el LED NeoPixel, y se alimentan con una batería LiPo de 2.000 mAh. Se diferencian exclusivamente por el firmware cargado y, en el caso del CO2 Tutor, por la adición de una pantalla OLED. Un mismo montaje puede convertirse en cualquiera de ellos con solo cambiar el sketch.
- LED NeoPixel siempre activo con 4 niveles: verde / amarillo / rojo / rojo persistente (alerta previa)
- Batería LiPo 2.000 mAh + light sleep adaptativo: 5 min (excelente), 2 min (bueno), 1 min (regular), 30 s (malo)
- WiFi y Bluetooth desactivados en producción para máximo ahorro
- Autonomía comprobada: ~15 días
- Offsets de calibración persistentes en NVS (flash del ESP32)
- Menú de calibración completo por Serial (modo DEBUG)
- Sin BLE en producción — calibración manual vía Serial o menú

- LED NeoPixel con 4 estados: Normal / Precaución / Alerta / Crítico
- Pantalla SH1106G 128×64 px: apagada en estado Normal, activa en Precaución, Alerta y Crítico
- Muestra CO2, temperatura, humedad y mensaje de acción específico por nivel
- Modo single-shot con light sleep entre mediciones
- Histéresis de 50 ppm para evitar oscilaciones de estado
- Sin BLE — calibración mediante menú Serie (modo DEBUG)
- Autonomía comprobada: 7–10 días
- LED NeoPixel con 3 estados: verde / ámbar / rojo parpadeante
- Publica mediciones en tiempo real por BLE (Sensirion Smart Gadget)
- Compatible con app MyAmbience: gráficas, historial y exportación CSV
- Calibración forzada (FRC) desde la app por BLE
- Batería LiPo 2.000 mAh; medición periódica cada 30 s
- Autonomía comprobada: 3–5 días
Tabla comparativa de funciones
| Función | CO2 Monitor | CO2 Tutor | CO2 Registrador |
|---|---|---|---|
| Sensor CO₂ (SCD41 NDIR) | ✓ | ✓ | ✓ |
| Sensor temperatura y humedad | ✓ | ✓ | ✓ |
| LED NeoPixel indicador de estado | ✓ | ✓ | ✓ |
| Niveles de CO₂ diferenciados por LED | 4 niveles | 4 niveles | 3 niveles |
| Publicación BLE en tiempo real | — | — | ✓ |
| App MyAmbience (iOS / Android) | — | — | ✓ |
| Calibración forzada FRC desde app BLE | — | — | ✓ |
| Calibración vía menú Serial (DEBUG) | ✓ | ✓ | — |
| Offsets persistentes en NVS (flash) | ✓ | ✓ | — |
| Pantalla OLED | — | ✓ | — |
| Mensajes de acción en pantalla | — | ✓ | — |
| OLED apagada en estado Normal | — | ✓ | — |
| Histéresis de estados (anti-oscilación) | — | ✓ 50 ppm | — |
| Modo de medición | Low-power periódico | Single-shot | Periódico continuo |
| Gestión de energía | LiPo 2.000 mAh + light sleep adaptativo | LiPo 2.000 mAh + light sleep entre ciclos | LiPo 2.000 mAh · BLE activo |
| WiFi / BT desactivados en producción | ✓ | — | — |
| Autonomía comprobada | ~15 días | 7–10 días | 3–5 días |
Los tres dispositivos comparten la misma placa (Lolin D32 + SCD41 + NeoPixel + batería LiPo). Para convertir cualquiera en CO2 Tutor basta con añadir la pantalla OLED SH1106G al bus I²C. Los tres firmwares se publican en este blog.
Hardware compartido
Los componentes de la tabla siguiente son comunes a los tres dispositivos. La placa Lolin D32 incorpora el circuito de carga LiPo, por lo que no se necesita módulo externo. Los tres dispositivos usan una batería LiPo de 2.000 mAh. El CO2 Tutor requiere además una pantalla OLED SH1106G.
| Componente | Función | |
|---|---|---|
| Sensirion SCD41 breakout board (p.ej. Adafruit o Sparkfun) |
CO₂ + temperatura + humedad (NDIR, I²C) | Los tres |
| Lolin D32 ESP32, carga LiPo integrada, 38 pines |
Microcontrolador con WiFi/BLE y gestión de batería LiPo | Los tres |
| NeoPixel WS2812B LED RGB individual |
Indicador visual de calidad del aire | Los tres |
| Pantalla OLED SH1106G 128×64 px, I²C (dirección 0x3C) |
Muestra lecturas e indicaciones de ventilación | CO2 Tutor |
| Batería LiPo 2.000 mAh conector JST-PH 2 mm compatible con Lolin D32 |
Alimentación autónoma (la gestión de carga la hace la Lolin D32) | Los tres |
| Cable USB-A a micro-USB | Programación y carga de la batería | Los tres |
Arduino IDE 2.x y multímetro básico. Para el CO2 Registrador: smartphone con la app MyAmbience (Sensirion, gratuita para iOS y Android).
Montaje del hardware
El circuito base es deliberadamente sencillo: el SCD41 se comunica con el ESP32 mediante I²C (dos hilos) y el NeoPixel se controla con un único pin digital. La pantalla OLED del CO2 Tutor comparte el mismo bus I²C que el sensor.

Conexiones base — ESP32 + SCD41 + NeoPixel (los tres dispositivos)
| Pin ESP32 | Destino | Color sugerido | Notas |
|---|---|---|---|
| GPIO 19 (SDA) | SCD41 — SDA | Azul | Bus I²C, datos |
| GPIO 22 (SCL) | SCD41 — SCL | Amarillo | Bus I²C, reloj |
| 3V3 | SCD41 — VCC | Rojo | 3,3 V obligatorio; el SCD41 no tolera 5 V |
| GND | SCD41 — GND | Negro | — |
| GPIO 4 | NeoPixel — DIN | Verde | — |
| 5V (VIN) | NeoPixel — +5 V | Rojo | — |
| GND | NeoPixel — GND | Negro | — |
Conexiones adicionales — CO2 Tutor (pantalla OLED)
| Pin ESP32 | Destino | Color sugerido | Notas |
|---|---|---|---|
| GPIO 19 (SDA) | OLED SH1106G — SDA | Azul | Mismo bus I²C que el SCD41 |
| GPIO 22 (SCL) | OLED SH1106G — SCL | Amarillo | Mismo bus I²C que el SCD41 |
| 3V3 | OLED SH1106G — VCC | Rojo | — |
| GND | OLED SH1106G — GND | Negro | — |



Notas de montaje
Verificar antes de fijar
Conecta los componentes con cables Dupont antes de fijarlos definitivamente, carga el firmware correspondiente y comprueba que el monitor serial muestra lecturas correctas y que el LED responde.
Ventilación del sensor
El SCD41 necesita circulación de aire. Deja al menos 5 mm libres alrededor del sensor. Sin ventilación las lecturas se estabilizarán con retraso.
Alejarlo de fuentes de calor
El PCB del ESP32 genera calor. Mantén el SCD41 al menos a 3–4 cm del microcontrolador. Cada firmware aplica sus propios offsets: el CO2 Monitor y CO2 Tutor los almacenan en NVS y son ajustables; el CO2 Registrador usa offsets fijos en el código.
El firmware explicado
Cada firmware tiene su propia estrategia de medición, calibración y comunicación. A continuación se describen las características distintivas de cada uno. Los tres códigos completos están disponibles en las secciones siguientes.
Diseñado para maximizar la autonomía de la batería LiPo. Desactiva WiFi y Bluetooth en producción
para eliminar el consumo de los subsistemas de radio. El intervalo de light sleep
varía automáticamente según el nivel de CO₂: 5 min en aire excelente,
2 min en bueno, 1 min en regular y 30 s en malo. El LED NeoPixel
permanece siempre activo como única interfaz de usuario.
Los offsets de calibración se guardan en la NVS del ESP32 y
sobreviven a los reinicios. El modo DEBUG_MODE 1 activa el monitor
serial y un menú completo de calibración (offsets, FRC, ASC, estadísticas).
// Intervalos de sleep según CO2:
CO2 <= 600 ppm → 300 s (excelente)
CO2 <= 800 ppm → 120 s (bueno)
CO2 <= 1000 ppm → 60 s (regular)
CO2 > 1000 ppm → 30 s (malo)
Usa single-shot: activa el sensor, espera ~5,5 s la medición y entra en light sleep entre ciclos, en lugar de mantenerlo midiendo continuamente. Define cuatro estados con una banda de histéresis de 50 ppm para evitar oscilaciones de LED y pantalla en valores límite. La pantalla SH1106G 128×64 se apaga en estado Normal y se activa en Precaución, Alerta y Crítico, mostrando el nivel de CO₂ y un mensaje de acción contextual. Los offsets y la altitud se persisten en NVS.
Normal : CO2 <= 1000 ppm — LED verde — pantalla OFF
Precaución : CO2 <= 1400 ppm — LED amarillo — "Atender vulnerables"
Alerta : CO2 <= 1600 ppm — LED naranja — "Abrir puerta/ventana"
Crítico : CO2 > 1600 ppm — LED rojo — "!! VENTILAR YA !!"
Único de los tres con Bluetooth LE activo. Se anuncia como Sensirion Smart Gadget y es compatible de forma nativa con la app MyAmbience (iOS/Android, gratuita): gráficas en tiempo real, exportación de historial a CSV y calibración forzada (FRC) desde el teléfono. El LED NeoPixel usa una tarea FreeRTOS dedicada en el Core 1 para que el parpadeo rojo sea exacto sin interferir con las mediciones del Core 0. Medición periódica cada 30 s con validación completa de rangos.
Código completo — los tres firmwares
Copia el código de cada dispositivo en un archivo nuevo del Arduino IDE, instala las librerías indicadas y cárgalo sobre el ESP32 correspondiente.
CO2 Monitor — v1.3.0
SparkFun SCD4x Arduino Library · Adafruit NeoPixel · Preferences (built-in ESP32)
// WWW.ACUSTICAESCOLAR.COM
// ═══════════════════════════════════════════════════════════════════
// SCD41 — Monitor de CO₂ con bajo consumo
// Plataforma : ESP32
// Sensor : Sensirion SCD41 (I2C)
// Firmware : v1.3.0
// ───────────────────────────────────────────────────────────────────
// Modos de operación
// DEBUG_MODE 1 — Serial activo, ciclo de 5 s, menú de calibración
// DEBUG_MODE 0 — Sin Serial, light sleep adaptativo (30–300 s)
// ═══════════════════════════════════════════════════════════════════
#include <Wire.h>
#include <Adafruit_NeoPixel.h>
#include "SparkFun_SCD4x_Arduino_Library.h"
#include "esp_wifi.h" // Deshabilitar WiFi en producción
#include "esp_bt.h" // Deshabilitar Bluetooth en producción
#include "driver/gpio.h" // Control de pines I2C durante sleep
#include <Preferences.h> // NVS: persistencia de offsets entre reinicios
#include "esp_task_wdt.h" // Watchdog por hardware
#define FIRMWARE_VERSION "1.3.0"
#define DEBUG_MODE 0 // 1 = depuración con Serial | 0 = producción
// ── Pines ────────────────────────────────────────────────────────────
#define I2C_SDA 19
#define I2C_SCL 22
#define LED_PIN 4
#define LED_COUNT 1
// ── Watchdog ─────────────────────────────────────────────────────────
// sleep máximo (300 s) + margen (60 s); cubre el peor caso en producción.
#define WDT_TIMEOUT_S 360
// ── Offsets de temperatura y humedad ─────────────────────────────────
// Los valores DEFAULT solo se usan la primera vez (NVS vacío).
// A partir del primer arranque se leen desde NVS y persisten
// entre reinicios. El menú de calibración los actualiza en caliente.
//
// TEMP_OFFSET_C → corrección vía setTemperatureOffset() del sensor.
// Valor positivo = el sensor reporta menos temperatura.
// Rango típico: 0–10 °C | Resolución: 0,01 °C
// HUMIDITY_OFFSET_RH → corrección en software (el SCD41 carece de comando
// nativo para HR). Se suma en getCorrectedHumidity().
// Rango típico: −20 a +20 %RH
#define TEMP_OFFSET_C_DEFAULT 2.0f // °C
#define HUMIDITY_OFFSET_RH_DEFAULT 0.0f // %RH
// ── Umbrales de CO₂ y política de muestreo ───────────────────────────
// Definen la calidad del aire y el intervalo de sleep adaptativo.
// Modificar aquí para ajustar la frecuencia de medición por nivel.
#define CO2_EXCELLENT_PPM 600 // ≤ este valor → aire excelente
#define CO2_GOOD_PPM 800 // ≤ este valor → aire bueno
#define CO2_FAIR_PPM 1000 // ≤ este valor → aire regular
// > CO2_FAIR_PPM → aire malo/crítico
#define SLEEP_EXCELLENT_S 300 // 5 min
#define SLEEP_GOOD_S 120 // 2 min
#define SLEEP_FAIR_S 60 // 1 min
#define SLEEP_POOR_S 30 // 30 s
// ── Menú de calibración (solo en DEBUG_MODE 1) ────────────────────────
#define SERIAL_INPUT_MAX 32 // Longitud máxima de entrada de usuario
#define MENU_TIMEOUT_MS 60000 // Timeout de inactividad en el menú (ms)
#define OFFSET_TIMEOUT_MS 30000 // Timeout de entrada de offset (ms)
#define FRC_STABILIZE_S 180 // Tiempo de estabilización antes del FRC (s)
// ── Instancias globales ───────────────────────────────────────────────
SCD4x mySensor;
Preferences prefs;
Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
// ── Variables de estado ───────────────────────────────────────────────
float tempOffsetC = TEMP_OFFSET_C_DEFAULT;
float humidityOffsetRH = HUMIDITY_OFFSET_RH_DEFAULT;
int alert = 0; // 0 = sin alerta previa | 1 = alerta activa
unsigned long sleepInterval = 30; // segundos; recalculado tras cada lectura
// ── NVS: carga y guardado de offsets ─────────────────────────────────
// Los offsets se almacenan en el namespace "scd41" de la flash NVS.
// Si la clave no existe (primer arranque), se usa el valor DEFAULT.
void nvsLoadOffsets() {
prefs.begin("scd41", true);
float t = prefs.getFloat("tempOff", TEMP_OFFSET_C_DEFAULT);
float rh = prefs.getFloat("humOff", HUMIDITY_OFFSET_RH_DEFAULT);
prefs.end();
// Valores fuera de rango físico se descartan y se sustituyen por DEFAULT
tempOffsetC = (t >= 0.0f && t <= 20.0f) ? t : TEMP_OFFSET_C_DEFAULT;
humidityOffsetRH = (rh >= -20.0f && rh <= 20.0f) ? rh : HUMIDITY_OFFSET_RH_DEFAULT;
}
void nvsSaveOffsets() {
prefs.begin("scd41", false);
prefs.putFloat("tempOff", tempOffsetC);
prefs.putFloat("humOff", humidityOffsetRH);
prefs.end();
}
// ═══════════════════════════════════════════════════════════════════
// MENÚ DE CALIBRACIÓN (compilado solo con DEBUG_MODE 1)
// ═══════════════════════════════════════════════════════════════════
#if DEBUG_MODE
#include <climits> // ULONG_MAX para inicializar co2Min en Stats
// ── Estadísticas de sesión ────────────────────────────────────────
// Acumula min/max/media de CO₂, temperatura y humedad desde el arranque.
// Se resetean únicamente al reiniciar el dispositivo.
struct Stats {
unsigned long co2Min = ULONG_MAX, co2Max = 0;
float tMin = 999, tMax = -999;
float rhMin = 999, rhMax = -999;
double co2Sum = 0, tSum = 0, rhSum = 0;
unsigned long count = 0;
unsigned long startMs = 0; // millis() en el momento de inicializar
} stats;
// ── Impresión de menús ────────────────────────────────────────────
void printMenuHeader() {
Serial.println(F("\n╔══════════════════════════════════════╗"));
Serial.println(F("║ MENÚ DE CALIBRACIÓN SCD41 ║"));
Serial.print (F("║ Firmware v"));
Serial.print (F(FIRMWARE_VERSION));
Serial.println(F(" ║"));
Serial.println(F("╠══════════════════════════════════════╣"));
}
void printMainMenu() {
printMenuHeader();
Serial.println(F("║ 1. Ver configuración actual ║"));
Serial.println(F("║ 2. Offset de temperatura (°C) ║"));
Serial.println(F("║ 3. Offset de humedad (%RH) ║"));
Serial.println(F("║ 4. Calibración forzada CO2 (FRC) ║"));
Serial.println(F("║ 5. Recalibración automática (ASC) ║"));
Serial.println(F("║ 6. Guardar en EEPROM del sensor ║"));
Serial.println(F("║ 7. Estadísticas de sesión ║"));
Serial.println(F("║ 0. Salir del menú ║"));
Serial.println(F("╚══════════════════════════════════════╝"));
Serial.print(F("Opción: "));
}
void printCurrentConfig() {
Serial.println(F("\n── Configuración actual ───────────────"));
Serial.print(F(" Firmware : v")); Serial.println(F(FIRMWARE_VERSION));
Serial.print(F(" Offset temperatura : ")); Serial.print(tempOffsetC, 2); Serial.println(F(" °C"));
Serial.print(F(" Offset humedad : ")); Serial.print(humidityOffsetRH, 1); Serial.println(F(" %RH"));
Serial.println(F("───────────────────────────────────────"));
}
void printStats() {
if (stats.count == 0) { Serial.println(F("\n Sin lecturas todavía.")); return; }
unsigned long upSec = (millis() - stats.startMs) / 1000UL;
unsigned long upMin = upSec / 60;
unsigned long upH = upMin / 60;
Serial.println(F("\n── Estadísticas de sesión ─────────────"));
Serial.print(F(" Tiempo activo : "));
if (upH) { Serial.print(upH); Serial.print(F("h ")); }
Serial.print(upMin % 60); Serial.print(F("m "));
Serial.print(upSec % 60); Serial.println(F("s"));
Serial.print(F(" Lecturas : ")); Serial.println(stats.count);
Serial.println(F(" Min Media Max"));
Serial.print(F(" CO2 (ppm) "));
Serial.print(stats.co2Min); Serial.print(F(" "));
Serial.print((unsigned long)(stats.co2Sum / stats.count)); Serial.print(F(" "));
Serial.println(stats.co2Max);
Serial.print(F(" Temp (°C) "));
Serial.print(stats.tMin, 1); Serial.print(F(" "));
Serial.print((float)(stats.tSum / stats.count), 1); Serial.print(F(" "));
Serial.println(stats.tMax, 1);
Serial.print(F(" HR (%RH) "));
Serial.print(stats.rhMin, 1); Serial.print(F(" "));
Serial.print((float)(stats.rhSum / stats.count), 1); Serial.print(F(" "));
Serial.println(stats.rhMax, 1);
Serial.println(F("───────────────────────────────────────"));
}
// Enter sin datos cancela (devuelve true con out vacío).
// Timeout sin actividad devuelve false. WDT alimentado cada 5 s.
bool readSerialLine(String &out, unsigned long timeoutMs = OFFSET_TIMEOUT_MS) {
out = "";
bool gotCr = false;
unsigned long t0 = millis();
unsigned long wdtLast = millis();
while (millis() - t0 < timeoutMs) {
if (millis() - wdtLast >= 5000UL) { esp_task_wdt_reset(); wdtLast = millis(); }
if (Serial.available()) {
char c = (char)Serial.read();
if (c == '\n' || c == '\r') {
if (out.length() > 0) break; // línea con datos — terminar
if (!gotCr) { gotCr = true; break; } // Enter en vacío — cancelar
} else if (out.length() < SERIAL_INPUT_MAX) {
out += c;
gotCr = false;
}
// Caracteres que exceden SERIAL_INPUT_MAX se descartan
}
}
out.trim();
return gotCr || out.length() > 0;
}
// Solicita, valida y guarda en NVS un offset. Si applyToSensor, lo aplica al sensor.
void handleMenuOffset(const char* label, const char* unit,
float &targetVar, float minVal, float maxVal,
bool applyToSensor) {
Serial.print(F("\nValor actual: ")); Serial.print(targetVar, 2); Serial.println(unit);
Serial.print(F("Nuevo valor (")); Serial.print(minVal, 1);
Serial.print(F(" a ")); Serial.print(maxVal, 1); Serial.print(unit); Serial.print(F("): "));
String input;
if (!readSerialLine(input)) { Serial.println(F("\nTiempo agotado. Sin cambios.")); return; }
if (input.length() == 0) { Serial.println(F("\nCancelado.")); return; }
// Verificar que la entrada contiene al menos un dígito antes de parsear
bool hasDigit = false;
for (unsigned int i = 0; i < input.length(); i++) {
if (isdigit(input[i]) || input[i] == '.' || input[i] == '-') { hasDigit = true; break; }
}
if (!hasDigit) { Serial.println(F("\nEntrada no válida. Sin cambios.")); return; }
float val = input.toFloat();
if (val < minVal || val > maxVal) {
Serial.print(F("\nFuera de rango [")); Serial.print(minVal, 1);
Serial.print(F(", ")); Serial.print(maxVal, 1); Serial.println(F("]. Sin cambios."));
return;
}
targetVar = val;
nvsSaveOffsets();
if (applyToSensor) {
// El SCD41 requiere detener las mediciones para modificar el offset de T
mySensor.stopPeriodicMeasurement(); delay(500);
mySensor.setTemperatureOffset(val); delay(100);
mySensor.startLowPowerPeriodicMeasurement();
}
Serial.print(F("\n✓ ")); Serial.print(label);
Serial.print(F(" → ")); Serial.print(val, 2); Serial.print(unit);
Serial.println(F(" (guardado en NVS)"));
}
// Solicita y valida la referencia de CO₂ para el FRC.
// Devuelve true y escribe el valor en ref si la entrada es válida.
bool promptFrcReference(uint16_t &ref) {
Serial.println(F("\n── Calibración Forzada CO2 (FRC) ─────"));
Serial.println(F(" El sensor necesita >= 3 min midiendo"));
Serial.println(F(" en aire de referencia antes del FRC."));
Serial.println(F(" Coloca el sensor en exterior o zona"));
Serial.println(F(" muy ventilada antes de continuar."));
Serial.println(F(" Escribe el CO2 de referencia en ppm"));
Serial.println(F(" (p.ej. 420 para aire exterior)"));
Serial.println(F(" o pulsa Enter sin valor para cancelar."));
Serial.print(F(" Referencia CO2 (ppm): "));
String input;
if (!readSerialLine(input)) { Serial.println(F("\nTiempo agotado. Sin cambios.")); return false; }
if (input.length() == 0) { Serial.println(F("\nCancelado.")); return false; }
ref = (uint16_t)input.toInt();
if (ref < 400 || ref > 2000) {
Serial.println(F("\nFuera de rango (400–2000 ppm). Sin cambios."));
return false;
}
return true;
}
// Cuenta atrás de estabilización antes del FRC.
// Alimenta el watchdog cada segundo. Devuelve false si el usuario cancela con 'c' o Enter.
bool waitForFrcStabilization() {
Serial.println(F("\n Estabilizando sensor... ('c' o Enter para cancelar)"));
for (int s = FRC_STABILIZE_S; s > 0; s--) {
Serial.print(F("\r Tiempo restante: "));
if (s < 10) Serial.print(F(" "));
else if (s < 100) Serial.print(F(" "));
Serial.print(s); Serial.print(F(" s "));
delay(1000);
esp_task_wdt_reset();
if (Serial.available()) {
char c = (char)Serial.read();
if (c == 'c' || c == 'C' || c == '\n' || c == '\r') {
Serial.println(F("\n\nCancelado."));
return false;
}
}
}
return true;
}
// Ejecuta el FRC con la referencia dada y valida el resultado.
// Un resultado fuera de ±400 ppm indica que el sensor no estaba listo.
void executeFrc(uint16_t ref) {
Serial.println(F("\n Ejecutando FRC..."));
mySensor.stopPeriodicMeasurement(); delay(500);
float correction = 0.0f;
bool ok = mySensor.performForcedRecalibration(ref, &correction);
mySensor.startLowPowerPeriodicMeasurement();
if (!ok || correction < -400.0f || correction > 400.0f) {
Serial.println(F("\n✗ FRC fallido o resultado inválido."));
Serial.println(F(" Verifica >= 3 min de medición en aire de referencia."));
} else {
Serial.print(F("\n✓ FRC completado. Corrección: "));
Serial.print(correction, 1); Serial.println(F(" ppm"));
}
}
// Calibración Forzada de CO₂ (FRC) — orquesta los tres pasos.
void handleFRC() {
uint16_t ref;
if (!promptFrcReference(ref)) return;
if (!waitForFrcStabilization()) return;
executeFrc(ref);
}
// Activa o desactiva la recalibración automática (ASC) del sensor.
// ASC ajusta el offset de CO₂ asumiendo exposición periódica a aire limpio.
void handleASC() {
Serial.println(F("\n── Recalibración Automática (ASC) ────"));
Serial.println(F(" 1. Activar 2. Desactivar"));
Serial.print(F(" Opción: "));
String input;
if (!readSerialLine(input)) { Serial.println(F("\nTiempo agotado. Sin cambios.")); return; }
if (input.length() == 0) { Serial.println(F("\nCancelado.")); return; }
mySensor.stopPeriodicMeasurement(); delay(500);
if (input == "1") { mySensor.setAutomaticSelfCalibrationEnabled(true); Serial.println(F("\n✓ ASC activado.")); }
else if (input == "2") { mySensor.setAutomaticSelfCalibrationEnabled(false); Serial.println(F("\n✓ ASC desactivado.")); }
else { mySensor.startLowPowerPeriodicMeasurement(); Serial.println(F("\nOpción no válida.")); return; }
mySensor.startLowPowerPeriodicMeasurement();
}
// Persiste la configuración interna del sensor (offset T, ASC, altitud…)
// en su EEPROM, y también los offsets de software en NVS del ESP32.
void handlePersist() {
mySensor.stopPeriodicMeasurement(); delay(500);
mySensor.persistSettings();
mySensor.startLowPowerPeriodicMeasurement();
nvsSaveOffsets();
Serial.println(F("\n✓ Configuración guardada:"));
Serial.println(F(" · EEPROM del sensor (offset T, ASC, altitud…)"));
Serial.println(F(" · NVS del ESP32 (offsets temperatura y humedad)"));
}
// Bucle principal del menú. Bloqueante: solo retorna cuando el usuario
// elige la opción 0, pulsa Enter o se agota el timeout de 60 s.
void runCalibrationMenu() {
bool running = true;
while (running) {
printMainMenu();
String input;
if (!readSerialLine(input, MENU_TIMEOUT_MS)) {
Serial.println(F("\nTiempo agotado. Reanudando medición."));
break;
}
if (input.length() == 0) {
Serial.println(F("\nReanudando medición…\n"));
break;
}
switch (input.toInt()) {
case 1: printCurrentConfig(); break;
case 2: handleMenuOffset("Offset temperatura", " °C", tempOffsetC, 0.0f, 20.0f, true); break;
case 3: handleMenuOffset("Offset humedad", " %RH", humidityOffsetRH, -20.0f, 20.0f, false); break;
case 4: handleFRC(); break;
case 5: handleASC(); break;
case 6: handlePersist(); break;
case 7: printStats(); break;
case 0: Serial.println(F("\nReanudando medición…\n")); running = false; break;
default: Serial.println(F("\nOpción no reconocida.")); break;
}
}
}
#endif // DEBUG_MODE
// ═══════════════════════════════════════════════════════════════════
// FUNCIONES DE MEDICIÓN Y LED
// ═══════════════════════════════════════════════════════════════════
// Devuelve la humedad con el offset de software aplicado, limitada a [0, 100].
float getCorrectedHumidity() {
float rh = mySensor.getHumidity() + humidityOffsetRH;
if (rh < 0.0f) rh = 0.0f;
if (rh > 100.0f) rh = 100.0f;
return rh;
}
// Intervalo más corto a mayor CO₂ para detectar cambios rápidamente.
unsigned long calculateSleepInterval(unsigned long CO2s) {
if (CO2s <= CO2_EXCELLENT_PPM) return SLEEP_EXCELLENT_S;
if (CO2s <= CO2_GOOD_PPM) return SLEEP_GOOD_S;
if (CO2s <= CO2_FAIR_PPM) return SLEEP_FAIR_S;
return SLEEP_POOR_S;
}
// Actualiza el LED NeoPixel según el nivel de CO₂:
// Verde ≤ CO2_GOOD_PPM — aire bueno
// Amarillo entre umbrales — primera alerta (permanece rojo si ya había alerta)
// Rojo ≥ CO2_FAIR_PPM — aire malo; activa el flag de alerta
void updateLED(unsigned long CO2s) {
if (CO2s <= CO2_GOOD_PPM) {
strip.setPixelColor(0, 0, 254, 0); strip.setBrightness(15); strip.show();
alert = 0;
}
if (CO2s > CO2_GOOD_PPM && CO2s < CO2_FAIR_PPM) {
if (alert == 0) { strip.setPixelColor(0, 254, 191, 0); strip.setBrightness(20); strip.show(); }
if (alert == 1) { strip.setPixelColor(0, 254, 0, 0); strip.setBrightness(20); strip.show(); }
}
if (CO2s >= CO2_FAIR_PPM) {
strip.setPixelColor(0, 254, 0, 0); strip.setBrightness(25); strip.show();
alert = 1;
}
}
// ═══════════════════════════════════════════════════════════════════
// FUNCIONES DE INICIALIZACIÓN
// ═══════════════════════════════════════════════════════════════════
// Ahorra ~1–3 mA constantes al eliminar los subsistemas de radio.
void disableRadios() {
esp_wifi_deinit();
btStop();
esp_bt_controller_disable();
}
// DEBUG_MODE 1: 80 MHz + Serial a 115200. DEBUG_MODE 0: 3 MHz sin Serial.
void initSerial() {
#if DEBUG_MODE
setCpuFrequencyMhz(80);
Serial.begin(115200);
delay(500);
Serial.println(F("SCD41 — Monitor CO2"));
Serial.println(F("Escribe 'm' para abrir el menú de calibración."));
#else
setCpuFrequencyMhz(3);
#endif
}
// Parpadeo rojo continuo hasta intervención manual.
// El WDT no está activo en este punto, por lo que no hay reinicio en bucle.
void haltWithErrorLed() {
while (1) {
strip.setPixelColor(0, 254, 0, 0); strip.setBrightness(30); strip.show(); delay(300);
strip.setPixelColor(0, 0, 0, 0); strip.show(); delay(300);
}
}
// Inicializa I2C y el sensor. Llama a haltWithErrorLed() si no se detecta.
void initSensor() {
Wire.begin(I2C_SDA, I2C_SCL);
if (mySensor.begin() == false) {
#if DEBUG_MODE
Serial.println(F("Sensor no detectado. Revisa el cableado."));
#endif
haltWithErrorLed();
}
}
// Aplica offsets, altitud y presión; persiste en EEPROM e inicia low power mode.
void configureSensor() {
mySensor.stopPeriodicMeasurement();
#if DEBUG_MODE
Serial.print(F("Offset T actual en sensor: "));
Serial.println(mySensor.getTemperatureOffset(), 2);
#endif
mySensor.setTemperatureOffset(tempOffsetC);
#if DEBUG_MODE
// getTemperatureOffset() devuelve 0 hasta el siguiente ciclo; se usa tempOffsetC como fuente de verdad.
Serial.print(F("Offset T → ")); Serial.print(tempOffsetC, 2);
Serial.println(F(" °C (aplicado)"));
Serial.print(F("Offset HR (software) → ")); Serial.print(humidityOffsetRH, 1); Serial.println(F(" %RH"));
#endif
mySensor.setSensorAltitude(121); // 121 m — Els Pallaresos
mySensor.setAmbientPressure(99787); // 99787 Pa — presión normal a 121 m
#if DEBUG_MODE
Serial.print(F("Altitud: ")); Serial.print(mySensor.getSensorAltitude()); Serial.println(F(" m"));
#endif
mySensor.persistSettings();
#if DEBUG_MODE
char serialNumber[13];
if (mySensor.getSerialNumber(serialNumber)) {
Serial.print(F("S/N sensor: 0x")); Serial.println(serialNumber);
}
#endif
mySensor.startLowPowerPeriodicMeasurement();
#if DEBUG_MODE
Serial.println(F("Modo low power activo."));
#endif
}
// Apaga el bus I2C y configura SDA/SCL como entradas para evitar fuga de corriente.
void closeI2C() {
Wire.end();
gpio_set_direction((gpio_num_t)I2C_SDA, GPIO_MODE_INPUT);
gpio_set_direction((gpio_num_t)I2C_SCL, GPIO_MODE_INPUT);
}
// Reconfigura el TWDT existente (Arduino lo inicializa antes del setup)
// y registra la tarea del loop con timeout WDT_TIMEOUT_S.
void initWatchdog() {
const esp_task_wdt_config_t wdtConfig = {
.timeout_ms = WDT_TIMEOUT_S * 1000U,
.idle_core_mask = 0,
.trigger_panic = true
};
esp_task_wdt_reconfigure(&wdtConfig);
esp_task_wdt_add(NULL);
#if DEBUG_MODE
Serial.print(F("Watchdog: reinicio tras "));
Serial.print(WDT_TIMEOUT_S); Serial.println(F(" s sin respuesta."));
#endif
}
// ═══════════════════════════════════════════════════════════════════
// SETUP
// ═══════════════════════════════════════════════════════════════════
void setup()
{
disableRadios();
initSerial();
nvsLoadOffsets();
#if DEBUG_MODE
Serial.print(F("NVS → tempOffset: ")); Serial.print(tempOffsetC, 2);
Serial.print(F(" °C | humOffset: ")); Serial.print(humidityOffsetRH, 1);
Serial.println(F(" %RH"));
#endif
strip.begin();
strip.show();
initSensor();
configureSensor();
closeI2C();
initWatchdog();
#if DEBUG_MODE
stats.startMs = millis();
#endif
}
// ═══════════════════════════════════════════════════════════════════
// FUNCIONES DE CICLO PRINCIPAL
// ═══════════════════════════════════════════════════════════════════
// Lee el sensor, actualiza LED, estadísticas e intervalo de sleep.
void takeMeasurement() {
Wire.begin(I2C_SDA, I2C_SCL);
if (mySensor.readMeasurement()) {
unsigned long CO2s = mySensor.getCO2();
float temp = mySensor.getTemperature();
float correctedRH = getCorrectedHumidity();
sleepInterval = calculateSleepInterval(CO2s);
#if DEBUG_MODE
stats.count++;
stats.co2Sum += CO2s; stats.tSum += temp; stats.rhSum += correctedRH;
if (CO2s < stats.co2Min) stats.co2Min = CO2s;
if (CO2s > stats.co2Max) stats.co2Max = CO2s;
if (temp < stats.tMin) stats.tMin = temp;
if (temp > stats.tMax) stats.tMax = temp;
if (correctedRH < stats.rhMin) stats.rhMin = correctedRH;
if (correctedRH > stats.rhMax) stats.rhMax = correctedRH;
Serial.println();
Serial.print(F("CO2(ppm):")); Serial.print(CO2s);
Serial.print(F("\tT(C):")); Serial.print(temp, 1);
Serial.print(F("\tHR(%):")); Serial.print(correctedRH, 1);
Serial.print(F("\tProx: ")); Serial.print(sleepInterval); Serial.print(F("s [m=menú]"));
Serial.println();
Serial.flush();
#endif
updateLED(CO2s);
} else {
#if DEBUG_MODE
Serial.print(F(".")); // Sensor aún sin datos listos
Serial.flush();
#endif
}
Wire.end();
gpio_set_direction((gpio_num_t)I2C_SDA, GPIO_MODE_INPUT);
gpio_set_direction((gpio_num_t)I2C_SCL, GPIO_MODE_INPUT);
}
// Espera activa de 5 s con polling del Serial para detectar 'm'.
// Usada en debug porque el UART se suspende durante el light sleep.
#if DEBUG_MODE
void waitWithMenuPolling() {
unsigned long t0 = millis();
while (millis() - t0 < 5000) {
if (Serial.available()) {
char c = (char)Serial.read();
if (c == 'm' || c == 'M') {
Wire.begin(I2C_SDA, I2C_SCL);
runCalibrationMenu();
closeI2C();
esp_task_wdt_reset();
}
}
delay(10);
}
}
#endif // DEBUG_MODE
// Light sleep adaptativo (sleepInterval segundos).
// Desregistra el WDT antes de dormir y lo reregistra al despertar para evitar
// disparo espurio (el contador puede seguir activo en algunas versiones del IDF).
void sleepUntilNextMeasurement() {
esp_task_wdt_delete(NULL);
esp_sleep_enable_timer_wakeup((uint64_t)sleepInterval * 1000000ULL);
esp_light_sleep_start();
esp_task_wdt_add(NULL);
esp_task_wdt_reset();
}
// ═══════════════════════════════════════════════════════════════════
// LOOP
// ═══════════════════════════════════════════════════════════════════
void loop()
{
takeMeasurement();
esp_task_wdt_reset();
#if DEBUG_MODE
waitWithMenuPolling();
#else
sleepUntilNextMeasurement();
#endif
}
CO2 Tutor — v14
SensirionI2CScd4x v0.4.0 · Adafruit NeoPixel 1.15.1 · Adafruit GFX Library 1.12.1 · Adafruit SH110X 2.1.12 · Preferences (built-in ESP32)
// WWW.ACUSTICAESCOLAR.COM
// ═══════════════════════════════════════════════════════════════════
// Monitor CO2 — SCD41 | ESP32 | v14
// Plataforma : ESP32 @ 80 MHz
// Sensor : Sensirion SCD41 — CO2, temperatura, humedad (single-shot)
// Display : OLED SH1106G 128×64 — se apaga en estado Normal
// LED : NeoPixel — color según nivel de CO2
// ═══════════════════════════════════════════════════════════════════
// Librerías
// Wire (built-in)
// Adafruit NeoPixel 1.15.1 — github.com/adafruit/Adafruit_NeoPixel
// Sensirion I2C SCD4x 0.4.0 — github.com/Sensirion/arduino-i2c-scd4x
// Adafruit GFX Library 1.12.1 — github.com/adafruit/Adafruit-GFX-Library
// Adafruit SH110X 2.1.12 — github.com/adafruit/Adafruit_SH110x
// Preferences (built-in ESP32 Arduino Core)
// ═══════════════════════════════════════════════════════════════════
#include <Wire.h>
#include <Adafruit_NeoPixel.h>
#include "SensirionI2CScd4x.h"
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
#include <esp_task_wdt.h>
#include <esp_sleep.h>
#include <Preferences.h>
#include <climits>
// ── Modo de operación ─────────────────────────────────────────────────────────
// DEBUG_SERIAL 1 : Serial activo, ciclo 10 s, menú de calibración con 'm'
// DEBUG_SERIAL 0 : sin Serial, light sleep adaptativo entre mediciones
#define DEBUG_SERIAL 0
#define FIRMWARE_VERSION "v14"
// ── Pines y direcciones I2C ───────────────────────────────────────────────────
static const uint8_t PIN_SDA = 19;
static const uint8_t PIN_SCL = 22;
static const uint8_t ADDR_OLED = 0x3C;
static const uint8_t ADDR_SCD41 = 0x62;
static const uint8_t OLED_WIDTH = 128;
static const uint8_t OLED_HEIGHT = 64;
static const uint8_t LED_PIN = 4;
static const uint8_t LED_COUNT = 1;
// ── Menú serie (solo con DEBUG_SERIAL 1) ─────────────────────────────────────
static const uint32_t MENU_TIMEOUT_MS = 60000; // inactividad máxima en el menú
static const uint32_t OFFSET_TIMEOUT_MS = 30000; // espera de entrada de valor
static const uint16_t FRC_STABILIZE_S = 180; // estabilización previa al FRC
static const uint8_t SERIAL_INPUT_MAX = 32; // bytes máximos por línea
// ── Niveles de calidad del aire ───────────────────────────────────────────────
enum class EstadoCO2 : uint8_t {
Normal = 0,
Precaucion = 1,
Alerta = 2,
Critico = 3
};
enum class CodigoError : uint8_t {
SensorInit = 1,
SensorLectura = 2,
DisplayInit = 3,
I2CComm = 4
};
// sensor_leer() devuelve este struct; no muta variables globales.
struct Medicion {
uint16_t co2 = 0;
float temp = 0.0f;
float hum = 0.0f;
bool valida = false;
};
// Parámetros de comportamiento — fijos en compilación, no van a NVS.
struct Config {
// Umbrales CO2 (ppm) y banda de histéresis
uint16_t co2_normal_max = 1000;
uint16_t co2_precaucion_max = 1400;
uint16_t co2_alerta_max = 1600;
uint16_t histeresis = 50;
// Brillo NeoPixel por nivel (0–255)
uint8_t brillo_normal = 10;
uint8_t brillo_precaucion = 125;
uint8_t brillo_alerta = 200;
uint8_t brillo_critico = 255;
// Intervalo entre mediciones según nivel (ms)
uint32_t intervalo_normal = 300000; // 5 min
uint32_t intervalo_precaucion = 60000; // 1 min
uint32_t intervalo_alerta = 30000; // 30 s
uint32_t intervalo_serial = 10000; // periodo del print de estado
uint8_t cpu_mhz = 80;
};
// Parámetros ajustables por el usuario — se cargan de NVS al arrancar
// y se guardan automáticamente al cambiarlos desde el menú.
// tempOffsetC : setTemperatureOffset(). Rango 0–20 °C.
// Valor positivo → el sensor reporta menos temperatura.
// humidityOffsetRH : corrección software (el SCD41 no tiene registro propio).
// Rango −20 a +20 %RH.
// altitud_m : setSensorAltitude(). Corrección CO2 via atmósfera ISA.
// Rango 0–3000 m.
struct Calibracion {
float tempOffsetC = 0.0f;
float humidityOffsetRH = 0.0f;
uint16_t altitud_m = 1;
};
// ── Estado global ─────────────────────────────────────────────────────────────
Config cfg;
Calibracion cal;
Preferences prefs;
TwoWire bus(1);
Adafruit_SH1106G display(OLED_WIDTH, OLED_HEIGHT, &bus, -1);
SensirionI2CScd4x sensor;
Adafruit_NeoPixel led(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
EstadoCO2 estado = EstadoCO2::Normal;
Medicion ultima; // última lectura válida — fuente de verdad para display y serial
bool watchdog_ok = false;
unsigned long t_ultimo_sensor = 0;
unsigned long t_ultimo_display = 0;
unsigned long t_ultimo_serial = 0;
// ── NVS — namespace "scd41t" ──────────────────────────────────────────────────
// Claves: tempOff (float), humOff (float), altitud (int).
// Primer arranque: las claves no existen y se usan los defaults de Calibracion.
void nvs_cargar() {
prefs.begin("scd41t", true);
float t = prefs.getFloat("tempOff", cal.tempOffsetC);
float rh = prefs.getFloat("humOff", cal.humidityOffsetRH);
int alt = prefs.getInt ("altitud", (int)cal.altitud_m);
prefs.end();
// Descartar valores fuera de rango (flash corrupta o primer arranque)
if (t >= 0.0f && t <= 20.0f) cal.tempOffsetC = t;
if (rh >= -20.0f && rh <= 20.0f) cal.humidityOffsetRH = rh;
if (alt >= 0 && alt <= 3000) cal.altitud_m = (uint16_t)alt;
#if DEBUG_SERIAL
Serial.print("NVS → tempOff: "); Serial.print(cal.tempOffsetC, 2);
Serial.print(" C humOff: "); Serial.print(cal.humidityOffsetRH, 1);
Serial.print(" %RH altitud: "); Serial.print(cal.altitud_m);
Serial.println(" m");
#endif
}
void nvs_guardar() {
prefs.begin("scd41t", false);
prefs.putFloat("tempOff", cal.tempOffsetC);
prefs.putFloat("humOff", cal.humidityOffsetRH);
prefs.putInt ("altitud", (int)cal.altitud_m);
prefs.end();
}
// ── Watchdog ──────────────────────────────────────────────────────────────────
// El WDT de Arduino Core v3.x es incompatible con el delay(5500) del sensor.
// Se desactiva en setup(); watchdog_ok permite reactivarlo si se añade soporte.
void watchdog_init() {
esp_task_wdt_delete(NULL);
watchdog_ok = false;
#if DEBUG_SERIAL
Serial.println("Watchdog: desactivado");
#endif
}
void watchdog_reset() { /* reservado para futura reactivación */ }
// ── Sleep ─────────────────────────────────────────────────────────────────────
void dormir_ms(uint32_t ms) {
esp_sleep_enable_timer_wakeup((uint64_t)ms * 1000ULL);
esp_light_sleep_start();
}
// ── LED ───────────────────────────────────────────────────────────────────────
void led_set(uint32_t color, uint8_t brillo) {
led.setBrightness(brillo);
led.setPixelColor(0, color);
led.show();
}
void led_actualizar(EstadoCO2 e) {
switch (e) {
case EstadoCO2::Normal: led_set(led.Color( 0, 255, 0), cfg.brillo_normal); break;
case EstadoCO2::Precaucion: led_set(led.Color(255, 191, 0), cfg.brillo_precaucion); break;
case EstadoCO2::Alerta: led_set(led.Color(255, 100, 0), cfg.brillo_alerta); break;
case EstadoCO2::Critico: led_set(led.Color(255, 0, 0), cfg.brillo_critico); break;
default: led_set(led.Color( 0, 0, 255), 50); break;
}
}
void led_error_parpadeo() {
for (int i = 0; i < 20; i++) {
led_set(led.Color(0, 0, 255), 100); delay(250);
led_set(led.Color(0, 0, 0), 0); delay(250);
}
}
// ── Display ───────────────────────────────────────────────────────────────────
void display_mostrar_arranque() {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
display.setCursor(0, 0);
display.println("Monitor CO2");
display.println("Inicializando...");
display.display();
}
void display_mostrar_config() {
display.clearDisplay();
display.setCursor(0, 0);
display.println("SCD41 Configurado");
display.println("Modo: Single-shot");
display.println("Normal: 5 min");
display.println("Precau.: 60 s");
display.println("Alerta: 30 s");
display.display();
}
void display_mostrar_error(CodigoError codigo, const char* mensaje) {
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
display.println("ERROR DEL SISTEMA");
display.println("");
display.print("Codigo: "); display.println((uint8_t)codigo);
display.println("");
display.println(mensaje);
display.println("");
display.println("Reiniciando en 10s...");
display.display();
}
void display_mostrar_medicion(const Medicion& m, EstadoCO2 e) {
display.oled_command(SH110X_DISPLAYON);
display.clearDisplay();
// Título + separador
display.setTextSize(1);
display.setCursor(0, 0);
display.println("Monitor CO2 - SCD41");
display.drawLine(0, 10, OLED_WIDTH - 1, 10, SH110X_WHITE);
// CO2 en grande
display.setTextSize(2);
display.setCursor(5, 12);
display.print(m.co2);
display.print(" ppm");
// Estado
display.setTextSize(1);
display.setCursor(0, 30);
display.print("Estado: ");
switch (e) {
case EstadoCO2::Normal:
display.println("NORMAL");
break;
case EstadoCO2::Precaucion:
display.println("RIESGO INDIV.");
display.setCursor(0, 40);
display.print("Atender vulnerables");
break;
case EstadoCO2::Alerta:
display.println("RIESGO GRUPAL");
display.setCursor(0, 40);
display.print("Abrir puerta/ventana");
break;
case EstadoCO2::Critico:
display.println("RIESGO GRAVE");
display.setCursor(0, 40);
display.print("!! VENTILAR YA !!");
break;
}
// Temperatura y humedad
display.setTextSize(1);
display.setCursor(0, 54);
display.print(m.temp, 1); display.print("C ");
display.print(m.hum, 1); display.print("%HR");
display.display();
}
void display_actualizar() {
if (!ultima.valida) return;
if (estado == EstadoCO2::Normal)
display.oled_command(SH110X_DISPLAYOFF);
else
display_mostrar_medicion(ultima, estado);
}
// ── Errores ───────────────────────────────────────────────────────────────────
// manejar_error() — notifica (Serial, display, LED); el llamador decide.
// manejar_error_fatal() — notifica y reinicia; para fallos de hardware críticos.
void manejar_error(CodigoError codigo, const char* mensaje) {
#if DEBUG_SERIAL
Serial.print("ERROR "); Serial.print((uint8_t)codigo);
Serial.print(": "); Serial.println(mensaje);
#endif
display_mostrar_error(codigo, mensaje);
led_error_parpadeo();
}
void manejar_error_fatal(CodigoError codigo, const char* mensaje) {
manejar_error(codigo, mensaje);
ESP.restart();
}
// ── I2C ───────────────────────────────────────────────────────────────────────
bool i2c_dispositivo_presente(uint8_t direccion) {
bus.beginTransmission(direccion);
return bus.endTransmission() == 0;
}
// ── Lógica de estados CO2 ─────────────────────────────────────────────────────
EstadoCO2 estado_base(uint16_t co2) {
if (co2 <= cfg.co2_normal_max) return EstadoCO2::Normal;
if (co2 <= cfg.co2_precaucion_max) return EstadoCO2::Precaucion;
if (co2 <= cfg.co2_alerta_max) return EstadoCO2::Alerta;
return EstadoCO2::Critico;
}
// Evita oscilaciones: mantiene el estado previo si co2 está dentro de la banda.
// int32_t para evitar desbordamiento al restar uint16_t.
EstadoCO2 aplicar_histeresis(uint16_t co2, EstadoCO2 nuevo, EstadoCO2 previo) {
if (nuevo == previo) return nuevo;
const int32_t v = co2;
const int32_t h = cfg.histeresis;
const int32_t norm = cfg.co2_normal_max;
const int32_t prec = cfg.co2_precaucion_max;
const int32_t alert = cfg.co2_alerta_max;
switch (previo) {
case EstadoCO2::Normal:
if (nuevo == EstadoCO2::Precaucion && v < norm + h) return EstadoCO2::Normal;
break;
case EstadoCO2::Precaucion:
if (nuevo == EstadoCO2::Normal && v > norm - h) return EstadoCO2::Precaucion;
if (nuevo == EstadoCO2::Alerta && v < prec + h) return EstadoCO2::Precaucion;
break;
case EstadoCO2::Alerta:
if (nuevo == EstadoCO2::Precaucion && v > prec - h) return EstadoCO2::Alerta;
if (nuevo == EstadoCO2::Critico && v < alert + h) return EstadoCO2::Alerta;
break;
case EstadoCO2::Critico:
if (nuevo == EstadoCO2::Alerta && v > alert - h) return EstadoCO2::Critico;
break;
}
return nuevo;
}
EstadoCO2 calcular_estado(uint16_t co2, EstadoCO2 previo) {
if (co2 < 400 || co2 > 5000) {
#if DEBUG_SERIAL
Serial.println("Advertencia: CO2 fuera de rango (400-5000 ppm)");
#endif
return previo;
}
return aplicar_histeresis(co2, estado_base(co2), previo);
}
uint32_t intervalo_segun_estado() {
switch (estado) {
case EstadoCO2::Normal: return cfg.intervalo_normal;
case EstadoCO2::Precaucion: return cfg.intervalo_precaucion;
case EstadoCO2::Alerta:
case EstadoCO2::Critico: return cfg.intervalo_alerta;
default: return cfg.intervalo_alerta;
}
}
// ── Sensor CO2 ────────────────────────────────────────────────────────────────
// Acumula fallos por fase; reinicia el ESP32 al tercer error consecutivo.
void sensor_registrar_error(uint8_t& errores, const char* fase) {
static const uint8_t MAX_ERRORES = 3;
errores++;
#if DEBUG_SERIAL
Serial.print("Error "); Serial.print(fase);
Serial.print(" (intento "); Serial.print(errores); Serial.println("/3)");
#endif
if (errores >= MAX_ERRORES)
manejar_error_fatal(CodigoError::SensorLectura, "Sensor no responde");
}
// Dispara measureSingleShot, espera ~5,5 s y devuelve la lectura con offsets.
Medicion sensor_leer() {
static uint8_t errores = 0;
Medicion m;
uint16_t err = sensor.measureSingleShot();
if (err) { sensor_registrar_error(errores, "single-shot"); return m; }
if (watchdog_ok) esp_task_wdt_delete(NULL);
delay(5500); // el SCD41 necesita ~5 s para completar la medición
if (watchdog_ok) { esp_task_wdt_add(NULL); esp_task_wdt_reset(); }
float temp_raw, hum_raw;
err = sensor.readMeasurement(m.co2, temp_raw, hum_raw);
if (err) { sensor_registrar_error(errores, "lectura"); return m; }
m.temp = temp_raw - cal.tempOffsetC;
m.hum = constrain(hum_raw + cal.humidityOffsetRH, 0.0f, 100.0f);
m.valida = true;
errores = 0; // lectura ok — reiniciar contador de fallos
return m;
}
// ── Estadísticas de sesión (solo con DEBUG_SERIAL 1) ──────────────────────────
#if DEBUG_SERIAL
struct Stats {
uint16_t co2Min = UINT16_MAX, co2Max = 0;
float tMin = 999.0f, tMax = -999.0f;
float rhMin = 999.0f, rhMax = -999.0f;
double co2Sum = 0, tSum = 0, rhSum = 0;
unsigned long count = 0;
unsigned long startMs = 0;
} stats;
void stats_actualizar(const Medicion& m) {
if (!m.valida) return;
stats.count++;
stats.co2Sum += m.co2;
stats.tSum += m.temp;
stats.rhSum += m.hum;
if (m.co2 < stats.co2Min) stats.co2Min = m.co2;
if (m.co2 > stats.co2Max) stats.co2Max = m.co2;
if (m.temp < stats.tMin) stats.tMin = m.temp;
if (m.temp > stats.tMax) stats.tMax = m.temp;
if (m.hum < stats.rhMin) stats.rhMin = m.hum;
if (m.hum > stats.rhMax) stats.rhMax = m.hum;
}
#endif // DEBUG_SERIAL
// ── Monitor serial ────────────────────────────────────────────────────────────
void serial_imprimir_estado() {
#if DEBUG_SERIAL
if (!ultima.valida) return;
const char* nombres[] = { "NORMAL", "PRECAUCION", "ALERTA", "CRITICO" };
Serial.println("=== MONITOR CO2 ===");
Serial.print("CO2: "); Serial.print(ultima.co2); Serial.println(" ppm");
Serial.print("Temp: "); Serial.print(ultima.temp, 1); Serial.println(" C");
Serial.print("HR: "); Serial.print(ultima.hum, 1); Serial.println(" %");
Serial.print("Estado: "); Serial.println(nombres[(uint8_t)estado]);
Serial.print("Intervalo: "); Serial.print(intervalo_segun_estado() / 1000); Serial.println(" s");
Serial.print("Heap: "); Serial.print(ESP.getFreeHeap()); Serial.println(" B");
Serial.print("Watchdog: "); Serial.println(watchdog_ok ? "activo" : "inactivo");
Serial.println("===================");
#endif
}
// ── Menú de calibración (solo con DEBUG_SERIAL 1) ────────────────────────────
#if DEBUG_SERIAL
void menu_imprimir_cabecera() {
Serial.println(F("\n╔══════════════════════════════════════╗"));
Serial.println(F("║ MENÚ DE CALIBRACIÓN SCD41 ║"));
Serial.print (F("║ Firmware "));
Serial.print (F(FIRMWARE_VERSION));
Serial.println(F(" ║"));
Serial.println(F("╠══════════════════════════════════════╣"));
}
void menu_imprimir_principal() {
menu_imprimir_cabecera();
Serial.println(F("║ 1. Ver configuración actual ║"));
Serial.println(F("║ 2. Offset de temperatura (°C) ║"));
Serial.println(F("║ 3. Offset de humedad (%RH) ║"));
Serial.println(F("║ 4. Altitud (m) ║"));
Serial.println(F("║ 5. Calibración forzada CO2 (FRC) ║"));
Serial.println(F("║ 6. Recalibración automática (ASC) ║"));
Serial.println(F("║ 7. Guardar en EEPROM del sensor ║"));
Serial.println(F("║ 8. Estadísticas de sesión ║"));
Serial.println(F("║ 9. Medición inmediata ║"));
Serial.println(F("║ 0. Salir del menú ║"));
Serial.println(F("╚══════════════════════════════════════╝"));
Serial.print(F("Opción: "));
}
void menu_imprimir_config() {
Serial.println(F("\n── Configuración actual ───────────────"));
Serial.print(F(" Firmware : ")); Serial.println(F(FIRMWARE_VERSION));
Serial.print(F(" Offset temperatura : ")); Serial.print(cal.tempOffsetC, 2); Serial.println(F(" °C"));
Serial.print(F(" Offset humedad : ")); Serial.print(cal.humidityOffsetRH, 1); Serial.println(F(" %RH"));
Serial.print(F(" Altitud : ")); Serial.print(cal.altitud_m); Serial.println(F(" m"));
Serial.println(F(" (corrección CO2 via altitud ISA — sin presión dinámica)"));
Serial.println(F("───────────────────────────────────────"));
}
void menu_imprimir_stats() {
if (stats.count == 0) { Serial.println(F("\n Sin lecturas todavía.")); return; }
unsigned long upSec = (millis() - stats.startMs) / 1000UL;
unsigned long upMin = upSec / 60;
unsigned long upH = upMin / 60;
Serial.println(F("\n── Estadísticas de sesión ─────────────"));
Serial.print(F(" Tiempo activo : "));
if (upH) { Serial.print(upH); Serial.print(F("h ")); }
Serial.print(upMin % 60); Serial.print(F("m "));
Serial.print(upSec % 60); Serial.println(F("s"));
Serial.print(F(" Lecturas : ")); Serial.println(stats.count);
Serial.println(F(" Min Media Max"));
Serial.print(F(" CO2 (ppm) "));
Serial.print(stats.co2Min); Serial.print(F(" "));
Serial.print((uint16_t)(stats.co2Sum / stats.count)); Serial.print(F(" "));
Serial.println(stats.co2Max);
Serial.print(F(" Temp (C) "));
Serial.print(stats.tMin, 1); Serial.print(F(" "));
Serial.print((float)(stats.tSum / stats.count), 1); Serial.print(F(" "));
Serial.println(stats.tMax, 1);
Serial.print(F(" HR (%) "));
Serial.print(stats.rhMin, 1); Serial.print(F(" "));
Serial.print((float)(stats.rhSum / stats.count), 1); Serial.print(F(" "));
Serial.println(stats.rhMax, 1);
Serial.println(F("───────────────────────────────────────"));
}
// Lee una línea del Serial con timeout. Devuelve false si no llega nada.
bool menu_leer_linea(String& out, uint32_t timeoutMs = OFFSET_TIMEOUT_MS) {
out = "";
bool gotCr = false;
unsigned long t0 = millis();
while (millis() - t0 < timeoutMs) {
if (!Serial.available()) continue;
char c = (char)Serial.read();
if (c == '\n' || c == '\r') {
if (out.length() > 0) break;
if (!gotCr) { gotCr = true; break; }
} else if (out.length() < SERIAL_INPUT_MAX) {
out += c;
gotCr = false;
}
}
out.trim();
return gotCr || out.length() > 0;
}
// Solicita un valor float, lo valida en [minVal, maxVal] y lo guarda en NVS.
void menu_offset(const char* etiqueta, const char* unidad,
float& variable, float minVal, float maxVal,
bool applyToSensor) {
Serial.print(F("\nValor actual: ")); Serial.print(variable, 2); Serial.println(unidad);
Serial.print(F("Nuevo valor (")); Serial.print(minVal, 1);
Serial.print(F(" a ")); Serial.print(maxVal, 1); Serial.print(unidad); Serial.print(F("): "));
String input;
if (!menu_leer_linea(input)) { Serial.println(F("\nTiempo agotado. Sin cambios.")); return; }
if (input.length() == 0) { Serial.println(F("\nCancelado.")); return; }
bool hasDigit = false;
for (unsigned int i = 0; i < input.length(); i++) {
if (isdigit(input[i]) || input[i] == '.' || input[i] == '-') { hasDigit = true; break; }
}
if (!hasDigit) { Serial.println(F("\nEntrada no válida. Sin cambios.")); return; }
float val = input.toFloat();
if (val < minVal || val > maxVal) {
Serial.print(F("\nFuera de rango [")); Serial.print(minVal, 1);
Serial.print(F(", ")); Serial.print(maxVal, 1); Serial.println(F("]. Sin cambios."));
return;
}
variable = val;
if (applyToSensor) sensor.setTemperatureOffset(val);
nvs_guardar();
Serial.print(F("\n✓ ")); Serial.print(etiqueta);
Serial.print(F(" → ")); Serial.print(val, 2); Serial.print(unidad);
Serial.println(F(" (guardado en NVS)"));
}
bool menu_frc_pedir_referencia(uint16_t& ref) {
Serial.println(F("\n── Calibración Forzada CO2 (FRC) ─────"));
Serial.println(F(" El sensor necesita >= 3 min midiendo"));
Serial.println(F(" en aire de referencia antes del FRC."));
Serial.println(F(" Coloca el sensor en exterior o zona"));
Serial.println(F(" muy ventilada antes de continuar."));
Serial.println(F(" Escribe el CO2 de referencia en ppm"));
Serial.println(F(" (p.ej. 420 para aire exterior)"));
Serial.println(F(" o pulsa Enter sin valor para cancelar."));
Serial.print(F(" Referencia CO2 (ppm): "));
String input;
if (!menu_leer_linea(input)) { Serial.println(F("\nTiempo agotado. Sin cambios.")); return false; }
if (input.length() == 0) { Serial.println(F("\nCancelado.")); return false; }
ref = (uint16_t)input.toInt();
if (ref < 400 || ref > 2000) {
Serial.println(F("\nFuera de rango (400–2000 ppm). Sin cambios."));
return false;
}
return true;
}
bool menu_frc_esperar_estabilizacion() {
Serial.println(F("\n Estabilizando sensor... ('c' o Enter para cancelar)"));
for (int s = FRC_STABILIZE_S; s > 0; s--) {
Serial.print(F("\r Tiempo restante: "));
if (s < 10) Serial.print(F(" "));
else if (s < 100) Serial.print(F(" "));
Serial.print(s); Serial.print(F(" s "));
delay(1000);
if (Serial.available()) {
char c = (char)Serial.read();
if (c == 'c' || c == 'C' || c == '\n' || c == '\r') {
Serial.println(F("\n\nCancelado."));
return false;
}
}
}
return true;
}
void menu_frc_ejecutar(uint16_t ref) {
Serial.println(F("\n Ejecutando FRC..."));
// rawCorrection == 0xFFFF → sensor no listo.
// Corrección real (ppm) = rawCorrection − 32768 (offset binario del protocolo SCD4x)
uint16_t rawCorrection = 0;
uint16_t err = sensor.performForcedRecalibration(ref, rawCorrection);
if (err != 0 || rawCorrection == 0xFFFF) {
Serial.println(F("\n✗ FRC fallido o resultado inválido."));
Serial.println(F(" Verifica >= 3 min de medición en aire de referencia."));
return;
}
int16_t corrPpm = (int16_t)(rawCorrection - 32768);
if (corrPpm < -400 || corrPpm > 400) {
Serial.println(F("\n✗ FRC fuera de rango (±400 ppm). Resultado ignorado."));
return;
}
Serial.print(F("\n✓ FRC completado. Corrección: "));
Serial.print(corrPpm); Serial.println(F(" ppm"));
}
void menu_frc() {
uint16_t ref;
if (!menu_frc_pedir_referencia(ref)) return;
if (!menu_frc_esperar_estabilizacion()) return;
menu_frc_ejecutar(ref);
}
void menu_asc() {
Serial.println(F("\n── Recalibración Automática (ASC) ────"));
Serial.println(F(" 1. Activar 2. Desactivar"));
Serial.print(F(" Opción: "));
String input;
if (!menu_leer_linea(input)) { Serial.println(F("\nTiempo agotado. Sin cambios.")); return; }
if (input.length() == 0) { Serial.println(F("\nCancelado.")); return; }
if (input == "1") { sensor.setAutomaticSelfCalibration(1); Serial.println(F("\n✓ ASC activado.")); }
else if (input == "2") { sensor.setAutomaticSelfCalibration(0); Serial.println(F("\n✓ ASC desactivado.")); }
else { Serial.println(F("\nOpción no válida.")); }
}
// Guarda en EEPROM del sensor (offset T, ASC, altitud) y en NVS del ESP32.
void menu_persistir() {
nvs_guardar();
uint16_t err = sensor.persistSettings();
if (err) {
Serial.println(F("\n✗ Error al guardar en EEPROM del sensor."));
Serial.println(F(" (NVS del ESP32 sí se ha guardado correctamente)"));
} else {
Serial.println(F("\n✓ Configuración guardada:"));
Serial.println(F(" · EEPROM sensor — offset T, ASC, altitud"));
Serial.println(F(" · NVS ESP32 — offsets T, HR y altitud"));
}
}
void menu_altitud() {
Serial.print(F("\nAltitud actual: ")); Serial.print(cal.altitud_m); Serial.println(F(" m"));
Serial.print(F("Nueva altitud (0 a 3000 m): "));
String input;
if (!menu_leer_linea(input)) { Serial.println(F("\nTiempo agotado. Sin cambios.")); return; }
if (input.length() == 0) { Serial.println(F("\nCancelado.")); return; }
bool hasDigit = false;
for (unsigned int i = 0; i < input.length(); i++) {
if (isdigit(input[i])) { hasDigit = true; break; }
}
if (!hasDigit) { Serial.println(F("\nEntrada no válida. Sin cambios.")); return; }
int val = input.toInt();
if (val < 0 || val > 3000) {
Serial.println(F("\nFuera de rango [0, 3000]. Sin cambios."));
return;
}
cal.altitud_m = (uint16_t)val;
sensor.setSensorAltitude(cal.altitud_m);
nvs_guardar();
Serial.print(F("\n✓ Altitud → ")); Serial.print(cal.altitud_m);
Serial.println(F(" m (guardado en NVS)"));
}
// Medición puntual fuera del ciclo automático; actualiza display, LED y stats.
void menu_medicion_inmediata() {
Serial.println(F("\n Midiendo... (≈5 s)"));
Medicion m = sensor_leer();
if (!m.valida) { Serial.println(F(" Error en la medición.")); return; }
ultima = m;
estado = calcular_estado(m.co2, estado);
led_actualizar(estado);
display_mostrar_medicion(m, estado);
stats_actualizar(m);
t_ultimo_sensor = millis(); // el ciclo automático cuenta desde ahora
const char* nombres[] = { "NORMAL", "PRECAUCION", "ALERTA", "CRITICO" };
Serial.println(F("\n Resultado:"));
Serial.print(F(" CO2 : ")); Serial.print(m.co2); Serial.println(F(" ppm"));
Serial.print(F(" Temp : ")); Serial.print(m.temp, 1); Serial.println(F(" C"));
Serial.print(F(" HR : ")); Serial.print(m.hum, 1); Serial.println(F(" %"));
Serial.print(F(" Estado : ")); Serial.println(nombres[(uint8_t)estado]);
}
// Bloqueante hasta que el usuario elige 0, pulsa Enter o expira el timeout.
void menu_ejecutar() {
bool activo = true;
while (activo) {
menu_imprimir_principal();
String input;
if (!menu_leer_linea(input, MENU_TIMEOUT_MS)) {
Serial.println(F("\nTiempo agotado. Reanudando medición."));
break;
}
if (input.length() == 0) {
Serial.println(F("\nReanudando medición…\n"));
break;
}
switch (input.toInt()) {
case 1: menu_imprimir_config(); break;
case 2: menu_offset("Offset temperatura", " °C", cal.tempOffsetC, 0.0f, 20.0f, true); break;
case 3: menu_offset("Offset humedad", " %RH", cal.humidityOffsetRH, -20.0f, 20.0f, false); break;
case 4: menu_altitud(); break;
case 5: menu_frc(); break;
case 6: menu_asc(); break;
case 7: menu_persistir(); break;
case 8: menu_imprimir_stats(); break;
case 9: menu_medicion_inmediata(); break;
case 0: Serial.println(F("\nReanudando medición…\n")); activo = false; break;
default: Serial.println(F("\nOpción no reconocida.")); break;
}
}
}
#endif // DEBUG_SERIAL
// ── Setup ─────────────────────────────────────────────────────────────────────
void setup() {
setCpuFrequencyMhz(cfg.cpu_mhz);
#if DEBUG_SERIAL
Serial.begin(115200);
delay(1000);
Serial.println("=== Monitor CO2 ESP32 " FIRMWARE_VERSION " ===");
Serial.print("CPU: "); Serial.print(getCpuFrequencyMhz()); Serial.println(" MHz");
Serial.println("Escribe 'm' para abrir el menú de calibración.");
#endif
watchdog_init();
nvs_cargar(); // debe ir antes de configurar el sensor
led.begin();
led_set(led.Color(0, 0, 255), 50); // azul durante arranque
bus.begin(PIN_SDA, PIN_SCL, 100000);
delay(100);
if (!i2c_dispositivo_presente(ADDR_OLED))
manejar_error_fatal(CodigoError::DisplayInit, "Display no encontrado");
if (!i2c_dispositivo_presente(ADDR_SCD41))
manejar_error_fatal(CodigoError::SensorInit, "Sensor no encontrado");
if (!display.begin(ADDR_OLED, true))
manejar_error_fatal(CodigoError::DisplayInit, "Fallo init display");
display_mostrar_arranque();
sensor.begin(bus);
uint16_t err = sensor.stopPeriodicMeasurement();
if (err) manejar_error_fatal(CodigoError::SensorInit, "Fallo init SCD41");
sensor.setTemperatureOffset(cal.tempOffsetC);
sensor.setSensorAltitude(cal.altitud_m);
sensor.setAutomaticSelfCalibration(0); // ASC desactivado — usamos FRC manual
sensor.persistSettings(); // graba offset, altitud y ASC en EEPROM
#if DEBUG_SERIAL
uint16_t sn0, sn1, sn2;
if (!sensor.getSerialNumber(sn0, sn1, sn2)) {
Serial.print("SCD41 S/N: ");
Serial.print(sn0, HEX); Serial.print(sn1, HEX); Serial.println(sn2, HEX);
}
Serial.println("ASC: desactivado | Modo: single-shot");
stats.startMs = millis();
#endif
display_mostrar_config();
led_set(led.Color(0, 255, 0), cfg.brillo_normal);
delay(5000); // estabilización térmica inicial (~5 s recomendados por Sensirion)
}
// ── Loop ──────────────────────────────────────────────────────────────────────
// Dispara en el primer ciclo (ultimo==0) y luego cada 'intervalo' ms.
bool es_momento_de(unsigned long& ultimo, uint32_t intervalo) {
unsigned long ahora = millis();
if (ultimo == 0 || (ahora - ultimo >= intervalo)) {
ultimo = ahora;
return true;
}
return false;
}
void loop() {
watchdog_reset();
if (es_momento_de(t_ultimo_sensor, intervalo_segun_estado())) {
Medicion m = sensor_leer();
if (m.valida) {
EstadoCO2 nuevo = calcular_estado(m.co2, estado);
#if DEBUG_SERIAL
stats_actualizar(m);
if (nuevo != estado) {
const char* nombres[] = { "NORMAL", "PRECAUCION", "ALERTA", "CRITICO" };
Serial.print("Estado: "); Serial.print(nombres[(uint8_t)estado]);
Serial.print(" -> "); Serial.println(nombres[(uint8_t)nuevo]);
}
#endif
ultima = m;
estado = nuevo;
led_actualizar(estado);
}
}
if (es_momento_de(t_ultimo_display, intervalo_segun_estado()))
display_actualizar();
if (es_momento_de(t_ultimo_serial, cfg.intervalo_serial))
serial_imprimir_estado();
#if DEBUG_SERIAL
// Detectar 'm' en el buffer; delay() en lugar de light sleep para no perder bytes.
while (Serial.available()) {
char c = (char)Serial.read();
if (c == 'm' || c == 'M') {
delay(50); // deja llegar bytes extra del terminal
while (Serial.available()) Serial.read(); // purga antes de entrar
menu_ejecutar();
delay(20);
while (Serial.available()) Serial.read(); // purga '\n' residual al salir
t_ultimo_sensor = millis();
t_ultimo_display = millis();
t_ultimo_serial = millis();
break;
}
}
delay(100);
#else
dormir_ms(100); // light sleep — el UART se suspende, no válido con DEBUG_SERIAL
#endif
}
CO2 Registrador — v09
Sensirion_Gadget_BLE v1.4.1 · NimBLE-Arduino v1.4.3 · SensirionI2CScd4x v0.4.4 · Adafruit NeoPixel (última versión)
//WWW.ACUSTICAESCOLAR.COM
// =============================================================================
// SCD41 MyAmbience v09 — Sensor CO₂ con BLE y NeoPixel para ESP32
// =============================================================================
#include "Sensirion_Gadget_BLE.h" // 1.4.1 + NimBle-Arduino 1.4.3
#include <SensirionI2CScd4x.h> // 0.4.4
#include <Wire.h>
#include <Adafruit_NeoPixel.h> // 1.15.1
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
// =============================================================================
// HARDWARE — pines físicos del ESP32 (Lolin D32)
// =============================================================================
namespace Hardware {
static constexpr uint8_t PIN_LED = 4;
static constexpr uint8_t CANTIDAD_LEDS = 1;
static constexpr uint8_t PIN_I2C_SDA = 19;
static constexpr uint8_t PIN_I2C_SCL = 22;
}
// =============================================================================
// TIEMPOS — todos en milisegundos
// =============================================================================
namespace Tiempos {
static constexpr uint32_t INTERVALO_MEDICION_MS = 30000; // entre lecturas del sensor
static constexpr uint32_t INTERVALO_PARPADEO_MS = 500; // periodo del parpadeo rojo
static constexpr uint32_t ESTABILIZACION_SENSOR_MS = 5000; // espera tras arrancar el sensor
static constexpr uint32_t TIMEOUT_SERIAL_MS = 3000; // espera máxima al monitor serial
static constexpr uint32_t INTERVALO_LOOP_IDLE_MS = 5; // quantum cedido al scheduler
}
// =============================================================================
// UMBRALES DE CALIDAD DEL AIRE — CO₂ en ppm
// Verde : CO₂ ≤ BUENO
// Ámbar : BUENO < CO₂ < ALERTA
// Rojo : CO₂ ≥ ALERTA
// =============================================================================
namespace UmbralesCO2 {
static constexpr uint16_t BUENO = 1000;
static constexpr uint16_t ALERTA = 1600;
}
// =============================================================================
// LÍMITES DEL SENSOR — rangos físicos admitidos por el SCD4x
// =============================================================================
namespace LimitesSensor {
static constexpr uint16_t CO2_MINIMO = 350; // ppm
static constexpr uint16_t CO2_MAXIMO = 5000; // ppm
static constexpr float TEMP_MINIMA = -40.0f; // °C
static constexpr float TEMP_MAXIMA = 85.0f; // °C
static constexpr float HUMEDAD_MINIMA = 0.0f; // %
static constexpr float HUMEDAD_MAXIMA = 100.0f; // %
static constexpr uint16_t DELTA_CO2_MAX = 500; // ppm, salto entre lecturas considerado anómalo
}
// =============================================================================
// CALIBRACIÓN EMPÍRICA — corrigen el calor generado por el PCB
// Revisar si cambia el entorno o el montaje.
// =============================================================================
namespace Calibracion {
static constexpr float OFFSET_TEMPERATURA = -6.0f; // °C (sensor sobreestima ~6 °C)
static constexpr float OFFSET_HUMEDAD = 17.0f; // % (sensor subestima ~12 %)
}
// =============================================================================
// TAREA LED — parámetros de la tarea FreeRTOS del Core 1
// =============================================================================
namespace TareaLed {
static constexpr uint32_t STACK_BYTES = 3072; // ampliado para margen de seguridad
static constexpr UBaseType_t PRIORIDAD = 1;
static constexpr BaseType_t CORE = 1;
}
// =============================================================================
// COLORES NEOPIXEL
// Adafruit_NeoPixel::Color() no es constexpr, así que empaquetamos RGB
// manualmente con la misma fórmula que usa la librería internamente.
// La reordenación a GRB en el bus la hace la librería al llamar a show().
// =============================================================================
namespace Color {
static constexpr uint32_t rgb(uint8_t r, uint8_t g, uint8_t b) {
return (static_cast<uint32_t>(r) << 16)
| (static_cast<uint32_t>(g) << 8)
| static_cast<uint32_t>(b);
}
static constexpr uint32_t VERDE = rgb( 0, 255, 0);
static constexpr uint32_t AMBER = rgb(255, 128, 0);
static constexpr uint32_t ROJO = rgb(255, 0, 0);
static constexpr uint32_t APAGADO = rgb( 0, 0, 0);
}
// =============================================================================
// TIPOS
// =============================================================================
// Estado de calidad del aire que se refleja en el LED NeoPixel.
enum class EstadoLed : uint8_t {
Bueno = 0,
Advertencia = 1,
Alerta = 2,
NoInit = 255 // valor inicial; fuerza el primer show()
};
// Datos que el Core 0 escribe y el Core 1 lee, protegidos por xMutexLed.
// No usar 'volatile': en ESP32 (Xtensa LX6) no garantiza coherencia de caché
// entre cores; esa responsabilidad recae en el mutex.
struct EstadoCompartido {
EstadoLed estado = EstadoLed::NoInit;
bool alerta = false;
};
// =============================================================================
// OBJETOS GLOBALES
// =============================================================================
static Adafruit_NeoPixel tiraLed(
Hardware::CANTIDAD_LEDS, Hardware::PIN_LED, NEO_GRB + NEO_KHZ800);
static NimBLELibraryWrapper lib;
static DataProvider proveedor(lib, DataType::T_RH_CO2, false, false, true);
static SensirionI2CScd4x scd4x;
static SemaphoreHandle_t xMutexLed = nullptr;
static EstadoCompartido estadoCompartido;
static unsigned long tiempoUltimaMedicion = 0;
// =============================================================================
// UTILIDADES
// =============================================================================
// Devuelve true si 'valor' pertenece al intervalo cerrado [minimo, maximo].
static bool dentroDeRango(float valor, float minimo, float maximo) {
return valor >= minimo && valor <= maximo;
}
// Imprime 'prefijo' seguido de la descripción textual del código de error Sensirion.
static void logErrorSensor(const char *prefijo, uint16_t codigo) {
char descripcion[256];
errorToString(codigo, descripcion, sizeof(descripcion));
Serial.print(prefijo);
Serial.println(descripcion);
}
// =============================================================================
// TAREA LED — Core 1
// =============================================================================
/**
* @brief Aplica 'color' al LED solo si el estado ha cambiado.
* Evita llamadas innecesarias a show(), que ocupa ~30 µs en el bus.
*/
static void mostrarColorSiCambio(EstadoLed estadoActual,
EstadoLed &estadoAnterior,
uint32_t color) {
if (estadoActual == estadoAnterior) return;
tiraLed.setPixelColor(0, color);
tiraLed.show();
estadoAnterior = estadoActual;
}
/**
* @brief Lee estadoCompartido.estado bajo mutex y lo copia en 'destino'.
* Si el mutex no se adquiere en 10 ms, conserva el valor anterior
* y registra un aviso; nunca bloquea indefinidamente.
*/
static void leerEstadoCompartido(EstadoLed &destino) {
if (xSemaphoreTake(xMutexLed, pdMS_TO_TICKS(10)) == pdTRUE) {
destino = estadoCompartido.estado;
xSemaphoreGive(xMutexLed);
} else {
Serial.println("[WARN] Mutex timeout en leerEstadoCompartido — estado sin actualizar");
}
}
/**
* @brief Bucle del Core 1: actualiza el LED según el estado publicado por el Core 0.
* Verde/Ámbar solo cambian el color si hay transición de estado.
* Rojo parpadea en cada ciclo de INTERVALO_PARPADEO_MS.
*/
static void tareaLedNeopixel(void * /*parametro*/) {
tiraLed.begin();
tiraLed.setPixelColor(0, Color::APAGADO);
tiraLed.show();
Serial.println("[LED] Tarea iniciada en Core 1");
Serial.printf("[LED] Stack libre al arrancar: %u bytes\n",
uxTaskGetStackHighWaterMark(nullptr) * sizeof(StackType_t));
EstadoLed estadoAnterior = EstadoLed::NoInit;
bool ledEncendido = true;
while (true) {
EstadoLed estadoActual = estadoAnterior;
leerEstadoCompartido(estadoActual);
switch (estadoActual) {
case EstadoLed::Bueno:
mostrarColorSiCambio(estadoActual, estadoAnterior, Color::VERDE);
break;
case EstadoLed::Advertencia:
mostrarColorSiCambio(estadoActual, estadoAnterior, Color::AMBER);
break;
case EstadoLed::Alerta:
tiraLed.setPixelColor(0, ledEncendido ? Color::ROJO : Color::APAGADO);
tiraLed.show();
estadoAnterior = estadoActual;
ledEncendido = !ledEncendido;
break;
default: // NoInit u estado inesperado: apagar
mostrarColorSiCambio(estadoActual, estadoAnterior, Color::APAGADO);
break;
}
vTaskDelay(pdMS_TO_TICKS(Tiempos::INTERVALO_PARPADEO_MS));
}
}
// =============================================================================
// VALIDACIÓN DE DATOS
// =============================================================================
/**
* @brief Comprueba que los datos crudos estén dentro de los rangos físicos del SCD4x.
* Se llama antes de aplicar la calibración.
*/
static bool validarDatosCrudos(uint16_t co2, float temperatura, float humedad) {
if (co2 < LimitesSensor::CO2_MINIMO || co2 > LimitesSensor::CO2_MAXIMO) {
Serial.printf("[ERROR] CO₂ fuera de rango: %d ppm (válido: %d-%d)\n",
co2, LimitesSensor::CO2_MINIMO, LimitesSensor::CO2_MAXIMO);
return false;
}
if (!dentroDeRango(temperatura, LimitesSensor::TEMP_MINIMA, LimitesSensor::TEMP_MAXIMA)) {
Serial.printf("[ERROR] Temperatura fuera de rango: %.2f °C\n", temperatura);
return false;
}
if (!dentroDeRango(humedad, LimitesSensor::HUMEDAD_MINIMA, LimitesSensor::HUMEDAD_MAXIMA)) {
Serial.printf("[ERROR] Humedad fuera de rango: %.2f %%\n", humedad);
return false;
}
return true;
}
/**
* @brief Comprueba que los datos ya calibrados sigan dentro del rango físico.
* Los offsets pueden empujar valores límite fuera del rango válido.
*/
static bool validarDatosCalibrados(float temperatura, float humedad) {
if (!dentroDeRango(temperatura, LimitesSensor::TEMP_MINIMA, LimitesSensor::TEMP_MAXIMA)) {
Serial.printf("[ERROR] Temperatura calibrada fuera de rango: %.2f °C\n", temperatura);
return false;
}
if (!dentroDeRango(humedad, LimitesSensor::HUMEDAD_MINIMA, LimitesSensor::HUMEDAD_MAXIMA)) {
Serial.printf("[ERROR] Humedad calibrada fuera de rango: %.2f %%\n", humedad);
return false;
}
return true;
}
/**
* @brief Detecta si el salto de CO₂ entre dos lecturas consecutivas es anómalo.
* El dato no se descarta: el salto puede ser real (ventana abierta, etc.).
* Devuelve false solo para registrar el aviso; el llamador decide qué hacer.
*/
static bool esCambioDeCO2Normal(uint16_t actual, uint16_t anterior) {
if (anterior == 0) return true; // primera lectura, sin referencia previa
const uint16_t delta = static_cast<uint16_t>(
abs(static_cast<int>(actual) - static_cast<int>(anterior)));
if (delta > LimitesSensor::DELTA_CO2_MAX) {
Serial.printf("[AVISO] Salto brusco de CO₂: %d → %d ppm (Δ %d)\n",
anterior, actual, delta);
return false;
}
return true;
}
// =============================================================================
// GESTIÓN DEL ESTADO LED
// =============================================================================
/**
* @brief Decide el nuevo EstadoLed a partir del CO₂ y del estado previo.
* Si ya había alerta y el CO₂ baja al rango ámbar, se mantiene Alerta
* para no ocultar un pico reciente al operador.
*/
static EstadoLed calcularEstadoLed(uint16_t co2, bool alertaPrevia) {
if (co2 <= UmbralesCO2::BUENO) return EstadoLed::Bueno;
if (co2 < UmbralesCO2::ALERTA) return alertaPrevia ? EstadoLed::Alerta
: EstadoLed::Advertencia;
return EstadoLed::Alerta;
}
/**
* @brief Calcula el nuevo estado, lo escribe en la sección crítica y lo registra.
* La lectura de 'alerta' y la escritura de 'estado'/'alerta' se hacen
* en el mismo bloqueo para evitar race conditions entre los dos cores.
*/
static void publicarEstadoLed(uint16_t co2) {
if (xSemaphoreTake(xMutexLed, pdMS_TO_TICKS(10)) != pdTRUE) {
Serial.println("[WARN] Mutex timeout en publicarEstadoLed — LED sin actualizar");
return;
}
const EstadoLed nuevoEstado = calcularEstadoLed(co2, estadoCompartido.alerta);
estadoCompartido.estado = nuevoEstado;
estadoCompartido.alerta = (nuevoEstado == EstadoLed::Alerta);
xSemaphoreGive(xMutexLed);
static const char* const ETIQUETAS[] = {"BUENA", "REGULAR", "MALA"};
Serial.printf("[CALIDAD] %s (CO₂: %d ppm)\n",
ETIQUETAS[static_cast<uint8_t>(nuevoEstado)], co2);
}
// =============================================================================
// SENSOR — auxiliares
// =============================================================================
// Lee el número de serie del SCD4x y lo imprime en el monitor serial.
static void mostrarNumeroSerieSensor() {
uint16_t parte0, parte1, parte2;
const uint16_t error = scd4x.getSerialNumber(parte0, parte1, parte2);
if (error) {
logErrorSensor("[ERROR] No se pudo leer el número de serie: ", error);
} else {
Serial.printf("[SENSOR] N/S: %04X-%04X-%04X\n", parte0, parte1, parte2);
}
}
// =============================================================================
// MEDICIÓN Y REPORTE
// =============================================================================
// Suma los offsets empíricos a temperatura y humedad.
static void aplicarCalibracion(float &temperatura, float &humedad) {
temperatura += Calibracion::OFFSET_TEMPERATURA;
humedad += Calibracion::OFFSET_HUMEDAD;
}
// Envía la muestra por BLE y actualiza el LED. Los datos de entrada ya están validados.
static void publicarMuestra(uint16_t co2, float temperatura, float humedad) {
Serial.printf("[DATOS] %lu ms | CO₂: %d ppm | Temp: %.1f °C | Humedad: %.0f %%\n",
millis(), co2, temperatura, humedad);
publicarEstadoLed(co2);
proveedor.writeValueToCurrentSample(co2, SignalType::CO2_PARTS_PER_MILLION);
proveedor.writeValueToCurrentSample(temperatura, SignalType::TEMPERATURE_DEGREES_CELSIUS);
proveedor.writeValueToCurrentSample(humedad, SignalType::RELATIVE_HUMIDITY_PERCENTAGE);
proveedor.commitSample();
}
/**
* @brief Lee el sensor, valida, calibra y publica. Se llama cada INTERVALO_MEDICION_MS.
*
* Flujo (early return en cada fallo):
* 1. Leer hardware — abortar si error I2C; no actualizar timer.
* 2. Actualizar timer — solo tras lectura exitosa.
* 3. Rechazar CO₂ = 0 — muestra inválida del sensor.
* 4. Validar rangos crudos.
* 5. Avisar salto brusco — dato sospechoso, pero no descartado.
* 6. Calibrar y revalidar rangos.
* 7. Publicar.
*/
static void realizarMedicionYReporte() {
static uint16_t co2Anterior = 0;
uint16_t co2;
float temperatura;
float humedad;
const uint16_t errorLectura = scd4x.readMeasurement(co2, temperatura, humedad);
if (errorLectura) {
logErrorSensor("[ERROR] Fallo al leer el sensor: ", errorLectura);
return; // sin actualizar timer: reintento en el siguiente ciclo
}
tiempoUltimaMedicion = millis();
if (co2 == 0) {
Serial.println("[ERROR] CO₂ = 0: muestra inválida — omitida");
return;
}
if (!validarDatosCrudos(co2, temperatura, humedad)) {
Serial.println("[ERROR] Datos crudos fuera de rango — medición descartada");
return;
}
esCambioDeCO2Normal(co2, co2Anterior); // solo registra aviso, no descarta
aplicarCalibracion(temperatura, humedad);
if (!validarDatosCalibrados(temperatura, humedad)) {
Serial.println("[ERROR] Datos calibrados fuera de rango — medición descartada");
return;
}
publicarMuestra(co2, temperatura, humedad);
co2Anterior = co2;
}
// =============================================================================
// CALIBRACIÓN FORZADA (FRC)
// =============================================================================
// Detiene la medición periódica del SCD4x. Devuelve false si hay error de bus.
static bool detenerMedicionPeriodica() {
const uint16_t error = scd4x.stopPeriodicMeasurement();
if (error) {
logErrorSensor("[ERROR] No se pudo detener la medición periódica: ", error);
return false;
}
return true;
}
// Reactiva la medición periódica del SCD4x. Devuelve false si hay error de bus.
static bool reiniciarMedicionPeriodica() {
const uint16_t error = scd4x.startPeriodicMeasurement();
if (error) {
logErrorSensor("[ERROR] No se pudo reiniciar la medición periódica: ", error);
return false;
}
return true;
}
// Ejecuta la recalibración forzada con 'nivelReferencia' ppm como valor conocido.
static void ejecutarFRC(uint16_t nivelReferencia) {
uint16_t correccion;
const uint16_t error = scd4x.performForcedRecalibration(nivelReferencia, correccion);
if (error) {
logErrorSensor("[ERROR] Fallo en la calibración forzada: ", error);
} else {
Serial.printf("[FRC] OK. Corrección aplicada: %d\n", correccion);
}
}
/**
* @brief Atiende la solicitud de FRC pendiente del proveedor BLE.
* Tras la recalibración reinicia el timer para que el loop espere
* un intervalo completo antes de la próxima lectura.
*/
static void manejarSolicitudFRC() {
if (!proveedor.isFRCRequested()) return;
const uint16_t nivelReferencia = proveedor.getReferenceCO2Level();
Serial.printf("[FRC] Solicitud recibida. Referencia: %d ppm\n", nivelReferencia);
if (!detenerMedicionPeriodica()) {
proveedor.completeFRCRequest();
return;
}
ejecutarFRC(nivelReferencia);
proveedor.completeFRCRequest();
reiniciarMedicionPeriodica();
vTaskDelay(pdMS_TO_TICKS(Tiempos::ESTABILIZACION_SENSOR_MS));
tiempoUltimaMedicion = millis();
Serial.println("[FRC] Completada. Timer reiniciado.");
}
// =============================================================================
// INICIALIZACIÓN — auxiliares de setup()
// =============================================================================
// Espera hasta TIMEOUT_SERIAL_MS a que el monitor serial esté listo.
// Continúa igualmente para no bloquear el arranque si no hay PC conectado.
static void esperarMonitorSerial() {
const unsigned long inicio = millis();
while (!Serial && (millis() - inicio < Tiempos::TIMEOUT_SERIAL_MS)) {
delay(10);
}
}
// Crea el mutex que sincroniza el acceso a estadoCompartido entre los dos cores.
// Si no hay memoria heap disponible, detiene el sistema: no tiene sentido continuar.
static void crearMutex() {
xMutexLed = xSemaphoreCreateMutex();
if (xMutexLed == nullptr) {
Serial.println("[FATAL] Sin memoria para el mutex — sistema detenido.");
while (true) vTaskDelay(pdMS_TO_TICKS(1000));
}
Serial.println("[SYNC] Mutex creado");
}
// Arranca el bus I2C en los pines definidos en Hardware.
static void iniciarI2C() {
Wire.begin(Hardware::PIN_I2C_SDA, Hardware::PIN_I2C_SCL);
Serial.printf("[I2C] Iniciado (SDA: %d, SCL: %d)\n",
Hardware::PIN_I2C_SDA, Hardware::PIN_I2C_SCL);
}
// Arranca el proveedor BLE e imprime el identificador del dispositivo.
static void iniciarBLE() {
proveedor.begin();
Serial.printf("[BLE] Iniciado — ID: %s\n", proveedor.getDeviceIdString());
}
/**
* @brief Inicializa el SCD4x y arranca la medición periódica.
* Se llama a stop antes de start para limpiar cualquier estado anterior
* (por ejemplo, tras un reset en caliente sin corte de alimentación).
*/
static void iniciarSensor() {
scd4x.begin(Wire);
scd4x.stopPeriodicMeasurement();
mostrarNumeroSerieSensor();
const uint16_t error = scd4x.startPeriodicMeasurement();
if (error) {
logErrorSensor("[ERROR] No se pudo iniciar la medición periódica: ", error);
} else {
Serial.println("[SENSOR] Medición periódica iniciada");
}
Serial.printf("[SENSOR] Estabilizando (%d s)...\n",
Tiempos::ESTABILIZACION_SENSOR_MS / 1000);
vTaskDelay(pdMS_TO_TICKS(Tiempos::ESTABILIZACION_SENSOR_MS));
}
// Crea la tarea FreeRTOS del LED en el Core 1 para que el parpadeo sea independiente
// del loop principal y no interfiera con las mediciones del sensor.
static void lanzarTareaLed() {
const BaseType_t resultado = xTaskCreatePinnedToCore(
tareaLedNeopixel,
"TareaLED",
TareaLed::STACK_BYTES,
nullptr,
TareaLed::PRIORIDAD,
nullptr,
TareaLed::CORE
);
if (resultado == pdPASS) {
Serial.printf("[TAREA] TareaLED creada en Core %d\n", TareaLed::CORE);
} else {
Serial.println("[ERROR] No se pudo crear TareaLED");
}
}
// Imprime los parámetros de operación activos para facilitar la verificación al arrancar.
static void imprimirResumenConfiguracion() {
Serial.println("[INFO] Sistema listo");
Serial.printf("[INFO] Intervalo de medición: %lu s\n",
Tiempos::INTERVALO_MEDICION_MS / 1000);
Serial.printf("[INFO] Umbrales CO₂ — Verde: ≤%d | Ámbar: %d-%d | Rojo: ≥%d ppm\n",
UmbralesCO2::BUENO,
UmbralesCO2::BUENO + 1,
UmbralesCO2::ALERTA - 1,
UmbralesCO2::ALERTA);
Serial.printf("[INFO] Calibración — ΔT: %.1f °C | ΔH: +%.1f %%\n",
Calibracion::OFFSET_TEMPERATURA,
Calibracion::OFFSET_HUMEDAD);
Serial.println();
Serial.println("[MEDICIONES] Iniciando monitoreo...");
Serial.println("Tiempo(ms)\tCO₂(ppm)\tTemperatura(°C)\tHumedad(%)");
Serial.println("------------------------------------------------------------");
}
// =============================================================================
// SETUP
// =============================================================================
void setup() {
Serial.begin(115200);
esperarMonitorSerial();
Serial.println("=== SENSOR DE CALIDAD DEL AIRE CO₂ v09 ===");
Serial.println("[INFO] Iniciando sistema...");
crearMutex();
iniciarI2C();
iniciarBLE();
iniciarSensor();
lanzarTareaLed();
imprimirResumenConfiguracion();
}
// =============================================================================
// LOOP PRINCIPAL — Core 0
// =============================================================================
void loop() {
const bool esMomentoMedicion =
(millis() - tiempoUltimaMedicion >= Tiempos::INTERVALO_MEDICION_MS);
if (esMomentoMedicion) {
realizarMedicionYReporte();
}
proveedor.handleDownload();
manejarSolicitudFRC();
vTaskDelay(pdMS_TO_TICKS(Tiempos::INTERVALO_LOOP_IDLE_MS)); // cede CPU al scheduler
}
Calibración y uso
Primera puesta en marcha
- Conecta el dispositivo por USB. Abre el Monitor Serial del IDE (115.200 baud). Deberías ver el número de serie del SCD41 y la cuenta atrás de 5 s de estabilización.
- Lleva el dispositivo al exterior durante 5 minutos. El CO₂ exterior es ~420 ppm: ese es el valor de referencia.
- Instálalo en el aula y observa la evolución. Con la clase llena y ventanas cerradas, el valor sube en 15–20 minutos.
- Comprueba que el LED cambia a ámbar alrededor de 1.000 ppm y a rojo parpadeante alrededor de 1.600 ppm. Para ajustar los umbrales edita
UmbralesCO2::BUENOyUmbralesCO2::ALERTA.
Calibración forzada (FRC)
El procedimiento varía según el dispositivo:
CO2 Registrador — calibra desde la app MyAmbience por BLE:
- Lleva el dispositivo al exterior al menos 3 minutos.
- En la app: panel del dispositivo → Calibrate → introduce 420 ppm.
- El firmware detiene la medición, ejecuta la FRC y reinicia el sensor. El LED se apagará brevemente.
- La siguiente lectura debería mostrar un valor próximo a 420 ppm.
CO2 Monitor y CO2 Tutor — calibran mediante el menú Serial en modo DEBUG (DEBUG_MODE 1 / DEBUG_SERIAL 1). Conecta por USB, abre el monitor serial a 115.200 baud, escribe m para acceder al menú y selecciona la opción de FRC.
Ajuste de los offsets de calibración
Si el montaje difiere del descrito, los offsets por defecto pueden no ser correctos.
Coloca un termómetro de referencia junto al dispositivo, espera 10 minutos y calcula la diferencia.
Edita namespace Calibracion con los valores obtenidos.
MyAmbience (Sensirion, gratuita para iOS y Android). Gráficas en tiempo real, historial, exportación CSV y calibración forzada. Busca el dispositivo por su ID BLE (visible en el monitor serial al arrancar).
Ubicación óptima en el aula
Coloca el dispositivo a 1–1,5 m de altura, lejos de ventanas, puertas y corrientes de aire directas. El centro del aula o la mesa del profesor son buenas posiciones. Evita proximidad a ordenadores o proyectores que emitan calor.
Guía práctica basada en la medición de CO₂ como indicador de eficacia de la ventilación. Disponible en la sección Bibliografía de Acústica Escolar.
Wargocki et al. (2020) demostraron que reducir el CO₂ mediante mejor ventilación mejora significativamente el rendimiento escolar, con mayor efecto en estudiantes que en adultos. Más referencias en Investigaciones — Acústica Escolar.
No hay comentarios:
Publicar un comentario
Nota: solo los miembros de este blog pueden publicar comentarios.