HomeKit и ioBroker Давайте дружить домами +13



Без сомнения, Apple iOS остается одной из самых популярных мобильных ОС, а значит, современные системы автоматизации должны уметь интегрироваться в эту экосистему и предоставлять возможность взаимодействия. Именно для этого предназначен фреймворк Homekit, который позволяет работать с "умными" устройствами с экрана iPhone/iPad/iWatch, а с недавнего времени и Mac (macOS Mojave).


Большинство систем автоматизации (не люблю маркетинговое название "умный дом") уже давно содержат модули интеграции с Homekit, но даже подготовленному пользователю не всегда просто разобраться в том, как сделать его устройство доступным в приложении "Дом" (или Eve).


Сегодня я расскажу, как проделать эти манипуляции в системе ioBroker (это открытая и бесплатная система автоматизации). Но чтобы не приводить тупо всё множество примеров устройств, я хочу объяснить некоторые принципы и показать подходы, зная которые, вы легко сможете реализовать остальные примеры.


"Знание некоторых принципов легко возмещает незнание некоторых фактов"
Клод Адриан Гельвеций


ioBroker. Драйверы, устройства и состояния


В первую очередь хочу объяснить что такое устройство в системе ioBroker, и как оно представлено.


Напомню, что система ioBroker модульная, и модули расширений называются драйверами (или адаптерами). Драйвер представляет собой модуль интеграции с каким-то устройством или группой устройств, объединенных общим функционалом, протоколом или производителем, и поэтому может "затащить" в систему ioBroker от одного до нескольких устройств. Еще одной особенностью является возможность создавать несколько экземпляров одного и того же драйвера, отличающихся какими-либо настройками.


Но каждое устройство уникально и неповторимо, обладает разными характеристиками и возможностями. Исходя из этого, в ioBroker ориентируются в первую очередь не на само устройство, а на его характеристики, которые представляются состояниями. Состояние — это внутренний объект ioBroker, который принимает и хранит какое-либо значение. Синонимами состояния можно считать: признаки, атрибуты, характеристики, свойства, события. Примеры состояний: "температура", "уровень яркости", "уровень батарейки", "признак включения", "признак ошибки", "признак нажатия", "признак двойного нажатия" и т.п. Таким образом, каждое устройство представляется множеством разных состояний.


Структура объектов


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


Все состояния устройств объединены в единое дерево (реестр) состояний. Они сгруппированы сперва по устройствам (в некоторых случаях еще применяется группировка по каналам), а затем по экземплярам драйвера.


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


А еще есть всевозможные online-сервисы, которые могут предоставлять полезную информацию и/или давать возможность управления другим оборудованием (автосигнализация, например). Результат взаимодействия с этими сервисами тоже представляется в виде набора состояний.


Дерево состояний


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


Homekit. Аксессуары, сервисы и характеристики


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


Категории устройств Homekit


Аксессуары (Accessories) — это эквивалент физического устройства. Аксессуар имеет категорию для отнесения его к определенной группе.


Сервисы (Services) — это эквивалент функционала, которым обладает аксессуар. У одного аксессуара может быть несколько сервисов.


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


Именно сервис определяет отображение, поведение устройства и набор характеристик.


Характеристики (Characteristic) — это эквивалент атрибутов/свойств, которыми характеризуется сервис. Именно характеристики определяют, включено ли устройство, уровень яркости лампы, или сколько раз нажата кнопка. У одного сервиса может быть множество характеристик.


Структура объектов


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


Итого, устройство в HomeKit представляется аксессуаром с набором сервисов и характеристик.


Yahka. Стыкуем концепции


Для работы с Homekit в ioBroker используется драйвер Yahka (перед установкой необходимо установить дополнительные модули) — надстройка над известной библиотекой https://github.com/KhaosT/HAP-NodeJS, на которой также построен популярный проект HomeBridge. Эта библиотека предназначена для создания виртуального шлюза/моста, который предоставляет в HomeKit набор виртуальных устройств. Сконфигурировав соответствующим образом виртуальные устройства и сервисы, задав значения характеристик — мы получим готовое устройство в Homekit и приложении Дом, а также можем попросить Сири управлять им.


Драйвер Yahka как раз и предназначен для настройки аксессуаров, добавления в них сервисов и указания соответствия характеристик (HomeKit) и состояний (ioBroker).


Но сперва, после установки, нужно настроить шлюз и завести его в приложение Дом. После настройки все добавляемые в шлюзе устройства будут автоматически добавляться в Дом. Для этого нужно указать "Имя устройства" (желательно указать только латинские буквы) и запомнить пин-код (или задать свой).


Настройка шлюза


Идем в приложение Дом и добавляем новый аксессуар.



Теперь займемся устройствами. Всё бы было хорошо, если бы набор состояний для устройства в ioBroker однозначно соответствовал набору сервисов и характеристик в HomeKit. А было бы еще лучше, если бы значения в состояниях подходили для значений характеристик. Но зачастую это не так, и приходится придумывать необычные способы стыковки. О некоторых из них я расскажу ниже, а все остальные варианты вам придется реализовать самим, "по образу и подобию".


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


Датчик температуры


Это самый простой пример — вам достаточно иметь одно состояние, содержащее числовое значение температуры. Оно может быть получено откуда угодно: с датчиков или из интернет-сервисов (погода).

Необходимо добавить устройство категории Sensor, а к устройству добавить сервис TemperatureSensor, и дать имя этому сервису. В этом сервисе 5 характеристик, из них самый важный для нас — CurrentTemperature (Текущая температура).


Аксессуар градусник


Сервис TemperatureSensor


Достаточно указать в характеристике CurrentTemperature имя состояния, соответствующего температуре.


Добавим сюда же еще сервис влажности HumiditySensor, при этом в Homekit будет создан отдельный значок аксессуара.


Сервис HumiditySensor


Сохраняем, и всё готово. Теперь можно обращаться к Сири, и спрашивать у нее про температуру и влажность.



Разговор с Сири



Батарейка


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


Сервис BatteryService


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


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


Теперь останется только корректно выставлять значение true в этом состоянии. Для этого будем применять скрипт — он будет выставлять значение true при достижении заряда батарейки 30 процентов.


createState("сдохлаБатарейка");

on({id: "zigbee.0.00158d0001f41725.battery", change: "ne"}, function (obj) {
    var value = obj.state.val;
    setState("javascript.0.сдохлаБатарейка", (value <= 30));
});

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



Отображаться этот признак будет на изображениях аксессуаров



и в подробных данных об устройстве


Лампы


Лампочки бывают разные — яркие, тёплые, красные. Можно выделить 4 случая:


  • Простые — управляются состоянием "вкл" и "выкл"
  • Диммируемые — управляются также уровнем яркости
  • С температурой — возможно управление температурой свечения
  • Цветные — возможно управлять цветом свечения

Для каждого их этих случаев есть соответствующая характеристика в сервисе Lightbulb:


  • On — включение / выключение
  • Brightness — уровень яркости
  • Hue — оттенок
  • Saturation — насыщенность
  • ColorTemperature — цветовая температура

В простом случае в характеристике "On" мы указываем состояние, отвечающее за включение и выключение.



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



Кроме назначения корректных состояний, важно соблюдать при этом интервал допустимых значений!


Пример: в некоторых случаях состояние, отвечающее за яркость лампы, может принимать значения от 0 до 255, однако в Homekit эти значения ограничены интервалом от 0 до 100. Для этого случая можно применить функции преобразования драйвера Yahka. Функция "level255" как раз преобразует интервал значений 0..255 в интервал 0..100 (и обратно).


Следующие трудности могут возникнуть, если ваша лампа цветная, но для цвета используется RGB значение. Это могут быть как три разных состояния, так и одно число (или строка). В этом случае понадобится сделать преобразование из одного цветового пространства RGB в другое пространство XYB (это пространство используется HomeKit), или в плоскость XY.


Для этого необходимо сделать 2 новых состояния (Hue и Saturation), в которые будем преобразовывать значения из RGB-состояния и обратно.


Итоговый скрипт для цвета получается такой
// создадим состояния для Оттенка и Насыщенности
createState("ночникHue");
createState("ночникSat");

// при изменении оттенка будем менять RGB-цвет
on({id: "ночникHue", ack: false, change: 'any'}, function (obj) {
   var hue = parseInt(obj.state.val);
   var sat = parseInt(getState('ночникSat').val);
   var res = hsvToRgb(hue, sat, 100);
   setRGB(parseInt(res[0]), parseInt(res[1]), parseInt(res[2]));
});

// заполним состояние с RGB-цветом
function setRGB(r, g, b){
   var val = ('00'+r.toString(16)).slice(-2)+('00'+g.toString(16)).slice(-2)+('00'+b.toString(16)).slice(-2);
   // RGB-состояние светильника
   setState('zigbee.0.00124b0014d016ab.color', val, false);
}

// функция преобразования HSV цвета в RGB
function hsvToRgb(h, s, v) {
   var r, g, b;
   var i;
   var f, p, q, t;
   
   h = Math.max(0, Math.min(360, h));
   s = Math.max(0, Math.min(100, s));
   v = Math.max(0, Math.min(100, v));

   s /= 100;
   v /= 100;
   
   if(s == 0) {
       r = g = b = v;
       return [
           Math.round(r * 255),
           Math.round(g * 255),
           Math.round(b * 255)
       ];
   }
   
   h /= 60;
   i = Math.floor(h);
   f = h - i;
   p = v * (1 - s);
   q = v * (1 - s * f);
   t = v * (1 - s * (1 - f));
   
   switch(i) {
       case 0:
           r = v;
           g = t;
           b = p;
           break;
   
       case 1:
           r = q;
           g = v;
           b = p;
           break;
   
       case 2:
           r = p;
           g = v;
           b = t;
           break;
   
       case 3:
           r = p;
           g = q;
           b = v;
           break;
   
       case 4:
           r = t;
           g = p;
           b = v;
           break;
   
       default: // case 5:
           r = v;
           g = p;
           b = q;
   }
   
   return [
       Math.round(r * 255),
       Math.round(g * 255),
       Math.round(b * 255)
   ];
}

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


Сервис Lightbulb



Глубже в лампе



Термостат


Термостат — устройство для поддержания установленной температуры (сервис Thermostat). Соответственно, главная характеристика термостата — желаемая температура (TargetTemperature). Помимо установочной температуры, может быть указана текущая температура (CurrentTemperature), которая носит информационный характер (т.к. устройство ее только считывает с датчиков).


Из приложения Дом в термостате выставляется целевая температура и отслеживается текущая. В моём термостате (Zont) как раз были эти два состояния — они были доступны через сервисное облачное api.


Для красоты отображения устройства в HomeKit пришлось добавить пару констант: текущее состояние нагрева — активно (1), целевое состояние нагрева — автоматическое (3).


Сервис Thermostat



Выбор температуры


Ворота


С гаражными воротами (сервис GarageDoorOpener) всё хитрее, чем с термостатом.


Из доступных характеристик у ворот есть целевое состояние (TargetDoorState), которое обозначает наше желание, чтобы ворота были "открыты" или "закрыты". Но так же нужно корректно отображать текущее состояние ворот (CurrentDoorState): они открыты или закрыты, или может быть они открываются, или закрываются?


В моём случае, ворота были заведены через mqtt в ioBroker несколькими информационными состояниями:


  • признак открытости ворот (ОВ)
  • признак движения ворот (ДВ)

Состояния управления гаражными воротами


Благодаря этим состояниям можно вычислить текущий статус ворот:


  • если нет ОВ и нет ДВ, то ворота закрыты
  • если нет ОВ и есть ДВ, то ворота открываются
  • если есть ОВ и нет ДВ, то ворота открыты
  • если есть ОВ и есть ДВ, то ворота закрываются

Для отправки сигнала на открытие и закрытие ворот у меня присутствуют два отдельных состояния (можно было бы обойтись и одним состоянием, но вот у меня два), которые отправляют сообщение по mqtt на контроллер управления воротами:


  • сигнал открытия
  • сигнал закрытия

Для отправки сигнала нужно имитировать "нажатие" кнопки: выставить значение true, и через некоторое время сбросить его на false. В связи с этим, для интеграции с HomeKit пришлось создать еще одно состояние — "целевое состояние ворот", при изменении которого и будет отправляться соответствующий сигнал.


Признак открытости ворот можно заменить на целевое состояние (т.е. то, к чему будут стремиться ворота):


  • если ЦС "закрыто" и нет ДВ, то ворота закрыты
  • если ЦС "закрыто" и есть ДВ, то ворота открываются
  • если ЦС "открыто" и нет ДВ, то ворота открыты
  • если ЦС "открыто" и есть ДВ, то ворота закрываются

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


Скрипт изменения состояний для гаражных ворот
createState("gate_0.current"); // текущее состояние
createState("gate_0.target"); // целевое состояние

// сбрасываем состояние в 0, по прошествии 300мс
on({id: "mqtt.0.gate.gpio.13", ack: false, val: 1}, function (obj) {
   setStateDelayed("mqtt.0.gate.gpio.13", 0,  300);
});

on({id: "mqtt.0.gate.gpio.12", ack: false, val: 1}, function (obj) {
   setStateDelayed("mqtt.0.gate.gpio.12", 0,  300);
});

// если изменилось состояние открытости
on({id: "mqtt.0.gate.is_open", ack: false, val: 1}, function (obj) {
   // "открылся"
   setState("javascript.0.gate_0.current", 0, true);
});

on({id: "mqtt.0.gate.is_open", ack: false, val: 0}, function (obj) {
   // "закрылся"
   setState("javascript.0.gate_0.current", 1, true);
});

// если установили цель - открыть, значит посылаем сигнал на открытие
on({id: "javascript.0.gate_0.target", ack: false, val: 0}, function (obj) {
   setState("mqtt.0.gate.gpio.12", 1);
});

// если установили цель - закрыть, значит посылаем сигнал на закрытие
on({id: "javascript.0.gate_0.target", ack: false, val: 1}, function (obj) {
   setState("mqtt.0.gate.gpio.13", 1);
});

on({id: "mqtt.0.gate.in_progress", ack: true, change: 'any'}, function (obj) {
   // если выставился режим "в процессе", то значит процесс пошел
   if (obj.state.val === 1) {
       // если целевой режим "открытие", то выставим состояние
       const target = getState("javascript.0.gate_0.target");
       if (target.val === 0) {
           // "открывается"
           setState("javascript.0.gate_0.current", 2, true);
       } else {
           // "закрывается"
           setState("javascript.0.gate_0.current", 3, true);
       }
   }
   // если сбросился режим "в процессе", то значит процесс закончен
   if (obj.state.val === 0) {
       // если целевой режим "открытие", то выставим состояние
       const target = getState("javascript.0.gate_0.target");
       if (target.val === 0) {
           // "открылся"
           setState("javascript.0.gate_0.current", 0, true);
       } else {
           // "закрылся"
           setState("javascript.0.gate_0.current", 1, true);
       }
   }
});

После запуска скрипта можно настраивать характеристики сервиса гаражных ворот:


Сервис GarageDoorOpener



Камера


Для добавления камеры в HomeKit будет использован "классический" метод. Организуется трансляция изображения с камеры посредством модуля ffmpeg. Через него будет кодироваться входной поток, шифроваться и отдаваться в Homekit.


В первую очередь нужно установить ffmpeg на сервере, где расположен ioBroker.


Для каждой платформы он ставится по-разному, можно собрать его из исходников, либо же поискать готовую сборку, например, тут: https://www.johnvansickle.com/ffmpeg/  Обязательно наличие кодировщика libx264. Проверить наличие кодировщика после установки ffmpeg можно командой:


ffmpeg -codecs | grep 264

В результатах должна быть строка вида:


DEV.LS h264                 H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (decoders: h264 h264_v4l2m2m h264_vdpau ) (encoders: libx264 libx264rgb h264_v4l2m2m )

Для Raspberry Pi 3 можно воспользоваться готовой сборкой, в которой есть кодек с поддержкой аппаратного кодирования GPU (h264_omx, меньше потребляет ресурсов). Ставить так:


wget https://github.com/legotheboss/YouTube-files/raw/master/ffmpeg_3.1.4-1_armhf.deb

sudo dpkg -i ffmpeg_3.1.4-1_armhf.deb

В этой сборке присутствуют оба кодека: libx264 и h264_omx


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


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


Важно: комбинации параметров очень важны для корректного отображения камеры в Homekit и зависят от камеры и потока. Это также влияет на производительность системы, т.к. запускаемый процесс ffmpeg отнимает много ресурсов.


Добавление камеры


Настройка потока


Камеры добавляются как отдельные устройства вне шлюза, и их необходимо добавлять также, как шлюз


Миниатюра камеры


Трансляция с камеры


Бонус


В качестве бонуса, расскажу о необычном применении трансляции камеры.


Используя тоже самый ffmpeg, вместо камеры можно попробовать транслировать изображение, любую картинку. Эти картинки можно также объединять с потоком видео. На картинку можно вывести текст, графики и прочую информацию.


Трансляция с наложением текста


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


В качестве примера я вывел график изменения некоторых показателей в виде картинки (файл на диске). Этот график обновляется один раз в минуту и перезаписывает картинку в файле.


(функции createImage1, createImage2, формирование графика и наложение текста на картинку выходят за рамки данной статьи, но намёк дам).

Расскажу, как можно получить график в виде картинки.


В ioBroker есть стандартный способ построения графиков — Flot-драйвер. Этот драйвер работает в паре с Web-драйвером и отображает результат в браузере. Но для того чтобы получить созданный график на сервере (в скрипте) в виде картинки нужен дополнительный драйвер PhantomJS, который делает “скриншот” страницы (на которой у нас нарисуется Flot-график).


Но я расскажу об альтернативном способе построения графиков на сервере в скрипте.


Есть такая библиотека Chart.js http://www.chartjs.org/ которая позволяет рисовать приятные на вид графики в браузере (примеры http://www.chartjs.org/samples/latest/).


Для рисования она использует “холст” (канва, canvas) браузера. Поэтому, чтобы рисовать с помощью этой библиотеки на сервере, нужно использовать “серверный” вариант “холста” и DOM-объекты. Это и делает пакет chartjs-node (https://github.com/vmpowerio/chartjs-node).


Основной зависимостью для этого пакета является пакет canvas (https://github.com/Automattic/node-canvas), который следует установить глобально (или в папку iobroker). Важно установить все зависимости для той платформы, куда вы ставите https://github.com/Automattic/node-canvas#compiling .


После этого можно в настройках драйвера javascript добавить модули chart.js, chartjs-node. Они должны установиться корректно, без ошибок. Иначе — разбираться с ошибками и решать их.


А дальше, можно написать скрипт.


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


Внимание! В скрипте есть сложные для новичков конструкции — Promise. Это удобные способ не писать функции с callback, а делать цепочки шагов. Так, например, это удобно делать для получения данных из истории состояний.


'use strict';
const ChartjsNode = require('chartjs-node');

/**
 * функция sendTo как Promise, чтобы удобно было строить цепочки
 */

function sendToPromise(adapter, cmd, params) {
    return new Promise((resolve, reject) => {
        sendTo(adapter, cmd, params, (result) => {
            resolve(result);
        });
    });
}

// константы для цветов
const chartColors = {
    black: 'rgb(0, 0, 0)',
    red: 'rgb(255, 99, 132)',
    orange: 'rgb(255, 159, 64)',
    yellow: 'rgb(255, 205, 86)',
    green: 'rgb(75, 192, 192)',
    blue: 'rgb(54, 162, 235)',
    purple: 'rgb(153, 102, 255)',
    grey: 'rgb(201, 203, 207)'
};

/**
 * функция рисования и сохранения картинки в файл
 *  параметры:
 *  @param config - конфигурация графика для рисования
 *  @param filename - имя файла для сохранения
 *  результат:
 *  @param Promise - успешное сохранение файла
 */

function doDraw(config, filename) {
    // создадим полотно с размером 640x480 пикселей
    var chartNode = new ChartjsNode(640, 480);
    return chartNode.drawChart(config)
        .then(() => {
            // запишем результат в файл
            return chartNode.writeImageToFile('image/png', filename);
        });
}

/**
 * функция подготовки параметров для ChartJS.
 *  результат:
 *  @param Promise - успешная подготовка параметров
 */

function prepareDraw0(){
    // переменная, куда сохраним данные
    var пример;
    // создадим Promise сборки данных и конфигурации
    return new Promise((resolve, reject)=>{resolve()})
        // здесь могут быть много шагов сбора данных, прежде чем перейти к графику
        .then(()=>{
            // произвольные данные, похожие на те, что хранятся в истории
            пример = [
                {"val":3,"ack":1,"ts":1539063874301},
                {"val":5,"ack":1,"ts":1539063884299},
                {"val":5.3,"ack":1,"ts":1539063894299},
                {"val":3.39,"ack":1,"ts":1539063904301},
                {"val":5.6,"ack":1,"ts":1539063914300},
                {"val":-1.3,"ack":1,"ts":1539063924300},
                {"val":-6.3,"ack":1,"ts":1539063934302},
                {"val":1.23,"ack":1,"ts":1539063944301},
            ];
        })
        // финальный шаг - создаем конфигурацию графиков
        .then(()=>{
            const chartJsOptions = {
                // тип графика - линейный
                type: 'line',
                data: {
                    // список наборов данных
                    datasets: [
                    {
                        // заголовок ряда 
                        label: 'тест',
                        // цвет
                        backgroundColor: chartColors.black,
                        borderColor: chartColors.black,
                        // размер точек
                        pointRadius: 3,
                        // ширина линии графика
                        borderWidth: 3,
                        // достанем данные из переменной 'пример' и оставим только значение и время изменения
                        data: пример.map((item) => {
                            return {y: item.val, t: new Date(item.ts)}
                        }),
                        // заливка графика - нет
                        fill: false,
                    }
                    ]
                },
                options: {
                    // настройка легенды
                    legend: {
                        labels: {
                            // размер шрифта
                            fontSize: 20,
                        },
                    },
                    // оси координат
                    scales: {
                        // оси X
                        xAxes: [{
                            // тип - временная ось
                            type: 'time',  
                            display: true,
                            // метка оси
                            scaleLabel: {
                                display: true,
                                labelString: 'Время'
                            },
                        }],
                        // оси Y
                        yAxes: [{
                            // тип - линейная
                            type: 'linear',
                            display: true,
                            // метка оси
                            scaleLabel: {
                                display: true,
                                labelString: 'Температура'
                            },
                        }]
                    }
                }
            };
            return chartJsOptions;
        });
}

/**
 * функция подготовки параметров для ChartJS.
 * собирает данные из истории и складывает их в переменные, 
 * чтобы потом включить в ряды.
 * 
 *  параметры:
 *  @param hours - количество часов, за которые получить данные
 *  результат:
 *  @param Promise - успешная подготовка параметров
 */

function prepareDraw1(hours){
    // вычислим интервал времени, за который надо получить данные
    const end = new Date().getTime(),
          start = end - 3600000*(hours || 1); // 1 = час назад

    // зададим переменные, в которые будем складывать результаты запроса
    // исторических данных
    var улица, куры2, куры1, куры2свет, куры2вент;

    // создадим Promise сборки данных и конфигурации
    return new Promise((resolve, reject)=>{resolve()})
        // на этом шаге собираем историю по 'mqtt.0.ESP_Easy.Улица.Temperature'
        .then(() => {
            return sendToPromise('history.0', 'getHistory', {
                    id: 'mqtt.0.ESP_Easy.Улица.Temperature',
                    options: {
                        start: start,
                        end: end,
                        aggregate: 'onchange'
                    }
                }
            ).then((result) => {
                // записываем результат в переменную 'улица'
                улица = result.result;
            });
        })
        // на этом шаге собираем историю по 'sonoff.0.chicken2.DS18B20_Temperature'
        .then(() => {
            return sendToPromise('history.0', 'getHistory', {
                id: 'sonoff.0.chicken2.DS18B20_Temperature',
                options: {
                    start: start,
                    end: end,
                    aggregate: 'onchange'
                }
            }).then((result)=>{
                // записываем результат в переменную 'куры2'
                куры2 = result.result;
            });
        })
        .then(() => {
            return sendToPromise('history.0', 'getHistory', {
                id: 'sonoff.0.sonoff_chicken_vent.DS18B20_Temperature',
                options: {
                    start: start,
                    end: end,
                    aggregate: 'onchange'
                }
            }).then((result)=>{
                куры1 = result.result;
            });
        })
        .then(() => {
            return sendToPromise('history.0', 'getHistory', {
                id: 'sonoff.0.chicken2.POWER1',
                options: {
                    start: start,
                    end: end,
                    aggregate: 'onchange'
                }
            }).then((result)=>{
                куры2свет = result.result;
            });
        })
        .then(() => {
            return sendToPromise('history.0', 'getHistory', {
                id: 'sonoff.0.chicken2.POWER2',
                options: {
                    start: start,
                    end: end,
                    aggregate: 'onchange'
                }
            }).then((result)=>{
                куры2вент = result.result;
            });
        })
        // финальный шаг - создаем конфигурацию графиков
        .then(()=>{
            const chartJsOptions = {
                // тип графика - линейный
                type: 'line',
                data: {
                    // список наборов данных
                    datasets: [
                    {
                        // заголовок ряда с указанием последнего значения из ряда в скобках
                        label: 'Улица ('+улица[улица.length - 1].val+')',
                        // цвет
                        backgroundColor: chartColors.blue,
                        borderColor: chartColors.blue,
                        // размер точек. 0 - нет точки
                        pointRadius: 0,
                        // ширина линии графика
                        borderWidth: 3,
                        // достанем данные из переменной 'улица' и оставим только значение и время изменения
                        data: улица.map((item) => {
                            return {y: item.val, t: new Date(item.ts)}
                        }),
                        // заливка графика - нет
                        fill: false,
                        // идентификатор оси Y
                        yAxisID: 'y-axis-1',
                    },{
                        label: 'Куры 1 ('+куры1[куры1.length - 1].val+')',
                        backgroundColor: chartColors.green,
                        borderColor: chartColors.green,
                        pointRadius: 0,
                        borderWidth: 3,
                        data: куры1.map((item) => {
                            return {y: item.val, t: new Date(item.ts)}
                        }),
                        fill: false,
                        yAxisID: 'y-axis-1',
                    },{
                        label: 'Куры 2 ('+куры2[куры2.length - 1].val+')',
                        backgroundColor: chartColors.red,
                        borderColor: chartColors.red,
                        pointRadius: 0,
                        borderWidth: 3,
                        data: куры2.map((item) => {
                            return {y: item.val, t: new Date(item.ts)}
                        }),
                        fill: false,
                        yAxisID: 'y-axis-1',
                    },{
                        label: 'Куры 2 свет ('+куры2свет[куры2свет.length - 1].val+')',
                        backgroundColor: chartColors.yellow,
                        borderColor: chartColors.yellow,
                        pointRadius: 0,
                        borderWidth: 1,
                        data: куры2свет.map((item) => {
                            return {y: (item.val) ? 1 : 0, t: new Date(item.ts)}
                        }),
                        fill: true,
                        lineTension: 0,
                        steppedLine: true,
                        yAxisID: 'y-axis-2',
                    },{
                        label: 'Куры 2 вент ('+куры2вент[куры2вент.length - 1].val+')',
                        backgroundColor: chartColors.grey,
                        borderColor: chartColors.grey,
                        pointRadius: 0,
                        borderWidth: 1,
                        data: куры2вент.map((item) => {
                            return {y: (item.val) ? -1 : 0, t: new Date(item.ts)}
                        }),
                        fill: true,
                        lineTension: 0,
                        steppedLine: true,
                        yAxisID: 'y-axis-2',
                    }
                    ]
                },
                options: {
                    // настройка легенды
                    legend: {
                        labels: {
                            // размер шрифта
                            fontSize: 20,
                        },
                    },
                    // оси координат
                    scales: {
                        // оси X
                        xAxes: [{
                            // тип - временная ось
                            type: 'time',  
                            display: true,
                            // метка оси
                            scaleLabel: {
                                display: true,
                                labelString: 'Время'
                            },
                            // настройка формата оси (времени)
                            time: {
                                unit: 'minute',
                                displayFormats: {
                                    minute: 'HH:mm'
                                }
                            },
                        }],
                        // оси Y
                        yAxes: [{
                            // тип - линейная
                            type: 'linear',
                            display: true,
                            // метка оси
                            scaleLabel: {
                                display: true,
                                labelString: 'Температура'
                            },
                            // расположение линейки - слева
                            position: 'left',
                            // идентификатор оси
                            id: 'y-axis-1',
                        },{
                            type: 'linear',
                            display: true,
                            scaleLabel: {
                                display: true,
                                labelString: 'Свет и вентиляция'
                            },
                            ticks: {
                                min: -4,
                                max: 2
                            },
                            // расположение линейки - справа
                            position: 'right',
                            id: 'y-axis-2',
                        }]
                    }
                }
            };
            return chartJsOptions;
        });
}

function createImage(filename, callback){
    // filename - имя файла, в который положим картинку с графиком
    // выполним подготовку данных 
    prepareDraw1(2)
        // на след шаге нарисуем
        .then((result) => {
            // рисуем картинку по полученным данным и конфигурации
            return doDraw(result, filename);
        })
        .then(()=>{
            if (callback) callback();
        })
        .catch((err)=>{
            console.error(err);
        });
}

Трансляция картинки вместо потока


Изображение миниатюры обновляется примерно раз в минуту, поэтому зададим выполнение обновления картинки раз в 10 секунд:


var fs = require('fs');

// каждую 10ю секунду обновляем картинку
schedule("*/10 * * * * *", () => {
   createImage1('/tmp/1_new.jpg', ()=> {
       fs.renameSync('/tmp/1_new.jpg', '/tmp/1.jpg');
   });
   createImage2('/tmp/2_new.jpg', ()=> {
       fs.renameSync('/tmp/2_new.jpg', '/tmp/2.jpg');
   });
});

Особенность в том, что в процессе трансляции изображения необходимо подменять картинку достаточно быстро, чтобы ffmpeg не обрушился :) Поэтому, картинка формируется сперва в один файл, а потом файл переименовывается в тот, который используется для трансляции.


Теперь в настройках камеры укажем имя формируемого файла вместо адреса потока, и добавим настройки, что картинка "обновляемая" (параметр "-loop 1"). Это настраивается в дополнительных свойствах камеры. Эти свойства не что иное как параметры командной строки для запуска ffmpeg. Поэтому комбинации параметров следует искать в документации к ffmpeg и примерах.


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


Параметры запуска ffmpeg


Живая трансляция изображения


Заключение


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


Есть надежда, что разработчики драйвера Yahka сделают усилие и добавят автоматическое определение устройств, как это сделано в драйвере Material. В этом случае необходимо будет указать роль и функцию у состояний, и драйвер должен предложить добавить соответствующее устройство в конфигурацию шлюза для HomeKit.


Кроме упомянутого в этой статье драйвера Yahka, для интеграции с HomeKit существует еще один способ — драйвер Ham, который позволяет запустить сервис HomeBridge с подключенными к нему устройствами и взаимодействовать с ими. Но это уже тема для отдельной статьи.




К сожалению, не доступен сервер mySQL