Блог компании Mail.ru Group
Умный дом
Интернет вещей
DIY или Сделай сам
10 декабря 2018

Система управления умным домом на коленке: Tarantool

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

Но что делать тем, кто не хочет мириться с таким состоянием, и хочет одно кольцо один интерфейс, чтобы править всеми? Конечно же, написать его самим!



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

Что такое Tarantool? Это связка «сервер приложений — база данных». Можно использовать её как базу данных с хранимыми процедурами, а можно как сервер приложений со встроенной базой данных. Вся внутренняя логика, будь она пользовательской или в виде хранимых процедур, пишется на Lua. Благодаря использованию LuaJIT, а не обычного интерпретатора, в скорости она не сильно уступает нативному коду.

Еще один важный фактор — Tarantool это noSQL база данных. Это означает, что вместо традиционных запросов вроде «SELECT… WHERE» вы управляете данными напрямую: пишете процедуру, которая переберет все данные (или их часть) и выдаст вам их. В версии 2.x поддержку SQL-запросов добавили, но панацеей они не являются — для высокой производительности часто важно понимать, как именно исполняется тот или иной запрос, а не отдавать это на откуп разработчикам.

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

Поехали!

Вступление

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

Дисклеймер 1:
Мое понимание веб-разработки на момент начала этой статьи застыло где-то в 2010 году (а то и раньше), так что воспринимайте код фронтенда в качестве примера «как не стоит делать».

Дисклеймер 2:
Давайте сразу условимся, что гипотетическое устройство умного дома у нас доступно через MQTT. Это достаточно универсальный и распространенный протокол, чтобы меня не обвинили в надуманности примера. Реализация других протоколов хоть и несложна, но явно выходит за рамки статьи, в которой я хочу показать пример работы с Tarantool, а не процесс написания драйвера для какой-нибудь китайской лампочки.

А что такое MQTT?

MQTT, как подсказывает нам Google, это сетевой протокол, используемый, в основном, для M2M-взаимодействия.

Протокол работает по модели «издатель-подписчик»: это значит, что кто-то (например, устройство) может публиковать сообщения, а вы, если подписаны на адрес (в MQTT это называется «топик», например, "/data/voltage/"), будете эти сообщения получать.

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

Чуть-чуть подробнее
Подписаться можно не только на конкретный топик, но и используя в адресе символы подстановки. Так, подписка на "/data/+" позволит получать сообщения из любых топиков вида "/data/что-угодно/", например, "/data/temperature/" и "/data/stat", а подписка на "/data/#" — из топиков вида "/data/что-угодно/что-угодно/что-угодно/...", т.е. не только из "/data/temperature/ и "/data/stat", но также из "/data/stat/today" и "/data/stat/today/user/ivan".

Названия топиков не стандартизированы, поэтому то, как вы распихаете по ним свои данные — исключительно ваше дело. Статистика по пользователю за текущий день может быть как в "/stat/today/user/ivan", так и в "/user/ivan/stat/today" или в "/today/ivan/stat". В первом случае вы сможете подписаться на все уведомления о статистике ("/stat/#"), а во втором — на все уведомления отдельного пользователя ("/user/ivan/#"). Впрочем, во втором случае вы тоже сможете подписаться на статистику за текущий день для всех пользователей ("/user/+/stat/today").

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

Еще сообщение можно пометить флагом «Retain». В этом случае сервер запомнит последнее значение сообщения в этом топике, и будет рассылать его всем заново подключенным клиентам.

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

Шаг первый: форма с кнопками

Итак, наша минимальная функциональность — возможность отправить какую-нибудь команду на гипотетическое устройство. Хотя, почему гипотетическое? Давайте возьмем Wiren Board.



Управлять будем хотя бы пищалкой на нем. Чтобы включить её, нам надо подключиться по MQTT к WirenBoard и отправить «1» в топик "/devices/buzzer/controls/enabled/on". Чтобы отключить — надо отправить туда же «0».

Установим пакет http-server, создадим новый файл, дадим ему права на исполнение и скажем, что его надо исполнять в интерпретаторе Tarantool, а не просто в Lua:

tarantoolctl rocks install http
echo '#!/usr/bin/env tarantool' > iot_scada.lua
chmod +x iot_scada.lua

Теперь можно открыть файл в любимом редакторе, и буквально через, несколько строчек кода у нас появится маленький, но очень гордый HTTP-сервер:

local config = {}
config.HTTP_PORT = 8080

local function http_server_root_handler(req)
   return req:render{ json = { server_status = "ok" } }
end

local http_server = require('http.server').new(nil, config.HTTP_PORT, {charset = "application/json"})
http_server:route({ path = '/' }, http_server_root_handler) 
http_server:start()

Теперь, запустив наш сервис (./iot_scada.lua), мы можем открыть в браузере страничку localhost:8080/ и увидеть там что-то вроде

{"server_status":"ok"}

Это означает, что наш сервер работает и способен общаться с внешним миром. Да, пока исключительно в формате JSON, но исправить это несложно. Дабы не заморачиваться с интерфейсом, возьмем для этой цели Twitter Bootstrap.



Рядом с нашим скриптом создадим папки public и templates. В первой будет находиться статичный контент, а вторая предназначается для HTML-шаблонов (они не относятся к статике, потому что Tarantool может выполнять в этих шаблонах lua-скрипты).

В папку public положим всякие bootstrap.min.css, bootstrap.min.js, jquery-slim.min.js и так далее (я нашел эти файлы в архиве с Bootstrap, вы можете найти там же или тут), а в templates закинем файлик dashboard.html — пример странички из той же стандартной поставки. Про него поговорим чуть позже.

Теперь, изменим немного наш сервис:

--...--
local function http_server_action_handler(req) --Обработчик endpoint-a /action
   return req:render{ json = { mqtt_result = true } } --Возвращаем JSON
end

local function http_server_root_handler(req) --Обработчик endpoint-a /
   return req:redirect_to('/dashboard') --Перенаправляем на /dashboard
end
--...--
http_server:route({ path = '/action' }, http_server_action_handler)
http_server:route({ path = '/' }, http_server_root_handler)
http_server:route({ path = '/dashboard', file = 'dashboard.html' })
--...--

Что мы тут сделали? Во-первых, описали еще две оконечных точки — "/action", которая будет использоваться для API-запросов, и "/dashboard", которая будет отдавать содержимое файла dashboard.html. Мы установили и описали функции, которые будут вызываться при запросе браузером этих адресов: при запросе "/" будет вызвана функция http_server_root_handler, которая перенаправит браузер на адрес /dashboard, а при запросе /action — функция http_server_action_handler, которая сформирует JSON из Lua-обьекта и отдаст его клиенту.

Теперь, как и обещал, займемся файлом dashboard.html. Я не буду приводить его весь, можете посмотреть тут, это почти копия примера из Bootstrap. Покажу только функциональные части:

<div class="row input-group">
   <div class="col-md-3 mb-1">
      <button type="button" action-button="on" class="btn btn-success mqtt-buttons">On buzzer</button>
      <button type="button" action-button="off" class="btn btn-success mqtt-buttons">Off buzzer</button>
   </div>
</div>

Тут мы описываем две кнопки, «On buzzer» и «Off buzzer». Добавляем к ним атрибут "action-button", описывающий функцию кнопки, и класс "mqtt-buttons", который мы и будем ловить в JS. А вот и он, кстати (да, прямо в теле страницы, не делайте так, фу такими быть).

  <script>
      var button_xhr = new XMLHttpRequest();
      var last_button_object;

      function mqtt_result() {
         if (button_xhr.readyState == 4) {
            if (button_xhr.status == 200) {
               var json_data = JSON.parse(button_xhr.responseText);
               console.log(json_data, button_xhr.responseText)
               if (json_data.mqtt_result == true)
                  last_button_object.removeClass("btn-warning").removeClass("btn-danger").addClass("btn-success");
               else
                  last_button_object.removeClass("btn-warning").removeClass("btn-success").addClass("btn-danger");
            }
            else {
               last_button_object.removeClass("btn-warning").removeClass("btn-success").addClass("btn-danger");
            }
         }
      }

      function send_to_mqtt() {
         button_xhr.open('POST', 'action?type=mqtt_send&action=' + $(this).attr('action-button'), true);
         button_xhr.send()
         last_button_object = $(this)
         $(this).removeClass("btn-success").removeClass("btn-danger").addClass("btn-warning");
      }

      $('.mqtt-buttons').on('click', send_to_mqtt);
      button_xhr.onreadystatechange = mqtt_result
   </script>

Читать проще снизу вверх. Мы устанавливаем функцию send_to_mqtt как обработчик всех кнопок с классом mqtt-buttons ($('.mqtt-buttons').on('click', send_to_mqtt);). В этой функции делаем POST-запрос вида /action?type=mqtt_send&action=on, причем последнее значение получаем из атрибута action-button нажатой кнопки. Ну и красим кнопку в желтый цвет (.addClass(«btn-warning»)), показывая тем самим, что запрос ушел на сервер.

Запрос асинхронный, поэтому мы устанавливаем и обработчик тех данных, которые нам вернет сервер в ответ на запрос (button_xhr.onreadystatechange = mqtt_result). В обработчике мы проверяем, пришел ли ответ, пришел ли он с кодом 200, и является ли он валидными JSON-данными с параметром mqtt_result = true. Если он такой — то красим кнопку обратно в зеленый, а если нет — то в красный (.addClass(«btn-danger»)): «шеф, всё пропало».

Теперь, если запустить сервис и открыть в браузере localhost:8080/, мы увидим такую страницу:



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



Кнопки работают, но ничего не делают: на стороне сервера нет логики. Давайте её добавим.

Для начала, надо установить библиотеку mqtt. По умолчанию её в поставке тарантула нет, поэтому надо поставить: sudo tarantoolctl rocks install mqtt. Выполнять эту команду надо в папке, содержащей iot_scada.lua, так как библиотека установится локально в папку .rocks.

Теперь можно писать код:

--...--
local mqtt = require 'mqtt'
config.MQTT_WIRENBOARD_HOST = "192.168.1.59"
config.MQTT_WIRENBOARD_PORT = 1883
config.MQTT_WIRENBOARD_ID = "tarantool_iot_scada"

--...--
mqtt.wb = mqtt.new(config.MQTT_WIRENBOARD_ID, true)
local mqtt_ok, mqtt_err = mqtt.wb:connect({host=config.MQTT_WIRENBOARD_HOST,port=config.MQTT_WIRENBOARD_PORT,keepalive=60,log_mask=mqtt.LOG_ALL})
if (mqtt_ok ~= true) then
   print ("Error mqtt: "..(mqtt_err or "No error"))
   os.exit()
end
--...--

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

После подключения библиотеки создаем новый объект mqtt и подключаемся к серверу. Теперь можем с помощью "mqtt.wb:publish" отправлять сообщения MQTT в разные топики.

Займемся функцией http_server_action_handler. Она должна, во-первых, получить данные о том, какой запрос ей отправила кнопка на странице, а во-вторых, исполнить его. С первым всё очень просто. Вот такая конструкция вытащит из адреса аргументы type и action:

local type_param, action_param = req:param("type"), req:param("action")
if (type_param ~= nil and action_param ~= nil) then
--body--
end

Аргумент type у нас будет равен «mqtt_send», а action может быть «on» или «off». При первом значении нам надо отправить в MQTT-топик «1», а при втором — «2». Реализовываем:

local function http_server_action_handler(req)
   local type_param, action_param = req:param("type"), req:param("action")

   if (type_param ~= nil and action_param ~= nil) then
      if (type_param == "mqtt_send") then
         local command = "0"
         if (action_param == "on") then
            command = "1"
         elseif (action_param == "off") then
            command = "0"
         end
         local result = mqtt.wb:publish("/devices/buzzer/controls/enabled/on", command, mqtt.QOS_1, mqtt.NON_RETAIN)
         return req:render{ json = { mqtt_result = result } }
      end
   end
end

Обратите внимание на переменную result — в нее функцией mqtt.wb:publish возвращается статус запроса (true или false), который затем пакуется в JSON и отправляется браузеру.

Теперь кнопки не только нажимаются, но еще и работают. Смотрите сами:


Весь код, относящийся к этому шагу, можно посмотреть тут. Или получить себе на диск такой командой:

git clone https://github.com/vvzvlad/tarantool-iotscada-mailru-gt.git 
cd tarantool-iotscada-mailru-gt
git checkout a2f55792019145ca2355012a65167ca7eae3154d

Шаг первый с половиной: играем имперский марш

Давайте добавим третью кнопку, что ли? Если у нас есть спикер, пусть он играет имперский марш!

Что замечательно, на страничке нам надо добавить только саму кнопку, определив ей какой-нибудь другой атрибут action-button:

<button type="button" action-button="sw" class="btn btn-success mqtt-buttons">Play Imperial march</button>

Вся магия будет происходить в файле с кодом. Добавим обработчик нового параметра:

--...--
local function play_star_wars()
end

--...--
elseif (action_param == "sw") then
  play_star_wars()
--...--

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

local imperial_march = {{392, 350}, {392, 350}, {392, 350}, {311, 250}, {466, 100}, {392, 350}, {311, 250}, {466, 100}, {392, 700}, {392, 350}, {392, 350}, {392, 350}, {311, 250}, {466, 100}, {392, 350}, {311, 250}, {466, 100}, {392, 700}, {784, 350}, {392, 250}, {392, 100}, {784, 350}, {739, 250}, {698, 100}, {659, 100}, {622, 100}, {659, 450}, {415, 150}, {554, 350}, {523, 250}, {493, 100}, {466, 100}, {440, 100}, {466, 450}, {311, 150}, {369, 350}, {311, 250}, {466, 100}, {392, 750}}

Правда, со временем там что-то не совсем то, и нет длительности пауз, но что уж делать. На WirenBoard можно изменять частоту спикера, отправляя значение новой частоты в герцах в топик "/devices/buzzer/controls/frequency/on", а вот задавать длительность звучания нельзя. Значит, будем отсчитывать длительность сами, на стороне приложения.

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

Для этого мы используем файберы (fibers) — это реализация отдельных потоков для Tarantool. Документацию можно найти тут. В самом простом варианте запуск еще одного потока внутри вашей программы требует всего несколько строчек:

local fiber = require 'fiber'
local function fiber_func()
   print("fiber ok")
end
fiber.create(fiber_func)

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

Всё остальное просто: нам надо обойти в цикле массив, получая у каждого элемента частоту и длительность, настраивая частоту через MQTT, а потом запуская задержки для ноты и паузы, а также включая/выключая звук.

--...--
for i = 1, #imperial_march do
   local freq = imperial_march[i][1]
   local delay = imperial_march[i][2]
   mqtt.wb:publish("/devices/buzzer/controls/frequency/on", freq, mqtt.QOS_0, mqtt.NON_RETAIN)
   mqtt.wb:publish("/devices/buzzer/controls/enabled/on", 1, mqtt.QOS_0, mqtt.NON_RETAIN)
   fiber.sleep(delay/1000*2)
   mqtt.wb:publish("/devices/buzzer/controls/enabled/on", 0, mqtt.QOS_0, mqtt.NON_RETAIN)
   fiber.sleep(delay/1000/3)
end
--...--

Посмотреть полный код можно тут, или в diff-виде.

Ура, работает!


Четкость мелодии немного плавает из-за непредсказуемых сетевых задержек, но мелодия вполне ясна и узнаваема. Коллеги радуются (на самом деле нет, на десятый раз их задолбало).

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

git checkout 10364cea7f3e1490ac3eb916b4f4b4c095bec705

Шаг третий: температура на веб-странице

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



Как нам обещает документация, больше делать ничего не потребуется, данные с датчиков появятся в MQTT сами.

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

Бэкенд

Первое, что нам надо сделать — создать функцию, которая должна вызываться при получении MQTT-сообщения с температурой, потом сказать библиотеке, что мы должны вызывать именно её, и подписаться на топик с сообщениями. Документация утверждает, что топик выглядит вот так: "/devices/wb-w1/controls/28-43276f64". 28-43276f64 — это и есть серийный номер датчика. Значит, подписка на данные со всех возможных датчиков будет выглядеть так: "/devices/wb-w1/controls/+".

local sensor_values = {}
--...--
local function mqtt_callback(message_id, topic, payload, gos, retain)
   local topic_pattern = "/devices/wb%-w1/controls/(%S+)"
   local _, _, sensor_address = string.find(topic, topic_pattern)
   if (sensor_address ~= nil) then
      sensor_values[sensor_address] = tonumber(payload)
   end
end
--...--
mqtt.wb:on_message(mqtt_callback)
mqtt.wb:subscribe('/devices/wb-w1/controls/+', 0)

Теперь разберемся подробнее, что же мы делаем в callback-функции. Для поиска серийного номера в строке адреса мы используем так называемые паттерны (регулярные выражения Lua-шного разлива). Функция string.find принимает строку и паттерн, в котором скобками отмечено то, что надо из этой строки захватить. В данном случае "%S+" означает «1 или более символов, которые не являются пробелами» — таким образом, функция захватит всё, что находится после "..controls/", до первого встреченного пробела. А так как у нас пробелов в номере датчиков не предполагается, а адрес подписки допускает сообщения только с "/devices/wb-w1/controls/адрес-датчика", но не с "/devices/wb-w1/controls/адрес-датчика/что-то-еще", то в переменной sensor_address у нас всегда будет адрес (серийный номер) датчика.

Обратите внимание, что строки '/devices/wb-w1/controls/+' и "/devices/wb%-w1/controls/(%S+)" хоть и похожи, но всё же разные: первая строка — это wildcard mqtt-подписка, а вторая — строка-аргумент для Lua-шной функции string.find, в которой используется подмножество регулярных выражений в формате Lua (там, например, "-" надо экранировать, поэтому оно записано в виде «wb%-w1»)

Следующими строками мы создаем и заполняем таблицу sensor_values, в которой у нас будут записи, соответствующие датчикам: ключом будет серийный номер, а значением — температура с датчика.

local sensor_values = {}
--...--
sensor_values[sensor_address] = tonumber(payload)

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

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


local function http_server_data_handler(req)
   local type_param = req:param("type")

   if (type_param ~= nil) then
      if (type_param == "temperature") then
         if (sensor_values ~= nil) then
            local temperature_data_object, i = {}, 0

            for key, value in pairs(sensor_values) do
               i = i + 1
               temperature_data_object[i] = {}
               temperature_data_object[i].sensor = key
               temperature_data_object[i].temperature = value
            end
            return req:render{ json = { temperature_data_object } }
         end
      end
   end
   return req:render{ json = { none_data = "true" } }
end

Конечно, можно сразу формировать правильную таблицу в mqtt-коллбеке, но выбор, где конвертировать, зависит от того, что происходит чаще — сохранение или выдача: сохранение в таблице по ключу гораздо быстрее, чем перебор таблицы в поисках нужного имени датчика для каждого значения. Таким образом, если значения мы показываем, в среднем, каждую минуту, а сохраняются они каждую секунду, то выгоднее сохранять по ключу, а потом форматировать по запросу. Если же наоборот, например, у нас десяток клиентов, которые смотрят на таблицу, а температура обновляется нечасто, то лучше хранить готовую таблицу.

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

Наконец, устанавливаем эту функцию как обработчик endpoint-a /data: http_server:route({ path = '/data' }, http_server_data_handler).

Проверяем:



Работает!

Фронтенд

Теперь надо нарисовать табличку. Создаем заготовку:

<h3>Sensors:</h3>
<div class="table-responsive">
   <table class="table table-striped table-sm" id="table_values_temp"></table>
</div>

И пишем две функции, которые будут превращать JS-объект в табличку:

function add_row_table(table_name, type, table_data) {
   var table_current_row;
   if (type == "head")
      table_current_row = document.getElementById(table_name).createTHead().insertRow(-1);
   else {
      if (document.getElementById(table_name).tBodies.length == 0)
         table_current_row = document.getElementById(table_name).createTBody().insertRow(-1);
      else
         table_current_row = document.getElementById(table_name).tBodies[0].insertRow(-1);
   }

   for (var j = 0; j < table_data.length; j++)
      table_current_row.insertCell(-1).innerHTML = table_data[j];
}

function clear_table(table_name) {
   document.getElementById(table_name).innerHTML = "";
}

Теперь осталось только запустить в цикле обновление этой таблички:

var xhr_tmr = new XMLHttpRequest();

function update_table_callback() {
   if (xhr_tmr.readyState == 4 && xhr_tmr.status == 200) {
      var json_data = JSON.parse(xhr_tmr.responseText);
      if (json_data.none_data != "true") {
         clear_table("table_values_temp")
         add_row_table("table_values_temp", "head", ["Sensor serial", "Temperature"])
         for (let index = 0; index < json_data[0].length; index++) {
            add_row_table("table_values_temp", "body", [json_data[0][index].sensor, json_data[0][index].temperature])
         }
      }
   }
}

function timer_update_field() {
   xhr_tmr.onreadystatechange = update_table_callback
   xhr_tmr.open('POST', 'data?type=temperature', true);
   xhr_tmr.send()
}
setInterval(timer_update_field, 1000);

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

Ну-ка, что у нас получилось?



Код, относящийся к этому шагу, можно посмотреть тут, или сделав git checkout e387430efed44598efe827016f903cc3c17634a8. Или DIFF-вид.

Шаг четвертый: температура в базе данных

А теперь сделаем то же самое, но с базой данных! В конце-концов, Tarantool — база данных или нет? :)

Изменений, на самом деле, будет совсем немного. Во-первых, инициализируем движок баз данных и создадим space (это аналог таблицы в какой-нибудь SQL):

local function database_init()
   box.cfg { log_level = 4 }
   box.schema.user.grant('guest', 'read,write,execute', 'universe', nil, {if_not_exists = true})
   local format = {
      {name='serial',      type='string'},   --1
      {name='timestamp',   type='number'},   --2
      {name='value',       type='number'},   --3
   }
   storage = box.schema.space.create('storage', {if_not_exists = true, format = format})
   storage:create_index('serial', {parts = {'serial'}, if_not_exists = true})
end

Теперь пройдемся по вызовам более внимательно: мы начинаем погружаться в самую суть Tarantool — в работу с базой данных.

box.cfg() — это инициализация. Мы передаем в нее параметр уровня логгирования, указав, логи какой важности мы хотим видеть, а какой — нет. Но вообще у нее много параметров.

Можно заметить, что вызов функции box.cfg какой-то странный: вместо круглых скобочек фигурные. Это потому, что в Lua при передаче функции одного аргумента скобки можно опускать. А так как {} — это таблица, то один аргумент и передается — таблица. Проще говоря, box.cfg({ log_level = 4 }) это тоже самое, что box.cfg { log_level = 4 }.

Функцией box.schema.user.grant мы даем пользователю guest без пароля (nil) права на чтение, запись и выполнение (read,write,execute) во всем пространстве текущего экземпляра Tarantool (universe). Последний аргумент (if_not_exists = true) разрешает системе ничего не делать, если у пользователя уже есть эти права (точнее, разрешает дать права, только если их у пользователя нет).

Теперь нам надо создать какое-то хранилище. Этим занимается функция box.schema.space.create. Мы передаем в нее имя хранилища, уже знакомое нам указание if_not_exists и формат — по сути, имена полей и типы хранимых в них данных, которые определили парой строчек выше. Штука эта опциональная, можно не передавать формат и всё равно работать с базой данных, просто доступ к полям будет не по именам, а по номерам (зато можно будет добавлять новые поля в процессе работы).

Возвращает эта функция объект созданного хранилища. Хранить ссылку на него не обязательно: она есть в глобальном пространстве имен: box.space.space_name (box.space.storage в данном случае, но мне было удобнее так). Записи storage:create_index и box.space.storage:create_index равнозначны.

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

Хоть индекс нам особо не нужен, создать его надо — базе в Tarantool всегда нужен первичный индекс.

Таким образом, мы создали базу данных, определили поля в ней и создали индекс. Теперь напишем функцию записи в базу:

local function save_value(serial, value)
   local timestamp = os.time()
   value = tonumber(value)
   if (value ~= nil and serial ~= nil) then
      storage:upsert({serial, timestamp, value}, {{"=", 2, timestamp} , {"=", 3, value}})
      return true
   end
   return false
end

Самая главная строчка тут — storage:upsert({serial, timestamp, value}, {{"=", 2, timestamp}, {"=", 3, value}}).

Upsert — это комбинация update и insert: если такой записи в базе нет, произойдет insert, а если есть — то update. Первым аргументом мы говорим, какие данные и в какой последовательности вставлять при insert (по порядку: serial, timestamp, value), а вторым — как именно делать update.

Обратите внимание: serial, timestamp и value это имена не полей, а локальный переменных внутри функции. Куда они будут вставлены, зависит от их порядка. То есть наша строка означает: создай новую запись, вставив в первое поле serial, во второе — timestamp, а в третье value. Порядок полей в БД определяется в данном случае при создании format (см. выше).

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

Запись "{"=", 2, timestamp}" означает, что мы должны обновить второе поле содержимым локальной переменной, причем с помощью перезаписи =). Виды способов обновления можно посмотреть тут. Например, мы можем прибавить или вычесть числовое значение, сделать XOR/AND, и так далее.

Остальные строки — это получение текущего времени, превращения полученного значения в число (mqtt представляет любые данные в виде строк, а у нас в БД value имеет тип number, что приведет в общем случае к ошибке), разнообразные проверки и возврат статуса операции.

Сделаем аналогичную функцию, которая будет получать значения из базы:

local function get_values()
   local temperature_data_object, i = {}, 0
   for _, tuple in storage:pairs() do
      i = i + 1
      local absolute_time_text = os.date("%Y-%m-%d, %H:%M:%S", tuple["timestamp"])
      local relative_time_text = (os.time() - tuple["timestamp"]).."s ago"

      temperature_data_object[i] = {}
      temperature_data_object[i].sensor = tuple["serial"]
      temperature_data_object[i].temperature = tuple["value"]
      temperature_data_object[i].update_time_epoch = tuple["timestamp"]
      temperature_data_object[i].update_time_text = absolute_time_text.." ("..relative_time_text..")"
   end
   return temperature_data_object
end

Она очень похожа на прошлую функцию. Мы не будем углубляться в магию функции pairs() в сочетании с циклом for, а покажем работу на простых примерах.

local table = {"test_1","test_2","test_3"}
for key, value in pairs(table) do
   print(value)
end

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

local table = {"test_1","test_2","test_3"}
print(table[1])
print(table[2])
print(table[3])

Еще есть переменная key, которая принимает ключ элемента в таблице. Так как мы ключи не указывали, они будут равны номеру элемента в таблице: 1,2,3.

Для БД в Tarantool существует точно такая же функция pairs(), которую можно использовать в подобной конструкции:

for _, tuple in box.space.storage:pairs() do
-- for body
end

При вызове без параметров она переберет всё содержимое таблицы (спейса), вызывая для каждой записи (кортежа) команды из тела цикла. Поскольку кортеж, как правило, состоит из нескольких полей, то получить доступ к отдельным полям можно в виде tuple[1] или, если есть format, то по имени поля: tuple[«name»]. Таким образом, мы получаем серийный номер (tuple[«serial»]), текущую температуру (tuple[«value»]) и время последнего обновления этой температуры (tuple[«timestamp»]).

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

С бекендом всё. Настало время фронтенда.

Фронтенд

А делать-то ничего и не пришлось: оно работает и так. Добавим еще один столбец в табличку, раз уж у нас есть время обновления. Было:

add_row_table("table_values_temp", "head", ["Sensor serial", "Temperature"])
for (let index = 0; index < json_data[0].length; index++) 
{
   add_row_table("table_values_temp", "body", [json_data[0][index].sensor, json_data[0][index].temperature)
}

Стало:

add_row_table("table_values_temp", "head", ["Sensor serial", "Temperature", "Update time"])
for (let index = 0; index < json_data[0].length; index++) 
{
   add_row_table("table_values_temp", "body", [json_data[0][index].sensor, json_data[0][index].temperature, json_data[0][index].update_time_text])
}

Вот и все изменения.

Теперь можно посмотреть и на результат:



Еее!

Код, относящийся к этому шагу, можно посмотреть тут, или сделав git checkout bf26c3aea21e68cd184594beec2e34f3413c2776. Или DIFF-вид.

Шаг пятый: исторические данные и график

Теперь нужно получить не только текущее значение, но еще и исторические данные. Ну, и построить по ним график.

Первым делом изменим конфигурацию базы данных. Было:

storage:create_index('serial', {parts = {'serial'}, if_not_exists = true})

Стало:

storage:create_index('timestamp', {parts = {'timestamp'}, if_not_exists = true})
storage:create_index('serial', {parts = {'serial'}, unique = false, if_not_exists = true})

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

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

Следующее решение я подсмотрел на T++ Conference.

local function gen_id()
   local new_id = clock.realtime()*10000
   while storage.index.timestamp:get(new_id) do
      new_id = new_id + 1
   end
   return new_id
end

local function save_value(serial, value)
   value = tonumber(value)
   if (value ~= nil and serial ~= nil) then
      storage:insert({serial, gen_id(), value})
      return true
   end
   return false
end

Используем insert вместо upsert: если раньше мы или создавали, или обновляли запись, соответствующую датчику, то теперь мы будем только вставлять новые записи с новым временем, не изменяя старые.

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

Для этого нам нужен модуль clock, поэтому в начале файла добавляется строчка

local clock = require 'clock'

Функция get_values тоже изменилась:

local function get_values_for_table(serial)
   local temperature_data_object, i = {}, 0
   for _, tuple in storage.index.serial:pairs(serial) do
      i = i + 1
      local time_in_sec = math.ceil(tuple["timestamp"]/10000)
      local absolute_time_text = os.date("%Y-%m-%d, %H:%M:%S", time_in_sec)

      temperature_data_object[i] = {}
      temperature_data_object[i].serial = tuple["serial"]
      temperature_data_object[i].temperature = tuple["value"]
      temperature_data_object[i].time_epoch = tuple["timestamp"]
      temperature_data_object[i].time_text = absolute_time_text
   end
   return temperature_data_object
end

Мы больше не перебираем всю базу с помощью функции storage:pairs(), а используем её версию, которая выбирает данные, соответствующие определенному признаку:

storage.index.serial:pairs(serial)

Такая запись означает буквально следующее: перебрать все данные (pairs) в базе данных storage, у которых индекс под названием serial соответствует тому, что находится в переменной serial. Такой индекс мы создали чуть выше, а переменная приходит в виде аргумента функции.

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

На стороне фронтенда изменится мало: только новая колонка в таблице, да серийник в адресе запроса:



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

<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
<script type="text/javascript">
   google.charts.load('current', { 'packages': ['corechart'] });
   google.charts.setOnLoadCallback(timer_update_graph);
   function update_graph_callback() {
      let data_b = JSON.parse(xhr_graph.responseText);
      var data = google.visualization.arrayToDataTable(data_b[0]);
      var options = {
         title: 'Temperatype',
         hAxis: { title: 'Time', titleTextStyle: { color: '#333' } },
      };
      var chart = new google.visualization.AreaChart(document.getElementById('chart_div'));
      chart.draw(data, options);
   }
   var xhr_graph = new XMLHttpRequest();
   function timer_update_graph() {
      xhr_graph.onreadystatechange = update_graph_callback
      xhr_graph.open('POST', 'data?data=graph&serial=28-000008e538e6', true);
      xhr_graph.send()
      setTimeout(timer_update_graph, 3000);
   }
</script>


<div id="chart_div" style="width: 100%; height: 300px;"></div>

Наверное, всем понятно, что делает этот код: превращаем JSON c сервера в массив, того превращаем в форму, понятную графической библиотеке, указываем цвет и название осей, и отрисовываем график. Ну и запускаем таймер, который будет забирать данные и заново перерисовывать график раз в 3 секунды.

Для графика, кстати, надо немного изменить данные так, чтобы они были не в виде массива ключ-значение, а просто списком, в котором тип поля определяется его местом:

local function get_values_for_graph(serial)
   local temperature_data_object, i = {}, 1
   temperature_data_object[1] = {"Time", "Value"}
   for _, tuple in storage.index.serial:pairs(serial) do
      i = i + 1
      local time_in_sec = math.ceil(tuple["timestamp"]/10000)
      temperature_data_object[i] = {os.date("%H:%M", time_in_sec), tuple["value"]}
   end
   return temperature_data_object
end

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

local function http_server_data_handler(req)
   local params = req:param()
   if (params["data"] == "table") then
      local values = get_values_for_table(params["serial"])
      return req:render{ json = { values } }
   elseif (params["data"] == "graph") then
      local values = get_values_for_graph(params["serial"])
      return req:render{ json = { values } }
   end

И всё готово:



График реален, это я засунул датчик температуры в морозилку.

Код, относящийся к этому шагу, можно посмотреть тут, или сделав git checkout 10ed490333bead9e8aeaa851dc52070050aac68c. Или DIFF-вид.

Заключение

Разумеется, за рамками статьи остались многие интересные вещи, касающиеся как тонкой душевной организации Tarantool, так и создания более удобного/качественного/современного интерфейса. Например, данные можно хранить на диске, а не в памяти. И конечно, нужна ротация данных, иначе десяток датчиков забьют память. И вообще, для хранения таких данных надо взять TSDB, а для веб-интерфейса — нормальный фреймворк типа Vue.

Я писал эту статью лишь в качестве примера, который подталкивает сделать первые шаги в мире Tarantool в чуть более дружелюбной форме, чем официальная документация. Надеюсь, у меня это получилось.

Tarantool — специфичная, но очень интересная штука, которая нравится мне, и надеюсь, понравится вам.

+34
22,6k 170
Поддержать автора
Комментарии 32