Как стать автором
Обновить
192.39
Wiren Board
Оборудование для автоматизации и мониторинга

Управление вентиляцией. Типовые алгоритмы и их реализация на wb-rules

Время на прочтение 35 мин
Количество просмотров 7.8K

Управление вентиляцией: собираем, интегрируем, экономим
Управление вентиляцией. Электронагрев воздуха. Эссе про технику и деньги
Управление вентиляцией. Типовые алгоритмы и их реализация на wb-rules

В финале прошлой статьи я устроил голосование, для какой именно вентустановки мы будем разрабатывать алгоритмы: с водяным нагревом или электрическим. Убедительную победу одержал водяной нагрев воздуха. Ну, значит, так тому и быть.

Вводные положения

Давайте вспомним, как устроена наша система автоматизации. Вот схема, которая была приведена в первой статье. Я ее немного доработал — добавил выключатель с фиксацией «Пуск/Стоп» и завел его на свободный вход релейного модуля. 

Сразу хочу предупредить, что я не программист, а инженер, никогда серьезно программированием не занимавшийся. Я могу неправильно применять термины, не знать типовые паттерны… отнеситесь снисходительно, если что. Я сразу согласен с тем, что можно написать по-другому: и лучше, и проще, и надежнее… Моя цель — показать, что программирование контроллера Wiren Board вполне доступно человеку, который способен собрать вентустановку и подключить ее к контроллеру. Знания я черпал из описания нашего движка правил wb-rules, примеров правил и спецификации языка ECMAScript.

Я протестировал всё мною написанное на тестовом стенде:

Поэтому могу уверенно заявить, что это работает (на фото много лишнего, не обращайте внимания). 

Да, до состояния Production скрипты еще надо «отлизывать», но весь основной функционал предусмотрен. 

Чтобы сделать статью компактней, я пошел на некоторые упрощения:

  • рассмотрена только приточная вентустановка (для вытяжной алгоритмы те же, исключая нагрев воздуха);

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

Самостоятельная реализация перечисленного не должна вызывать затруднений.

Про алгоритмы

Алгоритмы (точнее, блок-схемы) я отрисовывал, используя сервис DrakonHub, на «драконьем» языке. Он более удобен, чем классический алгоритмический язык, который нам давали на уроках информатики. На этом же сервисе есть статья, которую я активно использовал. Для дальнейшего понимания читать все это не обязательно — моих комментариев к алгоритмам будет вполне достаточно.

Настройка оборудования

Начну с настройки оборудования нашей системы автоматизации, используя веб-интерфейс контроллера. Для возможности осуществления настроек необходимо получить права доступа Администратор».

Датчики

Датчики температуры, подключенные к интерфейсу 1-Wire, настраивать не нужно. Драйвер wb-mqtt-w1 всегда ведет опрос устройств. Как только датчик подключен, он сразу появляется на странице Устройства веб-интерфейса контроллера. Там можно увидеть его адрес и  значение измеряемой температуры.

В моем случае это выглядит так:

Релейный модуль

Релейный модуль WB-MR6C v.3 подключен к первому порту RS-485 контроллера Wiren Board 7. Общается с модулем драйвер wb-mqtt-serial. Вот его мне и нужно настроить. Выбираю страницу Конфигурационные файлы - Настройка драйвера serial устройств, вижу список последовательных портов контроллера. Выбираю порт, добавляю устройство, из выпадающего списка выбираю название нашего модуля. Нажимаю Записать.

Обратите внимание — параметры порта соответствуют заводским настройкам  как контроллера, так и Modbus-модулей. В дальнейшем вы легко можете их изменить

Теперь можно настраивать сам релейный модуль. Он, в принципе, настроен и готов к работе, но мне нужно кое-что изменить. То, что касается безопасности как самой вентустановки, так и дома в целом.

Первое — это адекватное поведение вентустановки в момент включения питания. Представим, что мы включаем всю автоматику дома в сильный мороз. Контроллеру нужна пара минут на начальную загрузку — за это время можно легко заморозить калорифер. Скажу модулю, что при подаче питания он должен перевести свои выходы в безопасное состояние. И укажу, какое состояние безопасно.

В нашем случае безопасное состояние это:

  • воздушная заслонка (выход 1) закрыта;

  • насос (выход 2) включен;

  • вентиль (выход 3) открыт;

  • вентилятор (выход 5) выключен.

В итоге наружный воздух через установку почти не движется, горячая вода циркулирует в калорифере — все хорошо и безопасно. И даже в случае пожара безопасное состояние выходов соответствует требованиям нормативных документов.

В такое же состояние модуль должен перевести свои выходы в случае потери связи с контроллером. И в безопасном состоянии модуль не должен обращать внимание на состояние своих входов.

Всё, модуль настроен. Можно зайти на страницу Устройства, посмотреть его состояние и пощелкать выходами. Тут главное не забыть, что к выходам подключено реальное оборудование (если это так), и щелкать со знанием дела.

На этом настройки закончены, можно начинать программирование.

Программирование

Редактор кода

Переходим на страницу Правила веб-интерфейса, нажимаем кнопку Создать, вводим имя файла с расширением js. Теперь можно писать код, никаких дополнительных инструментальных средств не нужно.

Встроенный редактор выделяет синтаксические конструкции цветом, производит простейшее форматирование. При сохранении файла проверяет код на ошибки. Если ошиблись — показывает, где именно и в чем. В примере ниже ошибка при вызове функции makeSensorController в строке 59 файла ventilation.js:

Расшифровку ошибки редактор тоже приводит. Все сообщения об ошибках стандартны для JS, можно погуглить и понять, что не так.

MQTT

Все программные компоненты контроллера для обмена информацией используют MQTT, то есть общаются не напрямую, а через MQTT брокер, который работает на контроллере.

Как это устроено

Можно в качестве аналога представить фирму, где общение между сотрудниками возможно только через секретаря (брокера).

У секретаря есть папки (топики) с названиями. В папке может храниться только одно сообщение, имеющее специальную отметку (retain сообщение), либо папка может быть пустой. 

Сотрудники пишут свои фамилии на нужных им папках (подписываются на топики). Если нужной папки нет - секретарь ее заводит. 

В момент подписки секретарь показывает хранящееся в папке сообщение сотруднику, если оно там есть. 

Сотрудники в любой момент могут  принести сообщение для папки с таким-то названием (публикуют значения в топики). Опять же, если папки нет - секретарь ее заведет. Далее, секретарь показывает это сообщение всем, чьи фамилии есть на папке (подписчикам). Если сообщение имеет отметку retain - секретарь кладет его в папку вместо лежащего там сообщения. Если такой отметки нет - уничтожает после показа.

Драйверы, исходя из конфигурации известных им устройств, публикуют значения в соответствующие топики. Веб-интерфейс подписан на все топики, и может показывать их значения и записывать туда новые. Сервис wb-rules также имеет доступ ко всем топикам, соответственно, и мы в скриптах можем (и будем) их использовать. 

Полный перечень всех доступных топиков можно увидеть в веб-интерфейсе Настройки - Каналы MQTT.

Я подключил датчики температуры, сконфигурировал релейный модуль, и теперь у меня есть следующие необходимые мне топики:

Топик

Синоним

Описание

Примечания

/devices/wb-w1/controls/28-000009795bc2

ai_t_air

Температура воздуха

/devices/wb-w1/controls/28-00000978ba32

ai_t_water

Температура воды в «обратке»

/devices/wb-mr6cv3_111/controls/Input 0

di_not_fire

Сигнал «Пожар» от АУПС

разомкнуто = пожар

/devices/wb-mr6cv3_111/controls/Input 1

di_ps_filter

Реле давления фильтра

разомкнуто = чистый

/devices/wb-mr6cv3_111/controls/Input 2

di_ps_fan

Реле давления вентилятора

замкнуто = расчетный режим

/devices/wb-mr6cv3_111/controls/Input 3

di_open

Заслонка открыта

замкнуто = открыта

/devices/wb-mr6cv3_111/controls/Input 4

di_closed

Заслонка закрыта

замкнуто = закрыта

/devices/wb-mr6cv3_111/controls/Input 5

di_ts_fan

Термоконтакты вентилятора

разомкнуто = перегрет

/devices/wb-mr6cv3_111/controls/Input 6

di_hs

Выключатель с фиксацией «Пуск/Стоп»

замкнуто = пуск

/devices/wb-mr6cv3_111/controls/K1

do_open

Реле заслонки

замкнуто = открыть

/devices/wb-mr6cv3_111/controls/K2

do_pump

Реле насоса

/devices/wb-mr6cv3_111/controls/K3

do_more

Реле вентиля «больше»

/devices/wb-mr6cv3_111/controls/K4

do_less

Реле вентиля «меньше»

/devices/wb-mr6cv3_111/controls/K5

do_fan

Реле вентилятора

Учитывайте, что в названии топика включен адрес устройства, то есть в вашем случае вместо 28-000009795bc2, 28-00000978ba32 и 111 будут адреса ваших устройств.

Имена топиков малоинформативны — в колонке Синоним я привел то наименование, которое буду использовать в скриптах. В наименовании сигналов я использовал такую нотацию: [вид сигнала]_[параметр]_[устройство]. Если параметр единственный в системе — устройство не указываю.

В скриптах для доступа к топикам MQTT предназначен объект dev[имя_топика]. В имени топика /devices и /controls опускаются. Например, чтобы присвоить переменной x значение температуры воздуха, я должен использовать такую конструкцию:

x =  dev['wb-w1/28-000009795bc2'];

Чтобы включить насос:

dev['wb-mr6cv3_111/K2'] = true;

Объект dev позволяет использовать ранее инициализированные переменные, поэтому можно так:

var AI_T_AIR = 'wb-w1/28-000009795bc2'; 
x = dev[AI_T_AIR];

Для этого мне и нужны синонимы. В программировании принято константы обозначать заглавными буквами, поэтому синоним в коде тоже может быть написан как прописными, так и строчными буквами, в зависимости от содержания.

Виртуальные устройства

Из скрипта вывести информацию для пользователя мы можем только в лог-файл контроллера или окно сообщений веб-интерфейса. Если этого мало, и мы хотим, чтобы некая сущность отображалась в веб-интерфейсе и позволяла взаимодействовать с пользователем, нам нужно создать виртуальное устройство. Для его создания предназначена функция defineVirtualDevice

Для каждого виртуального устройства сервис wb-rules создаст топики, аналогичные физическим устройствам:
/devices/имя_устройства/controls/имя_параметра

Виртуальные устройства я тоже буду активно использовать. А вот синонимы для виртуальных устройств я использовать не буду, потому что:

  • названия устройств и параметров информативны и легко запоминаются;

  • эти названия не изменятся, в отличие от названий аппаратных устройств, которые меняются при замене устройства (в случае неисправности, например).

События

В повседневной жизни нам привычнее действовать последовательно: пощупал воду — повернул рукоятку крана — подождал — пощупал воду. Мы выполняем последовательность действий, и между действиями игнорируем все, что не относится к текущему действию. Движок правил wb-rules работает не так, он работает с событиями. 

Изменения любого топика MQTT, который мы используем в скриптах, является событием. Для нас событием будет каждое изменение температуры воздуха и воды, любое изменение состояния дискретных входов и выходов. И все эти события мы должны обработать, написав соответствующее правило. А если точнее, то наоборот: wb-rules подпишется на те топики, которые используются в наших правилах, и при их изменении будет вызывать эти правила. Таким образом, мы всегда должны представлять, в каком состоянии находится наша вентустановка в момент возникновения события, и выполнять соответствующие действия. Например, нет смысла обрабатывать команду на открытие заслонки, если она уже открыта.

Для работы с событиями я буду использовать функцию whenChanged, которая выполняет последовательность действий при любом изменении соответствующего топика (или топиков). Есть еще функции when и asSoonAs, но мне хватило whenChanged.

Еще одним источником событий являются таймеры. Я буду использовать пару setTimeout / clearTimeout. Функция setTimeout однократно выполняет последовательность действий, спустя временную задержку после вызова. Функция clearTimeout отменяет эти действия, если они еще не выполнены.

Логическая структура

Общая логика работы wb-rules такова:

  • при старте wb-rules (инициализация) выполняется весь код, который находится вне функций whenChanged и setTimeout (другие функции я не использовал, но там принцип тот же);

  • код, который находится внутри функций whenChanged и setTimeout, выполняется каждый раз при срабатывании их триггеров (появление нового значения или окончание таймаута соответственно).

Область видимости у wb-rules ограничена файлом, который именуется сценарием. Весь код я разместил в одном файле-сценарии, то есть все переменные и функции доступны во всех правилах, и нет необходимости беспокоиться о пересечении имен переменных с другими сценариями. 

Я разделил код на несколько частей, которые буду называть контроллерами (не путать с контроллером — физическим устройством). Это:

  • sensorController (контроллер датчика температуры);

  • flapController (контроллер для управления воздушной заслонкой);

  • fanController (контроллер для управления вентилятором);

  • heaterController (контроллер для управления калорифером);

  • safetyController (контроллер для обеспечения безопасности);

  • mainController (верховный арбитр);

  • logger (регистратор состояний контроллеров).

Для большей наглядности я для каждого контроллера создал виртуальные устройства, чтобы в веб-интерфейсе видеть значения их параметров. Это очень удобно для отладки.

Инициализация

В начале моего скрипта задаются константы — те самые синонимы, облегчающие читаемость кода. Я для них создал объект с именем topics:

var topics = {
  AI_T_AIR: 'wb-w1/28-00000978ba32',
  AI_T_WATER: 'wb-w1/28-000009795bc2',
  DI_NOT_FIRE: 'wb-mr6cv3_111/Input 0',
  DI_PS_FILTER: 'wb-mr6cv3_111/Input 1',
  DI_PS_FAN: 'wb-mr6cv3_111/Input 2',
  DI_OPEN: 'wb-mr6cv3_111/Input 3',
  DI_CLOSED: 'wb-mr6cv3_111/Input 4',
  DI_TS_FAN: 'wb-mr6cv3_111/Input 5',
  DI_HS: 'wb-mr6cv3_111/Input 6',
  DO_OPEN: 'wb-mr6cv3_111/K1',
  DO_PUMP: 'wb-mr6cv3_111/K2',
  DO_MORE: 'wb-mr6cv3_111/K3',
  DO_LESS: 'wb-mr6cv3_111/K4',
  DO_FAN: 'wb-mr6cv3_111/K5',
};

Можно было написать проще:
var AI_T_AIR = 'wb-w1/28-00000978ba32';
Но использование объекта topics мне кажется нагляднее.

При замене датчика либо релейного модуля мне достаточно будет внести изменения только в этой секции.

sensorController

Самый простой контроллер. Он мне нужен для валидации (проверки на достоверность) и форматирования значений, полученных от датчиков температуры. 

Вот алгоритм. Можно сказать, что это универсальный алгоритм для правил wb-rules: инициализация, переходы между состояниями, обработка событий в зависимости от состояния.

Как читать ДРАКОН-схемы

Вертикальные линии - это последовательность действий (ветка алгоритма). Направление всегда сверху вниз.  

Такой элемент в верхней части алгоритма означает нахождение контроллера в конкретном состоянии (этот - в состоянии init, т.е. в момент старта сервиса wb-rules):

Зеркальный элемент в нижней части алгоритма говорит о переходе в новое состояние:

По правилам языка Дракон считается, что правильный переход ведет слева направо. Если переход будет справа налево или на одной вертикали - оба элемента будут с закрашенным треугольником:

Находясь в конкретном состоянии, контроллер принимает (receive) сигналы - в нашем случае события:

В данном случае мы видим два события: изменение сигнала error и изменение значения датчика. Все остальные события в этом состоянии игнорируются.

Событием может быть не любое изменение, а получение конкретного значения. Тогда элемент будет выглядеть так:

Данный элемент говорит нам о том, что мы получаем информацию извне:

А здесь мы выдаем информацию во внешний мир:

В этом месте мы включаем таймер на обратный отсчет времени:

Здесь останавливаем (отменяем):

А здесь таймер закончил обратный отсчет и сгенерировал событие, которое мы должны обработать:

Этот контроллер ни от чего не зависит и работает всегда — от момента подачи питания до его отключения.

При старте wb-rules я вычитываю диапазон допустимых значений minSensorValue и maxSensorValue, поступающих от датчика. Эти границы я храню прямо в коде, но также их можно прочитать из файла либо хранить в виртуальном устройстве, чтобы пользователь мог их изменить, не влезая в код.

Дальше жду событий. Для датчиков 1-Wire их два: новое значение температуры и признак ошибки #error (формируется драйвером). 

При появлении ошибки сразу считаю датчик недостоверным. Если ошибки нет — проверяю, находится ли полученное от датчика значение в допустимых границах. Если и тут все хорошо - считаю значение достоверным и присваиваю value значение, полученное от датчика, округленное до 1 знака после запятой (округление в алгоритме я разрисовывать не стал, ограничимся словами).

Поскольку у нас два одинаковых датчика, для создания контроллера я написал функцию, чтобы не дублировать код. Вот эта функция:

function makeSensorController(name, target, minSensorValue, maxSensorValue) {
  defineVirtualDevice(name, {
    title: 'sensorController ' + name,
    cells: {
      value: {
        type: 'value',
        units: 'deg C',
        value: null,
        readonly: true,
      },
      valid: {
        type: 'switch',
        value: false,
        readonly: true,
      },
    }
  });

  defineRule({
    whenChanged: [target, target + '#error', ],
    then: function (newValue, devName, cellName) {
      var valid = true;
      if (typeof(dev[target + '#error']) !== 'undefined') {
        valid = false;
      }
      if (dev[target] == undefined || dev[target] == null) {
        valid = false;
      }
      if (dev[target] > maxSensorValue) {
        valid = false;
      }
      if (dev[target] < minSensorValue) {
        valid = false;
      }
      dev[name]['valid'] = valid;
      if (valid) {
        dev[name]['value'] = Number(dev[target].toFixed(1));
      }
    }
  });
}

Сами контроллеры создаются при вызове этой функции с соответствующими параметрами:

makeSensorController('ai_t_air', topics.AI_T_AIR, 0, 50);
makeSensorController('ai_t_water', topics.AI_T_WATER, 0, 100);

На странице Устройства веб-интерфейса эти контроллеры выглядят так:

Видны и сами значения, и их валидность.

flapController

Этот контроллер управляет воздушной заслонкой. Он контролирует концевые выключатели положения заслонки di_open и di_close. А также управляет реле, которое подает питание на электропривод заслонки для ее открытия do_open. Заслонка оборудована возвратной пружиной, поэтому отдельный сигнал на закрытие не нужен. 

Программно контроллеру можно дать команду на открытие enable = true. Если enable = false — это будет команда на закрытие.

Смотрим алгоритм.

Видим, что контроллер после инициализации может находится в одном из четырех состояний: open, close, moving и fault.

Состояния open и close определяются по концевым выключателям. 

Состояние moving соответствует разомкнутому состоянию этих выключателей. В состоянии moving контроллер может находиться только в течение времени полного хода привода (смотреть в паспорте на привод заслонки). 

Если это время превышено — контроллер переходит в состояние fault (заслонка застряла в промежуточном положении). Также в состояние fault контроллер переходит, если в состоянии close размыкается выключатель di_close, либо в состоянии open размыкается выключатель di_open. Эти события говорят о том, что есть проблема с приводом заслонки, либо с концевыми выключателями, либо с соединительными проводами.

При переходе в состояние fault реле do_open отключается и заслонка закрывается под действием пружины. В этом состоянии контроллер не реагирует на изменение di_open и di_close

Для сброса состояния fault необходимо отключить выключатель «Пуск/Стоп» di_hs. Если после сброса di_close окажется замкнутым (заслонка закрыта) — контроллер перейдет в состояние close. Если нет — останется в fault

Код, реализующий данный алгоритм.
Инициализируем:

var idFlapTimer = null; //идентификатор таймера заслонки для управления им
var FLAP_TIMEOUT_S = 30;  //допустимое время открытия заслонки, с

Создаем сам контроллер:

defineVirtualDevice('flapController', {
  title: 'Flap controller',
  cells: {
    enable: {
      type: 'switch',
      value: false,
      readonly: true
    },
    state: {
      type: 'text', //open, close, moving, fault
      value: 'close',
      readonly: true
    }
  }
});

Функция, создающая таймер и описывающая последовательность действий при его срабатывании (отключить привод и установить состояние fault):

function startFlapTimer() {
  return setTimeout(function () {
    dev[topics.DO_OPEN] = false;
    dev['flapController/state'] = 'fault';
  }, FLAP_TIMEOUT_S * 1000);
}

Обработаем поступление команды enable (здесь newValue — это поступившее значение, которое может быть true или false):

defineRule('flapControllerEnableTrigger', {
  whenChanged: 'flapController/enable',
  then: function (newValue, devName, cellName) {
    switch (dev['flapController/state']) {
    case 'close':
      if (newValue) {
        dev[topics.DO_OPEN] = true;
        idFlapTimer = startFlapTimer();
        dev['flapController/state'] = 'moving';
      }
      break;
    case 'open':
      if (!newValue) {
        dev[topics.DO_OPEN] = false;
        idFlapTimer = startFlapTimer();
        dev['flapController/state'] = 'moving';
      }
      break;
    }
  }
});

Обработаем переключение концевого выключателя «Закрыта» (newValue — его новое состояние):

defineRule('flapControllerSwClosedTrigger', {
  whenChanged: topics.DI_CLOSED,
  then: function (newValue, devName, cellName) {
    switch (dev['flapController/state']) {
    case 'close':
      if (!newValue) {
        dev['flapController/state'] = 'fault';
      }
      break;
    case 'moving':
      if (newValue) {
        clearTimeout(idFlapTimer);
        dev['flapController/state'] = 'close';
      }
      break;
    case 'fault':
      if (!dev[topics.DI_HS] && newValue) {
        dev['flapController/state'] = 'close';
      }
      break;
    }
  }
});

Обработаем переключение концевого выключателя «Открыта»:

defineRule('flapControllerSwOpenTrigger', {
  whenChanged: topics.DI_OPEN,
  then: function (newValue, devName, cellName) {
    switch (dev['flapController/state']) {
    case 'open':
      if (!newValue) {
        dev[topics.DO_OPEN] = false;
        dev['flapController/state'] = 'fault';
      }
      break;
    case 'moving':
      if (newValue) {
        clearTimeout(idFlapTimer);
        dev['flapController/state'] = 'open';
      }
      break;
    }
  }
});

Ну и обработаем ручное отключение вент. установки — сбросим состояние fault, если все в порядке:

defineRule('flapControllerHsTrigger', {
  whenChanged: topics.DI_HS,
  then: function (newValue, devName, cellName) {
    switch (dev['flapController/state']) {
    case 'fault':
      if (!newValue && dev[topics.DI_CLOSED]) {
        dev['flapController/state'] = 'close';
      }
      break;
    }
  }
});

Здесь оператор case можно было бы и не использовать — я его оставил для наглядности.

В веб-интерфейсе контроллер выглядит так:

fanController

Контроллер вентилятора. Контролирует термоконтакты обмотки двигателя di_ts_fan и реле перепада давления di_ps_fan.

Алгоритм:

Может находиться в состояниях stop, run (включен, но перепада давления еще нет), work и fault. Принимает команду enable

В состояние fault попадает при:

  • размыкании термоконтактов (перегрев двигателя);

  • превышении времени нахождения в режиме run;

  • размыкании реле перепада давления в режиме work (фильтр забился, например);

  • замыкания реле перепада давления в режиме stop (неисправность реле или соединительных проводов).

Для сброса режима fault необходимо отжать выключатель «Пуск/Стоп».

Код очень похож на описанный выше, поэтому я его подробно комментировать не буду.

fanController
var idFanTimer = null;
var FAN_TIMEOUT_S = 10; //допустимое время нахождения в режиме run, с

function startFanTimer() {
  return setTimeout(function () {
    dev[topics.DO_FAN] = false;
    dev['fanController/state'] = 'fault';
  }, FAN_TIMEOUT_S * 1000);
}

defineVirtualDevice('fanController', {
  title: 'Fan controller',
  cells: {
    enable: {
      type: 'switch',
      value: false,
      readonly: true
    },
    state: { //stop, run, work, fault
      type: 'text',
      value: 'stop',
      readonly: true
    }
  }
});

defineRule('fanControllerEnableTrigger', {
  whenChanged: 'fanController/enable',
  then: function (newValue, devName, cellName) {
    switch (dev['fanController/state']) {
    case 'stop':
      if (newValue) {
        dev[topics.DO_FAN] = true;
        idFanTimer = startFanTimer();
        dev['fanController/state'] = 'run';
      }
      break;
    case 'run':
      if (!newValue) {
        dev[topics.DO_FAN] = false;
        dev['fanController/state'] = 'stop';
      }
      break;
    case 'work':
      if (!newValue) {
        dev[topics.DO_FAN] = false;
        dev['fanController/state'] = 'stop';
      }
      break;
    }
  }
});

defineRule('fanControllerTsTrigger', {
  whenChanged: topics.DI_TS_FAN,
  then: function (newValue, devName, cellName) {
    switch (dev['fanController/state']) {
    case 'stop':
      if (!newValue) {
        dev['fanController/state'] = 'fault';
      }
      break;
    case 'run':
      if (!newValue) {
        dev[topics.DO_FAN] = false;
        dev['fanController/state'] = 'fault';
      }
      break;
    case 'work':
      if (!newValue) {
        dev[topics.DO_FAN] = false;
        dev['fanController/state'] = 'fault';
      }
      break;
    case 'fault':
      if (!dev[topics.DI_HS] && newValue && !dev[topics.DI_PS_FAN]) {
        dev['fanController/state'] = 'stop';
      }
      break;
    }
  }
});

defineRule('fanControllerPsTrigger', {
  whenChanged: topics.DI_PS_FAN,
  then: function (newValue, devName, cellName) {
    switch (dev['fanController/state']) {
    case 'stop':
      if (newValue) {
        dev['fanController/state'] = 'fault';
      }
      break;
    case 'run':
      if (newValue) {
        clearTimeout(idFanTimer);
        dev['fanController/state'] = 'work';
      }
      break;
    case 'work':
      if (!newValue) {
        dev[topics.DO_FAN] = false;
        dev['fanController/state'] = 'fault';
      }
      break;
    case 'fault':
      if (!dev[topics.DI_HS] && dev[topics.DI_TS_FAN] && !newValue) {
        dev['fanController/state'] = 'stop';
      }
      break;
    }
  }
});

defineRule('fanControllerHsTrigger', {
  whenChanged: topics.DI_HS,
  then: function (newValue, devName, cellName) {
    switch (dev['fanController/state']) {
    case 'fault':
      if (!newValue && dev[topics.DI_TS_FAN] && !dev[topics.DI_PS_FAN]) {
        dev['fanController/state'] = 'stop';
      }
      break;
    }
  }
});

Представление в веб-интерфейсе:

heaterController

Контроллер управления нагревом. Поддерживает постоянную температуру воздуха на выходе вент. установки, управляя электроприводом смесительного вентиля. Также управляет насосом.

Алгоритм:

Может находиться в состояниях safe и work. Переключается между этими режимами по команде enable

В режиме safe (безопасный) обеспечивает безопасность калорифера:

  • включает насос;

  • открывает смесительный вентиль.  

В режиме work осуществляет ПИД регулирование трехточечным приводом смесительного вентиля, генерируя импульсы «больше» или «меньше» различной длительности.

Реализаций программных ПИД регуляторов на просторах Сети множество — я выбрал самый простой, но вполне рабочий вариант. Он используется в промышленных аппаратных ПИД регуляторах для управления электроприводами вентилей - как раз наша задача. 

Поддерживать температуру будем с погрешностью +/- ERROR

Каждые PERIOD_S секунд (шаг регулирования) проверяем, не вышла ли температура воздуха ai_t_air за границы диапазона  SETPOINT_T_AIR +/- ERROR. Если не вышла — не делаем ничего.

Если вышла — вычисляем текущее рассогласование diff и изменение рассогласования между этим измерением и предыдущим delta.

Вычисляем длительность импульса управления pulseTime. Слишком короткие импульсы привод адекватно отработать не может. Специалисты не рекомендуют использовать импульсы короче 300 мс. Если длительность импульса получилась короче — импульс не выдаем, а запоминаем. В следующем цикле вычисления добавим это значение к вычисленной длительности. Если результат опять будет меньше минимальной длительности - опять импульс не выдаем, а сумму запоминаем. Так и накопим. 

Если запомненное значение длительности другого знака, чем вычисленное — обнуляем запомненное.  

Если длительность импульса больше минимально допустимой — выдаем импульс (включаем реле на вычисленное время). Результат будет со знаком. Если результат положительный - включаем реле do_more. Если отрицательный — реле do_less.

Смотрим код. Инициализация:

var error = 0.5; //допустимое отклонение температуры воздуха
var idMoreTimer = null; //идентификатор таймера для импульса "больше"
var idLessTimer = null; //идентификатор таймера для импульса "меньше"
var idPidTimer = null; //идентификатор таймера ПИД регулятора
var SETPOINT_T_AIR = 22; //уставка температуры воздуха
var pid = { //коэффициенты для ПИД регулятора
  K: 50, //общий коэффициент усиления
  TAU: 5, //коэффициент при дифференциальной составляющей, определяет чувствительность к резким изменениям температур
  BORDER_MS: 300, //минимальная длительность импульса управления, мс
  PERIOD_S: 10 //период регулирования, с
}
var prevDiff = null; //предыдущее рассогласование
var leftover = null; //если при вычислениях длина импульса меньше минимальной - пишем сюда

Период регулирования не должен быть меньше, чем частота обновления значений температуры. Драйвер wb-mqtt-w1 «по умолчанию» публикует значения температур раз в 10 секунд. Если нужно чаще — можно настроить драйвер на более частую публикацию значений и изменить PERIOD_S. Но, с учетом инерционности самого датчика, вам вряд ли это понадобится.

Коэффициенты ПИД регулятора надо подбирать под вашу конкретную вентустановку. Как? Есть куча статей в Сети на эту тему. Для начала наладки указанные мной коэффициенты вполне подойдут. 

Сам контроллер:

defineVirtualDevice('heaterController', {
  title: 'Heater controller',
  cells: {
    enable: {
      type: 'switch',
      value: false,
    },
    state: { //work, safe
      type: 'text',
      value: 'safe',
      readonly: true,
    }
  }
});

Пара функций для генерации импульсов «Больше» и «Меньше» (отличаются только используемым реле):

function generateMoreImpulse(pulseTime_s) {
  if (idMoreTimer) {
    clearTimeout(idMoreTimer);
    idMoreTimer = null;
  } else {
    dev[topics.DO_MORE] = true;
  }
  idMoreTimer = setTimeout(function() {
    dev[topics.DO_MORE] = false;
    idMoreTimer = null;
  }, pulseTime_s * 1000);
}

function generateLessImpulse(pulseTime_s) {
  if (idLessTimer) {
    clearTimeout(idLessTimer);
    idLessTimer = null;
  } else {
    dev[topics.DO_LESS] = true;
  }
  idLessTimer = setTimeout(function() {
    dev[topics.DO_LESS] = false;
    idLessTimer = null;
  }, pulseTime_s * 1000);
}

Функция вычисления длительности импульса:

function calculatePulseTime() {
  var diff = SETPOINT_T_AIR - dev[topics.AI_T_AIR]; //текущее рассогласование
  var delta = diff - prevDiff; //изменение рассогласования между соседними измерениями
  var result = Math.round(2.5 * pid.K * (diff + pid.TAU * delta));
  if (((result > 0) && (leftover < 0)) || ((result < 0) && (leftover > 0))) {
    leftover = 0;
  }
  result = result + leftover;
  // Записываем новую разницу
  prevDiff = diff;
  leftover = 0;
  // При длине импульса меньше минимальной - запоминаем, но не выдаем
  if (Math.abs(result) < pid.BORDER_MS) {
    leftover = result;
    result = 0;
  }
  return result;
}

Функция, вызываемая по таймеру с периодом регулирования:

function run() {
  //если текущая T в пределах уставки +/- погрешность - ничего не делаем
  if (Math.abs(dev[topics.AI_T_AIR] - SETPOINT_T_AIR) < error) {
    return;
  } 
  var out = calculatePulseTime();
  dev[topics.DO_MORE] = false;
  dev[topics.DO_LESS] = false;
  if (out < 0) {
    generateLessImpulse(Math.abs(out) / 1000);
  }
  if (out > 0) {
    generateMoreImpulse(out / 1000);
  }
}

Функции, включающие и отключающие ПИД регулятор:

function enablePid() {
  if (idPidTimer == null) {
    idPidTimer = setInterval(function() {
      run();
    }, pid.PERIOD_S * 1000);
  } 
}

function disablePid() {
  if (idPidTimer != null) {
    clearInterval(idPidTimer);
    idPidTimer = null;
  } 
}

Ну и само включение/отключение по внешней команде:

defineRule('heaterControllerEnableTrigger', {
  whenChanged: 'heaterController/enable',
  then: function (newValue, devName, cellName) {
    switch (dev['heaterController/state']) {
    case 'safe':
      if (newValue) {
        dev['heaterController/state'] = 'work';
        enablePid();
      }
      break;
    case 'work':
      if (!newValue) {
        dev[topics.DO_LESS] = false;
        dev[topics.DO_MORE] = true;
        dev[topics.DO_PUMP] = true;
        dev['heaterController/state'] = 'safe';
        disablePid();
      }
      break;
    }
  }
});

Представление в веб-интерфейсе:

safetyController

Контроллер для обеспечения безопасности. Отвечает на вопрос: безопасно текущее состояние вентустановки, или нет.

Алгоритм:

Контроллер отслеживает статус valid у sensorController, состояние fault остальных контроллеров, значения измеряемых температур и сигнал о пожаре. Устанавливает выход safe в соответствующее состояние.

В коде ничего хитрого:

safetyController
var T_AIR_MIN = 10; //минимально допустимая температура воздуха на выходе вентустановки
var T_WATER_MIN = 20; //минимально допустимая температура воды в "обратке"

defineVirtualDevice('safetyController', {
  title: 'Safety controller',
  cells: {
    safe: {
      type: 'switch',
      value: false,
      readonly: true
    },
  }
});

function setSafetyControllerState() {
  if (!dev['ai_t_air/valid']) {
    return false;
  }
  if (!dev['ai_t_water/valid']) {
    return false;
  }
  if (dev['ai_t_air/value'] < T_AIR_MIN) {
    return false;
  }
  if (dev['ai_t_water/value'] < T_WATER_MIN) {
    return false;
  }
  if (dev['flapController/state'] == 'fault') {
    return false;
  }
  if (dev['fanController/state'] == 'fault') {
    return false;
  }
  if (!dev[topics.DI_NOT_FIRE]) {
    return false;
  }
  return true;
}

defineRule('safetyControllerTriggers', {
  whenChanged: ['ai_t_air/valid', 
                'ai_t_water/valid', 
                'ai_t_air/value', 
                'ai_t_water/value', 
                'flapController/state', 
                'fanController/state', 
                topics.DI_NOT_FIRE],
  then: function (newValue, devName, cellName) {
    dev['safetyController/safe'] = setSafetyControllerState();
  }
});

Представление в веб-интерфейсе стандартно:

mainController

Верховный арбитр, как я его назвал. Обеспечивает логику работы всей установки в целом.

Алгоритм:

Вентустановка может находится в состоянии safe (безопасное состояние), warming (прогрев калорифера), opening (открывание заслонки), running (раскрутка вентилятора), work (рабочий режим).

Для того, чтобы вентустановка была запущена, необходимо, чтобы мы нажали выключатель Пуск/Стоп и safeController считал, что все безопасно. Все так? Тогда переходим в состояние warming — греем калорифер. 

Если калорифер прогрет (или как только прогреется) — переходим в состояние opening и разрешаем работу flapController. А тот уже открывает заслонку и следит за тем, чтобы она открылась и потом оставалась открытой. 

Как только flapController сообщит нам, что заслонка открыта — переходим в состояние running и разрешаем работу fanController. Тот включает вентилятор и следит за реле перепада давления.

Необходимый перепад давления появился и fanController перешел в состояние work? Тоже переходим в состояние work и разрешаем работу heaterController, который регулирует температуру воздуха, управляя смесительным вентилем. В этом состоянии остаемся.

В любой момент safeController может решить, что условия безопасности не выполнены — тогда mainController снимает разрешение на работу тем контроллерам, у которых оно есть. И оборудование, которым те управляют, переходит в безопасное состояние. Напомню, что безопасное состояние, это:

  • насос включен;

  • вентилятор выключен;

  • заслонка закрыта:

  • вентиль в положении «открыт».

Дальше ждем реакции хозяина вентустановки. Он должен:

  • посмотреть в веб-интерфейсе состояние контроллеров, которые в состоянии fault или не valid — те и виноваты;

  • разобраться в причине остановки, все починить;

  • отжать выключатель «Пуск/Стоп» и снова его нажать. 

Если safeController решит, что теперь все Ок — переходим в состояние warming и по новой...

В любой момент хозяин может отжать кнопку «Пуск/Стоп». Дальнейшие действия аналогичны: останавливаем включенное, остаемся в безопасном состоянии.

Код ничуть не сложнее приведенного выше.

mainController

Инициализация:

//минимальная температура воды в "обратке", при которой возможен старт вентустановки 
var T_WATER_START = 30;

Сам контроллер:

defineVirtualDevice('mainController', {
  title: 'Main controller',
  cells: {
    state: { //safe, warming, opening, running, work
      type: 'text',
      value: 'safe',
      readonly: true
    }
  }
});

Реагируем на изменение состояния выключателя «Пуск/Стоп»:

defineRule('mainControllerHsTrigger', {
  whenChanged: topics.DI_HS,
  then: function (newValue, devName, cellName) {
    switch (dev['mainController/state']) {
    case 'safe':
      if (newValue && dev['safetyController/safe']) {
        dev['mainController/state'] = 'warming';
      }
      break;
    case 'warming':
      if (!newValue) {
        dev['mainController/state'] = 'safe';
        break;
      }
    case 'opening':
      if (!newValue) {
        dev['flapController/enable'] = false;
        dev['mainController/state'] = 'safe';
      }
      break;
    case 'running':
      if (!newValue) {
        dev['flapController/enable'] = false;
        dev['fanController/enable'] = false;
        dev['mainController/state'] = 'safe';
      }
      break;
    case 'work':
      if (!newValue) {
        dev['heaterController/enable'] = false;
        dev['flapController/enable'] = false;
        dev['fanController/enable'] = false;
        dev['mainController/state'] = 'safe';
      }
      break;
    }
  }
});

Не разрешаем старт вент. установки, пока калорифер не прогрет:

defineRule('mainControllerAiTrigger', {
  whenChanged: 'ai_t_water/value',
  then: function (newValue, devName, cellName) {
    if (newValue < T_WATER_START)
      return;
    switch (dev['mainController/state']) {
    case 'warming':
      dev['flapController/enable'] = true;
      dev['mainController/state'] = 'opening';
      break;
    }
  }
});

Отслеживаем статусы контроллеров:

defineRule('mainControllerFlapTrigger', {
  whenChanged: 'flapController/state',
  then: function (newValue, devName, cellName) {
    if (newValue != 'open')
      return;
    switch (dev['mainController/state']) {
    case 'opening':
      dev['fanController/enable'] = true;
      dev['mainController/state'] = 'running';
      break;
    }
  }
});

defineRule('mainControllerSafeTrigger', {
  whenChanged: 'safetyController/safe',
  then: function (newValue, devName, cellName) {
    if (newValue)
      return;
    switch (dev['mainController/state']) {
    case 'safe':
      if (dev[topics.DI_HS] && newValue) {
        dev['mainController/state'] = 'warming';
      }
      dev['mainController/state'] = 'safe';
      break;
    case 'warming':
      dev['mainController/state'] = 'safe';
      break;
    case 'opening':
      dev['flapController/enable'] = false;
      dev['mainController/state'] = 'safe';
      break;
    case 'running':
      dev['flapController/enable'] = false;
      dev['fanController/enable'] = false;
      dev['mainController/state'] = 'safe';
      break;
    case 'work':
      dev['heaterController/enable'] = false;
      dev['flapController/enable'] = false;
      dev['fanController/enable'] = false;
      dev['mainController/state'] = 'safe';
      break;
    }
  }
});

defineRule('mainControllerFanTrigger', {
  whenChanged: 'fanController/state',
  then: function (newValue, devName, cellName) {
    if (newValue != 'work')
      return;
    switch (dev['mainController/state']) {
    case 'running':
      dev['heaterController/enable'] = true;
      dev['mainController/state'] = 'work';
      break;
    }
  }
});

Про представление в веб-интерфейсе ничего нового не скажу — все стандартно:

logger

Это простенький регистратор, который помогает в отладке. Он создает виртуальное устройство, в котором можно включить/отключить отладку.

Далее он следит за изменением состояний контроллеров и выводит новые состояния в окно сообщений веб-интерфейса, а также в лог-файл системной службы логирования:

Открывается окно сообщений при нажатии на расположенную в правом нижнем углу веб-интерфейса пиктограмму:

Код вполне очевиден:

logger
defineVirtualDevice('logger', {
  title: 'Logger',
  cells: {
    enable: {
      type: 'switch',
      value: false,
    }
  }
});

defineRule('changeControllersState', {
  whenChanged: ['safetyController/state', 
                'flapController/state', 
                'fanController/state', 
                'heaterController/state', 
                'mainController/state'],
  then: function (newValue, devName, cellName) {
    if (dev['logger/enable']) {
      log.info('[' + devName + '] state:' + newValue);
    }
  }
});

Подключаемый JS модуль

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

Подключаемые пользовательские модули wb-rules ищет в  /etc/wb-rules-modules. Сюда его и разместим. Кстати, при обновлении программного обеспечения контроллера он отсюда никуда не денется, можно не переживать.

Работу с модулем сделаем предельно простой: при старте wb-rules передадим в него адреса физических устройств, а дальше пусть работает сам. Другими словами, завернем весь предыдущий код в функцию init.

/etc/wb-rules-module/ventilation2wb.js
function init(topics) {
  //-------------------------------------------------------------------------------------------------------------------------
  //sensorController
  function makeSensorController(title, name, target, minSensorValue, maxSensorValue) {
    defineVirtualDevice(name, {
      title: 'Sensor Controller ' + title,
      cells: {
        value: {
          title: "Temperature",
          type: 'value',
          units: 'deg C',
          value: null,
          readonly: true,
        },
        valid: {
          title: "Valid Value",
          type: 'switch',
          value: false,
          readonly: true,
        },
      }
    });

    defineRule({
      whenChanged: [target, target + '#error',],
      then: function (newValue, devName, cellName) {
        var valid = true;
        if (typeof (dev[target + '#error']) !== 'undefined') {
          valid = false;
        }
        if (dev[target] == undefined || dev[target] == null) {
          valid = false;
        }
        if (dev[target] > maxSensorValue) {
          valid = false;
        }
        if (dev[target] < minSensorValue) {
          valid = false;
        }
        dev[name]['valid'] = valid;
        if (valid) {
          dev[name]['value'] = Number(dev[target].toFixed(1));
        }
      }
    });
  }

  makeSensorController('Air', 'ai_t_air', topics.AI_T_AIR, 0, 50);
  makeSensorController('Water', 'ai_t_water', topics.AI_T_WATER, 0, 100);

  //-------------------------------------------------------------------------------------------------------------------------
  //safetyController
  var T_AIR_MIN = 10; //минимально допустимая температура воздуха на выходе вентустановки
  var T_WATER_MIN = 20; //минимально допустимая температура воды в "обратке"

  defineVirtualDevice('safetyController', {
    title: 'Safety Controller',
    cells: {
      safe: {
        title: "Safe Mode",
        type: 'switch',
        value: false,
        readonly: true
      },
    }
  });

  function setSafetyControllerState() {
    if (!dev['ai_t_air/valid']) {
      return false;
    }
    if (!dev['ai_t_water/valid']) {
      return false;
    }
    if (dev['ai_t_air/value'] < T_AIR_MIN) {
      return false;
    }
    if (dev['ai_t_water/value'] < T_WATER_MIN) {
      return false;
    }
    if (dev['flapController/state'] == 'fault') {
      return false;
    }
    if (dev['fanController/state'] == 'fault') {
      return false;
    }
    if (!dev[topics.DI_NOT_FIRE]) {
      return false;
    }
    return true;
  }

  defineRule('safetyControllerTriggers', {
    whenChanged: ['ai_t_air/valid',
      'ai_t_water/valid',
      'ai_t_air/value',
      'ai_t_water/value',
      'flapController/state',
      'fanController/state',
      topics.DI_NOT_FIRE],
    then: function (newValue, devName, cellName) {
      dev['safetyController/safe'] = setSafetyControllerState();
    }
  });

  //-------------------------------------------------------------------------------------------------------------------------
  //flapController
  var idFlapTimer = null;
  var FLAP_TIMEOUT_S = 30;  //допустимое время открытия заслонки, с

  defineVirtualDevice('flapController', {
    title: 'Flap Controller',
    cells: {
      enable: {
        title: "Enable",
        type: 'switch',
        value: false,
        readonly: true
      },
      state: {
        title: "State",
        type: 'text', //open, close, moving, fault
        value: 'close',
        readonly: true
      }
    }
  });

  function startFlapTimer() {
    return setTimeout(function () {
      dev[topics.DO_OPEN] = false;
      dev['flapController/state'] = 'fault';
    }, FLAP_TIMEOUT_S * 1000);
  }

  defineRule('flapControllerEnableTrigger', {
    whenChanged: 'flapController/enable',
    then: function (newValue, devName, cellName) {
      switch (dev['flapController/state']) {
        case 'close':
          if (newValue) {
            dev[topics.DO_OPEN] = true;
            idFlapTimer = startFlapTimer();
            dev['flapController/state'] = 'moving';
          }
          break;
        case 'open':
          if (!newValue) {
            dev[topics.DO_OPEN] = false;
            idFlapTimer = startFlapTimer();
            dev['flapController/state'] = 'moving';
          }
          break;
      }
    }
  });

  defineRule('flapControllerSwClosedTrigger', {
    whenChanged: topics.DI_CLOSED,
    then: function (newValue, devName, cellName) {
      switch (dev['flapController/state']) {
        case 'close':
          if (!newValue) {
            dev['flapController/state'] = 'fault';
          }
          break;
        case 'moving':
          if (newValue) {
            clearTimeout(idFlapTimer);
            dev['flapController/state'] = 'close';
          }
          break;
        case 'fault':
          if (!dev[topics.DI_HS] && newValue) {
            dev['flapController/state'] = 'close';
          }
          break;
      }
    }
  });

  defineRule('flapControllerSwOpenTrigger', {
    whenChanged: topics.DI_OPEN,
    then: function (newValue, devName, cellName) {
      switch (dev['flapController/state']) {
        case 'open':
          if (!newValue) {
            dev[topics.DO_OPEN] = false;
            dev['flapController/state'] = 'fault';
          }
          break;
        case 'moving':
          if (newValue) {
            clearTimeout(idFlapTimer);
            dev['flapController/state'] = 'open';
          }
          break;
      }
    }
  });

  defineRule('flapControllerHsTrigger', {
    whenChanged: topics.DI_HS,
    then: function (newValue, devName, cellName) {
      switch (dev['flapController/state']) {
        case 'fault':
          if (!newValue && dev[topics.DI_CLOSED]) {
            dev['flapController/state'] = 'close';
          }
          break;
      }
    }
  });

  //-------------------------------------------------------------------------------------------------------------------------
  //fanController
  var idFanTimer = null;
  var FAN_TIMEOUT_S = 10; //допустимое время нахождения в режиме run, с

  function startFanTimer() {
    return setTimeout(function () {
      dev[topics.DO_FAN] = false;
      dev['fanController/state'] = 'fault';
    }, FAN_TIMEOUT_S * 1000);
  }

  defineVirtualDevice('fanController', {
    title: 'Fan Controller',
    cells: {
      enable: {
        title: "Enable",
        type: 'switch',
        value: false,
        readonly: true
      },
      state: { //stop, run, work, fault
        title: "State",
        type: 'text',
        value: 'stop',
        readonly: true
      }
    }
  });

  defineRule('fanControllerEnableTrigger', {
    whenChanged: 'fanController/enable',
    then: function (newValue, devName, cellName) {
      switch (dev['fanController/state']) {
        case 'stop':
          if (newValue) {
            dev[topics.DO_FAN] = true;
            idFanTimer = startFanTimer();
            dev['fanController/state'] = 'run';
          }
          break;
        case 'run':
          if (!newValue) {
            dev[topics.DO_FAN] = false;
            dev['fanController/state'] = 'stop';
          }
          break;
        case 'work':
          if (!newValue) {
            dev[topics.DO_FAN] = false;
            dev['fanController/state'] = 'stop';
          }
          break;
      }
    }
  });

  defineRule('fanControllerTsTrigger', {
    whenChanged: topics.DI_TS_FAN,
    then: function (newValue, devName, cellName) {
      switch (dev['fanController/state']) {
        case 'stop':
          if (!newValue) {
            dev['fanController/state'] = 'fault';
          }
          break;
        case 'run':
          if (!newValue) {
            dev[topics.DO_FAN] = false;
            dev['fanController/state'] = 'fault';
          }
          break;
        case 'work':
          if (!newValue) {
            dev[topics.DO_FAN] = false;
            dev['fanController/state'] = 'fault';
          }
          break;
        case 'fault':
          if (!dev[topics.DI_HS] && newValue && !dev[topics.DI_PS_FAN]) {
            dev['fanController/state'] = 'stop';
          }
          break;
      }
    }
  });

  defineRule('fanControllerPsTrigger', {
    whenChanged: topics.DI_PS_FAN,
    then: function (newValue, devName, cellName) {
      switch (dev['fanController/state']) {
        case 'stop':
          if (newValue) {
            dev['fanController/state'] = 'fault';
          }
          break;
        case 'run':
          if (newValue) {
            clearTimeout(idFanTimer);
            dev['fanController/state'] = 'work';
          }
          break;
        case 'work':
          if (!newValue) {
            dev[topics.DO_FAN] = false;
            dev['fanController/state'] = 'fault';
          }
          break;
        case 'fault':
          if (!dev[topics.DI_HS] && dev[topics.DI_TS_FAN] && !newValue) {
            dev['fanController/state'] = 'stop';
          }
          break;
      }
    }
  });

  defineRule('fanControllerHsTrigger', {
    whenChanged: topics.DI_HS,
    then: function (newValue, devName, cellName) {
      switch (dev['fanController/state']) {
        case 'fault':
          if (!newValue && dev[topics.DI_TS_FAN] && !dev[topics.DI_PS_FAN]) {
            dev['fanController/state'] = 'stop';
          }
          break;
      }
    }
  });

  //-------------------------------------------------------------------------------------------------------------------------
  //heaterController
  var error = 0.5; //допустимое отклонение температуры воздуха
  var idMoreTimer = null; //идентификатор таймера для импульса "больше"
  var idLessTimer = null; //идентификатор таймера для импульса "меньше"
  var idPidTimer = null; //идентификатор таймера ПИД регулятора
  var SETPOINT_T_AIR = 22; //уставка температуры воздуха
  var pid = { //коэффициенты для ПИД регулятора
    K: 50, //общий коэффициент усиления
    TAU: 5, //коэффициент при дифференциальной составляющей, определяет чувствительность к резким изменениям температур
    BORDER_MS: 300, //минимальная длительность импульса управления, мс
    PERIOD_S: 10 //период регулирования, с
  }
  var prevDiff = null; //предыдущее рассогласование
  var leftover = null; //если при вычислениях длина импульса меньше минимальной - пишем сюда

  function generateMoreImpulse(pulseTime_s) {
    if (idMoreTimer) {
      clearTimeout(idMoreTimer);
      idMoreTimer = null;
    } else {
      dev[topics.DO_MORE] = true;
    }
    idMoreTimer = setTimeout(function () {
      dev[topics.DO_MORE] = false;
      idMoreTimer = null;
    }, pulseTime_s * 1000);
  }

  function generateLessImpulse(pulseTime_s) {
    if (idLessTimer) {
      clearTimeout(idLessTimer);
      idLessTimer = null;
    } else {
      dev[topics.DO_LESS] = true;
    }
    idLessTimer = setTimeout(function () {
      dev[topics.DO_LESS] = false;
      idLessTimer = null;
    }, pulseTime_s * 1000);
  }

  function calculatePulseTime() {
    var diff = SETPOINT_T_AIR - dev[topics.AI_T_AIR]; //текущее рассогласование
    var delta = diff - prevDiff; //изменение рассогласования между соседними измерениями
    var result = Math.round(2.5 * pid.K * (diff + pid.TAU * delta));
    if (((result > 0) && (leftover < 0)) || ((result < 0) && (leftover > 0))) {
      leftover = 0;
    }
    result = result + leftover;
    // Записываем новую разницу
    prevDiff = diff;
    leftover = 0;
    // При длине импульса меньше минимальной - запоминаем, но не выдаем
    if (Math.abs(result) < pid.BORDER_MS) {
      leftover = result;
      result = 0;
    }
    return result;
  }

  function run() {
    //если текущая T в пределах уставки +/- погрешность - ничего не делаем
    if (Math.abs(dev[topics.AI_T_AIR] - SETPOINT_T_AIR) < error) {
      return;
    }
    var out = calculatePulseTime();
    dev[topics.DO_MORE] = false;
    dev[topics.DO_LESS] = false;
    if (out < 0) {
      generateLessImpulse(Math.abs(out) / 1000);
    }
    if (out > 0) {
      generateMoreImpulse(out / 1000);
    }
  }

  function enablePid() {
    if (idPidTimer == null) {
      idPidTimer = setInterval(function () {
        run();
      }, pid.PERIOD_S * 1000);
    }
  }

  function disablePid() {
    if (idPidTimer != null) {
      clearInterval(idPidTimer);
      idPidTimer = null;
    }
  }

  defineVirtualDevice('heaterController', {
    title: 'Heater Controller',
    cells: {
      enable: {
        title: "Enable",
        type: 'switch',
        value: false,
      },
      state: { //work, safe
        title: "State",
        type: 'text',
        value: 'safe',
        readonly: true,
      }
    }
  });


  defineRule('heaterControllerEnableTrigger', {
    whenChanged: 'heaterController/enable',
    then: function (newValue, devName, cellName) {
      switch (dev['heaterController/state']) {
        case 'safe':
          if (newValue) {
            dev['heaterController/state'] = 'work';
            enablePid();
          }
          break;
        case 'work':
          if (!newValue) {
            dev[topics.DO_LESS] = false;
            dev[topics.DO_MORE] = true;
            dev[topics.DO_PUMP] = true;
            dev['heaterController/state'] = 'safe';
            disablePid();
          }
          break;
      }
    }
  });

  //-------------------------------------------------------------------------------------------------------------------------
  //mainController
  var T_WATER_START = 30; //минимальная температура воды в "обратке", при которой возможен старт вентустановки 

  defineVirtualDevice('mainController', {
    title: 'Main Controller',
    cells: {
      state: { //safe, warming, opening, running, work
        title: "State",
        type: 'text',
        value: 'safe',
        readonly: true
      }
    }
  });

  defineRule('mainControllerHsTrigger', {
    whenChanged: topics.DI_HS,
    then: function (newValue, devName, cellName) {
      switch (dev['mainController/state']) {
        case 'safe':
          if (newValue && dev['safetyController/safe']) {
            dev['mainController/state'] = 'warming';
          }
          break;
        case 'warming':
          if (!newValue) {
            dev['mainController/state'] = 'safe';
            break;
          }
        case 'opening':
          if (!newValue) {
            dev['flapController/enable'] = false;
            dev['mainController/state'] = 'safe';
          }
          break;
        case 'running':
          if (!newValue) {
            dev['flapController/enable'] = false;
            dev['fanController/enable'] = false;
            dev['mainController/state'] = 'safe';
          }
          break;
        case 'work':
          if (!newValue) {
            dev['heaterController/enable'] = false;
            dev['flapController/enable'] = false;
            dev['fanController/enable'] = false;
            dev['mainController/state'] = 'safe';
          }
          break;
      }
    }
  });

  defineRule('mainControllerAiTrigger', {
    whenChanged: 'ai_t_water/value',
    then: function (newValue, devName, cellName) {
      if (newValue < T_WATER_START)
        return;
      switch (dev['mainController/state']) {
        case 'warming':
          dev['flapController/enable'] = true;
          dev['mainController/state'] = 'opening';
          break;
      }
    }
  });

  defineRule('mainControllerFlapTrigger', {
    whenChanged: 'flapController/state',
    then: function (newValue, devName, cellName) {
      if (newValue != 'open')
        return;
      switch (dev['mainController/state']) {
        case 'opening':
          dev['fanController/enable'] = true;
          dev['mainController/state'] = 'running';
          break;
      }
    }
  });

  defineRule('mainControllerSafeTrigger', {
    whenChanged: 'safetyController/safe',
    then: function (newValue, devName, cellName) {
      if (newValue)
        return;
      switch (dev['mainController/state']) {
        case 'safe':
          if (dev[topics.DI_HS] && newValue) {
            dev['mainController/state'] = 'warming';
          }
          dev['mainController/state'] = 'safe';
          break;
        case 'warming':
          dev['mainController/state'] = 'safe';
          break;
        case 'opening':
          dev['flapController/enable'] = false;
          dev['mainController/state'] = 'safe';
          break;
        case 'running':
          dev['flapController/enable'] = false;
          dev['fanController/enable'] = false;
          dev['mainController/state'] = 'safe';
          break;
        case 'work':
          dev['heaterController/enable'] = false;
          dev['flapController/enable'] = false;
          dev['fanController/enable'] = false;
          dev['mainController/state'] = 'safe';
          break;
      }
    }
  });

  defineRule('mainControllerFanTrigger', {
    whenChanged: 'fanController/state',
    then: function (newValue, devName, cellName) {
      if (newValue != 'work')
        return;
      switch (dev['mainController/state']) {
        case 'running':
          dev['heaterController/enable'] = true;
          dev['mainController/state'] = 'work';
          break;
      }
    }
  });

  //-------------------------------------------------------------------------------------------------------------------------
  //logger
  defineVirtualDevice('logger', {
    title: 'Logger',
    cells: {
      enable: {
        type: 'switch',
        value: false,
      }
    }
  });

  defineRule('changeControllersState', {
    whenChanged: ['safetyController/state', 'flapController/state', 'fanController/state', 'heaterController/state', 'mainController/state'],
    then: function (newValue, devName, cellName) {
      if (dev['logger/enable']) {
        log.info('[' + devName + '] state:' + newValue);
      }
    }
  });
}

exports.init = function (topics) {
  init(topics);
};

Теперь достаточно в нужном месте в нужном скрипте инициализировать модуль - и все заработает.

Пример инициализации модуля:

var vent = require("ventilation2wb"); // подключаем модуль

  var mqttTopics = {
    AI_T_AIR: 'wb-w1/28-00000978ba32', // Температура воздуха
    AI_T_WATER: 'wb-w1/28-000009795bc2', // Температура воды в «обратке»
    DI_NOT_FIRE: 'wb-mr6cv3_111/Input 0', // Сигнал «Пожар» от АУПС
    DI_PS_FILTER: 'wb-mr6cv3_111/Input 1', // Реле давления фильтра
    DI_PS_FAN: 'wb-mr6cv3_111/Input 2', // Реле давления вентилятора
    DI_OPEN: 'wb-mr6cv3_111/Input 3', // Заслонка открыта
    DI_CLOSED: 'wb-mr6cv3_111/Input 4', // Заслонка закрыта
    DI_TS_FAN: 'wb-mr6cv3_111/Input 5', // Термоконтакты вентилятора
    DI_HS: 'wb-mr6cv3_111/Input 6', // Выключатель с фиксацией «Пуск/Стоп»
    DO_OPEN: 'wb-mr6cv3_111/K1', // Реле заслонки
    DO_PUMP: 'wb-mr6cv3_111/K2', // Реле насоса
    DO_MORE: 'wb-mr6cv3_111/K3', // Реле вентиля «больше»
    DO_LESS: 'wb-mr6cv3_111/K4', // Реле вентиля «меньше»
    DO_FAN: 'wb-mr6cv3_111/K5', // Реле вентилятора
  };

vent.init(mqttTopics);

Оповещение

Логика выстроена, теперь пора сделать удобно и красиво. 

Удобно — это когда в любой момент мы можем посмотреть текущее состояние вентустановки, не подходя к ней. И когда, в случае проблем, вентустановка может нам об этом сообщить. Можно это сделать? Можно.

На контроллере WIren Board есть модуль уведомлений. Модуль может нас уведомлять посредством электронной почты и/или СМС сообщений (если вы его купили в комплекте с GSM модемом). Ну а раз нам доступна электронная почта, то возможна и интеграция с прочими сервисами уведомлений. 

Также у контроллера Wiren Board есть возможность из скрипта выполнять команды операционной системы Linux. В частности, команды curl — с ее помощью можно легко настроить интеграцию с мессенджером Telegram. Но это тема отдельного разговора. 

Я настроил уведомление по электронной почте. Пусть так, без затей. Предварительно необходимо настроить Wiren Board на подключение к используемому вами серверу электронной почты. Все необходимые действия описаны в инструкции.

Дальнейшие настройки производятся в веб-интерфейсе, на странице Настройки - Конфигурационные файлы - Модуль уведомлений. Там все интуитивно понятно. 

В качестве получателя сообщений выбираем Электронная почта. Вписываем ваш адрес электронной почты (не обязательно от GMAIL). Указываем, что будет написано в теме письма.

Можете описать несколько таких получателей уведомлений.

Далее идут описания самих уведомлений (алармов). Нужно дать внятное название уведомлению. Выбрать значение для его активации (Не равно заданному/Больше/Меньше/Вне диапазона). Указать топик, откуда брать это значение. Ввести понятные пользователю сообщения при активации и деактивации уведомления. Можно указать временную задержку, чтобы исключить переходные процессы.

В примере выше вы видите описание уведомления, активируемого при загрязнении фильтра. Я это событие в скриптах никак не обрабатываю. Только уведомляю.

Визуализация

Разглядывать виджеты контроллеров на странице Устройства не удобно. Для тестирования я создал панель Вентиляция и занес туда виджеты, относящиеся с вентустановке. Теперь и здесь все удобно.

Делается это в течение 30 минут, там все интуитивно понятно. Один нюанс — порядок следования виджетов изменить пока нельзя, создавайте их сразу в нужном порядке. 

Видно, что алармы на картинке не активны (когда аларм активен, будет белая надпись на красном фоне). Установка работает. На значения температур не смотрите — это стенд.

А вот для пользователя я бы сделал SVG панель. Типа такой:

Как ее сделать — читайте в документации

Теперь все удобно и красиво. Подытожим.

  1. Внедрить систему управления приточно-вытяжной вентустановкой в общую систему автоматизации дома на базе контроллера WIren Board можно. Для этого достаточно добавить пару релейных модулей и пару датчиков. Стоит это оборудование совсем немного.

  2. Безопасный режим в релейных модулях позволяет обеспечить безопасность как самой вентустановки, так и дома, даже при отсутствии связи с центральным контроллером.

  3. Написать скрипты управления вентустановкой не сложнее, чем собрать вентустановку.

  4. Контроллер WIren Board в заводской поставке обладает всем необходимым для написания скриптов, их отладки, визуализации процесса и дистанционного уведомления пользователя о нештатных ситуациях.

Еще раз подчеркну — не воспринимайте написанный код как готовое решение для тиражирования. Смотрите на него как на основу для вашего творчества. И творите.

Весь приведенный выше код и пример дашборда вы найдёте на GitHub в репозитории wb-community.

Теги:
Хабы:
+16
Комментарии 15
Комментарии Комментарии 15

Публикации

Информация

Сайт
wirenboard.com
Дата регистрации
Дата основания
Численность
51–100 человек
Местоположение
Россия
Представитель
Саша Дегтярев