Всем привет!

Если у устройства есть микроконтроллер, рано или поздно возникает вопрос обновления прошивки. Вам очень повезло, если у устройства есть какой-нибудь удобный интерфейс для обновления прошивок, вроде USB или SD карты. Тоже неплохим вариантом является наличие программатора и возможности подключить этот программатор. А что делать если устройство нельзя легко демонтировать или к нему нельзя подключить программатор? 

Но нам повезло: разработчики Zigbee продумали и стандартизировали способ обновления прошивок по воздуху (OTA), а в микроконтроллере NXP JN5169 достаточно флеш памяти для реализации OTA. Статья детально описывает как это реализовать на микроконтроллере JN5169, но этот подход с минимальными правками также должен заработать и на более новых микроконтроллерах (JN5179, JN5189). Ну а общие принципы диктуются спецификацией ZigBee и будут применимы и для микроконтроллеров других производителей.

Но не все так просто. Давайте разбираться.

Теория

Протокол обновления прошивки

Как и следовало ожидать, в интернетах не нашлось пошаговой инструкции как делать ОТА в целом, и на микроконтроллерах от NXP в частности. Пришлось погружаться в сотни страниц спецификаций и тысячи строк кода. Ниже представлена краткая выжимка из того, что удалось накопать.

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

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

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

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

Итак, процесс обновления прошивки выглядит так.

  • Сообщением Image Notify сервер может уведомить устройство, что доступна новая прошивка. Интересно то, что в этом сообщении может и не быть никакой конкретной информации о самой прошивке. Это скорее клич “эгегей, а у меня для тебя что-то есть”, а клиентское устройство само уже начинает интересоваться что именно есть у сервера.

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

  • Устройство может узнать какую конкретно прошивку предлагает сервер, запросив параметры прошивки (версию, размер, и другие параметры) с помощью сообщения Query Next Image. В запросе клиентское устройство указывает текущую версию прошивки и железа.

    Если для такой комбинации модели устройства и версии железа доступна новая прошивка, сервер отвечает сообщением Query Next Image Response.

  • Устройство поочередно запрашивает небольшие блоки данных с помощью сообщения Image Block Request. В запросе указывается сколько байт и по какому смещению от начала прошивки нужно переслать. Сервер отвечает сообщением Image Block Response, в теле которого будут запрашиваемые байты.

    Размер сообщения Zigbee не позволяет за раз отправить слишком много байт. Типичное значение для устройство NXP - 48 байт за раз. Теоретически можно и больше, но при этом сообщения будут фрагментироваться и отправляться по сети кусочками. Это означает, что прошивки всех промежуточных устройств должны понимать такой формат передачи (что не факт), а также задействует дополнительную логику и вычислительные ресурсы по собиранию фрагментированных пакетов в кучу. При этом совершенно не обязательно, что это приведет к повышению скорости передачи, поэтому мы, пожалуй, таким заниматься не будем.

  • Когда прошивка скачана, устройство рапортует об этом серверу сообщением Upgrade End Request. Прежде всего клиентское устройство должно верифицировать прошивку, проверить ее на целостность, контрольную сумму, и, возможно, проверить цифровую подпись. Информация об этом указывается в запросе. Также в этом сообщении клиент указывает готов ли он к обновлению - вполне вероятно, что потребуется скачать прошивку заново, или скачать дополнительные прошивки для других микроконтроллеров в этом устройстве.

    Сервер отвечает сообщением End Upgrade Response, и при этом указывает когда же именно нужно произвести, собственно, процедуру записи прошивки во флеш память. Это может понадобится, например, для координированного обновления прошивки нескольких устройств, или координирования обновления прошивок внутри устройства (если устройство имеет на борту несколько микроконтроллеров, каждому из которых нужна своя прошивка).

Zigbee2mqtt в общем и целом следует этому процессу и реализует 2 сценария. Во-первых это проверка доступности обновление. Раньше я думал, что zigbee2mqtt за обновлениями прошивок ходит только на внешний сервер, и не мог понять зачем в этом процессе нужно, собственно, устройство. Работает это так.

Дело в том, что по умолчанию Z2M не знает какая в данный момент прошивка в устройстве, а также какая версия железа (разным версиям железа, возможно, понадобится разные прошивки). Более того, сами прошивки живут на внешнем сервере, и заранее Z2M не знает какие прошивки доступны в данный конкретный момент. Чтобы разузнать всю необходимую информацию происходит следующее

  • zigbee2mqtt отправляет устройству сообщение Image Notify (“устройство, а у меня для тебя кое что есть, но в этом сообщении я тебе не скажу что именно”)

  • Устройство отправляет серверу Query Next Image Request, и сообщает какая сейчас прошивка на устройстве, и какая версия железа.

  • zigbee2mqtt НЕ отвечает (сообщение Query Next Image Response не отправляется), и устройство после которого таймаута приходит в исходное состояние.

  • Зато z2m уже знает что за прошивка залита в устройство, какое там железо, и может сходить на внешний сервер прошивок в поисках обновлений.

  • Если обновление найдено, и оно подходит для версии железа устройства, дашборд zigbee2mqtt отображает эту информацию в виде красной кнопки “Update device firmware”

Если пользователь нажал кнопку “Update Device Firmware” процесс происходит по полной программе, как описано выше: Image Notify -> Query Next Image Request -> Image Block Request -> End Update Request.

Заканчивая с описанием общей схемы OTA обновления прошивки приведу несколько скриншотов из вайршарка. Вот проверка текущей версии прошивки (чтобы потом понять есть ли обновление).

Вот еще пару скриншотов самого процесса прошивки.

С общей структурой обновления, вроде бы понятно. Но не все так просто, теперь начинаются нюансы.

Rate Limiting

На скриншотах выше можно заметить, что OTA сообщения являются обыкновенными сообщениями Zigbee. Если сообщение не может быть доставлено напрямую оно идет через промежуточные роутеры, а сами сообщения с прошивкой бегают по сети совместно с обычными сообщениями других устройств. В случае потери пакета сообщение переотправляется.

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

Также нужно помнить, что пропускная способность сети Zigbee не очень большая. Сеть работает на скорости 250 кБод, но на деле реальная пропускная способность меньше:

  • Zigbee работает на той же частоте что и WiFi и Bluetooth, а потому могут возникать потери пакетов, и некоторые пакеты будут передаваться заново.

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

  • На бОльшую часть сообщений отправляются подтверждения уровня сети, и иногда уровня приложения, которые хоть и ненадолго, но занимают канал

  • Если данные передаются через промежуточный роутер, то каждое сообщение передается как минимум дважды - от отправителя к роутеру, и от роутера к получателю. И все это на том же самом частотном канале.

Прошивка устройства - это достаточно большой объем данных, который измеряется десятками, а то и сотнями килобайт. Если лить это сплошным потоком, то другие устройства не смогут отправлять свои сообщения. Поэтому придумали процедуру ограничения скорости потока при передаче прошивки.

Работает это так. Устройство обращается за очередным блоком данных с помощью сообщения Image Block Request. Если сеть относительно свободна, сервер отправляет Image Block Response со статусом SUCCESS и байтами данных. Но если сеть в данный момент загружена, то сервер будет отвечать сообщением Image Block Response, но со статусом WAIT_FOR_DATA. При этом сервер укажет сколько миллисекунд нужно подождать прежде чем отправлять очередной запрос.

Также, чтобы не гонять пустые сообщения туда-сюда, сервер может установить клиенту атрибут OTA кластера MinBlockRequestDelay, и указать минимальный отрезок времени, которое устройство обязано выжидать между запросами. Для этого нужно будет подписать кластер ОТА на сообщения миллисекундного таймера, с помощью которого кластер будет отмерять нужные интервалы времени.

Впрочем, мы заниматься ограничением скорости со стороны прошивки не будем - эта функциональность уже встроена в zigbee2mqtt (точнее в herdsman-converters). Сервер сам будет следить за частотой запросов, и будет выдерживать промежутки в 250мс между пакетами.

Сохранение состояния

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

В этом вопросе нам отчасти повезло - бОльшая часть из этого реализована в SDK. Нам лишь нужно будет предоставить функции сохранения и восстановления состояния.

Upgrade Server Discovery

Выше я привел сценарии проверки доступности обновления, а также самого обновления, которое инициируется со стороны сервера. На самом деле существует еще один сценарий обновления - ничего не мешает устройству самостоятельно инициировать обновление прошивки, обратившись к серверу сразу сообщением Query Next Image Request. 

В общем случае сервер OTA обновлений и координатор не обязаны быть единым устройством - сервер обновлений может находится и по другому адресу. По умолчанию устройство не знает по какому адресу находится сервер обновлений, и нужны определенные предварительные ласки, чтобы настроить клиентское устройство. Для этого существует процедура Update Server Discovery.

В целом ничего сложного. Клиентское устройство посылает широковещательное сообщение Match Descriptor Request с вопросом “а кто вообще умеет OTA Server Cluster?”. Сервер, увидев такое сообщение ответит Match Descriptor Response.

Но это еще не все. Таким образом клиентское устройство узнает только короткий 16битный адрес сервера, который, в общем-то, может меняться со временем. Поэтому нужно будет сделать еще запрос Lookup IEEE Address.

Это вроде бы линейный алгоритм, и на языке высокого уровня вроде JavaScript или Python отлично реализуется с помощью async/await. К сожалению в прошивке нет await’ов, потому придется городить машину состояний, аккуратно обрабатывая запросы, ответы, и таймауты между ними. Причем это опциональная часть стандарта, и в SDK не реализована. В примерах от NXP такая машина состояний реализуется примерно в тысячу строк кода.

Но есть хорошая новость. Если отказаться от возможности инициировать обновление со стороны устройства, да еще если принять, что zigbee2mqtt реализует и координатор и сервер обновлений в одном флаконе, то от процедуры Update Server Discovery можно будет отказаться. Сервер сам будет приходить к нашему устройству с сообщением Image Notify.

OTA File Format

Zigbee устройства могут быть построены на базе микроконтроллеров разных производителей и разных архитектур (даже у одного производителя). Разумеется, формат прошивок у этих микроконтроллеров будет различным. Тем не менее zigbee2mqtt должен как-то понимать чем один файл прошивки отличается от другого, чтобы в свою очередь предлагать различным устройствам правильную версию прошивки.

Поэтому в Zigbee OTA используется единый формат прошивок для всех производителей и архитектур микроконтроллеров. Этот формат описан в спецификации Zigbee

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

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

Организация Flash памяти

Если со стороны протокола Zigbee теперь должно быть более менее понятно, то как именно происходит заливка прошивки в микроконтроллер нам расскажет документ JN-AN-1003: JN51xx Boot Loader Operation (он же, кстати, рассказывает про формат прошивки микроконтроллера, а также про то как работает UART заливка прошивки).

В микроконтроллере JN5169 512 кБ флеш памяти, которая разбита на 16 секторов по 32кБ. В части из этих секторов размещается текущая прошивка микроконтроллера. В оставшиеся блоки можно записывать скачанную прошивку. Но как переключить микроконтроллер на новую прошивку? 

Оказывается, в микроконтроллере JN5169 есть процедура переназначения (remap) секторов микроконтроллера. Можно указать в каких секторах находится текущая прошивка, а куда закачивать новую прошивку. Переключение на новую прошивку происходит просто переназначением секторов. 

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

Физический сектор

0

1

2

3

4

5

6

7

8

9

A

B

C

D

E

F

Логический сектор

0

1

2

3

4

5

6

7

8

9

A

B

C

D

E

F

Назначение

Текущая прошивка

Место для новой прошивки

После загрузки новой прошивки в сектора 0x8 - 0xF микроконтроллер может выполнить процедуру переназначения секторов - логический сектор 0 станет указывать на физический сектор 8, сектор 1 будет смотреть на сектор 9, и так далее (и наоборот).  После перезагрузки запустится новая прошивка - прошивка выполняется по логическим адресам, и код прошивки даже не заметит подмены. 

Физический сектор

8

9

A

B

C

D

E

7

0

1

2

3

4

5

6

F

Логический сектор

0

1

2

3

4

5

6

7

8

9

A

B

C

D

E

F

Назначение

Новая прошивка

Старая прошивка

Документация приводит и более хитрые схемы переназначения секторов, например чтобы часть секторов с какими-то полезными данными выживала при смене прошивки. Но в моем случае мне это нужно, поэтому я буду использовать вот такую схему как на картинке (примеры от NXP также используют эту схему).

Да, в случае использования внутренней флешки микроконтроллера прошивка не может занимать больше 256 кБ (половина флеша). Но сам микроконтроллер (как и реализация OTA) поддерживает загрузку прошивки во внешнюю SPI флеш память, если таковая имеется на плате. Так что если кому не хватает 256 кБ могут копнуть в эту сторону.

Кстати, все мелкие настройки хранятся не во флеш памяти, а в EEPROM, а потому не должны слетать при обновлении прошивки. Но вот если формат данных изменился, то придется сделать factory reset.

Реализация

Добавляем ОТА кластер

Итак, переходим к коду. Добавление самого ОТА кластера ничем особо не отличается от добавления других кластеров. Сначала добавляем ОТА кластер в Zigbee Configuration Editor.

Далее нужно перегенерировать zps_gen.c/h с помощью ZPSConfig.

C:\NXP\bstudio_nxp\sdk\JN-SW-4170\Tools\ZPSConfig\bin\ZPSConfig.exe -n HelloZigbee -f HelloZigBee.zpscfg -o . -t JN516x -l D:\Projects\NXP\JN5169-for-xiaomi-wireless-switch\sdk\JN-SW-4170\Components\Library\libZPSNWK_JN516x.a -a D:\Projects\NXP\JN5169-for-xiaomi-wireless-switch\sdk\JN-SW-4170\Components\Library\libZPSAPL_JN516x.a -c C:\NXP\bstudio_nxp\sdk\Tools\ba-elf-ba2-r36379

Теперь нужно включить сам кластер и его настройки в zcl_options.h

#define CLD_OTA
#define OTA_CLIENT
#define OTA_NO_CERTIFICATE
#define OTA_CLD_ATTR_FILE_OFFSET
#define OTA_CLD_ATTR_CURRENT_FILE_VERSION
#define OTA_CLD_ATTR_CURRENT_ZIGBEE_STACK_VERSION
#define OTA_MAX_BLOCK_SIZE 48
#define OTA_TIME_INTERVAL_BETWEEN_RETRIES 10
#define OTA_STRING_COMPARE
#define OTA_UPGRADE_VOLTAGE_CHECK

И тут уже начинается интересное. Эти настройки есть смысл прокомментировать, ибо без некоторых из них ОТА кластер не компилируется

  • CLD_OTA и OTA_CLIENT включает реализацию OTA кластера в режиме клиента

  • OTA_NO_CERTIFICATE - мы не будем использовать сертификаты шифрования. У нас нет ни сертификатов, ни библиотеки, которая для этого используется для проверки цифровой подписи.

  • OTA_CLD_ATTR_* включает несколько атрибутов ОТА кластера. Вроде бы они не используются в zigbee2mqtt, но пускай будут. Я включил атрибуты, которые могли бы быть интересны конечному пользователю (например если z2m показывает их на дашборде, хотя я сомневаюсь).

  • OTA_MAX_BLOCK_SIZE задает размер блока, который используется при скачивании прошивки в 48 байт. Этот параметр должен быть кратным 16, чтобы можно было записывать данные во флеш память. Слишком большим он тоже не может быть, т.к. можно превысить максимальный размер пакета zigbee. 

  • OTA_TIME_INTERVAL_BETWEEN_RETRIES - интервал в секундах между попытками запросов к серверу (например, если какой-то запрос к серверу потерялся). Просто чтобы клиент не долбился слишком часто в сервер. 

  • OTA_STRING_COMPARE включает проверку валидности прошивки, и в частности 32-байтного строкового идентификатора в OTA заголовке прошивки. Если строка не совпадает с ожидаемой, обновление прошивки останавливается с ошибкой. Причем проверка происходит в самом начале загрузки прошивки: загружается небольшой кусочек с заголовком, проверяется строковый идентификатор, и если он не совпадает с искомым прошивка даже не загружается.

  • OTA_UPGRADE_VOLTAGE_CHECK включает проверку уровня батарейки перед тем как обновлять прошивку (и не дает обновиться если батарейка уже на исходе). Для роутеров не актуально, но для батареечных устройств может быть кстати.

Также нужно добавить дефайн OTA_INTERNAL_STORAGE - эта настройка включает запись во внутренний флеш микроконтроллера (вместо внешней флешки). Вроде бы этот дефайн используется только в коде OTA кластера. Как мне кажется эта настройка больше относится к физическим характеристикам платы и микроконтроллера, нежели к настройкам ОТА кластера, поэтому я решил разместить ее в CMakeList.txt.

ADD_DEFINITIONS(
...
        -DOTA_INTERNAL_STORAGE

Инициализация ОТА кластера

С общими настройками проекта вроде все. Но теперь нужно написать еще некоторое количество кода. В документации достаточно детально описан процесс инициализации. Интересно то, что примеры от NXP некоторые вещи делают немного не так. Мы тоже позволим себе отходить от документации, к тому же в ней обнаружились неточности.

Документация описывает процесс инициализации вот так.

С пунктами 1-3 проблем нет - мы это уже делали еще 3 статьи назад, когда только знакомились с Zigbee. А вот в четвертом пункте обнаружилась ошибка. Точнее недосказанность. Документация предлагает вызывать eOTA_UpdateClientAttributes() сразу за eOTA_Create(). Так вот это не работает - Функция eOTA_UpdateClientAttributes() требует, чтобы была зарегистрирована конечная точка где живет ОТА. Т.е. пункт 6 (регистрация конечных точек) должен быть выполнен между eOTA_Create() и eOTA_UpdateClientAttributes().

ОТА кластер будет у меня жить вместе с другими общими для устройства кластерами (вроде Basic Cluster) в классе BasicClusterEndpoint. 

void BasicClusterEndpoint::init()
{
   registerBasicCluster();
   registerOtaCluster();
   registerEndpoint();
...

   // Initialize OTA
   otaHandlers.initOTA(getEndpointId());
}


Как я уже сказал, кластеры регистрируются до регистрации конечной точки. Регистрация ОТА кластера производится функцией eOTA_Create().

void BasicClusterEndpoint::registerOtaCluster()
{
   // Create an instance of an OTA cluster as a client */
   teZCL_Status status = eOTA_Create(&clusterInstances.sOTAClient,
                                     FALSE,  /* client */
                                     &sCLD_OTA,
                                     &sOTAClientCluster,  /* cluster definition */
                                     getEndpointId(),
                                     NULL,
                                     &sOTACustomDataStruct);

   if(status != E_ZCL_SUCCESS)
       DBG_vPrintf(TRUE, "BasicClusterEndpoint::init(): Failed to create OTA Cluster instance. status=%d\n", status);
}

Теперь уже можно заняться инициализацией ОТА. Все что связано с ОТА (ну может быть кроме регистрации кластера) я вынес в отдельный класс OTAHandlers, просто из соображений группировки функциональности.

Пункт 4 инструкции нам предлагает проинициализировать атрибуты ОТА кластера. Как я уже упоминал, реализация ОТА подразумевает, что состояние обновления прошивки будет сохраняться в EEPROM и будет восстанавливаться после перезагрузки. Я это реализовал так.

void resetPersistedOTAData(tsOTA_PersistedData * persistedData)
{
   memset(persistedData, 0, sizeof(tsOTA_PersistedData));
}


void OTAHandlers::restoreOTAAttributes()
{
   // Reset attributes to their default value
   teZCL_Status status = eOTA_UpdateClientAttributes(otaEp, 0);
   if(status != E_ZCL_SUCCESS)
       DBG_vPrintf(TRUE, "OTAHandlers::restoreOTAAttributes(): Failed to create OTA Cluster attributes. status=%d\n", status);

   // Restore previous values
   PersistedValue<tsOTA_PersistedData, PDM_ID_OTA_DATA> sPersistedData;
   sPersistedData.init(resetPersistedOTAData);
   status = eOTA_RestoreClientData(otaEp, &sPersistedData, TRUE);
   if(status != E_ZCL_SUCCESS)
       DBG_vPrintf(TRUE, "OTAHandlers::restoreOTAAttributes(): Failed to restore OTA data. status=%d\n", status);
}

Функция eOTA_UpdateClientAttributes() устанавливает атрибуты кластера в некие начальные значения. Второй блок кода будет вычитывать сохраненное состояние из флеш памяти, и при необходимости будет перекладывать прочитанную информацию также в атрибуты кластера. Если это самый первый запуск устройства, и в EEPROM нет записи о состоянии - будет вызвана функция resetPersistedOTAData(), которая проинициализирует структуру нулями.

Инициализация флеш памяти

Теперь нужно заняться инициализацией флеш памяти, куда будет загружаться прошивка.

void OTAHandlers::initFlash()
{
   // Fix and streamline possible incorrect or non-contiguous flash remapping
   if (u32REG_SysRead(REG_SYS_FLASH_REMAP) & 0xf)
   {
       vREG_SysWrite(REG_SYS_FLASH_REMAP,  0xfedcba98);
       vREG_SysWrite(REG_SYS_FLASH_REMAP2, 0x76543210);
   }

Тут есть смысл остановиться поподробнее. Этот кусочек кода объясняется в документе JN-AN-1003, а также в приложении F документа JN-UG-3115. Давайте представим, что мы обновляем прошивку. Пускай новая прошивка занимает 6 секторов и она скачивается в сектора с 8 по 13.

Физический сектор

0

1

2

3

4

5

6

7

8

9

A

B

C

D

E

F

Логический сектор

0

1

2

3

4

5

6

7

8

9

A

B

C

D

E

F

Назначение

Текущая прошивка

новая прошивка

пусто

Во время перезагрузки бутлоадер перенумерует сектора, но сделает это только для тех секторов, где есть новая прошивка (т.е. сектора с 8 по 13). Это может быть полезно, если мы в оставшихся секторах 14 и 15 хотим хранить какие-то данные, которые не должны исчезать при обновлении прошивки (вдруг нам 4 кБ EEPROM мало)

Получим вот такую картину.

Физический сектор

8

9

A

B

C

D

6

7

0

1

2

3

4

5

E

F

Логический сектор

0

1

2

3

4

5

6

7

8

9

A

B

C

D

E

F

Назначение

новая прошивка

пусто

место для еще более новой прошивки

Если мы захотим обновить прошивку еще раз, она будет скачиваться в логические сектора начиная с 8. Но если следующая версия прошивки будет занимать не 6 секторов а 7, то она будет записана в физические сектора 0-5 и 14. Т.е. прошивка будет записана в сектора, которые идут не подряд. Чтобы это исправить, нужно доперенумеровывать оставшиеся пустые сектора 6 и 7 в 14 и 15. Именно это и делает код выше.

Следующее. Теперь нужно проинциализировать флеш память куда будет записываться новая прошивка. Как я уже говорил, микроконтроллер поддерживает внешнюю память для прошивок. Но нам пока хватит и внутренней. (да, мы уже включили дефайн OTA_INTERNAL_STORAGE, но разработчикам из NXP этого показалось мало, нужно еще и в коде это сделать).

   // Initialize flash memory for storing downloaded firmwares
   tsNvmDefs sNvmDefs;
   sNvmDefs.u32SectorSize = 32*1024; // Sector Size = 32K
   sNvmDefs.u8FlashDeviceType = E_FL_CHIP_INTERNAL;
   vOTA_FlashInit(NULL, &sNvmDefs);

А вот еще один интересный кусочек.

   // Fill some OTA related records for the endpoint
   uint8 au8CAPublicKey[22] = {0};
   uint8 u8StartSector[1] = {8};
   teZCL_Status status = eOTA_AllocateEndpointOTASpace(
                           otaEp,
                           u8StartSector,
                           OTA_MAX_IMAGES_PER_ENDPOINT,
                           8,                                 // max sectors per image
                           FALSE,
                           au8CAPublicKey);
   if(status != E_ZCL_SUCCESS)
       DBG_vPrintf(TRUE, "OTAHandlers::initFlash(): Failed to allocate endpoint OTA space (can be ignored for non-OTA builds). status=%d\n", status);

Именно этот код настраивает куда же скачивать новую прошивку (в нашем случае начиная с 8 сектора) и максимальный размер прошивки (8 секторов, т.е. 256 кБ).

ZCL таймер

Едем дальше.

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

Все кластеры, которые мы использовали до этого работали независимо от времени - что-то получили, что-то отправили. Но ОТА кластер должен знать о том как тикают часики, чтобы иметь возможность отсчитывать таймауты. Для этого в библиотеке ZCL реализована подсистема программных таймеров. Она очень похожа на ту, которую мы разбирали в первой статье (более того, даже базируется на тех таймерах), только тут еще немножко плюшек добавили. 

Я не особо разбирался в отличиях одних таймеров от других, отчасти потому, что ZCL таймеры в основном используются внутри ZCL. Наша же задача запустить эти таймеры, чтобы тикали. У меня уже есть класс PeriodicTask, который крутит внутри свой таймер. Остается в качестве полезной нагрузки нотифицировать ZCL, что прошла очередная секунда.

class ZCLTimer: public PeriodicTask
{
public:
   ZCLTimer();

protected:
   virtual void timerCallback();
};

void ZCLTimer::timerCallback()
{
   // Restart the timer
   startTimer(1000);

   DBG_vPrintf(TRUE, "ZCLTimer::timerCallback(): Tick\n");

   // Process ZCL timers
   tsZCL_CallBackEvent sCallBackEvent;
   sCallBackEvent.pZPSevent = NULL;
   sCallBackEvent.eEventType = E_ZCL_CBET_TIMER;
   vZCL_EventHandler(&sCallBackEvent);
}

Нужно только не забыть запустить этот таймер в vAppMain().

   ZCLTimer zclTimer;
   zclTimer.init();
   zclTimer.startTimer(1000);

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

// 6 timers are:
// - 1 in ButtonTask
// - 2 in SwitchEndpoints
// - 1 in PollTask
// - 1 in DeferredExecutor (TODO: Do we still need it?)
// - 1 is ZCL timer
// Note: if not enough space in this timers array, some of the functions (e.g. network joining) may not work properly
ZTIMER_tsTimer timers[6 + BDB_ZTIMER_STORAGE];

Обработка событий ОТА

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

Много функций распечатывания различных событий ОТА
void vDumpCurrentImageOTAHeader(uint8 otaEp)
{
   tsOTA_ImageHeader sOTAHeader;
   eOTA_GetCurrentOtaHeader(otaEp, FALSE, &sOTAHeader);
   DBG_vPrintf(TRUE, "\nCurrent Image Details \n");
   DBG_vPrintf(TRUE, "  File ID = 0x%08x\n",sOTAHeader.u32FileIdentifier);
   DBG_vPrintf(TRUE, "  Header Ver ID = 0x%04x\n",sOTAHeader.u16HeaderVersion);
   DBG_vPrintf(TRUE, "  Header Length ID = 0x%04x\n",sOTAHeader.u16HeaderLength);
   DBG_vPrintf(TRUE, "  Header Control Field = 0x%04x\n",sOTAHeader.u16HeaderControlField);
   DBG_vPrintf(TRUE, "  Manufac Code = 0x%04x\n",sOTAHeader.u16ManufacturerCode);
   DBG_vPrintf(TRUE, "  Image Type = 0x%04x\n",sOTAHeader.u16ImageType);
   DBG_vPrintf(TRUE, "  File Ver = 0x%08x\n",sOTAHeader.u32FileVersion);
   DBG_vPrintf(TRUE, "  Stack Ver = 0x%04x\n",sOTAHeader.u16StackVersion);
   DBG_vPrintf(TRUE, "  Image Len = 0x%08x\n\n",sOTAHeader.u32TotalImage);
}


void vDumpImageNotifyMessage(tsOTA_ImageNotifyCommand * pMsg)
{
   DBG_vPrintf(TRUE, "OTA Image Notify: QueryJitter=%d", pMsg->u8QueryJitter);

   if(pMsg->ePayloadType != E_CLD_OTA_QUERY_JITTER)
       DBG_vPrintf(TRUE, ", manuID=%04x", pMsg->u16ManufacturerCode);

   if(pMsg->ePayloadType == E_CLD_OTA_ITYPE_MDID_JITTER || pMsg->ePayloadType == E_CLD_OTA_ITYPE_MDID_FVERSION_JITTER)
       DBG_vPrintf(TRUE, ", ImageType=%d", pMsg->u16ImageType);

   if(pMsg->ePayloadType == E_CLD_OTA_ITYPE_MDID_FVERSION_JITTER)
       DBG_vPrintf(TRUE, ", FileVersion=%d", pMsg->u32NewFileVersion);

   DBG_vPrintf(TRUE, "\n");
}

void vDumpQueryImageResponseMessage(tsOTA_QueryImageResponse * pMsg)
{
   DBG_vPrintf(TRUE, "OTA Query Image Resp: imageSize=%d, fileVersion=%d, imageType=%d, manufId=%04x, status=%02x\n",
               pMsg->u32ImageSize,
               pMsg->u32FileVersion,
               pMsg->u16ImageType,
               pMsg->u16ManufacturerCode,
               pMsg->u8Status
               );
}

void vDumpBlockResponseMessage(tsOTA_ImageBlockResponsePayload * pMsg)
{
   switch(pMsg->u8Status)
   {
       case OTA_STATUS_SUCCESS:
           DBG_vPrintf(TRUE, "OTA Image Block Resp: fileOffset=%d, dataSize=%d, fileVersion=%d, imageType=%d, manufID=%04x\n",
                       pMsg->uMessage.sBlockPayloadSuccess.u32FileOffset,
                       pMsg->uMessage.sBlockPayloadSuccess.u8DataSize,
                       pMsg->uMessage.sBlockPayloadSuccess.u32FileVersion,
                       pMsg->uMessage.sBlockPayloadSuccess.u16ImageType,
                       pMsg->uMessage.sBlockPayloadSuccess.u16ManufacturerCode
                       );
           break;

       //case OTA_STATUS_ABORT:
       //case OTA_STATUS_WAIT_FOR_DATA:
       default:
           DBG_vPrintf(TRUE, "OTA Image Block Resp: unknown status=%02x\n", pMsg->u8Status);
           break;
   }
}

void vDumpUpgradeEndResponseMessage(tsOTA_UpgradeEndResponsePayload * pMsg)
{
   DBG_vPrintf(TRUE, "OTA Block Resp: upgradeTime=%d, currentTime=%d, fileVersion=%d, imageType=%d, manufID=%04x\n",
               pMsg->u32UpgradeTime,
               pMsg->u32CurrentTime,
               pMsg->u32FileVersion,
               pMsg->u16ImageType,
               pMsg->u16ManufacturerCode
               );
}


void vDumpOTAMessage(tsOTA_CallBackMessage * pMsg)
{
   // Ignore these noisy messages
   switch(pMsg->eEventId)
   {
   case E_CLD_OTA_INTERNAL_COMMAND_LOCK_FLASH_MUTEX:
   case E_CLD_OTA_INTERNAL_COMMAND_FREE_FLASH_MUTEX:
   case E_CLD_OTA_INTERNAL_COMMAND_POLL_REQUIRED:
       return;
   default:
       break;
   }

   DBG_vPrintf(TRUE, "OTA Callback Message: fnPointer=0x%08x, ", pMsg->sPersistedData.u32FunctionPointer);

   switch(pMsg->eEventId)
   {
   case E_CLD_OTA_COMMAND_IMAGE_NOTIFY:
       vDumpImageNotifyMessage(&pMsg->uMessage.sImageNotifyPayload);
       break;

   case E_CLD_OTA_COMMAND_QUERY_NEXT_IMAGE_RESPONSE:
       vDumpQueryImageResponseMessage(&pMsg->uMessage.sQueryImageResponsePayload);
       break;

   case E_CLD_OTA_COMMAND_BLOCK_RESPONSE:
       vDumpBlockResponseMessage(&pMsg->uMessage.sImageBlockResponsePayload);
       break;

   case E_CLD_OTA_COMMAND_UPGRADE_END_RESPONSE:
       vDumpUpgradeEndResponseMessage(&pMsg->uMessage.sUpgradeResponsePayload);
       break;

   case E_CLD_OTA_INTERNAL_COMMAND_VERIFY_IMAGE_VERSION:
       DBG_vPrintf(TRUE, "OTA Verify image version (Internal)\n");
       break;

   case E_CLD_OTA_INTERNAL_COMMAND_VERIFY_STRING:
       DBG_vPrintf(TRUE, "OTA Verify string (Internal)\n");
       break;

   case E_CLD_OTA_INTERNAL_COMMAND_SAVE_CONTEXT:
       DBG_vPrintf(TRUE, "OTA Save Context (Internal)\n");
       break;

   case E_CLD_OTA_INTERNAL_COMMAND_OTA_DL_ABORTED:
       DBG_vPrintf(TRUE, "OTA Download aborted (Internal)\n");
       break;

   case E_CLD_OTA_INTERNAL_COMMAND_SWITCH_TO_UPGRADE_DOWNGRADE:
       DBG_vPrintf(TRUE, "OTA Switch to new image (Internal): oldVer=%d newVer=%d\n",
                   pMsg->uMessage.sUpgradeDowngradeVerify.u32CurrentImageVersion,
                   pMsg->uMessage.sUpgradeDowngradeVerify.u32DownloadImageVersion);
       break;

   case E_CLD_OTA_INTERNAL_COMMAND_RESET_TO_UPGRADE:
       DBG_vPrintf(TRUE, "OTA Reset to upgrade (Internal)\n");
       break;

   default:
       DBG_vPrintf(TRUE, "Unknown event type evt=%d\n", pMsg->eEventId);
   }
}

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

void OTAHandlers::handleOTAMessage(tsOTA_CallBackMessage * pMsg)
{
   vDumpOTAMessage(pMsg);

   switch(pMsg->eEventId)
   {
   default:
       break;
   }
}

Тем не менее эти события дают возможность кастомизации поведения. Так, примеры от NXP используют события ОТА для проверки необходимости обновления со стороны устройства, целостности загруженной прошивки, дополнительных таймаутов и контроля состояния загрузки (они там вообще много всего накрутили поверх стандартной реализации).

(Опционально) Сохранение и восстановление контекста

Единственное событие, которое мне показалось необходимым обработать это сохранение контекста. Реализация ОТА кластера время от времени сохраняет состояние, что позволяет после внезапной перезагрузки возобновить загрузку прошивки. 

Код сохранения состояния выглядит так.

void OTAHandlers::saveOTAContext()
{
   DBG_vPrintf(TRUE, "Saving OTA Context... ");

   // Get the OTA cluster data record
   tsZCL_ClusterInstance *psClusterInstance;
   teZCL_Status status = eZCL_SearchForClusterEntry(1, OTA_CLUSTER_ID, FALSE, &psClusterInstance);
   if(status  != E_ZCL_SUCCESS)
   {
       DBG_vPrintf(TRUE, "Search OTA entry failed with status %02x\n", status);
       return;
   }

   // Check the data pointer
   tsOTA_Common * pOTACustomData = (tsOTA_Common *)psClusterInstance->pvEndPointCustomStructPtr;

   // Store the data
   sPersistedData = pOTACustomData->sOTACallBackMessage.sPersistedData;

   DBG_vPrintf(TRUE, "Done\n");
}

void OTAHandlers::handleOTAMessage(tsOTA_CallBackMessage * pMsg)
{
   vDumpOTAMessage(pMsg);

   switch(pMsg->eEventId)
   {
   case E_CLD_OTA_INTERNAL_COMMAND_SAVE_CONTEXT:
       saveOTAContext();
       break;
   default:
       break;
   }
}

Само состояние хранится в недрах ОТА кластера, и его нужно оттуда вытянуть. В примерах от NXP они лезут напрямую в кишки ОТА, я же попытался хоть немного соблюдать рамки приличия и границы модулей.

Загрузку и восстановление состояния тоже нужно немного подточить (взято из примеров от NXP).

void OTAHandlers::restoreOTAAttributes()
{
   // Reset attributes to their default value
   teZCL_Status status = eOTA_UpdateClientAttributes(otaEp, 0);
   if(status != E_ZCL_SUCCESS)
       DBG_vPrintf(TRUE, "OTAHandlers::restoreOTAAttributes(): Failed to create OTA Cluster attributes. status=%d\n", status);

   // Restore previous values
   sPersistedData.init(resetPersistedOTAData);

   // Correct timeout value
   if((&sPersistedData)->u32RequestBlockRequestTime != 0)
   {
       (&sPersistedData)->u32RequestBlockRequestTime = 10;  //
       DBG_vPrintf(TRUE, "OTAHandlers::restoreOTAAttriutes(): Set block time to 10\n");
   }

   status = eOTA_RestoreClientData(otaEp, &sPersistedData, TRUE);
   if(status != E_ZCL_SUCCESS)
       DBG_vPrintf(TRUE, "OTAHandlers::restoreOTAAttributes(): Failed to restore OTA data. status=%d\n", status);
}

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

Скажу честно, я не очень уверен в этом коде. В какой-то момент всю эту конструкцию заклинило в каком-то неконсистентном состоянии. Произошло это следующим образом.

Ради теста во время обновления прошивки я выключил устройство, чтобы проверить что произойдет после того как на zigbee2mqtt сработает таймаут. После включения устройство восстановило предыдущее состояние, попробовало обратиться за очередным блоком, который, естественно, не получило. После нескольких попыток устройство плюнуло и перешло в нормальное состояние. 

Проблема в том, что это нормальное состояние не сохранилось в EEPROM, и вообще об этом мой код никак не проинформировали. Если перезагрузить устройство, оно опять прочитает из EEPROM неправильное состояние, и опять попробует продолжить обновление прошивки. 

По видимому для решения этой проблемы ребята из NXP в своих примерах накрутили поверх еще один таймаут. При срабатывании этого таймаута они насильно что-то чинили в недрах ОТА кластера, и приводили его в исходное состояние. 

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

Ровняем бинарь

Во флеш память нельзя писать как попало - есть требование, чтобы запись производилась блоками кратными 16 байт. Вот только прошивка из-под компилятора не обязательно получается кратной 16 байтам. 

Казалось бы, это не должно стать проблемой обработать эту ситуацию в коде, и добавить несколько байт, если это необходимо. К сожалению в реализации OTA от NXP есть баг: если размер прошивки не кратен 16, то после загрузки последнего блока устройство продолжает запрашивать новые блоки со смещениями больше чем размер бинарника.

Можно было бы починить этот баг в коде ОТА, но я не хотел менять код SDK. Вместо этого я решил поискать способ делать бинарники кратными 16 байт. Это оказалось весьма мучительно, т.к. я вообще не представлял как это сделать (ну разве что написать скрипт на питоне, который бы дописывал в конец нужное количество байт).

Долго ли, коротко ли, удалось наколдовать нужное поведение линкера с помощью следующего скрипта линковщика.

INCLUDE AppBuildZBPro.ld

SECTIONS
{
        /*
         * Make a dummy section, so that previous section is padded to a 16-byte boundary
         */
        .pad ((LOADADDR(.text) + SIZEOF(.text) + SIZEOF(.data) + 15) & ~ 15 ):
        {
            . = ALIGN (16);
            LONG(0x00000000)
            LONG(0x00000000)
            LONG(0x00000000)
            LONG(0x00000000)
        } > flash
}

Тут происходит следующее. Вначале отрабатывает скрипт из SDK AppBuildZBPro.ld, который делает всю структуру бинаря, добавляет нужные секции, раскладывает все нужным образом в памяти микроконтроллера. 

Магия заключается в том, что я добавляю еще одну секцию .pad размером в 16 нулевых байт. Но самое важное, эта секция выровнена на 16 байт, относительно предыдущих секций .text и .data. Это заставляет линковщик добавить нужное количество байт перед секцией .pad, а размер всей прошивки станет кратным 16 (строго говоря там еще добавочные 4 байта в начале файла добавляются - это сигнатура файла, но она не является частью самой прошивки, которая будет загружаться по ОТА).

Скрипт линкера включается в CMakeList.txt следующим образом.

SET(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -T${HelloZigbee_SOURCE_DIR}/HelloZigbee.ld")

Как мы знаем из-под компилятора и линковщика выходит .elf файл, и напрямую в микроконтроллер его загружать нельзя. Чтобы превратить .elf во что-нибудь удобоваримое используется утилита objcopy. Вызов этой утилиты уже есть в билд скриптах, но нужно сказать ей не выкидывать секцию .pad, иначе также будут убраны и добавочные байты перед этой секцией. Чтобы сделать это нужно добавить ключик “-j .ota” в командную строку objcopy.

Возможно существует и другой способ подровнять бинарник. Но я облазил все билд файлы из SDK и примеров, но ничего похожего не нашел. Буду благодарен, если кто-то подскажет как можно выровнять секции не исправляя файлы СДК, и не применяя костылей как у меня.

Добавляем OTA Header

Думали все? как бы не так!

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

Можно было бы всю эту информацию вкомпилировать в прошивку в виде каких-нибудь констант или define’ов, но разработчики NXP пошли другим путем - они решили положить в прошивку полноценный OTA Header, структура которого показана выше. Поскольку не все поля этой структуры известны на этапе компиляции, код прошивки просто резервирует место под структуру. Эта структура потом заполняется внешней утилитой Jennic Encryption Tool (JET) прямо в готовом бинарнике прошивки.

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

Но это еще не все. Перед этой секцией должна располагаться еще одна - .ro_mac_address. Каждый чип на заводе получает уникальный MAC адрес, но, возможно, в некоторых случаях этот адрес нужно изменить. Тогда с помощью JET можно вшить в секцию .ro_mac_address другой MAC адрес (в прошивке должен существовать код по переназначению МАС адреса с заводского на тот, что в .ro_mac_address). Переназначать MAC адреса мы не будем, но сама секция в прошивке должна быть, иначе JET промахнется мимо .ro_ota_header.

Короче говоря, мне пришлось написать такой код.

void OTAHandlers::vDumpOverridenMacAddress()
{
   static uint8 au8MacAddress[]  __attribute__ ((section (".ro_mac_address"))) = {
                     0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
   };

   DBG_vPrintf(TRUE, "MAC Address at address = %08x:  ", au8MacAddress);
   for (int i=0; i<8; i++)
       DBG_vPrintf(TRUE, "%02x ",au8MacAddress[i] );
   DBG_vPrintf(TRUE, "\n\n");
}

Этот код ничего полезного не делает, просто выводит содержимое секции .ro_mac_address в консоль. Если этого не делать (например просто определить массив au8MacAddress) линковщик выкинет эту секцию за ненадобностью, и структура бинарника прошивки нарушится.

И как я уже упоминал, нужно дать знать утилите objcopy не выкидывать секции .ro_mac_address и .ro_ota_header, добавив ключики “-j .ro_mac_address -j .ro_ota_header”. Если бы у нас было задействовано шифрование прошивки, то пришлось бы еще добавлять секции для сертификатов.

Теперь JET. Эта утилита входит в состав SDK, а работа с ней описана в документе JN-UG-3081. К сожалению глубокого понимания происходящих процессов документ особо не дает. Часть информации как-бы подразумевается и в документе не описана. В итоге опять пришлось использовать метод тыка, прежде чем я пришел к такой комбинации ключей.

C:\NXP\bstudio_nxp\sdk\JN-SW-4170\Tools\OTAUtils\JET.exe -m otamerge --embed_hdr -c HelloZigbee.bin -o HelloZigbee.ota.bin -v JN516x -n 1 -t 1 -u 0x1037 -j "HelloZigbee2021                 "

Значение ключей следующие:

  • -m otamerge - включает режим соединение различной OTA информации в едином бинарнике

  • --embed_hdr - включает подрежим вшивания OTA Header’а в бинарник

  • -с HelloZigbee.bin - исходный файл прошивки, который мы получили из-под objcopy. -c потому, что OTA клиент

  • -o HelloZigbee.ota.bin - имя результирующего файла. На сервер это класть еще нельзя, но уже можно шить в микроконтроллер с помощью программатора.

  • -v JN516x - название семейства микроконтроллеров (другие микроконтроллеры имеют другой формат прошивки)

  • -n 1 - версия файла прошивки

  • -t 1 - тип файла прошивки (актуально, если бы мы имели на борту несколько микроконтроллеров, каждому из которых нужна своя прошивка. У нас будет только один тип)

  • -u 0x1037 - manufacturer ID. Я пока использую NXP.

  • -j "HelloZigbee2021                 " (именно так, с кучей пробелов) -  прошивает 32-байтную строку в ОТА заголовок, который потом проверяется при обновлении прошивки (в обновляемой прошивке должен быть точно такой же заголовок, иначе прошивка не будет загружаться).

ОТА прошивка

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

Как я уже писал в теоретической части, структура прошивки для сервера четко регламентирована спецификацией Zigbee. По сути такая прошивка является оберткой над основной прошивкой микроконтроллера. 

А готовится такой файл с помощью все той же утилиты JET, только на этот раз с ключиком --ota (а не --embed_hdr, как в прошлый раз).

JET.exe -m otamerge --ota -v JN516x -n 2 -t 1 -u 0x1037 -p 1 -c HelloZigbee.bin -o HelloZigbee.ota

Расшифровка параметров:

  • -m otamerge - включает режим соединение различной OTA информации в едином бинарнике

  • --ota - включает подрежим сборки прошивки ОТА формата из одного или нескольких бинарников

  • -с HelloZigbee.bin - исходный файл прошивки, который мы получили на прошлом шаге (вместе с вшитым OTA заголовком). -c потому, что OTA клиент

  • -o HelloZigbee.ota - имя результирующего файла, который уже можно будет класть на сервер

  • -v JN516x - название семейства микроконтроллеров (другие микроконтроллеры имеют другой формат прошивки)

  • -n 2 - версия файла прошивки

  • -t 1 - тип файла прошивки (актуально, если бы мы имели на борту несколько микроконтроллеров, каждому из которых нужна своя прошивка. У нас будет только один тип)

  • -u 0x1037 - manufacturer ID. Я пока использую NXP.

  • -p 1 - очень важный ключик. Он добавляет в начале файла 4-байтную сигнатуру. Без нее zigbee2mqtt отказывается кушать такую прошивку.

По хорошему нужно было бы завести какой-нибудь CI и автоматически генерировать номер версии, который бы при этом автоматически инкрементировался с каждым билдом. Но пока CI мне настраивать было лень, потому я использую следующий хак. При сборке прошивки, которая будет заливаться через программатор я указываю версию №1. А при сборке прошивки, которая будет лежать на сервере я вшиваю номер 2 (см ключик -n 2). Таким образом у меня на сервере версия всегда "новее", чем в микроконтроллере. Разумеется для реальных устройств это нужно сделать по человечески.

Все вышеописанное колдунство я оформил в виде нескольких CMake функций.

FUNCTION(ADD_HEX_BIN_TARGETS TARGET)
    IF(EXECUTABLE_OUTPUT_PATH)
      SET(FILENAME "${EXECUTABLE_OUTPUT_PATH}/${TARGET}")
    ELSE()
      SET(FILENAME "${TARGET}")
    ENDIF()
    ADD_CUSTOM_TARGET(OUTPUT "${TARGET}.hex"
        DEPENDS ${TARGET}
        COMMAND ${CMAKE_OBJCOPY} -Oihex ${FILENAME} ${FILENAME}.hex
    )
    ADD_CUSTOM_TARGET("${TARGET}.bin"
        DEPENDS ${TARGET}
        COMMAND ${CMAKE_OBJCOPY} -j .version -j .bir -j .flashheader -j .vsr_table -j .vsr_handlers -j .rodata -j .text -j .data -j .bss -j .heap -j .stack -j .ro_mac_address -j .ro_ota_header -j .pad -S -O binary ${FILENAME} ${FILENAME}.tmp.bin
        COMMAND "${SDK_PREFIX}\\Tools\\OTAUtils\\JET.exe" -m otamerge --embed_hdr -c ${FILENAME}.tmp.bin -v JN516x -n 1 -t 1 -u 0x1037 -o ${FILENAME}.bin
    )
ENDFUNCTION()

FUNCTION(ADD_OTA_BIN_TARGETS TARGET)
    IF(EXECUTABLE_OUTPUT_PATH)
      SET(FILENAME "${EXECUTABLE_OUTPUT_PATH}/${TARGET}")
    ELSE()
      SET(FILENAME "${TARGET}")
    ENDIF()
    ADD_CUSTOM_TARGET(${TARGET}.ota
        DEPENDS ${TARGET}.bin
	# HACK/TODO: setting file version to 2 (-n 2), so that OTA image is always newer than current version
        COMMAND "${SDK_PREFIX}\\Tools\\OTAUtils\\JET.exe" -m otamerge --ota -v JN516x -n 2 -t 1 -u 0x1037 -p 1 -c ${FILENAME}.bin -o ${FILENAME}.ota
    )
ENDFUNCTION()

Настройка zigbee2mqtt

С добавлением своих прошивок в zigbee2mqtt есть определенное неудобство (я бы даже сказал проблема). Дело в том, что в zigbee2mqtt умеет качать прошивки только с определенного веб сервера, и никак иначе. На сервере лежит актуальный каталог прошивок, и добавлять свою тестовую прошивку в публичный каталог как минимум неразумно.

Уже 2 года идут разговоры про поддержку локальных файлов прошивок, но пока этого, увы, еще никто не сделал. Впрочем, Коэн (автор zigbee2mqtt) предлагает альтернативный вариант. К сожалению Коэн не видит проблемы в своем подходе и не горит желанием помогать в реализации более удобного варианта хранения локальных прошивок.

Вот моя вариация того, что предлагает Коэн.

  • клонируем себе https://github.com/koenkk/zigbee-ota

  • Запускаем свой собственный веб сервер, который будет отдавать файлы прошивок по http. Я использую питоновский простейший сервер

python3 -m http.server --directory path/to/zigbee-OTA

  • Кладем свою прошивку куда-нибудь рядом и запускаем следующую команду

js ./scripts/add.js path/to/HelloZigbee.ota

  • Скрипт копирует прошивку внутрь каталога images, и обновляет каталог в файле index.json

  • По умолчанию считается, что прошивки выкладываются на гитхаб, а потому в index.json будет иметь некорректный URL. Нужно его поменять на “http://127.0.0.1:8000/images/.....”. 

Обращаю внимание, что питоновский сервер по умолчанию работает на порту 8000, причем только по http, а не https. 

Также скрипт add.js раскладывает прошивки внутри директории images по папочкам, согласно коду разработчика. Я использую manufacturer ID 0x1037 (4151 в десятичной системе), который принадлежит NXP. И, похоже, что не только я так делаю - мои прошивки скрипт определил в поддиректорию Eurotronic, которые также используют код от NXP :)

Да, прошивка прописывается в реестр со своим размером, номером версии, и размером. При обновлении прошивки процедуру с запуском add.js и корректированием url в index.json придется повторить.

Но это еще не самое большое неудобство - еще нужно обучить zigbee2mqtt ходить на наш сервер. Ну тут мне вообще пришлось поднимать свою собственную версию Z2M из исходников, и менять URL сервера прям в кишках node_modules (в файле node_modules/zigbee-herdsman-converters/lib/ota/zigbeeOTA.js). И, конечно же, мы перестанем получать обновления из оригинального репозитория.

Это все делает обновление прошивок для своих собственных устройств крайне неудобным. Я искренне надеюсь в скором времени кто-нибудь таки допилит поддержку локальных ОТА файлов в z2m, чтобы не нужно было так извращаться. И тогда я с радостью выкину этот раздел на помойку. Ну а пока так.

Есть еще вариант форкнуть репозиторий Zigbee-OTA и заливать свои прошивки на гитхаб. Тогда не нужно будет поднимать свой собственный сервер. Но zigbee2mqtt патчить все равно придется, чтобы переключить на свой index.json. 

Ах да, забыл. ОТА еще нужно включить в конвертере устройства.

const device = {
...
    ota: ota.zigbeeOTA,
};

Вариант с внешним конвертером 

Изучая код herdsman-converters я пришел к мысли, что ему можно подсунуть свой набор OTA функций через внешний конвертер. Главное, чтобы интерфейс совпадал. Спустя некоторое время у меня получился следующий код.

Для начала подключим нужные библиотеки.

const ota = require('zigbee-herdsman-converters/lib/ota')
const otacommon = require('zigbee-herdsman-converters/lib/ota/common')
const assert = require('assert');
const fs = require('fs');
const crypto = require('crypto');
const path = require('path');

Следующая функция возвращает мета информацию о прошивке для заданного устройства (device) и его текущей версии (current). 

const fileName = '/app/data/HelloZigbee.ota';

async function getImageMeta(current, logger, device) {
    const modelId = device.modelID;

    const buffer = fs.readFileSync(fileName);
    const parsed = otacommon.parseImage(buffer);

    const hash = crypto.createHash('sha512');
    hash.update(buffer);

    return {
        fileVersion: parsed.header.fileVersion,
        fileSize: parsed.header.totalImageSize,
        url: fileName,
        sha512: hash.digest('hex'),
    };
}

Как я уже упоминал, zigbee2mqtt предварительно обратится к устройству с помощью сообщение Image Notify, и будет ожидать сообщения Query Next Image, откуда сможет узнать текущую версию железа и прошивки, тип необходимого образа и модель устройства. Эта информация будет передаваться в getImageMeta() в параметре current.

В обычной реализации именно getImageMeta() пойдет в реестр index.json в поисках нужной прошивки. Эта функция только ищет подходящую прошивку, но не принимает решения о необходимости обновления. Причем версия не проверяется - только тип образа и модель устройства.

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

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

async function getNewImage(current, logger, device, getImageMeta, downloadImage) {
    const meta = await getImageMeta(current, logger, device);
    logger.debug(`getNewImage for '${device.ieeeAddr}', meta ${JSON.stringify(meta)}`);

    const buffer = fs.readFileSync(fileName);
    const image = otacommon.parseImage(buffer);

    if ('minimumHardwareVersion' in image.header && 'maximumHardwareVersion' in image.header) {
        assert(image.header.minimumHardwareVersion <= device.hardwareVersion &&
            device.hardwareVersion <= image.header.maximumHardwareVersion, 'Hardware version mismatch');
    }
    return image;
}

В принципе ничего сложного - просто читаем и парсим файл прошивки.

Пару слов о том где должен лежать этот файл. Я использую z2m запущенный в докер контейнере. Директория с конфигами, внешним конвертером и файлом прошивки пробрасывается в контейнер по пути /app/data, поэтому свой файл прошивки я могу положить туда же.

В случае запуска z2m из исходников все сложнее. Файл конвертера запускается в отдельном контексте движка javascript (с помощью vm.runInNewContext()) и код конвертера на самом деле не знает откуда его запустили. Поэтому вычислить путь к конвертеру, и от него уже вычислять путь к файлу прошивки не получится. Единственный вариант который у меня заработал это прописать абсолютный путь файлу прошивки.

Но вернемся к коду. Интерфейс OTA по сути состоит из двух функций.

const MyOta = {
    isUpdateAvailable: async (device, logger, requestPayload=null) => {
        logger.debug(`My isUpdateAvailable()`);
        return otacommon.isUpdateAvailable(device, logger, otacommon.isNewImageAvailable, requestPayload, getImageMeta);
    },

    updateToLatest: async (device, logger, onProgress) => {
        logger.debug(`My updateToLatest()`);
        return otacommon.updateToLatest(device, logger, onProgress, getNewImage, getImageMeta);
    }
}

Эти функции опираются на стандартную реализацию обновления из herdsman-converters/lib/ota/common, только подставляют наши функции получения прошивки.

Последний штрих это включить нужные функции.

const device = {
    zigbeeModel: ['Hello Zigbee Switch'],
    model: 'Hello Zigbee Switch',
...
    ota: MyOta
};

Хорошая новость заключается в том, что с таким подходом не нужно поднимать никакие сервера и патчить код zigbee2mqtt. Можно использовать стоковый образ z2m с докерхаба. 

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

Будем ждать более удобной реализации со стороны zigbee2mqtt, но пока такой подход с кусочком кода в конвертере выглядит достаточно удобно. 

Заключение

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

В этой статье я показал как сделать обновление прошивки по воздуху для своего Zigbee устройства на базе микроконтроллера NXP JN5169. Я постарался дать не только пошаговую инструкцию, но и объяснил какой шаг для чего нужен, и что произойдет если делать по другому. Буду благодарен за конструктивную критику.

По прежнему остается сделать более добавление прошивок на стороне zigbee2mqtt более удобным способом. Я приглашаю всех к обсуждению в этот топик.

Код: https://github.com/grafalex82/hellozigbee/tree/hello_zigbee_ota

Использованная документация:

Комментарии (0)