From e45cad45fe360de3be7992dc0ce1b43e22cbf01b Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 13 Jan 2026 23:39:28 +0100 Subject: [PATCH] [nrf52,zigbee] Add binary output as switch (#13083) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/switch/__init__.py | 5 +- esphome/components/zigbee/__init__.py | 16 ++- esphome/components/zigbee/const_zephyr.py | 2 + .../zigbee/zigbee_switch_zephyr.cpp | 111 ++++++++++++++++++ .../components/zigbee/zigbee_switch_zephyr.h | 83 +++++++++++++ esphome/components/zigbee/zigbee_zephyr.cpp | 8 +- esphome/components/zigbee/zigbee_zephyr.h | 2 +- esphome/components/zigbee/zigbee_zephyr.py | 34 +++++- tests/components/zigbee/common.yaml | 9 +- 9 files changed, 260 insertions(+), 10 deletions(-) create mode 100644 esphome/components/zigbee/zigbee_switch_zephyr.cpp create mode 100644 esphome/components/zigbee/zigbee_switch_zephyr.h diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index e9473012cf..7424d7c92f 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -1,7 +1,7 @@ from esphome import automation 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 import esphome.config_validation as cv from esphome.const import ( CONF_DEVICE_CLASS, @@ -74,6 +74,7 @@ validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True) _SWITCH_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) + .extend(zigbee.SWITCH_SCHEMA) .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSwitchComponent), @@ -103,6 +104,7 @@ _SWITCH_SCHEMA = ( _SWITCH_SCHEMA.add_extra(entity_duplicate_validator("switch")) +_SWITCH_SCHEMA.add_extra(zigbee.validate_switch) def switch_schema( @@ -165,6 +167,7 @@ async def setup_switch_core_(var, config): cg.add(var.set_device_class(device_class)) cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) + await zigbee.setup_switch(var, config) async def register_switch(var, config): diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index e363164997..2281dd38a9 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -23,7 +23,7 @@ from .const_zephyr import ( ZigbeeComponent, zigbee_ns, ) -from .zigbee_zephyr import zephyr_binary_sensor, zephyr_sensor +from .zigbee_zephyr import zephyr_binary_sensor, zephyr_sensor, zephyr_switch _LOGGER = logging.getLogger(__name__) @@ -41,6 +41,7 @@ def zigbee_set_core_data(config: ConfigType) -> ConfigType: BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_binary_sensor) SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_sensor) +SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -107,6 +108,15 @@ async def setup_sensor(entity: cg.MockObj, config: ConfigType) -> None: await zephyr_setup_sensor(entity, config) +async def setup_switch(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_switch + + await zephyr_setup_switch(entity, config) + + def consume_endpoint(config: ConfigType) -> ConfigType: if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): return config @@ -130,6 +140,10 @@ def validate_sensor(config: ConfigType) -> ConfigType: return consume_endpoint(config) +def validate_switch(config: ConfigType) -> ConfigType: + return consume_endpoint(config) + + ZIGBEE_ACTION_SCHEMA = automation.maybe_simple_id( cv.Schema( { diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index 8d1f229b6e..0372f22593 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -11,6 +11,7 @@ CONF_ON_JOIN = "on_join" CONF_WIPE_ON_BOOT = "wipe_on_boot" CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" +CONF_ZIGBEE_SWITCH = "zigbee_switch" CONF_POWER_SOURCE = "power_source" POWER_SOURCE = { "UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN", @@ -35,3 +36,4 @@ 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" ZB_ZCL_CLUSTER_ID_ANALOG_INPUT = "ZB_ZCL_CLUSTER_ID_ANALOG_INPUT" +ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT = "ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT" diff --git a/esphome/components/zigbee/zigbee_switch_zephyr.cpp b/esphome/components/zigbee/zigbee_switch_zephyr.cpp new file mode 100644 index 0000000000..5454f262f9 --- /dev/null +++ b/esphome/components/zigbee/zigbee_switch_zephyr.cpp @@ -0,0 +1,111 @@ +#include "zigbee_switch_zephyr.h" +#if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_SWITCH) +#include "esphome/core/log.h" +#include + +extern "C" { +#include +#include +#include +#include +#include +} + +namespace esphome::zigbee { + +static const char *const TAG = "zigbee_on_off.switch"; + +void ZigbeeSwitch::dump_config() { + ESP_LOGCONFIG(TAG, + "Zigbee Switch\n" + " Endpoint: %d, present_value %u", + this->endpoint_, this->cluster_attributes_->present_value); +} + +void ZigbeeSwitch::setup() { + this->parent_->add_callback(this->endpoint_, [this](zb_bufid_t bufid) { this->zcl_device_cb_(bufid); }); + this->switch_->add_on_state_callback([this](bool state) { + this->cluster_attributes_->present_value = state ? ZB_TRUE : ZB_FALSE; + ESP_LOGD(TAG, "Set attribute endpoint: %d, present_value %d", this->endpoint_, + this->cluster_attributes_->present_value); + ZB_ZCL_SET_ATTRIBUTE(this->endpoint_, ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, ZB_ZCL_CLUSTER_SERVER_ROLE, + ZB_ZCL_ATTR_BINARY_OUTPUT_PRESENT_VALUE_ID, &this->cluster_attributes_->present_value, + ZB_FALSE); + this->parent_->flush(); + }); +} + +void ZigbeeSwitch::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; + + p_device_cb_param->status = RET_OK; + + switch (device_cb_id) { + /* ZCL set attribute value */ + case ZB_ZCL_SET_ATTR_VALUE_CB_ID: + if (cluster_id == ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT) { + uint8_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data8; + ESP_LOGI(TAG, "Binary output attribute setting to %hd", value); + if (attr_id == ZB_ZCL_ATTR_BINARY_OUTPUT_PRESENT_VALUE_ID) { + this->defer([this, value]() { + this->cluster_attributes_->present_value = value ? ZB_TRUE : ZB_FALSE; + this->switch_->publish_state(value); + }); + } + } else { + /* other clusters attribute handled here */ + ESP_LOGI(TAG, "Unhandled cluster attribute id: %d", cluster_id); + } + break; + default: + p_device_cb_param->status = RET_ERROR; + break; + } + + ESP_LOGD(TAG, "%s status: %hd", __func__, p_device_cb_param->status); +} + +const zb_uint8_t ZB_ZCL_BINARY_OUTPUT_STATUS_FLAG_MAX_VALUE = 0x0F; + +static zb_ret_t check_value_binary_output_server(zb_uint16_t attr_id, zb_uint8_t endpoint, + zb_uint8_t *value) { // NOLINT(readability-non-const-parameter) + zb_ret_t ret = RET_OK; + ZVUNUSED(endpoint); + + switch (attr_id) { + case ZB_ZCL_ATTR_BINARY_OUTPUT_OUT_OF_SERVICE_ID: + case ZB_ZCL_ATTR_BINARY_OUTPUT_PRESENT_VALUE_ID: + ret = ZB_ZCL_CHECK_BOOL_VALUE(*value) ? RET_OK : RET_ERROR; + break; + + case ZB_ZCL_ATTR_BINARY_OUTPUT_STATUS_FLAG_ID: + if (*value > ZB_ZCL_BINARY_OUTPUT_STATUS_FLAG_MAX_VALUE) { + ret = RET_ERROR; + } + break; + + default: + break; + } + + return ret; +} + +} // namespace esphome::zigbee + +void zb_zcl_binary_output_init_server() { + zb_zcl_add_cluster_handlers(ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, ZB_ZCL_CLUSTER_SERVER_ROLE, + esphome::zigbee::check_value_binary_output_server, + (zb_zcl_cluster_write_attr_hook_t) NULL, (zb_zcl_cluster_handler_t) NULL); +} + +void zb_zcl_binary_output_init_client() { + zb_zcl_add_cluster_handlers(ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, ZB_ZCL_CLUSTER_CLIENT_ROLE, + (zb_zcl_cluster_check_value_t) NULL, (zb_zcl_cluster_write_attr_hook_t) NULL, + (zb_zcl_cluster_handler_t) NULL); +} + +#endif diff --git a/esphome/components/zigbee/zigbee_switch_zephyr.h b/esphome/components/zigbee/zigbee_switch_zephyr.h new file mode 100644 index 0000000000..b774c23b3c --- /dev/null +++ b/esphome/components/zigbee/zigbee_switch_zephyr.h @@ -0,0 +1,83 @@ +#pragma once + +#include "esphome/components/zigbee/zigbee_zephyr.h" +#if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_SWITCH) +#include "esphome/core/component.h" +#include "esphome/components/switch/switch.h" +extern "C" { +#include +#include +} + +#define ZB_ZCL_BINARY_OUTPUT_CLUSTER_REVISION_DEFAULT ((zb_uint16_t) 0x0001u) + +// NOLINTNEXTLINE(readability-identifier-naming) +enum zb_zcl_binary_output_attr_e { + ZB_ZCL_ATTR_BINARY_OUTPUT_DESCRIPTION_ID = 0x001C, + ZB_ZCL_ATTR_BINARY_OUTPUT_OUT_OF_SERVICE_ID = 0x0051, + ZB_ZCL_ATTR_BINARY_OUTPUT_PRESENT_VALUE_ID = 0x0055, + ZB_ZCL_ATTR_BINARY_OUTPUT_STATUS_FLAG_ID = 0x006F, +}; + +#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_BINARY_OUTPUT_OUT_OF_SERVICE_ID(data_ptr) \ + { \ + ZB_ZCL_ATTR_BINARY_OUTPUT_OUT_OF_SERVICE_ID, ZB_ZCL_ATTR_TYPE_BOOL, \ + ZB_ZCL_ATTR_ACCESS_READ_ONLY | ZB_ZCL_ATTR_ACCESS_WRITE_OPTIONAL, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \ + (void *) (data_ptr) \ + } + +#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_BINARY_OUTPUT_PRESENT_VALUE_ID(data_ptr) \ + { \ + ZB_ZCL_ATTR_BINARY_OUTPUT_PRESENT_VALUE_ID, ZB_ZCL_ATTR_TYPE_BOOL, \ + ZB_ZCL_ATTR_ACCESS_READ_WRITE | ZB_ZCL_ATTR_ACCESS_REPORTING, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \ + (void *) (data_ptr) \ + } + +#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_BINARY_OUTPUT_STATUS_FLAG_ID(data_ptr) \ + { \ + ZB_ZCL_ATTR_BINARY_OUTPUT_STATUS_FLAG_ID, ZB_ZCL_ATTR_TYPE_8BITMAP, \ + ZB_ZCL_ATTR_ACCESS_READ_ONLY | ZB_ZCL_ATTR_ACCESS_REPORTING, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \ + (void *) (data_ptr) \ + } + +#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_BINARY_OUTPUT_DESCRIPTION_ID(data_ptr) \ + { \ + ZB_ZCL_ATTR_BINARY_OUTPUT_DESCRIPTION_ID, ZB_ZCL_ATTR_TYPE_CHAR_STRING, ZB_ZCL_ATTR_ACCESS_READ_ONLY, \ + (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), (void *) (data_ptr) \ + } + +#define ESPHOME_ZB_ZCL_DECLARE_BINARY_OUTPUT_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_OUTPUT) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_BINARY_OUTPUT_OUT_OF_SERVICE_ID, (out_of_service)) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_BINARY_OUTPUT_PRESENT_VALUE_ID, (present_value)) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_BINARY_OUTPUT_STATUS_FLAG_ID, (status_flag)) \ + ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_BINARY_OUTPUT_DESCRIPTION_ID, (description)) \ + ZB_ZCL_FINISH_DECLARE_ATTRIB_LIST + +void zb_zcl_binary_output_init_server(); +void zb_zcl_binary_output_init_client(); + +#define ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT_SERVER_ROLE_INIT zb_zcl_binary_output_init_server +#define ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT_CLIENT_ROLE_INIT zb_zcl_binary_output_init_client + +namespace esphome::zigbee { + +class ZigbeeSwitch : public ZigbeeEntity, public Component { + public: + ZigbeeSwitch(switch_::Switch *s) : switch_(s) {} + void set_cluster_attributes(BinaryAttrs &cluster_attributes) { this->cluster_attributes_ = &cluster_attributes; } + + void setup() override; + + void dump_config() override; + + protected: + void zcl_device_cb_(zb_bufid_t bufid); + + BinaryAttrs *cluster_attributes_{nullptr}; + switch_::Switch *switch_; +}; + +} // namespace esphome::zigbee +#endif diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index 9a421aaec1..e43ab8f84d 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -104,9 +104,15 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { 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); + /* Set default response value. */ + p_device_cb_param->status = RET_OK; + // endpoints are enumerated from 1 if (global_zigbee->callbacks_.size() >= endpoint) { - global_zigbee->callbacks_[endpoint - 1](bufid); + const auto &cb = global_zigbee->callbacks_[endpoint - 1]; + if (cb) { + cb(bufid); + } return; } p_device_cb_param->status = RET_ERROR; diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index fa23907bf4..d5f1257f9c 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -81,7 +81,7 @@ class ZigbeeComponent : public Component { #ifdef USE_ZIGBEE_WIPE_ON_BOOT void erase_flash_(int area); #endif - StaticVector, ZIGBEE_ENDPOINTS_COUNT> callbacks_; + std::array, ZIGBEE_ENDPOINTS_COUNT> callbacks_{}; CallbackManager join_cb_; Trigger<> join_trigger_; bool need_flush_{false}; diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 71ea0da6a7..7f1f7dc57f 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -55,6 +55,7 @@ from .const_zephyr import ( CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, CONF_ZIGBEE_SENSOR, + CONF_ZIGBEE_SWITCH, KEY_EP_NUMBER, KEY_ZIGBEE, POWER_SOURCE, @@ -62,6 +63,7 @@ from .const_zephyr import ( ZB_ZCL_CLUSTER_ID_ANALOG_INPUT, ZB_ZCL_CLUSTER_ID_BASIC, ZB_ZCL_CLUSTER_ID_BINARY_INPUT, + ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, ZB_ZCL_CLUSTER_ID_IDENTIFY, ZB_ZCL_IDENTIFY_ATTRS_T, AnalogAttrs, @@ -72,6 +74,7 @@ from .const_zephyr import ( ZigbeeBinarySensor = zigbee_ns.class_("ZigbeeBinarySensor", cg.Component) ZigbeeSensor = zigbee_ns.class_("ZigbeeSensor", cg.Component) +ZigbeeSwitch = zigbee_ns.class_("ZigbeeSwitch", cg.Component) # BACnet engineering units mapping (ZCL uses BACnet unit codes) # See: https://github.com/zigpy/zha/blob/dev/zha/application/platforms/number/bacnet.py @@ -126,6 +129,15 @@ zephyr_sensor = cv.Schema( } ) +zephyr_switch = cv.Schema( + { + cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent), + cv.OnlyWith(CONF_ZIGBEE_SWITCH, ["nrf52", "zigbee"]): cv.declare_id( + ZigbeeSwitch + ), + } +) + async def zephyr_to_code(config: ConfigType) -> None: zephyr_add_prj_conf("ZIGBEE", True) @@ -320,6 +332,10 @@ async def zephyr_setup_sensor(entity: cg.MockObj, config: ConfigType) -> None: CORE.add_job(_add_sensor, entity, config) +async def zephyr_setup_switch(entity: cg.MockObj, config: ConfigType) -> None: + CORE.add_job(_add_switch, entity, config) + + def _slot_index() -> int: """Find the next available endpoint slot""" slot = next( @@ -332,7 +348,7 @@ def _slot_index() -> int: return slot -async def _add_zigbee_input( +async def _add_zigbee_ep( entity: cg.MockObj, config: ConfigType, component_key, @@ -389,7 +405,7 @@ async def _add_zigbee_input( async def _add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: - await _add_zigbee_input( + await _add_zigbee_ep( entity, config, CONF_ZIGBEE_BINARY_SENSOR, @@ -405,7 +421,7 @@ async def _add_sensor(entity: cg.MockObj, config: ConfigType) -> None: unit = config.get(CONF_UNIT_OF_MEASUREMENT, "") bacnet_unit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) - await _add_zigbee_input( + await _add_zigbee_ep( entity, config, CONF_ZIGBEE_SENSOR, @@ -415,3 +431,15 @@ async def _add_sensor(entity: cg.MockObj, config: ConfigType) -> None: "ZB_HA_CUSTOM_ATTR_DEVICE_ID", extra_field_values={"engineering_units": bacnet_unit}, ) + + +async def _add_switch(entity: cg.MockObj, config: ConfigType) -> None: + await _add_zigbee_ep( + entity, + config, + CONF_ZIGBEE_SWITCH, + BinaryAttrs, + "ESPHOME_ZB_ZCL_DECLARE_BINARY_OUTPUT_ATTRIB_LIST", + ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, + "ZB_HA_CUSTOM_ATTR_DEVICE_ID", + ) diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index c91569bdbe..11100e1e0c 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -11,9 +11,7 @@ binary_sensor: - platform: template name: "Garage Door Open 5" - platform: template - name: "Garage Door Open 6" - - platform: template - name: "Garage Door Open 7" + name: "Garage Door Internal" internal: True sensor: @@ -36,3 +34,8 @@ output: type: binary write_action: - zigbee.factory_reset + +switch: + - platform: template + name: "Template Switch" + optimistic: true