
В этой части мы соберём прошивку для ESP и подключимся к интеграции.
Часть 2. Аппаратная часть (вы здесь)
Часть 3. Теория по аддонам Home Assistant + Установка ZigBridge (в процессе)
Аппаратная часть
Для реализации задачи был выбран микроконтроллер ESP32-C6, который имеет на борту модуль для работы с Zigbee. В среднем такой стоит до 600 рублей на разных площадках. Конкретно в моём случае используется 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.
Схематично логика работы выглядит следующим образом

Функция инициализации
код
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);)
Сборка и запуск прошивки
После того, как вы изучили и, если было необходимо, изменили код, нужно настроить взаимодействие с платой.

Пройдемся по пунктам меню:
Выбранная версия ESP-IDF
Режим работы с ESP. Необходимо выбрать UART, если прошиваете через usb.
COM порт с платой. Для изменения просто необходимо нажать на эту кнопку и выбрать из выпадающего списка нужный порт.
Модель ESP, под которую будет компилироваться прошивка.
Очистка кэша проекта. Полезно, когда изменения "залипают", отрицательно не влияет на работу, рекомендуется, если хочется сделать чистую сборку
Сборка проекта.
Прошивка ESP.
"Всё в одном" - по одной кнопке происходит сборка прошивки, загрузка в память ESP и открытие окна мониторинга.
Думаю тут не стоит на чем-то останавливаться, достаточно лишь сказать что для запуска ESP нам достаточно нажать кнопку под цифрой 8, предварительно выбрав нужный COM порт. На этом подготовка аппаратной части готова.
В следующей части разберёмся с интеграцией, её настройкой и релизом, для возможности установки её в Home Assistant.
Комментарии (0)

daniil_kulikov
19.09.2025 12:57Не понял, почему просто не использовать вместо Алисы wyoming satellite и ничего не костылить? Ещё если есть возможность развернуть локальную LLM, то вообще песня будет

kallibr44 Автор
19.09.2025 12:57Алису (вообще статьи не про саму Алису, а про использование Яндекс Станций) используют далеко не только для управления умным домом.

daniil_kulikov
19.09.2025 12:57Ну опять же, чего нельзя сделать инструментами HA, что есть в Алисе? Те же интенты через Rasa NLU или локальная нейронка (в идеале без цензуры)

Lobey
19.09.2025 12:57Только то, что есть у любой Алисы из коробки: настройка сценариев за несколько минут без специалиста, музыка, радио, нейронка, звонки, такси, заказы, аудиокниги, напоминания, погода, таймеры, элементарное добавление новых устройств, качественные распознавание и синтез русской речи, простая и понятная админка, многофункциональные навыки Алисы... Мог что-то запамятовать, простите.

daniil_kulikov
19.09.2025 12:57Да, но автор предлагает костылить «переходник», когда можно потратить меньше ресурсов и времени на сшивании уже готовых решений для собственного умного помощника

Lobey
19.09.2025 12:57Вы абсолютно правы. Если не нужно ничего из перечисленного выше и не нужна бесшовная интеграция умного дома с Яндекс Станцией / Алисой, но уже есть колонки и микрофоны, то предложенное вами решение имеет смысл. Для таких пользователей уже есть множество статей и инструкций, эта статья не для них.

daniil_kulikov
19.09.2025 12:57Да, я ЯС обозвал Алисой))
Если что, я не критикую! Изобретение чего-то своего и описание «как работает» — и полезно, и интересно. Просто задаюсь вопросами, потому что вдруг не понимаю чего-то. Может у этого решения есть куда более выгодные плюсы, кроме готовой ЯС, которую все равно дорабатываем)

kallibr44 Автор
19.09.2025 12:57для понимания кейса, в рамках которого это и было придумано, прошу прочитать первую часть, она как раз вводная, где я описал "что зачем и почему". То, что вы предлагаете делать, требует соответствующего железа под нагрузку, конкретно в моём случае HA крутится внутри виртуалки на мини-ПК и обвешивать LLM и микрофонными массивами нет желания и возможности. Яндекс станция это уже голосовой интерфейс, который имеет вендорские фичи, как обработка звука, прослушивание и фильтрация звукового потока и в целом очень чувствительный массив микрофонов + к тому же, Яндекс станция это Zigbee координатор и было бы странно иметь настолько проработанное вендорское решение и оставить его только для того, чтобы слушать музыку. К тому же, станции в оффлайне прекрасно работают с командами для умных устройств, но только для Zigbee + именно канал zigbee хорошо подходит для локального взаимодействия между экосистемами, поскольку для двух сторон мы имитируем стандартизированное устройство.
В первой статье были уже подобные суждения, но вы все почему-то выпадаете из контекста, ведь Яндекс станция здесь не используется как ассистент, это именно голосовой интерфейс, задача которого понять какое устройство нужно включить\выключить, аспекты самого ассистента и его возможностей уже находятся за рамками данного кейса)
У многих станции расставлены по всему дому, почему же не использовать их как готовые микрофонные массивы для голосового управления Home Assistant? К тому же, имеющие независимую от HA обработку голоса и команд, что разгружает основную систему.

mihmig
19.09.2025 12:57Можете проверить надёжность стека Zigbee в ESP в такой связке:
1. Выключите ESP-шку на сутки-двое, включите обратно. Заработает ли всё без "перезагрузки" оборудования?
2. Выключите колонку на сутки-двое, включите обратно. Заработает ли всё без "перезагрузки" оборудования?
3. Выключите HA (если такое возможно) на сутки-двое, включите обратно. Заработает ли всё без "перезагрузки" оборудования?
Merkalov
Внезапно! А я подумал, что к тебе пришли дяди из Яндекса и дали по шапке)
После первой статьи даже купил ESP...жду третей части.
kallibr44 Автор
выгорание и заход в тупик в коде не дали до конца закончить это решение и статьи, но я собрался с силами и доделал всё :) Последняя статья будет скоро.