Перейти к содержимому

Термостат на ESP32 с удаленным управлением. Часть 7. Автоматический контроль диапазонов температуры

Добрый день, уважаемые читатели! Данная статья продолжает цикл статей, посвященных самодельному устройству на базе 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-брокере для отображения всего этого в программе управления.

Кроме того, я очень (нет, таки очень-очень-очень-очень) не люблю повторять один и тот же код. Потому что любые мало-мальские изменения в нем придется повторить много раз в дальнейшем. Поэтому почти всегда код, который потенциально может быть использован в нескольких проектах, я выношу в отдельную библиотечку. В данном случае это напрашивалось само собой, тем более что классы очень удобно использовать многократно внутри одного проекта, и не нужно будет писать несколько отдельных функций.

Итак, представляю вам небольшую “классную” библиотечку:

GitHub – kotyara12/reRangeMonitor: Контроль значений (температуры, влажности, напряжения и т.д.) в заданных пределахgithub.com

Какие функции она предоставляет:

  • Хранение текущего состояния (как я описывал выше)
  • Хранение меток времени последнего пересечения заданных границ и возвращения в нормальный диапазон
  • Хранение текущего значения (только для публикации на 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-канале!

 

Все статьи цикла “Термостат и ОПС”:

Прошивка K12 для ESP32 и ESP-IDF:

Дополнительные статьи, которые применимы к любым устройствам, запрограммированным с помощью тех же самых библиотек.

 

💠 Полный архив статей вы найдете здесь

 


Пожалуйста, оцените статью:
[ 0 из 5, всего 0 оценок ]

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *