Доброго времени суток! 

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

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

Также в статье будут затронута дополнительная тема о моих попытках облагородить код и завернуть его в C++ классы. Но не все так просто, если используется обрезанный компилятор десятилетней давности.

Готовы продолжить погружение в мир ZigBee?

Теоретическая вводная

В сетях ZigBee различают процедуру присоединения (join) нового устройства, и переприсоединения (rejoin) устройства которое уже было присоединено к сети ранее. В чем разница?

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

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

  • Устройство посылает в эфир запрос Beacon request

  • В ответ роутеры неподалеку отправляют свои маяки (Beacon response)

  • Устройство выбирает роутер с наилучшими радио характеристиками и посылает ему association request

  • Роутер в ответ назначает новоприбывшему устройству сетевой адрес и сообщает его сообщением association response

  • Роутер также информирует координатора о новом устройстве

  • По сети прокатывается волна запросов на обновление таблиц маршрутизации о маршрутах к новому устройству

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

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

Правильное подключение роутеров к сети ZigBee

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

Заведем переменную, которая будет отображать режим работы устройства - считает ли оно себя подключённым к сети или нет. Причем переменная, которая задает режим работы должна уметь переживать перезагрузку. Я буду писать код по мотивам примера JN-AN-1220-Zigbee-3-0-Sensors (см переменную eNodeState) с небольшими отличиями в поведении. У переменной будет 3 состояния:

  • NOT_JOINED - состояние в котором устройство работает полностью автономно без взаимодействия с сетью. Более того, устройство не делает попыток соединения с сетью. Например выключатель Xiaomi если его не подключить к сети работает просто как обычный выключатель. 

  • JOINING - устройство производит поиск сетей в округе и пробует присоединиться к одной из них. Присоединения может по какой-то причине не получиться с первого раза, поэтому устройство вправе попробовать снова, возможно изменив параметры присоединения.

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

Теперь разберем самое интересное - переходы между этими состояниями. Для этого я нарисовал такую диаграмму состояний. Может показаться что тут всего 3 состояния и несколько стрелочек, и что тут сложного? На самом деле из кода примера это выудить было довольно непросто - все эти состояния и переходы довольно сильно размазаны по разным файлам и функциям, и осознать как это работает просто читая код оказалось делом нетривиальным. 

В изначальном состоянии устройство находится в состоянии NOT_JOINED и не предпринимает попыток соединения с сетью (в отличии от примеров от NXP). Пользователь может нажать кнопку и инициировать подключение к сети, тогда устройство переходит в состояние JOINING.

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

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

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

  • Пользователь может явно попросить устройство покинуть сеть зажав кнопку на устройстве. В этом случае устройство само сообщает всем, что оно покидает сеть, и также переходит в состояние NOT_JOINED

  • Банальная перезагрузка - выключили свет, сработал сторожевой таймер, или устройство просто вышло из глубокого сна. В этом случае устройство должно попробовать переподключиться к той же самой сети (rejoin). Если это удалось то устройство продолжает работать в состоянии JOINED. 

  • Иногда переподключиться не получается, и тогда возможны варианты. 

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

    • Устройство может переподключиться к другому роутеру той же сети. Этот и предыдущий пункты обслуживаются процедурой rejoin

    • В конце концов устройству ничего не мешает вернуться в состояние JOINING и по полной программе искать сеть к которой можно подключиться запустив процедуру network discovery.

Как мы увидим чуть далее, черным цветом обозначены процедуры и функции, которые нужно написать нам. Синеньким обозначено то, что реализовано фреймворком Zigbee.

Ну где же практика? Давайте уже ближе к коду! Хорошо, давайте ближе, но есть еще одна важная тема, которую нужно осветить. Дело в том, что в прошлой статье мы использовали довольно топорный способ подключения к сети с помощью низкоуровневых функций Zigbee стека (ZPS_eAplZdoStartStack(), ZPS_eAplZdoJoinNetwork()), которые в ответ отправляют низкоуровневые сообщения вроде ZPS_EVENT_NWK_DISCOVERY_COMPLETE, и ZPS_EVENT_NWK_JOINED_AS_ROUTER. Это все правильно, но не полностью соответствует спецификации. 

Дело в том, что есть Zigbee Base Device Specification которая описывает как именно должно себя вести устройство в тех или иных ситуациях, в том числе при подключении к сети. Там довольно подробно описано как, что, в какой последовательности и в каких случаях должно делать устройство. Вот, например, диаграмма (одна из нескольких) которая описывает процесс подключения к сети.

Выглядит сложно, да. Но есть хорошая новость: в NXP за нас уже все это реализовали. ZigBee SDK предоставляет компонент Base Device Behavior (BDB), который уже реализует необходимое поведение. Нужно просто переключиться на использование более высокоуровневых функций (вроде BDB_eNsStartNwkSteering()), которые в ответ будут слать высокоуровневые сообщения о событиях (такие как BDB_EVENT_NWK_STEERING_SUCCESS). Технически это не что-то такое совершенно другое - это просто надстройка над теми же самыми низкоуровневыми функциями и сообщениями, только реализует логику, которую требует BDB спецификация.

Ну вот теперь уже можно погружаться в код. Для начала что происходит в vAppMain()

typedef enum
{
   NOT_JOINED,
   JOINING,
   JOINED

} JoinStateEnum;

PersistedValue<JoinStateEnum, PDM_ID_NODE_STATE> connectionState;

extern "C" PUBLIC void vAppMain(void)
{
...
   // Restore network connection state
   connectionState.init(NOT_JOINED);
...
   sBDB.sAttrib.bbdbNodeIsOnANetwork = (connectionState == JOINED ? TRUE : FALSE);
   DBG_vPrintf(TRUE, "vAppMain(): Starting base device behavior... bNodeIsOnANetwork=%d\n", sBDB.sAttrib.bbdbNodeIsOnANetwork);
   BDB_vStart();
...
//    // Reset Zigbee stack to a very default state
//    ZPS_vDefaultStack();
//    ZPS_vSetKeys();
//    ZPS_eAplAibSetApsUseExtendedPanId(0);

//    // Start ZigBee stack
//    DBG_vPrintf(TRUE, "vAppMain(): Starting ZigBee stack... ");
//    status = ZPS_eAplZdoStartStack();
//    DBG_vPrintf(TRUE, "ZPS_eAplZdoStartStack() status %d\n", status);

Прежде всего перед всеми сетевыми операциями нужно восстановить предыдущее состояние (функция connectionState.init() вычитывает предыдущее состояние из PDM, ну а в случае если там ничего небыло, то устанавливает его как NOT_JOINED). Да, еще нужно дать знать модулю Base Device Behavior (BDB) было ли устройство ранее привязано к сети или нет с помощью флага bbdbNodeIsOnANetwork. Если этот флаг выставлен устройство восстановит параметры сети из EEPROM (номер канала, ключи шифрования) и переприсоединится к сети.

Раньше при включении мы делали полный сброс параметров подключения к сети и производили подключение с помощью функции ZPS_eAplZdoStartStack(). Теперь это все не нужно, т.к. мы будем использовать функции из BDB.

Теперь нужно написать несколько функций, который будут делать подключение и отключение от сети. Эти функции реализуют черные стрелочки на диаграмме состояний выше. Пара слов об именовании функций. Читая код примера я очень долго не мог понять зачем там столько функций и вызовов со словом Join в названии. И только написав этот код я понял принцип: функции с глаголом в названии выполняют некоторое действие, тогда как функции со словом Handle вызываются как реакция на событие, которое уже произошло. Т.е. функция vJoinNetwork() инициирует подключение, а функция vHandleNetworkJoinAndRejoin() вызывается как результат, когда устройство уже подключилось. Тоже самое и с другими функциями.

Для подключения к сети мы будем использовать функцию BDB_eNsStartNwkSteering() - она запускает процесс network discovery и присоединения к какой-нибудь сети, которая готова принять наше устройство. Важно отметить, что часть параметров сети сохраняется в PDM. Во время написания кода у меня случился некий рассинхрон между алгоритмом подключения и сохраненными параметрами сети. После некоторых экспериментов я пришел к выводу, что перед подключением к новой сети лучше всего будет сбрасывать все параметры и приводить устройство к заводскому виду.

PRIVATE void vJoinNetwork()
{
   DBG_vPrintf(TRUE, "== Joining the network\n");
   connectionState = JOINING;

   // Clear ZigBee stack internals
   sBDB.sAttrib.bbdbNodeIsOnANetwork = FALSE;
   sBDB.sAttrib.u8bdbCommissioningMode = BDB_COMMISSIONING_MODE_NWK_STEERING;
   ZPS_eAplAibSetApsUseExtendedPanId (0);
   ZPS_vDefaultStack();
   ZPS_vSetKeys();
   ZPS_vSaveAllZpsRecords();

   // Connect to a network
   BDB_eNsStartNwkSteering();
}

Устанавливать флаг sBDB.sAttrib.bbdbNodeIsOnANetwork в FALSE перед каждым подключением нужно обязательно. В противном случае может возникнуть ситуация, что устройство вывалилось из сети но флаг bbdbNodeIsOnANetwork будет по прежнему взведен. В свою очередь функция BDB_eNsStartNwkSteering() пойдет по отдельной ветке и не будет запускать network discovery.

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

PRIVATE void vHandleNetworkJoinAndRejoin()
{
   DBG_vPrintf(TRUE, "== Device now is on the network\n");
   connectionState = JOINED;
   ZPS_vSaveAllZpsRecords();
   ZPS_eAplAibSetApsUseExtendedPanId(ZPS_u64NwkNibGetEpid(ZPS_pvAplZdoGetNwkHandle()));
}

Суть вызова ZPS_eAplAibSetApsUseExtendedPanId() мне до конца не ясна, без него все также работает, но этот вызов требует документ ZigBee 3.0 Stack User Guide JN-UG-3113.

Аналогичная пара функций будет для запуска процедуры выхода из сети по инициативе устройства (vLeaveNetwork()) и обработки факта, что устройство вышло из сети (vHandleLeaveNetwork()). В обоих случаях мы окажемся в состоянии NOT_JOINED.

PRIVATE void vLeaveNetwork()
{
   DBG_vPrintf(TRUE, "== Leaving the network\n");
   sBDB.sAttrib.bbdbNodeIsOnANetwork = FALSE;
   connectionState = NOT_JOINED;

   if (ZPS_E_SUCCESS !=  ZPS_eAplZdoLeaveNetwork(0, FALSE, FALSE))
   {
       // Leave failed, probably lost parent, so just reset everything
       DBG_vPrintf(TRUE, "== Failed to properly leave the network. Force leaving the network\n");
       vHandleLeaveNetwork();
    }
}

PRIVATE void vHandleLeaveNetwork()
{
   DBG_vPrintf(TRUE, "== The device has left the network\n");

   connectionState = NOT_JOINED;

   // Clear ZigBee stack internals
   ZPS_eAplAibSetApsUseExtendedPanId (0);
   ZPS_vDefaultStack();
   ZPS_vSetKeys();
   ZPS_vSaveAllZpsRecords();
}

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

PRIVATE void vHandleRejoinFailure()
{
   DBG_vPrintf(TRUE, "== Failed to (re)join the network\n");

   vHandleLeaveNetwork();
}

Теперь нужно со всем этим попробовать взлететь. Для начала нужно рассмотреть случай, когда пользователь инициирует подключение или отключение. Это функция обработки событий от кнопки.

PRIVATE void APP_vTaskSwitch()
{
   ApplicationEvent value;
   if(appEventQueue.receive(&value))
   {
       DBG_vPrintf(TRUE, "Processing button message %d\n", value);

       if(value == BUTTON_SHORT_PRESS)
       {
           vToggleSwitchValue();
       }

       if(value == BUTTON_LONG_PRESS)
       {
           if(connectionState == JOINED)
               vLeaveNetwork();
           else
               vJoinNetwork();
       }
   }
}

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

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

PUBLIC void APP_vBdbCallback(BDB_tsBdbEvent *psBdbEvent)
{
   switch(psBdbEvent->eEventType)
   {
...
       case BDB_EVENT_REJOIN_SUCCESS:
           DBG_vPrintf(TRUE, "BDB event callback: Network Join Successful\n");
           vHandleNetworkJoinAndRejoin();
           break;

       case BDB_EVENT_NWK_STEERING_SUCCESS:
           DBG_vPrintf(TRUE, "BDB event callback: Network steering success\n");
           vHandleNetworkJoinAndRejoin();
           break;

       case BDB_EVENT_REJOIN_FAILURE:
           DBG_vPrintf(TRUE, "BDB event callback: Failed to rejoin\n");
           vHandleRejoinFailure();
           break;

       case BDB_EVENT_NO_NETWORK:
           DBG_vPrintf(TRUE, "BDB event callback: No good network to join\n");
           vHandleRejoinFailure();
           break;
...

Также нужно модифицировать функцию обработки событий к Zigbee Device Objects (ZDO) - части, которая обслуживает сетевые события от имени всего устройства. Поскольку мы поручили процесс подключения к сети компоненту BDB, в  ZDO уже нет смысла обрабатывать какие либо события до того как устройство подключилось.

PRIVATE void vAppHandleZdoEvents(ZPS_tsAfEvent* psStackEvent)
{
   if(connectionState != JOINED)
   {
       DBG_vPrintf(TRUE, "Handle ZDO event: Not joined yet. Discarding event %d\n", psStackEvent->eType);
       return;
   }

   switch(psStackEvent->eType)
   {
       case ZPS_EVENT_APS_DATA_INDICATION:
           vHandleZdoDataIndication(psStackEvent);
           break;

       case ZPS_EVENT_NWK_LEAVE_INDICATION:
           if(psStackEvent->uEvent.sNwkLeaveIndicationEvent.u64ExtAddr == 0)
               vHandleLeaveNetwork();
           break;

       case ZPS_EVENT_NWK_LEAVE_CONFIRM:
           vHandleLeaveNetwork();
           break;

       default:
           //DBG_vPrintf(TRUE, "Handle ZDO event: event type %d\n", psStackEvent->eType);
           break;
   }
}

На счет выхода из сети. Тут обнаружилось расхождение документации и реального поведения сети. Есть 2 разных сообщения, связанных с выходом из сети

  • у сообщения ZPS_EVENT_NWK_LEAVE_INDICATION есть два значения

    • 1) какое-то устройство вышло из сети и нас об этом просто информируют (нам в принципе пофиг)

    • 2) нас попросили покинуть сеть (это событие нужно обработать с помощью функции vHandleLeaveNetwork())

  • Иногда устройство хочет выйти из сети по своей инициативе (или инициативе пользователя). Устройство делает запрос на выход из сети, а в ответ приходит сообщение ZPS_EVENT_NWK_LEAVE_CONFIRM в качестве подтверждения, что координатор запрос на выход принял. 

Так вот на деле (во всяком случае с zigbee2mqtt) когда координатор просит устройство покинуть сеть сразу приходит сообщение ZPS_EVENT_NWK_LEAVE_CONFIRM, а не ZPS_EVENT_NWK_LEAVE_INDICATION. Я не понял почему именно так. Я реализовал обработчики обоих сообщений, но протестировать смог только на ZPS_EVENT_NWK_LEAVE_CONFIRM.

Как бы то ни было попробуем подключиться к сети и посмотрим как теперь работает подключение к сети.

Вывод достаточно мутный, я даже сначала подумал что тут что-то не то. На самом деле тут события происходят в двух потоках. Основная часть подключения происходит в потоке BDB (Я включил дефайн DEBUG_BDB, который включает отладочный вывод самого BDB). Когда случается событие оно укладывается в очередь, и обрабатываются в другом потоке чуточку позже. Поэтому этот лог выглядит достаточно странно - один поток уже начинает сканирование на 12 канале, тогда как второй только только рапортует о том, что сканирование на 11 канале закончено.

Иногда в радиоканале случаются сбои и подключение не удается. Тут нам на помощь опять приходит BDB, который пробует подключиться несколько раз. Со второй попытки подключение удалось. Но и тут приключение на заканчиваются. Вот еще кусочек лога, который не влез в предыдущий скриншот.

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

  • устройство посылает координатору запрос на Node Descriptor

  • В ответе устройство узнает версию ZigBee стека координатора, и в частности поддерживает ли он протокол r21 Trust Center. Если бы поддерживал, то тут мы бы видели еще обмен ключей шифрования

  • Последним действием устройство отправляет широковещательное сообщение Permit Join

  • Только после этого устройство считается полностью подключенным.

Оставшуюся часть лога вы уже видели в прошлой статье.

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

Вот так выглядит переподключение после перезагрузки.

А выглядит оно никак. При переподключении в режиме роутера устройство ничего не отправляет в сеть, и ничего не получает в ответ. Т.е. устройство просто загрузилось и работает. Я, конечно, ожидал что будет сообщение вроде BDB_EVENT_REJOIN_SUCCESS, но судя по коду оно отправляется только при переподключении конечных устройств, а не роутеров.

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

А вот так выглядит выход из сети.

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

  • Отойти подальше от координатора

  • Подключить устройство к сети и убедиться в том, что оно подключилось через промежуточный роутер

  • Выключить устройство

  • Выключить промежуточный роутер

  • Переместить устройство в другой конец квартиры где работают другие роутеры

  • Включить устройство и посмотреть что получится.

На удивление все прошло гладко. Я потерял лог сниффера, но там я увидел примерно следующее

  • Устройство при включении не отправляло в сеть никаких сигналов о переподключении (потому, что роутер. Конечное устройство бы посылало сообщение rejoin request)

  • Устройство начинает болтать с другими роутерами на тему обновления таблиц маршрутизации

  • Когда я нажал кнопку переключения лампочки, устройство осознало, что не знает маршрута к координатору, поэтому в сеть было отправлено широковещательное сообщение route request (“кто-нибудь знает маршрут к координатору?”)

  • Несколько роутеров в округе ответили, что да, знают. Сбор информации о маршрутах занял примерно 100мс.

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

  • Ну а что было дальше вы уже знаете из предыдущей статьи

Можно было бы на этом и закончить, но есть еще 2 момента, которые я бы хотел подчеркнуть.

Момент первый: Вероятно в 2006 году, когда придумывали ZigBee, механизмы переподключения к сети видели несколько иначе. Спецификация BDB этот процесс описывает так: попробовать подключиться к той же сети, а если не получилось - поискать другие сети и подключиться к ним. Какие еще другие сети? Я не хочу, чтобы мое устройство вдруг подключилось к сети соседа, который забыл снять флаг permit join. Тем не менее именно такое поведение реализовано в компоненте BDB. Впрочем, можно отучить BDB лезть в другие сети выставив настройку BDBC_IMP_MAX_REJOIN_CYCLES в 1.

Момент второй: в реальной жизни кода, который мы написали выше будет мало. Что произойдет, если в доме отключат свет и все роутеры и координатор вдруг исчезнут? Устройство несколько раз попробует куда-нибудь подключиться, сработает событие BDB_EVENT_REJOIN_FAILURE и устройство перейдет в состояние NOT_JOINED, где и останется. Вероятно это не то, что ожидает пользователь домашней сети ZigBee. Я думаю нужно как-то различать ситуации между “пользователь захотел куда-то подключиться и у него не получилось” и “что-то произошло с сетью и она пропала”. В первом случае, вероятно, ничего делать не нужно - пользователь рядом и он и так видит, что устройство не подключилось. Во втором случае думаю стОит запустить какой-нибудь таймер и попробовать переподключиться снова через некоторое время, и только после нескольких попыток уже сдаваться. Сделать это можно в обработчике vHandleRejoinFailure(). 

End devices / Конечные устройство

Как вы знаете в сетях ZigBee могут работать 3 типа устройств:

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

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

  • Конечные устройства реализуют функционал устройства, но в транзитной передаче данных не участвуют.

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

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

Давайте посмотрим как это работает на деле и какие процессы происходят вокруг. Пускай наш умный выключатель станет конечным устройством. Для начала в ZPS Configuration Editor создадим новую конфигурацию для конечного устройства. К сожалению программа не дает просто поменять тип устройства у того, что мы делали в прошлой статье, поэтому пришлось создать совершенно новую конфигурацию. Ну и без творческой копи/пасты никуда. Для эксперимента, я поставил тип питания устройства как rechargeable battery.

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

Теперь нужно перегенерировать pdum_gen.c и zps_gen.c. 

Это далеко не весь diff.
Это далеко не весь diff.

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

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

Тут мы видим:

  • устройство отправляет beacon request (осталось за кадром чуть выше)

  • роутеры неподалеку ему отвечают (осталось за кадром чуть выше)

  • Устройство делает запрос на привязку (association request)

  • Роутер подтверждает привязку к сети и выдает сетевой адрес 0x2fd4 (association response)

  • Роутер сообщает координатору что присоединилось новое устройство (device update) и просит выслать ему транспортный ключ шифрования

  • Координатор высылает транспортный ключ (transport key), который идет к устройству через промежуточный узел 0x924b (роутер, к которому привязалось наше конечное устройство)

  • Наконец роутер 4 раза пытается переслать транспортный ключ нашему устройству, и что самое важное на этом скриншоте - устройство его НЕ подтверждает (нет записи ACK)

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

Признаюсь, проблему я решил методом тыка, устанавливая разные опции в конфигураторе. Помогла установка опции sleeping в true. На деле эта опция изменяет параметр RxOnWhenIdle в Node Descriptor’е. Насколько я понял документацию, этот параметр как раз и говорит родительскому роутеру может ли устройство принимать сообщение когда попало, или только в определенные моменты времени, когда устройство не спит и реально готово к приему. Впрочем, определенная доля непонимания все же осталась - я что не могу сделать конечное устройство, которое не спит? Ну да ладно, все равно этот режим как раз и предназначен для спящих устройств. Работает - и славно.

Выглядит все это так

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

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

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

PollTask::PollTask()
{
   pollPeriod = 0;
   PeriodicTask::init();
}

PollTask& PollTask::getInstance()
{
   static PollTask task;
   return task;
}

void PollTask::startPoll(int period)
{
   pollPeriod = period;
   startTimer(period);
}

void PollTask::stopPoll()
{
   stopTimer();
}

void PollTask::timerCallback()
{
   ZPS_eAplZdoPoll();

   // Restart the timer
   startTimer(pollPeriod);
}


PRIVATE void vHandleNetworkJoinAndRejoin()
{
   DBG_vPrintf(TRUE, "== Device now is on the network\n");
...
   PollTask::getInstance()->startPoll(2000);
}

PRIVATE void vHandleLeaveNetwork()
{
   DBG_vPrintf(TRUE, "== The device has left the network\n");
...
   PollTask::getInstance()->stopPoll();
...
}

Вот так выглядит периодический опрос и попытка “включить свет” из zigbee2mqtt

Теперь становится понятно как работает опрос. Раз в 2 секунды устройство опрашивает роутер, и не получая никаких данных успокаивается. Если данные на роутере имеются - они доставляются в конечное устройство, где обрабатываются как обычно. В конце периода опроса генерируется сообщение ZPS_EVENT_NWK_POLL_CONFIRM со статусом Success, если данные были получены, или No Data если данных небыло. 

Лично мне было очень интересно посмотреть как опрос выглядит в сниффере. Оказалось что это происходит сообщением Data Request (раньше я не понимал зачем нужно это сообщение. Оно мозолило мне глаза и засоряло вывод сниффера. Теперь я, наконец, понял что через него и происходит опрос)

Итак, тут мы видим следующее

  • Координатор (0x0000) отправляет нашему устройству (0x0d21) команду ZCL OnOff. 

    • Колонки Source и Destination означают откуда и куда в целом двигается сообщение.

    • Поскольку узлы могут быть не связаны напрямую, сообщение может пересылаться через несколько узлов. Колонки HW Source и HW Destination показывают отсылку сообщения между двумя узлами на маршруте.

    • В данном случае (запись 406) сообщение пересылается от координатора (0x0000) к промежуточному роутеру (0xf544)

    • Происходит это в момент времени 19.55

  • Спустя почти секунду, в 20.44 (сообщение 416) наше устройство (0x0d21) обращается к роутеру (0xf544) с вопросом “есть ли корреспонденция для меня?”

  • Роутер отвечает, что да, координатор отправил команду ZCL OnOff (сообщение 418). 

    • Обратите внимание, что это то же самое сообщение, что и в строке 406, и у него такой же Sequence number 222. Просто на этот раз сообщение передается от роутера к нашему конечному устройству

  • Спустя 20мс устройство отвечает, что да, команду получили, вот вам ZCL Default Response (на тот же sequence number).

    • Конечный адрес сообщения - это координатор (0x0000), но пакет отправляется в 2 прыжка, через роутер (0xf544).

  • Координатор подтверждает наш default response с помощью сообщения APS Ack. При чем это сообщение еще 60мс болтается на роутере, пока наше конечное устройство его не заберет пакетом Data Request (записи 426, 430, и 432) 

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

void vHandlePollResponse(ZPS_tsAfPollConfEvent* pEvent)
{
   switch (pEvent->u8Status)
   {
       case MAC_ENUM_SUCCESS:
       case MAC_ENUM_NO_ACK:
           ZPS_eAplZdoPoll();
           break;

       case MAC_ENUM_NO_DATA:
       default:
           break;
   }

}

PRIVATE void vAppHandleZdoEvents(ZPS_tsAfEvent* psStackEvent)
{
....
       case ZPS_EVENT_NWK_POLL_CONFIRM:
           vHandlePollResponse(&psStackEvent->uEvent.sNwkPollConfirmEvent);
           break;

Подключение/переподключение конечных устройств и режимы сна

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

Оказывается есть отдельная команда ZigBee протокола - rejoin request. Поскольку устройство уже было в сети ранее и ключ шифрования сети устройству известен, эта команда идет зашифрованным каналом и обмен ключами шифрования не требуется. А вот адрес устройства в сети может поменяться, поэтому роутер в ответ отправляет rejoin response с новым адресом. Кстати говоря, процедура переподключения не требует, чтобы был включен режим permit join (считается что устройство уже в сети, просто ему потребовалось переподключиться).

После переподключения происходят еще несколько событий:

  • Роутер иформирует координатора, что конечное устройство переподключилось, и отправляет новый адрес устройства (сообщение Update Device)

  • Устройство объявляет сети о своем появлении (Device Announcement). Используется широковещательное сообщение.

  • координатор широковещательным сообщением отправляет информацию о маршруте к новому устройству (в скриншот не влезло)

В принципе все понятно, и компонент BDB за нас все делает. Вот только получается, что на каждую перезагрузку устройство генерирует лавину сообщений по сети. А если мое устройство будет все время находится в режиме deep sleep, мне эту процедуру нужно на каждое просыпание проходить?

Очень кстати нашелся пример JN-AN-1217-Zigbee-3-0-Base-Device, который представляет собой скелеты для прошивок разных устройств, в том числе и End Device. Пример показывает некий минимальный набор кода, необходимый для работы устройства в сети (по сути все то о чем я уже третью статью рассказываю). Так вот исходя из этого примера, а также кода других примеров я вижу несколько стратегий касательно засыпания и переподключения.

Наиболее простым является засыпание устройства в режиме OSC On / RAM On (держим включенными таймер-будильник и память). Таймер нужен, чтобы время от времени просыпаться и делать опрос родительского роутера на предмет сообщений к устройству.  При выходе из такого режима сна переподключаться к сети не нужно - поскольку память находится под напряжением, адрес в сети, таблица маршрутизации, и другие мгновенные параметры будут вполне актуальные. Т.е. проснувшись устройство сразу может начинать опрос родительского роутера.

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

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

Если все таки 1.5 мкА слишком много для режима сна, есть режим Deep Sleep, в котором потребление уже будет измеряться наноамперами. Минусом такого подхода будет необходимость делать процедуру rejoin’а при просыпании устройства, а как мы видели выше это влечет за собой определенный объем сообщений по сети.

Исходя из этого можно предположить, что первый режим (легкий сон) будет актуален для устройств, которые общаются с сетью хотя бы раз в несколько минут. Второй режим (глубокий сон) можно использовать для совсем долгоживующих устройств, для которых коммуникация раз в день это приемлемо. Примеры от NXP используют комбинированный подход: режим легкого сна в рабочем режиме, а если устройство вывалилось из сети и не может подключиться через несколько попыток - уходит в глубокий сон.

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

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

Пришлось пуститься в размышления:

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

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

  • Частый опрос -> быстро сядет батарейка.

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

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

  • Датчики температуры и датчики движения от Xiaomi делают опрос роутера примерно раз в час (при этом сами данные о температуре или освещенности отправляются гораздо чаще). Беспроводной батарейный выключатель Xiaomi (тот который Wall switch без самого реле) также опрашивает роутер раз в час. Это подтверждает теорию о том, что write-only устройства могут опрашивать роутер очень редко. Причем, вероятно, только для того, чтобы их не выкинули из сети.

  • Термостат Moes на радиатор отопления опрашивает роутер раз в 5 секунд. Это подтверждается наблюдениями что изменение температуры через home assistant доезжает до термостата с ощутимой задержкой в эти самые 5 секунд. Все это объясняет почему нужно на зиму готовить чемодан батареек - при такой частоте опроса термостат расходует 2 пальчиковые батарейки примерно за 2 месяца.

  • Выключатель Xiaomi Aqara без нулевой линии, не смотря на то, что в целом подключен к электросети (пусть и через нагрузку), почему-то в сети Zigbee является конечным устройством. А это означает, что он вынужден опрашивать роутер 4 раза в секунду (раз в 250мс), чтобы обеспечить приемлемую задержку срабатывания. Если таких устройств в сети много, то и радиоканал они будут забивать порядочно.

В общем, я не обнаружил у именитых производителей (Xiaomi, Moes, Tuya) каких либо хитрых алгоритмов по управлению частотой опроса роутера, а потому в своем примере пойду тем же путем. Пускай мое устройство будет работать следующим образом

  • Если все тихо и спокойно - устройство спит

  • Просыпаемся раз в 15 секунд, чтобы опросить родительский роутер

  • Также просыпаемся по кнопке, чтобы переключить состояние нашего выключателя, оглядываемся вокруг и засыпаем через 5 секунд.

Вспоминая материал самой первой статьи попробуем это реализовать. Как мы помним микроконтроллер уходит в сон вызовом функции PWRM_vManagePower(), которая вызывается из главного цикла. Но Функция эта умная, и требует настройки

  • PWRM_vInit(E_AHI_SLEEP_OSCON_RAMON) в разделе инициализации задаст режим легкого сна, при этом память отключаться не будет, а для просыпания будет использован будильник.

  • Вызов PWRM_eScheduleActivity() будет задавать этот самый будильник. Примерно так.

PRIVATE void APP_vTaskSwitch(Context * context)
{
...
   if(ButtonsTask::getInstance()->canSleep() &&
      ZigbeeDevice::getInstance()->canSleep())
   {
       DBG_vPrintf(TRUE, "=-=-=- Scheduling enter sleep mode... ");

       static pwrm_tsWakeTimerEvent wakeStruct;
       PWRM_teStatus status = PWRM_eScheduleActivity(&wakeStruct, 15 * 32000, wakeCallBack);
       DBG_vPrintf(TRUE, "status = %d\n", status);
   }
}

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

void ZigbeeDevice::pollParent()
{
   polling = true;
   DBG_vPrintf(TRUE, "Polling for zigbee messages...\n");
   ZPS_eAplZdoPoll();
}

void ZigbeeDevice::handlePollResponse(ZPS_tsAfPollConfEvent* pEvent)
{
   switch (pEvent->u8Status)
   {
       case MAC_ENUM_SUCCESS:
       case MAC_ENUM_NO_ACK:
           pollParent();
           break;

       case MAC_ENUM_NO_DATA:
           polling = false;
       default:
           break;
   }
}

bool ZigbeeDevice::canSleep() const
{
   return !polling;
}

Тут все просто. Заведем флажок polling, который будет сигнализировать что идет процесс опроса роутера. Флажок будет взводить когда этот опрос начинается, а снимать когда пришло подтверждение ZPS_EVENT_NWK_POLL_CONFIRM со статусом No Data.

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

PWRM_CALLBACK(PreSleep)
{
...
   // Save the MAC settings (will get lost though if we don't preserve RAM)
   vAppApiSaveMacSettings();
...
}

PWRM_CALLBACK(Wakeup)
{
...
   // Restore Mac settings (turns radio on)
   vMAC_RestoreSettings();
...
   // Poll the parent router for zigbee messages
   ZigbeeDevice::getInstance()->handleWakeUp();
}

void ZigbeeDevice::handleWakeUp()
{
       // TODO: more code here later

       pollParent();
}

При засыпании нужно сохранить настройки MAC уровня zigbee стека, а при просыпании, соответственно, восстановить. Но это еще не все. При просыпании также неплохо запустить опрос родительского роутера на предмет накопившихся сообщений.

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

extern "C" PUBLIC void vISR_SystemController(void)
{
   // clear pending DIO changed bits by reading register
   uint8 wakeStatus = u8AHI_WakeTimerFiredStatus();
   uint32 dioStatus = u32AHI_DioInterruptStatus();

   DBG_vPrintf(TRUE, "In vISR_SystemController\n");

   if(ButtonsTask::getInstance()->handleDioInterrupt(dioStatus))
   {
       DBG_vPrintf(TRUE, "=-=-=- Button interrupt dioStatus=%04x\n", dioStatus);
       PWRM_vWakeInterruptCallback();
   }

   if(wakeStatus & E_AHI_WAKE_TIMER_MASK_1)
   {
       DBG_vPrintf(TRUE, "=-=-=- Wake Timer Interrupt\n");
       PWRM_vWakeInterruptCallback();
   }
}

Посмотрим как это все работает.

На этом скриншоте видно как устройство просыпается по таймеру, опрашивает родительский роутер, получает ответ No Data и засыпает опять.

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

Просыпание по кнопке также работает, но там ничего интересного не обнаружилось.

Нештатные ситуации с переподключением конечных устройств

А теперь самая Zigbee-шная из сетевых и самая сонная из безпроводных рубрик - эээээксперименты (С) Пушной, программа Галилео

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

Эксперимент 1: Во время штатной коммуникации исчезает родительский роутер. 

Тестовый сценарий

  • Добавляем устройство в сеть и смотрим к какому роутеру оно прицепилось

  • Дожидается пока устройство уйдет в сон

  • Быстренько выключаем родительский роутер

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

Мы тут видим, что конечное устройство несколько раз пытается обратиться к роутеру запросом Data Request. Поскольку роутер не отвечает, конечное устройство решает поискать другой роутер с помощью уже знакомой нам процедуры network discovery. Когда новый роутер найден, устройство подключается к нему с помощью сообщения rejoin request. В ответ устройство получает право переприсоединиться к сети, только уже под новым адресом. После этого по сети прокатывается волна широковещательных сообщений Device announcement (о том, что присоединилось новое устройство), а также серия сообщений про обновление таблиц маршрутизации (чтобы другие узлы сети узнали новый маршрут к нашему устройству). 

Процедура переподключения заняла не более секунды, начиная с момента, когда устройство осознало себя одиноко в этом мире, до момента когда все в сети были уведомлены о новом положении вещей. Кстати говоря, для этого случая я не написал ни одной дополнительной строчки кода - функциональность по переподключению реализована в компоненте BDB и его подкомпоненте Failure Recovery.

Со стороны устройства это выглядит так

Ок. С этим сценарием устройство справилось. Попробуем усложнить.

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

Тестовый сценарий:

  • подключаем устройство к сети, убеждаемся, что устройство в сети правильно функционирует

  • отключаем устройство

  • отключаем роутер, к которому присоединилось устройство

  • включаем устройство

  • наблюдаем что происходит, когда устройство не может отправить rejoin request


С точки зрения сетевой коммуникации все весьма похоже:

  • устройство пытается отправить Rejoin Request роутеру с которым взаимодействовало ранее (0xdc47)

  • После четырех безуспешных попыток устройство начинает процедуру Network Discovery

  • Отправляется Rejoin Request новому родительскому роутеру (0x924b)

  • Далее Update Device чтобы обновить информацию об устройстве на координаторе, Device Announcement, и настройка таблиц маршрутизации (ниже по тексту - там ничего интересного, поэтому я не вставляю эти скриншоты)

А вот в логах устройства наблюдается весьма интересная картина (я включил логи BDB)

Тут опять сообщения немножко вперемешку, но дело обстоит так

  • Устройство стартует с флагом bNodeIsOnANetwork=1, это говорит BDB, что устройство уже было в сети и можно сразу общаться

  • BDB пробует переподключиться без network discovery (Rejoin Cycle 1-A without Disc)

  • В ответ приходит сообщение ZPS_EVENT_NWK_FAILED_TO_JOIN, которое обрабатывается в недрах BDB

  • В качестве реакции на сообщение BDB переходит к следующей фазе переподключения (Rejoin Cycle 1-B with Disc on Primary), что означает запуск процедуры Network Discovery

  • В конце концов приходит сообщение ZPS_EVENT_NWK_JOINED_AS_END_DEVICE, которое также обрабатывается в BDB, по результатам чего получаем сообщение уже от BDB - BDB event callback: Network Join Successful

Этим я хотел показать, что бОльшую часть рутинной работы по подключению, переподключению, и обработке нештатных ситуаций за нас делает BDB. Восстановление работоспособности в такой ситуации заняло около 4 секунд, причем бОльшую часть тупило наше устройство (возможно, часть времени занимает процесс network discovery на всех возможных каналах).

Едем дальше.

Ситуация 3: Устройство пробует переподключиться, а вокруг никого.

Тестовый сценарий:

  • подключаем устройство к сети, убеждаемся, что устройство в сети правильно функционирует

  • отключаем устройство

  • отключаем ВСЕ роутеры в сети (включая координатор)

  • включаем устройство

  • наблюдаем что происходит, когда устройство не может отправить rejoin request и не может никого найти с помощью Network Discovery

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

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

Только вот правильно ли такое поведение? Такая ситуация вполне может возникнуть если ненадолго отключили электричество. В наших краях это не то, чтобы частое явление, но все же встречается. Вероятно, нужно различать ситуацию, когда подключение инициировано пользователем, и если у нас происходит переподключение по причине неполадок с сетью. В первом случае никакой особой обработки ошибок не требуется - пользователь рядом, и при необходимости еще раз нажмет кнопку “подключиться”. Во втором случае есть смысл организовать переподключение через 5, 10, или 60 минут - вероятно к этому времени ситуация изменится.

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

Для начала я завел 2 переменные:

   int rejoinFailures;
   int cyclesTillNextRejoin;

Первая отсчитывает количество попыток переподключений, прежде чем окончательно сдаться и выйти из сети. Вторая отсчитывает количество 15-секундных интервалов сна, прежде чем повторить попытку переприсоединения.

Далее я выделил код для подключения и переподключения в 2 отдельные функции. Этот код вы уже видели выше.

void ZigbeeDevice::joinNetwork()
{
   DBG_vPrintf(TRUE, "== Joining the network\n");
   connectionState = JOINING;

   // Clear ZigBee stack internals
   sBDB.sAttrib.bbdbNodeIsOnANetwork = FALSE);
   sBDB.sAttrib.u8bdbCommissioningMode = BDB_COMMISSIONING_MODE_NWK_STEERING;
   ZPS_eAplAibSetApsUseExtendedPanId(0);
   ZPS_vDefaultStack();
   ZPS_vSetKeys();
   ZPS_vSaveAllZpsRecords();

   // Connect to a network
   BDB_eNsStartNwkSteering();
   DBG_vPrintf(TRUE, "  BDB_eNsStartNwkSteering=%d\n", status);
}

void ZigbeeDevice::rejoinNetwork()
{
   DBG_vPrintf(TRUE, "== Rejoining the network\n");

   sBDB.sAttrib.bbdbNodeIsOnANetwork = (connectionState == JOINED ? TRUE : FALSE);
   sBDB.sAttrib.u8bdbCommissioningMode = BDB_COMMISSIONING_MODE_NWK_STEERING;

   DBG_vPrintf(TRUE, "ZigbeeDevice(): Starting base device behavior... bNodeIsOnANetwork=%d\n", sBDB.sAttrib.bbdbNodeIsOnANetwork);
   ZPS_vSaveAllZpsRecords();
   BDB_vStart();
}

Счетчик переподключений сбрасывается при подключении и при выходе из сети.

void ZigbeeDevice::leaveNetwork()
{
...
   rejoinFailures = 0;
...
}

void ZigbeeDevice::handleNetworkJoinAndRejoin()
{
...
   rejoinFailures = 0;
}

Интересное начинается когда устройство не может переподключиться

void ZigbeeDevice::handleRejoinFailure()
{
   DBG_vPrintf(TRUE, "== Failed to (re)join the network\n");
   polling = false;

   if(connectionState == JOINED && ++rejoinFailures < 5)
   {
       DBG_vPrintf(TRUE, "  Rejoin counter %d\n", rejoinFailures);

       // Schedule sleep for a minute
       cyclesTillNextRejoin = 4; // 4 * 15s = 1 minute
   }
   else
       handleLeaveNetwork();
}

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

И, наконец, обработчик просыпания теперь будет участвовать в переподключении, если это необходимо.

bool ZigbeeDevice::needsRejoin() const
{
   // Non-zero rejoin failure counter reflects that we have received spontaneous
   // Rejoin failure message while the node was in JOINED state
   return rejoinFailures > 0 && connectionState == JOINED;
}

void ZigbeeDevice::handleWakeUp()
{
   if(connectionState != JOINED)
       return;

   if(needsRejoin())
   {
       // Device that is basically connected, but currently needs a rejoin will have to
       // sleep a few cycles between rejoin attempts
       if(cyclesTillNextRejoin-- > 0)
       {
           DBG_vPrintf(TRUE, "ZigbeeDevice: Rejoining in %d cycles\n", cyclesTillNextRejoin);
           return;
       }

       rejoinNetwork();
   }
   else
       // Connected device will just poll its parent on wake up
       pollParent();
}

Этот код работает только в случае если устройство было подключено к сети (JOINED) но по какой-то причине получило сообщение REJOIN_FAILED, что говорит о неполадках в сети. По прошествию необходимого таймаута в одну секунду устройство пробует переподключиться (rejoinNetwork() ). При штатной работе функция будет просто опрашивать родительский роутер, как и раньше.

C++

В этом разделе ничего не будет про ZigBee (ну практически). Так что если вы не интересуетесь языком С++ - можете смело мотать к заключению.

Лично я не очень люблю писать на чистом С, если можно писать на С++. Как мне кажется клиентский код на плюсах получается намного более читаемый и лаконичный, тогда как все потроха можно спрятать внутрь класса. Разумеется, в микроконтроллерах мы не говорим о полноценном С++ - придется отказаться от исключений, стандартной библиотеки, контейнеров и многого другого. Но даже без этого язык С++ дает гораздо более богатые возможности проверки типов чем С: наследование, шаблоны, полиморфизм, RAII, и многое другое. 

Компилятор с которым поставляется NXP SDK - очень древний gcc.

Для начала попробуем завернуть небольшие примитивы SDK от NXP в классики. Вот, например, класс таймера

class Timer
{
   uint8 timerHandle;

public:
   void init(ZTIMER_tpfCallback cb, void * param, bool preventSleep = false)
   {
       ZTIMER_eOpen(&timerHandle, cb, param, preventSleep ? ZTIMER_FLAG_PREVENT_SLEEP : ZTIMER_FLAG_ALLOW_SLEEP);
   }

   void start(uint32 time)
   {
       ZTIMER_eStart(timerHandle, time);
   }

   void stop()
   {
       ZTIMER_eStop(timerHandle);
   }
};

Тут ничего особенного нет, но зато это позволяет сократить объем клиентского кода, и улучшить читаемость.

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

template<class T, uint8 id>
class PersistedValue
{
   T value;

public:
   void init(const T & initValue)
   {
       uint16 readBytes;
       PDM_teStatus status = PDM_eReadDataFromRecord(id, &value, sizeof(T), &readBytes);
       if(status != PDM_E_STATUS_OK)
           setValue(initValue);

       DBG_vPrintf(TRUE, "PersistedValue::init(). Status %d, value %d\n", status, value);
   }

   T getValue()
   {
       return value;
   }

   operator T()
   {
       return value;
   }

   PersistedValue<T, id> & operator =(const T & newValue)
   {
       setValue(newValue);
       return *this;
   }

   void setValue(const T & newValue)
   {
       value = newValue;
       PDM_teStatus status = PDM_eSaveRecordData(id, &value, sizeof(T));
       DBG_vPrintf(TRUE, "PersistedValue::setValue() Status %d, value %d\n", status, value);
   }
};

Согласитесь, гораздо приятнее видеть в коде что-нибудь вроде

connectionState = JOINED;

чем

uint8 value = JOINED;
PDM_eSaveRecordData(PDM_ID_NODE_STATE, &value, sizeof(value));

Функция init() пробует прочитать предыдущее значение переменной из PDM, а если это не получилось, то используется значение по умолчанию.

С очередями меня ждало сразу 2 неприятных сюрприза.

template<tszQueue * handle>
struct QueueHandleExtStorage
{
   tszQueue * getHandle()
   {
       return handle;
   }
};

struct QueueHandleIntStorage
{
   tszQueue handle;

   tszQueue * getHandle()
   {
       return &handle;
   }
};


template<class T, uint32 size, class H>
class QueueBase : public H
{
   T queueStorage[size];

public:
   QueueBase()
   {
       // JN5169 CRT does not really call constrictors for global object
       DBG_vPrintf(TRUE, "In a queue constructor...\n");
   }

   void init()
   {
       ZQ_vQueueCreate(H::getHandle(), size, sizeof(T), (uint8*)queueStorage);
   }

   bool receive(T * val)
   {
       return ZQ_bQueueReceive(H::getHandle(), (uint8*)val) != 0;
   }

   void send(const T & val)
   {
       ZQ_bQueueSend(H::getHandle(), (uint8*)&val);
   }
};

template<class T, uint32 size>
class Queue : public QueueBase<T, size, QueueHandleIntStorage >
{};

template<class T, uint32 size, tszQueue * handle>
class QueueExt : public QueueBase<T, size, QueueHandleExtStorage<handle> >
{};

Сюрприз первый. Некоторые очереди мы объявляем в своем коде. В этом случае мы можем красиво завернуть их в свой класс (как я сделал на основе QueueHandleIntStorage). Но некоторые очереди объявляются в файле zps_gen.c (генерируется утилитой ZPSConfig.exe) и подключаются в наш код через extern (как и в код ZigBee стека). Потому пришлось выделить работу с такими очередями в класс QueueHandleExtStorage.

Поскольку компилятор очень старый, современных веяний в шаблонной магии сделать не получится. Признаться, я и сам не силен в шаблонах, поэтому получилось как получилось. Не хотелось тратить много времени на это дело, поэтому остановился на двух отдельных классах - Queue и QueueExt для своих и чужих очередей соответственно. Используется это так:

Queue<MyType, 3> myQueue;
myQueue.init();
myQueue.send(valueToSend);
myQueue.receive(&valueToReceive);

Внешние очереди zigbee стека объявляются так

extern PUBLIC tszQueue zps_msgMlmeDcfmInd;
QueueExt<MAC_tsMlmeVsDcfmInd, 10, &zps_msgMlmeDcfmInd> msgMlmeDcfmIndQueue;

Мне очень хотелось сделать так, чтобы объекты себя настраивали прямо в конструкторах - объявил объект очереди, и он уже готов к работе. Но тут меня ждал второй неприятный сюрприз: CRT, которая идет в комплекте с JN5169 SDK не запускает конструкторы для глобальных объектов. Я исследовал это дело на уровне скриптов линковщика, но там попросту нет ссылок на .init_array, т.е. в принципе не реализован механизм запуска конструкторов глобальных объектов. К тому же сам код CRT, и конкретнее код стартовых функций поставляется в предсобранном состоянии и без исходников, так что подправить это самостоятельно не получится. Обидно, но не смертельно. Пришлось в каждом классе завести функцию init() и вызывать ее явно из vAppMain(). Кстати, пока копал в этом направлении нашел отличную лекцию на эту тему (https://www.youtube.com/watch?v=dOfucXtyEsU) - очень рекомендую.

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

class PeriodicTask
{
   Timer timer;

public:
   void init()
   {
       timer.init(timerFunc, this);
   }

   void startTimer(uint32 delay)
   {
       timer.start(delay);
   }

   void stopTimer()
   {
       timer.stop();
   }

protected:
   static void timerFunc(void * param)
   {
       PeriodicTask * task = (PeriodicTask*)param;

       task->timerCallback();
   }

   virtual void timerCallback() = 0;
};

Класс моргалка может выглядеть, например так

class BlinkTask : public PeriodicTask
{
   bool fastBlinking;

public:
   BlinkTask()
   {
      fastBlinking = false;

      vAHI_DioSetDirection(0, BOARD_LED_PIN);

      PeriodicTask::init();
      startTimer(1000);
   }

   void setBlinkMode(bool fast)
   {
      fastBlinking = fast;
   }

protected:
   virtual void timerCallback()
   {
      // toggle LED
      uint32 currentState = u32AHI_DioReadInput();
      vAHI_DioSetOutput(currentState^BOARD_LED_PIN, currentState&BOARD_LED_PIN);

      //Restart the timer
      startTimer(fastBlinking ? ZTIMER_TIME_MSEC(200) : ZTIMER_TIME_MSEC(1000));
   }
};

Кстати, тут я опять столкнулся с проблемой незапуска конструкторов глобальных объектов, и на этот раз дела обстоят гораздо хуже. Дело в том, что таблица виртуальных функций настраивается в конструкторе. Если объект этого класса объявить как глобальный, то для него не будет вызван конструктор, а значит не будет настроена таблица виртуальных функций, и при вызове timerCallback все это с грохотом упадет. И тут уже вынесением функционала в функцию init() не обойдешься. Приходится объявлять объекты таких классов на стеке функции vAppMain(), но это означает в свою очередь означает, что другие функции к этому объекту не будут иметь доступа. В общем, приходится выкручиваться. 

Давайте чуть ближе к ZigBee - тут вылез целый ряд проблем. Дело в том, что API от NXP уж очень сильно противоречит принципам объектной ориентированности. Дело не в том, что этот API написан на языке C - Win32 API тоже на С написан, но он очень даже объектно ориентирован. У NXP все гораздо хуже:

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

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

  • Некоторые данные ZCL используются с помощью прямого доступа в кишки и структуры этого самого ZCL. Хоть бы функции доступа сделали.

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

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

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

В двух словах расскажу какой компонент чем занимается

  • Класс ZigbeeDevice обслуживает устройство в целом. В его компетенцию входит подключение/переподключение/выход из сети, а также общие запросы к устройству как к узлу сети ZigBee (запросы к ZDO). Именно этот класс связан с BDB - куском Zigbee стека, который отвечает за устройство в целом.

  • Класс EndpointManager по сути представляет собой список конечных точек. Его основной задачей является принимать сообщения ZCL и перенаправлять их в соответствующую конечную точку. Т.е. класс преобразует индекс конечной точки в указатель на связанный с ним объект.

  • Класс Endpoint является базовым классом для конечных точек. В его сферу ответственности входит анализ входящего запроса и перенаправление в соответствующую функцию-обработчик. Эти функции должны быть реализованы в классах наследниках

  • Класс SwitchEndpoint представляет собой реализацию конечной точки, и в данном случае реализует функционал одного конкретного выключателя. Класс умеет включать/выключать свет, рапортовать координатору о изменении своего состояния, и принимать команды на включение/выключение. У устройства может быть несколько каналов реле, поэтому я нарисовал несколько экземпляров этого класса.

  • Классы ThermometerEndpoint и PowerMeterEndpoint приведены тут для примера как могло бы быть, если бы мое устройство реализовывало функции термометра и измерителя энергии. Каждый из этих классов реализовывал бы функционал одной конечной точки.

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

class ZigbeeDevice
{
   typedef enum
   {
       NOT_JOINED,
       JOINING,
       JOINED

   } JoinStateEnum;

   PersistedValue<JoinStateEnum, PDM_ID_NODE_STATE> connectionState;
   Queue<BDB_tsZpsAfEvent, 3> bdbEventQueue;
   PollTask pollTask;

   bool polling;
   int rejoinFailures;
   int cyclesTillNextRejoin;


public:
   ZigbeeDevice();

   static ZigbeeDevice * getInstance();

   void joinNetwork();
   void rejoinNetwork();
   void leaveNetwork();
   void joinOrLeaveNetwork();

   void pollParent();
   bool canSleep() const;
   bool needsRejoin() const;
   void handleWakeUp();

protected:
   void handleNetworkJoinAndRejoin();
   void handleLeaveNetwork();
   void handleRejoinFailure();
   void handlePollResponse(ZPS_tsAfPollConfEvent* pEvent);
   void handleZdoBindEvent(ZPS_tsAfZdoBindEvent * pEvent);
   void handleZdoUnbindEvent(ZPS_tsAfZdoUnbindEvent * pEvent);
   void handleZdoDataIndication(ZPS_tsAfEvent * pEvent);
   void handleZdoEvents(ZPS_tsAfEvent* psStackEvent);
   void handleZclEvents(ZPS_tsAfEvent* psStackEvent);
   void handleAfEvent(BDB_tsZpsAfEvent *psZpsAfEvent);

public:
   void handleBdbEvent(BDB_tsBdbEvent *psBdbEvent);
};

В общем и целом этот класс занимается всеми процессами подключения и переподключения, о которых я веду речь в этой статье. Интересное в этом классе то как событие из BDB попадает в эти обработчики. Дело в том, что в самой реализации BDB захардкожено название сишной функции APP_vBdbCallback() как точка входа во все остальные обработчики. Пришлось класс ZigbeeDevice сделать синглтоном, чтобы знать куда отправлять запросы.

PUBLIC void APP_vBdbCallback(BDB_tsBdbEvent * event)
{
   ZigbeeDevice::getInstance()->handleBdbEvent(event);
}

ZigbeeDevice * ZigbeeDevice::getInstance()
{
   static ZigbeeDevice instance;
   return &instance;
}

void ZigbeeDevice::handleBdbEvent(BDB_tsBdbEvent *psBdbEvent)
{
   switch(psBdbEvent->eEventType)
   {
...

Вроде бы все просто, код даже компилируется... только не линкуется.

c:/nxp/bstudio_nxp/sdk/tools/ba-elf-ba2-r36379/bin/../lib/gcc/ba-elf/4.7.4/../../../../ba-elf/lib/mcpu_jn51xx_sizeopt\libg.a(lib_a-glue.o): In function `_sbrk':
/ba_toolchain/r36379/source/gcc-4.7.4-ba-r36379-build/ba-elf/mcpu_jn51xx_sizeopt/newlib/libc/sys/basim/../../../../../../../gcc-4.7.4-ba-r36379/newlib/libc/sys/basim/glue.c:75: undefined reference to `end'
/ba_toolchain/r36379/source/gcc-4.7.4-ba-r36379-build/ba-elf/mcpu_jn51xx_sizeopt/newlib/libc/sys/basim/../../../../../../../gcc-4.7.4-ba-r36379/newlib/libc/sys/basim/glue.c:75: undefined reference to `_stack'
/ba_toolchain/r36379/source/gcc-4.7.4-ba-r36379-build/ba-elf/mcpu_jn51xx_sizeopt/newlib/libc/sys/basim/../../../../../../../gcc-4.7.4-ba-r36379/newlib/libc/sys/basim/glue.c:75: undefined reference to `_stack'
/ba_toolchain/r36379/source/gcc-4.7.4-ba-r36379-build/ba-elf/mcpu_jn51xx_sizeopt/newlib/libc/sys/basim/../../../../../../../gcc-4.7.4-ba-r36379/newlib/libc/sys/basim/glue.c:75:(.text+0x197): relocation truncated to fit: R_BA_8 against undefined symbol `_stack'

Ошибка странная и совершенно не указывает на то, что именно не так. А дело в статической переменной instance. Дело в том, что по умолчанию компилятор генерирует код, который

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

  • регистрирует деструктор в функции atexit (чтобы деструктор был вызван в конце работы приложения). Код у нас будет крутиться в вечном цикле и понятия “выход” у него нет в принципе - можно выключать.

  • Вероятно какие-то из низкоуровневых обработчиков также могут кинуть исключение - это нам также не требуется. 

  • Еще много за собой тянет RTTI. 

В общем поотключав это все ключиками -fno-rtti -fno-exceptions -fno-use-cxa-atexit -fno-threadsafe-statics код нормально слинковался.

Перейдем к классу EndpointManager

class EndpointManager
{
private:
   Endpoint * registry[ZCL_NUMBER_OF_ENDPOINTS+1];

   EndpointManager()
   {
       memset(registry, 0, sizeof(Endpoint*) * (ZCL_NUMBER_OF_ENDPOINTS+1));
   }

public:
   static EndpointManager * getInstance()
   {
       static EndpointManager instance;
       return &instance;
   }

   void registerEndpoint(uint8 id, Endpoint * endpoint)
   {
       registry[id] = endpoint;
       endpoint->setEndpointId(id);
       endpoint->init();
   }

   static void handleZclEvent(tsZCL_CallBackEvent *psEvent)
   {
       EndpointManager::getInstance()->handleZclEventInt(psEvent);
   }

protected:
   void handleZclEventInt(tsZCL_CallBackEvent *psEvent)
   {
       uint8 ep = psEvent->u8EndPoint;
       registry[ep]->handleZclEvent(psEvent);
   }
};

Как я уже сказал этот класс заведует списком конечных точек. Мне было лень реализовывать полноценную map’у индексов конечных точек к указателям на классы, поэтому я завел просто массив, где в ячейках с соответствующим индексом расположены указатели. Т.е. если бы устройство реализовывало конечные точки с номерами 1, 10, и 30, то тут бы пришлось сделать массив размером 31.

Этот класс также пришлось сделать синглтоном, т.к. он тоже принимает обратные вызовы из Zigbee стека через функцию handleZclEvent() (в девичестве прошлой статье эта функция называлась APP_ZCL_cbEndpointCallback() ). Через функцию handleZclEventInt() сообщения перенаправляются в соответствующую конечную точку.

Рассмотрим базовый класс конечных точек - Endpoint

class Endpoint
{
   uint8 endpointId;

public:
   Endpoint();
   {
       endpointId = 0;
   }

   void setEndpointId(uint8 id);
   {
       endpointId = id;
   }

   uint8 getEndpointId() const;
   {
       return endpointId;
   }


   virtual void init() = 0;
   virtual void handleZclEvent(tsZCL_CallBackEvent *psEvent);

protected:
   virtual void handleClusterUpdate(tsZCL_CallBackEvent *psEvent) = 0;
};

void Endpoint::handleZclEvent(tsZCL_CallBackEvent *psEvent)
{
   switch (psEvent->eEventType)
   {
  ...
       case E_ZCL_CBET_CLUSTER_CUSTOM:
       case E_ZCL_CBET_CLUSTER_UPDATE:
           handleClusterUpdate(psEvent);
           break;
   }
}

Функция handleZclEvent полностью соответствует функции APP_ZCL_cbEndpointCallback() из прошлой статьи. Единственное что я бы хотел тут продемонстрировать это диспетчеризация различных видов сообщений в соответствующие обработчики из классов наследников (в данном случае вызов handleClusterUpdate()).

Но эта глава больше про C++, и тут меня ждал очередной неприятный сюрприз. Дело в том что как только в коде появляются чисто виртуальные функции (как init() или handleClusterUpdate()), то код опять перестает линковаться с той же странной ошибкой в функции _sbrk() и end.

Решение опять не лежит на поверхности. После нескольких дней мучений и попыток дизассембрировать все подряд я, наконец, локализировал проблему (было не очевидно, что это лезет из чисто виртуальных функций) и нашел решение. А дело вот в чем.

Когда вызывается конструктор класса Endpoint, то используется таблица виртуальных функций из трех записей

  • запись для виртуальной функции handleZclEvent() указывает на реализацию Endpoint::handleZclEvent()

  • записи для чисто виртуальных функций init() и handleClusterUpdate() указывают на библиотечную функцию __cxa_pure_virtual(). 

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

Так вот именно функция __cxa_pure_virtual() видимо тянула за собой непонятные _sbrk и end (и, наверное, еще исключения). Решение нашлось, как ни странно, в проекте arduino - там эту функцию просто переписали заново, и теперь она просто вешает систему.

extern "C" void __cxa_pure_virtual(void) __attribute__((__noreturn__));
void __cxa_pure_virtual(void)
{
 DBG_vPrintf(TRUE, "!!!!!!! Pure virtual function call.\n");
 while (1)
   ;
}

Реализация конечной точки выключателя находится в классе SwitchEndpoint.

class SwitchEndpoint: public Endpoint
{   
protected:
   tsZLO_OnOffLightDevice sSwitch;
   BlinkTask blinkTask;

public:
   SwitchEndpoint();
   virtual void init();

   bool getState() const;
   void switchOn();
   void switchOff();
   void toggle();

protected:
   void doStateChange(bool state);
   void reportStateChange();

protected:
   virtual void handleClusterUpdate(tsZCL_CallBackEvent *psEvent);
};

Я некоторое время размышлял над структурой этого класса и пришел к выводу, что он занимается двумя вещами: управление реле/лампочкой, и приемом/передачей сообщений ZigBee. Причем в разных сценариях эти задачи могут использоваться раздельно. Так если пользователь нажал кнопку, то нужно и переключить реле и отправить сообщение ZigBee. А если команда на включение пришла из сети, то нужно только переключить реле. Поэтому эти 2 действия я вынес в отдельные функции.

void SwitchEndpoint::doStateChange(bool state)
{
   DBG_vPrintf(TRUE, "SwitchEndpoint EP=%d: do state change %d\n", getEndpointId(), state);

   sSwitch.sOnOffServerCluster.bOnOff = state ? TRUE : FALSE;

   blinkTask.setBlinkMode(state);
}

void SwitchEndpoint::reportStateChange()
{
   // Destination address - 0x0000 (coordinator)
   tsZCL_Address addr;
   addr.uAddress.u16DestinationAddress = 0x0000;
   addr.eAddressMode = E_ZCL_AM_SHORT;

   DBG_vPrintf(TRUE, "Reporting attribute EP=%d value=%d... ", getEndpointId(), sSwitch.sOnOffServerCluster.bOnOff);
   PDUM_thAPduInstance myPDUM_thAPduInstance = hZCL_AllocateAPduInstance();
   teZCL_Status status = eZCL_ReportAttribute(&addr,
                                              GENERAL_CLUSTER_ID_ONOFF,
                                              E_CLD_ONOFF_ATTR_ID_ONOFF,
                                              getEndpointId(),
                                              1,
                                              myPDUM_thAPduInstance);
   PDUM_eAPduFreeAPduInstance(myPDUM_thAPduInstance);
   DBG_vPrintf(TRUE, "status: %02x\n", status);
}

Все остальное просто обвязка. Это обработка действий пользователя:

void SwitchEndpoint::switchOn()
{
    doStateChange(true);
    reportStateChange();
}

void SwitchEndpoint::switchOff()
{
    doStateChange(false);
    reportStateChange();
}

void SwitchEndpoint::toggle()
{
    doStateChange(!getState());
    reportStateChange();
}

А это прием сообщения из сети:

void SwitchEndpoint::handleClusterUpdate(tsZCL_CallBackEvent *psEvent)
{
   uint16 u16ClusterId = psEvent->uMessage.sClusterCustomMessage.u16ClusterId;
   tsCLD_OnOffCallBackMessage * msg = (tsCLD_OnOffCallBackMessage *)psEvent->uMessage.sClusterCustomMessage.pvCustomData;
   uint8 u8CommandId = msg->u8CommandId;

   DBG_vPrintf(TRUE, "SwitchEndpoint EP=%d: Cluster update message ClusterID=%04x Cmd=%02x\n",
               psEvent->u8EndPoint,
               u16ClusterId,
               u8CommandId);

   doStateChange(getState());
}

На инициализации остановлюсь отдельно.

void SwitchEndpoint::init()
{
   // Initialize the endpoint
   DBG_vPrintf(TRUE, "SwitchEndpoint::init(): register On/Off endpoint #%d...  ", getEndpointId());
   teZCL_Status status = eZLO_RegisterOnOffLightEndPoint(getEndpointId(), &EndpointManager::handleZclEvent, &sSwitch);
   DBG_vPrintf(TRUE, "eApp_ZCL_RegisterEndpoint() status %d\n", status);

   // Fill Basic cluster attributes
   // Note: I am not really sure why this device info shall be a part of a switch endpoint
   memcpy(sSwitch.sBasicServerCluster.au8ManufacturerName, CLD_BAS_MANUF_NAME_STR, CLD_BAS_MANUF_NAME_SIZE);
   memcpy(sSwitch.sBasicServerCluster.au8ModelIdentifier, CLD_BAS_MODEL_ID_STR, CLD_BAS_MODEL_ID_SIZE);
   memcpy(sSwitch.sBasicServerCluster.au8DateCode, CLD_BAS_DATE_STR, CLD_BAS_DATE_SIZE);
   memcpy(sSwitch.sBasicServerCluster.au8SWBuildID, CLD_BAS_SW_BUILD_STR, CLD_BAS_SW_BUILD_SIZE);
   sSwitch.sBasicServerCluster.eGenericDeviceType = E_CLD_BAS_GENERIC_DEVICE_TYPE_WALL_SWITCH;

   // Initialize blinking
   // Note: this blinking task represents a relay that would be tied with this switch. That is why blinkTask
   // is a property of SwitchEndpoint, and not the global task object
   // TODO: restore previous blink mode from PDM
   blinkTask.setBlinkMode(false);
}

Как и раньше конечную точку нужно зарегистрировать в стеке ZigBee. Вот только как я уже говорил фреймворк совсем не объектно ориентированный. Мы не можем связать наш конкретный объект с конечной точкой. Максимум, что мы можем сделать это передать фреймворку указательно на наш обратный вызов. Именно для этого мне и понадобился класс EndpointManager, чтобы быть точкой входа для сообщений, которые потом будут диспетчеризированы в конкретный объект-обработчик.

Следующей неожиданностью стала настройка Basic Cluster’а, который отвечает за описание устройства. И хотя это описание устройства в целом, почему-то оказалось, что за это отвечает конечная точка выключателя, а не, например, ZDO. Для меня это пока остается загадкой. Я посмотрел десяток разных устройств в сети, и, судя по всему, там та же фигня - Basic Cluster живет в первой конечной точке, независимо от того чем она занимается. Как идею я сейчас рассматриваю подход, что первая конечная точка будет реализовывать только Basic Cluster, тогда как остальные будут заниматься отдельными полезными задачами. Тогда структура кода будет красивой и удобной.

Наконец, последняя особенность - моргалка BlinkTask является частью класса SwitchEndpoint, а не живет отдельно. Это объясняется так. Моргающий светодиод у меня отражает состояние выключателя - включено или выключено, а значит должен быть частью класса выключателя. В будущем я тут буду переключать реле, которое в свою очередь будет включать лампочку.

Теперь это все нужно запустить. И тут подстерегает очередная засада, опять связанная с незапуском конструкторов глобальных объектов. Дело в том, что к объекту класса SwitchEndpoint нужен доступ из нескольких мест:

  • Из vAppMain(), чтобы создать и инициализировать конечную точку

  • из MainTask(), чтобы по нажатию кнопки переключать состояние конечной точки

  • (может быть) из Wakeup и PreSleep функций, чтобы приостановить или восстановить работоспособность конечной точки.

И вроде как мы уже научились делать синглтон, но конечная точка не может быть синглтоном. Как минимум потому, что этих конечных точек может быть несколько. Поэтому я просто завернул все такие объекты в класс Context и оперирую указателем на объект созданный на стеке в vAppMain().

struct Context
{
   SwitchEndpoint switch1;
};

extern "C" PUBLIC void vAppMain(void)
{
...
   Context context;
   EndpointManager::getInstance()->registerEndpoint(HELLOENDDEVICE_SWITCH_ENDPOINT, &context.switch1);
...
   while(1)
   {
       APP_vTaskSwitch(&context);
...

Update Jun 10:

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

Вспоминая материал предыдущей статьи мы знаем, что конечные точки регистрируются с помощью функции eZCL_Register(). Но это достаточно низкоуровневый вызов, и нужно еще отдельно зарегистрировать все кластера, которые живут в этой конечной точке. Реализация библиотеки Zigbee Class Library (ZCL) от NXP предоставляет различные заготовки для простых устройств - выключатели, лампочки, термометры. Для каждого устройства есть большущая функция вроде eZLO_RegisterXXXEndPoint() которая регистрирует и конечную точку, и кластеры, которые отвечают за функциональность устройства, и общие кластеры (Basic, OTA). Причем что именно регистрировать определяется дефайнами. Это не очень удобно, т.к. я не могу включать те или иные кластера индивидуально для конкретных конечных точек. Поэтому я решил делать свою версию функции eZLO_RegisterXXXEndPoint(), которая регистрирует только то что мне нужно и так как мне нужно.

Для начала нужно будет описать конечную точку в терминах структур данных. Конечная точка состоит из следующих частей

  • Дескриптор (структура), которая описывает конечную точку в целом

  • Набор дескрипторов (структур), которые описывают каждый кластер в конечной точке

  • Набор структур данных, которые отражают текущее значение атрибутов каждого кластера

В коде это будет выглядеть так. Такая компоновка структур в структурах выбрана разработчиками из NXP. Я не очень поддерживаю такую архитектуру, но на нее завязано API. Так что мне придется писать в том же духе.

// List of cluster instances (descriptor objects) that are included into an Endpoint
struct BasicClusterInstances
{
   // All devices have following mandatory clusters.
   #if (defined CLD_BASIC) && (defined BASIC_SERVER)
       tsZCL_ClusterInstance sBasicServer;
   #endif

   // Zigbee device may have also OTA optional clusters for the client
   #if (defined CLD_OTA) && (defined OTA_CLIENT)
       tsZCL_ClusterInstance sOTAClient;
   #endif
} __attribute__ ((aligned(4)));


// Endpoint low level structure
struct BasicClusterDevice
{
   tsZCL_EndPointDefinition endPoint;

   // Cluster instances
   BasicClusterInstances clusterInstances;

   // Value storage for endpoint's clusters
   #if (defined CLD_BASIC) && (defined BASIC_SERVER)
       // Basic Cluster - Server
       tsCLD_Basic sBasicServerCluster;
   #endif

   // On Off light device 2 optional clusters for the client
   #if (defined CLD_OTA) && (defined OTA_CLIENT)
       // OTA cluster - Client
       tsCLD_AS_Ota sCLD_OTA;
       tsOTA_Common sCLD_OTA_CustomDataStruct;
   #endif
};

По сути этот код я стянул из исходников ZCL (конкретнее on_off_light.h), только повыкидывал из него все то, что относится к выключателю, но оставил общие кластера. Лапшу из дефайнов я оставил как в оригинальном коде - я думаю это важно, чтобы структуры не разъехались с кодом, который есть в ZCL.

Несмотря на название, структура Cluster Instance на самом деле содержит общее описание кластера, тогда как сами данные хранятся в структуре вроде tsCLD_Basic. Короче, там все очень плохо с названиями.

Теперь, собственно, сам класс конечной точки. 

class BasicClusterEndpoint : public Endpoint
{
    BasicClusterDevice deviceObject;

public:
    BasicClusterEndpoint();

    virtual void init();
};

Единственная полезная функция - это функция инициализации, которая регистрирует конечную точку и связывает ее с объектом deviceObject. По сути это обрезанная функция eZLO_RegisterOnOffLightEndPoint() из ZCL

void BasicClusterEndpoint::init()
{
   // Fill in end point details
   deviceObject.endPoint.u8EndPointNumber = getEndpointId();
   deviceObject.endPoint.u16ManufacturerCode = ZCL_MANUFACTURER_CODE;
   deviceObject.endPoint.u16ProfileEnum = HA_PROFILE_ID;
   deviceObject.endPoint.bIsManufacturerSpecificProfile = FALSE;
   deviceObject.endPoint.u16NumberOfClusters = sizeof(BasicClusterInstances) / sizeof(tsZCL_ClusterInstance);
   deviceObject.endPoint.psClusterInstance = (tsZCL_ClusterInstance*)&deviceObject.clusterInstances;
   deviceObject.endPoint.bDisableDefaultResponse = ZCL_DISABLE_DEFAULT_RESPONSES;
   deviceObject.endPoint.pCallBackFunctions = &EndpointManager::handleZclEvent;

   #if (defined CLD_BASIC) && (defined BASIC_SERVER)
       // Create an instance of a basic cluster as a server
       teZCL_Status status = eCLD_BasicCreateBasic(&deviceObject.clusterInstances.sBasicServer,
                                                   TRUE,
                                                   &sCLD_Basic,
                                                   &deviceObject.sBasicServerCluster,
                                                   &au8BasicClusterAttributeControlBits[0]);
       if( status != E_ZCL_SUCCESS)
           DBG_vPrintf(TRUE, "BasicClusterEndpoint::init(): Failed to create Basic Cluster instance. status=%d\n", status);
   #endif

   #if (defined CLD_OTA) && (defined OTA_CLIENT)
       // Create an instance of an OTA cluster as a client */
       status = eOTA_Create(&deviceObject.clusterInstances.sOTAClient,
                            FALSE,  /* client */
                            &sCLD_OTA,
                            &deviceObject.sCLD_OTA,  /* cluster definition */
                            u8EndPointIdentifier,
                            NULL,
                            &deviceObject.sCLD_OTA_CustomDataStruct);
       if(status != E_ZCL_SUCCESS)
           DBG_vPrintf(TRUE, "BasicClusterEndpoint::init(): Failed to create OTA Cluster instance. status=%d\n", status);
   #endif

   // Register the endpoint with all the clusters above
   status = eZCL_Register(&deviceObject.endPoint);
   DBG_vPrintf(TRUE, "BasicClusterEndpoint::init(): Register Basic Cluster. status=%d\n", status);

   // Fill Basic cluster attributes
   // Note: I am not really sure why this device info shall be a part of a switch endpoint
   memcpy(deviceObject.sBasicServerCluster.au8ManufacturerName, CLD_BAS_MANUF_NAME_STR, CLD_BAS_MANUF_NAME_SIZE);
   memcpy(deviceObject.sBasicServerCluster.au8ModelIdentifier, CLD_BAS_MODEL_ID_STR, CLD_BAS_MODEL_ID_SIZE);
   memcpy(deviceObject.sBasicServerCluster.au8DateCode, CLD_BAS_DATE_STR, CLD_BAS_DATE_SIZE);
   memcpy(deviceObject.sBasicServerCluster.au8SWBuildID, CLD_BAS_SW_BUILD_STR, CLD_BAS_SW_BUILD_SIZE);
   deviceObject.sBasicServerCluster.eGenericDeviceType = E_CLD_BAS_GENERIC_DEVICE_TYPE_WALL_SWITCH;
}

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

Объект класса BasicClusterEndpoint нужно где-то создать и зарегистрировать в нашем реестре конечных точек

struct Context
{
   BasicClusterEndpoint basicEndpoint;
   SwitchEndpoint switch1;
};

extern "C" PUBLIC void vAppMain(void)
{
...
   EndpointManager::getInstance()->registerEndpoint(HELLOENDDEVICE_BASIC_ENDPOINT, &context.basicEndpoint);
   EndpointManager::getInstance()->registerEndpoint(HELLOENDDEVICE_SWITCH_ENDPOINT, &context.switch1);

Ну еще количество конечных точек в zcl_options.h правильные поставить нужно

#define ZCL_NUMBER_OF_ENDPOINTS                             2

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

После недолгой возни со сниффером и поиском мелких ошибок, все заработало отлично. Теперь структура устройства выглядит так: в первой конечной точке живет только Basic Cluster (OTA пока отключен дефайном), а во второй конечной точке живет выключатель (genOnOff).

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

Заключение

На этом, пожалуй, все что я смог собрать к текущему моменту. Сегодня мы разобрали как же правильно организовать подключение и переподключение устройство к сети ZigBee. Ключевым в этой части является то, что многие вещи за нас уже реализовали в компоненте Base Device Behavior и нужно было переключиться на его использование. BDB реализует для нас часть спецификации ZigBee как раз связанной с правильным поведением устройства при подключении и переподключении к сети. С добавлением функционала, который описан в этой статье мое устройство стало намного стабильнее подключаться к сети и в целом процесс отладки стал намного проще.

Вместе с этим по мере изучения вопроса у меня сложилось впечатление, что логика работы, которую предлагает Base Device Behavior Specification немного не соответствует современному пониманию об устройствах умного дома. Так некоторые ситуации (такие как временное пропадание света) нужно обрабатывать дополнительно.

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

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

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


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

Использованные примеры:

  • JN-AN-1219-Zigbee-3-0-Controller-and-Switch

  • JN-AN-1217-Zigbee-3-0-Base-Device   <---- Еще один хороший пример для изучения

  • JN-AN-1220-Zigbee-3-0-Sensor

Код:

https://github.com/grafalex82/hellozigbee/tree/hello_zigbee_part_2

https://github.com/grafalex82/hellozigbee/tree/hello_zigbee_end_device