From 66616549c71e6f424d07c4276ac3e55426bb2022 Mon Sep 17 00:00:00 2001 From: Michal Witkowski <9655971+mwitkow@users.noreply.github.com> Date: Sun, 25 Jan 2026 13:19:10 +0100 Subject: [PATCH 1/2] feat(zigbee): add on_identify_effect and on_custom_command triggers --- components/zigbee/__init__.py | 35 ++++++++++++++++++++++++++++++++++ components/zigbee/automation.h | 17 +++++++++++++++++ components/zigbee/const.py | 2 ++ components/zigbee/types.py | 7 +++++++ components/zigbee/zigbee.cpp | 22 +++++++++++++++++++++ components/zigbee/zigbee.h | 4 ++++ 6 files changed, 87 insertions(+) diff --git a/components/zigbee/__init__.py b/components/zigbee/__init__.py index b25e02a..4b3cf80 100644 --- a/components/zigbee/__init__.py +++ b/components/zigbee/__init__.py @@ -65,6 +65,8 @@ def require_vfs_select(): CONF_REPORT, CONF_ROLE, CONF_ROUTER, + CONF_ON_IDENTIFY_EFFECT, + CONF_ON_CUSTOM_COMMAND, CONF_SCALE, BinarySensor, Sensor, @@ -78,6 +80,8 @@ def require_vfs_select(): ZigBeeAttribute, ZigBeeComponent, ZigBeeJoinTrigger, + ZigbeeIdentifyEffectTrigger, + ZigbeeCustomCommandTrigger, ZigBeeOnReportTrigger, ZigBeeOnValueTrigger, ) @@ -340,6 +344,20 @@ def _require_vfs_select(config): cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ZigBeeJoinTrigger), } ), + cv.Optional(CONF_ON_IDENTIFY_EFFECT): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + ZigbeeIdentifyEffectTrigger + ), + } + ), + cv.Optional(CONF_ON_CUSTOM_COMMAND): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + ZigbeeCustomCommandTrigger + ), + } + ), } ).extend(cv.COMPONENT_SCHEMA), cv.require_framework_version(esp_idf=cv.Version(5, 1, 2)), @@ -535,6 +553,23 @@ async def to_code(config): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) + for conf in config.get(CONF_ON_IDENTIFY_EFFECT, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(cg.uint8, "effect_id"), (cg.uint8, "effect_variant")], conf) + + for conf in config.get(CONF_ON_CUSTOM_COMMAND, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation( + trigger, + [ + (cg.uint16, "cluster_id"), + (cg.uint8, "command_id"), + (cg.uint16, "size"), + (cg.RawExpression("void *"), "value"), + ], + conf, + ) + ZIGBEE_ACTION_SCHEMA = cv.Schema( { diff --git a/components/zigbee/automation.h b/components/zigbee/automation.h index 2ed58a7..07c2152 100644 --- a/components/zigbee/automation.h +++ b/components/zigbee/automation.h @@ -23,6 +23,23 @@ class ZigBeeJoinTrigger : public Trigger<> { } }; +class ZigbeeIdentifyEffectTrigger : public Trigger { + public: + explicit ZigbeeIdentifyEffectTrigger(ZigBeeComponent *parent) { + parent->on_identify_effect_callback_.add( + [this](uint8_t effect_id, uint8_t effect_variant) { this->trigger(effect_id, effect_variant); }); + } +}; + +class ZigbeeCustomCommandTrigger : public Trigger { + public: + explicit ZigbeeCustomCommandTrigger(ZigBeeComponent *parent) { + parent->on_custom_command_callback_.add([this](uint16_t cluster_id, uint8_t command_id, uint16_t size, void *value) { + this->trigger(cluster_id, command_id, size, value); + }); + } +}; + template class ResetZigbeeAction : public Action, public Parented { public: #if ESPHOME_VERSION_CODE >= VERSION_CODE(2025, 11, 0) diff --git a/components/zigbee/const.py b/components/zigbee/const.py index 4a9b2ca..b06cc6c 100644 --- a/components/zigbee/const.py +++ b/components/zigbee/const.py @@ -21,6 +21,8 @@ CONF_ROUTER = "router" CONF_AS_GENERIC = "as_generic" CONF_ON_REPORT = "on_report" +CONF_ON_IDENTIFY_EFFECT = "on_identify_effect" +CONF_ON_CUSTOM_COMMAND = "on_custom_command" # dummies for upstream compatibility binary_sensor_ns = cg.esphome_ns.namespace("binary_sensor") diff --git a/components/zigbee/types.py b/components/zigbee/types.py index 3992622..63c98b9 100644 --- a/components/zigbee/types.py +++ b/components/zigbee/types.py @@ -11,6 +11,13 @@ ZigBeeOnReportTrigger = zigbee_ns.class_( "ZigBeeOnReportTrigger", automation.Trigger.template(int), cg.Component ) +ZigbeeIdentifyEffectTrigger = zigbee_ns.class_( + "ZigbeeIdentifyEffectTrigger", automation.Trigger.template(cg.uint8, cg.uint8) +) +ZigbeeCustomCommandTrigger = zigbee_ns.class_( + "ZigbeeCustomCommandTrigger", + automation.Trigger.template(cg.uint16, cg.uint8, cg.uint16, cg.RawExpression("void *")), +) ResetZigbeeAction = zigbee_ns.class_( "ResetZigbeeAction", automation.Action, cg.Parented.template(ZigBeeComponent) ) diff --git a/components/zigbee/zigbee.cpp b/components/zigbee/zigbee.cpp index 80f8e02..4b1e458 100644 --- a/components/zigbee/zigbee.cpp +++ b/components/zigbee/zigbee.cpp @@ -268,6 +268,19 @@ static esp_err_t zb_action_handler(esp_zb_core_action_callback_id_t callback_id, case ESP_ZB_CORE_REPORT_ATTR_CB_ID: ret = zb_report_attribute_handler((esp_zb_zcl_report_attr_message_t *) message); break; + case ESP_ZB_CORE_IDENTIFY_EFFECT_CB_ID: { + esp_zb_zcl_identify_effect_message_t *msg = (esp_zb_zcl_identify_effect_message_t *) message; + ESP_LOGD(TAG, "Receive Identify Effect: effect_id(0x%x), variant(0x%x)", msg->effect_id, msg->effect_variant); + zigbeeC->handle_identify_effect(msg->info.dst_endpoint, msg->effect_id, msg->effect_variant); + break; + } + case ESP_ZB_CORE_CMD_CUSTOM_CLUSTER_REQ_CB_ID: { + esp_zb_zcl_custom_cluster_command_message_t *msg = (esp_zb_zcl_custom_cluster_command_message_t *) message; + ESP_LOGD(TAG, "Receive Custom Cluster command: cluster(0x%x), command(0x%x)", msg->info.cluster, + msg->info.command.id); + zigbeeC->handle_custom_command(msg); + break; + } case ESP_ZB_CORE_CMD_DEFAULT_RESP_CB_ID: ESP_LOGD(TAG, "Receive Zigbee default response callback"); break; @@ -297,6 +310,15 @@ void ZigBeeComponent::handle_report_attribute(uint8_t dst_endpoint, uint16_t clu attr->second->on_report(attribute, src_address, src_endpoint); } +void ZigBeeComponent::handle_identify_effect(uint8_t endpoint, uint8_t effect_id, uint8_t effect_variant) { + this->on_identify_effect_callback_.call(effect_id, effect_variant); +} + +void ZigBeeComponent::handle_custom_command(esp_zb_zcl_custom_cluster_command_message_t *message) { + this->on_custom_command_callback_.call(message->info.cluster, message->info.command.id, message->data.size, + message->data.value); +} + void ZigBeeComponent::create_default_cluster(uint8_t endpoint_id, esp_zb_ha_standard_devices_t device_id) { this->cluster_list_[endpoint_id] = esphome_zb_default_clusters_create(device_id); this->endpoint_list_[endpoint_id] = device_id; diff --git a/components/zigbee/zigbee.h b/components/zigbee/zigbee.h index fd7c433..2f8cdd0 100644 --- a/components/zigbee/zigbee.h +++ b/components/zigbee/zigbee.h @@ -79,6 +79,8 @@ class ZigBeeComponent : public Component { void handle_attribute(esp_zb_device_cb_common_info_t info, esp_zb_zcl_attribute_t attribute); void handle_report_attribute(uint8_t dst_endpoint, uint16_t cluster, esp_zb_zcl_attribute_t attribute, esp_zb_zcl_addr_t src_address, uint8_t src_endpoint); + void handle_identify_effect(uint8_t endpoint, uint8_t effect_id, uint8_t effect_variant); + void handle_custom_command(esp_zb_zcl_custom_cluster_command_message_t *message); void searchBindings(); static void bindingTableCb(const esp_zb_zdo_binding_table_info_t *table_info, void *user_ctx); @@ -101,6 +103,8 @@ class ZigBeeComponent : public Component { bool started_ = false; CallbackManager on_join_callback_{}; + CallbackManager on_identify_effect_callback_{}; + CallbackManager on_custom_command_callback_{}; std::deque> reporting_list; struct { std::string model; From 0b3b4b0b7006e8a7e3d952c324911f2bfb47a229 Mon Sep 17 00:00:00 2001 From: Michal Witkowski <9655971+mwitkow@users.noreply.github.com> Date: Sun, 25 Jan 2026 13:19:26 +0100 Subject: [PATCH 2/2] feat(zigbee): remember the last successfully joined channel --- components/zigbee/__init__.py | 18 ++++++++++++++++++ components/zigbee/const.py | 2 ++ components/zigbee/zigbee.cpp | 13 +++++++++++-- components/zigbee/zigbee.h | 7 +++++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/components/zigbee/__init__.py b/components/zigbee/__init__.py index b25e02a..f949350 100644 --- a/components/zigbee/__init__.py +++ b/components/zigbee/__init__.py @@ -58,6 +58,8 @@ def require_vfs_select(): CONF_DEVICE_TYPE, CONF_ENDPOINTS, CONF_IDENT_TIME, + CONF_CHANNELS, + CONF_STACK_SIZE, CONF_MANUFACTURER, CONF_NUM, CONF_ON_JOIN, @@ -249,6 +251,8 @@ def _require_vfs_select(config): cv.Optional(CONF_VERSION, default=0): cv.int_, cv.Optional(CONF_AREA, default=0): cv.int_, # make enum cv.Optional(CONF_ROUTER, default=False): cv.boolean, + cv.Optional(CONF_CHANNELS): cv.string, + cv.Optional(CONF_STACK_SIZE, default=4096): cv.int_, cv.Optional(CONF_DEBUG, default=False): cv.boolean, cv.Optional(CONF_COMPONENTS): cv.Any( cv.one_of("all", "none", lower=True), @@ -506,6 +510,20 @@ async def to_code(config): ) if CONF_IDENT_TIME in config: cg.add(var.set_ident_time(config[CONF_IDENT_TIME])) + if CONF_CHANNELS in config: + # Simple mask parsing if it's a list or single channel + # For simplicity in this common contribution, we assume a single channel string for now + # or a mask. Matching user's previous usage. + channels = config[CONF_CHANNELS] + if channels.isdigit(): + mask = 1 << int(channels) + else: + # Handle list-like strings or hex if needed? + # Sticking to what was tested earlier: "15" -> 1 << 15 + mask = 1 << int(channels) + cg.add(var.set_channels(mask)) + if CONF_STACK_SIZE in config: + cg.add(var.set_stack_size(config[CONF_STACK_SIZE])) for ep in ep_list: cg.add( var.create_default_cluster(ep[CONF_NUM], DEVICE_ID[ep[CONF_DEVICE_TYPE]]) diff --git a/components/zigbee/const.py b/components/zigbee/const.py index 4a9b2ca..4da10cc 100644 --- a/components/zigbee/const.py +++ b/components/zigbee/const.py @@ -21,6 +21,8 @@ CONF_ROUTER = "router" CONF_AS_GENERIC = "as_generic" CONF_ON_REPORT = "on_report" +CONF_CHANNELS = "channels" +CONF_STACK_SIZE = "stack_size" # dummies for upstream compatibility binary_sensor_ns = cg.esphome_ns.namespace("binary_sensor") diff --git a/components/zigbee/zigbee.cpp b/components/zigbee/zigbee.cpp index 80f8e02..5c894ff 100644 --- a/components/zigbee/zigbee.cpp +++ b/components/zigbee/zigbee.cpp @@ -106,6 +106,8 @@ void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { extended_pan_id[7], extended_pan_id[6], extended_pan_id[5], extended_pan_id[4], extended_pan_id[3], extended_pan_id[2], extended_pan_id[1], extended_pan_id[0], esp_zb_get_pan_id(), esp_zb_get_current_channel()); + zigbeeC->channel_ = esp_zb_get_current_channel(); + zigbeeC->pref_.save(&zigbeeC->channel_); zigbeeC->on_join_callback_.call(); zigbeeC->connected_ = true; } else { @@ -414,6 +416,13 @@ void ZigBeeComponent::setup() { .radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(), .host_config = ESP_ZB_DEFAULT_HOST_CONFIG(), }; + this->pref_ = global_preferences->make_preference(2024012501UL); + if (this->pref_.load(&this->channel_)) { + if (this->channel_mask_ == ESP_ZB_PRIMARY_CHANNEL_MASK) { + this->channel_mask_ = (1 << this->channel_); + ESP_LOGD(TAG, "Loaded channel %d from preferences", this->channel_); + } + } #ifdef CONFIG_WIFI_COEX if (esp_coex_wifi_i154_enable() != ESP_OK) { this->mark_failed(); @@ -474,7 +483,7 @@ void ZigBeeComponent::setup() { esp_zb_core_action_handler_register(zb_action_handler); - if (esp_zb_set_primary_network_channel_set(ESP_ZB_PRIMARY_CHANNEL_MASK) != ESP_OK) { + if (esp_zb_set_primary_network_channel_set(this->channel_mask_) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); this->mark_failed(); return; @@ -488,7 +497,7 @@ void ZigBeeComponent::setup() { reporting_info.attr_id, reporting_info.cluster_id, reporting_info.ep); } } - xTaskCreate(esp_zb_task_, "Zigbee_main", 4096, NULL, 24, NULL); + xTaskCreate(esp_zb_task_, "Zigbee_main", this->stack_size_, NULL, 24, NULL); } void ZigBeeComponent::dump_config() { diff --git a/components/zigbee/zigbee.h b/components/zigbee/zigbee.h index fd7c433..e069a79 100644 --- a/components/zigbee/zigbee.h +++ b/components/zigbee/zigbee.h @@ -11,6 +11,7 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/log.h" +#include "esphome/core/preferences.h" #include "zigbee_helpers.h" #ifdef USE_ZIGBEE_TIME @@ -65,6 +66,8 @@ class ZigBeeComponent : public Component { void dump_config() override; esp_err_t create_endpoint(uint8_t endpoint_id, esp_zb_ha_standard_devices_t device_id); void set_ident_time(uint8_t ident_time); + void set_channels(uint32_t mask) { this->channel_mask_ = mask; } + void set_stack_size(uint32_t stack_size) { this->stack_size_ = stack_size; } void set_basic_cluster(std::string model, std::string manufacturer, std::string date, uint8_t power, uint8_t app_version, uint8_t stack_version, uint8_t hw_version, std::string area, uint8_t physical_env); @@ -131,6 +134,10 @@ class ZigBeeComponent : public Component { std::map, ZigBeeAttribute *> attributes_; esp_zb_ep_list_t *esp_zb_ep_list_ = esp_zb_ep_list_create(); uint8_t ident_time_; + uint32_t channel_mask_ = ESP_ZB_PRIMARY_CHANNEL_MASK; + uint32_t stack_size_{4096}; + ESPPreferenceObject pref_; + uint8_t channel_ = 0; }; extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct);