Skip to content

Automation logic

Connecting everything: sensor decides, fan spins, portal controls.

1. State and settings

To the beginning of 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;   // VOC index to turn on
static bool       g_fanOn     = false;

static Preferences s_prefs;

Preferences (NVS) — non-volatile memory of ESP32: mode and threshold survive a reboot.

2. Fan control

All turning on and off goes into one setFan function. It takes one argument on — desired state: true = turn on, false = turn off. Further in the code we always call setFan(true) / setFan(false), and it does all the routine: toggles the pin, remembers the state, and tells the portal.

static void setFan(bool on) {      // on — argument: true = turn on, false = turn off
    if (g_fanOn == on) return;     // already in desired state — do nothing
    g_fanOn = on;                  // remember new state in global variable
    digitalWrite(FAN_PIN, on ? HIGH : LOW);  // physically turn fan switch on/off

    // Tell the core the state: fanOn[0] — vocabulary telemetry field
    // (appeared from hasFan = true; [0] — our only unit, like in chapter 5).
    // From here it will go to the cloud and to the "Fan" cell on the card.
    s_link.telemetry.fanOn[0] = on;

    // State change — reason to send telemetry immediately, don't wait for period.
    s_link.publishTelemetryNow();
}

publishTelemetryNow() makes the response instant: clicked on portal — within a second the card shows the confirmed state. Confirmed: iDryer portal never "guesses" state, it shows what the device actually sent.

3. Automation with hysteresis

If you turn the fan on exactly at the threshold, around the threshold it will chatter on/off. Fixed with a gap:

static void tickAutoLogic() {
    if (g_mode == FilterMode::On)  { setFan(true);  return; }
    if (g_mode == FilterMode::Off) { setFan(false); return; }

    // Auto: turn on at threshold, turn off 20 points below.
    if (g_vocIndex < 0) return;                      // sensor still silent
    if (!g_fanOn && g_vocIndex >= g_threshold)      setFan(true);
    if ( g_fanOn && g_vocIndex <= g_threshold - 20) setFan(false);
}

We will call tickAutoLogic() in the same place we read the sensor — in loop() with a one-second timer. This is the same loop() from chapter 5, adding one line to it. Now it looks like:

void loop() {
    s_link.loop();                        // network, telemetry, commands — always first

    uint32_t now = millis();
    if (now - s_lastReadMs >= 1000) {     // once per second:
        s_lastReadMs = now;
        readVocSensor();                  //   read VOC (chapter 5)
        tickAutoLogic();                  //   and immediately decide on fan
    }
}

The order inside the one-second block is not accidental: fresh sensor reading first, then decision on it.

4. Callbacks from portal

Those very functions we promised in chapter 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();   // apply immediately, don't wait for next tick
}

static void onThresholdChanged(float v) {
    g_threshold = (int32_t)v;
    s_prefs.putInt("thr", g_threshold);
}

These functions replace the stubs from chapter 6 — delete the empty versions.

Notice what this code doesn't have: MQTT parsing, topics, JSON commands. User selected on in list on portal → core got the command, checked it, called onModeSelected("on"). All transport mechanics — core's responsibility.

5. Final setup()

Two more things to add to setup(): loading saved settings from NVS (at the beginning, so logic works with them immediately) and configuring the fan pin. Complete setup() after this chapter looks like:

void setup() {
    Serial.begin(115200);

    // Settings from NVS: what the user chose before.
    s_prefs.begin("filter");   // open "filter" namespace in NVS
    g_mode      = (FilterMode)s_prefs.getUChar("mode", (uint8_t)FilterMode::Auto);
    g_threshold = s_prefs.getInt("thr", 150);
    // Second arguments of getUChar/getInt — default values: will return
    // on the very first startup, when NVS has nothing saved yet.

    pinMode(FAN_PIN, OUTPUT);  // fan switch pin — to output

    s_link.begin();
    initVocSensor();

    // Telemetry: custom vocIndex field (chapter 5).
    s_link.onTelemetryPublish([](JsonObject doc) {
        if (g_vocIndex >= 0) {
            doc["units"][0]["vocIndex"] = g_vocIndex;
        }
    });

    // Card: sensor + controls (chapter 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);
    });

    // Layout (chapter 6, optional).
    s_link.card().layoutRow("voc", "fan");
    s_link.card().layoutRow("mode", "threshold");
}

6. Scenario verification

Action Expected
Mode auto, blow on sensor VOC grows, at threshold fan turns on, card shows "On"
Air cleaned below threshold−20 fan turns off by itself
Mode on from portal fan spins regardless of VOC
Mode off from portal fan stops, VOC continues showing
Board reboot mode and threshold saved

7. Complete code: src/main.cpp whole

All code from chapters 4–7, assembled into one file. If something doesn't match yours — check against this listing.

// ============================================================
// Smart air filter on idryer-core.
// SGP40 (VOC) + fan via MOSFET, auto/manual mode,
// control and card on portal via card manifest.
// ============================================================

#include <iDryer.h>
#include <Wire.h>
#include <Adafruit_SGP40.h>
#include <Preferences.h>

// ── Pins ────────────────────────────────────────────────────
static const int FAN_PIN = 4;         // MOSFET gate for fan
// SDA=8, SCL=9 — set in Wire.begin() below

// ── Device passport (chapter 4) ──────────────────────────────
static const iDryer::Config CFG = {
    .deviceType        = iDryer::DeviceType::Unknown, // non-standard device
    .unitsCount        = 1,
    .hasFan            = true,        // only vocabulary skill
    .telemetryPeriodMs = 5000,
    .statusPeriodMs    = 10000,
    .hardwareVersion   = "1.0",
    .firmwareVersion   = "0.1.0",
    .model             = "DIY Air Filter",
};

static iDryer::Link s_link(CFG);

// ── State (chapter 7) ────────────────────────────────────────
enum class FilterMode : uint8_t { Auto, On, Off };
static FilterMode g_mode      = FilterMode::Auto;
static int32_t    g_threshold = 150;  // VOC index to turn on
static bool       g_fanOn     = false;

static Preferences s_prefs;           // NVS: settings survive reboot

// ── VOC sensor (chapter 5) ───────────────────────────────────
static Adafruit_SGP40 s_sgp;
static int32_t g_vocIndex = -1;       // -1 = no data yet

static void initVocSensor() {
    Wire.begin(/*SDA=*/8, /*SCL=*/9);
    if (!s_sgp.begin()) {
        Serial.println("[VOC] SGP40 not found, check wiring");
    }
}

static void readVocSensor() {
    // Index: ~100 = normal air, higher = dirtier (max 500).
    g_vocIndex = s_sgp.measureVocIndex();
}

// ── Fan (chapter 7) ──────────────────────────────────────────
static void setFan(bool on) {         // on: true = turn on, false = turn off
    if (g_fanOn == on) return;        // already in desired state
    g_fanOn = on;
    digitalWrite(FAN_PIN, on ? HIGH : LOW);
    s_link.telemetry.fanOn[0] = on;   // vocabulary field → cloud → card
    s_link.publishTelemetryNow();     // state change — publish immediately
}

// ── Automation with hysteresis (chapter 7) ──────────────────
static void tickAutoLogic() {
    if (g_mode == FilterMode::On)  { setFan(true);  return; }
    if (g_mode == FilterMode::Off) { setFan(false); return; }

    // Auto: turn on at threshold, turn off 20 points below.
    if (g_vocIndex < 0) return;       // sensor still silent
    if (!g_fanOn && g_vocIndex >= g_threshold)      setFan(true);
    if ( g_fanOn && g_vocIndex <= g_threshold - 20) setFan(false);
}

// ── Command callbacks from portal (chapters 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();                  // apply immediately
}

static void onThresholdChanged(float v) {
    g_threshold = (int32_t)v;
    s_prefs.putInt("thr", g_threshold);
}

// ── setup: settings, network, sensor, card ──────────────────
void setup() {
    Serial.begin(115200);

    // Settings from NVS (second arguments — defaults on first run).
    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, linking — all inside
    initVocSensor();

    // Telemetry: add custom vocIndex field (chapter 5).
    s_link.onTelemetryPublish([](JsonObject doc) {
        if (g_vocIndex >= 0) {
            doc["units"][0]["vocIndex"] = g_vocIndex;
        }
    });

    // Card: sensor + controls (chapter 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);
    });

    // Factory card layout (chapter 6, optional).
    s_link.card().layoutRow("voc", "fan");
    s_link.card().layoutRow("mode", "threshold");
}

// ── loop: network always, sensor and logic once per second ──
static uint32_t s_lastReadMs = 0;

void loop() {
    s_link.loop();                    // network, telemetry, commands — always first

    uint32_t now = millis();
    if (now - s_lastReadMs >= 1000) {
        s_lastReadMs = now;
        readVocSensor();              // fresh reading…
        tickAutoLogic();              // …and immediately decision on it
    }
}