ESP32 от Espressif. Часть 2. Углубленная настройка Mongoose-OS и Google Cloud IoT

8 ноября 2019

учёт ресурсовавтоматизацияинтернет вещейEspressif Systemsстатьяинтегральные микросхемыбеспроводные технологии

Сергей Китаин (г. Москва)

Продолжение рассказа о контроллере ESP32 производства Espressif: о настройках безопасности, регистрации и конфигурировании внешних устройств в Cloud IoT, контроле состояния с помощью удаленного индикатора, а также о сохранении данных в альтернативных хранилищах Google Cloud.

В предыдущей статье данного цикла – «ESP32 от Espressif. Часть 1. Определяем присутствие человека», – был приведен пример подключения контроллера ESP32 производства компании Espressif, работающего под управлением Mongoose-OS, к Google IoT Cloud и сохранения передаваемых данных в базе Firebase. Новая статья расскажет о мерах по повышению безопасности контроллера, о создании и вызове RPC-функций, шифровании памяти и обеспечении безопасности контроллера, автоматизации регистрации устройств в Firebase, их конфигурирования и отслеживания состояния, а также о передаче состояния на удаленный индикатор. В облачные функции будет добавлен функционал сохранения данных в альтернативных хранилищах – Big Query и Google Sheet.

Управление настройками

В статье «ESP32 от Espressif. Часть 1. Определяем присутствие человека» было описано, как сконфигурировать Wi-Fi с помощью команды mos. Такой метод пригоден для разового использования при изучении Mongoose, но не для постоянного использования и, тем более, не для серии. В идеале нужно иметь возможность реализации следующих двух сценариев:

  • прошивки контроллера с предустановленной настройкой беспроводной сети;
  • старта контроллера в режиме точки доступа с запущенным веб-сервером и предоставления пользователю возможности самостоятельно задать параметры сети.

Кроме того, можно использовать встроенную в контроллер ESP32 возможность подключения по Bluetooth и написать специальное приложение для этих целей.

Предустановленные настройки

Для прошивки контроллера с предустановленной настройкой сети достаточно добавить в файл конфигурации проекта mos.yml следующие строки:


 - ["wifi.ap.enable", false]
 - ["wifi.ap.ssid", "SenHub_??????"]
 - ["wifi.ap.pass", "12345678"]
 - ["wifi.sta1.enable", true]
 - ["wifi.sta1.pass", "PASSWORD"]
 - ["wifi.sta1.ssid", "SSID"]

Приведенная выше конфигурация определяет работу контроллера в режиме «станция» и отключает точку доступа. При необходимости можно перечислить несколько конфигураций сетей, и контроллер будет последовательно их перебирать, пока не получится подключиться к одной из заданных сетей.
Кроме конфигурации сети в проекте может быть достаточно много других параметров, требующих настройки, таких как назначение портов, адреса API, логины, пароли и прочие данные. Часть из этих параметров может быть доступна пользователю, часть должна изменяться только производителем. Все используемые параметры должны быть предварительно определены в файле конфигурации проекта mos.yml. В общем случае на рисунке 1 показана строка конфигурации одного параметра.

Рис. 1. Строка конфигурации файла mos.yml

Рис. 1. Строка конфигурации файла mos.yml

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

  • conf0.json – настройки по умолчанию;
  • conf1.json … conf8.json – настройки производителей;
  • conf9.json – настройки пользователей.

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

Контроль доступа к параметрам конфигурации

Для ограничения доступов система конфигурации Mongoose содержит специальный параметр, управляющий доступами к ключам – список управления доступом, или Field Access Control List (ACL):

  • ACL – это разделенный запятыми список ключей;
  • ключи ACL последовательно сопоставляются с ключами конфигурации, и когда найдено первое совпадение, поиск заканчивается;
  • ключ ACL может быть шаблоном с использованием «*» в качестве подстановочного символа;
  • ключ ACL может начинаться с символов «+» или «-», указывая, соответственно, разрешать или запрещать изменение поля, если ключ совпадает. Символ «+» может быть опущен»
  • по умолчанию подразумевается, что изменение любого поля разрешено.

ACL задается в конфигурации ключом «conf_acl». Обратите внимание, что во время загрузки действует настройка предыдущего слоя из соответствующего файла «confX.json» – при загрузке пользовательских настроек «conf_acl» берется из настроек производителя, а настройки производителя могут быть ограничены значением «conf_acl» по умолчанию.
Чтобы дать пользователям возможность изменять только настройки Wi-Fi и уровень отладки, нужно добавить «conf_acl»: «wifi.*,debug.level» в одном из файлов производителя (conf{1-8}.json) или в файле конфигурации приложения.
При использовании запрещающих записей ACL можно реализовать поведение «разрешить все, кроме того, что запрещено». Например, строка «conf_acl»: «-debug.*,*» разрешает изменение всех параметров кроме тех, что находятся в секции «debug».

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

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

Создание обработчика RPC-вызова

Mongoose предоставляет готовую возможность принимать RPC-вызовы через различные интерфейсы: HTTP, web socket (ws & wss), UART, MQTT, Bluetooth. С помощью сервисов OS RPC можно устанавливать состояние выводов, работать с шинами SPI и I2C и многое другое. Полный список уже подключенных сервисов можно получить по команде «mos call RPC.List».
Создание нового обработчика тоже не составляет проблем – API предоставляет для этого удобный механизм.
Для начала нужно добавить Github Repo mongoose-os-libs/rpc-common в файл конфигурации и подключить библиотеку api_rpc.js к скрипту. Шаблон обработчика, который принимает JSON с конфигурацией, сохраняет ее и перезагружает контроллер, выглядит следующим образом:


RPC.addHandler('setwifi', function (args) {
// проверка, что получен объект и у него есть свойство config
    if (typeof (args) === 'object'
            && typeof (args.config) === 'object') {
        print('Request:', JSON.stringify(args.config));
// обновление конфигурации
        print('Rsave:', Cfg.set(args.config, true));
        Sys.reboot(500);
        return {};
    } else {
        return {error: -1, message: ‘Error’};
    }
});

На HTML-странице (по умолчанию – index.htm, но может быть и любая другая по вашему выбору) нужно разместить следующий скрипт:


// функция для отправки post-запроса на контроллер 
// с коллбэками для успешного и неуспешного вызовов
const post = function (url, payload, success, error) {
    var request = new XMLHttpRequest();
    request.open('POST', url, true);
    request.setRequestHeader('Content-Type', 'application/json');
    request.onload = function () {
        if (request.status >= 200 && request.status < 400) {
            success(request);
        } else {
            error(request);
        }
    };
    request.send(payload);
}
// обработчик нажатия на кнопку сохранения с id=”save”
// готовит JSON в формате конфигурации Mongoose
// вызов rpc метода происходит по адресу '/rpc/setwifi'
document
    .querySelector('#save')
    .addEventListener(
      'click', function () {
        const ssid = document.querySelector('#ssid').value;
        const pass = document.querySelector('#pass').value;
        post('/rpc/setwifi', 
            JSON.stringify({config: {wifi: {sta: {enable: true, ssid: ssid, pass: pass}}}}),
                function (request) {
                    console.log('Configuration has been sent.');
                }, 
                function (request) {
                    console.log('Error occured on sending config: ' 
                        + request.responseText);
                }
            )
        }
    );

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

Код, управляемый событиями

Контроллер ESP32 под управлением OS Mongoose работает в асинхронном режиме. Наша программа, по сути, должна состоять из однократно выполняемой процедуры инициализации, а также таймеров и обработчиков событий. Стандартные библиотеки предоставляют готовые обработчики, многие библиотеки формируют события, которые пользователь может обработать при необходимости.
Кроме того, можно создавать собственные события и вешать на них обработчики. Причем события можно вызывать в коде, написанном на C, а обрабатывать в коде на JS. Рассмотрим, как реализовать создание и обработку собственных событий:


let Con = {
   // определение группы событий.
   // буквенный код «CHA» должен быть уникален, 
   // события с совпадающим буквенным кодом могут передаваться
    // из кода на С в код на JS
    CHANGE_BLINK_GROUP: Event.baseNumber("CHA"),
    intervals: {
        notConnected: 500,
        connected: 1000,
        cloud: 2000
    }
};

// определение собственно событий
Con.CHANGE_BLINK_CONNECT = Con.CHANGE_BLINK_GROUP + 0;
Con.CHANGE_BLINK_DISCONNECT = Con.CHANGE_BLINK_GROUP + 1;
Con.CHANGE_BLINK_CLOUD = Con.CHANGE_BLINK_GROUP + 2;

// обработчик для группы событий
Event.addGroupHandler(Con.CHANGE_BLINK_GROUP, function (ev, evdata, ud) {
    let int = 0;
    if (ev === Con.CHANGE_BLINK_CONNECT) {
        int = Con.intervals.connected;
    } else if (ev === Con.CHANGE_BLINK_DISCONNECT) {
        int = Con.intervals.notConnected;
    } else if (ev === Con.CHANGE_BLINK_CLOUD) {
        int = Con.intervals.cloud;
    }
    ;
    GPIO.blink(pin, int, int);
}, null);
// генерация события
Event.trigger(Con.CHANGE_BLINK_DISCONNECT, "notConnected");

Передача данных по UDP

Рассмотрим частный случай, когда все контроллеры расположены в одной сети и нужно только организовать обмен данными между ними. В этом случае для обмена можно использовать «легкий» протокол UDP с небольшой нагрузкой на контроллеры, но с отсутствием гарантии доставки конкретного пакета информации. Для обнаружения друг друга (или даже для обмена информацией) контроллеры могут посылать сообщения по широковещательному адресу. Прежде всего, напишем функцию, которая будет возвращать широковещательный адрес. Такую функцию удобно написать на С – там уже есть все необходимые данные.
В файле конфигурации «mos.yml» нужно определить папку, в которой будут расположены исходники на C. Для этого надо добавить следующие строки:


sources:
  - src

Теперь в папке «src» можно создать файл «main.c» со следующим содержимым:



#include "mgos.h"

char ip[16];
// функция, выполняемая при инициализации
enum mgos_app_init_result mgos_app_init(void) {
    return MGOS_APP_INIT_SUCCESS;
}

/** получение данных сети может возвратить строку со следующими видами адреса
0 -ip
1- gateway
2- netmask
3- broadcast
 */
char *get_net_mask(int type) {
    memset(ip, 0, sizeof (ip));
    struct sockaddr_in _ip;
    struct mgos_net_ip_info ip_info;
    if (mgos_net_get_ip_info(MGOS_NET_IF_TYPE_WIFI, 0, &ip_info)) {
        switch (type) {
            case 0:
            {
                _ip = ip_info.ip;
                break;
            }
            case 1:
            {
                _ip = ip_info.gw;
                break;
            }
            case 2:
            {
                _ip = ip_info.netmask;
                break;
            }
            case 3:
            {
                struct sockaddr_in _netmask;
                _ip = ip_info.ip;
                _netmask = ip_info.netmask;
                _ip.sin_addr.s_addr = _ip.sin_addr.s_addr | (~_netmask.sin_addr.s_addr);
                break;
            }
        }
        mgos_net_ip_to_str(&_ip, ip);
    }
    return (char *) ip;
}

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



// определение внешней функции на C
let getIp = ffi('char *get_net_mask(int)');
// получение широковещательного адреса и установка 
// UDP-соединения после подключения к сети
Event.addGroupHandler(Net.EVENT_GRP, function (ev, evdata, arg) {
    if (ev === Net.STATUS_GOT_IP) {
        Event.trigger(Con.CHANGE_BLINK_CONNECT, "connected");
        if ( broadcastIp = getIp(3)) {
            let addr = 'udp://' + broadcastIp + ":" + UDPport;
            UDPconn = Net.connect({"addr": addr});
            print("Connect to UDP :", addr);
        }
    }
}, null);

Теперь контроллер может отправить данные в широковещательном сообщении по протоколу UDP:


    if (broadcastIp) {
        Net.send(UDPconn, msg);
    }

Получатель может принять сообщение с помощью следующего кода:


Net.serve({
    addr: 'udp://' + UDPport, 
    ondata: function (conn, data) {
        print('Received from:', Net.ctos(conn, false, true, true), ':', data);
        // место для полезного кода

        // очистка ненужных данных
        Net.discard(conn, data.length);  
    }
});

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

Передача данных в Google Sheet

На этапе отладки бывает полезно сохранить логи работы устройства. Их можно писать и в память устройства, но у небольших контроллеров она конечна, а считывание и преобразование данных к удобному для восприятия виду тоже требует дополнительных ресурсов. Простым решением может стать сохранение данных на листе Google Sheet. Реализация функции для отправки объекта в Google Sheet занимает в Mongoose OS всего несколько строк кода (предварительно нужно подключить библиотеку с помощью строки load(‘api_http.js’)):


let urlGS= “{АДРЕС СКРИПТА}”;
let GSSend = function (obj) {
    HTTP.query({
        url: urlGS,
        data: obj,
        success: function (body, full_http_msg) {
             // здесь можно обработать возвращаемые данные
             // помните про редирект
        },
        error: function (err) {
            print(err);
        }, // опциональная обработка ошибок
    });
};

Переменная urlGS хранит адрес скрипта, который создается в Google Sheet. При необходимости, в функцию, вызываемую при успешной отправке данных, можно передать какие-то результаты из Google Sheet. Такой метод вполне работоспособен, но нужно учитывать, что скрипт Google всегда возвращает редирект, и для получения собственно данных придется выполнить еще один запрос.
Теперь добавим скрипт для таблицы Google Sheet. Для запуска редактора скриптов нужно на открытой таблице выбрать меню «Инструменты → Редактор скриптов» (рисунок 2).

Рис. 2. Вставка скрипта в Google-таблицы

Рис. 2. Вставка скрипта в Google-таблицы

В открывшемся окне нужно вставить следующую функцию (имя функции doPost жестко задано):



/ *получение данных из POST-запроса, используя spreadsheet API 
Функция сохраняет полученные данные на листе с именем устройства, 
при необходимости создает лист с именем устройства,
возвращает JSON с конфигурацией
обратите внимание: при обработке возвращаемого значения нужно обработать редирект
*/

function doPost(e) {
  var row=[]; // массив для создаваемой строки
  // подключение к текущему документу
  var doc = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = doc.getSheets()[0];
  // добавить дату в первую ячейку
  row.push(new Date()); 
  try{
    //console.info(JSON.stringify(e));
    // парсинг данных из POST-запроса
    var rowJSON = JSON.parse(e.postData.contents) || {};
    var keys = Object.keys(rowJSON);
    // получение имени устройства
    var deviceId=rowJSON.dev||"лист1";
    // если нет листа с именем устройства - создать такой лист
    var sheet = doc.getSheetByName(deviceId);
    if(sheet === null){
      sheet=doc.insertSheet(deviceId);
      // в первой строке храним конфигурационные данные для контроллера;
      var ins = sheet.appendRow(["Порог",1800 ]);
    }
    // добавить в сохраняемый массив все данные из полученного объекта
    for each (var i in keys){
      row.push(rowJSON[i]);
    }
    // сохранение массива в новой строке таблицы
    var r = sheet.getRange(sheet.getLastRow() + 1, 1, 1, row.length);
    r.setValues([row]);
    // получить конфигурацию из первой строки листа
    var config=sheet.getSheetValues(1, 2, 1, 1)[0][0];
  }catch(er){
    console.error('error: ' + er);
    return ContentService.createTextOutput('error: ' + er)
  }
  // возврат конфигурации
  return ContentService.createTextOutput(
           JSON.stringify({config:config}))
           .setMimeType(ContentService.MimeType.JSON);
}

После вставки скрипта его нужно сохранить (Ctrl–S) и опубликовать («Опубликовать → Развернуть как web-приложение»). В открывшемся окне (рисунок 3) нужно установить версию проекта – «Новый», уровень доступа – «Все, включая анонимных пользователей», и скопировать адрес скрипта, который нужно занести в переменную urlGS в контроллере. Обратите внимание, скрипт доступен всем, знающим его адрес.

Рис. 3. Развертывание скрипта

Рис. 3. Развертывание скрипта

Что делать, если контроллер часто перегружается

Предустановленная производителем настройка Brownout на многих отладочных платах ESP32 приводит к частым перезагрузкам. Не всегда удается исправить ситуацию применением более мощного источника питания, проводов с большим сечением и дополнительных конденсаторов. В некоторых случаях проще изменить настройки.
Как проверить текущую настройку Brownout? После выполнения команды «mos build» в рабочей папке приложения была создана директория «build\gen». Текущие настройки brownout SDK Expressif находятся в файле «sdkconfig». По умолчанию они выглядят следующим образом:


CONFIG_BROWNOUT_DET=y
CONFIG_BROWNOUT_DET_LVL_SEL_0=y
CONFIG_BROWNOUT_DET_LVL_SEL_1=
CONFIG_BROWNOUT_DET_LVL_SEL_2=
CONFIG_BROWNOUT_DET_LVL_SEL_3=
CONFIG_BROWNOUT_DET_LVL_SEL_4=
CONFIG_BROWNOUT_DET_LVL_SEL_5=
CONFIG_BROWNOUT_DET_LVL_SEL_6=
CONFIG_BROWNOUT_DET_LVL_SEL_7=
CONFIG_BROWNOUT_DET_LVL=0

Ориентировочно эти настройки позволяют задать напряжение, при котором произойдет сброс контроллера в диапазоне 2,40…2,74 В. Нужно иметь в виду, что точность внутреннего источника опорного напряжения контроллеров ESP32 составляет 10%.
Настройку можно изменить добавлением строки в раздел «build_vars» файла конфигурации «mos.yml». Например, следующая конфигурация отключает «Brownout» полностью:


build_vars:
  ESP_IDF_SDKCONFIG_OPTS: "${build_vars.ESP_IDF_SDKCONFIG_OPTS} CONFIG_BROWNOUT_DET="

После сборки приложения в файле «sdkconfig» будут следующие настройки:



CONFIG_TASK_WDT_CHECK_IDLE_TASK=y
CONFIG_BROWNOUT_DET=
CONFIG_ESP32_TIME_SYSCALL_USE_RTC=

Безопасность контроллера

Шифрование памяти контроллера ESP32

Для обеспечения безопасности сетевого соединения между контроллером и Google Cloud Iot Core используется TLS и аутентификация JWT, защищенная асимметричными ключами.
Но есть еще локальная безопасность, то есть меры по предотвращению получения учетных данных, ключей и возможного изменения программного обеспечения любым человеком, получившим физический доступ к устройству.
В общем случае для обеспечения локальной безопасности достаточно выполнения четырех условий:

  • Flash-память должна быть зашифрована (контроллеры семейства ESP32 поддерживают эту возможность;
  • загрузка должна выполняться в безопасном режиме, то есть при каждом запуске устройство должно проверять, оригинальность загруженной прошивки. Это особенно важно после ее обновления. Данный функционал реализован в ESPressif Iot Development Framework (ESP-IDF), но в Mongoose пока еще не доступен;
  • желательно затруднить доступ к разъемам для программирования;
  • нужно отключить весь ненужный сетевой и локальный функционал.

Ниже мы обсудим первый и последний пункты. Остальное выходит за пределы данной статьи.

Незашифрованная память

Для начала проверим, можно ли из памяти контроллера извлечь настройки Wi-Fi сети, токены и исходный код приложения, имея доступ к UART.
С помощью программы «mos» можно легко извлечь содержимое памяти контроллера и выделить блоки, содержащие конфиденциальную информацию. Например, вот команда, которая выведет на экран 2000 байт, начиная с адреса 0x190000, в которых можно увидеть часть программного кода (рисунок 4):


mos flash-read –platform esp32 0x190000 2000 –

Рис. 4. Вывод части программы из памяти

Рис. 4. Вывод части программы из памяти

А с помощью данной команды можно сохранить содержимое всей памяти в файл content.bin, и в текстовом редакторе по словам token, PRIVATE KEY и прочим найти то, что нужно:


mos flash-read –platform esp32 content.bin

Примечание. Для чтения памяти контроллера ESP32 его требуется перевести в режим BOOT. Для этого нужно нажать кнопку сброса, удерживая кнопку BOOT. Если у вас контроллер без отладочной платы, то роль кнопки BOOT выполнит замыкание на землю вывода GPIO0, а кнопки сброс – ChipPU. После этого в терминале вы увидите строку о переходе контроллера в режим загрузки [1].

Шифрование Flash-памяти

Приведенные выше примеры показывают, что память контроллера желательно шифровать. Flash-память ESP32 имеет встроенные возможности для этого и в ОС Mongoose можно легко запустить процесс шифрования.
Обратите внимание, что процесс шифрования памяти необратим, и чтобы перепрошить контроллер, потребуется вновь использовать созданные ключи шифрования.

Программирование фьюзов

Для включения шифрования Flash-памяти при следующей прошивке предусмотрена специальная команда:


mos -X esp32-gen-key flash_encryption_key fe.key \
--esp32-enable-flash-encryption --dry-run=false

Программирование должно завершиться сообщением (рисунок 5):
Programming eFuses…
Success

Рис. 5. Успешное программирование фьюзов

Рис. 5. Успешное программирование фьюзов

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

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

Прошивка контроллера

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


mos flash esp32 --esp32-encryption-key-file fe.key

Рис. 6. Шифрование контроллера

Рис. 6. Шифрование контроллера

Шифрование должно завершиться сообщением:
All done!
Как и во всех случаях, когда выполнялась прошивка контроллера, после этого требуется:

  • настроить Wi-Fi;
  • если файл «init.js» изменялся после создания прошивки, нужно загрузить файл в контроллер;
  • обновить конфигурации, связанные с облачными сервисами (mdash, Google Cloud, AWS и так далее).

Впрочем, часть настроек можно перенести в файл конфигурации производителя confX.json.

После включения шифрования чтение из памяти не позволит получить осмысленный текст (рисунок 7)..

Рис. 7. Чтение зашифрованной памяти

Рис. 7. Чтение зашифрованной памяти

Отключение ненужного функционала

Для скачивания через web достаточно зайти на IP-адрес контроллера в локальной сети. Например, по адресу «http://{IP адрес контроллера}/conf5.json» можно прочитать соответствующий конфигурационный файл с настройками сети и токенами (рисунок 8).

Рис. 8. Чтение конфигурационного файла

Рис. 8. Чтение конфигурационного файла

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


- ["http.hidden_files", "*.json"]

Помимо случая с доступностью файлов при работе контроллера в режиме web-сервера можно выделить следующие проблемы:

  • небольшой контроллер с малым количеством оперативной памяти может легко перейти в состояние DoS, обрабатывая множество сетевых подключений. Скажем, если контроллер имеет 40 кбайт свободной оперативной памяти, а каждое соединение занимает 10 кбайт, тогда для отказа в обслуживании достаточно четырех подключений:
  • необходимо разрабатывать механизмы аутентификации и авторизации, которые потенциально уязвимы;
  • если для доступа используется TLS, время установки соединения может быть большим из-за необходимости шифрования, что приводит к задержкам и недовольству пользователей;
  • управление сертификатами TLS для локально работающих контроллеров может быть нетривиальным.

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

  • невозможно взломать устройство напрямую, потому что его не видно в сети;
  • аутентификация и авторизация обрабатываются на стороне мощного сервера или облака, и весьма приветствуется использование при этом систем безопасности таких сервисов как Google IoT Core или AWS IoT;
  • все взаимодействие происходит только с одним облачным сервером через TLS;
  • требуется только одно сетевое подключение, что существенно экономит ресурсы.

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

Защита RPC вызовов

Для управления контроллерами в Mongoose OS предусмотрен механизм RPC. Например, через этот механизм работает программа mos, причем независимо от того, как выполняется команда — через последовательный порт или удаленно. Отображение списка файлов, конфигурации, объема свободной оперативной памяти, переключение выводов GPIO – все это возможно благодаря RPC.
По умолчанию, если не принять специальных мер, все виды RPC-команд разрешены для всех пользователей и всех протоколов. Поэтому для обеспечения безопасности существуют несколько механизмов ограничения доступа к службам RPC:

  • включить аутентификацию – доступ только для определенных пользователей;
  • включить авторизацию – список разрешений для конкретных пользователей;
  • определить транспорты, по которым будут разрешены RPC-вызовы;
  • как крайняя мера – отключить все функции RPC.

Включение аутентификации

Mongoose OS реализует аутентификацию с использованием механизма HTTP Digest:

  • клиент отправляет запрос;
  • если аутентификация не включена, сервер отвечает;
  • если аутентификация включена и запрос клиента содержит данные аутентификации, сервер отвечает;
  • если аутентификация включена и клиентский запрос не включает данные аутентификации, сервер отправляет обратно ошибку, запрашивая аутентификацию со случайным одноразовым идентификатором для предотвращения атаки перебором ключей;
  • клиент должен повторить запрос аутентификации, включая одноразовый идентификатор, полученный с сервера.

В случае использования HTTP данные аутентификации отправляются в заголовке HTTP Authorization. В случае RPC данные аутентификации отправляются в качестве дополнительного auth-ключа в массиве данных RPC.
Аутентификация включается с помощью создания файла паролей в стандартном формате «htdigest» и установки его в файле конфигурации. Создать файл можно командой «Apache htdigest» (для использования команды нужно скачать Apache с официального сайта).
Вот пример команд, которые создают файл паролей для пользователя Joe, загружают этот файл в контроллер и настраивают RPC для его использования:


htdigest -c rpc_auth.txt myproduct joe
mos put rpc_auth.txt
mos config-set rpc.auth_domain=myproduct
mos config-set rpc.auth_file=rpc_auth.txt

Если надо добавить новых пользователей, то команду «Htdigest» надо повторить без ключа -c.

Как включить авторизацию

После прохождения аутентификации с помощью файла «rpc_acl.json» для каждого пользователя можно определить список методов, доступных через RPC.
Создадим файл разрешением «rpc_acl.json» и загрузим его в контроллер. Например, следующий файл разрешает пользователю Joe работу с файлами, а пользователь Serg может выполнять перезагрузку:


[
  {"method": "FS.*", "acl": "+Joe,-Serg"},
  {"method": "SYS.*", "acl": "+Serg"}
]

Для загрузки файла в контроллер и его использования применим следующие команды:


mos put rpc_acl.json
mos config-set rpc.acl_file=rpc_acl.json

После этого, попытка использования RPC неавторизованным пользователем через любой протокол даст ошибку (рисунки 9 и 10).

Рис. 9. Блокировка RPC-вызова через http

Рис. 9. Блокировка RPC-вызова через http

Рис. 10. Код блокировки RPC-вызова через UART

Рис. 10. Код блокировки RPC-вызова через UART

В то же время авторизованный пользователь может получить список файлов с помощью команды:


mos ls -l --rpc-creds=joe:11111

Отключение RPC для определенных протоколов

В операционной системе можно определить доступность RPC-вызовов для определенных транспортов: HTTP, Websocket, MQTT, Bluetooth. Например, следующая строка отключает обработку полученных по HTTP запросов:


mos config-set rpc.http.enable=false

Естественно, конфигурацию можно изменить как через программу mos, так и изменением файла конфигурации или в коде.

Полное отключение RPC

Это самый радикальный метод. Для его реализации достаточно удалить все библиотеки, содержащие в названии слово «rpc» из файла конфигурации «mos.yml». Такое решение полностью удаляет функциональность RPC из прошивки.
Однако обратите внимание, что безопасность RPC во многом определяется безопасностью транспорта. Например, при использовании RPC через AWS IoT вы получите защищенный аутентифицированный механизм AWS IoT, использующий TLS и политику доступа. Таким образом, шифрование, аутентификация и авторизация канала RPC будут достаточно надежно обеспечиваться AWS. А использование RPC поверх простого HTTP без проверки подлинности и авторизации будет защищено очень слабо.
Вместо RPC для удаленного конфигурирования и управления устройством можно использовать облачный сервис, например, Shadow AWS IoT или config/state/command сообщения Google IoT Core.

Специальные темы MQTT config и state

Базовое представление о работе этих двух видов сообщений можно получить в документации. На блок-схеме проекта в предыдущей части статьи кроме сообщений телеметрии видны два других потока данных: Config и State (рисунок 11).

Рис. 11. Потоки данных конфигурации и состояния

Рис. 11. Потоки данных конфигурации и состояния

Действительно, в Cloud IoT Core можно опубликовать сообщение об обновлении конфигурации в специальной теме, на которую будет подписано устройство [3]. Это полезно, когда нам нужно, чтобы устройство перешло в новое состояние, например, путем обновления параметра соответствующего датчика, изменения периода глубокого сна, перемещения серводвигателя и так далее. Google не рекомендует посылать более одного сообщения такого типа в секунду на устройство. Каждое такое сообщение может быть размером до 64 кбайт. Название темы конфигурации MQTT должно следовать следующему шаблону:


/Device/{DEVICE-ID}/config

Свое состояние устройство может публиковать в специальной теме, на которую автоматически подписывается Cloud IoT Core – state [4]. Например, в сообщение можно включить количество доступной оперативной памяти, состояние кнопок, напряжение батареи и так далее. По строке состояния можно понять, дало ли изменение конфигурации ожидаемый эффект. Как и в случае с конфигурацией, состояние рекомендуется публиковать не чаще раза в секунду, и размер сообщения не должен превышать 64 кбайт. Шаблон темы состояния выглядит так:


/devices/{DEVICE-ID}/ state

Пример практического использования темы конфигурации

Реализуем следующий сценарий. Новое устройство подключается к IoT Core, для него в Firebase создаются настройки из шаблона по умолчанию и передаются на устройство в сообщении конфигурации. Соответственно, в прошивке контроллера мы должны подписаться на тему конфигурации и проанализировать полученное сообщение JSON, чтобы выполнить необходимые изменения.

Подписка на тему «config»

Функция для обработки сообщения конфигурации в файле «fs/init.js» может выглядеть примерно следующим образом:


let topic = '/devices/' + Cfg.get('device.id') + '/';
MQTT.sub(topic + "config", function (conn, top, msg) {
    print('Topic ', top, 'Got config update:', msg.slice(0, 100));
    if (msg) {
        let obj = JSON.parse(msg);
        if (obj) {
            if (typeof obj.s1 === "number")
                Sensors.list[0].set = obj.s1;
            if (typeof obj.s2 === "number")
                Sensors.list[1].set = obj.s2;
            if (typeof obj.s3 === "number")
                Sensors.list[2].set = obj.s3;
            if (typeof obj.hist === "number")
                Sensors.hist = obj.hist;
        }
    }
}, null);

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

Автоматизация регистрации устройств в Firebase

В предыдущей статье в Firebase список устройств создавался вручную. Автоматизируем этот процесс. Для этого после перезагрузки устройства при подключении к облаку будем отправлять специальное сообщение в подпапку «registry» (мы можем создать множество подпапок для различных целей и подписать на них различных получателей для реализации сложной логики обработки сообщений). Подпапка была уже создана в предыдущей статье командой:


# создание хранилища для устройств (cabine-devices-registry)
# определение топика Pub/Sub для публикации сообщений,
# в том числе для подпапки
# запрет подключения по протоколу HTTP
gcloud iot registries create cabine-devices-registry / 
--project=cabine-sensor-project --region=europe-west1 /
--event-notification-config=topic=registry-topic,subfolder=registry /
--event-notification-config=topic=main-telemetry-topic /
--no-enable-http-config

В этой команде создаются два топика для публикации. Для топика «registry-topic» определяется подпапка «registry».
Логику, отправляющую сообщение в папку «registry» после получения IP-адреса от точки доступа, реализует следующий код:


// универсальная функция для отправки сообщений в разные топики
let MQTTSend = function (suff, obj) {
    let msg = JSON.stringify(obj);
    // проверка, что соединение установлено
    let isConn = MQTT.isConnected();
    if (isConn) {
        let ok = MQTT.pub(topic + suff, msg, 1);
        print(ok, suff, msg);
    } else {
        print(suff + "= Not connected! ", msg);
    }
}; 
// обработка событий сети
Event.addGroupHandler(Net.EVENT_GRP, function (ev, evdata, arg) {
    // событие – получен IP адрес
    if (ev === Net.STATUS_GOT_IP) {
        MQTTSend("events/registry", 
           {device: Cfg.get('device.id'), ind: ind, port: port});
    }
}, null); 

В Firebase для подписки на сообщения регистрации, создания и отправки конфигурации на устройство можно использовать следующий шаблон функции:


exports.detectREgistryEvents = 
  functions.pubsub.topic('registry-topic').
    onPublish((message, context) => {
    // получение ID контроллера
    const deviceId = message.attributes.deviceId;
    console.log(`REGISTER Device=${deviceId}`);
    // попытаться найти запись с идентификатором 
    // устройства в разделе devices-ids
    return db.ref(`devices-ids/${deviceId}`)
    .once('value')
    .then((snapshot) => {
      const val = snapshot.val();
      if (val) {
          // если запись найдена, можно, например, обновить состояние
          console.log(`Founded ${val}`);
          return;
      } else {
          // создать конфигурацию устройства из настроек 
          // по умолчаний config_def
          db.ref(`config_def`)
             .once('value')
             .then((snapshot) => {
                // считать конфигурацию по умолчанию
                const val = snapshot.val();
                console.log(`Config Def ${val}`);
                return {config: val };
             })
             .then((conf) => {
                // сохранить ее как конфигурацию для 
                // конкретного контроллера
                console.log(`Config Set ${conf} , ${val}`);
                db.ref(`devices-ids/${deviceId}`).set(conf);
                // отправить конфигурацию на устройство
                return ConfigPromise(conf.config, deviceId);
             });
      }
   });
});

Отправка конфигурации на устройство выполняется вызовом функции «ConfigPromise(conf.config, deviceId)»:


function ConfigPromise(config, device) {
    return new Promise((resolve, reject) => {
.... // получим авторизацию в сервисах Google
        return getAuthorizedClient().then((client) => {
            // сохраним полученную авторизацию в подготавливаемом запросе
            google.options({
                auth: client
            });
            // получение адреса регистра
            const parentName = `projects/${process.env.GCLOUD_PROJECT}/`+
                         `locations/europe-west1`;
            const registryName = `${parentName}/registries/cabine-devices-registry`;
            // преобразование конфигурации в base64
            const binaryData = Buffer.from(JSON.stringify(config)).toString('base64');
            // формирование запроса
            const request = {
                name: `${registryName}/devices/${device}`,
                versionToUpdate: 0,
                binaryData: binaryData
            };
            // отправка запроса через API CloudIoT
            return google.cloudiot('v1').projects.locations
              .registries.devices
              .modifyCloudToDeviceConfig(request, (err, response) => {
                if (err) {
                    console.log(`The API returned an error: ${err}`);
                    return reject(err);
                }
                console.log("setDeviceConfig finished");
                return resolve(response.data);
            });
        });
    });
}

Для использования API Google в большинстве случаев требуется авторизация. Если при отправке данных напрямую из контроллера в Google Sheet авторизацию удалось обойти за счет существенного снижения уровня безопасности (в таблицу смогут писать все, у кого есть адрес скрипта), то здесь такое решение будет нецелесообразным. При использовании облачных функций «Firebase» авторизацию «OAuth2» можно реализовать полностью. В приведенной выше функции «ConfigPromise()» вызов «getAuthorizedClient()» возвращал токен «Oauth». Ниже рассмотрим все функции, необходимые для работы авторизации. Для получения токена авторизации нужно в web-приложении создать специальный адрес, на который будет выполнен переход в процессе авторизации. В коде этот адрес задается константой «FUNCTIONS_REDIRECT»:


const {google} = require('googleapis');
const {OAuth2Client} = require('google-auth-library');
// идентификатор клиента и ключ, который позже получим из консоли Google
const CONFIG_CLIENT_ID = functions.config().googleapi.client_id;
const CONFIG_CLIENT_SECRET = functions.config().googleapi.client_secret;
// адрес функции, вызываемой при редиректе со страницы авторизации
// для сохранения токена.
const FUNCTIONS_REDIRECT = 
`https://us-central1-${process.env.GCLOUD_PROJECT}.cloudfunctions.net/oauthcallback`;
// перечень API, в которых работает авторизация
// сразу добавлен APi для работы с google sheet и cloud iot
const SCOPES = ['https://www.googleapis.com/auth/spreadsheets', 
                                 'https://www.googleapis.com/auth/cloudiot'];
// создание объекта - клиента авторизации
const functionsOauthClient = new OAuth2Client(
        CONFIG_CLIENT_ID, 
        CONFIG_CLIENT_SECRET,
        FUNCTIONS_REDIRECT);

// сохраняемый в приложении токен OAuth.
let oauthTokens = null;

// создание страницы, которая будет запрашивать авторизацию
// ее нужно открыть в браузере для получения авторизации
// адрес функции выглядит примерно так:
//https://us-central1-{PROJECT}.cloudfunctions.net/authgoogleapi
exports.authgoogleapi = functions.https.onRequest((req, res) => {
    res.set('Cache-Control', 'private, max-age=0, s-maxage=0');
    res.redirect(functionsOauthClient.generateAuthUrl({
        access_type: 'offline',
        scope: SCOPES,
        prompt: 'consent',
    }));
});

// задание пути, по которому будет храниться полученный токен авторизации
const DB_TOKEN_PATH = '/api_tokens';

// функция обратного вызова, на которую будет осуществлен переход 
// после авторизации. Функция должна сохранить полученный токен в базе
// адрес функции выглядит примерно так:
//https://us-central1-{PROJECT}.cloudfunctions.net/oauthcallback
exports.oauthcallback = functions.https.onRequest(async(req, res) => {
    res.set('Cache-Control', 'private, max-age=0, s-maxage=0');
    const code = req.query.code;
    try {
        const {tokens} = await functionsOauthClient.getToken(code);
        // сохранение токена в базе.
        await db.ref(DB_TOKEN_PATH).set(tokens);
        return res.status(200).send(
                 'App successfully configured with new Credentials. '
                + 'You can now close this page.');
    } catch (error) {
        return res.status(400).send(error);
    }
});
// функция, использующая сохраненный токен для получения авторизации
async function getAuthorizedClient() {
    if (oauthTokens) {
        return functionsOauthClient;
    }
    const snapshot = await db.ref(DB_TOKEN_PATH).once('value');
    oauthTokens = snapshot.val();
    functionsOauthClient.setCredentials(oauthTokens);
    return functionsOauthClient;
}

Теперь все необходимые функции написаны, можно выполнить deploy для переноса их в облако и перейти в консоль Google для выполнения необходимых настроек.
Прежде всего, зайдем в консоль «Firebase» и узнаем web-адреса функций, созданных для выполнения авторизации. Они написаны мелким шрифтом в колонке «Триггер» (рисунок 12).

Рис. 12. Функции авторизации в панели «Firebase»

Рис. 12. Функции авторизации в панели «Firebase»

Создание идентификатора OAuth

Нужно открыть страницу консоли, в меню «API и сервисы» выбрать раздел «Учетные Данные», выбрать проект.
На закладке «Окно запроса доступа OAuth» в список авторизованных доменов добавить домен проекта с облачными функциями (рисунок 13).

Рис. 13. Добавление авторизованного домена

Рис. 13. Добавление авторизованного домена

После сохранения настроек можно перейти на закладку «Учетные данные» и нажать кнопку «Создать учетные данные». Из выпавшего списка нужно выбрать «Идентификатор клиента OAuth» (рисунок 14).

Рис. 14. Создание идентификатора

Рис. 14. Создание идентификатора

В открывшемся окне указать тип идентификатора – «веб-приложение» и занести в поле «Разрешенные URI перенаправления» адрес облачной функции «oauthcallback» (рисунок 15).

Рис. 15. Создание идентификатора клиента OAuth

Рис. 15. Создание идентификатора клиента OAuth

Открыв созданный идентификатор, можно увидеть значение идентификатора и секретный ключ клиента (рисунок 16).

Рис. 16. Идентификатор и секрет клиента

Рис. 16. Идентификатор и секрет клиента

Эти две строки нужно занести в конфигурацию «Firebase» командами:


firebase functions:config:set /
 googleapi.client_id="{ИДЕНТИФИКАТОР КЛИЕНТА} " /
 googleapi.client_secret="{СЕКРЕТ КЛИЕНТА}"

и выполнить «deploy».

Теперь осталось разрешить работу API в созданном проекте. Это можно сделать в консоли Google в меню «API и сервисы – Библиотека». Для работы приведенного выше кода требуются два API:

  • Google Sheets API (потребуется позже для сохранения данных в Google Sheets);
  • Google Cloud IoT API.

Эти API нужно найти через поиск и активировать.

И, наконец, собственно получение ключа в приложении. Для этого нужно открыть в браузере адрес функции «authgoogleapi» (он виден в консоли «Firebase»).

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

Рис. 17. Выбор аккаунта

Рис. 17. Выбор аккаунта

Рис. 18. Предоставление доступа к приложению

Рис. 18. Предоставление доступа к приложению

Рис. 19. Предоставление доступа к API

Рис. 19. Предоставление доступа к API

Рис. 20. Финальная страница

Рис. 20. Финальная страница

Проверим сохранение токена в firebase (рисунок 21).

Рис. 21. Сохраненный токен в базе данных Firebase

Рис. 21. Сохраненный токен в базе данных Firebase

После подключения устройства в Firebase успешно создан идентификатор устройства и его конфигурация (рисунок 22).

Рис. 22. Сохраненная конфигурация

Рис. 22. Сохраненная конфигурация

Таким образом мы создали систему, которая во все вновь подключаемые устройства отправляет актуальные данные конфигурации. Функция, с помощью которой контроллер обновит полученную конфигурацию, была описана ранее в разделе «Подписка на тему «config».

Публикация в теме «state»

Для публикации состояния не требуется создавать специальный топик в Google IoT Core – все уже предусмотрено. По аналогии с сообщением регистрации из предыдущего раздела в нужном месте надо добавить следующий вызов функции:


MQTTSend("state", state);

Эта функция передаст объект с состоянием устройства в тему «State».

Просмотр опубликованного состояния и конфигурации в Google Cloud Console

В истории конфигурации и состояния в IoT Core видна созданная конфигурация и полученные от устройства состояния (рисунок 23).

Рис. 23. Конфигурация и состояния в IoT Core

Рис. 23. Конфигурация и состояния в IoT Core

Передача данных в Google-таблицы

Использование Google Sheet вместо web-интерфейса в ряде случаев позволяет существенно сократить затраты на разработку. Ранее в этой статье было рассказано, каким образом контроллер может напрямую передать данные в Google Sheet. Такой метод можно использовать, но нужно учитывать два момента:

  • Безопасность. Полноценную авторизацию на небольшом контроллере — реализовать сложно, и скрипт будет доступен для всех;
  • Ресурсы контроллера. Использование HTTPS требует большого количества вычислительных ресурсов, выполнение запроса достаточно медленное.

Использование облачных функций «Firebase» и Cloud IoT Core позволяет реализовать этот функционал более безопасно и с меньшими затратами ресурсов контроллера.

При работе с Google Spreadsheet нужно решить следующие задачи:

  • пройти аутентификацию и авторизацию;
  • создать лист таблицы для каждого устройства;
  • занести данные в таблицу.

Первый пункт был реализован в прошлом разделе при отправке сообщений конфигурации. Перейдем к следующим пунктам.

Создание листа для каждого устройства

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


db.ref(`config_def`)
    .once('value')
    .then((snapshot) => {
        const val = snapshot.val();
        console.log(`Config Def ${val}`);
        return {config: val, indicator: indicator, port: port};
    })
    .then((conf) => {
        console.log(`Config Set ${conf} , ${val}`);
        db.ref(`devices-ids/${deviceId}`).set(conf);
        return ConfigPromise(conf.config, deviceId);
    })
   // вызов функции создание нового листа
    .then((val) => {
        return createSheetPromise(deviceId);
    });

И добавить функции для создания листа:


// перед использованием нужно задать конфигурацию командой
// firebase functions:config:set googleapi.sheet_id="{ID ДОКУМЕНТА}"
// {ID ДОКУМЕНТА} – длинная текстовая строка в адресе
// и выполнить deploy
const CONFIG_SHEET_ID = functions.config().googleapi.sheet_id;

/*
функция выполняет собственно добавление листа в книгу
*/
function addSheetPromise(auth, sheetName) {
    console.log("addSheet" + sheetName);
    const requestAdd = {
        spreadsheetId: CONFIG_SHEET_ID,
        auth: auth,
        resource: {
            requests: [
                {
                    addSheet: {
                        properties: {
                            title: sheetName,
                        }
                    }
                }
            ]
        }
    };
    const sheets = google.sheets('v4');
    return new Promise((resolve, reject) => {
        return sheets.spreadsheets.batchUpdate(requestAdd, (err, response) => {
            if (err) {
                console.log(`addSheet The API returned an error: ${err}`);
                return reject(err);
            }
            console.log("sheet added");
            return resolve(auth);
        });
    });
}

// функция для получения списка свойств книги
// в том числе перечня листов
function getSheetInfoPromise(auth) {
    console.log("getSheetInfo");
    const requestGet = {
        spreadsheetId: CONFIG_SHEET_ID,
        fields: "sheets.properties",
        auth: auth
    };
    const sheets = google.sheets('v4');
    return new Promise((resolve, reject) => {
        return sheets.spreadsheets.get(requestGet, (err, response) => {
            if (err) {
                console.log(`getSheetInfoThe API returned an error: ${err}`);
                return reject(err);
            }
            response.data.auth = auth;
            return resolve(response.data);
        });
    });
}
// функция проверяет наличие листа по имени, и если его нет, то создает лист
function createSheetPromise(deviceId) {
    console.log(`createSheet ${deviceId}`);
    const sheets = google.sheets('v4');
    return new Promise((resolve, reject) => {
        return getAuthorizedClient()
          .then((client) => {
            console.log("getAuthorizedClient");
            // получение свойств книги
            return getSheetInfoPromise(client);
          })
         .then((data) => {
            // проверка, что книга содержит нужный лист
            let haveSheet = false;
            if (typeof data.sheets === "object") {
                // перебор листов и сравнение наименований
                data.sheets.forEach(function (sh) {
                    if (sh.properties.title == deviceId) {
                        haveSheet = true;
                    }
                });
            }
            if (!haveSheet) {
                // если листа нет, то вызов функции создания листа
                return addSheetPromise(data.auth, deviceId);
            } else {
                return data.auth;
            }
        }).catch(error => {
            console.log(error);
        });
    });
}

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

Добавление данных в Google Spreadsheet

При получении сообщений телеметрии одновременно с сохранением данных в Firebase можно добавить эти данные в таблицу Google. Используя API Google, задачу можно реализовать с помощью следующего кода:


// получает массив с данными для записи и идентификатор устройства
function AddDatatoSheetPromise(sourceData, deviceId) {
    const sheets = google.sheets('v4');
    return new Promise((resolve, reject) => {
        return getAuthorizedClient()
          .then((client) => {
            // формирование запроса для добавления данных
            const request = {
                spreadsheetId: CONFIG_SHEET_ID,
                range: deviceId + '!A:C',
                valueInputOption: 'USER_ENTERED',
                insertDataOption: 'INSERT_ROWS',
                resource: {
                    values: [sourceData],
                },
            };
            request.auth = client;
            // вызов API для добавления в конец таблицы
            return sheets.spreadsheets.values.append(request, (err, response) => {
                if (err) {
                    console.log(`The API returned an error: ${err}`);
                    return reject(err);
                }
                return resolve(response.data);
            });
        }).catch(error => {
            console.log(error);
        });
    });
}

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

Передача в BigQuery

BigQuery – это инструмент Google, который позволяет работать с большими массивами данных, используя запросы SQL. Перед его использованием нужно создать таблицы, в которых будут храниться данные. Для создания перейдите в раздел BigQuery консоли Google и выполните шаги, отраженные на рисунках 24…26.

Рис. 24. Создание набора данных в проекте

Рис. 24. Создание набора данных в проекте

Рис. 25. Создание набора данных

Рис. 25. Создание набора данных

Рис. 26. Задание полей и названия таблицы, сохранение

Рис. 26. Задание полей и названия таблицы, сохранение

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


firebase functions:config:set bigquery.datasetname="station_data"
 firebase functions:config:set bigquery.tablename="sensor_data"

Затем в основной код добавить функции для сохранения и извлечения данных:


// подключение bigQuery
const {BigQuery} = require('@google-cloud/bigquery');
const bigquery = new BigQuery();
// вставка в функцию, получающую телеметрию
exports.detectTelemetryEvents = 
functions.pubsub.topic('main-telemetry-topic').onPublish(
(message, context) => {
     ...
    // json с именами свойств, совпадающими с полями таблицы
    const data = {
        timestamp: timestamp,
        ip: ip,
        light: light,
        dist1: d1,
        dist2: d2,
        dist3: d3,
        state1: message.json.st.s1,
        state2: message.json.st.s2,
        state3: message.json.st.s3,
        deviceId: deviceId,
        heap: ram,
        uptime: uptime
    };
    // одновременное обновление всех мест хранения
    //firebase, big query, google sheet:
    return  Promise.all([
        insertIntoBigquery(data),
        updateCurrentDataFirebase(data),
        addToSheet(data)
    ]);

// функция для сохранения data в bigquery
function insertIntoBigquery(data) {
    const dataset = bigquery.dataset(functions.config().bigquery.datasetname);
    const table = dataset.table(functions.config().bigquery.tablename);
    return table.insert(data).catch(error => {
        console.log(JSON.stringify(error));
    });
}
/**
 * доступный из браузера запрос, извлекающий усредненные
 * в пределах часа данные и возвращающий JSON
 */
exports.getReportData = functions.https.onRequest((req, res) => {
    const projectId = process.env.GCLOUD_PROJECT;
    const datasetName = functions.config().bigquery.datasetname;
    const tableName = functions.config().bigquery.tablename;
    const table = `${projectId}.${datasetName}.${tableName}`;
    const query = `
    SELECT 
      TIMESTAMP_TRUNC(data.timestampp , HOUR, 'America/Cuiaba') data_hora,
      avg(data.light) as avg_l,
      avg(data.distance) as avg_m,
      count(*) as data_points      
    FROM \`${table}\` data
    WHERE data.timestampp between timestamp_sub(current_timestamp, INTERVAL 7 DAY) and current_timestamp()
    group by data_hora
    order by data_hora
  `;
    return bigquery
            .query({
                query: query,
                useLegacySql: false
            })
            .then(result => {
                const rows = result[0];
                cors(req, res, () => {
                    res.json(rows);
                });
            });
});

Обработать полученный JSON в браузере не составит труда. Кроме того, BigQuery легко интегрируется с Google Data Studio и позволяет быстро получить профессионально выполненные страницы отчетов.

Заключение

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

Дополнительные материалы

  1. Процесс загрузки ESP32
  2. Сообщения config и state
  3. Управление конфигурацией устройства в Google IoT
  4. Получение состояния устройства
  5. ESP32 от Espressif. Часть 1. Определяем присутствие человека
•••

Наши информационные каналы

Товары
Наименование
ESP32-S2R2 (ESPRES)