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_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 используется только две внешние зависимости:
- dernasherbrezon/sx127x - для работы с LoRa чипом
- espressif/ssd1306 - для работы с OLED экраном
Помимо внешних, можно подключать компоненты самого фреймворка или свои собственные. Делается это с помощью 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 раза меньше!