Создание FOTA компонента
Эта статья продолжает цикл разработки проекта r2lora. Предыдущие статьи:
FOTA - это сокращение от Firmware-Over-The-Air (обновление по воздуху). Это специальный компонент, который обновляет приложение, если доступна новая версия.
Дизайн
Автообновление можно сделать несколькими способами:
- Специальный компонент начинает слушать TCP/UDP порт. Для обновления нужно отправить прошивку на этот порт с любого другого устройства работающего в сети. По сути push-обновление.
- Специальный компонент периодически проверяет центральный сервер на наличие новой версии. Если такая есть, то он скачивает её.
Первый способ реализован в стандартной библиотеке ArduinoOTA. Она используется повсеместно и стала стандартом де-факто в мире микроконтроллеров. Из плюсов можно отметить простоту работы: устройство начинает обновление, как только кто-то извне отправил новую версию. Не нужно создавать сложную инфраструктуру хранения и доставки обновлений: достаточно отправить прошивку с любого устройства находящегося в сети.
Но у этого подхода есть серьёзный недостаток: нужен ещё один внешний сервер (для автоматизации) или пользователь (ручное) для того, чтобы закачивать прошивку.
Я выбрал второй вариант. Он больше всего походит на классический способ обновления пакетов в Debian, Ubuntu и других операционных системах. APT или любой другой менеджер периодически проверяет сервер на наличие обновлений, скачивает и устанавливает их. Так работают все обновления безопасности почти во всех операционных системах. Из недостатков - нужно хранить прошивки на центральном сервере и FOTA-компонент должен периодически проверять доступность новой версии. В моём случае это не такая большая проблема, так как я уже поддерживаю собственный APT репозиторий. И добавление FOTA репозитория не так уж сложно.
FOTA репозиторий
Я не стал городить сложную структуру репозитория, как это сделано в APT. FOTA репозиторий состоит из двух логических файлов:
- r2lora.json - файл-индекс с текущими версиями прошивок под каждую плату
- непосредственно сами файлы прошивок
Файл-индекс имеет следующую структуру:
[
{
"board": "ttgo-lora32-v2",
"version": "1.1",
"filename": "/fotatest/ttgo-lora32-v2-1.1.bin.zz",
"size": 203984,
"md5Checksum": "6c0931332848636087c599a1ad9c06a8"
}
]
В нём находится массив json-объектов, описывающих каждую плату. Из-за того, что каждая плата отличается друг от друга, репозиторий должен хранить прошивки одной и той же версии для каждой из плат. Каждая прошивка описывается следующими обязательными полями:
Название | Описание | Пример |
---|---|---|
board | Имя платы. Должно совпадать с именем платы в PlatformIO | ttgo-lora32-v2 |
version | Версия приложения. Если текущая версия приложения не совпадает с версией в репозитории, то скачивается версия из репозитория. | 1.1 |
filename | Путь до файла внутри репозитория. Все файлы должны быть сжаты с помощью zlib. | /fota/ttgo-lora32-v2-1.1.bin.zz |
size | Размер файла прошивки в байтах ДО сжатия. Позволяет правильно рисовать прогресс бар при обновлении. | 203984 |
md5Checksum | Контрольная сумма файла прошивки ДО сжатия. Алгоритм: MD5 | 6c0931332848636087c599a1ad9c06a8 |
Все прошивки отлично сжимаются почти в 2 раза, поэтому по-умолчанию репозиторий может содержать только сжатые прошивки. При этом экономится не только место на моём S3 и сеть, но и увеличивается скорость скачивания новых версий. Это особенно актуально на таких маломощных устройствах как ESP32.
Доступ к FOTA репозиторию осуществляется с помощью HTTP, поэтому его можно развернуть где угодно. В моём случае - это Amazon S3.
Алгоритм работы
Первым делом FOTA компонент скачивает файл-индекс.
if (!this->client->begin(hostname, port, this->indexFile)) {
log_e("unable to begin");
return FOTA_UNKNOWN_ERROR;
}
if (!this->lastModified.isEmpty()) {
this->client->addHeader("If-Modified-Since", this->lastModified);
}
const char *headers[] = {"Last-Modified"};
this->client->collectHeaders(headers, 1);
int code = this->client->GET();
При этом я запоминаю время последнего обновления файла. Если файл не поменялся, то сервер должен вернуть HTTP 304. И тогда я немного сэкономлю на трафике и разборе json-файла. Для доступа к репозиторию я использую стандартный HTTPClient
из HTTPClient.h
, который поставляется вместе с фреймворком Arduino.
Далее компонент должен найти информацию о прошивке для текущей платы. PlatformIO передаёт имя платы при компиляции через параметр ARDUINO_VARIANT
.
Если информация найдена и версия не совпадает с текущей, то FOTA скачает файл прошивки с сервера. Правда, алгоритм скачивания достаточно хитрый. Дело в том, что файл полностью не поместится в памяти, поэтому надо скачивать небольшим буфером и сразу записывать в правильный раздел. Для работы с обновлениями есть специальный класс Update
из Update.h
. Он записывает новую версию в специальную партицию на флеш памяти, проверяет контрольную сумму полученного файла и устанавливает новую партицию в качестве загрузочной.
Помимо этого, FOTA компонент поддерживает специальный callback метод. По мере скачивания файла, которое может занять некоторое время, я обновляю прогресс бар.
if (this->onUpdateFunc) {
Update.onProgress(this->onUpdateFunc);
}
Эта лямбда-функция на вход получает текущее количество скачанных байт и общее количество байт. Вывод на экран при этом достаточно тривиальный:
updater->setOnUpdate([](size_t current, size_t total) {
display->setStatus("UPDATING");
display->setProgress((uint8_t)((float)current * 100 / total));
display->update();
});
Как только файл полностью скачен и контрольная сумма проверена, необходимо перезагрузить плату:
if (reboot) {
log_i("update completed. rebooting");
ESP.restart();
} else {
log_i("update completed");
}
zlib
Отдельно хочется остановиться на архивировании/разархивировании. В ESP32 нет поддержки zlib. Поэтому надо либо самому портировать zlib на ESP32, либо искать более легковесную альтернативу. И она есть - miniz. Но самое замечательное заключается в том, что ROM уже содержит реализацию основных функций miniz. Так что на размер прошивки использование miniz не влияет.
Разархивирование потока данных состоит из нескольких шагов. Во-первых, нужно инициализировать вспомогательные структуры для miniz:
tinfl_init(this->inflator);
Во-вторых, нужно создать два промежуточных буфера.
uint8_t *nextCompressedBuffer = this->compressedBuffer;
uint8_t *nextUncompressedBuffer = this->uncompressedBuffer;
В один буфер необходимо записывать сжатые данные, а в другой буфер miniz будет помещать разархивированные данные. Тут важно выделить не меньше 32768 байт для выходного массива. В документации это не описано, но почему-то miniz требует буфер именного такого размера. Если выделить меньше, то разархивация будет падать со статусом -1.
Далее интересно: нужно следить, чтобы во входящем буфере было достаточно данных для работы, и при этом, чтобы в исходящем буфере было место для новых данных.
size_t inBytes = actuallyRead;
size_t outBytes = availableOut;
status = tinfl_decompress(inflator, (const mz_uint8 *)nextCompressedBuffer, &inBytes,
this->uncompressedBuffer, nextUncompressedBuffer, &outBytes,
flags);
actuallyRead -= inBytes;
nextCompressedBuffer = nextCompressedBuffer + inBytes;
availableOut -= outBytes;
nextUncompressedBuffer = nextUncompressedBuffer + outBytes;
Как только исходящий буфер полностью заполнен, можно записывать на флэш:
size_t actuallyWrote = Update.write(this->uncompressedBuffer, bytesInTheOutput);
Однако, когда я запустил тест, то получил следующую ошибку:
[I][Fota.cpp:191] downloadAndApplyFirmware(): downloading new firmware: 203984 bytes
***ERROR*** A stack overflow in task loopTask has been detected.
abort() was called at PC 0x40088a50 on core 1
ELF file SHA256: 0000000000000000
Backtrace: 0x400887bc:0x3ffaee10 0x40088a39:0x3ffaee30 0x40088a50:0x3ffaee50 0x4008a633:0x3ffaee70 0x4008c1fc:0x3ffaee90 0x4008c1b2:0x00a42700
#0 0x400887bc:0x3ffaee10 in invoke_abort at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp32/panic.c:715
#1 0x40088a39:0x3ffaee30 in abort at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp32/panic.c:715
#2 0x40088a50:0x3ffaee50 in vApplicationStackOverflowHook at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp32/panic.c:715
#3 0x4008a633:0x3ffaee70 in vTaskSwitchContext at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/freertos/tasks.c:3507
#4 0x4008c1fc:0x3ffaee90 in _frxt_dispatch at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/freertos/portasm.S:406
#5 0x4008c1b2:0x00a42700 in _frxt_int_exit at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/freertos/portasm.S:206
Казалось бы, причём тут stack overflow
? У меня даже рекурсии нет. Оказывается, нельзя написать вот так:
tinfl_decompressor inflator;
Это создаст структуру на стеке вызова функции. И размер этой структуры больше, чем доступная память стека. Микроконтроллеры. По той же причине нельзя написать:
uint8_t uncompressedBuffer[32768];
Иначе получится:
[E][WiFiClient.cpp:62] fillBuffer(): Not enough memory to allocate buffer
[E][WiFiClient.cpp:439] read(): fail on fd 61, errno: 11, "No more processes"
Guru Meditation Error: Core 1 panic'ed (LoadProhibited). Exception was unhandled.
Решается это просто - нужно выделять объекты в heap с помощью malloc или new.
После того как я исправил все эти исключения, то обнаружил, что zlib - это не то же самое, что и gzip. У них разные заголовки. Поэтому создать прошивку следующей командой просто не получится:
gzip firmware.bin
miniz не сможет распаковать. Внутри всё тот же Deflate, но заголовки файлов отличаются. Можно воспользоваться разными хаками и подменять заголовок gzip, но мне показалось это ненадёжным. Я нашёл специальную программу pigz, которая может создавать zlib-файлы:
pigz --zlib firmware.bin
Немного неудобно, но работает.
Тестирование
Код нужно тестировать, и FOTA не исключение. Но как протестировать обновление самого себя? Ведь после обновления нужно перезагрузить программу и результат теста определённо пропадёт. Тут я пошёл на некоторые уступки и отошёл от классического юнит-тестирования:
- в метод
loop
я добавил параметрbool reboot
. Он нужен только для тестирования. - метод
loop
начал возвращать статус код. С одной стороны большинство программ на С и так возвращают статус-код. С другой стороны, он тут не нужен и используется в тестах, чтобы понять тип ошибки.
Ещё одной сложностью тестирования FOTA является сильная привязка к разной инфраструктуре. Нужно убедиться, что HTTP клиент правильно инициализирован и действительно скачивает прошивку. Нужно убедиться, что zlib распаковывает файл и контрольная сумма совпадает. “Раз нужно тестировать инфраструктуру, то нужно поднять инфраструктуру!” - подумал я и создал:
- специальные версии прошивок для каждой из плат, которые ничего не делают.
- залил их на S3
- добавил множество файлов-индексов с разными возможными ошибками
Пример юнит-теста ниже:
void test_non_existing_file() {
Fota fota;
fota.init("1.0", "apt.leosatdata.com", 80, "/fotatest/missingfile.json", 24 * 60 * 60 * 1000, ARDUINO_VARIANT);
TEST_ASSERT_EQUAL(FOTA_INVALID_SERVER_RESPONSE, fota.loop(false));
}
FOTA инициализируется и пытается проверить обновление. missingfile.json
- это заранее созданный файл с ожидаемой ошибкой, который я загрузил на S3.
Ещё одним неудобством стало подключение к WiFi перед выполнением теста. Ведь для того, чтобы скачать с S3 нужно полностью инициализированное подключение к Интернету. PlatformIO позволяет передавать переменные среды в билд:
build_flags = -DWIFI_SSID=\"${sysenv.WIFI_SSID}\" -DWIFI_PASSWORD=\"${sysenv.WIFI_PASSWORD}\"
В коде они используются следующим образом:
#ifndef WIFI_SSID
#define WIFI_SSID ""
#endif
#ifndef WIFI_PASSWORD
#define WIFI_PASSWORD ""
#endif
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
}
Если при сборке теста в переменных окружения были логин и пароль для локальной точки доступа, то они будут вкомпилированы в прошивку теста и использованы перед выполнением всех тестов.
Финальным штрихом будет отключение этого теста по-умолчанию. Я не рассчитываю, что любой желающий собрать проект будет тестировать FOTA, да и мне выполнять тест каждый раз при сборке совсем не хочется. Для этого в PlatformIO есть поддержка отключаемых тестов!
test_ignore = fota, testfirmware
testfirmware
- это ещё один тест, который на самом деле не тест, а та самая программа, которая ничего не делает и используется для тестирования FOTA.
Это всё звучит очень сложно, но и сам компонент достаточно сложный и делает много вещей, которые как раз хотелось бы проверять автоматически. А уж сколько ошибок я нашёл с помощью этих тестов! Зато теперь обновление работает идеально.