Hello NXP JN5169 Zigbee: OTA обновление прошивки

Всем привет!

image-loader.svg

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

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

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

Теория

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

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

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

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

image-loader.svg

В процессе участвуют 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 обновления прошивки приведу несколько скриншотов из вайршарка. Вот проверка текущей версии прошивки (чтобы потом понять есть ли обновление).

image-loader.svg

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

image-loader.svgimage-loader.svg

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

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. 

image-loader.svg

Этот формат отличается от того, что выходит из-под компилятора и линкера. Более того, файл 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

Назначение

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

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

После загрузки новой прошивки в сектора 0×8 — 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.

image-loader.svg

Далее нужно перегенерировать 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 некоторые вещи делают немного не так. Мы тоже позволим себе отходить от документации, к тому же в ней обнаружились неточности.

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

image-loader.svg

С пунктами 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 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
    
            

© Habrahabr.ru