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

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

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

Эта статья является продолжением серии статей (раз, два, три) про микроконтроллер NXP JN5169 и его работу в сетях Zigbee. Сегодня будет разбирать механизмы прямого соединения (binding) устройств в сетях Zigbee. Статья будет разбита на 2 части:

  • В первой части я расскажу про, собственно, связывание устройств. Также про клиентские и серверные устройства, и почему это важно.

  • Во второй части я расскажу как полтора месяца шел в неправильном направлении, но зато накопал много интересной инфы про микроконтроллер JN5169 и стек Zigbee.

Поехали!

Часть 1: про Binding, клиентские и серверные кластеры

Смотрим на BDB Find and Bind

Примеры от NXP построены на подходе Find and Bind. Этот подход предлагает Zigbee Base Device Behavior Specification и, как мы уже знаем из предыдущей статьи предоставляется как часть Zigbee SDK (компонент BDB). Суть этого похода в следующем.

  • Нажимая определенные кнопки на устройстве-источнике можно ввести устройство в режим спаривания (Find and Bind as Initiator)

  • Подобным образом режим спаривания включается и на устройстве-приемнике (Find and Bind as Target)

  • Устройства находят друг друга посылая в эфир определенные пакеты для спаривания

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

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

Со стороны кода все это реализуется компонентом BDB. Остается только написать обработчики кнопок, которые вызовут несколько функций из BDB, да обработают несколько сообщений. API для Find And Bind простое и состоит всего из нескольких функций.

PUBLIC BDB_teStatus BDB_eFbTriggerAsInitiator(uint8 u8SourceEndPointId);
PUBLIC void BDB_vFbExitAsInitiator();
PUBLIC BDB_teStatus BDB_eFbTriggerAsTarget(uint8 u8EndPointId);
PUBLIC void BDB_vFbExitAsTarget(uint8 u8SourceEndpoint);

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

  1. У меня банально нет устройств которые умеют спариваться таким образом. Даже если я реализую магию Find and Bind в своем устройстве, все равно это не будет поддерживаться в остальных устройствах от Xiaomi, Tuya, Moes, и других. 

  2. Моя Zigbee сеть построена на базе zigbee2mqtt, а связывание устройств происходит по команде от координатора. В команде четко указано кого, с кем, и как нужно связывать. Но, как вы можете заметить, функции BDB не предоставляют такой тонкой настройки - там нельзя указать ни адрес целевого устройства, ни кластеры, которые нужно связать.

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

Direct Binding для атрибутов

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

Посмотрим что предлагает нам дашборд zigbee2mqtt для связывания устройств.

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

Как добавлять обработчики сообщений я подробно рассказывал во второй статье. Если в двух словах, то по приходу сообщения Zigbee стек его распарсит, разложит в структуры, и вызовет обратный вызов, который мы предоставили. Сообщения на связывание (bind/unbind) приходят на нулевую конечную точку нашего устройства и в моем случае обрабатываются в методе ZigbeeDevice::handleZdoEvents(). Пока я просто распечатаю это сообщение.

PRIVATE void vPrintAddr(ZPS_tuAddress addr, uint8 mode)
{
   if(mode == ZPS_E_ADDR_MODE_IEEE)
       DBG_vPrintf(TRUE, "%016llx", addr.u64Addr);
   else if(mode == ZPS_E_ADDR_MODE_SHORT)
       DBG_vPrintf(TRUE, "%04x", addr.u16Addr);
   else
       DBG_vPrintf(TRUE, "unknown addr mode %d", mode);
}

void vDumpBindEvent(ZPS_tsAfZdoBindEvent * pEvent)
{
   DBG_vPrintf(TRUE, "ZPS_EVENT_ZDO_BIND: SrcEP=%d DstEP=%d DstAddr=", pEvent->u8SrcEp, pEvent->u8DstEp);
   vPrintAddr(pEvent->uDstAddr, pEvent->u8DstAddrMode);
   DBG_vPrintf(TRUE, "\n");
}

void ZigbeeDevice::handleZdoEvents(ZPS_tsAfEvent* psStackEvent)
{
...
   switch(psStackEvent->eType)
   {
       case ZPS_EVENT_ZDO_BIND:
           vDumpBindEvent(&psStackEvent->uEvent.sZdoBindEvent);
           break;
...

К моему удивлению в структуру ZPS_tsAfZdoBindEvent почему-то не доехало поле ClusterID, хотя оно абсолютно точно присутствует в пакете Zigbee, который нам показал сниффер. Отсутствие поля ClusterID сбило меня с пути на целых полтора месяца (чему посвящена вторая часть статьи). Изначальный план был написать обработчик события ZPS_EVENT_ZDO_BIND, который бы вызывал функцию ZPS_eAplZdoBind(), которая принимает ClusterID как один из параметров. Я тщетно пытался его раздобыть всеми возможными способами, хаками, и реверс инжинирингом. И в этом даже был какой-то прогресс, но оказалось я просто шел не в том направлении.

В какой-то момент в обработчик vDumpBindEvent() я добавил распечатывание таблиц связываний, которые живут внутри Zigbee стека. Эти функции являются аналогами функций vDisplayBindingTable() и vDisplayAddressMapTable(), которые поставляются вместе с Zigbee стеком. Просто свои я написал чуть раньше, чем увидел готовые.

PRIVATE void vDisplayBindTableEntry(ZPS_tsAplApsmeBindingTableEntry * entry)
{
    DBG_vPrintf(TRUE, "    ClusterID=%04x SrcEP=%d DstEP=%d DstAddr=", entry->u16ClusterId, entry->u8SourceEndpoint, entry->u8DestinationEndPoint);
    vPrintAddr(entry->uDstAddress, entry->u8DstAddrMode);
    DBG_vPrintf(TRUE, "\n");
}

void vDisplayBindTable()
{
   // Get pointers
   ZPS_tsAplAib * aib = ZPS_psAplAibGetAib();
   ZPS_tsAplApsmeBindingTableType * bindingTable = aib->psAplApsmeAibBindingTable;
   ZPS_tsAplApsmeBindingTableCache* cache = bindingTable->psAplApsmeBindingTableCache;
   ZPS_tsAplApsmeBindingTable* table = bindingTable->psAplApsmeBindingTable;

   // Print header
   DBG_vPrintf(TRUE, "\n+++++++ Binding Table\n");
   DBG_vPrintf(TRUE, "    Cache ptr=%04x:\n", cache);
   DBG_vPrintf(TRUE, "    Table ptr=%04x:\n", table);

   // Dump cache
   if(cache)
   {
       DBG_vPrintf(TRUE, "Cache:\n");
       vDisplayBindTableEntry(cache->pvAplApsmeBindingTableForRemoteSrcAddr);
       DBG_vPrintf(TRUE, "Cache size = %d\n", cache->u32SizeOfBindingCache);
       for(uint32 i=0; i < cache->u32SizeOfBindingCache; i++)
           DBG_vPrintf(TRUE, "    %016llx\n", cache->pu64RemoteDevicesList[i]);
   }

   // Dump table
   if(table)
   {
       DBG_vPrintf(TRUE, "Binding table (size=%d)\n", table->u32SizeOfBindingTable);
       for(uint32 i=0; i<table->u32SizeOfBindingTable; i++)
       {
           ZPS_tsAplApsmeBindingTableStoreEntry * entry = table->pvAplApsmeBindingTableEntryForSpSrcAddr + i;

           DBG_vPrintf(TRUE, "    Addr=%016llx ClusterID=%04x addrMode=%d SrcEP=%d DstEP=%d\n",
                       ZPS_u64NwkNibGetMappedIeeeAddr(ZPS_pvAplZdoGetNwkHandle(), entry->u16AddrOrLkUp),
                       entry->u16ClusterId,
                       entry->u8DstAddrMode,
                       entry->u8SourceEndpoint,
                       entry->u8DestinationEndPoint);
       }
   }
}

void vDisplayAddressMap()
{
   ZPS_tsNwkNib * nib = ZPS_psNwkNibGetHandle(ZPS_pvAplZdoGetNwkHandle());

   uint16 mapsize = nib->sTblSize.u16AddrMap;
   DBG_vPrintf(TRUE, "Address map (size=%d)\n", mapsize);

   for(uint16 i=0; i<mapsize; i++)
   {
       DBG_vPrintf(TRUE, "    Addr=%04x ieeeAddr=%016llx\n",
                   nib->sTbl.pu16AddrMapNwk[i],
                   ZPS_u64NwkNibGetMappedIeeeAddr(ZPS_pvAplZdoGetNwkHandle(),nib->sTbl.pu16AddrLookup[i]));
   }
}

Попробовав отправить команду на связывание я увидел это:

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

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

Это явно не совсем тот эффект, который я ожидал от связывания. Как минимум я ожидал бы несколько записей Report Attributes направленные разным подписчикам.

Если ничего не получается, прочтите же, наконец, инструкцию (С) известное выражение

Ок, давайте заглянем что нам говорит документация.... Только какая? Я попросту начал искать по слову Bind во всех документах от NXP и после недолгих поисков нашел раздел Bound Transmission Management в документе ZigBee Cluster Library (for ZigBee 3.0)

User Guide JN-UG-3115. Судя по названию как раз то, что нужно. Мурзилка предлагает нам для начала включить несколько опций в zcl_options.h

Звучит разумно. Если нужно отправить сообщение нескольким связанным устройствам, то как минимум где-то должен существовать код, который этим занимается. И этот код называется Bind Server и включается опцией CLD_BIND_SERVER. 

Компилируем, запускаем, но все равно отправляется ровно одно сообщение. Самое время почитать код Zigbee стека (из того, что открыто) на предмет что именно скрывается под дефайном CLD_BIND_SERVER. После непродолжительных поисков я наткнулся на несколько интересных кусков в файле zcl_transmit.c, и конкретнее в функции eZCL_TransmitDataRequest(). Эти куски занимались некой буферизацией пакетов для связанных устройств. И хотя на деле это оказалось не совсем то, что нужно, это подтолкнуло меня к изучению а как же именно отправляются пакеты данных.

Дело в том, что в функции eZCL_TransmitDataRequest() находится здоровенный switch с разными режимами отправки пакета, а документация на эту функцию привела меня к такой табличке

Т.е. пакет с данными в принципе можно отправить целой кучей различных способов: на короткий адрес, на длинный адрес, на групповой адрес, с подтверждением или без. И помимо всего прочего можно отправить пакет по всем связанным адресам (E_ZCL_AM_BOUND*), также целой кучей различных способов. Так это же то, что нужно! Посмотрим в код отправки состояния кнопки нашего выключателя.

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);
}

Ну конечно! Раньше мы отправляли пакет конкретно координатору по короткому адресу (0x0000, E_ZCL_AM_SHORT). А что если поставить режим отправки E_ZCL_AM_BOUND?

Итак, у меня к вам есть хорошая новость: сообщение от нашего устройства (0x8f6b) улетело связанному устройству (0x8428) напрямую минуя координатор. 

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

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

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

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

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

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

Вуаля, сообщение было отправлено координатору, и двум другим устройствам (0xa6da и 0xab1c). Может показаться странным, что сообщения отсылаются по нескольку раз, но тут все правильно - я просто сделал несколько привязок для первого и второго канала реле. Мне было важно протестировать случай, когда у целевого устройства несколько конечных точек, и всем им нужно отослать сообщение. Zigbee стек справился с этой задачей и отправил сообщение всем подписчикам. 

Кстати говоря, устройство отсылает сообщения не сплошным потоком, а по одному, дожидаясь подтверждения. Более того в настройках Zigbee3ConfigEditor даже специальная опция есть ZDO Configuration->Bind Request Server->Time Interval, которая не позволяет отсылать сообщения слишком часто. Когда все сообщения отправлены, zigbee стек генерирует событие ZPS_EVENT_BIND_REQUEST_SERVER в котором отчитывается о проделанной работе - все ли сообщения были отосланы, или отправка некоторых была неуспешна. На данном этапе мы не будем делать обработку особых ситуаций, но в принципе неплохо было бы дожидаться отсылки предыдущего комплекта сообщений прежде чем посылать новое.

Commands binding

С моей стороны пули вылетели, проблема на вашей стороне (С) известный анекдот про сисадмина

Ок, сообщения отправляются, но как на них реагируют другие устройства? А никак :) 

Давайте порассуждаем. Мы говорим о сообщении Report Attribute. С точки зрения отправителя это весьма важное сообщение. Но вот с точки зрения целевого устройства это некоторое непонятное сообщение вроде “слышь, чувак, у меня тут значение атрибута поменялось”. Как порядочное устройство должно на это реагировать? Ведь целевое устройство даже не знает что его с кем-то связали, и теперь еще какие-то сообщения наливают. 

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

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

Ок, зайдем в наших рассуждениях с другой стороны. Классический пример связывания устройств zigbee это выключатель, который управляет лампочкой. Немного усложним задачу: пускай у нас несколько выключателей управляют одной лампочкой. Представим себе, что один выключатель рапортует, что его состояние сейчас “включено”, а другой, что “выключено”. Что делать бедной лампочке?

Здравый смысл подсказывает, что в этой системе должен быть только один объект, у которого есть состояние - это лампочка. Выключатели же должны либо каким-то образом знать состояние лампочки (например из репортов этой самой лампочки), либо попросту вслепую слать одну из трех команд ВКЛЮЧИСЬ, ВЫКЛЮЧИСЬ, или ПЕРЕКЛЮЧИСЬ. Получается, что наш выключатель, который мы делаем, вообще не должен хранить никакого состояния, не должен рапортовать изменения своих атрибутов, а лишь посылать команды на включение/выключение. 

Эта теория подтверждается и другими наблюдениями. Вот например zigbee2mqtt и home assistant тоже не хранят текущее состояние реле в сети Zigbee, а лишь отображают состояние, которое присылает устройство в качестве репортов. А чтобы переключить состояние реле, Z2M шлют соответствующую команду на включение/выключение. 

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

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

    • Как правило это исполнительные устройства (например лампочка или реле), или датчики (например датчик температуры или освещенности)

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

  • Клиентское устройство (клиентский кластер) это устройство, у которого нет внутреннего состояния.

    • Клиентское устройство может взаимодействовать с серверными устройствами посредством команд (отправляются от клиента к серверу) или подписываться на репорты (от сервера клиенту)

И вот в этом месте я остановлюсь на видах взаимодействия. Так что же лучше, команды или репорты? Документация приводит (лишь) 2 примера:

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

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

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

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

Итак, давайте переделаем наш SwitchEndpoint из серверного в клиентский. Для начала нужно взять более подходящую заготовку. Ранее в качестве заготовки мы использовали структуру tsZLO_OnOffLightDevice, которая наделяла устройство всеми кластерами, необходимыми для серверного On/Off устройства. Теперь нужно переключиться на использование tsZLO_OnOffLightSwitchDevice, которая как раз реализует клиентское On/Off устройство. 

class SwitchEndpoint: public Endpoint
{   
protected:
   tsZLO_OnOffLightSwitchDevice sSwitch;

Реализация в целом идентичная, просто используется соответствующая функция регистрации.

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

Отправить команду тоже достаточно просто

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

   uint8 sequenceNo;
   teZCL_Status status = eCLD_OnOffCommandSend(getEndpointId(),
                                  1,
                                  &addr,
                                  &sequenceNo,
                                  E_CLD_ONOFF_CMD_TOGGLE);
   DBG_vPrintf(TRUE, "Sending On/Off command status: %02x\n", status);
}

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

С кодом как бы все, да не все. Просто взять и инициализировать все кластера этого мало - нужно же еще дескрипторы устройства подправить. А дескриптор этот генерируется через приложение ZPS Config Editor. Там нужно добавить клиентский (Output) кластер. Из этой настройки потом сгенерируется код дескриптора с помощью утилиты ZPSConfig.exe и вкомпилится в прошивку.

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

По нажатию на кнопку (точнее в моем случае функция SwitchEndpoint::reportStateChange() вызывается при отпускании кнопки) немедленно отправляется команда на переключение связанному реле Xiaomi. Я не использую команды на включение или выключение, т.к. мое устройство не знает текущего состояния реле. Именно поэтому я отсылаю команду Toggle (переключение). 

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

На этой картинке мое устройство (f2e5) широковещательным запросом Route Request пробует узнать маршрут к реле (0xa6da). После того как маршрут найден (Route Reply) команда переключения идет через промежуточный узел (координатор, который в данном случае работает как роутер). Т.о. связанным устройствам не обязательно находится в непосредственной близости, и они могут взаимодействовать через промежуточные роутеры.

Клиент-серверные устройства

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

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

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

  • ZigBee class library specification знакомит читателя с понятием кластера, какие бывают стандартные кластера (On/Off, Basic, OTA) и как они могут взаимодействовать между собой. При этом документ не дает никаких рекомендаций из каких кластеров должно состоять устройство - каждый может лепить что хочет.

  • ZigBee Lighting & Occupancy (ZLO) Device Specification описывает стандартные устройства из области освещения. Этот документ уже достаточно четко регламентирует из каких запчастей должно состоять устройство, и как оно должно себя вести. Существует еще несколько подобных спецификаций для других доменных областей (вентиляция и кондиционирование, охрана, всевозможная телеметрия, и другие) 

Например, в ZLO спецификации сказано, что выключатель должен состоять из следующих кластеров

На самом деле картинка из документа ZigBee 3.0 Devices User Guide JN-UG-3114 от NXP, но это пересказ того, что дается в ZLO спецификации. Просто у NXP картинка красивее.
На самом деле картинка из документа ZigBee 3.0 Devices User Guide JN-UG-3114 от NXP, но это пересказ того, что дается в ZLO спецификации. Просто у NXP картинка красивее.

А вот эти кластера требуют от лампочки (сюда же я бы отнес беспроводные реле).

Документ ZLO Specification описывает порядка двух десятков типовых устройств, каждое из которых предоставляет те или иные функции. Ребята в NXP тоже молодцы, и реализовали заготовки для таких устройств в SDK (они лежат в директории Components/ZCL/Devices). Устройства представлены двумя сущностями

  • Структурой tsZLO_***Device, которая описывает все данные связанные с конкретным устройством, набором кластеров

  • Функцией eZLO_Register***EndPoint(), которая регистрирует конечную точку в стеке Zigbee и связывает ее со структурой устройства.

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

Более того, ZLO спецификация определяет некоторое устройство в котором напичкано сразу все - и Basic (описание устройства), и On/Off функциональность, и OTA (которое, в общем-то, к функциональности выключателя отношение не имеет), и сцены (которых может и не быть). Все это опять же запутывает.

По всей видимости ZLO это попытка стандартизировать типовые устройства - выключатели, лампочки, датчики освещения и движения. Разработчики Zigbee видимо хотели, чтобы лампочки одного производителя работали с выключателями другого, и включались по датчику движения третьего вендора. Только у производителей свой взгляд - каждый старается закрыть свою экосистему и привязать все к собственному хабу. Поэтому и возникают такие проекты как zigbee2mqtt и SLS, которые объединяют разные устройства в одну сеть..

Порассуждаем еще немного. Мне кажется что все вот эти штуки вроде “Выключатель это клиент и у него нет своего состояния. А лампочка это сервер - у нее состояние есть” здорово запутывают. Вот взять, например, какой нибудь умный выключатель вроде Xiaomi Aqara. Он вроде бы выключатель - у него есть клавиши и он управляет освещением. Но при этом он очень даже сервер - у него есть внутреннее состояние. Т.е. по сути умный выключатель это просто реле с кнопками, но сам он при этом никакими другими Zigbee устройствами не управляет (ибо не клиент).

А можно создать свое устройство, которое будет состоять только из нужных компонентов? Да, можно. И, похоже, производители фабричных устройств тоже пошли этим путем.

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

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

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

В прошлой статье я, отчасти, затронул тему как можно делать свои конечные точки собственного наполнения. Конечная точка моего устройства будет состоять из двух On/Off кластеров (клиентского и серверного). Другие кластера (Groups, Scenes, Identify) я пока реализовывать не буду. В качестве источника вдохновения я буду подглядывать в control_bridge.h - это устройство также реализует и клиент и сервер On/Off.

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

Давайте посмотрим на код.

// List of cluster instances (descriptor objects) that are included into the endpoint
struct OnOffClusterInstances
{
   tsZCL_ClusterInstance sOnOffClient;
   tsZCL_ClusterInstance sOnOffServer;
} __attribute__ ((aligned(4)));


class SwitchEndpoint: public Endpoint
{   
protected:
   tsZCL_EndPointDefinition sEndPoint;
   OnOffClusterInstances sClusterInstance;
   tsCLD_OnOff sOnOffClientCluster;
   tsCLD_OnOff sOnOffServerCluster;
   tsCLD_OnOffCustomDataStructure sOnOffServerCustomDataStructure;

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

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

  • sClusterInstance - массив дескрипторов кластеров. Описывает каждый кластер, сколько в нем атрибутов, является ли он серверным или клиентским

  • sOnOffClientCluster и sOnOffServerCluster - данные серверного и клиентского кластеров

  • sOnOffServerCustomDataStructure - дополнительные данные серверного кластера. На деле не используется, но требуется внутренними API

Инициализировать это все дело будем следующим образом.

void SwitchEndpoint::registerServerCluster()
{
   // Initialize On/Off server cluser
   teZCL_Status status = eCLD_OnOffCreateOnOff(&sClusterInstance.sOnOffServer,
                                               TRUE,                               // Server
                                               &sCLD_OnOff,
                                               &sOnOffServerCluster,
                                               &au8OnOffAttributeControlBits[0],
                                               &sOnOffServerCustomDataStructure);
   if( status != E_ZCL_SUCCESS)
       DBG_vPrintf(TRUE, "SwitchEndpoint::init(): Failed to create OnOff server cluster instance. status=%d\n", status);
}

void SwitchEndpoint::registerClientCluster()
{
   // Initialize On/Off client cluser
   teZCL_Status status = eCLD_OnOffCreateOnOff(&sClusterInstance.sOnOffClient,
                                               FALSE,                              // Client
                                               &sCLD_OnOff,
                                               &sOnOffClientCluster,
                                               &au8OnOffAttributeControlBits[0],
                                               NULL);
   if( status != E_ZCL_SUCCESS)
       DBG_vPrintf(TRUE, "SwitchEndpoint::init(): Failed to create OnOff client cluster instance. status=%d\n", status);
}

void SwitchEndpoint::registerEndpoint()
{
   // Initialize endpoint structure
   sEndPoint.u8EndPointNumber = getEndpointId();
   sEndPoint.u16ManufacturerCode = ZCL_MANUFACTURER_CODE;
   sEndPoint.u16ProfileEnum = HA_PROFILE_ID;
   sEndPoint.bIsManufacturerSpecificProfile = FALSE;
   sEndPoint.u16NumberOfClusters = sizeof(OnOffClusterInstances) / sizeof(tsZCL_ClusterInstance);
   sEndPoint.psClusterInstance = (tsZCL_ClusterInstance*)&sClusterInstance;
   sEndPoint.bDisableDefaultResponse = ZCL_DISABLE_DEFAULT_RESPONSES;
   sEndPoint.pCallBackFunctions = &EndpointManager::handleZclEvent;

   // Register the endpoint with all the clusters in it
   teZCL_Status status = eZCL_Register(&sEndPoint);
   DBG_vPrintf(TRUE, "SwitchEndpoint::init(): Register Basic Cluster. status=%d\n", status);
}

void SwitchEndpoint::init()
{
   // Register all clusters and endpoint itself
   registerServerCluster();
   registerClientCluster();
   registerEndpoint();
...
}

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

Теперь отправка репортов и команд. И то и другое вы уже видели.

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

   // Send the report
   DBG_vPrintf(TRUE, "Reporting attribute EP=%d value=%d... ", getEndpointId(), 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::sendCommandToBoundDevices()
{
   // Destination address does not matter - we will send to all bound devices
   tsZCL_Address addr;
   addr.uAddress.u16DestinationAddress = 0x0000;
   addr.eAddressMode = E_ZCL_AM_BOUND;

   // Send the toggle command
   uint8 sequenceNo;
   teZCL_Status status = eCLD_OnOffCommandSend(getEndpointId(),
                                  1,
                                  &addr,
                                  &sequenceNo,
                                  E_CLD_ONOFF_CMD_TOGGLE);
   DBG_vPrintf(TRUE, "Sending On/Off command status: %02x\n", status);
}

void SwitchEndpoint::reportStateChange()
{
   if(runsInServerMode())
       reportState();
   else
       sendCommandToBoundDevices();
}

Репорты мы отправляем координатору направленным сообщением, а вот команды будем отправлять всем подписчикам с помощью флага E_ZCL_AM_BOUND. Тут больше интересен выбор когда отправлять репорты, а когда команды. Я решил так: если устройство имеет хоть одного связанного подписчика, то оно становится клиентом и рассылает команды. Если связанных устройств нет, то устройство будет сервером и будет отправлять репорты.

Чтобы узнать есть ли связанные устройства я написал простенькую функцию, которая просто перебирает записи в таблице связей.

bool SwitchEndpoint::runsInServerMode() const
{
   return !hasBindings(getEndpointId(), GENERAL_CLUSTER_ID_ONOFF);
}

bool hasBindings(uint8 ep, uint16 clusterID)
{
   // Get pointers
   ZPS_tsAplAib * aib = ZPS_psAplAibGetAib();
   ZPS_tsAplApsmeBindingTableType * bindingTable = aib->psAplApsmeAibBindingTable;
   ZPS_tsAplApsmeBindingTable* table = bindingTable->psAplApsmeBindingTable;

   if(!table)
       return false;

   for(uint32 i=0; i < table->u32SizeOfBindingTable; i++)
   {
       ZPS_tsAplApsmeBindingTableStoreEntry * entry = table->pvAplApsmeBindingTableEntryForSpSrcAddr + i;
       if(entry->u8SourceEndpoint == ep && entry->u16ClusterId == clusterID)
           return true;
   }

   return false;
}

Нужно отметить, что серверный режим включается для одной конкретной конечной точки. Другие конечные точки могут работать в каком-нибудь другом режиме.

Работа с адресом целевого устройства

Представьте себе ситуацию: работает себе устройство, никого не трогает, и тут бац, прилетает сообщение “свяжи свою конечную точку с устройством 0x0123456789abcdef”. Ну ок, вроде вся нужная информация у нас есть - 64-битный полный адрес целевого устройства, номера кластера и конечных точек. Стек ZIgbee для нас добавит необходимую запись в таблицу связей. Этого достаточно для последующей работы?

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

  • “выходим” устройство из сети, делаем сброс настроек.

  • Заново добавляем устройство в сеть. Все таблицы связей и кеши адресов в данный момент пустые.

  • С координатора отправляем устройству запрос на связывание с каким-нибудь другим устройством.

  • Проблема: Тискаем кнопку на устройстве. Устройство пишет что оно отправляет команду On/Off связанному устройству, и даже никаких ошибок не видно. Вот только если верить снифферу никакая команда никуда не отправляется.

  • Через десяток-другой секунд целевое устройство просто в порядке своей обычной работы заявляет о себе сообщением “Link Status”. Насколько я понимаю это что-то типа “я здесь, не забыли про меня? Мой адрес такой-то”.

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

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

void ZigbeeDevice::handleZdoBindEvent(ZPS_tsAfZdoBindEvent * pEvent)
{
   // Address of interest
   ZPS_tsAplZdpNwkAddrReq req = {pEvent->uDstAddr.u64Addr, 0, 0};

   // Target addr (Broadcast)
   ZPS_tuAddress uDstAddr;
   uDstAddr.u16Addr = 0xFFFF;   // Broadcast

   // Perform the request
   uint8 u8SeqNumber;
   PDUM_thAPduInstance hAPduInst = PDUM_hAPduAllocateAPduInstance(apduZDP);
   ZPS_teStatus status = ZPS_eAplZdpNwkAddrRequest(hAPduInst,
                                                   uDstAddr,     // Broadcast addr
                                                   FALSE,
                                                   &u8SeqNumber,
                                                   &req);
   DBG_vPrintf(TRUE, "ZigbeeDevice::handleZdoBindUnbindEvent(): looking for network addr for %016llx. Status=%02x\n", pEvent->uDstAddr.u64Addr, status);
}

void ZigbeeDevice::handleZdoEvents(ZPS_tsAfEvent* psStackEvent)
{
...
   switch(psStackEvent->eType)
   {
       case ZPS_EVENT_ZDO_BIND:
           handleZdoBindEvent(&psStackEvent->uEvent.sZdoBindEvent);
           break;

В сниффере это выглядит так.

В ответ на Bind Request наше устройство интересуется а кто же живет по адресу 60:a4:23:ff:fe:ab:4e:fb. Через несколько десятков миллисекунд нужное устройство отвечает сообщением Link Status информируя нас (и всех остальных) что такое устройство имеет короткий адрес 0xa6da. Соответствие короткого и длинного адреса сохраняется в кеше адресов устройства. С этого момента наше устройство точно знает кому отправлять команды, а сами команды отправляются мгновенно.

Часть 2 - про неверно понятую документацию, реверс инжиниринг и копание в кишочках Zigbee стека

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

Вернемся еще раз к сообщению ZPS_EVENT_ZDO_BIND. Практически все сообщения Zigbee стека подразумевают, что нужно написать на него какой-нибудь обработчик. Мол “начальник, тут ситуация такая. Что нам делать? отреагируй, пожалуйста”. Сообщение ZPS_EVENT_ZDO_BIND я (неверно) трактовал аналогичным образом - нас координатор просит сделать связывание вот по таким параметрам, и это связывание нужно сделать в коде обработчика. Теперь-то мы уже знаем связывание происходит средствами Zigbee стека автоматически, и специальный код писать не нужно. Но полтора месяца назад я об этом еще не знал, и потому пытался написать связывание самостоятельно. 

Согласно документации и коду BDB Find and Bind само связывание происходит вызовом функции ZPS_eAplZdoBind(). Попробуем написать свой обработчик Bind Request, который бы вызывал ZPS_eAplZdoBind(). 

void ZigbeeDevice::handleZdoBindEvent(ZPS_tsAfZdoBindEvent * pEvent)
{
   // We do not support group address as of now
   if(pEvent->u8DstAddrMode != ZPS_E_ADDR_MODE_IEEE)
   {
       DBG_vPrintf(TRUE, "ZigbeeDevice::handleZdoBindEvent() WARNING: Only IEEE address mode is supported\n");
       return;
   }


   // Prepare short and full address
   uint16 shortAddr = ZPS_u16AplZdoLookupAddr(pEvent->uDstAddr.u64Addr);

   // Bind endpoints
   ZPS_teStatus status = ZPS_eAplZdoBind(GENERAL_CLUSTER_ID_ONOFF,
                                         pEvent->u8SrcEp,
                                         shortAddr,
                                         ieeeAddr,
                                         pEvent->u8DstEp);
   DBG_vPrintf(TRUE, "Binding to %04x/%016llx SrcEP=%d to DstEP=%d Status=%d\n", shortAddr, ieeeAddr, pEvent->u8SrcEp, pEvent->u8DstEp, status);

   vDisplayBindTable();
   vDisplayAddressMap();
}

Согласно спецификации на Bind Request в поле целевого адреса может быть 2 вида адресации - полный 64-битный IEEE адрес целевого устройства, или 16-битный адрес группы устройств. Группы мы пока не поддерживаем, поэтому сосредоточимся на одном виде адресации.

Вот только проблема: функция ZPS_eAplZdoBind() принимает параметр ClusterID, но в структуре ZPS_tsAfZdoBindEvent, которую передают нам в обработчик этого поля нет. 

typedef struct {
   ZPS_tuAddress uDstAddr;
   uint8 u8DstAddrMode;
   uint8 u8SrcEp;
   uint8 u8DstEp;
} ZPS_tsAfZdoBindEvent;

Но мы же помним, что в низкоуровневом сообщении Zigbee поле присутствует. Пока я захардкодил clusterId в GENERAL_CLUSTER_ID_ONOFF (все равно наше устройство больше ничего полезного не умеет). Но отсутствие этого параметра меня зацепило. В этом разделе будет некоторое количество низкоуровневой магии. Я НЕ рекомендую полученные результаты к использованию, т.к. они базируются на определенном наборе хаков и предположений, а также на приватном API. Скорее тут сработал спортивный интерес расковырять эту проблему.

Итак, распарсить из пакета дополнительные 2 байта - выглядит как сущая мелочь, но к сожалению эта часть Zigbee фреймворка поставляется без исходников и сделать это не получится. Более того, я пересмотрел ВЕСЬ публичный API фреймворка в поисках способа как можно перехватить сетевые пакеты до того они попадают в сетевой стек, но к сожалению такой функции нам не предоставили. Все интересные структуры данных передаются через void * или через довольно обфусцированные структуры, и поиметь что нибудь интересное там не получилось. Я в тайне надеялся, что структуры где нибудь хранят хендл PDU пакета (кусок памяти, где живут данные пакета), но для Bind Request’а ничего такого не приезжает. Отчаявшись я даже спросил техподдержку, но там пока ничего внятного не рассказали.

Решение может состоять в том, чтобы использовать тот же самый хардкод, но затолкать реализацию в соответствующий класс конечной точки. Идея в следующем. Если нас просят чего-нибудь связать с конечной точкой №2, которая у нас реализует выключатель, то скорее всего речь идет о кластере GENERAL_CLUSTER_ID_ONOFF (0x0006). Ну а если обращаются к конечной точке термометра, то наверное имеется в виду кластер термометра (0x0402). 

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

Ладно, пойдем другим путем. Пускай исходников Zigbee стека у нас нету, но у нас есть objdump, которым можно хоть как-нибудь позаглядывать в бинарники. Я все таки попробовал найти место где парсится входящий пакет и данные перекладываются в структуру ZPS_tsAfZdoBindEvent. После непродолжительных поисков в библиотеке libZPSAPL_JN516x.a обнаружился файлик zps_apl_zdo_bind_unbind_server.o. Судя по наполнению (да и название тоже подсказывает) это та часть стека, которая обслуживает Bind/Unbind запросы. В этом файле обнаружились следующие символы.

00000000 l    d  .text.zps_vAplZdoBindUnbindServerInit	00000000 .text.zps_vAplZdoBindUnbindServerInit
00000000 l    d  .rodata.str1.1	00000000 .rodata.str1.1
00000000 l    d  .text.u8ZdoBindUnbindServerUnpackApdu	00000000 .text.u8ZdoBindUnbindServerUnpackApdu
00000000 l    d  .text.vAplZdoBindUnbindServerMapApsmeToZdp	00000000 .text.vAplZdoBindUnbindServerMapApsmeToZdp
00000000 l    d  .text.bAplZdoBindUnbindServerProcessApdu	00000000 .text.bAplZdoBindUnbindServerProcessApdu
00000000 l    d  .text.zps_bAplZdoBindUnbindServer	00000000 .text.zps_bAplZdoBindUnbindServer
00000000 l    d  .text.ZPS_vZdoSetBindCallback	00000000 .text.ZPS_vZdoSetBindCallback
00000000 l    d  .bss.pfBindAllowed	00000000 .bss.pfBindAllowed
00000000 l    d  .bss.psBindUnbindServerContext	00000000 .bss.psBindUnbindServerContext
00000000 l    d  .zps_apl_ZdoBindUnbindServerContextSize	00000000 .zps_apl_ZdoBindUnbindServerContextSize

Это же прям то, что нужно! Особенно u8ZdoBindUnbindServerUnpackApdu(), которая судя из названия как раз и занимается парсингом входящего запроса. Кстати говоря, спецификация описывает пакет Bind Request так

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

  • Всем заведует функция zps_bAplZdoBindUnbindServer(). Она вызывается в главном цикле программы где-то из-под zps_taskZPS(), по всей видимости по приходу входящего сетевого пакета.

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

    • Функция u8ZdoBindUnbindServerUnpackApdu() парсит входящий пакет

      • Сначала парсятся первые 4 поля

      • В зависимости от поля DstAddrMode парсятся либо 2 оставшихся поля, либо один 16-битный групповой адрес

      • Функция u8ZdoBindUnbindServerUnpackApdu() просто раскладывает принятые поля в какую-то структуру в памяти

      • В коде нет никакого явного выкидывания поля ClusterID - как минимум это поле вычитывается из пакета.

    • Принятый пакет освобождается с помощью вызова PDUM_eAPduFreeAPduInstance()

      • Это полностью ломает идею распарсить пакет еще разок в другом месте

    • Дальше идет то ли switch, то ли какие-то вычисления с ветвлениями, смысл которых разобрать очень трудно не зная ассемблера от этой архитектуры

    • Данные, которые были получены из входящего запроса раскладываются по разным структурам

      • Похоже именно в этом месте и происходит заполнение структуры ZPS_tsAfZdoBindEvent

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

    • Собирается Bind Response с помощью функции zps_eAplApsmeBindReqRsp()

    • Также в отправке Bind Response участвуют вызовы zps_eAplZdpBindResponse()/zps_eZdpUnbindResponse() и zps_eAplZdoSendZdpResponse(), возможно не все сразу.

    • Примерно тут же запись о связывании попадает в Binding Table, но где именно это происходит я не нашел.

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

Попробуем что нибудь изобразить. Я не очень люблю язык C за чрезмерное использование void * и слабой типизации, но в данном случае это нам на руку. Функция ZPS_vZdoSetBindCallback() не является частью публичного API, но мы сделаем для нее свое собственное определение.

extern "C"
void ZPS_vZdoSetBindCallback(void *);

Методом тыка, а также изучая дизассемблерный листинг функции bAplZdoBindUnbindServerProcessApdu() я выяснил, что функция принимает 5 параметров.

uint8 bindCallback(uint16 cmd, uint64 *addr, uint16 clusterID, uint8 dstEP, uint8 srcEP, uint8 addrMode)
{
   DBG_vPrintf(TRUE, "+_+_+_ bindCallback(): cmd=%04x, addr=0x%016llx, ClusterID=%04x, dstEP=%d, srcEP=%d, mode=%d\n",
               cmd, *addr, clusterID, dstEP, srcEP, addrMode);

   return TRUE; // Allow bind request
}

Обязательно нужно вернуть TRUE. Как я уже сказал эта функция разрешает или запрещает запрос на связывание - если вернуть 0 (FALSE), то запрос не будет обработан.

Установка обратного вызова:

ZigbeeDevice::ZigbeeDevice()
{
...
    ZPS_vZdoSetBindCallback((void*)bindCallback);
}

Запустив я попытался отправить устройству с координатора несколько запросов на связывание.

Вуаля, наша функция вызывается! Более того, в функцию приезжает вся необходимая информация - номер кластера, номера конечных точек, адрес целевого устройства. Этот обработчик, кстати, вызывается и на связывание (bind), и на отвязывание (unbind). Различается первым параметром: cmd=0x0021 - это номер команды на связывание, тогда как 0x0022 является командой на отвязывание (на самом деле это номера кластеров в ZDO - нулевой конечной точке).

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

uint8 bindCallback(uint16 cmd, uint64 *addr, uint16 clusterID, uint8 dstEP, uint8 srcEP, uint8 addrMode)
{
   DBG_vPrintf(TRUE, "+_+_+_ bindCallback(): cmd=%04x, addr=0x%016llx, ClusterID=%04x, dstEP=%d, srcEP=%d, mode=%d\n",
               cmd, *addr, clusterID, dstEP, srcEP, addrMode);

   // Just an assert
   if(addrMode != ZPS_E_ADDR_MODE_IEEE)
       DBG_vPrintf(TRUE, "WARNING: bindCallback(): Unsupported addressing mode %d\n", addrMode);

   // Prepare short and full address
   uint16 shortAddr = ZPS_u16AplZdoLookupAddr(*addr);
   DBG_vPrintf(TRUE, "bindCallback(): short address obtained %04x\n", shortAddr);

   // Bind endpoints
   ZPS_teStatus status = ZPS_eAplZdoBind(clusterID, srcEP, shortAddr, *addr, dstEP);
   DBG_vPrintf(TRUE, "Binding to %04x/%016llx SrcEP=%d to DstEP=%d Status=%d\n", shortAddr, *addr, srcEP, dstEP, status);

   return TRUE; // Allow bind request
}

Пробуем, и....

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

  • MAC уровень сети (тот, который работает с прерываниями) принимает пакетики и складывает их в несколько очередей с непроизносимыми названиями вроде zps_msgMlmeDcfmInd

  • Где-то в недрах функции zps_taskZPS() (напоминаю, что эта функция вообще-то не является частью публичного API) вытягиваются сообщения из MAC очередей и обрабатываются. Часть обрабатывается на месте и отсылаются соответствующие ответы, часть уходит в какие-то специализированные обработчики, и, наконец, часть конвертируется в структуры ZPS_tsAfEvent и отправляется в очередь BDB.

    • Именно в контексте этой функции вызывается обработчик zps_bAplZdoBindUnbindServer(), который и вызывает наш обратный вызов.

  • Функция bdb_taskBDB() обрабатывает сообщения из BDB очереди. Часть обрабатывается внутри BDB, а другая часть уже передается в пользовательский код.

  • Ну а про это я детально рассказываю на протяжении уже трех статей :)

Так вот что получается. В момент выполнения функции zps_taskZPS() внутренности Zigbee стека могут находится в неконсистентном состоянии, и не все функции Zigbee в этот момент можно использовать. В частности zps_taskZPS() захватывает какой-то mutex. Функция ZPS_eAplZdoBind() запущенная в том же контексте также пытается захватить этот же mutex и падает. К сожалению простыми средствами объехать эту ситуацию не удалось.

А победа была так близка....

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

class ZigbeeDevice
{
   struct BindRequest
   {
       bool bind;
       uint64 dstAddr;
       uint16 clusterID;
       uint8 srcEP;
       uint8 dstEP;
   };

   Queue<BindRequest, 5> bindRequestQueue;
...

protected:
   static uint8 notifyBindRequestComing(uint16 cmd, uint64 *addr, uint16 clusterID, uint8 dstEP, uint8 srcEP, uint8 addrMode);
   void handleZdoBindUnbindEvent(ZPS_tsAfZdoBindEvent * pEvent, bool bind);

Наш коллбек стал статическим методом notifyBindRequestComing() и его задача сохранить все данные из коллбека в структуру BindRequest и положить это в очередь.

uint8 ZigbeeDevice::notifyBindRequestComing(uint16 cmd, uint64 *addr, uint16 clusterID, uint8 dstEP, uint8 srcEP, uint8 addrMode)
{
   DBG_vPrintf(TRUE, "+_+_+_ bindCallback(): cmd=%04x, addr=0x%016llx, ClusterID=%04x, dstEP=%d, srcEP=%d, mode=%d\n",
               cmd, *addr, clusterID, dstEP, srcEP, addrMode);

   // Store the request in the queue to be processed when official bind request arrive
   BindRequest req = {
       cmd==ZPS_ZDP_BIND_REQ_CLUSTER_ID,
       *addr,
       clusterID,
       srcEP,
       dstEP
   };
   ZigbeeDevice::getInstance()->bindRequestQueue.send(req);

   return TRUE; // Allow bind request
}

Обработчики для ZPS_EVENT_ZDO_BIND и ZPS_EVENT_ZDO_UNBIND я объединил в один обработчик handleZdoBindUnbindEvent().

void ZigbeeDevice::handleZdoBindUnbindEvent(ZPS_tsAfZdoBindEvent * pEvent, bool bind)
{
   // We do not support group address as of now
   if(pEvent->u8DstAddrMode != ZPS_E_ADDR_MODE_IEEE)
   {
       DBG_vPrintf(TRUE, "ZigbeeDevice::handleZdoBindUnbindEvent(): WARNING: Only IEEE address mode is supported\n");
       return;
   }

   // Retrieve stored bind request
   BindRequest req;
   if(!bindRequestQueue.receive(&req))
   {
       DBG_vPrintf(TRUE, "ZigbeeDevice::handleZdoBindUnbindEvent(): WARNING: Unexpected bind request\n");
       return;
   }

   // Verify this is the same bind request that we were notified earlier
   if(bind != req.bind ||
      pEvent->uDstAddr.u64Addr != req.dstAddr ||
      pEvent->u8SrcEp != req.srcEP ||
      pEvent->u8DstEp != req.dstEP)
   {
       DBG_vPrintf(TRUE, "ZigbeeDevice::handleZdoBindUnbindEvent(): WARNING: Unexpected bind/unbind request bind=%d Addr=%016llx SrcEP=%d DstEP=%d\n",
                   req.bind, req.dstAddr, req.srcEP, req.dstEP);
       return;
   }

   // Prepare short and full address
   uint16 shortAddr = ZPS_u16AplZdoLookupAddr(pEvent->uDstAddr.u64Addr);

   // Bind endpoints
   if(bind)
   {
       ZPS_teStatus status = ZPS_eAplZdoBind(req.clusterID,
                                             req.srcEP,
                                             shortAddr,
                                             req.dstAddr,
                                             req.dstEP);
       DBG_vPrintf(TRUE, "Binding to %04x/%016llx SrcEP=%d to DstEP=%d Status=%d\n", shortAddr, req.dstAddr, req.srcEP, req.dstEP, status);
   }
   else
   {
       ZPS_teStatus status = ZPS_eAplZdoUnbind(req.clusterID,
                                             req.srcEP,
                                             shortAddr,
                                             req.dstAddr,
                                             req.dstEP);
       DBG_vPrintf(TRUE, "Unbinding to %04x/%016llx SrcEP=%d from DstEP=%d Status=%d\n", shortAddr, req.dstAddr, req.srcEP, req.dstEP, status);
   }

   vDisplayBindTable();
   vDisplayAddressMap();
}

Задача этого метода сначала убедится, что это тот же самый запрос, что мы перехватили в обратном вызове. Ну а если все впорядке, то теперь уже можно смело вызывать ZPS_eAplZdoBind()/ZPS_eAplZdoUnbind(). Получилось немного громоздко, но можно это в будущем немного причесать. Или даже выделить в отдельный класс, который бы занимался только связыванием.

Если уж совсем упороться...

Я несколько вечеров потратил изучая ассебмлерный код в районе вызова pfBindAllowed() в функции bAplZdoBindUnbindServerProcessApdu(). 

   95d14:    2c ec 45                    b.lwz      r7,0xa0(r12)
   95d17:    51 41 18                    b.mlwz     r10,0x18(r1),0x0
   95d1a:    2d 47 0e                    b.sw       0x70(r7),r10
   95d1d:    30 81 28                    b.addi     r4,r1,0x14
   95d20:    2d 67 2e                    b.sw       0x74(r7),r11
   95d23:    2c ec 45                    b.lwz      r7,0xa0(r12)
   95d26:    25 01 3c                    b.lbz      r8,0x3c(r1)
   95d29:    24 c1 12                    b.lbz      r6,0x48(r1)
   95d2c:    21 07 1e                    b.sb       0x78(r7),r8
   95d2f:    2c ec 45                    b.lwz      r7,0xa0(r12)
   95d32:    2d 41 04                    b.sw       0x20(r1),r10
   95d35:    20 c7 5e                    b.sb       0x7a(r7),r6
   95d38:    2c ac 45                    b.lwz      r5,0xa0(r12)
   95d3b:    24 e1 1c                    b.lbz      r7,0x38(r1)
   95d3e:    2d 61 24                    b.sw       0x24(r1),r11
   95d41:    20 e5 9e                    b.sb       0x79(r5),r7
   95d44:    28 a1 dc                    b.lhz      r5,0x3a(r1)
   95d47:    28 6d c4                    b.lhz      r3,0x22(r13)
   95d4a:    28 a1 14                    b.sh       0x28(r1),r5
   95d4d:    3c 23 84                    b.sfnei    r3,0x21
   95d50:    20 c1 34                    b.sb       0x2c(r1),r6
   95d53:    20 e1 d4                    b.sb       0x2b(r1),r7
   95d56:    21 01 54                    b.sb       0x2a(r1),r8

   95d59:    8d 40 4f 02 00 20           b.lwz      r10,0x40040f0(r0)
   95d5f:    47 2a 20                    b.bf       95da4 <bAplZdoBindUnbindServerProcessApdu+0x113>
   95d62:    41 0a e4                    b.bnei     r10,0x0,95d89 <bAplZdoBindUnbindServerProcessApdu+0xf8>
   95d65:    50 81 0c                    b.mlwz     r4,0x30(r1),0x0
   95d68:    30 c1 04                    b.addi     r6,r1,0x20
   95d6b:    04 6c                       b.mov      r3,r12
   95d6d:    4a 66 27                    b.jal      8ef06 <zps_eAplApsmeBindReqRsp>
   95d70:    2d 61 4c                    b.lwz      r11,0x30(r1)
   95d73:    20 61 28                    b.sb       0x14(r1),r3
   95d76:    2c 6c 40                    b.lwz      r3,0x0(r12)
   95d79:    2d 41 6c                    b.lwz      r10,0x34(r1)
   95d7c:    4b 30 18                    b.jal      9bdaf <ZPS_u64NwkNibGetExtAddr>
   95d7f:    d2 d6 39 80                 b.bne      r11,r3,95d98 <bAplZdoBindUnbindServerProcessApdu+0x107>
   95d83:    d2 d4 4a 80                 b.bne      r10,r4,95d98 <bAplZdoBindUnbindServerProcessApdu+0x107>
   95d87:    0e a0                       b.j        95d9c <bAplZdoBindUnbindServerProcessApdu+0x10b>
   95d89:    2c 81 00                    b.sw       0x0(r1),r4
   95d8c:    38 60 84                    b.ori      r3,r0,0x21
   95d8f:    30 81 18                    b.addi     r4,r1,0x18
   95d92:    47 d1 50                    b.jalr     r10
   95d95:    41 03 0b                    b.bnei     r3,0x0,95d65 <bAplZdoBindUnbindServerProcessApdu+0xd4>
   95d98:    01 48                       b.movi     r10,0x1
   95d9a:    0d 48                       b.j        95de4 <bAplZdoBindUnbindServerProcessApdu+0x153>
   95d9c:    2c ec 45                    b.lwz      r7,0xa0(r12)
   95d9f:    38 c0 88                    b.ori      r6,r0,0x11
   95da2:    0e f0                       b.j        95ddf <bAplZdoBindUnbindServerProcessApdu+0x14e>
   95da4:    41 0a e4                    b.bnei     r10,0x0,95dcb <bAplZdoBindUnbindServerProcessApdu+0x13a>
   95da7:    50 81 0c                    b.mlwz     r4,0x30(r1),0x0
   95daa:    30 c1 04                    b.addi     r6,r1,0x20
   95dad:    04 6c                       b.mov      r3,r12
   95daf:    4a d1 27                    b.jal      8efdc <zps_eAplApsmeUnBindReqRsp>
   95db2:    2d 61 4c                    b.lwz      r11,0x30(r1)
   95db5:    20 61 28                    b.sb       0x14(r1),r3
   95db8:    2c 6c 40                    b.lwz      r3,0x0(r12)
   95dbb:    2d 41 6c                    b.lwz      r10,0x34(r1)
   95dbe:    4a 3f e8                    b.jal      9bdaf <ZPS_u64NwkNibGetExtAddr>
   95dc1:    d2 d6 3e bf                 b.bne      r11,r3,95d98 <bAplZdoBindUnbindServerProcessApdu+0x107>
   95dc5:    d2 d4 4c bf                 b.bne      r10,r4,95d98 <bAplZdoBindUnbindServerProcessApdu+0x107>
   95dc9:    0c 20                       b.j        95dd9 <bAplZdoBindUnbindServerProcessApdu+0x148>
   95dcb:    2c 81 00                    b.sw       0x0(r1),r4
   95dce:    30 81 18                    b.addi     r4,r1,0x18
   95dd1:    47 d1 50                    b.jalr     r10

В первом блоке судя по всему происходит заполнение двух каких-то структур по адресам r1+0x20 - r1+0x29 и r7+0x70 - r7+0x79, причем вторая это скорее всего как раз и есть выходная структура ZPS_tsAfZdoBindEvent. Во втором блоке подготавливается вызов pfBindAllowed, где

  • r10 - хранит адрес функции обратного вызова

  • r3-r7 будут содержать параметры

  • Причем r3 будет содержать хардкодом либо 0x21 (строка 95d8c) либо 0x22 (не знаю как оно туда попадает) 

Сам обртный вызов pfBindAllowed() происходит в строках 95d92 для bind request и 95dd1 для unbind request.

Упоротая идея заключалась в следующем:

  • расширить структуру ZPS_tsAfZdoBindEvent еще одним полем ClusterID

    • Эта структура является частью union’а ZPS_tuAfEventData, который в свою очередь является частью ZPS_tsAfEvent

    • Скорее всего увеличение размера структуры ZPS_tsAfZdoBindEvent никак не поменяет размер ZPS_tsAfEvent, т.к. в union’е есть структуры и потолще

  • Поменять ассемблерный код выше так, чтобы в r3 оказался указатель на ZPS_tuAfEventData вместо cmd

  • в обратном вызове сохранить ClusterID в переданную структуру

  • Как результат в наш пользовательский код придет сообщение ZPS_EVENT_ZDO_BIND или ZPS_EVENT_ZDO_UNBIND с указателем на структуру ZPS_tsAfZdoBindEvent с одним дополнительным полем ClusterID, что собственно мы и добивались

Не знаю насколько это реализуемо. Я попросту запутался какой регистр в какой момент содержит, чтобы правильно вычислить как именно можно переписать этот код не зная ассемблера этой архитектуры. Так что пусть это остается на уровне идеи. Напоминаю, что всеми этими хаками я просто пытаюсь починить косяк NXP, которые посчитали, что поле ClusterID нам не нужно. Авось они когда нибудь выпустят новую версию SDK и добавят это поле по человечески - для них фикс проблемы, о которой я пишу на протяжении последних 10 страниц на самом деле делается в 2-3 дополнительных строки, даже с учетом обратной совместимости.

Резюме

Техническая реализация связывания (Binding) устройств и отдельных кластеров оказалась несложной. Теперь у вас еще и детально расписанный пример есть :) Неверное прочтение документации увело меня немного не туда, но вам я описал как делать связывание правильно.

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

В статье я описывал связывание по команде от координатора - именно такой режим уместен при использовании проектов умного дома вроде zigbee2mqtt или SLS. Есть еще подход Find and Bind, где связывание происходит без координатора, но таких устройств я пока не видел и в эту сторону не копал.

Буду рад конструктивной критике и дополнениям.

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

Документация:

Предыдущие статьи:

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


  1. DenisPryt
    20.07.2021 19:40

    Интересно, а существует ли в продаже хоть один зигби выключатель, которому можно поменять стандартные действия при биндинге? Потому что хочется установить свои сцены\действия на кнопку. Но увы, при биндинге нельзя менять действия или хотя-бы стандартные сцены.
    Кстати ещё мне кажется что было бы круто иметь возможность использовать билдинг только при потере связи с координатором. Что-то типо «если мост доступен пусть он выставит температуру\яркость света в зависимости от времени суток\дня недели. Иначе хотя-бы просто включи свет»


    1. DJONvl
      15.11.2021 15:21

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