From 12027569d33e7069bca7ed7986ba332ae3f55617 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 5 Jan 2026 03:11:14 +0100 Subject: [PATCH] [nrf52,zigbee] add support for binary_input (#11535) Co-authored-by: J. Nick Koston Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/binary_sensor/__init__.py | 6 +- esphome/components/zephyr/core.cpp | 5 + esphome/components/zigbee/__init__.py | 124 ++++++++ esphome/components/zigbee/automation.h | 16 ++ esphome/components/zigbee/const_zephyr.py | 24 ++ .../zigbee/zigbee_binary_sensor_zephyr.cpp | 37 +++ .../zigbee/zigbee_binary_sensor_zephyr.h | 45 +++ esphome/components/zigbee/zigbee_zephyr.cpp | 190 +++++++++++++ esphome/components/zigbee/zigbee_zephyr.h | 104 +++++++ esphome/components/zigbee/zigbee_zephyr.py | 265 ++++++++++++++++++ esphome/core/defines.h | 3 + script/helpers_zephyr.py | 12 +- tests/components/zigbee/common.yaml | 34 +++ .../zigbee/test.nrf52-adafruit.yaml | 1 + .../components/zigbee/test.nrf52-mcumgr.yaml | 1 + .../zigbee/test.nrf52-xiao-ble.yaml | 1 + 17 files changed, 866 insertions(+), 3 deletions(-) create mode 100644 esphome/components/zigbee/__init__.py create mode 100644 esphome/components/zigbee/automation.h create mode 100644 esphome/components/zigbee/const_zephyr.py create mode 100644 esphome/components/zigbee/zigbee_binary_sensor_zephyr.cpp create mode 100644 esphome/components/zigbee/zigbee_binary_sensor_zephyr.h create mode 100644 esphome/components/zigbee/zigbee_zephyr.cpp create mode 100644 esphome/components/zigbee/zigbee_zephyr.h create mode 100644 esphome/components/zigbee/zigbee_zephyr.py create mode 100644 tests/components/zigbee/common.yaml create mode 100644 tests/components/zigbee/test.nrf52-adafruit.yaml create mode 100644 tests/components/zigbee/test.nrf52-mcumgr.yaml create mode 100644 tests/components/zigbee/test.nrf52-xiao-ble.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 0d9396aa6f..00db5a3c79 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -575,5 +575,6 @@ esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68 esphome/components/xxtea/* @clydebarrow esphome/components/zephyr/* @tomaszduda23 esphome/components/zhlt01/* @cfeenstra1024 +esphome/components/zigbee/* @tomaszduda23 esphome/components/zio_ultrasonic/* @kahrendt esphome/components/zwave_proxy/* @kbx81 diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index cbf935a501..c38d6b78d3 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -3,7 +3,7 @@ from logging import getLogger from esphome import automation, core from esphome.automation import Condition, maybe_simple_id import esphome.codegen as cg -from esphome.components import mqtt, web_server +from esphome.components import mqtt, web_server, zigbee from esphome.components.const import CONF_ON_STATE_CHANGE import esphome.config_validation as cv from esphome.const import ( @@ -439,6 +439,7 @@ def validate_publish_initial_state(value): _BINARY_SENSOR_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMPONENT_SCHEMA) + .extend(zigbee.BINARY_SENSOR_SCHEMA) .extend( { cv.GenerateID(): cv.declare_id(BinarySensor), @@ -520,6 +521,7 @@ _BINARY_SENSOR_SCHEMA = ( _BINARY_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("binary_sensor")) +_BINARY_SENSOR_SCHEMA.add_extra(zigbee.validate_binary_sensor) def binary_sensor_schema( @@ -621,6 +623,8 @@ async def setup_binary_sensor_core_(var, config): if web_server_config := config.get(CONF_WEB_SERVER): await web_server.add_entity_config(var, web_server_config) + await zigbee.setup_binary_sensor(var, config) + async def register_binary_sensor(var, config): if not CORE.has_id(config[CONF_ID]): diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index 46589cdb62..d7027b33f5 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -26,7 +26,12 @@ void arch_init() { if (device_is_ready(WDT)) { static wdt_timeout_cfg wdt_config{}; wdt_config.flags = WDT_FLAG_RESET_SOC; +#ifdef USE_ZIGBEE + // zboss thread use a lot of cpu cycles during start + wdt_config.window.max = 10000; +#else wdt_config.window.max = 2000; +#endif wdt_channel_id = wdt_install_timeout(WDT, &wdt_config); if (wdt_channel_id >= 0) { uint8_t options = 0; diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py new file mode 100644 index 0000000000..2009f92d2e --- /dev/null +++ b/esphome/components/zigbee/__init__.py @@ -0,0 +1,124 @@ +from typing import Any + +from esphome import automation, core +import esphome.codegen as cg +from esphome.components.nrf52.boards import BOOTLOADER_CONFIG, Section +from esphome.components.zephyr import zephyr_add_pm_static, zephyr_data +from esphome.components.zephyr.const import KEY_BOOTLOADER +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERNAL +from esphome.core import CORE +from esphome.types import ConfigType + +from .const_zephyr import ( + CONF_MAX_EP_NUMBER, + CONF_ON_JOIN, + CONF_WIPE_ON_BOOT, + CONF_ZIGBEE_ID, + KEY_EP_NUMBER, + KEY_ZIGBEE, + ZigbeeComponent, + zigbee_ns, +) +from .zigbee_zephyr import zephyr_binary_sensor + +CODEOWNERS = ["@tomaszduda23"] + + +def zigbee_set_core_data(config: ConfigType) -> ConfigType: + if zephyr_data()[KEY_BOOTLOADER] in BOOTLOADER_CONFIG: + zephyr_add_pm_static( + [Section("empty_after_zboss_offset", 0xF4000, 0xC000, "flash_primary")] + ) + + return config + + +BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_binary_sensor) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(ZigbeeComponent), + cv.Optional(CONF_ON_JOIN): automation.validate_automation(single=True), + cv.Optional(CONF_WIPE_ON_BOOT, default=False): cv.All( + cv.boolean, + cv.requires_component("nrf52"), + ), + } + ).extend(cv.COMPONENT_SCHEMA), + zigbee_set_core_data, + cv.only_with_framework("zephyr"), +) + + +def validate_number_of_ep(config: ConfigType) -> None: + if KEY_ZIGBEE not in CORE.data: + raise cv.Invalid("At least one zigbee device need to be included") + count = len(CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER]) + if count == 1: + raise cv.Invalid( + "Single endpoint is not supported https://github.com/Koenkk/zigbee2mqtt/issues/29888" + ) + if count > CONF_MAX_EP_NUMBER: + raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER}") + + +FINAL_VALIDATE_SCHEMA = cv.All( + validate_number_of_ep, +) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_ZIGBEE") + if CORE.using_zephyr: + from .zigbee_zephyr import zephyr_to_code + + await zephyr_to_code(config) + + +async def setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: + if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): + return + if CORE.using_zephyr: + from .zigbee_zephyr import zephyr_setup_binary_sensor + + await zephyr_setup_binary_sensor(entity, config) + + +def validate_binary_sensor(config: ConfigType) -> ConfigType: + if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): + return config + data: dict[str, Any] = CORE.data.setdefault(KEY_ZIGBEE, {}) + slots: list[str] = data.setdefault(KEY_EP_NUMBER, []) + slots.extend([""]) + return config + + +ZIGBEE_ACTION_SCHEMA = automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(ZigbeeComponent), + } + ) +) + +FactoryResetAction = zigbee_ns.class_( + "FactoryResetAction", automation.Action, cg.Parented.template(ZigbeeComponent) +) + + +@automation.register_action( + "zigbee.factory_reset", + FactoryResetAction, + ZIGBEE_ACTION_SCHEMA, +) +async def reset_zigbee_to_code( + config: ConfigType, + action_id: core.ID, + template_arg: cg.TemplateArguments, + args: list[tuple], +) -> cg.Pvariable: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/esphome/components/zigbee/automation.h b/esphome/components/zigbee/automation.h new file mode 100644 index 0000000000..1822e6a029 --- /dev/null +++ b/esphome/components/zigbee/automation.h @@ -0,0 +1,16 @@ +#pragma once +#include "esphome/core/defines.h" +#ifdef USE_ZIGBEE +#ifdef USE_NRF52 +#include "zigbee_zephyr.h" +#endif +namespace esphome::zigbee { + +template class FactoryResetAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->factory_reset(); } +}; + +} // namespace esphome::zigbee + +#endif diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py new file mode 100644 index 0000000000..ecd08f1f0a --- /dev/null +++ b/esphome/components/zigbee/const_zephyr.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg + +zigbee_ns = cg.esphome_ns.namespace("zigbee") +ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component) +BinaryAttrs = zigbee_ns.struct("BinaryAttrs") + +CONF_MAX_EP_NUMBER = 8 +CONF_ZIGBEE_ID = "zigbee_id" +CONF_ON_JOIN = "on_join" +CONF_WIPE_ON_BOOT = "wipe_on_boot" +CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" + +# Keys for CORE.data storage +KEY_ZIGBEE = "zigbee" +KEY_EP_NUMBER = "ep_number" + +# External ZBOSS SDK types (just strings for codegen) +ZB_ZCL_BASIC_ATTRS_EXT_T = "zb_zcl_basic_attrs_ext_t" +ZB_ZCL_IDENTIFY_ATTRS_T = "zb_zcl_identify_attrs_t" + +# Cluster IDs +ZB_ZCL_CLUSTER_ID_BASIC = "ZB_ZCL_CLUSTER_ID_BASIC" +ZB_ZCL_CLUSTER_ID_IDENTIFY = "ZB_ZCL_CLUSTER_ID_IDENTIFY" +ZB_ZCL_CLUSTER_ID_BINARY_INPUT = "ZB_ZCL_CLUSTER_ID_BINARY_INPUT" diff --git a/esphome/components/zigbee/zigbee_binary_sensor_zephyr.cpp b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.cpp new file mode 100644 index 0000000000..744d04adc5 --- /dev/null +++ b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.cpp @@ -0,0 +1,37 @@ +#include "zigbee_binary_sensor_zephyr.h" +#if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_BINARY_SENSOR) +#include "esphome/core/log.h" +extern "C" { +#include +#include +#include +#include +#include +} +namespace esphome::zigbee { + +static const char *const TAG = "zigbee.binary_sensor"; + +ZigbeeBinarySensor::ZigbeeBinarySensor(binary_sensor::BinarySensor *binary_sensor) : binary_sensor_(binary_sensor) {} + +void ZigbeeBinarySensor::setup() { + this->binary_sensor_->add_on_state_callback([this](bool state) { + this->cluster_attributes_->present_value = state ? ZB_TRUE : ZB_FALSE; + ESP_LOGD(TAG, "Set attribute end point: %d, present_value %d", this->end_point_, + this->cluster_attributes_->present_value); + ZB_ZCL_SET_ATTRIBUTE(this->end_point_, ZB_ZCL_CLUSTER_ID_BINARY_INPUT, ZB_ZCL_CLUSTER_SERVER_ROLE, + ZB_ZCL_ATTR_BINARY_INPUT_PRESENT_VALUE_ID, &this->cluster_attributes_->present_value, + ZB_FALSE); + this->parent_->flush(); + }); +} + +void ZigbeeBinarySensor::dump_config() { + ESP_LOGCONFIG(TAG, + "Zigbee Binary Sensor\n" + " End point: %d, present_value %u", + this->end_point_, this->cluster_attributes_->present_value); +} + +} // namespace esphome::zigbee +#endif diff --git a/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h new file mode 100644 index 0000000000..aae79fa289 --- /dev/null +++ b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h @@ -0,0 +1,45 @@ +#pragma once +#include "esphome/core/defines.h" +#if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_BINARY_SENSOR) +#include "esphome/components/zigbee/zigbee_zephyr.h" +#include "esphome/core/component.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +extern "C" { +#include +#include +} + +// it should have been defined inside of sdk. It is missing though +#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_BINARY_INPUT_DESCRIPTION_ID(data_ptr) \ + { \ + ZB_ZCL_ATTR_BINARY_INPUT_DESCRIPTION_ID, ZB_ZCL_ATTR_TYPE_CHAR_STRING, ZB_ZCL_ATTR_ACCESS_READ_ONLY, \ + (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), (void *) (data_ptr) \ + } + +// copy of ZB_ZCL_DECLARE_BINARY_INPUT_ATTRIB_LIST + description +#define ESPHOME_ZB_ZCL_DECLARE_BINARY_INPUT_ATTRIB_LIST(attr_list, out_of_service, present_value, status_flag, \ + description) \ + ZB_ZCL_START_DECLARE_ATTRIB_LIST_CLUSTER_REVISION(attr_list, ZB_ZCL_BINARY_INPUT) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_BINARY_INPUT_OUT_OF_SERVICE_ID, (out_of_service)) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_BINARY_INPUT_PRESENT_VALUE_ID, (present_value)) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_BINARY_INPUT_STATUS_FLAG_ID, (status_flag)) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_BINARY_INPUT_DESCRIPTION_ID, (description)) \ + ZB_ZCL_FINISH_DECLARE_ATTRIB_LIST + +namespace esphome::zigbee { + +class ZigbeeBinarySensor : public ZigbeeEntity, public Component { + public: + explicit ZigbeeBinarySensor(binary_sensor::BinarySensor *binary_sensor); + void set_cluster_attributes(BinaryAttrs &cluster_attributes) { this->cluster_attributes_ = &cluster_attributes; } + + void setup() override; + void dump_config() override; + + protected: + BinaryAttrs *cluster_attributes_{nullptr}; + binary_sensor::BinarySensor *binary_sensor_; +}; + +} // namespace esphome::zigbee +#endif diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp new file mode 100644 index 0000000000..c9027d0a74 --- /dev/null +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -0,0 +1,190 @@ +#include "zigbee_zephyr.h" +#if defined(USE_ZIGBEE) && defined(USE_NRF52) +#include "esphome/core/log.h" +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +} + +namespace esphome::zigbee { + +static const char *const TAG = "zigbee"; + +ZigbeeComponent *global_zigbee = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +const uint8_t IEEE_ADDR_BUF_SIZE = 17; + +void ZigbeeComponent::zboss_signal_handler_esphome(zb_bufid_t bufid) { + zb_zdo_app_signal_hdr_t *sig_hndler = nullptr; + zb_zdo_app_signal_type_t sig = zb_get_app_signal(bufid, &sig_hndler); + zb_ret_t status = ZB_GET_APP_SIGNAL_STATUS(bufid); + + switch (sig) { + case ZB_ZDO_SIGNAL_SKIP_STARTUP: + ESP_LOGD(TAG, "ZB_ZDO_SIGNAL_SKIP_STARTUP, status: %d", status); + break; + case ZB_ZDO_SIGNAL_PRODUCTION_CONFIG_READY: + ESP_LOGD(TAG, "ZB_ZDO_SIGNAL_PRODUCTION_CONFIG_READY, status: %d", status); + break; + case ZB_ZDO_SIGNAL_LEAVE: + ESP_LOGD(TAG, "ZB_ZDO_SIGNAL_LEAVE, status: %d", status); + break; + case ZB_BDB_SIGNAL_DEVICE_REBOOT: + ESP_LOGD(TAG, "ZB_BDB_SIGNAL_DEVICE_REBOOT, status: %d", status); + if (status == RET_OK) { + on_join_(); + } + break; + case ZB_BDB_SIGNAL_STEERING: + break; + case ZB_COMMON_SIGNAL_CAN_SLEEP: + ESP_LOGV(TAG, "ZB_COMMON_SIGNAL_CAN_SLEEP, status: %d", status); + break; + case ZB_BDB_SIGNAL_DEVICE_FIRST_START: + ESP_LOGD(TAG, "ZB_BDB_SIGNAL_DEVICE_FIRST_START, status: %d", status); + break; + case ZB_NLME_STATUS_INDICATION: + ESP_LOGD(TAG, "ZB_NLME_STATUS_INDICATION, status: %d", status); + break; + case ZB_BDB_SIGNAL_TC_REJOIN_DONE: + ESP_LOGD(TAG, "ZB_BDB_SIGNAL_TC_REJOIN_DONE, status: %d", status); + break; + default: + ESP_LOGD(TAG, "zboss_signal_handler sig: %d, status: %d", sig, status); + break; + } + + auto err = zigbee_default_signal_handler(bufid); + if (err != RET_OK) { + ESP_LOGE(TAG, "Zigbee_default_signal_handler ERROR %u [%s]", err, zb_error_to_string_get(err)); + } + + switch (sig) { + case ZB_BDB_SIGNAL_STEERING: + ESP_LOGD(TAG, "ZB_BDB_SIGNAL_STEERING, status: %d", status); + if (status == RET_OK) { + zb_ext_pan_id_t extended_pan_id; + char ieee_addr_buf[IEEE_ADDR_BUF_SIZE] = {0}; + int addr_len; + + zb_get_extended_pan_id(extended_pan_id); + addr_len = ieee_addr_to_str(ieee_addr_buf, sizeof(ieee_addr_buf), extended_pan_id); + + for (int i = 0; i < addr_len; ++i) { + if (ieee_addr_buf[i] != '0') { + on_join_(); + break; + } + } + } + break; + } + + /* All callbacks should either reuse or free passed buffers. + * If bufid == 0, the buffer is invalid (not passed). + */ + if (bufid) { + zb_buf_free(bufid); + } +} + +void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { + zb_zcl_device_callback_param_t *p_device_cb_param = ZB_BUF_GET_PARAM(bufid, zb_zcl_device_callback_param_t); + zb_zcl_device_callback_id_t device_cb_id = p_device_cb_param->device_cb_id; + zb_uint16_t cluster_id = p_device_cb_param->cb_param.set_attr_value_param.cluster_id; + zb_uint16_t attr_id = p_device_cb_param->cb_param.set_attr_value_param.attr_id; + auto endpoint = p_device_cb_param->endpoint; + + ESP_LOGI(TAG, "Zcl_device_cb %s id %hd, cluster_id %d, attr_id %d, endpoint: %d", __func__, device_cb_id, cluster_id, + attr_id, endpoint); + + // endpoints are enumerated from 1 + if (global_zigbee->callbacks_.size() >= endpoint) { + global_zigbee->callbacks_[endpoint - 1](bufid); + return; + } + p_device_cb_param->status = RET_ERROR; +} + +void ZigbeeComponent::on_join_() { + this->defer([this]() { + ESP_LOGD(TAG, "Joined the network"); + this->join_trigger_.trigger(); + this->join_cb_.call(); + }); +} + +#ifdef USE_ZIGBEE_WIPE_ON_BOOT +void ZigbeeComponent::erase_flash_(int area) { + const struct flash_area *fap; + flash_area_open(area, &fap); + flash_area_erase(fap, 0, fap->fa_size); + flash_area_close(fap); +} +#endif + +void ZigbeeComponent::setup() { + global_zigbee = this; + auto err = settings_subsys_init(); + if (err) { + ESP_LOGE(TAG, "Failed to initialize settings subsystem, err: %d", err); + return; + } + +#ifdef USE_ZIGBEE_WIPE_ON_BOOT + erase_flash_(FIXED_PARTITION_ID(ZBOSS_NVRAM)); + erase_flash_(FIXED_PARTITION_ID(ZBOSS_PRODUCT_CONFIG)); + erase_flash_(FIXED_PARTITION_ID(SETTINGS_STORAGE)); +#endif + + ZB_ZCL_REGISTER_DEVICE_CB(zcl_device_cb); + err = settings_load(); + if (err) { + ESP_LOGE(TAG, "Cannot load settings, err: %d", err); + return; + } + zigbee_enable(); +} + +void ZigbeeComponent::dump_config() { + bool wipe = false; +#ifdef USE_ZIGBEE_WIPE_ON_BOOT + wipe = true; +#endif + ESP_LOGCONFIG(TAG, + "Zigbee\n" + " Wipe on boot: %s", + YESNO(wipe)); +} + +static void send_attribute_report(zb_bufid_t bufid, zb_uint16_t cmd_id) { + ESP_LOGD(TAG, "Force zboss scheduler to wake and send attribute report"); + zb_buf_free(bufid); +} + +void ZigbeeComponent::flush() { this->need_flush_ = true; } + +void ZigbeeComponent::loop() { + if (this->need_flush_) { + this->need_flush_ = false; + zb_buf_get_out_delayed_ext(send_attribute_report, 0, 0); + } +} + +void ZigbeeComponent::factory_reset() { + ESP_LOGD(TAG, "Factory reset"); + ZB_SCHEDULE_APP_CALLBACK(zb_bdb_reset_via_local_action, 0); +} + +} // namespace esphome::zigbee + +extern "C" void zboss_signal_handler(zb_uint8_t param) { + esphome::zigbee::global_zigbee->zboss_signal_handler_esphome(param); +} +#endif diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h new file mode 100644 index 0000000000..853c6deb4d --- /dev/null +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -0,0 +1,104 @@ +#pragma once +#include "esphome/core/defines.h" +#if defined(USE_ZIGBEE) && defined(USE_NRF52) +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +extern "C" { +#include +#include +} + +// copy of ZB_DECLARE_SIMPLE_DESC. Due to https://github.com/nrfconnect/sdk-nrfxlib/pull/666 +#define ESPHOME_ZB_DECLARE_SIMPLE_DESC(ep_name, in_clusters_count, out_clusters_count) \ + typedef ZB_PACKED_PRE struct zb_af_simple_desc_##ep_name##_##in_clusters_count##_##out_clusters_count##_s { \ + zb_uint8_t endpoint; /* Endpoint */ \ + zb_uint16_t app_profile_id; /* Application profile identifier */ \ + zb_uint16_t app_device_id; /* Application device identifier */ \ + zb_bitfield_t app_device_version : 4; /* Application device version */ \ + zb_bitfield_t reserved : 4; /* Reserved */ \ + zb_uint8_t app_input_cluster_count; /* Application input cluster count */ \ + zb_uint8_t app_output_cluster_count; /* Application output cluster count */ \ + /* Application input and output cluster list */ \ + zb_uint16_t app_cluster_list[(in_clusters_count) + (out_clusters_count)]; \ + } ZB_PACKED_STRUCT zb_af_simple_desc_##ep_name##_##in_clusters_count##_##out_clusters_count##_t + +#define ESPHOME_CAT7(a, b, c, d, e, f, g) a##b##c##d##e##f##g +// needed to use ESPHOME_ZB_DECLARE_SIMPLE_DESC +#define ESPHOME_ZB_AF_SIMPLE_DESC_TYPE(ep_name, in_num, out_num) \ + ESPHOME_CAT7(zb_af_simple_desc_, ep_name, _, in_num, _, out_num, _t) + +// needed to use ESPHOME_ZB_DECLARE_SIMPLE_DESC +#define ESPHOME_ZB_ZCL_DECLARE_SIMPLE_DESC(ep_name, ep_id, in_clust_num, out_clust_num, ...) \ + ESPHOME_ZB_DECLARE_SIMPLE_DESC(ep_name, in_clust_num, out_clust_num); \ + ESPHOME_ZB_AF_SIMPLE_DESC_TYPE(ep_name, in_clust_num, out_clust_num) \ + simple_desc_##ep_name = {ep_id, ZB_AF_HA_PROFILE_ID, ZB_HA_SIMPLE_SENSOR_DEVICE_ID, 0, 0, in_clust_num, \ + out_clust_num, {__VA_ARGS__}} + +// needed to use ESPHOME_ZB_ZCL_DECLARE_SIMPLE_DESC +#define ESPHOME_ZB_HA_DECLARE_EP(ep_name, ep_id, cluster_list, in_cluster_num, out_cluster_num, report_attr_count, \ + ...) \ + ESPHOME_ZB_ZCL_DECLARE_SIMPLE_DESC(ep_name, ep_id, in_cluster_num, out_cluster_num, __VA_ARGS__); \ + ZBOSS_DEVICE_DECLARE_REPORTING_CTX(reporting_info##ep_name, report_attr_count); \ + ZB_AF_DECLARE_ENDPOINT_DESC(ep_name, ep_id, ZB_AF_HA_PROFILE_ID, 0, NULL, \ + ZB_ZCL_ARRAY_SIZE(cluster_list, zb_zcl_cluster_desc_t), cluster_list, \ + (zb_af_simple_desc_1_1_t *) &simple_desc_##ep_name, report_attr_count, \ + reporting_info##ep_name, 0, NULL) + +namespace esphome::zigbee { + +struct BinaryAttrs { + zb_bool_t out_of_service; + zb_bool_t present_value; + zb_uint8_t status_flags; + zb_uchar_t description[ZB_ZCL_MAX_STRING_SIZE]; +}; + +struct AnalogAttrs { + zb_bool_t out_of_service; + float present_value; + zb_uint8_t status_flags; + zb_uchar_t description[ZB_ZCL_MAX_STRING_SIZE]; + float max_present_value; + float min_present_value; + float resolution; +}; + +class ZigbeeComponent : public Component { + public: + void setup() override; + void dump_config() override; + void add_callback(zb_uint8_t endpoint, std::function &&cb) { + // endpoints are enumerated from 1 + this->callbacks_[endpoint - 1] = std::move(cb); + } + void add_join_callback(std::function &&cb) { this->join_cb_.add(std::move(cb)); } + void zboss_signal_handler_esphome(zb_bufid_t bufid); + void factory_reset(); + Trigger<> *get_join_trigger() { return &this->join_trigger_; }; + void flush(); + void loop() override; + + protected: + static void zcl_device_cb(zb_bufid_t bufid); + void on_join_(); +#ifdef USE_ZIGBEE_WIPE_ON_BOOT + void erase_flash_(int area); +#endif + StaticVector, ZIGBEE_ENDPOINTS_COUNT> callbacks_; + CallbackManager join_cb_; + Trigger<> join_trigger_; + bool need_flush_{false}; +}; + +class ZigbeeEntity { + public: + void set_parent(ZigbeeComponent *parent) { this->parent_ = parent; } + void set_end_point(zb_uint8_t end_point) { this->end_point_ = end_point; } + + protected: + zb_uint8_t end_point_{0}; + ZigbeeComponent *parent_{nullptr}; +}; + +} // namespace esphome::zigbee +#endif diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py new file mode 100644 index 0000000000..ce55675c41 --- /dev/null +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -0,0 +1,265 @@ +from datetime import datetime + +from esphome import automation +import esphome.codegen as cg +from esphome.components.zephyr import zephyr_add_prj_conf +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_NAME, __version__ +from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import ( + AssignmentExpression, + MockObj, + VariableDeclarationExpression, +) +from esphome.types import ConfigType + +from .const_zephyr import ( + CONF_ON_JOIN, + CONF_WIPE_ON_BOOT, + CONF_ZIGBEE_BINARY_SENSOR, + CONF_ZIGBEE_ID, + KEY_EP_NUMBER, + KEY_ZIGBEE, + ZB_ZCL_BASIC_ATTRS_EXT_T, + ZB_ZCL_CLUSTER_ID_BASIC, + ZB_ZCL_CLUSTER_ID_BINARY_INPUT, + ZB_ZCL_CLUSTER_ID_IDENTIFY, + ZB_ZCL_IDENTIFY_ATTRS_T, + BinaryAttrs, + ZigbeeComponent, + zigbee_ns, +) + +ZigbeeBinarySensor = zigbee_ns.class_("ZigbeeBinarySensor", cg.Component) + +zephyr_binary_sensor = cv.Schema( + { + cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent), + cv.OnlyWith(CONF_ZIGBEE_BINARY_SENSOR, ["nrf52", "zigbee"]): cv.declare_id( + ZigbeeBinarySensor + ), + } +) + + +async def zephyr_to_code(config: ConfigType) -> None: + zephyr_add_prj_conf("ZIGBEE", True) + zephyr_add_prj_conf("ZIGBEE_APP_UTILS", True) + zephyr_add_prj_conf("ZIGBEE_ROLE_END_DEVICE", True) + + zephyr_add_prj_conf("ZIGBEE_CHANNEL_SELECTION_MODE_MULTI", True) + + zephyr_add_prj_conf("CRYPTO", True) + + zephyr_add_prj_conf("NET_IPV6", False) + zephyr_add_prj_conf("NET_IP_ADDR_CHECK", False) + zephyr_add_prj_conf("NET_UDP", False) + + if config[CONF_WIPE_ON_BOOT]: + cg.add_define("USE_ZIGBEE_WIPE_ON_BOOT") + var = cg.new_Pvariable(config[CONF_ID]) + + if on_join_config := config.get(CONF_ON_JOIN): + await automation.build_automation(var.get_join_trigger(), [], on_join_config) + + await cg.register_component(var, config) + + await _attr_to_code(config) + CORE.add_job(_ctx_to_code, config) + + +async def _attr_to_code(config: ConfigType) -> None: + # Create the basic attributes structure and attribute list + basic_attrs = zigbee_new_variable("zigbee_basic_attrs", ZB_ZCL_BASIC_ATTRS_EXT_T) + zigbee_new_attr_list( + "zigbee_basic_attrib_list", + "ZB_ZCL_DECLARE_BASIC_ATTRIB_LIST_EXT", + zigbee_assign(basic_attrs.zcl_version, cg.RawExpression("ZB_ZCL_VERSION")), + zigbee_assign(basic_attrs.app_version, 0), + zigbee_assign(basic_attrs.stack_version, 0), + zigbee_assign(basic_attrs.hw_version, 0), + zigbee_set_string(basic_attrs.mf_name, "esphome"), + zigbee_set_string(basic_attrs.model_id, CORE.name), + zigbee_set_string( + basic_attrs.date_code, datetime.now().strftime("%d/%m/%y %H:%M") + ), + zigbee_assign( + basic_attrs.power_source, + cg.RawExpression("ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE"), + ), + zigbee_set_string(basic_attrs.location_id, ""), + zigbee_assign( + basic_attrs.ph_env, cg.RawExpression("ZB_ZCL_BASIC_ENV_UNSPECIFIED") + ), + zigbee_set_string(basic_attrs.sw_ver, __version__), + ) + + # Create the identify attributes structure and attribute list + identify_attrs = zigbee_new_variable( + "zigbee_identify_attrs", ZB_ZCL_IDENTIFY_ATTRS_T + ) + zigbee_new_attr_list( + "zigbee_identify_attrib_list", + "ZB_ZCL_DECLARE_IDENTIFY_ATTRIB_LIST", + zigbee_assign( + identify_attrs.identify_time, + cg.RawExpression("ZB_ZCL_IDENTIFY_IDENTIFY_TIME_DEFAULT_VALUE"), + ), + ) + + +def zigbee_new_variable(name: str, type_: str) -> cg.MockObj: + """Create a global variable with the given name and type.""" + decl = VariableDeclarationExpression(type_, "", name) + CORE.add_global(decl) + return MockObj(name, ".") + + +def zigbee_assign(target: cg.MockObj, expression: cg.RawExpression | int) -> str: + """Assign an expression to a target and return a reference to it.""" + cg.add(AssignmentExpression("", "", target, expression)) + return f"&{target}" + + +def zigbee_set_string(target: cg.MockObj, value: str) -> str: + """Set a ZCL string value and return the target name (arrays decay to pointers).""" + cg.add( + cg.RawExpression( + f"ZB_ZCL_SET_STRING_VAL({target}, {cg.safe_exp(value)}, ZB_ZCL_STRING_CONST_SIZE({cg.safe_exp(value)}))" + ) + ) + return str(target) + + +def zigbee_new_attr_list(name: str, macro: str, *args: str) -> str: + """Create an attribute list using a ZBOSS macro and return the name.""" + obj = cg.RawExpression(f"{macro}({name}, {', '.join(args)})") + CORE.add_global(obj) + return name + + +class ZigbeeClusterDesc: + """Represents a Zigbee cluster descriptor for code generation.""" + + def __init__(self, cluster_id: str, attr_list_name: str | None = None) -> None: + self._cluster_id = cluster_id + self._attr_list_name = attr_list_name + + @property + def cluster_id(self) -> str: + return self._cluster_id + + @property + def has_attrs(self) -> bool: + return self._attr_list_name is not None + + def __str__(self) -> str: + role = ( + "ZB_ZCL_CLUSTER_SERVER_ROLE" + if self._attr_list_name + else "ZB_ZCL_CLUSTER_CLIENT_ROLE" + ) + if self._attr_list_name: + attr_count = f"ZB_ZCL_ARRAY_SIZE({self._attr_list_name}, zb_zcl_attr_t)" + return f"ZB_ZCL_CLUSTER_DESC({self._cluster_id}, {attr_count}, {self._attr_list_name}, {role}, ZB_ZCL_MANUF_CODE_INVALID)" + return f"ZB_ZCL_CLUSTER_DESC({self._cluster_id}, 0, NULL, {role}, ZB_ZCL_MANUF_CODE_INVALID)" + + +def zigbee_new_cluster_list( + name: str, clusters: list[ZigbeeClusterDesc] +) -> tuple[str, list[ZigbeeClusterDesc]]: + """Create a cluster list array and return its name and the clusters.""" + # Always include basic and identify clusters first + all_clusters = [ + ZigbeeClusterDesc(ZB_ZCL_CLUSTER_ID_BASIC, "zigbee_basic_attrib_list"), + ZigbeeClusterDesc(ZB_ZCL_CLUSTER_ID_IDENTIFY, "zigbee_identify_attrib_list"), + ] + all_clusters.extend(clusters) + + cluster_strs = [str(c) for c in all_clusters] + CORE.add_global( + cg.RawExpression( + f"zb_zcl_cluster_desc_t {name}[] = {{{', '.join(cluster_strs)}}}" + ) + ) + return (name, all_clusters) + + +def zigbee_register_ep( + ep_name: str, + cluster_list_name: str, + report_attr_count: int, + clusters: list[ZigbeeClusterDesc], + slot_index: int, +) -> None: + """Register a Zigbee endpoint.""" + in_cluster_num = sum(1 for c in clusters if c.has_attrs) + out_cluster_num = len(clusters) - in_cluster_num + cluster_ids = [c.cluster_id for c in clusters] + + # Store endpoint name for device context generation + CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER][slot_index] = ep_name + + # Generate the endpoint declaration + ep_id = slot_index + 1 # Endpoints are 1-indexed + obj = cg.RawExpression( + f"ESPHOME_ZB_HA_DECLARE_EP({ep_name}, {ep_id}, {cluster_list_name}, " + f"{in_cluster_num}, {out_cluster_num}, {report_attr_count}, {', '.join(cluster_ids)})" + ) + CORE.add_global(obj) + + +@coroutine_with_priority(CoroPriority.LATE) +async def _ctx_to_code(config: ConfigType) -> None: + cg.add_define("ZIGBEE_ENDPOINTS_COUNT", len(CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER])) + cg.add_global( + cg.RawExpression( + f"ZBOSS_DECLARE_DEVICE_CTX_EP_VA(zb_device_ctx, &{', &'.join(CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER])})" + ) + ) + cg.add(cg.RawExpression("ZB_AF_REGISTER_DEVICE_CTX(&zb_device_ctx)")) + + +async def zephyr_setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: + CORE.add_job(_add_binary_sensor, entity, config) + + +async def _add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: + # Find the next available endpoint slot + slot_index = next( + (i for i, v in enumerate(CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER]) if v == ""), None + ) + + # Create unique names for this sensor's variables based on slot index + prefix = f"zigbee_ep{slot_index + 1}" + attrs_name = f"{prefix}_binary_attrs" + attr_list_name = f"{prefix}_binary_input_attrib_list" + cluster_list_name = f"{prefix}_cluster_list" + ep_name = f"{prefix}_ep" + + # Create the binary attributes structure + binary_attrs = zigbee_new_variable(attrs_name, BinaryAttrs) + attr_list = zigbee_new_attr_list( + attr_list_name, + "ESPHOME_ZB_ZCL_DECLARE_BINARY_INPUT_ATTRIB_LIST", + zigbee_assign(binary_attrs.out_of_service, 0), + zigbee_assign(binary_attrs.present_value, 0), + zigbee_assign(binary_attrs.status_flags, 0), + zigbee_set_string(binary_attrs.description, config[CONF_NAME]), + ) + + # Create cluster list and register endpoint + cluster_list_name, clusters = zigbee_new_cluster_list( + cluster_list_name, + [ZigbeeClusterDesc(ZB_ZCL_CLUSTER_ID_BINARY_INPUT, attr_list)], + ) + zigbee_register_ep(ep_name, cluster_list_name, 2, clusters, slot_index) + + # Create the ZigbeeBinarySensor component + var = cg.new_Pvariable(config[CONF_ZIGBEE_BINARY_SENSOR], entity) + await cg.register_component(var, config) + + cg.add(var.set_end_point(slot_index + 1)) + cg.add(var.set_cluster_attributes(binary_attrs)) + hub = await cg.get_variable(config[CONF_ZIGBEE_ID]) + cg.add(var.set_parent(hub)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1fddc426d4..cee46a2df0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -299,6 +299,9 @@ #define USE_NRF52_UICR_ERASE #define USE_SOFTDEVICE_ID 7 #define USE_SOFTDEVICE_VERSION 1 +#define USE_ZIGBEE +#define USE_ZIGBEE_WIPE_ON_BOOT +#define ZIGBEE_ENDPOINTS_COUNT 8 #endif // Disabled feature flags diff --git a/script/helpers_zephyr.py b/script/helpers_zephyr.py index f72b335e64..1242a60cf4 100644 --- a/script/helpers_zephyr.py +++ b/script/helpers_zephyr.py @@ -17,6 +17,7 @@ def load_idedata(environment, temp_folder, platformio_ini): """ #include int main() { return 0;} +extern "C" void zboss_signal_handler() {}; """, encoding="utf-8", ) @@ -27,6 +28,12 @@ int main() { return 0;} CONFIG_NEWLIB_LIBC=y CONFIG_BT=y CONFIG_ADC=y +#zigbee begin +CONFIG_ZIGBEE=y +CONFIG_CRYPTO=y +CONFIG_NVS=y +CONFIG_SETTINGS=y +#zigbee end """, encoding="utf-8", ) @@ -44,10 +51,11 @@ CONFIG_ADC=y def extract_defines(command): define_pattern = re.compile(r"-D\s*([^\s]+)") + ignore_prefixes = ("_ASMLANGUAGE", "NRF_802154_ECB_PRIORITY=") return [ - match + match.replace("\\", "") for match in define_pattern.findall(command) - if match not in ("_ASMLANGUAGE") + if not any(match.startswith(prefix) for prefix in ignore_prefixes) ] def find_cxx_path(commands): diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml new file mode 100644 index 0000000000..eb30205446 --- /dev/null +++ b/tests/components/zigbee/common.yaml @@ -0,0 +1,34 @@ +--- +binary_sensor: + - platform: template + name: "Garage Door Open 1" + - platform: template + name: "Garage Door Open 2" + - platform: template + name: "Garage Door Open 3" + - platform: template + name: "Garage Door Open 4" + - platform: template + name: "Garage Door Open 5" + - platform: template + name: "Garage Door Open 6" + - platform: template + name: "Garage Door Open 7" + internal: True + - platform: template + name: "Garage Door Open 8" + - platform: template + name: "Garage Door Open 9" + +zigbee: + wipe_on_boot: true + on_join: + then: + - logger.log: "Joined network" + +output: + - platform: template + id: output_factory + type: binary + write_action: + - zigbee.factory_reset diff --git a/tests/components/zigbee/test.nrf52-adafruit.yaml b/tests/components/zigbee/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/zigbee/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/zigbee/test.nrf52-mcumgr.yaml b/tests/components/zigbee/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/zigbee/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/zigbee/test.nrf52-xiao-ble.yaml b/tests/components/zigbee/test.nrf52-xiao-ble.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/zigbee/test.nrf52-xiao-ble.yaml @@ -0,0 +1 @@ +<<: !include common.yaml