В этой части мы соберём прошивку для ESP и подключимся к интеграции.

  • Часть 1. Введение

  • Часть 2. Аппаратная часть (вы здесь)

  • Часть 3. Теория по аддонам Home Assistant (в процессе)

Аппаратная часть

Для реализации задачи был выбран микроконтроллер ESP32-C6, который имеет на борту модуль для работы с Zigbee. В среднем такой стоит до 600 рублей на разных площадках. Конкретно в моём случае используется ESP32-C6 Mini.

Внешний вид платы
ESP32-C6 Mini
ESP32-C6 Mini

Программная часть

Интеграция с Home Assistant будет самой сложной и объемной частью. Вся интеграция будет упакована в Docker контейнер, основной язык был выбран Python, фреймворк Flask. Для ESP основной язык будет С, поскольку за среду разработки мы берём ESP-IDF.

Меньше слов, больше дела, а поэтому...

Подготовка окружения

Ну что же, начнем с подготовки рабочей среды для написания кода под ESP, а именно с установки ESP-IDF. Все дальнейшие действия я буду делать только в Visual Studio Code.

Пропустим установку VS Code и приступим к установке расширения, через которое всё и будет работать. Достаточно перейти в раздел Extensions и вбить в поиск "ESP-IDF" или перейти по ссылке

После установки расширения скачиваем проект из репозитория и открываем проект в VS Code, ждем инициализации и видим такое окно (возможно откроется главное меню, тогда в нём надо нажать кнопку "Configure extension"):

настройка расширения
настройка расширения

Выбираем вариант Express, ставим галочку "Show all tags" и выбираем из списка релиз версии 5.2.1, далее выбираем пути для сохранения дистрибутивов и жмём Install (При выборе места сохранения стоит учесть, что дистрибутив IDF имеет вес в несколько Гб).

Меню настройки версии ESP-IDF
Меню настройки версии ESP-IDF

После завершения установки окружение готово к работе.

Основная часть прошивки

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

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

Схематичное отображение работы ESP
Схематичное отображение работы ESP

Функция инициализации

код
void app_main(void)
{
    esp_zb_platform_config_t config = {
        .radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(),
        .host_config = ESP_ZB_DEFAULT_HOST_CONFIG(),
    };
    esp_console_repl_t *repl = NULL;
    esp_console_repl_config_t repl_config = ESP_CONSOLE_REPL_CONFIG_DEFAULT();
    /* Prompt to be printed before each line.
     * This can be customized, made dynamic, etc.
     */
    repl_config.prompt = PROMPT_STR ">";
    repl_config.max_cmdline_length = 128;
    esp_console_dev_usb_serial_jtag_config_t hw_config = ESP_CONSOLE_DEV_USB_SERIAL_JTAG_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_console_new_repl_usb_serial_jtag(&hw_config, &repl_config, &repl));
    //hw_config.tx_gpio_num = 12;
    //hw_config.rx_gpio_num = 13;
    //ESP_ERROR_CHECK(esp_console_new_repl_uart(&hw_config, &repl_config, &repl));

    // Регистрация обработчика команды "init"
    esp_console_cmd_t initCommand = {
        .command = "init",
        .help = "Добавить числа в список",
        .hint = "<число1> <число2> ...",
        .func = &initCommandHandler,
    };

    esp_console_cmd_t restartCommand = {
        .command = "rst",
        .help = "Перезагрузка устройства",
        .hint = NULL,
        .func = &restartCommandHandler,
    };
    ESP_ERROR_CHECK(esp_console_cmd_register(&restartCommand));
    ESP_ERROR_CHECK(esp_console_cmd_register(&initCommand));
    ESP_ERROR_CHECK(nvs_flash_init());
    ESP_ERROR_CHECK(esp_zb_platform_config(&config));
    light_driver_init(LIGHT_DEFAULT_OFF);
    xTaskCreate(esp_zb_task, "Zigbee_main", 4096, NULL, 5, NULL);
    ESP_ERROR_CHECK(esp_console_start_repl(repl));
    vTaskDelay(pdMS_TO_TICKS(1000));  // Пауза в 1 секунду
}

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

    esp_console_cmd_t initCommand = {
        .command = "init",
        .help = "Добавить числа в список",
        .hint = "<число1> <число2> ...",
        .func = &initCommandHandler,
    };

    esp_console_cmd_t restartCommand = {
        .command = "rst",
        .help = "Перезагрузка устройства",
        .hint = NULL,
        .func = &restartCommandHandler,
    };
    ESP_ERROR_CHECK(esp_console_cmd_register(&restartCommand));
    ESP_ERROR_CHECK(esp_console_cmd_register(&initCommand));

Стоит обратить внимание, что в зависимости от варианта исполнения платы, доступ к серийному выводу USB в разных версиях платы делается по-разному, конкретно в случае с версией Mini порт доступен через JTAG.

    esp_console_dev_usb_serial_jtag_config_t hw_config = ESP_CONSOLE_DEV_USB_SERIAL_JTAG_CONFIG_DEFAULT(); //возможен как JTAG, так и UART
    ESP_ERROR_CHECK(esp_console_new_repl_usb_serial_jtag(&hw_config, &repl_config, &repl));

Так же мы должны явно указать максимальную длину вводимой команды. Я указал 256, но если не планируется использовать все каналы, то размер можно и сократить.

repl_config.max_cmdline_length = 256;

И непосредственно запуск главного цикла.

    light_driver_init(LIGHT_DEFAULT_OFF); //оставил инициализацию бортового светодиода, если кому-то надо (:
    xTaskCreate(esp_zb_task, "Zigbee_main", 4096, NULL, 5, NULL);

Запуск главной функции

Первыми же строками в главной функции esp_zb_task() мы создаем семафор и заставляем его ожидать результата выполнения функции обработчика команды init, иначе ESP сразу попытается инициализироваться без данных о каналах. clustersSemaphore это глобальная переменная и будет доступна из любой функции.

    clustersSemaphore = xSemaphoreCreateBinary();
    if (clustersSemaphore == NULL) {
        ESP_LOGE("APP", "Ошибка: не удалось создать семафор");
        // Обработка ошибки
        return;
    }

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

static int initCommandHandler(int argc, char **argv) {
    for (int i = 1; i < argc; i++) {
        // Преобразование строки в число и добавление в список
        int val = atoi(argv[i]);
        addCluster(val);
    }

    // Вывод содержимого списка в консоль (для тестирования)
    clusters_t *current = head;
    while (current != NULL) {
        printf("%d", current->val);
        if (current->next != NULL) {
            printf(", ");
        }
        current = current->next;
    }
    printf("\n");
    if (head != NULL) {
        xSemaphoreGive(clustersSemaphore);
    }
    return ESP_OK;
}

static void addCluster(int val) {
    clusters_t *newCluster = (clusters_t *)malloc(sizeof(clusters_t));
    if (newCluster == NULL) {
        ESP_LOGE("APP", "Ошибка: не удалось выделить память для нового элемента списка");
        return;
    }

    newCluster->val = val;
    newCluster->next = NULL;

    if (head == NULL) {
        // Если список пуст, новый элемент становится головой и хвостом
        head = newCluster;
        tail = newCluster;
    } else {
        // Иначе добавляем новый элемент в конец списка
        tail->next = newCluster;
        tail = newCluster;
    }
}

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

Код инициализации устройств
// Если список clusters пуст, ждем его заполнения
            if (xSemaphoreTake(clustersSemaphore, portMAX_DELAY) == pdTRUE) {
                

                /* initialize Zigbee stack */
                esp_zb_cfg_t zb_nwk_cfg = ESP_ZB_ZED_CONFIG();
                esp_zb_init(&zb_nwk_cfg);
                /* basic cluster create with fully customized */
                set_zcl_string(manufacturer, "kallibr44");
                set_zcl_string(model, "emulated_light");
                set_zcl_string(firmware_version, "0.0.1");
                uint8_t dc_power_source;
                dc_power_source = 4;
                uint16_t undefined_value;
                undefined_value = 0x8000;
                
                /* identify cluster create with fully customized */
                uint8_t identyfi_id;
                identyfi_id = 0;

                esp_zb_cluster_list_t *esp_zb_cluster_list = esp_zb_zcl_cluster_list_create();
                //esp_zb_cluster_list_t *esp_zb_cluster_switch = esp_zb_zcl_cluster_list_create();


                esp_zb_attribute_list_t *esp_zb_identify_cluster = esp_zb_zcl_attr_list_create(ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY);
                esp_zb_identify_cluster_add_attr(esp_zb_identify_cluster, ESP_ZB_ZCL_CMD_IDENTIFY_IDENTIFY_ID, &identyfi_id);

                /* Basic cluster data*/
                esp_zb_attribute_list_t *esp_zb_basic_cluster = esp_zb_zcl_attr_list_create(ESP_ZB_ZCL_CLUSTER_ID_BASIC);
                esp_zb_basic_cluster_add_attr(esp_zb_basic_cluster, ESP_ZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, manufacturer);
                esp_zb_basic_cluster_add_attr(esp_zb_basic_cluster, ESP_ZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, model);
                esp_zb_basic_cluster_add_attr(esp_zb_basic_cluster, ESP_ZB_ZCL_ATTR_BASIC_SW_BUILD_ID, firmware_version);
                esp_zb_basic_cluster_add_attr(esp_zb_basic_cluster, ESP_ZB_ZCL_ATTR_BASIC_POWER_SOURCE_ID, &dc_power_source);  /** важно указать именно номер 4, тогда координатор сможет чаще опрашивать наше устройство, поскольку не ожидает от него глубокого сна*/
            
                esp_zb_on_off_light_cfg_t light_cfg = ESP_ZB_DEFAULT_ON_OFF_LIGHT_CONFIG();
                //esp_zb_ep_list_t *esp_zb_on_off_light_ep = esp_zb_on_off_light_ep_create(HA_ESP_LIGHT_ENDPOINT, &light_cfg);
                esp_zb_attribute_list_t *zb_light_cfg = esp_zb_on_off_cluster_create(&light_cfg);
                
                esp_zb_cluster_list_add_basic_cluster(esp_zb_cluster_list,esp_zb_basic_cluster, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE);
                esp_zb_cluster_list_add_identify_cluster(esp_zb_cluster_list,esp_zb_identify_cluster, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE);
                esp_zb_cluster_list_add_on_off_cluster(esp_zb_cluster_list,zb_light_cfg,ESP_ZB_ZCL_CLUSTER_SERVER_ROLE);

                //esp_zb_ep_list_update_ep(esp_zb_on_off_light_ep, esp_zb_cluster_list, 1, ESP_ZB_AF_HA_PROFILE_ID, ESP_ZB_HA_ON_OFF_LIGHT_DEVICE_ID);
                esp_zb_ep_list_t *esp_zb_ep_list = esp_zb_ep_list_create();

                clusters_t *current = head;
                while (current != NULL) {
                    esp_zb_endpoint_config_t ep_config = {
                        .endpoint = current->val,
                        .app_device_id = ESP_ZB_HA_ON_OFF_LIGHT_DEVICE_ID,
                        .app_profile_id = ESP_ZB_AF_HA_PROFILE_ID
                    };
                    esp_zb_ep_list_add_ep(esp_zb_ep_list, esp_zb_cluster_list,ep_config);
                    //printf("Обработка значения: %d\n", current->val);
                    current = current->next;
                }
                // Освобождение памяти при завершении программы
                clusters_t *current_free = head;
                while (current_free != NULL) {
                    clusters_t *next = current_free->next;
                    free(current_free);
                    current_free = next;
                }
                esp_zb_device_register(esp_zb_ep_list);
                esp_zb_core_action_handler_register(zb_action_handler);
                esp_zb_set_primary_network_channel_set(ESP_ZB_PRIMARY_CHANNEL_MASK);
                ESP_ERROR_CHECK(esp_zb_start(false));
                esp_zb_main_loop_iteration();
            }

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

                //esp_zb_ep_list_update_ep(esp_zb_on_off_light_ep, esp_zb_cluster_list, 1, ESP_ZB_AF_HA_PROFILE_ID, ESP_ZB_HA_ON_OFF_LIGHT_DEVICE_ID);
                esp_zb_ep_list_t *esp_zb_ep_list = esp_zb_ep_list_create();

                clusters_t *current = head;
                while (current != NULL) {
                    esp_zb_endpoint_config_t ep_config = {
                        .endpoint = current->val,
                        .app_device_id = ESP_ZB_HA_ON_OFF_LIGHT_DEVICE_ID,
                        .app_profile_id = ESP_ZB_AF_HA_PROFILE_ID
                    };
                    esp_zb_ep_list_add_ep(esp_zb_ep_list, esp_zb_cluster_list,ep_config);
                    //printf("Обработка значения: %d\n", current->val);
                    current = current->next;
                }
                // Освобождение памяти при завершении программы
                clusters_t *current_free = head;
                while (current_free != NULL) {
                    clusters_t *next = current_free->next;
                    free(current_free);
                    current_free = next;
                }
                esp_zb_device_register(esp_zb_ep_list);
                esp_zb_core_action_handler_register(zb_action_handler);
                esp_zb_set_primary_network_channel_set(ESP_ZB_PRIMARY_CHANNEL_MASK);
                ESP_ERROR_CHECK(esp_zb_start(false));
                esp_zb_main_loop_iteration();

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

Заключительная часть прошивки

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

static esp_err_t zb_attribute_handler(const esp_zb_zcl_set_attr_value_message_t *message)
{
    esp_err_t ret = ESP_OK;
    bool light_state = 0;

    ESP_RETURN_ON_FALSE(message, ESP_FAIL, TAG, "Empty message");
    ESP_RETURN_ON_FALSE(message->info.status == ESP_ZB_ZCL_STATUS_SUCCESS, ESP_ERR_INVALID_ARG, TAG,
                        "Received message: error status(%d)", message->info.status);
    ESP_LOGI(TAG, "Received message: endpoint(%d), cluster(0x%x), attribute(0x%x), data size(%d)",
             message->info.dst_endpoint, message->info.cluster, message->attribute.id, message->attribute.data.size);
        if (message->info.cluster == ESP_ZB_ZCL_CLUSTER_ID_ON_OFF) {
            if (message->attribute.id == ESP_ZB_ZCL_ATTR_ON_OFF_ON_OFF_ID &&
                message->attribute.data.type == ESP_ZB_ZCL_ATTR_TYPE_BOOL) {
                light_state = message->attribute.data.value ? *(bool *)message->attribute.data.value : light_state;
                ESP_LOGI(TAG, "|{'cl':%d,'st':%d}|",message->info.dst_endpoint, *(bool *)message->attribute.data.value);
                light_driver_set_power(light_state);
            }
        }
    return ret;
}

Каждое входящее событие проходит через эту функцию с передачей объекта message, который содержит исчерпывающую информацию о входящем сообщении. Сначала сообщение проходит встроенные фильтры через ESP_RETURN_ON_FALSE(), а далее мы проверяем, что входящее сообщение относится именно к тому, что мы ожидаем, а именно отсев кластера по имени ON_OFF (message->info.cluster == ESP_ZB_ZCL_CLUSTER_ID_ON_OFF), после отсеиваем атрибут кластера ON_OFF и то, что он имеет булево значение (message->attribute.id == ESP_ZB_ZCL_ATTR_ON_OFF_ON_OFF_ID &&
message->attribute.data.type == ESP_ZB_ZCL_ATTR_TYPE_BOOL) и когда мы убедились, что получили именно то, что хотели, отправляем результат в JSON формате на серийный порт (ESP_LOGI(TAG, "|{'cl':%d,'st':%d}|",message->info.dst_endpoint, (bool )message->attribute.data.value);)

Сборка и запуск прошивки

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

Пройдемся по пунктам меню:

  1. Выбранная версия ESP-IDF

  2. Режим работы с ESP. Необходимо выбрать UART, если прошиваете через usb.

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

  4. Модель ESP, под которую будет компилироваться прошивка.

  5. Очистка кэша проекта. Полезно, когда изменения "залипают", отрицательно не влияет на работу, рекомендуется, если хочется сделать чистую сборку

  6. Сборка проекта.

  7. Прошивка ESP.

  8. "Всё в одном" - по одной кнопке происходит сборка прошивки, загрузка в память ESP и открытие окна мониторинга.

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

В следующей части разберёмся с интеграцией, её настройкой и релизом, для возможности установки её в Home Assistant.

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