Всем привет!

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

Эта статья является продолжением серии статей (раз, два, три, четыре) про постройку своего умного Zigbee выключателя. Сегодня будем обучать наш умный выключатель всяким длинным и двойным нажатиям. Но в нашем случае мы сделаем еще и возможность тонкой подстройки режимов работы, да еще и в рантайме, средствами Zigbee. Для этого придется написать свой кастомный кластер (точнее расточить кластер On/Off Switch Configuration), изучить кластер Multistate Input, и еще обучить этому всему zigbee2mqtt.

Поехали!

On/Off Switch Configuration Cluster

Разбираясь с темой клиентских и серверных кластеров для прошлой статьи я наткнулся на интересную картинку в Zigbee Class Library Specification.

Во-первых оно само по себе забавно, что устройство может одновременно быть сервером для одних данных и клиентом для других. А во-вторых тут упоминается некий On/Off Switch Configuration Cluster - стандартный кластер для настройки выключателя (который, как мы помним, On/Off клиент). В этом разделе я бы хотел его немного пощупать. Изначально у меня небыло никакой конкретной цели по использованию этого кластера. Но иногда идеи вырисовываются из тех или иных возможностей технологии. Так и было в этот раз.

Итак. On/Off Switch Configuration Cluster позволяет другим устройствам (или координатору) настраивать поведение нашей кнопки, и в частности задавать следующие 2 параметра:

1) Тип переключения определяет поведение кнопки.

  • Toggle - каждое нажатие на кнопку переключает состояние реле. Кнопка остается в таком же состоянии до следующего нажатия. Такой режим напоминает работу обычного выключателя-переключателя.

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

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

2) Тип действия определяет какие именно команды будут отсылаться при срабатывании выключателя

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

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

  • Toggle - будет отправлять команду “переключить” каждый раз

Попробуем это реализовать. Для начала нужно добавить On/Off Switch Configuration Cluster в программе ZPS Configuration Editor. Но просто так взять и добавить кластер у меня не получилось - нужно было сначала объявить в секции Home Automation Profile что у нас такой кластер в принципе возможен.

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

Теперь нужно определенными дефайнами включить реализацию серверного On Off Switch Configuration кластера в zcl_options.h. Сами дефайны я подсмотрел в реализации этого кластера в файлах OOSC.h/c

#define CLD_OOSC
#define OOSC_SERVER

Далее нужно зарегистрировать наш кластер в конечной точке (тут идем по аналогии с предыдущей статьей)

struct OnOffClusterInstances
{
...
   tsZCL_ClusterInstance sOnOffConfigServer;
...


class SwitchEndpoint: public Endpoint
{   
...
   tsCLD_OOSC sOnOffConfigServerCluster;
...


void SwitchEndpoint::registerOnOffConfigServerCluster()
{
   // Initialize On/Off config server cluser
   teZCL_Status status = eCLD_OOSCCreateOnOffSwitchConfig(&sClusterInstance.sOnOffConfigServer,
                                                          TRUE,                              // Server
                                                          &sCLD_OOSC,
                                                          &sOnOffConfigServerCluster,
                                                          &au8OOSCAttributeControlBits[0]);
   if( status != E_ZCL_SUCCESS)
       DBG_vPrintf(TRUE, "SwitchEndpoint::init(): Failed to create OnOff switch config server cluster instance. status=%d\n", status);
}

Теперь наше устройство утверждает, что умеет genOnOffSwitchCfg кластер. 

Вот только никаких дополнительных выключателей в закладке Exposes в zigbee2mqtt не появилось. Дело в том, что по умолчанию zigbee2mqtt не знает как правильно отображать эту закладку. За наполнение закладки Exposes отвечает специальный код в файле myswitch.js, который мы писали пару статей назад, который в свою очередь базируется на строительных блоках zigbee2mqtt, zigbee-herdsman, и zigbee-herdsman-converters. Последние два проекта обеспечивают конвертацию низкоуровневых сообщений zigbee в json объекты и назад, тогда как zigbee2mqtt взаимодействует с пользователем. Там же описаны множество поддерживаемых устройств

Я потратил некоторое время на изучение кода этих проектов в контексте реализации On/Off Switch Configuration, и к своему удивлению нашел только 2 куска кода, который делали настройку выключателей.

  • Взаимодействие с выключателями Xiaomi Opple - тоже настраивает клавиши выключателя, переключая режимы toggle и momentary. К сожалению эта реализация базируется на кастомном кластере, а потому в контексте изучения стандарта Zigbee будет неинтересна

  • Взаимодействие с пультами modkam.ru и некоторых производных. Эта реализация использует стандартный кластер genOnOffSwitchCfg, а значит будем использовать примитивы diyruz_freepad_config и diyruz_freepad_on_off_config. 

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

const fz = require('zigbee-herdsman-converters/converters/fromZigbee');
const tz = require('zigbee-herdsman-converters/converters/toZigbee');
const exposes = require('zigbee-herdsman-converters/lib/exposes');
const reporting = require('zigbee-herdsman-converters/lib/reporting');
const e = exposes.presets;
const ea = exposes.access;

const device = {
    zigbeeModel: ['Hello Zigbee Switch'],
    model: 'Hello Zigbee Switch',
    vendor: 'NXP',
    description: 'Hello Zigbee Switch',
    fromZigbee: [fz.on_off, fz.diyruz_freepad_config],
    toZigbee: [tz.on_off, tz.diyruz_freepad_on_off_config],
    exposes: [e.switch().withEndpoint('button_1'),
              exposes.enum('switch_type', ea.ALL, ['toggle', 'momentary', 'multifunction']).withEndpoint('button_1'),
              exposes.enum('switch_actions', ea.ALL, ['on', 'off', 'toggle']).withEndpoint('button_1')
             ],
    endpoint: (device) => {
        return {button_1: 2};
    },
};

module.exports = device;

На дашборде zigbee2mqtt это выглядит так

Попробуем переключить switch_type.... и в логах z2m видим

То есть как это READ_ONLY? А что в сниффере?

Хм... Устройство действительно возвращает статус read only. Но это, вероятно, какая-то ошибка где-нибудь в коде..... но нет, самый что ни на есть первоисточник гласит, что этот атрибут действительно только для чтения.

Второй атрибут, Switch Action, кстати, очень даже read-write. Запрос на запись такого атрибута срабатывает без ошибки. Реализации со стороны устройства, понятное дело, еще нету, но сам переключатель в z2m работает правильно.

Но что же делать с Switch Type? Почему он только для чтения? Оказывается все дело в том, что разработчики zigbee решили, что этот атрибут определяется физической конструкцией самого выключателя: если это реальный переключатель с двумя устойчивыми положениями, то в этом атрибуте лежит значение ‘toggle’. А если это звонковая кнопка, то это ‘momentary’. Клиенту дается возможность лишь узнать какого типа этот выключатель, но поменять этот параметр извне нельзя.

К этому моменту я уже начал придумывать различные возможности, которые я бы хотел реализовать в своем выключателе. Поле switch actions вроде бы можно менять, но параметры, которые предлагает спецификация как-то не очень натягиваются на поведение, которое я хочу реализовать. В общем я осознал, что мне тесновато в стандартном кластере OnOff Switch Configuration и нужно реализовывать свой кластер.

Постановка задачи

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

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

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

  • Фабричных устройств, которые работают как звонковые кнопки (во всяком случае в формфакторе обычного выключателя) я вообще не нашел.

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

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

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

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

  • Switch Mode - Режим работы кнопки. По сути аналог стандартного поля Switch Type, только read-write. Возможные значения

    • Toggle - срабатывание кнопки генерирует одну команду TOGGLE, которое переключает значение реле. Выключатель срабатывает по нажатию кнопки (по фронту). Двойные/тройные/длинные нажатия не отрабатываются.

    • Momentary - “звонковая” кнопка, при нажатии генерирует команду ON, при отпускании OFF (или наоборот - см. поле Action)

    • Multifunction - “умный” режим с поддержкой длинных/двойных/тройных нажатий. Какое именно событие произошло определяется по отпусканию кнопки.

  • Action - Прямой аналог стандартного поля Switch Action. Применимо только к режиму Momentary. Возможные значения

    • OnOff - при включении посылает команду On, при выключении Off

    • OffOn - при включении посылает команду Off, при выключении On

    • Toggle - при каждом срабатывании посылает команду Toggle

  • Relay Mode - режим работы встроенного реле.

    • Unlinked - реле работает независимо от кнопки. Кнопка является просто логической кнопкой, отправляя команды в сеть. Реле может независимо от кнопки принимать команды On/Off/Toggle от других устройств.

    • Front - срабатывание по переднему фронту (по нажатию) кнопки, независимо от длинных/двойных/тройных/комбо нажатий.

    • Single, Double, Triple, Long - Реле срабатывает по одинарному, двойному, тройному, или длинному нажатию соответственно. Все остальные виды нажатий генерируют лишь логический сигнал в сеть, реле при этом не срабатывает.

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

  • Min Long Press - длительность нажатия, чтобы оно считалось длинным. Значение в мс.

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

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

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

    • SwitchMode=Toggle

    • RelayMode=Unlinked (для управления внешним реле) или RelayMode=Front (для управления встроенным реле)

  • “Звонковая кнопка” - может использоваться для кратковременного срабатывания некоего исполнительного механизма. Например мотор штор/ролетов крутится, пока пользователь держит кнопку

    • SwitchMode=Momentary

    • Action (полярность) - в зависимости от конфигурации исполнительного механизма

    • RelayMode=Unlinked (для управления внешним реле) или RelayMode=Front (для управления встроенным реле)

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

    • SwitchMode=Multifunction

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

    • MaxPause=200ms (подбирается экспериментальным путем, чтобы пользователю было комфортно “даблкликать”)

    • MinLongPress=1000ms

Последний режим также может быть использован для управления шторами. Например короткое нажатие дает команду на полное открытие/закрытие штор. Если пользователь нажал клавишу и удерживает, то через MinLongPress миллисекунд отправляется команда ButtonPressed, а при отпускании ButtonReleased. В системе умного дома на эти события можно настроить запуск и остановку мотора штор, и таким образом организовать неполное открытие.

Расширяем On/Off Switch Configuration кластер

Давайте посмотрим что предлагает нам Zigbee Cluster Library спецификация в плане расширения стандартного функционала

Добавление новых команд для нашей задачи не нужно. Можно либо расточить готовый кластер, либо написать свой. Добавить несколько атрибутов в готовый кластер выглядит более предпочтительно, тем более что моя реализация будет лишь расширять, а не заменять стандартный функционал. Реализация кластера OnOff Switch Configuration находится в файлах OOSC.c/.h и в ней все просто. Этот кластер не принимает и не отправляет никакие команды - тут реализована только работа с атрибутами. 

Документ ZigBee Cluster Library (for ZigBee 3.0) User Guide JN-UG-3115 предлагает менять файлы прямо в SDK. Но мы, пожалуй, поступим более аккуратно, и скопируем эти файлы к себе в рабочую директорию. Разумеется, список Include path должен включать путь на рабочую директорию раньше чем на директории из SDK.

Теперь можно объявить идентификаторы для новых атрибутов.

typedef enum
{
   E_CLD_OOSC_ATTR_ID_SWITCH_TYPE              = 0x0000,   /* Mandatory */
   E_CLD_OOSC_ATTR_ID_SWITCH_ACTIONS           = 0x0010,   /* Mandatory */

   // Custom attributes
   E_CLD_OOSC_ATTR_ID_SWITCH_MODE              = 0xff00,
   E_CLD_OOSC_ATTR_ID_SWITCH_RELAY_MODE        = 0xff01,
   E_CLD_OOSC_ATTR_ID_SWITCH_MAX_PAUSE         = 0xff02,
   E_CLD_OOSC_ATTR_ID_SWITCH_LONG_PRESS_DUR    = 0xff03
} teCLD_OOSC_ClusterID;

Первые 2 атрибута стандартны и диктуются спецификацией. Новые атрибуты я добавил с идентификаторами 0xFFxx, хотя вроде бы спецификация не регламентирует номера для атрибутов, лишь бы они не пересекались с существующими. 

Я добавил атрибут MODE, несмотря на то, что он дублирует атрибут TYPE. Я просто решил не отходить от спецификации - пусть атрибут TYPE останется readonly, а MODE будет read-write, несмотря на то, что они фактически будут указывать на одну и ту же переменную (а значит значения будут синхронизированы).

Перечисления для атрибутов Switch Mode (он же Switch Type) и Actions уже есть в файле OOSC.h. Нужно добавить перечисления для Relay Mode.

typedef enum
{
   RELAY_MODE_UNLINKED,
   RELAY_MODE_FRONT,
   RELAY_MODE_SINGLE,
   RELAY_MODE_DOUBLE,
   RELAY_MODE_TRIPPLE,
   RELAY_MODE_LONG,
} RelayMode;

Теперь переменные, где будут хранится наши атрибуты (я добавил 3 поля в существующую структуру tsCLD_OOSC). Для числовых атрибутов думаю 16 бит должно хватить.

/* On/Off Switch Configuration Cluster */
typedef struct
{
#ifdef OOSC_SERVER   
   zenum8                  eSwitchMode;                /* Mandatory */
   zenum8                  eSwitchActions;             /* Mandatory */

   // Custom attrs
   zenum8                  eRelayMode;
   zuint16                 iMaxPause;
   zuint16                 iMinLongPress;
#endif   
   zuint16                 u16ClusterRevision;

} tsCLD_OOSC;

Рассмотрим таблицу (список) атрибутов, их флагов и указателей на переменные-хранилища значений. Все новые атрибуты объявлены с флагами E_ZCL_AF_RD|E_ZCL_AF_WR (read/write) и E_ZCL_AF_MS (manufacturer specific). Последний флаг отключает репортинг таких нестандартных атрибутов, чтобы устройства, которые не знают о наших доработках стандартного кластера не пугались. Правда некоторые производители не парятся и добавляют свои атрибуты без флага manufacturer specific, но мы будем делать правильно.

const tsZCL_AttributeDefinition asCLD_OOSCClusterAttributeDefinitions[] = {
#ifdef OOSC_SERVER
   {E_CLD_OOSC_ATTR_ID_SWITCH_TYPE,            E_ZCL_AF_RD,                            E_ZCL_ENUM8,    (uint32)(&((tsCLD_OOSC*)(0))->eSwitchMode),0},    /* Mandatory */
   {E_CLD_OOSC_ATTR_ID_SWITCH_ACTIONS,         (E_ZCL_AF_RD|E_ZCL_AF_WR),              E_ZCL_ENUM8,    (uint32)(&((tsCLD_OOSC*)(0))->eSwitchActions),0}, /* Mandatory */

   // Custom attributes
   {E_CLD_OOSC_ATTR_ID_SWITCH_MODE,            (E_ZCL_AF_RD|E_ZCL_AF_WR|E_ZCL_AF_MS),  E_ZCL_ENUM8,    (uint32)(&((tsCLD_OOSC*)(0))->eSwitchMode),0},
   {E_CLD_OOSC_ATTR_ID_SWITCH_RELAY_MODE,      (E_ZCL_AF_RD|E_ZCL_AF_WR|E_ZCL_AF_MS),  E_ZCL_ENUM8,    (uint32)(&((tsCLD_OOSC*)(0))->eRelayMode),0},
   {E_CLD_OOSC_ATTR_ID_SWITCH_MAX_PAUSE,       (E_ZCL_AF_RD|E_ZCL_AF_WR|E_ZCL_AF_MS),  E_ZCL_UINT16,   (uint32)(&((tsCLD_OOSC*)(0))->iMaxPause),0},
   {E_CLD_OOSC_ATTR_ID_SWITCH_LONG_PRESS_DUR,  (E_ZCL_AF_RD|E_ZCL_AF_WR|E_ZCL_AF_MS),  E_ZCL_UINT16,   (uint32)(&((tsCLD_OOSC*)(0))->iMinLongPress),0},

#endif       
   {E_CLD_GLOBAL_ATTR_ID_CLUSTER_REVISION,     (E_ZCL_AF_RD|E_ZCL_AF_GA),              E_ZCL_UINT16,   (uint32)(&((tsCLD_OOSC*)(0))->u16ClusterRevision),0},   /* Mandatory  */
};

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

PUBLIC  teZCL_Status eCLD_OOSCCreateOnOffSwitchConfig(
               tsZCL_ClusterInstance              *psClusterInstance,
               bool_t                              bIsServer,
               tsZCL_ClusterDefinition            *psClusterDefinition,
               void                               *pvEndPointSharedStructPtr,
               uint8                              *pu8AttributeControlBits)
{
...
       if(pvEndPointSharedStructPtr != NULL)
       {
#ifdef OOSC_SERVER           
           /* Set attribute defaults */
           ((tsCLD_OOSC*)psClusterInstance->pvEndPointSharedStructPtr)->eSwitchMode = E_CLD_OOSC_TYPE_TOGGLE;
           ((tsCLD_OOSC*)psClusterInstance->pvEndPointSharedStructPtr)->eSwitchActions = E_CLD_OOSC_ACTION_TOGGLE;
           ((tsCLD_OOSC*)psClusterInstance->pvEndPointSharedStructPtr)->eRelayMode = RELAY_MODE_FRONT;
           ((tsCLD_OOSC*)psClusterInstance->pvEndPointSharedStructPtr)->iMaxPause = 250;
           ((tsCLD_OOSC*)psClusterInstance->pvEndPointSharedStructPtr)->iMinLongPress = 1000;
#endif
           ((tsCLD_OOSC*)psClusterInstance->pvEndPointSharedStructPtr)->u16ClusterRevision = CLD_OOSC_CLUSTER_REVISION;
...
}

На этом добавление атрибутов в кластер можно считать завершенным. Можно даже создать свой новый кластер просто придумав ему новый идентификатор (но мы этого делать не будем). Нужно только не забыть также подправить все места использования этого идентификатора. Номера manufacturer-specific кластеров должны начинаться с 0xfc00. Вот пример как это можно было бы сделать.

//#define GENERAL_CLUSTER_ID_ONOFF_SWITCH_CONFIGURATION   0x0007
#define GENERAL_CLUSTER_ID_MY_ONOFF_SWITCH_CONFIGURATION   0xfc01

Поддержка в zigbee2mqtt

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

Правда есть одна проблема - вся эта затея документирована весьма слабо, а описание что именно должны делать функции я вообще не нашел. Есть несколько невнятных примеров, причем наиболее релевантный написан так что... в общем, к нему есть ряд вопросов. Пришлось писать свой конвертер активно используя метод тыка, и перебирая десятки существующих конвертеров, чтобы разобраться что к чему. Более того, написанный код не работал как надо на версии z2m 1.20, и я потратил почти месяц в попытках понять что не так. Видимо к версии 1.21 что-то внутри починилось и мой код заработал правильно. В общем и целом, во время написания своего конвертера меня не покидало стойкое ощущение сырости технологии :)

Я не претендую на звание мастера в написании конвертеров, но кое что рассказать про это могу.

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

const fz = require('zigbee-herdsman-converters/converters/fromZigbee');
const tz = require('zigbee-herdsman-converters/converters/toZigbee');
const exposes = require('zigbee-herdsman-converters/lib/exposes');
const reporting = require('zigbee-herdsman-converters/lib/reporting');
const e = exposes.presets;
const ea = exposes.access;

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

const DataType = {
    uint16: 0x21,
    enum8: 0x30,
}

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

const switchTypeValues = ['toggle', 'momentary', 'multifunction'];
const switchActionValues = ['onOff', 'offOn', 'toggle'];
const relayModeValues = ['unlinked', 'front', 'single', 'double', 'tripple', 'long'];

Обращаясь к manufacturer-specific атрибутам нужно обязательно указывать manufacturer code, иначе работать не будет - код ZCL ищет атрибут не только по номеру, но и по manufacturer code.

const manufacturerOptions = {
    jennic : {manufacturerCode: 0x1037}
}

Теперь нужно зарегистрировать соответствующие поля на веб форме z2m. Эти поля будут отображаться закладке Exposes для нашего устройства.

function genSwitchEndpoint(epName) {
   return [
       e.switch().withEndpoint(epName),
       exposes.enum('switch_mode', ea.ALL, switchModeValues).withEndpoint(epName),
       exposes.enum('switch_actions', ea.ALL, switchActionValues).withEndpoint(epName),
       exposes.enum('relay_mode', ea.ALL, relayModeValues).withEndpoint(epName),
       exposes.numeric('max_pause', ea.ALL).withEndpoint(epName),
       exposes.numeric('min_long_press', ea.ALL).withEndpoint(epName),
   ]
}

function genSwitchEndpoints(endpoinsCount) {
   let features = [];

   for (let i = 1; i <= endpoinsCount; i++) {
       const epName = `button_${i}`;
       features.push(...genSwitchEndpoint(epName));
   }

   return features;
}

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

Поля с 2-3 значениями превратились в кнопки, там где значений больше (relay_mode) получился выпадающий список. Два последних цифровых поля являются полями ввода со стрелочками инкремента/декремента.

Теперь в это нужно вдохнуть жизнь, и написать 3 функции конвертации. Эти 3 функции должны быть организованные в 2 структуры под кодовыми названиями toZigbee и fromZigbee. Что именно это означает я поясню ниже.

Попробуем нажать кнопку на форме и отправить новое значение в устройство. Для этого используется функция структура toZigbee c функцией convertSet(). Также поле key задает список ключей, которые наш конвертер умеет обрабатывать.

const toZigbee_OnOffSwitchCfg = {
   key: ['switch_mode', 'switch_actions', 'relay_mode', 'max_pause', 'min_long_press'],

   convertSet: async (entity, key, value, meta) => {
       let payload = {};
       let newValue = value;

       switch(key) {
           case 'switch_mode':
               newValue = switchModeValues.indexOf(value);
               payload = {65280: {'value': newValue, 'type': DataType.enum8}};
               await entity.write('genOnOffSwitchCfg', payload, manufacturerOptions.jennic);
               break;

           case 'switch_actions':
               newValue = switchActionValues.indexOf(value);
               payload = {switchActions: newValue};
               await entity.write('genOnOffSwitchCfg', payload);
               break;

           case 'relay_mode':
               newValue = relayModeValues.indexOf(value);
               payload = {65281: {'value': newValue, 'type': DataType.enum8}};
               await entity.write('genOnOffSwitchCfg', payload, manufacturerOptions.jennic);
               break;

           case 'max_pause':
               payload = {65282: {'value': value, 'type': DataType.uint16}};
               await entity.write('genOnOffSwitchCfg', payload, manufacturerOptions.jennic);
               break;

           case 'min_long_press':
               payload = {65283: {'value': value, 'type': DataType.uint16}};
               await entity.write('genOnOffSwitchCfg', payload, manufacturerOptions.jennic);
               break;

           default:
               break;
       }

       result = {state: {[key]: value}}
       return result;
   },

Функция toZigbee_OnOffSwitchCfg::convertSet() принимает текстовые названия атрибутов и значений для этих атрибутов. Задача функции преобразовать это в структуры, которые можно будет переслать по сети. Для стандартных атрибутов (например switch_actions) это будет всего лишь пара ключ-значение атрибута, только уже в цифрах. Код herdsman converters знает что это за атрибут, какого он типа, и как его передать в сеть. Для нестандартных атрибутов (например relay_mode) нам понадобится конвертация в тройки

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

  • тип атрибута (идентификатор типа)

  • цифровое значение

Далее эти значения упаковываются в низкоуровневое сообщение Zigbee и отправляются по сети: entity представляет собой сущность конечной точки, к которой можно применять команды на чтение и запись атрибутов. Для нестандартных атрибутов нужно обязательно указать, что это manufacturer-specific атрибут (с помощью manufacturerOptions.jennic).

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

На уровне zigbee отправка нового значения атрибута будет выглядеть как-то так.

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

ZPS_EVENT_APS_DATA_INDICATION: SrcEP=1 DstEP=2 SrcAddr=0000 Cluster=0007 () Status=0
ZCL Write Attribute: Clustter 0007 Attrib ff00
ZCL Endpoint Callback: Write attributes completed
ZPS_EVENT_APS_DATA_CONFIRM: SrcEP=2 DstEP=1 DstAddr=0000 Status=0
ZPS_EVENT_APS_DATA_ACK: SrcEP=1 DrcEP=2 DstAddr=0000 Profile=0104 Cluster=0007 ()

С чтением оказалось сложнее. По умолчанию UI zigbee2mqtt не отображает текущие значения атрибутов. Если нажать на кнопку “обновить” напротив одного из атрибутов, то вызовется функция toZigbeeConverter::convertGet(). 

Поначалу меня очень сбивало название: toZigbee :: converterGet. Почему не from zigbee? Детально изучая код и значения которые принимает эта функция стало понятнее: функция занимается формированием запроса на чтение атрибута. Т.е. toZigbee отвечает за то как zigbee2mqtt обращается К сети (даже если это запрос на чтение). Чуть позже устройство ответит сообщением read attribute response и пришлет прочитанное значение атрибута. Соответственно модуль fromZigbee занимается разбором того, что прилетело ИЗ сети (но об этом чуть ниже).

   convertGet: async (entity, key, meta) => {
       if(key == 'switch_actions') {
           await entity.read('genOnOffSwitchCfg', ['switchActions']);
       }
       else {
           const lookup = {
               switch_mode: 65280,
               relay_mode: 65281,
               max_pause: 65282,
               min_long_press: 65283
           };
           await entity.read('genOnOffSwitchCfg', [lookup[key]], manufacturerOptions.jennic);
       }
   },

Большинство реализаций добывают значения всех атрибутов сразу одним большим запросом - спецификация zigbee так позволяет. К сожалению из-за того в нашем кластере присутствуют как стандартные атрибуты ('switchActions'), так и нестандартные, одним большим запросом достать их не получается. Я остановился на схеме один атрибут - один запрос. Конечно же для нестандартных атрибутов нужно указывать manufacturer code.

В сеть запрос улетает так

А устройство отвечает так.

После того как координатор принял пакет от устройства начинается парсинг значений. Этим занимается структура fromZigbee.

const getKey = (object, value) => {
    for (const key in object) {
        if (object[key] == value) return key;
    }
};

const fromZigbee_OnOffSwitchCfg = {
   cluster: 'genOnOffSwitchCfg',
   type: ['attributeReport', 'readResponse'],

   convert: (model, msg, publish, options, meta) => {

       const ep_name = getKey(model.endpoint(msg.device), msg.endpoint.ID);
       const result = {};

       // switch type
       if(msg.data.hasOwnProperty('65280')) {
           result[`switch_mode_${ep_name}`] = switchModeValues[msg.data['65280']];
       }

       // switch action
       if(msg.data.hasOwnProperty('switchActions')) { // use standard 'switchActions' attribute identifier
           result[`switch_actions_${ep_name}`] = switchActionValues[msg.data['switchActions']];
       }

       // relay mode
       if(msg.data.hasOwnProperty('65281')) {
           result[`relay_mode_${ep_name}`] = relayModeValues[msg.data['65281']];
       }


       // Maximum pause between button clicks to be treates a single multiclick
       if(msg.data.hasOwnProperty('65282')) {
           result[`max_pause_${ep_name}`] = msg.data['65282'];
       }

       // Minimal duration for the long press
       if(msg.data.hasOwnProperty('65283')) {
           result[`min_long_press_${ep_name}`] = msg.data['65283'];
       }

       return result;
   },
}

Функция пытается выковырять из посылки значения соответствующих атрибутов и разложить их в структуру Z2M под именами <имя_атрибута>_<имя_конечной_точки>.

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

const device = {
    zigbeeModel: ['Hello Zigbee Switch'],
    model: 'Hello Zigbee Switch',
    vendor: 'NXP',
    description: 'Hello Zigbee Switch',
    fromZigbee: [fz.on_off, fromZigbee_OnOffSwitchCfg],
    toZigbee: [tz.on_off, toZigbee_OnOffSwitchCfg],
    exposes: genEndpoints(2),
    configure: async (device, coordinatorEndpoint, logger) => {
        device.endpoints.forEach(async (ep) => {
            await ep.read('genOnOff', ['onOff']);
            await ep.read('genOnOffSwitchCfg', ['switchActions']);
            await ep.read('genOnOffSwitchCfg', [65280, 65281, 65282, 65283], manufacturerOptions.jennic);
        });
    },
    endpoint: (device) => {
        return {button_1: 2, button_2: 3};
    },
};

module.exports = device;

Этот код регистрирует новое устройство, а также конвертеры к нему. В качестве конвертеров тут используются стандартные конвертеры для выключателя (fz/tz.on_off), и дополнительно к ним наши собственные конвертеры. 

И хотя мое устройство к этому моменту реализует только одну конечную точку выключателя, в этом коде я поэкспериментировал как бы я добавил вторую. Разумеется, устройство пока не реагирует на параметры второго выключателя. Да, если вы помните, то на конечной точке №1 у меня живет Basic Cluster, а выключатели начинаются с конечной точки №2 (а второй выключатель будет жить на конечной точке №3). Поэтому пришлось сделать вот такую-вот перенумерацию в поле endpoint.

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

Как же этот код подключить к Z2M? Все просто: в configuration.yaml нужно прописать соответствующую секцию

external_converters:
  - myswitch.js

Z2M спросит у устройства как его зовут, и найдет нужный конвертер основываясь на имени устройства.

Реализация режимов выключателя

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

  • Класс SwitchEndpoint как и раньше реализует конечную точку сети Zigbee. Тут живет реализация кластеров On/Off, On/Off Switch Configuration (и, забегая немного вперед, кластер Multistate Input)

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

  • Класс ButtonTask ближе к железу. Каждые 10мс опрашивает он опрашивает все кнопки и информирует зарегистрированные ButtonHandler’ы. 

Начнем с класса ButtonTask. У него, по сути, 4 задачи:

  • Хранить список обработчиков кнопок

  • Опрашивать железные кнопки, и вызывать соответствующие обработчики

  • Обрабатывать очень длинное нажатие кнопки (>5 секунд), чтобы запускать присоединение/отсоединение к/от сети.

  • Считать время неактивности, чтобы разрешить устройству заснуть

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

static const uint32 ButtonPollCycle = 10;

class IButtonHandler
{
public:
	// Executed by ButtonsTask every ButtonPollCycle ms for every handler
	virtual void handleButtonState(bool pressed) = 0;
	virtual void resetButtonStateMachine() = 0;
};


struct HandlerRecord
{
   uint32 pinMask;
   IButtonHandler * handler;
};

class ButtonsTask : public PeriodicTask
{
...
   HandlerRecord handlers[ZCL_NUMBER_OF_ENDPOINTS+1];
   uint8 numHandlers;
   uint32 buttonsMask;

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

void ButtonsTask::registerHandler(uint32 pinMask, IButtonHandler * handler)
{
   // Store the handler pointer
   handlers[numHandlers].pinMask = pinMask;
   handlers[numHandlers].handler = handler;
   numHandlers++;

   // Update the pin mask for all buttons
   buttonsMask |= pinMask;

   // Set up GPIO for the button
   vAHI_DioSetDirection(pinMask, 0);
   vAHI_DioSetPullup(pinMask, 0);
   vAHI_DioInterruptEdge(0, pinMask);
   vAHI_DioWakeEnable(pinMask, 0);
}

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

void ButtonsTask::timerCallback()
{
   uint32 input = u32AHI_DioReadInput();
   bool someButtonPressed = false;

   for(uint8 h = 0; h < numHandlers; h++)
   {
       bool pressed = ((input & handlers[h].pinMask) == 0);
       handlers[h].handler->handleButtonState(pressed);

       if(pressed)
           someButtonPressed = true;
   }

   // Reset the idle counter when user interacts with a button
   if(someButtonPressed)
   {
       idleCounter = 0;
       longPressCounter++;
   }
   else
   {
       idleCounter++;
       longPressCounter = 0;
   }

   // Process a very long press to join/leave the network
   if(longPressCounter > 5000/ButtonPollCycle)
   {
       ApplicationEvent evt = {BUTTON_VERY_LONG_PRESS, 0};
       appEventQueue.send(evt);

       for(uint8 h = 0; h < numHandlers; h++)
           handlers[h].handler->resetButtonStateMachine();

       longPressCounter = 0;
   }

   startTimer(ButtonPollCycle);
}

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

Обработчик кнопки реализует логику различных режимов, которые задаются через Z2M. Класс выглядит так.

class ButtonHandler: public IButtonHandler
{
   SwitchEndpoint * endpoint;

   uint32 currentStateDuration;

   SwitchMode switchMode;
   RelayMode relayMode;
   uint16 maxPause;
   uint16 longPressDuration;

   enum ButtonState
   {
       IDLE,
       PRESSED1,
       PAUSE1,
       PRESSED2,
       PAUSE2,
       PRESSED3,
       LONG_PRESS
   };

   ButtonState currentState;


ButtonHandler::ButtonHandler()
{
   endpoint = NULL;

   currentState = IDLE;
   currentStateDuration = 0;

   switchMode = SWITCH_MODE_TOGGLE;
   relayMode = RELAY_MODE_FRONT;
   maxPause = 250/ButtonPollCycle;
   longPressDuration = 1000/ButtonPollCycle;
}

void ButtonHandler::setEndpoint(SwitchEndpoint * ep)
{
   endpoint = ep;
}

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

void ButtonHandler::setSwitchMode(SwitchMode mode)
{
   switchMode = mode;
   changeState(IDLE);
}

void ButtonHandler::setRelayMode(RelayMode mode)
{
   relayMode = mode;
   changeState(IDLE);
}

void ButtonHandler::setMaxPause(uint16 value)
{
   maxPause = value/ButtonPollCycle;
   changeState(IDLE);
}

void ButtonHandler::setMinLongPress(uint16 value)
{
   longPressDuration = value/ButtonPollCycle;
   changeState(IDLE);
}

Посмотрим как обрабатываются нажатия кнопок

void ButtonHandler::changeState(ButtonState state)
{
   currentState = state;
   currentStateDuration = 0;
}

void ButtonHandler::handleButtonState(bool pressed)
{
   // Let at least 20ms to stabilize button value, do not make any early decisions
   // When button state is stabilized - go through the corresponding state machine
   currentStateDuration++;
   if(currentStateDuration < 2)
       return;

   switch(switchMode)
   {
   case SWITCH_MODE_TOGGLE:
       buttonStateMachineToggle(pressed);
       break;
   case SWITCH_MODE_MOMENTARY:
       buttonStateMachineMomentary(pressed);
       break;
   case SWITCH_MODE_MULTIFUNCTION:
       buttonStateMachineMultistate(pressed);
       break;
   default:
       break;
   }
}

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

Машина состояний для режима Toggle выглядит очень просто - по нажатию кнопки происходит переключение выключателя, по отпускании кнопки не происходит ничего (только возврат в исходное состояние). В зависимости от режима реле (relayMode) оно либо будет срабатывать (endpoint->toggle), либо будет отправлен только логический сигнал ACTION_SINGLE.

void ButtonHandler::buttonStateMachineToggle(bool pressed)
{
   // The state machine
   switch(currentState)
   {
       case IDLE:
           if(pressed)
           {
               changeState(PRESSED1);
               endpoint->reportAction(BUTTON_ACTION_SINGLE);

               if(relayMode != RELAY_MODE_UNLINKED)
                   endpoint->toggle();
           }
           break;

       case PRESSED1:
           if(!pressed)
               changeState(IDLE);

           break;

       default:
           changeState(IDLE);  // How did we get here?
           break;
   }
}

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

void ButtonHandler::buttonStateMachineMomentary(bool pressed)
{
   // The state machine
   switch(currentState)
   {
       case IDLE:
           if(pressed)
           {
               changeState(PRESSED1);
               endpoint->reportAction(BUTTON_PRESSED);

               if(relayMode != RELAY_MODE_UNLINKED)
                   endpoint->switchOn();
           }
           break;

       case PRESSED1:
           if(!pressed)
           {
               changeState(IDLE);
               endpoint->reportAction(BUTTON_RELEASED);

               if(relayMode != RELAY_MODE_UNLINKED)
                   endpoint->switchOff();
           }

           break;

       default:
           changeState(IDLE); // How did we get here?
           break;
   }
}

Машина состояний для “умного режима” побольше, но она также не особо сложна. В ответ на нажатия и отпускания кнопок машина состояний идет по пути IDLE -> PRESSED1 -> PAUSE1 -> PRESSED2 -> PAUSE2 -> PRESSED3, реагируя на одинарные, двойные, или тройные нажатия. Длинные нажатия обрабатываются по пути IDLE -> PRESSED1 -> LONG_PRESS. При переходах между состояниями учитываются настройки пауз между нажатиями (maxPause) и минимальной длины длинного нажатия (longPressDuration).

void ButtonHandler::buttonStateMachineMultistate(bool pressed)
{
   // The state machine
   switch(currentState)
   {
       case IDLE:
           if(pressed)
           {
               changeState(PRESSED1);

               if(relayMode == RELAY_MODE_FRONT)
                   endpoint->toggle();
           }
           break;

       case PRESSED1:
           if(pressed && currentStateDuration > longPressDuration)
           {
               changeState(LONG_PRESS);
               endpoint->reportAction(BUTTON_PRESSED);

               if(relayMode == RELAY_MODE_LONG)
                   endpoint->toggle();
           }

           if(!pressed)
           {
               changeState(PAUSE1);
           }

           break;

       case PAUSE1:
           if(!pressed && currentStateDuration > maxPause)
           {
               changeState(IDLE);
               endpoint->reportAction(BUTTON_ACTION_SINGLE);

               if(relayMode == RELAY_MODE_SINGLE)
                   endpoint->toggle();
           }

           if(pressed)
               changeState(PRESSED2);

           break;

       case PRESSED2:
           if(!pressed)
           {
               changeState(PAUSE2);
           }

           break;

       case PAUSE2:
           if(!pressed && currentStateDuration > maxPause)
           {
               changeState(IDLE);
               endpoint->reportAction(BUTTON_ACTION_DOUBLE);

               if(relayMode == RELAY_MODE_DOUBLE)
                   endpoint->toggle();
           }

           if(pressed)
           {
               changeState(PRESSED3);
           }

           break;

       case PRESSED3:
           if(!pressed)
           {
               changeState(IDLE);

               if(relayMode == RELAY_MODE_TRIPPLE)
                   endpoint->toggle();

               endpoint->reportAction(BUTTON_ACTION_TRIPPLE);
           }

           break;

       case LONG_PRESS:
           if(!pressed)
           {
               changeState(IDLE);

               endpoint->reportAction(BUTTON_RELEASED);
           }

           break;

       default: break;
   }
}

Класс SwitchEndpoint практически не изменился. Добавился только обработчик кнопок как часть самого SwitchEndpoint’а.

class SwitchEndpoint: public Endpoint
{   

...
   ButtonHandler buttonHandler;

Также добавилось только обработка параметра полярности.

void SwitchEndpoint::switchOn()
{
   bool newValue = true;

   // Invert the value in inverse mode
   if(sOnOffConfigServerCluster.eSwitchActions == E_CLD_OOSC_ACTION_S2OFF_S1ON)
       newValue = false;

   doStateChange(newValue);
   reportStateChange();
}

void SwitchEndpoint::switchOff()
{
   bool newValue = false;

   // Invert the value in inverse mode
   if(sOnOffConfigServerCluster.eSwitchActions == E_CLD_OOSC_ACTION_S2OFF_S1ON)
       newValue = true;

   doStateChange(newValue);
   reportStateChange();
}

Ну еще класс SwitchEndpoint должен зарегистрировать свой кнопочный обработчик в ButtonTask.

void SwitchEndpoint::setPins(uint8 ledPin, uint32 pinMask)
{
   blinkTask.init(ledPin);

   ButtonsTask::getInstance()->registerHandler(pinMask, &buttonHandler);
}

Осталось только собрать все это в кучу в Main.cpp. Заодно я таки добавил второй канал.

const uint8 SWITCH1_LED_PIN = 17;
const uint8 SWITCH2_LED_PIN = 0;

const uint8 SWITCH1_BTN_BIT = 1;
const uint32 SWITCH1_BTN_MASK = 1UL << SWITCH1_BTN_BIT;
const uint8 SWITCH2_BTN_BIT = 3;
const uint32 SWITCH2_BTN_MASK = 1UL << SWITCH2_BTN_BIT;

struct Context
{
   BasicClusterEndpoint basicEndpoint;
   SwitchEndpoint switch1;
   SwitchEndpoint switch2;
};

extern "C" PUBLIC void vAppMain(void)
{
...
   Context context;
   context.switch1.setPins(SWITCH1_LED_PIN, SWITCH1_BTN_MASK);
   context.switch2.setPins(SWITCH2_LED_PIN, SWITCH2_BTN_MASK);
   EndpointManager::getInstance()->registerEndpoint(HELLOENDDEVICE_BASIC_ENDPOINT, &context.basicEndpoint);
   EndpointManager::getInstance()->registerEndpoint(HELLOENDDEVICE_SWITCH1_ENDPOINT, &context.switch1);
   EndpointManager::getInstance()->registerEndpoint(HELLOENDDEVICE_SWITCH2_ENDPOINT, &context.switch2);

Multistate Input Cluster

В таком виде у нас уже работают 2 кнопки в режиме включено/выключено, можно настраивать поведение каждой из кнопок по отдельности. Код даже умеет различать одинарные, двойные, тройные и длинные нажатия. Только вот как об этом информировать сеть Zigbee? Кластер OnOff умеет оперировать только двумя состояниями - включено и выключено. Для передачи информации о сложных нажатиях этот кластер не подходит.

Скажу честно, я подсмотрел как это делают Xiaomi Opple и модкамовский пульт. А используют они для этого Multistate Input Cluster. Это такой специальный кластер, который может описывать значение в диапазоне от 0 до 65535. Вообще-то этот кластер предназначен для описания такого себе многопозиционного переключателя, который может продолжительной время находится в одном из множества состояний. Но если использовать только событие переключения то можно интерпретировать это значение так

  • переход в состояние 1 - одинарное нажатие

  • переход в состояние 2 - двойное нажатие

  • переход в состояние 3 - тройное нажатие

  • переход в состояние 255 - начало длинного нажатия

  • переход в состояние 0 - отпускание кнопки

В названии кластера опять присутствует некоторая неразбериха. Output (он же Client) кластер умеет отдавать команды на переключение, но сам своего состояния не имеет. Input (он же Server) кластер имеет внутреннее состояние, и может его отправлять координатору - и именно это нам и нужно.

Как добавлять новый кластер в наш выключатель мы уже знаем. Для начала добавим нужный кластер в ZPS Config Editor, после чего перегенерируем настроечные файлы с помощью ZPSConfig.exe.

В файле настроек zcl_options.h нужно также включить нужный кластер

#define CLD_MULTISTATE_INPUT_BASIC
#define MULTISTATE_INPUT_BASIC_SERVER
#define CLD_MULTISTATE_INPUT_BASIC_ATTR_NUMBER_OF_STATES 256

Последний параметр задает начальное значение атрибута NumberOfStates. В нашем коде мы его использовать не будем, но поскольку атрибут этот обязательный, то значение ему нужно задать. Значения у нас будут от 0 (кнопка не нажата) до 255 (кнопка нажата и удерживается, поэтому количество разных значений будет 256 (просто большая часть значений из диапазона 0 - 255 использоваться не будет).

Кстати об атрибутах. Вот что пишет спецификация.

Кластер реализует целый вагон разных атрибутов, но нас интересует только PresentValue.

Дальше нужно добавить кластер в нашу конечную точку. Тут ничего нового.

struct OnOffClusterInstances
{
...
   tsZCL_ClusterInstance sMultistateInputServer;


...
class SwitchEndpoint: public Endpoint
{   
...
   tsCLD_MultistateInputBasic sMultistateInputServerCluster;
...
   virtual void registerMultistateInputServerCluster();


void SwitchEndpoint::registerMultistateInputServerCluster()
{
   // Initialize Multistate Input server cluser
   teZCL_Status status = eCLD_MultistateInputBasicCreateMultistateInputBasic(
               &sClusterInstance.sMultistateInputServer,
               TRUE,                              // Server
               &sCLD_MultistateInputBasic,
               &sMultistateInputServerCluster,
               &au8MultistateInputBasicAttributeControlBits[0]);
   if( status != E_ZCL_SUCCESS)
       DBG_vPrintf(TRUE, "SwitchEndpoint::init(): Failed to create Multistate Input server cluster instance. status=%d\n", status);
}

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

void SwitchEndpoint::reportAction(ButtonActionType action)
{
   // Store new value in the cluster
   sMultistateInputServerCluster.u16PresentValue = (zuint16)action;

   // Prevent bothering Zigbee API if not connected
   if(!ZigbeeDevice::getInstance()->isJoined())
   {
       DBG_vPrintf(TRUE, "Device has not yet joined the network. Ignore reporting the change.\n");
       return;
   }

   // Destination address - 0x0000 (coordinator)
   tsZCL_Address addr;
   addr.uAddress.u16DestinationAddress = 0x0000;
   addr.eAddressMode = E_ZCL_AM_SHORT;

   // Send the report
   DBG_vPrintf(TRUE, "Reporting multistate action EP=%d value=%d... ", getEndpointId(), sMultistateInputServerCluster.u16PresentValue);
   PDUM_thAPduInstance myPDUM_thAPduInstance = hZCL_AllocateAPduInstance();
   teZCL_Status status = eZCL_ReportAttribute(&addr,
                                              GENERAL_CLUSTER_ID_MULTISTATE_INPUT_BASIC,
                                              E_CLD_MULTISTATE_INPUT_BASIC_ATTR_ID_PRESENT_VALUE,
                                              getEndpointId(),
                                              1,
                                              myPDUM_thAPduInstance);
   PDUM_eAPduFreeAPduInstance(myPDUM_thAPduInstance);
   DBG_vPrintf(TRUE, "status: %02x\n", status);
}

Устройство отправляет значение нового атрибута в сеть. Но теперь нужно научить Z2M понимать что это за атрибут и как на него реагировать. Ну это мы уже знаем - нужно написать еще один From Zigbee конвертер (to zigbee конвертер нам не нужен, т.к. мы не собираемся ничего писать в multistate input кластер, также как и читать из него).

const fromZigbee_MultistateInput = {
   cluster: 'genMultistateInput',
   type: ['attributeReport', 'readResponse'],

   convert: (model, msg, publish, options, meta) => {
       const actionLookup = {0: 'release', 1: 'single', 2: 'double', 3: 'tripple', 255: 'hold'};
       const value = msg.data['presentValue'];
       const action = actionLookup[value];

       const result = {action: utils.postfixWithEndpointName(action, msg, model)};
       return result;
   },
}

Конвертер честно слизан с конвертера ptvo_multistate_action. Тут просто вытаскивается значение атрибута presentValue и превращается в соответствующее текстовое значение.

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

function genSwitchActions(endpoinsCount) {
   let actions = [];

   for (let i = 1; i <= endpoinsCount; i++) {
       const epName = `button_${i}`;
       actions.push(... ['single', 'double', 'triple', 'hold', 'release'].map(action => action + "_" + epName));
   }

   return actions;
}

...

const device = {
...
   fromZigbee: [fz.on_off, fromZigbee_OnOffSwitchCfg, fromZigbee_MultistateInput],
...
   exposes: [
       e.action(genSwitchActions(2)),
       ...genSwitchEndpoints(2)
   ],
...

Мне очень хотелось, чтобы экшены от каждого эндпоинта объявлялись вместе с остальными потрохами этого эндпоинта (скажем в функции genSwitchEndpoint(), которую я приводил выше). но тут сказалось несовершенство архитектуры zigbee2mqtt. Так все экшены должны определяться только один раз с помощью функции e.action() в поле exposes (Коен Кантерс, автор zigbee2mqtt тут немного рассказал про историю и специфику этого поля).

Посмотрим как это работает. Вот так выглядит нажатие кнопки (и соответственно экшен hold) со стороны zigbee2mqtt.

Zigbee2MQTT:debug 2021-10-19 22:31:53: Received Zigbee message from 'TestSwitch', type 'attributeReport', cluster 'genMultistateInput', data '{"presentValue":255}' from endpoint 3 with groupID 0
Zigbee2MQTT:info  2021-10-19 22:31:53: MQTT publish: topic 'zigbee2mqtt2/TestSwitch', payload '{"action":"hold_button_2","last_seen":1634671913843,"linkquality":70,"max_pause_button_1":null,"max_pause_button_2":null,"min_long_press_button_1":null,"min_long_press_button_2":null,"relay_mode_button_1":"front","relay_mode_button_2":"unlinked","state_button_1":"OFF","state_button_2":"OFF","switch_actions_button_1":null,"switch_actions_button_2":null,"switch_type_button_1":null,"switch_type_button_2":"momentary"}'
Zigbee2MQTT:info  2021-10-19 22:31:53: MQTT publish: topic 'zigbee2mqtt2/TestSwitch', payload '{"action":"","last_seen":1634671913843,"linkquality":70,"max_pause_button_1":null,"max_pause_button_2":null,"min_long_press_button_1":null,"min_long_press_button_2":null,"relay_mode_button_1":"front","relay_mode_button_2":"unlinked","state_button_1":"OFF","state_button_2":"OFF","switch_actions_button_1":null,"switch_actions_button_2":null,"switch_type_button_1":null,"switch_type_button_2":"momentary"}'
Zigbee2MQTT:info  2021-10-19 22:31:53: MQTT publish: topic 'zigbee2mqtt2/TestSwitch/action', payload 'hold_button_2'

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

Что касается двух других сообщений по началу меня смутило, что отправляется запись "action":"hold_button_2" (помимо других параметров), а потом тут же "action":"". Но как выяснилось это нормальный ход педалей, о чем сказано в FAQ Zigbee2mqtt.

А можно ли по другому?

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

Но Multistate Input это не единственный способ сигнализировать различные кнопочные события. Так выключатель Xiaomi WXKG01LM нестандартно использует кластер OnOff, и прямо в атрибуте OnOff помимо значений 0 и 1 может указывать количество кликов. Иногда используется дополнительный нестандартный атрибут в кластере OnOff, который указывает количество кликов.

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

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

На последок, пару слов про биндинг. Тут у меня есть хорошие новости, есть плохие. Плохие в том, что связывать можно только однотипные кластера. Т.о. мы не сможем напрямую связать Multistate Input с On/Off кластером. Другими словами нельзя средствами zigbee забиндить, например, тройной клик выключателя с включением какой-нибудь лампочки. Ну то есть через специальное правило автоматизации в Home Assistant, конечно же, сможем, но напрямую средствами zigbee не получится.

Хорошая новость в том, что то обилие настроек, которое мы нагородили выше позволяет настроить срабатывание реле по третьему клику. В таком случае в сеть сначала будет отправлен экшен Multistate Input “тройной клик”, а потом команда On/Off toggle. И вот эту команду уже можно забиндить на On/Off кластер любого другого устройства. Правда при этом будет срабатывать еще и внутреннее реле выключателя, но нам ничего не мешает добавить еще одну настройку, которая будет отключать реле.

Home Assistant

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

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

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

Вроде бы видимость параметров по умолчанию и иконку можно переназначить в configuration.yaml zigbee2mqtt, но я не пробовал.

Что касается экшенов, то тут тоже не все так гладко. В конвертере мы объявляем экшены в секции exposes. В свою очередь zigbee2mqtt перечисляет эти экшены, когда описывает наше устройство во время процедуры mqtt discovery. Но не смотря на это Home Assistant игнорирует эти перечисления. Так, если попробовать создать автоматизацию, то HA не предложит двойные/тройные/длинные нажатия в качестве триггера для автоматизации.... Не предложит, пока такой экшен не произойдет хотя бы раз. Т.е. если потыкать кнопками и сгенерировать разные экшены, то они все же появляются в перечислении триггеров.

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

Буквально за несколько минут прямо в визуальном редакторе автоматизаций Home Assistant я накидал 4 правила.

Первая автоматизация полностью открывает/закрывает штору по одинарному нажатию кнопки.

- id: '1635018171508'
  alias: TestSwitch Open/Close cover
  description: 'ТЕСТ: открыть/закрыть штору'
  trigger:
  - platform: mqtt
    topic: zigbee2mqtt2/TestSwitch/action
    payload: single_button_1
  condition: []
  action:
  - service: cover.toggle
    target:
      entity_id: cover.living_room_curtain
  mode: single

Вторая автоматизация открывает штору на 50% по двойному нажатию кнопки.

- id: '1635017708545'
  alias: TestSwitch Half-open curtains
  description: 'ТЕСТ: Открыть шторы на 50%'
  trigger:
  - platform: mqtt
    topic: zigbee2mqtt2/TestSwitch/action
    payload: double_button_1
  condition: []
  action:
  - service: cover.set_cover_position
    target:
      entity_id: cover.living_room_curtain
    data:
      position: 50
  mode: single

Эти две автоматизации используют длинное нажатие - по нажатию кнопки штора начинает ехать, по отпусканию останавливается.

- id: '1635017908150'
  alias: TestSwitch toggle curtain on button press
  description: 'ТЕСТ: начать открывать или закрывать штору'
  trigger:
  - platform: mqtt
    topic: zigbee2mqtt2/TestSwitch/action
    payload: hold_button_1
  condition: []
  action:
  - service: cover.toggle
    target:
      entity_id: cover.living_room_curtain
  mode: single

- id: '1635017981037'
  alias: TestSwitch stop curtain
  description: 'ТЕСТ: остановить открытие/закрытие шторы по отпускании кнопки'
  trigger:
  - platform: mqtt
    topic: zigbee2mqtt2/TestSwitch/action
    payload: release_button_1
  condition: []
  action:
  - service: cover.stop_cover
    target:
      entity_id: cover.living_room_curtain
  mode: single

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

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

Заключение

Поздравляю, мы только что переизобрели выключатель Xiaomi Aqara! Ну или аналогичный умный выключатель. Ну может быть в нашем настроек чуток больше, но в целом все почти тоже самое. Но ведь и цель то была другая - разобраться в создании кастомных кластеров, genOnOffSettingCfg и genMultistateInput кластерах, а так же в написании внешних конвертеров для zigbee2mqtt.

Я хотел заглянуть в эту область одним глазком, в итоге залип на 2 месяца :)  Зато оказалось, что в создании собственных или расширении стандартных кластеров нет ничего сложного. А вот с zigbee2mqtt пришлось повозиться, в основном по причине отсутствия внятной документации. 

В общем и целом у меня получилось продвинуться в написании прошивки мечты для выключателей Xiaomi. Уже работают 2 кнопки. Добавление дополнительных кластеров занимает считанные минуты, а архитектура получается все более стройной. Думаю, следующая остановка - OTA Firmware Updates. Не переключайтесь.

Код

Код zigbee2mqtt herdsman converters, в котором пришлось глубоко покопаться, изучая разные конвертеры

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

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


  1. hobogene
    27.10.2021 17:28

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

    А чего ж тут такого забавного? Если копнуть чуть вглубь ZDO, быстро выяснится, что без этого было бы трудновато.


    1. grafalex Автор
      27.10.2021 19:39

      Не копал. А что там в ZDO с клиентами и серверами?


      1. hobogene
        27.10.2021 21:09

        Серверы для device discovery, например, всегда должны быть реализованы. У любого устройства.


  1. telobezumnoe
    27.10.2021 21:42

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


    1. grafalex Автор
      02.11.2021 14:55

      Может быть и так. Я просто потыкал пульт Aqara Opple, он мне показался слишком тупящим, да и толком применения ему не нашлось. Вот и отложил в сторонку.