Lógica de automatización¶
Conectamos todo: el sensor decide, el ventilador gira, el portal controla.
1. Estado y configuración¶
Al principio de src/main.cpp:
#include <Preferences.h>
static const int FAN_PIN = 4;
enum class FilterMode : uint8_t { Auto, On, Off };
static FilterMode g_mode = FilterMode::Auto;
static int32_t g_threshold = 150; // índice VOC para activación
static bool g_fanOn = false;
static Preferences s_prefs;
Preferences (NVS) — memoria no volátil de ESP32: el modo y el umbral sobreviven a un reinicio.
2. Control del ventilador¶
Todo encendido y apagado lo concentramos en una función setFan. Toma un argumento on — el estado deseado: true = encender, false = apagar. En el resto del código siempre llamamos a setFan(true) / setFan(false), y ella hace toda la rutina: tira del pin, recuerda el estado e informa al portal.
static void setFan(bool on) { // on — argumento: true = encender, false = apagar
if (g_fanOn == on) return; // ya en el estado deseado — no hacemos nada
g_fanOn = on; // guardamos el nuevo estado en la variable global
digitalWrite(FAN_PIN, on ? HIGH : LOW); // físicamente encendemos/apagamos la llave del ventilador
// Informamos el estado al núcleo: fanOn[0] — campo de telemetría del diccionario
// (apareció porque hasFan = true; [0] — nuestra única unidad, como en el capítulo 5).
// De aquí se irá a la nube y a la celda "Ventilador" de la tarjeta.
s_link.telemetry.fanOn[0] = on;
// Cambio de estado — razón para enviar telemetría de inmediato, sin esperar el período.
s_link.publishTelemetryNow();
}
publishTelemetryNow() hace el respuesta instantánea: presionaste en el portal — en un segundo la tarjeta muestra el estado confirmado. Exactamente confirmado: el portal de iDryer nunca "adivina" el estado, muestra lo que el dispositivo realmente envió.
3. Automatización con histéresis¶
Si enciendes el ventilador exactamente en el umbral, alrededor del umbral va a vibrar encendido/apagado. Se cura con brecha:
static void tickAutoLogic() {
if (g_mode == FilterMode::On) { setFan(true); return; }
if (g_mode == FilterMode::Off) { setFan(false); return; }
// Auto: encendemos en el umbral, apagamos 20 puntos debajo.
if (g_vocIndex < 0) return; // sensor aún en silencio
if (!g_fanOn && g_vocIndex >= g_threshold) setFan(true);
if ( g_fanOn && g_vocIndex <= g_threshold - 20) setFan(false);
}
Llamamos a tickAutoLogic() en el mismo lugar donde leemos el sensor — en loop() cada segundo con temporizador. Este es ese loop() del capítulo 5, se agrega una línea. Ahora completo se ve así:
void loop() {
s_link.loop(); // red, telemetría, comandos — siempre primero
uint32_t now = millis();
if (now - s_lastReadMs >= 1000) { // una vez por segundo:
s_lastReadMs = now;
readVocSensor(); // leemos VOC (capítulo 5)
tickAutoLogic(); // e inmediatamente tomamos decisión sobre el ventilador
}
}
El orden dentro del bloque de segundo no es accidental: primero lectura fresca del sensor, luego decisión por ella.
4. Callbacks desde el portal¶
Esas mismas funciones que prometimos en el capítulo 6:
static void onModeSelected(const char* opt) {
if (strcmp(opt, "auto") == 0) g_mode = FilterMode::Auto;
else if (strcmp(opt, "on") == 0) g_mode = FilterMode::On;
else g_mode = FilterMode::Off;
s_prefs.putUChar("mode", (uint8_t)g_mode);
tickAutoLogic(); // aplicamos de inmediato, no esperamos el siguiente tik
}
static void onThresholdChanged(float v) {
g_threshold = (int32_t)v;
s_prefs.putInt("thr", g_threshold);
}
Estas funciones reemplazan las versiones de prueba del capítulo 6 — elimina las versiones vacías.
Observa lo que no hay en este código: análisis de MQTT, tópicos, comandos JSON. El usuario eligió on en la lista del portal → el núcleo recibió el comando, lo verificó y llamó a onModeSelected("on"). Toda la mecánica de transporte — responsabilidad del núcleo.
5. setup() final¶
Queda agregar dos cosas al setup(): carga de configuración guardada desde NVS (al principio, para que la lógica trabaje con ella de inmediato) y configuración del pin del ventilador. El setup() completo después de este capítulo se ve así:
void setup() {
Serial.begin(115200);
// Configuración desde NVS: lo que el usuario elegía en ocasiones previas.
s_prefs.begin("filter"); // abrir el espacio de nombres "filter" en NVS
g_mode = (FilterMode)s_prefs.getUChar("mode", (uint8_t)FilterMode::Auto);
g_threshold = s_prefs.getInt("thr", 150);
// Los segundos argumentos getUChar/getInt — valores por defecto: se devolverán
// en el primer inicio, cuando nada se ha guardado en NVS aún.
pinMode(FAN_PIN, OUTPUT); // pin de la llave del ventilador — en salida
s_link.begin();
initVocSensor();
// Telemetría: campo vocIndex propio (capítulo 5).
s_link.onTelemetryPublish([](JsonObject doc) {
if (g_vocIndex >= 0) {
doc["units"][0]["vocIndex"] = g_vocIndex;
}
});
// Tarjeta: sensor + órganos de control (capítulo 6).
s_link.card().sensor("voc", "VOC index", "", "units[0].vocIndex");
static const char* kModes[] = { "auto", "on", "off" };
s_link.card().select("mode", "Mode", kModes, 3, [](const char* opt) {
onModeSelected(opt);
});
s_link.card().number("threshold", "VOC threshold", 100, 400, 10, "", [](float v) {
onThresholdChanged(v);
});
// Diseño (capítulo 6, opcional).
s_link.card().layoutRow("voc", "fan");
s_link.card().layoutRow("mode", "threshold");
}
6. Verificación de escenarios¶
| Acción | Expectativa |
|---|---|
Modo auto, soplar en el sensor |
VOC crece, en el umbral se enciende el ventilador, la tarjeta muestra "Encendido" |
| El aire se limpió | por debajo de umbral−20 se apaga el ventilador automáticamente |
Modo on desde el portal |
el ventilador gira independientemente del VOC |
Modo off desde el portal |
el ventilador está apagado, VOC sigue mostrándo |
| Reinicio de la placa | el modo y umbral se guardaron |
7. Código completo: src/main.cpp entero¶
Todo el código de los capítulos 4–7, reunido en un archivo. Si algo no coincide con el tuyo — compara con este listado.
// ============================================================
// Filtro de aire inteligente en idryer-core.
// SGP40 (VOC) + ventilador a través de MOSFET, modo auto/manual,
// control y tarjeta en el portal a través de card manifest.
// ============================================================
#include <iDryer.h>
#include <Wire.h>
#include <Adafruit_SGP40.h>
#include <Preferences.h>
// ── Pines ────────────────────────────────────────────────────
static const int FAN_PIN = 4; // compuerta MOSFET del ventilador
// SDA=8, SCL=9 — se especifican en Wire.begin() abajo
// ── Pasaporte del dispositivo (capítulo 4) ────────────────────
static const iDryer::Config CFG = {
.deviceType = iDryer::DeviceType::Unknown, // dispositivo no estándar
.unitsCount = 1,
.hasFan = true, // única habilidad del diccionario
.telemetryPeriodMs = 5000,
.statusPeriodMs = 10000,
.hardwareVersion = "1.0",
.firmwareVersion = "0.1.0",
.model = "DIY Air Filter",
};
static iDryer::Link s_link(CFG);
// ── Estado (capítulo 7) ─────────────────────────────────────
enum class FilterMode : uint8_t { Auto, On, Off };
static FilterMode g_mode = FilterMode::Auto;
static int32_t g_threshold = 150; // índice VOC para activación
static bool g_fanOn = false;
static Preferences s_prefs; // NVS: la configuración sobrevive el reinicio
// ── Sensor VOC (capítulo 5) ────────────────────────────────────
static Adafruit_SGP40 s_sgp;
static int32_t g_vocIndex = -1; // -1 = sin datos aún
static void initVocSensor() {
Wire.begin(/*SDA=*/8, /*SCL=*/9);
if (!s_sgp.begin()) {
Serial.println("[VOC] SGP40 not found, check wiring");
}
}
static void readVocSensor() {
// Índice: ~100 = aire normal, más alto = más sucio (máx 500).
g_vocIndex = s_sgp.measureVocIndex();
}
// ── Ventilador (capítulo 7) ────────────────────────────────────
static void setFan(bool on) { // on: true = encender, false = apagar
if (g_fanOn == on) return; // ya en estado deseado
g_fanOn = on;
digitalWrite(FAN_PIN, on ? HIGH : LOW);
s_link.telemetry.fanOn[0] = on; // campo del diccionario → nube → tarjeta
s_link.publishTelemetryNow(); // cambio de estado — publicamos de inmediato
}
// ── Automatización con histéresis (capítulo 7) ─────────────────
static void tickAutoLogic() {
if (g_mode == FilterMode::On) { setFan(true); return; }
if (g_mode == FilterMode::Off) { setFan(false); return; }
// Auto: encendemos en el umbral, apagamos 20 puntos debajo.
if (g_vocIndex < 0) return; // sensor aún en silencio
if (!g_fanOn && g_vocIndex >= g_threshold) setFan(true);
if ( g_fanOn && g_vocIndex <= g_threshold - 20) setFan(false);
}
// ── Callbacks de comandos del portal (capítulos 6–7) ────────────
static void onModeSelected(const char* opt) {
if (strcmp(opt, "auto") == 0) g_mode = FilterMode::Auto;
else if (strcmp(opt, "on") == 0) g_mode = FilterMode::On;
else g_mode = FilterMode::Off;
s_prefs.putUChar("mode", (uint8_t)g_mode);
tickAutoLogic(); // aplicamos de inmediato
}
static void onThresholdChanged(float v) {
g_threshold = (int32_t)v;
s_prefs.putInt("thr", g_threshold);
}
// ── setup: configuración, red, sensor, tarjeta ────────────────
void setup() {
Serial.begin(115200);
// Configuración desde NVS (segundos argumentos — valores por defecto).
s_prefs.begin("filter");
g_mode = (FilterMode)s_prefs.getUChar("mode", (uint8_t)FilterMode::Auto);
g_threshold = s_prefs.getInt("thr", 150);
pinMode(FAN_PIN, OUTPUT);
s_link.begin(); // Wi-Fi, MQTT, vinculación — todo adentro
initVocSensor();
// Telemetría: agregamos campo vocIndex propio (capítulo 5).
s_link.onTelemetryPublish([](JsonObject doc) {
if (g_vocIndex >= 0) {
doc["units"][0]["vocIndex"] = g_vocIndex;
}
});
// Tarjeta: sensor + órganos de control (capítulo 6).
s_link.card().sensor("voc", "VOC index", "", "units[0].vocIndex");
static const char* kModes[] = { "auto", "on", "off" };
s_link.card().select("mode", "Mode", kModes, 3, [](const char* opt) {
onModeSelected(opt);
});
s_link.card().number("threshold", "VOC threshold", 100, 400, 10, "", [](float v) {
onThresholdChanged(v);
});
// Diseño de la tarjeta de fábrica (capítulo 6, opcional).
s_link.card().layoutRow("voc", "fan");
s_link.card().layoutRow("mode", "threshold");
}
// ── loop: la red siempre, sensor y lógica una vez por segundo ─
static uint32_t s_lastReadMs = 0;
void loop() {
s_link.loop(); // red, telemetría, comandos — siempre primero
uint32_t now = millis();
if (now - s_lastReadMs >= 1000) {
s_lastReadMs = now;
readVocSensor(); // lectura fresca…
tickAutoLogic(); // …e inmediatamente decisión por ella
}
}