Создание 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.r2server.ru", 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.

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