lora-at

История

С момента моей последней статьи про LoRa прошло слишком много времени. За это время у меня появился проект lora-at, про который я бы хотел написать в этой статье. И начну я, пожалуй, с истории о том, как он появился.

Изначально я планировал создать небольшую прошивку для приёма сигналов со спутников по протоколу LoRa. За основу я взял tinyGS, которая делала почти всё, что мне было нужно. tinyGS позволял составлять расписание пролётов спутников, принимать пакеты со спутников и пересылать их на центральный сервер. Мне же нужно было что-то более легковесное и не интегрированное ни с какими серверами. Достаточно было, чтобы трансивер можно было контролировать с помощью REST API и получать данные по HTTP. В итоге у меня получилось создать проект r2lora. Он оказался более энергоэффективный, чем tinyGS, и поддерживал обновления по воздуху.

Однако, после нескольких полевых испытаний, стало понятно, что использование REST API и веб сервера - не самая лучшая идея. Во-первых, необходимо поднимать Wi-Fi точку доступа. А для этого нужен либо роутер, либо Raspberry PI в режиме точки доступа. Роутер нельзя запустить в поле без аккумулятора на 12В. Raspberry PI же не всегда может работать в режиме точки доступа. Иногда хочется принести его домой, подключить к домашней сети и отправить все полученные данные в leosatdata.com. Все эти проблемы решаемы, но как по мне, результат будет выглядеть громоздко для такой простой задачи, как передать несколько байтов с одного устройства на другое.

Именно тогда мне и пришла идея контролировать LoRa трансивер через UART интерфейс. Он используется для того, чтобы залить новую прошивку на устройство и посмотреть логи. Так почему бы не использовать его и для передачи команд?

Дизайн

Сейчас я заканчиваю работу над второй версией проекта. Первая была написана на C++, использовала множество идей r2lora и, в принципе, справлялась со своей задачей. Однако, в ней было и несколько достаточно неприятных моментов. Во-первых, она использовала библиотеку RadioLib, которая не поддерживала правильный выход из спящего режима. Во-вторых, код был написан с помощью Arduino фреймворка портированного на ESP32. Этот фреймворк достаточно простой и рассчитан на новичков. С его помощью можно относительно быстро на коленке сделать работающий прототип, но мне хотелось использовать всю мощь ESP32. Например, типичный обработчик Arduino выглядит так:

void loop() {
  if (lora->isReceivingData()) {
    LoRaFrame *frame = lora->loop();
    if (frame != NULL) {
      client->sendData(frame);
      handler->addFrame(frame);
    }
  }
  // ...
}

Непонятно зачем делать бесконечный цикл, если в ESP32 есть FreeRTOS, которая позволяет с помощью прерываний и тасков обрабатывать асинхронные события. В-третьих, С++. Долгая компиляция, мешанина из концепций, интерфейсов, системных и вспомогательных библиотек - всё это не даёт уверенности в написанном коде.

Мне же хотелось:

  • C
  • Как можно более низких API и абстракций ESP32
  • Собственной библиотеки sx127x
  • Прерываний и обработчиков в отдельных тасках FreeRTOS

Из функциональных требований осталось:

  • Поддержка AT команд через UART интерфейс. Они должны контролировать каждый аспект системы
  • Хранение конфигурации во флеш памяти
  • Поддержка режима глубокого сна и интеграция через Bluetooth
  • Поддержка LoRa и FSK модуляций
  • Отправка и получение данных в пакетном режиме. Максимальный размер пакета 255 байт

Дизайн принципиально не отличается от r2lora:

at_config
at_config
at_handler
at_handler
at_timer
at_timer
at_util
at_util
ble_client
ble_client
deep_sleep
deep_sleep
display
display
sx127x_util
sx127x_util
sx127x
sx127x
ssd1306
ssd1306
Text is not SVG - cannot display

Каждая подсистема должна быть в отдельном компоненте и все вместе они должны контролироваться at_handler - обработчиком AT команд.

А вот при выборе фреймворка я решил остановится на ESP-IDF. В конце-концов - это официально поддерживаемая среда для разработки под ESP32. А значит все остальные библиотеки и фреймворки основаны на ней. А значит она достаточно низкоуровневая.

ESP-IDF

Моя основная претензия к PlatformIO и arduino-esp32 заключается в том, что они добавляют не всегда удачные уровни абстрации. Это позволяет быстро собрать нужный прототип, но, зачастую, в ущерб общей структуре проекта и поддерживаемости. Например, LoRa чип подключен по SPI шине. Её, наверное, нужно инициализировать, послать команду на чип, получить ответ и обработать. Если где-то случается проблема, то очень важно понимать в какой момент. И, читая достаточно низкоуровневый код, это легко понять. Однако, типичный пример кода для arduino-esp32:

if (!LoRa.begin(915E6)) {
  Serial.println("Starting LoRa failed!");
  while (1);
}

Что это? Конструктор? Синглетон? Где инициализация SPI шины? Какие пины используются? Зато инициализация чипа одной строчкой.

Как оказалось, несмотря на то, что ESP-IDF достаточно низкоуровневый, у него вполне понятные абстракции и очень знакомые инструменты работы. Этот фреймворк определяет то, как должен проект собираться, как тестироваться, API к внутренним системам ESP32, где и как брать зависимости - в общем всё, что нужно для разработки. На официальном сайте есть интеграции с Eclipse и VSCode, но я решил попробовать CLion. Благо он у меня уже куплен и используется для разработки других проектов на Си. С какого-то времени CLion поддерживает инициализацию с помощью environment file. Это единственное, что мне нужно было сконфигурировать. Каждый раз, когда проект нужно собрать CLion запускает стандартый скрипт export.sh и получает всю информацию о том, какой используется компилятор, где брать разные исходники и заголовочные файлы и тд.

Из неочевидных преимуществ: CLion позволяет загружать проект на удалённый хост и там его компилировать. VSCode тоже может, но для этого ему нужен Python и NodeJS последней версии. И то, и другое ужасно тормозят на RaspberryPI. У CLion такой проблемы нет. Видимо у него очень легковесная работа с удалёнными хостами.

Управление зависимостями

Работа с зависимостями в ESP-IDF очень похожа на NodeJS. Они скачиваются в виде исходного кода в специальную папку и компилируются при сборке проекта. Добавить зависимость можно с помощью команды:

idf.py add-dependency dernasherbrezon/sx127x

После этого появляется папка node_modules managed_components с необходимыми зависимостями.

В lora-at используется только две внешние зависимости:

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

idf_component_register(SRCS "ble_client.c"
        INCLUDE_DIRS "."
        REQUIRES bt nvs_flash sx127x_util)

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

#include <nvs_flash.h>

Это немного отличается от подхода PlatformIO, где прямо сразу можно подключать заголовочный файл, а IDE сама найдёт нужные зависимости и подключит. Я сторонник всего явного, поэтому подход ESP-IDF мне больше импонирует.

Компоненты самого фреймворка, которые мне пришлось использовать:

  • bt - для работы с Bluetooth
  • nvs_flash - для работы с флеш памятью. Там lora-at хранит конфигурацию и загружает её оттуда после перезагрузки
  • driver - для работы с SPI шиной, по которой управляется чип sx127x. Там же лежит драйвер для работы с GPIO

Структура проекта

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

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

void app_main(void) {
	// do the stuff
}

Конфигурация

Конфигурировать проект можно двумя способами:

  • с помощью AT команд. Подходит для опций, которые могут меняться время от времени
  • с помощью KConfig. Статическая конфигурация на этапе компиляции

Если с AT всё ясно, то вот KConfig - достаточно мощная система, которой не было в PlatformIO. Для того, чтобы добавить свою конфигурацию, необходимо создать файл KConfig в специальном формате. Я не буду останавливаться на его структуре, тем более, что в официальной документации всё очень подробно описано. После того, как файл создан, можно запускать сам конфигуратор, используя команду:

idf.py menuconfig

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

Когда я запустил его в первый раз, то был крайне удивлён. Если поисследовать доступные опции, то становится понятно, что конфигурировать можно абсолютно всё: от размера партиций, до Bluetooth стека. Этот конфигуратор подцепляет не только конфигурацию приложения, но и самого фреймворка. И в целом это логично - ведь приложение компилируется вместе с фреймворком и потом заливается на устройство одним бинарником.

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

После того, как конфигуратор отработает, в корне проекта появится файл sdkconfig, содержащий конфигурацию.

...
CONFIG_MIN_FREQUENCY=25000000
CONFIG_MAX_FREQUENCY=1700000000
...

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

#define CONFIG_MIN_FREQUENCY 25000000
#define CONFIG_MAX_FREQUENCY 1700000000

В PlatformIO есть достаточно удобная функциональность по конфигурированию различных сред. В r2lora она использовалась для быстрой сборки под конкретную плату. ESP32 и sx127x могут быть по-разному распаяны на разных платах. Это приводит к тому, что номера GPIO пинов немного не совпадают. Я заранее создал конфигурацию пинов для наиболее часто используемых плат и назначил каждой плате свою “среду”. При компиляции достаточно было указать нужную среду под которую собирается прошивка.

В ESP-IDF я сделал нечто похожее. Фреймворк позволяет заранее создать различные настройки по-умолчанию в виде sdkconfig-файлов. Например, я создал: sdkconfig.ttgo-lora32-v2, sdkconfig.ttgo-lora32-v1 и тд. В них я переопределил пины для соответствующих плат. Использовать их можно при сборке в следующем виде:

SDKCONFIG_DEFAULTS="sdkconfig.ttgo-lora32-v2" idf.py build

Если нужна более сложная конфигурация, то можно передать несколько файлов. Но для lora-at этого оказалось вполне достаточно.

Реализация

Большинство компонентов, которые были в r2cloud пришлось значительно переписать. В итоге они стали занимать больше места и делать много низкоуровневой инициализации. Если раньше получить данные с флеш памяти можно было так:

Preferences preferences;
preferences.begin("lora-at", true);
size_t chip_index = preferences.getUChar("chip_index");
preferences.end();

То сейчас:

nvs_handle_t out_handle;
esp_err_t err = nvs_open("lora-at", NVS_READONLY, &out_handle);
if( err == ESP_ERR_NVS_NOT_FOUND ) {
  return EPS_OK;
} 
if( err != ESP_OK ) {
  return err;
} 
err = nvs_get_u64(out_handle, "period", &result->deep_sleep_period_micros);
if( err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND ) {
  return err;
} 
nvs_close(out_handle);

Код выглядит более сложным, но на самом деле это не так. Просто появляется обработка ошибок и явный вызов функций nvs. В примере же на С++ непонятно, что такое Preferences. Зачем nvs нужно было переименовывать? Почему названия классов нельзя было сделать близкими к C API? Почему типы фиксированной длины вроде uint32_t они почему-то решили переименовать в UInt?

uint32_t getUInt(const char* key, uint32_t defaultValue = 0);

В то время, как оригинальный API вполне лаконичен:

esp_err_t nvs_get_u32(nvs_handle_t handle, const char* key, uint32_t* out_value);

Так много вопросов, и так мало ответов.

Несмотря на то, что С API более лаконичный, с некоторыми компонентами пришлось повозиться. В основном из-за того, что я не понимал как работает та или иная технология, а С++ API умело скрывал её от меня.

Bluetooth

Наверное, самый сложный компонент, который мне пришлось написать. Изначально я открыл обучающую статью по ESP32 Bluetooth и начал реализовывать нужные функции. Однако, в какой-то момент я наткнулся на комментарий в документации: “Если Вам нужно только взаимодействие по протоколу BLE, то лучше взять NimBLE. Он более легковесен и занимает меньше кода, по сравнению с Bluedroid”. Что ж. Пришлось разбираться, что такое NimBLE и чем он отличается от Bluedroid.

Оказывается, Bluedroid - это полноценная реализация как “классического” Bluetooth, так и Bluetooth Low Energy (BLE). И, поскольку, он поддерживает оба режима, то его API более сложный. NimBLE реализует только BLE, и поэтому более легковесный. В ESP-IDF есть поддержка обеих реализаций и можно выбрать нужную на этапе конфигурации. Делается это с помощью sdkconfig:

CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_CONTROLLER_ENABLED=y

Сам же API мне не показался легче. Оба построены на callback, когда для каждой операции передаётся функция-обработчик, которая потом асинхронно вызывается в отдельном таске FreeRTOS. Иронично, но для lora-at асинхронность не особо нужна, так как все команды должны гарантированно завершиться прежде, чем устройство перейдёт в спящий режим. Типичный код выглядит следующим образом:

int ble_client_disc_svc_fn(/* some arguments */) {
  if( error->status == 0 ) {
      client->semaphore_result = ESP_OK;
  } else {
  	 // handle error properly
      client->semaphore_result = ESP_ERR_TIMEOUT;
  }
  xSemaphoreGive(client->semaphore);
  return 0;
}

esp_err_t ble_client_find_service(/* some arguments */) {
  client->semaphore_result = ESP_FAIL;
  esp_err_t code = ble_gattc_disc_svc_by_uuid(conn_handle, remote_svc_uuid, ble_client_disc_svc_fn, client);
  if( code != ESP_OK ) {
  	return code;
  }
  WAIT_FOR_SEMAPHORE("...");
  return client->semaphore_result;
}

UART

Вторым по сложности компонентом стало чтение команд из UART. Если раньше можно было в цикле читать по одному символу:

size_t AtHandler::read_line(Stream *in) {
  size_t result = 0;
  while (in->available() > 0) {
    char inByte = in->read();
    result++;
    // if char is \n, then return result
  }
  return 0;
}

То работа через прерывания требует чуть более сложного кода. Во-первых, нужно инициализировать одну из четырёх UART шин:

uart_config_t uart_config = {
    .baud_rate = CONFIG_AT_UART_BAUD_RATE,
    .data_bits = UART_DATA_8_BITS,
    .parity = UART_PARITY_DISABLE,
    .stop_bits = UART_STOP_BITS_1,
    .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
    .source_clk = UART_SCLK_DEFAULT,
};
uart_driver_install(result->uart_port_num, CONFIG_AT_UART_BUFFER_LENGTH * 2, CONFIG_AT_UART_BUFFER_LENGTH * 2, 20, &result->uart_queue, 0);
uart_param_config(result->uart_port_num, &uart_config);
uart_set_pin(result->uart_port_num, CONFIG_AT_UART_TX_PIN, CONFIG_AT_UART_RX_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
uart_enable_pattern_det_baud_intr(result->uart_port_num, '\n', 1, 9, 0, 0);
uart_pattern_queue_reset(result->uart_port_num, 20);

Во-вторых, нужно инициализировать FreeRTOS таск и начать читать данные из uart_queue:

void uart_at_handler_process(uart_at_handler_t *handler) {
  while (1) {
    if (xQueueReceive(handler->uart_queue, (void *) &event, (TickType_t) portMAX_DELAY)) {
      switch (event.type) {
        case UART_DATA:
        	// handle using uart_read_bytes
        case UART_PATTERN_DET:
         // handle using uart_read_bytes
        case UART_FIFO_OVF:
         // handle
        case UART_BUFFER_FULL:
         // handle
	}
	// determine if data contains new line
	if (found) {
	  // call at handler
	}
  }
}

xTaskCreate(uart_at_handler_process, "uart_rx_task", 1024 * 4, handler, configMAX_PRIORITIES, NULL);

Ручной контроль заставил задуматься о том, как работает UART на низком уровне, узнать, что существует целых 4 шины и написать более эффективный код. В качестве побочного бонуса, я смог переконфигурировать UART на другие GPIO, а само устройство запитать от JST коннектора. Это позволило делать подключить Power Profiler Kit II и измерить потребление энергии. Весь процесс занял минут 10. Из них 5 минут я искал USB-to-UART кабель и 3 минуты выбирал какие лучше пины использовать.

Результаты

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

$ ls -lh ./build/lora-at.bin 
-rw-r--r-- 1 pi pi 665K Nov  5 22:09 ./build/lora-at.bin

И С++ версия:

$ ls -lh .pio/build/ttgo-lora32-v2/firmware.bin
-rw-r--r-- 1 pi pi 1.1M Nov  9 20:39 .pio/build/ttgo-lora32-v2/firmware.bin

Версия на С почти в 2 раза меньше!