Предполагается, что читатель уже имеет начальные знания языка C, что-то знает о Zigbee, чипе cc2530, методах его прошивания и использования, а также знаком с такими проектами, как zigbee2mqtt. Если нет — подготовьтесь или сходите почитать на https://myzigbee.ru и https://www.zigbee2mqtt.io/
Статья написана сперва подробно, но постепенно ускоряется и уже не останавливается на деталях, а описывает готовый код прошивки. Если кому-то не интересны рассуждения, то просто открывайте исходники прошивки и читайте их.

Исходный код готовой прошивки

Код и подход к разработке не претендует на идеальность. “Я не волшебник, я только учусь.”

Цель


Основная цель — разобраться, как писать прошивки под Z-Stack, давно хотел. Поэтому решил реализовать альтернативную прошивку под готовое оборудование (в качестве примера выбрано реле Sonoff BASICZBR3) и добавить возможность подключения популярного датчика температуры ds18b20.

Дополнительно хотел показать начинающим Zigbee-разработчикам пример разработки прошивки под чип TI cc2530 на Z-Stack.

1. Подготовка


Для начала разработки нужно скачать и установить Z-Stack 3.0.2 — это SDK для разработки прошивок с примерами и документацией.
Также нужно скачать и установить IAR Embeded Workbench for 8051 — это среда разработки с возможностью компиляции под чипы TI cc2530. Бесплатный период использования — 1 месяц (но ищущий найдет решение).

Для разработки и отладки я использую CCDebugger — он позволяет не только прошивать чипы cc2531/cc2530, но и выполнять отладку приложения в среде IAR.



Для упрощения экспериментов, макетирование и отладку я делаю на devboard и соответствующем модуле cc2530:



2. Создание нового приложения


Создаем новый проект на база GenericApp. Это пример базового приложения на Z-Stack. Располагается оно в папке Z-Stack 3.0.2\Projects\zstack\HomeAutomation\GenericApp.
Копируем рядом и переименовываем, например, в DIYRuZRT (так назовем приложение для нашего устройства).

Внутри папки CC2530DB есть файлы:

  • GenericApp.ewd — настройки проекта для C-SPY
  • GenericApp.ewp — файл проекта
  • GenericApp.eww — рабочая область Workspace

Переименовываем файлы в DIYRuZRT.eww и DIYRuZRT.ewp.

Внутри всех файлов (в том числе и в папке Source) также меняем все упоминания GenericApp на DIYRuZRT.

Теперь открываем проект DIYRuZRT.ewp в IAR. Выбираем конфигурацию RouterEB и выполняем Rebuild All.



В папке CC2530DB создастся папка RouterEB, а внутри, в папке EXE, появится файл DIYRuZRT.d51 — этот файл удобен для прошивки и отладки из IAR.

Но если нам надо прошить прошивку через SmartRF Flash Programmer, то сделаем небольшие изменения. Для этого в настройках проекта в разделе Link на вкладке Output поменяем настройки Output file и Format:



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

3. Немного терминологии


В терминологии Zigbee есть следующие понятия:

  • Endpoint (эндпоинт) — точка описания конечного устройства. Обычно в простых устройствах один эндпоинт. В многофункциональных устройствах их может быть несколько, также как в устройствах с разными профилями взаимодействия (один профиль — один эндпоинт).
  • Cluster (кластер) — набор атрибутов и команд, относящихся к единому функционалу (вкл/выкл, регулирование освещения, температурные измерения и т.п.). Кластер указывает на возможности, реализуемые эндпоинтом. В одном эндпоинте можно реализовать несколько разных кластеров, но не одинаковых.
  • Attribute (атрибут) — характеристика кластера, значение которого можно прочитать или записать. В кластере может быть множество атрибутов.
  • Command (команда) — управляющее сообщение, которое может обработать кластер. У команды могут быть параметры. Это реализуется функцией, которая выполняется при при получении команды и параметров.

Виды кластеров, атрибутов, команд стандартизованы в Zigbee Cluster Library. Но производители могут применять собственные кластеры, со своими атрибутами и командами.

Некоторые горе-производители наплевательски относятся к стандартам и делают что-то около стандарта. Потом под них приходится подстраиваться.

В терминологии Z-Stack тоже есть свои понятия, например:

  • OSAL (Operating System Abstraction Layer) — уровень абстракции Операционной системы. Здесь оперируют задачами (tasks), сообщениями (messages), событиями (events), таймерами (timers) и другими объектами.
  • HAL (Hardware Abstraction Layer) — уровень абстракции оборудования. Здесь оперируют кнопками (keys), светодиодами (leds), прерываниями (Interrupt) и т.п.

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

Использование этого всего вас ждет ниже и в принципе при разработке прошивок.

4. Что же у нас внутри базового приложения?


Код приложения расположен в папке Source:

  • OSAL_DIYRuZRT.c — основной файл инициализирующий приложение
  • zcl_DIYRuZRT.h — заголовочный файл
  • zcl_DIYRuZRT.c — файл реализации функций
  • zcl_DIYRuZRT_data.c — файл констант, переменных и структур

OSAL_DIYRuZRT.c — основной файл, в котором заполняется массив обработчиков задач (task) pTaskEventHandlerFn tasksArr и реализуется функция их инициализации osalInitTasks.

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

Список обработчиков задач pTaskEventHandlerFn tasksArr заполняется ссылками на функции. Часть задач подключаются/отключаются соответствующими директивами компиляции.

Посмотреть и настроить директивы компиляции можно в опциях компилятора Defined symbols:



const pTaskEventHandlerFn tasksArr[] = {
  macEventLoop,
  nwk_event_loop,
#if !defined (DISABLE_GREENPOWER_BASIC_PROXY) && (ZG_BUILD_RTR_TYPE)
  gp_event_loop,
#endif  
  Hal_ProcessEvent,
#if defined( MT_TASK )
  MT_ProcessEvent,
#endif
  APS_event_loop,
#if defined ( ZIGBEE_FRAGMENTATION )
  APSF_ProcessEvent,
#endif
  ZDApp_event_loop,
#if defined ( ZIGBEE_FREQ_AGILITY ) || defined ( ZIGBEE_PANID_CONFLICT )
  ZDNwkMgr_event_loop,
#endif
  //Added to include TouchLink functionality
  #if defined ( INTER_PAN )
    StubAPS_ProcessEvent,
  #endif
  // Added to include TouchLink initiator functionality
  #if defined ( BDB_TL_INITIATOR )
    touchLinkInitiator_event_loop,
  #endif
  // Added to include TouchLink target functionality
  #if defined ( BDB_TL_TARGET )
    touchLinkTarget_event_loop,
  #endif
  zcl_event_loop,
  bdb_event_loop,
  zclDIYRuZRT_event_loop
};

osalInitTasks — стартовая функция приложения, которая регистрирует задачи, реализуемые приложением.

Регистрация задач выполняется по порядку, и каждая задача получает свой собственный номер. Важно соблюсти тот же порядок, что и в массиве tasksArr, т.к. обработчики вызываются в соответствие с номером задачи.

void osalInitTasks( void )
{
  uint8 taskID = 0;

  tasksEvents = (uint16 *)osal_mem_alloc( sizeof( uint16 ) * tasksCnt);
  osal_memset( tasksEvents, 0, (sizeof( uint16 ) * tasksCnt));

  macTaskInit( taskID++ );
  nwk_init( taskID++ );
#if !defined (DISABLE_GREENPOWER_BASIC_PROXY) && (ZG_BUILD_RTR_TYPE)
  gp_Init( taskID++ );
#endif
  Hal_Init( taskID++ );
#if defined( MT_TASK )
  MT_TaskInit( taskID++ );
#endif
  APS_Init( taskID++ );
#if defined ( ZIGBEE_FRAGMENTATION )
  APSF_Init( taskID++ );
#endif
  ZDApp_Init( taskID++ );
#if defined ( ZIGBEE_FREQ_AGILITY ) || defined ( ZIGBEE_PANID_CONFLICT )
  ZDNwkMgr_Init( taskID++ );
#endif
  // Added to include TouchLink functionality
#if defined ( INTER_PAN )
  StubAPS_Init( taskID++ );
#endif
// Added to include TouchLink initiator functionality
#if defined( BDB_TL_INITIATOR )
  touchLinkInitiator_Init( taskID++ );
#endif
// Added to include TouchLink target functionality
#if defined ( BDB_TL_TARGET )
  touchLinkTarget_Init( taskID++ );
#endif
  zcl_Init( taskID++ );
  bdb_Init( taskID++ );
  zclDIYRuZRT_Init( taskID );
}

Наше приложение зарегистрировало функцию обработчик zclDIYRuZRT_event_loop и функцию инициализации zclDIYRuZRT_Init. Они добавлены последними по списку.
Это две основных функций нашего приложения. Реализация этих функций находится в файле zcl_DIYRuZRT.c.

zclDIYRuZRT_Init — функция регистрации задачи.
DIYRuZRT_ENDPOINT — номер эндпоинта, реализуемого нашим приложением.

Последовательно выполняются шаги регистрации, описывающие наше приложение:

  • bdb_RegisterSimpleDescriptor — регистрация описания нашего приложения. Описание представлено переменной SimpleDescriptionFormat_t zclDIYRuZRT_SimpleDesc — структура описывает один эндпоинт, его профиль, характеристики, входящие и исходящие кластеры. Заполнение структур данных находится в файле OSAL_DIYRuZRT_data.c
  • zclGeneral_RegisterCmdCallbacks — регистрация таблицы обработчиков команд эндпоинта zclGeneral_AppCallbacks_t zclDIYRuZRT_CmdCallbacks — это структура, где для каждой команды надо указать обработчик.
  • zcl_registerAttrList — регистрация атрибутов эндпоинта zclAttrRec_t zclDIYRuZRT_Attrs — массив атрибутов, описывающих каждый зарегистрированный выше кластер.
  • zcl_registerForMsg — регистрация получения управляющих сообщений.
  • RegisterForKeys — подписываем нашу задачу на получение событий нажатия кнопок.

/*********************************************************************
 * SIMPLE DESCRIPTOR
 */
// This is the Cluster ID List and should be filled with Application
// specific cluster IDs.
const cId_t zclDIYRuZRT_InClusterList[] =
{
  ZCL_CLUSTER_ID_GEN_BASIC,
  ZCL_CLUSTER_ID_GEN_IDENTIFY,
  
  // DIYRuZRT_TODO: Add application specific Input Clusters Here. 
  //       See zcl.h for Cluster ID definitions
  
};
#define ZCLDIYRuZRT_MAX_INCLUSTERS   (sizeof(zclDIYRuZRT_InClusterList) / sizeof(zclDIYRuZRT_InClusterList[0]))


const cId_t zclDIYRuZRT_OutClusterList[] =
{
  ZCL_CLUSTER_ID_GEN_BASIC,
  
  // DIYRuZRT_TODO: Add application specific Output Clusters Here. 
  //       See zcl.h for Cluster ID definitions
};
#define ZCLDIYRuZRT_MAX_OUTCLUSTERS  (sizeof(zclDIYRuZRT_OutClusterList) / sizeof(zclDIYRuZRT_OutClusterList[0]))


SimpleDescriptionFormat_t zclDIYRuZRT_SimpleDesc =
{
  DIYRuZRT_ENDPOINT,                  //  int Endpoint;
  ZCL_HA_PROFILE_ID,                     //  uint16 AppProfId;
  // DIYRuZRT_TODO: Replace ZCL_HA_DEVICEID_ON_OFF_LIGHT with application specific device ID
  ZCL_HA_DEVICEID_ON_OFF_LIGHT,          //  uint16 AppDeviceId; 
  DIYRuZRT_DEVICE_VERSION,            //  int   AppDevVer:4;
  DIYRuZRT_FLAGS,                     //  int   AppFlags:4;
  ZCLDIYRuZRT_MAX_INCLUSTERS,         //  byte  AppNumInClusters;
  (cId_t *)zclDIYRuZRT_InClusterList, //  byte *pAppInClusterList;
  ZCLDIYRuZRT_MAX_OUTCLUSTERS,        //  byte  AppNumInClusters;
  (cId_t *)zclDIYRuZRT_OutClusterList //  byte *pAppInClusterList;
};


zclDIYRuZRT_event_loop — функция обработчиков событий нашего приложения.

Сперва в цикле обрабатываются системные события:

  • ZCL_INCOMING_MSG — команды управления устройством, обрабатываются в zclDIYRuZRT_ProcessIncomingMsg.
  • KEY_CHANGE — события нажатия кнопок, обрабатываются в zclDIYRuZRT_HandleKeys.
  • ZDO_STATE_CHANGE — события изменения состояния сети.

  if ( events & SYS_EVENT_MSG )
  {
    while ( (MSGpkt = (afIncomingMSGPacket_t *)osal_msg_receive( zclDIYRuZRT_TaskID )) )
    {
      switch ( MSGpkt->hdr.event )
      {
        case ZCL_INCOMING_MSG:
          // Incoming ZCL Foundation command/response messages
          zclDIYRuZRT_ProcessIncomingMsg( (zclIncomingMsg_t *)MSGpkt );
          break;

        case KEY_CHANGE:
          zclDIYRuZRT_HandleKeys( ((keyChange_t *)MSGpkt)->state, ((keyChange_t *)MSGpkt)->keys );
          break;

        case ZDO_STATE_CHANGE:
          zclDIYRuZRT_NwkState = (devStates_t)(MSGpkt->hdr.status);

          // now on the network
          if ( (zclDIYRuZRT_NwkState == DEV_ZB_COORD) ||
               (zclDIYRuZRT_NwkState == DEV_ROUTER)   ||
               (zclDIYRuZRT_NwkState == DEV_END_DEVICE) )
          {
            giGenAppScreenMode = GENERIC_MAINMODE;
            zclDIYRuZRT_LcdDisplayUpdate();
          }
          break;

        default:
          break;
      }

      // Release the memory
      osal_msg_deallocate( (uint8 *)MSGpkt );
    }

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

  if ( events & DIYRuZRT_EVT_1 )
  {
    // toggle LED 2 state, start another timer for 500ms
    HalLedSet ( HAL_LED_2, HAL_LED_MODE_TOGGLE );
    osal_start_timerEx( zclDIYRuZRT_TaskID, DIYRuZRT_EVT_1, 500 );
    
    return ( events ^ DIYRuZRT_EVT_1 );
  }

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

5. HAL: светодиоды и кнопки


«Погодите, какой светодиод и кнопки?», — спросите вы. Изначально, все примеры в Z-stack ориентированы на различного рода отладочные платы серии SmartRF05 EB:



У меня немного другая плата для отладки и модуль с чипом.

На плате есть 2 кнопки (+ ресет) и 3 светодиода (+ индикатор питания). Вот один из них (D2) мигает при корректной работе прошивки.

Прозвонив контакты, определяем соответствие пинов, диодов и кнопок:

  • D1 — P10
  • D2 — P11
  • D3 — P14
  • S2 — P20
  • S1 — P01

Так вот, HAL — это Hardware Abstraction Layer, способ абстрагироваться от реализации оборудования. В коде приложения используются макросы и функции, которые работают с абстракциями типа Кнопка 1 или Светодиод 2, а конкретное соответствие абстракций и оборудования задается отдельно.

Разберемся что за HAL_LED_2 и как понять, на какой пин он подвешен.

Поиском находим файл hal_led.h, где описаны эти константы и функция HalLedSet, куда передается номер светодиода и режим. Внутри вызывается функция HalLedOnOff для включения и выключения светодиода, которая в свою очередь выполняет либо HAL_TURN_ON_LED2 либо HAL_TURN_OFF_LED2.

HAL_TURN_ON_LED2 и HAL_TURN_OFF_LED2 — это макросы, описанные в hal_board_cfg.h. В зависимости от конфигурации оборудования макросы меняются.
В моём случае:

#define HAL_TURN_OFF_LED2()       st( LED2_SBIT = LED2_POLARITY (0); )
#define HAL_TURN_ON_LED2()        st( LED2_SBIT = LED2_POLARITY (1); )

Чуть выше в файле описаны соответствия LED2_SBIT и LED2_POLARITY:

  /* 2 - Red */
  #define LED2_BV           BV(1)
  #define LED2_SBIT         P1_1
  #define LED2_DDR          P1DIR
  #define LED2_POLARITY     ACTIVE_HIGH

Это означает, что светодиод 2 у нас располагается на пине P1_1 и его уровень включения — высокий. Но, судя по коду, светодиод должен был погаснуть при нажатии на кнопку, а у нас он остается гореть. Если в этом файле hal_board_cfg.h поменяем:

#define LED2_POLARITY     ACTIVE_HIGH

на

#define LED2_POLARITY     ACTIVE_LOW

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

Чтобы не менять общие файлы, не относящиеся к нашему приложению, лучше сделать иначе:

  • создадим копию файла hal_board_cfg.h (из папки Z-Stack 3.0.2\Components\hal\target\CC2530EB\) в нашу папку Source и назовём его например hal_board_cfg_DIYRuZRT.h
  • сделаем так, что наша копия файла подключалась самая первая (тем самым исключив подключение общего файла). Создадим в нашей папке Source файл preinclude.h и запишем туда строку:

#include "hal_board_cfg_DIYRuZRT.h"

  • укажем подключение этого файла самым первым — в настройках проекта:

$PROJ_DIR$\..\Source\preinclude.h



Теперь можем менять параметры оборудования в нашем файле hal_board_cfg_DIYRuZRT.h и в файле preinclude.h без необходимости править общие файлы.

В этот же файл preinclude.h я перенес директивы компилятора и удалил их в Options компилятора:

#define SECURE 1
#define TC_LINKKEY_JOIN
#define NV_INIT
#define NV_RESTORE
#define xZTOOL_P1
#define xMT_TASK
#define xMT_APP_FUNC
#define xMT_SYS_FUNC
#define xMT_ZDO_FUNC
#define xMT_ZDO_MGMT
#define xMT_APP_CNF_FUNC
#define LEGACY_LCD_DEBUG
#define LCD_SUPPORTED DEBUG
#define MULTICAST_ENABLED FALSE
#define ZCL_READ
#define ZCL_WRITE
#define ZCL_BASIC
#define ZCL_IDENTIFY
#define ZCL_SCENES
#define ZCL_GROUPS

В том же файле hal_board_cfg_DIYRuZRT.h находим описание кнопки S1 и Joystick Center Press:

/* S1 */
#define PUSH1_BV          BV(1)
#define PUSH1_SBIT        P0_1

/* Joystick Center Press */
#define PUSH2_BV          BV(0)
#define PUSH2_SBIT        P2_0
#define PUSH2_POLARITY    ACTIVE_HIGH

Это соответствует пинам кнопок на плате.

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

/* ----------- Board Initialization ---------- */
#if defined (HAL_BOARD_CC2530EB_REV17) && !defined (HAL_PA_LNA) &&     !defined (HAL_PA_LNA_CC2590) && !defined (HAL_PA_LNA_SE2431L) &&     !defined (HAL_PA_LNA_CC2592)

#define HAL_BOARD_INIT()                                         {                                                                  uint16 i;                                                                                                                         SLEEPCMD &= ~OSC_PD;                       /* turn on 16MHz RC and 32MHz XOSC */                  while (!(SLEEPSTA & XOSC_STB));            /* wait for 32MHz XOSC stable */                       asm("NOP");                                /* chip bug workaround */                              for (i=0; i<504; i++) asm("NOP");          /* Require 63us delay for all revs */                  CLKCONCMD = (CLKCONCMD_32MHZ | OSC_32KHZ); /* Select 32MHz XOSC and the source for 32K clock */   while (CLKCONSTA != (CLKCONCMD_32MHZ | OSC_32KHZ)); /* Wait for the change to be effective */     SLEEPCMD |= OSC_PD;                        /* turn off 16MHz RC */                                                                                                 /* Turn on cache prefetch mode */                                PREFETCH_ENABLE();                                                                                                                HAL_TURN_OFF_LED1();                                             LED1_DDR |= LED1_BV;                                             HAL_TURN_OFF_LED2();                                             LED2_DDR |= LED2_BV;                                             HAL_TURN_OFF_LED3();                                             LED3_DDR |= LED3_BV;                                             HAL_TURN_OFF_LED4();                                             LED4_SET_DIR();                                                                                                                   /* configure tristates */                                        P0INP |= PUSH2_BV;                                             }

Именно в этом макросе происходит инициализация режимов и регистров процессора.
Вместо LED2_DDR и других будет подставлен P1DIR — это регистр порта P1 , отвечающий за режим работы пинов (вход или выход). Соответственно LED2_BV — это установка в значения 1 в бит соответствующего пина (в нашем случае в 1й бит, что соответствует пину P1_1):



Регистры и режимы процессора описаны в документации

«cc253x User's Guide»

Но нигде не видно, как настраиваются кнопки. Кнопки обрабатываются аналогично, но в другом файле — hal_key.c. В нем определены параметры работы кнопок и функции HalKeyInit, HalKeyConfig, HalKeyRead, HalKeyPoll. Эти функции отвечают за инициализацию подсистемы работы с кнопками и считывания значений.

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

6. Настраиваем устройство под себя


Поменяем в файле zcl_DIYRuZRT.h:

  • DIYRuZRT_ENDPOINT на 1

в файле OSAL_DIYRuZRT_data.c:

  • DIYRuZRT_DEVICE_VERSION на 1
  • zclDIYRuZRT_ManufacturerName на { 6, 'D','I','Y','R','u','Z' }
  • zclDIYRuZRT_ModelId на { 9, 'D','I','Y','R','u','Z','_','R','T' }
  • zclDIYRuZRT_DateCode на { 8, '2','0','2','0','0','4','0','5' }

Для того, чтобы устройство могло подключаться к сети на любом канале (по умолчанию только на 11, указан в директиве DEFAULT_CHANLIST в файле Tools\f8wConfig.cfg), надо указать эту возможность в файле preinclude.h изменив значение директивы.
Еще добавим директиву компиляции DISABLE_GREENPOWER_BASIC_PROXY, чтобы для нашего устройства не создавался эндпоинт GREENPOWER.

Также отключим ненужную нам поддержку LCD экрана.

//#define LCD_SUPPORTED DEBUG
#define DISABLE_GREENPOWER_BASIC_PROXY
#define DEFAULT_CHANLIST 0x07FFF800  // ALL Channels

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

bdb_StartCommissioning(BDB_COMMISSIONING_MODE_NWK_STEERING |
                         BDB_COMMISSIONING_MODE_FINDING_BINDING);

После этого выполняем Build, заливаем прошивку в чип и запускаем спаривание на координаторе. Я проверяю работу Zigbee-сети в ioBroker.zigbee, вот так выглядит новое подключенное устройство:



Отлично, получилось подключить устройство!

7. Усложняем работу устройства


Теперь попробуем немного адаптировать функционал:

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

Для настройки собственной обработки кнопок я создал функцию DIYRuZRT_HalKeyInit по аналогии с подобной в модуле hal_key.c, но исключительно для своего набора кнопок.

// Инициализация работы кнопок (входов)
void DIYRuZRT_HalKeyInit( void )
{
  /* Сбрасываем сохраняемое состояние кнопок в 0 */
  halKeySavedKeys = 0;

  PUSH1_SEL &= ~(PUSH1_BV); /* Выставляем функцию пина - GPIO */
  PUSH1_DIR &= ~(PUSH1_BV); /* Выставляем режим пина - Вход */
  
  PUSH1_ICTL &= ~(PUSH1_ICTLBIT); /* Не генерируем прерывания на пине */
  PUSH1_IEN &= ~(PUSH1_IENBIT);   /* Очищаем признак включения прерываний */
  
  PUSH2_SEL &= ~(PUSH2_BV); /* Set pin function to GPIO */
  PUSH2_DIR &= ~(PUSH2_BV); /* Set pin direction to Input */
  
  PUSH2_ICTL &= ~(PUSH2_ICTLBIT); /* don't generate interrupt */
  PUSH2_IEN &= ~(PUSH2_IENBIT);   /* Clear interrupt enable bit */
}

Вызов этой функции добавил в макрос HAL_BOARD_INIT файла hal_board_cfg_DIYRuZRT.h. Чтобы не было конфликта — отключим встроенный hal_key в том же файле hal_board_cfg_DIYRuZRT.h:

#define HAL_KEY FALSE

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

osal_start_reload_timer( zclDIYRuZRT_TaskID, HAL_KEY_EVENT, 100);

А в цикле обработки событий обработаем событие HAL_KEY_EVENT вызвав функцию DIYRuZRT_HalKeyPoll:

// Считывание кнопок
void DIYRuZRT_HalKeyPoll (void)
{
  uint8 keys = 0;

  // нажата кнопка 1 ?
  if (HAL_PUSH_BUTTON1())
  {
    keys |= HAL_KEY_SW_1;
  }
  
  // нажата кнопка 2 ?
  if (HAL_PUSH_BUTTON2())
  {
    keys |= HAL_KEY_SW_2;
  }
  
  if (keys == halKeySavedKeys)
  {
    // Выход - нет изменений
    return;
  }
  // Сохраним текущее состояние кнопок для сравнения в след. раз
  halKeySavedKeys = keys;

  // Вызовем генерацию события изменений кнопок
  OnBoard_SendKeys(keys, HAL_KEY_STATE_NORMAL);
}

Сохранение состояния кнопок в переменной halKeySavedKeys позволяет нам определять момент изменения — нажатия и отжатия кнопок.

При нажатии на кнопку запустим таймер на 5 секунд. Если этот таймер сработает, то сформируется событие DIYRuZRT_EVT_LONG. Если кнопку отпускают, то таймер сбрасывается. В любом случае, если нажимают кнопку — переключаем состояние светодиода.

// Обработчик нажатий клавиш
static void zclDIYRuZRT_HandleKeys( byte shift, byte keys )
{
  if ( keys & HAL_KEY_SW_1 )
  {
    // Запускаем таймер для определения долгого нажатия - 5 сек
    osal_start_timerEx(zclDIYRuZRT_TaskID, DIYRuZRT_EVT_LONG, 5000);
    // Переключаем реле
    updateRelay(RELAY_STATE == 0);
  }
  else
  {
    // Останавливаем таймер ожидания долгого нажатия
    osal_stop_timerEx(zclDIYRuZRT_TaskID, DIYRuZRT_EVT_LONG);
  }
}

Теперь при обработке события долгого нажатия обращаем внимание на текущее состояние сети через атрибут структуры bdbAttributes.bdbNodeIsOnANetwork

  // событие DIYRuZRT_EVT_LONG
  if ( events & DIYRuZRT_EVT_LONG )
  {
    // Проверяем текущее состояние устройства
    // В сети или не в сети?
    if ( bdbAttributes.bdbNodeIsOnANetwork )
    {
      // покидаем сеть
      zclDIYRuZRT_LeaveNetwork();
    }
    else 
    {
      // инициируем вход в сеть
      bdb_StartCommissioning(
        BDB_COMMISSIONING_MODE_NWK_FORMATION | 
        BDB_COMMISSIONING_MODE_NWK_STEERING | 
        BDB_COMMISSIONING_MODE_FINDING_BINDING | 
        BDB_COMMISSIONING_MODE_INITIATOR_TL
      );
      // будем мигать, пока не подключимся
      osal_start_timerEx(zclDIYRuZRT_TaskID, DIYRuZRT_EVT_BLINK, 500);
    }
    
    return ( events ^ DIYRuZRT_EVT_LONG );
  }

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

  // инициализируем NVM для хранения RELAY STATE
  if ( SUCCESS == osal_nv_item_init( NV_DIYRuZRT_RELAY_STATE_ID, 1, &RELAY_STATE ) ) {
    // читаем значение RELAY STATE из памяти
    osal_nv_read( NV_DIYRuZRT_RELAY_STATE_ID, 0, 1, &RELAY_STATE );
  }
  // применяем состояние реле
  applyRelay();

// Изменение состояния реле
void updateRelay ( bool value )
{
  if (value) {
    RELAY_STATE = 1;
  } else {
    RELAY_STATE = 0;
  }
  // сохраняем состояние реле
  osal_nv_write(NV_DIYRuZRT_RELAY_STATE_ID, 0, 1, &RELAY_STATE);
  // Отображаем новое состояние
  applyRelay();
}
  
// Применение состояние реле
void applyRelay ( void )
{
  // если выключено
  if (RELAY_STATE == 0) {
    // то гасим светодиод 1
    HalLedSet ( HAL_LED_1, HAL_LED_MODE_OFF );
  } else {
    // иначе включаем светодиод 1
    HalLedSet ( HAL_LED_1, HAL_LED_MODE_ON );
  }
}

8. Теперь разберемся с Zigbee


С аппаратной частью пока разобрались — кнопкой управляем светодиодом. Теперь реализуем это же через Zigbee.

Для управления реле нам достаточно использовать наш единственный эндпоинт и реализовать кластер GenOnOff. Прочитаем спецификацию Zigbee Cluster Library для кластера GenOnOff:





Достаточно реализовать атрибут OnOff и команды On, Off, Toggle.
Для начала добавим директиву в preinclude.h:

#define ZCL_ON_OFF

В описание наших атрибутов zclDIYRuZRT_Attrs добавляем новые атрибуты кластера:

  // *** Атрибуты On/Off кластера ***
  {
    ZCL_CLUSTER_ID_GEN_ON_OFF,
    { // состояние
      ATTRID_ON_OFF,
      ZCL_DATATYPE_BOOLEAN,
      ACCESS_CONTROL_READ,
      (void *)&RELAY_STATE
    }
  },
  {
    ZCL_CLUSTER_ID_GEN_ON_OFF,
    {  // версия On/Off кластера
      ATTRID_CLUSTER_REVISION,
      ZCL_DATATYPE_UINT16,
      ACCESS_CONTROL_READ | ACCESS_CLIENT,
      (void *)&zclDIYRuZRT_clusterRevision_all
    }
  },

Также добавим кластер в список поддерживаемых входящих кластеров эндпоинта zclDIYRuZRT_InClusterList.

Для реализации команд управления добавим обработчик в таблицу zclDIYRuZRT_CmdCallbacks.

/*********************************************************************
 * Таблица обработчиков основных ZCL команд
 */
static zclGeneral_AppCallbacks_t zclDIYRuZRT_CmdCallbacks =
{
  zclDIYRuZRT_BasicResetCB,               // Basic Cluster Reset command
  NULL,                                   // Identify Trigger Effect command
  zclDIYRuZRT_OnOffCB,                    // On/Off cluster commands
  NULL,                                   // On/Off cluster enhanced command Off with Effect
  NULL,                                   // On/Off cluster enhanced command On with Recall Global Scene
  NULL,                                   // On/Off cluster enhanced command On with Timed Off
#ifdef ZCL_LEVEL_CTRL
  NULL,                                   // Level Control Move to Level command
  NULL,                                   // Level Control Move command
  NULL,                                   // Level Control Step command
  NULL,                                   // Level Control Stop command
#endif

И реализуем его:
// Обработчик команд кластера OnOff
static void zclDIYRuZRT_OnOffCB(uint8 cmd)
{
  // запомним адрес, откуда пришла команда
  // чтобы отправить обратно отчет
  afIncomingMSGPacket_t *pPtr = zcl_getRawAFMsg();
  zclDIYRuZRT_DstAddr.addr.shortAddr = pPtr->srcAddr.addr.shortAddr;
  
  // Включить
  if (cmd == COMMAND_ON) {
    updateRelay(TRUE);
  }
  // Выключить
  else if (cmd == COMMAND_OFF) {
    updateRelay(FALSE);
  }
  // Переключить
  else if (cmd == COMMAND_TOGGLE) {
    updateRelay(RELAY_STATE == 0);
  }
}

Отлично, теперь реле можно переключать командами.





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

Опять же, добавим директиву:

#define ZCL_REPORTING_DEVICE

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

// Информирование о состоянии реле
void zclDIYRuZRT_ReportOnOff(void) {
  const uint8 NUM_ATTRIBUTES = 1;

  zclReportCmd_t *pReportCmd;

  pReportCmd = osal_mem_alloc(sizeof(zclReportCmd_t) +
                              (NUM_ATTRIBUTES * sizeof(zclReport_t)));
  if (pReportCmd != NULL) {
    pReportCmd->numAttr = NUM_ATTRIBUTES;

    pReportCmd->attrList[0].attrID = ATTRID_ON_OFF;
    pReportCmd->attrList[0].dataType = ZCL_DATATYPE_BOOLEAN;
    pReportCmd->attrList[0].attrData = (void *)(&RELAY_STATE);

    zclDIYRuZRT_DstAddr.addrMode = (afAddrMode_t)Addr16Bit;
    zclDIYRuZRT_DstAddr.addr.shortAddr = 0;
    zclDIYRuZRT_DstAddr.endPoint = 1;

    zcl_SendReportCmd(DIYRuZRT_ENDPOINT, &zclDIYRuZRT_DstAddr,
                      ZCL_CLUSTER_ID_GEN_ON_OFF, pReportCmd,
                      ZCL_FRAME_CLIENT_SERVER_DIR, false, SeqNum++);
  }

  osal_mem_free(pReportCmd);
}

Теперь в логах видим сообщения об изменении состояния светодиода.

9. Подключаем датчик температуры ds18b20


Подключается датчик на любой свободный пин (в моем случае поставил P2_1).

Добавляем в приложение код опроса датчика. Опрашивать будем регулярно — раз в минуту.
Сразу при опросе будет оповещать координатор сети о текущем значении.

Прочитаем спецификации ZCL по отправке данных с датчиков температуры. Нам нужен кластер
Temperature Measurement



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

Здесь атрибуты добавляем по аналогии с кластером GenOnOff. Информировать координатор будет по событию DIYRuZRT_REPORTING_EVT, которое запланируем при старте раз в минуту. В обработчике события будем вызывать zclDIYRuZRT_ReportTemp, которая будет считывать температуру датчика и отправлять сообщение.

// Информирование о температуре
void zclDIYRuZRT_ReportTemp( void )
{
  // читаем температуру
  zclDIYRuZRT_MeasuredValue = readTemperature();
  
  const uint8 NUM_ATTRIBUTES = 1;

  zclReportCmd_t *pReportCmd;

  pReportCmd = osal_mem_alloc(sizeof(zclReportCmd_t) +
                              (NUM_ATTRIBUTES * sizeof(zclReport_t)));
  if (pReportCmd != NULL) {
    pReportCmd->numAttr = NUM_ATTRIBUTES;

    pReportCmd->attrList[0].attrID = ATTRID_MS_TEMPERATURE_MEASURED_VALUE;
    pReportCmd->attrList[0].dataType = ZCL_DATATYPE_INT16;
    pReportCmd->attrList[0].attrData = (void *)(&zclDIYRuZRT_MeasuredValue);

    zclDIYRuZRT_DstAddr.addrMode = (afAddrMode_t)Addr16Bit;
    zclDIYRuZRT_DstAddr.addr.shortAddr = 0;
    zclDIYRuZRT_DstAddr.endPoint = 1;

    zcl_SendReportCmd(DIYRuZRT_ENDPOINT, &zclDIYRuZRT_DstAddr,
                      ZCL_CLUSTER_ID_MS_TEMPERATURE_MEASUREMENT, pReportCmd,
                      ZCL_FRAME_CLIENT_SERVER_DIR, false, SeqNum++);
  }

  osal_mem_free(pReportCmd);
}

10. Заливаем прошивку в устройство


Для смены devboard на Sonoff BASICZBR3 необходимо скорректировать соответствие пинов светодиодов и кнопки.



Переделаем светодиод 1 на пин P0_7, чтобы управлять реле. Включение осуществляется высоким уровнем ACTIVE_HIGH. Кнопку S1 перевешиваем на пин P1_3, а информационный светодиод 2 на P1_0. Датчик температуры оставляем на пине P2_1. Все эти изменения делаем в файле hal_board_cfg_DIYRuZRT.h. Для выбора конфигурации сделаем отдельную директиву HAL_SONOFF. Если она задана, то будут использоваться настройки для Sonoff BASICZBR3, иначе для devboard.

#ifdef HAL_SONOFF
  /* 1 - P0_7 Реле */
  #define LED1_BV           BV(7)
  #define LED1_SBIT         P0_7
  #define LED1_DDR          P0DIR
  #define LED1_POLARITY     ACTIVE_HIGH

  /* 2 - P1_0 Синий */
  #define LED2_BV           BV(0)
  #define LED2_SBIT         P1_0
  #define LED2_DDR          P1DIR
  #define LED2_POLARITY     ACTIVE_LOW
#else
  /* 1 - P1_0 Зеленый */
  #define LED1_BV           BV(0)
  #define LED1_SBIT         P1_0
  #define LED1_DDR          P1DIR
  #define LED1_POLARITY     ACTIVE_LOW

  /* 2 - P1_1 Красный */
  #define LED2_BV           BV(1)
  #define LED2_SBIT         P1_1
  #define LED2_DDR          P1DIR
  #define LED2_POLARITY     ACTIVE_LOW
#endif

Еще один важный параметр пришлось поправить — наличие «часового» кварца, т.к. на плате Sonoff BASICZBR3 он не распаян:

//#define HAL_CLOCK_CRYSTAL
  #define OSC32K_CRYSTAL_INSTALLED FALSE

Без этих опций прошивка не стартует (точнее не всегда).

После это собираем прошивку и подключаемся для прошивки.
ВНИМАНИЕ!!! Отключите реле Sonoff BASICZBR3 от сети переменного тока перед любыми действиями с подключением и прошивкой!

Соединяем Sonoff BASICZBR3 проводками с CCDebugger, стираем чип и прошиваем нашу прошивку.



11. Заводим устройство в zigbee2mqtt и ioBroker.Zigbee


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

Чтобы завести новое устройство в ioBroker.Zigbee, нужно выполнить 2 шага:

  1. Добавить описание устройства в пакет zigbee-herdsman-converters. Выполнение этого шага будет достаточно для того, чтобы заставить работать устройство в проекте zigbee2mqtt.
  2. Добавить описание устройства в ioBroker.Zigbee

Все изменения можно сперва выполнить в локальных файлах, а затем сделать PR в соответствующие репозитории.

Находим расположение пакета zigbee-herdsman-converters в установленном ioBroker (или zigbee2mqtt). Внутри пакета находим файл devices.js https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/devices.js

В этом файле хранятся описания всех устройств, с которыми умеет работать ioBroker.zigbee и zigbee2mqtt. Находим в нем блок описаний устройств DIYRuZ (после 2300 строки). Добавляем в этот блок описание нового устройства:

    {
        zigbeeModel: ['DIYRuZ_RT'],
        model: 'DIYRuZ_RT',
        vendor: 'DIYRuZ',
        description: '',
        supports: 'on/off, temperature',
        fromZigbee: [fz.on_off, fz.temperature],
        toZigbee: [tz.on_off],
    },

В атрибут fromZigbee мы указываем конвертеры, которые будут обрабатывать сообщения, приходящие от устройства. Наши два сообщения стандартизованы. Конвертер fz.on_off обрабатывает сообщение о включении/выключении, а fz.temperature — данные о температуре. В коде этих конвертеров (располагаются в файле converters/fromZigbee.js) видно, как обрабатываются входящие сообщения и что температура делится на 100.

   on_off: {
        cluster: 'genOnOff',
        type: ['attributeReport', 'readResponse'],
        convert: (model, msg, publish, options, meta) => {
            if (msg.data.hasOwnProperty('onOff')) {
                const property = getProperty('state', msg, model);
                return {[property]: msg.data['onOff'] === 1 ? 'ON' : 'OFF'};
            }
        },
    },

 temperature: {
        cluster: 'msTemperatureMeasurement',
        type: ['attributeReport', 'readResponse'],
        convert: (model, msg, publish, options, meta) => {
            const temperature = parseFloat(msg.data['measuredValue']) / 100.0;
            return {temperature: calibrateAndPrecisionRoundOptions(temperature, options, 'temperature')};
        },
    },

В атрибут toZigbee указываем конвертеры, которые будут обрабатывать наши команды устройству. В нашем случае это конвертер tz.on_off для переключения реле.

Всё, в «конвертеры» добавили. Кто пользуется zigbee2mqtt — можете уже пользоваться.

А пользователи ioBroker еще добавляют описание устройства в файл ioBroker.zigbee\lib\devices.js

    {
        vendor: 'DIYRuZ',
        models: ['DIYRuZ_RT'],
        icon: 'img/DIYRuZ.png',
        states: [
            states.state,
            states.temperature,
        ],
    },

Здесь достаточно указать точно такую же модель, файл с картинкой и перечень состояний. В нашем случае состояния также стандартные: state для состояния реле, temperature для отображения значений температуры.



12. Что дальше?


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

  • Я не смог быстро найти решения по возможности подключения дочерних устройств через реле. Другие устройства-роутеры могут выполнять команду “permit_join” и подключать новые устройства через себя, без необходимости подносить новое устройство к координатору. Устройство представляется роутером, корректно отображается на карте сети, но выполнять команду “permit_join”, отказывается. Точнее, команду выполняет, но устройства не подключаются через него.
  • Также, не реализовал корректный reporting. Это способ настройки уведомления о состоянии, когда можно командой configReport указать перечень атрибутов для отправки и частоту уведомления.
  • Работа с группами.
  • Разобраться с прерываниями и реализовать опрос кнопки через прерывания.

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

Хочу выразить благодарность за поддержку и помощь в разработке своим коллегам по Телеграм-чату и Zigbee сообществу:


Ссылки


Основная документация включена в SDK Z-Stack 3.0.2 и устанавливается вместе с ним. Но часть ссылок приведу тут: