Добрый день, уважаемые читатели! Данная статья продолжает цикл статей, посвященных самодельному устройству на базе ESP32 DevKitC WROOM-32x и фреймворка Espressif IoT Development Framework. В прошлых статьях я рассказывал, как и из чего собрать устройство, а так же как создать самый простой вариант прошивки – устройство телеметрии.
Для тех, кто пропустил начальные статьи серии, приведу ссылки на них, дабы вы смогли с ними ознакомиться:
На текущий момент данная прошивка “умеет” считывать данные с подключенных сенсоров и отправлять их на различные сервера – MQTT брокер, Open Monitoring или (и) Thing Speak. В данном варианте устройство можно с успехом использовать как устройство телеметрии для дачи, гаража или, скажем, курятника. С помощью него можно периодически проверять температуру в доме и температуру “на выходе” из котла, дабы быть уверенным, что котел не погас и система не разморожена.
Но, согласитесь, не очень удобно всё время “вручную” контролировать заданные параметры температуры. У нас таки умный дом или что?
Гораздо удобнее было бы, если бы могли задать какие-то пороговые значения, и при выходе температуры за пределы заданных диапазонов устройство могло само уведомить вас о возникших проблемах, например посредством Telegram. Это позволит оперативно обнаружить неполадки без необходимости постоянного рутинного “ручного” мониторинга.
Вот этим мы и займемся в данной статье. А заодно я познакомлю вас с одной из своих библиотек – Range Monitor.
На самом деле, контроль температуры с уведомлениями в Telegram уже присутствует прошивке и до написания этой статьи. Просто я хочу объяснить, как работают некоторые “составные части” прошивки до того, как мы начнем двигаться далее.
Немного про гистерезис
Сама по себе логика контроля диапазонов (не только температуры, а и любых других физических параметров – влажности, напряжения, уровня воды и т.д. и т.п.) кажется очень-очень простой – задал границы диапазонов и всё. Это может быть только нижний предел диапазона (ниже которого опускаться нельзя); или только верхний предел (выше которого подниматься не рекомендуется); или же оба сразу. Далее просто сравниваем измеренное значение с заданным с помощью операторов < и > и всё. Как только измеряемая величина вышла за пределы – отправляем уведомление. Все? Да нет, не совсем…
Температура в доме – вещь непостоянная. Я бы даже сказал “ветренная”. Да и не только температура – и влажность, и напряжение и т.д. и т.п. Да и не только в доме – где угодно. За пару секунд температура может “пересечь” заданную вами границу несколько раз “туда” и “обратно”. Совсем не как у Толкиена. Сенсоры, с помощью которых эта самая температура измеряется, так же вещь несовершенная, и дают некую погрешность. Всё это приводит к тому, что при приближении контролируемой температуры к заданной границе могут происходить множественные срабатывания алгоритма контроля, что обязательно приведет к парочке десятков уведомлений, то есть спаму со стороны умного дома. Что нам отнюдь не нужно.
Например мы задали “погоду в доме” не ниже 20 °С. При достижении этой отметки отправляем уведомление – “похолодало, примите меры”. Но при следующем измерении сенсор выдал уже 20.01 °С. И устройство вновь шлет уведомление – “потеплело, расслабьтесь”. А через еще пару секунд – 19,99 °С. И цикл начинается по новой….
Поэтому вводим ещё один параметр – так называемый гистерезис.
Гистерезис позволяет несколько “сместить” границу в зависимости от того, в каком состоянии находится контролирующая система. Например устройство будет отправлять уведомление при снижении температуры до заданной границы 20°С, а вот при возвращении в заданный диапазон уведомление будет отправлено только при 21°С или даже 23°С. То есть гистерезис по сути позволяет организовать небольшую зону “нечувствительности” в зависимости от состояния. Можно организовать гистерезис:
- при выходе из диапазона – например отправлять уведомление о снижении температуры при 19°С, а при увеличении температуры – при 20°С
- при возвращении в диапазон – например отправлять уведомление о снижении температуры при 20°С, а при увеличении температуры – при 21°С
- симметрично – например отправлять уведомление о снижении температуры при 19,5°С, а при увеличении температуры – при 20,5°С (ну или не симметрично, никто не запрещает, но потребуется еще одна переменная)
Закат солнца вручную
Давайте таки уже будем писать наш г… хм, код. Как я уже писал выше, логика контроля диапазонов не представляет собой особых премудростей.
Для начала создадим новый тип – перечисление для хранения текущего состояния системы мониторинга, например так:
typedef enum { TMS_EMPTY = -2, TMS_TOO_LOW = -1, TMS_NORMAL = 0, TMS_TOO_HIGH = 1 } range_monitor_status_t;
Затем, само собой, создаем переменные для хранения этого самого состояния, для границ диапазонов и гистерезиса:
static range_monitor_status_t tempStatus = TMS_EMPTY; static float tempMin = 15.0; static float tempMax = 35.0; static float tempHyst = 1.0;
Ну и осталось написать несложную функцию:
void сheckTempRange(float value) { if (value != NAN) { if ((tempStatus == THS_EMPTY) || (tempStatus == TMS_NORMAL)) { // Если предыдущее состояние было в норме или не инициализировано, // то проверяем границы без учета гистерезиса и устанавливаем соответствующий статус if (value < tempMin) { tempStatus = THS_TOO_LOW; // Отправляем уведомление в главный чат о том, что температура вышла за нижний предел tgSend(MK_MAIN, МР_CRITICAL, 1, "ДОМ", "Температура в доме слишком <b>низкая</b>: <b>%.2f</b> °C", value); } else if (value > tempMax) { tempStatus = THS_TOO_HIGH; // Отправляем уведомление в главный чат о том, что температура вышла за верхний предел tgSend(MK_MAIN, МР_CRITICAL, 1, "ДОМ", "Температура в доме слишком <b>высокая</b>: <b>%.2f</b> °C", value); } else if (tempIndoorStatus == TMS_EMPTY) { // Просто меняем статус, без уведомлений tempStatus = THS_NORMAL; } else { // Если предыдущее состояние было вне диапазона, то проверяем границы уже с учетом гистерезиса if ((value >= (tempMin + tempHyst)) && (value <= (tempMax - tempHyst))) { tempStatus = THS_NORMAL; // Отправляем уведомление в главный чат о том, что температура вернулась в норму tgSend(MK_MAIN, МР_CRITICAL, 1, "ДОМ", "Температура в доме <b>вернулась в нормальный диапазон</b>: <b>%.2f</b> °С", value); }; }; };
Как я и говорил – ничего сложного. Здесь я применил готовую функцию отправки уведомлений в телегу из своих библиотек. Вы тоже можете её легко использовать.
Ну и в основном цикле прикладной задачи, в которой происходит чтение данных с сенсоров (она у меня так и называется – sensors), добавим вызов этой функции с передачей ей в качестве аргумента текущего значения температуры. Проверять температуру имеет смысл только тогда, когда сам сенсор находится в нормальном состоянии и выдает корректные данные:
if (sensorIndoor.getStatus() == SENSOR_STATUS_OK) { сheckTempRange(sensorIndoor.getValue2(false).filteredValue); ];
Если вам нужен контроль диапазона температуры ещё по одному датчику – повторяем шаги описанные выше. Ровно столько раз, сколько вам требуется, с разными переменными и значениями.
Здесь вам самим придется придумать, как изменять границы диапазонов “извне” устройства. Ну а я использую немного другой способ, об чем будет рассказано ниже.
Используем класс reRangeMonitor
Спустя какое-то время приходит понимание, что уведомления-то они то хорошо, да маловато будет. Например уведомление может и не прийти из-за отсутствия интернета. Хотелось бы зафиксировать время “перехода” значения из одного состояния в другое. А если уж мы фиксируем состояние и время, то хотелось бы публиковать все это добро на MQTT-брокере для отображения всего этого в программе управления.
Кроме того, я очень (нет, таки очень-очень-очень-очень) не люблю повторять один и тот же код. Потому что любые мало-мальские изменения в нем придется повторить много раз в дальнейшем. Поэтому почти всегда код, который потенциально может быть использован в нескольких проектах, я выношу в отдельную библиотечку. В данном случае это напрашивалось само собой, тем более что классы очень удобно использовать многократно внутри одного проекта, и не нужно будет писать несколько отдельных функций.
Итак, представляю вам небольшую “классную” библиотечку:
Какие функции она предоставляет:
- Хранение текущего состояния (как я описывал выше)
- Хранение меток времени последнего пересечения заданных границ и возвращения в нормальный диапазон
- Хранение текущего значения (только для публикации на MQTT-брокере в виде JSON-пакета)
- Генерацию JSON-пакета со всеми зафиксированными минимумами и максимумами, а также временными отметками для публикации на MQTT-брокере.
- Хранение динамически созданного MQTT-топика (в моих проектах топики могут изменяться в зависимости от того, к какому брокеру – основному или резервному, подключено в данным момент устройство)
- Отправка уведомлений осуществляется за счет подключенной callback функции, поэтому вы можете придумать свой способ уведомлений, например на email или в viber, например.
Как этим пользоваться
Конструктор класса выглядит следующим образом:
class reRangeMonitor { public: reRangeMonitor( float value_min, // Минимальное значение float value_max, // Максимальное значение float hysteresis, // Гистерезис const char* nvs_space, // Имя NVS секции, в которую будут сохранены параметры и внутренние данные объекта cb_monitor_outofrange_t cb_status, // Callback при изменении состояния cb_monitor_publish_t cb_publish // Callback при необходимости публикации состояния ); ... };
Здесь мы сразу же задаем:
- начальные границы диапазона и гистерезис (их можно будет потом изменить через MQTT-брокер)
- имя для сохранения внутренних данных в NVS-хранилище
- функцию обратного вызова для отправки уведомлений при изменении состояния
- функцию обратного вызова для публикации данных на брокере
Последние три параметра можно не указывать при создании экземпляра, а “привязать” их позже, во время создания прикладной задачи.
Перво-наперво объявим статическую переменную, в которой будет храниться указатель на экземпляр класса reRangeMonitor. Здесь я как раз не стал указывать функции обратного вызова, так как переменная объявлена в заголовочном файле h, а все callback-и описаны в cpp. Их я подключу позже.
#define CONTROL_TEMP_INDOOR_KEY "indoor" #define CONTROL_TEMP_INDOOR_TOPIC "indoor" #define CONTROL_TEMP_INDOOR_FRIENDLY "Дом" #define CONTROL_TEMP_INDOOR_NOTIFY_KIND MK_MAIN #define CONTROL_TEMP_INDOOR_NOTIFY_PRIORITY MP_CRITICAL #define CONTROL_TEMP_INDOOR_NOTIFY_ALARM 1 #define CONTROL_TEMP_INDOOR_NOTIFY_TOO_LOW "❄️ Температура в доме <i><b>слишком низкая</b></i>: <b>%.2f</b> °С" #define CONTROL_TEMP_INDOOR_NOTIFY_TOO_HIGH "☀️ Температура в доме <i><b>слишком высокая</b></i>: <b>%.2f</b> °С" #define CONTROL_TEMP_INDOOR_NOTIFY_NORMAL "🆗 Температура в доме <i><b>вернулась в нормальный диапазон</b></i>: <b>%.2f</b> °С" static reRangeMonitor tempMonitorIndoor(20, 30, 0.1, nullptr, nullptr, nullptr);
Далее, напишем как раз эти самые функции обратного вызова:
static bool monitorPublish(reRangeMonitor *monitor, char* topic, char* payload, bool free_topic, bool free_payload) { return mqttPublish(topic, payload, CONTROL_TEMP_QOS, CONTROL_TEMP_RETAINED, free_topic, free_payload); } static void monitorNotifyIndoor(reRangeMonitor *monitor, range_monitor_status_t status, bool notify, float value, float min, float max) { if (notify) { if (status == TMS_NORMAL) { tgSend(CONTROL_TEMP_INDOOR_NOTIFY_KIND, CONTROL_TEMP_INDOOR_NOTIFY_PRIORITY, CONTROL_TEMP_INDOOR_NOTIFY_ALARM, CONFIG_TELEGRAM_DEVICE, CONTROL_TEMP_INDOOR_NOTIFY_NORMAL, value); } else if (status == TMS_TOO_LOW) { tgSend(CONTROL_TEMP_INDOOR_NOTIFY_KIND, CONTROL_TEMP_INDOOR_NOTIFY_PRIORITY, CONTROL_TEMP_INDOOR_NOTIFY_ALARM, CONFIG_TELEGRAM_DEVICE, CONTROL_TEMP_INDOOR_NOTIFY_TOO_LOW, value); } else if (status == TMS_TOO_HIGH) { tgSend(CONTROL_TEMP_INDOOR_NOTIFY_KIND, CONTROL_TEMP_INDOOR_NOTIFY_PRIORITY, CONTROL_TEMP_INDOOR_NOTIFY_ALARM, CONFIG_TELEGRAM_DEVICE, CONTROL_TEMP_INDOOR_NOTIFY_TOO_HIGH, value); } } }
Инициализация мониторинга выглядит следующим образом:
tempMonitorIndoor.nvsRestore(CONTROL_TEMP_INDOOR_KEY); tempMonitorIndoor.setStatusCallback(monitorNotifyIndoor); tempMonitorIndoor.mqttSetCallback(monitorPublish); tempMonitorIndoor.paramsRegister(pgTempMonitor, CONTROL_TEMP_INDOOR_KEY, CONTROL_TEMP_INDOOR_TOPIC, CONTROL_TEMP_INDOOR_FRIENDLY);
Здесь:
- восстанавливается последнее состояние объекта из NVS-раздела
- подключаются callback-и
- регистрируются параметры (границы диапазонов и гистерезис) на MQTT-клиенте. Придуманная мной система управления параметрами выполнит всю необходимую работу, в том числе подписку на MQTT-брокере и хранение данных в NVS-разделе. Об этом я постараюсь рассказать как-нибудь позже, напомните мне, если я забуду.
Ну и осталось немного изменить код проверки внутри основного цикла прикладной задачи:
if (sensorIndoor.getStatus() == SENSOR_STATUS_OK) { tempMonitorIndoor.checkValue(sensorIndoor.getValue2(false).filteredValue); }; if (sensorBoiler.getStatus() == SENSOR_STATUS_OK) { tempMonitorBoiler.checkValue(sensorBoiler.getValue(false).filteredValue); };
Можно пробовать… Уведомления приходить уже будут (если вы правильно настроили токены telegram бота(ов) в project_config.h.
Но вот на MQTT брокере пока ничего не появится. Это потому, что мы не сгенерировали топик(и) для публикации. Для этого придется создать обработчики системных событий подключения и отключения MQTT-клиента к брокеру:
static void sensorsMqttEventHandler(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) { // MQTT connected if (event_id == RE_MQTT_CONNECTED) { re_mqtt_event_data_t* data = (re_mqtt_event_data_t*)event_data; sensorsMqttTopicsCreate(data->primary); } // MQTT disconnected else if ((event_id == RE_MQTT_CONN_LOST) || (event_id == RE_MQTT_CONN_FAILED)) { sensorsMqttTopicsFree(); } }
Проще всего добавить необходимый код в уже существующие функции sensorsMqttTopicsCreate() и sensorsMqttTopicsFree(), что я и сделал:
static void sensorsMqttTopicsCreate(bool primary) { sensorOutdoor.topicsCreate(primary); sensorIndoor.topicsCreate(primary); if (tempMonitorIndoor.mqttTopicCreate(primary, CONTROL_TEMP_LOCAL, CONTROL_TEMP_GROUP_TOPIC, CONTROL_TEMP_INDOOR_TOPIC, nullptr)) { rlog_i(logTAG, "Generated topic for indoor temperture control: [ %s ]", tempMonitorIndoor.mqttTopicGet()); }; sensorBoiler.topicsCreate(primary); if (tempMonitorBoiler.mqttTopicCreate(primary, CONTROL_TEMP_LOCAL, CONTROL_TEMP_GROUP_TOPIC, CONTROL_TEMP_BOILER_TOPIC, nullptr)) { rlog_i(logTAG, "Generated topic for boiler temperture control: [ %s ]", tempMonitorBoiler.mqttTopicGet()); }; lcBoiler.mqttTopicCreate(primary, CONTROL_THERMOSTAT_LOCAL, CONTROL_THERMOSTAT_BOILER_TOPIC, nullptr, nullptr); } static void sensorsMqttTopicsFree() { sensorOutdoor.topicsFree(); sensorIndoor.topicsFree(); tempMonitorIndoor.mqttTopicFree(); sensorBoiler.topicsFree(); tempMonitorBoiler.mqttTopicFree(); lcBoiler.mqttTopicFree(); rlog_d(logTAG, "Topics for temperture control has been scrapped"); }
Теперь при изменении состояния класс сам опубликует всю доступную ему информацию на брокере (ну и уведомление отправит, само собой). До выхода из границ никакой информации на брокере не появится. Если вы хотите принудительно публиковать данные с заданным интервалом, то можно добавить такую строчку в основной цикл задачи:
// ----------------------------------------------------------------------------------------------------- // Публикация данных с сенсоров // ----------------------------------------------------------------------------------------------------- // MQTT брокер if (esp_heap_free_check() && statesMqttIsConnected() && timerTimeout(&mqttPubTimer)) { timerSet(&mqttPubTimer, iMqttPubInterval*1000); sensorOutdoor.publishData(false); sensorIndoor.publishData(false); tempMonitorIndoor.mqttPublish(); sensorBoiler.publishData(false); tempMonitorBoiler.mqttPublish(); lcBoiler.mqttPublish(); };
Ах да, чуть не забыл… Если вы желаете сохранять состояние и после перезапуска устройства, то нужно вызывать функцию nvsStore(…); перед перезапуском устройства.
static void sensorsStoreData() { rlog_i(logTAG, "Store sensors data"); sensorOutdoor.nvsStoreExtremums(SENSOR_OUTDOOR_KEY); sensorIndoor.nvsStoreExtremums(SENSOR_INDOOR_KEY); sensorBoiler.nvsStoreExtremums(SENSOR_BOILER_KEY); tempMonitorIndoor.nvsStore(CONTROL_TEMP_INDOOR_KEY); tempMonitorBoiler.nvsStore(CONTROL_TEMP_BOILER_KEY); lcBoiler.countersNvsStore(); }
Можно делать это при изменении состояния или регулярно. Но не стоит делать это слишком часто, чтобы слишком быстро не заполнять NVS раздел и не изнашивать flash-память. Важно соблюдать баланс. Но сохранение состояния не является обязательной функцией, можно этим и пренебречь.
А что на MQTT-брокере?
На MQTT брокере должны быть вот такие топики (если вы все сделали правильно):
Данные из них уже можно извлечь и отобразить в каком-нибудь MQTT Dashboard.
Настройки границ, как им и полагается, расположены в разделе config / confirm:
Как я уже писал в предыдущих статьях цикла, здесь общее правило таково: с телефона мы отправляем новые значения в топик device/config/bla-bla-bla, а в ответ мы должны получить то же самое значение в device/confirm/bla-bla-bla. Это позволяет гарантировать, то наши настройки успешно получены устройством и обработаны.
На этом пока всё, до встречи на сайте и на dzen-канале!
Все статьи цикла “Термостат и ОПС”:
- Часть 1. Вводная: общее описание и возможности
- Часть 2. Перечень необходимых компонентов, схемы отдельных узлов, печатная плата
- Часть 3. Минимальный вариант: только телеметрия через MQTT брокер
- Часть 4. Описание генерируемых устройством MQTT-топиков
- Часть 5. Добавляем выгрузку данных на внешние сервисы
- Часть 6. Изменения в прошивке под требования на ESP-IDF 5.0.1
- Часть 7. Автоматический контроль диапазонов температуры
- Часть 8. Класс rSensor и как заменить сенсоры на другие из списка поддерживаемых
- Часть 9. Термостат и управление нагрузкой
- Часть 10. Охранно-пожарная и аварийная сигнализация
Прошивка K12 для ESP32 и ESP-IDF:
Дополнительные статьи, которые применимы к любым устройствам, запрограммированным с помощью тех же самых библиотек.
- Прошивка для ESP32 на основе ESP-IDF: описание модулей и библиотек
- Настройка Android-приложения MQTT Dash для работы с устройством
- rLoadControl: индикация состояния нагрузки на MQTT DASH
- Команды управления
💠 Полный архив статей вы найдете здесь
Пожалуйста, оцените статью: