Jump to content

Примеры правил

From Wiren Board
This is the approved revision of this page, as well as being the most recent.


Общая информация

Здесь вы найдёте учебные примеры скриптов, написанных для движка правил wb-rules.

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

Как получить рабочий скрипт от ИИ-бота поддержки

Языковые модели дошли до такого уровня развития, что написать правило для контроллера может не только программист. Если вы можете внятно описать, что должно происходить в вашем умном доме — «нажал кнопку два раза — свет включился», «стемнело — штора закрылась» — этого достаточно, чтобы получить готовый работающий скрипт, не изучая JavaScript и синтаксис wb-rules. Ниже — как задать вопрос так, чтобы получить рабочий результат с первого-второго ответа, а не через несколько дней переписки.

На портале поддержки (support.wirenboard.com) для этого есть встроенный ИИ-ассистент — он доступен всем пользователям бесплатно, в отличие от сторонних чат-ботов, к которым нужен отдельный доступ.

Пошагово:

1. Опишите задачу своими словами, без терминов. Не нужно знать, что такое «правило» или «контрол» — просто напишите, что есть и что должно происходить. Например: «Есть модуль реле с кнопкой на входе и лента подсветки. Хочу, чтобы двойное нажатие включало и выключало ленту, а если кнопку зажать и держать — чтобы яркость менялась, пока держу».

2. Прикладывайте скриншоты, когда просят. Особенно карточек устройств и их настроек — по описанию словами легко ошибиться, а по скриншоту бот видит точные названия каналов и текущий режим работы устройства.

3. Если для поиска технического имени канала бот предлагает что-то сложное — прямо попросите попроще. Например, он может сначала предложить посмотреть имена устройств через командную строку. Если вы с ней не работали — не мучайтесь, напишите: «это сложно, а можно попроще?». Бот подберёт более простой способ — например, навести курсор на название канала в карточке устройства на вкладке «Устройства» и прочитать подсказку.

4. Не бойтесь присылать неполные данные или переспрашивать. Если что-то непонятно в ответе бота — уточните тем же простым языком. Бот переспросит сам, если для готового скрипта не хватает данных, а не станет угадывать наугад.

5. Сохраните и проверьте присланный скрипт. Скопируйте код в раздел «Правила» веб-интерфейса, сохраните и проверьте, как реагирует устройство. Если что-то работает не так — опишите боту, что именно наблюдаете (и пришлите сообщения из логов) — он поможет доработать.

Разобраться с настройками конкретных устройств (какие режимы входов доступны, что такое mapping-матрица и т. п.) можно и без чтения документации заранее — бот подскажет по ходу разговора, что и где посмотреть в веб-интерфейсе.

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

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

Пример ниже создаст виртуальное устройство с именем deviceName и двумя контролами value и state. А благодаря правилу с whenChanged значение контрола state будет меняться в зависимости от значения контрола value.

Виртуальным устройствам и контролам можно присваивать русские имена, задавая title в виде title: { en: 'Title', ru: 'Заголовок' }, или через setTitle у контрола: setTitle({ en: 'Title', ru: 'Заголовок' }).

Контролы в интерфейсе выводятся в алфавитном порядке имён. Чтобы задать свой порядок, укажите каждому контролу параметр order.

Для значений параметров с типом value и text можно использовать перечисления enum в виде набора именованных констант. Перечисления удобно использовать, когда значение параметра может принимать ограниченное количество значений, например, дни недели.

Чтобы задать перечисление, используйте для нужного контрола параметр enum с набором пар "ключ": "значение". Если параметр имеет тип value, ключ должен быть десятичным числом.

Контролы типов value и text только для чтения, поэтому подпись из перечисления показывается текстом. Чтобы значение можно было выбирать из списка, задайте контролу readonly: false.

var deviceName = 'my-virtual-device';

defineVirtualDevice(deviceName, {
    title: {'en': 'My Virtual Device', 'ru': 'Мое виртуальное устройство'},
    cells: {
      value: {
        title: {'en': 'Value', 'ru': 'Значение'},
        type: "range",
        value: 1,
        max: 3,
        min: 1,
        order: 1
      },
      state: {
        title: {'en': 'State', 'ru': 'Состояние'},
        type: "value",
        value: 1,
        order: 2,
        enum: {
          1: {'en': 'Normal', 'ru': 'В норме'},
          2: {'en': 'Warning', 'ru': 'Внимание'},
          3: {'en': 'Crash', 'ru': 'Авария'}
        }
      }
    }
});

defineRule({
  whenChanged: deviceName + "/value",
  then: function (newValue, devName, cellName) {
    dev[deviceName + "/state"] = newValue;
  }
});

Слежение за контролом

Это простейшее правило следит за контролом и устанавливает другой контрол в такое же состояние.

Например, правило может включать сирену и лампу, если датчик движения заметил движение.

Такую же задачу — без программирования — решает сценарий «Маппинг каналов»: в таблице соответствий можно добавить несколько строк, каждая копирует значение канала A в свой канал B — так одно событие можно разослать на несколько получателей в одном экземпляре сценария.

В примере датчик движения подключен к универсальному входу A1, контрол типа «switch». Сирена подключена к универсальному выходу A3 контроллера, лампа — через релейный блок по Modbus. Когда вход A1 (выход датчика движения) замкнут, на A3_OUT и реле подаётся «1», когда выключен — «0».

Правило срабатывает каждый раз при изменении значения контрола «A1_IN» у устройства «wb-gpio». В код правила передаётся новое значение этого контрола в виде переменной newValue.

defineRule({
  whenChanged: "wb-gpio/A1_IN",
  then: function (newValue, devName, cellName) {
    dev["wb-gpio/A3_OUT"] = newValue;
    dev["wb-mrm2_6/Relay 1"] = newValue;
  }
});

Мастер-выключатель с восстановлением последнего состояния

На вход контроллера подключен мастер-выключатель, который при переключении отключает все устройства, указанные в соответствующем правиле. При повторном нажатии на выключатель устройствам возвращается первоначальное состояние.

Подключение осуществляется к контакту A1 и 5V на контроллере. При замыкании на соответствующем канале wb-gpio/A1_IN состояние меняется, и срабатывает правило.

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

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

defineVirtualDevice("power_off", {
    title: "Мастер-выключатель",
    cells: {
        power_off: {
            type: "pushbutton"
        },
    }
});

var ps = new PersistentStorage("power-storage", { global: true });
var lights = ["wb-mdm3_50/K1", "wb-mdm3_50/K2", "wb-mdm3_50/K3"];

var isPowerOff = (ps["isPowerOff"] === undefined) ? true : ps["isPowerOff"];

defineRule({
    whenChanged: ["wb-gpio/A1_IN", "power_off/power_off"],
    then: function (newValue, devName, cellName) {
        if (isPowerOff) {
            lights.forEach(function (light) {
                ps[light] = dev[light];
                dev[light] = false;
            });
        } else {
            lights.forEach(function (light) {
                dev[light] = ps[light];
            });
        }
        isPowerOff = !isPowerOff;
        ps["isPowerOff"] = isPowerOff;
    }
});

Детектор движения c таймаутом

На вход A2 подключен детектор движения с выходом «сухой контакт». При обнаружении движения он замыкает A2 и GND, и на соответствующем канале wb-gpio/A2_IN появляется статус «1».

На выход A4 подключено реле управления светом, соответствующий канал wb-gpio/A4_OUT.

Правило работает так:

  • когда движение появляется, свет включается. Если ранее был запущен тридцатисекундный таймер «на выключение», этот таймер отключается;
  • когда движение пропадает, запускается тридцатисекундный таймер «на выключение». Если ему удаётся дойти до конца, свет выключается.

Такую же логику (включение по движению с задержкой выключения) можно настроить без программирования через сценарии автоматизации — шаблон «Управление светом».

var motion_timer_1_timeout_ms = 30 * 1000;
var motion_timer_1_id = null;
defineRule("motion_detector_1", {
    whenChanged: "wb-gpio/A2_IN",
    then: function (newValue, devName, cellName) {
        if (motion_timer_1_id) {
            clearTimeout(motion_timer_1_id);
            motion_timer_1_id = null;
        }
        if (newValue) {
            dev["wb-gpio/A4_OUT"] = true;
        } else {
            motion_timer_1_id = setTimeout(function () {
                dev["wb-gpio/A4_OUT"] = false;
                motion_timer_1_id = null;
            }, motion_timer_1_timeout_ms);
        }
    },
});

Создание однотипных правил

Если таких детекторов движения нужно несколько, то, чтобы не копировать код, можно обернуть создание правила и переменных в функцию:

 function makeMotionDetector(name, timeout_ms, detector_control, relay_control) {
  var motion_timer_id = null;
  defineRule(name, {
      whenChanged: "wb-gpio/" + detector_control,
      then: function(newValue, devName, cellName) {
          if (motion_timer_id) {
              clearTimeout(motion_timer_id);
              motion_timer_id = null;
          }
          if (newValue) {
              dev["wb-gpio/" + relay_control] = true;
          } else {

              motion_timer_id = setTimeout(function() {
                  dev["wb-gpio/" + relay_control] = false;
                  motion_timer_id = null;
              }, timeout_ms);
          }
      }
  });
}

makeMotionDetector("motion_detector_1", 20000, "EXT1_DR1", "EXT2_R3A1");
makeMotionDetector("motion_detector_2", 10000, "EXT1_DR2", "EXT2_R3A2");
makeMotionDetector("motion_detector_3", 10000, "EXT1_DR3", "EXT2_R3A3");

Активация правила только в определённое время

Правило как в предыдущем разделе, но выполняется только с 9:30 до 17:10 по UTC.

var motion_timer_1_timeout_ms = 5 * 1000;
var motion_timer_1_id = null;

defineRule("motion_detector_1", {
  whenChanged: "wb-gpio/A1_IN",
  then: function (newValue, devName, cellName) {
    var date = new Date();

    // точка времени начала интервала
    // то есть «сегодня, в HH:MM». в UTC!
    var date_start = new Date(date);
    date_start.setUTCHours(9);
    date_start.setUTCMinutes(30);
    date_start.setUTCSeconds(0);
    date_start.setUTCMilliseconds(0);

    // точка времени конца интервала
    var date_end = new Date(date);
    date_end.setUTCHours(17);
    date_end.setUTCMinutes(10);
    date_end.setUTCSeconds(0);
    date_end.setUTCMilliseconds(0);

    // если время между 09:30 и 17:10 UTC
    if ((date > date_start) && (date < date_end)) {
      if (motion_timer_1_id) {
        clearTimeout(motion_timer_1_id);
        motion_timer_1_id = null;
      }
      if (newValue) {
        dev["wb-gpio/EXT1_R3A1"] = true;
      } else {
        motion_timer_1_id = setTimeout(function () {
          dev["wb-gpio/EXT1_R3A1"] = false;
          motion_timer_1_id = null;
        }, motion_timer_1_timeout_ms);
      }
    }
  }
});

Роллеты

Одно реле включает двигатель, поднимающий шторы, второе реле — включает двигатель, опускающий шторы. Правило следит за тем, чтобы оба реле не были включены одновременно.

Кроме этого, правило отключает двигатели спустя заданное время после включения.

Такой же функционал (блокировка одновременного включения двух реле, пауза при смене направления, автоотключение по таймауту) реализован на ряде модулей реле WB-MR (WB-MR3, WB-MR6C, WB-MR6CU, WB-MR6C v.3) — режим «Управление приводом» в настройках устройства. В реальных проектах рекомендуется использовать именно его; пример ниже приведён исключительно в демонстрационных и учебных целях.

(function() { //эту строку не трогать
  
  var suffix = "1"; // должен отличаться в разных JS-файлах
 
  var relay_up_device = "lc103_4";
  var relay_up_control = "Relay 1";

  var relay_down_device = "lc103_4";
  var relay_down_control = "Relay 2";

  var timeout_s = 15;
  
  // Конец настроек
  
  
  var relay_up_timer_id = null;
  var relay_down_timer_id = null;
  
  defineRule( "roller_shutter_up_on" + suffix, {
   asSoonAs: function() {
     return dev[relay_up_device][relay_up_control];
   },
    then: function () {
      if (relay_up_timer_id) {
        clearTimeout(relay_up_timer_id);
        relay_up_timer_id = null;
      }

      relay_up_timer_id = setTimeout(function() {
        dev[relay_up_device][relay_up_control] = false;
        relay_up_timer_id = null;
      }, timeout_s * 1000);
    }
  });

  defineRule("roller_shutter_down_on" + suffix, {
    asSoonAs: function() {
      return dev[relay_down_device][relay_down_control];
    },
    then: function () {
      if (relay_down_timer_id) {
        clearTimeout(relay_down_timer_id);
        relay_down_timer_id = null;
      }
      
      relay_down_timer_id = setTimeout(function() {
        dev[relay_down_device][relay_down_control] = false;
        relay_down_timer_id = null;
      }, timeout_s * 1000);
    }
  });

  defineRule("roller_shutter_both_on" + suffix, {
    asSoonAs: function() {
      return dev[relay_up_device][relay_up_control] && dev[relay_down_device][relay_down_control];
    },
    then: function () {
      if (relay_up_timer_id) {
        clearTimeout(relay_up_timer_id);
        relay_up_timer_id = null;
      }

      if (relay_down_timer_id) {
        clearTimeout(relay_down_timer_id);
        relay_down_timer_id = null;
      }

      
      dev[relay_up_device][relay_up_control] = false;
      dev[relay_down_device][relay_down_control] = false;
      log("Оба реле роллет включены, выключаем их");
    }
  });
})();

Подсчет импульсов на дискретном входе

Счетчик (или кнопка) с выходом типа «сухой контакт» подключен к универсальному входу A1 контроллера. Правило подсчитывает количество замыканий входа A1 на землю и выводит результат в виртуальное устройство. Виртуальное устройство также имеет кнопку сброса счетчика.

Виртуальное устройство счетчика импульсов с кнопкой сброса

Такой же функционал — подсчёт импульсов с хранением значения счётчика в энергонезависимой памяти самого модуля — есть в модуле WB-MCM8. В реальных проектах для этой задачи рекомендуется использовать именно его; пример ниже приведён исключительно в демонстрационных и учебных целях. При использовании в реальном проекте стоит внимательно отнестись к частоте записи в PersistentStorage — она физически записывается на eMMC-накопитель контроллера, и частые записи (на каждый импульс, при высокой частоте их следования) могут ускорить его износ.

// --- Виртуальное устройство для отображения количества импульсов с кнопкой сброса ---
defineVirtualDevice("pulse_counter", {
  title: "Pulse counter",
  cells: {
    impulses: {
      type: "value",
      value: 0
    },
    reset: {
      type: "pushbutton"
    }
  }
});

// --- Логика подсчета импульсов ---
var ps = new PersistentStorage("pulse-counter-storage", { global: true }); // хранилище, переживает перезапуск движка
var inputChannel = "wb-gpio/A1_IN"; // Топик дискретного входа

var impulseCount = (ps["impulseCount"] === undefined) ? 0 : ps["impulseCount"]; // Счетчик импульсов, восстанавливается после перезапуска

dev["pulse_counter"]["impulses"] = impulseCount; // Отображение восстановленного значения сразу после запуска

// Обработка импульсов: считаем фронты с 0 на 1
defineRule("count_impulses", {
  whenChanged: inputChannel, // Срабатывает только на фактическое изменение входа: и 0->1, и 1->0
  then: function (newValue) {
    if (newValue) {                                     // Считаем только фронт 0->1, спад пропускаем
      impulseCount += 1;
      dev["pulse_counter"]["impulses"] = impulseCount;  // Отображение импульсов в виртуальном устройстве
      ps["impulseCount"] = impulseCount;                // Сохранение в постоянное хранилище
    }
  }
});

// Обработка кнопки сброса
defineRule("reset_counter", {
  whenChanged: "pulse_counter/reset",
  then: function () {
    impulseCount = 0;  // Сброс счетчика
    dev["pulse_counter"]["impulses"] = impulseCount;
    ps["impulseCount"] = impulseCount;  // Сброс должен пережить перезапуск движка
  }
});

Импульсные счетчики

Импульсный счетчик подключен к WB-MCM8. Выдает 1 импульс на 10 литров воды. При подключении на счетчике были показания 123.120 м³, что равно 123120 литрам воды. У WB-MCM8 при подключении было насчитано 7 импульсов.

WB-MCM8 считает импульсы только с момента своего подключения и не знает, сколько воды прошло через счётчик раньше. Чтобы виртуальное устройство показывало реальные абсолютные литры, а не число импульсов с момента подключения, нужна одноразовая калибровка: meterCorrection/counterCorrection фиксируют показания счётчика воды и WB-MCM8 на момент подключения, дальше правило пересчитывает новые значения относительно этой точки.

var meterCorrection = 123120; // Корректировочное значение счетчика в литрах
var counterCorrection = 7; // Корректировочное значение WB-MCM8 в импульсах
var inpulseValue = 10; // Количество литров на один импульс
var counterChannel = "wb-mcm8_29/Input 1 counter"; // Топик счетчика импульсов

defineVirtualDevice("water_meters", { // Создаем виртуальный девайс для отображения в веб интерфейсе.
    title: "Счетчики воды",
    cells: {
        water_meter_1: {
            type: "value",
            value: 0
        },
    }
});

// Пересчет импульсов в литры относительно калибровочной точки
function calcLiters(counterValue) {
    return ((parseInt(counterValue) - counterCorrection) * inpulseValue) + meterCorrection;
}

// Показываем актуальное значение сразу при запуске, не дожидаясь первого импульса
dev["water_meters/water_meter_1"] = calcLiters(dev[counterChannel]);

defineRule("water_meter_1", {
    whenChanged: counterChannel,
    then: function(newValue, devName, cellName) {
        dev["water_meters/water_meter_1"] = calcLiters(newValue); // Пересчитываем при каждом изменении счетчика, включая сброс в 0
    }
});

Энергия за сутки и месяц по счётчику WB-MAP

Счётчики WB-MAP только накапливают энергию: сбросить или изменить накопленное значение нельзя, а часов у счётчика нет. Поэтому расход за сутки или месяц, тарифы и показания, продолжающие старый счётчик, считают на контроллере.

Правило запоминает показание на начало суток и на начало месяца в постоянном хранилище, которое переживает перезапуск движка, и выводит разницу в виртуальное устройство. Переменная offset — показания старого счётчика, если нужно продолжить их, а не начинать с нуля.

// --- Виртуальное устройство: расход по одному каналу счётчика WB-MAP ---
defineVirtualDevice("map_energy", {
  title: "Энергия счётчика",
  cells: {
    total: { type: "value", value: 0 },  // всего, с учётом показаний старого счётчика
    day:   { type: "value", value: 0 },  // с начала суток
    month: { type: "value", value: 0 }   // с начала месяца
  }
});

// --- Логика ---
var ps = new PersistentStorage("map-energy-storage", { global: true }); // переживает перезапуск движка
var meterChannel = "wb-map3et_72/Total AP energy"; // канал накопленной энергии; имя устройства смотрите в MQTT
var offset = 0;                                    // показания старого счётчика, кВт·ч

defineRule("map_energy_update", {
  whenChanged: meterChannel,
  then: function (newValue) {
    if (ps["dayStart"] === undefined || newValue < ps["dayStart"]) {
      ps["dayStart"] = newValue;          // первый запуск или счётчик заменили: точка отсчёта — текущее показание
      ps["monthStart"] = newValue;
    }
    dev["map_energy"]["total"] = newValue + offset;
    dev["map_energy"]["day"]   = newValue - ps["dayStart"];
    dev["map_energy"]["month"] = newValue - ps["monthStart"];
  }
});

defineRule("map_energy_day", {            // в полночь запоминаем показание на начало суток
  when: cron("0 0 0 * * *"),
  then: function () { ps["dayStart"] = dev[meterChannel]; }
});

defineRule("map_energy_month", {          // в полночь первого числа — на начало месяца
  when: cron("0 0 0 1 * *"),
  then: function () { ps["monthStart"] = dev[meterChannel]; }
});

Источником должен быть накопительный счётчик — величина, которая только растёт. Проверка «значение упало — перенести точку отсчёта» рассчитана на замену счётчика; для мгновенных величин вроде напряжения или мощности правило не подходит.

Если канал счётчика в состоянии ошибки чтения, правило не срабатывает: значения не обновляются и недостоверные данные в расчёт не попадают.

Пересчёт показаний счётчика при каскаде трансформаторов

Когда счётчик стоит за каскадом трансформаторов, коэффициенты перемножают и произведение записывают в счётчик. Но в регистр Turns помещается число только до 65 535, а с промежуточным WB-CT309 (коэффициент 2000) предел превышается почти сразу: после первичного трансформатора 200/5 А получается 2000 × 40 = 80 000.

Тогда в счётчик записывают только коэффициент промежуточного трансформатора, а на коэффициент первичного домножают показания правилом на контроллере.

// --- Пересчёт показаний счётчика при каскаде трансформаторов ---
// В счётчик записан только коэффициент промежуточного трансформатора,
// коэффициент первичного домножаем здесь.
var meter = "wb-map3et_72";  // имя счётчика в MQTT
var k = 40;                  // коэффициент первичного трансформатора: 200 А / 5 А

defineVirtualDevice("map_cascade", {
  title: "Счётчик за каскадом трансформаторов",
  cells: {
    current: { type: "value", value: 0 },  // А
    power:   { type: "value", value: 0 },  // Вт
    energy:  { type: "value", value: 0 }   // кВт·ч
  }
});

defineRule("map_cascade_update", {
  whenChanged: [meter + "/Irms L1", meter + "/P L1", meter + "/Total AP energy"],
  then: function () {
    dev["map_cascade"]["current"] = dev[meter + "/Irms L1"] * k;
    dev["map_cascade"]["power"]   = dev[meter + "/P L1"] * k;
    dev["map_cascade"]["energy"]  = dev[meter + "/Total AP energy"] * k;
  }
});

Домножать нужно токи, мощности и энергии. Напряжение, частота и углы от коэффициента трансформации не зависят.

Работа с аналоговыми входами

Для считывания и преобразования аналоговых сигналов (4-20 мА, 0-10 В, термосопротивления и т. п.) в инженерные величины есть два принципиально разных пути.

Модуль аналоговых входов WB-MAI6 (RS-485/Modbus RTU) выполняет пересчёт сигнала во встроенном функционале — при выборе типа датчика или типового сигнала параметры пересчёта задаются прямо в настройках канала модуля, без единой строчки кода.

Встроенные универсальные каналы контроллера A1 — A4 такого пересчёта не делают — только измеряют напряжение. Преобразование в инженерные величины (минимум/максимум шкалы датчика и соответствующие им значения напряжения) в этом случае делается в коде. Пример подключения датчика и преобразования сигнала — в статье Подключение датчиков с аналоговым выходом к Wiren Board.

Инвертирование значения контрола

Если нужна только односторонняя инверсия (контрол показывает состояние другого в инвертированном виде, без управления), то это решается без программирования через механизм сценариев, шаблон «Маппинг каналов».

Если инвертированным контролом нужно не только смотреть состояние, но и управлять, требуется скрипт с правилами обоих направлений. При этом перед записью важно проверять, действительно ли значение отличается от текущего. Без такой проверки на одно изменение приходится лишняя повторная запись — следствие «каскадной» зависимости сигналов друг от друга.

Правило ниже создаёт виртуальное устройство my-invert-buzzer с контролом disabled, который показывает состояние контрола enabled системной пищалки Buzzer в инвертированном виде, с возможностью управления.

defineVirtualDevice('my-invert-buzzer', {
    title: 'Buzzer Invert',
    cells: {
        disabled: {
            title: "disabled",
            type: "switch",
            value: !dev["buzzer/enabled"]
        }
    }
});

// Пишем только если значение действительно надо изменить — иначе на каждое
// переключение уйдёт лишняя запись в обратную сторону
defineRule("invert_buzzer_forward", {
    whenChanged: ["buzzer/enabled"],
    then: function(newValue, devName, cellName) {
        if (dev["my-invert-buzzer/disabled"] !== !newValue) {
            dev["my-invert-buzzer/disabled"] = !newValue;
        }
    }
});

defineRule("invert_buzzer_backward", {
    whenChanged: ["my-invert-buzzer/disabled"],
    then: function(newValue, devName, cellName) {
        if (dev["buzzer/enabled"] !== !newValue) {
            dev["buzzer/enabled"] = !newValue;
        }
    }
});

Обработка счётчиков нажатий

Описание

Последние версии прошивок устройств Wiren Board могут распознавать типы нажатий подключённых к входам кнопок и транслировать их по Modbus на контроллер Wiren Board. О том, как устройство распознаёт типы нажатий, читайте в его документации.

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

Обработку счётчиков удобно делать на wb-rules, но вы можете использовать любой инструмент для автоматизации, например, Node-RED. Чтобы ускорить опрос счетчиков, настройте период опроса.

Примеры

Пример работы правила

В примере мы используем модуль WB-MCM8 для управления первым каналом диммера WB-MDM3:

  1. Короткое нажатие включает канал.
  2. Двойное — выключает канал.
  3. Длинное — увеличивает яркость.
  4. Короткое, а затем длинное — уменьшает яркость.

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

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

/* ---------------------------- */
/* 1. Счётчик одиночных нажатий: включение */
/* ---------------------------- */

var lastSinglePress = dev["wb-mcm8_20/Input 1 Single Press Counter"];

defineRule({
  whenChanged: "wb-mcm8_20/Input 1 Single Press Counter",
  then: function (newValue, devName, cellName) {
    if (newValue > lastSinglePress) {
      dev["wb-mdm3_58/K1"] = true;
    }
    lastSinglePress = newValue;
  }
});


/* ---------------------------- */
/* 2. Счётчик двойных нажатий: выключение */
/* ---------------------------- */

var lastDoublePress = dev["wb-mcm8_20/Input 1 Double Press Counter"];

defineRule({
  whenChanged: "wb-mcm8_20/Input 1 Double Press Counter",
  then: function (newValue, devName, cellName) {
    if (newValue > lastDoublePress) {
      dev["wb-mdm3_58/K1"] = false;
    }
    lastDoublePress = newValue;
  }
});


/* --------------------------------------- */
/* 3. Счётчик длинных нажатий: увеличение яркости */
/* --------------------------------------- */

var lastLongPress = dev["wb-mcm8_20/Input 1 Long Press Counter"];

defineRule({
  whenChanged: "wb-mcm8_20/Input 1 Long Press Counter",
  then: function (newValue, devName, cellName) {
    if (newValue > lastLongPress) {
      // Запускаем таймер, увеличивающий значение контрола
      startTicker("input1_long_press", 75);
    }
    lastLongPress = newValue;
  }
});

// Правило, увеличивающее яркость по таймеру
defineRule({
  when: function () { return timers["input1_long_press"].firing; },
  then: function () {
    var i = dev["wb-mdm3_58/Channel 1"];

    if (i < 100 && dev["wb-mcm8_20/Input 1"]) {
      i++
      dev["wb-mdm3_58/Channel 1"] = i
    } else {
      timers["input1_long_press"].stop();
    }
  }
});


/* -------------------------------------------- */
/* 4. Счётчик «короткое-длинное» нажатие: уменьшение яркости */
/* -------------------------------------------- */

var lastShortlongPress = dev["wb-mcm8_20/Input 1 Shortlong Press Counter"];

defineRule({
  whenChanged: "wb-mcm8_20/Input 1 Shortlong Press Counter",
  then: function (newValue, devName, cellName) {
    if (newValue > lastShortlongPress) {
      // Запускаем таймер, уменьшающий значение контрола
      startTicker("input1_shortlong_press", 75);
    }
    lastShortlongPress = newValue;
  }
});

// Правило, уменьшающее яркость по таймеру
defineRule({
  when: function () { return timers["input1_shortlong_press"].firing; },
  then: function () {
    var i = dev["wb-mdm3_58/Channel 1"];

    if (i > 0 && dev["wb-mcm8_20/Input 1"]) {
      i--
      dev["wb-mdm3_58/Channel 1"] = i
    } else {
      timers["input1_shortlong_press"].stop();
    }
  }
});

Универсальный модуль для wb-rules

Мы написали модуль для wb-rules wb-press-actions, который облегчает обработку нажатий в ваших скриптах.

Датчик MSW v.4

При подключении датчика WB-MSW v.4 к контроллеру Wiren Board есть возможность создавать интересные сценарии, используя данные с датчика. Например, включать свет по движению, сигнализировать светодиодами о превышении значения CO2 или VOC, включать кондиционер, если жарко, или увлажнитель воздуха, если воздух слишком сухой. Правила создаются индивидуально под задачи. Здесь мы приведём несколько примеров для понимания принципа работы с датчиком. Больше примеров написания правил можно найти в документации Движок правил wb-rules.

CO2

При концентрации CO2 до 650 ppm включительно — раз в 10 секунд мигаем зелёным.

При концентрации CO2 от 651 до 1000 ppm — раз в 5 секунд мигаем жёлтым.

При концентрации CO2 свыше 1000 ppm — раз в секунду мигаем красным.

defineRule("msw4_co2", {
    whenChanged: "wb-msw-v4_97/CO2",
    then: function(newValue, devName, cellName) {
        var co2_good = newValue <= 650;
        var co2_middle = newValue > 650 && newValue <= 1000;
        var co2_bad = newValue > 1000;

        if (co2_good) {
            dev[devName+"/Green LED"] = true;
            dev[devName+"/Red LED"] = false;
            dev[devName+"/LED Period (s)"] = 10;
        }
        if (co2_middle) {
            dev[devName+"/Green LED"] = true;
            dev[devName+"/Red LED"] = true;
            dev[devName+"/LED Period (s)"] = 5;
        }
        if (co2_bad) {
            dev[devName+"/Green LED"] = false;
            dev[devName+"/Red LED"] = true;
            dev[devName+"/LED Period (s)"] = 1;
        }
    }
});

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

function ruleCO2 (devCO2, minCO2, maxCO2){
  defineRule ("ruleCO2"+devCO2, {
      whenChanged: devCO2+"/CO2",
      then: function(newValue, devName, cellName) {
        log.info("ruleCO2 " + devCO2 +" enter with", newValue)
        if (newValue <= minCO2) {
          dev[devCO2+"/LED Glow Duration (ms)"] = 50;
          dev[devCO2+"/Green LED"] = true;
          dev[devCO2+"/Red LED"] = false;
          dev[devCO2+"/LED Period (s)"] = 10;
        }
        if ((newValue > minCO2) && (newValue <= maxCO2)) {
          dev[devCO2+"/LED Glow Duration (ms)"] = 50;            
          dev[devCO2+"/Green LED"] = true;
          dev[devCO2+"/Red LED"] = true;
          dev[devCO2+"/LED Period (s)"] = 5;
        }
        if (newValue > maxCO2) {
          dev[devCO2+"/LED Glow Duration (ms)"] = 50;            
          dev[devCO2+"/Green LED"] = false;
          dev[devCO2+"/Red LED"] = true;
          dev[devCO2+"/LED Period (s)"] = 1;
        }
      }
  });
}

// Пороги можно задавать разными для каждого вызова — например, для датчика в другом помещении
ruleCO2("wb-msw-v4_97", 650, 1000);
ruleCO2("wb-msw-v4_98", 650, 1000);
ruleCO2("wb-msw-v4_11", 500, 700);

Max Motion

«Max Motion» — максимальное значение датчика движения за время N. Время от 1 до 60 секунд можно выставить в регистре 282. По умолчанию 10 секунд. При достижении Max Motion значения 50 проверяем, достаточно ли освещена комната, если нет — включаем свет. Как только значение Max Motion упадёт ниже 50, свет выключаем.

Системные правила

Многие показания, которые видны в веб-интерфейсе контроллера из коробки, тоже создаются правилами на движке wb-rules. Их код находится здесь: https://github.com/wirenboard/wb-rules-system. Системные правила собраны в пакет wb-rules-system, сами файлы скриптов на контроллере находятся в папке /usr/share/wb-rules-system/rules/.

Несколько примеров системных правил ниже. Код в репозитории развивается вместе с железом, поэтому вместо копии кода на этой странице — ссылки на актуальные файлы.

Правило для пищалки

buzzer.js создаёт виртуальное устройство buzzer с ползунками для регулировки громкости и частоты, а также кнопкой включения звука.

Правило для статуса питания

power_status.js создаёт виртуальное устройство, которое сообщает текущий статус питания: входное напряжение и признак работы от резервного источника.

Термостат

Логика простого термостата с гистерезисом (включать нагрев при падении температуры ниже уставки, выключать при превышении) реализована без программирования в сценарии «Термостат» — там же доступен и более точный режим ПИД-регулятора. Управление термостатом по расписанию (режим «ручной/по расписанию») можно получить, объединив его со сценарием «Расписание»: один сценарий может включать и выключать другой через его контрол rule_enabled.

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

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

Начиная с релиза Wb-2602 отображение текущей даты, дня недели, времени и часового пояса реализовано системным правилом «Системное время» (пакет wb-rules-system) — устройство появляется в веб-интерфейсе из коробки, писать правило самостоятельно не нужно.

Если текущее время нужно не для отображения, а внутри собственного правила, используйте стандартный объект JavaScript Date: new Date() и его методы getHours(), getMinutes(), getSeconds(), getDate(), getMonth(), getFullYear(), getDay() (либо их варианты getUTCHours() и т. д. — для времени в UTC, без учёта часового пояса контроллера).

Получение погоды

Пример виртуального устройства с погодой
// Город для прогноза
var city = "Stockholm";

defineVirtualDevice("weather_forecast", {
    title: "Weather Forecast",
    cells: {
        current_temp: { type: "temperature", value: 0, unit: "°C" },
        current_humidity: { type: "rel_humidity", value: 0, unit: "%" },
        current_wind: { type: "text", value: "" },
        current_cloudcover: { type: "text", value: "" },
        current_precipitation: { type: "text", value: "" },
        today_summary: { type: "text", value: "" },
        tomorrow_summary: { type: "text", value: "" },
        day_after_tomorrow_summary: { type: "text", value: "" }
    }
});

var updateWeather = function () {
    var url = "https://wttr.in/" + encodeURIComponent(city) + "?format=j1";

    runShellCommand("curl -s --max-time 15 '" + url + "'", {
        captureOutput: true,
        exitCallback: function (exitCode, capturedOutput) {
            if (exitCode !== 0) {
                log("Weather fetch failed with exit code: " + exitCode);
                return;
            }

            try {
                var weatherData = JSON.parse(capturedOutput);

                // Обновляем текущие данные
                var current = weatherData.current_condition[0];
                updateCurrentWeather(current);

                // Обновляем прогнозы
                updateForecast("today_summary", weatherData.weather[0]);
                updateForecast("tomorrow_summary", weatherData.weather[1]);
                updateForecast("day_after_tomorrow_summary", weatherData.weather[2]);

            } catch (e) {
                log("Failed to parse weather data: " + e.message);
            }
        }
    });
};

var updateCurrentWeather = function (current) {
    dev["weather_forecast/current_temp"] = parseFloat(current.temp_C);
    dev["weather_forecast/current_humidity"] = parseFloat(current.humidity);
    dev["weather_forecast/current_wind"] = current.windspeedKmph + " km/h (" + current.winddir16Point + ")";
    dev["weather_forecast/current_cloudcover"] = current.cloudcover + "%";
    dev["weather_forecast/current_precipitation"] = current.precipMM + " mm";
};

var updateForecast = function (controlName, forecast) {
    dev["weather_forecast/" + controlName] = forecast.hourly[0].weatherDesc[0].value;
};

// Обновляем каждые 30 минут
setInterval(updateWeather, 30 * 60 * 1000);

// Первоначальное обновление
updateWeather();

Отправка команд по RS-485

Способ прямой записи в файл устройства (например, /dev/ttyRS485-1, в обход драйвера wb-mqtt-serial) устарел и пример исключён. Актуальный способ: работа с последовательным портом через RPC. Запрос идёт через сам драйвер, не конфликтует со штатным опросом устройств на шине, не требует ни настройки порта командой stty, ни его описания в wb-mqtt-serial.conf.

Отправка сообщения через Telegram-бота

Сообщения отправляются с использованием Telegram Bot API через curl.

var message = "Text"; // напишите свой текст сообщения
var token = "TOKEN"; // замените на токен бота
var chat_id = 123456789; // замените на свой chat_id (число, без кавычек)
var command = 'curl -s --max-time 15 -X POST https://api.telegram.org/bot{}/sendMessage -d chat_id={} -d text="{}"'.format(token, chat_id, encodeURIComponent(message));

runShellCommand(command, {
  captureOutput: true,
  captureErrorOutput: true,
  exitCallback: function (exitCode, capturedOutput, capturedErrorOutput) {
    if (exitCode !== 0) {
      log.error('error sending telegram message (curl exit code {}):\n{}\n{}', exitCode, capturedOutput, capturedErrorOutput);
      return;
    }
    try {
      var response = JSON.parse(capturedOutput);
      if (!response.ok) {
        log.error('error sending telegram message: {} {}', response.error_code, response.description);
      }
    } catch (e) {
      log.error('error parsing response: {}', e);
    }
  }
});

Отправка сообщения через MAX-бота

Для отправки сообщения необходим созданный в мессенджере MAX-бот.

Создайте файл /etc/wb-rules-modules/max_notify.js:

exports.sendMaxMessage = function(token, chatId, text) {
  log('sending max message: {}', text);
  chatId = chatId.toString();
  var queryParam = (chatId[0] == "-") ? "chat_id" : "user_id";
  runShellCommand(
    "curl -s --max-time 15 -X POST https://platform-api2.max.ru/messages?{}={} -H 'Content-Type: application/json' -H 'Authorization: {}' -d @-".format(queryParam, chatId, token),
    {
      captureErrorOutput: true,
      captureOutput: true,
      input: JSON.stringify({ text: text }),
      exitCallback: function exitCallback(exitCode, capturedOutput, capturedErrorOutput) {
        if (exitCode != 0) {
          log.error(
            'error sending max message (curl exit code {}):\n{}\n{}',
            exitCode,
            capturedOutput,
            capturedErrorOutput
          );
          return;
        }
        try {
          var response = JSON.parse(capturedOutput);
          if (!response.message || !response.message.timestamp)
            log.error(
              'error sending max message:\n{} {}',
              response.code,
              response.message
            );
        } catch (e) {
          log.error('error parsing response: {}', e);
        }
      },
    }
  );
};

Дальше в скриптах:

var CustomNotify = require("max_notify");
...
CustomNotify.sendMaxMessage("token", "chatId", "text");

Источник: тема на портале техподдержки.

Обработка ошибок в работе с serial-устройствами

Реализована через подписку на все топики meta/error.

defineVirtualDevice("meta_error_test", {
  title: "Metaerordisplay",
  cells: {
      topic: {
      type: "text",
      value: "",
      readonly: true
    },
    value: {
      type: "text",
      value: "",
      readonly: true
    },
  }
});

trackMqtt("/devices/+/controls/+/meta/error", function(message){
  log.info("name: {}, value: {}".format(message.topic, message.value))
  dev["meta_error_test/topic"] = message.topic;
  dev["meta_error_test/value"] = message.value;
});

Пользовательские поля в веб-интерфейсе

Пример конфигурации
Пример скрипта

Задача — в веб-интерфейсе контроллера Wiren Board нужно вводить уставки температуры и влажности.

Простой способ — создать в defineVirtualDevice() поле с параметром readonly: false. Оно появится в веб-интерфейсе в Devices как редактируемое, а значение будет сохраняться в движке правил. Но сложную настройку с меню и вариантами так не сделать.

Правильный, но сложный способ — создать новую вкладку в разделе Configs с редактируемыми полями параметров установок.

Потребуются три файла:

1. Схема вывода HTML-страницы в разделе Configs: /usr/share/wb-mqtt-confed/schemas/test-config.schema.json

{
"type":"object",
"title":"Test configuration",
"description":"Long description configuration",

"configFile": {
"path":"/etc/test-config.conf",
"service":"wb-rules"
},

"properties": {
"temperature_setpoint": {
"type":"number",
"title":"Temperature Setpoint (Degrees C)",
"default": 25,
"propertyOrder": 1,
"minimum": 5,
"maximum": 40
},

"humidity_setpoint": {
"type":"number",
"title":"Humidity Setpoint (RH, %)",
"default": 60,
"propertyOrder": 2,
"minimum": 10,
"maximum": 95
}
},
"required": ["temperature_setpoint", "humidity_setpoint"]
}

2. Описание конфигурации по умолчанию (при сохранении формы в веб-интерфейсе значения запишутся в этот файл): /etc/test-config.conf

{
"temperature_setpoint": 25,
"humidity_setpoint": 60
}

3. Скрипт, обновляющий конфиг: /etc/wb-rules/test-config-script.js

var config = readConfig("/etc/test-config.conf");

log("temperature setpoint is: {}".format(config.temperature_setpoint));

Последний файл можно в том числе редактировать из веб-интерфейса на вкладке Scripts.

В JSON-файлах описаны схемы вывода HTML-страницы браузером, по общепринятому стандарту отображения. Описание ключей тут: json-schema.org.

После создания файлов нужно перезапустить сервисы:

systemctl restart wb-mqtt-confed

systemctl restart wb-rules

При нажатии кнопки Save в веб-интерфейсе будет перезапускаться сервис wb-rules, а значения установок — записываться в правила.

Работает всё это через json-editor .

Автоматика продуктового магазина по расписанию

Объект — продуктовый магазин. Различные системы магазина управляются по обратной связи от датчиков температуры и с учётом расписания работы магазина.

Расписания задаёт библиотека lib_schedules.js. Каждое расписание описывается одним или несколькими интервалами суток. Библиотека определяет, попадает ли текущий момент в эти интервалы, и публикует результат в виртуальное устройство _schedules — по контролу на каждое расписание. Правила подписываются на нужный контрол и срабатывают, когда он переключается.

Например, освещение должно гореть с 10 до 17 часов. Библиотека включит контрол расписания в 10:00 и выключит в 17:00, а подписанное правило по этим переключениям зажжёт и погасит свет.

Состояние расписаний пересчитывается на границах интервалов, при запуске движка правил и периодически — с интервалом из параметра autoUpdate. Благодаря этому контроллер, проработавший с перерывами, приведёт свет в соответствие с расписанием при первой же проверке.

Библиотека

Сохраните файл как /etc/wb-rules/lib_schedules.js. Он предоставляет три функции: registerSchedule описывает расписание, onScheduleChange подписывает на него правило, initSchedules запускает механизм. Все три вызываются из файла с вашими правилами.

// lib_schedules.js
//
// Границы интервалов задаются по локальному времени контроллера.

global.__proto__.Schedules = {};

(function(Schedules) {

  function todayAt(now, hours, minutes) {
    var date = new Date(now);
    date.setHours(hours);
    date.setMinutes(minutes);
    date.setSeconds(0);
    date.setMilliseconds(0);
    return date;
  }

  function checkScheduleInterval(now, start_time, end_time) {
    var start_date = todayAt(now, start_time[0], start_time[1]);
    var end_date = todayAt(now, end_time[0], end_time[1]);

    if (end_date >= start_date) {
      if ((now >= start_date) && (now < end_date)) {
        return true;
      }
    } else {
      if (now < end_date) {
        return true;
      }
      if (now >= start_date) {
        return true;
      }
    }
    return false;
  }

  function checkSchedule(schedule, now) {
    if (now == undefined) {
      now = new Date();
    }
    for (var i = 0; i < schedule.intervals.length; ++i) {
      var item = schedule.intervals[i];
      if (checkScheduleInterval(now, item[0], item[1])) {
        return true;
      }
    }
    return false;
  }

  function applyScheduleStatus(schedule) {
    dev["_schedules"][schedule.name] = checkSchedule(schedule);
  }

  function addScheduleDevCronTasks(schedule) {
    for (var i = 0; i < schedule.intervals.length; ++i) {
      var interval = schedule.intervals[i];
      for (var j = 0; j < 2; ++j) {
        var hours = interval[j][0];
        var minutes = interval[j][1];
        defineRule("_schedule_dev_{}_{}_{}".format(schedule.name, i, j), {
          when: cron("0 " + minutes + " " + hours + " * * *"),
          then: function () {
            applyScheduleStatus(schedule);
          }
        });
      }
    }
  }

  function addScheduleAutoUpdCronTask(schedule) {
    defineRule("_schedule_auto_upd_{}".format(schedule.name), {
      when: cron("@every " + schedule.autoUpdate),
      then: function() {
        applyScheduleStatus(schedule);
      }
    });
  }

  var _schedules = {};
  var _handlers = {};

  // Вызывает обработчик только при реальной смене значения.
  // Защищает и от повторного вызова стартовым проходом, и от лишних
  // команд в исполнительные устройства.
  function invokeHandler(entry, value) {
    if (entry.last === value) {
      return;
    }
    entry.last = value;
    entry.handler(value);
  }

  // autoUpdate обязателен: точные границы ставятся cron-правилами, но если
  // движок в этот момент не работал, переход будет пропущен — фоновая
  // проверка его подхватит.
  // Исключение прерывает весь файл правил: не создастся ни одно расписание.
  Schedules.registerSchedule = function(schedule) {
    if (!schedule.autoUpdate) {
      throw new Error("Schedules.registerSchedule: 'autoUpdate' is required for schedule '" + schedule.name + "'");
    }
    _schedules[schedule.name] = schedule;
  };

  // Подписка на расписание. Обработчик вызывается при каждой смене состояния
  // и один раз при старте — с уже правильным значением.
  // Необязательный id нужен, если на одно расписание вешается несколько
  // потребителей.
  Schedules.onScheduleChange = function(name, handler, id) {
    var key = name + "/" + (id || "default");
    var isNew = !_handlers.hasOwnProperty(key);
    _handlers[key] = {name: name, handler: handler, last: undefined};
    if (isNew) {
      defineRule("_schedule_consumer_" + key.replace("/", "_"), {
        whenChanged: "_schedules/" + name,
        then: function (newValue) {
          invokeHandler(_handlers[key], newValue);
        }
      });
    }
  };

  // Вызывается один раз, после того как описаны все расписания и подписки.
  // Повторный вызов из другого файла правил завершится ошибкой
  // "Device with given ID already exists".
  Schedules.initSchedules = function() {
    var params = {
      title: "Schedule Status",
      cells: {}
    };

    for (var schedule_name in _schedules) {
      if (_schedules.hasOwnProperty(schedule_name)) {
        params.cells[schedule_name] = {type: "switch", value: false, readonly: true};
      }
    }

    defineVirtualDevice("_schedules", params);

    for (var schedule_name in _schedules) {
      if (_schedules.hasOwnProperty(schedule_name)) {
        var schedule = _schedules[schedule_name];
        addScheduleDevCronTasks(schedule);
        addScheduleAutoUpdCronTask(schedule);
      }
    }

    setTimeout(function() {
      // сначала расставляем реальные состояния расписаний
      for (var name in _schedules) {
        if (_schedules.hasOwnProperty(name)) {
          applyScheduleStatus(_schedules[name]);
        }
      }
      // затем сообщаем их подписчикам — тем, кого не задело изменение значения
      for (var key in _handlers) {
        if (_handlers.hasOwnProperty(key)) {
          invokeHandler(_handlers[key], dev["_schedules"][_handlers[key].name]);
        }
      }
    }, 1);
  };

})(Schedules);

Правила магазина

Вывеска, освещение торгового зала и холодильных горок, вентиляция подсобки и питание брендовых холодильников управляются расписаниями. Отопление и кондиционеры дополнительно учитывают показания датчиков температуры, поэтому написаны обычными правилами с условием when — они читают состояние расписания через dev._schedules.имя.

Вызов initSchedules идёт последним, после всех registerSchedule и onScheduleChange.

// пример правил, использующих Schedules
//
// ВНИМАНИЕ: границы интервалов задаются по локальному времени контроллера.
// Значения ниже рассчитаны для Москвы (UTC+3).
// Проверьте таймзону контроллера: timedatectl show -p Timezone --value

(function() {

  defineAlias("countersTemperature", "wb-msw2_30/Temperature");
  defineAlias("vegetablesTemperature", "wb-msw2_31/Temperature");

  defineAlias("heater1EnableInverted", "wb-mrm2-old_70/Relay 1");

  // Вывеска: два окна — утреннее до рассвета и вечернее до закрытия.
  // Днём не горит, ночью после 23:30 тоже.
  Schedules.registerSchedule({
    "name" : "signboard",
    "autoUpdate" : "1m",
    "intervals" : [
      [ [15, 30], [23, 30] ],   // вечернее окно
      [ [6, 30], [8, 20] ],     // утреннее окно, гаснет вскоре после открытия
    ]
  });

  // Вентиляция подсобки: включается за 15 минут до открытия
  // и работает больше часа после закрытия — помещение нужно проветрить.
  Schedules.registerSchedule({
    "name" : "ext_working_hours_15m",
    "autoUpdate" : "1m",
    "intervals" : [
      [ [7, 45], [23, 15] ],
    ]
  });

  // Часы работы магазина — опорное расписание, от него отсчитываются остальные.
  Schedules.registerSchedule({
    "name" : "working_hours",
    "autoUpdate" : "1m",
    "intervals" : [
      [ [8, 0], [22, 0] ],      // открытие 8:00, закрытие 22:00
    ]
  });

  // Часы работы с запасом 15 минут с обеих сторон:
  // персонал приходит раньше и уходит позже покупателей.
  Schedules.registerSchedule({
    "name" : "working_hours_15m",
    "autoUpdate" : "1m",
    "intervals" : [
      [ [7, 45], [22, 15] ],
    ]
  });

  // Освещение торгового зала: раньше всех включается, позже всех гаснет.
  Schedules.registerSchedule({
    "name" : "frontshop_lighting",
    "autoUpdate" : "1m",
    "intervals" : [
      [ [7, 20], [23, 45] ],
    ]
  });

  // Дневной режим отопления. Вне этого окна действуют ночные уставки
  // (см. правила heatersNight* ниже) — ночью помещение греется слабее.
  Schedules.registerSchedule({
    "name" : "heaters_schedule",
    "autoUpdate" : "1m",
    "intervals" : [
       [ [7, 0], [20, 0] ],
    ]
  });

  // Вывеска и фасадное освещение
  Schedules.onScheduleChange("signboard", function (on) {
    dev["wb-mr6c_80/K2"] = !on;
    dev["wb-mr6c_80/K1"] = !on;
    dev["wb-mr6c_80/K3"] = !on;
  });

  // Освещение торгового зала
  Schedules.onScheduleChange("frontshop_lighting", function (on) {
    dev["wb-gpio/EXT1_R3A1"] = !on; // инвертированный контактор
  });

  // Вентиляция подсобного помещения
  Schedules.onScheduleChange("ext_working_hours_15m", function (on) {
    dev["wb-mr6c_81/K1"] = !on;
    dev["wb-mr6c_81/K5"] = !on;
  });

  // Освещение холодильных горок
  Schedules.onScheduleChange("working_hours_15m", function (on) {
    dev["wb-mrm2-old_60/Relay 1"] = !on;
    dev["wb-mrm2-old_61/Relay 1"] = !on;
    dev["wb-mrm2-old_62/Relay 1"] = !on;
    dev["wb-mrm2-old_63/Relay 1"] = !on;
    dev["wb-mrm2-old_64/Relay 1"] = !on;
    dev["wb-mrm2-old_65/Relay 1"] = !on;
    dev["wb-mrm2-old_66/Relay 1"] = !on;
    dev["wb-mrm2-old_67/Relay 1"] = !on;
  });

  // Брендовые холодильники (пиво, лимонады)
  Schedules.onScheduleChange("working_hours", function (on) {
    dev["wb-gpio/EXT1_R3A5"] = !on;
  });

  Schedules.initSchedules();

  // ========= Котлы и приточная вентиляция ТЗ  ===========

  defineRule("heatersDayOff", {
    when: function() {
      return (dev._schedules.heaters_schedule) && (vegetablesTemperature > 17.0);
    },
    then: function (newValue, devName, cellName) {
      heater1EnableInverted = !false;
    }
  });

  defineRule("heatersDayOn", {
    when: function() {
      return (dev._schedules.heaters_schedule) && (vegetablesTemperature < 16.7);
    },
    then: function (newValue, devName, cellName) {
      heater1EnableInverted = !true;
    }
  });

  defineRule("heatersNightOff", {
    when: function() {
      return (!dev._schedules.heaters_schedule) && (vegetablesTemperature > 11.6);
    },
    then: function (newValue, devName, cellName) {
      heater1EnableInverted = !false;
    }
  });

  defineRule("heatersNightOn", {
    when: function() {
      return (!dev._schedules.heaters_schedule) && (vegetablesTemperature < 11.3);
    },
    then: function (newValue, devName, cellName) {
      heater1EnableInverted = !true;
    }
  });

  defineRule("ventFrontshopAlwaysOff", {
    when: cron("@every 1m"),
    then: function() {
      dev["wb-gpio/EXT1_R3A3"] = !false;
      dev["wb-gpio/EXT1_R3A4"] = !false;
    }
  });

  // ================== Кассовая зона =================

  defineRule("countersACOn", {
    when: function() {
      return (dev._schedules.working_hours_15m) && (countersTemperature < 17.7);
    },
    then: function (newValue, devName, cellName) {
      dev["wb-mir_75/Play from ROM7"] = true;
    }
  });

  defineRule("countersACOff", {
    when: function() {
      return (!dev._schedules.working_hours_15m) || (countersTemperature > 18.0);
    },
    then: function (newValue, devName, cellName) {
      dev["wb-mir_75/Play from ROM2"] = true;
    }
  });

  // =============== Овощная зона ==============

  defineRule("acVegOn", {
    when: function() {
      return vegetablesTemperature >= 18.5
    },
    then: function (newValue, devName, cellName) {
      dev["wb-mir_76/Play from ROM3"] = true;
    }
  });

  defineRule("acVegOff", {
    when: function() {
      return vegetablesTemperature < 17.8
    },
    then: function (newValue, devName, cellName) {
      dev["wb-mir_76/Play from ROM2"] = true;
    }
  });
})()

Работа с JSON

Движок wb-rules поддерживает стандартные функции языка JavaScript для работы с JSON:

  • JSON.stringify() — преобразует объект в JSON-строку;
  • JSON.parse() — преобразует JSON-строку в объект.

Более подробную информацию о функциях можно найти в учебнике JavaScript.

Эти функции требуются, когда вы получаете данные из другого сервиса в JSON-формате.

В приведенном примере создается виртуальное устройство с одной кнопкой и числовым параметром, который хранится в виде JSON-строки. При нажатии на кнопку к значению параметра прибавляется 1.

defineVirtualDevice("JSON_test", {
    title: "JSON_device",
    cells: {
        Button: {
            type: "pushbutton"
        },
        Json: {
            type: "text",
            readonly: false,
            value: JSON.stringify({param: 0})
        }
    }
});

defineRule("change_value", {
  whenChanged: "JSON_test/Button",
  then: function () {
    var parameter;
    try {
      parameter = JSON.parse(dev["JSON_test/Json"]);
    } catch (e) {
      log.error("invalid JSON in JSON_test/Json, resetting: {}", e);
      parameter = { param: 0 };
    }
    parameter.param++;
    dev["JSON_test/Json"] = JSON.stringify(parameter);
  }
});

Работа с последовательным портом через RPC

Работа с последовательным портом через RPC

Если устройство на шине работает по протоколу, который не поддерживается драйвером wb-mqtt-serial, можно формировать запросы вручную и отправлять их драйверу через RPC-MQTT.

Раньше для этой задачи писали правило, работающее напрямую с файлом устройства в обход драйвера. Это устарело: RPC решает ту же задачу без конфликта с драйвером. Предварительно описывать порт в конфиге не требуется (драйвер открывает порт по параметрам из самого запроса), настройки (скорость, чётность, стоп-биты, raw-режим) выполняются драйвером автоматически, и запрос встраивается в цикл опроса устройств с приоритетом сразу после текущего цикла — то есть не обрывает штатный опрос других устройств на той же шине.

RPC-MQTT создает MQTT-топик для отправки запросов и топик для чтения ответов от драйвера. Поэтому для его использования достаточно отправить запрос в нужный топик функцией publish() и прочитать ответ функцией trackMqtt(). Как узнать адреса топиков, описано в документации.

RPC передаёт байты как есть, поэтому работает с любым протоколом на шине. В примере написан скрипт на wb-rules для периодического опроса состояния входов модуля ADAM-4053 на шине RS-485 — модуль работает по протоколу ASCII DCON.

var pathRPC = "/rpc/v1/wb-mqtt-serial/port/Load/";
var port = "/dev/ttyRS485-1";
var speed = 9600;
var parity = "N";
var stopbit = 2;
// Команда DCON "Digital Data In": $ + адрес модуля + 6 + CR.
// Ответ ADAM-4053: !(dataInput)(dataInput)00(cr) — 8 символов.
var message = "$016\r";

var clientID = "testRPC";

// requestID:    произвольный номер, возвращается в поле id ответа —
//               по нему сопоставляют запрос и reply.
// messageType:  "STR" — msg уходит в порт как есть, байт за байтом;
//               "HEX" — msg разбирается как шестнадцатеричная строка.
// responseSize: сколько байт ответа ждать. Больше фактического — вернётся
//               всё принятое, меньше — ответ обрежется, 0 — ждать не будет.
function requestRPC(port, speed, parity, stopbit, requestID, messageType, message, responseSize){
  var strJson = JSON.stringify({params: {response_size: responseSize, format: messageType, path: port, baud_rate: speed, parity: parity, "data_bits" : 8, "stop_bits" : stopbit, "msg": message}, "id" : requestID});
  log.info("strJson = {}", strJson);
  publish(pathRPC+clientID, strJson, 2, false)
};

// Разбор ответа ADAM-4053 на команду $AA6.
// Формат: !(dataInput)(dataInput)00(cr) — первая шестнадцатеричная пара это
// каналы 8-15, вторая каналы 0-7, младший бит пары — младший канал.
trackMqtt(pathRPC+clientID+"/reply", function(message){
  var reply;
  try {
    reply = JSON.parse(message.value);
  } catch (e) {
    log.error("RPC: ответ не разобрался как JSON: {}", e);
    return;
  }

  if (reply.error) {
    log.error("RPC: ошибка обмена: {}", reply.error.data || reply.error.message);
    return;
  }

  var response = reply.result.response;

  if (response.charAt(0) === "?") {
    log.error("ADAM: модуль отверг команду: {}", response);
    return;
  }
  if (response.charAt(0) !== "!" || response.length < 7) {
    log.error("ADAM: неожиданный формат ответа: {}", JSON.stringify(response));
    return;
  }

  var high = parseInt(response.substr(1, 2), 16);   // каналы 8-15
  var low  = parseInt(response.substr(3, 2), 16);   // каналы 0-7
  if (isNaN(high) || isNaN(low)) {
    log.error("ADAM: данные в ответе не шестнадцатеричные: {}", JSON.stringify(response));
    return;
  }

  var states = [];
  for (var ch = 0; ch < 16; ch++) {
    var byteValue = (ch < 8) ? low : high;
    states.push("DI" + ch + "=" + ((byteValue >> (ch % 8)) & 1));
  }
  log.info("ADAM-4053: {}", states.join(" "));
});

// Штатный периодический опрос модуля
setInterval(function () {
  requestRPC(port, speed, parity, stopbit, 1, "STR", message, 8);
}, 2000);

Получение SMS

В примере с периодом в 1 секунду выводится в лог вся информация о последнем сообщении. Полученные SMS будут в capturedOutput. Пример из темы на портале.

var period = 1000;
setInterval(function() {
    runShellCommand("mmcli --modem wbc --messaging-list-sms --output-keyvalue | grep length | cut -f2 -d':'", {
        captureOutput: true,
        exitCallback: function(exitCode, capturedOutput) {
            if (exitCode === 0) {
                runShellCommand("mmcli --modem wbc --sms " + (parseInt(capturedOutput) - 1).toString(), {
                    captureOutput: true,
                    exitCallback: function(exitCode, capturedOutput) {
                        if (exitCode === 0) {
                            log(capturedOutput);
                            return;
                        }
                    }
                });
                return;
            }
        }
    });
}, period);

Полезные ссылки