Логика автоматики¶
Соединяем всё: датчик решает, вентилятор крутит, портал управляет.
1. Состояние и настройки¶
В начало 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-индекс включения
static bool g_fanOn = false;
static Preferences s_prefs;
Preferences (NVS) — энергонезависимая память ESP32: режим и порог переживут перезагрузку.
2. Управление вентилятором¶
Всё включение и выключение сведём в одну функцию setFan. Она принимает один аргумент on — желаемое состояние: true = включить, false = выключить. Дальше по коду мы всегда зовём setFan(true) / setFan(false), а она делает всю рутину: дёргает пин, запоминает состояние и сообщает порталу.
static void setFan(bool on) { // on — аргумент: true = включить, false = выключить
if (g_fanOn == on) return; // уже в нужном состоянии — ничего не делаем
g_fanOn = on; // запоминаем новое состояние в глобальной переменной
digitalWrite(FAN_PIN, on ? HIGH : LOW); // физически включаем/выключаем ключ вентилятора
// Сообщаем состояние ядру: fanOn[0] — словарное поле телеметрии
// (появилось из hasFan = true; [0] — наш единственный юнит, как в главе 5).
// Отсюда оно уедет в облако и на ячейку «Вентилятор» карточки.
s_link.telemetry.fanOn[0] = on;
// Смена состояния — повод отправить телеметрию сразу, не ждать периода.
s_link.publishTelemetryNow();
}
publishTelemetryNow() делает отклик мгновенным: нажали на портале — через секунду карточка показывает подтверждённое состояние. Именно подтверждённое: портал iDryer никогда не «угадывает» состояние, он показывает то, что устройство реально прислало.
3. Автоматика с гистерезисом¶
Если включать вентилятор ровно на пороге, около порога он будет дребезжать вкл/выкл. Лечится зазором:
static void tickAutoLogic() {
if (g_mode == FilterMode::On) { setFan(true); return; }
if (g_mode == FilterMode::Off) { setFan(false); return; }
// Auto: включаемся на пороге, выключаемся на 20 пунктов ниже.
if (g_vocIndex < 0) return; // датчик ещё молчит
if (!g_fanOn && g_vocIndex >= g_threshold) setFan(true);
if ( g_fanOn && g_vocIndex <= g_threshold - 20) setFan(false);
}
Вызывать tickAutoLogic() будем там же, где читаем датчик, — в loop() по секундному таймеру. Это тот самый loop() из главы 5, в него добавляется одна строка. Целиком он теперь выглядит так:
void loop() {
s_link.loop(); // сеть, телеметрия, команды — всегда первым
uint32_t now = millis();
if (now - s_lastReadMs >= 1000) { // раз в секунду:
s_lastReadMs = now;
readVocSensor(); // читаем VOC (глава 5)
tickAutoLogic(); // и сразу принимаем решение по вентилятору
}
}
Порядок внутри секундного блока не случаен: сначала свежее показание датчика, потом решение по нему.
4. Колбэки с портала¶
Те самые функции, что мы обещали в главе 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(); // применяем сразу, не ждём следующего тика
}
static void onThresholdChanged(float v) {
g_threshold = (int32_t)v;
s_prefs.putInt("thr", g_threshold);
}
Эти функции заменяют заглушки из главы 6 — пустые версии удалите.
Обратите внимание, чего в этом коде нет: разбора MQTT, топиков, JSON команд. Пользователь выбрал on в списке на портале → ядро получило команду, проверило её и вызвало onModeSelected("on"). Вся транспортная механика — забота ядра.
5. Финальный setup()¶
Осталось добавить в setup() две вещи: загрузку сохранённых настроек из NVS (в начале, чтобы логика сразу работала с ними) и настройку пина вентилятора. Целиком setup() после этой главы выглядит так:
void setup() {
Serial.begin(115200);
// Настройки из NVS: то, что пользователь выбирал в прошлые разы.
s_prefs.begin("filter"); // открыть пространство имён "filter" в NVS
g_mode = (FilterMode)s_prefs.getUChar("mode", (uint8_t)FilterMode::Auto);
g_threshold = s_prefs.getInt("thr", 150);
// Вторые аргументы getUChar/getInt — значения по умолчанию: вернутся
// при самом первом запуске, когда в NVS ещё ничего не сохранено.
pinMode(FAN_PIN, OUTPUT); // пин ключа вентилятора — на выход
s_link.begin();
initVocSensor();
// Телеметрия: своё поле vocIndex (глава 5).
s_link.onTelemetryPublish([](JsonObject doc) {
if (g_vocIndex >= 0) {
doc["units"][0]["vocIndex"] = g_vocIndex;
}
});
// Карточка: сенсор + органы управления (глава 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);
});
// Разметка (глава 6, по желанию).
s_link.card().layoutRow("voc", "fan");
s_link.card().layoutRow("mode", "threshold");
}
6. Проверка сценариев¶
| Действие | Ожидание |
|---|---|
Режим auto, дунуть на датчик |
VOC растёт, на пороге вентилятор включается, карточка показывает «Вкл» |
| Воздух очистился | ниже порога−20 вентилятор выключается сам |
Режим on с портала |
вентилятор крутит независимо от VOC |
Режим off с портала |
вентилятор стоит, VOC продолжает показываться |
| Перезагрузка платы | режим и порог сохранились |
7. Итоговый код: src/main.cpp целиком¶
Весь код глав 4–7, собранный в один файл. Если что-то не сходится с вашим — сверяйтесь с этим листингом.
// ============================================================
// Умный фильтр воздуха на idryer-core.
// SGP40 (VOC) + вентилятор через MOSFET, авто/ручной режим,
// управление и карточка на портале через card-манифест.
// ============================================================
#include <iDryer.h>
#include <Wire.h>
#include <Adafruit_SGP40.h>
#include <Preferences.h>
// ── Пины ────────────────────────────────────────────────────
static const int FAN_PIN = 4; // затвор MOSFET вентилятора
// SDA=8, SCL=9 — задаются в Wire.begin() ниже
// ── Паспорт устройства (глава 4) ────────────────────────────
static const iDryer::Config CFG = {
.deviceType = iDryer::DeviceType::Unknown, // нестандартное устройство
.unitsCount = 1,
.hasFan = true, // единственный словарный навык
.telemetryPeriodMs = 5000,
.statusPeriodMs = 10000,
.hardwareVersion = "1.0",
.firmwareVersion = "0.1.0",
.model = "DIY Air Filter",
};
static iDryer::Link s_link(CFG);
// ── Состояние (глава 7) ─────────────────────────────────────
enum class FilterMode : uint8_t { Auto, On, Off };
static FilterMode g_mode = FilterMode::Auto;
static int32_t g_threshold = 150; // VOC-индекс включения
static bool g_fanOn = false;
static Preferences s_prefs; // NVS: настройки переживают перезагрузку
// ── Датчик VOC (глава 5) ────────────────────────────────────
static Adafruit_SGP40 s_sgp;
static int32_t g_vocIndex = -1; // -1 = данных ещё нет
static void initVocSensor() {
Wire.begin(/*SDA=*/8, /*SCL=*/9);
if (!s_sgp.begin()) {
Serial.println("[VOC] SGP40 not found, check wiring");
}
}
static void readVocSensor() {
// Индекс: ~100 = обычный воздух, выше = грязнее (макс 500).
g_vocIndex = s_sgp.measureVocIndex();
}
// ── Вентилятор (глава 7) ────────────────────────────────────
static void setFan(bool on) { // on: true = включить, false = выключить
if (g_fanOn == on) return; // уже в нужном состоянии
g_fanOn = on;
digitalWrite(FAN_PIN, on ? HIGH : LOW);
s_link.telemetry.fanOn[0] = on; // словарное поле → облако → карточка
s_link.publishTelemetryNow(); // смена состояния — публикуем сразу
}
// ── Автоматика с гистерезисом (глава 7) ─────────────────────
static void tickAutoLogic() {
if (g_mode == FilterMode::On) { setFan(true); return; }
if (g_mode == FilterMode::Off) { setFan(false); return; }
// Auto: включаемся на пороге, выключаемся на 20 пунктов ниже.
if (g_vocIndex < 0) return; // датчик ещё молчит
if (!g_fanOn && g_vocIndex >= g_threshold) setFan(true);
if ( g_fanOn && g_vocIndex <= g_threshold - 20) setFan(false);
}
// ── Колбэки команд с портала (главы 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(); // применяем сразу
}
static void onThresholdChanged(float v) {
g_threshold = (int32_t)v;
s_prefs.putInt("thr", g_threshold);
}
// ── setup: настройки, сеть, датчик, карточка ────────────────
void setup() {
Serial.begin(115200);
// Настройки из NVS (вторые аргументы — дефолты первого запуска).
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, привязка — всё внутри
initVocSensor();
// Телеметрия: дописываем своё поле vocIndex (глава 5).
s_link.onTelemetryPublish([](JsonObject doc) {
if (g_vocIndex >= 0) {
doc["units"][0]["vocIndex"] = g_vocIndex;
}
});
// Карточка: сенсор + органы управления (глава 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);
});
// Заводская разметка карточки (глава 6, по желанию).
s_link.card().layoutRow("voc", "fan");
s_link.card().layoutRow("mode", "threshold");
}
// ── loop: сеть всегда, датчик и логика раз в секунду ────────
static uint32_t s_lastReadMs = 0;
void loop() {
s_link.loop(); // сеть, телеметрия, команды — всегда первым
uint32_t now = millis();
if (now - s_lastReadMs >= 1000) {
s_lastReadMs = now;
readVocSensor(); // свежее показание…
tickAutoLogic(); // …и сразу решение по нему
}
}